Extend the boundary rule to callers: non-decorated code that built or
annotated with ItemDetailsCtx now uses frappe._dict directly, and drops
the now-unused import. asset_capitalization keeps ItemDetailsCtx for its
own normalize_ctx_input-decorated functions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The contact lookup orders only by is_primary_contact desc then takes contacts[0]; contacts commonly
tie (the no-primary case), so MariaDB and Postgres could pick a different contact. Add a parent
(contact name) tiebreaker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
get_last_sales_amt ordered by the sales date DESC only; same-date documents made the reported Last Order Amount engine-dependent. Add name DESC tiebreaker.
A deeper re-audit (with an adversarial skeptic) of the queries left raw
in the prior commit found more that have exact ORM equivalents:
- scalar SUM/MAX -> frappe.qb + Sum/Max .run()[0][0]
- SUM ... GROUP BY -> frappe.qb .groupby().select(Sum().as_()) run(as_dict)
- name IN (values) -> get_all(filters={'f': ['in', ...]})
- sql_list(select col) -> get_all(pluck='col')
- bulk UPDATE ... = NULL/value -> frappe.db.set_value(filters, field, val)
- positional as_list reads -> get_all(..., as_list=True) (+ sorted())
Note: get_value(dt, filters, 'sum(x)') and get_all(fields=['sum(x)'])
are rejected by frappe ('SQL functions are not allowed as strings'), so
aggregates go through frappe.qb. get_all(as_list=True) returns a tuple
(not a list), so consumers that mutate use sorted().
All affected test modules pass on MariaDB.
Convert test-only raw frappe.db.sql calls that have an exact ORM
equivalent: full-table/filtered deletes -> frappe.db.delete, count ->
frappe.db.count, row-existence assertions -> frappe.db.exists,
single-row scalar fetches -> frappe.db.get_value, and simple
equality/range-filter selects -> frappe.get_all. No behaviour change.
Raw SQL that genuinely needs it is left as-is (dynamic identifiers,
aggregates/group-by, positional as_list consumers, DB-catalog
introspection).
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:
- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings
Part of #53976.
Conservative cleanup of frappe.throw/msgprint messages per the message style
guide; meaning, severity, and .format() arguments are unchanged:
- index bare {} placeholders as {0}/{1}/... so translators can reorder
- move f-strings / .format() / concatenation out of _() (they break gettext
extraction and never translate)
- wrap translatable dynamic values (DocType/Select labels) in _()
- fix grammar and colloquialisms
- drop no-op _() wrapping runtime-built strings
Part of #53976.
get_items orders Item Price by valid_from and picks the first match. MariaDB sorts
NULL valid_from last under DESC (so a dated price wins); Postgres sorts it first,
so the undated base price wins and POS shows the wrong rate. Order by
(valid_from IS NULL) asc, valid_from desc so NULLs sort last on both backends
regardless of any real date value (MariaDB unchanged).
POS get_items keeps Item Price rows with a NULL valid_from (open-ended base
price) alongside dated rows, orders by valid_from DESC, then picks the first
matching UOM positionally via next()/[0]. MariaDB sorts NULL last for DESC, so
a dated override wins; PostgreSQL defaults to NULLS FIRST for DESC, so the
NULL-valid_from base price wins instead — the POS shows a different
price_list_rate/currency on the two engines for an item that has both an
undated standing price and a dated price.
Coalesce(valid_from, "1900-01-01") in the ORDER BY forces the NULL row to sort
last on both engines. MariaDB already placed it last for DESC, so its output is
unchanged; PostgreSQL now picks the same dated override.
Customer-wise Item Price builds `ip.selling.eq(True)`, which renders
`WHERE "selling" = true`. `selling` is a Check field (smallint on Postgres),
and PostgreSQL has no `smallint = boolean` operator:
operator does not exist: smallint = boolean
MariaDB accepts it because `true` aliases to `1`. Comparing with the integer
`1` (`ip.selling.eq(1)`) selects identical rows on MariaDB and is valid on
PostgreSQL.
The Lost Quotations report's lost-value ratio divides Sum(base_net_total) by
total_value, a scalar Sum(base_net_total) subquery over the same lost
quotations. If every lost quotation in the period is zero-amount, total_value
is 0 while the grouped query still returns rows.
MariaDB returns NULL for x/0; PostgreSQL raises `division by zero` and aborts
the report. Wrapping the divisor in NullIf(total_value, 0) yields the same NULL
column on MariaDB and no error on PostgreSQL. (The sibling count ratio divides
by Count >= 1 in any returned row and is unaffected.)
get_teams fetched distinct order_types with get_all(distinct=True, order_by="order_type").
frappe drops ORDER BY for distinct queries on postgres (db_query), so the order_by is a
no-op there and the report's order-type leaf rows are not guaranteed any order on PG
(PostgreSQL only sorts them incidentally via its DISTINCT plan). Sort in python with
key=str.casefold instead, matching MariaDB's case-insensitive collation and guaranteeing
an identical, stable order on both engines (same pattern as the Sales/Purchase Register
account-column fix). Add a test locking the sorted order-type row order.
The Postgres-portability change moved the POS item-group filters to the query builder
(item.item_group.isin(...)) and frappe.get_all(["name","in",...]), which escape values
once. get_item_groups() still pre-escaped each name with frappe.db.escape(), so the
names were escaped TWICE -> `item_group IN ('''Products''')`, matching nothing. Any POS
Profile that restricts item groups returned ZERO items, on both MariaDB and Postgres.
Return raw names; the parameterized callers escape them correctly. (get_parent_item_group
also returned the quoted literal before this fix.) Add a regression test: a POS Profile
restricted to an item group must still surface that group's items — it returns 0 before
the fix and passes after, on both engines.