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

chore: release v15
This commit is contained in:
ruthra kumar
2025-08-12 17:33:04 +05:30
committed by GitHub
29 changed files with 252 additions and 53 deletions

View File

@@ -169,7 +169,7 @@ class Account(NestedSet):
if par.root_type:
self.root_type = par.root_type
if self.is_group:
if cint(self.is_group):
db_value = self.get_doc_before_save()
if db_value:
if self.report_type != db_value.report_type:
@@ -212,7 +212,7 @@ class Account(NestedSet):
if doc_before_save and not doc_before_save.parent_account:
throw(_("Root cannot be edited."), RootNotEditable)
if not self.parent_account and not self.is_group:
if not self.parent_account and not cint(self.is_group):
throw(_("The root account {0} must be a group").format(frappe.bold(self.name)))
def validate_root_company_and_sync_account_to_children(self):
@@ -261,7 +261,7 @@ class Account(NestedSet):
if self.check_gle_exists():
throw(_("Account with existing transaction cannot be converted to ledger"))
elif self.is_group:
elif cint(self.is_group):
if self.account_type and not self.flags.exclude_account_type_check:
throw(_("Cannot covert to Group because Account Type is selected."))
elif self.check_if_child_exists():

View File

@@ -252,10 +252,6 @@ 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();
}
},
@@ -274,8 +270,7 @@ frappe.treeview_settings["Account"] = {
].treeview.page.fields_dict.root_company.get_value() ||
frappe.flags.ignore_root_company_validation) &&
node.expandable &&
!node.hide_add &&
!node.is_root
!node.hide_add
);
},
click: function () {

View File

@@ -550,7 +550,7 @@ def send_auto_email():
selected = frappe.get_list(
"Process Statement Of Accounts",
filters={"enable_auto_email": 1},
or_filters={"to_date": format_date(today()), "posting_date": format_date(today())},
or_filters={"to_date": today(), "posting_date": today()},
)
for entry in selected:
send_emails(entry.name, from_scheduler=True)

View File

@@ -178,7 +178,7 @@ def start_repost(account_repost_doc=str) -> None:
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
if not repost_doc.delete_cancelled_entries:
doc.docstatus = 2
doc.make_gl_entries_on_cancel()
doc.make_gl_entries_on_cancel(from_repost=True)
doc.docstatus = 1
if doc.doctype == "Sales Invoice":
@@ -190,7 +190,7 @@ def start_repost(account_repost_doc=str) -> None:
elif doc.doctype == "Purchase Receipt":
if not repost_doc.delete_cancelled_entries:
doc.docstatus = 2
doc.make_gl_entries_on_cancel()
doc.make_gl_entries_on_cancel(from_repost=True)
doc.docstatus = 1
doc.make_gl_entries(from_repost=True)

View File

@@ -1206,7 +1206,6 @@ class SalesInvoice(SellingController):
self.make_exchange_gain_loss_journal()
elif self.docstatus == 2:
cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
if update_outstanding == "No":

View File

@@ -4668,6 +4668,59 @@ class TestSalesInvoice(FrappeTestCase):
doc.db_set("do_not_use_batchwise_valuation", original_value)
def test_system_generated_exchange_gain_or_loss_je_after_repost(self):
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.repost_accounting_ledger.test_repost_accounting_ledger import (
update_repost_settings,
)
update_repost_settings()
si = create_sales_invoice(
customer="_Test Customer USD",
debit_to="_Test Receivable USD - _TC",
currency="USD",
conversion_rate=80,
)
pe = get_payment_entry("Sales Invoice", si.name)
pe.reference_no = "10"
pe.reference_date = nowdate()
pe.paid_from_account_currency = si.currency
pe.paid_to_account_currency = "INR"
pe.source_exchange_rate = 85
pe.target_exchange_rate = 1
pe.paid_amount = si.outstanding_amount
pe.insert()
pe.submit()
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = si.company
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.save()
ral.submit()
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
q = (
(
frappe.qb.from_(je)
.join(jea)
.on(je.name == jea.parent)
.select(je.docstatus)
.where(
(je.voucher_type == "Exchange Gain Or Loss")
& (jea.reference_name == si.name)
& (jea.reference_type == "Sales Invoice")
& (je.is_system_generated == 1)
)
)
.limit(1)
.run()
)
self.assertEqual(q[0][0], 1)
def make_item_for_si(item_code, properties=None):
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -203,6 +203,12 @@ def get_gl_entries(filters, accounting_dimensions):
as_dict=1,
)
party_name_map = get_party_name_map()
for gl_entry in gl_entries:
if gl_entry.party_type and gl_entry.party:
gl_entry.party_name = party_name_map.get(gl_entry.party_type, {}).get(gl_entry.party)
if filters.get("presentation_currency"):
return convert_to_presentation_currency(gl_entries, currency_map, filters)
else:
@@ -337,6 +343,20 @@ def get_conditions(filters):
return "and {}".format(" and ".join(conditions)) if conditions else ""
def get_party_name_map():
party_map = {}
customers = frappe.get_all("Customer", fields=["name", "customer_name"])
party_map["Customer"] = {c.name: c.customer_name for c in customers}
suppliers = frappe.get_all("Supplier", fields=["name", "supplier_name"])
party_map["Supplier"] = {s.name: s.supplier_name for s in suppliers}
employees = frappe.get_all("Employee", fields=["name", "employee_name"])
party_map["Employee"] = {e.name: e.employee_name for e in employees}
return party_map
def get_accounts_with_children(accounts):
if not isinstance(accounts, list):
accounts = [d.strip() for d in accounts.strip().split(",") if d]
@@ -701,6 +721,19 @@ def get_columns(filters):
{"label": _("Party"), "fieldname": "party", "width": 100},
]
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
if supplier_master_name != "Supplier Name" or customer_master_name != "Customer Name":
columns.append(
{
"label": _("Party Name"),
"fieldname": "party_name",
"fieldtype": "Data",
"width": 150,
}
)
if filters.get("include_dimensions"):
columns.append({"label": _("Project"), "options": "Project", "fieldname": "project", "width": 100})

