Compare commits

...

14 Commits

Author SHA1 Message Date
Mihir Kandoi
9d28bea453 Merge pull request #56394 from mihir-kandoi/pg-revert-3-commits
Revert 3 Postgres-parity commits (bom variant lookup, traceability div-by-zero, POS NULL ordering)
2026-06-24 07:27:12 +05:30
Mihir Kandoi
40960a5ff9 Merge pull request #56393 from frappe/revert-56239-pg-parity-case-insensitive
Revert "fix: case-insensitive matching match MariaDB on Postgres"
2026-06-24 07:26:01 +05:30
Ravibharathi
022845e4e7 fix(pos): remove redundant opening balance dialog onchange handler (#54591) 2026-06-24 02:20:44 +05:30
Ravibharathi
1e37c4b9ac feat(opening invoice creation tool): add project to opening invoice child row (#54662) 2026-06-24 02:16:05 +05:30
Ravibharathi
32e971e374 fix(payment_entry): recompute base amount when exchange rate changes (#56136)
Co-authored-by: ervishnucs <ervishnucs369@gmail.com>
2026-06-24 01:59:16 +05:30
Mihir Kandoi
1f86b57f94 Revert "fix(manufacturing): case-sensitive variant BOM lookup on Postgres"
This reverts commit 2e5310f8a0.
2026-06-23 23:07:54 +05:30
Mihir Kandoi
c989e424f0 Revert "fix(stock): guard traceability qty division against a zero divisor (Postgres)"
This reverts commit 3859919263.
2026-06-23 23:07:54 +05:30
Mihir Kandoi
49e3830e7f Revert "fix(selling): make POS item-price NULL ordering match across engines (Postgres)"
This reverts commit 20e6a6e149.
2026-06-23 23:07:54 +05:30
Diptanil Saha
b356dbd59e fix(budget): ambiguous error message for budget assignment validation (#56390)
Co-authored-by: Wolfram Schmidt <wolfram.schmidt@phamos.eu>
2026-06-23 17:18:08 +00:00
Mihir Kandoi
a0bbca166f Merge pull request #56389 from frappe/revert-56330-pg-queries-locate-case
Revert "fix(controllers): case-insensitive employee/lead/bom search ranking on Postgres"
2026-06-23 22:29:50 +05:30
Mihir Kandoi
4fb781ae54 Merge pull request #56388 from frappe/revert-56380-pg-get-item-price-null-order
Revert "fix(stock): make get_item_price NULL ordering match across engines (Postgres)"
2026-06-23 22:26:45 +05:30
Mihir Kandoi
2b1a477fc8 Revert "fix: case-insensitive matching match MariaDB on Postgres" 2026-06-23 22:07:48 +05:30
Mihir Kandoi
e4e6e52a4d Revert "fix(controllers): case-insensitive employee/lead/bom search ranking on Postgres" 2026-06-23 22:02:17 +05:30
Mihir Kandoi
c868de324d Revert "fix(stock): make get_item_price NULL ordering match across engines (P…"
This reverts commit 116ef44ddb.
2026-06-23 22:02:00 +05:30
19 changed files with 210 additions and 309 deletions

View File

@@ -162,9 +162,9 @@ class Budget(Document):
frappe.throw(_("Account {0} does not belong to company {1}").format(self.account, self.company))
elif account_details.report_type != "Profit and Loss":
frappe.throw(
_("Budget cannot be assigned against {0}, as it's not an Income or Expense account").format(
self.account
)
_(
"Budget cannot be assigned against {0}, as its Root Type is not of Income or Expense"
).format(self.account)
)
def set_null_value(self):

View File

@@ -74,29 +74,31 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
},
setup_company_filters: function (frm) {
frm.set_query("cost_center", "invoices", function (doc, cdt, cdn) {
return {
filters: {
company: doc.company,
},
};
frm.events.apply_company_query_filter(frm, "cost_center", "invoices", { is_group: 0 });
frm.events.apply_company_query_filter(frm, "project", "invoices");
frm.events.apply_company_query_filter(frm, "project");
frm.events.apply_company_query_filter(frm, "cost_center", undefined, { is_group: 0 });
frm.events.apply_company_query_filter(frm, "temporary_opening_account", "invoices", {
account_type: "Temporary",
is_group: 0,
});
},
frm.set_query("cost_center", function (doc) {
apply_company_query_filter: function (frm, field_name, child_doctype = null, filters = {}) {
const query = function (doc) {
return {
filters: {
company: doc.company,
...filters,
},
};
});
};
frm.set_query("temporary_opening_account", "invoices", function (doc, cdt, cdn) {
return {
filters: {
company: doc.company,
},
};
});
if (child_doctype) {
frm.set_query(field_name, child_doctype, query);
} else {
frm.set_query(field_name, query);
}
},
company: function (frm) {
@@ -120,11 +122,6 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
},
invoice_type: function (frm) {
$.each(frm.doc.invoices, (idx, row) => {
row.party_type = frm.doc.invoice_type == "Sales" ? "Customer" : "Supplier";
frappe.model.set_value(row.doctype, row.name, "party", "");
frappe.model.set_value(row.doctype, row.name, "party_name", "");
});
frm.clear_table("invoices");
frm.refresh_fields();
frm.trigger("update_party_labels");
@@ -219,7 +216,19 @@ frappe.ui.form.on("Opening Invoice Creation Tool Item", {
});
},
invoices_add: (frm) => {
invoices_add: (frm, cdt, cdn) => {
const row = frappe.get_doc(cdt, cdn);
const field_copy = [];
["project", "cost_center"].forEach((fieldname) => {
if (frm.doc[fieldname]) {
frappe.model.set_value(cdt, cdn, fieldname, frm.doc[fieldname]);
} else {
field_copy.push(fieldname);
}
});
frm.script_manager.copy_from_first_row("invoices", row, field_copy);
frm.trigger("update_invoice_table");
},
});

View File

@@ -133,6 +133,17 @@ class OpeningInvoiceCreationTool(Document):
if not row.get(scrub(d)):
frappe.throw(mandatory_error_msg.format(row.idx, d, self.invoice_type))
self.validate_temporary_opening_account(row)
def validate_temporary_opening_account(self, row):
account_type = frappe.get_cached_value("Account", row.temporary_opening_account, "account_type")
if account_type != "Temporary":
frappe.throw(
_("Row #{0}: {1} account is not of type {2}").format(
row.idx, row.temporary_opening_account, "Temporary"
)
)
def get_invoices(self):
invoices = []
for row in self.invoices:
@@ -203,6 +214,7 @@ class OpeningInvoiceCreationTool(Document):
"description": row.item_name or "Opening Invoice Item",
income_expense_account_field: row.temporary_opening_account,
"cost_center": cost_center,
"project": row.get("project") or self.get("project"),
}
)

View File

@@ -2,10 +2,12 @@
# See license.txt
import frappe
from frappe.utils import add_days, today
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
get_temporary_opening_account,
)
from erpnext.projects.doctype.project.test_project import make_project
from erpnext.tests.utils import ERPNextTestSuite
@@ -14,21 +16,26 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
self,
invoice_type="Sales",
company=None,
party_1=None,
party_2=None,
invoice_number=None,
invoices=None,
project=None,
cost_center=None,
department=None,
return_doc=False,
):
doc = frappe.get_single("Opening Invoice Creation Tool")
args = get_opening_invoice_creation_dict(
invoice_type=invoice_type,
company=company,
party_1=party_1,
party_2=party_2,
invoice_number=invoice_number,
invoices=invoices,
project=project,
cost_center=cost_center,
department=department,
)
doc.update(args)
if return_doc:
return doc
return doc.make_invoices()
def test_opening_sales_invoice_creation(self):
@@ -37,8 +44,8 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
self.assertEqual(len(invoices), 2)
expected_value = {
"keys": ["customer", "outstanding_amount", "status"],
0: ["_Test Customer", 300, "Overdue"],
1: ["_Test Customer 1", 250, "Overdue"],
0: ["_Test Customer", 200, "Overdue"],
1: ["_Test Customer 1", 200, "Overdue"],
}
self.check_expected_values(invoices, expected_value)
@@ -55,48 +62,34 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
for field_idx, field in enumerate(expected_value["keys"]):
self.assertEqual(si.get(field, ""), expected_value[invoice_idx][field_idx])
def test_opening_invoice_requires_temporary_account_type(self):
doc = self.make_invoices(company="_Test Opening Invoice Company", return_doc=True)
doc.invoices[0].temporary_opening_account = "Sales - _TOIC"
self.assertRaises(frappe.ValidationError, doc.make_invoices)
def test_opening_purchase_invoice_creation(self):
invoices = self.make_invoices(invoice_type="Purchase", company="_Test Opening Invoice Company")
self.assertEqual(len(invoices), 2)
expected_value = {
"keys": ["supplier", "outstanding_amount", "status"],
0: ["_Test Supplier", 300, "Overdue"],
1: ["_Test Supplier 1", 250, "Overdue"],
0: ["_Test Supplier", 200, "Overdue"],
1: ["_Test Supplier 1", 200, "Overdue"],
}
self.check_expected_values(invoices, expected_value, "Purchase")
def test_opening_sales_invoice_creation_with_missing_debit_account(self):
company = "_Test Opening Invoice Company"
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
old_default_receivable_account = frappe.db.get_value("Company", company, "default_receivable_account")
frappe.db.set_value("Company", company, "default_receivable_account", "")
old_default_receivable_account = frappe.db.get_value(
"Company", "_Test Opening Invoice Company", "default_receivable_account"
)
frappe.db.set_value("Company", "_Test Opening Invoice Company", "default_receivable_account", "")
if not frappe.db.exists("Cost Center", "_Test Opening Invoice Company - _TOIC"):
cc = frappe.get_doc(
{
"doctype": "Cost Center",
"cost_center_name": "_Test Opening Invoice Company",
"is_group": 1,
"company": "_Test Opening Invoice Company",
}
)
cc.insert(ignore_mandatory=True)
cc2 = frappe.get_doc(
{
"doctype": "Cost Center",
"cost_center_name": "Main",
"is_group": 0,
"company": "_Test Opening Invoice Company",
"parent_cost_center": cc.name,
}
)
cc2.insert()
frappe.db.set_value("Company", company, "cost_center", "Main - _TOIC")
self.make_invoices(company="_Test Opening Invoice Company", party_1=party_1, party_2=party_2)
self.make_invoices(
company="_Test Opening Invoice Company",
invoices=[{"party": party_1}, {"party": party_2}],
)
# Check if missing debit account error raised
error_log = frappe.db.exists(
@@ -106,71 +99,107 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
self.assertTrue(error_log)
# teardown
frappe.db.set_value("Company", company, "default_receivable_account", old_default_receivable_account)
def test_renaming_of_invoice_using_invoice_number_field(self):
company = "_Test Opening Invoice Company"
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
self.make_invoices(
company=company, party_1=party_1, party_2=party_2, invoice_number="TEST-NEW-INV-11"
frappe.db.set_value(
"Company",
"_Test Opening Invoice Company",
"default_receivable_account",
old_default_receivable_account,
)
sales_inv1 = frappe.get_all("Sales Invoice", filters={"customer": "Customer A"})[0].get("name")
sales_inv2 = frappe.get_all("Sales Invoice", filters={"customer": "Customer B"})[0].get("name")
self.assertEqual(sales_inv1, "TEST-NEW-INV-11")
def test_renaming_of_invoice_using_invoice_number_field(self):
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
invoices = self.make_invoices(
company="_Test Opening Invoice Company",
invoices=[
{"party": party_1, "invoice_number": "TEST-NEW-INV-11"},
{"party": party_2},
],
)
# teardown
for inv in [sales_inv1, sales_inv2]:
doc = frappe.get_doc("Sales Invoice", inv)
doc.cancel()
self.assertEqual(invoices[0], "TEST-NEW-INV-11")
def test_opening_invoice_with_accounting_dimension(self):
invoices = self.make_invoices(
invoice_type="Sales", company="_Test Opening Invoice Company", department="Sales - _TOIC"
)
expected_value = {
"keys": ["customer", "outstanding_amount", "status", "department"],
0: ["_Test Customer", 300, "Overdue", "Sales - _TOIC"],
1: ["_Test Customer 1", 250, "Overdue", "Sales - _TOIC"],
}
self.check_expected_values(invoices, expected_value, invoice_type="Sales")
for invoice in invoices:
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
def test_opening_entry_project_linking(self):
doc = self.make_invoices(
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True
)
project_1 = make_project(
{"project_name": "Test Opening Invoice projecty 01", "company": "_Test Opening Invoice Company"}
)
project_2 = make_project(
{"project_name": "Test Opening Invoice projecty 02", "company": "_Test Opening Invoice Company"}
)
doc.invoices[0].project = project_1.name
doc.invoices[1].project = project_2.name
invoices = doc.make_invoices()
sales_invoice_1 = frappe.get_doc("Sales Invoice", invoices[0])
sales_invoice_2 = frappe.get_doc("Sales Invoice", invoices[1])
self.assertEqual(sales_invoice_1.items[0].project, project_1.name)
self.assertEqual(sales_invoice_2.items[0].project, project_2.name)
def get_opening_invoice_creation_dict(**args):
party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier"
company = args.get("company", "_Test Company")
default_invoices = []
default_invoice_rows = [
{
"qty": 1.0,
"outstanding_amount": 200,
"party": f"_Test {party}",
"item_name": "Opening Item",
"due_date": add_days(today(), -10),
"posting_date": add_days(today(), -15),
"temporary_opening_account": get_temporary_opening_account(company),
},
{
"qty": 1.0,
"outstanding_amount": 200,
"party": f"_Test {party} 1",
"item_name": "Opening Item",
"due_date": add_days(today(), -10),
"posting_date": add_days(today(), -15),
"temporary_opening_account": get_temporary_opening_account(company),
},
]
for row in args.get("invoices") or default_invoice_rows:
default_invoices.append(
{
"qty": row.get("qty") or 1.0,
"outstanding_amount": row.get("outstanding_amount") or 200,
"party": row.get("party") or f"_Test {party}",
"item_name": row.get("item_name") or "Opening Item",
"due_date": row.get("due_date") or add_days(today(), -10),
"posting_date": row.get("posting_date") or add_days(today(), -15),
"temporary_opening_account": row.get("temporary_opening_account")
or get_temporary_opening_account(company),
"invoice_number": row.get("invoice_number"),
"project": row.get("project"),
"cost_center": row.get("cost_center"),
}
)
invoice_dict = frappe._dict(
{
"company": company,
"invoice_type": args.get("invoice_type", "Sales"),
"invoices": [
{
"qty": 1.0,
"outstanding_amount": 300,
"party": args.get("party_1") or f"_Test {party}",
"item_name": "Opening Item",
"due_date": "2016-09-10",
"posting_date": "2016-09-05",
"temporary_opening_account": get_temporary_opening_account(company),
"invoice_number": args.get("invoice_number"),
},
{
"qty": 2.0,
"outstanding_amount": 250,
"party": args.get("party_2") or f"_Test {party} 1",
"item_name": "Opening Item",
"due_date": "2016-09-10",
"posting_date": "2016-09-05",
"temporary_opening_account": get_temporary_opening_account(company),
"invoice_number": None,
},
],
"project": args.get("project"),
"cost_center": args.get("cost_center"),
"invoices": default_invoices,
}
)
invoice_dict.update(args)
invoice_dict.invoices = default_invoices
return invoice_dict

View File

@@ -21,7 +21,8 @@
"qty",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break"
"dimension_col_break",
"project"
],
"fields": [
{
@@ -125,11 +126,17 @@
"fieldtype": "Data",
"in_list_view": 1,
"label": "Party Name"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
}
],
"istable": 1,
"links": [],
"modified": "2026-03-20 02:11:42.023575",
"modified": "2026-04-29 17:08:15.617047",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Opening Invoice Creation Tool Item",

View File

@@ -26,6 +26,7 @@ class OpeningInvoiceCreationToolItem(Document):
party_name: DF.Data | None
party_type: DF.Link | None
posting_date: DF.Date | None
project: DF.Link | None
qty: DF.Data | None
supplier_invoice_date: DF.Date | None
temporary_opening_account: DF.Link | None

View File

@@ -754,17 +754,21 @@ frappe.ui.form.on("Payment Entry", {
frm.set_paid_amount_based_on_received_amount = true;
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.base_received_amount && frm.doc.source_exchange_rate) {
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
if (frm.doc.paid_amount && frm.doc.source_exchange_rate) {
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
// target exchange rate should always be same as source if both account currencies is same
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate);
frm.set_value("received_amount", frm.doc.paid_amount);
} else {
frm.set_value(
"paid_amount",
flt(frm.doc.base_paid_amount) / flt(frm.doc.source_exchange_rate)
);
const target_rate =
flt(frm.doc.target_exchange_rate) ||
(company_currency == frm.doc.paid_to_account_currency ? 1 : 0);
if (target_rate) {
frm.set_value("received_amount", flt(frm.doc.base_received_amount) / target_rate);
}
}
// set_unallocated_amount is called by below method,
@@ -780,18 +784,23 @@ frappe.ui.form.on("Payment Entry", {
target_exchange_rate: function (frm) {
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.base_paid_amount && frm.doc.target_exchange_rate) {
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
if (
!frm.doc.source_exchange_rate &&
frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency
) {
if (frm.doc.received_amount && frm.doc.target_exchange_rate) {
frm.set_value(
"base_received_amount",
flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate)
);
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
frm.set_value("paid_amount", frm.doc.received_amount);
} else {
frm.set_value(
"received_amount",
flt(frm.doc.base_received_amount) / flt(frm.doc.target_exchange_rate)
);
const source_rate =
flt(frm.doc.source_exchange_rate) ||
(company_currency == frm.doc.paid_from_account_currency ? 1 : 0);
if (source_rate) {
frm.set_value("paid_amount", flt(frm.doc.base_paid_amount) / source_rate);
}
}
// set_unallocated_amount is called by below method,

View File

@@ -72,17 +72,14 @@ def employee_query(
.where(Criterion.any(search_conditions))
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(Employee.name)) > 0,
Locate(Lower(txt_no_percent), Lower(Employee.name)),
)
.when(Locate(txt_no_percent, Employee.name) > 0, Locate(txt_no_percent, Employee.name))
.else_(99999)
)
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(Employee.employee_name)) > 0,
Locate(Lower(txt_no_percent), Lower(Employee.employee_name)),
Locate(txt_no_percent, Employee.employee_name) > 0,
Locate(txt_no_percent, Employee.employee_name),
)
.else_(99999)
)
@@ -138,28 +135,17 @@ def lead_query(
query.where(Lead.docstatus < 2)
.where(Lead.status.isnull() | (Lead.status != "Converted"))
.where(Criterion.any(search_conditions))
.orderby(
Case().when(Locate(txt_no_percent, Lead.name) > 0, Locate(txt_no_percent, Lead.name)).else_(99999)
)
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(Lead.name)) > 0,
Locate(Lower(txt_no_percent), Lower(Lead.name)),
)
.when(Locate(txt_no_percent, Lead.lead_name) > 0, Locate(txt_no_percent, Lead.lead_name))
.else_(99999)
)
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(Lead.lead_name)) > 0,
Locate(Lower(txt_no_percent), Lower(Lead.lead_name)),
)
.else_(99999)
)
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(Lead.company_name)) > 0,
Locate(Lower(txt_no_percent), Lower(Lead.company_name)),
)
.when(Locate(txt_no_percent, Lead.company_name) > 0, Locate(txt_no_percent, Lead.company_name))
.else_(99999)
)
.orderby(Lead.idx, order=Order.desc)
@@ -397,12 +383,7 @@ def bom(
.where(BOM.is_active == 1)
.where(BOM[searchfield].like(f"%{txt}%"))
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(BOM.name)) > 0,
Locate(Lower(txt_no_percent), Lower(BOM.name)),
)
.else_(99999)
Case().when(Locate(txt_no_percent, BOM.name) > 0, Locate(txt_no_percent, BOM.name)).else_(99999)
)
.orderby(BOM.idx, order=Order.desc)
.orderby(BOM.name)

