mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-03 08:32:24 +00:00
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:
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user