View File

@@ -33,6 +33,7 @@ def execute(filters=None):
"invoice_or_item",
"customer",
"customer_group",
"customer_name",
"posting_date",
"item_code",
"item_name",
@@ -95,6 +96,7 @@ def execute(filters=None):
"customer": [
"customer",
"customer_group",
"customer_name",
"qty",
"base_rate",
"buying_rate",
@@ -250,6 +252,10 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_
def get_columns(group_wise_columns, filters):
columns = []
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
column_map = frappe._dict(
{
"parent": {
@@ -395,6 +401,12 @@ def get_columns(group_wise_columns, filters):
"options": "Customer Group",
"width": 100,
},
"customer_name": {
"label": _("Customer Name"),
"fieldname": "customer_name",
"fieldtype": "Data",
"width": 150,
},
"territory": {
"label": _("Territory"),
"fieldname": "territory",
@@ -419,6 +431,10 @@ def get_columns(group_wise_columns, filters):
)
for col in group_wise_columns.get(scrub(filters.group_by)):
if col == "customer_name" and (
supplier_master_name == "Supplier Name" and customer_master_name == "Customer Name"
):
continue
columns.append(column_map.get(col))
columns.append(
@@ -440,6 +456,7 @@ def get_column_names():
"invoice_or_item": "sales_invoice",
"customer": "customer",
"customer_group": "customer_group",
"customer_name": "customer_name",
"posting_date": "posting_date",
"item_code": "item_code",
"item_name": "item_name",
@@ -905,7 +922,7 @@ class GrossProfitGenerator:
`tabSales Invoice Item`.parenttype, `tabSales Invoice Item`.parent,
`tabSales Invoice`.posting_date, `tabSales Invoice`.posting_time,
`tabSales Invoice`.project, `tabSales Invoice`.update_stock,
`tabSales Invoice`.customer, `tabSales Invoice`.customer_group,
`tabSales Invoice`.customer, `tabSales Invoice`.customer_group, `tabSales Invoice`.customer_name,
`tabSales Invoice`.territory, `tabSales Invoice Item`.item_code,
`tabSales Invoice`.base_net_total as "invoice_base_net_total",
`tabSales Invoice Item`.item_name, `tabSales Invoice Item`.description,
@@ -1003,6 +1020,7 @@ class GrossProfitGenerator:
"update_stock": row.update_stock,
"customer": row.customer,
"customer_group": row.customer_group,
"customer_name": row.customer_name,
"item_code": None,
"item_name": None,
"description": None,
@@ -1032,6 +1050,7 @@ class GrossProfitGenerator:
"project": row.project,
"customer": row.customer,
"customer_group": row.customer_group,
"customer_name": row.customer_name,
"item_code": item.item_code,
"item_name": item.item_name,
"description": item.description,

View File

@@ -578,7 +578,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
},
allow_child_item_selection: true,
child_fieldname: "items",
child_columns: ["item_code", "qty", "ordered_qty"],
child_columns: ["item_code", "item_name", "qty", "ordered_qty"],
});
},
__("Get Items From")