View File

@@ -1403,18 +1403,16 @@ def validate_bom_no(item, bom_no):
def _bom_contains_item(bom, item):
item_lower = item.lower()
item = item.lower()
for d in bom.items:
if d.item_code.lower() == item_lower:
if d.item_code.lower() == item:
return True
for d in bom.secondary_items:
if d.item_code.lower() == item_lower:
if d.item_code.lower() == item:
return True
# Use the original-cased `item` for the Item lookup: names are case-sensitive on Postgres,
# so a lowercased name would miss the record and drop the variant->template BOM match.
return (
bom.item.lower() == item_lower
bom.item.lower() == item
or bom.item.lower() == cstr(frappe.db.get_value("Item", item, "variant_of")).lower()
)

View File

@@ -3,8 +3,6 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Lower
from frappe.utils import cstr
def execute(filters=None):
@@ -65,9 +63,6 @@ def append_filters(query, report_filters, operations, job_card):
if report_filters.get(field):
if field == "serial_no":
query = query.where(job_card[field].like(f"%{report_filters.get(field)}%"))
elif field == "batch_no":
# Lower() both sides: exact match stays case-insensitive on Postgres as it is on MariaDB
query = query.where(Lower(job_card[field]) == cstr(report_filters.get(field)).lower())
elif field == "operation":
query = query.where(job_card[field].isin(operations))
else:

