refactor(postgres): port Setup/Utilities/Templates/Regional queries to the query builder

Convert raw `frappe.db.sql` in the Setup, Utilities, Templates and Regional
areas to `frappe.qb` / the ORM so the same code runs on MariaDB and Postgres.
Behaviour is preserved on MariaDB; the conversions also make these paths valid
under Postgres' stricter SQL (GROUP BY, case-sensitivity, reserved words).

Conversions of note (behaviour kept identical to the MariaDB original):
- email_digest: ToDo ordering replicated with a CASE that mirrors MySQL
  `field(priority,'High','Medium','Low')` (unknown/NULL -> 0, sorts first),
  NULL-date-first and a `name` tie-break for a deterministic LIMIT.
- company.get_all_transactions_annual_history: the cross-DocType UNION + GROUP BY
  is replaced by one grouped query per DocType merged with a Counter, so two
  different DocTypes sharing a transaction_date still collapse into one bucket.
- templates/utils.send_message: contact lookup wraps both sides in LOWER() to
  keep MariaDB's case-insensitive email match on case-sensitive Postgres.
- regional/irs_1099 & uae_vat_201: address ranking and emirate aggregation
  rebuilt with CASE/aggregate selects that satisfy Postgres GROUP BY, with a
  deterministic tie-break on the LIMIT-1 address lookups.
- utilities/product.get_item_codes_by_attributes: numeric attribute values are
  cast with cstr() so Postgres doesn't reject `varchar = numeric`.

Tests (run on both MariaDB and Postgres, --lightmode):
- New: company merge test, authorization_rule duplicate-check, youtube report,
  templates/utils, and utilities/templates page reports (partners, rfq,
  material_request_info, product, utilities __init__).
- Existing suites kept green: company, email_digest, transaction_deletion_record,
  irs_1099, uae_vat_201.

Deferred (tracked separately):
- setup/doctype/authorization_control.py still has raw `.format()` SELECTs;
  left for its own PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mihir Kandoi
2026-06-19 14:02:48 +05:30
parent c6f9415e9d
commit 96d4c48357
28 changed files with 1251 additions and 544 deletions

View File

