Merge branch 'version-16-hotfix' into mergify/bp/version-16-hotfix/pr-56964

This commit is contained in:
Khushi Rawat
2026-07-13 02:10:42 +05:30
committed by GitHub
93 changed files with 46881 additions and 43784 deletions

View File

@@ -6,7 +6,7 @@ import frappe
from frappe.model.document import Document
from frappe.utils.user import is_website_user
__version__ = "16.25.0"
__version__ = "16.26.2"
def get_default_company(user=None):

View File

@@ -406,8 +406,7 @@
"Customer Deposits": {
"account_number": "2500",
"is_group": 0,
"root_type": "Liability",
"account_type": "Payable"
"root_type": "Liability"
}
},
"Non Current Liabilities": {

View File

@@ -54,7 +54,6 @@
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Closing Balance",
"non_negative": 1,
"options": "currency"
},
{
@@ -191,7 +190,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-05-08 17:55:25.615942",
"modified": "2026-07-09 17:55:25.615942",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Statement Import Log",

View File

@@ -557,7 +557,7 @@ class BankStatementImportLog(Document):
docname=self.name,
)
if self.closing_balance and self.closing_balance > 0 and self.end_date:
if self.closing_balance is not None and self.end_date:
set_closing_balance_as_per_statement(
self.bank_account, frappe.utils.getdate(self.end_date), self.closing_balance
)

View File

