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

chore: release v15
This commit is contained in:
ruthra kumar
2025-12-30 19:03:35 +05:30
committed by GitHub
20 changed files with 261 additions and 68 deletions

View File

@@ -304,6 +304,7 @@ def create_payment_entry_bts(
project=None,
cost_center=None,
allow_edit=None,
company_bank_account=None,
):
# Create a new payment entry based on the bank transaction
bank_transaction = frappe.db.get_values(
@@ -345,6 +346,9 @@ def create_payment_entry_bts(
pe.project = project
pe.cost_center = cost_center
if company_bank_account:
pe.bank_account = company_bank_account
pe.validate()
if allow_edit:

View File

@@ -435,6 +435,7 @@ frappe.ui.form.on("Payment Entry", {
"paid_to",
"references",
"total_allocated_amount",
"party_name",
],
function (i, field) {
frm.set_value(field, null);

View File

@@ -115,6 +115,10 @@ class RepostAccountingLedger(Document):
def generate_preview(self):
from erpnext.accounts.report.general_ledger.general_ledger import get_columns as get_gl_columns
if not self.vouchers:
frappe.msgprint(_("Add vouchers to generate preview."))
return
gl_columns = []
gl_data = []
@@ -142,6 +146,7 @@ class RepostAccountingLedger(Document):
account_repost_doc=self.name,
is_async=True,
job_name=job_name,
enqueue_after_commit=True,
)
frappe.msgprint(_("Repost has started in the background"))
else:

View File

@@ -1056,3 +1056,21 @@ def add_party_account(party_type, party, company, account):
def render_address(address, check_permissions=True):
return frappe.call(_render_address, address, check_permissions=check_permissions)
def validate_party_currency_before_merging(party_type, old_party, new_party):
for company in frappe.get_all("Company"):
old_party_currency = get_party_gle_currency(party_type, old_party, company.name)
new_party_currency = get_party_gle_currency(party_type, new_party, company.name)
if old_party_currency and new_party_currency and old_party_currency != new_party_currency:
frappe.throw(
_(
"Cannot merge {0} '{1}' into '{2}' as both have existing accounting entries in different currencies for company '{3}'."
).format(
party_type,
old_party,
new_party,
company.name,
)
)

View File

@@ -102,6 +102,11 @@ frappe.query_reports["Accounts Payable Summary"] = {
label: __("Revaluation Journals"),
fieldtype: "Check",
},
{
fieldname: "show_gl_balance",
label: __("Show GL Balance"),
fieldtype: "Check",
},
],
onload: function (report) {

View File

@@ -53,7 +53,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
)
if self.filters.show_gl_balance:
gl_balance_map = get_gl_balance(self.filters.report_date, self.filters.company)
gl_balance_map = get_gl_balance(self.filters.report_date, self.filters.company, self.account_type)
for party, party_dict in self.party_total.items():
if flt(party_dict.outstanding, self.currency_precision) == 0:
@@ -206,11 +206,15 @@ class AccountsReceivableSummary(ReceivablePayableReport):
)
def get_gl_balance(report_date, company):
def get_gl_balance(report_date, company, account_type):
if account_type == "Payable":
balance_calc_fields = ["party", "SUM(credit - debit) AS balance"]
else:
balance_calc_fields = ["party", "SUM(debit - credit) AS balance"]
return frappe._dict(
frappe.db.get_all(
"GL Entry",
fields=["party", "sum(debit - credit)"],
fields=balance_calc_fields,
filters={"posting_date": ("<=", report_date), "is_cancelled": 0, "company": company},
group_by="party",
as_list=1,

View File

@@ -480,6 +480,7 @@ class Asset(AccountsController):
def set_depreciation_rate(self):
for d in self.get("finance_books"):
self.validate_asset_finance_books(d)
d.rate_of_depreciation = flt(
self.get_depreciation_rate(d, on_validate=True), d.precision("rate_of_depreciation")
)
@@ -488,6 +489,10 @@ class Asset(AccountsController):
row.expected_value_after_useful_life = flt(
row.expected_value_after_useful_life, self.precision("gross_purchase_amount")
)
if flt(row.expected_value_after_useful_life) < 0:
frappe.throw(_("Row {0}: Expected Value After Useful Life cannot be negative").format(row.idx))
if flt(row.expected_value_after_useful_life) >= flt(self.gross_purchase_amount):
frappe.throw(
_("Row {0}: Expected Value After Useful Life must be less than Gross Purchase Amount").format(
@@ -503,50 +508,71 @@ class Asset(AccountsController):
title=_("Invalid Schedule"),
)
row.depreciation_start_date = get_last_day(self.available_for_use_date)
self.validate_depreciation_start_date(row)
self.validate_total_number_of_depreciations_and_frequency(row)
if not self.is_existing_asset:
self.opening_accumulated_depreciation = 0
self.opening_number_of_booked_depreciations = 0
else:
depreciable_amount = flt(
flt(self.gross_purchase_amount) - flt(row.expected_value_after_useful_life),
self.precision("gross_purchase_amount"),
)
if flt(self.opening_accumulated_depreciation) > depreciable_amount:
self.validate_opening_depreciation_values(row)
def validate_depreciation_start_date(self, row):
if row.depreciation_start_date:
if getdate(row.depreciation_start_date) < getdate(self.purchase_date):
frappe.throw(
_("Opening Accumulated Depreciation must be less than or equal to {0}").format(
depreciable_amount
_("Row #{0}: Next Depreciation Date cannot be before Purchase Date").format(row.idx)
)
if getdate(row.depreciation_start_date) < getdate(self.available_for_use_date):
frappe.throw(
_("Row #{0}: Next Depreciation Date cannot be before Available-for-use Date").format(
row.idx
)
)
if self.opening_accumulated_depreciation:
if not self.opening_number_of_booked_depreciations:
frappe.throw(_("Please set Opening Number of Booked Depreciations"))
else:
self.opening_number_of_booked_depreciations = 0
if flt(row.total_number_of_depreciations) <= cint(self.opening_number_of_booked_depreciations):
frappe.throw(
_(
"Row {0}: Total Number of Depreciations cannot be less than or equal to Opening Number of Booked Depreciations"
).format(row.idx),
title=_("Invalid Schedule"),
)
if row.depreciation_start_date and getdate(row.depreciation_start_date) < getdate(self.purchase_date):
else:
frappe.throw(
_("Depreciation Row {0}: Next Depreciation Date cannot be before Purchase Date").format(
row.idx
_("Row #{0}: Depreciation Start Date is required").format(row.idx),
title=_("Invalid Schedule"),
)
def validate_total_number_of_depreciations_and_frequency(self, row):
if row.total_number_of_depreciations <= 0:
frappe.throw(
_("Row #{0}: Total Number of Depreciations must be greater than zero").format(row.idx)
)
if row.frequency_of_depreciation <= 0:
frappe.throw(_("Row #{0}: Frequency of Depreciation must be greater than zero").format(row.idx))
def validate_opening_depreciation_values(self, row):
row.expected_value_after_useful_life = flt(
row.expected_value_after_useful_life, self.precision("gross_purchase_amount")
)
depreciable_amount = flt(
flt(self.gross_purchase_amount) - flt(row.expected_value_after_useful_life),
self.precision("gross_purchase_amount"),
)
if flt(self.opening_accumulated_depreciation) > depreciable_amount:
frappe.throw(
_("Opening Accumulated Depreciation must be less than or equal to {0}").format(
depreciable_amount
)
)
if row.depreciation_start_date and getdate(row.depreciation_start_date) < getdate(
self.available_for_use_date
):
if self.opening_accumulated_depreciation:
if not self.opening_number_of_booked_depreciations:
frappe.throw(_("Please set Opening Number of Booked Depreciations"))
else:
self.opening_number_of_booked_depreciations = 0
if flt(row.total_number_of_depreciations) <= cint(self.opening_number_of_booked_depreciations):
frappe.throw(
_(
"Depreciation Row {0}: Next Depreciation Date cannot be before Available-for-use Date"
).format(row.idx)
"Row {0}: Total Number of Depreciations cannot be less than or equal to Opening Number of Booked Depreciations"
).format(row.idx),
title=_("Invalid Schedule"),
)
def set_total_booked_depreciations(self):

View File

@@ -14,6 +14,7 @@ from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_
from erpnext.accounts.party import (
get_dashboard_info,
validate_party_accounts,
validate_party_currency_before_merging,
)
from erpnext.controllers.website_list_for_contact import add_role_for_portal_user
from erpnext.utilities.transaction_base import TransactionBase
@@ -208,6 +209,10 @@ class Supplier(TransactionBase):
delete_contact_and_address("Supplier", self.name)
def before_rename(self, olddn, newdn, merge=False):
if merge:
validate_party_currency_before_merging("Supplier", olddn, newdn)
def after_rename(self, olddn, newdn, merge=False):
if frappe.defaults.get_global_default("supp_master_name") == "Supplier Name":
self.db_set("supplier_name", newdn)

View File

@@ -17,6 +17,15 @@ class TestTimesheet(unittest.TestCase):
def setUp(self):
frappe.db.delete("Timesheet")
def test_timesheet_base_amount(self):
emp = make_employee("test_employee_6@salary.com")
timesheet = make_timesheet(emp, simulate=True, is_billable=1)
self.assertEqual(timesheet.time_logs[0].base_billing_rate, 50)
self.assertEqual(timesheet.time_logs[0].base_costing_rate, 20)
self.assertEqual(timesheet.time_logs[0].base_billing_amount, 100)
self.assertEqual(timesheet.time_logs[0].base_costing_amount, 40)
def test_timesheet_billing_amount(self):
emp = make_employee("test_employee_6@salary.com")
timesheet = make_timesheet(emp, simulate=True, is_billable=1)
@@ -236,4 +245,5 @@ def make_timesheet(
def update_activity_type(activity_type):
activity_type = frappe.get_doc("Activity Type", activity_type)
activity_type.billing_rate = 50.0
activity_type.costing_rate = 20.0
activity_type.save(ignore_permissions=True)

View File

@@ -296,6 +296,20 @@ class Timesheet(Document):
data.billing_amount = data.billing_rate * hours
data.costing_amount = data.costing_rate * costing_hours
exchange_rate = flt(self.get("exchange_rate")) or 1.0
data.base_billing_rate = flt(
data.billing_rate * exchange_rate, data.precision("base_billing_rate")
)
data.base_costing_rate = flt(
data.costing_rate * exchange_rate, data.precision("base_costing_rate")
)
data.base_billing_amount = flt(
data.billing_amount * exchange_rate, data.precision("base_billing_amount")
)
data.base_costing_amount = flt(
data.costing_amount * exchange_rate, data.precision("base_costing_amount")
)
def update_time_rates(self, ts_detail):
if not ts_detail.is_billable:
ts_detail.billing_rate = 0.0

View File

@@ -361,6 +361,21 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
mandatory_depends_on:
"eval:doc.action=='Create Voucher' && doc.document_type=='Payment Entry'",
},
{
fieldname: "bank_account",
fieldtype: "Link",
label: "Company Bank Account",
options: "Bank Account",
depends_on: "eval:doc.party",
get_query: function () {
return {
filters: {
is_company_account: 1,
company: this.company,
},
};
},
},
{
fieldname: "project",
fieldtype: "Link",
@@ -511,6 +526,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
mode_of_payment: values.mode_of_payment,
project: values.project,
cost_center: values.cost_center,
company_bank_account: values?.bank_account || this?.bank_account,
},
callback: (response) => {
const alert_string = __("Bank Transaction {0} added as Payment Entry", [
@@ -582,6 +598,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
project: values.project,
cost_center: values.cost_center,
allow_edit: true,
company_bank_account: values?.bank_account || this?.bank_account,
},
callback: (r) => {
const doc = frappe.model.sync(r.message);

View File

@@ -18,7 +18,11 @@ from frappe.utils import cint, cstr, flt, get_formatted_email, today
from frappe.utils.deprecations import deprecated
from frappe.utils.user import get_users_with_role
from erpnext.accounts.party import get_dashboard_info, validate_party_accounts
from erpnext.accounts.party import (
get_dashboard_info,
validate_party_accounts,
validate_party_currency_before_merging,
)
from erpnext.controllers.website_list_for_contact import add_role_for_portal_user
from erpnext.utilities.transaction_base import TransactionBase
@@ -367,6 +371,10 @@ class Customer(TransactionBase):
if self.lead_name:
frappe.db.sql("update `tabLead` set status='Interested' where name=%s", self.lead_name)
def before_rename(self, olddn, newdn, merge=False):
if merge:
validate_party_currency_before_merging("Customer", olddn, newdn)
def after_rename(self, olddn, newdn, merge=False):
if frappe.defaults.get_global_default("cust_master_name") == "Customer Name":
self.db_set("customer_name", newdn)

View File

@@ -383,35 +383,23 @@ class PickList(TransactionBase):
picked_items = get_picked_items_qty(packed_items, contains_packed_items=True)
self.validate_picked_qty(picked_items)
picked_qty = frappe._dict()
doc_updates = {}
for d in picked_items:
picked_qty[d.product_bundle_item] = d.picked_qty
doc_updates[d.product_bundle_item] = {"picked_qty": flt(d.picked_qty)}
for packed_item in packed_items:
frappe.db.set_value(
"Packed Item",
packed_item,
"picked_qty",
flt(picked_qty.get(packed_item)),
update_modified=False,
)
if doc_updates:
frappe.db.bulk_update("Packed Item", doc_updates, update_modified=False)
def update_sales_order_item_qty(self, so_items):
picked_items = get_picked_items_qty(so_items)
self.validate_picked_qty(picked_items)
picked_qty = frappe._dict()
doc_updates = {}
for d in picked_items:
picked_qty[d.sales_order_item] = d.picked_qty
doc_updates[d.sales_order_item] = {"picked_qty": flt(d.picked_qty)}
for so_item in so_items:
frappe.db.set_value(
"Sales Order Item",
so_item,
"picked_qty",
flt(picked_qty.get(so_item)),
update_modified=False,
)
if doc_updates:
frappe.db.bulk_update("Sales Order Item", doc_updates, update_modified=False)
def update_sales_order_picking_status(self) -> None:
sales_orders = []

View File

@@ -17,13 +17,6 @@ frappe.ui.form.on("Purchase Receipt", {
"Landed Cost Voucher": "Landed Cost Voucher",
};
frm.set_query("expense_account", "items", function () {
return {
query: "erpnext.controllers.queries.get_expense_account",
filters: { company: frm.doc.company },
};
});
frm.set_query("wip_composite_asset", "items", function () {
return {
filters: { is_composite_asset: 1, docstatus: 0 },
@@ -171,6 +164,16 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend
this.setup_accounting_dimension_triggers();
this.setup_posting_date_time_check();
super.setup(doc);
this.frm.set_query("expense_account", "items", () => {
return {
query: "erpnext.controllers.queries.get_expense_account",
filters: {
company: this.frm.doc.company,
disabled: 0,
},
};
});
}
refresh() {

View File

@@ -312,6 +312,30 @@ class SerialandBatchBundle(Document):
SerialNoDuplicateError,
)
if (
self.voucher_type == "Stock Entry"
and self.type_of_transaction == "Inward"
and frappe.get_cached_value("Stock Entry", self.voucher_no, "purpose")
in ["Manufacture", "Repack"]
):
serial_nos = frappe.get_all(
"Serial No", filters={"name": ("in", serial_nos), "status": "Delivered"}, pluck="name"
)
if serial_nos:
if len(serial_nos) == 1:
frappe.throw(
_(
"Serial No {0} is already Delivered. You cannot use them again in Manufacture / Repack entry."
).format(bold(serial_nos[0]))
)
else:
frappe.throw(
_(
"Serial Nos {0} are already Delivered. You cannot use them again in Manufacture / Repack entry."
).format(bold(", ".join(serial_nos)))
)
def throw_error_message(self, message, exception=frappe.ValidationError):
frappe.throw(_(message), exception, title=_("Error"))

View File

@@ -282,7 +282,7 @@
"icon": "fa fa-barcode",
"idx": 1,
"links": [],
"modified": "2025-07-15 13:40:21.938700",
"modified": "2025-12-24 20:14:52.942251",
"modified_by": "Administrator",
"module": "Stock",
"name": "Serial No",

View File

@@ -302,3 +302,7 @@ def get_serial_nos_for_outward(kwargs):
return []
return [d.serial_no for d in serial_nos]
def on_doctype_update():
frappe.db.add_index("Serial No", ["item_code", "warehouse"])

View File

@@ -2088,6 +2088,45 @@ class TestStockEntry(FrappeTestCase):
self.assertEqual(incoming_rate, 125.0)
def test_prevent_reuse_delivered_serial_no_in_repack(self):
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
item = "Test Prevent Reuse Delivered Serial No"
warehouse = "_Test Warehouse - _TC"
item_doc = make_item(item, {"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "SHGJ.####"})
make_stock_entry(item_code="_Test Item", target=warehouse, qty=2, rate=100)
make_stock_entry(item_code=item, target=warehouse, qty=2, rate=100)
dn = create_delivery_note(item_code=item, qty=2)
delivered_serial_no = get_serial_nos_from_bundle(dn.get("items")[0].serial_and_batch_bundle)[0]
se = make_stock_entry(
item_code="_Test Item", source=warehouse, qty=1, purpose="Repack", do_not_save=True
)
se.append(
"items",
{
"item_code": item_doc.name,
"item_name": item_doc.item_name,
"s_warehouse": None,
"t_warehouse": warehouse,
"description": item_doc.description,
"uom": item_doc.stock_uom,
"qty": 1,
"use_serial_batch_fields": 1,
"serial_no": delivered_serial_no,
},
)
se.save()
status = frappe.db.get_value("Serial No", delivered_serial_no, "status")
self.assertEqual(status, "Delivered")
self.assertEqual(se.purpose, "Repack")
self.assertRaises(frappe.ValidationError, se.submit)
def make_serialized_item(**args):
args = frappe._dict(args)

View File

@@ -20,6 +20,9 @@ def execute(filters=None):
def get_chart_data(data, filters):
def wrap_in_quotes(label):
return f"'{label}'"
if not data:
return []
@@ -36,6 +39,9 @@ def get_chart_data(data, filters):
data = data[:10]
for row in data:
if row[0] == wrap_in_quotes(_("Total")):
continue
labels.append(row[0])
datapoints.append(row[-1])

View File

@@ -406,12 +406,7 @@ class SerialBatchBundle:
self.update_serial_no_status_warehouse(self.sle, serial_nos)
def update_serial_no_status_warehouse(self, sle, serial_nos):
warehouse = sle.warehouse if sle.actual_qty > 0 else None
if isinstance(serial_nos, str):
serial_nos = [serial_nos]
def get_status_for_serial_nos(self, sle):
status = "Inactive"
if sle.actual_qty < 0:
status = "Delivered"
@@ -425,6 +420,23 @@ class SerialBatchBundle:
]:
status = "Consumed"
if sle.is_cancelled == 1 and (
sle.voucher_type in ["Purchase Invoice", "Purchase Receipt"] or status == "Consumed"
):
status = "Inactive"
return status
def update_serial_no_status_warehouse(self, sle, serial_nos):
warehouse = sle.warehouse if sle.actual_qty > 0 else None
if isinstance(serial_nos, str):
serial_nos = [serial_nos]
status = "Active"
if not warehouse:
status = self.get_status_for_serial_nos(sle)
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")