@@ -49,24 +49,22 @@ class AuthorizationRule(Document):
# end: auto-generated types
def check_duplicate_entry(self):
exists = frappe.db.sql(
"""select name, docstatus from `tabAuthorization Rule`
where transaction = %s and based_on = %s and system_user = %s
and system_role = %s and approving_user = %s and approving_role = %s
and to_emp =%s and to_designation=%s and name != %s""",
(
self.transaction,
self.based_on,
cstr(self.system_user),
cstr(self.system_role),
cstr(self.approving_user),
cstr(self.approving_role),
cstr(self.to_emp),
cstr(self.to_designation),
self.name,
),
exists = frappe.get_all(
"Authorization Rule",
filters={
"transaction": self.transaction,
"based_on": self.based_on,
"system_user": cstr(self.system_user),
"system_role": cstr(self.system_role),
"approving_user": cstr(self.approving_user),
"approving_role": cstr(self.approving_role),
"to_emp": cstr(self.to_emp),
"to_designation": cstr(self.to_designation),
"name": ["!=", self.name],
},
pluck="name",
)
auth_exists = exists and exists[0][0] or ""
auth_exists = exists[0] if exists else ""
if auth_exists:
frappe.throw(_("Duplicate Entry. Please check Authorization Rule {0}").format(auth_exists))

View File

@@ -1,8 +1,28 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.tests.utils import ERPNextTestSuite
class TestAuthorizationRule(ERPNextTestSuite):
pass
def test_duplicate_rule_is_blocked(self):
"""check_duplicate_entry uses frappe.get_all over Authorization Rule; a second rule with the
same transaction/based_on/approving_role/value must be rejected as a duplicate (the converted
query must find the existing row on both engines)."""
def make_rule():
return frappe.get_doc(
{
"doctype": "Authorization Rule",
"transaction": "Sales Order",
"based_on": "Grand Total",
"approving_role": "Sales Manager",
"value": 100000,
}
)
make_rule().insert(ignore_permissions=True)
# a second identical rule must be caught by the converted duplicate-check query
self.assertRaises(frappe.ValidationError, make_rule().insert, ignore_permissions=True)

View File

@@ -3,6 +3,7 @@
import json
from collections import Counter
from typing import Literal
import frappe
@@ -13,11 +14,13 @@ from frappe.contacts.address_and_contact import load_address_and_contact
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.desk.page.setup_wizard.setup_wizard import make_records
from frappe.utils import (
add_to_date,
cint,
get_first_day,
get_last_day,
get_link_to_form,
get_timestamp,
nowdate,
today,
)
from frappe.utils.nestedset import NestedSet, rebuild_tree
@@ -154,11 +157,7 @@ class Company(NestedSet):
"Purchase Order",
"Supplier Quotation",
]:
if frappe.db.sql(
"""select name from `tab{}` where company={} and docstatus=1
limit 1""".format(doctype, "%s"),
self.name,
):
if frappe.db.exists(doctype, {"company": self.name, "docstatus": 1}):
exists = True
break
@@ -196,12 +195,21 @@ class Company(NestedSet):
if previous_valuation_method and previous_valuation_method != self.valuation_method:
# check if there are any stock ledger entries against items
# which does not have it's own valuation method
sle = frappe.db.sql(
"""select name from `tabStock Ledger Entry` sle
where exists(select name from tabItem
where name=sle.item_code and (valuation_method is null or valuation_method='')) and sle.company=%s limit 1
""",
self.name,
sle_dt = frappe.qb.DocType("Stock Ledger Entry")
item = frappe.qb.DocType("Item")
sle = (
frappe.qb.from_(sle_dt)
.select(sle_dt.name)
.where(
(sle_dt.company == self.name)
& sle_dt.item_code.isin(
frappe.qb.from_(item)
.select(item.name)
.where(item.valuation_method.isnull() | (item.valuation_method == ""))
)
)
.limit(1)
.run()
)
if sle:
@@ -237,7 +245,7 @@ class Company(NestedSet):
if not self.abbr.strip():
frappe.throw(_("Abbreviation is mandatory"))
if frappe.db.sql("select abbr from tabCompany where name!=%s and abbr=%s", (self.name, self.abbr)):
if frappe.db.exists("Company", {"name": ["!=", self.name], "abbr": self.abbr}):
frappe.throw(_("Abbreviation already used for another company"))
@frappe.whitelist()
@@ -338,11 +346,7 @@ class Company(NestedSet):
def on_update(self):
NestedSet.on_update(self)
if not frappe.db.sql(
"""select name from tabAccount
where company=%s and docstatus<2 limit 1""",
self.name,
):
if not frappe.db.exists("Account", {"company": self.name, "docstatus": ["<", 2]}):
if not frappe.local.flags.ignore_chart_of_accounts:
frappe.flags.country_change = True
sync_financial_report_templates(self.chart_of_accounts, self.existing_company)
@@ -743,11 +747,12 @@ class Company(NestedSet):
def after_rename(self, olddn, newdn, merge=False):
self.db_set("company_name", newdn)
frappe.db.sql(
"""update `tabDefaultValue` set defvalue=%s
where defkey='Company' and defvalue=%s""",
(newdn, olddn),
)
default_value = frappe.qb.DocType("DefaultValue")
(
frappe.qb.update(default_value)
.set(default_value.defvalue, newdn)
.where((default_value.defkey == "Company") & (default_value.defvalue == olddn))
).run()
clear_defaults_cache()
@@ -761,73 +766,69 @@ class Company(NestedSet):
NestedSet.validate_if_child_exists(self)
frappe.utils.nestedset.update_nsm(self)
rec = frappe.db.sql("SELECT name from `tabGL Entry` where company = %s", self.name)
if not rec:
frappe.db.sql(
"""delete from `tabBudget Account`
where exists(select name from tabBudget
where name=`tabBudget Account`.parent and company = %s)""",
self.name,
)
if not frappe.db.exists("GL Entry", {"company": self.name}):
budgets = frappe.get_all("Budget", filters={"company": self.name}, pluck="name")
if budgets:
frappe.db.delete("Budget Account", {"parent": ["in", budgets]})
for doctype in ["Account", "Cost Center", "Budget", "Party Account"]:
frappe.db.sql(f"delete from `tab{doctype}` where company = %s", self.name)
frappe.db.delete(doctype, {"company": self.name})
if not frappe.db.get_value("Stock Ledger Entry", {"company": self.name}):
frappe.db.sql("""delete from `tabWarehouse` where company=%s""", self.name)
frappe.db.delete("Warehouse", {"company": self.name})
frappe.defaults.clear_default("company", value=self.name)
for doctype in ["Mode of Payment Account", "Item Default"]:
frappe.db.sql(f"delete from `tab{doctype}` where company = %s", self.name)
frappe.db.delete(doctype, {"company": self.name})
# clear default accounts, warehouses from item
warehouses = frappe.db.sql_list("select name from tabWarehouse where company=%s", self.name)
warehouses = frappe.get_all("Warehouse", filters={"company": self.name}, pluck="name")
if warehouses:
frappe.db.sql(
"""delete from `tabItem Reorder` where warehouse in (%s)"""
% ", ".join(["%s"] * len(warehouses)),
tuple(warehouses),
frappe.db.delete("Item Reorder", {"warehouse": ["in", warehouses]})
# reset default company
singles = frappe.qb.DocType("Singles")
(
frappe.qb.update(singles)
.set(singles.value, "")
.where(
(singles["doctype"] == "Global Defaults")
& (singles.field == "default_company")
& (singles.value == self.name)
)
).run()
# reset default company
frappe.db.sql(
"""update `tabSingles` set value=''
where doctype='Global Defaults' and field='default_company'
and value=%s""",
self.name,
)
# reset default company
frappe.db.sql(
"""update `tabSingles` set value=''
where doctype='Chart of Accounts Importer' and field='company'
and value=%s""",
self.name,
)
(
frappe.qb.update(singles)
.set(singles.value, "")
.where(
(singles["doctype"] == "Chart of Accounts Importer")
& (singles.field == "company")
& (singles.value == self.name)
)
).run()
# delete BOMs
boms = frappe.db.sql_list("select name from tabBOM where company=%s", self.name)
boms = frappe.get_all("BOM", filters={"company": self.name}, pluck="name")
if boms:
frappe.db.sql("delete from tabBOM where company=%s", self.name)
frappe.db.delete("BOM", {"company": self.name})
for dt in ("BOM Operation", "BOM Item", "BOM Secondary Item", "BOM Explosion Item"):
frappe.db.sql(
"delete from `tab{}` where parent in ({})".format(dt, ", ".join(["%s"] * len(boms))),
tuple(boms),
)
frappe.db.delete(dt, {"parent": ["in", boms]})
frappe.db.sql("delete from tabEmployee where company=%s", self.name)
frappe.db.sql("delete from tabDepartment where company=%s", self.name)
frappe.db.sql("delete from `tabTax Withholding Account` where company=%s", self.name)
frappe.db.sql("delete from `tabTransaction Deletion Record` where company=%s", self.name)
frappe.db.delete("Employee", {"company": self.name})
frappe.db.delete("Department", {"company": self.name})
frappe.db.delete("Tax Withholding Account", {"company": self.name})
frappe.db.delete("Transaction Deletion Record", {"company": self.name})
# delete tax templates
frappe.db.sql("delete from `tabSales Taxes and Charges Template` where company=%s", self.name)
frappe.db.sql("delete from `tabPurchase Taxes and Charges Template` where company=%s", self.name)
frappe.db.sql("delete from `tabItem Tax Template` where company=%s", self.name)
frappe.db.delete("Sales Taxes and Charges Template", {"company": self.name})
frappe.db.delete("Purchase Taxes and Charges Template", {"company": self.name})
frappe.db.delete("Item Tax Template", {"company": self.name})
# delete Process Deferred Accounts if no GL Entry found
if not frappe.db.get_value("GL Entry", {"company": self.name}):
frappe.db.sql("delete from `tabProcess Deferred Accounting` where company=%s", self.name)
frappe.db.delete("Process Deferred Accounting", {"company": self.name})
def check_parent_changed(self):
frappe.flags.parent_company_changed = False
@@ -935,17 +936,12 @@ def get_children(doctype: str, parent: str | None = None, company: str | None =
if parent is None or parent == "All Companies":
parent = ""
return frappe.db.sql(
f"""
select
name as value,
is_group as expandable
from
`tabCompany` comp
where
ifnull(parent_company, "")={frappe.db.escape(parent)}
""",
as_dict=1,
filters = {"parent_company": parent} if parent else {"parent_company": ["is", "not set"]}
return frappe.get_all(
"Company",
filters=filters,
fields=["name as value", "is_group as expandable"],
)
@@ -965,55 +961,37 @@ def add_node():
def get_all_transactions_annual_history(company):
out = {}
items = frappe.db.sql(
"""
select transaction_date, count(*) as count
one_year_ago = add_to_date(nowdate(), years=-1)
date_doctypes = [
("Quotation", "transaction_date"),
("Sales Order", "transaction_date"),
("Delivery Note", "posting_date"),
("Sales Invoice", "posting_date"),
("Issue", "creation"),
("Project", "creation"),
]
from (
select name, transaction_date, company
from `tabQuotation`
from frappe.query_builder.functions import Count
UNION ALL
# Count per date in the DB (one grouped query per DocType) rather than streaming every
# transaction row into Python. A portable UNION across these mixed date columns isn't
# straightforward, so we aggregate each DocType and merge the per-date counts.
counts = Counter()
for doctype, date_field in date_doctypes:
dt = frappe.qb.DocType(doctype)
date_col = getattr(dt, date_field)
rows = (
frappe.qb.from_(dt)
.select(date_col.as_("transaction_date"), Count("*").as_("count"))
.where((dt.company == company) & (date_col > one_year_ago))
.groupby(date_col)
.run(as_dict=True)
)
for row in rows:
counts[row.transaction_date] += row.count
select name, transaction_date, company
from `tabSales Order`
UNION ALL
select name, posting_date as transaction_date, company
from `tabDelivery Note`
UNION ALL
select name, posting_date as transaction_date, company
from `tabSales Invoice`
UNION ALL
select name, creation as transaction_date, company
from `tabIssue`
UNION ALL
select name, creation as transaction_date, company
from `tabProject`
) t
where
company=%s
and
transaction_date > date_sub(curdate(), interval 1 year)
group by
transaction_date
""",
(company),
as_dict=True,
)
for d in items:
timestamp = get_timestamp(d["transaction_date"])
out.update({timestamp: d["count"]})
for transaction_date, count in counts.items():
out.update({get_timestamp(transaction_date): count})
return out
@@ -1043,17 +1021,20 @@ def get_default_company_address(
sort_key: Literal["is_shipping_address", "is_primary_address"] = "is_primary_address",
existing_address: str | None = None,
):
out = frappe.db.sql(
""" SELECT
addr.name, addr.{}
FROM
`tabAddress` addr, `tabDynamic Link` dl
WHERE
dl.parent = addr.name and dl.link_doctype = 'Company' and
dl.link_name = {} and ifnull(addr.disabled, 0) = 0
""".format(sort_key, "%s"),
(name),
) # nosec
addr = frappe.qb.DocType("Address")
dl = frappe.qb.DocType("Dynamic Link")
out = (
frappe.qb.from_(addr)
.inner_join(dl)
.on(dl.parent == addr.name)
.select(addr.name, addr[sort_key])
.where(
(dl.link_doctype == "Company")
& (dl.link_name == name)
& ((addr.disabled == 0) | addr.disabled.isnull())
)
.run()
)
if existing_address:
if existing_address in [d[0] for d in out]:

View File

@@ -4,6 +4,7 @@ import json
import frappe
from frappe import _
from frappe.query_builder.functions import IfNull
from frappe.utils import random_string
from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import (
@@ -100,7 +101,7 @@ class TestCompany(ERPNextTestSuite):
def test_basic_tree(self, records=None):
self.load_test_records("Company")
min_lft = 1
max_rgt = frappe.db.sql("select max(rgt) from `tabCompany`")[0][0]
max_rgt = frappe.get_all("Company", fields=[{"MAX": "rgt", "as": "max_rgt"}])[0].max_rgt
if not records:
records = self.globalTestRecords["Company"][2:]
@@ -162,10 +163,12 @@ class TestCompany(ERPNextTestSuite):
def get_no_of_children(companies, no_of_children):
children = []
for company in companies:
children += frappe.db.sql_list(
"""select name from `tabCompany`
where ifnull(parent_company, '')=%s""",
company or "",
company_dt = frappe.qb.DocType("Company")
children += (
frappe.qb.from_(company_dt)
.select(company_dt.name)
.where(IfNull(company_dt.parent_company, "") == (company or ""))
.run(pluck=True)
)
if len(children):
@@ -188,6 +191,49 @@ class TestCompany(ERPNextTestSuite):
child_company.save()
self.test_basic_tree()
def test_get_children_root_includes_empty_string_parent(self):
"""get_children at the root mirrors the original ifnull(parent_company,"")="": the converted
`["is", "not set"]` filter expands to `parent_company IS NULL OR parent_company = ''`, so a
company whose parent_company is '' (MariaDB keeps '') is still listed as a root. Guards against
narrowing this to an IS NULL-only check."""
from erpnext.setup.doctype.company.company import get_children
company = "_Test Company"
cd = frappe.qb.DocType("Company")
original = frappe.db.get_value("Company", company, "parent_company")
# force '' (not NULL) at the SQL layer, bypassing frappe's empty -> NULL doc coercion
frappe.qb.update(cd).set(cd.parent_company, "").where(cd.name == company).run()
self.addCleanup(
lambda: frappe.qb.update(cd).set(cd.parent_company, original).where(cd.name == company).run()
)
roots = {row.value for row in get_children("Company", parent="")}
self.assertIn(company, roots)
def test_annual_transaction_history_merges_dates_across_doctypes(self):
"""get_all_transactions_annual_history aggregates each DocType separately, then merges the
per-date counts. Two transactions of different DocTypes sharing a transaction_date must land
in one date bucket with the summed count (the UNION GROUP BY -> Counter-merge conversion)."""
from frappe.utils import add_days, get_timestamp, nowdate
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.setup.doctype.company.company import get_all_transactions_annual_history
company = "_Test Company"
txn_date = add_days(nowdate(), -30)
key = get_timestamp(txn_date)
before = get_all_transactions_annual_history(company).get(key, 0)
quotation = make_quotation(company=company, transaction_date=txn_date, do_not_submit=True)
self.addCleanup(frappe.delete_doc, "Quotation", quotation.name, force=True)
sales_order = make_sales_order(company=company, transaction_date=txn_date, do_not_submit=True)
self.addCleanup(frappe.delete_doc, "Sales Order", sales_order.name, force=True)
after = get_all_transactions_annual_history(company).get(key, 0)
self.assertEqual(after - before, 2)
def test_demo_data(self):
from erpnext.setup.demo import clear_demo_data, setup_demo_data

View File

@@ -9,6 +9,8 @@ import frappe.desk.notifications
from dateutil.relativedelta import relativedelta
from frappe import _
from frappe.core.doctype.user.user import STANDARD_USERS
from frappe.query_builder import Case
from frappe.query_builder.functions import Count, IfNull, Sum
from frappe.utils import (
add_to_date,
flt,
@@ -85,14 +87,11 @@ class EmailDigest(Document):
@frappe.whitelist()
def get_users(self):
"""get list of users"""
user_list = frappe.db.sql(
"""
select name, enabled from tabUser
where name not in ({})
and user_type != "Website User"
order by enabled desc, name asc""".format(", ".join(["%s"] * len(STANDARD_USERS))),
STANDARD_USERS,
as_dict=1,
user_list = frappe.get_all(
"User",
filters={"name": ["not in", STANDARD_USERS], "user_type": ["!=", "Website User"]},
fields=["name", "enabled"],
order_by="enabled desc, name asc",
)
if self.recipient_list:
@@ -107,13 +106,7 @@ class EmailDigest(Document):
@frappe.whitelist()
def send(self):
# send email only to enabled users
valid_users = [
p[0]
for p in frappe.db.sql(
"""select name from `tabUser`
where enabled=1"""
)
]
valid_users = frappe.get_all("User", filters={"enabled": 1}, pluck="name")
if self.recipients:
for row in self.recipients:
@@ -229,12 +222,24 @@ class EmailDigest(Document):
if not user_id:
user_id = frappe.session.user
todo_list = frappe.db.sql(
"""select *
from `tabToDo` where (owner=%s or assigned_by=%s) and status='Open'
order by field(priority, 'High', 'Medium', 'Low') asc, date asc limit 20""",
(user_id, user_id),
as_dict=True,
todo = frappe.qb.DocType("ToDo")
# matches MySQL field(priority,'High','Medium','Low'): unknown/empty/NULL -> 0 (sorts first)
priority_order = (
Case()
.when(todo.priority == "High", 1)
.when(todo.priority == "Medium", 2)
.when(todo.priority == "Low", 3)
.else_(0)
)
todo_list = (
frappe.qb.from_(todo)
.select(todo.star)
.where(((todo.owner == user_id) | (todo.assigned_by == user_id)) & (todo.status == "Open"))
.orderby(priority_order)
.orderby(IfNull(todo.date, "1000-01-01")) # NULL dates first, as MariaDB `date asc` did
.orderby(todo.name)
.limit(20)
.run(as_dict=True)
)
for t in todo_list:
@@ -247,10 +252,12 @@ class EmailDigest(Document):
if not user_id:
user_id = frappe.session.user
return frappe.db.sql(
"""select count(*) from `tabToDo`
where status='Open' and (owner=%s or assigned_by=%s)""",
(user_id, user_id),
todo = frappe.qb.DocType("ToDo")
return (
frappe.qb.from_(todo)
.select(Count("*"))
.where((todo.status == "Open") & ((todo.owner == user_id) | (todo.assigned_by == user_id)))
.run()
)[0][0]
def get_issue_list(self, user_id=None):
@@ -263,11 +270,12 @@ class EmailDigest(Document):
if not role_permissions.get("read"):
return None
issue_list = frappe.db.sql(
"""select *
from `tabIssue` where status in ("Replied","Open")
order by creation asc limit 10""",
as_dict=True,
issue_list = frappe.get_all(
"Issue",
filters={"status": ["in", ["Replied", "Open"]]},
fields=["*"],
order_by="creation asc",
limit=10,
)
for t in issue_list:
@@ -277,21 +285,19 @@ class EmailDigest(Document):
def get_issue_count(self):
"""Get count of Issue"""
return frappe.db.sql(
"""select count(*) from `tabIssue`
where status in ('Open','Replied') """
)[0][0]
return frappe.db.count("Issue", {"status": ["in", ["Open", "Replied"]]})
def get_project_list(self, user_id=None):
"""Get project list"""
if not user_id:
user_id = frappe.session.user
project_list = frappe.db.sql(
"""select *
from `tabProject` where status='Open' and project_type='External'
order by creation asc limit 10""",
as_dict=True,
project_list = frappe.get_all(
"Project",
filters={"status": "Open", "project_type": "External"},
fields=["*"],
order_by="creation asc",
limit=10,
)
for t in project_list:
@@ -301,10 +307,7 @@ class EmailDigest(Document):
def get_project_count(self):
"""Get count of Project"""
return frappe.db.sql(
"""select count(*) from `tabProject`
where status='Open' and project_type='External'"""
)[0][0]
return frappe.db.count("Project", {"status": "Open", "project_type": "External"})
def set_accounting_cards(self, context):
"""Create accounting cards if checked"""
@@ -485,12 +488,20 @@ class EmailDigest(Document):
def get_sales_orders_to_bill(self):
"""Get value not billed"""
value, count = frappe.db.sql(
"""select ifnull((sum(grand_total)) - (sum(grand_total*per_billed/100)),0),
count(*) from `tabSales Order`
where (transaction_date <= %(to_date)s) and billing_status != "Fully Billed" and company = %(company)s
and status not in ('Closed','Cancelled', 'Completed') """,
{"to_date": self.future_to_date, "company": self.company},
so = frappe.qb.DocType("Sales Order")
value, count = (
frappe.qb.from_(so)
.select(
IfNull(Sum(so.grand_total) - Sum(so.grand_total * so.per_billed / 100), 0),
Count("*"),
)
.where(
(so.transaction_date <= self.future_to_date)
& (so.billing_status != "Fully Billed")
& (so.company == self.company)
& so.status.notin(["Closed", "Cancelled", "Completed"])
)
.run()
)[0]
label = get_link_to_report(
@@ -511,12 +522,20 @@ class EmailDigest(Document):
def get_sales_orders_to_deliver(self):
"""Get value not delivered"""
value, count = frappe.db.sql(
"""select ifnull((sum(grand_total)) - (sum(grand_total*per_delivered/100)),0),
count(*) from `tabSales Order`
where (transaction_date <= %(to_date)s) and delivery_status != "Fully Delivered" and company = %(company)s
and status not in ('Closed','Cancelled', 'Completed') """,
{"to_date": self.future_to_date, "company": self.company},
so = frappe.qb.DocType("Sales Order")
value, count = (
frappe.qb.from_(so)
.select(
IfNull(Sum(so.grand_total) - Sum(so.grand_total * so.per_delivered / 100), 0),
Count("*"),
)
.where(
(so.transaction_date <= self.future_to_date)
& (so.delivery_status != "Fully Delivered")
& (so.company == self.company)
& so.status.notin(["Closed", "Cancelled", "Completed"])
)
.run()
)[0]
label = get_link_to_report(
@@ -537,12 +556,20 @@ class EmailDigest(Document):
def get_purchase_orders_to_receive(self):
"""Get value not received"""
value, count = frappe.db.sql(
"""select ifnull((sum(grand_total))-(sum(grand_total*per_received/100)),0),
count(*) from `tabPurchase Order`
where (transaction_date <= %(to_date)s) and per_received < 100 and company = %(company)s
and status not in ('Closed','Cancelled', 'Completed') """,
{"to_date": self.future_to_date, "company": self.company},
po = frappe.qb.DocType("Purchase Order")
value, count = (
frappe.qb.from_(po)
.select(
IfNull(Sum(po.grand_total) - Sum(po.grand_total * po.per_received / 100), 0),
Count("*"),
)
.where(
(po.transaction_date <= self.future_to_date)
& (po.per_received < 100)
& (po.company == self.company)
& po.status.notin(["Closed", "Cancelled", "Completed"])
)
.run()
)[0]
label = get_link_to_report(
@@ -563,12 +590,20 @@ class EmailDigest(Document):
def get_purchase_orders_to_bill(self):
"""Get purchase not billed"""
value, count = frappe.db.sql(
"""select ifnull((sum(grand_total)) - (sum(grand_total*per_billed/100)),0),
count(*) from `tabPurchase Order`
where (transaction_date <= %(to_date)s) and per_billed < 100 and company = %(company)s
and status not in ('Closed','Cancelled', 'Completed') """,
{"to_date": self.future_to_date, "company": self.company},
po = frappe.qb.DocType("Purchase Order")
value, count = (
frappe.qb.from_(po)
.select(
IfNull(Sum(po.grand_total) - Sum(po.grand_total * po.per_billed / 100), 0),
Count("*"),
)
.where(
(po.transaction_date <= self.future_to_date)
& (po.per_billed < 100)
& (po.company == self.company)
& po.status.notin(["Closed", "Cancelled", "Completed"])
)
.run()
)[0]
label = get_link_to_report(
@@ -707,13 +742,21 @@ class EmailDigest(Document):
return self.get_summary_of_pending_quotations("pending_quotations")
def get_summary_of_pending(self, doc_type, fieldname, getfield):
value, count, billed_value, delivered_value = frappe.db.sql(
"""select ifnull(sum(grand_total),0), count(*),
ifnull(sum(grand_total*per_billed/100),0), ifnull(sum(grand_total*{}/100),0) from `tab{}`
where (transaction_date <= %(to_date)s)
and status not in ('Closed','Cancelled', 'Completed')
and company = %(company)s """.format(getfield, doc_type),
{"to_date": self.future_to_date, "company": self.company},
doc = frappe.qb.DocType(doc_type)
value, count, billed_value, delivered_value = (
frappe.qb.from_(doc)
.select(
IfNull(Sum(doc.grand_total), 0),
Count("*"),
IfNull(Sum(doc.grand_total * doc.per_billed / 100), 0),
IfNull(Sum(doc.grand_total * doc[getfield] / 100), 0),
)
.where(
(doc.transaction_date <= self.future_to_date)
& doc.status.notin(["Closed", "Cancelled", "Completed"])
& (doc.company == self.company)
)
.run()
)[0]
return {
@@ -725,20 +768,27 @@ class EmailDigest(Document):
}
def get_summary_of_pending_quotations(self, fieldname):
value, count = frappe.db.sql(
"""select ifnull(sum(grand_total),0), count(*) from `tabQuotation`
where (transaction_date <= %(to_date)s)
and company = %(company)s
and status not in ('Ordered','Cancelled', 'Lost') """,
{"to_date": self.future_to_date, "company": self.company},
quotation = frappe.qb.DocType("Quotation")
value, count = (
frappe.qb.from_(quotation)
.select(IfNull(Sum(quotation.grand_total), 0), Count("*"))
.where(
(quotation.transaction_date <= self.future_to_date)
& (quotation.company == self.company)
& quotation.status.notin(["Ordered", "Cancelled", "Lost"])
)
.run()
)[0]
last_value = frappe.db.sql(
"""select ifnull(sum(grand_total),0) from `tabQuotation`
where (transaction_date <= %(to_date)s)
and company = %(company)s
and status not in ('Ordered','Cancelled', 'Lost') """,
{"to_date": self.past_to_date, "company": self.company},
last_value = (
frappe.qb.from_(quotation)
.select(IfNull(Sum(quotation.grand_total), 0))
.where(
(quotation.transaction_date <= self.past_to_date)
& (quotation.company == self.company)
& quotation.status.notin(["Ordered", "Cancelled", "Lost"])
)
.run()
)[0][0]
label = get_link_to_report(
@@ -898,10 +948,8 @@ class EmailDigest(Document):
def send():
now_date = now_datetime().date()
for ed in frappe.db.sql(
"""select name from `tabEmail Digest`
where enabled=1 and docstatus<2""",
as_list=1,
for ed in frappe.get_all(
"Email Digest", filters={"enabled": 1, "docstatus": ["<", 2]}, fields=["name"], as_list=True
):
ed_obj = frappe.get_doc("Email Digest", ed[0])
if now_date == ed_obj.get_next_sending():

View File

@@ -40,6 +40,45 @@ class TestEmailDigest(ERPNextTestSuite):
self.assertIn(po1.name, overdue_items)
self.assertNotIn(po2.name, overdue_items)
def test_get_todo_list_priority_and_date_ordering(self):
"""Original SQL ordered by `field(priority,'High','Medium','Low') asc, date asc`: MySQL
FIELD() returns 0 for empty/unknown priority (sorts FIRST under asc) and MariaDB sorts NULL
dates FIRST. The conversion preserves this: the priority CASE uses else_(0) (unknown/empty
priority sorts FIRST) and IfNull(date,'1000-01-01') keeps NULL dates FIRST, so the LIMIT-20
slice is identical on both engines. The two assertions below exercise both branches and would
fail if either sentinel were flipped to sort those rows last."""
user = "_test_todo_order@example.com"
if not frappe.db.exists("User", user):
frappe.get_doc(
{"doctype": "User", "email": user, "first_name": "Todo Order", "send_welcome_email": 0}
).insert(ignore_permissions=True)
def mk(desc, priority, date):
td = frappe.get_doc(
{
"doctype": "ToDo",
"description": desc,
"assigned_by": user,
"status": "Open",
"priority": "Medium",
}
).insert(ignore_permissions=True)
frappe.db.set_value("ToDo", td.name, {"priority": priority, "date": date}, update_modified=False)
return td.name
empty_pri = mk("empty-priority", "", "2020-01-01")
high_dated = mk("high-dated", "High", "2020-06-15")
high_nulldate = mk("high-nulldate", "High", None)
mk("low", "Low", "2020-03-01")
rows = frappe.new_doc("Email Digest").get_todo_list(user_id=user)
order = [r.name for r in rows]
# unknown/empty priority (FIELD()=0) must sort before High
self.assertLess(order.index(empty_pri), order.index(high_dated))
# within the High tier, a NULL date must sort before a real date (MariaDB NULLs-first)
self.assertLess(order.index(high_nulldate), order.index(high_dated))
def create_email_digest(**args):
args = frappe._dict(args)

View File

@@ -669,11 +669,9 @@ class TransactionDeletionRecord(Document):
self.enqueue_task(task="Delete Leads and Addresses")
return
frappe.db.sql(
"""delete from `tabBin` where warehouse in
(select name from tabWarehouse where company=%s)""",
self.company,
)
warehouses = frappe.get_all("Warehouse", filters={"company": self.company}, pluck="name")
if warehouses:
frappe.db.delete("Bin", {"warehouse": ["in", warehouses]})
self.db_set("delete_bin_data_status", "Completed")
self.enqueue_task(task="Delete Leads and Addresses")

View File

@@ -58,10 +58,8 @@ def set_single_defaults():
"Selling Settings",
"Stock Settings",
):
default_values = frappe.db.sql(
"""select fieldname, `default` from `tabDocField`
where parent=%s""",
dt,
default_values = frappe.get_all(
"DocField", filters={"parent": dt}, fields=["fieldname", "default"], as_list=True
)
if default_values:
try: