Files
erpnext/erpnext/utilities/report/test_product_util.py
Mihir Kandoi 96d4c48357 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>
2026-06-19 14:37:50 +05:30

104 lines
3.6 KiB
Python

# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import flt
from erpnext.tests.utils import ERPNextTestSuite
from erpnext.utilities.product import get_price
class TestProductUtil(ERPNextTestSuite):
"""Cover the ``get_price`` sales-UOM conversion query (UOM Conversion Detail join Item).
The converted query in ``erpnext.utilities.product.get_price`` resolves the
item's sales UOM conversion factor and scales ``formatted_price_sales_uom``
by it. We seed a sales UOM whose conversion factor differs from 1 so the join
produces an observable, non-trivial result.
"""
ITEM_CODE = "_Test Item"
PRICE_LIST = "_Test Selling Price List"
SALES_UOM = "_Test UOM 1"
SALES_UOM_FACTOR = 10.0
PRICE_LIST_RATE = 200.0
def setUp(self):
# _Test Item bootstrap ships uoms [_Test UOM (1.0), _Test UOM 1 (10.0)].
# Point its sales_uom at the 10x conversion so the joined query returns a
# factor != 1; assert against the bootstrapped conversion_factor to keep
# the check tied to real seeded state rather than a literal.
uom_cf = frappe.db.get_value(
"UOM Conversion Detail",
{"parent": self.ITEM_CODE, "parenttype": "Item", "uom": self.SALES_UOM},
"conversion_factor",
)
self.assertEqual(
flt(uom_cf),
self.SALES_UOM_FACTOR,
msg=f"Expected bootstrap UOM Conversion Detail {self.SALES_UOM} = {self.SALES_UOM_FACTOR}",
)
frappe.db.set_value("Item", self.ITEM_CODE, "sales_uom", self.SALES_UOM)
if not frappe.db.exists("Item Price", {"item_code": self.ITEM_CODE, "price_list": self.PRICE_LIST}):
frappe.get_doc(
{
"doctype": "Item Price",
"item_code": self.ITEM_CODE,
"price_list": self.PRICE_LIST,
"price_list_rate": self.PRICE_LIST_RATE,
}
).insert()
def test_sales_uom_conversion_factor_applied(self):
price = get_price(
item_code=self.ITEM_CODE,
price_list=self.PRICE_LIST,
customer_group="_Test Customer Group",
company="_Test Company",
)
self.assertIsNotNone(price, msg="get_price returned no price for seeded Item Price")
rate = flt(price["price_list_rate"])
self.assertTrue(rate, msg="seeded Item Price did not resolve a price_list_rate")
# The converted query (UOM Conversion Detail join Item on uom == sales_uom)
# multiplies the rate by the sales-UOM conversion factor for this field.
expected_sales_uom_price = frappe.utils.fmt_money(
rate * self.SALES_UOM_FACTOR, currency=price["currency"]
)
self.assertEqual(
price["formatted_price_sales_uom"],
expected_sales_uom_price,
msg="sales-UOM conversion factor (10x) was not applied by the converted join query",
)
# Guard against a degenerate factor of 1 silently passing: the sales-UOM
# price must differ from the plain formatted price.
self.assertNotEqual(
price["formatted_price_sales_uom"],
price["formatted_price"],
msg="formatted_price_sales_uom equals formatted_price; conversion factor was not picked up",
)
def test_factor_defaults_to_one_without_matching_sales_uom(self):
# When sales_uom has no matching UOM Conversion Detail row, the join
# returns nothing and the factor falls back to 1 (price unchanged).
frappe.db.set_value("Item", self.ITEM_CODE, "sales_uom", None)
price = get_price(
item_code=self.ITEM_CODE,
price_list=self.PRICE_LIST,
customer_group="_Test Customer Group",
company="_Test Company",
)
self.assertIsNotNone(price)
self.assertEqual(
price["formatted_price_sales_uom"],
price["formatted_price"],
msg="factor should default to 1 when no UOM Conversion Detail matches sales_uom",
)