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

chore: release v15
This commit is contained in:
ruthra kumar
2025-11-25 20:31:01 +05:30
committed by GitHub
57 changed files with 516 additions and 204 deletions

View File

@@ -19,7 +19,7 @@ frappe.ui.form.on("Currency Exchange Settings", {
to: "{to_currency}",
};
add_param(frm, r.message, params, result);
} else if (frm.doc.service_provider == "frankfurter.app") {
} else if (frm.doc.service_provider == "frankfurter.dev") {
let result = ["rates", "{to_currency}"];
let params = {
base: "{from_currency}",

View File

@@ -78,7 +78,7 @@
"fieldname": "service_provider",
"fieldtype": "Select",
"label": "Service Provider",
"options": "frankfurter.app\nexchangerate.host\nCustom",
"options": "frankfurter.dev\nexchangerate.host\nCustom",
"reqd": 1
},
{
@@ -104,7 +104,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2024-03-18 08:32:26.895076",
"modified": "2025-11-25 13:03:41.896424",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Currency Exchange Settings",
@@ -141,8 +141,9 @@
"write": 1
}
],
"sort_field": "modified",
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -29,7 +29,7 @@ class CurrencyExchangeSettings(Document):
disabled: DF.Check
req_params: DF.Table[CurrencyExchangeSettingsDetails]
result_key: DF.Table[CurrencyExchangeSettingsResult]
service_provider: DF.Literal["frankfurter.app", "exchangerate.host", "Custom"]
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "Custom"]
url: DF.Data | None
use_http: DF.Check
# end: auto-generated types
@@ -60,7 +60,7 @@ class CurrencyExchangeSettings(Document):
self.append("req_params", {"key": "date", "value": "{transaction_date}"})
self.append("req_params", {"key": "from", "value": "{from_currency}"})
self.append("req_params", {"key": "to", "value": "{to_currency}"})
elif self.service_provider == "frankfurter.app":
elif self.service_provider == "frankfurter.dev":
self.set("result_key", [])
self.set("req_params", [])
@@ -105,11 +105,11 @@ class CurrencyExchangeSettings(Document):
@frappe.whitelist()
def get_api_endpoint(service_provider: str | None = None, use_http: bool = False):
if service_provider and service_provider in ["exchangerate.host", "frankfurter.app"]:
if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev"]:
if service_provider == "exchangerate.host":
api = "api.exchangerate.host/convert"
elif service_provider == "frankfurter.app":
api = "api.frankfurter.app/{transaction_date}"
elif service_provider == "frankfurter.dev":
api = "api.frankfurter.dev/v1/{transaction_date}"
protocol = "https://"
if use_http:

View File