View File

@@ -1,65 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_to_date, now
from erpnext.manufacturing.doctype.job_card.mapper import make_corrective_job_card
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.report.cost_of_poor_quality_report.cost_of_poor_quality_report import execute
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestCostOfPoorQualityReport(ERPNextTestSuite):
def setUp(self):
self.load_test_records("BOM")
# BOM with operations for _Test FG Item 2, so submitting the work order creates Job Cards
bom = frappe.copy_doc(self.globalTestRecords["BOM"][2])
bom.set_rate_of_sub_assembly_item_based_on_bom = 0
bom.rm_cost_as_per = "Valuation Rate"
bom.items[0].uom = "_Test UOM 1"
bom.items[0].conversion_factor = 5
bom.insert(ignore_if_duplicate=True)
def test_batch_no_filter_is_case_insensitive(self):
# The report's batch_no filter used an exact `==`, which is case-sensitive on Postgres -- a
# differently-cased batch_no would miss job cards that MariaDB (case-insensitive collation)
# matches. Lower() both sides keeps MariaDB unchanged and makes Postgres match too.
wo = make_wo_order_test_record(item="_Test FG Item 2", qty=2, transfer_material_against="Work Order")
for item in wo.required_items:
make_stock_entry(
item_code=item.item_code,
target=item.source_warehouse,
qty=item.required_qty * 2,
basic_rate=100,
)
job_card = frappe.get_last_doc("Job Card", {"work_order": wo.name})
job_card.append(
"time_logs", {"from_time": now(), "to_time": add_to_date(now(), hours=1), "completed_qty": 2}
)
job_card.submit()
corrective_op = frappe.get_doc(
doctype="Operation", is_corrective_operation=1, name=frappe.generate_hash()
).insert()
corrective_jc = make_corrective_job_card(
job_card.name, operation=corrective_op.name, for_operation=job_card.operation
)
corrective_jc.hour_rate = 100
corrective_jc.insert()
corrective_jc.append(
"time_logs",
{
"from_time": add_to_date(now(), hours=2),
"to_time": add_to_date(now(), hours=2, minutes=30),
"completed_qty": 2,
},
)
corrective_jc.submit()
# store an uppercase batch_no; the report is then filtered with a lowercase value
corrective_jc.db_set("batch_no", "TESTCOPQBATCH")
_columns, data = execute(frappe._dict({"batch_no": "testcopqbatch"}))
self.assertTrue(any(row.get("name") == corrective_jc.name for row in data))