View File

@@ -959,8 +959,9 @@ class StockController(AccountsController):
make_sl_entries(sl_entries, allow_negative_stock, via_landed_cost_voucher)
update_batch_qty(self.doctype, self.name, via_landed_cost_voucher=via_landed_cost_voucher)
def make_gl_entries_on_cancel(self):
cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
def make_gl_entries_on_cancel(self, from_repost=False):
if not from_repost:
cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
if frappe.db.sql(
"""select name from `tabGL Entry` where voucher_type=%s
and voucher_no=%s""",

View File

@@ -284,6 +284,9 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
this.frm.set_value("currency", frappe.defaults.get_user_default("Currency"));
}
if (this.frm.is_new() && this.frm.doc.opportunity_type === undefined) {
this.frm.doc.opportunity_type = __("Sales");
}
this.setup_queries();
}

View File

@@ -152,7 +152,6 @@
"no_copy": 1
},
{
"default": "Sales",
"fieldname": "opportunity_type",
"fieldtype": "Link",
"in_list_view": 1,
@@ -670,4 +669,4 @@
"title_field": "title",
"track_seen": 1,
"track_views": 1
}
}

View File

@@ -126,6 +126,7 @@ class Opportunity(TransactionBase, CRMNote):
link_communications(self.opportunity_from, self.party_name, self)
def validate(self):
self.set_opportunity_type()
self.make_new_lead_if_required()
self.validate_item_details()
self.validate_uom_is_integer("uom", "qty")
@@ -150,6 +151,10 @@ class Opportunity(TransactionBase, CRMNote):
except Exception:
continue
def set_opportunity_type(self):
if self.is_new() and not self.opportunity_type:
self.opportunity_type = _("Sales")
def set_exchange_rate(self):
company_currency = frappe.get_cached_value("Company", self.company, "default_currency")
if self.currency == company_currency:

View File