@@ -24,15 +24,22 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
setTimeout(
() => {
frm.doc.import_in_progress = false;
frm.clear_table("invoices");
frm.refresh_fields();
frm.page.clear_indicator();
frm.dashboard.hide_progress();
if (frm.doc.invoice_type == "Sales") {
frappe.msgprint(__("Opening Sales Invoices have been created."));
if (!data.errors) {
frm.clear_table("invoices");
frm.refresh_fields();
const message =
frm.doc.invoice_type == "Sales"
? __("Opening Sales Invoice(s) have been created.")
: __("Opening Purchase Invoice(s) have been created.");
frappe.show_alert({
message: message,
indicator: "green",
});
} else {
frappe.msgprint(__("Opening Purchase Invoices have been created."));
frm.refresh_fields();
}
},
1500,

View File

@@ -281,12 +281,20 @@ class OpeningInvoiceCreationTool(Document):
def start_import(invoices):
errors = 0
names = []
total = len(invoices)
for idx, d in enumerate(invoices):
# Scope each invoice to a savepoint so a failure only undoes that invoice.
# A plain rollback() would discard the whole transaction — including invoices
# imported earlier in this batch and the error logs of earlier failures (the
# latter only survive on mariadb because the Error Log table is MyISAM; on
# postgres they would be lost). Rolling back to a savepoint keeps both.
savepoint = f"opening_invoice_{frappe.generate_hash(length=8)}"
frappe.db.savepoint(savepoint)
is_last = idx == total - 1
try:
invoice_number = None
if d.invoice_number:
invoice_number = d.invoice_number
publish(idx, len(invoices), d.doctype)
doc = frappe.get_doc(d)
doc.flags.ignore_mandatory = True
doc.insert(set_name=invoice_number)
@@ -294,10 +302,12 @@ def start_import(invoices):
if not frappe.in_test:
frappe.db.commit()
names.append(doc.name)
publish(idx, total, d.doctype, errors=errors if is_last else None)
except Exception:
errors += 1
frappe.db.rollback()
doc.log_error("Opening invoice creation failed")
publish(idx, total, d.doctype, errors=errors if is_last else None)
if errors:
frappe.msgprint(
_("You had {} errors while creating opening invoices. Check {} for more details").format(
@@ -309,7 +319,7 @@ def start_import(invoices):
return names
def publish(index, total, doctype):
def publish(index, total, doctype, errors=None):
frappe.publish_realtime(
"opening_invoice_creation_progress",
dict(
@@ -317,6 +327,7 @@ def publish(index, total, doctype):
message=_("Creating {} out of {} {}").format(index + 1, total, doctype),
count=index + 1,
total=total,
errors=errors,
),
user=frappe.session.user,
)

View File

@@ -82,6 +82,7 @@
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Outstanding Amount",
"options": "Company:company:default_currency",
"reqd": 1
},
{
@@ -136,7 +137,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-04-29 17:08:15.617047",
"modified": "2026-07-02 15:17:11.938499",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Opening Invoice Creation Tool Item",

View File

@@ -6,8 +6,10 @@ import frappe
from frappe import _, msgprint, qb
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.permissions import get_allowed_docs_for_doctype, get_user_permissions
from frappe.query_builder import Case, Criterion
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import IfNull
from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today
import erpnext
@@ -74,6 +76,10 @@ class PaymentReconciliation(Document):
self.ple_posting_date_filter = []
self.dimensions = get_dimensions(with_cost_center_and_project=True)[0]
@property
def user_permissions(self):
return get_user_permissions(frappe.session.user)
def load_from_db(self):
# 'modified' attribute is required for `run_doc_method` to work properly.
doc_dict = frappe._dict(
@@ -153,6 +159,22 @@ class PaymentReconciliation(Document):
self.add_payment_entries(non_reconciled_payments)
def get_permitted_dimension_values(self, document_type, reference_doctype):
return get_allowed_docs_for_doctype(self.user_permissions.get(document_type, []), reference_doctype)
def validate_permitted_dimension_value(self, document_type, value, allowed):
if value and allowed and value not in allowed:
frappe.throw(
_("You do not have enough permission to access {0}: {1}").format(_(document_type), value),
frappe.PermissionError,
)
def get_user_permission_dimension_condition(self, field, allowed):
value_condition = field.isin(allowed)
if frappe.get_system_settings("apply_strict_user_permissions"):
return value_condition
return (IfNull(field, "") == "") | value_condition
def get_payment_entries(self):
party_account = [self.receivable_payable_account]
@@ -176,8 +198,13 @@ class PaymentReconciliation(Document):
dimensions = {}
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension):
dimensions.update({dimension: self.get(dimension)})
allowed = self.get_permitted_dimension_values(x.document_type, "Payment Entry")
if value := self.get(dimension):
self.validate_permitted_dimension_value(x.document_type, value, allowed)
dimensions[dimension] = value
elif allowed:
dimensions[dimension] = allowed
condition.update({"accounting_dimensions": dimensions})
payment_entries = get_advance_payment_entries_for_regional(
@@ -201,8 +228,12 @@ class PaymentReconciliation(Document):
# Dimension filters
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension):
conditions.append(jea[dimension] == self.get(dimension))
allowed = self.get_permitted_dimension_values(x.document_type, "Journal Entry Account")
if value := self.get(dimension):
self.validate_permitted_dimension_value(x.document_type, value, allowed)
conditions.append(jea[dimension] == value)
elif allowed:
conditions.append(self.get_user_permission_dimension_condition(jea[dimension], allowed))
if self.payment_name:
conditions.append(je.name.like(f"%%{self.payment_name}%%"))
@@ -746,8 +777,15 @@ class PaymentReconciliation(Document):
ple = qb.DocType("Payment Ledger Entry")
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension) and frappe.db.has_column("Payment Ledger Entry", dimension):
self.accounting_dimension_filter_conditions.append(ple[dimension] == self.get(dimension))
if frappe.db.has_column("Payment Ledger Entry", dimension):
allowed = self.get_permitted_dimension_values(x.document_type, "Payment Ledger Entry")
if value := self.get(dimension):
self.validate_permitted_dimension_value(x.document_type, value, allowed)
self.accounting_dimension_filter_conditions.append(ple[dimension] == value)
elif allowed:
self.accounting_dimension_filter_conditions.append(
self.get_user_permission_dimension_condition(ple[dimension], allowed)
)
def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False):
self.common_filter_conditions.clear()

View File

@@ -4,7 +4,7 @@
import frappe
from frappe import qb
from frappe.utils import add_days, add_years, flt, getdate, nowdate, today
from frappe.utils import add_days, add_years, cint, flt, getdate, nowdate, today
from frappe.utils.data import getdate as convert_to_date
from erpnext import get_default_cost_center
@@ -1106,6 +1106,101 @@ class TestPaymentReconciliation(ERPNextTestSuite):
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
self.assertCountEqual(payment_vouchers, [je2.name, pe2.name])
def test_user_permission_on_accounting_dimension_filters_vouchers(self):
test_user = "test@example.com"
permitted_ccs = ["_Test Cost Center - _TC", "_Test Cost Center 2 - _TC"]
restricted_cc = "_Test Write Off Cost Center - _TC"
existing_apply_strict_user_permissions = cint(
frappe.db.get_single_value("System Settings", "apply_strict_user_permissions")
)
self.addCleanup(
frappe.db.set_single_value,
"System Settings",
"apply_strict_user_permissions",
existing_apply_strict_user_permissions,
)
transaction_date = nowdate()
rate = 100
def make_invoice(cost_center):
si = self.create_sales_invoice(
qty=1, rate=rate, posting_date=transaction_date, do_not_submit=True
)
si.cost_center = cost_center
for row in si.items:
row.cost_center = cost_center
return si.submit()
def make_payment(cost_center):
pe = self.create_payment_entry(posting_date=transaction_date, amount=rate)
pe.cost_center = cost_center
return pe.save().submit()
def make_journal(cost_center):
je = self.create_journal_entry(
self.bank, self.debit_to, 100, transaction_date, cost_center=cost_center
)
je.accounts[1].party_type = "Customer"
je.accounts[1].party = self.customer
return je.save().submit()
# Vouchers tagged with the two permitted cost centers
si_allowed = make_invoice(permitted_ccs[0])
pe_allowed = make_payment(permitted_ccs[1])
je_allowed = make_journal(permitted_ccs[0])
# Vouchers tagged with the restricted cost center
si_restricted = make_invoice(restricted_cc)
pe_restricted = make_payment(restricted_cc)
je_restricted = make_journal(restricted_cc)
# Payment entry with a BLANK cost center
pe_blank = make_payment(None)
for cc in permitted_ccs:
frappe.permissions.add_user_permission("Cost Center", cc, test_user)
# Without strict user permissions
frappe.db.set_single_value("System Settings", "apply_strict_user_permissions", 0)
with self.set_user(test_user):
pr = self.create_payment_reconciliation()
pr.get_unreconciled_entries()
invoice_numbers = [x.get("invoice_number") for x in pr.get("invoices")]
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
self.assertIn(si_allowed.name, invoice_numbers)
self.assertIn(pe_allowed.name, payment_vouchers)
self.assertIn(je_allowed.name, payment_vouchers)
self.assertIn(pe_blank.name, payment_vouchers)
self.assertNotIn(si_restricted.name, invoice_numbers)
self.assertNotIn(pe_restricted.name, payment_vouchers)
self.assertNotIn(je_restricted.name, payment_vouchers)
# With strict user permissions
frappe.db.set_single_value("System Settings", "apply_strict_user_permissions", 1)
with self.set_user(test_user):
pr = self.create_payment_reconciliation()
pr.get_unreconciled_entries()
invoice_numbers = [x.get("invoice_number") for x in pr.get("invoices")]
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
self.assertIn(si_allowed.name, invoice_numbers)
self.assertIn(pe_allowed.name, payment_vouchers)
self.assertIn(je_allowed.name, payment_vouchers)
self.assertNotIn(pe_blank.name, payment_vouchers)
self.assertNotIn(si_restricted.name, invoice_numbers)
self.assertNotIn(pe_restricted.name, payment_vouchers)
self.assertNotIn(je_restricted.name, payment_vouchers)
# with restricted dimension as a filter
with self.set_user(test_user):
pr = self.create_payment_reconciliation()
pr.cost_center = restricted_cc
self.assertRaises(frappe.PermissionError, pr.get_unreconciled_entries)
for cc in permitted_ccs:
frappe.permissions.remove_user_permission("Cost Center", cc, test_user)
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{

View File

@@ -379,6 +379,7 @@ class PaymentRequest(Document):
bank_amount=bank_amount,
created_from_payment_request=True,
)
payment_entry.set_missing_ref_details(force=True)
payment_entry.update(
{

View File

@@ -775,6 +775,22 @@ class TestPaymentRequest(ERPNextTestSuite):
pi.load_from_db()
self.assertEqual(pr_2.grand_total, pi.outstanding_amount)
def test_payment_entry_reference_details_fetched_from_invoice(self):
pi = make_purchase_invoice(currency="INR", qty=1, rate=94500)
pi.submit()
pr = make_payment_request(dt="Purchase Invoice", dn=pi.name, mute_email=1, submit_doc=0, return_doc=1)
pr.grand_total = 94000
pr.submit()
pe = pr.create_payment_entry(submit=False)
self.assertEqual(pe.references[0].reference_name, pi.name)
self.assertEqual(pe.references[0].total_amount, pi.grand_total)
self.assertEqual(pe.references[0].outstanding_amount, pi.outstanding_amount)
self.assertEqual(pe.references[0].allocated_amount, 94000)
self.assertEqual(pe.paid_amount, 94000)
def test_consider_journal_entry_and_return_invoice(self):
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry

View File

@@ -11,7 +11,7 @@ from frappe.contacts.doctype.address.address import get_address_display
from frappe.model.workflow import get_workflow_name
from frappe.query_builder import Criterion, DocType
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Abs, Sum
from frappe.query_builder.functions import Abs, IfNull, Sum
from frappe.utils import (
add_days,
add_months,
@@ -3511,8 +3511,18 @@ def get_common_query(
common_filter_conditions.append(payment_entry.cost_center == condition["cost_center"])
if condition.get("accounting_dimensions"):
apply_strict_user_permissions = frappe.get_system_settings("apply_strict_user_permissions")
for field, val in condition.get("accounting_dimensions").items():
common_filter_conditions.append(payment_entry[field] == val)
if isinstance(val, list | tuple | set):
value_condition = payment_entry[field].isin(val)
if apply_strict_user_permissions:
common_filter_conditions.append(value_condition)
else:
common_filter_conditions.append(
(IfNull(payment_entry[field], "") == "") | value_condition
)
else:
common_filter_conditions.append(payment_entry[field] == val)
if condition.get("minimum_payment_amount"):
common_filter_conditions.append(

View File

@@ -177,6 +177,68 @@ def update_variant_attribute_values(item_attribute):
frappe.flags.attribute_values = None
def get_attribute_abbr_renames(item_attribute):
"""Return the set of (current) attribute values whose abbreviation was renamed."""
if item_attribute.numeric_values:
return set()
db_value = item_attribute.get_doc_before_save()
if not db_value:
return set()
old_abbrs = {d.name: d.abbr for d in db_value.item_attribute_values}
changed_values = set()
for row in item_attribute.item_attribute_values:
if row.name in old_abbrs and old_abbrs[row.name] != row.abbr:
changed_values.add(row.attribute_value)
return changed_values
def update_variant_item_codes_for_abbr_renames(item_attribute):
"""Rebuild item_code/item_name of variant Items affected by a renamed Item Attribute abbreviation."""
changed_values = get_attribute_abbr_renames(item_attribute)
if not changed_values:
return
item_variant_table = frappe.qb.DocType("Item Variant Attribute")
variant_names = (
frappe.qb.from_(item_variant_table)
.select(item_variant_table.parent)
.where(item_variant_table.attribute == item_attribute.name)
.where(item_variant_table.attribute_value.isin(list(changed_values)))
.distinct()
.run(pluck=True)
)
for variant_name in variant_names:
rename_variant_item_code(variant_name)
def rename_variant_item_code(variant_name):
"""Recompute a variant's item_code/item_name from its template and current attribute abbreviations,
renaming the Item if it has changed."""
variant = frappe.get_doc("Item", variant_name)
if not variant.variant_of:
return
template = frappe.get_cached_doc("Item", variant.variant_of)
new_code = frappe._dict({"item_code": None, "item_name": None, "attributes": variant.attributes})
make_variant_item_code(template.item_code, template.item_name, new_code)
if not new_code.item_code or new_code.item_code == variant.item_code:
return
frappe.rename_doc("Item", variant.item_code, new_code.item_code)
# Keep item_name in lockstep with item_code: both are derived from the same abbreviation, so
# item_name is always rebuilt here too, even if it had since been customized away from that pattern.
if new_code.item_name and new_code.item_name != variant.item_name:
frappe.db.set_value("Item", new_code.item_code, "item_name", new_code.item_name)
def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True):
allow_rename_attribute_value = frappe.db.get_single_value(
"Item Variant Settings", "allow_rename_attribute_value"

View File

@@ -166,7 +166,8 @@ status_map = {
"Pick List": [
["Draft", None],
["Open", "eval:self.docstatus == 1"],
["Completed", "stock_entry_exists"],
["Completed", "is_fully_transferred"],
["Partially Transferred", "is_partially_transferred"],
[
"Partly Delivered",
"eval:self.purpose == 'Delivery' and self.delivery_status == 'Partly Delivered'",

View File

@@ -2060,6 +2060,7 @@ class StockController(AccountsController):
def show_accounting_ledger_preview(company, doctype, docname):
filters = frappe._dict(company=company, include_dimensions=1)
doc = frappe.get_lazy_doc(doctype, docname)
doc.check_permission("read")
doc.run_method("before_gl_preview")
gl_columns, gl_data = get_accounting_ledger_preview(doc, filters)
@@ -2073,6 +2074,7 @@ def show_accounting_ledger_preview(company, doctype, docname):
def show_stock_ledger_preview(company, doctype, docname):
filters = frappe._dict(company=company)
doc = frappe.get_lazy_doc(doctype, docname)
doc.check_permission("read")
doc.run_method("before_sl_preview")
sl_columns, sl_data = get_stock_ledger_preview(doc, filters)

View File

@@ -0,0 +1,77 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.controllers.stock_controller import (
show_accounting_ledger_preview,
show_stock_ledger_preview,
)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.tests.utils import ERPNextTestSuite
class TestLedgerPreviewPermission(ERPNextTestSuite):
def test_accounting_ledger_preview_requires_read_permission(self):
company = "_Test Company"
je = make_journal_entry("_Test Cash - _TC", "_Test Bank - _TC", 100, submit=True)
email = "ledger_preview_no_role@example.com"
if not frappe.db.exists("User", email):
frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": "No Role",
"user_type": "Website User",
"send_welcome_email": 0,
}
).insert(ignore_permissions=True)
try:
frappe.set_user(email)
self.assertRaises(
frappe.PermissionError,
show_accounting_ledger_preview,
company,
"Journal Entry",
je.name,
)
finally:
frappe.set_user("Administrator")
# a permitted user is still able to read the preview
accounting_ledger_result = show_accounting_ledger_preview(company, "Journal Entry", je.name)
self.assertTrue(accounting_ledger_result.get("gl_data"))
def test_stock_ledger_preview_requires_read_permission(self):
company = "_Test Company"
pr = make_purchase_receipt()
email = "ledger_preview_no_role@example.com"
if not frappe.db.exists("User", email):
frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": "No Role",
"user_type": "Website User",
"send_welcome_email": 0,
}
).insert(ignore_permissions=True)
try:
frappe.set_user(email)
self.assertRaises(
frappe.PermissionError,
show_stock_ledger_preview,
company,
"Purchase Receipt",
pr.name,
)
finally:
frappe.set_user("Administrator")
stock_ledger_result = show_stock_ledger_preview(company, "Purchase Receipt", pr.name)
self.assertTrue(stock_ledger_result.get("sl_data"))

View File

@@ -361,13 +361,24 @@ def based_wise_columns_query(based_on, trans):
# based_on_cols, based_on_select, based_on_group_by, addl_tables
if based_on == "Item":
based_on_details["based_on_cols"] = ["Item:Link/Item:120", "Item Name:Data:120"]
based_on_details["based_on_cols"] = [
{"label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 120, "fieldname": "item"},
{"label": _("Item Name"), "fieldtype": "Data", "width": 120, "fieldname": "item_name"},
]
based_on_details["based_on_select"] = "t2.item_code, t2.item_name,"
based_on_details["based_on_group_by"] = "t2.item_code"
based_on_details["addl_tables"] = ""
elif based_on == "Item Group":
based_on_details["based_on_cols"] = ["Item Group:Link/Item Group:120"]
based_on_details["based_on_cols"] = [
{
"label": _("Item Group"),
"fieldtype": "Link",
"options": "Item Group",
"width": 120,
"fieldname": "item_group",
}
]
based_on_details["based_on_select"] = "t2.item_group,"
based_on_details["based_on_group_by"] = "t2.item_group"
based_on_details["addl_tables"] = ""
@@ -375,32 +386,80 @@ def based_wise_columns_query(based_on, trans):
elif based_on == "Customer":
if trans == "Quotation":
based_on_details["based_on_cols"] = [
"Party:Link/Customer:120",
"Party Name:Data:120",
"Territory:Link/Territory:120",
{
"label": _("Party"),
"fieldtype": "Link",
"options": "Customer",
"width": 120,
"fieldname": "party",
},
{"label": _("Party Name"), "fieldtype": "Data", "width": 120, "fieldname": "party_name"},
{
"label": _("Territory"),
"fieldtype": "Link",
"options": "Territory",
"width": 120,
"fieldname": "territory",
},
]
based_on_details["based_on_select"] = "t1.party_name, t1.customer_name, t1.territory,"
else:
based_on_details["based_on_cols"] = [
"Customer:Link/Customer:120",
"Customer Name:Data:120",
"Territory:Link/Territory:120",
{
"label": _("Customer"),
"fieldtype": "Link",
"options": "Customer",
"width": 120,
"fieldname": "customer",
},
{
"label": _("Customer Name"),
"fieldtype": "Data",
"width": 120,
"fieldname": "customer_name",
},
{
"label": _("Territory"),
"fieldtype": "Link",
"options": "Territory",
"width": 120,
"fieldname": "territory",
},
]
based_on_details["based_on_select"] = "t1.customer, t1.customer_name, t1.territory,"
based_on_details["based_on_group_by"] = "t1.party_name" if trans == "Quotation" else "t1.customer"
based_on_details["addl_tables"] = ""
elif based_on == "Customer Group":
based_on_details["based_on_cols"] = ["Customer Group:Link/Customer Group"]
based_on_details["based_on_cols"] = [
{
"label": _("Customer Group"),
"fieldtype": "Link",
"options": "Customer Group",
"fieldname": "customer_group",
}
]
based_on_details["based_on_select"] = "t1.customer_group,"
based_on_details["based_on_group_by"] = "t1.customer_group"
based_on_details["addl_tables"] = ""
elif based_on == "Supplier":
based_on_details["based_on_cols"] = [
"Supplier:Link/Supplier:120",
"Supplier Name:Data:120",
"Supplier Group:Link/Supplier Group:140",
{
"label": _("Supplier"),
"fieldtype": "Link",
"options": "Supplier",
"width": 120,
"fieldname": "supplier",
},
{"label": _("Supplier Name"), "fieldtype": "Data", "width": 120, "fieldname": "supplier_name"},
{
"label": _("Supplier Group"),
"fieldtype": "Link",
"options": "Supplier Group",
"width": 140,
"fieldname": "supplier_group",
},
]
based_on_details["based_on_select"] = "t1.supplier, t1.supplier_name, t3.supplier_group,"
based_on_details["based_on_group_by"] = "t1.supplier"
@@ -408,26 +467,58 @@ def based_wise_columns_query(based_on, trans):
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"
elif based_on == "Supplier Group":
based_on_details["based_on_cols"] = ["Supplier Group:Link/Supplier Group:140"]
based_on_details["based_on_cols"] = [
{
"label": _("Supplier Group"),
"fieldtype": "Link",
"options": "Supplier Group",
"width": 140,
"fieldname": "supplier_group",
}
]
based_on_details["based_on_select"] = "t3.supplier_group,"
based_on_details["based_on_group_by"] = "t3.supplier_group"
based_on_details["addl_tables"] = ",`tabSupplier` t3"
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"
elif based_on == "Territory":
based_on_details["based_on_cols"] = ["Territory:Link/Territory:120"]
based_on_details["based_on_cols"] = [
{
"label": _("Territory"),
"fieldtype": "Link",
"options": "Territory",
"width": 120,
"fieldname": "territory",
}
]
based_on_details["based_on_select"] = "t1.territory,"
based_on_details["based_on_group_by"] = "t1.territory"
based_on_details["addl_tables"] = ""
elif based_on == "Project":
if trans in ["Sales Invoice", "Delivery Note", "Sales Order"]:
based_on_details["based_on_cols"] = ["Project:Link/Project:120"]
based_on_details["based_on_cols"] = [
{
"label": _("Project"),
"fieldtype": "Link",
"options": "Project",
"width": 120,
"fieldname": "project",
}
]
based_on_details["based_on_select"] = "t1.project,"
based_on_details["based_on_group_by"] = "t1.project"
based_on_details["addl_tables"] = ""
elif trans in ["Purchase Order", "Purchase Invoice", "Purchase Receipt"]:
based_on_details["based_on_cols"] = ["Project:Link/Project:120"]
based_on_details["based_on_cols"] = [
{
"label": _("Project"),
"fieldtype": "Link",
"options": "Project",
"width": 120,
"fieldname": "project",
}
]
based_on_details["based_on_select"] = "t2.project,"
based_on_details["based_on_group_by"] = "t2.project"
based_on_details["addl_tables"] = ""
@@ -435,7 +526,15 @@ def based_wise_columns_query(based_on, trans):
frappe.throw(_("Project-wise data is not available for Quotation"))
based_on_details["based_on_select"] += "t4.default_currency as currency,"
based_on_details["based_on_cols"].append("Currency:Link/Currency:120")
based_on_details["based_on_cols"].append(
{
"label": _("Currency"),
"fieldtype": "Link",
"options": "Currency",
"width": 120,
"fieldname": "currency",
}
)
based_on_details["addl_tables"] += ", `tabCompany` t4"
based_on_details["addl_tables_relational_cond"] = (
based_on_details.get("addl_tables_relational_cond", "") + " and t1.company = t4.name"
@@ -446,6 +545,14 @@ def based_wise_columns_query(based_on, trans):
def group_wise_column(group_by):
if group_by:
return [group_by + ":Link/" + group_by + ":120"]
return [
{
"label": _(group_by),
"fieldtype": "Link",
"options": group_by,
"width": 120,
"fieldname": frappe.scrub(group_by),
}
]
else:
return []

View File

@@ -2,6 +2,35 @@
// For license information, please see license.txt
frappe.ui.form.on("CRM Settings", {
// refresh: function(frm) {
// }
refresh: function (frm) {
const flag = frm.events.calculate_visiblity_flag(frm);
frm.set_df_property("allowed_users", "hidden", !flag);
frm.set_df_property("allowed_users", "reqd", flag);
},
enable_frappe_crm_data_synchronization: function (frm) {
const flag = frm.events.calculate_visiblity_flag(frm);
if (flag) {
frappe.show_alert(
__("Allowed Users is required for data synchronization from remote Frappe CRM site.")
);
}
/*
make allowed_users field visible and mandatory if enable_frappe_crm_data_synchronization
is set and crm app is not installed.
*/
frm.set_df_property("allowed_users", "hidden", !flag);
frm.set_df_property("allowed_users", "reqd", flag);
},
calculate_visiblity_flag: function (frm) {
const crm_sync_enabled = frm.doc.enable_frappe_crm_data_synchronization;
const is_crm_installed = cint(frappe.utils.get_installed_apps().includes("crm"));
return crm_sync_enabled && !is_crm_installed;
},
});

View File

@@ -120,9 +120,9 @@
"fieldtype": "Column Break"
},
{
"depends_on": "eval:doc.enable_frappe_crm_data_synchronization === 1;",
"fieldname": "allowed_users",
"fieldtype": "Table MultiSelect",
"hidden": 1,
"label": "Allowed Users",
"options": "Frappe CRM Allowed User",
"permlevel": 1
@@ -140,7 +140,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-22 01:26:13.474915",
"modified": "2026-07-01 01:09:16.461470",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM Settings",

View File

@@ -6,6 +6,8 @@ from frappe import _
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from frappe.model.document import Document
from erpnext.crm.frappe_crm_api import is_crm_installed
class CRMSettings(Document):
# begin: auto-generated types
@@ -46,13 +48,16 @@ class CRMSettings(Document):
)
def validate_allowed_users(self):
if self.enable_frappe_crm_data_synchronization and not self.allowed_users:
if self.enable_frappe_crm_data_synchronization and not (is_crm_installed() or self.allowed_users):
frappe.throw(
_(
"Please add atleast one user on Allowed Users to allow Data Synchronization from Frappe CRM site."
)
)
if self.enable_frappe_crm_data_synchronization and is_crm_installed() and self.allowed_users:
frappe.throw(_("Allowed Users is not required as Frappe CRM is already installed on the site."))
def before_save(self):
self.clear_allowed_users()

View File

@@ -1,5 +1,6 @@
import json
import click
import frappe
from frappe import _
@@ -150,7 +151,9 @@ def create_customer(customer_data=None):
for field in CUSTOMER_ALLOWED_FIELDS:
if customer_data.get(field) is not None:
customer.set(field, customer_data.get(field))
customer.insert(ignore_permissions=True)
# If CRM is installed on the site, User Permission cannot be ignored while saving Customer Records.
customer.insert(ignore_permissions=not is_crm_installed())
customer_name = customer.name
contacts = json.loads(customer_data.get("contacts"))
@@ -169,6 +172,10 @@ def validate_frappe_crm_sync():
_("Frappe CRM data synchronization is not enabled on ERPNext. Contact System Manager of ERPNext.")
)
# Skip allowed_users validation if CRM is installed on the site.
if is_crm_installed():
return
allowed_users = [d.user for d in CRMSettings.allowed_users]
if frappe.session.user not in allowed_users:
@@ -178,3 +185,35 @@ def validate_frappe_crm_sync():
),
exc=frappe.PermissionError,
)
def is_crm_installed():
return "crm" in frappe.get_installed_apps()
def remove_allowed_users_on_crm_install():
try:
CRMSettings = frappe.get_single("CRM Settings")
if not CRMSettings.enable_frappe_crm_data_synchronization:
return
CRMSettings.allowed_users = []
CRMSettings.save()
click.secho("Removed 'Allowed Users' from CRM Settings.")
except Exception:
click.secho("'Allowed Users' from CRM Settings couldn't be cleared.")
def disable_frappe_crm_data_synchronization_on_crm_uninstall():
try:
CRMSettings = frappe.get_single("CRM Settings")
if not CRMSettings.enable_frappe_crm_data_synchronization:
return
CRMSettings.enable_frappe_crm_data_synchronization = 0
CRMSettings.save()
click.secho("'Enable Frappe CRM Data Synchronization' on CRM Settings has been disabled.")
except Exception:
click.secho("'Enable Frappe CRM Data Synchronization' on CRM Settings could not be disabled.")

View File

@@ -189,6 +189,7 @@ def get_filtered_todos(ref_doctype, ref_docname, status: str | tuple[str, str]):
"allocated_to",
"date",
],
order_by="date asc",
)
@@ -218,6 +219,7 @@ def get_filtered_events(ref_doctype, ref_docname, open: bool):
& (event_link.reference_docname == ref_docname)
& (event_status_filter)
)
.orderby(event.starts_on)
)
data = query.run(as_dict=True)