View File

@@ -6,7 +6,6 @@ import json
import frappe
from frappe.query_builder import Criterion, DocType, Order
from frappe.query_builder.functions import Coalesce
from frappe.utils import cint, get_datetime
from frappe.utils.nestedset import get_root_of
@@ -232,10 +231,7 @@ def get_items(
.where(ItemPrice.selling == 1)
.where((ItemPrice.valid_from <= current_date) | (ItemPrice.valid_from.isnull()))
.where((ItemPrice.valid_upto >= current_date) | (ItemPrice.valid_upto.isnull()))
# Coalesce so a NULL valid_from (open-ended base price) sorts last under DESC on both
# engines: MariaDB already sorts NULL last for DESC, Postgres defaults to NULLS FIRST, which
# would otherwise make the base price win the positional pick over a dated override.
.orderby(Coalesce(ItemPrice.valid_from, "1900-01-01"), order=Order.desc)
.orderby(ItemPrice.valid_from, order=Order.desc)
).run(as_dict=True)
stock_uom_price = next((d for d in item_prices if d.get("uom") == item.stock_uom), {})

View File

@@ -40,15 +40,6 @@ erpnext.PointOfSale.Controller = class {
in_list_view: 1,
label: __("Opening Amount"),
options: "company:company_currency",
onchange: function () {
dialog.fields_dict.balance_details.df.data.some((d) => {
if (d.idx == this.doc.idx) {
d.opening_amount = this.value;
dialog.fields_dict.balance_details.grid.refresh();
return true;
}
});
},
},
];
const fetch_pos_payment_methods = () => {

View File

@@ -11,7 +11,7 @@ import frappe.query_builder
from frappe import _, _dict, bold
from frappe.model.document import Document
from frappe.model.naming import make_autoname
from frappe.query_builder.functions import Concat_ws, Lower, Max, Sum
from frappe.query_builder.functions import Concat_ws, Max, Sum
from frappe.utils import (
cint,
cstr,
@@ -3389,8 +3389,7 @@ def get_stock_ledgers_for_serial_nos(kwargs):
query.left_join(serial_batch_entry)
.on(stock_ledger_entry.serial_and_batch_bundle == serial_batch_entry.parent)
.where(
# Lower() both sides so serial-no matching is case-insensitive on Postgres as on MariaDB
Lower(serial_batch_entry.serial_no).isin([sn.lower() for sn in serial_nos])
serial_batch_entry.serial_no.isin(serial_nos)
| Concat_ws("", "\n", stock_ledger_entry.serial_no, "\n").regexp(regex_pattern)
)
.distinct()

View File

@@ -80,31 +80,6 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
self.assertFalse(bundle_doc.name.startswith("SABB-"))
def test_get_stock_ledgers_for_serial_nos_is_case_insensitive(self):
# get_stock_ledgers_for_serial_nos matches Serial and Batch Entry.serial_no with isin(), which is
# case-sensitive on Postgres -- a differently-cased serial no would miss entries that MariaDB
# (case-insensitive collation) matches. Lower() both sides keeps MariaDB unchanged and makes
# Postgres match too.
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_stock_ledgers_for_serial_nos,
)
item_code = "Test SBB Case-insensitive Serial Item"
make_item(
item_code,
{"has_serial_no": 1, "serial_no_series": "TESTCISER-.#####", "is_stock_item": 1},
)
pr = make_purchase_receipt(item_code=item_code, warehouse="_Test Warehouse - _TC", qty=1, rate=100)
bundle = pr.items[0].serial_and_batch_bundle
serial_no = get_serial_nos_from_bundle(bundle)[0]
# query with a lowercased serial no; the stored Serial and Batch Entry value is uppercase
rows = get_stock_ledgers_for_serial_nos(
frappe._dict({"item_code": item_code, "serial_nos": [serial_no.lower()]})
)
self.assertTrue(any(row.serial_and_batch_bundle == bundle for row in rows))
def test_inward_outward_serial_valuation(self):
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt

View File

@@ -1234,10 +1234,7 @@ def get_item_price(
& (ip.price_list == pctx.price_list)
& (IfNull(ip.uom, "").isin(["", pctx.uom]))
)
# IfNull so a NULL valid_from sorts last under DESC on both engines: MariaDB sorts NULL last
# for DESC, but Postgres defaults to NULLS FIRST, which would otherwise make a NULL-valid_from
# price win the LIMIT 1 over the most-recent dated price.
.orderby(IfNull(ip.valid_from, "1900-01-01"), order=frappe.qb.desc)
.orderby(ip.valid_from, order=frappe.qb.desc)
.orderby(IfNull(ip.batch_no, ""), order=frappe.qb.desc)
.orderby(ip.uom, order=frappe.qb.desc)
.limit(1)

View File

@@ -4,7 +4,6 @@
import frappe
from frappe import _
from frappe.query_builder import Case
from frappe.query_builder.functions import NullIf
def execute(filters: dict | None = None):
@@ -301,12 +300,9 @@ class ReportData:
(
(
stock_entry_detail.qty
/ NullIf(
Case()
.when(stock_entry.fg_completed_qty > 0, stock_entry.fg_completed_qty)
.else_(sabb_data.qty),
0,
)
/ Case()
.when(stock_entry.fg_completed_qty > 0, stock_entry.fg_completed_qty)
.else_(sabb_data.qty)
)
* sabb_data.qty
).as_("qty"),

View File

@@ -2,7 +2,6 @@
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.query_builder.functions import Lower
from frappe.utils import cint, cstr, flt, fmt_money
from erpnext.accounts.doctype.pricing_rule.pricing_rule import get_pricing_rule_for_item
@@ -134,12 +133,9 @@ def get_item_codes_by_attributes(attribute_filters, template_item_code=None):
frappe.qb.from_(iva)
.select(iva.parent)
# attribute_value is a varchar column; cast values to str so postgres doesn't choke on
# `varchar = numeric` for numeric attributes (stored values are strings on both backends).
# Lower() both sides so matching is case-insensitive on Postgres too, matching MariaDB's
# default collation (MariaDB result is unchanged -- it already matches case-insensitively).
# `varchar = numeric` for numeric attributes (stored values are strings on both backends)
.where(
(Lower(iva.attribute) == cstr(attribute).lower())
& (Lower(iva.attribute_value).isin([cstr(v).lower() for v in attribute_values]))
(iva.attribute == attribute) & (iva.attribute_value.isin([cstr(v) for v in attribute_values]))
)
.where(iva.parent.isin(item_subquery))
.groupby(iva.parent)

View File

@@ -1,25 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.controllers.item_variant import create_variant
from erpnext.tests.utils import ERPNextTestSuite
from erpnext.utilities.product import get_item_codes_by_attributes
class TestProduct(ERPNextTestSuite):
def test_get_item_codes_by_attributes_is_case_insensitive(self):
# get_item_codes_by_attributes matches Item Variant Attribute values. A raw equality is
# case-sensitive on Postgres, so a differently-cased filter value would miss variants that
# MariaDB (case-insensitive collation) matches. Lower() both sides keeps MariaDB unchanged and
# makes Postgres match too.
template = "_Test Variant Item"
variant = create_variant(template, {"Test Size": "Small"})
if not frappe.db.exists("Item", variant.name):
variant.insert()
self.addCleanup(frappe.delete_doc, "Item", variant.name, force=True)
# stored attribute value is "Small"; query with a different case
matches = get_item_codes_by_attributes({"Test Size": ["small"]}, template)
self.assertIn(variant.name, matches)