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

chore: release v15
This commit is contained in:
ruthra kumar
2025-07-15 18:20:37 +05:30
committed by GitHub
49 changed files with 525 additions and 107 deletions

View File

@@ -252,6 +252,10 @@ frappe.treeview_settings["Account"] = {
root_company,
]);
} else {
const node = treeview.tree.get_selected_node();
if (node.is_root) {
frappe.throw(__("Cannot create root account."));
}
treeview.new_node();
}
},
@@ -270,7 +274,8 @@ frappe.treeview_settings["Account"] = {
].treeview.page.fields_dict.root_company.get_value() ||
frappe.flags.ignore_root_company_validation) &&
node.expandable &&
!node.hide_add
!node.hide_add &&
!node.is_root
);
},
click: function () {

View File

@@ -26,9 +26,20 @@ frappe.ui.form.on("Accounts Settings", {
add_taxes_from_taxes_and_charges_template(frm) {
toggle_tax_settings(frm, "add_taxes_from_taxes_and_charges_template");
},
add_taxes_from_item_tax_template(frm) {
toggle_tax_settings(frm, "add_taxes_from_item_tax_template");
},
drop_ar_procedures: function (frm) {
frm.call({
doc: frm.doc,
method: "drop_ar_sql_procedures",
callback: function (r) {
frappe.show_alert(__("Procedures dropped"), 5);
},
});
},
});
function toggle_tax_settings(frm, field_name) {

View File

@@ -88,6 +88,8 @@
"receivable_payable_remarks_length",
"accounts_receivable_payable_tuning_section",
"receivable_payable_fetch_method",
"column_break_ntmi",
"drop_ar_procedures",
"legacy_section",
"ignore_is_opening_check_for_reporting",
"payment_request_settings",
@@ -552,7 +554,7 @@
"fieldname": "receivable_payable_fetch_method",
"fieldtype": "Select",
"label": "Data Fetch Method",
"options": "Buffered Cursor\nUnBuffered Cursor"
"options": "Buffered Cursor\nUnBuffered Cursor\nRaw SQL"
},
{
"fieldname": "accounts_receivable_payable_tuning_section",
@@ -609,6 +611,17 @@
"fieldname": "add_taxes_from_taxes_and_charges_template",
"fieldtype": "Check",
"label": "Automatically Add Taxes from Taxes and Charges Template"
},
{
"fieldname": "column_break_ntmi",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:doc.receivable_payable_fetch_method == \"Raw SQL\"",
"description": "Drops existing SQL Procedures and Function setup by Accounts Receivable report",
"fieldname": "drop_ar_procedures",
"fieldtype": "Button",
"label": "Drop Procedures"
}
],
"icon": "icon-cog",

View File

@@ -58,7 +58,7 @@ class AccountsSettings(Document):
merge_similar_account_heads: DF.Check
over_billing_allowance: DF.Currency
post_change_gl_entries: DF.Check
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor", "Raw SQL"]
receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int
role_allowed_to_over_bill: DF.Link | None
@@ -152,3 +152,11 @@ class AccountsSettings(Document):
),
title=_("Auto Tax Settings Error"),
)
@frappe.whitelist()
def drop_ar_sql_procedures(self):
from erpnext.accounts.report.accounts_receivable.accounts_receivable import InitSQLProceduresForAR
frappe.db.sql(f"drop function if exists {InitSQLProceduresForAR.genkey_function_name}")
frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.init_procedure_name}")
frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.allocate_procedure_name}")

View File

@@ -1071,10 +1071,3 @@ def create_pos_invoice(**args):
pos_inv.payment_schedule = []
return pos_inv
def make_batch_item(item_name):
from erpnext.stock.doctype.item.test_item import make_item
if not frappe.db.exists(item_name):
return make_item(item_name, dict(has_batch_no=1, create_new_batch=1, is_stock_item=1))

View File

@@ -461,6 +461,7 @@ class SalesInvoice(SellingController):
self.make_bundle_for_sales_purchase_return(table_name)
self.make_bundle_using_old_serial_batch_fields(table_name)
self.validate_standalone_serial_nos_customer()
self.update_stock_reservation_entries()
self.update_stock_ledger()

View File