@@ -23,6 +23,14 @@ frappe.ui.form.on("Job Card", {
};
});
frm.set_query("item_code", "scrap_items", () => {
return {
filters: {
disabled: 0,
},
};
});
frm.set_indicator_formatter("sub_operation", function (doc) {
if (doc.status == "Pending") {
return "red";

View File

@@ -419,3 +419,4 @@ erpnext.patches.v15_0.remove_sales_partner_from_consolidated_sales_invoice
erpnext.patches.v15_0.repost_gl_entries_with_no_account_subcontracting #2025-08-04
execute:frappe.db.set_single_value("Accounts Settings", "fetch_valuation_rate_for_internal_transaction", 1)
erpnext.patches.v15_0.add_company_payment_gateway_account
erpnext.patches.v15_0.update_uae_zero_rated_fetch

View File

@@ -0,0 +1,10 @@
import frappe
from erpnext.regional.united_arab_emirates.setup import make_custom_fields
def execute():
if not frappe.db.get_value("Company", {"country": "United Arab Emirates"}):
return
make_custom_fields()

View File

@@ -143,7 +143,7 @@ def get_total_emiratewise(filters):
on
i.parent = s.name
where
s.docstatus = 1 and i.is_exempt != 1 and i.is_zero_rated != 1
s.docstatus = 1 and i.is_exempt != 1 and i.is_zero_rated != 1
{conditions}
group by
s.vat_emirate;

View File

@@ -20,6 +20,7 @@ def make_custom_fields():
label="Is Zero Rated",
fieldtype="Check",
fetch_from="item_code.is_zero_rated",
fetch_if_empty=1,
insert_after="description",
print_hide=1,
)

View File

@@ -7,10 +7,6 @@ from erpnext.controllers.taxes_and_totals import get_itemised_tax
def update_itemised_tax_data(doc):
# maybe this should be a standard function rather than a regional one
if not doc.taxes:
return
if not doc.items:
return
@@ -20,6 +16,29 @@ def update_itemised_tax_data(doc):
itemised_tax = get_itemised_tax(doc.taxes)
def determine_if_export(doc):
if doc.doctype != "Sales Invoice":
return False
if not doc.customer_address:
if not doc.total_taxes_and_charges:
frappe.msgprint(
_("Please set Customer Address to determine if the transaction is an export."),
alert=True,
)
return False
company_country = frappe.get_cached_value("Company", doc.company, "country")
customer_country = frappe.db.get_value("Address", doc.customer_address, "country")
if company_country != customer_country:
return True
return False
is_export = determine_if_export(doc)
for row in doc.items:
tax_rate, tax_amount = 0.0, 0.0
# dont even bother checking in item tax template as it contains both input and output accounts - double the tax rate
@@ -30,6 +49,9 @@ def update_itemised_tax_data(doc):
tax_amount += flt((row.net_amount * _tax_rate) / 100, row.precision("tax_amount"))
tax_rate += _tax_rate
if not tax_rate or row.get("is_zero_rated"):
row.is_zero_rated = is_export or frappe.get_cached_value("Item", row.item_code, "is_zero_rated")
row.tax_rate = flt(tax_rate, row.precision("tax_rate"))
row.tax_amount = flt(tax_amount, row.precision("tax_amount"))
row.total_amount = flt((row.net_amount + row.tax_amount), row.precision("total_amount"))

View File

@@ -1219,6 +1219,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
read_only: 1,
fieldname: "uom",
label: __("UOM"),
options: "UOM",
in_list_view: 1,
},
{
@@ -1292,7 +1293,6 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
let pending_qty = (flt(d.stock_qty) - ordered_qty) / flt(d.conversion_factor);
if (pending_qty > 0) {
po_items.push({
doctype: "Sales Order Item",
name: d.name,
item_name: d.item_name,
item_code: d.item_code,

View File

@@ -1785,7 +1785,9 @@ def create_pick_list(source_name, target_doc=None):
doc.purpose = "Delivery"
doc.set_item_locations()
# Only auto-assign serial numbers if not picking manually
if not doc.pick_manually:
doc.set_item_locations()
return doc

View File

@@ -35,7 +35,7 @@ def get_brand_defaults(item, company):
for d in brand.brand_defaults or []:
if d.company == company:
row = copy.deepcopy(d.as_dict())
row = d.as_dict(no_private_properties=True)
row.pop("name")
return row

View File

@@ -90,7 +90,7 @@ def get_item_group_defaults(item, company):
for d in item_group.item_group_defaults or []:
if d.company == company:
row = copy.deepcopy(d.as_dict())
row = d.as_dict(no_private_properties=True)
row.pop("name")
return row

View File

@@ -26,13 +26,27 @@ class PartyType(Document):
@frappe.validate_and_sanitize_search_inputs
def get_party_type(doctype, txt, searchfield, start, page_len, filters):
cond = ""
account_type = None
if filters and filters.get("account"):
account_type = frappe.db.get_value("Account", filters.get("account"), "account_type")
cond = "and account_type = '%s'" % account_type
if account_type:
if account_type in ["Receivable", "Payable"]:
# Include Employee regardless of its configured account_type, but still respect the text filter
cond = "and (account_type = %(account_type)s or name = 'Employee')"
else:
cond = "and account_type = %(account_type)s"
return frappe.db.sql(
# Build parameters dictionary
params = {"txt": "%" + txt + "%", "start": start, "page_len": page_len}
if account_type:
params["account_type"] = account_type
result = frappe.db.sql(
f"""select name from `tabParty Type`
where `{searchfield}` LIKE %(txt)s {cond}
order by name limit %(page_len)s offset %(start)s""",
{"txt": "%" + txt + "%", "start": start, "page_len": page_len},
where `{searchfield}` LIKE %(txt)s {cond}
order by name limit %(page_len)s offset %(start)s""",
params,
)
return result or []

View File

@@ -318,7 +318,8 @@
"fieldname": "shelf_life_in_days",
"fieldtype": "Int",
"label": "Shelf Life In Days",
"mandatory_depends_on": "eval:doc.has_batch_no && doc.has_expiry_date"
"mandatory_depends_on": "eval:doc.has_batch_no && doc.has_expiry_date",
"non_negative": 1
},
{
"default": "2099-12-31",
@@ -362,7 +363,8 @@
"depends_on": "is_stock_item",
"fieldname": "weight_per_unit",
"fieldtype": "Float",
"label": "Weight Per Unit"
"label": "Weight Per Unit",
"non_negative": 1
},
{
"depends_on": "eval:doc.is_stock_item",
@@ -534,13 +536,15 @@
"fieldname": "min_order_qty",
"fieldtype": "Float",
"label": "Minimum Order Qty",
"non_negative": 1,
"oldfieldname": "min_order_qty",
"oldfieldtype": "Currency"
},
{
"fieldname": "safety_stock",
"fieldtype": "Float",
"label": "Safety Stock"
"label": "Safety Stock",
"non_negative": 1
},
{
"fieldname": "purchase_details_cb",
@@ -551,6 +555,7 @@
"fieldname": "lead_time_days",
"fieldtype": "Int",
"label": "Lead Time in days",
"non_negative": 1,
"oldfieldname": "lead_time_days",
"oldfieldtype": "Int"
},
@@ -559,6 +564,7 @@
"fieldtype": "Float",
"label": "Last Purchase Rate",
"no_copy": 1,
"non_negative": 1,
"oldfieldname": "last_purchase_rate",
"oldfieldtype": "Currency",
"read_only": 1
@@ -889,7 +895,7 @@
"image_field": "image",
"links": [],
"make_attachments_public": 1,
"modified": "2025-02-03 23:43:57.253667",
"modified": "2025-08-08 14:58:48.674193",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",
@@ -954,6 +960,7 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"search_fields": "item_name,description,item_group,customer_code",
"show_name_in_global_search": 1,
"show_preview_popup": 1,

View File

@@ -1281,7 +1281,7 @@ def get_item_defaults(item_code, company):
for d in item.item_defaults:
if d.company == company:
row = copy.deepcopy(d.as_dict())
row = d.as_dict(no_private_properties=True)
row.pop("name")
out.update(row)
return out

View File

@@ -81,6 +81,7 @@ frappe.ui.form.on("Pick List", {
};
});
},
set_item_locations: (frm, save) => {
if (!(frm.doc.locations && frm.doc.locations.length)) {
frappe.msgprint(__("Add items in the Item Locations table"));
@@ -101,11 +102,34 @@ frappe.ui.form.on("Pick List", {
},
pick_manually: function (frm) {
// Update warehouse field read-only property
frm.fields_dict.locations.grid.update_docfield_property(
"warehouse",
"read_only",
!frm.doc.pick_manually
);
// Clear auto-assigned serial numbers and related fields when switching to manual picking
if (frm.doc.pick_manually && frm.doc.locations) {
let has_changes = false;
frm.doc.locations.forEach((row) => {
if (row.serial_no || row.batch_no || row.serial_and_batch_bundle) {
row.serial_no = "";
row.batch_no = "";
row.serial_and_batch_bundle = "";
row.picked_qty = 0;
has_changes = true;
}
});
if (has_changes) {
frappe.show_alert(
__("Cleared auto-assigned serial numbers and batch numbers for manual picking"),
3
);
frm.refresh_field("locations");
}
}
},
get_item_locations: (frm) => {
@@ -273,7 +297,7 @@ frappe.ui.form.on("Pick List", {
max_qty_field: "qty",
dont_allow_new_row: true,
prompt_qty: frm.doc.prompt_qty,
serial_no_field: "not_supported", // doesn't make sense for picklist without a separate field.
serial_no_field: "serial_no",
};
const barcode_scanner = new erpnext.utils.BarcodeScanner(opts);
barcode_scanner.process_scan();

View File

@@ -572,10 +572,18 @@ class PickList(TransactionBase):
if not item.item_code:
frappe.throw(f"Row #{item.idx}: Item Code is Mandatory")
if not cint(
frappe.get_cached_value("Item", item.item_code, "is_stock_item")
) and not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code, "disabled": 0}):
continue
# Check if item is stock item or product bundle
is_stock_item = cint(frappe.get_cached_value("Item", item.item_code, "is_stock_item"))
is_product_bundle = frappe.db.exists(
"Product Bundle", {"new_item_code": item.item_code, "disabled": 0}
)
# Include non-stock items for delivery purposes, but skip them for warehouse assignment
if not is_stock_item and not is_product_bundle:
# For non-stock items, set warehouse to None and continue processing
item.warehouse = None
item_code = item.item_code
reference = item.sales_order_item or item.material_request_item
key = (item_code, item.uom, item.warehouse, item.batch_no, reference)

View File

@@ -567,20 +567,15 @@ def get_item_warehouse(item, args, overwrite_warehouse, defaults=None):
or args.get("warehouse")
)
if not warehouse:
defaults = frappe.defaults.get_defaults() or {}
warehouse_exists = frappe.db.exists(
"Warehouse", {"name": defaults.default_warehouse, "company": args.company}
)
if defaults.get("default_warehouse") and warehouse_exists:
warehouse = defaults.default_warehouse
else:
warehouse = args.get("warehouse")
if not warehouse:
default_warehouse = frappe.db.get_single_value("Stock Settings", "default_warehouse")
if frappe.db.get_value("Warehouse", default_warehouse, "company") == args.company:
default_warehouse = frappe.get_single_value("Stock Settings", "default_warehouse")
if (
default_warehouse
and frappe.get_cached_value("Warehouse", default_warehouse, "company") == args.company
):
return default_warehouse
return warehouse