Continues the AccountsController service decomposition (Phase 5).
- Add accounts/services/deferred_accounting.py with DeferredAccountingService
owning the deferred revenue/expense validations (income/expense account
defaulting and service start/end date checks).
- Move the document-schedule orchestration (validate_all_documents_schedule
and the invoice/non-invoice variants) into PaymentScheduleService, where
they already delegated, removing the controller-to-service round trip.
- Update the three validate() call sites; keep
validate_auto_repeat_subscription_dates on the controller (still called by
buying/selling controllers).
No behavior change. accounts_controller.py 1818 -> 1745 lines.
With an explicit GROUP BY, a zero-match detail query returns no rows (the old bare aggregate returned one all-NULL row), so row1[0][0] would raise IndexError. Skip the entry instead.
get_requested_amount multiplied the pooled pending qty of all matching Material Request items by a single Max(rate), fabricating the total whenever rates differ and biasing it upward - making false Budget Exceeded stops more likely. Sum (stock_qty - ordered_qty) * rate per row instead, matching get_ordered_amount.
Add methods=["POST"] to 50 whitelisted functions that create or modify
documents (get_doc followed by insert/save/submit), so they can no
longer be invoked via GET requests.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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>
Creating a Stock Entry from a Pick List blocked any further entry
(stock_entry_exists) and flipped the pick list to Completed as soon as
one entry existed, so picked stock could not be transferred in parts.
Track transferred_qty per Pick List Item (summed from submitted Stock
Entry rows via a new pick_list_item link, mirroring delivered_qty), add
a Partially Transferred status, and map each new Stock Entry from the
remaining qty so transfers can continue until fully transferred.
get_rate_for_return builds Abs(stock_value_difference / actual_qty) for Sales/Delivery returns and passes it to get_value with no actual_qty filter. A matched Stock Ledger Entry with actual_qty=0 (a zero-qty repost / serial-batch row) makes Postgres raise 'division by zero' while MariaDB returns NULL. Wrap the divisor in NullIf(actual_qty, 0) so both engines return NULL. MariaDB output unchanged. Sibling of the already-fixed /actual_qty sites in stock_ledger.py and incorrect_serial_no_valuation.py.
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_filtered_child_rows searched child rows by row number with table.idx.like(...). idx is an integer column; frappe maps .like() to ILIKE on Postgres, which has no bigint ILIKE operator ('operator does not exist: bigint ~~* unknown'). Cast idx to string via frappe's Cast_ with 'varchar': a bare CAST(idx AS CHAR) is character(1) on Postgres and silently truncates a two-digit idx (11 -> '1'), dropping the row; CAST(idx AS VARCHAR) keeps the full value, and on MariaDB Cast_ rewrites to CONCAT(idx, '') matching the previous implicit coercion. MariaDB output unchanged. The test builds an order with >10 rows and searches row 11 (fails on Postgres with a char(1) cast).
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.
employee_query, lead_query and bom() ranked autocomplete results with a bare
Locate(txt, col) in ORDER BY. frappe maps Locate -> strpos on Postgres, which is
case-sensitive, while MariaDB's LOCATE against a column uses the column's
case-insensitive collation. So the search-dropdown ordering diverged between engines for
mixed-case matches (row count/membership unchanged — the WHERE .like() is already ILIKE).
Wrap both Locate operands in Lower(), matching the sibling item_query/get_project_name
handlers in the same file: a no-op on MariaDB, and case-insensitive (MariaDB-faithful) on
Postgres. The existing test_queries suite stays green on both engines.
The `skip_transfer` and transfer branches of `validate_manufacture` ran the
same per-item validation loop — look the row up or throw "not a part of",
check overconsumption, guard against duplicates, record — differing only in
the data source (SCIO Received Item vs Work Order Item), the available-qty
basis, the source-warehouse check (skip_transfer only) and the message text.
Split each branch into a small method that builds a normalised
`{item_code: {consumed_qty, available_qty}}` lookup, and share the loop via
`_validate_customer_provided_consumption`. Branch-specific throw messages are
passed as callbacks so the user-facing strings (and their translations) are
unchanged, and the order in which checks fire is preserved. Also drops the
unused `name` column from the skip_transfer query.
Adds a test for the non-skip-transfer manufacture flow (Material Transfer for
Manufacture -> Manufacture), which exercises the Work Order branch that the
existing suite — all of whose manufacture tests set skip_transfer=1 — never
covered. Full subcontracting-inward suite passes on MariaDB and PostgreSQL.
`get_fg_reference_names` built its LIKE filter with old-style
`"%%%s%%" % txt`. Use an f-string (`f"%{txt}%"`) for readability; the value
is still passed as a parameterised filter, so behaviour is unchanged.
Each new child row was given `idx=frappe.db.count(...) + 1`, issuing a count
query per inserted row across three insert loops (received items on receipt,
self-procured RM on manufacture, secondary items on manufacture). Compute the
starting index once before each loop and increment a local counter, producing
the same idx sequence with a single count query.
`update_inward_order_received_items_for_manufacture` unpacks
`zip(*item_code_wh.keys(), strict=True)`. When the manufacture entry has no
raw-material rows (all rows are finished/secondary/scrap), `item_code_wh` is
empty and the unpack raises `ValueError: not enough values to unpack`.
Return early when there are no such rows, mirroring the `if secondary_items:`
guard already present in `update_inward_order_secondary_items`.
In `validate_manufacture`, `customer_warehouse` is read only inside the
`skip_transfer` branch but was fetched unconditionally, wasting a lookup on
the non-skip-transfer path. Move it inside the branch that uses it.
`validate_material_transfer` ran the `Work Order Item` query and rebuilt
`wo_item_dict` inside the per-item loop, even though both depend only on
`self.work_order`. For an entry with N customer-provided rows that meant N
identical queries. Build the lookup once before the loop.
`validate_manufacture` already builds the analogous dict once up front, so
this also aligns the two methods.
In `update_inward_order_item`, the walrus assignment `scio_item_name :=` is
already part of the truthy `if` condition, so the nested `if scio_item_name:`
is always true. Remove it and dedent the body.
`validate_delivery_on_save` imported `pypika.terms.ValueWrapper` inside its
per-item loop, re-running the import on every iteration. Move it to the
module-level imports.
The "exceeds quantity available" throw in `validate_manufacture` passes a
third positional arg (`item.transfer_qty`), but the message only has `{0}`
and `{1}` placeholders, so `str.format` silently discards it. Remove the
dead argument; no behaviour change.
`validate_manufacture` builds its "Target Warehouse for Finished Good must
be same as Finished Good Warehouse ..." message with placeholders `{1}` and
`{2}`, but only passes two positional args (indices 0 and 1). `str.format`
raises `IndexError: Replacement index 2 out of range` instead of rendering
the message, so a user who sets the wrong FG target warehouse gets an opaque
traceback rather than the intended validation error.
Renumber the placeholders to `{0}` and `{1}` to match the args.
The earlier parity fix aggregated the non-key descriptive columns for the Item
and Customer based-on paths but left Supplier grouping by all three selected
columns (supplier, supplier_name, supplier_group). supplier_name is a stored
per-transaction field, so historical purchase docs holding a divergent value for
the same supplier would split one supplier into multiple rows — diverging from
the original MariaDB output, which grouped by t1.supplier only.
Aggregate supplier_name with Max() and keep only supplier + the FD master column
supplier_group in GROUP BY, restoring one row per supplier on both engines.
Add regression tests for the Supplier (purchase) and Customer (sales) paths that
assert a single row per key even when stored descriptive fields diverge; both
fail on the pre-fix multi-column GROUP BY and pass after the fix, on MariaDB and
Postgres.
#56192 made the trends queries Postgres-strict-GROUP-BY-valid by widening based_on_group_by
to include the selected descriptive columns. For Item it added t2.item_name, for Customer
t1.territory (and customer_name) — but item_name is an editable per-line field and territory an
editable per-document field, not functionally dependent on the item_code/customer key. On MariaDB
(ONLY_FULL_GROUP_BY off) this SPLITS the single row per key into one row per distinct
(key, item_name)/(key, territory), so a customer transacting across two territories (or an item
with an edited item_name) now shows duplicate rows with fractured per-period subtotals.
Group by the KEY only and aggregate the non-key descriptive columns with Max(): one row per
based-on key (identical to the pre-#56192 MariaDB output) and still Postgres-valid. Supplier
columns are master-joined / fetch-locked (functionally dependent) so they stay unchanged.
rfq_transaction_list had two defects introduced when it was converted to the query
builder:
1. `party.supplier == party[0]` compared supplier to a column literally named "0"
(a stray index on the DocType, not the intended `parties[0]` value). This renders
as `supplier = \`0\`` / `supplier = "0"` and errors on BOTH engines
(MariaDB: Unknown column '0'; Postgres: column "0" does not exist), so the
supplier portal RFQ list was completely broken.
2. SELECT DISTINCT ordered by `creation`, which is not in the select list. Postgres
rejects this ("for SELECT DISTINCT, ORDER BY expressions must appear in select list").
Compare against `parties[0]` and add `creation` to the select list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
update_variant_attribute_values (propagates renamed Item Attribute Values to
variant items) used a qb UPDATE ... JOIN. Postgres has no UPDATE..JOIN syntax,
so renaming an Item Attribute Value errored on Postgres.
Rewrite as a correlated UPDATE that restricts to variant items via a subquery
on the parent (item_variant_table.parent.isin(variant Items)) instead of
joining the Item table. MariaDB behaviour is unchanged.
Covered by the existing test_item.test_rename_attribute_value_updates_variants
and test_swapped_attribute_value_renames_update_variants, which errored on
Postgres before and now pass on both engines.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>