Compare commits

...

282 Commits

Author SHA1 Message Date
MochaMind
0ff0343588 chore: update POT file (#56253) 2026-06-21 14:26:56 +02:00
Mihir Kandoi
d9d94da9f5 Merge pull request #56256 from mihir-kandoi/pg-precommit-lint
ci(postgres): static pre-commit check for MySQL-only SQL
2026-06-21 17:29:15 +05:30
Mihir Kandoi
b2ee8cb1b9 ci(postgres): fix semgrep + two review findings in the checker
- semgrep: annotate the source-reading open() with # nosemgrep for the
  frappe-security-file-traversal rule (dev-only lint tool; path comes from pre-commit,
  not user input).
- bool-scan: only inspect the field *value* arg (db_set args[1]/dict args[0];
  set_value args[3]/dict args[2]) so a positional update_modified=False
  (e.g. db_set('f', 0, False)) no longer false-positives.
- # pg-ok: also honour the annotation on a multi-line call's closing paren line
  (scan one line past the node's end).
2026-06-21 17:10:34 +05:30
Mihir Kandoi
16e45c41f5 ci(postgres): drop the checker's unit test
Remove erpnext/tests/test_postgres_compat.py (and its pre-commit exclude); a unit
test for the dev-tooling lint helper isn't needed in the app test suite.
2026-06-21 17:03:51 +05:30
Mihir Kandoi
0e0575f27b Merge pull request #56243 from frappe/pg-ci-required
ci: upgrade the PostgreSQL server test workflow (opt-in via 'postgres' label)
2026-06-21 17:00:49 +05:30
Mihir Kandoi
549a24f7b9 ci(postgres): add a static pre-commit check for MySQL-only SQL
The Postgres test job is label-gated, so it does not run on every PR. This adds an
always-on pre-commit hook that statically flags the *mechanical* breaks: MySQL-only
functions (timestamp(date,time), timediff, str_to_date, date_format/add/sub,
group_concat, period_diff, SQL IF()), SHOW INDEX/TABLES/COLUMNS, single-quoted
aliases, UPDATE..JOIN, interpolated/f-string SQL carrying MySQL-isms,
set_value/db_set(<Check>, bool), and MySQL SHOW INDEX result keys.

It deliberately does NOT flag the framework auto-translations (ifnull->coalesce,
backtick/locate/REGEXP, .like()->ILIKE) nor the *semantic* divergences (loose GROUP
BY, case-sensitive ==/IN, NULL ordering, tiebreakers) — those need the test suite,
which remains the backstop. AST + structure-gated regex keep false positives near
zero (docstrings and prose skipped); '# pg-ok' exempts intentional MariaDB-only
branches. Scoped to erpnext/ excluding patches/. Includes a unit test of the checker.
2026-06-21 16:53:53 +05:30
Mihir Kandoi
f95e91323e ci(postgres): install payments app on the test site
The Postgres CI site only listed erpnext in install_apps, so the payments app
(fetched and built by install.sh via 'bench get-app payments') was never
installed on the site — leaving 'tabPayment Gateway' absent. test_payment_request
(and other payment-gateway-dependent tests) then errored on Postgres with
'relation "tabPayment Gateway" does not exist', while MariaDB passed because its
site_config already lists ["payments", "erpnext"]. Match that ordering for parity.
2026-06-21 16:19:52 +05:30
Mihir Kandoi
a46a6bf921 ci: speed up Postgres CI by disabling DB durability for the disposable test DB
Postgres fsyncs on every commit by default, which dominates a commit-heavy test suite.
Turn off synchronous_commit/fsync/full_page_writes on the throwaway CI database (reload-
time settings, no restart). MariaDB CI is unaffected (DB != postgres).
2026-06-21 16:19:52 +05:30
Mihir Kandoi
c820591089 ci: name the Postgres job distinctly so it is not a required check
The MariaDB job is named 'Python Unit Tests', and 'Python Unit Tests (1..4)' are the
required status checks on develop. Naming the Postgres matrix job the same made its
checks report under those required contexts, effectively gating every (labelled) PR on
Postgres. Rename it to 'Postgres Unit Tests' so its contexts are distinct and the
workflow stays non-required until we deliberately add it to branch protection.
2026-06-21 16:19:52 +05:30
Mihir Kandoi
57d0cebfb8 ci: make Postgres coverage upload glob explicit (codecov files) 2026-06-21 16:19:52 +05:30
Mihir Kandoi
d7eb54b153 ci: upgrade the PostgreSQL server test workflow (kept opt-in via 'postgres' label)
Bring the Server (Postgres) workflow in line with Server (MariaDB) internals while
keeping it opt-in for now: pull_request runs still require the 'postgres' label, but the
job now uses the full 4-container matrix (was 1), adds the nightly schedule /
workflow_dispatch / repository_dispatch triggers (which always run), and uploads
coverage. Builds ERPNext against frappe `develop` (PostgreSQL query-builder/ORM support
is merged there), so no fork override is needed.

The ERPNext server suite now passes on PostgreSQL and MariaDB from a single codebase;
flipping this to run on every PR / become a required check is a later, separate step.
2026-06-21 16:19:52 +05:30
Mihir Kandoi
0beb29321e Merge pull request #56251 from mihir-kandoi/pg-ci-remaining-failures
fix(postgres): resolve remaining Postgres test failures on develop
2026-06-21 16:19:28 +05:30
Mihir Kandoi
f595b3c0eb Merge pull request #56252 from mihir-kandoi/pg-savepoint-guards
fix(postgres): savepoint-guard swallow-and-continue insert paths
2026-06-21 16:16:08 +05:30
Mihir Kandoi
3cd2a36117 test(stock): tolerate timezone slack in test_heatmap_data on Postgres
get_timeline_data uses UnixTimestamp(posting_date); on Postgres that is the date's
midnight epoch in the DB session timezone, which can sit up to a day ahead of the
Python time.time() instant when the app timezone is ahead of UTC. The strict
'<= now' upper bound is therefore flaky on Postgres. Allow a day of slack on the
upper bound; MariaDB's UNIX_TIMESTAMP stays <= now so its pass/fail is unchanged.
2026-06-21 15:59:04 +05:30
Mihir Kandoi
bac4f1de52 fix(postgres): savepoint bank-account creation during company setup
create_bank_account() inserts a bank Account and swallows DuplicateEntryError
('bank account same as a CoA entry'). On Postgres the failed insert aborts the
transaction, so the rest of company setup ran against a poisoned transaction.
Take a savepoint and roll back to it in the handler. No-op on MariaDB.
2026-06-21 15:48:37 +05:30
Mihir Kandoi
0e25a77a62 fix(postgres): savepoint Plaid bank-account creation loop
add_bank_accounts() inserts a Bank Account per Plaid account in a loop. On a
duplicate the bare insert raises UniqueValidationError, which on Postgres aborts
the whole transaction; the handler only msgprint'd and continued, so the next
iteration's insert died with InFailedSqlTransaction. Wrap each iteration in a
savepoint and roll back to it in the handlers (the pattern frappe#40075 prescribes
after dropping the blanket per-insert savepoint). No-op on MariaDB.
2026-06-21 15:48:32 +05:30
Mihir Kandoi
f1a7b14e25 test(perf): Postgres-valid index introspection in test_ensure_indexes
SHOW INDEX is MySQL-only and errored on Postgres. Add a db-aware helper that reads
the leading index column from pg_index on Postgres and keeps SHOW INDEX on
MariaDB; both assert the field is the first column of some index.
2026-06-21 15:38:06 +05:30
Mihir Kandoi
b760b9d935 test(stock): savepoint around expected duplicate barcode save (Postgres)
The deliberate UniqueValidationError when re-adding a barcode aborts the
transaction on Postgres, so the next frappe.get_doc() failed with
InFailedSqlTransaction. Wrap the expected-failure save in a savepoint and roll
back to it. No-op on MariaDB.
2026-06-21 15:29:36 +05:30
Mihir Kandoi
72046d3688 test(stock): savepoint around expected duplicate Bin insert (Postgres)
The deliberate UniqueValidationError from the second Bin insert aborts the
transaction on Postgres, so the following _create_bin() (which takes its own
savepoint) failed with InFailedSqlTransaction. Wrap the expected-failure insert in
a savepoint and roll back to it, mirroring _create_bin's 'preserve transaction in
postgres' pattern. No-op on MariaDB.
2026-06-21 15:29:30 +05:30
Mihir Kandoi
b97a0c9a13 test(accounts): set Check field with int, not bool (Postgres)
set_value(Company, ..., "book_advance_payments_in_separate_party_account", True)
errored on Postgres (smallint column, boolean expression). Use 1; MariaDB unchanged.
2026-06-21 15:29:25 +05:30
Mihir Kandoi
e076a78003 fix(accounts): set Check field 'reconciled' with int, not bool (Postgres)
frappe.db.set_value(..., "reconciled", True) renders SET reconciled=true; the
column is smallint, which Postgres rejects (DatatypeMismatch). MariaDB coerces the
boolean to 1. Pass 1 so both engines store the same value.
2026-06-21 15:29:19 +05:30
Mihir Kandoi
2e5310f8a0 fix(manufacturing): case-sensitive variant BOM lookup on Postgres
_bom_contains_item() lowercased the item name and then reused that lowercased
value as a doc name in frappe.db.get_value("Item", item, "variant_of"). Doc
names are case-sensitive on Postgres, so the lowercased name matched no row,
variant_of came back NULL, and a Work Order for a variant item built from the
template's BOM was wrongly rejected with 'BOM ... does not belong to Item ...'.
Keep the original case for the Item lookup; the comparisons stay case-insensitive.
MariaDB is unchanged (its name lookup was case-insensitive either way).
2026-06-21 15:29:16 +05:30
Mihir Kandoi
07aa0fe6c1 Merge pull request #56250 from mihir-kandoi/pg-56249-review-followup
fix(stock): make get_incoming_value_for_serial_nos a staticmethod
2026-06-21 15:17:59 +05:30
Mihir Kandoi
81a0709dbd fix(stock): make get_incoming_value_for_serial_nos a staticmethod
It never references `self`. The deterministic-serial-value test added in #56249
called it as `get_incoming_value_for_serial_nos(None, sle, serial_nos)` — passing
None for self, which is fragile: a future `self.*` access would fail with an opaque
AttributeError. Declaring it @staticmethod makes the call honest
(`get_incoming_value_for_serial_nos(sle, serial_nos)`) and is backward compatible —
the method has no in-repo callers besides that test, and any `self.`-style call still
binds correctly to a staticmethod.

Addresses Greptile review feedback on #56249.
2026-06-21 14:56:29 +05:30
Mihir Kandoi
ed1261ef8d Merge pull request #56249 from mihir-kandoi/pg-test-helpers-parity
test(postgres): make test-helper SQL Postgres-valid across the suite
2026-06-21 14:26:39 +05:30
Mihir Kandoi
8a5f659681 test(postgres): make test-helper SQL Postgres-valid across the suite
The repo-wide query audit fixed runtime/source queries, but test files carry their
own raw SQL helpers that were never swept and only fail when the suite runs on
Postgres. Port the staging branch's already-green fixes for them:

- timestamp(posting_date, posting_time) (raw + qb Timestamp) -> posting_datetime /
  CombineDatetime (test_stock_ledger_entry, test_stock_balance, test_utils)
- HAVING <select-alias> -> qb .having(<expr>) (test_asset_capitalization, test_purchase_order)
- capital-cased identifiers ("Status", "Name") -> lowercase (test_delivery_note,
  test_purchase_order, test_employee)
- raw GL/SLE select helpers -> frappe.get_all / qb, with order-independent
  comparisons where account ordering is collation-dependent across engines
  (test_purchase_invoice, test_sales_invoice, test_payment_entry, test_asset,
  test_purchase_receipt, test_payment_request, test_repost_accounting_ledger,
  test_journal_entry)

All changes are test-only and behaviour-identical on MariaDB (lowercase column names
resolve the same; posting_datetime == timestamp(posting_date, posting_time); HAVING on
the expression is the same computation). Verified: the heavy modules pass on both
MariaDB and Postgres, and MariaDB output is unchanged.
2026-06-21 14:05:49 +05:30
Mihir Kandoi
362126a627 Merge pull request #56239 from mihir-kandoi/pg-parity-case-insensitive
fix: case-insensitive matching match MariaDB on Postgres
2026-06-21 13:11:21 +05:30
rohitwaghchaure
cc354c4e94 Merge pull request #56235 from mihir-kandoi/pg-remove-dead-get-batches
refactor(stock): remove dead get_batches() in batch.py
2026-06-21 12:46:22 +05:30
Mihir Kandoi
442ba48341 fix(manufacturing): case-insensitive batch_no filter in Cost of Poor Quality report
The report's batch_no filter used an exact `==`, which is case-sensitive on Postgres -- a
differently-cased batch_no missed Job Cards that MariaDB (case-insensitive collation)
matches. Add a dedicated batch_no branch wrapping both sides in Lower() (keeping the exact
match, not a substring like serial_no): MariaDB result is unchanged, Postgres now matches.
2026-06-21 11:59:57 +05:30
Nabin Hait
97acd4b33b Merge pull request #56141 from frappe/refactor/journal-entry-client-script
refactor: simplify Journal Entry client script
2026-06-21 11:54:38 +05:30
Nabin Hait
1de903143a Merge pull request #56150 from nabinhait/refactor-si-intercompany-fixedassets
refactor(sales_invoice): simplify fixed-asset and inter-company validations
2026-06-21 11:48:20 +05:30
Mihir Kandoi
db3f70c0e7 fix(stock): case-insensitive serial-no match in get_stock_ledgers_for_serial_nos
The serial-no filter used serial_batch_entry.serial_no.isin(serial_nos), which is
case-sensitive on Postgres -- a differently-cased serial no missed Serial and Batch
Entry rows that MariaDB (case-insensitive collation) matches (the OR'd regexp branch
only covers the legacy Stock Ledger Entry.serial_no text, empty for bundle-tracked
serials). Lower() both sides: MariaDB result unchanged, Postgres now matches too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:44:06 +05:30
Mihir Kandoi
9389ce6d9a fix(website): case-insensitive Item Variant attribute match on Postgres
get_item_codes_by_attributes compared Item Variant Attribute `attribute`/`attribute_value`
with raw equality/IN, which is case-sensitive on Postgres -- a differently-cased website
filter value missed variants that MariaDB (case-insensitive collation) matches. Lower()
both sides: MariaDB result is unchanged (already case-insensitive), Postgres now matches too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:30:29 +05:30
Mihir Kandoi
c4bbf22c62 Merge pull request #56236 from mihir-kandoi/pg-test-stock-entry-timestamp
test(stock): order get_sle by posting_datetime, not MySQL timestamp()
2026-06-21 11:20:00 +05:30
Mihir Kandoi
1772ccc61a test(stock): order get_sle by posting_datetime, not MySQL timestamp()
test_stock_entry.get_sle ordered by `timestamp(posting_date, posting_time)`, a
MySQL-only two-arg function that errors on Postgres ("function timestamp(date, time)
does not exist"), so every test using get_sle (test_fifo, test_stock_entry_qty, ...)
failed to run on Postgres. Order by the precomputed `posting_datetime` column instead
(identical value on MariaDB, valid on both engines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:00:50 +05:30
Mihir Kandoi
bc9503bdbf Merge pull request #56234 from mihir-kandoi/pg-stock-index-tests
test(stock): make Bin/Item index tests Postgres-valid (SHOW INDEX → db-agnostic helpers)
2026-06-21 10:53:48 +05:30
Mihir Kandoi
851e70b0f3 Merge pull request #56233 from mihir-kandoi/pg-timesheet-coalesce
fix(projects): use Coalesce for timesheet portal sales_invoice (not bitwise OR)
2026-06-21 10:51:25 +05:30
Mihir Kandoi
345cbc97e1 refactor(stock): remove dead get_batches() in batch.py
batch.get_batches(item_code, warehouse, ...) was added by #55647 and has no callers
anywhere in erpnext, frappe, or payments (not whitelisted, not referenced from JS/hooks).
It is also obsolete: it joins Stock Ledger Entry on `batch_no`, which the Serial and
Batch Bundle system no longer populates, so it returns nothing even on MariaDB. Its
query was additionally Postgres-invalid (GROUP BY batch_id with ORDER BY expiry_date/
creation -> GroupingError, since batch_id is not the primary key).

Remove the dead function (and its now-unused CurDate/Sum import) rather than fix a query
that nothing can reach. Live batch-quantity lookups go through get_batch_qty() /
get_auto_batch_nos(), which use the bundle model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:36:09 +05:30
Mihir Kandoi
dd2f3d42a5 Merge pull request #56231 from mihir-kandoi/pg-rfq-transaction-list
fix(controllers): fix supplier-RFQ portal list query (wrong column + Postgres DISTINCT)
2026-06-21 10:35:33 +05:30
Mihir Kandoi
a927397eac Merge pull request #56230 from mihir-kandoi/pg-lost-quotations-intdiv
fix(selling): keep Lost Quotations % fractional on Postgres (integer division)
2026-06-21 10:29:28 +05:30
Mihir Kandoi
5a0e7f57a3 fix(projects): use Coalesce for timesheet portal sales_invoice (not bitwise OR)
get_timesheets_list selected `timesheet.sales_invoice | detail.sales_invoice`,
intending COALESCE (pick the parent timesheet's invoice, else the detail's) -- the
original raw SQL was COALESCE(ts.sales_invoice, tsd.sales_invoice). pypika's `|` is a
bitwise OR, not a coalesce:

- Postgres: `varchar | varchar` -> "operator does not exist" (hard error).
- MariaDB: bitwise OR coerces the operands to integers; with a NULL detail invoice the
  result is NULL, so the portal showed no invoice even when the timesheet was billed.

Replace with Coalesce(table.sales_invoice, child_table.sales_invoice).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:27:14 +05:30
Mihir Kandoi
9fe2202f39 test(stock): use db-agnostic index introspection in Item index test
test_index_creation used `frappe.db.sql("show index from tabItem")` and the MySQL-only
result key "Column_name". "SHOW INDEX" errors on Postgres, so the test could not run
there. Use the db-agnostic frappe.db.get_column_index("tabItem", column) (checking both
unique and non-unique single-column indexes) instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:25:32 +05:30
Mihir Kandoi
f66ef869fc test(stock): use db-agnostic index introspection in Bin index test
test_index_exists used `frappe.db.sql("show index from tabBin ...")`. "SHOW INDEX"
is MySQL-only syntax and errors on Postgres (syntax error at "from"), so the test
could not run there. Use the db-agnostic frappe.db.has_index("tabBin",
"unique_item_warehouse") instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:25:30 +05:30
Mihir Kandoi
42fffc4857 Merge pull request #56227 from mihir-kandoi/pg-general-ledger-alias
fix(accounts): General Ledger remarks alias Postgres-valid
2026-06-21 10:19:25 +05:30
Mihir Kandoi
de70333f8f Merge pull request #56229 from mihir-kandoi/pg-lcv-having
fix(stock): make Landed Cost Voucher vendor-invoice query Postgres-valid (HAVING→WHERE)
2026-06-21 10:18:54 +05:30
Mihir Kandoi
404d4413b5 Merge pull request #56228 from nishkagosalia/st-71960
fix: disarding stock entry fix
2026-06-21 10:16:42 +05:30
Mihir Kandoi
a7d9078bf4 fix(controllers): fix supplier-RFQ portal list query (wrong column + Postgres DISTINCT)
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>
2026-06-21 10:14:42 +05:30
Mihir Kandoi
0e88f59196 Merge pull request #56226 from mihir-kandoi/pg-item-variant-update-join
fix(controllers): make update_variant_attribute_values Postgres-valid (drop UPDATE..JOIN)
2026-06-21 10:13:50 +05:30
Mihir Kandoi
ed6a682779 fix(selling): keep Lost Quotations % fractional on Postgres (integer division)
The "Lost Quotations %" column computed Count(distinct) / total_quotations * 100,
where both operands are integers. Postgres does integer division on int/int, so any
group that is a strict minority of the total truncated to 0 (e.g. 1 of 4 -> 0%);
MariaDB always divides as decimal. Multiply by 100.0 before dividing so the division
is done in floating point on both engines.

The "Lost Value %" column already divided Sum(Currency)/Sum(Currency) (numeric), so it
was unaffected; left unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:09:45 +05:30
Mihir Kandoi
016097da2e Merge pull request #56225 from mihir-kandoi/pg-projects-pg-validity
fix(projects): Project timeline GROUP BY Postgres-valid
2026-06-21 10:04:08 +05:30
Mihir Kandoi
acbf453def fix(accounts): make General Ledger remarks alias Postgres-valid
When Accounts Settings -> general_ledger_remarks_length is set, the GL report
adds `substr(remarks, 1, n) as 'remarks'` to its raw SQL. Postgres treats a
single-quoted column alias as a string literal and raises a syntax error, so
the General Ledger report is broken on Postgres whenever that setting is on.

Use a bare alias (`as remarks`). substr() itself is portable.

Adds a test that sets general_ledger_remarks_length and runs the report,
asserting it executes (and returns rows) on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:58:06 +05:30
Mihir Kandoi
7f8fa5b5a2 fix(stock): make Landed Cost Voucher vendor-invoice query Postgres-valid
get_vendor_invoice_query filtered unclaimed invoices with
.having(unclaimed_amount > 0), but the query has no GROUP BY/aggregate and
unclaimed_amount is a SELECT alias. Postgres rejects HAVING on a SELECT alias
(and HAVING without GROUP BY on a non-aggregated column); MariaDB allowed it.
Move the threshold into WHERE on the underlying expression.

Behaviour is identical on MariaDB (same rows); fixes a hard error on Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:55:51 +05:30
Mihir Kandoi
3d9b704730 Merge pull request #56224 from mihir-kandoi/pg-production-planning-report
fix(manufacturing): Production Planning report GROUP BY Postgres-valid
2026-06-21 09:54:49 +05:30
nishkagosalia
debe1855c6 fix: disarding stock entry fix 2026-06-21 09:54:20 +05:30
Mihir Kandoi
598864f0be fix(controllers): make update_variant_attribute_values Postgres-valid (drop UPDATE..JOIN)
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>
2026-06-21 09:46:43 +05:30
Mihir Kandoi
a483177690 fix(projects): make Project timeline GROUP BY Postgres-valid
get_timeline_data grouped Timesheet Detail by Date(from_time) but selected
UnixTimestamp(from_time) (the full timestamp, ungrouped). MariaDB
arbitrary-picks a row's timestamp; Postgres rejects it ("must appear in the
GROUP BY clause"), so the Project timeline (calendar heatmap) is broken on PG.

Select UnixTimestamp(Date(from_time)) — the day's epoch — which is the
timeline key and matches the GROUP BY. CurDate() - Interval(years=1) is
portable and kept as-is.

Adds a test (no coverage existed) that records a timesheet against a project
and asserts get_timeline_data returns day-bucketed counts, on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:44:48 +05:30
Mihir Kandoi
469d58d1f4 fix(manufacturing): make Production Planning report GROUP BY Postgres-valid
get_purchase_details grouped Purchase Order Item by (item_code, warehouse)
while selecting `qty` ungrouped/unaggregated. MariaDB arbitrary-picks one
row's qty; Postgres rejects the query ("must appear in the GROUP BY clause"),
so the report is broken on Postgres.

Sum the qty per item+warehouse ({"SUM": "qty"}). The column is the "Arrival
Qty" (quantity on order arriving) display figure; summing the open PO lines is
the meaningful planning number, and is deterministic vs MariaDB's arbitrary
single-line pick (which only differed when an item+warehouse had multiple open
PO lines).

Adds a test (no test file existed) that creates a Work Order plus two PO lines
for a BOM raw material and asserts the report runs and reports arrival_qty = 7
(3 + 4), on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:35:56 +05:30
Mihir Kandoi
f8359c91b2 Merge pull request #56221 from mihir-kandoi/pg-stock-entry
fix(stock): convert get_used_alternative_items to query builder (Postgres)
2026-06-21 08:09:27 +05:30
Mihir Kandoi
59dd3fe84e Merge pull request #56218 from mihir-kandoi/pg-stock-ledger
fix(stock): stock_ledger raw SQL → qb + case-insensitive serial matching (Postgres)
2026-06-21 08:00:17 +05:30
Mihir Kandoi
100eefc146 Merge pull request #56219 from mihir-kandoi/pg-repost-item-valuation
fix(stock): Repost Item Valuation dedup Postgres-valid (TIMESTAMP → CombineDatetime)
2026-06-21 07:59:23 +05:30
Mihir Kandoi
51448a2bda Merge pull request #56217 from mihir-kandoi/pg-controllers-buying-itemvariant-trends
refactor(controllers): buying_controller + item_variant + trends Postgres validity
2026-06-21 07:58:38 +05:30
Mihir Kandoi
d707fb541d Merge pull request #56216 from mihir-kandoi/pg-controllers-queries
refactor(controllers): queries.py search handlers raw SQL → qb (Postgres)
2026-06-21 07:55:35 +05:30
Mihir Kandoi
ea025b6b61 Merge pull request #56211 from mihir-kandoi/pg-project-update-daily-reminder
fix(projects): repair project_update daily_reminder + convert to ORM (Postgres)
2026-06-21 07:53:30 +05:30
Mihir Kandoi
025f0db7d7 Merge pull request #56215 from mihir-kandoi/pg-controllers-budget-subcon
fix(controllers): budget GROUP BY + subcontracting bool-OR Postgres validity
2026-06-21 07:52:00 +05:30
Mihir Kandoi
0d8abba0d8 Merge pull request #56214 from mihir-kandoi/pg-controllers-return-stock
refactor(controllers): sales/purchase return + stock_controller raw SQL → qb/ORM (Postgres)
2026-06-21 07:51:13 +05:30
Mihir Kandoi
f8ae4f99af Merge pull request #56212 from mihir-kandoi/pg-controllers-selling-status-website
refactor(controllers): selling/status_updater/website raw SQL → qb/ORM (Postgres)
2026-06-21 07:46:39 +05:30
Mihir Kandoi
f8f6c444c8 Merge pull request #56213 from mihir-kandoi/pg-customer-name-pg-extract
fix(selling): make Customer name de-duplication work on Postgres
2026-06-21 07:45:37 +05:30
Mihir Kandoi
a1f7bf8195 Merge pull request #56210 from mihir-kandoi/pg-authcontrol-boot
refactor(postgres): Authorization Control + startup boot raw SQL → qb/ORM
2026-06-21 07:44:55 +05:30
Mihir Kandoi
08dd8cb9da Merge pull request #56209 from mihir-kandoi/pg-stock-batch-report-item-attribute
fix(stock): Available Batch report GROUP BY + ItemAttribute raw SQL→qb (Postgres)
2026-06-21 07:43:07 +05:30
Mihir Kandoi
f14610e31b Merge pull request #56208 from mihir-kandoi/pg-work-order-stock-report-groupby
fix(manufacturing): make Work Order Stock report GROUP BY Postgres-valid
2026-06-21 07:42:06 +05:30
Mihir Kandoi
3ce0c23513 Merge pull request #56220 from mihir-kandoi/pg-item
fix(stock): convert item.py raw SQL → qb/ORM (Postgres)
2026-06-21 07:36:59 +05:30
Mihir Kandoi
e8acc00921 Merge pull request #56207 from mihir-kandoi/pg-buying-reports-groupby
fix(buying): make Procurement Tracker & PO Analysis reports Postgres-valid (GROUP BY)
2026-06-21 07:32:58 +05:30
Mihir Kandoi
74368bc744 fix(stock): convert get_used_alternative_items to query builder
get_used_alternative_items built its WHERE with f-string interpolation of
subcontract_order / subcontract_order_field / work_order (a SQL-injection risk)
and used a raw implicit comma cross-join. Convert to frappe.qb with an
inner_join on sted.parent == ste.name and parameterised conditions. The raw
SELECT listed sted.conversion_factor twice; the qb version selects it once.
Engine-portable and MariaDB-identical.

Surgical re-apply: the rest of stock_entry.py (the services/ package layout and
other develop-only logic) is untouched.

Adds a test that substitutes an alternative item in a work order's transfer
entry and asserts get_used_alternative_items returns the mapping, on MariaDB
and Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:32:54 +05:30
Mihir Kandoi
b85c79c3e0 fix(stock): convert item.py raw SQL to qb/ORM (Postgres-valid)
Convert the raw frappe.db.sql statements in Item to frappe.qb / ORM:
validate_barcode duplicate check (-> frappe.get_all), stock_ledger_created
(-> frappe.db.exists), update_item_price + the BOM/BOM Item/BOM Explosion
description updates + check_stock_uom_with_bin's Bin UOM update (-> frappe.qb
.update), on_trash Bin/Item Price deletes (-> frappe.db.delete),
check_stock_uom_with_bin's bin lookup (-> frappe.get_all with or_filters), and
get_uom_conv_factor's self-join (-> frappe.qb).

The one genuine Postgres break is validate_duplicate_item_in_stock_reconciliation:
its raw query used `HAVING records > 1`, referencing the SELECT alias, which
Postgres rejects. The qb version uses `HAVING Count("*") > 1`.

Surgical re-apply (not a whole-file port): develop's opening-stock-reconciliation
flow (set_opening_stock / create_opening_stock_reconciliation /
make_opening_stock_entry) is preserved, and get_timeline_data keeps develop's
CurDate()-Interval form (valid on both engines), so the Interval/CurDate/
SerialBatchCreation imports are retained.

Verified: test_item 38/38 on MariaDB. Added a merge-rename test exercising the
HAVING query (validate_duplicate_item_in_stock_reconciliation) which passes on
MariaDB and Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:30:07 +05:30
Mihir Kandoi
3f360dde3a fix(stock): convert stock_ledger raw SQL to qb + case-insensitive serial match (Postgres)
Convert four raw frappe.db.sql statements to frappe.qb:
- set_as_cancel (UPDATE -> frappe.qb.update)
- the invalid-serial-no incoming_rate lookup
- get_valuation_rate's last-valuation lookup
- get_future_sle_with_negative_qty

The serial-no comparisons (invalid-serial lookup and the get_stock_ledger_entries
condition builder, which stays raw) are wrapped in lower()/Lower() so serial
matching is case-insensitive on Postgres too -- MariaDB's collation already is,
so this is a no-op there. Deterministic creation/name tiebreakers are added to
the "ORDER BY posting_date DESC LIMIT 1" lookups so Postgres picks the same row
MariaDB did.

Surgical re-apply (not a whole-file port): develop's reposting valuation-recalc
clause (`recalculate_valuation_rate`) in update_entries_after and the
already-shipped Min()-wrapped get_items_to_be_repost GROUP BY are preserved. The
dynamic-condition / row-locking raw queries (get_previous_sle,
get_stock_ledger_entries builder, get_future_sle_with_negative_batch_qty, the
qty_shift UPDATE) are intentionally left raw.

Verified: full test_stock_ledger_entry suite 22/22 on MariaDB; added focused
tests for set_as_cancel / get_valuation_rate / get_future_sle_with_negative_qty
that pass on MariaDB and Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:08:21 +05:30
Mihir Kandoi
be213d9d3d fix(stock): make Repost Item Valuation dedup Postgres-valid (TIMESTAMP→CombineDatetime)
deduplicate_similar_repost used a raw UPDATE with the MySQL-only two-arg
TIMESTAMP(posting_date, posting_time) constructor, which is invalid on Postgres.

Convert the UPDATE to frappe.qb and replace TIMESTAMP() with CombineDatetime
on the column (portable, and preserves the original NULL semantics so rows with
a NULL posting_time stay excluded); the right-hand side is this document's own
always-set posting datetime, computed in Python via get_combine_datetime to
avoid wrapping literals in a SQL datetime function.

Surgical re-apply: develop's recalculate_valuation_rate field /
_recalculate_valuation_rate method / repost() branch are left intact.

The existing test_repost_item_valuation.test_deduplication directly exercises
this UPDATE; it errors on develop's Postgres and now passes on MariaDB and
Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:06:16 +05:30
Mihir Kandoi
d065a18d16 fix(controllers): make trends queries Postgres-valid (SUM(CASE), GROUP BY)
The *_trends reports (Sales/Purchase Order/Invoice, Delivery Note, etc.) built
raw SQL that is invalid on Postgres:

- `SUM(IF(...))` -> `SUM(CASE WHEN ... ELSE NULL END)` (IF is MySQL-only).
- Loose GROUP BY: each based_on `group by` listed only the key column while the
  SELECT also returned name/territory/group/currency columns. Widen the GROUP BY
  to include every selected non-aggregated column so the query is valid on
  Postgres.
- Add a based_on_key (the first group-by column) for the group-by detail
  subqueries, which equate against a single column (a multi-column group_by
  spliced into an equality produced malformed SQL on both engines).

Behaviour note: widening the GROUP BY can split one based-on group into multiple
report rows when the snapshot columns (territory, renamed customer/item) differ
across transactions, vs MariaDB's previous one-arbitrary-row-per-group. Grand
totals are unchanged (calculate_total_row); per-group subtotals become
deterministic partial sums. This is the accepted widen-vs-arbitrary-pick
tradeoff.

Adds a test (no test file existed) running Sales Order Trends with a group_by,
exercising the widened GROUP BY / based_on_key / SUM(CASE) on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:02:29 +05:30
Mihir Kandoi
af2f53bee1 refactor(controllers): convert make_variant_item_code lookup to query builder
make_variant_item_code used a raw frappe.db.sql left join over Item Attribute /
Item Attribute Value. Convert to frappe.qb. The attribute_value comparison
casts the param with cstr() so Postgres does not error on `varchar = numeric`
for numeric attributes (where that side is irrelevant, since numeric_values == 1
already satisfies the OR). MariaDB-identical.

Surgical re-apply: develop's get_attribute_value_renames /
update_variant_attribute_values helpers and the Case import are preserved.
Covered by test_item_variant on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:02:29 +05:30
Mihir Kandoi
08f39c5345 refactor(controllers): convert BuyingController raw SQL lookups to ORM
- Asset Movement deletion: raw implicit-join select -> frappe.get_all on
  Asset Movement Item (pluck="parent").
- validate_item_type: raw `name in (...)` select -> frappe.get_all with an
  `in` filter (pluck="item_code").

Both are engine-portable, MariaDB-identical. Surgical re-apply: develop's
actual-tax distribution rewrite (distribute_actual_tax_amount / get_tax_details)
is preserved (the staging branch predated it).

validate_item_type runs on every Purchase Receipt validation (covered by
test_asset.test_purchase_asset on both engines); the Asset Movement deletion
is covered by the asset cancellation flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:02:29 +05:30
Mihir Kandoi
957f9d866a refactor(controllers): convert queries.py search handlers to qb/ORM (Postgres)
Convert eight raw frappe.db.sql search handlers to frappe.qb / frappe.qb.get_query
(which applies user-permission match conditions): employee_query, lead_query,
tax_account_query, bom, warehouse_query, get_batch_numbers, get_purchase_receipts
and get_purchase_invoices. Removes the MySQL-only get_match_cond/get_filters_cond
string building and ifnull usage.

The genuine Postgres break is get_project_name: it used CustomFunction("IF")
which emits a literal IF() (invalid on Postgres). Switch it to Case().

Surgical re-apply (not a whole-file port): develop's case-insensitive
Lower() ordering in item_query and get_project_name is preserved (the staging
branch reverted it), item_query is otherwise left untouched, and the Lower
import is retained.

Existing test_queries tests cover the converted handlers and now pass on
Postgres (test_project_query errors on develop). Adds smoke tests for the
three previously-untested handlers (batch numbers / purchase receipts /
purchase invoices).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:50:08 +05:30
Mihir Kandoi
8138f5aecd refactor(controllers): convert StockController future-SLE/GL checks to qb/ORM
- make_gl_entries_on_cancel: raw GL Entry existence select -> frappe.db.exists.
- future_sle_exists: raw GROUP BY count -> frappe.qb Count with Criterion.any,
  and get_conditions_to_validate_future_sle builds qb Criterion objects
  (warehouse == x & item_code.isin(...)) instead of escaped SQL strings.

Parity-preserving and valid on Postgres. Surgical re-apply: develop's
check_item_quality_inspection fix (`return items if doctype == "Stock Entry"
else []`) is preserved (the staging branch predated and would have reverted
it).

Adds a test asserting future_sle_exists detects a later SLE for the same
item/warehouse on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:40:30 +05:30
Mihir Kandoi
465446bb79 refactor(controllers): convert sales/purchase return lookups to qb/ORM
validate_returned_items used a raw frappe.db.sql with a string-built column
list (and a separate Packed Item select); get_already_returned_items used a
raw GROUP BY sum. Convert both to frappe.get_all / frappe.qb (Sum(Abs(...))
with an explicit groupby). The qb GROUP BY mirrors the original
`group by item_code, <field>`, so it is parity-preserving (not a behaviour
change) and valid on Postgres.

Surgical re-apply: develop's `is_debit_note = 0` credit-note fix in
make_return_doc is preserved (the staging branch predated and would have
reverted it).

Adds a test (Delivery Note -> sales return) exercising validate_returned_items
and get_already_returned_items on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:40:30 +05:30
Mihir Kandoi
7793e31e4e fix(projects): repair project_update daily_reminder and convert to ORM
daily_reminder/email_sending used raw frappe.db.sql with two portability
and correctness problems:

- The update query selected `progress` and `progress_details` from
  `tabProject Update`, but those columns do not exist on the Project
  Update doctype, so the query raised on BOTH MariaDB and Postgres
  (the function is whitelisted-only, so the bug was latent). Drop the
  non-existent columns and the corresponding "Project Status"/"Notes"
  cells from the summary table.
- `DATE_ADD(CURRENT_DATE, INTERVAL -1 DAY)` (MySQL-only) and a
  `CURRENT_DATE` Holiday lookup are not valid on Postgres.

Convert to ORM: frappe.get_all for Project/Project Update/Project User,
frappe.db.count for drafts, frappe.db.exists for the holiday check, and
add_days(today(), -1) for the date filter. Also str() the frequency in the
message so a NULL/empty frequency (Postgres returns None) does not raise.

Adds a test (the file was an empty stub) that creates a project + an update
dated yesterday and asserts the reminder finds it and runs end to end on
both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:33:47 +05:30
Mihir Kandoi
147a8672b4 fix(controllers): cast overproduced-qty flag to bool in subcontracting Case
The max-allowed-qty Case used `... | ValueWrapper(allow_delivery_of_overproduced_qty)`
where the flag is an int (0/1). Postgres rejects `OR <integer>` ("argument of
OR must be type boolean"). Wrap it in bool() so the literal renders as
true/false. MariaDB behaviour is unchanged.

Surgical: only the bool() wrap is applied; develop's weighted-average rate
logic and the internal/whitelisted status-helper split are left intact (the
staging branch predated both).

Covered by test_subcontracting_inward_order.test_over_production_delivery,
which now passes on Postgres and is unchanged on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:28:12 +05:30
Mihir Kandoi
f95e32a581 fix(controllers): make budget requested-amount aggregate Postgres-valid
The Material Request requested-amount query selects
`Sum(stock_qty - ordered_qty) * mri.rate` -- an implicit aggregate with no
GROUP BY, where mri.rate is neither grouped nor aggregated. MariaDB
arbitrary-picks the rate; Postgres rejects it ("must appear in the GROUP BY
clause"). Wrap the rate in Max(mri.rate) so the SELECT is a pure aggregate.

Behaviour note: for matched MR items with differing rates, Max() picks the
highest (vs MariaDB's arbitrary single rate). The underlying Sum(qty) * rate
is a pre-existing single-rate aggregation; this preserves it under the
accepted arbitrary-pick convention.

Covered by erpnext.accounts.doctype.budget.test_budget
.test_monthly_budget_crossed_for_mr, which now passes on Postgres (it errors
on develop) and is unchanged on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:28:11 +05:30
Mihir Kandoi
c4d2228b36 refactor(controllers): convert website_list_for_contact currency lookup to ORM
get_list_context built the enabled-currency symbol map with a raw
frappe.db.sql select. Convert to frappe.get_all (as_list). MariaDB-identical.

Adds a test asserting the currency-symbol map is built and contains a known
enabled currency, on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:18:58 +05:30
Mihir Kandoi
9c3f09927f fix(selling): make Customer name de-duplication work on Postgres
get_customer_name's Postgres branch used `Substring(Customer.name, r"\d+$")`,
but pypika's Substring is a start/length function, not a regex extractor, so
it raised `TypeError: Substring.__init__() missing 1 required positional
argument: 'stop'` at query-build time. Creating a second Customer with an
existing name therefore failed outright on Postgres.

Extract the trailing digits with regexp_replace + NULLIF + CAST instead. A
non-numeric trailing token strips to an empty string, which NULLIF turns into
NULL so MAX() skips it and COALESCE floors to 0 -- matching MariaDB's
CAST(... AS UNSIGNED) -> 0. MariaDB behaviour is unchanged (its branch is
untouched). Drops the now-unused Substring import.

Adds a test that creates "<name>" and "<name> - 3" and asserts the next
de-duplicated name is "<name> - 4" on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:16:58 +05:30
Mihir Kandoi
083858d450 refactor(controllers): make StatusUpdater Postgres-valid (ifnull→coalesce, raw SQL→qb)
- Replace MySQL-only `ifnull(...)` with `coalesce(...)` in the two
  source/second-source percentage subqueries that remain raw (they
  interpolate dynamic table/field names).
- zero_amount_refdocs: raw `sql_list` → `frappe.get_all(pluck="name")`.
- update_billing_status: two raw `ifnull(sum(qty), 0)` selects → frappe.qb
  `Sum`; an empty result yields None and flt(None) == 0, matching the old
  ifnull behaviour.

Behaviour is unchanged on MariaDB. The percentage path (coalesce subquery)
is exercised by test_selling_controller's Sales Order -> Delivery Note
per_delivered test on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:10:19 +05:30
Mihir Kandoi
f793027800 refactor(controllers): convert SellingController delivered-qty lookups to qb/ORM
get_already_delivered_qty used two raw frappe.db.sql sums (Delivery Note
Item, and Sales Invoice Item joined to Sales Invoice) and
get_so_qty_and_warehouse used a raw select. Convert to frappe.qb (Sum) and
frappe.db.get_value. Engine-portable and MariaDB-identical.

Adds a test (Sales Order -> partial Delivery Note) that asserts
per_delivered, exercising get_already_delivered_qty / get_so_qty_and_warehouse
(and the StatusUpdater percentage path) on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:10:17 +05:30
Mihir Kandoi
ce2e7fb7ee refactor(startup): convert boot_session raw SQL to ORM (Postgres-valid)
boot_session used raw `frappe.db.sql`, including a MySQL-only
`ifnull(account_type, '')` over Party Type that is invalid on Postgres.

- customer_count: `SELECT count(*)` → `frappe.db.count`
- setup_complete: `SELECT name ... LIMIT 1` → `frappe.db.get_all(limit=1)`
- companies: raw select → `frappe.get_all`, preserving the `:Company`
  virtual-doc marker
- party_account_types: `ifnull(account_type,'')` → `frappe.get_all` with a
  Python `account_type or ""`, which collapses NULL→'' and ''→''
  identically on both engines (handles Postgres storing '' as NULL)

Adds a test (no test file existed) that runs boot_session and asserts the
company list and party_account_types are populated, on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:03:47 +05:30
Mihir Kandoi
7fe79b115d refactor(stock): convert ItemAttribute.validate_exising_items to query builder
validate_exising_items() used a raw frappe.db.sql implicit-join to find
variant items using the attribute. Convert it to a frappe.qb inner join
(engine-portable, MariaDB-identical) so it no longer relies on raw SQL.

Only this query is converted; develop's update_variant_attribute_values
on_update hook and its imports are left intact (the staging branch's
whole-file version predated and would have reverted them).

Adds a focused test that creates a variant and asserts validate_exising_items
finds it (the validation only raises if the converted query returned the
variant row). Passes on MariaDB and Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 04:58:14 +05:30
Mihir Kandoi
9fb08153d6 fix(stock): make Available Batch report GROUP BY Postgres-valid
Both get_batchwise_data_from_stock_ledger and
get_batchwise_data_from_serial_batch_bundle select Batch columns
(expiry_date, and item_name when show_item_name is set) while grouping
only by Stock Ledger Entry columns. MariaDB arbitrary-picks the Batch
columns; Postgres rejects the query with "column ... must appear in the
GROUP BY clause".

Add the Batch PK (batch.name) to both GROUP BYs. batch.name is 1:1 with
the grouped batch_no (the join condition), so groups are unchanged and the
result is identical on MariaDB.

The serial-batch-bundle query additionally grouped by ch_table.warehouse
while selecting table.warehouse; group by the selected (SLE) warehouse so
the grouped and selected columns match (also required by Postgres).

Adds a test (no test file existed) that receives batch stock and asserts
the report lists it with the correct balance, exercising the GROUP BY on
both engines (with show_item_name set to force the extra Batch column).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 04:58:14 +05:30
Mihir Kandoi
52d7f56922 refactor(setup): make Authorization Control Postgres-valid (ifnull→coalesce, raw SQL→qb)
authorization_control.py used MySQL-only `ifnull()` in its raw rule
lookups (invalid on Postgres) and several raw `frappe.db.sql` selects.

- Replace every `ifnull(...)` with the portable `coalesce(...)` in the
  rule-lookup statements that remain raw (they interpolate dynamic
  conditions and rely on Frappe's Postgres backtick translation).
- Convert the user/role based_on lookups in validate_approving_authority
  and the four value-based lookups in get_value_based_rule to frappe.qb
  (Coalesce, isin, and a fresh Employee-designation subquery per use).

Behaviour is unchanged on MariaDB; the queries now run on Postgres.

Adds a test (no test file existed): a not-authorized case that exercises
the based_on + coalesce rule lookups (run as a non-admin user, since
Administrator implicitly holds every role), and a get_value_based_rule
call that exercises all four query-builder lookups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 04:55:59 +05:30
Mihir Kandoi
e9c391608c fix(manufacturing): make Work Order Stock report GROUP BY Postgres-valid
get_item_list() computes build_qty as IfNull(bin.actual_qty * bom.quantity
/ bom_item.stock_qty, 0) while grouping only by bom_item.item_code. The
three operand columns are neither grouped nor aggregated, so MariaDB
arbitrary-picks them but Postgres rejects the query with "column ... must
appear in the GROUP BY clause".

Add bom.quantity, bom_item.stock_qty and bin.actual_qty to the GROUP BY.
The WHERE pins bom/item and the join pins warehouse to single rows (Bin is
unique per item+warehouse), so the result stays one row per item and
MariaDB behaviour is unchanged.

Adds a test (no test file existed) that runs the report against a Work
Order and asserts it is listed, exercising the query on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 04:44:02 +05:30
Mihir Kandoi
0b4e52e8d7 fix(buying): make Purchase Order Analysis GROUP BY Postgres-valid
get_data() grouped only by Purchase Order Item while selecting Purchase
Order parent columns. MariaDB allows this loose GROUP BY; Postgres rejects
it with "column ... must appear in the GROUP BY clause".

Add the Purchase Order PK (po.name) to the GROUP BY. po.name is 1:1 with
the already-grouped po_item.name, so groups are unchanged and the result
is identical on MariaDB.

Adds a test (no test file existed) that runs the report and asserts the PO
is listed, exercising the GROUP BY on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 04:40:35 +05:30
Mihir Kandoi
8b4845d272 fix(buying): make Procurement Tracker GROUP BY Postgres-valid
get_po_entries() grouped only by (Purchase Order, material_request_item)
while selecting other Purchase Order Item columns. MariaDB allows this
loose GROUP BY (arbitrary-picking the extra columns); Postgres rejects it
with "column ... must appear in the GROUP BY clause".

Add the Purchase Order Item PK (child.name) to the GROUP BY so the
selected child columns are functionally determined by a grouped key.

Behaviour note: this is not a MariaDB no-op. When one PO has multiple
items sharing the same/blank material_request_item, MariaDB collapsed
them into one arbitrary row; now there is one row per PO line. The
downstream report already keys rows by purchase_order, so totals are
unaffected and the per-line breakdown is more correct.

Adds a test that runs the report and asserts the PO is listed, exercising
the GROUP BY on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 04:40:33 +05:30
mergify[bot]
efa4d76c50 Merge pull request #56187 from aerele/fix/job-card-partially-transferred-status
fix: add partially transferred status and fix button visibility for partial material transfer on job card
2026-06-20 19:02:28 +00:00
Shllokkk
f83a80de48 Merge pull request #56155 from aerele/fix/party-type-filter-v16
fix: fetch party types based on account type in journal entry
2026-06-21 00:02:02 +05:30
Mihir Kandoi
4255059846 Merge pull request #56196 from mihir-kandoi/pg-bom-groupby-fix
fix(manufacturing): make get_bom_items_as_dict Postgres-valid (GROUP BY)
2026-06-20 22:33:30 +05:30
Mihir Kandoi
cfedcc06c8 Merge pull request #56197 from mihir-kandoi/pg-stock-entry-groupby-fix
fix(stock): Postgres GROUP-BY validity for Job Card secondary-item & disassembly queries
2026-06-20 22:24:10 +05:30
Mihir Kandoi
79cbefb088 fix(manufacturing): make get_bom_items_as_dict Postgres-valid (GROUP BY)
_query_bom_items / _build_base_bom_items_query / _add_*_item_columns selected
non-grouped columns (idx, item_name, image, project, item-default fields, BOM
Item attributes) alongside `group by item_code` -> arbitrary pick on MariaDB,
GroupingError on Postgres. Wrap them in Max() (Min() for idx, preserving the
original ordering). Every wrapped column is functionally dependent on the
grouped item_code (item attributes / the single BOM's project / one Item
Default per item+company), so Max()/Min() returns exactly the value MySQL
picked arbitrarily -> MariaDB output unchanged.

This was previously shipped in #56008 and reverted with that batch; re-applied
in isolation here. Verified: test_work_order 85/85 on BOTH MariaDB (no change)
and Postgres (was 85/85 failing on this query).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:13:29 +05:30
Mihir Kandoi
911a27e8e6 Merge pull request #56195 from mihir-kandoi/pg-stock-reconciliation
refactor(stock): port Stock Reconciliation raw SQL to qb/ORM (Postgres compat)
2026-06-20 22:12:14 +05:30
Mihir Kandoi
810e93758e fix(stock): make disassembly manufacture-entry query Postgres-valid (GROUP BY)
get_items_from_manufacture_stock_entry aggregated Stock Entry Detail rows by
item_code while selecting item_name/description/warehouses/etc. (and an orderby
on the non-grouped idx) -> arbitrary pick on MariaDB, GroupingError on Postgres.
Wrap the non-grouped columns in Max() (Min(idx) for the orderby), preserving the
one-row-per-item shape the disassembly expects; an item plays one role with one
uom/warehouse across the WO's manufacture entries, so MariaDB output is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:04:46 +05:30
Mihir Kandoi
4d29bfbe07 fix(stock): make Job Card secondary-item query Postgres-valid (GROUP BY)
get_secondary_items_from_job_card selected item_name/description/stock_uom/
bom_secondary_item alongside `group by item_code, secondary_item_type` (and an
orderby on the non-grouped idx) -> arbitrary pick on MariaDB, GroupingError on
Postgres. Wrap the non-grouped columns in Max() (Min(idx) for the orderby);
they are item attributes / the secondary-item BOM link, constant per group, so
MariaDB output is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:53:50 +05:30
Mihir Kandoi
554c196870 refactor(stock): convert Stock Reconciliation raw SQL to qb/ORM
validate_expense_account SLE existence check -> frappe.db.get_all(limit=1);
get_items_for_stock_reco's two comma-join SELECTs -> frappe.qb inner_joins, with
the correlated Warehouse-subtree EXISTS replaced by a precomputed
warehouses_in_tree subquery + isin and ifnull(disabled,0)=0 -> disabled==0|isnull.
The Item-Default query's `group by i.name` is dropped (sound: validate_item_defaults
enforces one Item Default per (item, company), so the company-filtered query already
returns one row per item; the downstream (item_code, warehouse) de-dup is unchanged).
Same result on MariaDB; valid under Postgres.

Tests: get_items_for_stock_reco Bin branch (stocked item) and Item-Default branch
(default_warehouse, no stock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:19:36 +05:30
Mihir Kandoi
8c1c8a3cee Merge pull request #56192 from mihir-kandoi/pg-purchase-receipt
refactor(stock): port Purchase Receipt + LCV raw SQL to qb/ORM + #39 GROUP-BY fixes (Postgres)
2026-06-20 20:56:56 +05:30
Mihir Kandoi
b811dba5c2 fix(stock): aggregate non-grouped cols in get_items_to_be_repost (PG #39)
get_items_to_be_repost selected posting_date/posting_time/creation/posting_datetime
alongside `group_by item_code, warehouse` with no aggregation -> arbitrary pick on
MariaDB, GroupingError on Postgres. Wrap the four columns in `Min()` (earliest row
per item+warehouse, the correct repost-start point; a single voucher's SLEs share
posting_date/time per group -> MariaDB-identical). This is reached by every stock
transaction submit/cancel via repost_future_sle_and_gle, so it unblocks the whole
transaction-heavy stock suite on Postgres (e.g. test_purchase_receipt 105/105).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:09:05 +05:30
Mihir Kandoi
afb7c25141 refactor(stock): convert LCV serial-rate update to qb + fix cost_center GROUP BY (PG)
Convert the `update tabSerial No set purchase_rate ... where name in (...)` to
frappe.qb.update(isin). Also fix the #39 Postgres bug in
set_landed_cost_voucher_amount: `.select(Sum(applicable_charges), cost_center)`
selected a non-grouped column with no GROUP BY (GroupingError on PG) -> wrap it
in `Max(cost_center)` (deterministic representative; per (receipt_document,
receipt_item) the matching LCV items share a cost_center -> MariaDB-identical).
Covered by the existing landed-cost tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:09:05 +05:30
Mihir Kandoi
c76c0d85ba refactor(stock): convert PR get_invoiced_qty_map to qb aggregate
Replace the raw `select pr_detail, qty from Purchase Invoice Item` (summed in
Python) with a frappe.qb GROUP BY Sum(qty) per pr_detail, matching the sibling
get_returned_qty_map. Same result on MariaDB; valid under Postgres. Covered by
the existing make_purchase_invoice tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:09:05 +05:30
Mihir Kandoi
a65aa27225 refactor(stock): convert Purchase Receipt raw SQL to ORM
get_already_received_qty (sum over Purchase Receipt Item, parent != self.name)
and the two Purchase-Invoice-against-receipt existence checks (implicit
comma-joins -> child-table get_all on Purchase Invoice Item, docstatus=1).
Also fixes a pre-existing `self.submit_rv` -> `submit_rv` typo in the (dead)
check_next_docstatus that staging carried forward. Same result on MariaDB;
valid under Postgres.

Tests: get_already_received_qty (parent-exclusion sum) and check_next_docstatus
(blocks on a submitted Purchase Invoice; also locks the typo fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:08:43 +05:30
Diptanil Saha
5505ae43d4 Merge pull request #56191 from diptanilsaha/fix/perms_on_whitelisted_functions
fix: added missing permission validation on whitelisted function and removed unnecessary whitelisted decorator
2026-06-20 19:50:28 +05:30
diptanilsaha
e29535f29c fix(report_utils): remove unnecessary whitelist decorator on get_invoiced_item_gross_margin 2026-06-20 19:28:49 +05:30
diptanilsaha
9bf1e847d2 fix(err): add missing permission check on get_account_details 2026-06-20 19:28:49 +05:30
Mihir Kandoi
bfffed0f52 Merge pull request #56186 from mihir-kandoi/pg-stock-valuation-core
refactor(stock): port valuation-core helpers raw SQL to qb/ORM (Postgres compat)
2026-06-20 19:10:11 +05:30
pandiyan
a22b83a97f fix: add partially transferred status and fix button visibility for partial material transfer on job card 2026-06-20 14:08:14 +05:30
Mihir Kandoi
46c1b49be1 refactor(stock): convert backdated-entry SLE check to qb (PG fix)
The authorized-user backdated-entry guard used MariaDB-only
`MAX(timestamp(posting_date, posting_time))`, invalid on Postgres. Convert to
`Max(posting_datetime)` (the precomputed column) via frappe.qb. Same result on
MariaDB; now valid under Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:01:33 +05:30
Mihir Kandoi
aa73606ed2 refactor(stock): convert get_warehouse_account lookup to get_all
Replace the raw nearest-ancestor warehouse-account SELECT with frappe.get_all;
`account is not null and ifnull(account,'')!=''` -> filter ["account","is","set"]
(IS NOT NULL AND != ''), order_by lft desc, limit 1, pluck. Same result on
MariaDB; valid under Postgres. Covered by the existing get_warehouse_account tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:01:33 +05:30
Mihir Kandoi
57ea0ff6aa refactor(stock): convert stock/utils.py raw SQL to qb/ORM
Convert get_stock_value_from_bin (comma-join + internal ifnull/warehouse-subtree
fragments -> inner_join + qb subquery), get_latest_stock_qty, get_latest_stock_balance,
get_avg_purchase_rate and get_incoming_outgoing_rate_for_cancel (Case/Abs) to
frappe.qb / get_all. Same result on MariaDB; valid under Postgres.

Tests: get_latest_stock_qty and get_stock_value_from_bin against received stock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:01:32 +05:30
Mihir Kandoi
17108d8a37 refactor(stock): convert stock_balance raw SQL to qb/ORM
Convert the repost item/warehouse UNION, get_balance_qty_from_sle,
get_reserved_qty (UNION of correlated subqueries -> two qb Sum branches with an
inner_join to Sales Order Item, added in Python; qty!=0 guards the divide and
mirrors the original `qty>=delivered_qty` which on MariaDB excluded x/0 NULL
rows), get_indented_qty, get_planned_qty and set_stock_balance_as_per_serial_no
to frappe.qb / get_all / db.count. Same result on MariaDB; valid under Postgres.

Tests (new test_stock_balance.py): get_reserved_qty SO-item + packed-bundle
branches and get_indented_qty, all without delivery so they avoid the unrelated
#39 SLE-repost path and pass on Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:39:43 +05:30
Mihir Kandoi
85556913d6 Merge pull request #56185 from mihir-kandoi/pg-material-request
refactor(stock): port Material Request raw SQL to qb/ORM (Postgres compat + TIMEDIFF fix)
2026-06-20 09:36:54 +05:30
Mihir Kandoi
4062f72bdb refactor(stock): convert Material Request raw SQL to ORM
validate_qty_against_so: the already-indented (Material Request Item) and
Sales-Order-qty (Sales Order Item) sum lookups -> frappe.get_all({SUM}).
check_modified_date: raw `select modified` + MariaDB-only `TIMEDIFF` ->
frappe.db.get_value + a get_datetime() comparison. The TIMEDIFF removal also
fixes a real Postgres bug: update_status() (Stop/Reopen/Cancel) ran TIMEDIFF,
which errors on PG (`function timediff does not exist`); this greens 7
previously-failing status-change tests on Postgres.

Same result on MariaDB. Tests: concurrent-modification guard (pass + throw
branches) and the over-request-against-SO throw (both converted SUM queries +
the boundary). mapper.py is intentionally left untouched (no raw SQL; its
staging copy predates develop's RFQ cost_center field-map).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:17:07 +05:30
Mihir Kandoi
4479d7ff18 Merge pull request #56181 from mihir-kandoi/pg-delivery-note
refactor(stock): port Delivery Note & Delivery Trip raw SQL to qb/ORM (Postgres compat)
2026-06-20 00:21:37 +05:30
Mihir Kandoi
5b8ba4bd52 Merge pull request #56179 from mihir-kandoi/pg-stock-masters
refactor(stock): port masters/settings/dashboards raw SQL to qb/ORM (Postgres compat)
2026-06-20 00:21:25 +05:30
Mihir Kandoi
7f81ffca23 refactor(stock): convert Delivery Trip contact/address lookups to qb
get_default_contact / get_default_address: raw correlated-subquery SELECTs over
Dynamic Link -> frappe.qb with a LEFT join (preserving the original
correlated-subquery semantics: a Dynamic Link whose parent Contact/Address is
missing still returns, with a NULL flag). Same result on MariaDB; valid under
Postgres.

Tests: pin the converted query output (real linked Contact/Address) and lock
the LEFT-join choice with an orphaned-Dynamic-Link case (fails under an inner
join).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:01:05 +05:30
Mihir Kandoi
28f6994520 refactor(stock): convert DN billed-amount SUM to get_all
update_billed_amount_based_on_so: raw "select sum(amount) ... where
dn_detail=%s and docstatus=1" -> frappe.get_all(fields=[{SUM: amount}]); the
bare aggregate needs no GROUP BY and the NULL-sum still resolves to 0. Same
result on MariaDB; valid under Postgres. Covered by the existing billing tests
in test_delivery_note.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:01:05 +05:30
Mihir Kandoi
6c96606c18 refactor(stock): convert packing-slip cancellation lookup to get_all
cancel_packing_slips: raw "SELECT name FROM `tabPacking Slip` WHERE
delivery_note=%s AND docstatus=1" -> frappe.get_all(pluck="name") with
pluck-aware iteration. Same result on MariaDB; valid under Postgres.

Covered by test_cancel_packing_slips_cancels_submitted_slips in
test_delivery_note.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:01:05 +05:30
Mihir Kandoi
ff4adce91b refactor(stock): convert Delivery Note raw SQL to ORM
set_actual_qty Bin lookup -> frappe.db.get_value; validate_proj_cust raw
"customer=%s OR ifnull(customer,'')=''" -> get_all or_filters with
[customer, is, not set] (correct PG empty-string/NULL handling); the two
check_next_docstatus implicit comma-joins -> get_all on the child table
(Sales Invoice Item / Installation Note Item, docstatus=1). Same result on
MariaDB; valid under Postgres.

Tests: validate_proj_cust mismatch + no-customer (the or_filters branch), and
check_next_docstatus blocking cancel when a submitted Sales Invoice draws from
the DN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:01:05 +05:30
Mihir Kandoi
48bbf66422 refactor(stock): convert packing slip item search to qb.get_query
Replace the raw SELECT with get_match_cond in item_details with
frappe.qb.get_query(ignore_permissions=False) plus a Delivery Note Item
subquery; get_query applies the permission match conditions. Same result on
MariaDB; valid under Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:00:58 +05:30
Mihir Kandoi
f9732efb23 refactor(stock): convert stock settings checks to ORM/qb
Replace the get_all(limit=1, pluck) existence checks with frappe.db.exists and
the no-own-valuation-method Stock Ledger Entry EXISTS with a frappe.qb
subquery (null-or-empty valuation_method preserved). Same result on MariaDB;
valid under Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:00:58 +05:30
Mihir Kandoi
3e801a2067 refactor(stock): convert serial_no lookups to ORM
Replace the on_trash Stock Ledger Entry serial-match SELECT and the
update_maintenance_status expiry SELECT with frappe.get_all (or_filters for
the amc/warranty expiry OR). Same result on MariaDB; valid under Postgres.

Tests: maintenance-status expiry transition, the not-in exclusion (with a
negative-control candidate), and NULL-status handling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:00:58 +05:30
Mihir Kandoi
d61720c3e2 refactor(stock): convert Job Card reference update to qb
Replace the raw f-string UPDATE (name + production_item match) that links a
Quality Inspection back to its Job Card with frappe.qb.update. Same result on
MariaDB; valid under Postgres.

Tests: the Job Card reference update and its production_item scoping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:00:57 +05:30
Mihir Kandoi
d955122c88 refactor(stock): convert Item Price bulk update to qb
Replace the raw UPDATE ... modified=NOW() in update_item_price with
frappe.qb.update (now() for modified). Same result on MariaDB; valid under
Postgres.

Tests: currency/buying/selling/modified propagation and price-list scoping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:00:57 +05:30
Mihir Kandoi
b0d9208561 refactor(stock): convert get_alternative_items UNION to ORM
Replace the raw UNION of forward/two-way alternative-item matches with two
frappe.get_all calls, order-preserving dedup (dict.fromkeys) and Python
pagination. Each leg is bounded to start+page_len rows so the per-keystroke
search round trip stays small (the original bounded with LIMIT/OFFSET);
ItemAlternative forbids duplicate (item_code, alternative_item_code) pairs, so
each leg is internally distinct and that bound is exact. Same result on
MariaDB; valid under Postgres.

Tests: both-direction dedup, txt filtering, pagination, bounded-and-exact
page-walk reconstruction, and case-insensitive (ILIKE-on-Postgres) matching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:00:57 +05:30
Mihir Kandoi
f03a81b943 refactor(stock): use get_all for warehouse subtree in capacity dashboard
Replace the raw lft/rgt SELECT with frappe.get_all(pluck="name"). Same result
on MariaDB; valid under Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:00:57 +05:30
Mihir Kandoi
497a0abb07 refactor(stock): build item-group filter via qb in item_dashboard
Replace the raw EXISTS subquery (items within an item-group subtree) with
frappe.qb (item_group.isin(subquery)). Same result on MariaDB; valid under
Postgres' stricter SQL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:00:57 +05:30
Mihir Kandoi
884f57d5f6 Merge pull request #56182 from mihir-kandoi/pg-fix-timesheet-summary-flaky
test(projects): fix time-of-day flaky daily-timesheet-summary test
2026-06-20 00:00:17 +05:30
Mihir Kandoi
9dcd561778 test(projects): fix time-of-day flaky daily-timesheet-summary test
make_timesheet(simulate=True) logs at now_datetime(); when the suite runs late
in the day under the site timezone the 2h log crosses midnight, so its to_time
falls outside the report's `to_time <= end-of-day` bound and the submitted
timesheet is (correctly) excluded — making test_submitted_timesheet_in_summary
fail in an evening window (observed at 22:51 IST in CI). Pin the log to a fixed
mid-day window on today so the assertion is time-of-day independent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:29:45 +05:30
Nabin Hait
0bcafa1fde fix: use full refresh instead of refresh_fields for multi currency
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-19 23:16:27 +05:30
Mihir Kandoi
a5f21331a4 Merge pull request #56178 from mihir-kandoi/pg-misc
refactor(postgres): port Telephony/Quality/Bulk-Transaction/Utilities/Portal queries to the query builder
2026-06-19 21:41:37 +05:30
Mihir Kandoi
be21f56771 refactor(postgres): port payment_setup_certification query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:21:13 +05:30
Mihir Kandoi
fbcec6e75f refactor(postgres): port rename_tool get_doctypes to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:21:11 +05:30
Mihir Kandoi
5fcaa54f04 refactor(postgres): port bulk_transaction_log existence check to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:21:10 +05:30
Mihir Kandoi
c18ca7af22 refactor(postgres): port quality_procedure on_trash to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:21:08 +05:30
Mihir Kandoi
1cfae33fb0 refactor(postgres): port transaction_base delete_events to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:21:06 +05:30
Mihir Kandoi
5548c3a713 refactor(postgres): port support index favorite-articles query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:21:03 +05:30
Mihir Kandoi
928bbf22d2 refactor(postgres): port call_log link_existing_conversations to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:10:00 +05:30
Mihir Kandoi
57e44b3a5f Merge pull request #56173 from mihir-kandoi/pg-manufacturing-maintenance
refactor(postgres): port Manufacturing & Maintenance module queries to the query builder
2026-06-19 18:57:06 +05:30
mergify[bot]
a01da137ba Merge pull request #56066 from frappe/l10n_develop
fix: sync translations from crowdin
2026-06-19 13:24:25 +00:00
Mihir Kandoi
09f03e34d0 refactor(postgres): port maintenance_visit queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:23:08 +05:30
Mihir Kandoi
afbaaafd00 refactor(postgres): port maintenance_schedule queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:23:08 +05:30
Mihir Kandoi
71a07ee7af refactor(postgres): port workstation queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:23:08 +05:30
Mihir Kandoi
8134199a57 refactor(postgres): port work_order make_bom and stock-entry check to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:23:08 +05:30
rohitwaghchaure
8479a8b4d3 Merge pull request #56102 from rohitwaghchaure/feat-allocate-full-amount-to-stock-items
feat: allocate full actual charge to stock items only (e.g. Freight)
2026-06-19 18:05:18 +05:30
Mihir Kandoi
9a612d0164 Merge pull request #56153 from mihir-kandoi/pg-selling
refactor(postgres): port Selling module queries to the query builder
2026-06-19 17:09:29 +05:30
rohitwaghchaure
3a6b32bcf9 Merge pull request #56032 from rohitwaghchaure/add_serial_no_composite_index_develop
perf: composite index on (serial_no, warehouse, posting_datetime) for Serial and Batch Entry
2026-06-19 17:00:50 +05:30
rohitwaghchaure
81dea34dd3 Merge pull request #56077 from aerele/fix/support-#69720
fix(stock): apply precision to the additional cost amount in stock entry
2026-06-19 16:52:04 +05:30
Nabin Hait
be05e01bd7 Merge pull request #56151 from nabinhait/refactor-si-mapper
refactor(sales_invoice): shrink make_inter_company_transaction mapper
2026-06-19 16:47:56 +05:30
Nabin Hait
61927b61fe Merge pull request #56139 from nabinhait/refactor-si-timesheet-billing
refactor(sales_invoice): simplify TimesheetBillingService link decision
2026-06-19 16:47:16 +05:30
Nabin Hait
b8e699b226 refactor(sales_invoice): simplify fixed-asset and inter-company validations
validate_fixed_asset (C/11) is flattened with a guard clause and the per-item
checks move into _validate_fixed_asset_item. validate_inter_company_party
(C/12) splits into _get_inter_company_party_config plus _validate_against_reference
and _validate_internal_party_company (conditions preserved verbatim). No C-rank
function remains in either module.

Characterize the previously-untested asset-sale throws (missing asset, update
stock, return without return-against, selling a sold/scrapped asset) and the
asset-restore note text before the move; behaviour is unchanged (asset and
inter-company suites green).
2026-06-19 16:41:39 +05:30
ruthra kumar
f8550838a3 Merge pull request #55265 from aerele/bank-guarantee-type
fix: update reference doctype mapping and field visibility in bank guarantee
2026-06-19 16:38:53 +05:30
Rohit Waghchaure
9e15e52847 feat: allocate full actual charge to stock items only (e.g. Freight)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:35:45 +05:30
Nabin Hait
f24ea74ef8 refactor: simplify Journal Entry client script
- Replace the JournalEntry controller class and cur_frm.cscript free
  functions with frappe.ui.form.on event blocks plus a namespaced
  erpnext.journal_entry helper object
- Drop deprecated APIs: cur_frm/script_manager, add_fetch, $.each and var
- Move the bank_account -> account fetch to fetch_from on the
  Journal Entry Account "account" field
- Keep totals/difference and company-currency conversion on the client
  (cheap, race-free); call the server only to fetch exchange rates
- get_balance now computes its own difference instead of trusting the
  client-sent value, with a regression test
2026-06-19 16:34:31 +05:30
Mihir Kandoi
a954539b53 refactor(postgres): port sales_analytics tree/order-type queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:28:23 +05:30
Mihir Kandoi
f8120d1818 refactor(postgres): port point_of_sale get_items to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:28:21 +05:30
Nabin Hait
d5d2e3406b Merge pull request #56144 from nabinhait/refactor-si-status
refactor(sales_invoice): simplify StatusService.set_status, cover set_indicator
2026-06-19 16:17:11 +05:30
Mihir Kandoi
a80be19081 refactor(postgres): port sales_funnel funnel counts to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:34 +05:30
Mihir Kandoi
9ce1b02e6e refactor(postgres): rebuild available_stock_for_packing_items report without raw SQL
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:32 +05:30
Mihir Kandoi
f4d9869d7b refactor(postgres): port customer_acquisition_and_loyalty report to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:31 +05:30
Mihir Kandoi
6b1e339ed4 refactor(postgres): port customer_credit_balance report to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:29 +05:30
Mihir Kandoi
fe13c0709b refactor(postgres): port pending_so_items_for_purchase_request report to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:28 +05:30
Mihir Kandoi
c86aa3e3ad refactor(postgres): port sales_order_analysis report to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:26 +05:30
Mihir Kandoi
60e05bdaa6 refactor(postgres): port quotation.set_expired_status off multisql to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:15:25 +05:30
Nabin Hait
4f42f52306 Merge pull request #56147 from nabinhait/refactor-si-gl-helper-names
refactor(sales_invoice): verb-prefix SalesInvoiceGLComposer helper names
2026-06-19 16:12:30 +05:30
Nabin Hait
e85f2c4fbc refactor(sales_invoice): shrink make_inter_company_transaction mapper
The function was 199 lines, dominated by a 102-line update_details closure.
Extract the two party-mapping branches into module helpers
_apply_purchase_party_details / _apply_sales_party_details and the address
lookup into _get_linked_address; update_details is now a 6-line dispatcher.
make_inter_company_transaction drops to ~104 lines. No behaviour change
(inter-company SI->PI and PO->SO suites green).
2026-06-19 16:09:49 +05:30
Mihir Kandoi
bbc684aa80 Merge pull request #56142 from mihir-kandoi/pg-projects
refactor(postgres): port Projects module queries to the query builder
2026-06-19 15:54:52 +05:30
Nabin Hait
cb97c3a55a refactor(sales_invoice): verb-prefix SalesInvoiceGLComposer helper names
Rename three private helpers to follow the verb-prefixed convention used
across the services:
- _amount_in_account_currency -> _get_amount_in_account_currency
- _return_aware_against_voucher -> _resolve_against_voucher
- _sdbnb_booking_for_item -> _get_sdbnb_booking_for_item

Pure rename of private methods, no behaviour change.
2026-06-19 15:49:17 +05:30
Nabin Hait
cb6fc640ce refactor(sales_invoice): simplify StatusService.set_status and cover set_indicator
set_status was a single status-resolution cascade (cyclomatic complexity
C/19). Extract the submitted-invoice resolution into _submitted_status (with
the invoice-discounting suffix) and _payment_status (guard clauses), leaving
the cancelled/draft quirks inline. set_status drops C/19 -> B/7; no C-rank
function remains in the module.

Add a characterization test for set_indicator, which was 93% untested -
the portal indicator colour/title for credit-note / unpaid / overdue /
return / paid states. Behaviour is unchanged (status and invoice-discounting
suites green).
2026-06-19 15:44:55 +05:30
Nabin Hait
3d44b4d98c refactor(sales_invoice): simplify TimesheetBillingService._update_time_sheet_detail
The link-decision was a single four-way boolean OR (cyclomatic complexity
C/16) where every branch repeated 'args.timesheet_detail == data.name'.
Factor that match out as a loop guard and extract the remaining decision into
_should_set_sales_invoice as ordered guard clauses. Behaviour is unchanged
(project, link-on-submit, unlink-on-cancel and return paths preserved);
characterization tests and the full timesheet suite are green.
2026-06-19 15:31:31 +05:30
Nabin Hait
dd7891e18f test(timesheet): characterize sales-invoice link/unlink and submit guard
Pin TimesheetBillingService behaviour before refactor: billing a timesheet
into a Sales Invoice links the timesheet detail and marks it Billed on submit,
and clears the link / reverts status on cancel; an unsubmitted timesheet
cannot be invoiced.
2026-06-19 15:31:31 +05:30
Mihir Kandoi
ea665d1a9b refactor(postgres): port Projects module queries to the query builder
Convert raw `frappe.db.sql` across the Projects module 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, empty-string handling).

Conversions of note (behaviour kept identical to the MariaDB original):
- project.get_users_for_project: search selects the stored full_name instead of
  concat_ws(first, middle, last) (concat_ws diverges on Postgres, where empty
  Data fields are NULL) and wraps Locate in LOWER() to keep MariaDB's
  case-insensitive result ordering.
- project costing: percent-complete and sales/billed-amount aggregates rebuilt
  as Sum() query-builder selects.
- task.reschedule_dependent_tasks: the correlated subquery is split into a
  `Task Depends On` parent-pluck + a Task lookup (same rows, no nested SQL).
- timesheet.get_events: user-permission match conditions move to the query-builder
  form via get_event_conditions_qb; calendar columns rebuilt with Concat/Round.
- report/project_wise_stock_tracking & report/daily_timesheet_summary: GROUP BY
  cost aggregates and the timesheet date window (timestamp(to_date,'24:00:00') ->
  end-of-day via get_combine_datetime) rebuilt to satisfy Postgres.
- search helpers (query_task, get_project, get_timesheet) use frappe.qb.get_query
  with ignore_permissions=False in place of build_match_conditions/get_match_cond.

Tests (run on both MariaDB and Postgres, --lightmode):
- Existing project/task/timesheet/activity_cost suites kept green (27 tests).
- New project_wise_stock_tracking test drives all three cost aggregates with
  positive data (purchased / issued / delivered GROUP BY) plus get_project_details.
- New daily_timesheet_summary test covers the date-window join.

Not included: project_update.py is deferred. Its daily_reminder()/email_sending()
select `progress`/`progress_details`, columns that do not exist on the Project
Update doctype, so the function errors when invoked regardless of backend - a
pre-existing bug that needs an email-rework, not just a query port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:22:20 +05:30
Nabin Hait
6255495cc4 Merge pull request #55897 from nabinhait/fix/project-sales-order-link-overwrite
fix(projects): don't overwrite existing Sales Order project link
2026-06-19 15:15:35 +05:30
Diptanil Saha
8c1a1aafe6 fix(coa_importer): allow importing COA through import_coa only for Accounts Manager (#56132)
* fix(coa_importer): allow importing COA only for `Accounts Manager`

Co-authored-by: Pratheep S <pratheeps2024@gmail.com>

* fix(coa_importer): check permissions in `unset_existing_data`

---------

Co-authored-by: Pratheep S <pratheeps2024@gmail.com>
2026-06-19 15:08:20 +05:30
Mihir Kandoi
0a9aa448c1 Merge pull request #56134 from mihir-kandoi/pg-setup-utilities-templates-regional
refactor(postgres): port Setup/Utilities/Templates/Regional queries to the query builder
2026-06-19 15:02:56 +05:30
Mihir Kandoi
02f7cba20a Merge pull request #55920 from ljain112/fix-scio-customer-material-avg-rate
fix: update weighted average rate calculation to consider returned and consumed quantities
2026-06-19 14:47:16 +05:30
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
Nabin Hait
db2e2105ab fix: Use get_value instead of get_doc
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-19 14:37:32 +05:30
Nabin Hait
e2fbc48b9a Merge pull request #55898 from nabinhait/fix/stock-closing-entry-wrong-field
fix(stock): use correct field when reading previous stock closing balance
2026-06-19 14:36:21 +05:30
Mihir Kandoi
4180e29af4 Merge pull request #56130 from mihir-kandoi/pg-support
refactor(postgres): port Support module queries to the query builder
2026-06-19 13:52:10 +05:30
Mihir Kandoi
39eb34f333 refactor(postgres): address Greptile review on warranty_claim on_cancel
- Filter the parent Maintenance Visit's docstatus (mv.docstatus != 2) via a qb join, as
  the original SQL did, instead of the child Maintenance Visit Purpose row's docstatus.
  Synced in normal flows, but exactly faithful to the original intent.
- Add a limit(500) to bound the read on a cancellation path.

Adds two both-engine tests calling on_cancel directly: an active (non-cancelled) visit
blocks the claim cancel; with no referencing visit the claim is marked Cancelled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:13:14 +05:30
Mihir Kandoi
c6f9415e9d Merge pull request #56129 from mihir-kandoi/fix-test-workstation-warehouse
fix(tests): point _Test Workstation 1 fixture at an existing warehouse
2026-06-19 13:06:48 +05:30
S Sakthivel Murugan
6f225920d0 fix: fetch party types based on account type in journal entry and refactor SQL to query builder 2026-06-19 13:05:35 +05:30
Mihir Kandoi
f768778d81 refactor(postgres): port Support module queries to the query builder
Convert the remaining raw frappe.db.sql in the Support module to frappe.qb / ORM so the
queries run on PostgreSQL as well as MariaDB. Faithful conversions -- no MariaDB
behaviour change:

- issue.py, warranty_claim.py (Maintenance Visit lookup / make_maintenance_visit)
- reports: first_response_time_for_issues and issue_summary (GROUP BY on the grouped
  Date(creation)/based-on field + Avg/Count -- Postgres-valid), support_hour_distribution

Tests: existing issue suite (35) passes on both engines; adds both-engine tests for the
previously-untested warranty_claim mapper (3) and the three reports. All green on
MariaDB and PostgreSQL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:58:52 +05:30
Mihir Kandoi
c541bc9239 fix(tests): point _Test Workstation 1 fixture at an existing warehouse
BootStrapTestData.make_workstation() created _Test Workstation 1 referencing the
warehouse "_Test warehouse - _TC" (lowercase 'w'), but make_warehouse() never
creates that name -- it makes "_Test Warehouse - _TC" (capital W) and others.

On MariaDB the lowercase reference resolves to the capital warehouse via the default
case-insensitive collation, so it silently works. On PostgreSQL (case-sensitive) the
link cannot be found, so on a freshly-bootstrapped site make_workstation() raises
LinkValidationError: Could not find Warehouse: _Test warehouse - _TC, which aborts the
module-level BootStrapTestData() import and blocks every test extending ERPNextTestSuite.
It was masked only on sites where the workstation was already bootstrapped (make_records
skips existing) or where the lowercase name happened to exist from legacy data.

Point the fixture at the existing "_Test Warehouse - _TC". Verified on a freshly-reset
site: the buggy fixture raises the LinkValidationError on Postgres and passes after the
fix; MariaDB passes either way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:47:19 +05:30
Nabin Hait
817c5007d9 Merge pull request #55899 from nabinhait/fix/margin-currency-rate-refresh
fix: don't convert item margin on exchange rate refresh
2026-06-19 12:42:21 +05:30
Nabin Hait
900c71840c test(projects): set company on second project to fix CI mandatory error
The second Project in test_sales_order_link_is_not_overwritten_by_second_project
was inserted without a company, which only succeeds when a default company is
configured. On a fresh CI site this raised MandatoryError. Set company from the
sales order explicitly.
2026-06-19 12:37:43 +05:30
Nabin Hait
dfd0c85ba4 Merge pull request #56083 from nabinhait/refactor-si-pos-service
refactor(sales_invoice): tidy POSService (set_pos_fields + mode-of-payment queries)
2026-06-19 12:26:19 +05:30
Nabin Hait
8caaac96b6 Merge pull request #56086 from nabinhait/refactor-si-gl-composer
refactor(sales_invoice): simplify SalesInvoiceGLComposer GL builders
2026-06-19 12:03:21 +05:30
Nabin Hait
9f02c47592 refactor(sales_invoice): decompose POSService.set_pos_fields and dedupe MOP queries
set_pos_fields was a 97-line method (cyclomatic complexity E/36). Split
it into a small orchestrator plus focused helpers, each preserving the
exact for_validate semantics (A/5 after).

Collapse the three near-identical mode-of-payment query builders onto a
shared _enabled_mode_of_payment_query, and add type hints and docstrings.
Public signatures and return shapes are unchanged.
2026-06-19 11:58:43 +05:30
Nabin Hait
7f47c218ce test(sales_invoice): characterize POSService default and mode-of-payment logic
Pin the behaviour of POSService.set_pos_fields (POS-profile default
resolution and the for_validate guard) and the mode-of-payment query
helpers before refactoring them.
2026-06-19 11:57:40 +05:30
Nabin Hait
ae11b3b848 Merge pull request #56085 from nabinhait/fix-pos-get-warehouse
fix(sales_invoice): remove dead, non-functional POSService.get_warehouse
2026-06-19 11:52:41 +05:30
Mihir Kandoi
64e177df8b Merge pull request #56125 from mihir-kandoi/pg-crm 2026-06-19 10:44:48 +05:30
Mihir Kandoi
413ec60a3e refactor(postgres): address Greptile review on prospects report
- Fix N+1: the per-lead conversion issued 4 queries per lead (Opportunity, Quotation,
  Issue, Communication). Collect the reference documents for all leads in 3 bulk
  queries, then one Communication query per lead -> ~N+3 round-trips instead of 4N,
  matching the original single-query-per-lead cost.
- Constrain reference_doctype in the Communication lookup (names are unique only within
  a doctype), closing a latent cross-doctype name-collision gap the original also had.

Both-engine test still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:26:45 +05:30
Mihir Kandoi
6733681e93 Merge pull request #56124 from mihir-kandoi/pg-assets 2026-06-19 10:22:21 +05:30
Mihir Kandoi
5104007d12 refactor(postgres): port CRM module queries to the query builder
Convert the remaining raw frappe.db.sql in the CRM module to frappe.qb / ORM so the
queries run on PostgreSQL as well as MariaDB. Faithful conversions -- no MariaDB
behaviour change:

- opportunity.py, doctype/utils.py (get_last_interaction)
- reports: campaign_efficiency, first_response_time_for_opportunity (GROUP BY on the
  grouped Date(creation) + Avg -- Postgres-valid), lead_conversion_time,
  prospects_engaged_but_not_converted

lead_conversion_time also keeps the IS NOT NULL communication-date guard (forward-port
of the fix already on develop). Also drops invalid backtick notation from two get_all
order_by clauses in doctype/utils.py (order_by="`creation` DESC"), which frappe's
query engine rejects -- a latent failure on both engines, surfaced by the new test.

Tests: existing opportunity suite plus new both-engine tests for the four previously
untested reports/utils. All green on MariaDB and PostgreSQL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:15:20 +05:30
Mihir Kandoi
4bc3420b21 refactor(postgres): address Greptile review on Assets conversions
- cancel_movement_entries: filter the parent Asset Movement's docstatus via a qb join
  (as the original SQL did), instead of the child Asset Movement Item.docstatus. Behaviour
  is identical in normal flows (child docstatus is synced) but this is exactly faithful.
- get_maintenance_log: add a both-engine test for this previously-untested whitelisted
  endpoint. Confirms the frappe v16 dict aggregate field spec ({"COUNT": ...}) runs and
  returns correct per-status counts (no runtime crash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:02:59 +05:30
Mihir Kandoi
fc9608d14d refactor(postgres): port Assets module queries to the query builder
Convert the remaining raw frappe.db.sql in the Assets module to frappe.qb / ORM so
the queries run on PostgreSQL as well as MariaDB. Faithful 1:1 conversions -- no
MariaDB behaviour change:

- asset.py (gl-entry / bom-cost fetches), asset_maintenance.py (team members),
  asset_movement.py (latest location/custodian), location.py (get_children)
- fixed_asset_register.py: the depreciation-amount aggregate groups by asset.name
  (the primary key) selecting only asset.name + Sum(gle.debit), which is valid under
  Postgres strict GROUP BY (PK functional dependency)

Tests: existing asset (61), asset_maintenance, asset_movement and location suites
pass on both engines; adds a test for the previously-untested Fixed Asset Register
report (covers the GROUP BY aggregate on both engines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:48:35 +05:30
Mihir Kandoi
facb27c3f4 Merge pull request #56089 from mihir-kandoi/pg-ar-future-payments
fix(accounts): Journal Entry future payments mis-allocated to one invoice in Accounts Receivable
2026-06-19 08:50:35 +05:30
Smit Vora
68a1fe1480 Merge pull request #56104 from frappe/fix-inclusive-payment-entry-none-base-tax-amount
fix: base_tax_amount as none when payment entry created using API
2026-06-19 08:47:18 +05:30
Mihir Kandoi
e34a64ecee Merge pull request #56118 from frappe/mergify/bp/develop/pr-56065
fix(stock): propagate renamed attribute values to variant items (backport #56065)
2026-06-19 07:42:50 +05:30
MochaMind
060cd9f320 fix: Bosnian translations 2026-06-18 23:55:57 +05:30
MochaMind
eb6530208b fix: Croatian translations 2026-06-18 23:55:52 +05:30
Mihir Kandoi
98e012095a Merge pull request #56113 from mihir-kandoi/pg-accounts-sales-payment-summary
fix(postgres): port sales_payment_summary to qb and make POS data Postgres-valid
2026-06-18 23:03:47 +05:30
Mihir Kandoi
e8bebba915 Merge pull request #56112 from mihir-kandoi/pg-accounts-budget
fix(postgres): port budget queries to qb and fix requested-amount aggregation
2026-06-18 23:01:01 +05:30
Mihir Kandoi
d3c0d9b283 fix: resolve item attribute backport conflict 2026-06-18 22:38:23 +05:30
Mihir Kandoi
1cfb41e1c4 Merge pull request #56111 from mihir-kandoi/pg-accounts-doctypes
refactor(postgres): port accounts doctypes & match-condition helper to qb
2026-06-18 22:35:44 +05:30
barredterra
0e244dd83a fix(stock): update variant attributes on value rename
(cherry picked from commit c7acd88742)
2026-06-18 17:05:40 +00:00
barredterra
4c29d5630d test(stock): add cleanup for item attribute value changes in tests
(cherry picked from commit 60f5de7ab8)
2026-06-18 17:05:40 +00:00
barredterra
4806b82add fix(stock): propagate renamed attribute values to variant items
(cherry picked from commit 27d574dad5)

# Conflicts:
#	erpnext/stock/doctype/item_attribute/item_attribute.py
2026-06-18 17:05:40 +00:00
Mihir Kandoi
5a80278d1e Merge pull request #56098 from aerele/fix/serial-no-work-order-docstatus-filter
fix: apply docstatus filter to exclude cancelled Work Orders in Seria…
2026-06-18 22:25:56 +05:30
Mihir Kandoi
c4e1fe274b Merge pull request #56055 from aerele/fix/support-71415
fix: disable is_debit_note while creating credit note
2026-06-18 22:23:39 +05:30
Mihir Kandoi
1a56f3b032 Merge pull request #56105 from mihir-kandoi/pg-parity-other-class
fix(postgres): MariaDB/Postgres parity in pick-list, serial match, null ordering & link-query ranking
2026-06-18 22:22:16 +05:30
Mihir Kandoi
08375a9e2f Merge pull request #56110 from mihir-kandoi/pg-accounts-gl-core
refactor(postgres): port GL & ledger-core queries to the query builder
2026-06-18 22:02:05 +05:30
Mihir Kandoi
fa378e2d7a test(sales_payment_summary): exercise the POS customer filter
Address review (#56113): add a customer filter to the is_pos test so the
customer-subquery fix is covered (a.customer was unselected before and errored).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:34:37 +05:30
Mihir Kandoi
006a65e873 refactor(postgres): finish budget qb conversion (validate_expense_against_budget)
Address review / semgrep (#56112): convert the last raw f-string query
(budget_records, with a dynamic dimension column + tree EXISTS condition) to
frappe.qb, clearing the sql-format-injection finding. Also switch the two
remaining implicit comma-joins (get_requested_amount / get_ordered_amount) to
explicit .join().on() for consistency. test_budget 23/23 on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:32:46 +05:30
Mihir Kandoi
e7b135b51e fix(postgres): aggregate bare account in pos_closing payments; explicit limit on gl-entry fetch
Address review (#56111):
- pos_closing get_payments grouped by mode_of_payment but selected a bare account ->
  Postgres GroupingError. Wrap in Max() (deterministic, both engines agree; account is
  consumed downstream for the change-amount adjustment). test_pos_closing_entry 9/9 both engines.
- get_voucherwise_gl_entries: add limit=0 to make the unbounded fetch explicit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:29:00 +05:30
Mihir Kandoi
dabc94ed06 Merge pull request #56101 from mihir-kandoi/pg-arbitrary-representative
fix(selling): split multi-order invoice amount across its sales orders (payment terms status)
2026-06-18 21:06:33 +05:30
Mihir Kandoi
1f4702bde7 fix(crm): skip rows with no first-contact date in lead conversion time
Address review (#56105): when there's no matching communication, first_contact is
None and date_diff(invoice_date, None) treats None as today, giving a wrong
(negative) duration. Skip the entry instead, mirroring the communication_count guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:04:33 +05:30
Mihir Kandoi
4708ac4e3d fix(postgres): port sales_payment_summary to qb and make POS data Postgres-valid
Converts the report's raw f-string SQL (incl. 3-way UNIONs) to frappe.qb. Split out of #56082.

The non-POS path is MariaDB-identical. get_pos_invoice_data had a loose GROUP BY that
errors on Postgres; line-level warehouse/cost_center/mode_of_payment are now Max()
(deterministic, both engines agree), the unused item_code dropped, and customer added to
the invoice subquery so the customer filter works (it referenced a column the subquery
never selected). + a test covering the previously-untested is_pos path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:01:35 +05:30
Mihir Kandoi
996a02180b fix(postgres): port budget queries to qb and fix requested-amount aggregation
Converts budget.py raw SQL to frappe.qb for Postgres compatibility. Split out of #56082.

Mostly MariaDB-identical, with one behaviour change: get_requested_amount summed
Sum(qty) * <arbitrary rate> (a bare rate column, invalid on Postgres and wrong when an
item was requested at different rates). Now Sum(qty * rate) per line -- correct, and
identical on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:01:33 +05:30
Mihir Kandoi
d8a2f53a29 refactor(postgres): port accounts doctypes & match-condition helper to the query builder
Pure MariaDB-identical conversion for Postgres compatibility. Split out of #56082.

bank_clearance, pos_closing_entry, process_statement_of_accounts, party, utils, and
sales_invoice/services/pos converted to frappe.qb; bundles erpnext/utilities/query.py
(the get_match_conditions_qb helper, frappe#40075 follow-up) which
process_statement_of_accounts depends on. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:01:11 +05:30
Mihir Kandoi
13d06e77b4 refactor(postgres): port GL & ledger-core queries to the query builder
Pure MariaDB-identical conversion (raw frappe.db.sql -> frappe.qb / portable
functions) for Postgres compatibility. Split out of #56082.

general_ledger, gl_entry, gl_validator, period_closing_voucher, deferred_revenue,
process_payment_reconciliation + their tests. No behaviour change on MariaDB;
verified equivalent and the suites pass on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:00:55 +05:30
Mihir Kandoi
5787951ed1 fix(crm): guard first_contact result before indexing (lead conversion time)
Address review (#56105): the IS NOT NULL guard can return no rows (the count above
filters on sender, this query on recipients), so [0][0] would raise IndexError.
Fall back to None when empty, matching the prior behaviour for that case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:50:31 +05:30
Mihir Kandoi
3f6f3abf69 fix(selling): make multi-order invoice split sum exactly to the grand total
Address review (#56101): with 3+ orders the proportional shares could drift by a
sub-cent and not sum back to the grand total, leaving the last payment term
"Partly Paid". The last order (sorted, so MariaDB and Postgres agree) now absorbs
the residual: grand_total - sum(prior shares). Extended the test to three orders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:48:36 +05:30
Mihir Kandoi
055c58364a Merge pull request #56090 from mihir-kandoi/pg-bom-rowshape
fix(manufacturing): make arbitrary GROUP-BY picks deterministic in BOM & transfer queries
2026-06-18 20:46:30 +05:30
Mihir Kandoi
cfa6d286ad fix(postgres): other-class parity fixes (case-folding & null-ordering) for merged queries
Parity sweep findings in queries already merged in develop:

- pos_invoice.py: serial_no return-match matched case-sensitively on Postgres (case-insensitive
  on MariaDB). Replaced the get_all or_filters lookup with a qb query that case-folds both sides
  via Lower() (no-op on MariaDB).
- controllers/queries.py + pick_list.py: the Locate()-based relevance ranking in link-query
  ORDER BY is case-sensitive on Postgres (Strpos) vs case-insensitive on MariaDB (Locate), so
  autocomplete order differed. Lower() both arguments so the ranking matches on both engines.
- crm/lead_conversion_time.py: "first contact" used ORDER BY communication_date LIMIT 1 read by
  index; a NULL date sorts first on MariaDB but last on Postgres, changing the result. Added
  `communication_date IS NOT NULL` so both engines return the earliest real contact date.

Verified on MariaDB and Postgres: test_pick_list 40/40, test_pos_invoice 26/26 on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:36:30 +05:30
vorasmit
b9b402f2ec fix: tax.base_tax_amount as none when payment entry created using API 2026-06-18 20:15:03 +05:30
Mihir Kandoi
b04a9e25ff fix(stock): make pick_list link query valid on Postgres (GROUP BY joined column)
get_pick_list_query selects Sales Order.customer (a joined table's column) while
grouping only by Pick List.name. Postgres' functional-dependency relaxation applies
to a table's own primary key, not to a joined table's columns, so the query raises
GroupingError on Postgres. MariaDB arbitrary-picks and runs.

customer is already pinned to a single value by `WHERE Sales Order.customer = filter`,
so adding it to the GROUP BY is identical on MariaDB and valid on Postgres.

Test (errors with GroupingError on the old code on Postgres, passes on both engines):
- test_get_pick_list_query_postgres_valid: a submitted pick list for a customer is
  returned by the link query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:14:31 +05:30
Mihir Kandoi
b1c6666d02 fix(selling): split multi-order invoice amount across its sales orders (payment terms status)
payment_terms_status_for_sales_order grouped invoice rows by `sii.parent` and took
`Max(sii.sales_order)`, so an invoice that bills several Sales Orders was credited
in full to one arbitrary order and the rest were starved of that payment.

get_so_with_invoices now returns one row per (invoice, sales_order) and splits the
invoice's grand total across the orders in proportion to each order's net line
amount on that invoice. A single-order invoice keeps the full grand total (ratio 1),
so the common case is unchanged; the split is pure Python over deterministic SQL,
so MariaDB and Postgres produce identical results (100% parity).

Test (fails on the old code, passes on both engines):
- test_invoice_billing_multiple_orders_splits_proportionally: one invoice billing two
  SOs 600/400 -> each order credited its share, summing to the grand total. Old
  Max(sales_order) collapsed the invoice onto one order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:44:31 +05:30
Mihir Kandoi
fe0465f16e fix(manufacturing): deterministic arbitrary-pick in BOM explosion & WO transfer tracking
Same class as the sub_assembly_queries / bom_stock_analysis fixes in this PR: two
more merged queries wrapped a non-functionally-dependent column in Max(), so the
engines can disagree and the value is wrong for the case the column drives.

bom_explosion._subitems_query — Max(is_phantom_item):
  Rows are grouped by item_code and get_subitems() drops any grouped row whose
  is_phantom_item is truthy. When one item_code is listed in a BOM both as a
  phantom sub-assembly and as a plain raw material, Max() returns 1 and the real
  raw material is silently dropped from the plan. Use Min(): an item is phantom
  only when EVERY line for it is phantom, so a real material is never lost.

required_items._material_transfer_qty_by_item — Max(original_item):
  original_item is the output dict key. The same item B can be transferred both
  for itself (original_item NULL) and as a substitute for required item A
  (original_item=A). Grouping by item_code alone with Max() merged the two and
  credited B's whole transfer to A, leaving B at 0. Group by
  (item_code, original_item) and accumulate into the keyed dict so each transfer
  is credited to the right required item (two rows can resolve to one key, e.g.
  A's own transfer and B-for-A, hence += not plain assignment).

Both were previously undefined SQL (loose GROUP BY); the fix makes MariaDB and
Postgres agree on the correct, deterministic value. Other Max()-wrapped columns
in these queries are functionally dependent on the grouped item and unchanged.

Tests (fail on the old code, pass on both engines):
- test_subitems_query_keeps_real_rm_listed_alongside_phantom
- test_transferred_qty_not_misattributed_between_item_and_its_substitute

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:03:26 +05:30
pandiyan
3ba8f690a4 fix: apply docstatus filter to exclude cancelled Work Orders in Serial No 2026-06-18 18:29:08 +05:30
rohitwaghchaure
47a9c54b70 Merge pull request #55931 from DipenFrappe/fix-rfq-accounting-dimensions
feat(rfq): add accounting dimensions support to Request for Quotation
2026-06-18 18:27:49 +05:30
Shllokkk
336307f287 Merge pull request #56087 from Shllokkk/pcv-je-validation
fix(journal entry): validate opening entry against pcv on save
2026-06-18 18:04:33 +05:30
Dipen Gala
79421bcfcc fix: set cost_center on RFQ item before submitting in test
The test was mutating an already-submitted RFQ, which raised
UpdateAfterSubmitError because cost_center lacks allow_on_submit.
Use do_not_submit=True, set cost_center on the draft, save, then submit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 17:21:26 +05:30
Shllokkk
e23a7883f3 fix(journal entry): validate opening entry against pcv on save 2026-06-18 16:57:00 +05:30
Shllokkk
deff5848ed Merge pull request #55504 from aerele/fix/soa-show-opening-entries
fix(accounts): allow process statement of account generation with opening entries
2026-06-18 16:44:25 +05:30
Mihir Kandoi
b579dbc1e6 fix(manufacturing): prefer the phantom line as bom_stock_analysis representative
Address review on #56090: get_bom_data groups components by item_code, so it
picks one representative BOM Item line for the (bom_no, is_phantom_item) pair.
Taking the first line by idx dropped the phantom flag when a non-phantom line
was listed before the phantom one, so explode_phantom_boms skipped the sub-BOM.

Keep one row per item_code (preserving the qty_per_unit total per component
rather than widening the GROUP BY), but make the representative phantom-
preferring: the first line, upgraded to the first phantom line if any exists.
A phantom sub-BOM is therefore never dropped due to line order, on either engine.

Adds test_phantom_explosion_when_phantom_line_is_not_first (phantom line at
idx 2) alongside the existing idx-1 case; both pass on MariaDB and Postgres and
the new one fails on the naive first-line representative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:27:23 +05:30
Mihir Kandoi
41da9eb7fc fix(manufacturing): keep bom_no/is_phantom_item pair coherent in sub-BOM resolution
When a component is listed more than once in a BOM pointing at different
sub-BOMs (e.g. one phantom, one not), two queries grouped the duplicate
lines into a single row and aggregated bom_no and is_phantom_item with
*independent* Max(). That could pair the phantom flag of one line with the
bom_no of another, so the consumer recursed into the wrong sub-BOM:

- sub_assembly_queries._sub_assembly_rm_query keys on (item_code, bom_no)
  and recurses on is_phantom_item. An incoherent pair sent raw-material
  resolution down the wrong sub-assembly BOM.
- bom_stock_analysis.get_bom_data: explode_phantom_boms recurses into
  bom_no only when is_phantom_item is set; an incoherent pair exploded a
  non-phantom sub-BOM as if it were phantom (or vice-versa).

Fix:
- sub_assembly_queries: group also by (bom_no, is_phantom_item) so each
  distinct sub-BOM is its own coherent row.
- bom_stock_analysis: drop the two independent Max()es and attach a single
  representative line (lowest idx) per item_code before exploding.

This was previously undefined SQL (loose GROUP BY); the fix makes MariaDB
and Postgres agree on a deterministic, coherent pairing. Other Max()-wrapped
columns are functionally dependent on the grouped item and keep their value
on both engines.

Tests (fail on the old code, pass on both engines):
- test_phantom_explosion_picks_coherent_sub_bom: duplicate-component BOM
  explodes the phantom sub-BOM, not the lexically-larger non-phantom one.
- test_sub_assembly_rm_query_keeps_bom_no_phantom_pair_coherent: the query
  returns one coherent row per distinct sub-BOM with the right phantom flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:14:30 +05:30
Mihir Kandoi
1cc98a82ba fix(accounts): Journal Entry future payments mis-allocated to one invoice in AR
get_future_payments_from_journal_entry has no GROUP BY: the Sum() makes it an
implicit single-group aggregate, so every future-dated JE payment company-wide
collapses into ONE row keyed by an arbitrary (invoice, party). The Accounts
Receivable report then allocates the entire future sum against that one invoice and
shows zero future payment for every other invoice. This predates the postgres work
(MariaDB returned an arbitrary single row); the qb conversion only made the arbitrary
pick deterministic via Max().

Add an explicit GROUP BY (je.name, jea.reference_name, jea.party, jea.party_type,
je.posting_date, je.cheque_no) and drop the Max() wrappers, so each (JE, invoice,
party) is its own future-payment row -- matching the Payment Entry path and the
(invoice_no, party) keying the report's allocator already expects. Identical on
MariaDB and PostgreSQL.

Ships a JE-path future-payment test (one future JE paying two invoices -> each
invoice keeps its own future amount; fails on the old code with 0 != 50).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:53:57 +05:30
Mihir Kandoi
eb7f7f2124 Merge pull request #56081 from mihir-kandoi/pg-query-report-json
fix(postgres): make Query Report SQL portable across MariaDB and PostgreSQL
2026-06-18 15:18:49 +05:30
Nabin Hait
fcd312f205 refactor(sales_invoice): decompose SDBNB and change-amount GL methods
stock_delivered_but_not_billed_gl_entries (C/15) is split into a thin loop
plus _sdbnb_booking_for_item (eligibility + valuation), _is_sdbnb_reversal
(the account predicate) and _append_sdbnb_gl_entries (the two GL rows) —
guard clauses replace the nested continues. get_gle_for_change_amount now
uses the shared _amount_in_account_currency helper. No C-rank functions
remain in the file. Behaviour unchanged; characterization tests and the
full Sales Invoice suite (133) green.
2026-06-18 15:03:32 +05:30
Mihir Kandoi
3d4b50d37d fix(postgres): make Query Report SQL portable across MariaDB and PostgreSQL
Three Query Reports embedded double-quoted string literals and an unquoted table
identifier that error on PostgreSQL. Portability-only, no behaviour change on either engine:
- material_requests_for_which_supplier_quotations_are_not_created,
  requested_items_to_be_transferred: double-quoted string literals ("Stopped",
  "Material Transfer") -> single quotes (double quotes are identifiers on postgres, not strings).
- items_to_be_requested: quote the `tabBin` table identifier so postgres doesn't lower-case it.

(received_items_to_be_billed was dropped: it is a Script Report, so its `query` field is dead
code and the fix never reaches the DB.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:59:01 +05:30
Nabin Hait
5de87f473e test(sales_invoice): characterize SDBNB and change-amount GL entries
Pin the two least-covered SalesInvoiceGLComposer methods before refactor:
- stock_delivered_but_not_billed_gl_entries: billing a perpetual Delivery
  Note via Sales Invoice reverses the SDBNB account into COGS for an equal
  amount.
- get_gle_for_change_amount: empty without change, mandatory-account error,
  and the debit-to / change-account entry pair.
2026-06-18 14:55:37 +05:30
rohitwaghchaure
31849f6029 Merge pull request #56079 from rohitwaghchaure/allow-negative-stock-for-batch-level
feat: allow negative stock at batch level
2026-06-18 14:31:09 +05:30
Nabin Hait
1f06f2e3a0 refactor(sales_invoice): simplify SalesInvoiceGLComposer GL builders
Extract two cross-cutting helpers used across the GL methods:
- _amount_in_account_currency: the repeated 'base if account is in company
  currency else transaction amount' ternary (customer, tax, item, POS and
  write-off entries).
- _return_aware_against_voucher: the return/self-outstanding against_voucher
  rule duplicated in the customer and POS entries.

Pull the per-item income and discount rows into their own builders and
flatten make_item_gl_entries with a guard clause. Complexity drops:
make_customer C11->B7, make_pos C12->B7, make_item C14->B10,
make_discount C13->B10, make_tax->A3. No behaviour change; entry dicts and
amounts are identical (full Sales Invoice suite green).

stock_delivered_but_not_billed_gl_entries is left for a coverage-first pass
(it is the least-tested GL branch).
2026-06-18 14:16:41 +05:30
Nabin Hait
3038ad8abe fix(sales_invoice): remove dead, non-functional POSService.get_warehouse
get_warehouse could never run: it filtered POS Profile on a non-existent
'user' column (users live in the applicable_for_users child table), and
embedded a Python bool (frappe.session["user"] == "") inside a query
builder predicate, which raises before reaching the database. It also
has no callers. Remove it and the now-unused msgprint import.
2026-06-18 14:00:07 +05:30
Khushi Rawat
dc202ac4a2 Merge pull request #56030 from Shllokkk/budget-distribution-grid-lock
fix: lock budget distribution table and guard against null distributi…
2026-06-18 13:58:07 +05:30
Rohit Waghchaure
ca07982ee0 feat: add batch-level option to allow negative stock for batch 2026-06-18 13:41:09 +05:30
Mihir Kandoi
f269f6a8d8 Merge pull request #56062 from mihir-kandoi/pg-accounts-pos
refactor(postgres): port Accounts POS, pricing & invoicing doctype queries to the query builder
2026-06-18 13:22:49 +05:30
Sudharsanan11
2ca1bdd8a7 test(stock): add test to validate the precision for additional cost amount 2026-06-18 12:12:05 +05:30
Sudharsanan11
cf338bb757 fix(stock): apply precision to the additional cost amount in stock entry 2026-06-18 12:12:05 +05:30
Shllokkk
d37e5cd97d fix: lock budget distribution table and guard against null distribution rows 2026-06-18 11:23:39 +05:30
MochaMind
526f91f6b5 fix: Bosnian translations 2026-06-17 23:22:48 +05:30
MochaMind
4465ebaeb5 fix: Persian translations 2026-06-17 23:22:44 +05:30
Mihir Kandoi
88cb132fd1 refactor(postgres): port Purchase Invoice expense-account queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:39 +05:30
Mihir Kandoi
d23677636d refactor(postgres): port Purchase Invoice GL composer queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:38 +05:30
Mihir Kandoi
8e0ba50c4d refactor(postgres): port Purchase Invoice doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:37 +05:30
Mihir Kandoi
08abf96047 refactor(postgres): port Fiscal Year doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:37 +05:30
Mihir Kandoi
813b42d706 refactor(postgres): port Cost Center doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:36 +05:30
Mihir Kandoi
8ce63dac65 refactor(postgres): port Sales Taxes and Charges Template queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:35 +05:30
Mihir Kandoi
8e9680afce refactor(postgres): port Pricing Rule utils queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:35 +05:30
Mihir Kandoi
a09e875109 refactor(postgres): port Loyalty Point Entry doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:34 +05:30
Mihir Kandoi
42c61915c4 refactor(postgres): port POS Invoice doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:33 +05:30
Mihir Kandoi
37a6ebd431 refactor(postgres): port POS Profile doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:01:33 +05:30
pandiyan
279c8dea06 fix: disable is_debit_note while creating credit note 2026-06-17 18:35:10 +05:30
Rohit Waghchaure
b1b6ae98ed perf: composite index on (serial_no, warehouse, posting_datetime) 2026-06-17 12:17:11 +05:30
Dipen Gala
e91bcd6dd6 feat(rfq): add accounting dimensions support to Request for Quotation
- Add `cost_center` field and `accounting_dimensions_section` / `dimension_col_break`
  to Request for Quotation Item DocType so custom accounting dimensions propagate automatically
- Register `Request for Quotation Item` in `accounting_dimension_doctypes` hook
- Map `cost_center` from Material Request → RFQ in `make_request_for_quotation`
- Map `cost_center` from RFQ → Supplier Quotation in `make_supplier_quotation_from_rfq`
  and `create_rfq_items` (portal flow)
- Map `cost_center` in `get_item_from_material_requests_based_on_supplier` (MR-based RFQ flow)
- Add test cases to verify cost_center propagation through the MR → RFQ → SQ chain

Fixes #55855

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:58:57 +05:30
ljain112
35e55d3e13 fix: update weighted average rate calculation to consider returned and consumed quantities 2026-06-15 14:16:07 +05:30
Nabin Hait
6f9a8ff101 fix: don't convert item margin on exchange rate refresh
Changing the transaction/posting date re-triggers the `currency` handler,
which fetches a fresh exchange rate and, whenever it differs from the
current one, divided every Amount-type item margin and Actual tax charge
by the new rate. `margin_rate_or_amount` is an amount in the transaction
currency, so dividing it is only meaningful when the document actually
switches currency; on a mere rate refresh it silently shrinks the margin
every time.

Track the currency the rendered document is denominated in and convert
margins/actual charges only on a real currency change, while still
updating `conversion_rate` so base amounts recalculate correctly.

Also remove the duplicated `currency()` override in quotation.js: it
re-ran the same fetch-and-convert block after `super.currency()` (double
converting margins) and lacked the `load_after_mapping` guard. The base
handler already covers Quotation via `transaction_date`.

Fixes #45210
2026-06-14 20:32:56 +05:30
Nabin Hait
8e627db785 fix(stock): use correct field when reading previous stock closing balance
`StockClosing.get_sle_entries` filtered `Stock Closing Balance` by a
`closing_stock_balance` column that does not exist; the link field back to
the closing entry is `stock_closing_entry`. As a result every Stock
Closing Entry created after the first one failed with
`OperationalError (1054, "Unknown column 'closing_stock_balance'")`, since
the previous-balance branch only runs once an earlier closing exists.

Fixes #54819
2026-06-14 20:25:57 +05:30
Nabin Hait
b2eb6a69c1 fix(projects): don't overwrite existing Sales Order project link
Creating a Project with a `sales_order` set used `frappe.db.set_value`
to unconditionally write the Sales Order's `project` field. When several
projects were created for the same Sales Order, each new project silently
overwrote the previous link, leaving earlier projects orphaned.

Only back-link the Sales Order when it is not already tied to another
project, and write the value through the document's `db_set` so the
modified timestamp and realtime update are handled. A warning is shown
when an existing link is left untouched.

Fixes #52179
2026-06-14 20:17:08 +05:30
S Sakthivel Murugan
d0f1239d2b fix(accounts): allow process statement of account generation with opening entries 2026-06-11 15:51:20 +05:30
nareshkannasln
b1de654dfd fix: update reference doctype mapping and field visibility in bank guarantee 2026-05-26 12:39:51 +05:30
306 changed files with 14659 additions and 7253 deletions

View File

@@ -74,6 +74,14 @@ fi
if [ "$DB" == "postgres" ];then
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
# CI databases are disposable, so trade durability for speed: postgres fsyncs on every commit
# by default, which dominates a commit-heavy test suite. These are all reload-time settings
# (no restart needed). MariaDB CI is unaffected (DB != postgres).
echo "travis" | psql -h 127.0.0.1 -p 5432 -U postgres \
-c "ALTER SYSTEM SET synchronous_commit = 'off'" \
-c "ALTER SYSTEM SET fsync = 'off'" \
-c "ALTER SYSTEM SET full_page_writes = 'off'" \
-c "SELECT pg_reload_conf()";
fi

197
.github/helper/postgres_compat.py vendored Executable file
View File

@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""Static guard against MySQL-only SQL that breaks on PostgreSQL.
The Postgres test job is label-gated, so it does not run on every PR. This pre-commit
hook is the always-on first line of defence: it flags the *mechanical* Postgres breaks
that static analysis can catch reliably with a low false-positive rate.
It deliberately does NOT try to catch the *semantic* divergences (loose GROUP BY,
case-sensitive ==/IN, NULL ordering, ORDER BY ... LIMIT 1 tiebreakers, integer-division
intent, savepoint discipline) — those genuinely need the test suite. Run the full suite
on a Postgres site for those.
Escape hatch: put `# pg-ok` anywhere on the offending statement's line span (e.g. on a
`SHOW INDEX` query that lives inside an `if frappe.db.db_type == "mariadb":` branch).
Usage: postgres_compat.py <file.py> [<file.py> ...] (pre-commit passes staged files)
"""
from __future__ import annotations
import ast
import re
import sys
IGNORE = "pg-ok"
# Strings are only scanned for the patterns below when they have real SQL *structure*
# (not just an English word like "select" or "from"), to keep false positives near zero.
SQL_HINT = re.compile(
r"\bselect\b[\s\S]{0,800}\bfrom\b" # SELECT ... FROM
r"|\bupdate\b[\s\S]{0,400}\bset\b" # UPDATE ... SET
r"|\bdelete\s+from\b"
r"|\binsert\s+into\b"
r"|\bshow\s+(?:index|tables|columns)\b"
r"|\bfrom\s+[\"'`]?tab", # FROM `tabDocType`
re.I,
)
# MySQL-only constructs with NO frappe auto-translation. (frappe.db.sql already rewrites
# ifnull->coalesce on all engines and backtick/locate/REGEXP on Postgres, and .like()
# renders ILIKE — so those are NOT listed here; flagging them would be false positives.)
SQL_PATTERNS: list[tuple[re.Pattern, str]] = [
(re.compile(r"\btimestamp\s*\(\s*[^,()]+,", re.I),
"timestamp(date, time) is MySQL-only -> use CombineDatetime() or a precomputed datetime column"),
(re.compile(r"\btimediff\s*\(", re.I),
"timediff() is MySQL-only -> compute the delta in Python"),
(re.compile(r"\bstr_to_date\s*\(", re.I),
"str_to_date() is MySQL-only -> parse in Python and pass a real date"),
(re.compile(r"\bdate_format\s*\(", re.I),
"date_format() is MySQL-only -> filter on a date range instead"),
(re.compile(r"\bdate_(add|sub)\s*\(", re.I),
"date_add()/date_sub() are MySQL-only -> use Python date math or interval arithmetic"),
(re.compile(r"\bgroup_concat\s*\(", re.I),
"group_concat() is MySQL-only -> use GroupConcat (string_agg) or aggregate in Python"),
(re.compile(r"\bperiod_diff\s*\(", re.I),
"period_diff() is MySQL-only -> compute in Python"),
(re.compile(r"\bshow\s+index\b", re.I),
"SHOW INDEX is MySQL-only -> use frappe.db.has_index() / get_column_index()"),
(re.compile(r"\bshow\s+(tables|columns)\b", re.I),
"SHOW TABLES/COLUMNS is MySQL-only -> use frappe.db.get_tables()/table_columns / information-schema helpers"),
(re.compile(r"\bas\s+'[^']+'", re.I),
"single-quoted column alias breaks on Postgres -> use a bare or double-quoted alias"),
(re.compile(r"\bif\s*\(", re.I),
"SQL IF() is MySQL-only -> use CASE WHEN ... THEN ... ELSE ... END (frappe.qb.Case())"),
]
# UPDATE ... JOIN: both keywords in the same SQL string.
UPDATE_JOIN = (re.compile(r"\bupdate\b", re.I), re.compile(r"\bjoin\b", re.I))
MYSQL_RESULT_KEYS = {"Column_name", "Key_name", "Seq_in_index", "Non_unique", "Index_type"}
SET_BOOL_FUNCS = {"set_value", "db_set"}
def _docstring_ids(tree: ast.AST) -> set[int]:
"""ids of Constant nodes that are docstrings (so prose describing the rules isn't flagged)."""
ids: set[int] = set()
for node in ast.walk(tree):
if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
body = getattr(node, "body", None)
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) and isinstance(body[0].value.value, str):
ids.add(id(body[0].value))
return ids
class Visitor(ast.NodeVisitor):
def __init__(self, lines: list[str], docstrings: set[int]):
self.lines = lines
self.docstrings = docstrings
self.violations: list[tuple[int, str]] = []
def _ignored(self, node: ast.AST) -> bool:
start = getattr(node, "lineno", 1)
end = getattr(node, "end_lineno", start) or start
# honour `# pg-ok` anywhere on the node's line span, the line just above (the enclosing
# call, e.g. `frappe.db.sql( # pg-ok`), or the line just below (a multi-line call's `) # pg-ok`).
lo = max(0, start - 2)
return any(IGNORE in self.lines[i] for i in range(lo, min(end + 1, len(self.lines))))
def _flag(self, node: ast.AST, msg: str) -> None:
if not self._ignored(node):
self.violations.append((getattr(node, "lineno", 1), msg))
def _scan_sql(self, text: str, node: ast.AST) -> None:
if not SQL_HINT.search(text):
return
for pattern, msg in SQL_PATTERNS:
if pattern.search(text):
self._flag(node, msg)
if UPDATE_JOIN[0].search(text) and UPDATE_JOIN[1].search(text):
self._flag(node, "UPDATE ... JOIN is MySQL-only -> use a correlated subquery (WHERE ... IN/EXISTS)")
def visit_Constant(self, node: ast.Constant) -> None:
# plain string literals, incl. `"...".format()` and `"..." % (...)` templates
if isinstance(node.value, str) and id(node) not in self.docstrings:
self._scan_sql(node.value, node)
self.generic_visit(node)
def visit_JoinedStr(self, node: ast.JoinedStr) -> None:
# f-string: scan its STATIC text (interpolated values become a placeholder) so MySQL-isms
# in dynamic SQL are caught, without flagging safe interpolation of identifiers.
text = "".join(
v.value if isinstance(v, ast.Constant) and isinstance(v.value, str) else " ? "
for v in node.values
)
self._scan_sql(text, node)
# don't recurse: child literal chunks would otherwise be re-scanned individually
def visit_Call(self, node: ast.Call) -> None:
fn = node.func
name = fn.attr if isinstance(fn, ast.Attribute) else (fn.id if isinstance(fn, ast.Name) else "")
# row.get("Column_name") — MySQL SHOW INDEX result key
if name == "get" and node.args and isinstance(node.args[0], ast.Constant) and node.args[0].value in MYSQL_RESULT_KEYS:
self._flag(node, f'"{node.args[0].value}" is a MySQL SHOW INDEX result key -> use frappe.db.has_index()/get_column_index()')
# set_value(..., True) / db_set("field", True) on a Check (int) column.
# Only the field *value* arg carries bool->smallint risk — NOT trailing flags like
# update_modified. db_set(field, value, update_modified, ...) -> value at args[1] (or a dict
# at args[0]); set_value(dt, dn, field, value, ...) -> value at args[3] (or a dict at args[2]).
if name in SET_BOOL_FUNCS:
value_idx, dict_idx = (1, 0) if name == "db_set" else (3, 2)
dict_arg = (
node.args[dict_idx]
if len(node.args) > dict_idx and isinstance(node.args[dict_idx], ast.Dict)
else None
)
if dict_arg is not None:
for v in dict_arg.values:
if isinstance(v, ast.Constant) and isinstance(v.value, bool):
self._flag(node, f"{name}(...) sets an int/Check column with a bool in a dict -> pass 1/0 (Postgres rejects bool->smallint)")
elif len(node.args) > value_idx:
a = node.args[value_idx]
if isinstance(a, ast.Constant) and isinstance(a.value, bool):
self._flag(node, f"{name}(..., {a.value}) sets an int/Check column with a bool -> pass 1/0 (Postgres rejects bool->smallint)")
self.generic_visit(node)
def visit_Subscript(self, node: ast.Subscript) -> None:
key = node.slice
if isinstance(key, ast.Constant) and key.value in MYSQL_RESULT_KEYS:
self._flag(node, f'"{key.value}" is a MySQL SHOW INDEX result key -> use frappe.db.has_index()/get_column_index()')
self.generic_visit(node)
def check_file(path: str) -> list[str]:
try:
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-security-file-traversal -- dev-only lint tool; `path` is a source file supplied by pre-commit, not user input
src = open(path, encoding="utf-8").read()
except (OSError, UnicodeDecodeError):
return []
try:
tree = ast.parse(src, filename=path)
except SyntaxError:
return [] # check-ast hook reports real syntax errors
v = Visitor(src.splitlines(), _docstring_ids(tree))
v.visit(tree)
return [f"{path}:{line}: [pg-compat] {msg}" for line, msg in sorted(set(v.violations))]
def main(argv: list[str]) -> int:
out: list[str] = []
for path in argv:
if path.endswith(".py"):
out.extend(check_file(path))
if out:
print("\n".join(out))
print(
f"\n{len(out)} PostgreSQL-incompatibility issue(s). Fix them, or add `# pg-ok` to a "
"line that is intentionally MariaDB-only (e.g. inside an `if frappe.db.db_type == 'mariadb':` branch)."
)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -13,6 +13,6 @@
"root_login": "postgres",
"root_password": "travis",
"host_name": "http://test_site:8000",
"install_apps": ["erpnext"],
"install_apps": ["payments", "erpnext"],
"throttle_user_limit": 100
}

View File

@@ -1,51 +1,80 @@
name: Server (Postgres)
on:
repository_dispatch:
types: [frappe-framework-change]
pull_request:
# 'labeled' is required so adding the 'postgres' label to an open PR triggers this run
# (the job itself is gated on that label below)
types: [opened, reopened, synchronize, labeled]
paths-ignore:
- '**.js'
- '**.css'
- '**.svg'
- '**.md'
- '**.html'
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
types: [opened, labelled, synchronize, reopened]
schedule:
# Run everday at midnight UTC / 5:30 IST
- cron: "0 0 * * *"
workflow_dispatch:
inputs:
user:
description: 'Frappe Framework repository user (add your username for forks)'
required: true
default: 'frappe'
type: string
branch:
description: 'Frappe Framework branch'
default: 'develop'
required: false
type: string
permissions:
contents: read
concurrency:
group: server-postgres-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
if: ${{ contains(github.event.pull_request.labels.*.name, 'postgres') }}
# Opt-in on PRs: only runs when the PR carries the 'postgres' label. Scheduled / manual /
# framework-dispatch runs always execute (no PR labels to gate on).
if: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'postgres') }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
TZ: 'Asia/Kolkata'
NODE_ENV: "production"
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
container: [1]
name: Python Unit Tests
matrix:
container: [1, 2, 3, 4]
# Distinct from the MariaDB job's "Python Unit Tests" so its check contexts do NOT collide with
# the required "Python Unit Tests (1..4)" status checks -- this keeps Postgres non-required for now.
name: Postgres Unit Tests
services:
postgres:
image: postgres:13.3
env:
POSTGRES_PASSWORD: travis
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Clone
uses: actions/checkout@v6
@@ -104,15 +133,65 @@ jobs:
restore-keys: |
${{ runner.os }}-yarn-
- name: Cache wkhtmltopdf
uses: actions/cache@v4
with:
path: /tmp/wkhtmltox.deb
key: wkhtmltox-0.12.6.1-2-jammy-amd64
- name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env:
DB: postgres
TYPE: server
FRAPPE_USER: ${{ github.event.inputs.user }}
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
- name: Run Tests
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator
run: |
cd ~/frappe-bench/
coverage_flag=""
if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
bench --site test_site run-parallel-tests --lightmode --app erpnext \
--total-builds ${{ strategy.job-total }} \
--build-number ${{ matrix.container }} \
$coverage_flag
env:
TYPE: server
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
- name: Show bench output
if: ${{ always() }}
run: cat ~/frappe-bench/bench_start.log || true
- name: Upload coverage data
if: ${{ env.WITH_COVERAGE == 'true' }}
uses: actions/upload-artifact@v4
with:
name: coverage-postgres-${{ matrix.container }}
path: /home/runner/frappe-bench/sites/coverage.xml
coverage:
name: Coverage Wrap Up
needs: test
if: ${{ github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Clone
uses: actions/checkout@v6
- name: Download artifacts
uses: actions/download-artifact@v4
with:
pattern: coverage-postgres-*
- name: Upload coverage data
uses: codecov/codecov-action@v4
with:
name: Postgres
flags: postgres
# explicit glob: download-artifact extracts each shard into its own coverage-postgres-N/ dir
files: coverage-postgres-*/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
verbose: true

View File

@@ -66,6 +66,18 @@ repos:
- id: ruff-format
name: "Run ruff formatter"
- repo: local
hooks:
- id: postgres-compat
name: "PostgreSQL compatibility (static check)"
description: "Flags MySQL-only SQL that breaks on Postgres; the label-gated PG test job is the backstop for semantic divergences."
entry: .github/helper/postgres_compat.py
language: script
files: ^erpnext/.*\.py$
# patches/ are historical, version-gated migrations (skipped on fresh Postgres installs);
# out of scope for the always-on gate.
exclude: ^erpnext/patches/
ci:
autoupdate_schedule: weekly
skip: []

View File

@@ -1,6 +1,7 @@
import frappe
from frappe import _
from frappe.email import sendmail_to_system_managers
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import (
add_days,
add_months,
@@ -53,20 +54,24 @@ def validate_service_stop_date(doc):
def build_conditions(process_type, account, company):
conditions = ""
deferred_account = (
"item.deferred_revenue_account" if process_type == "Income" else "item.deferred_expense_account"
)
if process_type == "Income":
item = frappe.qb.DocType("Sales Invoice Item")
parent = frappe.qb.DocType("Sales Invoice")
deferred_account = item.deferred_revenue_account
else:
item = frappe.qb.DocType("Purchase Invoice Item")
parent = frappe.qb.DocType("Purchase Invoice")
deferred_account = item.deferred_expense_account
if account:
conditions += f"AND {deferred_account}={frappe.db.escape(account)}"
return deferred_account == account
elif company:
conditions += f"AND p.company = {frappe.db.escape(company)}"
return parent.company == company
return conditions
return None
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=""):
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=None):
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
if not start_date:
@@ -75,17 +80,25 @@ def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_d
end_date = add_days(today(), -1)
# check for the purchase invoice for which GL entries has to be done
invoices = frappe.db.sql_list(
f"""
select distinct item.parent
from `tabPurchase Invoice Item` item, `tabPurchase Invoice` p
where item.service_start_date<=%s and item.service_end_date>=%s
and item.enable_deferred_expense = 1 and item.parent=p.name
and item.docstatus = 1 and ifnull(item.amount, 0) > 0
{conditions}
""",
(end_date, start_date),
) # nosec
item = frappe.qb.DocType("Purchase Invoice Item")
parent = frappe.qb.DocType("Purchase Invoice")
query = (
frappe.qb.from_(item)
.inner_join(parent)
.on(item.parent == parent.name)
.select(item.parent)
.distinct()
.where(
(item.service_start_date <= end_date)
& (item.service_end_date >= start_date)
& (item.enable_deferred_expense == 1)
& (item.docstatus == 1)
& (IfNull(item.amount, 0) > 0)
)
)
if conditions is not None:
query = query.where(conditions)
invoices = query.run(pluck=True)
# For each invoice, book deferred expense
for invoice in invoices:
@@ -96,7 +109,7 @@ def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_d
send_mail(deferred_process)
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=""):
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=None):
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
if not start_date:
@@ -105,17 +118,25 @@ def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_da
end_date = add_days(today(), -1)
# check for the sales invoice for which GL entries has to be done
invoices = frappe.db.sql_list(
f"""
select distinct item.parent
from `tabSales Invoice Item` item, `tabSales Invoice` p
where item.service_start_date<=%s and item.service_end_date>=%s
and item.enable_deferred_revenue = 1 and item.parent=p.name
and item.docstatus = 1 and ifnull(item.amount, 0) > 0
{conditions}
""",
(end_date, start_date),
) # nosec
item = frappe.qb.DocType("Sales Invoice Item")
parent = frappe.qb.DocType("Sales Invoice")
query = (
frappe.qb.from_(item)
.inner_join(parent)
.on(item.parent == parent.name)
.select(item.parent)
.distinct()
.where(
(item.service_start_date <= end_date)
& (item.service_end_date >= start_date)
& (item.enable_deferred_revenue == 1)
& (item.docstatus == 1)
& (IfNull(item.amount, 0) > 0)
)
)
if conditions is not None:
query = query.where(conditions)
invoices = query.run(pluck=True)
for invoice in invoices:
doc = frappe.get_doc("Sales Invoice", invoice)
@@ -136,26 +157,39 @@ def get_booking_dates(doc, item, posting_date=None, prev_posting_date=None):
)
if not prev_posting_date:
prev_gl_entry = frappe.db.sql(
"""
select name, posting_date from `tabGL Entry` where company=%s and account=%s and
voucher_type=%s and voucher_no=%s and voucher_detail_no=%s
and is_cancelled = 0
order by posting_date desc limit 1
""",
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
as_dict=True,
prev_gl_entry = frappe.get_all(
"GL Entry",
filters={
"company": doc.company,
"account": item.get(deferred_account),
"voucher_type": doc.doctype,
"voucher_no": doc.name,
"voucher_detail_no": item.name,
"is_cancelled": 0,
},
fields=["name", "posting_date"],
order_by="posting_date desc",
limit=1,
)
prev_gl_via_je = frappe.db.sql(
"""
SELECT p.name, p.posting_date FROM `tabJournal Entry` p, `tabJournal Entry Account` c
WHERE p.name = c.parent and p.company=%s and c.account=%s
and c.reference_type=%s and c.reference_name=%s
and c.reference_detail_no=%s and c.docstatus < 2 order by posting_date desc limit 1
""",
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
as_dict=True,
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
prev_gl_via_je = (
frappe.qb.from_(je)
.inner_join(jea)
.on(je.name == jea.parent)
.select(je.name, je.posting_date)
.where(
(je.company == doc.company)
& (jea.account == item.get(deferred_account))
& (jea.reference_type == doc.doctype)
& (jea.reference_name == doc.name)
& (jea.reference_detail_no == item.name)
& (jea.docstatus < 2)
)
.orderby(je.posting_date, order=frappe.qb.desc)
.limit(1)
.run(as_dict=True)
)
if prev_gl_via_je:
@@ -277,26 +311,47 @@ def get_already_booked_amount(doc, item):
total_credit_debit, total_credit_debit_currency = "credit", "credit_in_account_currency"
deferred_account = "deferred_expense_account"
gl_entries_details = frappe.db.sql(
"""
select sum({}) as total_credit, sum({}) as total_credit_in_account_currency, voucher_detail_no
from `tabGL Entry` where company=%s and account=%s and voucher_type=%s and voucher_no=%s and voucher_detail_no=%s
and is_cancelled = 0
group by voucher_detail_no
""".format(total_credit_debit, total_credit_debit_currency),
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
as_dict=True,
gle = frappe.qb.DocType("GL Entry")
gl_entries_details = (
frappe.qb.from_(gle)
.select(
Sum(gle[total_credit_debit]).as_("total_credit"),
Sum(gle[total_credit_debit_currency]).as_("total_credit_in_account_currency"),
gle.voucher_detail_no,
)
.where(
(gle.company == doc.company)
& (gle.account == item.get(deferred_account))
& (gle.voucher_type == doc.doctype)
& (gle.voucher_no == doc.name)
& (gle.voucher_detail_no == item.name)
& (gle.is_cancelled == 0)
)
.groupby(gle.voucher_detail_no)
.run(as_dict=True)
)
journal_entry_details = frappe.db.sql(
"""
SELECT sum(c.{}) as total_credit, sum(c.{}) as total_credit_in_account_currency, reference_detail_no
FROM `tabJournal Entry` p , `tabJournal Entry Account` c WHERE p.name = c.parent and
p.company = %s and c.account=%s and c.reference_type=%s and c.reference_name=%s and c.reference_detail_no=%s
and p.docstatus < 2 group by reference_detail_no
""".format(total_credit_debit, total_credit_debit_currency),
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
as_dict=True,
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
journal_entry_details = (
frappe.qb.from_(je)
.inner_join(jea)
.on(je.name == jea.parent)
.select(
Sum(jea[total_credit_debit]).as_("total_credit"),
Sum(jea[total_credit_debit_currency]).as_("total_credit_in_account_currency"),
jea.reference_detail_no,
)
.where(
(je.company == doc.company)
& (jea.account == item.get(deferred_account))
& (jea.reference_type == doc.doctype)
& (jea.reference_name == doc.name)
& (jea.reference_detail_no == item.name)
& (je.docstatus < 2)
)
.groupby(jea.reference_detail_no)
.run(as_dict=True)
)
already_booked_amount = gl_entries_details[0].total_credit if gl_entries_details else 0

View File

@@ -7,7 +7,7 @@ from frappe import _, msgprint
from frappe.model.document import Document
from frappe.query_builder import Case
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Coalesce, Sum
from frappe.query_builder.functions import Coalesce, Max, Sum
from frappe.utils import cint, flt, fmt_money, getdate
from pypika import Order
@@ -195,14 +195,17 @@ def get_payment_entries_for_bank_clearance(
.select(
ConstantColumn("Journal Entry").as_("payment_document"),
journal_entry.name.as_("payment_entry"),
journal_entry.cheque_no.as_("cheque_number"),
journal_entry.cheque_date,
# non-grouped columns are constant per grouped JE name / account (against_account is
# arbitrary per group on MySQL) -> Max() keeps the GROUP BY valid on postgres with the
# same value MySQL picked.
Max(journal_entry.cheque_no).as_("cheque_number"),
Max(journal_entry.cheque_date).as_("cheque_date"),
Sum(journal_entry_account.debit_in_account_currency).as_("debit"),
Sum(journal_entry_account.credit_in_account_currency).as_("credit"),
journal_entry.posting_date,
journal_entry_account.against_account,
journal_entry.clearance_date,
journal_entry_account.account_currency,
Max(journal_entry.posting_date).as_("posting_date"),
Max(journal_entry_account.against_account).as_("against_account"),
Max(journal_entry.clearance_date).as_("clearance_date"),
Max(journal_entry_account.account_currency).as_("account_currency"),
)
.where(
(journal_entry_account.account == account)
@@ -215,12 +218,13 @@ def get_payment_entries_for_bank_clearance(
if not include_reconciled_entries:
journal_entry_query = journal_entry_query.where(
(journal_entry.clearance_date.isnull()) | (journal_entry.clearance_date == "0000-00-00")
(journal_entry.clearance_date.isnull())
| (journal_entry.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
)
journal_entries = (
journal_entry_query.groupby(journal_entry_account.account, journal_entry.name)
.orderby(journal_entry.posting_date)
.orderby(Max(journal_entry.posting_date))
.orderby(journal_entry.name, order=Order.desc)
).run(as_dict=True)
@@ -290,7 +294,8 @@ def get_payment_entries_for_bank_clearance(
if not include_reconciled_entries:
payment_entry_query = payment_entry_query.where(
(pe.clearance_date.isnull()) | (pe.clearance_date == "0000-00-00")
(pe.clearance_date.isnull())
| (pe.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
)
payment_entries = (payment_entry_query.orderby(pe.posting_date).orderby(pe.name, order=Order.desc)).run(
@@ -327,7 +332,8 @@ def get_payment_entries_for_bank_clearance(
if not include_reconciled_entries:
paid_purchase_invoices_query = paid_purchase_invoices_query.where(
(pi.clearance_date.isnull()) | (pi.clearance_date == "0000-00-00")
(pi.clearance_date.isnull())
| (pi.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
)
paid_purchase_invoices = (
@@ -367,7 +373,8 @@ def get_payment_entries_for_bank_clearance(
if not include_reconciled_entries:
pos_sales_invoices_query = pos_sales_invoices_query.where(
(si_payment.clearance_date.isnull()) | (si_payment.clearance_date == "0000-00-00")
(si_payment.clearance_date.isnull())
| (si_payment.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
)
pos_sales_invoices = (

View File

@@ -9,6 +9,13 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
frappe.ui.form.on("Bank Guarantee", {
setup: function (frm) {
frm.set_query("reference_doctype", function () {
return {
filters: {
name: ["in", ["Sales Order", "Purchase Order"]],
},
};
});
frm.set_query("bank_account", function () {
return {
filters: {

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "ACC-BG-.YYYY.-.#####",
"creation": "2016-12-17 10:43:35.731631",
"doctype": "DocType",
@@ -50,8 +51,7 @@
"fieldname": "reference_doctype",
"fieldtype": "Link",
"label": "Reference Document Type",
"options": "DocType",
"read_only": 1
"options": "DocType"
},
{
"fieldname": "reference_docname",
@@ -60,14 +60,14 @@
"options": "reference_doctype"
},
{
"depends_on": "eval: doc.bg_type == \"Receiving\"",
"depends_on": "eval: doc.reference_doctype == \"Sales Order\"",
"fieldname": "customer",
"fieldtype": "Link",
"label": "Customer",
"options": "Customer"
},
{
"depends_on": "eval: doc.bg_type == \"Providing\"",
"depends_on": "eval: doc.reference_doctype == \"Purchase Order\"",
"fieldname": "supplier",
"fieldtype": "Link",
"label": "Supplier",
@@ -218,10 +218,11 @@
"grid_page_length": 50,
"is_submittable": 1,
"links": [],
"modified": "2025-08-29 11:52:33.550847",
"modified": "2026-05-25 18:12:10.768835",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Guarantee",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{

View File

@@ -136,6 +136,9 @@ function set_total_budget_amount(frm) {
function toggle_distribution_fields(frm) {
const grid = frm.fields_dict.budget_distribution.grid;
frm.set_df_property("budget_distribution", "cannot_add_rows", true);
frm.set_df_property("budget_distribution", "cannot_delete_rows", true);
["amount", "percent"].forEach((field) => {
grid.update_docfield_property(field, "read_only", frm.doc.distribute_equally);
});

View File

@@ -5,9 +5,11 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Coalesce, Sum
from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate
from frappe.utils.data import get_first_day
from pypika.terms import ExistsCriterion
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
@@ -115,23 +117,26 @@ class Budget(Document):
if not account:
return
existing_budget = frappe.db.sql(
f"""
SELECT name, account
FROM `tabBudget`
WHERE
docstatus < 2
AND company = %s
AND {budget_against_field} = %s
AND account = %s
AND name != %s
AND (
(SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
AND (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
)
""",
(self.company, budget_against, account, self.name, self.budget_end_date, self.budget_start_date),
as_dict=True,
budget = frappe.qb.DocType("Budget")
fy_from = frappe.qb.DocType("Fiscal Year").as_("fy_from")
fy_to = frappe.qb.DocType("Fiscal Year").as_("fy_to")
existing_budget = (
frappe.qb.from_(budget)
.inner_join(fy_from)
.on(fy_from.name == budget.from_fiscal_year)
.inner_join(fy_to)
.on(fy_to.name == budget.to_fiscal_year)
.select(budget.name, budget.account)
.where(
(budget.docstatus < 2)
& (budget.company == self.company)
& (budget[budget_against_field] == budget_against)
& (budget.account == account)
& (budget.name != self.name)
& (fy_from.year_start_date <= self.budget_end_date)
& (fy_to.year_end_date >= self.budget_start_date)
)
.run(as_dict=True)
)
if existing_budget:
@@ -353,8 +358,8 @@ class Budget(Document):
if self.should_regenerate_budget_distribution():
return
total_amount = sum(d.amount for d in self.budget_distribution)
total_percent = sum(d.percent for d in self.budget_distribution)
total_amount = sum(flt(d.amount) for d in self.budget_distribution)
total_percent = sum(flt(d.percent) for d in self.budget_distribution)
if flt(abs(total_amount - self.budget_amount), 2) > 0.10:
frappe.throw(
@@ -381,17 +386,24 @@ def validate_expense_against_budget(params, expense_amount=0):
posting_fiscal_year = get_fiscal_year(posting_date, company=params.get("company"))[0]
year_start_date, year_end_date = get_fiscal_year_date_range(posting_fiscal_year, posting_fiscal_year)
budget_exists = frappe.db.sql(
"""
select name
from `tabBudget`
where company = %s
and docstatus = 1
and (SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
and (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
limit 1
""",
(params.company, year_end_date, year_start_date),
budget = frappe.qb.DocType("Budget")
fy_from = frappe.qb.DocType("Fiscal Year").as_("fy_from")
fy_to = frappe.qb.DocType("Fiscal Year").as_("fy_to")
budget_exists = (
frappe.qb.from_(budget)
.inner_join(fy_from)
.on(fy_from.name == budget.from_fiscal_year)
.inner_join(fy_to)
.on(fy_to.name == budget.to_fiscal_year)
.select(budget.name)
.where(
(budget.company == params.company)
& (budget.docstatus == 1)
& (fy_from.year_start_date <= year_end_date)
& (fy_to.year_end_date >= year_start_date)
)
.limit(1)
.run()
)
if not budget_exists:
@@ -434,50 +446,52 @@ def validate_expense_against_budget(params, expense_amount=0):
and (frappe.get_cached_value("Account", params.account, "root_type") == "Expense")
):
doctype = dimension.get("document_type")
if frappe.get_cached_value("DocType", doctype, "is_tree"):
lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"])
condition = f"""and exists(select name from `tab{doctype}`
where lft<={lft} and rgt>={rgt} and name=b.{budget_against})""" # nosec
params.is_tree = True
else:
condition = f"and b.{budget_against}={frappe.db.escape(params.get(budget_against))}"
params.is_tree = False
params.is_tree = bool(frappe.get_cached_value("DocType", doctype, "is_tree"))
params.budget_against_field = budget_against
params.budget_against_doctype = doctype
budget_records = frappe.db.sql(
f"""
SELECT
b = frappe.qb.DocType("Budget")
query = (
frappe.qb.from_(b)
.select(
b.name,
b.{budget_against} AS budget_against,
getattr(b, budget_against).as_("budget_against"),
b.budget_amount,
b.from_fiscal_year,
b.to_fiscal_year,
b.budget_start_date,
b.budget_end_date,
IFNULL(b.applicable_on_material_request, 0) AS for_material_request,
IFNULL(b.applicable_on_purchase_order, 0) AS for_purchase_order,
IFNULL(b.applicable_on_booking_actual_expenses, 0) AS for_actual_expenses,
Coalesce(b.applicable_on_material_request, 0).as_("for_material_request"),
Coalesce(b.applicable_on_purchase_order, 0).as_("for_purchase_order"),
Coalesce(b.applicable_on_booking_actual_expenses, 0).as_("for_actual_expenses"),
b.action_if_annual_budget_exceeded,
b.action_if_accumulated_monthly_budget_exceeded,
b.action_if_annual_budget_exceeded_on_mr,
b.action_if_accumulated_monthly_budget_exceeded_on_mr,
b.action_if_annual_budget_exceeded_on_po,
b.action_if_accumulated_monthly_budget_exceeded_on_po
FROM
`tabBudget` b
WHERE
b.company = %s
AND b.docstatus = 1
AND %s BETWEEN b.budget_start_date AND b.budget_end_date
AND b.account = %s
{condition}
""",
(params.company, params.posting_date, params.account),
as_dict=True,
) # nosec
b.action_if_accumulated_monthly_budget_exceeded_on_po,
)
.where(b.company == params.company)
.where(b.docstatus == 1)
.where(b.budget_start_date <= params.posting_date)
.where(b.budget_end_date >= params.posting_date)
.where(b.account == params.account)
)
if params.is_tree:
lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"])
dim = frappe.qb.DocType(doctype)
query = query.where(
ExistsCriterion(
frappe.qb.from_(dim)
.select(dim.name)
.where((dim.lft <= lft) & (dim.rgt >= rgt) & (dim.name == getattr(b, budget_against)))
)
)
else:
query = query.where(getattr(b, budget_against) == params.get(budget_against))
budget_records = query.run(as_dict=True)
if budget_records:
validate_budget_records(params, budget_records, expense_amount)
@@ -674,15 +688,27 @@ def get_actions(params, budget):
def get_requested_amount(params):
item_code = params.get("item_code")
condition = get_other_condition(params, "Material Request")
data = frappe.db.sql(
""" select ifnull((sum(child.stock_qty - child.ordered_qty) * rate), 0) as amount
from `tabMaterial Request Item` child, `tabMaterial Request` parent where parent.name = child.parent and
child.item_code = %s and parent.docstatus = 1 and child.stock_qty > child.ordered_qty and {} and
parent.material_request_type = 'Purchase' and parent.status != 'Stopped'""".format(condition),
item_code,
as_list=1,
child = frappe.qb.DocType("Material Request Item")
parent = frappe.qb.DocType("Material Request")
data = (
frappe.qb.from_(child)
.join(parent)
.on(parent.name == child.parent)
.select(
# rate inside the aggregate: Sum(qty * rate) is the correct requested amount and is PG-valid
Coalesce(Sum((child.stock_qty - child.ordered_qty) * child.rate), 0).as_("amount")
)
.where(
(child.item_code == item_code)
& (parent.docstatus == 1)
& (child.stock_qty > child.ordered_qty)
& Criterion.all(get_other_condition(params, child, parent, "Material Request"))
& (parent.material_request_type == "Purchase")
& (parent.status != "Stopped")
)
.run(as_list=1)
)
return data[0][0] if data else 0
@@ -690,37 +716,43 @@ def get_requested_amount(params):
def get_ordered_amount(params):
item_code = params.get("item_code")
condition = get_other_condition(params, "Purchase Order")
data = frappe.db.sql(
f""" select ifnull(sum(child.amount - child.billed_amt), 0) as amount
from `tabPurchase Order Item` child, `tabPurchase Order` parent where
parent.name = child.parent and child.item_code = %s and parent.docstatus = 1 and child.amount > child.billed_amt
and parent.status != 'Closed' and {condition}""",
item_code,
as_list=1,
child = frappe.qb.DocType("Purchase Order Item")
parent = frappe.qb.DocType("Purchase Order")
data = (
frappe.qb.from_(child)
.join(parent)
.on(parent.name == child.parent)
.select(Coalesce(Sum(child.amount - child.billed_amt), 0).as_("amount"))
.where(
(child.item_code == item_code)
& (parent.docstatus == 1)
& (child.amount > child.billed_amt)
& (parent.status != "Closed")
& Criterion.all(get_other_condition(params, child, parent, "Purchase Order"))
)
.run(as_list=1)
)
return data[0][0] if data else 0
def get_other_condition(params, for_doc):
condition = f"expense_account = {frappe.db.escape(params.expense_account)}"
def get_other_condition(params, child, parent, for_doc):
conditions = [child.expense_account == params.expense_account]
budget_against_field = params.get("budget_against_field")
if budget_against_field and params.get(budget_against_field):
condition += (
f" and child.{budget_against_field} = {frappe.db.escape(params.get(budget_against_field))}"
)
conditions.append(child[budget_against_field] == params.get(budget_against_field))
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
start_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date")
end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date")
condition += f" and parent.{date_field} between {frappe.db.escape(str(start_date))} and {frappe.db.escape(str(end_date))}"
conditions.append(parent[date_field][str(start_date) : str(end_date)])
return condition
return conditions
def get_actual_expense(params):
@@ -728,11 +760,19 @@ def get_actual_expense(params):
params.budget_against_doctype = frappe.unscrub(params.budget_against_field)
budget_against_field = params.get("budget_against_field")
condition1 = " and gle.posting_date <= %(month_end_date)s" if params.get("month_end_date") else ""
date_condition = (
f"and gle.posting_date between '{params.budget_start_date}' and '{params.budget_end_date}'"
)
gle = frappe.qb.DocType("GL Entry")
conditions = [
gle.is_cancelled == 0,
gle.account == params.get("account"),
gle.posting_date[str(params.budget_start_date) : str(params.budget_end_date)],
gle.company == params.get("company"),
gle.docstatus == 1,
]
if params.get("month_end_date"):
conditions.append(gle.posting_date <= params.get("month_end_date"))
if params.is_tree:
lft_rgt = frappe.db.get_value(
@@ -740,35 +780,27 @@ def get_actual_expense(params):
)
params.update(lft_rgt)
condition2 = f"""
and exists(
select name from `tab{params.budget_against_doctype}`
where lft >= %(lft)s and rgt <= %(rgt)s
and name = gle.{budget_against_field}
tree = frappe.qb.DocType(params.budget_against_doctype)
conditions.append(
ExistsCriterion(
frappe.qb.from_(tree)
.select(tree.name)
.where(
(tree.lft >= params.get("lft"))
& (tree.rgt <= params.get("rgt"))
& (tree.name == gle[budget_against_field])
)
)
"""
)
else:
condition2 = f"""
and gle.{budget_against_field} = %({budget_against_field})s
"""
conditions.append(gle[budget_against_field] == params.get(budget_against_field))
amount = flt(
frappe.db.sql(
f"""
select sum(gle.debit) - sum(gle.credit)
from `tabGL Entry` gle
where
is_cancelled = 0
and gle.account = %(account)s
{condition1}
{date_condition}
and gle.company = %(company)s
and gle.docstatus = 1
{condition2}
""",
params,
)[0][0]
) # nosec
frappe.qb.from_(gle)
.select(Sum(gle.debit) - Sum(gle.credit))
.where(Criterion.all(conditions))
.run()[0][0]
)
return amount

View File

@@ -18,6 +18,7 @@
"in_list_view": 1,
"label": "Start Date",
"read_only": 1,
"reqd": 1,
"search_index": 1
},
{
@@ -25,26 +26,29 @@
"fieldtype": "Date",
"in_list_view": 1,
"label": "End Date",
"read_only": 1
"read_only": 1,
"reqd": 1
},
{
"fieldname": "amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Amount"
"label": "Amount",
"reqd": 1
},
{
"fieldname": "percent",
"fieldtype": "Percent",
"in_list_view": 1,
"label": "Percent"
"label": "Percent",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-11-03 13:18:28.398198",
"modified": "2026-06-18 11:23:17.669733",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Budget Distribution",

View File

@@ -15,12 +15,12 @@ class BudgetDistribution(Document):
from frappe.types import DF
amount: DF.Currency
end_date: DF.Date | None
end_date: DF.Date
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
percent: DF.Percent
start_date: DF.Date | None
start_date: DF.Date
# end: auto-generated types
pass

View File

@@ -75,7 +75,10 @@ def validate_company(company: str):
@frappe.whitelist()
def import_coa(file_name: str, company: str):
frappe.only_for("Accounts Manager")
# delete existing data for accounts
frappe.has_permission("Company", "write", company, throw=True)
unset_existing_data(company)
# create accounts
@@ -453,6 +456,7 @@ def unset_existing_data(company):
fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", [])
linked = [{"fieldname": name} for name in fieldnames]
update_values = {d.get("fieldname"): "" for d in linked}
frappe.db.set_value("Company", company, update_values, update_values)
# remove accounts data from various doctypes
@@ -464,8 +468,7 @@ def unset_existing_data(company):
"Sales Taxes and Charges Template",
"Purchase Taxes and Charges Template",
]:
dt = frappe.qb.DocType(doctype)
frappe.qb.from_(dt).where(dt.company == company).delete().run()
frappe.get_query(doctype, delete=True, filters={"company": company}, ignore_permissions=False).run()
def set_default_accounts(company):

View File

@@ -84,10 +84,10 @@ class CostCenter(NestedSet):
return frappe.db.get_value("GL Entry", {"cost_center": self.name})
def check_if_child_exists(self):
return frappe.db.sql(
"select name from `tabCost Center` where \
parent_cost_center = %s and docstatus != 2",
self.name,
return frappe.get_all(
"Cost Center",
filters={"parent_cost_center": self.name, "docstatus": ["!=", 2]},
pluck="name",
)
def if_allocation_exists_against_cost_center(self):

View File

@@ -626,6 +626,8 @@ def get_account_details(
party: str | None = None,
rounding_loss_allowance: float = 0.0,
):
frappe.has_permission("Account", doc=account, throw=True)
if not (company and posting_date):
frappe.throw(_("Company and Posting Date is mandatory"))

View File

@@ -72,10 +72,8 @@ class FiscalYear(Document):
if existing_fiscal_years:
for existing in existing_fiscal_years:
company_for_existing = frappe.db.sql_list(
"""select company from `tabFiscal Year Company`
where parent=%s""",
existing.name,
company_for_existing = frappe.get_all(
"Fiscal Year Company", filters={"parent": existing.name}, pluck="company"
)
overlap = False

View File

@@ -7,6 +7,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.model.naming import set_name_from_naming_options
from frappe.query_builder.functions import Sum
from frappe.utils import create_batch, flt, fmt_money, now
import erpnext
@@ -331,10 +332,12 @@ def validate_balance_type(account, adv_adj=False):
if not adv_adj and account:
balance_must_be = frappe.get_cached_value("Account", account, "balance_must_be")
if balance_must_be:
balance = frappe.db.sql(
"""select sum(debit) - sum(credit)
from `tabGL Entry` where is_cancelled = 0 and account = %s""",
account,
gle = frappe.qb.DocType("GL Entry")
balance = (
frappe.qb.from_(gle)
.select(Sum(gle.debit) - Sum(gle.credit))
.where((gle.is_cancelled == 0) & (gle.account == account))
.run()
)[0][0]
if (balance_must_be == "Debit" and flt(balance) < 0) or (
@@ -348,44 +351,48 @@ def validate_balance_type(account, adv_adj=False):
def update_outstanding_amt(
account, party_type, party, against_voucher_type, against_voucher, on_cancel=False
):
gle = frappe.qb.DocType("GL Entry")
conditions = (
(gle.against_voucher_type == against_voucher_type)
& (gle.against_voucher == against_voucher)
& (gle.voucher_type != "Invoice Discounting")
)
if party_type and party:
party_condition = " and party_type={} and party={}".format(
frappe.db.escape(party_type), frappe.db.escape(party)
)
else:
party_condition = ""
conditions &= (gle.party_type == party_type) & (gle.party == party)
if against_voucher_type == "Sales Invoice":
party_account = frappe.get_cached_value(against_voucher_type, against_voucher, "debit_to")
account_condition = f"and account in ({frappe.db.escape(account)}, {frappe.db.escape(party_account)})"
conditions &= gle.account.isin([account, party_account])
else:
account_condition = f" and account = {frappe.db.escape(account)}"
conditions &= gle.account == account
# get final outstanding amt
bal = flt(
frappe.db.sql(
f"""
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabGL Entry`
where against_voucher_type=%s and against_voucher=%s
and voucher_type != 'Invoice Discounting'
{party_condition} {account_condition}""",
(against_voucher_type, against_voucher),
)[0][0]
frappe.qb.from_(gle)
.select(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
.where(conditions)
.run()[0][0]
or 0.0
)
if against_voucher_type == "Purchase Invoice":
bal = -bal
elif against_voucher_type == "Journal Entry":
je_conditions = (
(gle.voucher_type == "Journal Entry")
& (gle.voucher_no == against_voucher)
& (gle.account == account)
& (gle.against_voucher.isnull() | (gle.against_voucher == ""))
)
if party_type and party:
je_conditions &= (gle.party_type == party_type) & (gle.party == party)
against_voucher_amount = flt(
frappe.db.sql(
f"""
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabGL Entry` where voucher_type = 'Journal Entry' and voucher_no = %s
and account = %s and (against_voucher is null or against_voucher='') {party_condition}""",
(against_voucher, account),
)[0][0]
frappe.qb.from_(gle)
.select(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
.where(je_conditions)
.run()[0][0]
)
if not against_voucher_amount:
@@ -480,10 +487,14 @@ def rename_temporarily_named_docs(doctype):
oldname = doc.name
set_name_from_naming_options(autoname, doc)
newname = doc.name
frappe.db.sql(
f"UPDATE `tab{doctype}` SET name = %s, to_rename = 0, modified = %s where name = %s",
(newname, now(), oldname),
)
dt = frappe.qb.DocType(doctype)
(
frappe.qb.update(dt)
.set(dt.name, newname)
.set(dt.to_rename, 0)
.set(dt.modified, now())
.where(dt.name == oldname)
).run()
for hook_type in ("on_gle_rename", "on_sle_rename"):
for hook in frappe.get_hooks(hook_type):

View File

@@ -26,12 +26,17 @@ class TestGLEntry(ERPNextTestSuite):
jv.flags.ignore_validate = True
jv.submit()
round_off_entry = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Journal Entry' and voucher_no = %s
and account='_Test Write Off - _TC' and cost_center='_Test Cost Center - _TC'
and debit = 0 and credit = '.01'""",
jv.name,
round_off_entry = frappe.get_all(
"GL Entry",
filters={
"voucher_type": "Journal Entry",
"voucher_no": jv.name,
"account": "_Test Write Off - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit": 0,
"credit": 0.01,
},
pluck="name",
)
self.assertTrue(round_off_entry)
@@ -55,8 +60,9 @@ class TestGLEntry(ERPNextTestSuite):
)
self.assertTrue(all(entry.to_rename == 1 for entry in gl_entries))
old_naming_series_current_value = frappe.db.sql(
"SELECT current from tabSeries where name = %s", naming_series
series = frappe.qb.DocType("Series")
old_naming_series_current_value = (
frappe.qb.from_(series).select(series["current"]).where(series.name == naming_series).run()
)[0][0]
rename_gle_sle_docs()
@@ -73,8 +79,8 @@ class TestGLEntry(ERPNextTestSuite):
all(new.name != old.name for new, old in zip(gl_entries, new_gl_entries, strict=False))
)
new_naming_series_current_value = frappe.db.sql(
"SELECT current from tabSeries where name = %s", naming_series
new_naming_series_current_value = (
frappe.qb.from_(series).select(series["current"]).where(series.name == naming_series).run()
)[0][0]
self.assertEqual(old_naming_series_current_value + 2, new_naming_series_current_value)

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
)
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import JournalTaxWithholding
from erpnext.accounts.party import get_party_account
from erpnext.accounts.services.gl_validator import validate_opening_entry_against_pcv
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
get_account_currency,
@@ -149,6 +150,9 @@ class JournalEntry(AccountsController):
if not self.is_opening:
self.is_opening = "No"
if self.is_opening == "Yes":
validate_opening_entry_against_pcv(self.company)
self.clearance_date = None
self.validate_party()
@@ -889,7 +893,7 @@ class JournalEntry(AccountsController):
msgprint(_("'Entries' cannot be empty"), raise_exception=True)
return
self.total_debit, self.total_credit = 0, 0
self.set_total_debit_credit()
diff = flt(self.difference, self.precision("difference"))
if diff:
self._apply_difference_to_blank_row(diff, difference_account)

View File

@@ -43,18 +43,18 @@ class TestJournalEntry(ERPNextTestSuite):
if test_voucher.doctype == "Journal Entry":
self.assertTrue(
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where account = %s and docstatus = 1 and parent = %s""",
("Debtors - _TC", test_voucher.name),
frappe.get_all(
"Journal Entry Account",
filters={"account": "Debtors - _TC", "docstatus": 1, "parent": test_voucher.name},
pluck="name",
)
)
self.assertFalse(
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_type = %s and reference_name = %s""",
(test_voucher.doctype, test_voucher.name),
frappe.get_all(
"Journal Entry Account",
filters={"reference_type": test_voucher.doctype, "reference_name": test_voucher.name},
pluck="name",
)
)
@@ -69,10 +69,14 @@ class TestJournalEntry(ERPNextTestSuite):
submitted_voucher = frappe.get_doc(test_voucher.doctype, test_voucher.name)
self.assertTrue(
frappe.db.sql(
f"""select name from `tabJournal Entry Account`
where reference_type = %s and reference_name = %s and {dr_or_cr}=400""",
(submitted_voucher.doctype, submitted_voucher.name),
frappe.get_all(
"Journal Entry Account",
filters={
"reference_type": submitted_voucher.doctype,
"reference_name": submitted_voucher.name,
dr_or_cr: 400,
},
pluck="name",
)
)
@@ -82,24 +86,20 @@ class TestJournalEntry(ERPNextTestSuite):
def advance_paid_testcase(self, base_jv, test_voucher, dr_or_cr):
# Test advance paid field
advance_paid = frappe.db.sql(
"""select advance_paid from `tab{}`
where name={}""".format(test_voucher.doctype, "%s"),
(test_voucher.name),
)
advance_paid = frappe.db.get_value(test_voucher.doctype, test_voucher.name, "advance_paid")
payment_against_order = base_jv.get("accounts")[0].get(dr_or_cr)
self.assertEqual(flt(advance_paid[0][0]), flt(payment_against_order))
self.assertEqual(flt(advance_paid), flt(payment_against_order))
def cancel_against_voucher_testcase(self, test_voucher):
if test_voucher.doctype == "Journal Entry":
# if test_voucher is a Journal Entry, test cancellation of test_voucher
test_voucher.cancel()
self.assertFalse(
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_type='Journal Entry' and reference_name=%s""",
test_voucher.name,
frappe.get_all(
"Journal Entry Account",
filters={"reference_type": "Journal Entry", "reference_name": test_voucher.name},
pluck="name",
)
)
@@ -202,10 +202,10 @@ class TestJournalEntry(ERPNextTestSuite):
# cancel
jv.cancel()
gle = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
jv.name,
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": jv.name},
pluck="name",
)
self.assertFalse(gle)
@@ -526,9 +526,16 @@ class TestJournalEntry(ERPNextTestSuite):
gl_entries = query.run(as_dict=True)
for i in range(len(self.expected_gle)):
# MariaDB and Postgres collate `account` differently, so the DB ordering isn't portable;
# sort both sides identically before the positional comparison.
def _key(row):
return tuple(str(row[f]) for f in self.fields)
gl_entries = sorted(gl_entries, key=_key)
expected_gle = sorted(self.expected_gle, key=_key)
for i in range(len(expected_gle)):
for field in self.fields:
self.assertEqual(self.expected_gle[i][field], gl_entries[i][field])
self.assertEqual(expected_gle[i][field], gl_entries[i][field])
def test_negative_debit_and_credit_with_same_account_head(self):
from erpnext.accounts.general_ledger import process_gl_map
@@ -764,6 +771,29 @@ class TestJournalEntry(ERPNextTestSuite):
self.assertEqual(blank_row.credit_in_account_currency, 100)
self.assertEqual(jv.total_debit, jv.total_credit)
def test_get_balance_recomputes_difference_ignoring_client_value(self):
"""get_balance computes its own difference instead of trusting a stale client-sent value."""
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.append(
"accounts",
{
"account": "_Test Cash - _TC",
"debit_in_account_currency": 100,
"debit": 100,
"exchange_rate": 1,
},
)
jv.append("accounts", {"account": "_Test Bank - _TC", "exchange_rate": 1})
# a stale/incorrect value as the client might send; get_balance must not rely on it
jv.difference = 0
jv.get_balance()
self.assertEqual(jv.accounts[1].credit_in_account_currency, 100)
self.assertEqual(jv.total_debit, jv.total_credit)
self.assertEqual(jv.difference, 0)
def test_get_outstanding_invoices_builds_write_off_rows(self):
"""Characterize: get_outstanding_invoices adds a party row for each outstanding invoice."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice

View File

@@ -44,6 +44,7 @@
{
"bold": 1,
"columns": 4,
"fetch_from": "bank_account.account",
"fieldname": "account",
"fieldtype": "Link",
"in_global_search": 1,

View File

@@ -39,28 +39,32 @@ def get_loyalty_point_entries(customer, loyalty_program, company, expiry_date=No
if not expiry_date:
expiry_date = today()
return frappe.db.sql(
"""
select name, loyalty_points, expiry_date, loyalty_program_tier, invoice_type, invoice
from `tabLoyalty Point Entry`
where customer=%s and loyalty_program=%s
and expiry_date>=%s and loyalty_points>0 and company=%s
order by expiry_date
""",
(customer, loyalty_program, expiry_date, company),
as_dict=1,
return frappe.get_all(
"Loyalty Point Entry",
filters={
"customer": customer,
"loyalty_program": loyalty_program,
"expiry_date": [">=", expiry_date],
"loyalty_points": [">", 0],
"company": company,
},
fields=["name", "loyalty_points", "expiry_date", "loyalty_program_tier", "invoice_type", "invoice"],
order_by="expiry_date",
)
def get_redemption_details(customer, loyalty_program, company):
return frappe._dict(
frappe.db.sql(
"""
select redeem_against, sum(loyalty_points)
from `tabLoyalty Point Entry`
where customer=%s and loyalty_program=%s and loyalty_points<0 and company=%s
group by redeem_against
""",
(customer, loyalty_program, company),
frappe.get_all(
"Loyalty Point Entry",
filters={
"customer": customer,
"loyalty_program": loyalty_program,
"loyalty_points": ["<", 0],
"company": company,
},
fields=["redeem_against", {"SUM": "loyalty_points", "as": "loyalty_points"}],
group_by="redeem_against",
as_list=True,
)
)

View File

@@ -1191,9 +1191,9 @@ class PaymentEntry(AccountsController):
continue
if tax.add_deduct_tax == "Add":
included_taxes += tax.base_tax_amount
included_taxes += flt(tax.base_tax_amount)
else:
included_taxes -= tax.base_tax_amount
included_taxes -= flt(tax.base_tax_amount)
return included_taxes

View File

@@ -818,12 +818,11 @@ class TestPaymentEntry(ERPNextTestSuite):
self.assertEqual(expected_gle[gle.account][3], gle.against_voucher)
def get_gle(self, voucher_no):
return frappe.db.sql(
"""select account, debit, credit, against_voucher
from `tabGL Entry` where voucher_type='Payment Entry' and voucher_no=%s
order by account asc""",
voucher_no,
as_dict=1,
return frappe.get_all(
"GL Entry",
filters={"voucher_type": "Payment Entry", "voucher_no": voucher_no},
fields=["account", "debit", "credit", "against_voucher"],
order_by="account asc",
)
def test_payment_entry_write_off_difference(self):
@@ -918,13 +917,19 @@ class TestPaymentEntry(ERPNextTestSuite):
"Debtors - _TC": {"cost_center": cost_center},
}
gl_entries = frappe.db.sql(
"""select account, cost_center, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Payment Entry' and voucher_no=%s
order by account asc""",
pe.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Payment Entry", "voucher_no": pe.name},
fields=[
"account",
"cost_center",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
)
self.assertTrue(gl_entries)
@@ -955,13 +960,19 @@ class TestPaymentEntry(ERPNextTestSuite):
"Creditors - _TC": {"cost_center": cost_center},
}
gl_entries = frappe.db.sql(
"""select account, cost_center, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Payment Entry' and voucher_no=%s
order by account asc""",
pe.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Payment Entry", "voucher_no": pe.name},
fields=[
"account",
"cost_center",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
)
self.assertTrue(gl_entries)
@@ -1113,6 +1124,27 @@ class TestPaymentEntry(ERPNextTestSuite):
self.assertEqual(gl_entries, expected_gl_entries)
def test_payment_entry_with_inclusive_tax(self):
# inclusive tax built server-side: base_tax_amount is None until apply_taxes()
payment_entry = create_payment_entry(paid_amount=1180)
payment_entry.append(
"taxes",
{
"account_head": "_Test Account Service Tax - _TC",
"charge_type": "On Paid Amount",
"rate": 18,
"included_in_paid_amount": 1,
"add_deduct_tax": "Add",
"description": "Service Tax",
},
)
payment_entry.save()
payment_entry.submit()
# 1180 incl 18% => 1000 base + 180 tax
self.assertEqual(flt(payment_entry.total_taxes_and_charges, 2), 180.0)
self.assertEqual(flt(payment_entry.unallocated_amount, 2), 1000.0)
def test_payment_entry_against_onhold_purchase_invoice(self):
pi = make_purchase_invoice()
@@ -1749,9 +1781,18 @@ class TestPaymentEntry(ERPNextTestSuite):
.where((gle.voucher_no == self.voucher_no) & (gle.is_cancelled == 0))
.orderby(gle.account, gle.debit, gle.credit, order=frappe.qb.desc)
).run(as_dict=True)
for row in range(len(self.expected_gle)):
for field in ["account", "debit", "credit"]:
self.assertEqual(self.expected_gle[row][field], gl_entries[row][field])
# MariaDB and Postgres collate `account` differently, so the DB ordering isn't portable;
# sort both sides identically before the positional comparison.
fields = ["account", "debit", "credit"]
def _key(row):
return tuple(str(row[f]) for f in fields)
gl_entries = sorted(gl_entries, key=_key)
expected_gle = sorted(self.expected_gle, key=_key)
for row in range(len(expected_gle)):
for field in fields:
self.assertEqual(expected_gle[row][field], gl_entries[row][field])
def test_reverse_payment_reconciliation(self):
customer = create_customer(frappe.generate_hash(length=10), "INR")

View File

@@ -347,12 +347,11 @@ class TestPaymentRequest(ERPNextTestSuite):
]
)
gl_entries = frappe.db.sql(
"""select account, debit, credit, against_voucher
from `tabGL Entry` where voucher_type='Payment Entry' and voucher_no=%s
order by account asc""",
pe.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Payment Entry", "voucher_no": pe.name},
fields=["account", "debit", "credit", "against_voucher"],
order_by="account asc",
)
self.assertTrue(gl_entries)

View File

@@ -73,7 +73,10 @@ class PeriodClosingVoucher(AccountsController):
if not previous_fiscal_year:
return
previous_fiscal_year_start_date = previous_fiscal_year[0][1]
# get_fiscal_year() returns a single (name, start_date, end_date) tuple, so the start date
# is [1]; the old [0][1] read the 2nd char of the name ('T'), which MariaDB silently
# coerced to NULL but postgres rejects as an invalid date.
previous_fiscal_year_start_date = previous_fiscal_year[1]
previous_fiscal_year_closed = frappe.db.exists(
"Period Closing Voucher",
{
@@ -287,41 +290,44 @@ class PeriodClosingVoucher(AccountsController):
self.accounting_dimension_fields = default_dimensions + get_accounting_dimensions()
def get_gl_entries_for_current_period(self, report_type, only_opening_entries=False, as_iterator=False):
date_condition = ""
if only_opening_entries:
date_condition = "is_opening = 'Yes'"
else:
date_condition = f"posting_date BETWEEN '{self.period_start_date}' AND '{self.period_end_date}' and is_opening = 'No'"
gle = frappe.qb.DocType("GL Entry")
account = frappe.qb.DocType("Account")
# nosemgrep
return frappe.db.sql(
"""
SELECT
name,
posting_date,
account,
account_currency,
debit_in_account_currency,
credit_in_account_currency,
debit,
credit,
{}
FROM `tabGL Entry`
WHERE
{}
AND company = %s
AND voucher_type != 'Period Closing Voucher'
AND EXISTS(SELECT name FROM `tabAccount` WHERE name = account AND report_type = %s)
AND is_cancelled = 0
""".format(
", ".join(self.accounting_dimension_fields),
date_condition,
),
(self.company, report_type),
as_dict=1,
as_iterator=as_iterator,
fields = [
gle.name,
gle.posting_date,
gle.account,
gle.account_currency,
gle.debit_in_account_currency,
gle.credit_in_account_currency,
gle.debit,
gle.credit,
]
fields += [gle[dimension] for dimension in self.accounting_dimension_fields]
query = (
frappe.qb.from_(gle)
.select(*fields)
.where(
(gle.company == self.company)
& (gle.voucher_type != "Period Closing Voucher")
& (gle.is_cancelled == 0)
& gle.account.isin(
frappe.qb.from_(account).select(account.name).where(account.report_type == report_type)
)
)
)
if only_opening_entries:
query = query.where(gle.is_opening == "Yes")
else:
query = query.where(
gle.posting_date.between(self.period_start_date, self.period_end_date)
& (gle.is_opening == "No")
)
return query.run(as_dict=1, as_iterator=as_iterator)
def set_account_balance_dict(self, gle, acc_bal_dict):
key = self.get_key(gle)

View File

@@ -55,15 +55,19 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
("Sales - TPC", 400.0, 0.0),
)
pcv_gle = frappe.db.sql(
"""
select account, debit, credit from `tabGL Entry` where voucher_no=%s order by account
""",
(pcv.name),
)
pcv_gle = [
tuple(row)
for row in frappe.get_all(
"GL Entry",
filters={"voucher_no": pcv.name},
fields=["account", "debit", "credit"],
order_by="account",
as_list=True,
)
]
pcv.reload()
self.assertEqual(pcv.gle_processing_status, "Completed")
self.assertEqual(pcv_gle, expected_gle)
self.assertEqual(tuple(pcv_gle), expected_gle)
def test_cost_center_wise_posting(self):
surplus_account = create_account()
@@ -106,14 +110,16 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
("Sales - TPC", 200.0, 0.0, cost_center2),
)
pcv_gle = frappe.db.sql(
"""
select account, debit, credit, cost_center
from `tabGL Entry` where voucher_no=%s
order by account, cost_center
""",
(pcv.name),
)
pcv_gle = [
tuple(row)
for row in frappe.get_all(
"GL Entry",
filters={"voucher_no": pcv.name},
fields=["account", "debit", "credit", "cost_center"],
order_by="account, cost_center",
as_list=True,
)
]
self.assertSequenceEqual(pcv_gle, expected_gle)
@@ -166,16 +172,19 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
("Sales - TPC", 400.0, 0.0, jv.finance_book),
)
pcv_gle = frappe.db.sql(
"""
select account, debit, credit, finance_book
from `tabGL Entry` where voucher_no=%s
order by account, finance_book
""",
(pcv.name),
)
pcv_gle = [
tuple(row)
for row in frappe.get_all(
"GL Entry",
filters={"voucher_no": pcv.name},
fields=["account", "debit", "credit", "finance_book"],
order_by="account, finance_book",
as_list=True,
)
]
self.assertSequenceEqual(pcv_gle, expected_gle)
# compare order-independently: postgres and MariaDB order NULL finance_book differently
self.assertSequenceEqual(sorted(pcv_gle, key=str), sorted(expected_gle, key=str))
def test_gl_entries_restrictions(self):
cost_center = create_cost_center("Test Cost Center 1")
@@ -358,14 +367,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
posting_date="2022-01-01",
)
totals_after_cancel = frappe.db.sql(
"""
select sum(debit) as total_debit, sum(credit) as total_credit
from `tabGL Entry`
where voucher_type=%s and voucher_no=%s and is_cancelled=0
""",
("Journal Entry", jv.name),
as_dict=True,
totals_after_cancel = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Journal Entry", "voucher_no": jv.name, "is_cancelled": 0},
fields=[{"SUM": "debit", "as": "total_debit"}, {"SUM": "credit", "as": "total_credit"}],
)[0]
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)

View File

@@ -295,7 +295,7 @@ def get_payments(invoices):
.groupby(SalesInvoicePayment.mode_of_payment)
.select(
SalesInvoicePayment.mode_of_payment,
SalesInvoicePayment.account,
fn.Max(SalesInvoicePayment.account).as_("account"),
fn.Sum(SalesInvoicePayment.amount).as_("amount"),
)
)
@@ -419,7 +419,7 @@ def build_invoice_query(invoice_doctype, user, pos_profile, start, end):
InvoiceDocType.account_for_change_amount,
InvoiceDocType.is_return,
InvoiceDocType.return_against,
fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time).as_("timestamp"),
fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time).as_("timestamp"),
ConstantColumn(invoice_doctype).as_("doctype"),
)
.where(
@@ -428,8 +428,8 @@ def build_invoice_query(invoice_doctype, user, pos_profile, start, end):
& (InvoiceDocType.is_pos == 1)
& (InvoiceDocType.pos_profile == pos_profile)
& (
(fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time) >= start)
& (fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time) <= end)
(fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time) >= start)
& (fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time) <= end)
)
)
)

View File

@@ -6,7 +6,7 @@ import frappe
from frappe import _, bold
from frappe.model.document import Document
from frappe.model.mapper import map_child_doc, map_doc
from frappe.query_builder.functions import IfNull, Sum
from frappe.query_builder.functions import IfNull, Lower, Sum
from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate
from frappe.utils.nestedset import get_descendants_of
@@ -505,19 +505,20 @@ class POSInvoice(SalesInvoice):
if d.get("serial_no"):
serial_nos = get_serial_nos(d.serial_no)
for sr in serial_nos:
serial_no_exists = frappe.db.sql(
"""
SELECT name
FROM `tabPOS Invoice Item`
WHERE
parent = %s
and (serial_no = %s
or serial_no like %s
or serial_no like %s
or serial_no like %s
)
""",
(self.return_against, sr, sr + "\n%", "%\n" + sr, "%\n" + sr + "\n%"),
POI = frappe.qb.DocType("POS Invoice Item")
s = sr.lower()
serial_no_exists = (
frappe.qb.from_(POI)
.select(POI.name)
.where(POI.parent == self.return_against)
.where(
(Lower(POI.serial_no) == s)
| Lower(POI.serial_no).like(f"{s}\n%")
| Lower(POI.serial_no).like(f"%\n{s}")
| Lower(POI.serial_no).like(f"%\n{s}\n%")
)
.limit(1)
.run()
)
if not serial_no_exists:
@@ -963,15 +964,9 @@ def get_bundle_availability(bundle_item_code, warehouse):
def get_bin_qty(item_code, warehouse):
bin_qty = frappe.db.sql(
"""select actual_qty from `tabBin`
where item_code = %s and warehouse = %s
limit 1""",
(item_code, warehouse),
as_dict=1,
)
actual_qty = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty")
return bin_qty[0].actual_qty or 0 if bin_qty else 0
return actual_qty or 0
def get_pos_reserved_qty(item_code, warehouse):

View File

@@ -118,14 +118,21 @@ class POSProfile(Document):
def validate_default_profile(self):
for row in self.applicable_for_users:
res = frappe.db.sql(
"""select pf.name
from
`tabPOS Profile User` pfu, `tabPOS Profile` pf
where
pf.name = pfu.parent and pfu.user = %s and pf.name != %s and pf.company = %s
and pfu.default=1 and pf.disabled = 0""",
(row.user, self.name, self.company),
pfu = frappe.qb.DocType("POS Profile User")
pf = frappe.qb.DocType("POS Profile")
res = (
frappe.qb.from_(pfu)
.inner_join(pf)
.on(pf.name == pfu.parent)
.select(pf.name)
.where(
(pfu.user == row.user)
& (pf.name != self.name)
& (pf.company == self.company)
& (pfu.default == 1)
& (pf.disabled == 0)
)
.run()
)
if row.default and res:
@@ -265,10 +272,11 @@ def get_permitted_nodes(group_type):
def get_child_nodes(group_type, root):
lft, rgt = frappe.db.get_value(group_type, root, ["lft", "rgt"])
return frappe.db.sql(
f""" Select name, lft, rgt from `tab{group_type}` where
lft >= {lft} and rgt <= {rgt} order by lft""",
as_dict=1,
return frappe.get_all(
group_type,
filters={"lft": [">=", lft], "rgt": ["<=", rgt]},
fields=["name", "lft", "rgt"],
order_by="lft",
)
@@ -278,40 +286,33 @@ def pos_profile_query(doctype: str, txt: str, searchfield: str, start: int, page
user = frappe.session["user"]
company = filters.get("company") or frappe.defaults.get_user_default("company")
args = {
"user": user,
"start": start,
"company": company,
"page_len": page_len,
"txt": "%%%s%%" % txt,
}
pf = frappe.qb.DocType("POS Profile")
pfu = frappe.qb.DocType("POS Profile User")
pos_profile = frappe.db.sql(
"""select pf.name
from
`tabPOS Profile` pf, `tabPOS Profile User` pfu
where
pfu.parent = pf.name and pfu.user = %(user)s and pf.company = %(company)s
and (pf.name like %(txt)s)
and pf.disabled = 0 limit %(page_len)s offset %(start)s""",
args,
pos_profile = (
frappe.qb.from_(pf)
.inner_join(pfu)
.on(pfu.parent == pf.name)
.select(pf.name)
.where((pfu.user == user) & (pf.company == company) & pf.name.like(f"%{txt}%") & (pf.disabled == 0))
.limit(page_len)
.offset(start)
.run()
)
if not pos_profile:
del args["user"]
pos_profile = frappe.db.sql(
"""select pf.name
from
`tabPOS Profile` pf left join `tabPOS Profile User` pfu
on
pf.name = pfu.parent
where
ifnull(pfu.user, '') = ''
and pf.company = %(company)s
and pf.name like %(txt)s
and pf.disabled = 0""",
args,
pos_profile = (
frappe.qb.from_(pf)
.left_join(pfu)
.on(pf.name == pfu.parent)
.select(pf.name)
.where(
(pfu.user.isnull() | (pfu.user == ""))
& (pf.company == company)
& pf.name.like(f"%{txt}%")
& (pf.disabled == 0)
)
.run()
)
return pos_profile

View File

@@ -114,7 +114,7 @@ def _get_pricing_rules(apply_on, args, values):
if apply_on_field == "item_code":
if args.get("uom", None):
item_conditions += (
" and ({child_doc}.uom={item_uom} or IFNULL({child_doc}.uom, '')='')".format(
" and ({child_doc}.uom={item_uom} or COALESCE({child_doc}.uom, '')='')".format(
child_doc=child_doc, item_uom=frappe.db.escape(args.get("uom"))
)
)
@@ -127,7 +127,7 @@ def _get_pricing_rules(apply_on, args, values):
elif apply_on_field == "item_group":
item_conditions = _get_tree_conditions(args, "Item Group", child_doc, False)
if args.get("uom", None):
item_conditions += " and ({child_doc}.uom={item_uom} or IFNULL({child_doc}.uom, '')='')".format(
item_conditions += " and ({child_doc}.uom={item_uom} or COALESCE({child_doc}.uom, '')='')".format(
child_doc=child_doc, item_uom=frappe.db.escape(args.get("uom"))
)
@@ -139,7 +139,7 @@ def _get_pricing_rules(apply_on, args, values):
if not args.price_list:
args.price_list = None
conditions += " and ifnull(`tabPricing Rule`.for_price_list, '') in (%(price_list)s, '')"
conditions += " and coalesce(`tabPricing Rule`.for_price_list, '') in (%(price_list)s, '')"
values["price_list"] = args.get("price_list")
pricing_rules = (
@@ -195,10 +195,8 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
except TypeError:
frappe.throw(_("Invalid {0}").format(args.get(field)))
parent_groups = frappe.db.sql_list(
"""select name from `tab{}`
where lft<={} and rgt>={}""".format(parenttype, "%s", "%s"),
(lft, rgt),
parent_groups = frappe.get_all(
parenttype, filters={"lft": ["<=", lft], "rgt": [">=", rgt]}, pluck="name"
)
if parenttype in ["Customer Group", "Item Group", "Territory"]:
@@ -217,14 +215,14 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
if parent_groups:
if allow_blank:
parent_groups.append("")
condition = "ifnull({table}.{field}, '') in ({parent_groups})".format(
condition = "coalesce({table}.{field}, '') in ({parent_groups})".format(
table=table, field=field, parent_groups=", ".join(frappe.db.escape(d) for d in parent_groups)
)
frappe.flags.tree_conditions[key] = condition
elif allow_blank:
condition = f"ifnull({table}.{field}, '') = ''"
condition = f"coalesce({table}.{field}, '') = ''"
return condition
@@ -232,10 +230,10 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
def get_other_conditions(conditions, values, args):
for field in ["company", "customer", "supplier", "campaign", "sales_partner"]:
if args.get(field):
conditions += f" and ifnull(`tabPricing Rule`.{field}, '') in (%({field})s, '')"
conditions += f" and coalesce(`tabPricing Rule`.{field}, '') in (%({field})s, '')"
values[field] = args.get(field)
else:
conditions += f" and ifnull(`tabPricing Rule`.{field}, '') = ''"
conditions += f" and coalesce(`tabPricing Rule`.{field}, '') = ''"
for parenttype in ["Customer Group", "Territory", "Supplier Group"]:
group_condition = _get_tree_conditions(args, parenttype, "`tabPricing Rule`")
@@ -248,8 +246,8 @@ def get_other_conditions(conditions, values, args):
or frappe.get_value(args.get("doctype"), args.get("name"), "posting_date", ignore=True)
)
if date:
conditions += """ and %(transaction_date)s between ifnull(`tabPricing Rule`.valid_from, '2000-01-01')
and ifnull(`tabPricing Rule`.valid_upto, '2500-12-31')"""
conditions += """ and %(transaction_date)s between coalesce(`tabPricing Rule`.valid_from, '2000-01-01')
and coalesce(`tabPricing Rule`.valid_upto, '2500-12-31')"""
values["transaction_date"] = date
if args.get("doctype") in [
@@ -264,9 +262,9 @@ def get_other_conditions(conditions, values, args):
"POS Invoice",
"POS Invoice Item",
]:
conditions += """ and ifnull(`tabPricing Rule`.selling, 0) = 1"""
conditions += """ and coalesce(`tabPricing Rule`.selling, 0) = 1"""
else:
conditions += """ and ifnull(`tabPricing Rule`.buying, 0) = 1"""
conditions += """ and coalesce(`tabPricing Rule`.buying, 0) = 1"""
return conditions

View File

@@ -431,7 +431,9 @@ def reconcile(doc: None | str = None) -> None:
# Update reconciled flag
allocation_names = [x.name for x in allocations]
ppa = qb.DocType("Process Payment Reconciliation Log Allocations")
qb.update(ppa).set(ppa.reconciled, True).where(ppa.name.isin(allocation_names)).run()
qb.update(ppa).set(ppa.reconciled, 1).where(
ppa.name.isin(allocation_names)
).run() # smallint, not bool
# Update reconciled count
reconciled_count = frappe.db.count(
@@ -477,7 +479,7 @@ def reconcile(doc: None | str = None) -> None:
finally:
if reconciled_entries == total_allocations:
frappe.db.set_value("Process Payment Reconciliation Log", log, "status", "Reconciled")
frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", True)
frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", 1)
frappe.db.set_value("Process Payment Reconciliation", doc, "status", "Completed")
else:
if frappe.db.get_value("Process Payment Reconciliation", doc, "status") != "Paused":
@@ -501,7 +503,7 @@ def reconcile(doc: None | str = None) -> None:
)
else:
frappe.db.set_value("Process Payment Reconciliation Log", log, "status", "Reconciled")
frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", True)
frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", 1)
frappe.db.set_value("Process Payment Reconciliation", doc, "status", "Completed")

View File

@@ -16,6 +16,7 @@
"categorize_by",
"cost_center",
"territory",
"show_opening_entries",
"ignore_exchange_rate_revaluation_journals",
"ignore_cr_dr_notes",
"column_break_14",
@@ -414,10 +415,17 @@
"fieldtype": "Link",
"label": "Print Format",
"options": "Print Format"
},
{
"default": "0",
"depends_on": "eval:(doc.report == 'General Ledger');",
"fieldname": "show_opening_entries",
"fieldtype": "Check",
"label": "Show Opening Entries"
}
],
"links": [],
"modified": "2025-10-07 12:19:20.719898",
"modified": "2026-06-01 15:37:07.660442",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Statement Of Accounts",

View File

@@ -6,7 +6,6 @@ import copy
import frappe
from frappe import _
from frappe.desk.reportview import get_match_cond
from frappe.model.document import Document
from frappe.utils import add_days, add_months, format_date, getdate, today
from frappe.utils.jinja import validate_template
@@ -20,6 +19,7 @@ from erpnext.accounts.report.accounts_receivable_summary.accounts_receivable_sum
execute as get_ageing,
)
from erpnext.accounts.report.general_ledger.general_ledger import execute as get_soa
from erpnext.utilities.query import get_match_conditions_qb
class ProcessStatementOfAccounts(Document):
@@ -75,6 +75,7 @@ class ProcessStatementOfAccounts(Document):
sender: DF.Link | None
show_future_payments: DF.Check
show_net_values_in_party_account: DF.Check
show_opening_entries: DF.Check
show_remarks: DF.Check
start_date: DF.Date | None
subject: DF.Data | None
@@ -270,7 +271,7 @@ def get_gl_filters(doc, entry, tax_id, presentation_currency):
"categorize_by": doc.categorize_by,
"currency": doc.currency,
"project": [p.project_name for p in doc.project],
"show_opening_entries": 0,
"show_opening_entries": doc.show_opening_entries,
"include_default_book_entries": 0,
"tax_id": tax_id if tax_id else None,
"show_net_values_in_party_account": doc.show_net_values_in_party_account,
@@ -365,15 +366,19 @@ def get_customers_based_on_territory_or_customer_group(customer_collection, coll
def get_customers_based_on_sales_person(sales_person):
lft, rgt = frappe.db.get_value("Sales Person", sales_person, ["lft", "rgt"])
records = frappe.db.sql(
"""
select distinct parent, parenttype
from `tabSales Team` steam
where parenttype = 'Customer'
and exists(select name from `tabSales Person` where lft >= %s and rgt <= %s and name = steam.sales_person)
""",
(lft, rgt),
as_dict=1,
steam = frappe.qb.DocType("Sales Team")
sp = frappe.qb.DocType("Sales Person")
records = (
frappe.qb.from_(steam)
.select(steam.parent, steam.parenttype)
.distinct()
.where(
(steam.parenttype == "Customer")
& steam.sales_person.isin(
frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt))
)
)
.run(as_dict=1)
)
sales_person_records = frappe._dict()
for d in records:
@@ -468,31 +473,30 @@ def get_customer_emails(customer_name: str, primary_mandatory: str | int, billin
frappe.has_permission("Customer", "read", customer_name, throw=True)
billing_email = frappe.db.sql(
"""
SELECT
email.email_id
FROM
`tabContact Email` AS email
JOIN
`tabDynamic Link` AS link
ON
email.parent=link.parent
JOIN
`tabContact` AS contact
ON
contact.name=link.parent
WHERE
link.link_doctype='Customer'
and link.link_name=%s
and contact.is_billing_contact=1
{mcond}
ORDER BY
contact.creation desc
""".format(mcond=get_match_cond("Contact")),
customer_name,
email = frappe.qb.DocType("Contact Email")
link = frappe.qb.DocType("Dynamic Link")
contact = frappe.qb.DocType("Contact")
query = (
frappe.qb.from_(email)
.join(link)
.on(email.parent == link.parent)
.join(contact)
.on(contact.name == link.parent)
.select(email.email_id)
.where(
(link.link_doctype == "Customer")
& (link.link_name == customer_name)
& (contact.is_billing_contact == 1)
)
.orderby(contact.creation, order=frappe.qb.desc)
)
for condition in get_match_conditions_qb("Contact", table=contact):
query = query.where(condition)
billing_email = query.run()
if len(billing_email) == 0 or (billing_email[0][0] is None):
if billing_and_primary:
frappe.throw(_("No billing email found for customer: {0}").format(customer_name))

View File

@@ -524,16 +524,11 @@ class PurchaseInvoice(BuyingController):
def check_prev_docstatus(self):
for d in self.get("items"):
if d.purchase_order:
submitted = frappe.db.sql(
"select name from `tabPurchase Order` where docstatus = 1 and name = %s", d.purchase_order
)
submitted = frappe.db.exists("Purchase Order", {"docstatus": 1, "name": d.purchase_order})
if not submitted:
frappe.throw(_("Purchase Order {0} is not submitted").format(d.purchase_order))
if d.purchase_receipt:
submitted = frappe.db.sql(
"select name from `tabPurchase Receipt` where docstatus = 1 and name = %s",
d.purchase_receipt,
)
submitted = frappe.db.exists("Purchase Receipt", {"docstatus": 1, "name": d.purchase_receipt})
if not submitted:
frappe.throw(_("Purchase Receipt {0} is not submitted").format(d.purchase_receipt))
@@ -801,25 +796,20 @@ class PurchaseInvoice(BuyingController):
if cint(frappe.get_single_value("Accounts Settings", "check_supplier_invoice_uniqueness")):
fiscal_year = get_fiscal_year(self.posting_date, company=self.company, as_dict=True)
pi = frappe.db.sql(
"""select name from `tabPurchase Invoice`
where
bill_no = %(bill_no)s
and supplier = %(supplier)s
and name != %(name)s
and docstatus < 2
and posting_date between %(year_start_date)s and %(year_end_date)s""",
{
pi = frappe.get_all(
"Purchase Invoice",
filters={
"bill_no": self.bill_no,
"supplier": self.supplier,
"name": self.name,
"year_start_date": fiscal_year.year_start_date,
"year_end_date": fiscal_year.year_end_date,
"name": ["!=", self.name],
"docstatus": ["<", 2],
"posting_date": ["between", [fiscal_year.year_start_date, fiscal_year.year_end_date]],
},
pluck="name",
)
if pi:
pi = pi[0][0]
pi = pi[0]
frappe.throw(
_("Supplier Invoice No exists in Purchase Invoice {0}").format(

View File

@@ -55,10 +55,13 @@ class ExpenseAccountService:
else:
# check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not
if item.purchase_receipt:
negative_expense_booked_in_pr = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Purchase Receipt' and voucher_no=%s and account = %s""",
(item.purchase_receipt, stock_not_billed_account),
negative_expense_booked_in_pr = frappe.db.exists(
"GL Entry",
{
"voucher_type": "Purchase Receipt",
"voucher_no": item.purchase_receipt,
"account": stock_not_billed_account,
},
)
if negative_expense_booked_in_pr:

View File

@@ -395,10 +395,14 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
):
# Post reverse entry for Stock-Received-But-Not-Billed if booked in Purchase Receipt
if item.purchase_receipt and valuation_tax_accounts:
negative_expense_booked_in_pr = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Purchase Receipt' and voucher_no=%s and account in %s""",
(item.purchase_receipt, valuation_tax_accounts),
negative_expense_booked_in_pr = frappe.get_all(
"GL Entry",
filters={
"voucher_type": "Purchase Receipt",
"voucher_no": item.purchase_receipt,
"account": ["in", valuation_tax_accounts],
},
pluck="name",
)
(

View File

@@ -3,6 +3,7 @@
import frappe
from frappe.query_builder.functions import Sum
from frappe.utils import add_days, cint, flt, getdate, nowdate, today
import erpnext
@@ -123,11 +124,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
"_Test Account Discount - _TC": [0, 168.03],
"Round Off - _TC": [0, 0.3],
}
gl_entries = frappe.db.sql(
"""select account, debit, credit from `tabGL Entry`
where voucher_type = 'Purchase Invoice' and voucher_no = %s""",
pi.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=["account", "debit", "credit"],
)
for d in gl_entries:
self.assertEqual([d.debit, d.credit], expected_gl_entries.get(d.account))
@@ -317,12 +317,11 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
self.check_gle_for_pi(pi.name)
def check_gle_for_pi(self, pi):
gl_entries = frappe.db.sql(
"""select account, sum(debit) as debit, sum(credit) as credit
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
group by account""",
pi,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi},
fields=["account", {"SUM": "debit", "as": "debit"}, {"SUM": "credit", "as": "credit"}],
group_by="account",
)
self.assertTrue(gl_entries)
@@ -461,12 +460,11 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
self.assertTrue(pi.status, "Unpaid")
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
order by account asc""",
pi.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=["account", "debit", "credit"],
order_by="account asc",
)
self.assertTrue(gl_entries)
@@ -475,10 +473,11 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
["Creditors - TCP1", 0, 250],
]
for i, gle in enumerate(gl_entries):
self.assertEqual(expected_values[i][0], gle.account)
self.assertEqual(expected_values[i][1], gle.debit)
self.assertEqual(expected_values[i][2], gle.credit)
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
self.assertEqual(
sorted((gle.account, gle.debit, gle.credit) for gle in gl_entries),
sorted((e[0], e[1], e[2]) for e in expected_values),
)
def test_purchase_invoice_calculation(self):
pi = frappe.copy_doc(self.globalTestRecords["Purchase Invoice"][0])
@@ -546,21 +545,24 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pi.load_from_db()
self.assertTrue(
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_type='Purchase Invoice'
and reference_name=%s and debit_in_account_currency=300""",
pi.name,
frappe.get_all(
"Journal Entry Account",
filters={
"reference_type": "Purchase Invoice",
"reference_name": pi.name,
"debit_in_account_currency": 300,
},
pluck="name",
)
)
pi.cancel()
self.assertFalse(
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_type='Purchase Invoice' and reference_name=%s""",
pi.name,
frappe.get_all(
"Journal Entry Account",
filters={"reference_type": "Purchase Invoice", "reference_name": pi.name},
pluck="name",
)
)
@@ -604,10 +606,14 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pi.load_from_db()
self.assertTrue(
frappe.db.sql(
"select name from `tabJournal Entry Account` where reference_type='Purchase Invoice' and "
"reference_name=%s and debit_in_account_currency=300",
pi.name,
frappe.get_all(
"Journal Entry Account",
filters={
"reference_type": "Purchase Invoice",
"reference_name": pi.name,
"debit_in_account_currency": 300,
},
pluck="name",
)
)
@@ -616,10 +622,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pi.cancel()
self.assertFalse(
frappe.db.sql(
"select name from `tabJournal Entry Account` where reference_type='Purchase Invoice' and "
"reference_name=%s",
pi.name,
frappe.get_all(
"Journal Entry Account",
filters={"reference_type": "Purchase Invoice", "reference_name": pi.name},
pluck="name",
)
)
@@ -629,13 +635,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
else:
project = frappe.get_doc("Project", {"project_name": "_Test Project for Purchase"})
existing_purchase_cost = frappe.db.sql(
f"""select sum(base_net_amount)
from `tabPurchase Invoice Item`
where project = '{project.name}'
and docstatus=1"""
existing_purchase_cost = frappe.get_all(
"Purchase Invoice Item",
filters={"project": project.name, "docstatus": 1},
fields=[{"SUM": "base_net_amount", "as": "base_net_amount"}],
)
existing_purchase_cost = existing_purchase_cost and existing_purchase_cost[0][0] or 0
existing_purchase_cost = existing_purchase_cost and existing_purchase_cost[0].base_net_amount or 0
pi = make_purchase_invoice(currency="USD", conversion_rate=60, project=project.name)
self.assertEqual(
@@ -679,12 +684,11 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
)
# check gl entries for return
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type=%s and voucher_no=%s
order by account desc""",
("Purchase Invoice", return_pi.name),
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": return_pi.name},
fields=["account", "debit", "credit"],
order_by="account desc",
)
self.assertTrue(gl_entries)
@@ -773,13 +777,18 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
conversion_rate=50,
)
gl_entries = frappe.db.sql(
"""select account, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
order by account asc""",
pi.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=[
"account",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
)
self.assertTrue(gl_entries)
@@ -821,10 +830,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
# cancel
pi.cancel()
gle = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
pi.name,
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": pi.name},
pluck="name",
)
self.assertFalse(gle)
@@ -842,13 +851,18 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
expense_account="_Test Account Cost for Goods Sold - TCP1",
)
gl_entries = frappe.db.sql(
"""select account, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
order by account asc""",
pi.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=[
"account",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
)
self.assertTrue(gl_entries)
@@ -877,13 +891,16 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
expense_account="_Test Account Cost for Goods Sold - TCP1",
)
gl_entries = frappe.db.sql(
"""select account, account_currency, sum(debit) as debit,
sum(credit) as credit, debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
group by account, voucher_no order by account asc;""",
pi.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=[
"account",
{"SUM": "debit", "as": "debit"},
{"SUM": "credit", "as": "credit"},
],
group_by="account, voucher_no",
order_by="account asc",
)
stock_in_hand_account = get_inventory_account(pi.company, pi.get("items")[0].warehouse)
@@ -1145,13 +1162,19 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
"_Test Account Cost for Goods Sold - _TC": {"cost_center": cost_center},
}
gl_entries = frappe.db.sql(
"""select account, cost_center, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
order by account asc""",
pi.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=[
"account",
"cost_center",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
)
self.assertTrue(gl_entries)
@@ -1168,13 +1191,19 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
"_Test Account Cost for Goods Sold - _TC": {"cost_center": cost_center},
}
gl_entries = frappe.db.sql(
"""select account, cost_center, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
order by account asc""",
pi.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=[
"account",
"cost_center",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
)
self.assertTrue(gl_entries)
@@ -1209,13 +1238,20 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
"_Test Account Cost for Goods Sold - _TC": {"project": item_project.name},
}
gl_entries = frappe.db.sql(
"""select account, cost_center, project, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
order by account asc""",
pi.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=[
"account",
"cost_center",
"project",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
)
self.assertTrue(gl_entries)
@@ -1269,13 +1305,15 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
[deferred_account, 23.07, 0.0, "2019-03-15"],
]
gl_entries = gl_entries = frappe.db.sql(
"""select account, debit, credit, posting_date
from `tabGL Entry`
where voucher_type='Journal Entry' and voucher_detail_no=%s and posting_date <= %s
order by posting_date asc, account asc""",
(pi.items[0].name, pi.posting_date),
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={
"voucher_type": "Journal Entry",
"voucher_detail_no": pi.items[0].name,
"posting_date": ["<=", pi.posting_date],
},
fields=["account", "debit", "credit", "posting_date"],
order_by="posting_date asc, account asc",
)
for i, gle in enumerate(gl_entries):
@@ -1350,14 +1388,14 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
["_Test Payable USD - _TC", -37500.0],
]
gl_entries = frappe.db.sql(
"""
select account, sum(debit - credit) as balance from `tabGL Entry`
where voucher_no=%s
group by account
order by account asc""",
(pi.name),
as_dict=1,
gle = frappe.qb.DocType("GL Entry")
gl_entries = (
frappe.qb.from_(gle)
.select(gle.account, Sum(gle.debit - gle.credit).as_("balance"))
.where(gle.voucher_no == pi.name)
.groupby(gle.account)
.orderby(gle.account)
.run(as_dict=1)
)
for i, gle in enumerate(gl_entries):
@@ -1421,13 +1459,14 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
["_Test Payable USD - _TC", -36500.0],
]
gl_entries = frappe.db.sql(
"""
select account, sum(debit - credit) as balance from `tabGL Entry`
where voucher_no=%s
group by account order by account asc""",
(pi_2.name),
as_dict=1,
gle = frappe.qb.DocType("GL Entry")
gl_entries = (
frappe.qb.from_(gle)
.select(gle.account, Sum(gle.debit - gle.credit).as_("balance"))
.where(gle.voucher_no == pi_2.name)
.groupby(gle.account)
.orderby(gle.account)
.run(as_dict=1)
)
for i, gle in enumerate(gl_entries):
@@ -1436,18 +1475,21 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
expected_gle = [["_Test Payable USD - _TC", 70000.0], ["Cash - _TC", -70000.0]]
gl_entries = frappe.db.sql(
"""
select account, sum(debit - credit) as balance from `tabGL Entry`
where voucher_no=%s and is_cancelled=0
group by account order by account asc""",
(pay.name),
as_dict=1,
gle = frappe.qb.DocType("GL Entry")
gl_entries = (
frappe.qb.from_(gle)
.select(gle.account, Sum(gle.debit - gle.credit).as_("balance"))
.where((gle.voucher_no == pay.name) & (gle.is_cancelled == 0))
.groupby(gle.account)
.orderby(gle.account)
.run(as_dict=1)
)
for i, gle in enumerate(gl_entries):
self.assertEqual(expected_gle[i][0], gle.account)
self.assertEqual(expected_gle[i][1], gle.balance)
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
self.assertEqual(
sorted((gle.account, gle.balance) for gle in gl_entries),
sorted((e[0], e[1]) for e in expected_gle),
)
total_debit_amount = frappe.db.get_all(
"Journal Entry Account",
@@ -1546,19 +1588,18 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
[tds_account, 0, 3000],
]
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry`
where voucher_type='Payment Entry' and voucher_no=%s
order by account asc""",
(payment_entry.name),
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Payment Entry", "voucher_no": payment_entry.name},
fields=["account", "debit", "credit"],
order_by="account asc",
)
for i, gle in enumerate(gl_entries):
self.assertEqual(expected_gle[i][0], gle.account)
self.assertEqual(expected_gle[i][1], gle.debit)
self.assertEqual(expected_gle[i][2], gle.credit)
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
self.assertEqual(
sorted((gle.account, gle.debit, gle.credit) for gle in gl_entries),
sorted((e[0], e[1], e[2]) for e in expected_gle),
)
# Create Purchase Invoice against Purchase Order
purchase_invoice = get_mapped_purchase_invoice(po.name)
@@ -1572,19 +1613,21 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
# Zero net effect on final TDS payable on invoice
expected_gle = [["_Test Account Cost for Goods Sold - _TC", 30000], ["Creditors - _TC", -30000]]
gl_entries = frappe.db.sql(
"""select account, sum(debit - credit) as amount
from `tabGL Entry`
where voucher_type='Purchase Invoice' and voucher_no=%s
group by account
order by account asc""",
(purchase_invoice.name),
as_dict=1,
gle = frappe.qb.DocType("GL Entry")
gl_entries = (
frappe.qb.from_(gle)
.select(gle.account, Sum(gle.debit - gle.credit).as_("amount"))
.where((gle.voucher_type == "Purchase Invoice") & (gle.voucher_no == purchase_invoice.name))
.groupby(gle.account)
.orderby(gle.account)
.run(as_dict=1)
)
for i, gle in enumerate(gl_entries):
self.assertEqual(expected_gle[i][0], gle.account)
self.assertEqual(expected_gle[i][1], gle.amount)
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
self.assertEqual(
sorted((gle.account, gle.amount) for gle in gl_entries),
sorted((e[0], e[1]) for e in expected_gle),
)
payment_entry.load_from_db()
tax_allocated = sum(
@@ -2476,12 +2519,11 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pi.insert()
pi.submit()
pr_gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Purchase Receipt' and voucher_no=%s
order by account asc""",
pr.name,
as_dict=1,
pr_gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name},
fields=["account", "debit", "credit"],
order_by="account asc",
)
pr_expected_values = [
@@ -2494,12 +2536,11 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
self.assertEqual(pr_expected_values[i][1], gle.debit)
self.assertEqual(pr_expected_values[i][2], gle.credit)
pi_gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
order by account asc""",
pi.name,
as_dict=1,
pi_gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=["account", "debit", "credit"],
order_by="account asc",
)
pi_expected_values = [
["Asset Received But Not Billed - _TC", 5000, 0],
@@ -3047,17 +3088,25 @@ def check_gl_entries(
gl_entries = query.run(as_dict=True)
for i, gle in enumerate(gl_entries):
doc.assertEqual(expected_gle[i][0], gle.account)
doc.assertEqual(expected_gle[i][1], gle.debit)
doc.assertEqual(expected_gle[i][2], gle.credit)
doc.assertEqual(getdate(expected_gle[i][3]), gle.posting_date)
# MariaDB and Postgres collate `account` differently, so the DB row order isn't portable.
# Match each actual GL row against the expected set instead of comparing positionally; like the
# original loop (which iterated the actual rows), extra expected rows are tolerated.
cols = additional_columns or []
if additional_columns:
j = 4
for col in additional_columns:
doc.assertEqual(expected_gle[i][j], gle[col])
j += 1
def _key(account, debit, credit, posting_date, extras):
return (account, flt(debit), flt(credit), getdate(posting_date), *(str(v) for v in extras))
remaining = {}
for e in expected_gle:
k = _key(e[0], e[1], e[2], e[3], e[4 : 4 + len(cols)])
remaining[k] = remaining.get(k, 0) + 1
for gle in gl_entries:
k = _key(gle.account, gle.debit, gle.credit, gle.posting_date, [gle[c] for c in cols])
doc.assertGreater(
remaining.get(k, 0), 0, msg=f"Unexpected GL entry {k}; expected one of {list(remaining)}"
)
remaining[k] -= 1
def create_tax_witholding_category(category_name, company, account):

View File

@@ -11,6 +11,7 @@
"add_deduct_tax",
"charge_type",
"row_id",
"allocate_full_amount_to_stock_items",
"included_in_print_rate",
"included_in_paid_amount",
"col_break1",
@@ -78,6 +79,14 @@
"oldfieldname": "row_id",
"oldfieldtype": "Data"
},
{
"default": "1",
"depends_on": "eval:doc.charge_type=='Actual' && ['Valuation', 'Valuation and Total'].includes(doc.category)",
"description": "If checked, the entire amount (e.g. Freight) is allocated to the valuation of stock & asset items only. If unchecked, the amount is distributed across all items and the portion belonging to non-stock items is not added to valuation.",
"fieldname": "allocate_full_amount_to_stock_items",
"fieldtype": "Check",
"label": "Allocate Full Amount to Stock Items"
},
{
"default": "0",
"description": "If checked, the tax amount will be considered as already included in the Print Rate / Print Amount",

View File

@@ -69,6 +69,7 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
qb.from_(gl)
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
.groupby(gl.voucher_no)
.run()
)
@@ -82,6 +83,7 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
qb.from_(gl)
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
.groupby(gl.voucher_no)
.run()
)

View File

@@ -200,106 +200,11 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
set_purchase_references(target)
def update_details(source_doc, target_doc, source_parent):
def _validate_address_link(address, link_doctype, link_name):
return frappe.db.get_value(
"Dynamic Link",
{
"parent": address,
"parenttype": "Address",
"link_doctype": link_doctype,
"link_name": link_name,
},
"parent",
)
target_doc.inter_company_invoice_reference = source_doc.name
if target_doc.doctype in ["Purchase Invoice", "Purchase Order"]:
currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency")
target_doc.company = details.get("company")
target_doc.supplier = details.get("party")
target_doc.is_internal_supplier = 1
target_doc.ignore_pricing_rule = 1
target_doc.buying_price_list = source_doc.selling_price_list
# Invert Addresses
if source_doc.company_address and _validate_address_link(
source_doc.company_address, "Supplier", details.get("party")
):
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
if source_doc.dispatch_address_name and _validate_address_link(
source_doc.dispatch_address_name, "Company", details.get("company")
):
update_address(
target_doc,
"dispatch_address",
"dispatch_address_display",
source_doc.dispatch_address_name,
)
if source_doc.shipping_address_name and _validate_address_link(
source_doc.shipping_address_name, "Company", details.get("company")
):
update_address(
target_doc,
"shipping_address",
"shipping_address_display",
source_doc.shipping_address_name,
)
if source_doc.customer_address and _validate_address_link(
source_doc.customer_address, "Company", details.get("company")
):
update_address(
target_doc, "billing_address", "billing_address_display", source_doc.customer_address
)
if currency:
target_doc.currency = currency
update_taxes(
target_doc,
party=target_doc.supplier,
party_type="Supplier",
company=target_doc.company,
doctype=target_doc.doctype,
party_address=target_doc.supplier_address,
company_address=target_doc.shipping_address,
)
_apply_purchase_party_details(target_doc, source_doc, details)
else:
currency = frappe.db.get_value("Customer", details.get("party"), "default_currency")
target_doc.company = details.get("company")
target_doc.customer = details.get("party")
target_doc.selling_price_list = source_doc.buying_price_list
if source_doc.supplier_address and _validate_address_link(
source_doc.supplier_address, "Company", details.get("company")
):
update_address(
target_doc, "company_address", "company_address_display", source_doc.supplier_address
)
if source_doc.shipping_address and _validate_address_link(
source_doc.shipping_address, "Customer", details.get("party")
):
update_address(
target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address
)
if source_doc.shipping_address and _validate_address_link(
source_doc.shipping_address, "Customer", details.get("party")
):
update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
if currency:
target_doc.currency = currency
update_taxes(
target_doc,
party=target_doc.customer,
party_type="Customer",
company=target_doc.company,
doctype=target_doc.doctype,
party_address=target_doc.customer_address,
company_address=target_doc.company_address,
shipping_address_name=target_doc.shipping_address_name,
)
_apply_sales_party_details(target_doc, source_doc, details)
def update_item(source, target, source_parent):
target.qty = flt(source.qty) - received_items.get(source.name, 0.0)
@@ -378,6 +283,97 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
return doclist
def _get_linked_address(address, link_doctype, link_name):
return frappe.db.get_value(
"Dynamic Link",
{
"parent": address,
"parenttype": "Address",
"link_doctype": link_doctype,
"link_name": link_name,
},
"parent",
)
def _apply_purchase_party_details(target_doc, source_doc, details):
currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency")
target_doc.company = details.get("company")
target_doc.supplier = details.get("party")
target_doc.is_internal_supplier = 1
target_doc.ignore_pricing_rule = 1
target_doc.buying_price_list = source_doc.selling_price_list
# Invert Addresses
if source_doc.company_address and _get_linked_address(
source_doc.company_address, "Supplier", details.get("party")
):
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
if source_doc.dispatch_address_name and _get_linked_address(
source_doc.dispatch_address_name, "Company", details.get("company")
):
update_address(
target_doc, "dispatch_address", "dispatch_address_display", source_doc.dispatch_address_name
)
if source_doc.shipping_address_name and _get_linked_address(
source_doc.shipping_address_name, "Company", details.get("company")
):
update_address(
target_doc, "shipping_address", "shipping_address_display", source_doc.shipping_address_name
)
if source_doc.customer_address and _get_linked_address(
source_doc.customer_address, "Company", details.get("company")
):
update_address(target_doc, "billing_address", "billing_address_display", source_doc.customer_address)
if currency:
target_doc.currency = currency
update_taxes(
target_doc,
party=target_doc.supplier,
party_type="Supplier",
company=target_doc.company,
doctype=target_doc.doctype,
party_address=target_doc.supplier_address,
company_address=target_doc.shipping_address,
)
def _apply_sales_party_details(target_doc, source_doc, details):
currency = frappe.db.get_value("Customer", details.get("party"), "default_currency")
target_doc.company = details.get("company")
target_doc.customer = details.get("party")
target_doc.selling_price_list = source_doc.buying_price_list
if source_doc.supplier_address and _get_linked_address(
source_doc.supplier_address, "Company", details.get("company")
):
update_address(target_doc, "company_address", "company_address_display", source_doc.supplier_address)
if source_doc.shipping_address and _get_linked_address(
source_doc.shipping_address, "Customer", details.get("party")
):
update_address(target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address)
if source_doc.shipping_address and _get_linked_address(
source_doc.shipping_address, "Customer", details.get("party")
):
update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
if currency:
target_doc.currency = currency
update_taxes(
target_doc,
party=target_doc.customer,
party_type="Customer",
company=target_doc.company,
doctype=target_doc.doctype,
party_address=target_doc.customer_address,
company_address=target_doc.company_address,
shipping_address_name=target_doc.shipping_address_name,
)
@frappe.whitelist()
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
reference_field = "inter_company_invoice_reference"

View File

@@ -25,30 +25,35 @@ class FixedAssetService:
if doc.doctype != "Sales Invoice":
return
for d in doc.get("items"):
if not d.is_fixed_asset:
continue
for item in doc.get("items"):
if item.is_fixed_asset:
self._validate_fixed_asset_item(item)
if d.asset:
if not doc.is_return:
asset_status = frappe.db.get_value("Asset", d.asset, "status")
if doc.update_stock:
frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale"))
elif asset_status in ("Scrapped", "Cancelled", "Capitalized"):
frappe.throw(
_("Row #{0}: Asset {1} cannot be sold, it is already {2}").format(
d.idx, d.asset, asset_status
)
)
elif asset_status == "Sold" and not doc.is_return:
frappe.throw(_("Row #{0}: Asset {1} is already sold").format(d.idx, d.asset))
elif not doc.return_against:
frappe.throw(_("Row #{0}: Return Against is required for returning asset").format(d.idx))
else:
frappe.throw(
_("Row #{0}: You must select an Asset for Item {1}.").format(d.idx, d.item_code),
title=_("Missing Asset"),
def _validate_fixed_asset_item(self, item) -> None:
doc = self.doc
if not item.asset:
frappe.throw(
_("Row #{0}: You must select an Asset for Item {1}.").format(item.idx, item.item_code),
title=_("Missing Asset"),
)
if doc.is_return:
if not doc.return_against:
frappe.throw(_("Row #{0}: Return Against is required for returning asset").format(item.idx))
return
if doc.update_stock:
frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale"))
asset_status = frappe.db.get_value("Asset", item.asset, "status")
if asset_status in ("Scrapped", "Cancelled", "Capitalized"):
frappe.throw(
_("Row #{0}: Asset {1} cannot be sold, it is already {2}").format(
item.idx, item.asset, asset_status
)
)
if asset_status == "Sold":
frappe.throw(_("Row #{0}: Asset {1} is already sold").format(item.idx, item.asset))
def set_income_account_for_fixed_assets(self) -> None:
for item in self.doc.items:

View File

@@ -93,54 +93,7 @@ class SalesInvoiceGLComposer(BaseGLComposer):
if enable_discount_accounting:
for item in doc.get("items"):
if item.get("discount_amount") and item.get("discount_account"):
discount_amount = item.discount_amount * item.qty
income_account = (
item.income_account
if (not item.enable_deferred_revenue or doc.is_return)
else item.deferred_revenue_account
)
account_currency = get_account_currency(item.discount_account)
gl_entries.append(
doc.get_gl_dict(
{
"account": item.discount_account,
"against": doc.customer,
"debit": flt(
discount_amount * doc.get("conversion_rate"),
item.precision("discount_amount"),
),
"debit_in_transaction_currency": flt(
discount_amount, item.precision("discount_amount")
),
"cost_center": item.cost_center,
"project": item.project,
},
account_currency,
item=item,
)
)
account_currency = get_account_currency(income_account)
gl_entries.append(
doc.get_gl_dict(
{
"account": income_account,
"against": doc.customer,
"credit": flt(
discount_amount * doc.get("conversion_rate"),
item.precision("discount_amount"),
),
"credit_in_transaction_currency": flt(
discount_amount, item.precision("discount_amount")
),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
self._append_item_discount_gl_entries(item, gl_entries)
if (
(enable_discount_accounting or doc.get("is_cash_or_non_trade_discount"))
@@ -159,81 +112,143 @@ class SalesInvoiceGLComposer(BaseGLComposer):
)
)
def _append_item_discount_gl_entries(self, item, gl_entries) -> None:
doc = self.doc
discount_amount = item.discount_amount * item.qty
income_account = (
item.income_account
if (not item.enable_deferred_revenue or doc.is_return)
else item.deferred_revenue_account
)
account_currency = get_account_currency(item.discount_account)
gl_entries.append(
doc.get_gl_dict(
{
"account": item.discount_account,
"against": doc.customer,
"debit": flt(
discount_amount * doc.get("conversion_rate"),
item.precision("discount_amount"),
),
"debit_in_transaction_currency": flt(discount_amount, item.precision("discount_amount")),
"cost_center": item.cost_center,
"project": item.project,
},
account_currency,
item=item,
)
)
account_currency = get_account_currency(income_account)
gl_entries.append(
doc.get_gl_dict(
{
"account": income_account,
"against": doc.customer,
"credit": flt(
discount_amount * doc.get("conversion_rate"),
item.precision("discount_amount"),
),
"credit_in_transaction_currency": flt(discount_amount, item.precision("discount_amount")),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
def stock_delivered_but_not_billed_gl_entries(self, gl_entries):
doc = self.doc
if doc.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(doc.company)):
return
for item in doc.get("items"):
if not item.delivery_note and not item.dn_detail:
continue
booking = self._get_sdbnb_booking_for_item(item)
if booking:
self._append_sdbnb_gl_entries(item, booking, gl_entries)
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
continue
def _get_sdbnb_booking_for_item(self, item) -> dict | None:
"""SDBNB account and valuation to reverse for a billed-from-delivery-note item, if any."""
if not item.delivery_note and not item.dn_detail:
return None
dn_expense_account = frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "expense_account"
)
if (
not dn_expense_account
or frappe.get_cached_value("Account", dn_expense_account, "account_type")
!= "Stock Delivered But Not Billed"
or not item.expense_account
or dn_expense_account == item.expense_account
):
continue
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
return None
delivery_note = item.delivery_note or frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "parent"
)
if not delivery_note:
continue
dn_expense_account = frappe.get_cached_value("Delivery Note Item", item.dn_detail, "expense_account")
if not self._is_sdbnb_reversal(dn_expense_account, item):
return None
item_g = frappe.get_cached_value(
"Stock Ledger Entry",
delivery_note = item.delivery_note or frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "parent"
)
if not delivery_note:
return None
item_g = frappe.get_cached_value(
"Stock Ledger Entry",
{
"voucher_no": delivery_note,
"voucher_detail_no": item.dn_detail,
"item_code": item.item_code,
"is_cancelled": 0,
},
["stock_value_difference", "actual_qty"],
as_dict=True,
)
if not item_g or not flt(item_g.actual_qty):
return None
valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty)
return {
"dn_expense_account": dn_expense_account,
"valuation_amount": valuation_rate * item.stock_qty,
}
def _is_sdbnb_reversal(self, dn_expense_account, item) -> bool:
"""True when the DN booked to an SDBNB account distinct from the item's expense account."""
return bool(
dn_expense_account
and frappe.get_cached_value("Account", dn_expense_account, "account_type")
== "Stock Delivered But Not Billed"
and item.expense_account
and dn_expense_account != item.expense_account
)
def _append_sdbnb_gl_entries(self, item, booking, gl_entries) -> None:
dn_expense_account = booking["dn_expense_account"]
valuation_amount = booking["valuation_amount"]
dn_account_currency = get_account_currency(dn_expense_account)
item_account_currency = get_account_currency(item.expense_account)
gl_entries.append(
self.get_gl_dict(
{
"voucher_no": delivery_note,
"voucher_detail_no": item.dn_detail,
"item_code": item.item_code,
"is_cancelled": 0,
"account": dn_expense_account,
"against": item.expense_account,
"credit": flt(valuation_amount),
"credit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
["stock_value_difference", "actual_qty"],
as_dict=True,
dn_account_currency,
item=item,
)
if not item_g or not flt(item_g.actual_qty):
continue
valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty)
valuation_amount = valuation_rate * item.stock_qty
dn_account_currency = get_account_currency(dn_expense_account)
item_account_currency = get_account_currency(item.expense_account)
gl_entries.append(
self.get_gl_dict(
{
"account": dn_expense_account,
"against": item.expense_account,
"credit": flt(valuation_amount),
"credit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
dn_account_currency,
item=item,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": item.expense_account,
"against": dn_expense_account,
"debit": flt(valuation_amount),
"debit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
item_account_currency,
item=item,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": item.expense_account,
"against": dn_expense_account,
"debit": flt(valuation_amount),
"debit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
item_account_currency,
item=item,
)
)
def make_customer_gl_entry(self, gl_entries):
doc = self.doc
@@ -250,10 +265,6 @@ class SalesInvoiceGLComposer(BaseGLComposer):
)
if grand_total and not doc.is_internal_transfer():
against_voucher = doc.name
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
against_voucher = doc.return_against
# Did not use base_grand_total to book rounding loss gle
gl_entries.append(
self.get_gl_dict(
@@ -264,11 +275,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"due_date": doc.due_date,
"against": doc.against_income_account,
"debit": base_grand_total,
"debit_in_account_currency": base_grand_total
if doc.party_account_currency == doc.company_currency
else grand_total,
"debit_in_account_currency": self._get_amount_in_account_currency(
doc.party_account_currency, base_grand_total, grand_total
),
"debit_in_transaction_currency": grand_total,
"against_voucher": against_voucher,
"against_voucher": self._resolve_against_voucher(),
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
"project": doc.project,
@@ -296,10 +307,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"account": tax.account_head,
"against": doc.customer,
"credit": flt(base_amount, tax.precision("tax_amount_after_discount_amount")),
"credit_in_account_currency": (
flt(base_amount, tax.precision("base_tax_amount_after_discount_amount"))
if account_currency == doc.company_currency
else flt(amount, tax.precision("tax_amount_after_discount_amount"))
"credit_in_account_currency": self._get_amount_in_account_currency(
account_currency,
flt(base_amount, tax.precision("base_tax_amount_after_discount_amount")),
flt(amount, tax.precision("tax_amount_after_discount_amount")),
),
"credit_in_transaction_currency": flt(
amount, tax.precision("tax_amount_after_discount_amount")
@@ -341,53 +352,57 @@ class SalesInvoiceGLComposer(BaseGLComposer):
)
for item in doc.get("items"):
if (
if not (
flt(item.base_net_amount, item.precision("base_net_amount"))
or item.is_fixed_asset
or enable_discount_accounting
):
# Do not book income for transfer within same company
if doc.is_internal_transfer():
continue
continue
if item.is_fixed_asset and item.asset:
self.get_gl_entries_for_fixed_asset(item, gl_entries)
else:
income_account = (
item.income_account
if (not item.enable_deferred_revenue or doc.is_return)
else item.deferred_revenue_account
)
# Do not book income for transfer within same company
if doc.is_internal_transfer():
continue
amount, base_amount = tax_service.get_amount_and_base_amount(
item, enable_discount_accounting
)
account_currency = get_account_currency(income_account)
gl_entries.append(
self.get_gl_dict(
{
"account": income_account,
"against": doc.customer,
"credit": flt(base_amount, item.precision("base_net_amount")),
"credit_in_account_currency": (
flt(base_amount, item.precision("base_net_amount"))
if account_currency == doc.company_currency
else flt(amount, item.precision("net_amount"))
),
"credit_in_transaction_currency": flt(amount, item.precision("net_amount")),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
if item.is_fixed_asset and item.asset:
self.get_gl_entries_for_fixed_asset(item, gl_entries)
else:
self._append_item_income_gl_entry(item, gl_entries, tax_service, enable_discount_accounting)
# expense account gl entries
if cint(doc.update_stock) and erpnext.is_perpetual_inventory_enabled(doc.company):
gl_entries += super(SalesInvoice, doc).get_gl_entries()
def _append_item_income_gl_entry(self, item, gl_entries, tax_service, enable_discount_accounting) -> None:
doc = self.doc
income_account = (
item.income_account
if (not item.enable_deferred_revenue or doc.is_return)
else item.deferred_revenue_account
)
amount, base_amount = tax_service.get_amount_and_base_amount(item, enable_discount_accounting)
account_currency = get_account_currency(income_account)
gl_entries.append(
self.get_gl_dict(
{
"account": income_account,
"against": doc.customer,
"credit": flt(base_amount, item.precision("base_net_amount")),
"credit_in_account_currency": self._get_amount_in_account_currency(
account_currency,
flt(base_amount, item.precision("base_net_amount")),
flt(amount, item.precision("net_amount")),
),
"credit_in_transaction_currency": flt(amount, item.precision("net_amount")),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
def get_gl_entries_for_fixed_asset(self, item, gl_entries):
doc = self.doc
asset = frappe.get_cached_doc("Asset", item.asset)
@@ -461,10 +476,6 @@ class SalesInvoiceGLComposer(BaseGLComposer):
if skip_change_gl_entries and payment_mode.account == doc.account_for_change_amount:
payment_mode.base_amount -= flt(doc.change_amount)
against_voucher = doc.name
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
against_voucher = doc.return_against
if payment_mode.base_amount:
# POS, make payment entries
gl_entries.append(
@@ -475,11 +486,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"party": doc.customer,
"against": payment_mode.account,
"credit": payment_mode.base_amount,
"credit_in_account_currency": payment_mode.base_amount
if doc.party_account_currency == doc.company_currency
else payment_mode.amount,
"credit_in_account_currency": self._get_amount_in_account_currency(
doc.party_account_currency, payment_mode.base_amount, payment_mode.amount
),
"credit_in_transaction_currency": payment_mode.amount,
"against_voucher": against_voucher,
"against_voucher": self._resolve_against_voucher(),
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
},
@@ -495,9 +506,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"account": payment_mode.account,
"against": doc.customer,
"debit": payment_mode.base_amount,
"debit_in_account_currency": payment_mode.base_amount
if payment_mode_account_currency == doc.company_currency
else payment_mode.amount,
"debit_in_account_currency": self._get_amount_in_account_currency(
payment_mode_account_currency,
payment_mode.base_amount,
payment_mode.amount,
),
"debit_in_transaction_currency": payment_mode.amount,
"cost_center": doc.cost_center,
},
@@ -525,9 +538,9 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"party": doc.customer,
"against": doc.account_for_change_amount,
"debit": flt(doc.base_change_amount),
"debit_in_account_currency": flt(doc.base_change_amount)
if doc.party_account_currency == doc.company_currency
else flt(doc.change_amount),
"debit_in_account_currency": self._get_amount_in_account_currency(
doc.party_account_currency, flt(doc.base_change_amount), flt(doc.change_amount)
),
"debit_in_transaction_currency": flt(doc.change_amount),
"against_voucher": doc.return_against
if cint(doc.is_return) and doc.return_against
@@ -570,10 +583,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"party": doc.customer,
"against": doc.write_off_account,
"credit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
"credit_in_account_currency": (
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount"))
if doc.party_account_currency == doc.company_currency
else flt(doc.write_off_amount, doc.precision("write_off_amount"))
"credit_in_account_currency": self._get_amount_in_account_currency(
doc.party_account_currency,
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
flt(doc.write_off_amount, doc.precision("write_off_amount")),
),
"credit_in_transaction_currency": flt(
doc.write_off_amount, doc.precision("write_off_amount")
@@ -593,10 +606,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"account": doc.write_off_account,
"against": doc.customer,
"debit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
"debit_in_account_currency": (
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount"))
if write_off_account_currency == doc.company_currency
else flt(doc.write_off_amount, doc.precision("write_off_amount"))
"debit_in_account_currency": self._get_amount_in_account_currency(
write_off_account_currency,
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
flt(doc.write_off_amount, doc.precision("write_off_amount")),
),
"debit_in_transaction_currency": flt(
doc.write_off_amount, doc.precision("write_off_amount")
@@ -659,3 +672,14 @@ class SalesInvoiceGLComposer(BaseGLComposer):
item=doc,
)
)
def _get_amount_in_account_currency(self, account_currency, base_amount, transaction_amount):
"""Base amount when the account is in company currency, else the transaction amount."""
return base_amount if account_currency == self.doc.company_currency else transaction_amount
def _resolve_against_voucher(self) -> str:
"""Settle against the original invoice for returns not kept on their own outstanding."""
doc = self.doc
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
return doc.return_against
return doc.name

View File

@@ -13,36 +13,54 @@ def validate_inter_company_party(
if not party:
return
if doctype in ["Sales Invoice", "Sales Order"]:
partytype, ref_partytype, internal = "Customer", "Supplier", "is_internal_customer"
ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order"
else:
partytype, ref_partytype, internal = "Supplier", "Customer", "is_internal_supplier"
ref_doc = "Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order"
config = _get_inter_company_party_config(doctype)
if inter_company_reference:
doc = frappe.get_doc(ref_doc, inter_company_reference)
ref_party = doc.supplier if doctype in ["Sales Invoice", "Sales Order"] else doc.customer
if frappe.db.get_value(partytype, {"represents_company": doc.company}, "name") != party:
frappe.throw(_("Invalid {0} for Inter Company Transaction.").format(_(partytype)))
if frappe.get_cached_value(ref_partytype, ref_party, "represents_company") != company:
frappe.throw(_("Invalid Company for Inter Company Transaction."))
_validate_against_reference(config, party, company, inter_company_reference)
elif frappe.db.get_value(config.partytype, {"name": party, config.internal: 1}, "name") == party:
_validate_internal_party_company(config.partytype, party, company)
elif frappe.db.get_value(partytype, {"name": party, internal: 1}, "name") == party:
companies = [
d.company
for d in frappe.get_all(
"Allowed To Transact With",
fields=["company"],
filters={"parenttype": partytype, "parent": party},
)
]
if company not in companies:
frappe.throw(
_(
"{0} not allowed to transact with {1}. Please change the Company or add the Company in the 'Allowed To Transact With'-Section in the Customer record."
).format(_(partytype), company)
)
def _get_inter_company_party_config(doctype: str) -> "frappe._dict":
if doctype in ["Sales Invoice", "Sales Order"]:
return frappe._dict(
partytype="Customer",
ref_partytype="Supplier",
internal="is_internal_customer",
ref_doc="Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order",
)
return frappe._dict(
partytype="Supplier",
ref_partytype="Customer",
internal="is_internal_supplier",
ref_doc="Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order",
)
def _validate_against_reference(config, party: str, company: str, inter_company_reference: str) -> None:
doc = frappe.get_doc(config.ref_doc, inter_company_reference)
ref_party = doc.supplier if config.partytype == "Customer" else doc.customer
if frappe.db.get_value(config.partytype, {"represents_company": doc.company}, "name") != party:
frappe.throw(_("Invalid {0} for Inter Company Transaction.").format(_(config.partytype)))
if frappe.get_cached_value(config.ref_partytype, ref_party, "represents_company") != company:
frappe.throw(_("Invalid Company for Inter Company Transaction."))
def _validate_internal_party_company(partytype: str, party: str, company: str) -> None:
companies = [
d.company
for d in frappe.get_all(
"Allowed To Transact With",
fields=["company"],
filters={"parenttype": partytype, "parent": party},
)
]
if company not in companies:
frappe.throw(
_(
"{0} not allowed to transact with {1}. Please change the Company or add the Company in the 'Allowed To Transact With'-Section in the Customer record."
).format(_(partytype), company)
)
def update_linked_doc(doctype: str, name: str, inter_company_reference: str | None) -> None:

View File

@@ -4,7 +4,7 @@
"""POS helpers for Sales Invoice."""
import frappe
from frappe import _, msgprint
from frappe import _
from frappe.utils import cint, flt, get_link_to_form
@@ -13,106 +13,140 @@ class PartialPaymentValidationError(frappe.ValidationError):
class POSService:
def __init__(self, doc):
def __init__(self, doc) -> None:
self.doc = doc
def set_pos_fields(self, for_validate: bool = False) -> frappe.Document | None:
"""Populate POS-profile fields on the invoice; return the profile or None."""
def set_pos_fields(self, for_validate: bool = False) -> frappe.Document | dict | None:
"""Populate POS-profile fields on the invoice; return the profile, {} or None."""
doc = self.doc
if cint(doc.is_pos) != 1:
return None
self._set_default_change_amount_account()
if not self._ensure_pos_profile():
return None
pos = frappe.get_doc("POS Profile", doc.pos_profile) if doc.pos_profile else {}
if pos:
self._apply_pos_profile(pos, for_validate)
return pos
def _set_default_change_amount_account(self) -> None:
doc = self.doc
if not doc.account_for_change_amount:
doc.account_for_change_amount = frappe.get_cached_value(
"Company", doc.company, "default_cash_account"
)
from erpnext.stock.get_item_details import (
ItemDetailsCtx,
get_pos_profile,
get_pos_profile_item_details_,
)
def _ensure_pos_profile(self) -> bool:
"""Auto-pick a POS Profile for the company; return False if none could be found."""
doc = self.doc
if doc.pos_profile or doc.flags.ignore_pos_profile:
return True
if not doc.pos_profile and not doc.flags.ignore_pos_profile:
pos_profile = get_pos_profile(doc.company) or {}
if not pos_profile:
return None
doc.pos_profile = pos_profile.get("name")
from erpnext.stock.get_item_details import get_pos_profile
pos = {}
if doc.pos_profile:
pos = frappe.get_doc("POS Profile", doc.pos_profile)
pos_profile = get_pos_profile(doc.company) or {}
if not pos_profile:
return False
if pos:
if not for_validate:
update_multi_mode_option(doc, pos)
doc.tax_category = pos.get("tax_category")
doc.pos_profile = pos_profile.get("name")
return True
if not for_validate and not doc.customer:
doc.customer = pos.customer
def _apply_pos_profile(self, pos, for_validate: bool) -> None:
doc = self.doc
if not for_validate:
self._apply_editable_pos_defaults(pos)
if not for_validate:
doc.ignore_pricing_rule = pos.ignore_pricing_rule
if pos.get("account_for_change_amount"):
doc.account_for_change_amount = pos.get("account_for_change_amount")
if pos.get("account_for_change_amount"):
doc.account_for_change_amount = pos.get("account_for_change_amount")
self._copy_pos_profile_fields(pos, for_validate)
for fieldname in (
"currency",
"letter_head",
"tc_name",
"company",
"select_print_heading",
"write_off_account",
"taxes_and_charges",
"write_off_cost_center",
"apply_discount_on",
"cost_center",
):
if (not for_validate) or (for_validate and not doc.get(fieldname)):
doc.set(fieldname, pos.get(fieldname))
if pos.get("company_address"):
doc.company_address = pos.get("company_address")
if pos.get("company_address"):
doc.company_address = pos.get("company_address")
self._set_selling_price_list(pos)
if doc.customer:
customer_price_list, customer_group = frappe.get_value(
"Customer", doc.customer, ["default_price_list", "customer_group"]
)
customer_group_price_list = frappe.get_value(
"Customer Group", customer_group, "default_price_list"
)
selling_price_list = (
customer_price_list or customer_group_price_list or pos.get("selling_price_list")
)
else:
selling_price_list = pos.get("selling_price_list")
if not for_validate:
self._set_update_stock_from_profile(pos)
if selling_price_list:
doc.set("selling_price_list", selling_price_list)
self._apply_pos_item_defaults(pos, for_validate)
self._set_terms_and_taxes(pos)
if not for_validate:
dn_flag = any(d.get("dn_detail") for d in doc.get("items"))
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
def _apply_editable_pos_defaults(self, pos) -> None:
"""Profile defaults the user may override; only applied outside validation."""
doc = self.doc
update_multi_mode_option(doc, pos)
doc.tax_category = pos.get("tax_category")
if not doc.customer:
doc.customer = pos.customer
doc.ignore_pricing_rule = pos.ignore_pricing_rule
for item in doc.get("items"):
if item.get("item_code"):
profile_details = get_pos_profile_item_details_(
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
)
for fname, val in profile_details.items():
if (not for_validate) or (for_validate and not item.get(fname)):
item.set(fname, val)
def _copy_pos_profile_fields(self, pos, for_validate: bool) -> None:
doc = self.doc
for fieldname in (
"currency",
"letter_head",
"tc_name",
"company",
"select_print_heading",
"write_off_account",
"taxes_and_charges",
"write_off_cost_center",
"apply_discount_on",
"cost_center",
):
if (not for_validate) or (for_validate and not doc.get(fieldname)):
doc.set(fieldname, pos.get(fieldname))
if doc.tc_name and not doc.terms:
doc.terms = frappe.db.get_value("Terms and Conditions", doc.tc_name, "terms")
def _set_selling_price_list(self, pos) -> None:
doc = self.doc
if doc.customer:
customer_price_list, customer_group = frappe.get_value(
"Customer", doc.customer, ["default_price_list", "customer_group"]
)
customer_group_price_list = frappe.get_value(
"Customer Group", customer_group, "default_price_list"
)
selling_price_list = (
customer_price_list or customer_group_price_list or pos.get("selling_price_list")
)
else:
selling_price_list = pos.get("selling_price_list")
if doc.taxes_and_charges and not len(doc.get("taxes")):
from erpnext.accounts.services.taxes import TaxService
if selling_price_list:
doc.set("selling_price_list", selling_price_list)
TaxService(doc).set_taxes()
def _set_update_stock_from_profile(self, pos) -> None:
doc = self.doc
dn_flag = any(d.get("dn_detail") for d in doc.get("items"))
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
return pos
def _apply_pos_item_defaults(self, pos, for_validate: bool) -> None:
from erpnext.stock.get_item_details import ItemDetailsCtx, get_pos_profile_item_details_
for item in self.doc.get("items"):
if not item.get("item_code"):
continue
profile_details = get_pos_profile_item_details_(
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
)
for fname, val in profile_details.items():
if (not for_validate) or (for_validate and not item.get(fname)):
item.set(fname, val)
def _set_terms_and_taxes(self, pos) -> None:
doc = self.doc
if doc.tc_name and not doc.terms:
doc.terms = frappe.db.get_value("Terms and Conditions", doc.tc_name, "terms")
if doc.taxes_and_charges and not len(doc.get("taxes")):
from erpnext.accounts.services.taxes import TaxService
TaxService(doc).set_taxes()
def update_paid_amount(self) -> None:
doc = self.doc
@@ -144,6 +178,7 @@ class POSService:
doc.paid_amount = 0
def validate_pos_return(self) -> None:
"""Ensure POS return payments are not less than the (negative) invoice total."""
doc = self.doc
if doc.is_consolidated:
return
@@ -160,6 +195,7 @@ class POSService:
frappe.throw(_("At least one mode of payment is required for POS invoice."))
def validate_pos(self) -> None:
"""On a POS return, paid amount plus write-off cannot exceed the grand total."""
doc = self.doc
if doc.is_return:
invoice_total = doc.rounded_total or doc.grand_total
@@ -180,6 +216,7 @@ class POSService:
self.validate_pos_opening_entry()
def validate_full_payment(self) -> None:
"""Block partial payment on a submitted POS invoice unless the profile allows it."""
doc = self.doc
allow_partial_payment = frappe.db.get_value("POS Profile", doc.pos_profile, "allow_partial_payment")
invoice_total = flt(doc.rounded_total) or flt(doc.grand_total)
@@ -196,6 +233,7 @@ class POSService:
)
def validate_pos_opening_entry(self) -> None:
"""Require exactly one current, open POS Opening Entry for the profile."""
doc = self.doc
opening_entries = frappe.get_all(
"POS Opening Entry",
@@ -281,38 +319,6 @@ class POSService:
if entry.amount > 0:
frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx))
def get_warehouse(self) -> str | None:
doc = self.doc
POSProfile = frappe.qb.DocType("POS Profile")
user_query = (
frappe.qb.from_(POSProfile)
.select(POSProfile.name, POSProfile.warehouse)
.where(POSProfile.company == doc.company)
.where(
(POSProfile.user == frappe.session["user"])
| ((POSProfile.user.isnull() | (POSProfile.user == "")) & (frappe.session["user"] == ""))
)
)
user_pos_profile = user_query.run()
warehouse = user_pos_profile[0][1] if user_pos_profile else None
if not warehouse:
global_query = (
frappe.qb.from_(POSProfile)
.select(POSProfile.name, POSProfile.warehouse)
.where(POSProfile.company == doc.company)
.where(POSProfile.user.isnull() | (POSProfile.user == ""))
)
global_pos_profile = global_query.run()
if global_pos_profile:
warehouse = global_pos_profile[0][1]
elif not user_pos_profile:
msgprint(_("POS Profile required to make POS Entry"), raise_exception=True)
return warehouse
def get_bank_cash_account(mode_of_payment: str, company: str) -> dict:
account = frappe.db.get_value(
@@ -369,61 +375,43 @@ def update_multi_mode_option(doc, pos_profile) -> None:
def get_all_mode_of_payments(doc) -> list:
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
query = (
frappe.qb.from_(ModeOfPaymentAccount)
.join(ModeOfPayment)
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
.select(
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
)
.where(ModeOfPaymentAccount.company == doc.company)
.where(ModeOfPayment.enabled == 1)
)
return query.run(as_dict=1)
"""All enabled modes of payment with their default accounts for the doc's company."""
query, mopa, mop = _enabled_mode_of_payment_query(doc.company)
return query.select(mopa.default_account, mopa.parent, mop.type.as_("type")).run(as_dict=1)
def get_mode_of_payments_info(mode_of_payments: list, company: str) -> dict:
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
query = (
frappe.qb.from_(ModeOfPaymentAccount)
.join(ModeOfPayment)
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
.select(
ModeOfPaymentAccount.default_account,
ModeOfPaymentAccount.parent.as_("mop"),
ModeOfPayment.type.as_("type"),
)
.where(ModeOfPaymentAccount.company == company)
.where(ModeOfPayment.enabled == 1)
.where(ModeOfPayment.name.isin(mode_of_payments))
.groupby(ModeOfPayment.name)
"""Map each of the named modes of payment to its account info for the company."""
query, mopa, mop = _enabled_mode_of_payment_query(company)
data = (
query.select(mopa.default_account, mopa.parent.as_("mop"), mop.type.as_("type"))
.where(mop.name.isin(mode_of_payments))
# group by all selected columns so postgres accepts it (one row per mode of payment)
.groupby(mopa.default_account, mopa.parent, mop.type)
.run(as_dict=1)
)
data = query.run(as_dict=1)
return {row.get("mop"): row for row in data}
def get_mode_of_payment_info(mode_of_payment: str, company: str) -> list:
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
query = (
frappe.qb.from_(ModeOfPayment)
.join(ModeOfPaymentAccount)
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
.select(
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
)
.where(ModeOfPaymentAccount.company == company)
.where(ModeOfPayment.enabled == 1)
.where(ModeOfPayment.name == mode_of_payment)
"""Account info for a single mode of payment in the company."""
query, mopa, mop = _enabled_mode_of_payment_query(company)
return (
query.select(mopa.default_account, mopa.parent, mop.type.as_("type"))
.where(mop.name == mode_of_payment)
.run(as_dict=1)
)
return query.run(as_dict=1)
def _enabled_mode_of_payment_query(company: str):
"""Base query joining enabled modes of payment to their accounts for a company."""
mopa = frappe.qb.DocType("Mode of Payment Account")
mop = frappe.qb.DocType("Mode of Payment")
query = (
frappe.qb.from_(mopa)
.join(mop)
.on(mopa.parent == mop.name)
.where(mopa.company == company)
.where(mop.enabled == 1)
)
return query, mopa, mop

View File

@@ -21,45 +21,52 @@ class StatusService:
doc.status = "Draft"
return
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
total = get_total_in_party_account_currency(doc)
if not status:
if doc.docstatus == 2:
status = "Cancelled"
elif doc.docstatus == 1:
if doc.is_internal_transfer():
doc.status = "Internal Transfer"
elif is_overdue(doc, total):
doc.status = "Overdue"
elif 0 < outstanding_amount < total:
doc.status = "Partly Paid"
elif outstanding_amount > 0 and getdate(doc.due_date) >= getdate():
doc.status = "Unpaid"
elif doc.is_return == 0 and frappe.db.get_value(
"Sales Invoice", {"is_return": 1, "return_against": doc.name, "docstatus": 1}
):
doc.status = "Credit Note Issued"
elif doc.is_return == 1:
doc.status = "Return"
elif outstanding_amount <= 0:
doc.status = "Paid"
else:
doc.status = "Submitted"
if (
doc.status in ("Unpaid", "Partly Paid", "Overdue")
and doc.is_discounted
and get_discounting_status(doc.name) == "Disbursed"
):
doc.status += " and Discounted"
doc.status = self._get_submitted_status()
else:
doc.status = "Draft"
if update:
doc.db_set("status", doc.status, update_modified=update_modified)
def _get_submitted_status(self) -> str:
"""Status of a submitted invoice, with the invoice-discounting suffix applied."""
doc = self.doc
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
total = get_total_in_party_account_currency(doc)
status = self._get_payment_status(outstanding_amount, total)
if (
status in ("Unpaid", "Partly Paid", "Overdue")
and doc.is_discounted
and get_discounting_status(doc.name) == "Disbursed"
):
status += " and Discounted"
return status
def _get_payment_status(self, outstanding_amount: float, total: float) -> str:
doc = self.doc
if doc.is_internal_transfer():
return "Internal Transfer"
if is_overdue(doc, total):
return "Overdue"
if 0 < outstanding_amount < total:
return "Partly Paid"
if outstanding_amount > 0 and getdate(doc.due_date) >= getdate():
return "Unpaid"
if doc.is_return == 0 and frappe.db.get_value(
"Sales Invoice", {"is_return": 1, "return_against": doc.name, "docstatus": 1}
):
return "Credit Note Issued"
if doc.is_return == 1:
return "Return"
if outstanding_amount <= 0:
return "Paid"
return "Submitted"
def set_indicator(self) -> None:
doc = self.doc
if doc.outstanding_amount < 0:

View File

@@ -99,23 +99,24 @@ class TimesheetBillingService:
doc.total_billing_hours = sum(flt(ts.billing_hours) for ts in doc.timesheets)
def _update_time_sheet_detail(self, timesheet, args, sales_invoice: str | None) -> None:
doc = self.doc
for data in timesheet.time_logs:
if (
(doc.project and args.timesheet_detail == data.name)
or (not doc.project and not data.sales_invoice and args.timesheet_detail == data.name)
or (
not sales_invoice
and data.sales_invoice == doc.name
and args.timesheet_detail == data.name
)
or (
doc.is_return
and doc.return_against
and data.sales_invoice
and data.sales_invoice == doc.return_against
and not sales_invoice
and args.timesheet_detail == data.name
)
):
if args.timesheet_detail == data.name and self._should_set_sales_invoice(data, sales_invoice):
data.sales_invoice = sales_invoice
def _should_set_sales_invoice(self, time_log, sales_invoice: str | None) -> bool:
"""Whether this time log's sales-invoice link should be (re)set to sales_invoice."""
doc = self.doc
if doc.project:
return True
if not time_log.sales_invoice:
return True
if not sales_invoice and time_log.sales_invoice == doc.name:
# clearing the link on cancellation of this invoice
return True
# clearing the link on a return raised against the original invoice
return bool(
doc.is_return
and doc.return_against
and not sales_invoice
and time_log.sales_invoice == doc.return_against
)

View File

@@ -20,6 +20,12 @@ from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
unlink_payment_on_cancel_of_invoice,
)
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction
from erpnext.accounts.doctype.sales_invoice.services.pos import (
POSService,
get_all_mode_of_payments,
get_mode_of_payment_info,
get_mode_of_payments_info,
)
from erpnext.accounts.utils import PaymentEntryUnlinkError
from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
from erpnext.assets.doctype.asset.test_asset import create_asset
@@ -739,12 +745,11 @@ class TestSalesInvoice(ERPNextTestSuite):
si.insert()
si.submit()
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
si.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc",
)
self.assertTrue(gl_entries)
@@ -775,10 +780,10 @@ class TestSalesInvoice(ERPNextTestSuite):
# cancel
si.cancel()
gle = frappe.db.sql(
"""select * from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
si.name,
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["*"],
)
self.assertTrue(gle)
@@ -1195,12 +1200,11 @@ class TestSalesInvoice(ERPNextTestSuite):
si.insert()
si.submit()
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
si.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc",
)
self.assertTrue(gl_entries)
@@ -1223,10 +1227,10 @@ class TestSalesInvoice(ERPNextTestSuite):
# cancel
si.cancel()
gle = frappe.db.sql(
"""select * from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
si.name,
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["*"],
)
self.assertTrue(gle)
@@ -1346,6 +1350,101 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(pos.grand_total, 100.0)
self.assertEqual(pos.write_off_amount, 0)
def test_set_pos_fields_populates_invoice_from_profile(self):
terms = frappe.db.exists("Terms and Conditions", "_Test POS Terms")
if not terms:
terms = (
frappe.get_doc(
{
"doctype": "Terms and Conditions",
"title": "_Test POS Terms",
"terms": "POS terms and conditions",
"selling": 1,
}
)
.insert()
.name
)
profile = make_pos_profile()
profile.customer = "_Test Customer"
profile.tax_category = "_Test Tax Category 1"
profile.account_for_change_amount = "Cash - _TC"
profile.ignore_pricing_rule = 1
profile.update_stock = 1
profile.apply_discount_on = "Grand Total"
profile.tc_name = terms
profile.taxes_and_charges = "_Test Sales Taxes and Charges Template - _TC"
profile.save()
si = create_sales_invoice(do_not_save=True)
si.is_pos = 1
si.pos_profile = profile.name
si.customer = None
si.taxes = []
POSService(si).set_pos_fields(for_validate=False)
self.assertEqual(si.customer, "_Test Customer")
self.assertEqual(si.tax_category, "_Test Tax Category 1")
self.assertEqual(si.ignore_pricing_rule, 1)
self.assertEqual(si.account_for_change_amount, "Cash - _TC")
self.assertEqual(si.taxes_and_charges, "_Test Sales Taxes and Charges Template - _TC")
self.assertEqual(si.apply_discount_on, "Grand Total")
self.assertEqual(si.update_stock, 1)
self.assertEqual(si.terms, "POS terms and conditions")
self.assertTrue(si.get("payments"))
self.assertTrue(si.get("taxes"))
def test_set_pos_fields_for_validate_preserves_existing_values(self):
profile = make_pos_profile()
profile.tax_category = "_Test Tax Category 1"
profile.save()
si = create_sales_invoice(do_not_save=True)
si.is_pos = 1
si.pos_profile = profile.name
si.apply_discount_on = "Net Total"
existing_customer = si.customer
POSService(si).set_pos_fields(for_validate=True)
# for_validate must not overwrite a field the user already set
self.assertEqual(si.apply_discount_on, "Net Total")
# for_validate skips mode-of-payment fetch and profile-driven customer/tax_category
self.assertFalse(si.get("payments"))
self.assertEqual(si.customer, existing_customer)
self.assertFalse(si.tax_category)
def test_set_pos_fields_uses_profile_price_list_without_customer(self):
profile = make_pos_profile(selling_price_list="_Test Price List")
profile.customer = None
profile.save()
si = create_sales_invoice(do_not_save=True)
si.is_pos = 1
si.pos_profile = profile.name
si.customer = None
POSService(si).set_pos_fields(for_validate=False)
self.assertEqual(si.selling_price_list, "_Test Price List")
def test_pos_service_mode_of_payment_queries(self):
make_pos_profile() # ensures a Cash mode-of-payment account for _Test Company
si = create_sales_invoice(do_not_save=True)
single = get_mode_of_payment_info("Cash", "_Test Company")
self.assertTrue(single)
self.assertEqual(single[0].parent, "Cash")
all_modes = get_all_mode_of_payments(si)
self.assertTrue(any(row.parent == "Cash" for row in all_modes))
grouped = get_mode_of_payments_info(["Cash"], "_Test Company")
self.assertIn("Cash", grouped)
self.assertEqual(grouped["Cash"].mop, "Cash")
def test_auto_write_off_amount(self):
make_pos_profile(
company="_Test Company with perpetual inventory",
@@ -1476,16 +1575,84 @@ class TestSalesInvoice(ERPNextTestSuite):
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
def test_stock_delivered_but_not_billed_gl_on_invoice(self):
company = "_Test Company with perpetual inventory"
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
make_purchase_receipt(
company=company,
item_code="_Test FG Item",
warehouse="Stores - TCP1",
cost_center="Main - TCP1",
qty=5,
rate=100,
)
dn = create_delivery_note(
company=company,
item_code="_Test FG Item",
warehouse="Stores - TCP1",
cost_center="Main - TCP1",
qty=2,
rate=300,
)
# A perpetual-inventory Delivery Note books the cost to the SDBNB account
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - TCP1")
si = make_sales_invoice(dn.name)
si.insert()
si.submit()
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_no": si.name, "is_cancelled": 0},
fields=["account", "debit", "credit"],
)
sdbnb_credit = sum(
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - TCP1"
)
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - TCP1")
# Billing reverses SDBNB and recognises the cost in COGS for an equal amount
self.assertTrue(sdbnb_credit > 0)
self.assertEqual(sdbnb_credit, cogs_debit)
def test_get_gle_for_change_amount(self):
from erpnext.accounts.doctype.sales_invoice.services.gl_composer import SalesInvoiceGLComposer
si = create_sales_invoice(do_not_save=True)
si.is_pos = 1
si.party_account_currency = "INR"
# no change amount -> no entries
si.change_amount = 0
self.assertEqual(SalesInvoiceGLComposer(si).get_gle_for_change_amount(), [])
# change amount without an account -> mandatory error
si.change_amount = 10
si.base_change_amount = 10
si.account_for_change_amount = None
self.assertRaises(frappe.ValidationError, SalesInvoiceGLComposer(si).get_gle_for_change_amount)
# change amount with an account -> debit-to debited, change account credited
si.account_for_change_amount = "Cash - _TC"
entries = SalesInvoiceGLComposer(si).get_gle_for_change_amount()
self.assertEqual(len(entries), 2)
debit_entry = next(entry for entry in entries if entry["account"] == si.debit_to)
credit_entry = next(entry for entry in entries if entry["account"] == "Cash - _TC")
self.assertEqual(debit_entry["party"], si.customer)
self.assertEqual(flt(debit_entry["debit"]), 10.0)
self.assertEqual(flt(credit_entry["credit"]), 10.0)
def validate_pos_gl_entry(self, si, pos, cash_amount, validate_without_change_gle=False):
if validate_without_change_gle:
cash_amount -= pos.change_amount
# check stock ledger entries
sle = frappe.db.sql(
"""select * from `tabStock Ledger Entry`
where voucher_type = 'Sales Invoice' and voucher_no = %s""",
si.name,
as_dict=1,
sle = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["*"],
)[0]
self.assertTrue(sle)
self.assertEqual(
@@ -1493,12 +1660,11 @@ class TestSalesInvoice(ERPNextTestSuite):
)
# check gl entries
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc, debit asc, credit asc""",
si.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc, debit asc, credit asc",
)
self.assertTrue(gl_entries)
@@ -1525,15 +1691,15 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(expected_gl_entries[i][2], gle.credit)
si.cancel()
gle = frappe.db.sql(
"""select * from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
si.name,
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["*"],
)
self.assertTrue(gle)
frappe.db.sql("delete from `tabPOS Profile`")
frappe.db.delete("POS Profile")
def test_bin_details_of_packed_item(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
@@ -1600,12 +1766,11 @@ class TestSalesInvoice(ERPNextTestSuite):
si.insert()
si.submit()
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
si.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc",
)
self.assertTrue(gl_entries)
@@ -1620,12 +1785,11 @@ class TestSalesInvoice(ERPNextTestSuite):
def test_sales_invoice_gl_entry_with_perpetual_inventory_non_stock_item(self):
si = create_sales_invoice(item="_Test Non Stock Item")
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
si.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc",
)
self.assertTrue(gl_entries)
@@ -1679,18 +1843,18 @@ class TestSalesInvoice(ERPNextTestSuite):
si.load_from_db()
self.assertTrue(
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_name=%s""",
si.name,
frappe.get_all(
"Journal Entry Account",
filters={"reference_name": si.name},
pluck="name",
)
)
self.assertTrue(
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_name=%s and credit_in_account_currency=300""",
si.name,
frappe.get_all(
"Journal Entry Account",
filters={"reference_name": si.name, "credit_in_account_currency": 300},
pluck="name",
)
)
@@ -2002,13 +2166,18 @@ class TestSalesInvoice(ERPNextTestSuite):
conversion_rate=50,
)
gl_entries = frappe.db.sql(
"""select account, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
si.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=[
"account",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
)
self.assertTrue(gl_entries)
@@ -2043,10 +2212,10 @@ class TestSalesInvoice(ERPNextTestSuite):
# cancel
si.cancel()
gle = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
si.name,
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
pluck="name",
)
self.assertTrue(gle)
@@ -2073,14 +2242,16 @@ class TestSalesInvoice(ERPNextTestSuite):
)
si.submit()
gl_entries = frappe.db.sql(
"""select transaction_currency, transaction_exchange_rate,
debit_in_transaction_currency, credit_in_transaction_currency
from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s and account = 'Sales - _TC'
order by account asc""",
si.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name, "account": "Sales - _TC"},
fields=[
"transaction_currency",
"transaction_exchange_rate",
"debit_in_transaction_currency",
"credit_in_transaction_currency",
],
order_by="account asc",
)
expected_gle = {
@@ -2425,12 +2596,11 @@ class TestSalesInvoice(ERPNextTestSuite):
]
)
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
si.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc",
)
for gle in gl_entries:
@@ -2482,13 +2652,12 @@ class TestSalesInvoice(ERPNextTestSuite):
"Sales - _TC": [0.0, 1272.20],
}
gl_entries = frappe.db.sql(
"""select account, sum(debit) as debit, sum(credit) as credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
group by account
order by account asc""",
si.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", {"SUM": "debit", "as": "debit"}, {"SUM": "credit", "as": "credit"}],
group_by="account",
order_by="account asc",
)
for gle in gl_entries:
@@ -2549,13 +2718,12 @@ class TestSalesInvoice(ERPNextTestSuite):
]
)
gl_entries = frappe.db.sql(
"""select account, sum(debit) as debit, sum(credit) as credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
group by account
order by account asc""",
si.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", {"SUM": "debit", "as": "debit"}, {"SUM": "credit", "as": "credit"}],
group_by="account",
order_by="account asc",
)
debit_credit_diff = 0
@@ -2565,7 +2733,9 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(expected_values[gle.account][2], gle.credit)
debit_credit_diff += gle.debit - gle.credit
self.assertEqual(debit_credit_diff, 0)
# Postgres returns DECIMAL columns as float (DEC2FLOAT), so a debit-credit sum carries a
# tiny FP residue where MariaDB's DECIMAL arithmetic is exact; assert it's ~0.
self.assertAlmostEqual(debit_credit_diff, 0)
round_off_gle = frappe.db.get_value(
"GL Entry",
@@ -2649,13 +2819,19 @@ class TestSalesInvoice(ERPNextTestSuite):
"Sales - _TC": {"cost_center": cost_center},
}
gl_entries = frappe.db.sql(
"""select account, cost_center, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
si.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=[
"account",
"cost_center",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
)
self.assertTrue(gl_entries)
@@ -2692,13 +2868,20 @@ class TestSalesInvoice(ERPNextTestSuite):
"Sales - _TC": {"project": item_project.name},
}
gl_entries = frappe.db.sql(
"""select account, cost_center, project, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
sales_invoice.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": sales_invoice.name},
fields=[
"account",
"cost_center",
"project",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
)
self.assertTrue(gl_entries)
@@ -2715,13 +2898,19 @@ class TestSalesInvoice(ERPNextTestSuite):
"Sales - _TC": {"cost_center": cost_center},
}
gl_entries = frappe.db.sql(
"""select account, cost_center, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
si.name,
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=[
"account",
"cost_center",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
)
self.assertTrue(gl_entries)
@@ -3497,6 +3686,49 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(expected_values[i][2], schedule.accumulated_depreciation_amount)
self.assertTrue(schedule.journal_entry)
def test_fixed_asset_sale_validations(self):
from erpnext.accounts.doctype.sales_invoice.services.fixed_assets import FixedAssetService
asset = create_asset(item_code="Macbook Pro", calculate_depreciation=0, submit=1)
def asset_invoice(asset_name, **kwargs):
si = create_sales_invoice(
item_code="Macbook Pro", asset=asset_name, qty=1, rate=90000, do_not_save=True, **kwargs
)
si.items[0].is_fixed_asset = 1
return si
with self.subTest("item without an asset is rejected"):
si = asset_invoice(None)
self.assertRaises(frappe.ValidationError, FixedAssetService(si).validate_fixed_asset)
with self.subTest("update stock on an asset sale is rejected"):
si = asset_invoice(asset.name, update_stock=1)
self.assertRaises(frappe.ValidationError, FixedAssetService(si).validate_fixed_asset)
with self.subTest("return without return-against is rejected"):
si = asset_invoice(asset.name, is_return=1)
self.assertRaises(frappe.ValidationError, FixedAssetService(si).validate_fixed_asset)
for bad_status in ("Sold", "Scrapped", "Cancelled", "Capitalized"):
with self.subTest(f"selling a {bad_status} asset is rejected"):
frappe.db.set_value("Asset", asset.name, "status", bad_status)
si = asset_invoice(asset.name)
self.assertRaises(frappe.ValidationError, FixedAssetService(si).validate_fixed_asset)
frappe.db.set_value("Asset", asset.name, "status", "Submitted")
def test_fixed_asset_restore_note_text(self):
from erpnext.accounts.doctype.sales_invoice.services.fixed_assets import FixedAssetService
asset = frappe._dict(doctype="Asset", name="_Test Asset For Note")
si = create_sales_invoice(do_not_save=True)
si.is_return = 1
self.assertIn("returned", FixedAssetService(si)._get_note_for_asset_return(asset))
si.is_return = 0
self.assertIn("restored", FixedAssetService(si)._get_note_for_asset_return(asset))
def test_sales_invoice_against_supplier(self):
from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
make_customer,
@@ -3710,6 +3942,27 @@ class TestSalesInvoice(ERPNextTestSuite):
party_link.delete()
def test_status_indicator(self):
from erpnext.accounts.doctype.sales_invoice.services.status import StatusService
si = create_sales_invoice(do_not_save=True)
cases = [
# outstanding, due_date, is_return -> indicator color, title
(-50, nowdate(), 0, "gray", "Credit Note Issued"),
(100, add_days(nowdate(), 5), 0, "orange", "Unpaid"),
(100, add_days(nowdate(), -5), 0, "red", "Overdue"),
(0, nowdate(), 1, "gray", "Return"),
(0, nowdate(), 0, "green", "Paid"),
]
for outstanding, due_date, is_return, color, title in cases:
with self.subTest(title=title):
si.outstanding_amount = outstanding
si.due_date = due_date
si.is_return = is_return
StatusService(si).set_indicator()
self.assertEqual(si.indicator_color, color)
self.assertEqual(si.indicator_title, title)
def test_payment_statuses(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
@@ -3935,13 +4188,15 @@ class TestSalesInvoice(ERPNextTestSuite):
[deferred_account, 2022.47, 0.0, "2019-03-15"],
]
gl_entries = frappe.db.sql(
"""select account, debit, credit, posting_date
from `tabGL Entry`
where voucher_type='Journal Entry' and voucher_detail_no=%s and posting_date <= %s
order by posting_date asc, account asc""",
(si.items[0].name, si.posting_date),
as_dict=1,
gl_entries = frappe.get_all(
"GL Entry",
filters={
"voucher_type": "Journal Entry",
"voucher_detail_no": si.items[0].name,
"posting_date": ["<=", si.posting_date],
},
fields=["account", "debit", "credit", "posting_date"],
order_by="posting_date asc, account asc",
)
for i, gle in enumerate(gl_entries):
@@ -4583,7 +4838,8 @@ class TestSalesInvoice(ERPNextTestSuite):
{"account": "Temporary Opening - _TC", "debit": 0.0, "credit": 138.09, "is_opening": "Yes"},
]
self.assertEqual(len(actual), 4)
self.assertEqual(expected, actual)
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
self.assertCountEqual(actual, expected)
@ERPNextTestSuite.change_settings("Accounts Settings", {"enable_common_party_accounting": True})
def test_common_party_with_foreign_currency_jv(self):
@@ -4888,7 +5144,7 @@ class TestSalesInvoice(ERPNextTestSuite):
def test_pos_sales_invoice_creation_during_pos_invoice_mode(self):
# Deleting all opening entry
frappe.db.sql("delete from `tabPOS Opening Entry`")
frappe.db.delete("POS Opening Entry")
with self.change_settings("POS Settings", {"invoice_type": "POS Invoice"}):
pos_profile = make_pos_profile()
@@ -5258,6 +5514,11 @@ def check_gl_entries(doc, voucher_no, expected_gle, posting_date, voucher_type="
doc.assertGreater(len(gl_entries), 0)
# MariaDB and Postgres collate `account` differently, so the DB ordering isn't portable;
# sort both sides identically (by the compared values) before the positional check.
gl_entries = sorted(gl_entries, key=lambda g: (g.account, g.debit, g.credit))
expected_gle = sorted(expected_gle, key=lambda e: (e[0], e[1], e[2]))
for i, gle in enumerate(gl_entries):
doc.assertEqual(expected_gle[i][0], gle.account)
doc.assertEqual(expected_gle[i][1], gle.debit)
@@ -5405,17 +5666,21 @@ def create_sales_invoice_against_cost_center(**args):
def get_outstanding_amount(against_voucher_type, against_voucher, account, party, party_type):
bal = flt(
frappe.db.sql(
"""
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabGL Entry`
where against_voucher_type=%s and against_voucher=%s
and account = %s and party = %s and party_type = %s""",
(against_voucher_type, against_voucher, account, party, party_type),
)[0][0]
or 0.0
balance = frappe.get_all(
"GL Entry",
filters={
"against_voucher_type": against_voucher_type,
"against_voucher": against_voucher,
"account": account,
"party": party,
"party_type": party_type,
},
fields=[
{"SUM": "debit_in_account_currency", "as": "debit"},
{"SUM": "credit_in_account_currency", "as": "credit"},
],
)
bal = flt(balance[0].debit) - flt(balance[0].credit)
if against_voucher_type == "Purchase Invoice":
bal = bal * -1

View File

@@ -56,11 +56,14 @@ def valdiate_taxes_and_charges_template(doc):
# doc.is_default = 1
if doc.is_default == 1:
frappe.db.sql(
f"""update `tab{doc.doctype}` set is_default = 0
where is_default = 1 and name != %s and company = %s""",
(doc.name, doc.company),
)
template = frappe.qb.DocType(doc.doctype)
(
frappe.qb.update(template)
.set(template.is_default, 0)
.where(
(template.is_default == 1) & (template.name != doc.name) & (template.company == doc.company)
)
).run()
validate_disabled(doc)

View File

@@ -424,7 +424,7 @@ class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
self.disable_advance_as_liability()
def test_07_adv_from_so_to_invoice(self):
frappe.db.set_value("Company", self.company, "book_advance_payments_in_separate_party_account", True)
frappe.db.set_value("Company", self.company, "book_advance_payments_in_separate_party_account", 1)
frappe.db.set_value(
"Company", self.company, "default_advance_received_account", "Advance Received - _TC"
)

View File

@@ -672,7 +672,7 @@ def make_reverse_gl_entries(
)
if not immutable_ledger_enabled:
query = query.set(gle.is_cancelled, True)
query = query.set(gle.is_cancelled, 1) # smallint column; postgres rejects boolean true
query.run()
else:
@@ -683,12 +683,14 @@ def make_reverse_gl_entries(
if not all(gle_names):
set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"])
else:
frappe.db.sql(
"""UPDATE `tabGL Entry` SET is_cancelled = 1,
modified=%s, modified_by=%s
where name in %s and is_cancelled = 0""",
(now(), frappe.session.user, tuple(gle_names)),
)
gle = frappe.qb.DocType("GL Entry")
(
frappe.qb.update(gle)
.set(gle.is_cancelled, 1)
.set(gle.modified, now())
.set(gle.modified_by, frappe.session.user)
.where(gle.name.isin(gle_names) & (gle.is_cancelled == 0))
).run()
for entry in gl_entries:
new_gle = copy.deepcopy(entry)
@@ -725,9 +727,11 @@ def set_as_cancel(voucher_type, voucher_no):
"""
Set is_cancelled=1 in all original gl entries for the voucher
"""
frappe.db.sql(
"""UPDATE `tabGL Entry` SET is_cancelled = 1,
modified=%s, modified_by=%s
where voucher_type=%s and voucher_no=%s and is_cancelled = 0""",
(now(), frappe.session.user, voucher_type, voucher_no),
)
gle = frappe.qb.DocType("GL Entry")
(
frappe.qb.update(gle)
.set(gle.is_cancelled, 1)
.set(gle.modified, now())
.set(gle.modified_by, frappe.session.user)
.where((gle.voucher_type == voucher_type) & (gle.voucher_no == voucher_no) & (gle.is_cancelled == 0))
).run()

View File

@@ -900,16 +900,13 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
d.company, {"grand_total": d.grand_total, "base_grand_total": d.base_grand_total}
)
gle = frappe.qb.DocType("GL Entry")
company_wise_total_unpaid = frappe._dict(
frappe.db.sql(
"""
select company, sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabGL Entry`
where party_type = %s and party=%s
and is_cancelled = 0
group by company""",
(party_type, party),
)
frappe.qb.from_(gle)
.select(gle.company, Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
.where((gle.party_type == party_type) & (gle.party == party) & (gle.is_cancelled == 0))
.groupby(gle.company)
.run()
)
for d in companies:

View File

@@ -7,7 +7,7 @@ from collections import OrderedDict
import frappe
from frappe import _, qb, query_builder, scrub
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Date, Max, Substring, Sum
from frappe.query_builder.functions import Date, Substring, Sum
from frappe.utils import cint, cstr, flt, getdate, nowdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
@@ -691,13 +691,11 @@ class ReceivablePayableReport:
.inner_join(jea)
.on(jea.parent == je.name)
.select(
# Sum() below makes this an implicit aggregate (no GROUP BY); the non-aggregated columns
# are arbitrary per the single group on MySQL -> Max() keeps it valid on postgres.
Max(jea.reference_name).as_("invoice_no"),
Max(jea.party).as_("party"),
Max(jea.party_type).as_("party_type"),
Max(je.posting_date).as_("future_date"),
Max(je.cheque_no).as_("future_ref"),
jea.reference_name.as_("invoice_no"),
jea.party,
jea.party_type,
je.posting_date.as_("future_date"),
je.cheque_no.as_("future_ref"),
)
.where(
(je.docstatus < 2)
@@ -727,6 +725,14 @@ class ReceivablePayableReport:
future_amount.as_("future_amount"),
future_amount_in_base_currency.as_("future_amount_in_base_currency"),
)
# One row per (future-payment JE, invoice, party): group by the JE name (primary key, so the
# JE-level posting_date/cheque_no are deterministic) plus the per-reference dimensions, summing
# amounts across JE Account rows that hit the same invoice. Without this GROUP BY the implicit
# single-group aggregate collapsed every future JE payment into one row keyed by an arbitrary
# invoice, mis-allocating the whole sum.
query = query.groupby(
je.name, jea.reference_name, jea.party, jea.party_type, je.posting_date, je.cheque_no
)
# use the aggregate expression in HAVING; postgres can't reference a SELECT alias there
query = query.having(future_amount > 0)
return query.run(as_dict=True)

View File

@@ -699,6 +699,61 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
[row.invoiced, row.paid, row.outstanding, row.remaining_balance, row.future_amount],
)
def test_future_payments_from_journal_entry(self):
# A single future-dated Journal Entry paying two different invoices must surface as one
# future-payment row PER invoice, not collapse the whole sum onto one arbitrary invoice
# (regression: the implicit single-group aggregate filed all future JE payments under one key).
si_a = self.create_sales_invoice(no_payment_schedule=True)
si_b = self.create_sales_invoice(no_payment_schedule=True)
je = frappe.get_doc(
{
"doctype": "Journal Entry",
"voucher_type": "Journal Entry",
"company": self.company,
"posting_date": add_days(today(), 1),
"accounts": [
{
"account": self.debit_to,
"party_type": "Customer",
"party": self.customer,
"reference_type": "Sales Invoice",
"reference_name": si_a.name,
"credit_in_account_currency": 50,
"credit": 50,
},
{
"account": self.debit_to,
"party_type": "Customer",
"party": self.customer,
"reference_type": "Sales Invoice",
"reference_name": si_b.name,
"credit_in_account_currency": 50,
"credit": 50,
},
{"account": self.cash, "debit_in_account_currency": 100, "debit": 100},
],
}
)
je.insert().submit()
filters = {
"company": self.company,
"report_date": today(),
"range": "30, 60, 90, 120",
"show_future_payments": True,
}
report = execute(filters)[1]
rows_a = [row for row in report if row.voucher_no == si_a.name]
rows_b = [row for row in report if row.voucher_no == si_b.name]
# exactly one report row per invoice, each keeping its own future payment; the bug collapsed
# both into a single row and allocated the whole 100 to one arbitrary invoice
self.assertEqual(len(rows_a), 1)
self.assertEqual(len(rows_b), 1)
self.assertEqual(rows_a[0].future_amount, 50.0)
self.assertEqual(rows_b[0].future_amount, 50.0)
def test_sales_person(self):
sales_person = frappe.get_doc(
{"doctype": "Sales Person", "sales_person_name": "John Clark", "enabled": True}

View File

@@ -84,7 +84,13 @@ def build_budget_map(budget_records, filters):
budget_distributions = get_budget_distributions(budget)
for row in budget_distributions:
if not row.start_date or not row.end_date:
continue
months = get_months_in_range(row.start_date, row.end_date)
if not months:
continue
monthly_budget = flt(row.amount) / len(months)
for month_date in months:

View File

@@ -164,7 +164,8 @@ def get_gl_entries(filters, accounting_dimensions):
if filters.get("show_remarks"):
if remarks_length := frappe.get_single_value("Accounts Settings", "general_ledger_remarks_length"):
select_fields += f",substr(remarks, 1, {remarks_length}) as 'remarks'"
# bare alias, not 'remarks' — Postgres treats a single-quoted alias as a string literal
select_fields += f",substr(remarks, 1, {remarks_length}) as remarks"
else:
select_fields += """,remarks"""

View File

@@ -15,6 +15,42 @@ class TestGeneralLedger(ERPNextTestSuite):
def setUp(self):
self.company = "_Test Company"
def test_gl_report_runs_with_remarks_length(self):
# general_ledger_remarks_length adds `substr(remarks, 1, n) as remarks` to the raw SQL; the
# alias must be unquoted to be valid on Postgres (a single-quoted alias is a string literal there).
from frappe.utils import today
frappe.db.set_single_value("Accounts Settings", "general_ledger_remarks_length", 50)
self.addCleanup(frappe.db.set_single_value, "Accounts Settings", "general_ledger_remarks_length", 0)
si = create_sales_invoice(company=self.company)
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
columns, data = execute(
frappe._dict(
{
"company": self.company,
"from_date": today(),
"to_date": today(),
"group_by": "Group by Voucher (Consolidated)",
# required to reach the `substr(remarks, 1, n) as remarks` branch under test
"show_remarks": True,
}
)
)
self.assertTrue(columns)
self.assertTrue(data)
self.assertTrue(any("remarks" in row for row in data))
@staticmethod
def _cancel_and_delete(doctype, name):
if not frappe.db.exists(doctype, name):
return
doc = frappe.get_doc(doctype, name)
if doc.docstatus == 1:
doc.cancel()
frappe.delete_doc(doctype, name, force=1)
def clear_old_entries(self):
doctype_list = [
"GL Entry",
@@ -134,17 +170,17 @@ class TestGeneralLedger(ERPNextTestSuite):
revaluation_jv.submit()
# check the balance of the account
balance = frappe.db.sql(
"""
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabGL Entry`
where account = %s
group by account
""",
account.name,
balance = frappe.get_all(
"GL Entry",
filters={"account": account.name},
fields=[
{"SUM": "debit_in_account_currency", "as": "debit"},
{"SUM": "credit_in_account_currency", "as": "credit"},
],
group_by="account",
)
self.assertEqual(balance[0][0], 100)
self.assertEqual(flt(balance[0].debit) - flt(balance[0].credit), 100)
# check if general ledger shows correct balance
columns, data = execute(

View File

@@ -3,6 +3,7 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Coalesce, Max, Sum
from frappe.utils import cstr
@@ -100,178 +101,275 @@ def get_sales_payment_data(filters, columns):
return data
def get_conditions(filters):
conditions = "1=1"
def apply_conditions(query, a, filters):
"""Apply the same filters get_conditions() used to build, as parameterized qb .where() clauses.
`a` is the field source for the Sales Invoice columns -- either the `tabSales Invoice`
DocType or a subquery aliased `a` that selects those columns. This mirrors the previous
raw SQL where every predicate was keyed on the `a` alias.
"""
if filters.get("from_date"):
conditions += " and a.posting_date >= %(from_date)s"
query = query.where(a.posting_date >= filters.get("from_date"))
if filters.get("to_date"):
conditions += " and a.posting_date <= %(to_date)s"
query = query.where(a.posting_date <= filters.get("to_date"))
if filters.get("company"):
conditions += " and a.company=%(company)s"
query = query.where(a.company == filters.get("company"))
if filters.get("customer"):
conditions += " and a.customer = %(customer)s"
query = query.where(a.customer == filters.get("customer"))
if filters.get("owner"):
conditions += " and a.owner = %(owner)s"
query = query.where(a.owner == filters.get("owner"))
if filters.get("is_pos"):
conditions += " and a.is_pos = %(is_pos)s"
return conditions
query = query.where(a.is_pos == filters.get("is_pos"))
return query
def get_pos_invoice_data(filters):
conditions = get_conditions(filters)
result = frappe.db.sql(
""
"SELECT "
'posting_date, owner, sum(net_total) as "net_total", sum(total_taxes) as "total_taxes", '
'sum(paid_amount) as "paid_amount", sum(outstanding_amount) as "outstanding_amount", '
"mode_of_payment, warehouse, cost_center "
"FROM ("
"SELECT "
'parent, item_code, sum(amount) as "base_total", warehouse, cost_center '
"from `tabSales Invoice Item` group by parent"
") t1 "
"left join "
"(select parent, mode_of_payment from `tabSales Invoice Payment` group by parent) t3 "
"on (t3.parent = t1.parent) "
"JOIN ("
"SELECT "
'docstatus, company, is_pos, name, posting_date, owner, sum(base_total) as "base_total", '
'sum(net_total) as "net_total", sum(total_taxes_and_charges) as "total_taxes", '
'sum(base_paid_amount) as "paid_amount", sum(outstanding_amount) as "outstanding_amount" '
"FROM `tabSales Invoice` "
"GROUP BY name"
") a "
"ON ("
"t1.parent = a.name and t1.base_total = a.base_total) "
"WHERE a.docstatus = 1"
f" AND {conditions} "
"GROUP BY "
"owner, posting_date, warehouse",
filters,
as_dict=1,
sii = frappe.qb.DocType("Sales Invoice Item")
sip = frappe.qb.DocType("Sales Invoice Payment")
si = frappe.qb.DocType("Sales Invoice")
# t1: one row per invoice with the summed item base_total. warehouse/cost_center are line-level and
# not grouped, so they are arbitrary per invoice -- Max() makes that pick deterministic and valid on
# Postgres (item_code was selected but never consumed downstream, so it is dropped).
t1 = (
frappe.qb.from_(sii)
.select(
sii.parent,
Sum(sii.amount).as_("base_total"),
Max(sii.warehouse).as_("warehouse"),
Max(sii.cost_center).as_("cost_center"),
)
.groupby(sii.parent)
)
return result
# t3: mode_of_payment per invoice (arbitrary across an invoice's payment lines -> Max() to be valid)
t3 = (
frappe.qb.from_(sip)
.select(sip.parent, Max(sip.mode_of_payment).as_("mode_of_payment"))
.groupby(sip.parent)
)
# a: invoice-level aggregates. Grouped by the primary key (si.name), so the other plain si columns
# (incl. customer, needed by the customer filter) are functionally dependent and valid on Postgres.
a = (
frappe.qb.from_(si)
.select(
si.docstatus,
si.company,
si.customer,
si.is_pos,
si.name,
si.posting_date,
si.owner,
Sum(si.base_total).as_("base_total"),
Sum(si.net_total).as_("net_total"),
Sum(si.total_taxes_and_charges).as_("total_taxes"),
Sum(si.base_paid_amount).as_("paid_amount"),
Sum(si.outstanding_amount).as_("outstanding_amount"),
)
.groupby(si.name)
)
query = (
frappe.qb.from_(t1)
.left_join(t3)
.on(t3.parent == t1.parent)
.join(a)
.on((t1.parent == a.name) & (t1.base_total == a.base_total))
.select(
a.posting_date,
a.owner,
Sum(a.net_total).as_("net_total"),
Sum(a.total_taxes).as_("total_taxes"),
Sum(a.paid_amount).as_("paid_amount"),
Sum(a.outstanding_amount).as_("outstanding_amount"),
# mode_of_payment/cost_center are not in the outer GROUP BY -> Max() (deterministic, both engines)
Max(t3.mode_of_payment).as_("mode_of_payment"),
t1.warehouse,
Max(t1.cost_center).as_("cost_center"),
)
.where(a.docstatus == 1)
.groupby(a.owner, a.posting_date, t1.warehouse)
)
query = apply_conditions(query, a, filters)
return query.run(as_dict=True)
def get_sales_invoice_data(filters):
conditions = get_conditions(filters)
return frappe.db.sql(
f"""
select
a.posting_date, a.owner,
sum(a.net_total) as "net_total",
sum(a.total_taxes_and_charges) as "total_taxes",
sum(a.base_paid_amount) as "paid_amount",
sum(a.outstanding_amount) as "outstanding_amount"
from `tabSales Invoice` a
where a.docstatus = 1
and {conditions}
group by
a.owner, a.posting_date
""",
filters,
as_dict=1,
a = frappe.qb.DocType("Sales Invoice")
query = (
frappe.qb.from_(a)
.select(
a.posting_date,
a.owner,
Sum(a.net_total).as_("net_total"),
Sum(a.total_taxes_and_charges).as_("total_taxes"),
Sum(a.base_paid_amount).as_("paid_amount"),
Sum(a.outstanding_amount).as_("outstanding_amount"),
)
.where(a.docstatus == 1)
.groupby(a.owner, a.posting_date)
)
query = apply_conditions(query, a, filters)
return query.run(as_dict=True)
def get_mode_of_payments(filters):
mode_of_payments = {}
invoice_list = get_invoices(filters)
invoice_list_names = ",".join("'" + invoice["name"] + "'" for invoice in invoice_list)
invoice_names = [invoice["name"] for invoice in invoice_list]
if invoice_list:
inv_mop = frappe.db.sql(
f"""select a.owner,a.posting_date, ifnull(b.mode_of_payment, '') as mode_of_payment
from `tabSales Invoice` a, `tabSales Invoice Payment` b
where a.name = b.parent
and a.docstatus = 1
and a.name in ({invoice_list_names})
union
select a.owner,a.posting_date, ifnull(b.mode_of_payment, '') as mode_of_payment
from `tabSales Invoice` a, `tabPayment Entry` b,`tabPayment Entry Reference` c
where a.name = c.reference_name
and b.name = c.parent
and b.docstatus = 1
and a.name in ({invoice_list_names})
union
select a.owner, a.posting_date,
ifnull(a.voucher_type,'') as mode_of_payment
from `tabJournal Entry` a, `tabJournal Entry Account` b
where a.name = b.parent
and a.docstatus = 1
and b.reference_type = 'Sales Invoice'
and b.reference_name in ({invoice_list_names})
""",
as_dict=1,
# Branch 1: payments recorded directly on the Sales Invoice
si1 = frappe.qb.DocType("Sales Invoice")
sip = frappe.qb.DocType("Sales Invoice Payment")
branch1 = (
frappe.qb.from_(si1)
.join(sip)
.on(si1.name == sip.parent)
.select(si1.owner, si1.posting_date, Coalesce(sip.mode_of_payment, "").as_("mode_of_payment"))
.where(si1.docstatus == 1)
.where(si1.name.isin(invoice_names))
)
# Branch 2: payments via Payment Entry referencing the invoice
si2 = frappe.qb.DocType("Sales Invoice")
pe = frappe.qb.DocType("Payment Entry")
per = frappe.qb.DocType("Payment Entry Reference")
branch2 = (
frappe.qb.from_(si2)
.join(per)
.on(si2.name == per.reference_name)
.join(pe)
.on(pe.name == per.parent)
.select(si2.owner, si2.posting_date, Coalesce(pe.mode_of_payment, "").as_("mode_of_payment"))
.where(pe.docstatus == 1)
.where(si2.name.isin(invoice_names))
)
# Branch 3: payments via Journal Entry referencing the invoice
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
branch3 = (
frappe.qb.from_(je)
.join(jea)
.on(je.name == jea.parent)
.select(je.owner, je.posting_date, Coalesce(je.voucher_type, "").as_("mode_of_payment"))
.where(je.docstatus == 1)
.where(jea.reference_type == "Sales Invoice")
.where(jea.reference_name.isin(invoice_names))
)
# bare UNION => de-duplicated rows across the three branches
inv_mop = (branch1.union(branch2).union(branch3)).run(as_dict=True)
for d in inv_mop:
mode_of_payments.setdefault(d["owner"] + cstr(d["posting_date"]), []).append(d.mode_of_payment)
return mode_of_payments
def get_invoices(filters):
conditions = get_conditions(filters)
return frappe.db.sql(
f"""select a.name
from `tabSales Invoice` a
where a.docstatus = 1 and {conditions}""",
filters,
as_dict=1,
)
a = frappe.qb.DocType("Sales Invoice")
query = frappe.qb.from_(a).select(a.name).where(a.docstatus == 1)
query = apply_conditions(query, a, filters)
return query.run(as_dict=True)
def get_mode_of_payment_details(filters):
mode_of_payment_details = {}
invoice_list = get_invoices(filters)
invoice_list_names = ",".join("'" + invoice["name"] + "'" for invoice in invoice_list)
invoice_names = [invoice["name"] for invoice in invoice_list]
if invoice_list:
inv_mop_detail = frappe.db.sql(
f"""
select t.owner,
t.posting_date,
t.mode_of_payment,
sum(t.paid_amount) as paid_amount
from (
select a.owner, a.posting_date,
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(b.base_amount) as paid_amount
from `tabSales Invoice` a, `tabSales Invoice Payment` b
where a.name = b.parent
and a.docstatus = 1
and a.name in ({invoice_list_names})
group by a.owner, a.posting_date, mode_of_payment
union
select a.owner,a.posting_date,
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(c.allocated_amount) as paid_amount
from `tabSales Invoice` a, `tabPayment Entry` b,`tabPayment Entry Reference` c
where a.name = c.reference_name
and b.name = c.parent
and b.docstatus = 1
and a.name in ({invoice_list_names})
group by a.owner, a.posting_date, mode_of_payment
union
select a.owner, a.posting_date,
ifnull(a.voucher_type,'') as mode_of_payment, sum(b.credit)
from `tabJournal Entry` a, `tabJournal Entry Account` b
where a.name = b.parent
and a.docstatus = 1
and b.reference_type = 'Sales Invoice'
and b.reference_name in ({invoice_list_names})
group by a.owner, a.posting_date, mode_of_payment
) t
group by t.owner, t.posting_date, t.mode_of_payment
""",
as_dict=1,
# Branch 1: amounts paid directly on the Sales Invoice
si1 = frappe.qb.DocType("Sales Invoice")
sip = frappe.qb.DocType("Sales Invoice Payment")
mop1 = Coalesce(sip.mode_of_payment, "")
branch1 = (
frappe.qb.from_(si1)
.join(sip)
.on(si1.name == sip.parent)
.select(
si1.owner,
si1.posting_date,
mop1.as_("mode_of_payment"),
Sum(sip.base_amount).as_("paid_amount"),
)
.where(si1.docstatus == 1)
.where(si1.name.isin(invoice_names))
.groupby(si1.owner, si1.posting_date, mop1)
)
inv_change_amount = frappe.db.sql(
f"""select a.owner, a.posting_date,
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(a.base_change_amount) as change_amount
from `tabSales Invoice` a, `tabSales Invoice Payment` b
where a.name = b.parent
and a.name in ({invoice_list_names})
and b.type = 'Cash'
and a.base_change_amount > 0
group by a.owner, a.posting_date, mode_of_payment""",
as_dict=1,
# Branch 2: amounts allocated via Payment Entry
si2 = frappe.qb.DocType("Sales Invoice")
pe = frappe.qb.DocType("Payment Entry")
per = frappe.qb.DocType("Payment Entry Reference")
mop2 = Coalesce(pe.mode_of_payment, "")
branch2 = (
frappe.qb.from_(si2)
.join(per)
.on(si2.name == per.reference_name)
.join(pe)
.on(pe.name == per.parent)
.select(
si2.owner,
si2.posting_date,
mop2.as_("mode_of_payment"),
Sum(per.allocated_amount).as_("paid_amount"),
)
.where(pe.docstatus == 1)
.where(si2.name.isin(invoice_names))
.groupby(si2.owner, si2.posting_date, mop2)
)
# Branch 3: amounts credited via Journal Entry
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
mop3 = Coalesce(je.voucher_type, "")
branch3 = (
frappe.qb.from_(je)
.join(jea)
.on(je.name == jea.parent)
.select(
je.owner, je.posting_date, mop3.as_("mode_of_payment"), Sum(jea.credit).as_("paid_amount")
)
.where(je.docstatus == 1)
.where(jea.reference_type == "Sales Invoice")
.where(jea.reference_name.isin(invoice_names))
.groupby(je.owner, je.posting_date, mop3)
)
# bare UNION => de-duplicated rows; wrapped as subquery `t` for the outer re-aggregation
t = branch1.union(branch2).union(branch3)
inv_mop_detail = (
frappe.qb.from_(t)
.select(
t.owner,
t.posting_date,
t.mode_of_payment,
Sum(t.paid_amount).as_("paid_amount"),
)
.groupby(t.owner, t.posting_date, t.mode_of_payment)
.run(as_dict=True)
)
# change amount paid back in cash, subtracted from the matching mode-of-payment detail below
sic = frappe.qb.DocType("Sales Invoice")
sipc = frappe.qb.DocType("Sales Invoice Payment")
mopc = Coalesce(sipc.mode_of_payment, "")
inv_change_amount = (
frappe.qb.from_(sic)
.join(sipc)
.on(sic.name == sipc.parent)
.select(
sic.owner,
sic.posting_date,
mopc.as_("mode_of_payment"),
Sum(sic.base_change_amount).as_("change_amount"),
)
.where(sic.name.isin(invoice_names))
.where(sipc.type == "Cash")
.where(sic.base_change_amount > 0)
.groupby(sic.owner, sic.posting_date, mopc)
.run(as_dict=True)
)
for d in inv_change_amount:

View File

@@ -2,12 +2,13 @@
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import today
from frappe.utils import flt, today
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.report.sales_payment_summary.sales_payment_summary import (
get_mode_of_payment_details,
get_mode_of_payments,
get_pos_invoice_data,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -102,6 +103,33 @@ class TestSalesPaymentSummary(ERPNextTestSuite):
self.assertGreater(cc_init_amount, cc_final_amount)
def test_get_pos_invoice_data(self):
"""The POS path (is_pos filter -> get_pos_invoice_data) used nested loose-GROUP-BY subqueries
that raised on Postgres; it now aggregates deterministically and runs identically on both
engines."""
si = create_sales_invoice_record()
si.is_pos = 1
si.append(
"payments",
{"mode_of_payment": "Cash", "account": "_Test Cash - _TC", "amount": 10000},
)
si.insert()
si.submit()
filters = frappe._dict(
{"is_pos": 1, "company": "_Test Company", "from_date": today(), "to_date": today()}
)
data = get_pos_invoice_data(filters)
# the POS invoice's paid amount is aggregated; previously this query raised GroupingError on PG
self.assertTrue(data)
self.assertTrue(any(flt(row.get("paid_amount")) >= 10000 for row in data))
# customer filter must work: a.customer was not selected by the invoice subquery before the fix,
# so the filter errored on both engines. With the invoice's customer it still returns its payment.
filters["customer"] = si.customer
self.assertTrue(any(flt(row.get("paid_amount")) >= 10000 for row in get_pos_invoice_data(filters)))
def get_filters():
return {"from_date": "1900-01-01", "to_date": today(), "company": "_Test Company"}

View File

@@ -146,7 +146,6 @@ def get_appropriate_company(filters):
return company
@frappe.whitelist()
def get_invoiced_item_gross_margin(
sales_invoice: str | None = None,
item_code: str | None = None,

View File

@@ -37,25 +37,22 @@ def validate_disabled_accounts(gl_map):
def validate_accounting_period(gl_map):
accounting_periods = frappe.db.sql(
""" SELECT
ap.name as name, ap.exempted_role as exempted_role
FROM
`tabAccounting Period` ap, `tabClosed Document` cd
WHERE
ap.name = cd.parent
AND ap.company = %(company)s
AND ap.disabled = 0
AND cd.closed = 1
AND cd.document_type = %(voucher_type)s
AND %(date)s between ap.start_date and ap.end_date
""",
{
"date": gl_map[0].posting_date,
"company": gl_map[0].company,
"voucher_type": gl_map[0].voucher_type,
},
as_dict=1,
ap = frappe.qb.DocType("Accounting Period")
cd = frappe.qb.DocType("Closed Document")
accounting_periods = (
frappe.qb.from_(ap)
.inner_join(cd)
.on(ap.name == cd.parent)
.select(ap.name.as_("name"), ap.exempted_role.as_("exempted_role"))
.where(
(ap.company == gl_map[0].company)
& (ap.disabled == 0)
& (cd.closed == 1)
& (cd.document_type == gl_map[0].voucher_type)
& (ap.start_date <= gl_map[0].posting_date)
& (ap.end_date >= gl_map[0].posting_date)
)
.run(as_dict=1)
)
if accounting_periods:
@@ -81,13 +78,11 @@ def validate_cwip_accounts(gl_map):
for ac in frappe.db.get_all("Asset Category", "enable_cwip_accounting")
)
if cwip_enabled:
cwip_accounts = [
d[0]
for d in frappe.db.sql(
"""select name from tabAccount
where account_type = 'Capital Work in Progress' and is_group=0"""
)
]
cwip_accounts = frappe.get_all(
"Account",
filters={"account_type": "Capital Work in Progress", "is_group": 0},
pluck="name",
)
for entry in gl_map:
if entry.account in cwip_accounts:
@@ -122,13 +117,24 @@ def check_freezing_date(posting_date, company, adv_adj=False):
)
def validate_against_pcv(is_opening, posting_date, company):
if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
def validate_opening_entry_against_pcv(company):
if frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
frappe.throw(
_("Opening Entry can not be created after Period Closing Voucher is created."),
_(
"A Period Closing Voucher is already submitted and an Opening Entry can no longer be created. {0} to learn more."
).format(
'<a href="https://docs.frappe.io/erpnext/period-closing-voucher#14-pcv-and-opening-entries" target="_blank" rel="noopener">'
+ _("Read the docs")
+ "</a>"
),
title=_("Invalid Opening Entry"),
)
def validate_against_pcv(is_opening, posting_date, company):
if is_opening:
validate_opening_entry_against_pcv(company)
last_pcv_date = frappe.db.get_value(
"Period Closing Voucher", {"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}]
)

View File

@@ -13,7 +13,7 @@ from frappe.desk.reportview import build_match_conditions
from frappe.model.meta import get_field_precision
from frappe.model.naming import determine_consecutive_week_number
from frappe.query_builder import AliasedQuery, Case, Criterion, Field, Table
from frappe.query_builder.functions import Count, IfNull, Max, Round, Sum
from frappe.query_builder.functions import Count, IfNull, Max, Min, Round, Sum
from frappe.query_builder.utils import DocType
from frappe.utils import (
add_days,
@@ -411,10 +411,9 @@ def get_count_on(account, fieldname, date):
else:
dr_or_cr = "debit" if fieldname == "invoiced_amount" else "credit"
cr_or_dr = "credit" if fieldname == "invoiced_amount" else "debit"
select_fields = (
"ifnull(sum(credit-debit),0)"
if fieldname == "invoiced_amount"
else "ifnull(sum(debit-credit),0)"
gl = frappe.qb.DocType("GL Entry")
amount_expr = (
Sum(gl.credit - gl.debit) if fieldname == "invoiced_amount" else Sum(gl.debit - gl.credit)
)
if (
@@ -422,14 +421,21 @@ def get_count_on(account, fieldname, date):
or (gle.against_voucher_type in ["Sales Order", "Purchase Order"])
or (gle.against_voucher == gle.voucher_no and gle.get(dr_or_cr) > 0)
):
payment_amount = frappe.db.sql(
f"""
SELECT {select_fields}
FROM `tabGL Entry` gle
WHERE docstatus < 2 and posting_date <= %(date)s and against_voucher = %(voucher_no)s
and party = %(party)s and name != %(name)s""",
{"date": date, "voucher_no": gle.voucher_no, "party": gle.party, "name": gle.name},
)[0][0]
payment_amount = (
(
frappe.qb.from_(gl)
.select(amount_expr)
.where(
(gl.docstatus < 2)
& (gl.posting_date <= date)
& (gl.against_voucher == gle.voucher_no)
& (gl.party == gle.party)
& (gl.name != gle.name)
)
.run()[0][0]
)
or 0
)
outstanding_amount = flt(gle.get(dr_or_cr)) - flt(gle.get(cr_or_dr)) - payment_amount
currency_precision = get_currency_precision() or 2
@@ -1169,26 +1175,27 @@ def get_company_default(company: str, fieldname: str, ignore_validation: bool =
def fix_total_debit_credit():
vouchers = frappe.db.sql(
"""select voucher_type, voucher_no,
sum(debit) - sum(credit) as diff
from `tabGL Entry`
group by voucher_type, voucher_no
having sum(debit) != sum(credit)""",
as_dict=1,
gle = frappe.qb.DocType("GL Entry")
vouchers = (
frappe.qb.from_(gle)
.select(gle.voucher_type, gle.voucher_no, (Sum(gle.debit) - Sum(gle.credit)).as_("diff"))
.groupby(gle.voucher_type, gle.voucher_no)
.having(Sum(gle.debit) != Sum(gle.credit))
.run(as_dict=1)
)
for d in vouchers:
if abs(d.diff) > 0:
dr_or_cr = d.voucher_type == "Sales Invoice" and "credit" or "debit"
frappe.db.sql(
"""update `tabGL Entry` set {} = {} + {}
where voucher_type = {} and voucher_no = {} and {} > 0 limit 1""".format(
dr_or_cr, dr_or_cr, "%s", "%s", "%s", dr_or_cr
),
(d.diff, d.voucher_type, d.voucher_no),
gle = frappe.qb.DocType("GL Entry")
name = frappe.db.get_value(
"GL Entry",
{"voucher_type": d.voucher_type, "voucher_no": d.voucher_no, dr_or_cr: [">", 0]},
"name",
)
if name:
frappe.qb.update(gle).set(gle[dr_or_cr], gle[dr_or_cr] + d.diff).where(gle.name == name).run()
def get_currency_precision():
@@ -1230,11 +1237,12 @@ def get_held_invoices(party_type, party):
held_invoices = None
if party_type == "Supplier":
held_invoices = frappe.db.sql(
"select name from `tabPurchase Invoice` where on_hold = 1 and release_date IS NOT NULL and release_date > CURDATE()",
as_dict=1,
held_invoices = frappe.get_all(
"Purchase Invoice",
filters={"on_hold": 1, "release_date": [">", nowdate()]},
pluck="name",
)
held_invoices = set(d["name"] for d in held_invoices)
held_invoices = set(held_invoices)
return held_invoices
@@ -1742,13 +1750,15 @@ def sort_stock_vouchers_by_posting_date(
sle = frappe.qb.DocType("Stock Ledger Entry")
voucher_nos = [v[1] for v in stock_vouchers]
# only voucher_type/voucher_no are used downstream; order by Min() of the (per-voucher constant)
# posting_datetime so postgres accepts the GROUP BY without selecting non-aggregated columns
sles = (
frappe.qb.from_(sle)
.select(sle.voucher_type, sle.voucher_no, sle.posting_date, sle.posting_time, sle.creation)
.select(sle.voucher_type, sle.voucher_no)
.where((sle.is_cancelled == 0) & (sle.voucher_no.isin(voucher_nos)))
.groupby(sle.voucher_type, sle.voucher_no)
.orderby(sle.posting_datetime)
.orderby(sle.creation)
.orderby(Min(sle.posting_datetime))
.orderby(Min(sle.creation))
)
if company:
@@ -1769,25 +1779,37 @@ def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, f
SLE = DocType("Stock Ledger Entry")
conditions = (SLE.posting_datetime >= posting_datetime) & (SLE.is_cancelled == 0)
if for_items:
conditions &= SLE.item_code.isin(for_items)
if for_warehouses:
conditions &= SLE.warehouse.isin(for_warehouses)
if company:
conditions &= SLE.company == company
# These SLE rows must stay locked for the duration of the repost so a concurrent stock
# transaction can't modify them mid-flight (the original DISTINCT ... FOR UPDATE did this).
# MariaDB carries the lock on the grouped query below; postgres rejects FOR UPDATE alongside
# GROUP BY, so lock the matching rows in a separate pass first -- the row locks are held until
# the surrounding transaction ends, giving the same protection.
if frappe.db.db_type == "postgres":
frappe.qb.from_(SLE).select(SLE.name).where(conditions).for_update().run()
# distinct vouchers in chronological order; expressed as GROUP BY + Min() so it's valid on
# postgres (SELECT DISTINCT can't ORDER BY non-selected cols, and FOR UPDATE is invalid with both).
# posting_datetime is constant per voucher, so the ordering is unchanged vs the DISTINCT form.
query = (
frappe.qb.from_(SLE)
.select(SLE.voucher_type, SLE.voucher_no)
.distinct()
.where(SLE.posting_datetime >= posting_datetime)
.where(SLE.is_cancelled == 0)
.orderby(SLE.posting_datetime)
.orderby(SLE.creation)
.for_update()
.where(conditions)
.groupby(SLE.voucher_type, SLE.voucher_no)
.orderby(Min(SLE.posting_datetime))
.orderby(Min(SLE.creation))
)
if for_items:
query = query.where(SLE.item_code.isin(for_items))
if for_warehouses:
query = query.where(SLE.warehouse.isin(for_warehouses))
if company:
query = query.where(SLE.company == company)
# lock scanned rows on MariaDB; on postgres they were already locked above
if frappe.db.db_type != "postgres":
query = query.for_update()
future_stock_vouchers = query.run(as_dict=True)
@@ -1809,14 +1831,11 @@ def get_voucherwise_gl_entries(future_stock_vouchers, posting_date):
voucher_nos = [d[1] for d in future_stock_vouchers]
gles = frappe.db.sql(
"""
select name, account, credit, debit, cost_center, project, voucher_type, voucher_no
from `tabGL Entry`
where
posting_date >= {} and voucher_no in ({})""".format("%s", ", ".join(["%s"] * len(voucher_nos))),
tuple([posting_date, *voucher_nos]),
as_dict=1,
gles = frappe.get_all(
"GL Entry",
filters={"posting_date": [">=", posting_date], "voucher_no": ["in", voucher_nos]},
fields=["name", "account", "credit", "debit", "cost_center", "project", "voucher_type", "voucher_no"],
limit=0,
)
for d in gles:
@@ -2235,7 +2254,7 @@ def delink_original_entry(pl_entry, partial_cancel=False):
qb.update(ple)
.set(ple.modified, now())
.set(ple.modified_by, frappe.session.user)
.set(ple.delinked, True)
.set(ple.delinked, 1) # smallint column; postgres rejects boolean true
.where(
(ple.company == pl_entry.company)
& (ple.account_type == pl_entry.account_type)
@@ -2350,8 +2369,10 @@ class QueryPaymentLedger:
.where(Criterion.all(self.dimensions_filter))
.where(Criterion.all(self.voucher_posting_date))
.groupby(ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
.orderby(ple.invoice_date, ple.voucher_no)
.having(qb.Field("amount_in_account_currency") > 0)
# order by the select aliases (postgres can't ORDER BY a non-existent ple column)
.orderby(qb.Field("invoice_date"), qb.Field("voucher_no"))
# postgres HAVING can't reference a select alias; use the aggregate expression
.having(Sum(ple.amount_in_account_currency) > 0)
.limit(self.limit)
.run()
)
@@ -2365,18 +2386,21 @@ class QueryPaymentLedger:
query_voucher_amount = (
qb.from_(ple)
.select(
ple.account,
# columns that are constant per (voucher_type, voucher_no, party_type, party) are
# wrapped in Max() so the query is valid on postgres (which, unlike MariaDB, requires
# every non-aggregated column to be grouped or aggregated)
Max(ple.account).as_("account"),
ple.voucher_type,
ple.voucher_no,
ple.party_type,
ple.party,
ple.posting_date,
ple.due_date,
ple.account_currency.as_("currency"),
ple.cost_center.as_("cost_center"),
Max(ple.posting_date).as_("posting_date"),
Max(ple.due_date).as_("due_date"),
Max(ple.account_currency).as_("currency"),
Max(ple.cost_center).as_("cost_center"),
Sum(ple.amount).as_("amount"),
Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
ple.remarks,
Max(ple.remarks).as_("remarks"),
)
.where(ple.delinked == 0)
.where(Criterion.all(filter_on_voucher_no))
@@ -2390,14 +2414,15 @@ class QueryPaymentLedger:
query_voucher_outstanding = (
qb.from_(ple)
.select(
ple.account,
# Max() on columns constant per group keeps this valid on postgres (see above)
Max(ple.account).as_("account"),
ple.against_voucher_type.as_("voucher_type"),
ple.against_voucher_no.as_("voucher_no"),
ple.party_type,
ple.party,
ple.posting_date,
ple.due_date,
ple.account_currency.as_("currency"),
Max(ple.posting_date).as_("posting_date"),
Max(ple.due_date).as_("due_date"),
Max(ple.account_currency).as_("currency"),
Sum(ple.amount).as_("amount"),
Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
)
@@ -2446,17 +2471,19 @@ class QueryPaymentLedger:
# build CTE filter
# only fetch invoices
# The combined CTE query has no GROUP BY, so these are row filters. MariaDB tolerates HAVING
# on a select alias here, but postgres does not; express them as WHERE on the source column.
if self.get_invoices:
self.cte_query_voucher_amount_and_outstanding = (
self.cte_query_voucher_amount_and_outstanding.having(
qb.Field("outstanding_in_account_currency") > 0
self.cte_query_voucher_amount_and_outstanding.where(
Table("outstanding").amount_in_account_currency > 0
)
)
# only fetch payments
elif self.get_payments:
self.cte_query_voucher_amount_and_outstanding = (
self.cte_query_voucher_amount_and_outstanding.having(
qb.Field("outstanding_in_account_currency") < 0
self.cte_query_voucher_amount_and_outstanding.where(
Table("outstanding").amount_in_account_currency < 0
)
)

View File

@@ -735,12 +735,16 @@ class Asset(AccountsController):
frappe.throw(_("Asset cannot be cancelled, as it is already {0}").format(self.status))
def cancel_movement_entries(self):
movements = frappe.db.sql(
"""SELECT asm.name, asm.docstatus
FROM `tabAsset Movement` asm, `tabAsset Movement Item` asm_item
WHERE asm_item.parent=asm.name and asm_item.asset=%s and asm.docstatus=1""",
self.name,
as_dict=1,
# filter the parent Asset Movement's docstatus (as the original SQL did), not the child row's
asm = frappe.qb.DocType("Asset Movement")
asm_item = frappe.qb.DocType("Asset Movement Item")
movements = (
frappe.qb.from_(asm_item)
.inner_join(asm)
.on(asm_item.parent == asm.name)
.select(asm.name)
.where((asm_item.asset == self.name) & (asm.docstatus == 1))
.run(as_dict=True)
)
for movement in movements:
@@ -860,15 +864,18 @@ class Asset(AccountsController):
cwip_enabled = is_cwip_accounting_enabled(self.asset_category)
cwip_account = self.get_cwip_account(cwip_enabled=cwip_enabled)
query = """SELECT name FROM `tabGL Entry` WHERE voucher_no = %s and account = %s"""
if asset_bought_with_invoice:
# with invoice purchase either expense or cwip has been booked
expense_booked = frappe.db.sql(query, (purchase_document, fixed_asset_account), as_dict=1)
expense_booked = frappe.db.exists(
"GL Entry", {"voucher_no": purchase_document, "account": fixed_asset_account}
)
if expense_booked:
# if expense is already booked from invoice then do not make gl entries regardless of cwip enabled/disabled
return False
cwip_booked = frappe.db.sql(query, (purchase_document, cwip_account), as_dict=1)
cwip_booked = frappe.db.exists(
"GL Entry", {"voucher_no": purchase_document, "account": cwip_account}
)
if cwip_booked:
# if cwip is booked from invoice then make gl entries regardless of cwip enabled/disabled
return True
@@ -878,10 +885,11 @@ class Asset(AccountsController):
# if cwip account isn't available do not make gl entries
return False
cwip_booked = frappe.db.sql(query, (purchase_document, cwip_account), as_dict=1)
# if cwip is not booked from receipt then do not make gl entries
# if cwip is booked from receipt then make gl entries
return cwip_booked
return bool(
frappe.db.exists("GL Entry", {"voucher_no": purchase_document, "account": cwip_account})
)
def get_purchase_document(self):
asset_bought_with_invoice = self.purchase_invoice and frappe.db.get_value(
@@ -1074,11 +1082,15 @@ def make_post_gl_entry():
for asset_category in asset_categories:
if cint(asset_category.enable_cwip_accounting):
assets = frappe.db.sql_list(
""" select name from `tabAsset`
where asset_category = %s and ifnull(booked_fixed_asset, 0) = 0
and available_for_use_date = %s and docstatus = 1""",
(asset_category.name, nowdate()),
assets = frappe.get_all(
"Asset",
filters={
"asset_category": asset_category.name,
"booked_fixed_asset": 0,
"available_for_use_date": nowdate(),
"docstatus": 1,
},
pluck="name",
)
for asset in assets:

View File

@@ -85,8 +85,8 @@ class TestAsset(AssetSetup):
self.assertRaises(frappe.ValidationError, asset.save)
def test_validate_item(self):
asset = create_asset(item_code="MacBook Pro", do_not_save=1)
item = frappe.get_doc("Item", "MacBook Pro")
asset = create_asset(item_code="Macbook Pro", do_not_save=1)
item = frappe.get_doc("Item", "Macbook Pro")
item.disabled = 1
item.save()
@@ -140,7 +140,7 @@ class TestAsset(AssetSetup):
)
gle = get_gl_entries("Purchase Invoice", pi.name)
self.assertSequenceEqual(gle, expected_gle)
self.assertCountEqual(gle, expected_gle)
pi.cancel()
asset.cancel()
@@ -283,7 +283,7 @@ class TestAsset(AssetSetup):
)
gle = get_gl_entries("Journal Entry", asset.journal_entry_for_scrap)
self.assertSequenceEqual(gle, expected_gle)
self.assertCountEqual(gle, expected_gle)
restore_asset(asset.name)
second_asset_depr_schedule.load_from_db()
@@ -362,7 +362,7 @@ class TestAsset(AssetSetup):
("Debtors - _TC", 25000.0, 0.0),
)
gle = get_gl_entries("Sales Invoice", si.name)
self.assertSequenceEqual(gle, expected_gle)
self.assertCountEqual(gle, expected_gle)
si.cancel()
self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Partially Depreciated")
@@ -436,7 +436,7 @@ class TestAsset(AssetSetup):
)
gle = get_gl_entries("Sales Invoice", si.name)
self.assertSequenceEqual(gle, expected_gle)
self.assertCountEqual(gle, expected_gle)
def test_asset_with_maintenance_required_status_after_sale(self):
asset = create_asset(
@@ -577,7 +577,7 @@ class TestAsset(AssetSetup):
)
pr_gle = get_gl_entries("Purchase Receipt", pr.name)
self.assertSequenceEqual(pr_gle, expected_gle)
self.assertCountEqual(pr_gle, expected_gle)
pi = make_invoice(pr.name)
pi.submit()
@@ -590,7 +590,7 @@ class TestAsset(AssetSetup):
)
pi_gle = get_gl_entries("Purchase Invoice", pi.name)
self.assertSequenceEqual(pi_gle, expected_gle)
self.assertCountEqual(pi_gle, expected_gle)
asset = frappe.db.get_value("Asset", {"purchase_receipt": pr.name, "docstatus": 0}, "name")
@@ -617,7 +617,7 @@ class TestAsset(AssetSetup):
expected_gle = (("_Test Fixed Asset - _TC", 5250.0, 0.0), ("CWIP Account - _TC", 0.0, 5250.0))
gle = get_gl_entries("Asset", asset_doc.name)
self.assertSequenceEqual(gle, expected_gle)
self.assertCountEqual(gle, expected_gle)
def test_asset_cwip_toggling_cases(self):
cwip = frappe.db.get_value("Asset Category", "Computers", "enable_cwip_accounting")
@@ -1732,14 +1732,18 @@ class TestDepreciationBasics(AssetSetup):
("_Test Depreciations - _TC", 30000.0, 0.0),
)
gle = frappe.db.sql(
"""select account, debit, credit from `tabGL Entry`
where against_voucher_type='Asset' and against_voucher = %s
order by account""",
asset.name,
)
gle = [
tuple(row)
for row in frappe.get_all(
"GL Entry",
filters={"against_voucher_type": "Asset", "against_voucher": asset.name},
fields=["account", "debit", "credit"],
order_by="account",
as_list=True,
)
]
self.assertSequenceEqual(gle, expected_gle)
self.assertCountEqual(gle, expected_gle)
self.assertEqual(asset.get("value_after_depreciation"), 70000)
def test_expected_value_change(self):

View File

@@ -2,6 +2,7 @@
# See license.txt
import frappe
from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt, now_datetime
from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
@@ -549,34 +550,33 @@ def create_depreciation_asset(**args):
def get_actual_gle_dict(name):
gle = frappe.qb.DocType("GL Entry")
diff = Sum(gle.debit - gle.credit)
return dict(
frappe.db.sql(
"""
select account, sum(debit-credit) as diff
from `tabGL Entry`
where voucher_type = 'Asset Capitalization' and voucher_no = %s
group by account
having diff != 0
""",
name,
)
frappe.qb.from_(gle)
.select(gle.account, diff.as_("diff"))
.where((gle.voucher_type == "Asset Capitalization") & (gle.voucher_no == name))
.groupby(gle.account)
.having(diff != 0)
.run()
)
def get_actual_sle_dict(name):
sles = frappe.db.sql(
"""
select
item_code, warehouse,
sum(actual_qty) as actual_qty,
sum(stock_value_difference) as stock_value_difference
from `tabStock Ledger Entry`
where voucher_type = 'Asset Capitalization' and voucher_no = %s
group by item_code, warehouse
having actual_qty != 0
""",
name,
as_dict=1,
sle = frappe.qb.DocType("Stock Ledger Entry")
actual_qty = Sum(sle.actual_qty)
sles = (
frappe.qb.from_(sle)
.select(
sle.item_code,
sle.warehouse,
actual_qty.as_("actual_qty"),
Sum(sle.stock_value_difference).as_("stock_value_difference"),
)
.where((sle.voucher_type == "Asset Capitalization") & (sle.voucher_no == name))
.groupby(sle.item_code, sle.warehouse)
.having(actual_qty != 0)
.run(as_dict=1)
)
sle_dict = {}

View File

@@ -79,11 +79,14 @@ def assign_tasks(asset_maintenance_name, assign_to_member, maintenance_task, nex
"description": maintenance_task,
"date": next_due_date,
}
if not frappe.db.sql(
"""select owner from `tabToDo`
where reference_type=%(doctype)s and reference_name=%(name)s and status='Open'
and owner=%(assign_to)s""",
args,
if not frappe.db.exists(
"ToDo",
{
"reference_type": args["doctype"],
"reference_name": args["name"],
"status": "Open",
"owner": args["assign_to"],
},
):
# assign_to function expects a list
args["assign_to"] = [args["assign_to"]]
@@ -187,13 +190,9 @@ def get_team_members(
@frappe.whitelist()
def get_maintenance_log(asset_name: str):
return frappe.db.sql(
"""
select maintenance_status, count(asset_name) as count, asset_name
from `tabAsset Maintenance Log`
where asset_name=%s
group by maintenance_status
""",
(asset_name,),
as_dict=1,
return frappe.get_all(
"Asset Maintenance Log",
filters={"asset_name": asset_name},
fields=["maintenance_status", {"COUNT": "asset_name", "as": "count"}, "asset_name"],
group_by="maintenance_status, asset_name",
)

View File

@@ -18,6 +18,36 @@ class TestAssetMaintenance(ERPNextTestSuite):
self.asset_name = frappe.db.get_value("Asset", {"purchase_receipt": self.pr.name}, "name")
self.asset_doc = frappe.get_doc("Asset", self.asset_name)
def test_get_maintenance_log_counts_by_status(self):
"""get_maintenance_log uses a v16 dict aggregate field spec
({"COUNT": "asset_name", "as": "count"}); confirm it runs and returns correct per-status counts
on both engines (the whitelisted endpoint was previously untested)."""
from erpnext.assets.doctype.asset_maintenance.asset_maintenance import get_maintenance_log
self.asset_doc.available_for_use_date = nowdate()
self.asset_doc.purchase_date = nowdate()
self.asset_doc.save()
frappe.get_doc(
{
"doctype": "Asset Maintenance",
"asset_name": self.asset_name,
"maintenance_team": "Team Awesome",
"company": "_Test Company",
"asset_maintenance_tasks": get_maintenance_tasks(),
}
).insert()
rows = get_maintenance_log(self.asset_name)
# the dict aggregate spec did not crash and returned grouped rows...
self.assertTrue(rows)
self.assertTrue(all("maintenance_status" in r for r in rows))
# ...and the per-status counts sum to the total number of logs for this asset
self.assertEqual(
sum(r["count"] for r in rows),
frappe.db.count("Asset Maintenance Log", {"asset_name": self.asset_name}),
)
def test_create_asset_maintenance_with_log(self):
month_end_date = get_last_day(nowdate())

View File

@@ -127,24 +127,20 @@ class AssetMovement(Document):
def get_latest_location_and_custodian(self, asset):
current_location, current_employee = "", ""
cond = "1=1"
# latest entry corresponds to current document's location, employee when transaction date > previous dates
# In case of cancellation it corresponds to previous latest document's location, employee
args = {"asset": asset, "company": self.company}
latest_movement_entry = frappe.db.sql(
f"""
SELECT asm_item.target_location, asm_item.to_employee
FROM `tabAsset Movement Item` asm_item
JOIN `tabAsset Movement` asm ON asm_item.parent = asm.name
WHERE
asm_item.asset = %(asset)s AND
asm.company = %(company)s AND
asm.docstatus = 1 AND {cond}
ORDER BY asm.transaction_date DESC
LIMIT 1
""",
args,
asm = frappe.qb.DocType("Asset Movement")
asm_item = frappe.qb.DocType("Asset Movement Item")
latest_movement_entry = (
frappe.qb.from_(asm_item)
.inner_join(asm)
.on(asm_item.parent == asm.name)
.select(asm_item.target_location, asm_item.to_employee)
.where((asm_item.asset == asset) & (asm.company == self.company) & (asm.docstatus == 1))
.orderby(asm.transaction_date, order=frappe.qb.desc)
.limit(1)
.run()
)
if latest_movement_entry:

View File

@@ -215,17 +215,12 @@ def get_children(doctype: str, parent: str | None = None, location: str | None =
if parent is None or parent == "All Locations":
parent = ""
return frappe.db.sql(
f"""
select
name as value,
is_group as expandable
from
`tabLocation` comp
where
ifnull(parent_location, "")={frappe.db.escape(parent)}
""",
as_dict=1,
filters = {"parent_location": parent} if parent else {"parent_location": ["is", "not set"]}
return frappe.get_all(
"Location",
filters=filters,
fields=["name as value", "is_group as expandable"],
)

View File

@@ -395,32 +395,30 @@ def get_group_by_data(
def get_purchase_receipt_supplier_map():
pr = frappe.qb.DocType("Purchase Receipt")
pri = frappe.qb.DocType("Purchase Receipt Item")
return frappe._dict(
frappe.db.sql(
""" Select
pr.name, pr.supplier
FROM `tabPurchase Receipt` pr, `tabPurchase Receipt Item` pri
WHERE
pri.parent = pr.name
AND pri.is_fixed_asset=1
AND pr.docstatus=1
AND pr.is_return=0"""
)
frappe.qb.from_(pr)
.inner_join(pri)
.on(pri.parent == pr.name)
.select(pr.name, pr.supplier)
.distinct()
.where((pri.is_fixed_asset == 1) & (pr.docstatus == 1) & (pr.is_return == 0))
.run()
)
def get_purchase_invoice_supplier_map():
pi = frappe.qb.DocType("Purchase Invoice")
pii = frappe.qb.DocType("Purchase Invoice Item")
return frappe._dict(
frappe.db.sql(
""" Select
pi.name, pi.supplier
FROM `tabPurchase Invoice` pi, `tabPurchase Invoice Item` pii
WHERE
pii.parent = pi.name
AND pii.is_fixed_asset=1
AND pi.docstatus=1
AND pi.is_return=0"""
)
frappe.qb.from_(pi)
.inner_join(pii)
.on(pii.parent == pi.name)
.select(pi.name, pi.supplier)
.distinct()
.where((pii.is_fixed_asset == 1) & (pi.docstatus == 1) & (pi.is_return == 0))
.run()
)

View File

@@ -0,0 +1,33 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.assets.doctype.asset.test_asset import AssetSetup, create_asset
from erpnext.assets.report.fixed_asset_register.fixed_asset_register import execute
class TestFixedAssetRegister(AssetSetup):
def test_report_lists_submitted_asset(self):
"""Exercises the report's converted queries -- including the depreciation aggregate that groups
by asset.name (must be valid on Postgres) -- by asserting a submitted asset is listed."""
asset = create_asset(
item_code="Macbook Pro",
purchase_date="2020-01-01",
available_for_use_date="2020-06-06",
location="Test Location",
submit=1,
)
filters = frappe._dict(
{
"company": "_Test Company",
"status": "In Location",
"filter_based_on": "Date Range",
"from_date": "2020-01-01",
"to_date": "2030-12-31",
"date_based_on": "Purchase Date",
}
)
data = execute(filters)[1]
asset_ids = {row.get("asset_id") for row in data}
self.assertIn(asset.name, asset_ids)

View File

@@ -30,10 +30,7 @@ class BulkTransactionLog(Document):
def load_from_db(self):
log_detail = qb.DocType("Bulk Transaction Log Detail")
has_records = frappe.db.sql(
"select exists (select * from `tabBulk Transaction Log Detail` where date = %s);",
(self.name,),
)[0][0]
has_records = frappe.db.exists("Bulk Transaction Log Detail", {"date": self.name})
if not has_records:
raise frappe.DoesNotExistError

View File

@@ -1,11 +1,76 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import nowtime, random_string
from erpnext.tests.utils import ERPNextTestSuite
class TestBulkTransactionLog(ERPNextTestSuite):
pass
def _make_log_doc(self, date):
# "Bulk Transaction Log" is a virtual doctype named by date; build the doc
# in-memory and drive load_from_db() directly to exercise the converted query.
doc = frappe.new_doc("Bulk Transaction Log")
doc.name = date
return doc
def _insert_detail(self, date, status="Success"):
detail = frappe.get_doc(
{
"doctype": "Bulk Transaction Log Detail",
"from_doctype": "Sales Order",
"to_doctype": "Sales Invoice",
"transaction_name": "_Test BTLD " + random_string(8),
"date": date,
"time": nowtime(),
"transaction_status": status,
}
)
# transaction_name is a Dynamic Link (options=from_doctype); the converted
# query never reads it, so skip link validation rather than create real txns.
detail.insert(ignore_permissions=True, ignore_links=True)
return detail
def test_load_raises_when_no_detail_rows(self):
# A date with zero Bulk Transaction Log Detail rows must not resolve to a log.
date = "2024-01-01"
self.assertFalse(
frappe.db.exists("Bulk Transaction Log Detail", {"date": date}),
"precondition: no detail rows for this date",
)
doc = self._make_log_doc(date)
self.assertRaises(frappe.DoesNotExistError, doc.load_from_db)
def test_load_succeeds_and_aggregates_after_detail_inserted(self):
date = "2024-02-02"
# Initially absent -> load_from_db must raise.
self.assertRaises(frappe.DoesNotExistError, self._make_log_doc(date).load_from_db)
# Insert detail rows for this date: 2 succeeded, 1 failed.
self._insert_detail(date, "Success")
self._insert_detail(date, "Success")
self._insert_detail(date, "Failed")
# Now the exists() check passes and load_from_db() populates aggregates.
doc = self._make_log_doc(date)
doc.load_from_db()
self.assertEqual(doc.date, date)
self.assertEqual(doc.succeeded, 2)
self.assertEqual(doc.failed, 1)
self.assertEqual(doc.log_entries, 3)
def test_load_isolated_per_date(self):
# Detail rows on a different date must not satisfy the lookup for our date.
other_date = "2024-03-03"
self._insert_detail(other_date, "Success")
target_date = "2024-04-04"
self.assertFalse(
frappe.db.exists("Bulk Transaction Log Detail", {"date": target_date}),
"target date has no rows; rows on another date must not leak in",
)
self.assertRaises(frappe.DoesNotExistError, self._make_log_doc(target_date).load_from_db)

View File

@@ -477,10 +477,8 @@ class TestPurchaseOrder(ERPNextTestSuite):
item_doc.save()
else:
# update valid from
frappe.db.sql(
"""UPDATE `tabItem Tax` set valid_from = CURRENT_DATE
where parent = %(item)s and item_tax_template = %(tax)s""",
{"item": item, "tax": tax_template},
frappe.db.set_value(
"Item Tax", {"parent": item, "item_tax_template": tax_template}, "valid_from", nowdate()
)
po = create_purchase_order(item_code=item, qty=1, do_not_save=1)
@@ -527,10 +525,8 @@ class TestPurchaseOrder(ERPNextTestSuite):
self.assertEqual(po.taxes[1].total, 840)
# teardown
frappe.db.sql(
"""UPDATE `tabItem Tax` set valid_from = NULL
where parent = %(item)s and item_tax_template = %(tax)s""",
{"item": item, "tax": tax_template},
frappe.db.set_value(
"Item Tax", {"parent": item, "item_tax_template": tax_template}, "valid_from", None
)
po.cancel()
po.delete()
@@ -652,7 +648,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
def test_purchase_order_on_hold(self):
po = create_purchase_order(item_code="_Test Product Bundle Item")
po.db_set("Status", "On Hold")
po.db_set("status", "On Hold")
pi = make_pi_from_po(po.name)
pr = make_purchase_receipt(po.name)
self.assertRaises(frappe.ValidationError, pr.submit)

View File

@@ -43,6 +43,7 @@ def make_supplier_quotation_from_rfq(
"name": "request_for_quotation_item",
"parent": "request_for_quotation",
"project_name": "project",
"cost_center": "cost_center",
},
},
},
@@ -110,6 +111,7 @@ def create_rfq_items(sq_doc, supplier, data):
"material_request_item",
"stock_qty",
"uom",
"cost_center",
]:
args[field] = data.get(field)
@@ -176,6 +178,7 @@ def get_item_from_material_requests_based_on_supplier(
["name", "material_request_item"],
["parent", "material_request"],
["uom", "uom"],
["cost_center", "cost_center"],
],
},
},

View File

@@ -19,6 +19,7 @@ from erpnext.controllers.accounts_controller import InvalidQtyError
from erpnext.crm.doctype.opportunity.mapper import make_request_for_quotation as make_rfq
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
from erpnext.templates.pages.rfq import check_supplier_has_docname_access
from erpnext.tests.utils import ERPNextTestSuite
@@ -250,6 +251,41 @@ class TestRequestforQuotation(ERPNextTestSuite):
self.assertEqual(sq.items[0].qty, 0)
self.assertEqual(sq.items[0].item_code, rfq.items[0].item_code)
def test_cost_center_flows_from_mr_to_rfq(self):
from erpnext.stock.doctype.material_request.mapper import (
make_request_for_quotation as mr_make_rfq,
)
mr = make_material_request(cost_center="_Test Cost Center - _TC")
rfq = mr_make_rfq(mr.name)
self.assertEqual(rfq.items[0].cost_center, "_Test Cost Center - _TC")
def test_cost_center_flows_from_rfq_to_supplier_quotation(self):
rfq = make_request_for_quotation(do_not_submit=True)
rfq.items[0].cost_center = "_Test Cost Center - _TC"
rfq.save()
rfq.submit()
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.get("suppliers")[0].supplier)
self.assertEqual(sq.items[0].cost_center, "_Test Cost Center - _TC")
def test_cost_center_flows_end_to_end_mr_rfq_sq(self):
from erpnext.stock.doctype.material_request.mapper import (
make_request_for_quotation as mr_make_rfq,
)
mr = make_material_request(cost_center="_Test Cost Center - _TC")
rfq = mr_make_rfq(mr.name)
rfq.append("suppliers", {"supplier": "_Test Supplier", "supplier_name": "_Test Supplier"})
rfq.insert()
rfq.submit()
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier="_Test Supplier")
self.assertEqual(sq.items[0].cost_center, "_Test Cost Center - _TC")
def make_request_for_quotation(**args):
"""

View File

@@ -30,7 +30,9 @@
"col_break4",
"material_request",
"material_request_item",
"section_break_24",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project_name",
"section_break_23",
"page_break"
@@ -253,15 +255,26 @@
},
{
"collapsible": 1,
"fieldname": "section_break_24",
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center",
"print_hide": 1
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-01-31 19:46:27.884592",
"modified": "2026-06-15 00:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation Item",

View File

@@ -305,7 +305,9 @@ def get_po_entries(filters):
& (parent.name == child.parent)
& (parent.status.notin(("Closed", "Completed", "Cancelled")))
)
.groupby(parent.name, child.material_request_item)
# This is one row per PO item; the selected child.* columns are only functionally dependent
# on the child PK, which postgres requires in the GROUP BY (MariaDB allows omitting it).
.groupby(parent.name, child.material_request_item, child.name)
)
query = apply_filters_on_query(filters, parent, child, query)

View File

@@ -6,4 +6,16 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestProcurementTracker(ERPNextTestSuite):
pass
def test_report_executes_and_lists_po(self):
# get_po_entries groups by (Purchase Order, material_request_item, Purchase Order Item)
# while selecting other child columns; this exercises that GROUP BY so the report stays
# valid on Postgres (which rejects selecting non-grouped columns).
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.buying.report.procurement_tracker.procurement_tracker import execute
po = create_purchase_order(company="_Test Company")
columns, data = execute({"company": "_Test Company"})
self.assertTrue(columns)
self.assertIn(po.name, {row.get("purchase_order") for row in data})

View File

@@ -71,7 +71,9 @@ def get_data(filters):
po_item.name,
)
.where((po_item.parent == po.name) & (po.status.notin(("Stopped", "On Hold"))) & (po.docstatus == 1))
.groupby(po_item.name)
# the selected po.* columns need the Purchase Order PK grouped on postgres; po.name is 1:1
# with the grouped po_item.name, so groups are unchanged.
.groupby(po_item.name, po.name)
.orderby(po.transaction_date)
)

View File

@@ -0,0 +1,28 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from frappe.utils import add_days, nowdate
from erpnext.tests.utils import ERPNextTestSuite
class TestPurchaseOrderAnalysis(ERPNextTestSuite):
def test_report_executes_and_lists_po(self):
# get_data groups by (Purchase Order Item, Purchase Order) while selecting other parent
# columns; this exercises that GROUP BY so the report stays valid on Postgres (which rejects
# selecting non-grouped columns).
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.buying.report.purchase_order_analysis.purchase_order_analysis import execute
po = create_purchase_order(company="_Test Company")
filters = {
"company": "_Test Company",
"from_date": add_days(nowdate(), -1),
"to_date": add_days(nowdate(), 1),
}
result = execute(filters)
columns, data = result[0], result[1]
self.assertTrue(columns)
self.assertIn(po.name, {row.get("purchase_order") for row in data})

View File

@@ -3,7 +3,7 @@ from collections import OrderedDict
import frappe
from frappe import _, qb
from frappe.query_builder import Criterion
from frappe.query_builder.functions import IfNull, Sum
from frappe.query_builder.functions import IfNull, Max, Sum
from frappe.utils import fmt_money
from erpnext.accounts.doctype.budget.budget import BudgetError, get_accumulated_monthly_budget
@@ -260,7 +260,11 @@ class BudgetValidation:
qb.from_(mr)
.inner_join(mri)
.on(mr.name == mri.parent)
.select((Sum(IfNull(mri.stock_qty, 0) - IfNull(mri.ordered_qty, 0)) * mri.rate).as_("amount"))
# rate is outside the Sum (no GROUP BY -> implicit aggregate); Max() keeps it valid on
# postgres and matches MySQL's arbitrary single-rate choice for this aggregate.
.select(
(Sum(IfNull(mri.stock_qty, 0) - IfNull(mri.ordered_qty, 0)) * Max(mri.rate)).as_("amount")
)
.where(Criterion.all(conditions))
.run(as_dict=True)
):

View File

@@ -413,39 +413,29 @@ class BuyingController(SubcontractingController):
stock_and_asset_items = []
stock_and_asset_items = self.get_stock_items() + self.get_asset_items()
stock_and_asset_items_qty, stock_and_asset_items_amount = 0, 0
last_item_idx = 1
for d in self.get("items"):
if d.item_code:
stock_and_asset_items_qty += flt(d.qty)
stock_and_asset_items_amount += flt(d.base_net_amount)
(
tax_accounts,
total_valuation_amount,
total_actual_tax_amount,
total_actual_tax_on_stock_items,
) = self.get_tax_details()
last_item_idx = d.idx
# Pre-compute each item's share of the "Actual" valuation charges (keyed by row object).
actual_charge_per_item = self.distribute_actual_tax_amount(
stock_and_asset_items, total_actual_tax_amount, total_actual_tax_on_stock_items
)
tax_accounts, total_valuation_amount, total_actual_tax_amount = self.get_tax_details()
remaining_amount = total_actual_tax_amount
last_item_idx = max((d.idx for d in self.get("items")), default=1)
for i, item in enumerate(self.get("items")):
if item.item_code and (item.qty or item.get("rejected_qty")):
item_tax_amount, actual_tax_amount = 0.0, 0.0
if i == (last_item_idx - 1):
# dump any rounding remainder of the On Net Total valuation on the last item
item_tax_amount = total_valuation_amount
actual_tax_amount = remaining_amount
else:
# calculate item tax amount
item_tax_amount = self.get_item_tax_amount(item, tax_accounts)
total_valuation_amount -= item_tax_amount
if total_actual_tax_amount:
actual_tax_amount = self.get_item_actual_tax_amount(
item,
total_actual_tax_amount,
stock_and_asset_items_amount,
stock_and_asset_items_qty,
)
remaining_amount -= actual_tax_amount
# This code is required here to calculate the correct valuation for stock items
if item.item_code not in stock_and_asset_items:
item.valuation_rate = 0.0
@@ -453,7 +443,8 @@ class BuyingController(SubcontractingController):
# Item tax amount is the total tax amount applied on that item and actual tax type amount
item.item_tax_amount = flt(
item_tax_amount + actual_tax_amount, self.precision("item_tax_amount", item)
item_tax_amount + actual_charge_per_item.get(item.idx, 0.0),
self.precision("item_tax_amount", item),
)
self.round_floats_in(item)
@@ -494,6 +485,7 @@ class BuyingController(SubcontractingController):
tax_accounts = []
total_valuation_amount = 0.0
total_actual_tax_amount = 0.0
total_actual_tax_on_stock_items = 0.0
for d in self.get("taxes"):
if d.category not in ["Valuation", "Valuation and Total"]:
@@ -506,10 +498,13 @@ class BuyingController(SubcontractingController):
if d.charge_type == "On Net Total":
total_valuation_amount += amount
tax_accounts.append(d.account_head)
elif d.charge_type == "Actual" and d.get("allocate_full_amount_to_stock_items"):
# Allocate the full amount to stock/asset items only (e.g. Freight)
total_actual_tax_on_stock_items += amount
else:
total_actual_tax_amount += amount
return tax_accounts, total_valuation_amount, total_actual_tax_amount
return tax_accounts, total_valuation_amount, total_actual_tax_amount, total_actual_tax_on_stock_items
def get_item_tax_amount(self, item, tax_accounts):
item_tax_amount = 0.0
@@ -530,16 +525,75 @@ class BuyingController(SubcontractingController):
return item_tax_amount
def get_item_actual_tax_amount(
self, item, actual_tax_amount, stock_and_asset_items_amount, stock_and_asset_items_qty
):
item_proportion = (
flt(item.base_net_amount) / stock_and_asset_items_amount
if stock_and_asset_items_amount
else flt(item.qty) / stock_and_asset_items_qty
def distribute_actual_tax_amount(self, stock_and_asset_items, total_on_all_items, total_on_stock_items):
"""Distribute "Actual" valuation charges to each item, keyed by row idx.
`total_on_all_items` is spread across every item by net amount; a non-stock item's
share is computed but never capitalized (e.g. a genuine tax). `total_on_stock_items`
(flagged `allocate_full_amount_to_stock_items`) is spread across stock/asset items only,
so the whole charge is capitalized (e.g. Freight).
"""
all_items = [d for d in self.get("items") if d.item_code]
stock_items = [d for d in all_items if d.item_code in stock_and_asset_items]
charge_per_item = {}
self._spread_charge_over_items(charge_per_item, total_on_all_items, all_items)
self._spread_charge_over_items(charge_per_item, total_on_stock_items, stock_items)
return charge_per_item
def _spread_charge_over_items(self, charge_per_item, total_charge, items):
"""Add each item's proportional share of `total_charge` into `charge_per_item`.
Proportion is by net amount (falling back to qty); any rounding remainder is assigned
to the last item in the group."""
if not total_charge or not items:
return
total_amount = sum(flt(d.base_net_amount) for d in items)
total_qty = sum(flt(d.qty) for d in items)
# Nothing to proportion against (all rows have zero amount and zero qty)
if not total_amount and not total_qty:
return
remaining = total_charge
for d in items[:-1]:
proportion = flt(d.base_net_amount) / total_amount if total_amount else flt(d.qty) / total_qty
charge = flt(proportion * total_charge, self.precision("item_tax_amount", d))
charge_per_item[d.idx] = charge_per_item.get(d.idx, 0.0) + charge
remaining -= charge
last = items[-1]
charge_per_item[last.idx] = charge_per_item.get(last.idx, 0.0) + flt(
remaining, self.precision("item_tax_amount", last)
)
return flt(item_proportion * actual_tax_amount, self.precision("item_tax_amount", item))
def get_capitalized_valuation_tax(self):
stock_and_asset_items = self.get_stock_items() + self.get_asset_items()
all_items = [d for d in self.get("items") if d.item_code]
stock_item_idx = {d.idx for d in all_items if d.item_code in stock_and_asset_items}
capitalized = {}
for tax in self.get("taxes"):
if tax.category not in ("Valuation", "Valuation and Total"):
continue
amount = flt(tax.base_tax_amount_after_discount_amount) * (
-1 if tax.get("add_deduct_tax") == "Deduct" else 1
)
if not amount:
continue
if tax.charge_type == "Actual" and not tax.get("allocate_full_amount_to_stock_items"):
# Spread across all items; only the stock/asset items' share is capitalized.
charge_per_item = {}
self._spread_charge_over_items(charge_per_item, amount, all_items)
amount = sum(
charge for item_idx, charge in charge_per_item.items() if item_idx in stock_item_idx
)
capitalized[tax.name] = amount
return capitalized
def set_incoming_rate(self):
"""
@@ -1069,15 +1123,14 @@ class BuyingController(SubcontractingController):
asset = frappe.get_doc("Asset", asset.name)
if delete_asset and is_auto_create_enabled:
# need to delete movements to delete assets otherwise throws link exists error
movements = frappe.db.sql(
"""SELECT asm.name
FROM `tabAsset Movement` asm, `tabAsset Movement Item` asm_item
WHERE asm_item.parent=asm.name and asm_item.asset=%s""",
asset.name,
as_dict=1,
movements = frappe.get_all(
"Asset Movement Item",
filters={"asset": asset.name},
pluck="parent",
limit_page_length=0, # delete every movement of the asset (no default 20 cap)
)
for movement in movements:
frappe.delete_doc("Asset Movement", movement.name, force=1)
frappe.delete_doc("Asset Movement", movement, force=1)
frappe.delete_doc("Asset", asset.name, force=1)
continue
@@ -1170,17 +1223,12 @@ def validate_item_type(doc, fieldname, message):
if not items:
return
item_list = ", ".join(["%s" % frappe.db.escape(d) for d in items])
invalid_items = [
d[0]
for d in frappe.db.sql(
f"""
select item_code from tabItem where name in ({item_list}) and {fieldname}=0
""",
as_list=True,
)
]
invalid_items = frappe.get_all(
"Item",
filters={"name": ["in", items], fieldname: 0},
pluck="item_code",
limit_page_length=0, # validate every item in the document (no default 20 cap)
)
if invalid_items:
items = ", ".join([d for d in invalid_items])

View File

@@ -7,6 +7,7 @@ import json
import frappe
from frappe import _
from frappe.query_builder import Case
from frappe.utils import cstr, flt
from erpnext.utilities.product import get_item_codes_by_attributes
@@ -135,6 +136,57 @@ def validate_is_incremental(numeric_attribute, attribute, value, item):
)
def get_attribute_value_renames(item_attribute):
"""Return old to new attribute value mappings for renamed Item Attribute Value rows."""
if item_attribute.numeric_values:
return {}
db_value = item_attribute.get_doc_before_save()
if not db_value:
return {}
old_values = {d.name: d.attribute_value for d in db_value.item_attribute_values}
renames = {}
for row in item_attribute.item_attribute_values:
if row.name in old_values and old_values[row.name] != row.attribute_value:
renames[old_values[row.name]] = row.attribute_value
return renames
def update_variant_attribute_values(item_attribute):
"""Propagate renamed Item Attribute Values to Item Variant Attribute on variant items."""
value_map = get_attribute_value_renames(item_attribute)
if not value_map:
return
item_variant_table = frappe.qb.DocType("Item Variant Attribute")
item_table = frappe.qb.DocType("Item")
attribute_value = item_variant_table.attribute_value
attribute_value_case = Case()
for old_value, new_value in value_map.items():
attribute_value_case = attribute_value_case.when(attribute_value == old_value, new_value)
# Postgres has no UPDATE ... JOIN; restrict to variant items via a subquery on the parent instead.
variant_items = (
frappe.qb.from_(item_table)
.select(item_table.name)
.where(item_table.variant_of.isnotnull())
.where(item_table.variant_of != "")
)
(
frappe.qb.update(item_variant_table)
.set(attribute_value, attribute_value_case.else_(attribute_value))
.where(item_variant_table.parent.isin(variant_items))
.where(item_variant_table.attribute == item_attribute.name)
.where(attribute_value.isin(list(value_map)))
).run()
frappe.flags.attribute_values = None
def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True):
allow_rename_attribute_value = frappe.db.get_single_value(
"Item Variant Settings", "allow_rename_attribute_value"
@@ -395,13 +447,21 @@ def make_variant_item_code(template_item_code, template_item_name, variant):
abbreviations = []
for attr in variant.attributes:
item_attribute = frappe.db.sql(
"""select i.numeric_values, v.abbr
from `tabItem Attribute` i left join `tabItem Attribute Value` v
on (i.name=v.parent)
where i.name=%(attribute)s and (v.attribute_value=%(attribute_value)s or i.numeric_values = 1)""",
{"attribute": attr.attribute, "attribute_value": attr.attribute_value},
as_dict=True,
ia = frappe.qb.DocType("Item Attribute")
iav = frappe.qb.DocType("Item Attribute Value")
item_attribute = (
frappe.qb.from_(ia)
.left_join(iav)
.on(ia.name == iav.parent)
.select(ia.numeric_values, iav.abbr)
.where(
(ia.name == attr.attribute)
# attribute_value is a varchar column; cast the param to str so postgres doesn't choke on
# `varchar = numeric` for numeric attributes (where this side is irrelevant anyway, since
# numeric_values == 1 already satisfies the OR). Non-numeric values are already strings.
& ((iav.attribute_value == cstr(attr.attribute_value)) | (ia.numeric_values == 1))
)
.run(as_dict=True)
)
if not item_attribute:

View File

@@ -7,10 +7,18 @@ from collections import OrderedDict, defaultdict
import frappe
from frappe import qb, scrub
from frappe.desk.reportview import get_filters_cond, get_match_cond
from frappe.permissions import has_permission
from frappe.query_builder import Case, Criterion, DocType
from frappe.query_builder.functions import Concat, CustomFunction, Length, Locate, Substring, Sum
from frappe.query_builder.functions import (
Concat,
IfNull,
Length,
Locate,
Lower,
Round,
Substring,
Sum,
)
from frappe.utils import nowdate, today, unique
from pypika import Order
@@ -18,6 +26,7 @@ import erpnext
from erpnext.accounts.utils import build_qb_match_conditions
from erpnext.stock.get_item_details import ItemDetailsCtx, _get_item_tax_template
from erpnext.stock.utils import get_combine_datetime
from erpnext.utilities.query import get_filter_conditions_qb
# searches for active employees
@@ -34,7 +43,6 @@ def employee_query(
ignore_user_permissions: bool = False,
):
doctype = "Employee"
conditions = []
fields = get_fields(doctype, ["name", "employee_name"])
ignore_permissions = False
@@ -44,32 +52,45 @@ def employee_query(
ptype="select" if frappe.only_has_select_perm(doctype) else "read",
)
search_conditions = " or ".join([f"{field} like %(txt)s" for field in fields])
mcond = "" if ignore_permissions else get_match_cond(doctype)
Employee = frappe.qb.DocType("Employee")
search_str = f"%{txt}%"
txt_no_percent = txt.replace("%", "")
search_fields = list(dict.fromkeys([searchfield, *fields]))
search_conditions = [Employee[field].like(search_str) for field in search_fields]
return frappe.db.sql(
"""select {fields} from `tabEmployee`
where status in ('Active', 'Suspended')
and docstatus < 2
and ({key} like %(txt)s or {search_conditions})
{fcond} {mcond}
order by
(case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end),
(case when locate(%(_txt)s, employee_name) > 0 then locate(%(_txt)s, employee_name) else 99999 end),
idx desc,
name, employee_name
limit %(page_len)s offset %(start)s""".format(
**{
"fields": ", ".join(fields),
"key": searchfield,
"fcond": get_filters_cond(doctype, filters, conditions),
"mcond": mcond,
"search_conditions": search_conditions,
}
),
{"txt": "%%%s%%" % txt, "_txt": txt.replace("%", ""), "start": start, "page_len": page_len},
query = frappe.qb.get_query(
"Employee",
fields=fields,
filters=filters,
ignore_permissions=ignore_permissions,
)
query = (
query.where(Employee.status.isin(["Active", "Suspended"]))
.where(Employee.docstatus < 2)
.where(Criterion.any(search_conditions))
.orderby(
Case()
.when(Locate(txt_no_percent, Employee.name) > 0, Locate(txt_no_percent, Employee.name))
.else_(99999)
)
.orderby(
Case()
.when(
Locate(txt_no_percent, Employee.employee_name) > 0,
Locate(txt_no_percent, Employee.employee_name),
)
.else_(99999)
)
.orderby(Employee.idx, order=Order.desc)
.orderby(Employee.name)
.orderby(Employee.employee_name)
.limit(page_len)
.offset(start)
)
return query.run()
def has_ignored_field(reference_doctype, doctype):
meta = frappe.get_meta(reference_doctype)
@@ -99,74 +120,73 @@ def lead_query(
doctype = "Lead"
fields = get_fields(doctype, ["name", "lead_name", "company_name"])
searchfields = frappe.get_meta(doctype).get_search_fields()
searchfields = " or ".join(field + " like %(txt)s" for field in searchfields)
Lead = frappe.qb.DocType("Lead")
search_str = f"%{txt}%"
txt_no_percent = txt.replace("%", "")
return frappe.db.sql(
"""select {fields} from `tabLead`
where docstatus < 2
and ifnull(status, '') != 'Converted'
and ({key} like %(txt)s
or lead_name like %(txt)s
or company_name like %(txt)s
or {scond})
{mcond}
order by
(case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end),
(case when locate(%(_txt)s, lead_name) > 0 then locate(%(_txt)s, lead_name) else 99999 end),
(case when locate(%(_txt)s, company_name) > 0 then locate(%(_txt)s, company_name) else 99999 end),
idx desc,
name, lead_name
limit %(page_len)s offset %(start)s""".format(
**{
"fields": ", ".join(fields),
"key": searchfield,
"scond": searchfields,
"mcond": get_match_cond(doctype),
}
),
{"txt": "%%%s%%" % txt, "_txt": txt.replace("%", ""), "start": start, "page_len": page_len},
searchfields = frappe.get_meta(doctype).get_search_fields()
search_fields = list(dict.fromkeys([searchfield, "lead_name", "company_name", *searchfields]))
search_conditions = [Lead[field].like(search_str) for field in search_fields]
query = frappe.qb.get_query("Lead", fields=fields, filters=filters, ignore_permissions=False)
query = (
query.where(Lead.docstatus < 2)
.where(Lead.status.isnull() | (Lead.status != "Converted"))
.where(Criterion.any(search_conditions))
.orderby(
Case().when(Locate(txt_no_percent, Lead.name) > 0, Locate(txt_no_percent, Lead.name)).else_(99999)
)
.orderby(
Case()
.when(Locate(txt_no_percent, Lead.lead_name) > 0, Locate(txt_no_percent, Lead.lead_name))
.else_(99999)
)
.orderby(
Case()
.when(Locate(txt_no_percent, Lead.company_name) > 0, Locate(txt_no_percent, Lead.company_name))
.else_(99999)
)
.orderby(Lead.idx, order=Order.desc)
.orderby(Lead.name)
.orderby(Lead.lead_name)
.limit(page_len)
.offset(start)
)
return query.run()
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def tax_account_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
doctype = "Account"
company_currency = erpnext.get_company_currency(filters.get("company"))
def get_accounts(with_account_type_filter):
account_type_condition = ""
if with_account_type_filter:
account_type_condition = "AND account_type in %(account_types)s"
Account = frappe.qb.DocType("Account")
accounts = frappe.db.sql(
f"""
SELECT name, parent_account
FROM `tabAccount`
WHERE `tabAccount`.docstatus!=2
{account_type_condition}
AND is_group = 0
AND company = %(company)s
AND disabled = %(disabled)s
AND (account_currency = %(currency)s or ifnull(account_currency, '') = '')
AND `{searchfield}` LIKE %(txt)s
{get_match_cond(doctype)}
ORDER BY idx DESC, name
LIMIT %(limit)s offset %(offset)s
""",
dict(
account_types=filters.get("account_type"),
company=filters.get("company"),
disabled=filters.get("disabled", 0),
currency=company_currency,
txt=f"%{txt}%",
offset=start,
limit=page_len,
),
def get_accounts(with_account_type_filter):
query = frappe.qb.get_query("Account", fields=["name", "parent_account"], ignore_permissions=False)
query = (
query.where(Account.docstatus != 2)
.where(Account.is_group == 0)
.where(Account.company == filters.get("company"))
.where(Account.disabled == filters.get("disabled", 0))
.where(
(Account.account_currency == company_currency)
| Account.account_currency.isnull()
| (Account.account_currency == "")
)
.where(Account[searchfield].like(f"%{txt}%"))
)
return accounts
if with_account_type_filter:
query = query.where(Account.account_type.isin(filters.get("account_type")))
query = (
query.orderby(Account.idx, order=Order.desc).orderby(Account.name).limit(page_len).offset(start)
)
return query.run()
tax_accounts = get_accounts(True)
@@ -313,11 +333,19 @@ def item_query(
.where(date_condition)
.where(Criterion.any(search_conditions))
.orderby(
Case().when(Locate(txt_no_percent, item.name) > 0, Locate(txt_no_percent, item.name)).else_(99999)
Case()
.when(
Locate(Lower(txt_no_percent), Lower(item.name)) > 0,
Locate(Lower(txt_no_percent), Lower(item.name)),
)
.else_(99999)
)
.orderby(
Case()
.when(Locate(txt_no_percent, item.item_name) > 0, Locate(txt_no_percent, item.item_name))
.when(
Locate(Lower(txt_no_percent), Lower(item.item_name)) > 0,
Locate(Lower(txt_no_percent), Lower(item.item_name)),
)
.else_(99999)
)
.orderby(item.idx, order=Order.desc)
@@ -336,33 +364,28 @@ def bom(
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | str | None = None
):
doctype = "BOM"
conditions = []
fields = get_fields(doctype, ["name", "item"])
return frappe.db.sql(
"""select {fields}
from `tabBOM`
where `tabBOM`.docstatus=1
and `tabBOM`.is_active=1
and `tabBOM`.`{key}` like %(txt)s
{fcond} {mcond}
order by
(case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end),
idx desc, name
limit %(page_len)s offset %(start)s""".format(
fields=", ".join(fields),
fcond=get_filters_cond(doctype, filters, conditions).replace("%", "%%"),
mcond=get_match_cond(doctype).replace("%", "%%"),
key=searchfield,
),
{
"txt": "%" + txt + "%",
"_txt": txt.replace("%", ""),
"start": start or 0,
"page_len": page_len or 20,
},
BOM = frappe.qb.DocType("BOM")
txt_no_percent = txt.replace("%", "")
query = frappe.qb.get_query("BOM", fields=fields, filters=filters, ignore_permissions=False)
query = (
query.where(BOM.docstatus == 1)
.where(BOM.is_active == 1)
.where(BOM[searchfield].like(f"%{txt}%"))
.orderby(
Case().when(Locate(txt_no_percent, BOM.name) > 0, Locate(txt_no_percent, BOM.name)).else_(99999)
)
.orderby(BOM.idx, order=Order.desc)
.orderby(BOM.name)
.limit(page_len or 20)
.offset(start or 0)
)
return query.run()
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
@@ -372,7 +395,6 @@ def get_project_name(
proj = qb.DocType("Project")
qb_filter_and_conditions = []
qb_filter_or_conditions = []
ifelse = CustomFunction("IF", ["condition", "then", "else"])
if filters:
if filters.get("customer"):
@@ -406,7 +428,14 @@ def get_project_name(
# ordering
if txt:
# project_name containing search string 'txt' will be given higher precedence
q = q.orderby(ifelse(Locate(txt, proj.project_name) > 0, Locate(txt, proj.project_name), 99999))
q = q.orderby(
Case()
.when(
Locate(Lower(txt), Lower(proj.project_name)) > 0,
Locate(Lower(txt), Lower(proj.project_name)),
)
.else_(99999)
)
q = q.orderby(proj.idx, order=Order.desc).orderby(proj.name)
if page_len:
@@ -834,8 +863,6 @@ def get_expense_account(doctype: str, txt: str, searchfield: str, start: int, pa
@frappe.validate_and_sanitize_search_inputs
def warehouse_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: list):
# Should be used when item code is passed in filters.
doctype = "Warehouse"
conditions, bin_conditions = [], []
filter_dict = get_doctype_wise_filters(filters)
warehouse_field = "name"
@@ -844,30 +871,36 @@ def warehouse_query(doctype: str, txt: str, searchfield: str, start: int, page_l
searchfield = meta.get("title_field")
warehouse_field = meta.get("title_field")
query = """select `tabWarehouse`.`{warehouse_field}`,
CONCAT_WS(' : ', 'Actual Qty', ifnull(round(`tabBin`.actual_qty, 2), 0 )) actual_qty
from `tabWarehouse` left join `tabBin`
on `tabBin`.warehouse = `tabWarehouse`.name {bin_conditions}
where
`tabWarehouse`.`{key}` like {txt}
{fcond} {mcond}
order by ifnull(`tabBin`.actual_qty, 0) desc, `tabWarehouse`.`{warehouse_field}` asc
limit
{page_len} offset {start}
""".format(
warehouse_field=warehouse_field,
bin_conditions=get_filters_cond(
doctype, filter_dict.get("Bin"), bin_conditions, ignore_permissions=True
),
key=searchfield,
fcond=get_filters_cond(doctype, filter_dict.get("Warehouse"), conditions),
mcond=get_match_cond(doctype),
start=start,
page_len=page_len,
txt=frappe.db.escape(f"%{txt}%"),
wh = frappe.qb.DocType("Warehouse")
bin_dt = frappe.qb.DocType("Bin")
# Bin filters go on the LEFT JOIN so warehouses without a matching Bin row are still returned
join_condition = bin_dt.warehouse == wh.name
for condition in get_filter_conditions_qb("Bin", filter_dict.get("Bin")):
join_condition &= condition
# Base the query on Warehouse so get_query applies its user-permission match conditions;
# Bin is left-joined (its filters on the JOIN) so warehouses without a Bin row still match.
query = (
frappe.qb.get_query("Warehouse", fields=[warehouse_field], ignore_permissions=False)
.left_join(bin_dt)
.on(join_condition)
.select(
Concat("Actual Qty", " : ", IfNull(Round(bin_dt.actual_qty, 2), 0)).as_("actual_qty"),
)
.where(wh[searchfield].like(f"%{txt}%"))
)
return frappe.db.sql(query)
for condition in get_filter_conditions_qb("Warehouse", filter_dict.get("Warehouse")):
query = query.where(condition)
return (
query.orderby(IfNull(bin_dt.actual_qty, 0), order=Order.desc)
.orderby(wh[warehouse_field], order=Order.asc)
.limit(page_len)
.offset(start)
.run()
)
def get_doctype_wise_filters(filters):
@@ -881,15 +914,21 @@ def get_doctype_wise_filters(filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_batch_numbers(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
query = """select batch_id from `tabBatch`
where disabled = 0
and (expiry_date >= CURRENT_DATE or expiry_date IS NULL)
and name like {txt}""".format(txt=frappe.db.escape(f"%{txt}%"))
batch = frappe.qb.DocType("Batch")
query = (
frappe.qb.from_(batch)
.select(batch.batch_id)
.where(
(batch.disabled == 0)
& (batch.expiry_date.isnull() | (batch.expiry_date >= today()))
& batch.name.like(f"%{txt}%")
)
)
if filters and filters.get("item"):
query += " and item = {item}".format(item=frappe.db.escape(filters.get("item")))
query = query.where(batch.item == filters.get("item"))
return frappe.db.sql(query, filters)
return query.orderby(batch.batch_id).limit(page_len).offset(start).run()
@frappe.whitelist()
@@ -916,35 +955,41 @@ def item_manufacturer_query(
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_purchase_receipts(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
query = """
select pr.name
from `tabPurchase Receipt` pr, `tabPurchase Receipt Item` pritem
where pr.docstatus = 1 and pritem.parent = pr.name
and pr.name like {txt}""".format(txt=frappe.db.escape(f"%{txt}%"))
pr = frappe.qb.DocType("Purchase Receipt")
pr_item = frappe.qb.DocType("Purchase Receipt Item")
query = (
frappe.qb.from_(pr)
.inner_join(pr_item)
.on(pr_item.parent == pr.name)
.select(pr.name)
.distinct() # one row per receipt, not per matching item line
.where((pr.docstatus == 1) & pr.name.like(f"%{txt}%"))
)
if filters and filters.get("item_code"):
query += " and pritem.item_code = {item_code}".format(
item_code=frappe.db.escape(filters.get("item_code"))
)
query = query.where(pr_item.item_code == filters.get("item_code"))
return frappe.db.sql(query, filters)
return query.orderby(pr.name).limit(page_len).offset(start).run()
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_purchase_invoices(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
query = """
select pi.name
from `tabPurchase Invoice` pi, `tabPurchase Invoice Item` piitem
where pi.docstatus = 1 and piitem.parent = pi.name
and pi.name like {txt}""".format(txt=frappe.db.escape(f"%{txt}%"))
pi = frappe.qb.DocType("Purchase Invoice")
pi_item = frappe.qb.DocType("Purchase Invoice Item")
query = (
frappe.qb.from_(pi)
.inner_join(pi_item)
.on(pi_item.parent == pi.name)
.select(pi.name)
.distinct() # one row per invoice, not per matching item line
.where((pi.docstatus == 1) & pi.name.like(f"%{txt}%"))
)
if filters and filters.get("item_code"):
query += " and piitem.item_code = {item_code}".format(
item_code=frappe.db.escape(filters.get("item_code"))
)
query = query.where(pi_item.item_code == filters.get("item_code"))
return frappe.db.sql(query, filters)
return query.orderby(pi.name).limit(page_len).offset(start).run()
@frappe.whitelist()

View File

@@ -7,7 +7,7 @@ import frappe
from frappe import _, bold
from frappe.model.meta import get_field_precision
from frappe.query_builder import DocType
from frappe.query_builder.functions import Abs
from frappe.query_builder.functions import Abs, Sum
from frappe.utils import cint, flt, format_datetime, get_datetime
import erpnext
@@ -86,26 +86,27 @@ def validate_return_against(doc):
def validate_returned_items(doc):
valid_items = frappe._dict()
select_fields = "item_code, qty, stock_qty, rate, parenttype, conversion_factor, name"
select_fields = ["item_code", "qty", "stock_qty", "rate", "parenttype", "conversion_factor", "name"]
if doc.doctype != "Purchase Invoice":
select_fields += ",serial_no, batch_no"
select_fields += ["serial_no", "batch_no"]
if doc.doctype in ["Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"]:
select_fields += ",rejected_qty, received_qty"
select_fields += ["rejected_qty", "received_qty"]
for d in frappe.db.sql(
f"""select {select_fields} from `tab{doc.doctype} Item` where parent = %s""",
doc.return_against,
as_dict=1,
for d in frappe.get_all(
f"{doc.doctype} Item",
filters={"parent": doc.return_against},
fields=select_fields,
limit_page_length=0, # all item rows of the reference document are needed (no default 20 cap)
):
valid_items = get_ref_item_dict(valid_items, d)
if doc.doctype in ("Delivery Note", "Sales Invoice"):
for d in frappe.db.sql(
"""select item_code, qty, serial_no, batch_no from `tabPacked Item`
where parent = %s""",
doc.return_against,
as_dict=1,
for d in frappe.get_all(
"Packed Item",
filters={"parent": doc.return_against},
fields=["item_code", "qty", "serial_no", "batch_no"],
limit_page_length=0, # all packed-item rows are needed (no default 20 cap)
):
valid_items = get_ref_item_dict(valid_items, d)
@@ -271,29 +272,35 @@ def get_ref_item_dict(valid_items, ref_item_row):
def get_already_returned_items(doc):
column = "child.item_code, sum(abs(child.qty)) as qty, sum(abs(child.stock_qty)) as stock_qty"
if doc.doctype in ["Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"]:
column += """, sum(abs(child.rejected_qty) * child.conversion_factor) as rejected_qty,
sum(abs(child.received_qty) * child.conversion_factor) as received_qty"""
child = DocType(f"{doc.doctype} Item")
par = DocType(doc.doctype)
field = (
frappe.scrub(doc.doctype) + "_item"
if doc.doctype in ["Purchase Invoice", "Purchase Receipt", "Sales Invoice", "POS Invoice"]
else "dn_detail"
)
data = frappe.db.sql(
f"""
select {column}, child.{field}
from
`tab{doc.doctype} Item` child, `tab{doc.doctype}` par
where
child.parent = par.name and par.docstatus = 1
and par.is_return = 1 and par.return_against = %s
group by item_code, {field}
""",
doc.return_against,
as_dict=1,
query = (
frappe.qb.from_(child)
.inner_join(par)
.on(child.parent == par.name)
.select(
child.item_code,
Sum(Abs(child.qty)).as_("qty"),
Sum(Abs(child.stock_qty)).as_("stock_qty"),
child[field],
)
.where((par.docstatus == 1) & (par.is_return == 1) & (par.return_against == doc.return_against))
.groupby(child.item_code, child[field])
)
if doc.doctype in ["Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"]:
query = query.select(
Sum(Abs(child.rejected_qty) * child.conversion_factor).as_("rejected_qty"),
Sum(Abs(child.received_qty) * child.conversion_factor).as_("received_qty"),
)
data = query.run(as_dict=1)
items = {}
@@ -445,6 +452,8 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
doc.pricing_rules = []
doc.return_against = source.name
doc.set_warehouse = ""
if doctype == "Sales Invoice":
doc.is_debit_note = 0
if doctype == "Sales Invoice" or doctype == "POS Invoice":
doc.is_pos = source.is_pos

View File

@@ -4,6 +4,7 @@
import frappe
from frappe import _, bold, throw
from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt, get_link_to_form, nowtime
from erpnext.accounts.party import render_address
@@ -439,22 +440,34 @@ class SellingController(StockController):
product_bundle_items[item_code] = item_code in items_with_product_bundle
def get_already_delivered_qty(self, current_docname, so, so_detail):
delivered_via_dn = frappe.db.sql(
"""select sum(qty) from `tabDelivery Note Item`
where so_detail = %s and docstatus = 1
and against_sales_order = %s
and parent != %s""",
(so_detail, so, current_docname),
dn_item = frappe.qb.DocType("Delivery Note Item")
delivered_via_dn = (
frappe.qb.from_(dn_item)
.select(Sum(dn_item.qty))
.where(
(dn_item.so_detail == so_detail)
& (dn_item.docstatus == 1)
& (dn_item.against_sales_order == so)
& (dn_item.parent != current_docname)
)
.run()
)
delivered_via_si = frappe.db.sql(
"""select sum(si_item.qty)
from `tabSales Invoice Item` si_item, `tabSales Invoice` si
where si_item.parent = si.name and si.update_stock = 1
and si_item.so_detail = %s and si.docstatus = 1
and si_item.sales_order = %s
and si.name != %s""",
(so_detail, so, current_docname),
si = frappe.qb.DocType("Sales Invoice")
si_item = frappe.qb.DocType("Sales Invoice Item")
delivered_via_si = (
frappe.qb.from_(si_item)
.inner_join(si)
.on(si_item.parent == si.name)
.select(Sum(si_item.qty))
.where(
(si.update_stock == 1)
& (si_item.so_detail == so_detail)
& (si.docstatus == 1)
& (si_item.sales_order == so)
& (si.name != current_docname)
)
.run()
)
total_delivered_qty = (flt(delivered_via_dn[0][0]) if delivered_via_dn else 0) + (
@@ -464,14 +477,11 @@ class SellingController(StockController):
return total_delivered_qty
def get_so_qty_and_warehouse(self, so_detail):
so_item = frappe.db.sql(
"""select qty, warehouse from `tabSales Order Item`
where name = %s and docstatus = 1""",
so_detail,
as_dict=1,
so_item = frappe.db.get_value(
"Sales Order Item", {"name": so_detail, "docstatus": 1}, ["qty", "warehouse"], as_dict=True
)
so_qty = so_item and flt(so_item[0]["qty"]) or 0.0
so_warehouse = so_item and so_item[0]["warehouse"] or ""
so_qty = flt(so_item.qty) if so_item else 0.0
so_warehouse = (so_item.warehouse if so_item else "") or ""
return so_qty, so_warehouse
def check_sales_order_on_hold_or_close(self, ref_fieldname):

View File

@@ -5,6 +5,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.utils import comma_or, flt, get_link_to_form, getdate, now, nowdate, safe_div
@@ -186,7 +187,8 @@ class StatusUpdater(Document):
"""
def on_discard(self):
self.db_set("status", "Cancelled")
if self.meta.has_field("status"):
self.db_set("status", "Cancelled")
def update_prevdoc_status(self):
self.update_qty()
@@ -554,7 +556,7 @@ class StatusUpdater(Document):
args["second_source_extra_cond"] = ""
args["second_source_condition"] = frappe.db.sql(
""" select ifnull((select sum({second_source_field})
""" select coalesce((select sum({second_source_field})
from `tab{second_source_dt}`
where `{second_join_field}`=%(detail_id)s
and (`tab{second_source_dt}`.docstatus=1)
@@ -569,7 +571,7 @@ class StatusUpdater(Document):
args["source_dt_value"] = (
frappe.db.sql(
"""
(select ifnull(sum({source_field}), 0)
(select coalesce(sum({source_field}), 0)
from `tab{source_dt}` where `{join_field}`=%(detail_id)s
and (docstatus=1 {cond}) {extra_cond})
""".format(**args),
@@ -684,18 +686,10 @@ class StatusUpdater(Document):
if not ref_docs:
return
zero_amount_refdocs = frappe.db.sql_list(
f"""
SELECT
name
from
`tab{ref_dt}`
where
docstatus = 1
and base_net_total = 0
and name in %(ref_docs)s
""",
{"ref_docs": ref_docs},
zero_amount_refdocs = frappe.get_all(
ref_dt,
filters={"docstatus": 1, "base_net_total": 0, "name": ["in", ref_docs]},
pluck="name",
)
if zero_amount_refdocs:
@@ -703,20 +697,20 @@ class StatusUpdater(Document):
def update_billing_status(self, zero_amount_refdoc, ref_dt, ref_fieldname):
for ref_dn in zero_amount_refdoc:
ref_item = frappe.qb.DocType(f"{ref_dt} Item")
ref_doc_qty = flt(
frappe.db.sql(
"""select ifnull(sum(qty), 0) from `tab{} Item`
where parent={}""".format(ref_dt, "%s"),
(ref_dn),
)[0][0]
frappe.qb.from_(ref_item)
.select(Sum(ref_item.qty))
.where(ref_item.parent == ref_dn)
.run()[0][0]
)
doc_item = frappe.qb.DocType(f"{self.doctype} Item")
billed_qty = flt(
frappe.db.sql(
"""select ifnull(sum(qty), 0)
from `tab{} Item` where {}={} and docstatus=1""".format(self.doctype, ref_fieldname, "%s"),
(ref_dn),
)[0][0]
frappe.qb.from_(doc_item)
.select(Sum(doc_item.qty))
.where((doc_item[ref_fieldname] == ref_dn) & (doc_item.docstatus == 1))
.run()[0][0]
)
per_billed = safe_div(min(ref_doc_qty, billed_qty), ref_doc_qty) * 100

View File

@@ -5,6 +5,8 @@ import json
import frappe
from frappe import _, bold
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Count
from frappe.utils import cint, cstr, flt, get_link_to_form, getdate
import erpnext
@@ -279,11 +281,7 @@ class StockController(AccountsController):
def make_gl_entries_on_cancel(self, from_repost=False):
if not from_repost:
cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
if frappe.db.sql(
"""select name from `tabGL Entry` where voucher_type=%s
and voucher_no=%s""",
(self.doctype, self.name),
):
if frappe.db.exists("GL Entry", {"voucher_type": self.doctype, "voucher_no": self.name}):
self.make_gl_entries()
def validate_warehouse(self):
@@ -725,20 +723,18 @@ def future_sle_exists(args, sl_entries=None):
args["posting_datetime"] = get_combine_datetime(args["posting_date"], args["posting_time"])
data = frappe.db.sql(
"""
select item_code, warehouse, count(name) as total_row
from `tabStock Ledger Entry`
where
({})
and posting_datetime >= %(posting_datetime)s
and voucher_no != %(voucher_no)s
and is_cancelled = 0
GROUP BY
item_code, warehouse
""".format(" or ".join(or_conditions)),
args,
as_dict=1,
sle = frappe.qb.DocType("Stock Ledger Entry")
data = (
frappe.qb.from_(sle)
.select(sle.item_code, sle.warehouse, Count(sle.name).as_("total_row"))
.where(
Criterion.any(or_conditions)
& (sle.posting_datetime >= args["posting_datetime"])
& (sle.voucher_no != args["voucher_no"])
& (sle.is_cancelled == 0)
)
.groupby(sle.item_code, sle.warehouse)
.run(as_dict=1)
)
for d in data:
@@ -792,12 +788,10 @@ def get_conditions_to_validate_future_sle(sl_entries):
warehouse_items_map[entry.warehouse].add(entry.item_code)
sle = frappe.qb.DocType("Stock Ledger Entry")
or_conditions = []
for warehouse, items in warehouse_items_map.items():
or_conditions.append(
f"""warehouse = {frappe.db.escape(warehouse)}
and item_code in ({", ".join(frappe.db.escape(item) for item in items)})"""
)
or_conditions.append((sle.warehouse == warehouse) & sle.item_code.isin(list(items)))
return or_conditions

View File

@@ -509,8 +509,9 @@ class SubcontractingInwardController:
(
Case()
.when(
# bool() so the literal renders as true/false; postgres rejects `OR <integer>`
(table.produced_qty < table.qty)
| ValueWrapper(allow_delivery_of_overproduced_qty),
| ValueWrapper(bool(allow_delivery_of_overproduced_qty)),
table.produced_qty,
)
.else_(table.qty)
@@ -744,7 +745,14 @@ class SubcontractingInwardController:
"name": ["in", list(data.keys())],
"docstatus": 1,
},
fields=["rate", "name", "required_qty", "received_qty"],
fields=[
"rate",
"name",
"required_qty",
"received_qty",
"returned_qty",
"consumed_qty",
],
)
doc_updates = {}
@@ -752,13 +760,17 @@ class SubcontractingInwardController:
current_qty = flt(data[d.name].transfer_qty) * (1 if self._action == "submit" else -1)
current_rate = flt(data[d.name].rate)
# Calculate weighted average rate
old_total = d.rate * d.received_qty
# Weighted average rate must be computed on the on-hand balance
balance_qty = d.received_qty - d.returned_qty - d.consumed_qty
old_total = d.rate * balance_qty
current_total = current_rate * current_qty
new_balance_qty = balance_qty + current_qty
d.received_qty = d.received_qty + current_qty
d.rate = (
flt((old_total + current_total) / d.received_qty, precision) if d.received_qty else 0.0
flt((old_total + current_total) / new_balance_qty, precision)
if new_balance_qty > 0
else 0.0
)
if not d.required_qty and not d.received_qty:

View File

@@ -80,6 +80,19 @@ class TestQueries(ERPNextTestSuite):
wh = query(filters=[["Bin", "item_code", "=", "_Test Item"]])
self.assertGreaterEqual(len(wh), 1)
def test_get_batch_numbers_query(self):
# converted from raw SQL to query builder; assert it executes on both engines
query = add_default_params(queries.get_batch_numbers, "Batch")
self.assertIsInstance(query(txt="", filters={}), list | tuple)
def test_get_purchase_receipts_query(self):
query = add_default_params(queries.get_purchase_receipts, "Purchase Receipt")
self.assertIsInstance(query(txt="", filters={}), list | tuple)
def test_get_purchase_invoices_query(self):
query = add_default_params(queries.get_purchase_invoices, "Purchase Invoice")
self.assertIsInstance(query(txt="", filters={}), list | tuple)
def test_default_uoms(self):
self.assertGreaterEqual(frappe.db.count("UOM", {"enabled": 1}), 10)

View File

@@ -0,0 +1,39 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.tests.utils import ERPNextTestSuite
class TestSalesAndPurchaseReturn(ERPNextTestSuite):
@staticmethod
def _cancel_and_delete(doctype, name):
if not frappe.db.exists(doctype, name):
return
doc = frappe.get_doc(doctype, name)
if doc.docstatus == 1:
doc.cancel()
frappe.delete_doc(doctype, name, force=1)
def test_sales_return_validates_against_original(self):
# Submitting a return Delivery Note runs validate_returned_items (Item / Packed Item lookups
# via frappe.get_all) and get_already_returned_items (qb GROUP BY of the returned qty) -- both
# converted from raw SQL here. Exercises them on both engines.
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100)
self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name)
dn = create_delivery_note(qty=5)
self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name)
return_dn = make_sales_return(dn.name)
return_dn.insert()
return_dn.submit()
self.addCleanup(self._cancel_and_delete, "Delivery Note", return_dn.name)
self.assertEqual(return_dn.is_return, 1)
self.assertEqual(return_dn.items[0].qty, -5)

View File

@@ -0,0 +1,39 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.tests.utils import ERPNextTestSuite
class TestSellingControllerConversions(ERPNextTestSuite):
@staticmethod
def _cancel_and_delete(doctype, name):
if not frappe.db.exists(doctype, name):
return
doc = frappe.get_doc(doctype, name)
if doc.docstatus == 1:
doc.cancel()
frappe.delete_doc(doctype, name, force=1)
def test_partial_delivery_updates_sales_order_status(self):
# Submitting a Delivery Note against a Sales Order calls
# SellingController.get_already_delivered_qty / get_so_qty_and_warehouse and StatusUpdater
# (per_delivered via coalesce(sum(...))) -- all converted to query builder / ORM here.
from erpnext.selling.doctype.sales_order.mapper import make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100)
self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name)
so = make_sales_order(qty=10)
dn = make_delivery_note(so.name)
dn.items[0].qty = 4
dn.insert()
dn.submit()
self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name)
so.reload()
self.assertEqual(so.per_delivered, 40.0)

View File

@@ -0,0 +1,42 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_days, today
from erpnext.tests.utils import ERPNextTestSuite
class TestStockControllerConversions(ERPNextTestSuite):
@staticmethod
def _cancel_and_delete(doctype, name):
if not frappe.db.exists(doctype, name):
return
doc = frappe.get_doc(doctype, name)
if doc.docstatus == 1:
doc.cancel()
frappe.delete_doc(doctype, name, force=1)
def test_future_sle_exists_detects_later_entries(self):
# future_sle_exists / get_conditions_to_validate_future_sle were converted to query builder
# (Count + Criterion.any). A later SLE for the same item+warehouse must be detected, which
# exercises the converted GROUP BY query on both engines.
from erpnext.controllers.stock_controller import future_sle_exists
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
item = make_item("_Test Future SLE Item", {"is_stock_item": 1}).name
se = make_stock_entry(item_code=item, target="_Test Warehouse - _TC", qty=10, basic_rate=100)
self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name)
# Pretend a different voucher posts a day earlier for the same item/warehouse: the existing
# (later) SLE must be reported as a future entry.
args = frappe._dict(
voucher_type="Stock Entry",
voucher_no="_TEST-NONEXISTENT-SE",
posting_date=add_days(today(), -1),
posting_time="00:00:00",
)
sl_entries = [frappe._dict(item_code=item, warehouse="_Test Warehouse - _TC")]
self.assertTrue(future_sle_exists(args, sl_entries))

View File

@@ -0,0 +1,36 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import json
from erpnext.tests.utils import ERPNextTestSuite
class TestWebsiteListForContact(ERPNextTestSuite):
def test_get_list_context_currency_symbols(self):
# get_list_context builds the enabled-currency symbol map via frappe.get_all (converted from
# raw SQL). Exercises that query and asserts a known enabled currency is present.
from erpnext.controllers.website_list_for_contact import get_list_context
context = get_list_context()
symbols = json.loads(context["currency_symbols"])
self.assertIsInstance(symbols, dict)
self.assertIn("USD", symbols)
def test_rfq_transaction_list_returns_supplier_rfq(self):
# rfq_transaction_list filters RFQs by the supplier (parties[0]) and uses SELECT DISTINCT with
# ORDER BY creation -- both must be valid on Postgres, and the supplier filter must compare to the
# party value (not a stray `party[0]` column reference).
from erpnext.buying.doctype.request_for_quotation.test_request_for_quotation import (
make_request_for_quotation,
)
from erpnext.controllers.website_list_for_contact import rfq_transaction_list
rfq = make_request_for_quotation()
supplier = rfq.suppliers[0].supplier
rows = rfq_transaction_list(
"Request for Quotation Supplier", "Request for Quotation", [supplier], 0, 20
)
self.assertIn(rfq.name, [row.name for row in rows])

View File

@@ -111,6 +111,9 @@ def get_data(filters, conditions):
elif filters.get("group_by") == "Supplier":
sel_col = "t1.supplier"
# first column of the multi-column group_by = the based-on key the detail queries equate against
based_on_key = conditions["group_by"].split(",")[0].strip()
if filters.get("based_on") in ["Customer", "Supplier"]:
inc = 3
elif filters.get("based_on") in ["Item"]:
@@ -160,7 +163,7 @@ def get_data(filters, conditions):
posting_date,
"%s",
"%s",
conditions["group_by"],
based_on_key,
"%s",
conditions.get("addl_tables_relational_cond"),
cond,
@@ -177,6 +180,7 @@ def get_data(filters, conditions):
""" select t4.default_currency AS currency , {} , {} from `tab{}` t1, `tab{} Item` t2 {}
where t2.parent = t1.name and t1.company = {} and {} between {} and {}
and t1.docstatus = 1 and {} = {} and {} = {} {} {}
group by t4.default_currency, {}
""".format(
sel_col,
conditions["period_wise_select"],
@@ -189,10 +193,11 @@ def get_data(filters, conditions):
"%s",
sel_col,
"%s",
conditions["group_by"],
based_on_key,
"%s",
conditions.get("addl_tables_relational_cond"),
cond,
sel_col,
),
(filters.get("company"), year_start_date, year_end_date, row[i][0], data1[d][0]),
as_list=1,
@@ -307,8 +312,8 @@ def get_period_wise_columns(bet_dates, period, pwc):
def get_period_wise_query(bet_dates, trans_date, query_details):
query_details += """SUM(IF(t1.{trans_date} BETWEEN '{sd}' AND '{ed}', t2.stock_qty, NULL)),
SUM(IF(t1.{trans_date} BETWEEN '{sd}' AND '{ed}', t2.base_net_amount, NULL)),
query_details += """SUM(CASE WHEN t1.{trans_date} BETWEEN '{sd}' AND '{ed}' THEN t2.stock_qty ELSE NULL END),
SUM(CASE WHEN t1.{trans_date} BETWEEN '{sd}' AND '{ed}' THEN t2.base_net_amount ELSE NULL END),
""".format(
trans_date=trans_date,
sd=bet_dates[0],
@@ -365,7 +370,7 @@ def based_wise_columns_query(based_on, trans):
if based_on == "Item":
based_on_details["based_on_cols"] = ["Item:Link/Item:120", "Item Name:Data:120"]
based_on_details["based_on_select"] = "t2.item_code, t2.item_name,"
based_on_details["based_on_group_by"] = "t2.item_code"
based_on_details["based_on_group_by"] = "t2.item_code, t2.item_name"
based_on_details["addl_tables"] = ""
elif based_on == "Item Group":
@@ -389,7 +394,11 @@ def based_wise_columns_query(based_on, trans):
"Territory:Link/Territory:120",
]
based_on_details["based_on_select"] = "t1.customer, t1.customer_name, t1.territory,"
based_on_details["based_on_group_by"] = "t1.party_name" if trans == "Quotation" else "t1.customer"
based_on_details["based_on_group_by"] = (
"t1.party_name, t1.customer_name, t1.territory"
if trans == "Quotation"
else "t1.customer, t1.customer_name, t1.territory"
)
based_on_details["addl_tables"] = ""
elif based_on == "Customer Group":
@@ -405,7 +414,7 @@ def based_wise_columns_query(based_on, trans):
"Supplier Group:Link/Supplier Group:140",
]
based_on_details["based_on_select"] = "t1.supplier, t1.supplier_name, t3.supplier_group,"
based_on_details["based_on_group_by"] = "t1.supplier"
based_on_details["based_on_group_by"] = "t1.supplier, t1.supplier_name, t3.supplier_group"
based_on_details["addl_tables"] = ",`tabSupplier` t3"
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"
@@ -437,6 +446,7 @@ def based_wise_columns_query(based_on, trans):
frappe.throw(_("Project-wise data is not available for Quotation"))
based_on_details["based_on_select"] += "t4.default_currency as currency,"
based_on_details["based_on_group_by"] += ", t4.default_currency"
based_on_details["based_on_cols"].append("Currency:Link/Currency:120")
based_on_details["addl_tables"] += ", `tabCompany` t4"
based_on_details["addl_tables_relational_cond"] = (

View File

@@ -17,9 +17,12 @@ def get_list_context(context=None):
"currency": frappe.db.get_default("currency"),
"currency_symbols": json.dumps(
dict(
frappe.db.sql(
"""select name, symbol
from tabCurrency where enabled=1"""
frappe.get_all(
"Currency",
filters={"enabled": 1},
fields=["name", "symbol"],
as_list=True,
limit_page_length=0, # all enabled currencies are needed for the symbol map
)
)
),
@@ -181,9 +184,10 @@ def rfq_transaction_list(parties_doctype, doctype, parties, limit_start, limit_p
party = frappe.qb.DocType(parties_doctype)
data = (
frappe.qb.from_(party)
.select(party.parent.as_("name"), party.supplier)
# creation must be selected: Postgres requires SELECT DISTINCT order-by exprs in the select list
.select(party.parent.as_("name"), party.supplier, party.creation)
.distinct()
.where((party.supplier == party[0]) & (party.docstatus == 1))
.where((party.supplier == parties[0]) & (party.docstatus == 1))
.orderby(party.creation, order=frappe.qb.desc)
.limit(limit_page_length)
.offset(limit_start)

View File

@@ -290,13 +290,19 @@ class Opportunity(TransactionBase, CRMNote):
"name",
)
else:
return frappe.db.sql(
"""
select q.name
from `tabQuotation` q, `tabQuotation Item` qi
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
and q.status not in ('Lost', 'Closed')""",
self.name,
q = frappe.qb.DocType("Quotation")
qi = frappe.qb.DocType("Quotation Item")
return (
frappe.qb.from_(q)
.inner_join(qi)
.on(q.name == qi.parent)
.select(q.name)
.where(
(q.docstatus == 1)
& (qi.prevdoc_docname == self.name)
& q.status.notin(["Lost", "Closed"])
)
.run()
)
def has_ordered_quotation(self):
@@ -305,24 +311,20 @@ class Opportunity(TransactionBase, CRMNote):
"Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name"
)
else:
return frappe.db.sql(
"""
select q.name
from `tabQuotation` q, `tabQuotation Item` qi
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
and q.status = 'Ordered'""",
self.name,
q = frappe.qb.DocType("Quotation")
qi = frappe.qb.DocType("Quotation Item")
return (
frappe.qb.from_(q)
.inner_join(qi)
.on(q.name == qi.parent)
.select(q.name)
.where((q.docstatus == 1) & (qi.prevdoc_docname == self.name) & (q.status == "Ordered"))
.run()
)
def has_lost_quotation(self):
lost_quotation = frappe.db.sql(
"""
select name
from `tabQuotation`
where docstatus=1
and opportunity =%s and status = 'Lost'
""",
self.name,
lost_quotation = frappe.get_all(
"Quotation", filters={"docstatus": 1, "opportunity": self.name, "status": "Lost"}
)
if lost_quotation:
if self.has_active_quotation():
@@ -371,19 +373,19 @@ class Opportunity(TransactionBase, CRMNote):
@frappe.whitelist()
def get_item_details(item_code: str):
item = frappe.db.sql(
"""select item_name, stock_uom, image, description, item_group, brand
from `tabItem` where name = %s""",
item = frappe.db.get_value(
"Item",
item_code,
as_dict=1,
["item_name", "stock_uom", "image", "description", "item_group", "brand"],
as_dict=True,
)
return {
"item_name": item and item[0]["item_name"] or "",
"uom": item and item[0]["stock_uom"] or "",
"description": item and item[0]["description"] or "",
"image": item and item[0]["image"] or "",
"item_group": item and item[0]["item_group"] or "",
"brand": item and item[0]["brand"] or "",
"item_name": item and item.item_name or "",
"uom": item and item.stock_uom or "",
"description": item and item.description or "",
"image": item and item.image or "",
"item_group": item and item.item_group or "",
"brand": item and item.brand or "",
}

Some files were not shown because too many files have changed in this diff Show More