View File

@@ -65,6 +65,9 @@ setup_wizard_stages = "erpnext.setup.setup_wizard.setup_wizard.get_setup_stages"
after_install = "erpnext.setup.install.after_install"
after_app_install = "erpnext.setup.install.after_app_install"
after_app_uninstall = "erpnext.setup.install.after_app_uninstall"
boot_session = "erpnext.startup.boot.boot_session"
notification_config = "erpnext.startup.notifications.get_notification_config"
get_help_messages = "erpnext.utilities.activation.get_help_messages"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -586,7 +586,11 @@ frappe.ui.form.on("BOM", {
},
routing(frm) {
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) {
// Refetch operations whenever the routing is (re)selected, so that
// changing the routing - e.g. on a new BOM version copied from another
// BOM - replaces the operations with those of the newly selected routing
// instead of keeping the old ones.
if (frm.doc.routing && frm.doc.with_operations) {
frappe.call({
doc: frm.doc,
method: "get_routing",

View File

@@ -662,6 +662,48 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(ste.from_bom, 1.0)
self.assertEqual(ste.bom_no, work_order.bom_no)
def test_job_card_material_transfer_via_pick_list(self):
from erpnext.stock.doctype.material_request.material_request import create_pick_list
from erpnext.stock.doctype.pick_list.pick_list import (
create_stock_entry as create_stock_entry_from_pick_list,
)
create_bom_with_multiple_operations()
work_order = make_wo_with_transfer_against_jc()
for item in work_order.required_items:
make_stock_entry(
item_code=item.item_code,
target=item.source_warehouse,
qty=item.required_qty * 2,
basic_rate=100,
)
job_card_name = frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name")
job_card = frappe.get_doc("Job Card", job_card_name)
mr = make_material_request(job_card_name)
mr.schedule_date = today()
mr.submit()
pick_list = create_pick_list(mr.name)
pick_list.submit()
ste = frappe.get_doc(create_stock_entry_from_pick_list(pick_list.as_dict()))
self.assertEqual(ste.purpose, "Material Transfer for Manufacture")
self.assertEqual(ste.job_card, job_card_name)
self.assertEqual(ste.work_order, work_order.name)
self.assertEqual(ste.fg_completed_qty, job_card.for_quantity)
for row in ste.items:
self.assertEqual(row.t_warehouse, job_card.wip_warehouse)
self.assertTrue(row.job_card_item)
ste.insert()
ste.submit()
job_card.reload()
self.assertEqual(job_card.transferred_qty, job_card.for_quantity)
def test_job_card_proccess_qty_and_completed_qty(self):
from erpnext.manufacturing.doctype.routing.test_routing import (
create_routing,

View File

@@ -18,6 +18,7 @@ from erpnext.manufacturing.doctype.work_order.work_order import (
StockOverProductionError,
close_work_order,
make_job_card,
make_material_request,
make_stock_entry,
make_stock_return_entry,
stop_unstop,
@@ -1547,6 +1548,97 @@ class TestWorkOrder(ERPNextTestSuite):
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
def test_work_order_material_request_and_bom_details(self):
from erpnext.stock.doctype.material_request.material_request import (
make_stock_entry as mr_to_stock_entry,
)
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=2, source_warehouse="Stores - _TC"
)
mr = make_material_request(work_order.name)
mr.schedule_date = today()
for item in mr.items:
item.schedule_date = today()
mr.submit()
self.assertEqual(mr.work_order, work_order.name)
ste = mr_to_stock_entry(mr.name)
self.assertEqual(ste.purpose, "Material Transfer for Manufacture")
self.assertEqual(ste.work_order, work_order.name)
self.assertEqual(ste.from_bom, 1.0)
self.assertEqual(ste.bom_no, work_order.bom_no)
self.assertEqual(ste.fg_completed_qty, 0.0)
def test_status_in_process_when_only_one_required_item_transferred_via_material_request(self):
"""Same bottleneck scenario as the Pick List flow, but the intermediate document is a
Material Request created directly from the Work Order: min-fraction keeps
material_transferred_for_manufacturing at 0, but the work order must still move to
In Process because material is already in WIP.
"""
from erpnext.stock.doctype.material_request.material_request import (
make_stock_entry as mr_to_stock_entry,
)
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=2, source_warehouse="Stores - _TC"
)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=5000.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=1000.0
)
mr = make_material_request(work_order.name)
mr.schedule_date = today()
# request only _Test Item; the other required item is left off this material request
mr.items = [item for item in mr.items if item.item_code == "_Test Item"]
for item in mr.items:
item.schedule_date = today()
mr.submit()
stock_entry = frappe.get_doc(mr_to_stock_entry(mr.name))
self.assertEqual(stock_entry.fg_completed_qty, 0.0)
stock_entry.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
self.assertEqual(work_order.status, "In Process")
def test_status_in_process_when_only_one_required_item_transferred_via_pick_list(self):
"""Stock Entry created from a Pick List that picked only one of the required items:
min-fraction keeps material_transferred_for_manufacturing at 0, but the work order must
still move to In Process because material is already in WIP."""
from erpnext.manufacturing.doctype.work_order.work_order import create_pick_list
from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=2, source_warehouse="Stores - _TC"
)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=5000.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=1000.0
)
pick_list = create_pick_list(work_order.name, for_qty=work_order.qty)
# pick only _Test Item; the other required item is left out of this pick list
pick_list.pick_manually = 1
pick_list.locations = [loc for loc in pick_list.locations if loc.item_code == "_Test Item"]
pick_list.save()
pick_list.submit()
stock_entry = frappe.get_doc(create_stock_entry(frappe.as_json(pick_list.as_dict())))
self.assertEqual(stock_entry.fg_completed_qty, 0.0)
stock_entry.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
self.assertEqual(work_order.status, "In Process")
def test_backflushed_batch_raw_materials_based_on_transferred(self):
frappe.db.set_single_value(
"Manufacturing Settings",

View File

@@ -813,6 +813,10 @@ erpnext.work_order = {
erpnext.work_order.create_pick_list(frm);
});
frm.add_custom_button(__("Material Request"), function () {
erpnext.work_order.make_material_request(frm);
});
var start_btn = frm.add_custom_button(__("Start"), function () {
erpnext.work_order.make_se(frm, "Material Transfer for Manufacture");
});
@@ -1151,6 +1155,13 @@ erpnext.work_order = {
}
},
make_material_request: function (frm) {
frappe.model.open_mapped_doc({
method: "erpnext.manufacturing.doctype.work_order.work_order.make_material_request",
frm,
});
},
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
const max = this.get_max_transferable_qty(frm, purpose);

View File

@@ -295,6 +295,10 @@ class WorkOrder(Document):
self.validate_subcontracting_inward_order()
def validate_dates(self):
if self.planned_start_date and self.planned_end_date:
if get_datetime(self.planned_end_date) < get_datetime(self.planned_start_date):
frappe.throw(_("Planned End Date cannot be before Planned Start Date"))
if self.actual_start_date and self.actual_end_date:
if self.actual_end_date < self.actual_start_date:
frappe.throw(_("Actual End Date cannot be before Actual Start Date"))
@@ -677,7 +681,11 @@ class WorkOrder(Document):
elif self.docstatus == 1:
if status not in ["Closed", "Stopped"]:
status = "Not Started"
if flt(self.material_transferred_for_manufacturing) > 0 or self.skip_transfer:
if (
flt(self.material_transferred_for_manufacturing) > 0
or self.skip_transfer
or self._has_transferred_material()
):
status = "In Process"
precision = frappe.get_precision("Work Order", "produced_qty")
@@ -711,6 +719,57 @@ class WorkOrder(Document):
return status
def _has_transferred_material(self):
"""True if any raw material transferred against this work order via a pick list or a
material request is still, net of returns, in WIP (these leave
material_transferred_for_manufacturing at 0 via the min-fraction rule)."""
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")
mr_ste = frappe.qb.DocType("Stock Entry")
mr_child = frappe.qb.DocType("Stock Entry Detail")
# Stock Entry only carries `material_request` at the child-row level, so a Stock
# Entry is "MR-sourced" if *any* of its rows link back to a Material Request against
# this work order; the join to mr_ste keeps this scoped to this work order's entries
# instead of scanning every Material-Request-linked row in the system.
mr_sourced_stock_entries = (
frappe.qb.from_(mr_child)
.inner_join(mr_ste)
.on(mr_ste.name == mr_child.parent)
.select(mr_child.parent)
.where(
(mr_child.material_request.isnotnull())
& (mr_ste.work_order == self.name)
& (mr_ste.docstatus == 1)
& (mr_ste.purpose == "Material Transfer for Manufacture")
)
)
common_filters = (
(ste.work_order == self.name)
& (ste.docstatus == 1)
& (ste.purpose == "Material Transfer for Manufacture")
)
transferred_qty = (
frappe.qb.from_(ste)
.inner_join(ste_child)
.on(ste_child.parent == ste.name)
.select(Sum(ste_child.transfer_qty))
.where(
common_filters
& (ste.is_return == 0)
& (ste.pick_list.isnotnull() | ste.name.isin(mr_sourced_stock_entries))
)
).run()[0][0]
# Returns don't carry their own pick_list/material_request reference, so net every
# return against this work order to correctly clear WIP after a full return.
returned_qty = (
frappe.qb.from_(ste)
.inner_join(ste_child)
.on(ste_child.parent == ste.name)
.select(Sum(ste_child.transfer_qty))
.where(common_filters & (ste.is_return == 1))
).run()[0][0]
return flt(transferred_qty) - flt(returned_qty) > 0
def update_work_order_qty(self):
"""Update **Manufactured Qty** and **Material Transferred for Qty** in Work Order
based on Stock Entry"""
@@ -3014,6 +3073,40 @@ def get_reserved_qty_for_production(
return query.run()[0][0] or 0.0
@frappe.whitelist()
def make_material_request(source_name: str, target_doc: str | dict | None = None):
frappe.has_permission("Material Request", "create", throw=True)
doc = get_mapped_doc("Work Order", source_name, _material_request_mapping(), target_doc)
doc.material_request_type = "Material Transfer"
return doc
def _material_request_mapping():
return {
"Work Order": {
"doctype": "Material Request",
"validation": {"docstatus": ["=", 1]},
"field_map": {"name": "work_order"},
},
"Work Order Item": {
"doctype": "Material Request Item",
"field_map": [
("stock_uom", "uom"),
("source_warehouse", "from_warehouse"),
],
"postprocess": _set_material_request_item,
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
},
}
def _set_material_request_item(source, target, source_parent):
target.warehouse = source_parent.wip_warehouse
target.qty = flt(source.required_qty) - flt(source.transferred_qty)
target.schedule_date = nowdate()
@frappe.whitelist()
def make_stock_return_entry(work_order):
from erpnext.stock.doctype.stock_entry.stock_entry import get_available_materials

View File

@@ -260,7 +260,6 @@ execute:frappe.rename_doc("Report", "TDS Payable Monthly", "Tax Withholding Deta
erpnext.patches.v14_0.update_proprietorship_to_individual
erpnext.patches.v15_0.rename_subcontracting_fields
erpnext.patches.v15_0.unset_incorrect_additional_discount_percentage
erpnext.patches.v16_0.create_company_custom_fields
[post_model_sync]
erpnext.patches.v15_0.rename_gross_purchase_amount_to_net_purchase_amount
@@ -439,6 +438,7 @@ erpnext.patches.v16_0.set_reporting_currency
erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes
erpnext.patches.v16_0.update_serial_no_reference_name
erpnext.patches.v16_0.update_account_categories_for_existing_accounts
erpnext.patches.v16_0.create_company_custom_fields
erpnext.patches.v16_0.rename_subcontracted_quantity
erpnext.patches.v16_0.add_new_stock_entry_types
erpnext.patches.v15_0.set_asset_status_if_not_already_set
@@ -488,3 +488,5 @@ execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600)
erpnext.patches.v16_0.remove_mandatory_from_inv_dimension_fields
erpnext.patches.v15_0.backfill_sla_link_filters_on_custom_field
erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield
erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
erpnext.patches.v16_0.backfill_pick_list_transferred_qty

View File

@@ -0,0 +1,58 @@
import frappe
from frappe.query_builder.functions import Sum
from frappe.utils import flt
def execute():
StockEntry = frappe.qb.DocType("Stock Entry")
StockEntryDetail = frappe.qb.DocType("Stock Entry Detail")
pick_lists = (
frappe.qb.from_(StockEntry)
.select(StockEntry.pick_list)
.distinct()
.where((StockEntry.pick_list.isnotnull()) & (StockEntry.docstatus == 1))
).run(pluck=True)
if not pick_lists:
return
rows = (
frappe.qb.from_(StockEntryDetail)
.join(StockEntry)
.on(StockEntryDetail.parent == StockEntry.name)
.select(
StockEntry.pick_list,
StockEntryDetail.item_code,
StockEntryDetail.s_warehouse,
Sum(StockEntryDetail.transfer_qty).as_("qty"),
)
.where((StockEntry.pick_list.isin(pick_lists)) & (StockEntry.docstatus == 1))
.groupby(StockEntry.pick_list, StockEntryDetail.item_code, StockEntryDetail.s_warehouse)
).run(as_dict=True)
transferred = {(r.pick_list, r.item_code, r.s_warehouse): flt(r.qty) for r in rows}
items = frappe.get_all(
"Pick List Item",
filters={"parent": ("in", pick_lists), "picked_qty": (">", 0)},
fields=["name", "parent", "item_code", "warehouse", "picked_qty"],
order_by="idx",
)
updates = {}
for row in items:
key = (row.parent, row.item_code, row.warehouse)
available = transferred.get(key, 0)
if available <= 0:
continue
qty = min(flt(row.picked_qty), available)
transferred[key] = available - qty
updates[row.name] = {"transferred_qty": qty}
if not updates:
return
frappe.db.auto_commit_on_many_writes = True
frappe.db.bulk_update("Pick List Item", updates)
frappe.db.auto_commit_on_many_writes = False

View File

@@ -0,0 +1,10 @@
import frappe
def execute():
from erpnext.crm.frappe_crm_api import is_crm_installed, remove_allowed_users_on_crm_install
if not is_crm_installed():
return
remove_allowed_users_on_crm_install()

View File

@@ -28,8 +28,8 @@ erpnext.financial_statements = {
},
is_blank_row: function (data) {
if (!data || data.segment_values) return false;
return (
data &&
!data.account &&
!data.accounts &&
!data.child_accounts &&

View File

@@ -893,13 +893,15 @@
"print_hide": 1
},
{
"description": "Applying a Discount Amount? When this Sales Order is partially fulfilled through multiple Delivery Notes and Sales Invoices, the Discount Amount is allocated on a FIFO basis. The earlier transactions receive a larger share of the discount. To spread the discount proportionally across item prices, use Additional Discount Percentage instead.",
"fieldname": "discount_amount",
"fieldtype": "Currency",
"hide_days": 1,
"hide_seconds": 1,
"label": "Additional Discount Amount",
"options": "currency",
"print_hide": 1
"print_hide": 1,
"show_description_on_click": 1
},
{
"fieldname": "base_grand_total",
@@ -1760,7 +1762,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-05-28 11:41:11.823034",
"modified": "2026-06-24 12:00:00.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",

View File

@@ -183,8 +183,22 @@ def get_entries(filters):
.as_("contribution_amt")
)
# Only pass valid document-field filters to get_query; report-specific keys such as
# doc_type / sales_person / item_group are handled separately below.
doc_filters = {"docstatus": 1}
for field in ["company", "customer", "territory"]:
if filters.get(field):
doc_filters[field] = filters.get(field)
if filters.get("from_date") and filters.get("to_date"):
doc_filters[date_field] = ["between", [filters.get("from_date"), filters.get("to_date")]]
elif filters.get("from_date"):
doc_filters[date_field] = [">=", filters.get("from_date")]
elif filters.get("to_date"):
doc_filters[date_field] = ["<=", filters.get("to_date")]
query = (
frappe.get_query(dt, filters=filters, ignore_permissions=False)
frappe.get_query(dt, filters=doc_filters, ignore_permissions=False)
.join(dt_item)
.on(dt.name == dt_item.parent)
.join(st)
@@ -203,48 +217,29 @@ def get_entries(filters):
contribution_amt_case,
)
.where(st.parenttype == doc_type)
.where(dt.docstatus == 1)
)
if filters.get("sales_person"):
lft, rgt = frappe.db.get_value("Sales Person", filters.get("sales_person"), ["lft", "rgt"])
sp = frappe.qb.DocType("Sales Person")
query = query.where(
st.sales_person.isin(frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt)))
)
# only resolve items when an item_group/brand filter is set; otherwise get_items
# would return every item in the system and add a huge IN() clause on each run
if filters.get("item_group") or filters.get("brand"):
items = get_items(filters)
if not items:
# the item_group/brand filter matched nothing -> no rows
return []
query = query.where(dt_item.item_code.isin([d[0] for d in items]))
query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc)
return query.run(as_dict=True)
def get_conditions(filters, date_field):
conditions = [""]
values = []
for field in ["company", "customer", "territory"]:
if filters.get(field):
conditions.append(f"dt.{field}=%s")
values.append(filters[field])
if filters.get("sales_person"):
lft, rgt = frappe.get_value("Sales Person", filters.get("sales_person"), ["lft", "rgt"])
conditions.append(
f"exists(select name from `tabSales Person` where lft >= {lft} and rgt <= {rgt} and name=st.sales_person)"
)
if filters.get("from_date"):
conditions.append(f"dt.{date_field}>=%s")
values.append(filters["from_date"])
if filters.get("to_date"):
conditions.append(f"dt.{date_field}<=%s")
values.append(filters["to_date"])
items = get_items(filters)
if items:
conditions.append("dt_item.item_code in (%s)" % ", ".join(["%s"] * len(items)))
values += items
else:
# return empty result, if no items are fetched after filtering on 'item group' and 'brand'
conditions.append("dt_item.item_code = Null")
return " and ".join(conditions), values
def get_items(filters):
item = qb.DocType("Item")

