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

chore: release v15
This commit is contained in:
ruthra kumar
2025-08-26 17:15:09 +05:30
committed by GitHub
26 changed files with 186 additions and 84 deletions

View File

@@ -576,6 +576,7 @@ class PaymentReconciliation(Document):
"difference_amount": flt(row.get("difference_amount")),
"difference_account": row.get("difference_account"),
"difference_posting_date": row.get("gain_loss_posting_date"),
"debit_or_credit_note_posting_date": row.get("debit_or_credit_note_posting_date"),
"cost_center": row.get("cost_center"),
}
)
@@ -765,7 +766,7 @@ def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None):
{
"doctype": "Journal Entry",
"voucher_type": voucher_type,
"posting_date": today(),
"posting_date": inv.get("debit_or_credit_note_posting_date") or today(),
"company": company,
"multi_currency": 1 if inv.currency != company_currency else 0,
"accounts": [

View File

@@ -20,6 +20,7 @@
"section_break_5",
"difference_amount",
"gain_loss_posting_date",
"debit_or_credit_note_posting_date",
"column_break_7",
"difference_account",
"exchange_rate",
@@ -168,12 +169,17 @@
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
},
{
"fieldname": "debit_or_credit_note_posting_date",
"fieldtype": "Date",
"label": "Debit / Credit Note Posting Date"
}
],
"is_virtual": 1,
"istable": 1,
"links": [],
"modified": "2023-12-14 13:38:26.104150",
"modified": "2025-08-20 19:12:50.406769",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Reconciliation Allocation",
@@ -183,4 +189,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -18,6 +18,7 @@ class PaymentReconciliationAllocation(Document):
amount: DF.Currency
cost_center: DF.Link | None
currency: DF.Link | None
debit_or_credit_note_posting_date: DF.Date | None
difference_account: DF.Link | None
difference_amount: DF.Currency
exchange_rate: DF.Float

View File

@@ -18,7 +18,7 @@ from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_f
)
from erpnext.accounts.doctype.accounting_period.accounting_period import ClosedAccountingPeriod
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
from erpnext.accounts.utils import create_payment_ledger_entry
from erpnext.accounts.utils import create_payment_ledger_entry, is_immutable_ledger_enabled
from erpnext.exceptions import InvalidAccountDimensionError, MandatoryAccountDimensionError
@@ -838,7 +838,3 @@ def validate_allowed_dimensions(gl_entry, dimension_filter_map):
),
InvalidAccountDimensionError,
)
def is_immutable_ledger_enabled():
return frappe.db.get_single_value("Accounts Settings", "enable_immutable_ledger")

View File

