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_serial_nos_based_on_filters with based_on='Expiry' orders by amc_expiry_date
asc and limits to qty. MariaDB sorts NULL (no-AMC) serials first; Postgres last,
so a different set of serials is auto-selected. Order by (amc_expiry_date IS NULL)
desc, amc_expiry_date so NULLs sort first on both (MariaDB unchanged).
Both Item Price lookups order by valid_from desc and take the first valid row.
MariaDB sorts NULL valid_from last; Postgres first, so the undated base price was
winning over a dated one. Order by (valid_from IS NULL) asc, valid_from desc (the
get_all is converted to qb since its order_by won't take such an expression).
MariaDB output is unchanged.
get_item_price orders Item Price rows by valid_from DESC and takes LIMIT 1 to
pick the most-recent applicable price. NULL-valid_from rows are kept (the
transaction-date guard uses IfNull(valid_from, '2000-01-01')), and MariaDB
sorts NULL last for DESC while PostgreSQL defaults to NULLS FIRST — so when an
item/price_list/uom has both a dated price and a NULL-valid_from price,
PostgreSQL returns the NULL one and MariaDB the most-recent dated one, a silent
price divergence.
Wrap the sort key in IfNull(valid_from, '1900-01-01') so the NULL row sorts
last on both engines. MariaDB already placed it last for DESC, so its pick is
unchanged. Same NULL-ordering class fixed in point_of_sale.get_items (#56378).
get_materials divides stock_entry_detail.qty by a CASE that returns
fg_completed_qty when it is > 0 and otherwise the injected sabb_data.qty. The
code explicitly anticipates fg_completed_qty <= 0 (the else branch), and
neither fg_completed_qty nor sabb_data.qty is constrained non-zero, so the
divisor can be 0. MariaDB returns NULL for x/0; PostgreSQL raises
`division by zero` and aborts the report. Wrapping the CASE in NullIf(..., 0)
makes the divisor NULL instead of 0 — unchanged on MariaDB, valid on Postgres.
The Quality Inspection item link search builds a distinct, paginated
get_query with order_by="items.item_code". frappe's db_query silently drops
the ORDER BY for a distinct query on Postgres, so with offset/limit the
results come back in a different order AND a different page slice than MariaDB.
Append the ordering to the built query instead of passing order_by: item_code
is already in the DISTINCT select list, so ORDER BY on it is valid under
DISTINCT on Postgres, and it now applies before LIMIT on both engines. MariaDB
output is unchanged (it was already ordered by item_code). The items child
field is guarded for None so a doctype without it degrades gracefully rather
than raising AttributeError.
The non-stock-item valuation rate divides Sum(base_net_amount) by
Sum(qty * conversion_factor) over Purchase Invoice Items. A line with qty 0
zeroes the divisor. MariaDB returns NULL for x/0 (the caller maps it via
`or 0.0`); PostgreSQL raises `division by zero` and aborts. Wrap the divisor in
NullIf(Sum(qty * conversion_factor), 0): unchanged on MariaDB, valid on Postgres.
update_received_qty_if_from_pp divides received_qty by (qty / fg_item_qty) over
Purchase Order Items. Both qty and fg_item_qty are Float with no non-zero
constraint, so a zero qty (or fg_item_qty) drives the divisor to 0.
MariaDB returns NULL for x/0 (dropped by the surrounding Sum); PostgreSQL
raises `division by zero` and aborts the Purchase Receipt submit/cancel.
Wrapping both divisors in NullIf(..., 0) makes the zero row contribute NULL on
both engines, leaving MariaDB output unchanged.
The Incorrect Serial No Valuation report computes
stock_value_difference / actual_qty for every matching Stock Ledger Entry. A
valuation-only Stock Reconciliation of serialized/batched stock writes an SLE
with actual_qty = 0 and a non-zero stock_value_difference, and the or_filters
(serial_no / serial_and_batch_bundle set) do not exclude it.
MariaDB returns NULL for x/0; PostgreSQL raises `division by zero` and aborts
the report. Using the get_all nested NULLIF form
{"DIV": ["stock_value_difference", {"NULLIF": ["actual_qty", 0]}]} yields NULL
on both engines, leaving MariaDB output unchanged.
fix(stock): guard batchwise valuation-rate division against a zero divisor
get_valuation_rate's batchwise fallback selects
Sum(stock_value_difference) / Sum(actual_qty). When a batch's non-current
Stock Ledger Entries net to zero quantity (equal received and issued) the
divisor Sum(actual_qty) is 0. On MariaDB x/0 yields NULL and the caller's
`if last_valuation_rate and last_valuation_rate[0][0] is not None` check
falls through to the next strategy; on PostgreSQL float division by zero
raises `division by zero`, aborting the query (and the transaction).
Wrap the divisor in NullIf(Sum(actual_qty), 0) so a zero divisor yields
NULL on both engines, matching MariaDB and preserving the caller's
is-not-None fall-through. (stock_value_difference is Currency and actual_qty
is Float, so the division was already float — no integer-truncation change.)
Add a test exercising the full projected_qty formula - actual + ordered +
requested + planned minus reserved, reserved-for-production, reserved-for-
subcontract and reserved-for-production-plan - and asserting each component
is surfaced as its own column.
Add coverage for two untested Stock Balance checkbox filters: zero-balance
items are hidden unless 'include zero stock items' is on, and the stock
ageing columns appear only when 'show stock ageing data' is on.
get_opening_balance_for_inv_dimension selected item_code and warehouse alongside Sum() aggregates with no GROUP BY, which PostgreSQL rejects ('column ...item_code must appear in the GROUP BY clause'). Add GROUP BY item_code, warehouse. The query already returns early unless a single item and warehouse is selected, so this stays one row with identical values on MariaDB while becoming valid on Postgres.
The batch-number link picker (get_batch_no) had two Postgres-only defects in
both of its query builders (get_batches_from_stock_ledger_entries and
get_batches_from_serial_and_batch_bundle):
1. GROUP BY. They group by Stock Ledger Entry / Serial-and-Batch-Entry columns
while selecting un-aggregated Batch-master columns (manufacturing_date,
expiry_date, search fields). PostgreSQL only accepts that when the Batch
primary key is in the GROUP BY, so the picker raised GroupingError. Adding
batch_table.name (equal to the grouped batch_no via the join) keeps the
group count - and the MariaDB result - unchanged while making it valid.
2. CONCAT over nullable dates. "MFG-"/"EXP-" labels were built with
Concat("MFG-", manufacturing_date). When the date is NULL, MariaDB CONCAT
returns NULL but Postgres CONCAT drops the NULL and yields a bare "MFG-"/
"EXP-". Guard each with Case().when(date.isnotnull(), ...) so a missing date
is NULL on both engines (matching MariaDB, fixing Postgres).
Both leave MariaDB output unchanged. test_get_batch_no_search_returns_batches
exercises both builders directly and asserts no bare "MFG-"/"EXP-" leaks;
reverting either fix makes it fail on Postgres.
get_picked_batches summed Serial-and-Batch-Entry qty while selecting bare
batch_no and warehouse with no GROUP BY. PostgreSQL rejects this outright:
column "tabSerial and Batch Entry.batch_no" must appear in the GROUP BY clause
MariaDB does not error but collapses every picked row into a single result -
the grand-total qty pinned to one arbitrary batch - so the caller, which keys
the result by (batch_no, warehouse) to subtract already-picked stock, under-counts
whenever more than one batch is picked.
Add GROUP BY batch_no, warehouse so the query returns one correct row per batch
on both engines (this corrects the MariaDB result, not just Postgres validity).
get_material_requests_based_on_supplier deduplicated requests with
SELECT DISTINCT (name, transaction_date, company) while ordering by
mr_item.item_code, which is not in the select list. MariaDB allows this;
PostgreSQL rejects it:
psycopg2.errors.InvalidColumnReference: for SELECT DISTINCT,
ORDER BY expressions must appear in select list
so the picker errored out there.
Group by the three selected columns (equivalent to the DISTINCT, so the
same set of requests is returned) and order by Min(item_code). The order
key stays item_code but is now a well-defined aggregate, making the query
valid - and the ordering deterministic and identical - on both engines.
frappe's db_query SILENTLY drops ORDER BY for distinct queries on Postgres (the ORDER BY
column must appear in the SELECT-DISTINCT list), so `get_all/get_list(distinct=True,
order_by="<col>")` is a no-op there and the result comes back unordered — the root cause of
the Sales Register, Purchase Register and Sales Analytics ordering fixes. Add an AST rule to
.github/helper/postgres_compat.py that flags this (literal order_by only; an empty order_by=""
suppression and a dynamic/variable order_by are not flagged). `# pg-ok` escape hatch as usual.
Grandfather the three pre-existing low-impact sites the rule surfaces (paging/iteration order
only, not data): job_card operation autocomplete, inventory_dimension config list, and a
work_order test loop.