View File

@@ -0,0 +1,69 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.selling.report.sales_person_wise_transaction_summary.sales_person_wise_transaction_summary import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestSalesPersonWiseTransactionSummary(ERPNextTestSuite):
"""Item-level summary joining a sales document with its Sales Team rows, showing
each sales person's contributed qty and amount per item line."""
def setUp(self):
self.sales_person = "_Test Sales Person"
def make_invoice_with_commission(self, qty=5, rate=200, percentage=100):
si = create_sales_invoice(
item="_Test Item", qty=qty, rate=rate, do_not_save=True, posting_date="2026-06-01"
)
si.append("sales_team", {"sales_person": self.sales_person, "allocated_percentage": percentage})
si.insert()
si.submit()
return si
def run_report(self, **extra):
filters = frappe._dict(
{"company": "_Test Company", "doc_type": "Sales Invoice", "sales_person": self.sales_person}
)
filters.update(extra)
return execute(filters)[1]
def test_doc_type_is_mandatory(self):
self.assertRaises(frappe.ValidationError, execute, frappe._dict({"company": "_Test Company"}))
def test_invalid_doc_type_throws(self):
self.assertRaises(
frappe.ValidationError,
execute,
frappe._dict({"company": "_Test Company", "doc_type": "Purchase Invoice"}),
)
def test_item_line_contribution(self):
si = self.make_invoice_with_commission(qty=5, rate=200, percentage=100)
item = si.items[0]
rows = self.run_report()
row = next((r for r in rows if r[0] == si.name and r[5] == "_Test Item"), None)
self.assertIsNotNone(row, "Invoice item line missing from report")
# row: name, customer, territory, warehouse, posting_date, item_code, item_group,
# brand, stock_qty, base_net_amount, sales_person, allocated_percentage,
# contributed_qty, contribution_amt, currency
self.assertEqual(row[1], si.customer)
self.assertEqual(row[8], item.stock_qty)
self.assertEqual(row[9], item.base_net_amount)
self.assertEqual(row[10], self.sales_person)
self.assertEqual(row[11], 100)
self.assertEqual(row[12], item.stock_qty * 100 / 100) # contributed qty
self.assertEqual(row[13], item.base_net_amount * 100 / 100) # contribution amount
def test_appends_total_row(self):
self.make_invoice_with_commission()
rows = self.run_report()
self.assertTrue(rows)
self.assertEqual(rows[-1], [""] * len(rows[0]))