@@ -1743,40 +1743,38 @@ def create_err_and_its_journals(companies: list | None = None) -> None:
jv and frappe.get_doc("Journal Entry", jv).submit()
def _auto_create_exchange_rate_revaluation_for(frequency: str) -> None:
"""
Internal helper to avoid code duplication and typos.
Fetches companies by frequency and triggers ERR.
"""
companies = frappe.db.get_all(
"Company",
filters={"auto_exchange_rate_revaluation": 1, "auto_err_frequency": frequency},
fields=["name", "submit_err_jv"],
)
create_err_and_its_journals(companies)
def auto_create_exchange_rate_revaluation_daily() -> None:
"""
Executed by background job
"""
companies = frappe.db.get_all(
"Company",
filters={"auto_exchange_rate_revaluation": 1, "auto_err_frequency": "Daily"},
fields=["name", "submit_err_jv"],
)
create_err_and_its_journals(companies)
_auto_create_exchange_rate_revaluation_for("Daily")
def auto_create_exchange_rate_revaluation_weekly() -> None:
"""
Executed by background job
"""
companies = frappe.db.get_all(
"Company",
filters={"auto_exchange_rate_revaluation": 1, "auto_err_frequency": "Weekly"},
fields=["name", "submit_err_jv"],
)
create_err_and_its_journals(companies)
_auto_create_exchange_rate_revaluation_for("Weekly")
def auto_create_exchange_rate_revaluation_monthly() -> None:
"""
Executed by background job
"""
companies = frappe.db.get_all(
"Company",
filters={"auto_exchange_rate_revaluation": 1, "auto_err_frequency": "Montly"},
fields=["name", "submit_err_jv"],
)
create_err_and_its_journals(companies)
_auto_create_exchange_rate_revaluation_for("Monthly")
def get_payment_ledger_entries(gl_entries, cancel=0):
@@ -1897,6 +1895,9 @@ def create_payment_ledger_entry(
if cancel:
delink_original_entry(ple, partial_cancel=partial_cancel)
if is_immutable_ledger_enabled():
ple.delinked = 0
ple.posting_date = frappe.form_dict.get("posting_date") or getdate()
ple.flags.ignore_permissions = 1
ple.flags.adv_adj = adv_adj
@@ -1984,7 +1985,6 @@ def delink_original_entry(pl_entry, partial_cancel=False):
ple = qb.DocType("Payment Ledger Entry")
query = (
qb.update(ple)
.set(ple.delinked, True)
.set(ple.modified, now())
.set(ple.modified_by, frappe.session.user)
.where(
@@ -2003,6 +2003,9 @@ def delink_original_entry(pl_entry, partial_cancel=False):
if partial_cancel:
query = query.where(ple.voucher_detail_no == pl_entry.voucher_detail_no)
if not is_immutable_ledger_enabled():
query = query.set(ple.delinked, True)
query.run()
@@ -2459,3 +2462,7 @@ def build_qb_match_conditions(doctype, user=None) -> list:
criterion.append(cond)
return criterion
def is_immutable_ledger_enabled():
return frappe.get_single_value("Accounts Settings", "enable_immutable_ledger")

View File

@@ -322,6 +322,9 @@ class Asset(AccountsController):
finance_books = get_item_details(self.item_code, self.asset_category, self.gross_purchase_amount)
self.set("finance_books", finance_books)
if self.asset_owner == "Company" and not self.asset_owner_company:
self.asset_owner_company = self.company
def validate_finance_books(self):
if not self.calculate_depreciation or len(self.finance_books) == 1:
return

View File

@@ -456,7 +456,7 @@ class AssetDepreciationSchedule(Document):
continue
depreciation_amount = flt(depreciation_amount, asset_doc.precision("gross_purchase_amount"))
value_after_depreciation = flt(
value_after_depreciation - flt(depreciation_amount),
flt(value_after_depreciation) - flt(depreciation_amount),
asset_doc.precision("gross_purchase_amount"),
)

View File

@@ -599,6 +599,9 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
docstatus: 1,
status: ["not in", ["Stopped", "Expired"]],
},
allow_child_item_selection: true,
child_fieldname: "items",
child_columns: ["item_code", "item_name", "qty", "rate", "amount"],
});
},
__("Get Items From")

View File

