diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py index db68b6d894d..2cd076c2286 100644 --- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py @@ -105,25 +105,30 @@ class BankStatementImport(DataImport): @frappe.whitelist() def get_preview_from_template(data_import, import_file=None, google_sheets_url=None): - return frappe.get_doc("Bank Statement Import", data_import).get_preview_from_template( - import_file, google_sheets_url - ) + bsi = frappe.get_doc("Bank Statement Import", data_import) + bsi.check_permission() + return bsi.get_preview_from_template(import_file, google_sheets_url) @frappe.whitelist() def form_start_import(data_import): - return frappe.get_doc("Bank Statement Import", data_import).start_import() + bsi = frappe.get_doc("Bank Statement Import", data_import) + bsi.check_permission("write") + return bsi.start_import() @frappe.whitelist() def download_errored_template(data_import_name): data_import = frappe.get_doc("Bank Statement Import", data_import_name) + data_import.check_permission() data_import.export_errored_rows() @frappe.whitelist() def download_import_log(data_import_name): - return frappe.get_doc("Bank Statement Import", data_import_name).download_import_log() + bsi = frappe.get_doc("Bank Statement Import", data_import_name) + bsi.check_permission() + return bsi.download_import_log() def parse_data_from_template(raw_data): diff --git a/erpnext/accounts/doctype/dunning/dunning.js b/erpnext/accounts/doctype/dunning/dunning.js index c9955c7e359..10d6043605b 100644 --- a/erpnext/accounts/doctype/dunning/dunning.js +++ b/erpnext/accounts/doctype/dunning/dunning.js @@ -234,8 +234,10 @@ frappe.ui.form.on("Dunning", { dn: frm.doc.name, }, callback: function (r) { - var doc = frappe.model.sync(r.message); - frappe.set_route("Form", doc[0].doctype, doc[0].name); + if (!r.exc) { + var doc = frappe.model.sync(r.message); + frappe.set_route("Form", doc[0].doctype, doc[0].name); + } }, }); }, diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index bccf718f4a6..2069593992d 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -1526,6 +1526,7 @@ def get_payment_entry_against_order( dt, dn, amount=None, debit_in_account_currency=None, journal_entry=False, bank_account=None ): ref_doc = frappe.get_doc(dt, dn) + ref_doc.check_permission() if flt(ref_doc.per_billed, 2) > 0: frappe.throw(_("Can only make payment against unbilled {0}").format(dt)) @@ -1571,6 +1572,8 @@ def get_payment_entry_against_invoice( dt, dn, amount=None, debit_in_account_currency=None, journal_entry=False, bank_account=None ): ref_doc = frappe.get_doc(dt, dn) + ref_doc.check_permission() + if dt == "Sales Invoice": party_type = "Customer" party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to @@ -1606,6 +1609,8 @@ def get_payment_entry_against_invoice( def get_payment_entry(ref_doc, args): + frappe.has_permission("Journal Entry", ptype="create", throw=True) + cost_center = ref_doc.get("cost_center") or frappe.get_cached_value( "Company", ref_doc.company, "cost_center" ) diff --git a/erpnext/accounts/doctype/ledger_merge/ledger_merge.py b/erpnext/accounts/doctype/ledger_merge/ledger_merge.py index 49993393fe6..08e16b5a206 100644 --- a/erpnext/accounts/doctype/ledger_merge/ledger_merge.py +++ b/erpnext/accounts/doctype/ledger_merge/ledger_merge.py @@ -56,7 +56,9 @@ class LedgerMerge(Document): @frappe.whitelist() def form_start_merge(docname): - return frappe.get_doc("Ledger Merge", docname).start_merge() + lm_doc = frappe.get_doc("Ledger Merge", docname) + lm_doc.check_permission("write") + return lm_doc.start_merge() def start_merge(docname): diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py index 48551eab3b2..258643fc56e 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py @@ -282,6 +282,9 @@ def start_import(invoices): invoice_number = d.invoice_number doc = frappe.get_doc(d) doc.flags.ignore_mandatory = True + # the outstanding amount is entered inclusive of tax, so taxes must not + # be added on top of it + doc.flags.dont_auto_add_taxes = True doc.insert(set_name=invoice_number) doc.submit() frappe.db.commit() diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py index 9bb69cacd33..21735270281 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py @@ -2,9 +2,10 @@ # See license.txt import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, today +from erpnext.accounts.doctype.account.test_account import create_account from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import ( create_dimension, disable_dimension, @@ -12,6 +13,7 @@ from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension imp from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import ( get_temporary_opening_account, ) +from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule from erpnext.projects.doctype.project.test_project import make_project test_dependencies = ["Customer", "Supplier", "Accounting Dimension"] @@ -140,6 +142,55 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase): for invoice in invoices: self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC") + @change_settings( + "Accounts Settings", + {"add_taxes_from_taxes_and_charges_template": 1, "add_taxes_from_item_tax_template": 0}, + ) + def test_opening_invoice_creation_without_taxes(self): + company = "_Test Opening Invoice Company" + template = frappe.get_doc( + { + "doctype": "Sales Taxes and Charges Template", + "company": company, + "title": "_Test Opening Invoice Tax", + "taxes": [ + { + "charge_type": "On Net Total", + "account_head": create_account( + account_name="_Test Opening Tax Account", + parent_account="Duties and Taxes - _TOIC", + account_type="Tax", + company=company, + ), + "description": "Test taxes", + "rate": 9, + } + ], + } + ).insert() + + # makes the template the default for the party, as it would be on a live site + make_tax_rule(tax_type="Sales", company=company, sales_tax_template=template.name, save=1) + + tool = self.make_invoices(company=company, return_doc=True) + invoices = tool.make_invoices() + self.assertEqual(len(invoices), 2) + + # outstanding amount is entered inclusive of tax, so taxes must not be added on top of it + for invoice in invoices: + si = frappe.get_doc("Sales Invoice", invoice) + self.assertFalse(si.taxes) + self.assertEqual(si.grand_total, 200) + self.assertEqual(si.outstanding_amount, 200) + + # the same invoice created outside the tool keeps the default taxes, + # since adding them there is the user's decision + si = frappe.get_doc(tool.get_invoices()[0]) + si.flags.ignore_mandatory = True + si.insert() + self.assertTrue(si.taxes) + self.assertEqual(si.grand_total, 218) + def test_opening_entry_project_linking(self): doc = self.make_invoices( company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 2746de2b99b..a16e6d01639 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -2894,10 +2894,13 @@ def get_payment_entry( party_type=None, payment_type=None, reference_date=None, - ignore_permissions=False, created_from_payment_request=False, ): + frappe.has_permission("Payment Entry", ptype="create", throw=True) + doc = frappe.get_doc(dt, dn) + doc.check_permission() + over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance") if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= (100.0 + over_billing_allowance): frappe.throw(_("Can only make payment against unbilled {0}").format(_(dt))) diff --git a/erpnext/accounts/doctype/payment_order/payment_order.py b/erpnext/accounts/doctype/payment_order/payment_order.py index a4c596249b1..05af90fd9fb 100644 --- a/erpnext/accounts/doctype/payment_order/payment_order.py +++ b/erpnext/accounts/doctype/payment_order/payment_order.py @@ -83,6 +83,7 @@ def get_supplier_query(doctype, txt, searchfield, start, page_len, filters): @frappe.whitelist() def make_payment_records(name, supplier, mode_of_payment=None): doc = frappe.get_doc("Payment Order", name) + doc.check_permission() make_journal_entry(doc, supplier, mode_of_payment) diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py index 8671213c3cc..89dd73af457 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py @@ -54,7 +54,7 @@ class PeriodClosingVoucher(AccountsController): if for_cancellation and is_immutable_ledger_enabled(): posting_date = getdate() - check_freezing_date(posting_date, self.company) + check_freezing_date(posting_date) def validate_start_and_end_date(self): self.fy_start_date, self.fy_end_date = frappe.db.get_value( diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json index 42f62147129..a0adf4e1133 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json @@ -499,6 +499,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "depends_on": "customer", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", @@ -1571,7 +1572,7 @@ "icon": "fa fa-file-text", "is_submittable": 1, "links": [], - "modified": "2026-02-22 04:18:50.691218", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice", diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.py b/erpnext/accounts/doctype/pos_profile/pos_profile.py index 5edaa2b89c3..f7596c98d92 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.py +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.py @@ -271,40 +271,40 @@ def pos_profile_query(doctype, txt, searchfield, start, page_len, filters): user = frappe.session["user"] company = filters.get("company") or frappe.defaults.get_user_default("company") - args = { - "user": user, - "start": start, - "company": company, - "page_len": page_len, - "txt": "%%%s%%" % txt, - } + allowed_pos_profiles = frappe.get_list("POS Profile", pluck="name") - pos_profile = frappe.db.sql( - """select pf.name - from - `tabPOS Profile` pf, `tabPOS Profile User` pfu - where - pfu.parent = pf.name and pfu.user = %(user)s and pf.company = %(company)s - and (pf.name like %(txt)s) - and pf.disabled = 0 limit %(page_len)s offset %(start)s""", - args, + if not allowed_pos_profiles: + return {} + + pf = frappe.qb.DocType("POS Profile") + pfu = frappe.qb.DocType("POS Profile User") + + pos_profile = ( + frappe.qb.from_(pf) + .inner_join(pfu) + .on(pfu.parent == pf.name) + .select(pf.name) + .where((pfu.user == user) & (pf.company == company) & pf.name.like(f"%{txt}%") & (pf.disabled == 0)) + .where(pf.name.isin(allowed_pos_profiles)) + .limit(page_len) + .offset(start) + .run() ) if not pos_profile: - del args["user"] - - pos_profile = frappe.db.sql( - """select pf.name - from - `tabPOS Profile` pf left join `tabPOS Profile User` pfu - on - pf.name = pfu.parent - where - ifnull(pfu.user, '') = '' - and pf.company = %(company)s - and pf.name like %(txt)s - and pf.disabled = 0""", - args, + pos_profile = ( + frappe.qb.from_(pf) + .left_join(pfu) + .on(pf.name == pfu.parent) + .select(pf.name) + .where( + (pfu.user.isnull() | (pfu.user == "")) + & (pf.company == company) + & pf.name.like(f"%{txt}%") + & (pf.disabled == 0) + & (pf.name.isin(allowed_pos_profiles)) + ) + .run() ) return pos_profile diff --git a/erpnext/accounts/doctype/pos_search_fields/pos_search_fields.json b/erpnext/accounts/doctype/pos_search_fields/pos_search_fields.json index a627f5b5be4..7299039cd26 100644 --- a/erpnext/accounts/doctype/pos_search_fields/pos_search_fields.json +++ b/erpnext/accounts/doctype/pos_search_fields/pos_search_fields.json @@ -12,20 +12,22 @@ { "fieldname": "fieldname", "fieldtype": "Data", - "hidden": 1, - "label": "Fieldname" + "in_list_view": 1, + "label": "Fieldname", + "read_only": 1 }, { "fieldname": "field", "fieldtype": "Select", "in_list_view": 1, - "label": "Field" + "label": "Field", + "reqd": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-04-21 11:12:54.632093", + "modified": "2026-08-31 20:41:12.000000", "modified_by": "Administrator", "module": "Accounts", "name": "POS Search Fields", @@ -34,4 +36,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/pos_settings/pos_settings.js b/erpnext/accounts/doctype/pos_settings/pos_settings.js index a2e5a57e5f7..4175a09be7a 100644 --- a/erpnext/accounts/doctype/pos_settings/pos_settings.js +++ b/erpnext/accounts/doctype/pos_settings/pos_settings.js @@ -1,40 +1,9 @@ // Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt -let search_fields_datatypes = [ - "Data", - "Link", - "Dynamic Link", - "Long Text", - "Select", - "Small Text", - "Text", - "Text Editor", -]; -let do_not_include_fields = [ - "naming_series", - "item_code", - "item_name", - "stock_uom", - "asset_naming_series", - "default_material_request_type", - "valuation_method", - "warranty_period", - "weight_uom", - "batch_number_series", - "serial_no_series", - "purchase_uom", - "customs_tariff_number", - "sales_uom", - "deferred_revenue_account", - "deferred_expense_account", - "quality_inspection_template", - "route", - "slideshow", - "website_image_alt", - "thumbnail", - "web_long_description", -]; +function is_valid_invoice_field(df) { + return frappe.model.no_value_type.indexOf(df.fieldtype) === -1 || df.fieldtype === "Button"; +} frappe.ui.form.on("POS Settings", { onload: function (frm) { @@ -44,57 +13,46 @@ frappe.ui.form.on("POS Settings", { get_invoice_fields: function (frm) { frappe.model.with_doctype("POS Invoice", () => { - var fields = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) { - if ( - frappe.model.no_value_type.indexOf(d.fieldtype) === -1 || - ["Button"].includes(d.fieldtype) - ) { - return { label: d.label + " (" + d.fieldtype + ")", value: d.fieldname }; - } else { - return null; - } - }); + const fields = frappe.get_doc("DocType", "POS Invoice").fields.filter(is_valid_invoice_field); frm.fields_dict.invoice_fields.grid.update_docfield_property( "fieldname", "options", - [""].concat(fields) + [""].concat( + fields.map((df) => { + return { label: `${df.label} (${df.fieldtype})`, value: df.fieldname }; + }) + ) ); }); }, add_search_options: function (frm) { - frappe.model.with_doctype("Item", () => { - var fields = $.map(frappe.get_doc("DocType", "Item").fields, function (d) { - if ( - search_fields_datatypes.includes(d.fieldtype) && - !do_not_include_fields.includes(d.fieldname) - ) { - return [d.label]; - } else { - return null; - } - }); + frappe.call({ + method: "erpnext.accounts.doctype.pos_settings.pos_settings.get_pos_search_field_options", + callback: ({ message }) => { + const fields = message || []; - fields.unshift(""); - frm.fields_dict.pos_search_fields.grid.update_docfield_property("field", "options", fields); + frm.searchable_item_fields = Object.fromEntries( + fields.map((df) => [df.option, df.fieldname]) + ); + + frm.fields_dict.pos_search_fields.grid.update_docfield_property( + "field", + "options", + [""].concat(fields.map((df) => df.option)) + ); + }, }); }, }); frappe.ui.form.on("POS Search Fields", { field: function (frm, doctype, name) { - var doc = frappe.get_doc(doctype, name); - var df = $.map(frappe.get_doc("DocType", "Item").fields, function (d) { - if (doc.field == d.label && search_fields_datatypes.includes(d.fieldtype)) { - return d; - } else { - return null; - } - })[0]; + const doc = frappe.get_doc(doctype, name); - doc.fieldname = df.fieldname; - frm.refresh_field("fields"); + doc.fieldname = frm.searchable_item_fields?.[doc.field] || ""; + frm.refresh_field("pos_search_fields"); }, }); @@ -110,6 +68,6 @@ frappe.ui.form.on("POS Field", { doc.options = df.options; doc.fieldtype = df.fieldtype; doc.default_value = df.default; - frm.refresh_field("fields"); + frm.refresh_field("invoice_fields"); }, }); diff --git a/erpnext/accounts/doctype/pos_settings/pos_settings.py b/erpnext/accounts/doctype/pos_settings/pos_settings.py index 1e298a68f4b..e970e636841 100644 --- a/erpnext/accounts/doctype/pos_settings/pos_settings.py +++ b/erpnext/accounts/doctype/pos_settings/pos_settings.py @@ -1,9 +1,50 @@ # Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +from collections import Counter +import frappe +from frappe import _ +from frappe.model import no_value_fields from frappe.model.document import Document +SEARCH_FIELD_TYPES = ( + "Data", + "Link", + "Dynamic Link", + "Long Text", + "Select", + "Small Text", + "Text", + "Text Editor", +) + +# Item fields that are of a searchable fieldtype, but are not meaningful to search a POS item by +DO_NOT_INCLUDE_FIELDS = ( + "naming_series", + "item_code", + "item_name", + "stock_uom", + "asset_naming_series", + "default_material_request_type", + "valuation_method", + "warranty_period", + "weight_uom", + "batch_number_series", + "serial_no_series", + "purchase_uom", + "customs_tariff_number", + "sales_uom", + "deferred_revenue_account", + "deferred_expense_account", + "quality_inspection_template", + "route", + "slideshow", + "website_image_alt", + "thumbnail", + "web_long_description", +) + class POSSettings(Document): # begin: auto-generated types @@ -22,4 +63,95 @@ class POSSettings(Document): # end: auto-generated types def validate(self): - pass + self.validate_duplicate_invoice_fields() + self.validate_invoice_fields() + self.validate_duplicate_pos_search_fields() + self.validate_pos_search_fields() + + def validate_duplicate_invoice_fields(self): + fieldnames = [field.fieldname for field in self.invoice_fields] + + for fieldname, count in Counter(fieldnames).items(): + if count > 1: + frappe.throw( + title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(fieldname) + ) + + def validate_invoice_fields(self): + # the POS screen only ever creates a POS Invoice + meta = frappe.get_meta("POS Invoice") + + for field in self.invoice_fields: + df = meta.get_field(field.fieldname) + + if not df or not is_valid_invoice_field(df): + frappe.throw( + title=_("Invalid POS Field"), + msg=_("Row #{0}: '{1}' is not a valid field of {2}.").format( + field.idx, frappe.bold(field.fieldname or ""), frappe.bold(_("POS Invoice")) + ), + ) + + # read only in the form, so keep them in sync with the invoice + field.label = df.label + field.fieldtype = df.fieldtype + field.options = df.options + + def validate_duplicate_pos_search_fields(self): + fieldnames = [field.fieldname for field in self.pos_search_fields] + + for fieldname, count in Counter(fieldnames).items(): + if count > 1: + frappe.throw( + title=_("Duplicate POS Search Fields"), + msg=_("'{0}' has been already added.").format(fieldname), + ) + + def validate_pos_search_fields(self): + searchable_fields = {df.fieldname: df for df in get_searchable_item_fields()} + + for field in self.pos_search_fields: + df = searchable_fields.get(field.fieldname) + + if not df: + frappe.throw( + title=_("Invalid POS Search Field"), + msg=_("Row #{0}: '{1}' cannot be used to search items.").format( + field.idx, frappe.bold(field.fieldname or "") + ), + ) + + if field.field != get_search_field_option(df): + frappe.throw( + title=_("Invalid POS Search Field"), + msg=_("Row #{0}: '{1}' does not match {2}.").format( + field.idx, frappe.bold(field.field or ""), frappe.bold(df.fieldname) + ), + ) + + +def is_valid_invoice_field(df): + return df.fieldtype not in no_value_fields or df.fieldtype == "Button" + + +def get_searchable_item_fields(): + return [ + df + for df in frappe.get_meta("Item").fields + if df.fieldtype in SEARCH_FIELD_TYPES and df.fieldname not in DO_NOT_INCLUDE_FIELDS + ] + + +def get_search_field_option(df): + # the fieldname keeps the option unique, two Item fields can share a label + return f"{df.label} ({df.fieldname})" + + +@frappe.whitelist() +def get_pos_search_field_options(): + frappe.has_permission("POS Settings", throw=True) + + return [ + {"option": get_search_field_option(df), "fieldname": df.fieldname} + for df in get_searchable_item_fields() + ] diff --git a/erpnext/accounts/doctype/pos_settings/test_pos_settings.py b/erpnext/accounts/doctype/pos_settings/test_pos_settings.py index 630b30564ad..f58dbc3ccdc 100644 --- a/erpnext/accounts/doctype/pos_settings/test_pos_settings.py +++ b/erpnext/accounts/doctype/pos_settings/test_pos_settings.py @@ -3,6 +3,119 @@ import unittest +import frappe + +from erpnext.patches.v16_0.append_fieldname_to_pos_search_fields import execute as append_fieldname + class TestPOSSettings(unittest.TestCase): - pass + def setUp(self): + self.settings = frappe.get_single("POS Settings") + self.settings.invoice_fields = [] + self.settings.pos_search_fields = [] + + def tearDown(self): + frappe.db.rollback() + + def assertInvalid(self, message): + with self.assertRaises(frappe.ValidationError) as context: + self.settings.save() + + self.assertIn(message, str(context.exception)) + + def test_duplicate_invoice_field_is_not_allowed(self): + self.settings.append("invoice_fields", {"fieldname": "customer"}) + self.settings.append("invoice_fields", {"fieldname": "customer"}) + self.assertInvalid("'customer' has been already added.") + + def test_unknown_invoice_field_is_not_allowed(self): + self.settings.append("invoice_fields", {"fieldname": "not_a_field"}) + self.assertInvalid("is not a valid field of") + + def test_layout_invoice_field_is_not_allowed(self): + self.settings.append("invoice_fields", {"fieldname": "accounting_dimensions_section"}) + self.assertInvalid("is not a valid field of") + + def test_invoice_field_properties_are_set_from_the_invoice(self): + self.settings.append( + "invoice_fields", {"fieldname": "customer", "label": "Tampered", "fieldtype": "Data"} + ) + self.settings.save() + + field = self.settings.invoice_fields[0] + self.assertEqual(field.label, "Customer") + self.assertEqual(field.fieldtype, "Link") + self.assertEqual(field.options, "Customer") + + def test_searchable_item_field_is_allowed(self): + self.settings.append( + "pos_search_fields", {"field": "Description (description)", "fieldname": "description"} + ) + self.settings.save() + + self.assertEqual(self.settings.pos_search_fields[0].fieldname, "description") + + def test_excluded_search_field_is_not_allowed(self): + self.settings.append( + "pos_search_fields", {"field": "Item Name (item_name)", "fieldname": "item_name"} + ) + self.assertInvalid("cannot be used to search items") + + def test_search_field_of_unsearchable_type_is_not_allowed(self): + # maintain stock is a Check field + self.settings.append( + "pos_search_fields", {"field": "Maintain Stock (is_stock_item)", "fieldname": "is_stock_item"} + ) + self.assertInvalid("cannot be used to search items") + + def test_unknown_search_field_is_not_allowed(self): + self.settings.append( + "pos_search_fields", {"field": "Nope (not_an_item_field)", "fieldname": "not_an_item_field"} + ) + self.assertInvalid("cannot be used to search items") + + def test_search_field_without_a_fieldname_is_not_allowed(self): + # the form fills the fieldname in, it cannot be picked on its own + self.settings.append("pos_search_fields", {"field": "Description (description)"}) + self.assertInvalid("cannot be used to search items") + + def test_search_field_option_must_match_its_fieldname(self): + self.settings.append("pos_search_fields", {"field": "Brand (brand)", "fieldname": "description"}) + self.assertInvalid("does not match") + + def test_bare_label_is_not_accepted_as_a_search_field(self): + # the stored option carries the fieldname, the patch backfills older rows + self.settings.append("pos_search_fields", {"field": "Description", "fieldname": "description"}) + self.assertInvalid("does not match") + + def test_duplicate_search_fields_are_not_allowed(self): + for _ in range(2): + self.settings.append( + "pos_search_fields", {"field": "Description (description)", "fieldname": "description"} + ) + + self.assertInvalid("has been already added") + + def test_patch_appends_the_fieldname_to_a_legacy_search_field(self): + self.settings.append( + "pos_search_fields", {"field": "Description (description)", "fieldname": "description"} + ) + self.settings.save() + + row = self.settings.pos_search_fields[0].name + frappe.db.set_value("POS Search Fields", row, "field", "Description", update_modified=False) + + append_fieldname() + + self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)") + + def test_patch_leaves_an_already_migrated_search_field_alone(self): + self.settings.append( + "pos_search_fields", {"field": "Description (description)", "fieldname": "description"} + ) + self.settings.save() + + append_fieldname() + + row = self.settings.pos_search_fields[0].name + self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)") 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 ecedc8dc089..9fe9da0aa4c 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 @@ -139,6 +139,8 @@ def start_pcv_processing(docname: str): @frappe.whitelist() def pause_pcv_processing(docname: str): + frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True) + ppcv = qb.DocType("Process Period Closing Voucher") qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run() @@ -154,6 +156,8 @@ def pause_pcv_processing(docname: str): @frappe.whitelist() def cancel_pcv_processing(docname: str): + frappe.has_permission("Process Period Closing Voucher", ptype="cancel", doc=docname, throw=True) + ppcv = qb.DocType("Process Period Closing Voucher") qb.update(ppcv).set(ppcv.status, "Cancelled").where(ppcv.name.eq(docname)).run() @@ -168,6 +172,8 @@ def cancel_pcv_processing(docname: str): @frappe.whitelist() def resume_pcv_processing(docname: str): + frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True) + ppcv = qb.DocType("Process Period Closing Voucher") qb.update(ppcv).set(ppcv.status, "Running").where(ppcv.name.eq(docname)).run() diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index 058b8c8613b..a24c81544a5 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -502,6 +502,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", "label": "Currency and Price List", @@ -1663,7 +1664,7 @@ "idx": 204, "is_submittable": 1, "links": [], - "modified": "2026-08-05 15:40:16.519774", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index e4cc191ffb0..26c77701140 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -40,6 +40,7 @@ from erpnext.assets.doctype.asset_category.asset_category import get_asset_categ from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head from erpnext.controllers.buying_controller import BuyingController +from erpnext.controllers.mapper import get_qty_already_mapped from erpnext.stock import get_warehouse_account_map from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( get_item_account_wise_additional_cost, @@ -2201,6 +2202,11 @@ def make_purchase_receipt(source_name, target_doc=None, args=None): if isinstance(args, str): args = json.loads(args) + mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_invoice_item") + + def received_and_mapped_qty(obj): + return flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0)) + def post_parent_process(source_parent, target_parent): remove_items_with_zero_qty(target_parent) set_missing_values(source_parent, target_parent) @@ -2215,13 +2221,13 @@ def make_purchase_receipt(source_name, target_doc=None, args=None): target_parent.run_method("calculate_taxes_and_totals") def update_item(obj, target, source_parent): - target.qty = flt(obj.qty) - flt(obj.received_qty) - target.received_qty = flt(obj.qty) - flt(obj.received_qty) - target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor) - target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) - target.base_amount = ( - (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate) - ) + pending_qty = flt(obj.qty) - received_and_mapped_qty(obj) + + target.qty = pending_qty + target.received_qty = pending_qty + target.stock_qty = pending_qty * flt(obj.conversion_factor) + target.amount = pending_qty * flt(obj.rate) + target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate) def select_item(d): filtered_items = args.get("filtered_children", []) @@ -2251,7 +2257,8 @@ def make_purchase_receipt(source_name, target_doc=None, args=None): "wip_composite_asset": "wip_composite_asset", }, "postprocess": update_item, - "condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc), + "condition": lambda doc: abs(received_and_mapped_qty(doc)) < abs(doc.qty) + and select_item(doc), }, "Purchase Taxes and Charges": { "doctype": "Purchase Taxes and Charges", diff --git a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py index 6fd1b0f2bf2..be896b6f143 100644 --- a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py +++ b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py @@ -27,6 +27,7 @@ def start_payment_ledger_repost(docname=None): """ if docname: repost_doc = frappe.get_doc("Repost Payment Ledger", docname) + repost_doc.check_permission("submit") if repost_doc.docstatus.is_submitted() and repost_doc.repost_status in ["Queued", "Failed"]: try: for entry in repost_doc.repost_vouchers: diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index a5abf3dd32f..d4efe4d0c15 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -597,6 +597,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "depends_on": "customer", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", @@ -2198,7 +2199,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2026-04-06 22:30:28.513139", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py b/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py index 86c8463b021..9d7a2ee8b09 100644 --- a/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py +++ b/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py @@ -9,6 +9,7 @@ from frappe.utils import cint, flt from erpnext.accounts.report.general_ledger.general_ledger import get_accounts_with_children from erpnext.accounts.report.trial_balance.trial_balance import validate_filters +from erpnext.accounts.utils import get_currency_precision def execute(filters=None): @@ -43,6 +44,7 @@ def get_data(filters, show_party_name): account_filter = get_accounts_with_children(filters.get("account")) company_currency = frappe.get_cached_value("Company", filters.company, "default_currency") + precision = get_currency_precision() opening_balances = get_opening_balances(filters, account_filter) balances_within_period = get_balances_within_period(filters, account_filter) @@ -65,14 +67,17 @@ def get_data(filters, show_party_name): # opening opening_debit, opening_credit = opening_balances.get(party.name, [0, 0]) + opening_debit, opening_credit = flt(opening_debit, precision), flt(opening_credit, precision) row.update({"opening_debit": opening_debit, "opening_credit": opening_credit}) # within period debit, credit = balances_within_period.get(party.name, [0, 0]) + debit, credit = flt(debit, precision), flt(credit, precision) row.update({"debit": debit, "credit": credit}) # closing closing_debit, closing_credit = toggle_debit_credit(opening_debit + debit, opening_credit + credit) + closing_debit, closing_credit = flt(closing_debit, precision), flt(closing_credit, precision) row.update({"closing_debit": closing_debit, "closing_credit": closing_credit}) row.update({"currency": company_currency}) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index 7d92a197a82..654e92f763e 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -402,6 +402,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", "label": "Currency and Price List", @@ -1307,7 +1308,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2026-07-28 12:20:11.284370", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 651c781f4f6..171a89e826c 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -21,6 +21,7 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category from erpnext.accounts.party import get_party_account, get_party_account_currency from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items from erpnext.controllers.buying_controller import BuyingController +from erpnext.controllers.mapper import get_qty_already_mapped from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( validate_against_blanket_order, ) @@ -737,13 +738,16 @@ def make_purchase_receipt(source_name, target_doc=None, args=None): def is_unit_price_row(source): return has_unit_price_items and source.qty == 0 + mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_order_item") + def update_item(obj, target, source_parent): - target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty) - target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor) - target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) - target.base_amount = ( - (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate) - ) + received_qty = flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0)) + pending_qty = flt(obj.qty) - received_qty + + target.qty = flt(obj.qty) if is_unit_price_row(obj) else pending_qty + target.stock_qty = pending_qty * flt(obj.conversion_factor) + target.amount = pending_qty * flt(obj.rate) + target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate) def select_item(d): filtered_items = args.get("filtered_children", []) @@ -775,7 +779,9 @@ def make_purchase_receipt(source_name, target_doc=None, args=None): }, "postprocess": update_item, "condition": lambda doc: ( - True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty) + doc.name not in mapped_qty_by_item + if is_unit_price_row(doc) + else abs(doc.received_qty) + abs(mapped_qty_by_item.get(doc.name, 0)) < abs(doc.qty) ) and doc.delivered_by_supplier != 1 and select_item(doc), @@ -837,9 +843,13 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions ) return query.run(pluck="qty")[0] or 0 + mapped_qty_by_item = get_qty_already_mapped(target_doc, "po_detail") + + def get_billed_and_mapped_qty(po_item_name): + return flt(get_billed_qty(po_item_name)) + flt(mapped_qty_by_item.get(po_item_name, 0)) + def update_item(obj, target, source_parent): - billed_qty = flt(get_billed_qty(obj.name)) - target.qty = flt(obj.qty) - billed_qty + target.qty = flt(obj.qty) - get_billed_and_mapped_qty(obj.name) item = get_item_defaults(target.item_code, source_parent.company) item_group = get_item_group_defaults(target.item_code, source_parent.company) @@ -882,6 +892,7 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions or abs(doc.billed_amt) < abs(doc.amount) or doc.qty > flt(get_billed_qty(doc.name)) ) + and (doc.name not in mapped_qty_by_item or doc.qty > get_billed_and_mapped_qty(doc.name)) and select_item(doc), }, "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True}, diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index 0b463a36530..7740561ac2b 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -10,6 +10,7 @@ from frappe.contacts.address_and_contact import ( load_address_and_contact, ) from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options +from frappe.utils import get_link_to_form from erpnext.accounts.party import ( get_dashboard_info, @@ -177,10 +178,15 @@ class Supplier(TransactionBase): ) if internal_supplier: + internal_supplier_link = get_link_to_form("Supplier", internal_supplier) frappe.throw( - _("Internal Supplier for company {0} already exists").format( - frappe.bold(self.represents_company) - ) + _( + "Internal Supplier {0} already exists for {1}. Disable it to make this Supplier internal." + ).format( + internal_supplier_link, + frappe.bold(self.represents_company), + ), + title=_("Internal Supplier Already Exists"), ) def create_primary_contact(self): diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json index 6682c7e3585..b7edb2d2980 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json @@ -257,6 +257,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", "label": "Currency and Price List", @@ -938,7 +939,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-03-03 17:39:38.459977", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Buying", "name": "Supplier Quotation", diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index 790e89f8c0e..c5282f3866a 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -11,6 +11,7 @@ from frappe.utils import flt, getdate, nowdate from erpnext.buying.utils import validate_for_items from erpnext.controllers.buying_controller import BuyingController +from erpnext.controllers.mapper import get_qty_already_mapped form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -243,6 +244,8 @@ def make_purchase_order(source_name, target_doc=None, args=None): if isinstance(args, str): args = json.loads(args) + mapped_items = get_qty_already_mapped(target_doc, "supplier_quotation_item") + def set_missing_values(source, target): target.run_method("set_missing_values") target.run_method("get_schedule_dates") @@ -277,7 +280,8 @@ def make_purchase_order(source_name, target_doc=None, args=None): ["sales_order", "sales_order"], ], "postprocess": update_item, - "condition": select_item, + # no qty tracking between the two, so dedupe on the row reference alone + "condition": lambda d: d.name not in mapped_items and select_item(d), }, "Purchase Taxes and Charges": { "doctype": "Purchase Taxes and Charges", diff --git a/erpnext/buying/report/purchase_analytics/purchase_analytics.js b/erpnext/buying/report/purchase_analytics/purchase_analytics.js index 23a188057d9..b5ac62ed532 100644 --- a/erpnext/buying/report/purchase_analytics/purchase_analytics.js +++ b/erpnext/buying/report/purchase_analytics/purchase_analytics.js @@ -10,6 +10,26 @@ frappe.query_reports["Purchase Analytics"] = { options: ["Supplier Group", "Supplier", "Item Group", "Item"], default: "Supplier", reqd: 1, + on_change: function () { + const entity_filter = frappe.query_report.get_filter("entity"); + if (entity_filter) { + entity_filter.df.label = __(frappe.query_report.get_filter_value("tree_type")); + entity_filter.set_value([]); + entity_filter.refresh(); + } + frappe.query_report.refresh(); + }, + }, + { + fieldname: "entity", + label: __("Entity"), + fieldtype: "MultiSelectList", + get_data: function (txt) { + const tree_type = frappe.query_report.get_filter_value("tree_type"); + if (!tree_type || tree_type === "Order Type") return []; + return frappe.db.get_link_options(tree_type, txt); + }, + depends_on: "eval:doc.tree_type != 'Order Type'", }, { fieldname: "doc_type", @@ -65,6 +85,19 @@ frappe.query_reports["Purchase Analytics"] = { default: "Monthly", reqd: 1, }, + { + fieldname: "curves", + label: __("Curves"), + fieldtype: "Select", + options: [ + { value: "select", label: __("Select") }, + { value: "all", label: __("All") }, + { value: "non-zeros", label: __("Non-Zeros") }, + { value: "total", label: __("Total Only") }, + ], + default: "select", + reqd: 1, + }, ], get_datatable_options(options) { return Object.assign(options, { diff --git a/erpnext/buying/report/purchase_analytics/test_purchase_analytics.py b/erpnext/buying/report/purchase_analytics/test_purchase_analytics.py new file mode 100644 index 00000000000..ffa887aad09 --- /dev/null +++ b/erpnext/buying/report/purchase_analytics/test_purchase_analytics.py @@ -0,0 +1,131 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import flt + +from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order +from erpnext.buying.report.purchase_analytics.purchase_analytics import execute + +COMPANY = "_Test Company" +SUPPLIER = "_Test Supplier" +SUPPLIER_GROUP = "_Test Supplier Group" +# A historical window that ordinary test fixtures don't post into. +FROM_DATE = "2019-04-01" +TO_DATE = "2019-06-30" + + +class TestPurchaseAnalytics(FrappeTestCase): + """purchase_analytics reuses the shared Analytics engine; these tests lock its + wiring (doc_type=Purchase Order) across the Supplier Group / Item Group trees.""" + + def setUp(self): + frappe.set_user("Administrator") + + def _filters(self, **overrides): + filters = { + "doc_type": "Purchase Order", + "value_quantity": "Value", + "range": "Monthly", + "company": COMPANY, + "from_date": FROM_DATE, + "to_date": TO_DATE, + } + filters.update(overrides) + return frappe._dict(filters) + + def _rows(self, filters): + return {row["entity"]: row for row in execute(filters)[1]} + + def make_po(self, qty=4, rate=250): + return create_purchase_order( + company=COMPANY, supplier=SUPPLIER, qty=qty, rate=rate, transaction_date="2019-04-10" + ) + + def test_supplier_entity_filter(self): + filters = self._filters(tree_type="Supplier", entity=[SUPPLIER], curves="all") + base_total = flt(self._rows(filters).get(SUPPLIER, {}).get("total", 0.0)) + + po = self.make_po() + columns, data, _message, chart, *_rest = execute(filters) + + self.assertTrue(columns) + self.assertEqual({row["entity"] for row in data}, {SUPPLIER}) + self.assertAlmostEqual(data[0]["total"] - base_total, flt(po.base_net_total), places=2) + + supplier_name = frappe.db.get_value("Supplier", SUPPLIER, "supplier_name") + self.assertEqual({dataset["name"] for dataset in chart["data"]["datasets"]}, {supplier_name}) + + def test_parent_supplier_group_filter_preserves_rollup(self): + self.make_po() + filters = self._filters(tree_type="Supplier Group") + unfiltered = self._rows(filters) + filtered = self._rows(self._filters(tree_type="Supplier Group", entity=["All Supplier Groups"])) + + self.assertEqual(set(filtered), {"All Supplier Groups"}) + self.assertAlmostEqual( + filtered["All Supplier Groups"]["total"], + unfiltered["All Supplier Groups"]["total"], + places=2, + ) + + def test_supplier_group_entity_filter(self): + self.make_po() + unfiltered = self._rows(self._filters(tree_type="Supplier Group")) + filtered = self._rows(self._filters(tree_type="Supplier Group", entity=[SUPPLIER_GROUP])) + + self.assertEqual(set(filtered), {SUPPLIER_GROUP}) + self.assertEqual(filtered[SUPPLIER_GROUP]["indent"], 0) + self.assertAlmostEqual( + filtered[SUPPLIER_GROUP]["total"], unfiltered[SUPPLIER_GROUP]["total"], places=2 + ) + + def test_supplier_group_tree_rolls_up_to_root(self): + filters = self._filters(tree_type="Supplier Group") + base = self._rows(filters) + base_group = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0)) + + po = self.make_po(qty=4, rate=250) + rows = self._rows(filters) + + # supplier is remapped to its group; the root sits at indent 0 + self.assertIn(SUPPLIER_GROUP, rows) + self.assertIn("All Supplier Groups", rows) + self.assertNotIn(SUPPLIER, rows) + self.assertEqual(rows["All Supplier Groups"]["indent"], 0) + + self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_group, flt(po.base_net_total), places=2) + self.assertGreaterEqual(flt(rows["All Supplier Groups"]["total"]), flt(po.base_net_total)) + + def test_item_group_tree_rolls_up_to_root(self): + item_group = frappe.db.get_value("Item", "_Test Item", "item_group") + filters = self._filters(tree_type="Item Group") + base = self._rows(filters) + base_group = flt(base.get(item_group, {}).get("total", 0.0)) + + po = self.make_po(qty=4, rate=250) + rows = self._rows(filters) + + self.assertIn(item_group, rows) + self.assertIn("All Item Groups", rows) + # the raw item code must not leak as its own entity; the root sits at indent 0 + self.assertNotIn("_Test Item", rows) + self.assertEqual(rows["All Item Groups"]["indent"], 0) + self.assertAlmostEqual(rows[item_group]["total"] - base_group, flt(po.base_net_total), places=2) + self.assertGreaterEqual(flt(rows["All Item Groups"]["total"]), flt(po.base_net_total)) + + def test_supplier_group_by_quantity(self): + filters = self._filters(tree_type="Supplier Group", value_quantity="Quantity") + base = self._rows(filters) + base_qty = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0)) + base_root_qty = flt(base.get("All Supplier Groups", {}).get("total", 0.0)) + + po = self.make_po(qty=7, rate=100) + rows = self._rows(filters) + + self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_qty, flt(po.total_qty), places=2) + # the quantity must roll up to the root too, not just the leaf group + self.assertAlmostEqual( + rows["All Supplier Groups"]["total"] - base_root_qty, flt(po.total_qty), places=2 + ) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 74325b7c0bc..ba4786cddaa 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1267,6 +1267,11 @@ class AccountsController(TransactionBase): if self.get("taxes") or self.get("is_pos"): return + # set by the Opening Invoice Creation Tool, where the outstanding amount + # entered against a party is already inclusive of tax + if self.flags.dont_auto_add_taxes: + return + if frappe.get_single_value( "Accounts Settings", "add_taxes_from_taxes_and_charges_template" ) and hasattr(self, "taxes_and_charges"): diff --git a/erpnext/controllers/mapper.py b/erpnext/controllers/mapper.py new file mode 100644 index 00000000000..4b27c406f90 --- /dev/null +++ b/erpnext/controllers/mapper.py @@ -0,0 +1,25 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe.utils import flt + + +def get_qty_already_mapped(target_doc, ref_field: str, qty_field: str = "qty") -> frappe._dict: + """Return a map: {source row name: qty} of rows already mapped into the target document. + + "Get Items From" passes the in-progress (unsaved) document back as `target_doc`. Its rows + are invisible to the pending-qty queries in the mappers, which only count submitted + documents -- so without this, selecting the same source document twice maps every row + again. Rows are keyed by `ref_field` (dn_detail, so_detail, ...), and a row is present in + the map even when its qty is 0, so mappers without qty tracking can dedupe on presence. + """ + if isinstance(target_doc, str): + target_doc = frappe.parse_json(target_doc) + + qty_map = frappe._dict() + for row in (target_doc and target_doc.get("items")) or []: + if ref := row.get(ref_field): + qty_map[ref] = qty_map.get(ref, 0) + flt(row.get(qty_field)) + + return qty_map diff --git a/erpnext/controllers/tests/test_mapper.py b/erpnext/controllers/tests/test_mapper.py index 9dbcf6cafd2..dbc35da26a7 100644 --- a/erpnext/controllers/tests/test_mapper.py +++ b/erpnext/controllers/tests/test_mapper.py @@ -28,6 +28,96 @@ class TestMapper(unittest.TestCase): src_items = item_list_1 + item_list_2 + item_list_3 self.assertEqual(set(d for d in src_items), set(d.item_code for d in updated_so.items)) + def test_get_items_from_is_idempotent(self): + """Selecting the same source document twice must not duplicate rows in the target. + + "Get Items From" hands the in-progress document back to the mapper as `target_doc`. + Its rows are unsaved, so the mappers' pending-qty queries (submitted documents only) + cannot see them -- every mapper has to discount them explicitly. + """ + for label, make_source, method in self.idempotency_cases(): + with self.subTest(label): + source = make_source() + target = frappe.get_attr(method)(source.name) + mapped_rows = len(target.items) + self.assertTrue(mapped_rows, f"{label}: nothing was mapped") + + target = frappe.get_attr(method)(source.name, target) + self.assertEqual(len(target.items), mapped_rows, f"{label}: rows were duplicated") + + def idempotency_cases(self): + """(label, source factory, mapper method) for every "Get Items From" button. + + Quotation -> Sales Invoice is absent: Sales Invoice Item keeps no reference to the + Quotation row, so there is nothing to deduplicate on. + """ + from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.doctype.supplier_quotation.test_supplier_quotation import ( + test_records as supplier_quotation_records, + ) + from erpnext.selling.doctype.quotation.test_quotation import make_quotation + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.doctype.material_request.test_material_request import make_material_request + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + + def make_supplier_quotation(): + return frappe.copy_doc(supplier_quotation_records[0]).submit() + + return [ + ( + "Quotation -> Sales Order", + lambda: make_quotation(), + "erpnext.selling.doctype.quotation.quotation.make_sales_order", + ), + ( + "Sales Order -> Sales Invoice", + lambda: make_sales_order(), + "erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice", + ), + ( + "Sales Order -> Delivery Note", + lambda: make_sales_order(), + "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note", + ), + ( + "Delivery Note -> Sales Invoice", + lambda: create_delivery_note(), + "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice", + ), + ( + "Material Request -> Purchase Order", + lambda: make_material_request(), + "erpnext.stock.doctype.material_request.material_request.make_purchase_order", + ), + ( + "Supplier Quotation -> Purchase Order", + make_supplier_quotation, + "erpnext.buying.doctype.supplier_quotation.supplier_quotation.make_purchase_order", + ), + ( + "Purchase Order -> Purchase Receipt", + lambda: create_purchase_order(), + "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_receipt", + ), + ( + "Purchase Order -> Purchase Invoice", + lambda: create_purchase_order(), + "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_invoice", + ), + ( + "Purchase Receipt -> Purchase Invoice", + lambda: make_purchase_receipt(), + "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_invoice", + ), + ( + "Purchase Invoice -> Purchase Receipt", + lambda: make_purchase_invoice(), + "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_purchase_receipt", + ), + ] + def make_quotation(self, item_list, customer): qtn = frappe.get_doc( { diff --git a/erpnext/crm/doctype/contract_template/contract_template.py b/erpnext/crm/doctype/contract_template/contract_template.py index d0bf738d79d..587e2ce4bcd 100644 --- a/erpnext/crm/doctype/contract_template/contract_template.py +++ b/erpnext/crm/doctype/contract_template/contract_template.py @@ -39,6 +39,7 @@ def get_contract_template(template_name, doc): doc = json.loads(doc) contract_template = frappe.get_doc("Contract Template", template_name) + contract_template.check_permission() contract_terms = None if contract_template.contract_terms: diff --git a/erpnext/crm/utils.py b/erpnext/crm/utils.py index 06d97b5173f..15569ed61ec 100644 --- a/erpnext/crm/utils.py +++ b/erpnext/crm/utils.py @@ -227,7 +227,10 @@ class CRMNote(Document): notify_mentions(self.doctype, self.name, note) @frappe.whitelist() - def edit_note(self, note, row_id): + def edit_note(self, note: str, row_id: str): + # db_update() skips the write check that save() does in add_note/delete_note + self.check_permission("write") + for d in self.notes: if cstr(d.name) == row_id: d.note = note diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index ceb5ce299eb..9d50a57b8be 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -206,6 +206,43 @@ class TestJobCard(FrappeTestCase): # transfer was made for 2 fg qty in first transfer Stock Entry self.assertEqual(transfer_entry_2.fg_completed_qty, 0) + def test_material_request_stock_entry_uses_job_card_coverage(self): + from erpnext.stock.doctype.material_request.material_request import make_stock_entry + + self.transfer_material_against = "Job Card" + self.source_warehouse = "Stores - _TC" + job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) + mr = make_material_request(job_card.name) + mr.schedule_date = today() + for row in mr.items: + row.qty = flt(row.qty) / 2 + row.stock_qty = flt(row.stock_qty) / 2 + mr.submit() + + stock_entry = make_stock_entry(mr.name) + self.assertEqual(stock_entry.fg_completed_qty, job_card.for_quantity / 2) + + selected_row = mr.items[0] + try: + frappe.flags.selected_children = {"items": [selected_row.name]} + selected_stock_entry = make_stock_entry(mr.name) + finally: + frappe.flags.selected_children = None + + self.assertEqual( + [row.job_card_item for row in selected_stock_entry.items], [selected_row.job_card_item] + ) + self.assertEqual(selected_stock_entry.fg_completed_qty, 0) + + for row in mr.items: + transferred_qty = flt(row.stock_qty) / 2 + frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", transferred_qty) + frappe.db.set_value(row.doctype, row.name, "ordered_qty", transferred_qty) + mr.reload() + + repeated_stock_entry = make_stock_entry(mr.name) + self.assertEqual(repeated_stock_entry.fg_completed_qty, job_card.for_quantity / 4) + @change_settings("Manufacturing Settings", {"job_card_excess_transfer": 1}) def test_job_card_excess_material_transfer(self): "Test transferring more than required RM against Job Card." @@ -616,6 +653,7 @@ class TestJobCard(FrappeTestCase): self.assertEqual(ste.job_card, job_card_name) self.assertEqual(ste.from_bom, 1.0) self.assertEqual(ste.bom_no, work_order.bom_no) + self.assertEqual(ste.fg_completed_qty, frappe.get_value("Job Card", job_card_name, "for_quantity")) def test_job_card_proccess_qty_and_completed_qty(self): from erpnext.manufacturing.doctype.routing.test_routing import ( diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index e453dcc26c3..069907c1b05 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -3421,6 +3421,42 @@ class TestWorkOrder(FrappeTestCase): frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 0) + def test_transferred_qty_sums_item_and_its_alternate(self): + # Base item + its alternate transfers must sum onto the required row, not overwrite. + fg_item = "Test FG Item For Alternate Transferred Qty" + source_warehouse = "Stores - _TC" + raw_material = "Test RM For Alternate Transferred Qty" + alternate_item = "Alternate Test RM For Alternate Transferred Qty" + + make_item(fg_item, {"is_stock_item": 1}) + for item in [raw_material, alternate_item]: + make_item(item, {"is_stock_item": 1, "allow_alternative_item": 1}) + test_stock_entry.make_stock_entry(item_code=item, target=source_warehouse, qty=10, basic_rate=100) + + frappe.get_doc( + { + "doctype": "Item Alternative", + "item_code": raw_material, + "alternative_item_code": alternate_item, + "two_way": 1, + } + ).insert() + + make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=[raw_material]) + wo = make_wo_order_test_record(item=fg_item, qty=10, source_warehouse=source_warehouse) + + # 6 as the base item + frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 6)).submit() + # 4 as the alternate item, linked back to the base + alt_transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 4)) + alt_transfer.items[0].item_code = alternate_item + alt_transfer.items[0].original_item = raw_material + alt_transfer.submit() + + wo.reload() + self.assertEqual(wo.required_items[0].transferred_qty, 10) + self.assertEqual(wo.material_transferred_for_manufacturing, 10) + def test_components_qty_for_bom_based_manufacture_entry(self): frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM") frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 3397e7940de..dd5a66e096c 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -258,7 +258,7 @@ class WorkOrder(Document): PackedItem = frappe.qb.DocType("Packed Item") ProductBundleItem = frappe.qb.DocType("Product Bundle Item") - so = ( + so_query = ( frappe.qb.from_(SalesOrder) .inner_join(SalesOrderItem) .on(SalesOrderItem.parent == SalesOrder.name) @@ -274,16 +274,23 @@ class WorkOrder(Document): | (ProductBundleItem.item_code == production_item) ) ) - .run(as_dict=1) ) + if self.sales_order_item: + so_query = so_query.where(SalesOrderItem.name == self.sales_order_item) + + so = so_query.run(as_dict=1) + if not so: - so = ( + packed_so_query = ( frappe.qb.from_(SalesOrder) .inner_join(SalesOrderItem) .on(SalesOrderItem.parent == SalesOrder.name) .inner_join(PackedItem) - .on(PackedItem.parent == SalesOrder.name) + .on( + (PackedItem.parent == SalesOrder.name) + & (PackedItem.parent_detail_docname == SalesOrderItem.name) + ) .select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date) .where( (SalesOrder.name == self.sales_order) @@ -292,9 +299,16 @@ class WorkOrder(Document): & (SalesOrder.docstatus == 1) & (PackedItem.item_code == production_item) ) - .run(as_dict=1) ) + if self.sales_order_item: + packed_so_query = packed_so_query.where( + (PackedItem.name == self.sales_order_item) + | (SalesOrderItem.name == self.sales_order_item) + ) + + so = packed_so_query.run(as_dict=1) + if len(so): if not self.expected_delivery_date: self.expected_delivery_date = so[0].delivery_date @@ -1272,11 +1286,15 @@ class WorkOrder(Document): & (ste.purpose == "Material Transfer for Manufacture") & (ste.is_return == 0) ) - .groupby(ste_child.item_code) + .groupby(ste_child.item_code, ste_child.original_item) ) data = query.run(as_dict=1) or [] - transferred_items = frappe._dict({d.original_item or d.item_code: d.qty for d in data}) + # An item's own transfer and its substitutes both key to the original item, so sum them. + transferred_items = frappe._dict() + for d in data: + key = d.original_item or d.item_code + transferred_items[key] = flt(transferred_items.get(key)) + flt(d.qty) for row in self.required_items: row.db_set( diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 5535458d3ed..d5a7ff156d3 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -449,3 +449,5 @@ erpnext.patches.v16_0.backfill_repost_accounting_ledger_status erpnext.patches.v16_0.merge_seeded_item_group_root erpnext.patches.v16_0.repair_work_order_material_transfer erpnext.patches.v16_0.remove_frappe_crm_custom_fields +erpnext.patches.v16_0.append_fieldname_to_pos_search_fields +erpnext.patches.v16_0.add_transaction_roles_to_sms_settings \ No newline at end of file diff --git a/erpnext/patches/v16_0/add_transaction_roles_to_sms_settings.py b/erpnext/patches/v16_0/add_transaction_roles_to_sms_settings.py new file mode 100644 index 00000000000..c287c838091 --- /dev/null +++ b/erpnext/patches/v16_0/add_transaction_roles_to_sms_settings.py @@ -0,0 +1,39 @@ +import frappe +from frappe import _ + +STANDARD_TRANSACTION_ROLES = [ + "Sales User", + "Sales Manager", + "Purchase User", + "Purchase Manager", + "Stock User", + "Stock Manager", + "Accounts User", + "Accounts Manager", +] + + +def execute(): + """Seed SMS Settings.allowed_roles with ERPNext's standard transaction roles.""" + frappe.reload_doctype("SMS Settings") + + if not frappe.get_meta("SMS Settings").has_field("allowed_roles"): + frappe.throw( + _( + "SMS Settings.allowed_roles not found. Update the Frappe Framework app to a " + "version that includes this field, then re-run bench migrate." + ) + ) + + sms_settings = frappe.get_single("SMS Settings") + existing_roles = {d.role for d in sms_settings.get("allowed_roles")} + + added = False + for role in STANDARD_TRANSACTION_ROLES: + if role not in existing_roles and frappe.db.exists("Role", role): + sms_settings.append("allowed_roles", {"role": role}) + added = True + + if added: + sms_settings.flags.ignore_mandatory = True + sms_settings.save() diff --git a/erpnext/patches/v16_0/append_fieldname_to_pos_search_fields.py b/erpnext/patches/v16_0/append_fieldname_to_pos_search_fields.py new file mode 100644 index 00000000000..686d94dbb11 --- /dev/null +++ b/erpnext/patches/v16_0/append_fieldname_to_pos_search_fields.py @@ -0,0 +1,20 @@ +import frappe + + +def execute(): + rows = frappe.get_all( + "POS Search Fields", filters={"parent": "POS Settings"}, fields=["name", "field", "fieldname"] + ) + + for row in rows: + # the row used to hold the label alone, it now holds "Label (fieldname)" + if not (row.field and row.fieldname) or row.field.endswith(f"({row.fieldname})"): + continue + + frappe.db.set_value( + "POS Search Fields", + row.name, + "field", + f"{row.field} ({row.fieldname})", + update_modified=False, + ) diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index 80ef45c099d..34b8e15f32f 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -590,12 +590,16 @@ def allow_to_make_project_update(project, time, frequency): @frappe.whitelist() -def create_duplicate_project(prev_doc, project_name): +def create_duplicate_project(prev_doc: str | dict, project_name: str): """Create duplicate project based on the old project""" import json prev_doc = json.loads(prev_doc) + # prev_doc is caller-supplied, but the tasks below are read from the db by name + if source_name := prev_doc.get("name"): + frappe.has_permission("Project", "read", source_name, throw=True) + if project_name == prev_doc.get("name"): frappe.throw(_("Use a name that is different from previous project name")) @@ -790,5 +794,6 @@ def calculate_total_purchase_cost(project: str | None = None): @frappe.whitelist() def update_costing_and_billing(project: str | None = None): project = frappe.get_doc("Project", project) + project.check_permission("write") project.update_costing() project.db_update() diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index bf3116cb409..a7c13ba8af0 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -7,6 +7,7 @@ import json import frappe from frappe import _ from frappe.model.document import Document +from frappe.query_builder.functions import Date from frappe.utils import add_to_date, flt, get_datetime, getdate, time_diff_in_hours, time_diff_in_seconds from erpnext.controllers.queries import get_match_cond @@ -322,45 +323,62 @@ class Timesheet(Document): @frappe.whitelist() def get_projectwise_timesheet_data(project=None, parent=None, from_time=None, to_time=None): - condition = "" + tsd = frappe.qb.DocType("Timesheet Detail") + ts = frappe.qb.DocType("Timesheet") + + allowed_timesheets = frappe.get_list("Timesheet", pluck="name") + allowed_projects = frappe.get_list("Project", pluck="name") + + if not allowed_timesheets: + return [] + + query = ( + frappe.qb.from_(tsd) + .inner_join(ts) + .on(ts.name == tsd.parent) + .select( + tsd.name.as_("name"), + tsd.parent.as_("time_sheet"), + tsd.from_time.as_("from_time"), + tsd.to_time.as_("to_time"), + tsd.billing_hours.as_("billing_hours"), + tsd.billing_amount.as_("billing_amount"), + tsd.activity_type.as_("activity_type"), + tsd.description.as_("description"), + ts.currency.as_("currency"), + tsd.project_name.as_("project_name"), + ) + .where( + (tsd.parenttype == "Timesheet") + & (tsd.docstatus == 1) + & (tsd.is_billable == 1) + & tsd.sales_invoice.isnull() + & (tsd.parent.isin(allowed_timesheets)) + ) + ) + + if allowed_projects: + query = query.where((tsd.project.isin(allowed_projects)) | (tsd.project.isnull())) + else: + query = query.where(tsd.project.isnull()) + if project: - condition += "AND tsd.project = %(project)s " + query = query.where(tsd.project == project) if parent: - condition += "AND tsd.parent = %(parent)s " + query = query.where(tsd.parent == parent) if from_time and to_time: - condition += "AND CAST(tsd.from_time as DATE) BETWEEN %(from_time)s AND %(to_time)s" + query = query.where(Date(tsd.from_time).between(from_time, to_time)) - query = f""" - SELECT - tsd.name as name, - tsd.parent as time_sheet, - tsd.from_time as from_time, - tsd.to_time as to_time, - tsd.billing_hours as billing_hours, - tsd.billing_amount as billing_amount, - tsd.activity_type as activity_type, - tsd.description as description, - ts.currency as currency, - tsd.project_name as project_name - FROM `tabTimesheet Detail` tsd - INNER JOIN `tabTimesheet` ts - ON ts.name = tsd.parent - WHERE - tsd.parenttype = 'Timesheet' - AND tsd.docstatus = 1 - AND tsd.is_billable = 1 - AND tsd.sales_invoice is NULL - {condition} - ORDER BY tsd.from_time ASC - """ - - filters = {"project": project, "parent": parent, "from_time": from_time, "to_time": to_time} - - return frappe.db.sql(query, filters, as_dict=1) + return query.orderby(tsd.from_time).run(as_dict=1) @frappe.whitelist() def get_timesheet_detail_rate(timelog, currency): + allowed_timesheets = frappe.get_list("Timesheet", pluck="name") + + if not allowed_timesheets: + return 0.0 + ts = frappe.qb.DocType("Timesheet") ts_detail = frappe.qb.DocType("Timesheet Detail") @@ -368,10 +386,20 @@ def get_timesheet_detail_rate(timelog, currency): frappe.qb.from_(ts_detail) .inner_join(ts) .on(ts.name == ts_detail.parent) - .select(ts_detail.billing_amount.as_("billing_amount"), ts.currency.as_("currency")) - .where(ts_detail.name == timelog) + .select( + ts_detail.billing_amount.as_("billing_amount"), + ts.currency.as_("currency"), + ts.name.as_("timesheet"), + ) + .where((ts_detail.name == timelog) & ts_detail.parent.isin(allowed_timesheets)) + .limit(1) .run(as_dict=1) - )[0] + ) + + if not timelog_detail: + return 0.0 + + timelog_detail = timelog_detail[0] if timelog_detail.currency: exchange_rate = get_exchange_rate(timelog_detail.currency, currency) @@ -386,33 +414,42 @@ def get_timesheet(doctype, txt, searchfield, start, page_len, filters): if not filters: filters = {} - condition = "" - if filters.get("project"): - condition = "and tsd.project = %(project)s" + allowed_timesheets = frappe.get_list("Timesheet", pluck="name") - return frappe.db.sql( - f"""select distinct tsd.parent from `tabTimesheet Detail` tsd, - `tabTimesheet` ts where - ts.status in ('Submitted', 'Payslip') and tsd.parent = ts.name and - tsd.docstatus = 1 and ts.total_billable_amount > 0 - and tsd.parent LIKE %(txt)s {condition} - order by tsd.parent limit %(page_len)s offset %(start)s""", - { - "txt": "%" + txt + "%", - "start": start, - "page_len": page_len, - "project": filters.get("project"), - }, + if not allowed_timesheets: + return [] + + tsd = frappe.qb.DocType("Timesheet Detail") + ts = frappe.qb.DocType("Timesheet") + + query = ( + frappe.qb.from_(tsd) + .inner_join(ts) + .on(tsd.parent == ts.name) + .select(tsd.parent) + .distinct() + .where( + ts.status.isin(["Submitted", "Payslip"]) + & (tsd.docstatus == 1) + & (ts.total_billable_amount > 0) + & tsd.parent.like(f"%{txt}%") + & tsd.parent.isin(allowed_timesheets) + ) ) + if filters.get("project"): + query = query.where(tsd.project == filters.get("project")) + + return query.orderby(tsd.parent).limit(page_len).offset(start).run() + @frappe.whitelist() -def get_timesheet_data(name, project): +def get_timesheet_data(name, project=None): data = None - if project and project != "": + if project: data = get_projectwise_timesheet_data(project, name) else: - data = frappe.get_all( + data = frappe.get_list( "Timesheet", fields=[ "(total_billable_amount - total_billed_amount) as billing_amt", @@ -546,8 +583,14 @@ def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20 customer = contact.get_link_for("Customer") if customer: - sales_invoices = frappe.get_all("Sales Invoice", filters={"customer": customer}, pluck="name") + sales_invoices = frappe.get_all( + "Sales Invoice", + filters={"customer": customer, "docstatus": ["!=", 2]}, + pluck="name", + ) projects = frappe.get_all("Project", filters={"customer": customer}, pluck="name") + if not (sales_invoices or projects): + return [] # Return timesheet related data to web portal. table = frappe.qb.DocType("Timesheet") @@ -577,10 +620,7 @@ def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20 if projects: conditions.append(child_table.project.isin(projects)) - if conditions: - query = query.where(frappe.qb.terms.Criterion.any(conditions)) - - return query.run(as_dict=True) + return query.where(frappe.qb.terms.Criterion.any(conditions)).run(as_dict=True) else: return {} diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 7a133acaf9e..54c13bd0dff 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -2367,8 +2367,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe method: me.get_method_for_payment(), args: args, callback: function(r) { - var doclist = frappe.model.sync(r.message); - frappe.set_route("Form", doclist[0].doctype, doclist[0].name); + if (!r.exc) { + var doclist = frappe.model.sync(r.message); + frappe.set_route("Form", doclist[0].doctype, doclist[0].name); + } } }); } diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 2f66c0720d8..4ec8bddb6b1 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -982,7 +982,7 @@ erpnext.utils.map_current_doc = function (opts) { if (already_set) { frappe.msgprint( - __("You have already selected items from {0} {1}", [opts.source_doctype, src]) + __("You have already selected items from {0} {1}", [__(opts.source_doctype), src]) ); return; } diff --git a/erpnext/public/js/utils/party.js b/erpnext/public/js/utils/party.js index 1652a849850..66fd0eeec8e 100644 --- a/erpnext/public/js/utils/party.js +++ b/erpnext/public/js/utils/party.js @@ -81,7 +81,7 @@ erpnext.utils.get_party_details = function (frm, method, args, callback) { if ( !erpnext.utils.validate_mandatory( frm, - "Posting / Transaction Date", + __("Posting / Transaction Date"), args.posting_date, args.party_type == "Customer" ? "customer" : "supplier" ) @@ -92,7 +92,7 @@ erpnext.utils.get_party_details = function (frm, method, args, callback) { if ( !erpnext.utils.validate_mandatory( frm, - "Company", + __("Company"), frm.doc.company, args.party_type == "Customer" ? "customer" : "supplier" ) @@ -177,7 +177,7 @@ erpnext.utils.set_taxes_from_address = function ( if ( !erpnext.utils.validate_mandatory( frm, - "Lead / Customer / Supplier", + __("Lead / Customer / Supplier"), frm.doc.customer || frm.doc.supplier || frm.doc.lead || frm.doc.party_name, triggered_from_field ) @@ -188,7 +188,7 @@ erpnext.utils.set_taxes_from_address = function ( if ( !erpnext.utils.validate_mandatory( frm, - "Posting / Transaction Date", + __("Posting / Transaction Date"), frm.doc.posting_date || frm.doc.transaction_date, triggered_from_field ) @@ -220,14 +220,14 @@ erpnext.utils.set_taxes_from_address = function ( erpnext.utils.set_taxes = function (frm, triggered_from_field) { if (frappe.meta.get_docfield(frm.doc.doctype, "taxes")) { - if (!erpnext.utils.validate_mandatory(frm, "Company", frm.doc.company, triggered_from_field)) { + if (!erpnext.utils.validate_mandatory(frm, __("Company"), frm.doc.company, triggered_from_field)) { return; } if ( !erpnext.utils.validate_mandatory( frm, - "Lead / Customer / Supplier", + __("Lead / Customer / Supplier"), frm.doc.customer || frm.doc.supplier || frm.doc.lead || frm.doc.party_name, triggered_from_field ) @@ -238,7 +238,7 @@ erpnext.utils.set_taxes = function (frm, triggered_from_field) { if ( !erpnext.utils.validate_mandatory( frm, - "Posting / Transaction Date", + __("Posting / Transaction Date"), frm.doc.posting_date || frm.doc.transaction_date, triggered_from_field ) diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 4df15cac65e..8245c75770a 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -14,7 +14,7 @@ from frappe.contacts.address_and_contact import ( from frappe.model.mapper import get_mapped_doc from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options from frappe.model.utils.rename_doc import update_linked_doctypes -from frappe.utils import cint, cstr, flt, get_formatted_email, today +from frappe.utils import cint, cstr, flt, get_formatted_email, get_link_to_form, today from frappe.utils.deprecations import deprecated from frappe.utils.user import get_users_with_role @@ -227,10 +227,15 @@ class Customer(TransactionBase): ) if internal_customer: + internal_customer_link = get_link_to_form("Customer", internal_customer) frappe.throw( - _("Internal Customer for company {0} already exists").format( - frappe.bold(self.represents_company) - ) + _( + "Internal Customer {0} already exists for {1}. Disable it to make this Customer internal." + ).format( + internal_customer_link, + frappe.bold(self.represents_company), + ), + title=_("Internal Customer Already Exists"), ) def on_update(self): diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json index 58f18dd731a..411632005a5 100644 --- a/erpnext/selling/doctype/quotation/quotation.json +++ b/erpnext/selling/doctype/quotation/quotation.json @@ -346,6 +346,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", "label": "Currency and Price List", @@ -1107,7 +1108,7 @@ "idx": 82, "is_submittable": 1, "links": [], - "modified": "2026-05-30 17:40:02.667637", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Selling", "name": "Quotation", diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 8366f61c6b3..856ba33a486 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -9,6 +9,7 @@ from frappe import _ from frappe.model.mapper import get_mapped_doc from frappe.utils import cint, flt, getdate, nowdate +from erpnext.controllers.mapper import get_qty_already_mapped from erpnext.controllers.selling_controller import SellingController form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -257,7 +258,11 @@ class Quotation(SellingController): opp.set_status(status=status, update=True) @frappe.whitelist() - def declare_enquiry_lost(self, lost_reasons_list, competitors, detailed_reason=None): + def declare_enquiry_lost( + self, lost_reasons_list: list, competitors: list, detailed_reason: str | None = None + ): + self.check_permission("write") + if not (self.is_fully_ordered() or self.is_partially_ordered()): get_lost_reasons = frappe.get_list("Quotation Lost Reason", fields=["name"]) lost_reasons_lst = [reason.get("name") for reason in get_lost_reasons] @@ -387,6 +392,9 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar customer = _make_customer(source_name, ignore_permissions) ordered_items = get_ordered_items(source_name) + mapped_items = get_qty_already_mapped(target_doc, "quotation_item", "stock_qty") + for name, stock_qty in mapped_items.items(): + ordered_items[name] = flt(ordered_items.get(name)) + stock_qty selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])] @@ -440,7 +448,10 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar 2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty 3. If no selections: Simple row: Map if adequate qty """ - if not ((item.stock_qty > ordered_items.get(item.name, 0.0)) or is_unit_price_row(item)): + if not ( + (item.stock_qty > ordered_items.get(item.name, 0.0)) + or (is_unit_price_row(item) and item.name not in mapped_items) + ): return False if not selected_rows: diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 876cc5bb760..3d68495c5ec 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -478,6 +478,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", "hide_days": 1, @@ -1679,7 +1680,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2026-07-28 12:20:44.130918", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 583486960c1..e664f69e14b 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -22,6 +22,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( validate_inter_company_party, ) from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account +from erpnext.controllers.mapper import get_qty_already_mapped from erpnext.controllers.selling_controller import SellingController from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( validate_against_blanket_order, @@ -977,6 +978,8 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None): if kwargs.for_reserved_stock: sre_details = get_sre_reserved_qty_details_for_voucher("Sales Order", source_name) + mapped_qty_by_item = get_qty_already_mapped(target_doc, "so_detail") + mapper = { "Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}}, "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True}, @@ -1031,15 +1034,17 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None): return False return ( - (abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc) + (abs(doc.delivered_qty) + abs(mapped_qty_by_item.get(doc.name, 0)) < abs(doc.qty)) + or (is_unit_price_row(doc) and doc.name not in mapped_qty_by_item) ) and doc.delivered_by_supplier != 1 + def remaining_qty(source): + return flt(source.qty) - flt(source.delivered_qty) - flt(mapped_qty_by_item.get(source.name, 0)) + def update_item(source, target, source_parent): - target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate) - target.amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.rate) - target.qty = ( - flt(source.qty) if is_unit_price_row(source) else flt(source.qty) - flt(source.delivered_qty) - ) + target.base_amount = remaining_qty(source) * flt(source.base_rate) + target.amount = remaining_qty(source) * flt(source.rate) + target.qty = flt(source.qty) if is_unit_price_row(source) else remaining_qty(source) item = get_item_defaults(target.item_code, source_parent.company) item_group = get_item_group_defaults(target.item_code, source_parent.company) @@ -1137,6 +1142,7 @@ def make_sales_invoice( has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items") billed_qty_by_item = None pending_qty_by_item = {} + mapped_qty_by_item = get_qty_already_mapped(target_doc, "so_detail") def is_unit_price_row(source): return has_unit_price_items and source.qty == 0 @@ -1165,6 +1171,7 @@ def make_sales_invoice( if source.qty and source.billed_amt: billable_qty -= get_billed_qty_by_item().get(source.name, 0) + billable_qty -= mapped_qty_by_item.get(source.name, 0) pending_qty_by_item[source.name] = max(flt(billable_qty, source.precision("qty")), 0) return pending_qty_by_item[source.name] @@ -1244,7 +1251,7 @@ def make_sales_invoice( "postprocess": update_item, "condition": lambda doc: select_item(doc) and ( - True + doc.name not in mapped_qty_by_item if is_unit_price_row(doc) else ( doc.qty @@ -1663,8 +1670,10 @@ def is_product_bundle(item_code): @frappe.whitelist() -def make_work_orders(items, sales_order, company, project=None): +def make_work_orders(items: str, sales_order: str, company: str, project: str | None = None): """Make Work Orders against the given Sales Order for the given `items`""" + frappe.has_permission("Sales Order", "read", sales_order, throw=True) + items = json.loads(items).get("items") out = [] diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index b71e2d6878a..8c0631c1b52 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1730,6 +1730,61 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): ) self.assertEqual(wo_qty[0][0], so_item_name.get(item)) + @change_settings("Selling Settings", {"allow_multiple_items": 1}) + def test_make_work_order_for_duplicate_product_bundle_rows(self): + from erpnext.selling.doctype.sales_order.sales_order import get_work_order_items + + bundle_item = make_item("_Test Work Order Product Bundle", {"is_stock_item": 0}).name + make_product_bundle(bundle_item, ["_Test FG Item"]) + + first_delivery_date = add_days(today(), 5) + second_delivery_date = add_days(today(), 10) + so = make_sales_order( + item_list=[ + { + "item_code": bundle_item, + "qty": 1, + "rate": 100, + "warehouse": "_Test Warehouse - _TC", + "delivery_date": first_delivery_date, + }, + { + "item_code": bundle_item, + "qty": 1, + "rate": 100, + "warehouse": "_Test Warehouse - _TC", + "delivery_date": second_delivery_date, + }, + ] + ) + + items = [ + { + "warehouse": item.get("warehouse"), + "item_code": item.get("item_code"), + "pending_qty": item.get("pending_qty"), + "sales_order_item": item.get("sales_order_item"), + "bom": item.get("bom"), + "description": item.get("description"), + } + for item in get_work_order_items(so.name) + ] + work_orders = make_work_orders(json.dumps({"items": items}), so.name, so.company) + + expected_delivery_dates = { + packed_item.name: next( + item.delivery_date for item in so.items if item.name == packed_item.parent_detail_docname + ) + for packed_item in so.packed_items + } + self.assertEqual(len(work_orders), 2) + for work_order_name in work_orders: + work_order = frappe.get_doc("Work Order", work_order_name) + self.assertEqual( + getdate(work_order.expected_delivery_date), + getdate(expected_delivery_dates[work_order.sales_order_item]), + ) + def test_advance_payment_entry_unlink_against_sales_order(self): from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry diff --git a/erpnext/selling/report/sales_analytics/sales_analytics.js b/erpnext/selling/report/sales_analytics/sales_analytics.js index 96d91374c61..c945d4b02ab 100644 --- a/erpnext/selling/report/sales_analytics/sales_analytics.js +++ b/erpnext/selling/report/sales_analytics/sales_analytics.js @@ -2,6 +2,18 @@ // For license information, please see license.txt frappe.query_reports["Sales Analytics"] = { + // "All" reports on every doctype at once and forces the tree to Customer + entity_tree_type() { + const doc_type = frappe.query_report.get_filter_value("doc_type"); + return doc_type === "All" ? "Customer" : frappe.query_report.get_filter_value("tree_type"); + }, + reset_entity_filter() { + const entity_filter = frappe.query_report.get_filter("entity"); + if (!entity_filter) return; + entity_filter.df.label = __(this.entity_tree_type()); + entity_filter.set_value([]); + entity_filter.refresh(); + }, filters: [ { fieldname: "tree_type", @@ -18,6 +30,21 @@ frappe.query_reports["Sales Analytics"] = { ], default: "Customer", reqd: 1, + on_change: function () { + frappe.query_reports["Sales Analytics"].reset_entity_filter(); + frappe.query_report.refresh(); + }, + }, + { + fieldname: "entity", + label: __("Entity"), + fieldtype: "MultiSelectList", + get_data: function (txt) { + const tree_type = frappe.query_reports["Sales Analytics"].entity_tree_type(); + if (!tree_type || tree_type === "Order Type") return []; + return frappe.db.get_link_options(tree_type, txt); + }, + depends_on: "eval:doc.tree_type != 'Order Type'", }, { fieldname: "doc_type", @@ -26,6 +53,10 @@ frappe.query_reports["Sales Analytics"] = { options: ["Sales Order", "Delivery Note", "Sales Invoice"], default: "Sales Invoice", reqd: 1, + on_change: function () { + frappe.query_reports["Sales Analytics"].reset_entity_filter(); + frappe.query_report.refresh(); + }, }, { fieldname: "value_quantity", diff --git a/erpnext/selling/report/sales_analytics/sales_analytics.py b/erpnext/selling/report/sales_analytics/sales_analytics.py index 5d4478f8dd6..edab7420df7 100644 --- a/erpnext/selling/report/sales_analytics/sales_analytics.py +++ b/erpnext/selling/report/sales_analytics/sales_analytics.py @@ -18,6 +18,7 @@ def execute(filters=None): class Analytics: def __init__(self, filters=None): self.filters = frappe._dict(filters or {}) + self.entities = self.filters.get("entity") or [] self.date_field = ( "transaction_date" if self.filters.doc_type in ["Sales Order", "Purchase Order"] @@ -61,6 +62,7 @@ class Analytics: self.update_company_list_for_parent_company() self.get_columns() self.get_data() + self.filter_data_by_entities() self.get_chart_data() # Skipping total row for tree-view reports @@ -325,6 +327,23 @@ class Analytics: filters=filters, ) + def filter_data_by_entities(self): + if not self.entities: + return + + entities = set(self.entities) + selected_data = [] + for row in self.data: + if row["entity"] not in entities: + continue + + row = row.copy() + if "indent" in row: + row["indent"] = 0 + selected_data.append(row) + + self.data = selected_data + def get_rows(self): self.data = [] self.get_periodic_data() diff --git a/erpnext/selling/report/sales_analytics/test_sales_analytics.py b/erpnext/selling/report/sales_analytics/test_sales_analytics.py new file mode 100644 index 00000000000..81880238494 --- /dev/null +++ b/erpnext/selling/report/sales_analytics/test_sales_analytics.py @@ -0,0 +1,253 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + + +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import flt + +from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.selling.report.sales_analytics.sales_analytics import execute + +# Bootstrap masters reused as-is (see erpnext/tests/utils.py): +# "_Test Customer" -> customer_group "_Test Customer Group", territory "_Test Territory" +# "_Test Supplier" -> supplier_group "_Test Supplier Group" (child of "All Supplier Groups") +# Sales Order.order_type defaults to "Sales" (reqd Select field) +COMPANY = "_Test Company" +CUSTOMER = "_Test Customer" +CUSTOMER_GROUP = "_Test Customer Group" +TERRITORY = "_Test Territory" +SUPPLIER = "_Test Supplier" +SUPPLIER_GROUP = "_Test Supplier Group" +FROM_DATE = "2019-04-01" +TO_DATE = "2019-06-30" + + +class TestSalesAnalytics(FrappeTestCase): + def setUp(self): + frappe.set_user("Administrator") + self.created_docs = [] + # Two submitted Sales Orders for the bootstrap customer inside the report window. + # These roll up into the tree roots the converted tree/order-type queries build. + self.orders = [ + self.make_so(qty=5, rate=100, transaction_date="2019-04-10"), + self.make_so(qty=3, rate=100, transaction_date="2019-05-15"), + ] + + def tearDown(self): + for doctype, name in reversed(self.created_docs): + if not frappe.db.exists(doctype, name): + continue + + doc = frappe.get_doc(doctype, name) + if doc.docstatus == 1: + doc.cancel() + frappe.delete_doc(doctype, name, force=True) + + super().tearDown() + + def make_so(self, qty, rate, transaction_date, order_type=None): + so = make_sales_order( + company=COMPANY, + customer=CUSTOMER, + qty=qty, + rate=rate, + transaction_date=transaction_date, + do_not_save=True, + ) + # v15's test helper does not populate these hidden analytics dimensions. + so.customer_group = CUSTOMER_GROUP + so.territory = TERRITORY + if order_type: + so.order_type = order_type + so.insert() + so.submit() + self.created_docs.append((so.doctype, so.name)) + return so + + def _base_filters(self, **overrides): + filters = { + "doc_type": "Sales Order", + "value_quantity": "Value", + "range": "Monthly", + "company": COMPANY, + "from_date": FROM_DATE, + "to_date": TO_DATE, + } + filters.update(overrides) + return filters + + def _expected_value_total(self): + return sum(flt(so.base_net_total) for so in self.orders) + + def _expected_qty_total(self): + return sum(flt(so.total_qty) for so in self.orders) + + def _row_by_entity(self, data): + return {row["entity"]: row for row in data} + + def test_customer_entity_filter(self): + _columns, data, _message, chart, *_rest = execute( + self._base_filters(tree_type="Customer", entity=[CUSTOMER], curves="all") + ) + + self.assertEqual({row["entity"] for row in data}, {CUSTOMER}) + self.assertAlmostEqual(data[0]["total"], self._expected_value_total(), places=2) + self.assertEqual({dataset["name"] for dataset in chart["data"]["datasets"]}, {CUSTOMER}) + + def test_parent_customer_group_filter_preserves_rollup(self): + _columns, unfiltered_data, *_rest = execute(self._base_filters(tree_type="Customer Group")) + _columns, filtered_data, *_rest = execute( + self._base_filters(tree_type="Customer Group", entity=["All Customer Groups"]) + ) + + unfiltered = self._row_by_entity(unfiltered_data) + filtered = self._row_by_entity(filtered_data) + self.assertEqual(set(filtered), {"All Customer Groups"}) + self.assertAlmostEqual( + filtered["All Customer Groups"]["total"], + unfiltered["All Customer Groups"]["total"], + places=2, + ) + + def test_customer_group_entity_filter(self): + _columns, unfiltered_data, *_rest = execute(self._base_filters(tree_type="Customer Group")) + _columns, filtered_data, *_rest = execute( + self._base_filters(tree_type="Customer Group", entity=[CUSTOMER_GROUP]) + ) + + unfiltered = self._row_by_entity(unfiltered_data) + filtered = self._row_by_entity(filtered_data) + self.assertEqual(set(filtered), {CUSTOMER_GROUP}) + self.assertEqual(filtered[CUSTOMER_GROUP]["indent"], 0) + self.assertAlmostEqual( + filtered[CUSTOMER_GROUP]["total"], unfiltered[CUSTOMER_GROUP]["total"], places=2 + ) + + def test_customer_group_tree_rolls_up_to_root(self): + """tree_type='Customer Group' drives get_groups (tree get_all ordered by lft) + and get_rows_by_group, rolling child values up to the 'All Customer Groups' root.""" + columns, data, *_ = execute(self._base_filters(tree_type="Customer Group")) + + self.assertTrue(columns) + self.assertTrue(data) + + rows = self._row_by_entity(data) + # The whole tree is returned, so both the root and the customer's own group appear. + self.assertIn("All Customer Groups", rows) + self.assertIn(CUSTOMER_GROUP, rows) + + expected = self._expected_value_total() + self.assertGreater(expected, 0) + # Leaf group holds the orders; root receives the same total via roll-up. + self.assertAlmostEqual(rows[CUSTOMER_GROUP]["total"], expected, places=2) + self.assertAlmostEqual(rows["All Customer Groups"]["total"], expected, places=2) + # Roots of a tree report sit at indent 0. + self.assertEqual(rows["All Customer Groups"]["indent"], 0) + + def test_territory_tree_rolls_up_to_root(self): + """tree_type='Territory' exercises the same tree path against the Territory tree.""" + columns, data, *_ = execute(self._base_filters(tree_type="Territory")) + + self.assertTrue(columns) + rows = self._row_by_entity(data) + self.assertIn("All Territories", rows) + self.assertIn(TERRITORY, rows) + + expected = self._expected_value_total() + self.assertAlmostEqual(rows[TERRITORY]["total"], expected, places=2) + self.assertAlmostEqual(rows["All Territories"]["total"], expected, places=2) + + def test_order_type_synthetic_tree(self): + """tree_type='Order Type' drives get_teams: distinct order_type rebuilt in Python + under a synthetic 'Order Types' root, then rolled up via get_rows_by_group.""" + columns, data, *_ = execute(self._base_filters(tree_type="Order Type")) + + self.assertTrue(columns) + rows = self._row_by_entity(data) + # Synthetic root plus the default order_type the bootstrap Sales Orders carry. + self.assertIn("Order Types", rows) + self.assertIn("Sales", rows) + self.assertEqual(rows["Order Types"]["indent"], 0) + + expected = self._expected_value_total() + self.assertAlmostEqual(rows["Sales"]["total"], expected, places=2) + self.assertAlmostEqual(rows["Order Types"]["total"], expected, places=2) + + def test_order_type_leaf_rows_in_sorted_order(self): + """get_teams fetches distinct order_types; frappe drops the SQL ORDER BY for distinct queries on + postgres, so the report sorts the order-type rows in python (key=str.casefold) to keep them in a + deterministic, case-insensitive order identical on both engines.""" + for order_type in ("Shopping Cart", "Maintenance", "Sales"): # created out of sorted order + self.make_so( + qty=1, + rate=100, + transaction_date="2019-04-12", + order_type=order_type, + ) + + columns, data, *_ = execute(self._base_filters(tree_type="Order Type")) + + mine = {"Sales", "Maintenance", "Shopping Cart"} + leaves = [row["entity"] for row in data if row.get("entity") in mine] + # the order-type rows must appear in casefold-sorted order on both engines + self.assertEqual(leaves, sorted(leaves, key=str.casefold)) + self.assertEqual(set(leaves), mine) + + def test_customer_group_by_quantity(self): + """value_quantity='Quantity' switches the selected value column (total_qty).""" + _columns, data, *_ = execute( + self._base_filters(tree_type="Customer Group", value_quantity="Quantity") + ) + + rows = self._row_by_entity(data) + self.assertIn(CUSTOMER_GROUP, rows) + + expected_qty = self._expected_qty_total() + self.assertGreater(expected_qty, 0) + self.assertAlmostEqual(rows[CUSTOMER_GROUP]["total"], expected_qty, places=2) + self.assertAlmostEqual(rows["All Customer Groups"]["total"], expected_qty, places=2) + + def test_supplier_group_tree_maps_supplier_to_group(self): + """tree_type='Supplier Group' (doc_type='Purchase Order') exercises + get_supplier_parent_child_map: the query selects 'supplier' as entity, then + get_periodic_data remaps each supplier to its group via the parent->child map + built by frappe.get_all('Supplier', ['name', 'supplier_group'], as_list=True). + The group total then rolls up into the 'All Supplier Groups' root.""" + # Baseline the report before adding our Purchase Order so the assertion is + # robust to any pre-existing rows in the historical window. + base_filters = self._base_filters(tree_type="Supplier Group", doc_type="Purchase Order") + _columns, base_data, *_ = execute(base_filters) + base_rows = self._row_by_entity(base_data) + base_group_total = flt(base_rows.get(SUPPLIER_GROUP, {}).get("total", 0.0)) + + po = create_purchase_order( + company=COMPANY, + supplier=SUPPLIER, + qty=4, + rate=250, + transaction_date="2019-04-10", + ) + self.created_docs.append((po.doctype, po.name)) + po_value = flt(po.base_net_total) + self.assertGreater(po_value, 0) + + columns, data, *_ = execute(base_filters) + + self.assertTrue(columns) + self.assertTrue(data) + + rows = self._row_by_entity(data) + # The supplier was remapped to its group; both the leaf group and the tree + # root appear as entities (no raw supplier name leaks into the output). + self.assertIn(SUPPLIER_GROUP, rows) + self.assertIn("All Supplier Groups", rows) + self.assertNotIn(SUPPLIER, rows) + # Roots of a tree report sit at indent 0. + self.assertEqual(rows["All Supplier Groups"]["indent"], 0) + + # The new PO lands in the supplier's group via the parent->child map. + self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_group_total, po_value, places=2) + # Roll-up: the root aggregates every group, so it covers at least this PO. + self.assertGreaterEqual(flt(rows["All Supplier Groups"]["total"]), po_value) diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 05b3ce7aa7b..569d06349f4 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -667,6 +667,13 @@ class Company(NestedSet): """ Trash accounts and cost centers for this company if no gl entry exists """ + if frappe.db.get_single_value("Global Defaults", "demo_company") == self.name: + frappe.throw( + _("{0} is the site's Demo Company and cannot be deleted directly. Use {1} instead.").format( + self.name, _("Delete Demo Data") + ) + ) + NestedSet.validate_if_child_exists(self) frappe.utils.nestedset.update_nsm(self) diff --git a/erpnext/setup/setup_wizard/data/country_wise_tax.json b/erpnext/setup/setup_wizard/data/country_wise_tax.json index 7ee3a79b4a0..000c115d6bc 100644 --- a/erpnext/setup/setup_wizard/data/country_wise_tax.json +++ b/erpnext/setup/setup_wizard/data/country_wise_tax.json @@ -1395,6 +1395,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1413,6 +1414,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1431,6 +1433,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1449,6 +1452,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1467,6 +1471,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1485,6 +1490,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1507,6 +1513,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1525,6 +1532,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1543,6 +1551,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1561,6 +1570,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1579,6 +1589,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1597,6 +1608,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1628,6 +1640,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1637,6 +1650,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1646,6 +1660,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1655,6 +1670,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1664,6 +1680,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1673,6 +1690,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1682,6 +1700,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1691,6 +1710,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1700,6 +1720,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1709,6 +1730,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1718,6 +1740,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1727,6 +1750,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1735,6 +1759,7 @@ "account_number": "1433", "root_type": "Asset" }, + "not_applicable": 1, "tax_rate": 0.00 } ] @@ -2158,6 +2183,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2176,6 +2202,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2194,6 +2221,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2212,6 +2240,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2230,6 +2259,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2248,6 +2278,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2270,6 +2301,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2288,6 +2320,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2306,6 +2339,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2324,6 +2358,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2342,6 +2377,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2360,6 +2396,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2391,6 +2428,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2400,6 +2438,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2409,6 +2448,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2418,6 +2458,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2427,6 +2468,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2436,6 +2478,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2445,6 +2488,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2454,6 +2498,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2463,6 +2508,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2472,6 +2518,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2481,6 +2528,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2490,6 +2538,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2498,6 +2547,7 @@ "account_number": "1588", "root_type": "Asset" }, + "not_applicable": 1, "tax_rate": 0.00 } ] @@ -2921,6 +2971,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2939,6 +2990,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2957,6 +3009,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2975,6 +3028,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2993,6 +3047,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3011,6 +3066,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3033,6 +3089,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3051,6 +3108,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3069,7 +3127,8 @@ "root_type": "Liability", "tax_rate": 19.00 }, - "tax_rate": 19.00 + "not_applicable": 1, + "tax_rate": 0.00 }, { "tax_type": { @@ -3087,6 +3146,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3105,6 +3165,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3123,6 +3184,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3154,6 +3216,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3163,6 +3226,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3172,6 +3236,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3181,6 +3246,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3190,6 +3256,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3199,6 +3266,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3208,6 +3276,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3217,6 +3286,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3226,6 +3296,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3235,6 +3306,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3244,6 +3316,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3253,6 +3326,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3261,6 +3335,7 @@ "account_number": "1550", "root_type": "Asset" }, + "not_applicable": 1, "tax_rate": 0.00 } ] @@ -3653,6 +3728,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3669,6 +3745,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3685,6 +3762,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3701,6 +3779,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3717,6 +3796,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3733,6 +3813,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3753,6 +3834,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3769,6 +3851,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3785,6 +3868,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3801,6 +3885,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3817,6 +3902,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3833,6 +3919,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3861,6 +3948,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3869,6 +3957,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3877,6 +3966,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3885,6 +3975,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3893,6 +3984,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3901,6 +3993,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3909,6 +4002,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3917,6 +4011,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3925,6 +4020,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3933,6 +4029,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3941,6 +4038,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3949,6 +4047,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3956,6 +4055,7 @@ "account_name": "Entstandene Einfuhrumsatzsteuer", "root_type": "Asset" }, + "not_applicable": 1, "tax_rate": 0.00 } ] diff --git a/erpnext/stock/doctype/bin/test_bin.py b/erpnext/stock/doctype/bin/test_bin.py index e4f5565cd75..36f06572cf5 100644 --- a/erpnext/stock/doctype/bin/test_bin.py +++ b/erpnext/stock/doctype/bin/test_bin.py @@ -28,6 +28,68 @@ class TestBin(FrappeTestCase): frappe.db.rollback() + def test_repost_resets_bin_without_sle(self): + """A repost must zero the bin when the ledger is empty, e.g. after entries were deleted.""" + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + from erpnext.stock.stock_ledger import update_entries_after + + item_code = make_item().name + warehouse = "_Test Warehouse - _TC" + make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100) + + # deleting a transaction with `delete_linked_ledger_entries` on drops its entries outright + frappe.db.delete("Stock Ledger Entry", {"item_code": item_code, "warehouse": warehouse}) + + update_entries_after( + { + "item_code": item_code, + "warehouse": warehouse, + "posting_date": "1900-01-01", + "posting_time": "00:01", + } + ) + + bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse}) + self.assertEqual(bin.actual_qty, 0) + self.assertEqual(bin.valuation_rate, 0) + self.assertEqual(bin.stock_value, 0) + + def test_cancelling_last_entry_resets_bin(self): + """Cancelling the only voucher must clear stock value, not just quantity.""" + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + item_code = make_item().name + warehouse = "_Test Warehouse - _TC" + se = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100) + + se.cancel() + + bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse}) + self.assertEqual(bin.actual_qty, 0) + self.assertEqual(bin.valuation_rate, 0) + self.assertEqual(bin.stock_value, 0) + + def test_deleting_last_voucher_resets_bin(self): + """Deleting the only voucher wipes its ledger entries outright, the bin must still be cleared.""" + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + item_code = make_item().name + warehouse = "_Test Warehouse - _TC" + delete_entries = frappe.get_single_value("Accounts Settings", "delete_linked_ledger_entries") + frappe.db.set_single_value("Accounts Settings", "delete_linked_ledger_entries", 1) + + try: + se = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100) + se.cancel() + frappe.delete_doc("Stock Entry", se.name, force=1) + finally: + frappe.db.set_single_value("Accounts Settings", "delete_linked_ledger_entries", delete_entries) + + bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse}) + self.assertEqual(bin.actual_qty, 0) + self.assertEqual(bin.valuation_rate, 0) + self.assertEqual(bin.stock_value, 0) + def test_index_exists(self): indexes = frappe.db.sql("show index from tabBin where Non_unique = 0", as_dict=1) if not any(index.get("Key_name") == "unique_item_warehouse" for index in indexes): diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index 06623192797..0c2d2242ab6 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -428,6 +428,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", "label": "Currency and Price List", @@ -1404,7 +1405,7 @@ "idx": 146, "is_submittable": 1, "links": [], - "modified": "2026-02-03 12:27:19.055918", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 49aa06ddd79..fe132d589d4 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -18,6 +18,7 @@ from frappe.utils import cint, flt from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes +from erpnext.controllers.mapper import get_qty_already_mapped from erpnext.controllers.selling_controller import SellingController from erpnext.stock.stock_ledger import validate_reserved_stock @@ -936,6 +937,8 @@ def make_sales_invoice( to_make_invoice_qty_map = {} returned_qty_map = get_returned_qty_map(source_name) invoiced_qty_map = get_invoiced_qty_map(source_name) + for ref, qty in get_qty_already_mapped(target_doc, "dn_detail").items(): + invoiced_qty_map[ref] = invoiced_qty_map.get(ref, 0) + qty def set_missing_values(source, target): target.run_method("set_missing_values") @@ -1008,7 +1011,7 @@ def make_sales_invoice( "postprocess": update_item, "filter": lambda d: get_pending_qty(d) <= 0 if not doc.get("is_return") - else get_pending_qty(d) > 0, + else get_pending_qty(d) >= 0, "condition": select_item, }, "Sales Taxes and Charges": { @@ -1263,7 +1266,8 @@ def make_sales_return(source_name, target_doc=None): @frappe.whitelist() def update_delivery_note_status(docname, status): - dn = frappe.get_doc("Delivery Note", docname) + dn = frappe.get_lazy_doc("Delivery Note", docname) + dn.check_permission("submit") dn.update_status(status) diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 25c86fee7c9..56622562d7a 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -1091,6 +1091,25 @@ class TestDeliveryNote(FrappeTestCase): self.assertEqual(dn2.per_billed, 100) self.assertEqual(dn2.status, "Completed") + def test_mapping_same_dn_twice_is_idempotent(self): + # "Get Items From > Delivery Note" passes the in-progress invoice back as target_doc. + # Selecting the same DN again must not append a second row for the same dn_detail. + dn = create_delivery_note(qty=5) + + si = make_sales_invoice(dn.name) + self.assertEqual(len(si.items), 1) + self.assertEqual(si.items[0].qty, 5) + + si = make_sales_invoice(dn.name, target_doc=si) + self.assertEqual(len(si.items), 1) + self.assertEqual(si.items[0].qty, 5) + + # a partly reduced draft row still tops up to the delivered qty + si.items[0].qty = 2 + si = make_sales_invoice(dn.name, target_doc=si) + self.assertEqual(len(si.items), 2) + self.assertEqual([d.qty for d in si.items], [2, 3]) + @change_settings("Accounts Settings", {"delete_linked_ledger_entries": True}) def test_sales_invoice_qty_after_return(self): from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return diff --git a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py index 29e811ea4c4..52652cdd840 100644 --- a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py @@ -13,10 +13,13 @@ from erpnext.stock.doctype.inventory_dimension.inventory_dimension import ( DoNotChangeError, delete_dimension, ) -from erpnext.stock.doctype.item.test_item import create_item +from erpnext.stock.doctype.item.test_item import create_item, make_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry -from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import InventoryDimensionNegativeStockError +from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import ( + InventoryDimensionNegativeStockError, + SerialNoInventoryDimensionError, +) from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse @@ -504,6 +507,193 @@ class TestInventoryDimension(FrappeTestCase): self.assertEqual(site_name, "Site 1") + def test_serial_no_cannot_be_issued_from_incorrect_inventory_dimension(self): + item = make_item( + "Test Serialized Inventory Dimension Item", + {"has_serial_no": 1, "is_stock_item": 1}, + ) + serial_no = "Test Serialized Inventory Dimension Serial No" + warehouse = create_warehouse("Serialized Inventory Dimension Warehouse") + + create_inventory_dimension( + apply_to_all_doctypes=1, + dimension_name="Serial Rack", + reference_document="Rack", + validate_negative_stock=0, + ) + + receipt = make_stock_entry( + item_code=item.name, + to_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + do_not_submit=True, + ) + receipt.items[0].to_serial_rack = "Rack 1" + receipt.save() + receipt.submit() + + transfer = make_stock_entry( + item_code=item.name, + from_warehouse=warehouse, + to_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + do_not_submit=True, + ) + transfer.items[0].serial_rack = "Rack 1" + transfer.items[0].to_serial_rack = "Rack 2" + transfer.save() + transfer.submit() + + issue = make_stock_entry( + item_code=item.name, + from_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + do_not_submit=True, + ) + issue.items[0].serial_rack = "Rack 1" + issue.save() + + self.assertRaises(SerialNoInventoryDimensionError, issue.submit) + self.assertFalse( + frappe.db.exists( + "Stock Ledger Entry", + {"voucher_no": issue.name, "is_cancelled": 0}, + ) + ) + + def test_serial_no_cannot_move_from_empty_inventory_dimension(self): + item = make_item( + "Test Serialized Empty Inventory Dimension Item", + {"has_serial_no": 1, "is_stock_item": 1}, + ) + serial_no = "Test Serialized Empty Inventory Dimension Serial No" + warehouse = create_warehouse("Serialized Empty Inventory Dimension Warehouse") + + create_inventory_dimension( + apply_to_all_doctypes=1, + dimension_name="Empty Serial Rack", + reference_document="Rack", + validate_negative_stock=0, + ) + + make_stock_entry( + item_code=item.name, + to_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + ) + + issue = make_stock_entry( + item_code=item.name, + from_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + do_not_submit=True, + ) + issue.items[0].empty_serial_rack = "Rack 1" + issue.save() + + self.assertRaises(SerialNoInventoryDimensionError, issue.submit) + + def test_serial_no_cannot_be_issued_without_inventory_dimension(self): + item = make_item( + "Test Serialized Required Inventory Dimension Item", + {"has_serial_no": 1, "is_stock_item": 1}, + ) + serial_no = "Test Serialized Required Inventory Dimension Serial No" + warehouse = create_warehouse("Serialized Required Inventory Dimension Warehouse") + + create_inventory_dimension( + apply_to_all_doctypes=1, + dimension_name="Required Serial Rack", + reference_document="Rack", + validate_negative_stock=0, + ) + + receipt = make_stock_entry( + item_code=item.name, + to_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + do_not_submit=True, + ) + receipt.items[0].to_required_serial_rack = "Rack 1" + receipt.save() + receipt.submit() + + issue = make_stock_entry( + item_code=item.name, + from_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + do_not_submit=True, + ) + issue.save() + + self.assertRaises(SerialNoInventoryDimensionError, issue.submit) + self.assertFalse( + frappe.db.exists( + "Stock Ledger Entry", + {"voucher_no": issue.name, "is_cancelled": 0}, + ) + ) + + def test_serial_no_inventory_dimension_with_legacy_inward_sle(self): + item = make_item( + "Test Serialized Legacy Inventory Dimension Item", + {"has_serial_no": 1, "is_stock_item": 1}, + ) + serial_no = "Test Serialized Legacy Inventory Dimension Serial No" + warehouse = create_warehouse("Serialized Legacy Inventory Dimension Warehouse") + + create_inventory_dimension( + apply_to_all_doctypes=1, + dimension_name="Legacy Serial Rack", + reference_document="Rack", + validate_negative_stock=0, + ) + + receipt = make_stock_entry( + item_code=item.name, + to_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + do_not_submit=True, + ) + receipt.items[0].to_legacy_serial_rack = "Rack 2" + receipt.save() + receipt.submit() + + frappe.db.set_value( + "Stock Ledger Entry", + {"voucher_no": receipt.name, "actual_qty": (">", 0), "is_cancelled": 0}, + {"serial_and_batch_bundle": None, "serial_no": f"Other Legacy Serial, {serial_no}"}, + ) + + issue = make_stock_entry( + item_code=item.name, + from_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + do_not_submit=True, + ) + issue.items[0].legacy_serial_rack = "Rack 1" + issue.save() + + self.assertRaises(SerialNoInventoryDimensionError, issue.submit) + def test_validate_negative_stock_with_multiple_dimension(self): frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 0) item_code = "Test Negative Multi Inventory Dimension Item" @@ -709,13 +899,16 @@ def create_inventory_dimension(**args): args = frappe._dict(args) if frappe.db.exists("Inventory Dimension", args.dimension_name): - return frappe.get_doc("Inventory Dimension", args.dimension_name) + doc = frappe.get_doc("Inventory Dimension", args.dimension_name) + else: + doc = frappe.new_doc("Inventory Dimension") + doc.update(args) - doc = frappe.new_doc("Inventory Dimension") - doc.update(args) + if not args.do_not_save: + doc.insert(ignore_permissions=True) - if not args.do_not_save: - doc.insert(ignore_permissions=True) + frappe.local.inventory_dimensions = {} + frappe.local.document_wise_inventory_dimensions = {} return doc diff --git a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py index 60a26b91665..512687a7784 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py @@ -290,7 +290,7 @@ class LandedCostVoucher(Document): # update stock & gl entries for cancelled state of PR doc.docstatus = 2 doc.update_stock_ledger(allow_negative_stock=True, via_landed_cost_voucher=True) - doc.make_gl_entries_on_cancel() + doc.make_gl_entries_on_cancel(from_repost=True) # update stock & gl entries for submit state of PR doc.docstatus = 1 diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index eaf28dd4b96..e5d8dc0d474 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -10,6 +10,7 @@ import json import frappe import frappe.defaults from frappe import _, msgprint +from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.query_builder import Order from frappe.query_builder.functions import Sum @@ -17,6 +18,7 @@ from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, new_line_se from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items from erpnext.controllers.buying_controller import BuyingController +from erpnext.controllers.mapper import get_qty_already_mapped from erpnext.manufacturing.doctype.work_order.work_order import get_item_details from erpnext.stock.doctype.item.item import get_item_defaults from erpnext.stock.get_item_details import get_price_list_rate_for @@ -94,6 +96,22 @@ class MaterialRequest(BuyingController): def check_if_already_pulled(self): pass + def validate_with_previous_doc(self): + super().validate_with_previous_doc( + { + "Sales Order": { + "ref_dn_field": "sales_order", + "compare_fields": [["company", "="]], + }, + "Sales Order Item": { + "ref_dn_field": "sales_order_item", + "compare_fields": [["item_code", "="], ["uom", "="], ["conversion_factor", "="]], + "is_child_table": True, + "allow_duplicate_prev_row_id": True, + }, + } + ) + def validate_qty_against_so(self): so_items = {} # Format --> {'SO/00001': {'Item/001': 120, 'Item/002': 24}} for d in self.get("items"): @@ -136,6 +154,7 @@ class MaterialRequest(BuyingController): self.validate_schedule_date() self.check_for_on_hold_or_closed_status("Sales Order", "sales_order") + self.validate_with_previous_doc() self.validate_uom_is_integer("uom", "qty") self.validate_material_request_type() @@ -482,6 +501,8 @@ def make_purchase_order(source_name, target_doc=None, args=None): if isinstance(args, str): args = json.loads(args) + mapped_qty_by_item = get_qty_already_mapped(target_doc, "material_request_item", "stock_qty") + def postprocess(source, target_doc): if frappe.flags.args and frappe.flags.args.default_supplier: # items only for given default supplier @@ -498,7 +519,7 @@ def make_purchase_order(source_name, target_doc=None, args=None): filtered_items = args.get("filtered_children", []) child_filter = d.name in filtered_items if filtered_items else True - qty = d.ordered_qty or d.received_qty + qty = (d.ordered_qty or d.received_qty) + flt(mapped_qty_by_item.get(d.name, 0)) return qty < d.stock_qty and child_filter @@ -534,7 +555,16 @@ def make_purchase_order(source_name, target_doc=None, args=None): @frappe.whitelist() -def make_request_for_quotation(source_name, target_doc=None): +def make_request_for_quotation(source_name: str, target_doc: str | dict | Document | None = None): + def update_item(obj, target, source_parent): + qty = obj.ordered_qty or obj.received_qty + target.qty = flt(flt(obj.stock_qty) - flt(qty)) / target.conversion_factor + target.stock_qty = target.qty * target.conversion_factor + + def select_item(d): + qty = d.ordered_qty or d.received_qty + return qty < d.stock_qty + doclist = get_mapped_doc( "Material Request", source_name, @@ -550,6 +580,8 @@ def make_request_for_quotation(source_name, target_doc=None): ["parent", "material_request"], ["project", "project_name"], ], + "postprocess": update_item, + "condition": select_item, }, }, target_doc, @@ -774,6 +806,8 @@ def make_stock_entry(source_name, target_doc=None): target.fg_completed_qty = job_card_details[0].for_quantity target.from_bom = 1 + target.cap_completed_qty_to_material_coverage() + doclist = get_mapped_doc( "Material Request", source_name, diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 6ae289625bc..9a8a87d2fd5 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -15,6 +15,7 @@ from erpnext.stock.doctype.material_request.material_request import ( create_pick_list, make_in_transit_stock_entry, make_purchase_order, + make_request_for_quotation, make_stock_entry, make_supplier_quotation, raise_work_orders, @@ -47,6 +48,26 @@ class TestMaterialRequest(FrappeTestCase): self.assertEqual(po.doctype, "Purchase Order") self.assertEqual(len(po.get("items")), len(mr.get("items"))) + def test_make_request_for_quotation_skips_ordered_items(self): + mr = frappe.copy_doc(test_records[0]).insert() + mr = frappe.get_doc("Material Request", mr.name) + mr.submit() + + # fully order the first item, leave the second pending + po = make_purchase_order(mr.name) + po.supplier = "_Test Supplier" + po.schedule_date = today() + po.items = [po.items[0]] + po.items[0].schedule_date = today() + po.insert() + po.submit() + + rfq = make_request_for_quotation(mr.name) + + self.assertEqual(len(rfq.get("items")), 1) + self.assertEqual(rfq.items[0].material_request_item, mr.items[1].name) + self.assertEqual(rfq.items[0].qty, mr.items[1].qty) + def test_make_supplier_quotation(self): mr = frappe.copy_doc(test_records[0]).insert() @@ -919,6 +940,18 @@ class TestMaterialRequest(FrappeTestCase): self.assertRaises(OverAllowanceError, mr.submit) + def test_item_change_on_sales_order_row_is_blocked(self): + from erpnext.selling.doctype.sales_order.sales_order import make_material_request + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + + other_item = create_item("_Test MR Item Swap").name + so = make_sales_order() + mr = make_material_request(so.name) + mr.material_request_type = "Purchase" + # swapping the fetched item would leave a stale link to the SO row + mr.items[0].item_code = other_item + self.assertRaises(frappe.ValidationError, mr.insert) + def test_pending_qty_in_pick_list(self): """Test for pick list mapped doc qty from partially received Material Request Transfer""" import json diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 7a2dd32291a..144e486d94e 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -1508,6 +1508,7 @@ def add_product_bundles_to_delivery_note( @frappe.whitelist() def create_stock_entry(pick_list: str | dict): pick_list = frappe.get_doc(frappe.parse_json(pick_list)) + pick_list.check_permission("read") validate_item_locations(pick_list) stock_entry = frappe.new_doc("Stock Entry") diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 8ed07e61381..d777e0862e7 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -1078,6 +1078,58 @@ class TestPickList(FrappeTestCase): self.assertEqual(pick_list.locations[0].transferred_qty, 4) self.assertEqual(pick_list.status, "Partially Transferred") + def test_get_items_keeps_pick_list_rows_on_stock_entry(self): + """Entering fg_completed_qty on a Stock Entry mapped from a Pick List triggers get_items(); + it must not refetch from the BOM, or the pick_list_item links transferred_qty rides on are + lost and the Pick List stays Open with every row offered again.""" + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + from erpnext.manufacturing.doctype.work_order.work_order import ( + create_pick_list as pick_list_for_wo, + ) + from erpnext.manufacturing.doctype.work_order.work_order import make_work_order + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + source_warehouse = create_warehouse("_Test Partial Transfer Source") + wip_warehouse = create_warehouse("_Test Partial Transfer WIP", company="_Test Company") + fg_warehouse = create_warehouse("_Test Partial Transfer FG", company="_Test Company") + fg_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item(properties={"is_stock_item": 1}).name + bom = make_bom(item=fg_item, rate=100, raw_materials=[rm_item]) + make_stock_entry(item=rm_item, to_warehouse=source_warehouse, qty=100) + + wo = make_work_order(item=fg_item, qty=10, bom_no=bom.name, company="_Test Company") + wo.required_items[0].source_warehouse = source_warehouse + wo.wip_warehouse = wip_warehouse + wo.fg_warehouse = fg_warehouse + wo.submit() + + pick_list = pick_list_for_wo(wo.name, for_qty=wo.qty) + pick_list.save().submit() + self.assertEqual(pick_list.status, "Open") + + se = frappe.get_doc(create_stock_entry(pick_list.as_dict())) + self.assertTrue(all(row.pick_list_item for row in se.items)) + self.assertEqual(se.fg_completed_qty, 0) + + se.fg_completed_qty = 4 + se.get_items() + self.assertEqual(len(se.items), len(pick_list.locations)) + self.assertTrue(all(row.pick_list_item for row in se.items)) + se.fg_completed_qty = 0 + + for row in se.items: + row.qty = 4 + se.save().submit() + self.assertEqual(se.fg_completed_qty, 0) + + pick_list.reload() + self.assertEqual(pick_list.locations[0].transferred_qty, 4) + self.assertEqual(pick_list.status, "Partially Transferred") + + next_se = frappe.get_doc(create_stock_entry(pick_list.as_dict())) + self.assertEqual(len(next_se.items), 1) + self.assertEqual(next_se.items[0].qty, 6) + 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 diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json index 83243056c68..9a7ac741b5d 100755 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json @@ -365,6 +365,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", "label": "Currency and Price List", @@ -1301,7 +1302,7 @@ "idx": 261, "is_submittable": 1, "links": [], - "modified": "2025-11-27 16:46:30.210628", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt", diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index f7df8ecd9f3..00145ffa47c 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -18,6 +18,7 @@ from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accoun from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.accounts_controller import merge_taxes from erpnext.controllers.buying_controller import BuyingController +from erpnext.controllers.mapper import get_qty_already_mapped from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_transaction from erpnext.stock.serial_batch_bundle import ( SerialBatchCreation, @@ -1350,6 +1351,8 @@ def make_purchase_invoice(source_name, target_doc=None, args=None): doc = frappe.get_doc("Purchase Receipt", source_name) returned_qty_map = get_returned_qty_map(source_name) invoiced_qty_map = get_invoiced_qty_map(source_name) + for ref, qty in get_qty_already_mapped(target_doc, "pr_detail").items(): + invoiced_qty_map[ref] = invoiced_qty_map.get(ref, 0) + qty def set_missing_values(source, target): if len(target.get("items")) == 0: @@ -1434,7 +1437,7 @@ def make_purchase_invoice(source_name, target_doc=None, args=None): }, "postprocess": update_item, "filter": lambda d: ( - get_pending_qty(d)[0] <= 0 if not doc.get("is_return") else get_pending_qty(d)[0] > 0 + get_pending_qty(d)[0] <= 0 if not doc.get("is_return") else get_pending_qty(d)[0] >= 0 ), "condition": select_item, }, diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py index 85c7372be59..a2847a120b5 100644 --- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, call import frappe from frappe.tests.utils import FrappeTestCase, change_settings -from frappe.utils import add_days, add_to_date, now, nowdate, today +from frappe.utils import add_days, add_to_date, flt, now, nowdate, today from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.utils import repost_gle_for_stock_vouchers @@ -475,6 +475,56 @@ class TestRepostItemValuation(FrappeTestCase, StockTestMixin): # incoming rate after reposting should be 150 self.assertSLEs(se, [{"incoming_rate": 150}]) + def test_recalculate_stock_entry_additional_cost_updates_all_incoming_rows(self): + from erpnext.stock.stock_ledger import update_entries_after + + company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") + warehouse = "Stores - TCP1" + items = [ + self.make_item(f"_Test Repost Addl Cost {x}", {"is_stock_item": 1}).name for x in ("A", "B", "C") + ] + + for item_code in items: + make_stock_entry(item_code=item_code, target=warehouse, company=company, qty=100, rate=10) + + transfer = make_stock_entry(company=company, purpose="Material Transfer", do_not_save=True) + transfer.from_warehouse = warehouse + transfer.to_warehouse = warehouse + transfer.items = [] + for item_code in items: + transfer.append( + "items", + { + "item_code": item_code, + "qty": 100, + "s_warehouse": warehouse, + "t_warehouse": warehouse, + "uom": "Nos", + "conversion_factor": 1, + }, + ) + transfer.append( + "additional_costs", + { + "expense_account": "Expenses Included In Valuation - TCP1", + "description": "freight", + "amount": 100, + }, + ) + transfer.insert() + transfer.submit() + + first_row = transfer.items[0] + frappe.db.set_value("Stock Entry Detail", first_row.name, "basic_rate", first_row.basic_rate + 1) + update_entries_after.recalculate_amounts_in_stock_entry(MagicMock(), transfer.name, first_row.name) + + transfer.load_from_db() + detail_additional_cost = sum(row.additional_cost for row in transfer.items) + net_added_to_stock = sum(row.amount - row.basic_amount for row in transfer.items) + + self.assertEqual(flt(detail_additional_cost, 2), flt(transfer.total_additional_costs, 2)) + self.assertEqual(flt(net_added_to_stock, 2), flt(transfer.total_additional_costs, 2)) + def test_repost_multi_line_moving_average_return(self): from erpnext.controllers.sales_and_purchase_return import make_return_doc diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index e2db1c7c98a..dcbcd292647 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -181,6 +181,13 @@ frappe.ui.form.on("Stock Entry", { if (!check_should_not_attach_bom_items(frm.doc.bom_no)) { erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); } + + if (frm.doc.pick_list) { + frm.set_df_property("get_items", "hidden", 1); + if (!frm.doc.job_card) { + frm.set_df_property("fg_completed_qty", "read_only", 1); + } + } }, setup_quality_inspection: function (frm) { @@ -1356,10 +1363,16 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle ) { frappe.model.remove_from_locals("Work Order", this.frm.doc.work_order); } + + if (this.frm.doc.pick_list) { + frappe.model.remove_from_locals("Pick List", this.frm.doc.pick_list); + } } fg_completed_qty() { - this.get_items(); + if (!this.frm.doc.pick_list) { + this.get_items(); + } } get_items() { diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 0541193184c..c2f991321c2 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1205,17 +1205,26 @@ class StockEntry(StockController): if transfer_limit_qty < to_transfer_qty: return - required_qty, transferred_qty = self._get_work_order_material_qty() + self.cap_completed_qty_to_material_coverage() + + def cap_completed_qty_to_material_coverage(self): + required_qty, transferred_qty, target_qty, precision = self._get_material_coverage_data() if not required_qty: return - covered_before = self._get_covered_work_order_qty(required_qty, transferred_qty) + covered_before = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision) for row in self.items: - item_code = row.original_item or row.item_code - if row.s_warehouse and item_code in required_qty: - transferred_qty[item_code] += flt(row.qty) * flt(row.conversion_factor or 1) + if self.job_card: + material_reference = row.job_card_item + transferred = flt(row.qty) + else: + material_reference = row.original_item or row.item_code + transferred = flt(row.qty) * flt(row.conversion_factor or 1) - covered_after = self._get_covered_work_order_qty(required_qty, transferred_qty) + if material_reference in required_qty and (self.job_card or row.s_warehouse): + transferred_qty[material_reference] += transferred + + covered_after = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision) covered_by_entry = flt(max(covered_after - covered_before, 0), self.precision("fg_completed_qty")) self.fg_completed_qty = min(flt(self.fg_completed_qty), covered_by_entry) @@ -1230,6 +1239,49 @@ class StockEntry(StockController): return False return not (self.pro_doc.operations and self.pro_doc.transfer_material_against == "Job Card") + def _get_material_coverage_data(self): + if self.job_card: + return self._get_job_card_material_qty() + return self._get_work_order_material_qty() + + def _get_job_card_material_qty(self): + job_card = frappe.get_doc("Job Card", self.job_card) + required_qty = {} + transferred_qty = {} + for row in job_card.items: + if flt(row.required_qty) <= 0: + continue + required_qty[row.name] = flt(row.required_qty) + transferred_qty[row.name] = flt(row.transferred_qty) + + return ( + required_qty, + transferred_qty, + self._get_job_card_target_qty(job_card), + job_card.precision("required_qty", "items"), + ) + + def _get_job_card_target_qty(self, job_card): + required_by_item = {} + for row in job_card.items: + required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty) + + work_order_required_by_item = {} + work_order = frappe.get_doc("Work Order", job_card.work_order) + for row in work_order.required_items: + if job_card.operation != row.operation: + continue + work_order_required_by_item[row.item_code] = work_order_required_by_item.get( + row.item_code, 0.0 + ) + flt(row.required_qty) + + target_qty = [ + item_required * flt(work_order.qty) / work_order_required_by_item[item_code] + for item_code, item_required in required_by_item.items() + if work_order_required_by_item.get(item_code) + ] + return min(target_qty) if target_qty else job_card.for_quantity + def _get_work_order_material_qty(self): required_qty = {} transferred_qty = {} @@ -1241,15 +1293,20 @@ class StockEntry(StockController): transferred_qty[row.item_code] = max( transferred_qty.get(row.item_code, 0.0), flt(row.transferred_qty) ) - return required_qty, transferred_qty + return ( + required_qty, + transferred_qty, + self.pro_doc.qty, + self.pro_doc.precision("required_qty", "required_items"), + ) - def _get_covered_work_order_qty(self, required_qty, transferred_qty): + def _get_covered_qty(self, required_qty, transferred_qty, target_qty, precision): min_fraction = get_minimum_material_coverage_fraction( required_qty, transferred_qty, - self.pro_doc.precision("required_qty", "required_items"), + precision, ) - return min_fraction * flt(self.pro_doc.qty) + return min_fraction * flt(target_qty) def _validate_no_excess_transfer(self): if self.is_return: @@ -2576,6 +2633,9 @@ class StockEntry(StockController): @frappe.whitelist() def get_items(self): + if self.pick_list: + return + self.set("items", []) self.validate_work_order() diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index 5344636dfaa..7e7a76fb9df 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -2,19 +2,21 @@ # License: GNU General Public License v3. See license.txt +import re from datetime import date import frappe from frappe import _, bold from frappe.core.doctype.role.role import get_users from frappe.model.document import Document -from frappe.query_builder.functions import Sum +from frappe.query_builder.functions import Concat_ws, Sum from frappe.utils import add_days, cint, flt, formatdate, get_datetime, getdate from erpnext.accounts.utils import get_fiscal_year from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions -from erpnext.stock.serial_batch_bundle import SerialBatchBundle +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos as get_parsed_serial_nos +from erpnext.stock.serial_batch_bundle import SerialBatchBundle, get_serial_nos from erpnext.stock.stock_ledger import get_previous_sle @@ -30,6 +32,10 @@ class InventoryDimensionNegativeStockError(frappe.ValidationError): pass +class SerialNoInventoryDimensionError(frappe.ValidationError): + pass + + exclude_from_linked_with = True @@ -98,6 +104,7 @@ class StockLedgerEntry(Document): self.block_transactions_against_group_warehouse() self.validate_with_last_transaction_posting_time() self.validate_inventory_dimension_negative_stock() + self.validate_serial_no_inventory_dimension() def set_posting_datetime(self): from erpnext.stock.utils import get_combine_datetime @@ -172,6 +179,87 @@ class StockLedgerEntry(Document): return inv_dimension_dict + def validate_serial_no_inventory_dimension(self): + if self.is_cancelled or self.actual_qty >= 0: + return + + dimensions = get_inventory_dimensions() + if not dimensions: + return + + serial_nos = get_serial_nos(self.serial_and_batch_bundle) + if not serial_nos and self.serial_no: + serial_nos = get_parsed_serial_nos(self.serial_no) + + if not serial_nos: + return + + for serial_no, values in self.get_last_inward_dimensions(serial_nos, dimensions).items(): + mismatches = [] + for dimension in dimensions: + fieldname = dimension.fieldname + expected_value = values.get(fieldname) + if expected_value != self.get(fieldname): + mismatches.append( + _('{0}: expected "{1}", got "{2}"').format( + dimension.dimension_name, + expected_value or _("Not Set"), + self.get(fieldname), + ) + ) + + if mismatches: + frappe.throw( + _("Serial No {0} is not available in the selected inventory dimensions: {1}").format( + frappe.bold(serial_no), frappe.bold(", ".join(mismatches)) + ), + title=_("Incorrect Inventory Dimension"), + exc=SerialNoInventoryDimensionError, + ) + + def get_last_inward_dimensions(self, serial_nos, dimensions): + sle = frappe.qb.DocType("Stock Ledger Entry") + serial_entry = frappe.qb.DocType("Serial and Batch Entry") + dimension_fields = [sle[dimension.fieldname].as_(dimension.fieldname) for dimension in dimensions] + escaped_serial_nos = [re.escape(serial_no) for serial_no in serial_nos] + legacy_serial_pattern = r"[\n,][[:space:]]*(" + "|".join(escaped_serial_nos) + r")[[:space:]]*[\n,]" + legacy_serial_condition = ( + sle.serial_and_batch_bundle.isnull() | (sle.serial_and_batch_bundle == "") + ) & Concat_ws("", "\n", sle.serial_no, "\n").regexp(legacy_serial_pattern) + + rows = ( + frappe.qb.from_(sle) + .left_join(serial_entry) + .on(serial_entry.parent == sle.serial_and_batch_bundle) + .select( + serial_entry.serial_no.as_("bundle_serial_no"), + sle.serial_no.as_("legacy_serial_nos"), + *dimension_fields, + ) + .where( + (serial_entry.serial_no.isin(serial_nos) | legacy_serial_condition) + & (sle.item_code == self.item_code) + & (sle.actual_qty > 0) + & (sle.is_cancelled == 0) + & (sle.posting_datetime <= self.posting_datetime) + ) + .orderby(sle.posting_datetime, order=frappe.qb.desc) + .orderby(sle.creation, order=frappe.qb.desc) + ).run(as_dict=True) + + serial_nos = set(serial_nos) + last_inward_dimensions = {} + for row in rows: + row_serial_nos = ( + [row.bundle_serial_no] + if row.bundle_serial_no + else get_parsed_serial_nos(row.legacy_serial_nos) + ) + for serial_no in serial_nos.intersection(row_serial_nos): + last_inward_dimensions.setdefault(serial_no, row) + + return last_inward_dimensions + def on_submit(self): self.check_stock_frozen_date() diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index c9fa5fe6e16..b68cbc08174 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -7,6 +7,7 @@ import json import frappe from frappe import _, throw from frappe.model import child_table_fields, default_fields +from frappe.model.document import Document from frappe.model.meta import get_field_precision from frappe.model.utils import get_fetch_values from frappe.query_builder.functions import IfNull, Sum @@ -34,11 +35,34 @@ purchase_doctypes = [ "Purchase Invoice", ] +maintain_same_rate_source_fields = { + "Purchase Order": {"supplier_quotation_item": "Supplier Quotation Item"}, + "Purchase Receipt": {"purchase_order_item": "Purchase Order Item"}, + "Purchase Invoice": {"po_detail": "Purchase Order Item", "pr_detail": "Purchase Receipt Item"}, + "Sales Order": {"quotation_item": "Quotation Item"}, + "Delivery Note": {"so_detail": "Sales Order Item", "si_detail": "Sales Invoice Item"}, + "Sales Invoice": {"so_detail": "Sales Order Item", "dn_detail": "Delivery Note Item"}, +} + +LOCKED_RATE_FIELDS = [ + "price_list_rate", + "rate", + "discount_percentage", + "discount_amount", + "margin_type", + "margin_rate_or_amount", +] + NOT_APPLICABLE_TAX = "N/A" @frappe.whitelist() -def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=True): +def get_item_details( + args: dict | str, + doc: Document | dict | str | None = None, + for_validate: bool | str = False, + overwrite_warehouse: bool | str = True, +): """ args = { "item_code": "", @@ -100,16 +124,20 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru if args.get("doctype") in ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]: args.customer = None - out.update(get_price_list_rate(args, item)) + source_row = get_rate_locked_source_row(args, doc) + if source_row: + lock_source_rate(out, source_row) + else: + out.update(get_price_list_rate(args, item)) - if ( - not out.price_list_rate - and args.transaction_type == "selling" - and frappe.get_single_value("Selling Settings", "fallback_to_default_price_list") - ): - fallback_args = args.copy() - fallback_args.price_list = frappe.get_single_value("Selling Settings", "selling_price_list") - out.update(get_price_list_rate(fallback_args, item)) + if ( + not out.price_list_rate + and args.transaction_type == "selling" + and frappe.get_single_value("Selling Settings", "fallback_to_default_price_list") + ): + fallback_args = args.copy() + fallback_args.price_list = frappe.get_single_value("Selling Settings", "selling_price_list") + out.update(get_price_list_rate(fallback_args, item)) args.customer = current_customer @@ -124,9 +152,8 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru if args.get(key) is None: args[key] = value - data = get_pricing_rule_for_item(args, doc=doc, for_validate=for_validate) - - out.update(data) + if not source_row: + out.update(get_pricing_rule_for_item(args, doc=doc, for_validate=for_validate)) if ( frappe.db.get_single_value("Stock Settings", "auto_create_serial_and_batch_bundle_for_outward") @@ -156,6 +183,52 @@ def remove_standard_fields(details): return details +def get_rate_locked_source_row(args, doc): + """Reads the source row from the DB, not the mutable target row, so an unsaved edit can't override the locked rate.""" + if isinstance(doc, str): + doc = json.loads(doc) + + source_fields = maintain_same_rate_source_fields.get(args.parenttype or args.doctype) + if not source_fields or not doc or args.get("is_return") or not maintain_same_rate_enabled(args): + return None + + row = next((d for d in doc.get("items") or [] if d.get("name") == args.child_docname), None) + if not row: + return None + + for link_field, source_doctype in source_fields.items(): + if source_name := row.get(link_field): + # don't leak another document's pricing to a caller without read access + source = frappe.db.get_value( + source_doctype, source_name, [*LOCKED_RATE_FIELDS, "parent", "parenttype"], as_dict=True + ) + if source and frappe.has_permission(source.parenttype, doc=source.parent): + return source + return None + return None + + +def maintain_same_rate_enabled(transaction_args): + if (transaction_args.parenttype or transaction_args.doctype) in purchase_doctypes: + if transaction_args.get("is_internal_supplier"): + return False + return bool(cint(frappe.get_cached_value("Buying Settings", "None", "maintain_same_rate"))) + + if transaction_args.get("is_internal_customer"): + return False + return bool(cint(frappe.get_cached_value("Selling Settings", "None", "maintain_same_sales_rate"))) + + +def lock_source_rate(out, source_row): + """Copies the full pricing block so a manual discount or margin on the source row survives.""" + out.price_list_rate = flt(source_row.get("price_list_rate")) or flt(source_row.get("rate")) + out.rate = flt(source_row.get("rate")) + out.discount_percentage = flt(source_row.get("discount_percentage")) + out.discount_amount = flt(source_row.get("discount_amount")) + out.margin_type = source_row.get("margin_type") + out.margin_rate_or_amount = flt(source_row.get("margin_rate_or_amount")) + + def set_valuation_rate(out, args): if frappe.db.exists("Product Bundle", {"name": args.item_code, "disabled": 0}, cache=True): valuation_rate = 0.0 @@ -1345,6 +1418,11 @@ def get_pos_profile(company, pos_profile=None, user=None): if not user: user = frappe.session["user"] + allowed_pos_profiles = frappe.get_list("POS Profile", pluck="name") + + if not allowed_pos_profiles: + return None + pf = frappe.qb.DocType("POS Profile") pfu = frappe.qb.DocType("POS Profile User") @@ -1354,6 +1432,7 @@ def get_pos_profile(company, pos_profile=None, user=None): .on(pf.name == pfu.parent) .select(pf.star) .where((pfu.user == user) & (pfu.default == 1)) + .where(pf.name.isin(allowed_pos_profiles)) ) if company: @@ -1368,6 +1447,7 @@ def get_pos_profile(company, pos_profile=None, user=None): .on(pf.name == pfu.parent) .select(pf.star) .where((pf.company == company) & (pf.disabled == 0)) + .where(pf.name.isin(allowed_pos_profiles)) ).run(as_dict=True) return pos_profile and pos_profile[0] or None @@ -1522,13 +1602,21 @@ 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) + + source_row = get_rate_locked_source_row(args, doc) + if source_row: + item_details = frappe._dict() + lock_source_rate(item_details, source_row) + else: + 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)) + + if not source_row: + item_details.update(get_pricing_rule_for_item(args, doc=doc)) return item_details diff --git a/erpnext/stock/report/available_serial_no/available_serial_no.js b/erpnext/stock/report/available_serial_no/available_serial_no.js index c69c6503de8..976d799532c 100644 --- a/erpnext/stock/report/available_serial_no/available_serial_no.js +++ b/erpnext/stock/report/available_serial_no/available_serial_no.js @@ -77,4 +77,4 @@ frappe.query_reports["Available Serial No"] = { }, }; -erpnext.utils.add_inventory_dimensions("Balance Serial No", 10); +erpnext.utils.add_inventory_dimensions("Available Serial No", 10); diff --git a/erpnext/stock/report/incorrect_serial_and_batch_bundle/incorrect_serial_and_batch_bundle.json b/erpnext/stock/report/incorrect_serial_and_batch_bundle/incorrect_serial_and_batch_bundle.json index 11e6e4ea3d4..be5c06d8b59 100644 --- a/erpnext/stock/report/incorrect_serial_and_batch_bundle/incorrect_serial_and_batch_bundle.json +++ b/erpnext/stock/report/incorrect_serial_and_batch_bundle/incorrect_serial_and_batch_bundle.json @@ -9,9 +9,9 @@ "idx": 0, "is_standard": "Yes", "json": "{}", - "letter_head": "Test", + "letter_head": null, "letterhead": null, - "modified": "2025-02-03 15:39:47.613040", + "modified": "2026-08-26 14:39:19.102191", "modified_by": "Administrator", "module": "Stock", "name": "Incorrect Serial and Batch Bundle", diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 7a9bc900307..9e11762362c 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1406,12 +1406,14 @@ class update_entries_after: stock_entry = frappe.get_doc("Stock Entry", voucher_no, for_update=True) stock_entry.calculate_rate_and_amount(reset_outgoing_rate=False, raise_error_if_no_rate=False) stock_entry.db_update() + update_additional_cost_rows = bool(stock_entry.get("additional_costs")) for d in stock_entry.items: - # Update only the row that matches the voucher_detail_no or the row containing the FG/Scrap Item. + # Additional costs are redistributed across all incoming rows. if ( d.name == voucher_detail_no or (not d.s_warehouse and d.t_warehouse) or stock_entry.purpose in ["Manufacture", "Repack"] + or (update_additional_cost_rows and d.t_warehouse) ): d.db_update() @@ -1750,6 +1752,30 @@ class update_entries_after: frappe.db.set_value("Bin", bin_name, updated_values, update_modified=True) + self.reset_bin_without_stock_ledger_entries() + + def reset_bin_without_stock_ledger_entries(self): + """Reset the bin when its ledger has no entries left, a repost never covers that case.""" + item_code, warehouse = self.args.get("item_code"), self.args.get("warehouse") + if not item_code or not warehouse or (item_code, warehouse) in self.prev_sle_dict: + return + + if frappe.db.exists( + "Stock Ledger Entry", {"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0} + ): + return + + bin_name = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}) + if not bin_name: + return + + frappe.db.set_value( + "Bin", + bin_name, + {"actual_qty": 0.0, "stock_value": 0.0, "valuation_rate": 0.0}, + update_modified=True, + ) + def get_sle_against_current_voucher(kwargs): kwargs["posting_datetime"] = get_combine_datetime(kwargs.posting_date, kwargs.posting_time) @@ -1866,9 +1892,6 @@ def get_stock_ledger_entries( else: conditions += " and warehouse = %(warehouse)s" - elif previous_sle.get("warehouse_condition"): - conditions += " and " + previous_sle.get("warehouse_condition") - if check_serial_no and previous_sle.get("serial_no"): # conditions += " and serial_no like {}".format(frappe.db.escape('%{0}%'.format(previous_sle.get("serial_no")))) serial_no = previous_sle.get("serial_no") diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py index d0b725b9179..398d7e603b5 100644 --- a/erpnext/stock/tests/test_get_item_details.py +++ b/erpnext/stock/tests/test_get_item_details.py @@ -177,3 +177,248 @@ class TestGetItemDetail(FrappeTestCase): dn.save() self.assertEqual(dn.items[0].batch_no, "BATCH01") self.assertEqual(dn.items[0].rate, 50) + + def test_maintain_same_rate_keeps_source_rate_on_refetch(self): + """#57436: with "maintain same rate" on, re-fetching a PR row mapped from a + PO must keep the PO rate instead of pulling a newer, higher Item Price. + + The rate is validated on save, so it can never persist changed; assert the + fetched rate directly to prove the newer Item Price is never picked up. + """ + from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.stock.doctype.item.test_item import make_item + + def set_maintain_same_rate(value): + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", value) + frappe.clear_cache(doctype="Buying Settings") + + set_maintain_same_rate(1) + + item_code = make_item(properties={"is_stock_item": 1}).name + po = create_purchase_order(item_code=item_code, qty=1, rate=100) + + # The PO may auto-insert an Item Price at 100; bump it to the newer, higher rate. + item_price = frappe.db.get_value( + "Item Price", {"item_code": item_code, "price_list": "Standard Buying"} + ) + if item_price: + frappe.db.set_value("Item Price", item_price, "price_list_rate", 120) + else: + frappe.get_doc( + { + "doctype": "Item Price", + "price_list": "Standard Buying", + "item_code": item_code, + "price_list_rate": 120, + } + ).insert() + + pr = make_purchase_receipt(po.name) + pr.insert() + + def fetch_price_list_rate(): + args = frappe._dict( + { + "item_code": item_code, + "doctype": "Purchase Receipt", + "name": pr.name, + "company": pr.company, + "supplier": pr.supplier, + "currency": pr.currency, + "conversion_rate": 1.0, + "price_list": "Standard Buying", + "price_list_currency": pr.currency, + "plc_conversion_rate": 1.0, + "warehouse": pr.items[0].warehouse, + "uom": pr.items[0].uom, + "stock_uom": pr.items[0].stock_uom, + "qty": pr.items[0].qty, + "child_doctype": pr.items[0].doctype, + "child_docname": pr.items[0].name, + "is_return": 0, + "is_internal_supplier": 0, + "ignore_pricing_rule": 1, + } + ) + return get_item_details(args, pr).get("price_list_rate") + + # Rate stays at the PO rate; the newer Item Price (120) is not fetched. + self.assertEqual(fetch_price_list_rate(), 100) + + # Control: without the setting the newer Item Price would be fetched. + set_maintain_same_rate(0) + self.assertEqual(fetch_price_list_rate(), 120) + + def test_apply_price_list_keeps_source_rate_when_maintain_same_rate(self): + """#57436: the bulk apply_price_list path (price list / party / conversion rate + change) must also keep the source rate on mapped rows, not just re-fetch of a + single row. Here a PR row carries its PO rate (175) while the current price list + rate is 100; the bulk apply must keep 175. + """ + from frappe.utils import flt, nowdate + + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.stock.get_item_details import apply_price_list + + item_code = "_Test Item" + price_list = "_Test Buying Price List" + + original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate") + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) + frappe.clear_cache(doctype="Buying Settings") + + try: + po = create_purchase_order(item_code=item_code, rate=175, qty=1) + + row_name = "pr-row-1" + pr_doc = { + "doctype": "Purchase Receipt", + "items": [ + { + "name": row_name, + "item_code": item_code, + "purchase_order_item": po.items[0].name, + "price_list_rate": 175, + "rate": 175, + } + ], + } + args = frappe._dict( + doctype="Purchase Receipt", + supplier=po.supplier, + company=po.company, + currency=po.currency, + conversion_rate=1.0, + price_list=price_list, + plc_conversion_rate=1.0, + transaction_date=nowdate(), + items=[ + frappe._dict( + doctype="Purchase Receipt Item", + parenttype="Purchase Receipt", + item_code=item_code, + child_docname=row_name, + qty=1, + uom=po.items[0].uom, + stock_uom=po.items[0].stock_uom, + conversion_factor=1.0, + ) + ], + ) + + result = apply_price_list(args, doc=pr_doc) + self.assertEqual(flt(result["children"][0].get("price_list_rate")), 175) + finally: + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original) + frappe.clear_cache(doctype="Buying Settings") + + def test_maintain_same_rate_keeps_source_discount_on_refetch(self): + """A mapped source row with a discount has rate != price_list_rate. Re-fetch must + return the source's rate and discount, not just the pre-discount price, or the + recomputed rate diverges from the reference and fails maintain-same-rate on save. + """ + from frappe.utils import flt + + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + + item_code = "_Test Item" + price_list = "_Test Buying Price List" + + original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate") + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) + frappe.clear_cache(doctype="Buying Settings") + + try: + # source PO carries the discount: list rate 100, 10% off, effective rate 90 + frappe.flags.dont_fetch_price_list_rate = True + po = create_purchase_order(item_code=item_code, qty=1, do_not_save=True) + po.buying_price_list = price_list + po.items[0].price_list_rate = 100 + po.items[0].discount_percentage = 10 + po.items[0].rate = 90 + po.insert() + po.submit() + frappe.flags.dont_fetch_price_list_rate = False + + row_name = "pr-row-1" + pr_doc = { + "doctype": "Purchase Receipt", + "items": [ + {"name": row_name, "item_code": item_code, "purchase_order_item": po.items[0].name} + ], + } + args = frappe._dict( + item_code=item_code, + doctype="Purchase Receipt", + company=po.company, + supplier=po.supplier, + currency=po.currency, + conversion_rate=1.0, + price_list=price_list, + price_list_currency=po.currency, + plc_conversion_rate=1.0, + warehouse="_Test Warehouse - _TC", + uom=po.items[0].uom, + stock_uom=po.items[0].stock_uom, + qty=1, + child_docname=row_name, + is_return=0, + is_internal_supplier=0, + ignore_pricing_rule=1, + ) + + out = get_item_details(args, pr_doc) + self.assertEqual(flt(out.get("price_list_rate")), 100) + self.assertEqual(flt(out.get("rate")), 90) + self.assertEqual(flt(out.get("discount_percentage")), 10) + finally: + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original) + frappe.clear_cache(doctype="Buying Settings") + frappe.flags.dont_fetch_price_list_rate = False + + def test_rate_lock_source_lookup_checks_permission(self): + """The lock reads source pricing via a direct DB read, so it must not disclose a + source document's pricing to a caller who cannot read that document. + """ + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.stock.get_item_details import get_rate_locked_source_row + + original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate") + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) + frappe.clear_cache(doctype="Buying Settings") + + role, email = "_Test Role Without PO Access", "_test_rate_lock_probe@example.com" + try: + po = create_purchase_order(item_code="_Test Item", qty=1, rate=90) + pr_doc = { + "doctype": "Purchase Receipt", + "items": [{"name": "r1", "item_code": "_Test Item", "purchase_order_item": po.items[0].name}], + } + args = frappe._dict(doctype="Purchase Receipt", child_docname="r1") + + # an authorized caller receives the source row + self.assertIsNotNone(get_rate_locked_source_row(args.copy(), dict(pr_doc))) + + if not frappe.db.exists("Role", role): + frappe.get_doc({"doctype": "Role", "role_name": role, "desk_access": 1}).insert( + ignore_permissions=True + ) + if not frappe.db.exists("User", email): + frappe.get_doc( + { + "doctype": "User", + "email": email, + "first_name": "Probe", + "send_welcome_email": 0, + "roles": [{"role": role}], + } + ).insert(ignore_permissions=True) + + frappe.set_user(email) + # a caller who cannot read the Purchase Order gets nothing + self.assertIsNone(get_rate_locked_source_row(args.copy(), dict(pr_doc))) + finally: + frappe.set_user("Administrator") + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original) + frappe.clear_cache(doctype="Buying Settings") diff --git a/erpnext/utilities/bulk_transaction.py b/erpnext/utilities/bulk_transaction.py index 1dc45b426b7..38399857d69 100644 --- a/erpnext/utilities/bulk_transaction.py +++ b/erpnext/utilities/bulk_transaction.py @@ -50,6 +50,7 @@ def transaction_processing(data, from_doctype, to_doctype): @frappe.whitelist() def retry(date: str | None = None): + frappe.only_for("System Manager") if not date: date = today()