Merge pull request #58646 from frappe/version-15-hotfix

chore: release v15
This commit is contained in:
Diptanil Saha
2026-09-02 12:45:04 +05:30
committed by GitHub
77 changed files with 2561 additions and 288 deletions

View File

@@ -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):

View File

@@ -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);
}
},
});
},

View File

@@ -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"
)

View File

@@ -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):

View File

@@ -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()

View File

@@ -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

View File

@@ -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)))

View File

@@ -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)

View File

@@ -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(

View File

@@ -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",

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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");
},
});

View File

@@ -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()
]

View File

@@ -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)")

View File

@@ -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()

View File

@@ -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",

View File

@@ -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",

View File

@@ -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:

View File

@@ -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",

View File

@@ -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})

View File

@@ -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",

View File

@@ -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},

View File

@@ -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):

View File

@@ -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",

View File

@@ -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",

View File

@@ -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, {

View File

@@ -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
)

View File

@@ -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"):

View File

@@ -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

View File

@@ -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(
{

View File

@@ -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:

View File

@@ -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

View File

@@ -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 (

View File

@@ -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)

View File

@@ -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(

View File

@@ -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

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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()

View File

@@ -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 {}

View File

@@ -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);
}
}
});
}

View File

@@ -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;
}

View File

@@ -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
)

View File

@@ -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):

View File

@@ -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",

View File

@@ -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:

View File

@@ -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",

View File

@@ -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 = []

View File

@@ -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

View File

@@ -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",

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
}
]

View File

@@ -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):

View File

@@ -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",

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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",

View File

@@ -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,
},

View File

@@ -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

View File

@@ -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() {

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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);

View File

@@ -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",

View File

@@ -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")

View File

@@ -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")

View File

@@ -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()