@@ -64,6 +64,11 @@ frappe.ui.form.on("Supplier", {
},
};
});
frm.make_methods = {
"Bank Account": () => erpnext.utils.make_bank_account(frm.doc.doctype, frm.doc.name),
"Pricing Rule": () => erpnext.utils.make_pricing_rule(frm.doc.doctype, frm.doc.name),
};
},
refresh: function (frm) {

View File

@@ -2,6 +2,8 @@
# License: GNU General Public License v3. See license.txt
import json
import frappe
from frappe import _
from frappe.model.mapper import get_mapped_doc
@@ -235,7 +237,12 @@ def get_list_context(context=None):
@frappe.whitelist()
def make_purchase_order(source_name, target_doc=None):
def make_purchase_order(source_name, target_doc=None, args=None):
if args is None:
args = {}
if isinstance(args, str):
args = json.loads(args)
def set_missing_values(source, target):
target.run_method("set_missing_values")
target.run_method("get_schedule_dates")
@@ -244,6 +251,11 @@ def make_purchase_order(source_name, target_doc=None):
def update_item(obj, target, source_parent):
target.stock_qty = flt(obj.qty) * flt(obj.conversion_factor)
def select_item(d):
filtered_items = args.get("filtered_children", [])
child_filter = d.name in filtered_items if filtered_items else True
return child_filter
doclist = get_mapped_doc(
"Supplier Quotation",
source_name,
@@ -265,6 +277,7 @@ def make_purchase_order(source_name, target_doc=None):
["sales_order", "sales_order"],
],
"postprocess": update_item,
"condition": select_item,
},
"Purchase Taxes and Charges": {
"doctype": "Purchase Taxes and Charges",

View File

@@ -108,7 +108,7 @@ status_map = {
["Pending", "eval:self.status != 'Stopped' and self.per_ordered == 0 and self.docstatus == 1"],
[
"Ordered",
"eval:self.status != 'Stopped' and self.per_ordered == 100 and self.docstatus == 1 and self.material_request_type == 'Purchase'",
"eval:self.status != 'Stopped' and self.per_ordered == 100 and self.docstatus == 1 and self.material_request_type in ['Purchase', 'Manufacture']",
],
[
"Transferred",
@@ -134,10 +134,6 @@ status_map = {
"Partially Ordered",
"eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.docstatus == 1 and self.material_request_type != 'Material Transfer'",
],
[
"Manufactured",
"eval:self.status != 'Stopped' and self.per_ordered == 100 and self.docstatus == 1 and self.material_request_type == 'Manufacture'",
],
],
"POS Opening Entry": [
["Draft", None],

View File

@@ -462,7 +462,7 @@
"index_web_pages_for_search": 1,
"links": [],
"max_attachments": 4,
"modified": "2025-07-03 10:54:30.444139",
"modified": "2025-08-21 17:57:58.314809",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project",
@@ -512,6 +512,7 @@
"row_format": "Dynamic",
"search_fields": "project_name,customer, status, priority, is_active",
"show_name_in_global_search": 1,
"show_title_field_in_link": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],

View File

@@ -551,6 +551,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
item_code(doc, cdt, cdn) {
var me = this;
frappe.flags.dialog_set = false;
var item = frappe.get_doc(cdt, cdn);
var update_stock = 0, show_batch_dialog = 0;

View File

@@ -209,17 +209,9 @@ $.extend(erpnext.utils, {
},
make_bank_account: function (doctype, docname) {
frappe.call({
method: "erpnext.accounts.doctype.bank_account.bank_account.make_bank_account",
args: {
doctype: doctype,
docname: docname,
},
freeze: true,
callback: function (r) {
var doclist = frappe.model.sync(r.message);
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
},
frappe.new_doc("Bank Account", {
party_type: doctype,
party: docname,
});
},

View File

@@ -113,12 +113,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
frappe.flags.trigger_from_barcode_scanner = true;
const { item_code, barcode, batch_no, serial_no, uom, default_warehouse } = data;
const warehouse = this.has_last_scanned_warehouse
? this.frm.doc.last_scanned_warehouse || default_warehouse
: null;
let row = this.get_row_to_modify_on_scan(item_code, batch_no, uom, barcode, warehouse);
let row = this.get_row_to_modify_on_scan(item_code, batch_no, uom, barcode, default_warehouse);
const is_new_row = !row?.item_code;
if (!row) {
if (this.dont_allow_new_row) {
@@ -151,7 +146,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
() => this.set_serial_no(row, serial_no),
() => this.set_batch_no(row, batch_no),
() => this.set_barcode(row, barcode),
() => this.set_warehouse(row, warehouse),
() => this.set_warehouse(row),
() => this.clean_up(),
() => this.revert_selector_flag(),
() => resolve(row),
@@ -412,12 +407,16 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
}
}
async set_warehouse(row, warehouse) {
const warehouse_field = this.get_warehouse_field();
async set_warehouse(row) {
if (!this.has_last_scanned_warehouse) return;
if (warehouse && frappe.meta.has_field(row.doctype, warehouse_field)) {
await frappe.model.set_value(row.doctype, row.name, warehouse_field, warehouse);
}
const last_scanned_warehouse = this.frm.doc.last_scanned_warehouse;
if (!last_scanned_warehouse) return;
const warehouse_field = this.get_warehouse_field();
if (!warehouse_field || !frappe.meta.has_field(row.doctype, warehouse_field)) return;
await frappe.model.set_value(row.doctype, row.name, warehouse_field, last_scanned_warehouse);
}
show_scan_message(idx, is_existing_row = false, qty = 1) {
@@ -438,15 +437,19 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
return is_duplicate;
}
get_row_to_modify_on_scan(item_code, batch_no, uom, barcode, warehouse) {
get_row_to_modify_on_scan(item_code, batch_no, uom, barcode, default_warehouse) {
let cur_grid = this.frm.fields_dict[this.items_table_name].grid;
// Check if batch is scanned and table has batch no field
let is_batch_no_scan = batch_no && frappe.meta.has_field(cur_grid.doctype, this.batch_no_field);
let check_max_qty = this.max_qty_field && frappe.meta.has_field(cur_grid.doctype, this.max_qty_field);
const warehouse_field = this.get_warehouse_field();
let has_warehouse_field = frappe.meta.has_field(cur_grid.doctype, warehouse_field);
const warehouse_field = this.has_last_scanned_warehouse && this.get_warehouse_field();
const has_warehouse_field =
warehouse_field && frappe.meta.has_field(cur_grid.doctype, warehouse_field);
const warehouse = has_warehouse_field
? this.frm.doc.last_scanned_warehouse || default_warehouse
: null;
const matching_row = (row) => {
const item_match = row.item_code == item_code;
@@ -509,7 +512,8 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
handle_warehouse_scan(data) {
const warehouse = data.warehouse;
const warehouse_field = this.get_warehouse_field();
const warehouse_field_label = frappe.meta.get_label(this.items_table_name, warehouse_field);
const cur_grid = this.frm.fields_dict[this.items_table_name].grid;
const warehouse_field_label = frappe.meta.get_label(cur_grid.doctype, warehouse_field);
if (!this.last_scanned_warehouse_initialized) {
this.setup_last_scanned_warehouse();

View File

@@ -14,6 +14,7 @@ frappe.ui.form.on("Customer", {
method: "erpnext.selling.doctype.customer.customer.make_opportunity",
frm: cur_frm,
}),
"Bank Account": () => erpnext.utils.make_bank_account(frm.doc.doctype, frm.doc.name),
};
frm.add_fetch("lead_name", "company_name", "customer_name");

View File

@@ -1144,6 +1144,17 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, a
target.debit_to = get_party_account("Customer", source.customer, source.company)
def update_item(source, target, source_parent):
def get_billed_qty(so_item_name):
from frappe.query_builder.functions import Sum
table = frappe.qb.DocType("Sales Invoice Item")
query = (
frappe.qb.from_(table)
.select(Sum(table.qty).as_("qty"))
.where((table.docstatus == 1) & (table.so_detail == so_item_name))
)
return query.run(pluck="qty")[0] or 0
if source_parent.has_unit_price_items:
# 0 Amount rows (as seen in Unit Price Items) should be mapped as it is
pending_amount = flt(source.amount) - flt(source.billed_amt)
@@ -1153,8 +1164,8 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, a
target.base_amount = target.amount * flt(source_parent.conversion_rate)
target.qty = (
target.amount / flt(source.rate)
if (source.rate and source.billed_amt)
source.qty - get_billed_qty(source.name)
if (source.qty and source.billed_amt)
else (source.qty if is_unit_price_row(source) else source.qty - source.returned_qty)
)

View File

@@ -1830,7 +1830,7 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
wo.reload()
so.reload()
self.assertEqual(so.items[0].work_order_qty, wo.produced_qty)
self.assertEqual(mr.status, "Manufactured")
self.assertEqual(mr.status, "Ordered")
@change_settings(
"Accounts Settings",
@@ -2396,6 +2396,30 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
self.assertFalse(so.per_billed)
self.assertEqual(so.status, "To Deliver and Bill")
def test_pending_quantity_after_update_item_during_invoice_creation(self):
so = make_sales_order(qty=30, rate=100)
si1 = make_sales_invoice(so.name)
si1.get("items")[0].qty = 10
si1.insert()
si1.submit()
first_item_of_so = so.get("items")[0]
trans_item = json.dumps(
[
{
"item_code": first_item_of_so.item_code,
"rate": 1000,
"qty": first_item_of_so.qty,
"docname": first_item_of_so.name,
},
]
)
update_child_qty_rate("Sales Order", trans_item, so.name)
si2 = make_sales_invoice(so.name)
self.assertEqual(si2.items[0].qty, 20)
def automatically_fetch_payment_terms(enable=1):
accounts_settings = frappe.get_doc("Accounts Settings")

View File

@@ -21,6 +21,12 @@ erpnext.setup.EmployeeController = class EmployeeController extends frappe.ui.fo
};
frappe.ui.form.on("Employee", {
setup: function (frm) {
frm.make_methods = {
"Bank Account": () => erpnext.utils.make_bank_account(frm.doc.doctype, frm.doc.name),
};
},
onload: function (frm) {
frm.set_query("department", function () {
return {

View File

@@ -35,7 +35,7 @@ frappe.listview_settings["Material Request"] = {
return [__("Partially Received"), "yellow", "per_received,<,100"];
} else if (doc.material_request_type == "Purchase" && flt(doc.per_received, precision) == 100) {
return [__("Received"), "green", "per_received,=,100"];
} else if (doc.material_request_type == "Purchase") {
} else if (["Purchase", "Manufacture"].includes(doc.material_request_type)) {
return [__("Ordered"), "green", "per_ordered,=,100"];
} else if (doc.material_request_type == "Material Transfer") {
return [__("Transfered"), "green", "per_ordered,=,100"];
@@ -43,8 +43,6 @@ frappe.listview_settings["Material Request"] = {
return [__("Issued"), "green", "per_ordered,=,100"];
} else if (doc.material_request_type == "Customer Provided") {
return [__("Received"), "green", "per_ordered,=,100"];
} else if (doc.material_request_type == "Manufacture") {
return [__("Manufactured"), "green", "per_ordered,=,100"];
}
}
},

View File

@@ -866,6 +866,23 @@ class TestMaterialRequest(FrappeTestCase):
for perm in permissions:
perm.delete()
def test_manufacture_type_status_over_wo(self):
from erpnext.stock.doctype.material_request.material_request import raise_work_orders
mr = make_material_request(
item_code="_Test FG Item", material_request_type="Manufacture", do_not_submit=False
)
work_order = raise_work_orders(mr.name)
wo = frappe.get_doc("Work Order", work_order[0])
wo.wip_warehouse = "_Test Warehouse 1 - _TC"
wo.submit()
mr.reload()
self.assertEqual(mr.per_ordered, 100)
self.assertEqual(mr.status, "Ordered")
def get_in_transit_warehouse(company):
if not frappe.db.exists("Warehouse Type", "Transit"):

View File

@@ -1322,8 +1322,8 @@ def make_purchase_invoice(source_name, target_doc=None, args=None):
"postprocess": update_item,
"filter": lambda d: (
get_pending_qty(d)[0] <= 0 if not doc.get("is_return") else get_pending_qty(d)[0] > 0
)
and select_item(d),
),
"condition": select_item,
},
"Purchase Taxes and Charges": {
"doctype": "Purchase Taxes and Charges",

View File

@@ -608,7 +608,7 @@ class TestSerialandBatchBundle(FrappeTestCase):
def test_serial_no_valuation_for_legacy_ledgers(self):
sn_item = make_item(
"Test Serial No Valuation for Legacy Ledgers",
properties={"has_serial_no": 1, "serial_no_series": "SNN-TSNVL.-#####"},
properties={"has_serial_no": 1, "serial_no_series": "SNN-TSNVL-.#####"},
).name
serial_nos = []

View File

@@ -1277,17 +1277,33 @@ def get_pos_profile(company, pos_profile=None, user=None):
@frappe.whitelist()
def get_conversion_factor(item_code, uom):
variant_of = frappe.db.get_value("Item", item_code, "variant_of", cache=True)
filters = {"parent": item_code, "uom": uom}
item = frappe.get_cached_value("Item", item_code, ["variant_of", "stock_uom"], as_dict=True)
if not item_code or not item or uom == item.stock_uom:
return {"conversion_factor": 1.0}
item_codes = [item_code]
if item.variant_of:
item_codes.append(item.variant_of)
parent = frappe.qb.DocType("Item")
child = frappe.qb.DocType("UOM Conversion Detail")
query = (
frappe.qb.from_(parent)
.join(child)
.on(parent.name == child.parent)
.select(child.conversion_factor)
.where((parent.name.isin(item_codes)) & (child.uom == uom))
.orderby(parent.has_variants)
.limit(1)
)
conversion_factor = query.run(pluck="conversion_factor")
if variant_of:
filters["parent"] = ("in", (item_code, variant_of))
conversion_factor = frappe.get_all("UOM Conversion Detail", filters, pluck="conversion_factor")
if not conversion_factor:
stock_uom = frappe.db.get_value("Item", item_code, "stock_uom")
conversion_factor = [get_uom_conv_factor(uom, stock_uom) or 1]
conversion_factor = get_uom_conv_factor(uom, item.stock_uom)
else:
conversion_factor = conversion_factor[0]
return {"conversion_factor": conversion_factor[-1]}
return {"conversion_factor": conversion_factor or 1.0}
@frappe.whitelist()

View File

@@ -61,10 +61,10 @@ frappe.query_reports["Stock Balance"] = {
},
});
data = data.map(({ name, description }) => {
data = data.map(({ name, ...rest }) => {
return {
value: name,
description: description,
description: Object.values(rest),
};
});

View File

@@ -56,11 +56,10 @@ frappe.query_reports["Stock Ledger"] = {
as_dict: 1,
},
});
data = data.map(({ name, description }) => {
data = data.map(({ name, ...rest }) => {
return {
value: name,
description: description,
description: Object.values(rest),
};
});