@@ -3055,6 +3055,28 @@ class TestSalesInvoice(FrappeTestCase):
check_gl_entries(self, si.name, expected_gle, add_days(nowdate(), -1))
# cases where distributed discount amount is not set
frappe.db.set_value(
"Sales Invoice Item",
{"name": ["in", [d.name for d in si.items]]},
"distributed_discount_amount",
0,
)
si.load_from_db()
si.additional_discount_account = additional_discount_account
# Ledger reposted implicitly upon 'Update After Submit'
si.save()
expected_gle = [
["Debtors - _TC", 88, 0.0, nowdate()],
["Discount Account - _TC", 22.0, 0.0, nowdate()],
["Service - _TC", 0.0, 100.0, nowdate()],
["TDS Payable - _TC", 0.0, 10.0, nowdate()],
]
check_gl_entries(self, si.name, expected_gle, add_days(nowdate(), -1))
def test_asset_depreciation_on_sale_with_pro_rata(self):
"""
Tests if an Asset set to depreciate yearly on June 30, that gets sold on Sept 30, creates an additional depreciation entry on its date of sale.

View File

@@ -23,6 +23,13 @@ frappe.query_reports["Accounts Payable Summary"] = {
options: "Posting Date\nDue Date",
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldtype: "Select",
options: "Report Date\nToday Date",
default: "Report Date",
},
{
fieldname: "range",
label: __("Ageing Range"),

View File

@@ -6,7 +6,7 @@ from collections import OrderedDict
import frappe
from frappe import _, qb, query_builder, scrub
from frappe.desk.reportview import build_match_conditions
from frappe.database.schema import get_definition
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Date, Substring, Sum
from frappe.utils import cint, cstr, flt, getdate, nowdate
@@ -16,6 +16,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_dimension_with_children,
)
from erpnext.accounts.utils import (
build_qb_match_conditions,
get_advance_payment_doctypes,
get_currency_precision,
get_party_types_from_account_type,
@@ -127,6 +128,8 @@ class ReceivablePayableReport:
self.fetch_ple_in_buffered_cursor()
elif self.ple_fetch_method == "UnBuffered Cursor":
self.fetch_ple_in_unbuffered_cursor()
elif self.ple_fetch_method == "Raw SQL":
self.fetch_ple_in_sql_procedures()
# Build delivery note map against all sales invoices
self.build_delivery_note_map()
@@ -134,8 +137,7 @@ class ReceivablePayableReport:
self.build_data()
def fetch_ple_in_buffered_cursor(self):
query, param = self.ple_query
self.ple_entries = frappe.db.sql(query, param, as_dict=True)
self.ple_entries = self.ple_query.run(as_dict=True)
for ple in self.ple_entries:
self.init_voucher_balance(ple) # invoiced, paid, credit_note, outstanding
@@ -148,9 +150,8 @@ class ReceivablePayableReport:
def fetch_ple_in_unbuffered_cursor(self):
self.ple_entries = []
query, param = self.ple_query
with frappe.db.unbuffered_cursor():
for ple in frappe.db.sql(query, param, as_dict=True, as_iterator=True):
for ple in self.ple_query.run(as_dict=True, as_iterator=True):
self.init_voucher_balance(ple) # invoiced, paid, credit_note, outstanding
self.ple_entries.append(ple)
@@ -318,6 +319,79 @@ class ReceivablePayableReport:
row.paid -= amount
row.paid_in_account_currency -= amount_in_account_currency
def fetch_ple_in_sql_procedures(self):
self.proc = InitSQLProceduresForAR()
build_balance = f"""
begin not atomic
declare done boolean default false;
declare rec1 row type of `{self.proc._row_def_table_name}`;
declare ple cursor for {self.ple_query.get_sql()};
declare continue handler for not found set done = true;
open ple;
fetch ple into rec1;
while not done do
call {self.proc.init_procedure_name}(rec1);
fetch ple into rec1;
end while;
close ple;
set done = false;
open ple;
fetch ple into rec1;
while not done do
call {self.proc.allocate_procedure_name}(rec1);
fetch ple into rec1;
end while;
close ple;
end;
"""
frappe.db.sql(build_balance)
balances = frappe.db.sql(
f"""select
name,
voucher_type,
voucher_no,
party,
party_account `account`,
posting_date,
account_currency,
cost_center,
sum(invoiced) `invoiced`,
sum(paid) `paid`,
sum(credit_note) `credit_note`,
sum(invoiced) - sum(paid) - sum(credit_note) `outstanding`,
sum(invoiced_in_account_currency) `invoiced_in_account_currency`,
sum(paid_in_account_currency) `paid_in_account_currency`,
sum(credit_note_in_account_currency) `credit_note_in_account_currency`,
sum(invoiced_in_account_currency) - sum(paid_in_account_currency) - sum(credit_note_in_account_currency) `outstanding_in_account_currency`
from `{self.proc._voucher_balance_name}` group by name order by posting_date;""",
as_dict=True,
)
for x in balances:
if self.filters.get("ignore_accounts"):
key = (x.voucher_type, x.voucher_no, x.party)
else:
key = (x.account, x.voucher_type, x.voucher_no, x.party)
_d = self.build_voucher_dict(x)
for field in [
"invoiced",
"paid",
"credit_note",
"outstanding",
"invoiced_in_account_currency",
"paid_in_account_currency",
"credit_note_in_account_currency",
"outstanding_in_account_currency",
"cost_center",
]:
_d[field] = x.get(field)
self.voucher_balance[key] = _d
def update_sub_total_row(self, row, party):
total_row = self.total_row_map.get(party)
@@ -861,18 +935,15 @@ class ReceivablePayableReport:
else:
query = query.select(ple.remarks)
query, param = query.walk()
match_conditions = build_match_conditions("Payment Ledger Entry")
if match_conditions:
query += " AND " + match_conditions
if match_conditions := build_qb_match_conditions("Payment Ledger Entry"):
query = query.where(Criterion.all(match_conditions))
if self.filters.get("group_by_party"):
query += f" ORDER BY `{self.ple.party.name}`, `{self.ple.posting_date.name}`"
query = query.orderby(self.ple.party, self.ple.posting_date)
else:
query += f" ORDER BY `{self.ple.posting_date.name}`, `{self.ple.party.name}`"
query = query.orderby(self.ple.posting_date, self.ple.party)
self.ple_query = (query, param)
self.ple_query = query
def get_sales_invoices_or_customers_based_on_sales_person(self):
if self.filters.get("sales_person"):
@@ -1253,3 +1324,134 @@ def get_customer_group_with_children(customer_groups):
frappe.throw(_("Customer Group: {0} does not exist").format(d))
return list(set(all_customer_groups))
class InitSQLProceduresForAR:
"""
Initialize SQL Procedures, Functions and Temporary tables to build Receivable / Payable report
"""
_varchar_type = get_definition("Data")
_currency_type = get_definition("Currency")
# Temporary Tables
_voucher_balance_name = "_ar_voucher_balance"
_voucher_balance_definition = f"""
create temporary table `{_voucher_balance_name}`(
name {_varchar_type},
voucher_type {_varchar_type},
voucher_no {_varchar_type},
party {_varchar_type},
party_account {_varchar_type},
posting_date date,
account_currency {_varchar_type},
cost_center {_varchar_type},
invoiced {_currency_type},
paid {_currency_type},
credit_note {_currency_type},
invoiced_in_account_currency {_currency_type},
paid_in_account_currency {_currency_type},
credit_note_in_account_currency {_currency_type}) engine=memory;
"""
_row_def_table_name = "_ar_ple_row"
_row_def_table_definition = f"""
create temporary table `{_row_def_table_name}`(
name {_varchar_type},
account {_varchar_type},
voucher_type {_varchar_type},
voucher_no {_varchar_type},
against_voucher_type {_varchar_type},
against_voucher_no {_varchar_type},
party_type {_varchar_type},
cost_center {_varchar_type},
party {_varchar_type},
posting_date date,
due_date date,
account_currency {_varchar_type},
amount {_currency_type},
amount_in_account_currency {_currency_type}) engine=memory;
"""
# Function
genkey_function_name = "ar_genkey"
genkey_function_sql = f"""
create function `{genkey_function_name}`(rec row type of `{_row_def_table_name}`, allocate bool) returns char(40)
begin
if allocate then
return sha1(concat_ws(',', rec.account, rec.against_voucher_type, rec.against_voucher_no, rec.party));
else
return sha1(concat_ws(',', rec.account, rec.voucher_type, rec.voucher_no, rec.party));
end if;
end
"""
# Procedures
init_procedure_name = "ar_init_tmp_table"
init_procedure_sql = f"""
create procedure ar_init_tmp_table(in ple row type of `{_row_def_table_name}`)
begin
if not exists (select name from `{_voucher_balance_name}` where name = `{genkey_function_name}`(ple, false))
then
insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, false), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, 0, 0, 0, 0, 0, 0);
end if;
end;
"""
allocate_procedure_name = "ar_allocate_to_tmp_table"
allocate_procedure_sql = f"""
create procedure ar_allocate_to_tmp_table(in ple row type of `{_row_def_table_name}`)
begin
declare invoiced {_currency_type} default 0;
declare invoiced_in_account_currency {_currency_type} default 0;
declare paid {_currency_type} default 0;
declare paid_in_account_currency {_currency_type} default 0;
declare credit_note {_currency_type} default 0;
declare credit_note_in_account_currency {_currency_type} default 0;
if ple.amount > 0 then
if (ple.voucher_type in ("Journal Entry", "Payment Entry") and (ple.voucher_no != ple.against_voucher_no)) then
set paid = -1 * ple.amount;
set paid_in_account_currency = -1 * ple.amount_in_account_currency;
else
set invoiced = ple.amount;
set invoiced_in_account_currency = ple.amount_in_account_currency;
end if;
else
if ple.voucher_type in ("Sales Invoice", "Purchase Invoice") then
if (ple.voucher_no = ple.against_voucher_no) then
set paid = -1 * ple.amount;
set paid_in_account_currency = -1 * ple.amount_in_account_currency;
else
set credit_note = -1 * ple.amount;
set credit_note_in_account_currency = -1 * ple.amount_in_account_currency;
end if;
else
set paid = -1 * ple.amount;
set paid_in_account_currency = -1 * ple.amount_in_account_currency;
end if;
end if;
insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, true), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0);
end;
"""
def __init__(self):
existing_procedures = frappe.db.get_routines()
if self.genkey_function_name not in existing_procedures:
frappe.db.sql(self.genkey_function_sql)
if self.init_procedure_name not in existing_procedures:
frappe.db.sql(self.init_procedure_sql)
if self.allocate_procedure_name not in existing_procedures:
frappe.db.sql(self.allocate_procedure_sql)
frappe.db.sql(f"drop table if exists `{self._voucher_balance_name}`")
frappe.db.sql(self._voucher_balance_definition)
frappe.db.sql(f"drop table if exists `{self._row_def_table_name}`")
frappe.db.sql(self._row_def_table_definition)

View File

@@ -23,6 +23,13 @@ frappe.query_reports["Accounts Receivable Summary"] = {
options: "Posting Date\nDue Date",
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldtype: "Select",
options: "Report Date\nToday Date",
default: "Report Date",
},
{
fieldname: "range",
label: __("Ageing Range"),

View File

@@ -179,7 +179,7 @@ def accumulate_values_into_parents(accounts, accounts_by_name, dimension_list):
def get_condition(dimension):
conditions = []
conditions.append(f"{frappe.scrub(dimension)} in %(dimensions)s")
conditions.append(f"{frappe.scrub(dimension)} in (%(dimensions)s)")
return " and {}".format(" and ".join(conditions)) if conditions else ""

View File

@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Optional
import frappe
import frappe.defaults
from frappe import _, qb, throw
from frappe.desk.reportview import build_match_conditions
from frappe.model.meta import get_field_precision
from frappe.query_builder import AliasedQuery, Case, Criterion, Table
from frappe.query_builder.functions import Count, Max, Sum
@@ -2347,3 +2348,19 @@ def sync_auto_reconcile_config(auto_reconciliation_job_trigger: int = 15):
"frequency": "Cron",
}
).save()
def build_qb_match_conditions(doctype, user=None) -> list:
match_filters = build_match_conditions(doctype, user, False)
criterion = []
if match_filters:
from frappe import qb
_dt = qb.DocType(doctype)
for filter in match_filters:
for d, names in filter.items():
fieldname = d.lower().replace(" ", "_")
criterion.append(_dt[fieldname].isin(names))
return criterion

View File

@@ -1215,6 +1215,10 @@ def update_existing_asset(asset, remaining_qty, new_asset_name):
opening_accumulated_depreciation = flt(
(asset.opening_accumulated_depreciation * remaining_qty) / asset.asset_quantity
)
value_after_depreciation = flt(
(asset.value_after_depreciation * remaining_qty) / asset.asset_quantity,
asset.precision("gross_purchase_amount"),
)
frappe.db.set_value(
"Asset",
@@ -1222,6 +1226,7 @@ def update_existing_asset(asset, remaining_qty, new_asset_name):
{
"opening_accumulated_depreciation": opening_accumulated_depreciation,
"gross_purchase_amount": remaining_gross_purchase_amount,
"value_after_depreciation": value_after_depreciation,
"asset_quantity": remaining_qty,
},
)
@@ -1283,6 +1288,10 @@ def create_new_asset_after_split(asset, split_qty):
new_asset.opening_accumulated_depreciation = opening_accumulated_depreciation
new_asset.asset_quantity = split_qty
new_asset.split_from = asset.name
new_asset.value_after_depreciation = flt(
(asset.value_after_depreciation * split_qty) / asset.asset_quantity,
asset.precision("gross_purchase_amount"),
)
for row in new_asset.get("finance_books"):
row.value_after_depreciation = flt((row.value_after_depreciation * split_qty) / asset.asset_quantity)

View File

@@ -1946,6 +1946,15 @@ class AccountsController(TransactionBase):
and self.get("discount_amount")
and self.get("additional_discount_account")
):
# cases where distributed_discount_amount is not patched
if not hasattr(self, "__has_distributed_discount_set"):
self.__has_distributed_discount_set = any(
i.distributed_discount_amount for i in self.get("items")
)
if not self.__has_distributed_discount_set:
return item.amount, item.base_amount
amount += item.distributed_discount_amount
base_amount += flt(
item.distributed_discount_amount * self.get("conversion_rate"),

View File

@@ -36,6 +36,17 @@ def validate_return_against(doc):
party_type = "customer" if doc.doctype in ("Sales Invoice", "Delivery Note") else "supplier"
if ref_doc.get(party_type) != doc.get(party_type):
frappe.throw(
_("The {0} {1} does not match with the {0} {2} in the {3} {4}").format(
doc.meta.get_label(party_type),
doc.get(party_type),
ref_doc.get(party_type),
ref_doc.doctype,
ref_doc.name,
)
)
if (
ref_doc.company == doc.company
and ref_doc.get(party_type) == doc.get(party_type)

View File

@@ -57,6 +57,35 @@ class SellingController(StockController):
if self.get(table_field):
self.set_serial_and_batch_bundle(table_field)
def validate_standalone_serial_nos_customer(self):
if not self.is_return or self.return_against:
return
if self.doctype in ["Sales Invoice", "Delivery Note"]:
bundle_ids = [d.serial_and_batch_bundle for d in self.get("items") if d.serial_and_batch_bundle]
if not bundle_ids:
return
serial_nos = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": ("in", bundle_ids)},
pluck="serial_no",
)
if serial_nos := frappe.get_all(
"Serial No",
filters={"name": ("in", serial_nos), "customer": ("is", "set")},
fields=["name", "customer"],
):
for sn in serial_nos:
if sn.customer and sn.customer != self.customer:
frappe.throw(
_(
"Serial No {0} is already assigned to customer {1}. Can only be returned against the customer {1}"
).format(frappe.bold(sn.name), frappe.bold(sn.customer)),
title=_("Serial No Already Assigned"),
)
def set_missing_values(self, for_validate=False):
super().set_missing_values(for_validate)

View File

@@ -464,7 +464,7 @@ class SubcontractingController(StockController):
i += 1
def __remove_serial_and_batch_bundle(self, item):
if item.serial_and_batch_bundle:
if item.get("serial_and_batch_bundle"):
frappe.delete_doc("Serial and Batch Bundle", item.serial_and_batch_bundle, force=True)
def __get_materials_from_bom(self, item_code, bom_no, exploded_item=0):
@@ -953,7 +953,7 @@ class SubcontractingController(StockController):
)
sco_doc.update_ordered_qty_for_subcontracting(sco_item_rows)
sco_doc.update_reserved_qty_for_subcontracting()
sco_doc.update_reserved_qty_for_subcontracting(sco_item_rows)
def make_sl_entries_for_supplier_warehouse(self, sl_entries):
if hasattr(self, "supplied_items"):
@@ -1046,7 +1046,7 @@ class SubcontractingController(StockController):
return supplied_items_cost
def set_subcontracting_order_status(self):
def set_subcontracting_order_status(self, update_bin=True):
if self.doctype == "Subcontracting Order":
self.update_status()
elif self.doctype == "Subcontracting Receipt":
@@ -1055,7 +1055,7 @@ class SubcontractingController(StockController):
if self.subcontract_orders:
for sco in set(self.subcontract_orders):
sco_doc = frappe.get_doc("Subcontracting Order", sco)
sco_doc.update_status()
sco_doc.update_status(update_bin=update_bin)
def calculate_additional_costs(self):
self.total_additional_costs = sum(flt(item.amount) for item in self.get("additional_costs"))

View File

@@ -1274,7 +1274,7 @@ def get_children(parent=None, is_root=False, **filters):
bom_items = frappe.get_all(
"BOM Item",
fields=["item_code", "bom_no as value", "stock_qty"],
fields=["item_code", "bom_no as value", "stock_qty", "qty"],
filters=[["parent", "=", frappe.form_dict.parent]],
order_by="idx",
)

View File

@@ -16,7 +16,14 @@ frappe.treeview_settings["BOM"] = {
show_expand_all: false,
get_label: function (node) {
if (node.data.qty) {
return node.data.qty + " x " + node.data.item_code;
const escape = frappe.utils.escape_html;
let label = escape(node.data.item_code);
if (node.data.item_name && node.data.item_code !== node.data.item_name) {
label += `: ${escape(node.data.item_name)}`;
}
return `${label} <span class="badge badge-pill badge-light">${node.data.qty} ${escape(
__(node.data.stock_uom)
)}</span>`;
} else {
return node.data.item_code || node.data.value;
}

View File

@@ -412,3 +412,4 @@ erpnext.patches.v15_0.drop_sle_indexes
erpnext.patches.v15_0.update_pick_list_fields
erpnext.patches.v15_0.update_pegged_currencies
erpnext.patches.v15_0.set_company_on_pos_inv_merge_log
erpnext.patches.v15_0.rename_price_list_to_buying_price_list

View File

@@ -0,0 +1,11 @@
import frappe
from frappe.model.utils.rename_field import rename_field
def execute():
if frappe.db.has_column("Material Request", "price_list"):
rename_field(
"Material Request",
"price_list",
"buying_price_list",
)

View File

@@ -582,7 +582,6 @@ erpnext.buying.get_items_from_product_bundle = function(frm) {
ignore_pricing_rule: frm.doc.ignore_pricing_rule,
doctype: frm.doc.doctype
},
price_list: frm.doc.price_list,
},
freeze: true,
callback: function(r) {

View File

@@ -42,6 +42,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}
item.base_rate_with_margin = item.rate_with_margin * flt(frm.doc.conversion_rate);
cur_frm.cscript.set_gross_profit(item);
cur_frm.cscript.calculate_taxes_and_totals();
cur_frm.cscript.calculate_stock_uom_rate(frm, cdt, cdn);
if (item.item_code && item.rate) {
frappe.call({
method: "erpnext.stock.get_item_details.get_item_tax_template",
@@ -63,10 +67,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}
});
}
cur_frm.cscript.set_gross_profit(item);
cur_frm.cscript.calculate_taxes_and_totals();
cur_frm.cscript.calculate_stock_uom_rate(frm, cdt, cdn);
});
frappe.ui.form.on(this.frm.cscript.tax_table, "rate", function(frm, cdt, cdn) {
@@ -989,7 +989,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}
var party = me.frm.doc[frappe.model.scrub(party_type)];
if(party && me.frm.doc.company && (!me.frm.doc.__onload?.load_after_mapping || !me.frm.doc.get(party_account_field))) {
if(party && me.frm.doc.company && (!me.frm.doc.__onload?.load_after_mapping || !me.frm.doc[party_account_field])) {
return frappe.call({
method: "erpnext.accounts.party.get_party_account",
args: {

View File

@@ -345,7 +345,10 @@ erpnext.PointOfSale.ItemSelector = class {
const items = this.search_index[selling_price_list][search_term];
this.items = items;
this.render_item_list(items);
this.auto_add_item && this.items.length == 1 && this.add_filtered_item_to_cart();
this.auto_add_item &&
this.search_field.$input[0].value &&
this.items.length == 1 &&
this.add_filtered_item_to_cart();
return;
}
}
@@ -358,7 +361,10 @@ erpnext.PointOfSale.ItemSelector = class {
}
this.items = items;
this.render_item_list(items);
this.auto_add_item && this.items.length == 1 && this.add_filtered_item_to_cart();
this.auto_add_item &&
this.search_field.$input[0].value &&
this.items.length == 1 &&
this.add_filtered_item_to_cart();
});
}

View File

@@ -600,7 +600,7 @@
"collapsible": 1,
"fieldname": "exit",
"fieldtype": "Tab Break",
"label": "Exit",
"label": "Employee Exit",
"oldfieldtype": "Section Break"
},
{
@@ -822,7 +822,7 @@
"image_field": "image",
"is_tree": 1,
"links": [],
"modified": "2025-02-07 13:54:40.122345",
"modified": "2025-07-04 08:29:34.347269",
"modified_by": "Administrator",
"module": "Setup",
"name": "Employee",

View File

@@ -2,8 +2,10 @@ frappe.listview_settings["Employee"] = {
add_fields: ["status", "branch", "department", "designation", "image"],
filters: [["status", "=", "Active"]],
get_indicator: function (doc) {
var indicator = [__(doc.status), frappe.utils.guess_colour(doc.status), "status,=," + doc.status];
indicator[1] = { Active: "green", Inactive: "red", Left: "gray", Suspended: "orange" }[doc.status];
return indicator;
return [
__(doc.status, null, "Employee"),
{ Active: "green", Inactive: "red", Left: "gray", Suspended: "orange" }[doc.status],
"status,=," + doc.status,
];
},
};

View File

@@ -104,7 +104,7 @@ def get_exchange_rate(from_currency, to_currency, transaction_date=None, args=No
if not transaction_date:
transaction_date = nowdate()
currency_settings = frappe.get_doc("Accounts Settings").as_dict()
currency_settings = frappe.get_cached_doc("Accounts Settings")
allow_stale_rates = currency_settings.get("allow_stale")
filters = [

View File

@@ -35,9 +35,11 @@ class TestBatch(FrappeTestCase):
def make_batch_item(cls, item_name=None):
from erpnext.stock.doctype.item.test_item import make_item
if not frappe.db.exists(item_name):
if not frappe.db.exists("Item", item_name):
return make_item(item_name, dict(has_batch_no=1, create_new_batch=1, is_stock_item=1))
return frappe.get_doc("Item", item_name)
def test_purchase_receipt(self, batch_qty=100):
"""Test automated batch creation from Purchase Receipt"""
self.make_batch_item("ITEM-BATCH-1")
@@ -305,8 +307,18 @@ class TestBatch(FrappeTestCase):
self.assertEqual(
get_batch_qty(item_code="ITEM-BATCH-2", warehouse="_Test Warehouse - _TC"),
[
{"batch_no": "batch a", "qty": 90.0, "warehouse": "_Test Warehouse - _TC"},
{"batch_no": "batch b", "qty": 90.0, "warehouse": "_Test Warehouse - _TC"},
{
"batch_no": "batch a",
"qty": 90.0,
"warehouse": "_Test Warehouse - _TC",
"expiry_date": None,
},
{
"batch_no": "batch b",
"qty": 90.0,
"warehouse": "_Test Warehouse - _TC",
"expiry_date": None,
},
],
)

View File

@@ -466,6 +466,8 @@ class DeliveryNote(SellingController):
self.make_bundle_for_sales_purchase_return(table_name)
self.make_bundle_using_old_serial_batch_fields(table_name)
self.validate_standalone_serial_nos_customer()
# Updating stock ledger should always be called after updating prevdoc status,
# because updating reserved qty in bin depends upon updated delivered qty in SO
self.update_stock_ledger()

View File

@@ -43,7 +43,7 @@ frappe.ui.form.on("Material Request", {
};
});
frm.set_query("price_list", () => {
frm.set_query("buying_price_list", () => {
return {
filters: {
buying: 1,
@@ -79,7 +79,7 @@ frappe.ui.form.on("Material Request", {
});
erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
frm.doc.price_list = frappe.defaults.get_default("buying_price_list");
frm.doc.buying_price_list = frappe.defaults.get_default("buying_price_list");
},
company: function (frm) {
@@ -255,8 +255,8 @@ frappe.ui.form.on("Material Request", {
from_warehouse: item.from_warehouse,
warehouse: item.warehouse,
doctype: frm.doc.doctype,
buying_price_list: frm.doc.price_list
? frm.doc.price_list
buying_price_list: frm.doc.buying_price_list
? frm.doc.buying_price_list
: frappe.defaults.get_default("buying_price_list"),
currency: frappe.defaults.get_default("Currency"),
name: frm.doc.name,

View File

@@ -16,7 +16,7 @@
"column_break_2",
"transaction_date",
"schedule_date",
"price_list",
"buying_price_list",
"amended_from",
"warehouse_section",
"scan_barcode",
@@ -354,7 +354,7 @@
"fieldtype": "Column Break"
},
{
"fieldname": "price_list",
"fieldname": "buying_price_list",
"fieldtype": "Link",
"label": "Price List",
"options": "Price List"
@@ -364,7 +364,7 @@
"idx": 70,
"is_submittable": 1,
"links": [],
"modified": "2025-07-07 13:15:28.615984",
"modified": "2025-07-11 21:03:26.588307",
"modified_by": "Administrator",
"module": "Stock",
"name": "Material Request",

View File

@@ -153,8 +153,8 @@ class MaterialRequest(BuyingController):
self.reset_default_field_value("set_warehouse", "items", "warehouse")
self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse")
if not self.price_list:
self.price_list = frappe.defaults.get_defaults().buying_price_list
if not self.buying_price_list:
self.buying_price_list = frappe.defaults.get_defaults().buying_price_list
def before_update_after_submit(self):
self.validate_schedule_date()

View File

@@ -6,11 +6,11 @@ frappe.listview_settings["Material Request"] = {
return [__("Stopped"), "red", "status,=,Stopped"];
} else if (doc.transfer_status && doc.docstatus != 2) {
if (doc.transfer_status == "Not Started") {
return [__("Not Started"), "orange"];
return [__("Not Started"), "orange", "transfer_status,=,Not Started"];
} else if (doc.transfer_status == "In Transit") {
return [__("In Transit"), "yellow"];
return [__("In Transit"), "yellow", "transfer_status,=,In Transit"];
} else if (doc.transfer_status == "Completed") {
return [__("Completed"), "green"];
return [__("Completed"), "green", "transfer_status,=,Completed"];
}
} else if (doc.docstatus == 1 && flt(doc.per_ordered, precision) == 0) {
return [__("Pending"), "orange", "per_ordered,=,0|docstatus,=,1"];

View File

@@ -340,7 +340,7 @@ def on_doctype_update():
@frappe.whitelist()
def get_items_from_product_bundle(row, price_list):
def get_items_from_product_bundle(row):
row, items = json.loads(row), []
bundled_items = get_product_bundle_items(row["item_code"])
@@ -350,7 +350,6 @@ def get_items_from_product_bundle(row, price_list):
"item_code": item.item_code,
"qty": flt(row["quantity"]) * flt(item.qty),
"conversion_rate": 1,
"price_list": price_list,
"currency": frappe.defaults.get_defaults().currency,
}
)

View File

@@ -116,13 +116,15 @@ class PickList(TransactionBase):
continue
bin_qty = frappe.db.get_value(
"Bin",
{"item_code": row.item_code, "warehouse": row.warehouse},
"actual_qty",
bin_qty = flt(
frappe.db.get_value(
"Bin",
{"item_code": row.item_code, "warehouse": row.warehouse},
"actual_qty",
)
)
if row.picked_qty > flt(bin_qty):
if row.picked_qty > bin_qty:
frappe.throw(
_(
"At Row #{0}: The picked quantity {1} for the item {2} is greater than available stock {3} in the warehouse {4}."

View File

@@ -55,14 +55,14 @@ frappe.ui.form.on("Serial and Batch Bundle", {
let fields = frm.events.get_prompt_fields(frm);
frm.add_custom_button(__("Make " + label), () => {
frm.add_custom_button(__("Make {0}", [label]), () => {
frappe.prompt(
fields,
(data) => {
frm.events.add_serial_batch(frm, data);
},
"Add " + label,
"Make " + label
__("Add {0}", [label]),
__("Make {0}", [label])
);
});
}

View File

@@ -11,11 +11,11 @@ from frappe.model.document import Document
from frappe.model.naming import make_autoname
from frappe.query_builder.functions import CombineDatetime, Sum
from frappe.utils import (
add_days,
cint,
cstr,
flt,
get_link_to_form,
getdate,
now,
nowtime,
parse_json,
@@ -804,7 +804,7 @@ class SerialandBatchBundle(Document):
if qty_field == "qty" and row.get("stock_qty"):
qty = row.get("stock_qty")
precision = row.precision
precision = row.precision(qty_field)
if abs(abs(flt(self.total_qty, precision)) - abs(flt(qty, precision))) > 0.01:
total_qty = frappe.format_value(abs(flt(self.total_qty)), "Float", row)
set_qty = frappe.format_value(abs(flt(row.get(qty_field))), "Float", row)
@@ -2148,6 +2148,9 @@ def get_auto_batch_nos(kwargs):
picked_batches,
)
if kwargs.based_on == "Expiry":
available_batches = sorted(available_batches, key=lambda x: (x.expiry_date or getdate("9999-12-31")))
if not kwargs.get("do_not_check_future_batches") and available_batches and kwargs.get("posting_date"):
filter_zero_near_batches(available_batches, kwargs)
@@ -2247,6 +2250,7 @@ def get_available_batches(kwargs):
batch_ledger.batch_no,
batch_ledger.warehouse,
Sum(batch_ledger.qty).as_("qty"),
batch_table.expiry_date,
)
.where(batch_table.disabled == 0)
.where(stock_ledger_entry.is_cancelled == 0)
@@ -2537,6 +2541,7 @@ def get_stock_ledgers_batches(kwargs):
stock_ledger_entry.item_code,
Sum(stock_ledger_entry.actual_qty).as_("qty"),
stock_ledger_entry.batch_no,
batch_table.expiry_date,
)
.where((stock_ledger_entry.is_cancelled == 0) & (stock_ledger_entry.batch_no.isnotnull()))
.groupby(stock_ledger_entry.batch_no, stock_ledger_entry.warehouse)

View File

@@ -15,6 +15,7 @@
"batch_no",
"warehouse",
"purchase_rate",
"customer",
"column_break1",
"status",
"item_name",
@@ -267,12 +268,21 @@
"label": "Creation Document No",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "customer",
"fieldtype": "Link",
"label": "Customer",
"no_copy": 1,
"options": "Customer",
"print_hide": 1,
"read_only": 1
}
],
"icon": "fa fa-barcode",
"idx": 1,
"links": [],
"modified": "2025-01-15 16:22:49.873889",
"modified": "2025-07-15 13:40:21.938700",
"modified_by": "Administrator",
"module": "Stock",
"name": "Serial No",
@@ -310,6 +320,7 @@
"role": "Stock User"
}
],
"row_format": "Dynamic",
"search_fields": "item_code",
"show_name_in_global_search": 1,
"sort_field": "modified",

View File

@@ -40,6 +40,7 @@ class SerialNo(StockController):
batch_no: DF.Link | None
brand: DF.Link | None
company: DF.Link
customer: DF.Link | None
description: DF.Text | None
employee: DF.Link | None
item_code: DF.Link

View File

@@ -435,7 +435,9 @@ class StockEntry(StockController):
additional_cost_amt = additional_costs[0][0] if additional_costs else 0
amount += additional_cost_amt
frappe.db.set_value("Project", self.project, "total_consumed_material_cost", amount)
project = frappe.get_doc("Project", self.project)
project.total_consumed_material_cost = amount
project.save()
def validate_item(self):
stock_items = self.get_stock_items()

View File

@@ -1089,17 +1089,14 @@ class StockReconciliation(StockController):
}
)
if (
add_new_sle
and not frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": row.name, "actual_qty": ("<", 0), "is_cancelled": 0},
"name",
)
and not row.current_serial_and_batch_bundle
if add_new_sle and not frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": row.name, "actual_qty": ("<", 0), "is_cancelled": 0},
"name",
):
self.set_current_serial_and_batch_bundle(voucher_detail_no, save=True)
row.reload()
if not row.current_serial_and_batch_bundle:
self.set_current_serial_and_batch_bundle(voucher_detail_no, save=True)
row.reload()
self.add_missing_stock_ledger_entry(row, voucher_detail_no, sle_creation)

View File

@@ -120,7 +120,11 @@ class StockSettings(Document):
)
def cant_change_valuation_method(self):
previous_valuation_method = self.get_doc_before_save().get("valuation_method")
doc_before_save = self.get_doc_before_save()
if not doc_before_save:
return
previous_valuation_method = doc_before_save.get("valuation_method")
if previous_valuation_method and previous_valuation_method != self.valuation_method:
# check if there are any stock ledger entries against items

View File

@@ -19,21 +19,17 @@ def execute(filters=None):
columns = get_columns(filters)
items = get_items(filters)
sl_entries = get_stock_ledger_entries(filters, items)
if not sl_entries:
return columns, []
item_details = get_item_details(items, sl_entries, False)
opening_row = get_opening_balance_data(filters, columns, sl_entries)
opening_row = get_opening_balance(filters, columns, sl_entries)
precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
data = process_stock_ledger_entries(sl_entries, item_details, opening_row, precision)
return columns, data
def get_opening_balance_data(filters, columns, sl_entries):
opening_row = get_opening_balance(filters, columns, sl_entries)
return opening_row
def process_stock_ledger_entries(sl_entries, item_details, opening_row, precision):
data = []

View File

@@ -5,6 +5,7 @@
import frappe
from frappe import _
from frappe.utils import flt, today
from frappe.utils.nestedset import get_descendants_of
from pypika.terms import ExistsCriterion
from erpnext.accounts.doctype.pos_invoice.pos_invoice import get_pos_reserved_qty
@@ -21,6 +22,10 @@ def execute(filters=None):
columns = get_columns()
bin_list = get_bin_list(filters)
item_map = get_item_map(filters.get("item_code"), include_uom)
item_groups = []
if filters.get("item_group"):
item_groups.append(filters.item_group)
item_groups.extend(get_descendants_of("Item Group", filters.item_group))
warehouse_company = {}
data = []
@@ -40,7 +45,7 @@ def execute(filters=None):
if filters.brand and filters.brand != item.brand:
continue
elif filters.item_group and filters.item_group != item.item_group:
elif item_groups and item.item_group not in item_groups:
continue
elif filters.company and filters.company != company:

View File

@@ -377,6 +377,10 @@ class SerialBatchBundle:
]:
status = "Consumed"
customer = None
if sle.voucher_type in ["Sales Invoice", "Delivery Note"] and sle.actual_qty < 0:
customer = frappe.get_cached_value(sle.voucher_type, sle.voucher_no, "customer")
sn_table = frappe.qb.DocType("Serial No")
query = (
@@ -387,10 +391,11 @@ class SerialBatchBundle:
"Active"
if warehouse
else status
if (sn_table.purchase_document_no != sle.voucher_no and sle.is_cancelled != 1)
if (sn_table.purchase_document_no != sle.voucher_no or sle.is_cancelled != 1)
else "Inactive",
)
.set(sn_table.company, sle.company)
.set(sn_table.customer, customer)
.where(sn_table.name.isin(serial_nos))
)

View File

@@ -919,6 +919,7 @@ class update_entries_after:
)
sle.doctype = "Stock Ledger Entry"
sle.modified = now()
frappe.get_doc(sle).db_update()
if not self.args.get("sle_id") or (

View File

@@ -236,8 +236,11 @@ class SubcontractingOrder(SubcontractingController):
return flt(query[0][0]) if query else 0
def update_reserved_qty_for_subcontracting(self):
def update_reserved_qty_for_subcontracting(self, sco_item_rows=None):
for item in self.supplied_items:
if sco_item_rows and item.reference_name not in sco_item_rows:
continue
if item.rm_item_code:
stock_bin = get_bin(item.rm_item_code, item.reserve_warehouse)
stock_bin.update_reserved_qty_for_sub_contracting()
@@ -299,7 +302,7 @@ class SubcontractingOrder(SubcontractingController):
self.set_missing_values()
def update_status(self, status=None, update_modified=True):
def update_status(self, status=None, update_modified=True, update_bin=True):
if self.status == "Closed" and self.status != status:
check_on_hold_or_closed_status("Purchase Order", self.purchase_order)
@@ -329,8 +332,9 @@ class SubcontractingOrder(SubcontractingController):
self.db_set("status", status, update_modified=update_modified)
self.update_requested_qty()
self.update_ordered_qty_for_subcontracting()
self.update_reserved_qty_for_subcontracting()
if update_bin:
self.update_ordered_qty_for_subcontracting()
self.update_reserved_qty_for_subcontracting()
def update_subcontracted_quantity_in_po(self, cancel=False):
for service_item in self.service_items:

View File

@@ -152,7 +152,7 @@ class SubcontractingReceipt(SubcontractingController):
self.validate_available_qty_for_consumption()
self.update_status_updater_args()
self.update_prevdoc_status()
self.set_subcontracting_order_status()
self.set_subcontracting_order_status(update_bin=False)
self.set_consumed_qty_in_subcontract_order()
for table_name in ["items", "supplied_items"]:
@@ -179,7 +179,7 @@ class SubcontractingReceipt(SubcontractingController):
self.update_status_updater_args()
self.update_prevdoc_status()
self.set_consumed_qty_in_subcontract_order()
self.set_subcontracting_order_status()
self.set_subcontracting_order_status(update_bin=False)
self.update_stock_ledger()
self.make_gl_entries_on_cancel()
self.repost_future_sle_and_gle()

View File

@@ -157,6 +157,8 @@ def link_existing_conversations(doc, state):
"""
Called from hooks on creation of Contact or Lead to link all the existing conversations.
"""
if doc.flags.ignore_auto_link_call_log:
return
if doc.doctype != "Contact":
return
try:
@@ -183,12 +185,12 @@ def link_existing_conversations(doc, state):
""",
dict(phone_number=f"%{number}", docname=doc.name, doctype=doc.doctype),
)
for log in logs:
call_log = frappe.get_doc("Call Log", log)
call_log.add_link(link_type=doc.doctype, link_name=doc.name)
call_log.save(ignore_permissions=True)
frappe.db.commit()
if logs:
for log in logs:
call_log = frappe.get_doc("Call Log", log)
call_log.add_link(link_type=doc.doctype, link_name=doc.name)
call_log.save(ignore_permissions=True)
frappe.db.commit()
except Exception:
frappe.log_error(title=_("Error during caller information update"))