View File

@@ -432,3 +432,19 @@ DEFAULT_ROLE_PROFILES = {
"Purchase Manager",
],
}
def after_app_install(app_name=None):
if app_name == "crm":
from erpnext.crm.frappe_crm_api import remove_allowed_users_on_crm_install
remove_allowed_users_on_crm_install()
def after_app_uninstall(app_name=None):
if app_name == "crm":
from erpnext.crm.frappe_crm_api import disable_frappe_crm_data_synchronization_on_crm_uninstall
disable_frappe_crm_data_synchronization_on_crm_uninstall()
frappe.db.commit() # nosemgrep

View File

@@ -415,22 +415,34 @@ class DeliveryNote(SellingController):
frappe.throw(_("Warehouse required for stock Item {0}").format(d["item_code"]))
def update_current_stock(self):
if self.get("_action") and self._action != "update_after_submit":
for d in self.get("items"):
d.actual_qty = frappe.db.get_value(
"Bin", {"item_code": d.item_code, "warehouse": d.warehouse}, "actual_qty"
)
if not (self.get("_action") and self._action != "update_after_submit"):
return
for d in self.get("packed_items"):
bin_qty = frappe.db.get_value(
"Bin",
{"item_code": d.item_code, "warehouse": d.warehouse},
["actual_qty", "projected_qty"],
as_dict=True,
)
if bin_qty:
d.actual_qty = flt(bin_qty.actual_qty)
d.projected_qty = flt(bin_qty.projected_qty)
warehouse_item_codes = {}
for d in self.get("items") + self.get("packed_items"):
warehouse_item_codes.setdefault(d.warehouse, set()).add(d.item_code)
if not warehouse_item_codes:
return
bin_map = {}
for warehouse, item_codes in warehouse_item_codes.items():
for b in frappe.get_all(
"Bin",
filters={"item_code": ["in", item_codes], "warehouse": warehouse},
fields=["item_code", "actual_qty", "projected_qty"],
):
bin_map[(b.item_code, warehouse)] = b
for d in self.get("items"):
bin_data = bin_map.get((d.item_code, d.warehouse))
d.actual_qty = bin_data.actual_qty if bin_data else None
for d in self.get("packed_items"):
bin_data = bin_map.get((d.item_code, d.warehouse))
if bin_data:
d.actual_qty = flt(bin_data.actual_qty)
d.projected_qty = flt(bin_data.projected_qty)
def on_submit(self):
self.validate_packed_qty()