@@ -243,10 +243,13 @@ def get_other_conditions(conditions, values, args):
if group_condition:
conditions += " and " + group_condition
if args.get("transaction_date"):
date = args.get("transaction_date") or frappe.get_value(
args.get("doctype"), args.get("name"), "posting_date", ignore=True
)
if date:
conditions += """ and %(transaction_date)s between ifnull(`tabPricing Rule`.valid_from, '2000-01-01')
and ifnull(`tabPricing Rule`.valid_upto, '2500-12-31')"""
values["transaction_date"] = args.get("transaction_date")
values["transaction_date"] = date
if args.get("doctype") in [
"Quotation",

View File

@@ -12,6 +12,7 @@ erpnext.buying.setup_buying_controller();
erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.BuyingController {
setup(doc) {
this.setup_accounting_dimension_triggers();
this.setup_posting_date_time_check();
super.setup(doc);

View File

@@ -14,6 +14,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
erpnext.selling.SellingController
) {
setup(doc) {
this.setup_accounting_dimension_triggers();
this.setup_posting_date_time_check();
super.setup(doc);
this.frm.make_methods = {

View File

@@ -26,16 +26,13 @@ frappe.query_reports["Accounts Payable"] = {
{
fieldname: "cost_center",
label: __("Cost Center"),
fieldtype: "Link",
options: "Cost Center",
get_query: () => {
var company = frappe.query_report.get_filter_value("company");
return {
filters: {
company: company,
},
};
fieldtype: "MultiSelectList",
get_data: function (txt) {
return frappe.db.get_link_options("Cost Center", txt, {
company: frappe.query_report.get_filter_value("company"),
});
},
options: "Cost Center",
},
{
fieldname: "party_account",

View File

@@ -45,16 +45,13 @@ frappe.query_reports["Accounts Payable Summary"] = {
{
fieldname: "cost_center",
label: __("Cost Center"),
fieldtype: "Link",
options: "Cost Center",
get_query: () => {
var company = frappe.query_report.get_filter_value("company");
return {
filters: {
company: company,
},
};
fieldtype: "MultiSelectList",
get_data: function (txt) {
return frappe.db.get_link_options("Cost Center", txt, {
company: frappe.query_report.get_filter_value("company"),
});
},
options: "Cost Center",
},
{
fieldname: "party_type",

View File

@@ -28,16 +28,13 @@ frappe.query_reports["Accounts Receivable"] = {
{
fieldname: "cost_center",
label: __("Cost Center"),
fieldtype: "Link",
options: "Cost Center",
get_query: () => {
var company = frappe.query_report.get_filter_value("company");
return {
filters: {
company: company,
},
};
fieldtype: "MultiSelectList",
get_data: function (txt) {
return frappe.db.get_link_options("Cost Center", txt, {
company: frappe.query_report.get_filter_value("company"),
});
},
options: "Cost Center",
},
{
fieldname: "party_type",

View File

@@ -15,6 +15,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
get_dimension_with_children,
)
from erpnext.accounts.report.financial_statements import get_cost_centers_with_children
from erpnext.accounts.utils import (
build_qb_match_conditions,
get_advance_payment_doctypes,
@@ -994,11 +995,7 @@ class ReceivablePayableReport:
self.add_accounting_dimensions_filters()
def get_cost_center_conditions(self):
lft, rgt = frappe.db.get_value("Cost Center", self.filters.cost_center, ["lft", "rgt"])
cost_center_list = [
center.name
for center in frappe.get_list("Cost Center", filters={"lft": (">=", lft), "rgt": ("<=", rgt)})
]
cost_center_list = get_cost_centers_with_children(self.filters.cost_center)
self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list))
def add_common_filters(self):

View File

@@ -45,16 +45,13 @@ frappe.query_reports["Accounts Receivable Summary"] = {
{
fieldname: "cost_center",
label: __("Cost Center"),
fieldtype: "Link",
options: "Cost Center",
get_query: () => {
var company = frappe.query_report.get_filter_value("company");
return {
filters: {
company: company,
},
};
fieldtype: "MultiSelectList",
get_data: function (txt) {
return frappe.db.get_link_options("Cost Center", txt, {
company: frappe.query_report.get_filter_value("company"),
});
},
options: "Cost Center",
},
{
fieldname: "party_type",

View File

@@ -69,12 +69,18 @@ class PartyLedgerSummaryReport:
party_type = self.filters.party_type
doctype = qb.DocType(party_type)
party_details_fields = [
doctype.name.as_("party"),
f"{scrub(party_type)}_name",
f"{scrub(party_type)}_group",
]
if party_type == "Customer":
party_details_fields.append(doctype.territory)
conditions = self.get_party_conditions(doctype)
query = (
qb.from_(doctype)
.select(doctype.name.as_("party"), f"{scrub(party_type)}_name")
.where(Criterion.all(conditions))
)
query = qb.from_(doctype).select(*party_details_fields).where(Criterion.all(conditions))
from frappe.desk.reportview import build_match_conditions
@@ -153,6 +159,31 @@ class PartyLedgerSummaryReport:
credit_or_debit_note = "Credit Note" if self.filters.party_type == "Customer" else "Debit Note"
if self.filters.party_type == "Customer":
columns += [
{
"label": _("Customer Group"),
"fieldname": "customer_group",
"fieldtype": "Link",
"options": "Customer Group",
},
{
"label": _("Territory"),
"fieldname": "territory",
"fieldtype": "Link",
"options": "Territory",
},
]
else:
columns += [
{
"label": _("Supplier Group"),
"fieldname": "supplier_group",
"fieldtype": "Link",
"options": "Supplier Group",
}
]
columns += [
{
"label": _("Opening Balance"),
@@ -213,35 +244,6 @@ class PartyLedgerSummaryReport:
},
]
# Hidden columns for handling 'User Permissions'
if self.filters.party_type == "Customer":
columns += [
{
"label": _("Territory"),
"fieldname": "territory",
"fieldtype": "Link",
"options": "Territory",
"hidden": 1,
},
{
"label": _("Customer Group"),
"fieldname": "customer_group",
"fieldtype": "Link",
"options": "Customer Group",
"hidden": 1,
},
]
else:
columns += [
{
"label": _("Supplier Group"),
"fieldname": "supplier_group",
"fieldtype": "Link",
"options": "Supplier Group",
"hidden": 1,
}
]
return columns
def get_data(self):

View File

@@ -47,22 +47,23 @@ frappe.query_reports["Trial Balance"] = {
{
fieldname: "cost_center",
label: __("Cost Center"),
fieldtype: "Link",
options: "Cost Center",
get_query: function () {
var company = frappe.query_report.get_filter_value("company");
return {
doctype: "Cost Center",
filters: {
company: company,
},
};
fieldtype: "MultiSelectList",
get_data: function (txt) {
return frappe.db.get_link_options("Cost Center", txt, {
company: frappe.query_report.get_filter_value("company"),
});
},
options: "Cost Center",
},
{
fieldname: "project",
label: __("Project"),
fieldtype: "Link",
fieldtype: "MultiSelectList",
get_data: function (txt) {
return frappe.db.get_link_options("Project", txt, {
company: frappe.query_report.get_filter_value("company"),
});
},
options: "Project",
},
{

View File

@@ -15,6 +15,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
from erpnext.accounts.report.financial_statements import (
filter_accounts,
filter_out_zero_value_rows,
get_cost_centers_with_children,
set_gl_entries_by_account,
)
from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
@@ -103,10 +104,6 @@ def get_data(filters):
opening_balances = get_opening_balances(filters, ignore_is_opening)
# add filter inside list so that the query in financial_statements.py doesn't break
if filters.project:
filters.project = [filters.project]
set_gl_entries_by_account(
filters.company,
filters.from_date,
@@ -270,18 +267,12 @@ def get_opening_balance(
opening_balance = opening_balance.where(closing_balance.voucher_type != "Period Closing Voucher")
if filters.cost_center:
lft, rgt = frappe.db.get_value("Cost Center", filters.cost_center, ["lft", "rgt"])
cost_center = frappe.qb.DocType("Cost Center")
opening_balance = opening_balance.where(
closing_balance.cost_center.isin(
frappe.qb.from_(cost_center)
.select("name")
.where((cost_center.lft >= lft) & (cost_center.rgt <= rgt))
)
closing_balance.cost_center.isin(get_cost_centers_with_children(filters.get("cost_center")))
)
if filters.project:
opening_balance = opening_balance.where(closing_balance.project == filters.project)
opening_balance = opening_balance.where(closing_balance.project.isin(filters.project))
if frappe.db.count("Finance Book"):
if filters.get("include_default_book_entries"):

View File

@@ -202,7 +202,7 @@ frappe.ui.form.on("Asset", {
callback: function (r) {
if (!r.message) {
$(".primary-action").prop("hidden", true);
$(".form-message").text("Capitalize this asset to confirm");
$(".form-message").text(__("Capitalize this asset to confirm"));
frm.add_custom_button(__("Capitalize Asset"), function () {
frm.trigger("create_asset_capitalization");

View File

@@ -316,7 +316,6 @@ class AssetRepair(AccountsController):
"cost_center": self.cost_center,
"posting_date": self.completion_date,
"against_voucher_type": "Purchase Invoice",
"against_voucher": self.purchase_invoice,
"company": self.company,
},
item=self,

View File

@@ -36,6 +36,7 @@
"backflush_raw_materials_of_subcontract_based_on",
"column_break_11",
"over_transfer_allowance",
"validate_consumed_qty",
"section_break_xcug",
"auto_create_subcontracting_order",
"column_break_izrr",
@@ -270,6 +271,14 @@
"label": "Fixed Outgoing Email Account",
"link_filters": "[[\"Email Account\",\"enable_outgoing\",\"=\",1]]",
"options": "Email Account"
},
{
"default": "0",
"depends_on": "eval:doc.backflush_raw_materials_of_subcontract_based_on == \"Material Transferred for Subcontract\"",
"description": "Raw materials consumed qty will be validated based on FG BOM required qty",
"fieldname": "validate_consumed_qty",
"fieldtype": "Check",
"label": "Validate Consumed Qty (as per BOM)"
}
],
"grid_page_length": 50,
@@ -278,7 +287,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-08-20 22:13:38.506889",
"modified": "2025-11-20 12:59:09.925862",
"modified_by": "Administrator",
"module": "Buying",
"name": "Buying Settings",

View File

@@ -44,6 +44,7 @@ class BuyingSettings(Document):
supp_master_name: DF.Literal["Supplier Name", "Naming Series", "Auto Name"]
supplier_group: DF.Link | None
use_transaction_date_exchange_rate: DF.Check
validate_consumed_qty: DF.Check
# end: auto-generated types
def validate(self):

View File

@@ -303,6 +303,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
erpnext.buying.BuyingController
) {
setup() {
this.setup_accounting_dimension_triggers();
this.frm.custom_make_buttons = {
"Purchase Receipt": "Purchase Receipt",
"Purchase Invoice": "Purchase Invoice",

View File

@@ -41,18 +41,20 @@ frappe.ui.form.on("Supplier", {
frm.set_query("supplier_primary_contact", function (doc) {
return {
query: "erpnext.buying.doctype.supplier.supplier.get_supplier_primary_contact",
query: "erpnext.buying.doctype.supplier.supplier.get_supplier_primary",
filters: {
supplier: doc.name,
type: "Contact",
},
};
});
frm.set_query("supplier_primary_address", function (doc) {
return {
query: "erpnext.buying.doctype.supplier.supplier.get_supplier_primary",
filters: {
link_doctype: "Supplier",
link_name: doc.name,
supplier: doc.name,
type: "Address",
},
};
});

View File

@@ -215,19 +215,25 @@ class Supplier(TransactionBase):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_supplier_primary_contact(doctype, txt, searchfield, start, page_len, filters):
def get_supplier_primary(doctype, txt, searchfield, start, page_len, filters):
supplier = filters.get("supplier")
contact = frappe.qb.DocType("Contact")
type = filters.get("type")
type_doctype = frappe.qb.DocType(type)
dynamic_link = frappe.qb.DocType("Dynamic Link")
return (
frappe.qb.from_(contact)
query = (
frappe.qb.from_(type_doctype)
.join(dynamic_link)
.on(contact.name == dynamic_link.parent)
.select(contact.name, contact.email_id)
.on(type_doctype.name == dynamic_link.parent)
.select(type_doctype.name)
.where(
(dynamic_link.link_name == supplier)
& (dynamic_link.link_doctype == "Supplier")
& (contact.name.like(f"%{txt}%"))
& (type_doctype.name.like(f"%{txt}%"))
)
).run(as_dict=False)
)
if type == "Contact":
query = query.select(type_doctype.email_id)
return query.run()

View File

@@ -93,6 +93,7 @@ status_map = {
["Draft", None],
["To Bill", "eval:self.per_billed == 0 and self.docstatus == 1"],
["Partly Billed", "eval:self.per_billed > 0 and self.per_billed < 100 and self.docstatus == 1"],
["Return", "eval:self.is_return == 1 and self.per_billed == 0 and self.docstatus == 1"],
["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"],
[
"Completed",

View File

@@ -505,7 +505,7 @@ class SubcontractingController(StockController):
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):
def _get_materials_from_bom(self, item_code, bom_no, exploded_item=0):
doctype = "BOM Item" if not exploded_item else "BOM Explosion Item"
fields = [f"`tab{doctype}`.`stock_qty` / `tabBOM`.`quantity` as qty_consumed_per_unit"]
@@ -849,7 +849,7 @@ class SubcontractingController(StockController):
if self.doctype == self.subcontract_data.order_doctype or (
self.backflush_based_on == "BOM" or self.is_return
):
for bom_item in self.__get_materials_from_bom(
for bom_item in self._get_materials_from_bom(
row.item_code, row.bom, row.get("include_exploded_items")
):
qty = flt(bom_item.qty_consumed_per_unit) * flt(row.qty) * row.conversion_factor

View File

@@ -548,12 +548,14 @@
{
"fieldname": "process_loss_percentage",
"fieldtype": "Percent",
"label": "% Process Loss"
"label": "% Process Loss",
"non_negative": 1
},
{
"fieldname": "process_loss_qty",
"fieldtype": "Float",
"label": "Process Loss Qty",
"non_negative": 1,
"read_only": 1
},
{
@@ -639,7 +641,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2025-10-29 17:43:12.966753",
"modified": "2025-11-19 16:17:15.925156",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM",

View File

@@ -465,7 +465,7 @@ class BOM(WebsiteGenerator):
)
)
def get_rm_rate(self, arg):
def get_rm_rate(self, arg, notify=True):
"""Get raw material rate as per selected method, if bom exists takes bom cost"""
rate = 0
if not self.rm_cost_as_per:
@@ -491,7 +491,7 @@ class BOM(WebsiteGenerator):
),
alert=True,
)
else:
elif notify:
frappe.msgprint(
_("{0} not found for item {1}").format(self.rm_cost_as_per, arg["item_code"]),
alert=True,
@@ -796,11 +796,14 @@ class BOM(WebsiteGenerator):
"stock_uom": d.stock_uom,
"conversion_factor": d.conversion_factor,
"sourced_by_supplier": d.sourced_by_supplier,
}
},
notify=False,
)
d.base_rate = flt(d.rate) * flt(self.conversion_rate)
d.amount = flt(d.rate, d.precision("rate")) * flt(d.qty, d.precision("qty"))
d.amount = flt(
flt(d.rate, d.precision("rate")) * flt(d.qty, d.precision("qty")), d.precision("amount")
)
d.base_amount = d.amount * flt(self.conversion_rate)
d.qty_consumed_per_unit = flt(d.stock_qty, d.precision("stock_qty")) / flt(
self.quantity, self.precision("quantity")
@@ -823,7 +826,10 @@ class BOM(WebsiteGenerator):
d.base_rate = flt(d.rate, d.precision("rate")) * flt(
self.conversion_rate, self.precision("conversion_rate")
)
d.amount = flt(d.rate, d.precision("rate")) * flt(d.stock_qty, d.precision("stock_qty"))
d.amount = flt(
flt(d.rate, d.precision("rate")) * flt(d.stock_qty, d.precision("stock_qty")),
d.precision("amount"),
)
d.base_amount = flt(d.amount, d.precision("amount")) * flt(
self.conversion_rate, self.precision("conversion_rate")
)

View File

@@ -38,6 +38,15 @@ frappe.ui.form.on("Job Card", {
return doc.status === "Complete" ? "green" : "orange";
}
});
frm.set_query("employee", () => {
return {
filters: {
company: frm.doc.company,
status: "Active",
},
};
});
},
refresh: function (frm) {

View File

@@ -318,7 +318,7 @@
"type": "Link"
}
],
"modified": "2024-10-21 14:13:38.777556",
"modified": "2025-11-24 11:11:28.343568",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Manufacturing",
@@ -336,7 +336,7 @@
"doc_view": "List",
"label": "Learn Manufacturing",
"type": "URL",
"url": "https://school.frappe.io/lms/courses/manufacturing?utm_source=in_app"
"url": "https://school.frappe.io/lms/courses/production-planning-and-execution"
},
{
"color": "Grey",

View File

@@ -426,3 +426,4 @@ erpnext.patches.v15_0.set_asset_status_if_not_already_set
erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing
execute:frappe.db.set_single_value("Accounts Settings", "show_party_balance", 1)
execute:frappe.db.set_single_value("Accounts Settings", "show_account_balance", 1)
erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter

View File

@@ -0,0 +1,12 @@
import frappe
def execute():
settings = frappe.get_doc("Currency Exchange Settings")
if settings.service_provider != "frankfurter.app":
return
settings.service_provider = "frankfurter.dev"
settings.set_parameters_and_result()
settings.flags.ignore_validate = True
settings.save()

View File

@@ -2749,6 +2749,23 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
]);
}
}
setup_accounting_dimension_triggers() {
frappe.call({
method: "erpnext.accounts.doctype.accounting_dimension.accounting_dimension.get_dimensions",
callback: function (r) {
if (r.message && r.message[0]) {
let dimensions = r.message[0].map((d) => d.fieldname);
dimensions.forEach((dim) => {
// nosemgrep: frappe-semgrep-rules.rules.frappe-cur-frm-usage
cur_frm.cscript[dim] = function (doc, cdt, cdn) {
erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", dim);
};
});
}
},
});
}
};
erpnext.show_serial_batch_selector = function (frm, item_row, callback, on_close, show_dialog) {

View File

@@ -79,18 +79,35 @@ erpnext.financial_statements = {
},
open_general_ledger: function (data) {
if (!data.account && !data.accounts) return;
let project = $.grep(frappe.query_report.filters, function (e) {
let filters = frappe.query_report.filters;
let project = $.grep(filters, function (e) {
return e.df.fieldname == "project";
});
let cost_center = $.grep(filters, function (e) {
return e.df.fieldname == "cost_center";
});
frappe.route_options = {
account: data.account || data.accounts,
company: frappe.query_report.get_filter_value("company"),
from_date: data.from_date || data.year_start_date,
to_date: data.to_date || data.year_end_date,
project: project && project.length > 0 ? project[0].$input.val() : "",
project: project && project.length > 0 ? project[0].get_value() : "",
cost_center: cost_center && cost_center.length > 0 ? cost_center[0].get_value() : "",
};
filters.forEach((f) => {
if (f.df.fieldtype == "MultiSelectList") {
if (f.df.fieldname in frappe.route_options) return;
let value = f.get_value();
if (value && value.length > 0) {
frappe.route_options[f.df.fieldname] = value;
}
}
});
let report = "General Ledger";
if (["Payable", "Receivable"].includes(data.account_type)) {

View File

@@ -55,17 +55,20 @@ frappe.ui.form.on("Customer", {
frm.set_query("customer_primary_contact", function (doc) {
return {
query: "erpnext.selling.doctype.customer.customer.get_customer_primary_contact",
query: "erpnext.selling.doctype.customer.customer.get_customer_primary",
filters: {
customer: doc.name,
type: "Contact",
},
};
});
frm.set_query("customer_primary_address", function (doc) {
return {
query: "erpnext.selling.doctype.customer.customer.get_customer_primary",
filters: {
link_doctype: "Customer",
link_name: doc.name,
customer: doc.name,
type: "Address",
},
};
});

View File

@@ -610,7 +610,7 @@
"link_fieldname": "party"
}
],
"modified": "2025-03-05 10:01:47.885574",
"modified": "2025-11-25 09:35:56.772949",
"modified_by": "Administrator",
"module": "Selling",
"name": "Customer",
@@ -696,4 +696,4 @@
"states": [],
"title_field": "customer_name",
"track_changes": 1
}
}

View File

@@ -232,7 +232,7 @@ class Customer(TransactionBase):
self.update_lead_status()
if self.flags.is_new_doc:
self.link_lead_address_and_contact()
self.link_address_and_contact()
self.copy_communication()
self.update_customer_groups()
@@ -272,15 +272,23 @@ class Customer(TransactionBase):
if self.lead_name:
frappe.db.set_value("Lead", self.lead_name, "status", "Converted")
def link_lead_address_and_contact(self):
if self.lead_name:
# assign lead address and contact to customer (if already not set)
def link_address_and_contact(self):
linked_documents = {
"Lead": self.lead_name,
"Opportunity": self.opportunity_name,
"Prospect": self.prospect_name,
}
for doctype, docname in linked_documents.items():
# assign lead, opportunity and prospect address and contact to customer (if already not set)
if not docname:
continue
linked_contacts_and_addresses = frappe.get_all(
"Dynamic Link",
filters=[
["parenttype", "in", ["Contact", "Address"]],
["link_doctype", "=", "Lead"],
["link_name", "=", self.lead_name],
["link_doctype", "=", doctype],
["link_name", "=", docname],
],
fields=["parent as name", "parenttype as doctype"],
)
@@ -792,21 +800,29 @@ def make_address(args, is_primary_address=1, is_shipping_address=1):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_customer_primary_contact(doctype, txt, searchfield, start, page_len, filters):
def get_customer_primary(doctype, txt, searchfield, start, page_len, filters):
customer = filters.get("customer")
con = qb.DocType("Contact")
type = filters.get("type")
type_doctype = qb.DocType(type)
dlink = qb.DocType("Dynamic Link")
return (
qb.from_(con)
query = (
qb.from_(type_doctype)
.join(dlink)
.on(con.name == dlink.parent)
.select(con.name, con.email_id)
.where((dlink.link_name == customer) & (con.name.like(f"%{txt}%")))
.run()
.on(type_doctype.name == dlink.parent)
.select(type_doctype.name)
.where(
(dlink.link_name == customer)
& (type_doctype.name.like(f"%{txt}%"))
& (dlink.link_doctype == "Customer")
)
)
if type == "Contact":
query = query.select(type_doctype.email_id)
return query.run()
def parse_full_name(full_name: str) -> tuple[str, str | None, str | None]:
"""Parse full name into first name, middle name and last name"""

View File

@@ -574,6 +574,9 @@ frappe.ui.form.on("Sales Order Item", {
});
erpnext.selling.SalesOrderController = class SalesOrderController extends erpnext.selling.SellingController {
setup() {
this.setup_accounting_dimension_triggers();
}
onload(doc, dt, dn) {
super.onload(doc, dt, dn);
}

View File

@@ -68,9 +68,9 @@ def patched_requests_get(*args, **kwargs):
if kwargs["params"].get("date") and kwargs["params"].get("from") and kwargs["params"].get("to"):
if test_exchange_values.get(kwargs["params"]["date"]):
return PatchResponse({"result": test_exchange_values[kwargs["params"]["date"]]}, 200)
elif args[0].startswith("https://api.frankfurter.app") and kwargs.get("params"):
elif args[0].startswith("https://api.frankfurter.dev") and kwargs.get("params"):
if kwargs["params"].get("base") and kwargs["params"].get("symbols"):
date = args[0].replace("https://api.frankfurter.app/", "")
date = args[0].replace("https://api.frankfurter.dev/v1/", "")
if test_exchange_values.get(date):
return PatchResponse(
{"rates": {kwargs["params"].get("symbols"): test_exchange_values.get(date)}}, 200
@@ -149,7 +149,7 @@ class TestCurrencyExchange(unittest.TestCase):
self.assertEqual(flt(exchange_rate, 3), 65.1)
settings = frappe.get_single("Currency Exchange Settings")
settings.service_provider = "frankfurter.app"
settings.service_provider = "frankfurter.dev"
settings.save()
def test_exchange_rate_strict(self, mock_get):

View File

@@ -93,7 +93,7 @@ def setup_currency_exchange():
ces.set("result_key", [])
ces.set("req_params", [])
ces.api_endpoint = "https://api.frankfurter.app/{transaction_date}"
ces.api_endpoint = "https://api.frankfurter.dev/v1/{transaction_date}"
ces.append("result_key", {"key": "rates"})
ces.append("result_key", {"key": "{to_currency}"})
ces.append("req_params", {"key": "base", "value": "{from_currency}"})

View File

@@ -140,6 +140,7 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends (
erpnext.selling.SellingController
) {
setup(doc) {
this.setup_accounting_dimension_triggers();
this.setup_posting_date_time_check();
super.setup(doc);
this.frm.make_methods = {

View File

@@ -332,6 +332,9 @@ frappe.ui.form.on("Material Request", {
label: __("For Warehouse"),
options: "Warehouse",
reqd: 1,
get_query: function () {
return { filters: { company: frm.doc.company } };
},
},
{ fieldname: "qty", fieldtype: "Float", label: __("Quantity"), reqd: 1, default: 1 },
{

View File

@@ -108,7 +108,12 @@ def get_indexed_packed_items_table(doc):
"""
indexed_table = {}
for packed_item in doc.get("packed_items"):
key = (packed_item.parent_item, packed_item.item_code, packed_item.parent_detail_docname)
key = (
packed_item.parent_item,
packed_item.item_code,
packed_item.idx if doc.is_new() else packed_item.parent_detail_docname,
)
indexed_table[key] = packed_item
return indexed_table
@@ -169,7 +174,11 @@ def add_packed_item_row(doc, packing_item, main_item_row, packed_items_table, re
exists, pi_row = False, {}
# check if row already exists in packed items table
key = (main_item_row.item_code, packing_item.item_code, main_item_row.name)
key = (
main_item_row.item_code,
packing_item.item_code,
main_item_row.idx if doc.is_new() else main_item_row.name,
)
if packed_items_table.get(key):
pi_row, exists = packed_items_table.get(key), True

View File

@@ -7,7 +7,7 @@ from itertools import groupby
import frappe
from frappe import _, bold
from frappe.model.mapper import get_mapped_doc, map_child_doc
from frappe.model.mapper import map_child_doc
from frappe.query_builder import Case
from frappe.query_builder.custom import GROUP_CONCAT
from frappe.query_builder.functions import Coalesce, Locate, Replace, Sum
@@ -646,8 +646,8 @@ class PickList(TransactionBase):
product_bundles = self._get_product_bundles()
product_bundle_qty_map = self._get_product_bundle_qty_map(product_bundles.values())
for so_row, item_code in product_bundles.items():
picked_qty = self._compute_picked_qty_for_bundle(so_row, product_bundle_qty_map[item_code])
for so_row, value in product_bundles.items():
picked_qty = self._compute_picked_qty_for_bundle(so_row, product_bundle_qty_map[value.item_code])
item_table = "Sales Order Item"
already_picked = frappe.db.get_value(item_table, so_row, "picked_qty", for_update=True)
frappe.db.set_value(
@@ -770,19 +770,23 @@ class PickList(TransactionBase):
if not item.product_bundle_item:
continue
product_bundles[item.sales_order_item] = frappe.db.get_value(
"Sales Order Item",
item.sales_order_item,
"item_code",
product_bundles[item.sales_order_item] = frappe._dict(
{
"item_code": frappe.db.get_value(
"Sales Order Item",
item.sales_order_item,
"item_code",
),
"pick_list_item": item.name,
}
)
return product_bundles
def _get_product_bundle_qty_map(self, bundles: list[str]) -> dict[str, dict[str, float]]:
# bundle_item_code: Dict[component, qty]
def _get_product_bundle_qty_map(self, bundles) -> dict[str, dict[str, float]]:
product_bundle_qty_map = {}
for bundle_item_code in bundles:
bundle = frappe.get_last_doc("Product Bundle", {"new_item_code": bundle_item_code, "disabled": 0})
product_bundle_qty_map[bundle_item_code] = {item.item_code: item.qty for item in bundle.items}
for data in bundles:
bundle = frappe.get_last_doc("Product Bundle", {"new_item_code": data.item_code, "disabled": 0})
product_bundle_qty_map[data.item_code] = {item.item_code: item.qty for item in bundle.items}
return product_bundle_qty_map
def _compute_picked_qty_for_bundle(self, bundle_row, bundle_items) -> int:
@@ -1388,15 +1392,16 @@ def add_product_bundles_to_delivery_note(
product_bundles = pick_list._get_product_bundles()
product_bundle_qty_map = pick_list._get_product_bundle_qty_map(product_bundles.values())
for so_row, item_code in product_bundles.items():
for so_row, value in product_bundles.items():
sales_order_item = frappe.get_doc("Sales Order Item", so_row)
if sales_order and sales_order_item.parent != sales_order:
continue
dn_bundle_item = map_child_doc(sales_order_item, delivery_note, item_mapper)
dn_bundle_item.qty = pick_list._compute_picked_qty_for_bundle(
so_row, product_bundle_qty_map[item_code]
so_row, product_bundle_qty_map[value.item_code]
)
dn_bundle_item.pick_list_item = value.pick_list_item
dn_bundle_item.against_pick_list = pick_list.name
update_delivery_note_item(sales_order_item, dn_bundle_item, delivery_note)

View File

@@ -195,6 +195,7 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend
erpnext.buying.BuyingController
) {
setup(doc) {
this.setup_accounting_dimension_triggers();
this.setup_posting_date_time_check();
super.setup(doc);
}

View File

@@ -893,7 +893,7 @@
"no_copy": 1,
"oldfieldname": "status",
"oldfieldtype": "Select",
"options": "\nDraft\nPartly Billed\nTo Bill\nCompleted\nReturn Issued\nCancelled\nClosed",
"options": "\nDraft\nPartly Billed\nTo Bill\nCompleted\nReturn\nReturn Issued\nCancelled\nClosed",
"print_hide": 1,
"print_width": "150px",
"read_only": 1,
@@ -1300,7 +1300,7 @@
"idx": 261,
"is_submittable": 1,
"links": [],
"modified": "2025-08-06 16:41:02.690658",
"modified": "2025-11-12 19:53:48.173096",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt",

View File

@@ -117,7 +117,15 @@ class PurchaseReceipt(BuyingController):
shipping_address_display: DF.SmallText | None
shipping_rule: DF.Link | None
status: DF.Literal[
"", "Draft", "Partly Billed", "To Bill", "Completed", "Return Issued", "Cancelled", "Closed"
"",
"Draft",
"Partly Billed",
"To Bill",
"Completed",
"Return",
"Return Issued",
"Cancelled",
"Closed",
]
subcontracting_receipt: DF.Link | None
supplied_items: DF.Table[PurchaseReceiptItemSupplied]

View File

@@ -18,6 +18,9 @@ def get_data():
"Purchase Order": ["items", "purchase_order"],
"Project": ["items", "project"],
},
"internal_and_external_links": {
"Purchase Invoice": ["items", "purchase_invoice"],
},
"transactions": [
{
"label": _("Related"),

View File

@@ -11,7 +11,7 @@ frappe.listview_settings["Purchase Receipt"] = {
"currency",
],
get_indicator: function (doc) {
if (cint(doc.is_return) == 1) {
if (cint(doc.is_return) == 1 && doc.status == "Return") {
return [__("Return"), "gray", "is_return,=,Yes"];
} else if (doc.status === "Closed") {
return [__("Closed"), "green", "status,=,Closed"];

View File

@@ -455,6 +455,7 @@ class TestPurchaseReceipt(FrappeTestCase):
# Check if Original PR updated
self.assertEqual(pr.items[0].returned_qty, 2)
self.assertEqual(pr.per_returned, 40)
self.assertEqual(returned.status, "Return")
from erpnext.controllers.sales_and_purchase_return import make_return_doc
@@ -2128,7 +2129,7 @@ class TestPurchaseReceipt(FrappeTestCase):
return_pr.items[0].stock_qty = 0.0
return_pr.submit()
self.assertEqual(return_pr.status, "To Bill")
self.assertEqual(return_pr.status, "Return")
pi = make_purchase_invoice(return_pr.name)
pi.submit()

View File

@@ -1347,7 +1347,36 @@ class SerialandBatchBundle(Document):
if self.voucher_type == "POS Invoice":
return
if frappe.db.get_value(self.voucher_type, self.voucher_no, "docstatus") == 1:
child_doctype = self.voucher_type + " Item"
mapper = {
"Asset Capitalization": "Asset Capitalization Stock Item",
"Asset Repair": "Asset Repair Consumed Item",
"Stock Entry": "Stock Entry Detail",
}.get(self.voucher_type)
if mapper:
child_doctype = mapper
if self.voucher_type == "Delivery Note" and not frappe.db.exists(
"Delivery Note Item", self.voucher_detail_no
):
child_doctype = "Packed Item"
elif self.voucher_type == "Sales Invoice" and not frappe.db.exists(
"Sales Invoice Item", self.voucher_detail_no
):
child_doctype = "Packed Item"
elif self.voucher_type == "Subcontracting Receipt" and not frappe.db.exists(
"Subcontracting Receipt Item", self.voucher_detail_no
):
child_doctype = "Subcontracting Receipt Supplied Item"
if (
frappe.db.get_value(self.voucher_type, self.voucher_no, "docstatus") == 1
and self.voucher_detail_no
and frappe.db.exists(child_doctype, self.voucher_detail_no)
):
msg = f"""The {self.voucher_type} {bold(self.voucher_no)}
is in submitted state, please cancel it first"""
frappe.throw(_(msg))

View File

@@ -951,12 +951,10 @@ frappe.ui.form.on("Stock Entry Detail", {
no_batch_serial_number_value = true;
}
if (
no_batch_serial_number_value &&
!frappe.flags.hide_serial_batch_dialog &&
!frappe.flags.dialog_set
) {
frappe.flags.dialog_set = true;
if (no_batch_serial_number_value && !frappe.flags.hide_serial_batch_dialog) {
if (!frappe.flags.dialog_set) {
frappe.flags.dialog_set = true;
}
erpnext.stock.select_batch_and_serial_no(frm, d);
} else {
frappe.flags.dialog_set = false;

View File

@@ -1136,7 +1136,9 @@ class StockEntry(StockController):
"qty": row.transfer_qty * -1,
}
).update_serial_and_batch_entries()
elif not row.serial_and_batch_bundle:
elif not row.serial_and_batch_bundle and frappe.get_single_value(
"Stock Settings", "auto_create_serial_and_batch_bundle_for_outward"
):
bundle_doc = SerialBatchCreation(
{
"item_code": row.item_code,

View File

@@ -979,6 +979,7 @@ class StockReconciliation(StockController):
is_customer_item = frappe.db.get_value("Item", d.item_code, "is_customer_provided_item")
if is_customer_item and d.valuation_rate:
d.valuation_rate = 0.0
d.allow_zero_valuation_rate = 1
changed_any_values = True
if changed_any_values:

View File

@@ -185,12 +185,10 @@
},
{
"default": "0",
"depends_on": "allow_zero_valuation_rate",
"fieldname": "allow_zero_valuation_rate",
"fieldtype": "Check",
"label": "Allow Zero Valuation Rate",
"print_hide": 1,
"read_only": 1
"print_hide": 1
},
{
"depends_on": "barcode",
@@ -256,7 +254,7 @@
],
"istable": 1,
"links": [],
"modified": "2025-03-12 16:34:51.326821",
"modified": "2025-11-20 15:27:13.868179",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reconciliation Item",
@@ -267,4 +265,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -221,7 +221,9 @@ def get_child_warehouses(warehouse):
def get_warehouses_based_on_account(account, company=None):
warehouses = []
for d in frappe.get_all("Warehouse", fields=["name", "is_group"], filters={"account": account}):
for d in frappe.get_all(
"Warehouse", fields=["name", "is_group"], filters={"account": account, "disabled": 0}
):
if d.is_group:
warehouses.extend(get_child_warehouses(d.name))
else:

View File

@@ -259,7 +259,7 @@ class SerialBatchBundle:
and not self.sle.serial_and_batch_bundle
and self.item_details.has_batch_no == 1
and (
self.item_details.create_new_batch
(self.item_details.create_new_batch and self.sle.actual_qty > 0)
or (
frappe.db.get_single_value(
"Stock Settings", "auto_create_serial_and_batch_bundle_for_outward"

View File

@@ -187,7 +187,7 @@ class SubcontractingOrder(SubcontractingController):
for item in self.get("items"):
bom = frappe.get_doc("BOM", item.bom)
rm_cost = sum(flt(rm_item.amount) for rm_item in bom.items)
item.rm_cost_per_qty = rm_cost / flt(bom.quantity)
item.rm_cost_per_qty = flt(rm_cost / flt(bom.quantity), item.precision("rm_cost_per_qty"))
def calculate_items_qty_and_amount(self):
total_qty = total = 0

View File

@@ -1,6 +1,8 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from collections import defaultdict
import frappe
from frappe import _
from frappe.model.mapper import get_mapped_doc
@@ -17,6 +19,10 @@ from erpnext.stock.get_item_details import get_default_cost_center, get_default_
from erpnext.stock.stock_ledger import get_valuation_rate
class BOMQuantityError(frappe.ValidationError):
pass
class SubcontractingReceipt(SubcontractingController):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@@ -156,6 +162,7 @@ class SubcontractingReceipt(SubcontractingController):
def on_submit(self):
self.validate_closed_subcontracting_order()
self.validate_available_qty_for_consumption()
self.validate_bom_required_qty()
self.update_status_updater_args()
self.update_prevdoc_status()
self.set_subcontracting_order_status(update_bin=False)
@@ -512,12 +519,60 @@ class SubcontractingReceipt(SubcontractingController):
item.available_qty_for_consumption
and flt(item.available_qty_for_consumption, precision) - flt(item.consumed_qty, precision) < 0
):
msg = f"""Row {item.idx}: Consumed Qty {flt(item.consumed_qty, precision)}
must be less than or equal to Available Qty For Consumption
{flt(item.available_qty_for_consumption, precision)}
in Consumed Items Table."""
msg = _(
"""Row {0}: Consumed Qty {1} {2} must be less than or equal to Available Qty For Consumption
{3} {4} in Consumed Items Table."""
).format(
item.idx,
flt(item.consumed_qty, precision),
item.stock_uom,
flt(item.available_qty_for_consumption, precision),
item.stock_uom,
)
frappe.throw(_(msg))
frappe.throw(msg)
def validate_bom_required_qty(self):
if (
frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on")
== "Material Transferred for Subcontract"
) and not (frappe.db.get_single_value("Buying Settings", "validate_consumed_qty")):
return
rm_consumed_dict = self.get_rm_wise_consumed_qty()
for row in self.items:
precision = row.precision("qty")
for bom_item in self._get_materials_from_bom(
row.item_code, row.bom, row.get("include_exploded_items")
):
required_qty = flt(
bom_item.qty_consumed_per_unit * row.qty * row.conversion_factor, precision
)
consumed_qty = rm_consumed_dict.get(bom_item.rm_item_code, 0)
diff = flt(consumed_qty, precision) - flt(required_qty, precision)
if diff < 0:
msg = _(
"""Additional {0} {1} of item {2} required as per BOM to complete this transaction"""
).format(
frappe.bold(abs(diff)),
frappe.bold(bom_item.stock_uom),
frappe.bold(bom_item.rm_item_code),
)
frappe.throw(
msg,
exc=BOMQuantityError,
)
def get_rm_wise_consumed_qty(self):
rm_dict = defaultdict(float)
for row in self.supplied_items:
rm_dict[row.rm_item_code] += row.consumed_qty
return rm_dict
def update_status_updater_args(self):
if cint(self.is_return):

View File

@@ -38,6 +38,9 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
make_subcontracting_receipt,
)
from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import (
BOMQuantityError,
)
class TestSubcontractingReceipt(FrappeTestCase):
@@ -174,7 +177,7 @@ class TestSubcontractingReceipt(FrappeTestCase):
def test_subcontracting_over_receipt(self):
"""
Behaviour: Raise multiple SCRs against one SCO that in total
receive more than the required qty in the SCO.
receive more than the required qty in the SCO.
Expected Result: Error Raised for Over Receipt against SCO.
"""
from erpnext.controllers.subcontracting_controller import (
@@ -1785,6 +1788,109 @@ class TestSubcontractingReceipt(FrappeTestCase):
self.assertEqual(scr.items[0].rm_cost_per_qty, 300)
self.assertEqual(scr.items[0].service_cost_per_qty, 100)
def test_bom_required_qty_validation_based_on_bom(self):
set_backflush_based_on("BOM")
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1)
fg_item = make_item(properties={"is_stock_item": 1, "is_sub_contracted_item": 1}).name
rm_item1 = make_item(
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "BRQV-.####",
}
).name
make_bom(item=fg_item, raw_materials=[rm_item1], rm_qty=2)
se = make_stock_entry(
item_code=rm_item1,
qty=1,
target="_Test Warehouse 1 - _TC",
rate=300,
)
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
service_items = [
{
"warehouse": "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 1",
"qty": 1,
"rate": 100,
"fg_item": fg_item,
"fg_item_qty": 1,
},
]
sco = get_subcontracting_order(service_items=service_items)
scr = make_subcontracting_receipt(sco.name)
scr.save()
scr.reload()
self.assertEqual(scr.supplied_items[0].batch_no, batch_no)
self.assertEqual(scr.supplied_items[0].consumed_qty, 1)
self.assertEqual(scr.supplied_items[0].required_qty, 2)
self.assertRaises(BOMQuantityError, scr.submit)
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 0)
def test_bom_required_qty_validation_based_on_transfer(self):
from erpnext.controllers.subcontracting_controller import (
make_rm_stock_entry as make_subcontract_transfer_entry,
)
set_backflush_based_on("Material Transferred for Subcontract")
frappe.db.set_single_value("Buying Settings", "validate_consumed_qty", 1)
item_code = "_Test Subcontracted Validation FG Item 1"
rm_item1 = make_item(
properties={
"is_stock_item": 1,
}
).name
make_subcontracted_item(item_code=item_code, raw_materials=[rm_item1])
service_items = [
{
"warehouse": "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 1",
"qty": 10,
"rate": 100,
"fg_item": item_code,
"fg_item_qty": 10,
},
]
sco = get_subcontracting_order(
service_items=service_items,
include_exploded_items=0,
)
# inward raw material stock
make_stock_entry(target="_Test Warehouse - _TC", item_code=rm_item1, qty=10, basic_rate=100)
rm_items = [
{
"item_code": item_code,
"rm_item_code": sco.supplied_items[0].rm_item_code,
"qty": sco.supplied_items[0].required_qty - 5,
"warehouse": "_Test Warehouse - _TC",
"stock_uom": "Nos",
},
]
# transfer partial raw materials
ste = frappe.get_doc(make_subcontract_transfer_entry(sco.name, rm_items))
ste.to_warehouse = "_Test Warehouse 1 - _TC"
ste.save()
ste.submit()
scr = make_subcontracting_receipt(sco.name)
scr.save()
self.assertRaises(BOMQuantityError, scr.submit)
def make_return_subcontracting_receipt(**args):
args = frappe._dict(args)