View File

@@ -170,6 +170,7 @@
"ignore_user_permissions": 1,
"in_standard_filter": 1,
"label": "Variant Of",
"link_filters": "[[\"Item\",\"has_variants\",\"=\",1]]",
"options": "Item",
"read_only": 1,
"search_index": 1,
@@ -1092,7 +1093,7 @@
"image_field": "image",
"links": [],
"make_attachments_public": 1,
"modified": "2026-05-27 10:18:46.862670",
"modified": "2026-07-05 23:24:45.734144",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",

View File

@@ -493,6 +493,100 @@ class TestItem(ERPNextTestSuite):
"Large",
)
def test_rename_attribute_abbr_updates_variant_item_code(self):
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1)
variant = create_variant("_Test Variant Item", {"Test Size": "Large"})
variant.save()
attribute = frappe.get_doc("Item Attribute", "Test Size")
for row in attribute.item_attribute_values:
if row.attribute_value == "Large":
row.abbr = "LRG"
break
def restore_test_size_abbr():
doc = frappe.get_doc("Item Attribute", "Test Size")
for row in doc.item_attribute_values:
if row.attribute_value == "Large":
row.abbr = "L"
break
frappe.flags.attribute_values = None
doc.save()
self.addCleanup(restore_test_size_abbr)
self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1))
frappe.flags.attribute_values = None
attribute.save()
self.assertFalse(frappe.db.exists("Item", "_Test Variant Item-L"))
self.assertTrue(frappe.db.exists("Item", "_Test Variant Item-LRG"))
self.assertEqual(
frappe.db.get_value("Item", "_Test Variant Item-LRG", "item_name"),
"_Test Variant Item-LRG",
)
def test_rename_attribute_abbr_updates_variant_item_name_from_template_name(self):
# item_name can be derived from the template's item_name, which may differ from its
# item_code (e.g. a friendly display name vs. a SKU-style code). The variant's item_name
# must follow the abbreviation rename the same way item_code does.
frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-L", force=1)
frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1)
frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1)
template = frappe.get_doc("Item", "_Test Variant Item").as_dict()
template = frappe.get_doc(
{
"doctype": "Item",
"item_code": "_Test Variant Item Diff",
"item_name": "Test Variant Friendly Name",
"item_group": template.item_group,
"stock_uom": template.stock_uom,
"has_variants": 1,
"attributes": [{"attribute": "Test Size"}],
}
)
template.insert()
self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1))
variant = create_variant("_Test Variant Item Diff", {"Test Size": "Large"})
variant.save()
self.assertEqual(variant.item_code, "_Test Variant Item Diff-L")
self.assertEqual(variant.item_name, "Test Variant Friendly Name-L")
# even a manually customized item_name (unrelated to the auto-generated pattern) must be
# rebuilt on abbreviation rename, since item_code and item_name are meant to stay in lockstep.
frappe.db.set_value("Item", variant.name, "item_name", "Custom Friendly Large Shirt Name")
attribute = frappe.get_doc("Item Attribute", "Test Size")
for row in attribute.item_attribute_values:
if row.attribute_value == "Large":
row.abbr = "LRG"
break
def restore_test_size_abbr():
doc = frappe.get_doc("Item Attribute", "Test Size")
for row in doc.item_attribute_values:
if row.attribute_value == "Large":
row.abbr = "L"
break
frappe.flags.attribute_values = None
doc.save()
self.addCleanup(restore_test_size_abbr)
self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1))
frappe.flags.attribute_values = None
attribute.save()
self.assertFalse(frappe.db.exists("Item", "_Test Variant Item Diff-L"))
self.assertEqual(
frappe.db.get_value("Item", "_Test Variant Item Diff-LRG", "item_name"),
"Test Variant Friendly Name-LRG",
)
def test_make_item_variant(self):
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)

View File

@@ -10,6 +10,7 @@ from frappe.utils import flt
from erpnext.controllers.item_variant import (
InvalidItemAttributeValueError,
update_variant_attribute_values,
update_variant_item_codes_for_abbr_renames,
validate_is_incremental,
validate_item_attribute_value,
)
@@ -46,6 +47,7 @@ class ItemAttribute(Document):
def on_update(self):
update_variant_attribute_values(self)
update_variant_item_codes_for_abbr_renames(self)
self.validate_exising_items()
self.set_enabled_disabled_in_items()

View File

@@ -717,7 +717,7 @@ def make_supplier_quotation(source_name, target_doc=None):
@frappe.whitelist()
def make_stock_entry(source_name, target_doc=None):
def make_stock_entry(source_name: str, target_doc: str | dict | None = None):
def update_item(obj, target, source_parent):
qty = (
flt(flt(obj.stock_qty) - flt(obj.ordered_qty)) / target.conversion_factor
@@ -753,6 +753,9 @@ def make_stock_entry(source_name, target_doc=None):
if source.job_card:
target.purpose = "Material Transfer for Manufacture"
if source.work_order:
target.purpose = "Material Transfer for Manufacture"
if source.material_request_type == "Customer Provided":
target.purpose = "Material Receipt"
@@ -772,6 +775,18 @@ def make_stock_entry(source_name, target_doc=None):
target.fg_completed_qty = job_card_details[0].for_quantity
target.from_bom = 1
if source.work_order:
work_order_details = frappe.db.get_value(
"Work Order", source.work_order, ["bom_no", "use_multi_level_bom"], as_dict=True
)
if work_order_details:
target.bom_no = work_order_details.bom_no
target.use_multi_level_bom = work_order_details.use_multi_level_bom
target.from_bom = 1
# not fg-qty-driven, mirrors the Pick List -> Stock Entry transfer for this Work Order
target.fg_completed_qty = 0
doclist = get_mapped_doc(
"Material Request",
source_name,

View File

@@ -190,7 +190,7 @@
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
"options": "Draft\nOpen\nPartly Delivered\nCompleted\nCancelled",
"options": "Draft\nOpen\nPartly Delivered\nPartially Transferred\nCompleted\nCancelled",
"print_hide": 1,
"read_only": 1,
"report_hide": 1,
@@ -278,7 +278,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2026-02-06 18:14:18.361039",
"modified": "2026-07-06 18:17:18.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List",

View File

@@ -73,7 +73,9 @@ class PickList(TransactionBase):
purpose: DF.Literal["Material Transfer for Manufacture", "Material Transfer", "Delivery"]
scan_barcode: DF.Data | None
scan_mode: DF.Check
status: DF.Literal["Draft", "Open", "Partly Delivered", "Completed", "Cancelled"]
status: DF.Literal[
"Draft", "Open", "Partly Delivered", "Partially Transferred", "Completed", "Cancelled"
]
work_order: DF.Link | None
# end: auto-generated types
@@ -419,6 +421,34 @@ class PickList(TransactionBase):
return stock_entry_exists(self.name)
def get_transfer_status(self):
"""Return the pick list's transfer progress based on how much of the picked qty has been
moved into submitted Stock Entries (tracked on Pick List Item.transferred_qty).
Only applies to purposes that move stock via Stock Entry; the Delivery purpose is tracked
via delivery_status instead. Returns "Completed", "Partially Transferred" or None."""
if self.purpose == "Delivery":
return None
total_picked = sum(flt(row.picked_qty) for row in self.locations)
if not total_picked:
return None
total_transferred = sum(flt(row.transferred_qty) for row in self.locations)
if total_transferred <= 0:
return None
if total_transferred >= total_picked:
return "Completed"
return "Partially Transferred"
def is_fully_transferred(self):
return self.get_transfer_status() == "Completed"
def is_partially_transferred(self):
return self.get_transfer_status() == "Partially Transferred"
def update_reference_qty(self):
packed_items = []
so_items = []
@@ -1481,6 +1511,9 @@ def map_pl_locations(pick_list, item_mapper, target_doc, sales_order=None):
if location.sales_order != sales_order or location.product_bundle_item:
continue
if flt(location.picked_qty) - flt(location.delivered_qty) <= 0:
continue
if location.sales_order_item:
sales_order_item = frappe.get_doc("Sales Order Item", location.sales_order_item)
else:
@@ -1544,26 +1577,33 @@ def add_product_bundles_to_target(pick_list, target_doc, item_mapper, sales_orde
@frappe.whitelist()
def create_stock_entry(pick_list):
pick_list = frappe.get_doc(json.loads(pick_list))
def create_stock_entry(pick_list: str | dict):
pick_list = frappe.get_doc(frappe.parse_json(pick_list))
validate_item_locations(pick_list)
if stock_entry_exists(pick_list.get("name")):
return frappe.msgprint(_("Stock Entry has been already created against this Pick List"))
stock_entry = frappe.new_doc("Stock Entry")
stock_entry.pick_list = pick_list.get("name")
stock_entry.purpose = pick_list.get("purpose")
stock_entry.company = pick_list.get("company")
stock_entry.set_stock_entry_type()
if pick_list.get("work_order"):
job_card = pick_list.get("material_request") and frappe.db.get_value(
"Material Request", pick_list.get("material_request"), "job_card"
)
if job_card:
stock_entry = update_stock_entry_based_on_job_card(pick_list, stock_entry, job_card)
elif pick_list.get("work_order"):
stock_entry = update_stock_entry_based_on_work_order(pick_list, stock_entry)
elif pick_list.get("material_request"):
stock_entry = update_stock_entry_based_on_material_request(pick_list, stock_entry)
else:
stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry)
stock_entry.set_stock_entry_type()
if not stock_entry.get("items"):
return frappe.msgprint(_("All picked items have already been transferred against this Pick List"))
stock_entry.set_missing_values()
return stock_entry.as_dict()
@@ -1651,9 +1691,67 @@ def stock_entry_exists(pick_list_name):
return frappe.db.exists("Stock Entry", {"pick_list": pick_list_name})
def update_stock_entry_based_on_job_card(pick_list, stock_entry, job_card):
job_card = frappe.db.get_value(
"Job Card",
job_card,
[
"name",
"work_order",
"bom_no",
"semi_fg_bom",
"for_quantity",
"transferred_qty",
"wip_warehouse",
"project",
],
as_dict=True,
)
stock_entry.purpose = "Material Transfer for Manufacture"
stock_entry.job_card = job_card.name
stock_entry.work_order = job_card.work_order
stock_entry.from_bom = 1
stock_entry.bom_no = job_card.semi_fg_bom or job_card.bom_no
stock_entry.fg_completed_qty = max(flt(job_card.for_quantity) - flt(job_card.transferred_qty), 0)
stock_entry.to_warehouse = job_card.wip_warehouse
stock_entry.project = job_card.project
job_card_items = get_job_card_items_by_material_request_item(pick_list)
for location in pick_list.locations:
if get_pending_transfer_stock_qty(location) <= 0:
continue
item = frappe._dict()
update_common_item_properties(item, location)
item.t_warehouse = job_card.wip_warehouse
item.job_card_item = job_card_items.get(location.material_request_item)
stock_entry.append("items", item)
return stock_entry
def get_job_card_items_by_material_request_item(pick_list):
material_request_items = [
location.material_request_item for location in pick_list.locations if location.material_request_item
]
if not material_request_items:
return {}
return dict(
frappe.get_all(
"Material Request Item",
filters={"name": ["in", material_request_items]},
fields=["name", "job_card_item"],
as_list=True,
)
)
def update_stock_entry_based_on_work_order(pick_list, stock_entry):
work_order = frappe.get_doc("Work Order", pick_list.get("work_order"))
stock_entry.purpose = "Material Transfer for Manufacture"
stock_entry.work_order = work_order.name
stock_entry.company = work_order.company
stock_entry.from_bom = 1
@@ -1673,6 +1771,8 @@ def update_stock_entry_based_on_work_order(pick_list, stock_entry):
stock_entry.project = work_order.project
for location in pick_list.locations:
if get_pending_transfer_stock_qty(location) <= 0:
continue
item = frappe._dict()
update_common_item_properties(item, location)
item.t_warehouse = wip_warehouse
@@ -1684,6 +1784,8 @@ def update_stock_entry_based_on_work_order(pick_list, stock_entry):
def update_stock_entry_based_on_material_request(pick_list, stock_entry):
for location in pick_list.locations:
if get_pending_transfer_stock_qty(location) <= 0:
continue
target_warehouse = None
if location.material_request_item:
target_warehouse = frappe.get_value(
@@ -1699,6 +1801,8 @@ def update_stock_entry_based_on_material_request(pick_list, stock_entry):
def update_stock_entry_items_with_no_reference(pick_list, stock_entry):
for location in pick_list.locations:
if get_pending_transfer_stock_qty(location) <= 0:
continue
item = frappe._dict()
update_common_item_properties(item, location)
@@ -1707,11 +1811,18 @@ def update_stock_entry_items_with_no_reference(pick_list, stock_entry):
return stock_entry
def get_pending_transfer_stock_qty(location):
"""Stock qty of this pick list row still to be moved into a Stock Entry."""
return flt(location.picked_qty) - flt(location.transferred_qty)
def update_common_item_properties(item, location):
pending_stock_qty = get_pending_transfer_stock_qty(location)
item.item_code = location.item_code
item.item_name = location.item_name
item.s_warehouse = location.warehouse
item.transfer_qty = location.picked_qty
item.qty = flt(location.picked_qty / (location.conversion_factor or 1), location.precision("qty"))
item.transfer_qty = pending_stock_qty
item.qty = flt(pending_stock_qty / (location.conversion_factor or 1), location.precision("qty"))
item.uom = location.uom
item.conversion_factor = location.conversion_factor
item.stock_uom = location.stock_uom
@@ -1719,6 +1830,7 @@ def update_common_item_properties(item, location):
item.serial_no = location.serial_no
item.batch_no = location.batch_no
item.material_request_item = location.material_request_item
item.pick_list_item = location.name
def get_rejected_warehouses():

View File

@@ -7,6 +7,7 @@ frappe.listview_settings["Pick List"] = {
Draft: "red",
Open: "orange",
"Partly Delivered": "orange",
"Partially Transferred": "yellow",
Completed: "green",
Cancelled: "red",
};

View File

@@ -13,6 +13,7 @@ from erpnext.stock.doctype.pick_list.pick_list import (
create_delivery,
create_delivery_note,
create_dn_for_pick_lists,
create_stock_entry,
)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
@@ -1221,6 +1222,103 @@ class TestPickList(ERPNextTestSuite):
pl.reload()
self.assertEqual(pl.status, "Cancelled")
def test_pick_list_partial_transfer_status(self):
"""Partial Stock Entries from a Pick List should track transferred_qty and drive the
Partially Transferred / Completed status, and allow further transfers for the remainder."""
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
item = make_item(properties={"is_stock_item": 1}).name
source_warehouse = "_Test Warehouse - _TC"
target_warehouse = create_warehouse("_Test Transfer Target Warehouse")
make_stock_entry(item=item, to_warehouse=source_warehouse, qty=10)
pick_list = frappe.get_doc(
{
"doctype": "Pick List",
"company": "_Test Company",
"purpose": "Material Transfer",
"pick_manually": 1,
"locations": [
{
"item_code": item,
"qty": 10,
"stock_qty": 10,
"conversion_factor": 1,
"warehouse": source_warehouse,
"picked_qty": 10,
}
],
}
)
pick_list.submit()
self.assertEqual(pick_list.status, "Open")
# Transfer 4 of the 10 picked units.
se1 = frappe.get_doc(create_stock_entry(pick_list.as_dict()))
self.assertEqual(se1.items[0].qty, 10)
se1.items[0].qty = 4
se1.items[0].t_warehouse = target_warehouse
se1.submit()
pick_list.reload()
self.assertEqual(pick_list.locations[0].transferred_qty, 4)
self.assertEqual(pick_list.status, "Partially Transferred")
# The next Stock Entry should only offer the remaining 6 units.
se2 = frappe.get_doc(create_stock_entry(pick_list.as_dict()))
self.assertEqual(se2.items[0].qty, 6)
se2.items[0].t_warehouse = target_warehouse
se2.submit()
pick_list.reload()
self.assertEqual(pick_list.locations[0].transferred_qty, 10)
self.assertEqual(pick_list.status, "Completed")
# Cancelling the last entry rolls transferred_qty and status back.
se2.cancel()
pick_list.reload()
self.assertEqual(pick_list.locations[0].transferred_qty, 4)
self.assertEqual(pick_list.status, "Partially Transferred")
def test_create_second_delivery_note_with_fully_delivered_location(self):
# When one pick list item is fully delivered by the first Delivery Note
# and another item is still pending, creating a second Delivery Note from
# the Pick List must not create a zero-qty row for the delivered item.
warehouse = "_Test Warehouse - _TC"
item_a = make_item(properties={"is_stock_item": 1}).name
item_b = make_item(properties={"is_stock_item": 1}).name
make_stock_entry(item=item_a, to_warehouse=warehouse, qty=20)
make_stock_entry(item=item_b, to_warehouse=warehouse, qty=20)
so = make_sales_order(
item_list=[
{"item_code": item_a, "warehouse": warehouse, "qty": 10, "rate": 100},
{"item_code": item_b, "warehouse": warehouse, "qty": 5, "rate": 100},
]
)
pl = create_pick_list(so.name)
pl.save().submit()
# First Delivery Note: fully deliver item_a, drop item_b.
dn1 = create_delivery_note(pl.name)
for row in list(dn1.items):
if row.item_code == item_b:
dn1.remove(row)
dn1.save().submit()
pl.reload()
delivered = {loc.item_code: loc.delivered_qty for loc in pl.locations}
self.assertEqual(delivered[item_a], 10)
self.assertEqual(delivered[item_b], 0)
# Second Delivery Note for the remaining item must succeed and must not
# include a zero-qty row for the already delivered item_a.
dn2 = create_delivery_note(pl.name)
self.assertEqual(len(dn2.items), 1)
self.assertEqual(dn2.items[0].item_code, item_b)
self.assertEqual(dn2.items[0].qty, 5)
def test_pick_list_validation(self):
warehouse = "_Test Warehouse - _TC"
item = make_item("Test Non Serialized Pick List Item", properties={"is_stock_item": 1}).name

View File

@@ -22,6 +22,7 @@
"conversion_factor",
"stock_uom",
"delivered_qty",
"transferred_qty",
"available_quantity_section",
"actual_qty",
"column_break_kyek",
@@ -255,6 +256,16 @@
"read_only": 1,
"report_hide": 1
},
{
"default": "0",
"fieldname": "transferred_qty",
"fieldtype": "Float",
"label": "Transferred Qty (in Stock UOM)",
"no_copy": 1,
"print_hide": 1,
"read_only": 1,
"report_hide": 1
},
{
"fieldname": "available_quantity_section",
"fieldtype": "Section Break",
@@ -285,7 +296,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-03-17 16:25:10.358013",
"modified": "2026-07-06 18:17:18.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List Item",

View File

@@ -39,6 +39,7 @@ class PickListItem(Document):
stock_qty: DF.Float
stock_reserved_qty: DF.Float
stock_uom: DF.Link | None
transferred_qty: DF.Float
uom: DF.Link | None
use_serial_batch_fields: DF.Check
warehouse: DF.Link | None

View File

@@ -27,6 +27,7 @@ from frappe.utils import (
)
from frappe.utils.csvutils import build_csv_response
from erpnext.stock.doctype.purchase_receipt_item.purchase_receipt_item import PurchaseReceiptItem
from erpnext.stock.serial_batch_bundle import (
BatchNoValuation,
SerialNoValuation,
@@ -2152,9 +2153,14 @@ def get_reference_serial_and_batch_bundle(child_row):
@frappe.whitelist()
def add_serial_batch_ledgers(entries, child_row, doc, warehouse, do_not_save=False) -> object:
if isinstance(child_row, str):
child_row = frappe._dict(parse_json(child_row))
def add_serial_batch_ledgers(
entries: list | str,
child_row: PurchaseReceiptItem | dict | str,
doc: Document | dict | str,
warehouse: str | None = None,
do_not_save: bool = False,
):
child_row = parse_json(child_row)
if isinstance(entries, str):
entries = parse_json(entries)
@@ -2186,7 +2192,9 @@ def create_serial_batch_no_ledgers(
if parent_doc.get("doctype") == "Stock Entry":
warehouse = warehouse or child_row.s_warehouse or child_row.t_warehouse
posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time"))
posting_datetime = combine_datetime(
parent_doc.get("posting_date") or today(), parent_doc.get("posting_time") or nowtime()
)
doc = frappe.get_doc(
{
@@ -2303,7 +2311,9 @@ def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, wareh
)
doc.voucher_detail_no = child_row.name
doc.posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time"))
doc.posting_datetime = combine_datetime(
parent_doc.get("posting_date") or today(), parent_doc.get("posting_time") or nowtime()
)
doc.warehouse = warehouse or doc.warehouse
doc.set("entries", [])

View File

@@ -178,6 +178,15 @@ class StockEntry(StockController, SubcontractingInwardController):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.status_updater = [
{
"source_dt": "Stock Entry Detail",
"target_dt": "Pick List Item",
"join_field": "pick_list_item",
"target_field": "transferred_qty",
"source_field": "transfer_qty",
}
]
if self.purchase_order:
self.subcontract_data = frappe._dict(
{
@@ -571,6 +580,7 @@ class StockEntry(StockController, SubcontractingInwardController):
self.validate_closed_subcontracting_order()
self.update_subcontract_order_supplied_items()
self.update_subcontracting_order_status()
self.update_pick_list_status()
self.cancel_stock_reserve_for_wip_and_fg()
if self.work_order and self.purpose == "Material Consumption for Manufacture":
@@ -4054,6 +4064,9 @@ class StockEntry(StockController, SubcontractingInwardController):
def update_pick_list_status(self):
from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status
if self.pick_list:
self.update_qty()
update_pick_list_status(self.pick_list)
def set_missing_values(self):

View File

@@ -72,6 +72,7 @@
"col_break6",
"material_request",
"material_request_item",
"pick_list_item",
"original_item",
"reference_section",
"against_stock_entry",
@@ -423,6 +424,16 @@
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "pick_list_item",
"fieldtype": "Link",
"hidden": 1,
"label": "Pick List Item",
"no_copy": 1,
"options": "Pick List Item",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "original_item",
"fieldtype": "Link",
@@ -678,7 +689,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-03 12:11:53.714931",
"modified": "2026-07-06 18:17:18.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry Detail",

View File

@@ -47,6 +47,7 @@ class StockEntryDetail(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
pick_list_item: DF.Link | None
po_detail: DF.Data | None
project: DF.Link | None
putaway_rule: DF.Link | None

View File

@@ -1615,6 +1615,12 @@ def apply_price_list(ctx, as_doc=False, doc=None):
def apply_price_list_on_item(ctx, doc=None):
item_doc = frappe.get_cached_doc("Item", ctx.item_code)
item_details = get_price_list_rate(ctx, item_doc)
ctx.conversion_factor = flt(ctx.conversion_factor) or get_conversion_factor(ctx.item_code, ctx.uom).get(
"conversion_factor", 1
)
ctx.stock_qty = flt(ctx.qty) * flt(ctx.conversion_factor)
item_details.update(get_pricing_rule_for_item(ctx, doc=doc))
return item_details

View File

@@ -190,6 +190,10 @@ def get_item_warehouse_projected_qty(items_to_consider):
item_warehouse_projected_qty = {}
items_to_consider = list(items_to_consider.keys())
warehouse_parent_map = frappe._dict(
frappe.get_all("Warehouse", fields=["name", "parent_warehouse"], as_list=True)
)
for item_code, warehouse, projected_qty in frappe.db.sql(
"""select item_code, warehouse, projected_qty
from tabBin where item_code in ({})
@@ -204,16 +208,14 @@ def get_item_warehouse_projected_qty(items_to_consider):
if warehouse not in item_warehouse_projected_qty.get(item_code):
item_warehouse_projected_qty[item_code][warehouse] = flt(projected_qty)
warehouse_doc = frappe.get_doc("Warehouse", warehouse)
parent_warehouse = warehouse_parent_map.get(warehouse)
while warehouse_doc.parent_warehouse:
if not item_warehouse_projected_qty.get(item_code, {}).get(warehouse_doc.parent_warehouse):
item_warehouse_projected_qty.setdefault(item_code, {})[warehouse_doc.parent_warehouse] = flt(
projected_qty
)
while parent_warehouse:
if not item_warehouse_projected_qty.get(item_code, {}).get(parent_warehouse):
item_warehouse_projected_qty.setdefault(item_code, {})[parent_warehouse] = flt(projected_qty)
else:
item_warehouse_projected_qty[item_code][warehouse_doc.parent_warehouse] += flt(projected_qty)
warehouse_doc = frappe.get_doc("Warehouse", warehouse_doc.parent_warehouse)
item_warehouse_projected_qty[item_code][parent_warehouse] += flt(projected_qty)
parent_warehouse = warehouse_parent_map.get(parent_warehouse)
return item_warehouse_projected_qty

View File

@@ -174,14 +174,20 @@ def get_columns(filters):
@frappe.whitelist()
def create_reposting_entries(rows, company):
def create_reposting_entries(rows: str | list, company: str):
if isinstance(rows, str):
rows = parse_json(rows)
entries = []
item_wh = frappe._dict()
vouchers = [row.get("voucher_no") for row in rows]
vouchers = [
row.get("voucher_no")
for row in rows
if row.get("voucher_type") not in ["Purchase Receipt", "Purchase Invoice"]
]
repost_based_on_transaction(rows, company, entries)
sles = get_stock_ledgers(vouchers)
for sle in sles:
key = (sle.item_code, sle.warehouse)
@@ -214,3 +220,39 @@ def create_reposting_entries(rows, company):
if entries:
entries = ", ".join(entries)
frappe.msgprint(_("Reposting entries created: {0}").format(entries))
def repost_based_on_transaction(rows, company=None, entries=None):
if entries is None:
entries = []
duplicate_vouchers = set()
for row in rows:
if (
row.get("voucher_type") == "Purchase Invoice"
and frappe.get_cached_value("Purchase Invoice", row.get("voucher_no"), "update_stock") == 0
):
continue
if row.get("voucher_type") in ["Purchase Receipt", "Purchase Invoice"]:
voucher_key = (row.get("voucher_type"), row.get("voucher_no"))
if voucher_key in duplicate_vouchers:
continue
duplicate_vouchers.add(voucher_key)
doc = frappe.get_doc(
{
"doctype": "Repost Item Valuation",
"based_on": "Transaction",
"status": "Queued",
"voucher_type": row.get("voucher_type"),
"voucher_no": row.get("voucher_no"),
"posting_date": row.get("posting_date"),
"posting_time": row.get("posting_time"),
"company": company,
"allow_nagative_stock": 1,
"recalculate_valuation_rate": 1,
}
).submit()
entries.append(get_link_to_form("Repost Item Valuation", doc.name))

View File

@@ -0,0 +1,57 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.utils import today
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import (
create_reposting_entries,
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
PI_COMPANY = "_Test Company with perpetual inventory"
PI_STORES = "Stores - TCP1"
class TestStockAndAccountValueComparison(ERPNextTestSuite):
def test_purchase_voucher_reposted_transaction_based(self):
# A Purchase Receipt whose GL entries are missing must surface in the report and, when reposted
# from it, be reposted Transaction-based (so its own GL is regenerated) rather than the slower
# Item-and-Warehouse based reposting.
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
pr = make_purchase_receipt(item_code=item, company=PI_COMPANY, warehouse=PI_STORES, qty=5, rate=100)
# Simulate the out-of-sync state: stock ledger exists but the accounting ledger does not.
frappe.db.delete("GL Entry", {"voucher_type": "Purchase Receipt", "voucher_no": pr.name})
# The receipt now shows up in the comparison report (stock value 500 vs account value 0).
filters = frappe._dict(company=PI_COMPANY, as_on_date=today())
_columns, data = execute(filters)
row = next((d for d in data if d.get("voucher_no") == pr.name), None)
self.assertIsNotNone(row, "Out-of-sync Purchase Receipt should appear in the report")
self.assertEqual(row.get("voucher_type"), "Purchase Receipt")
# Repost from the report.
create_reposting_entries([row], PI_COMPANY)
# A Transaction-based Repost Item Valuation must have been created for this voucher...
transaction_rivs = frappe.get_all(
"Repost Item Valuation",
filters={"voucher_no": pr.name, "voucher_type": "Purchase Receipt"},
fields=["name", "based_on"],
)
self.assertTrue(transaction_rivs, "Expected a Repost Item Valuation for the Purchase Receipt")
self.assertTrue(all(riv.based_on == "Transaction" for riv in transaction_rivs))
# ...and no Item-and-Warehouse based reposting should have been created for this item.
item_wh_rivs = frappe.get_all(
"Repost Item Valuation",
filters={"based_on": "Item and Warehouse", "item_code": item},
)
self.assertFalse(item_wh_rivs, "Purchase vouchers must not be reposted Item-and-Warehouse based")

View File

@@ -1093,7 +1093,11 @@ class update_entries_after:
self.wh_data.stock_queue = json.loads(stock_queue[0]) if stock_queue else []
self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + doc.total_amount)
self.wh_data.qty_after_transaction += flt(doc.total_qty, self.flt_precision)
# Replay the immutable qty recorded on the SLE at submission, not the bundle's recomputed
# total_qty. A valuation repost must never rewrite physical quantities; if the bundle's child
# rows were edited after submission, doc.total_qty would silently corrupt qty_after_transaction
# (and every downstream balance). sle.actual_qty is the frozen movement for this entry.
self.wh_data.qty_after_transaction += flt(sle.actual_qty, self.flt_precision)
if flt(self.wh_data.qty_after_transaction, self.flt_precision):
self.wh_data.valuation_rate = flt(self.wh_data.stock_value, self.flt_precision) / flt(
self.wh_data.qty_after_transaction, self.flt_precision