Compare commits

...

227 Commits

Author SHA1 Message Date
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
65d9f78409 Merge pull request #56057 from mihir-kandoi/pg-accounts-payments 2026-06-17 18:59:43 +05:30
Mihir Kandoi
acda04a4bd refactor(postgres): port Payment Request doctype queries to the query builder
3-way merged onto develop (preserving the get_party_bank_account import move).
get_subscription_details passes order_by="" so get_all does not inject the
doctype default sort the raw query never had.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:38:02 +05:30
Mihir Kandoi
d1e167815f refactor(postgres): port Payment Entry doctype queries to the query builder
3-way merged onto develop, preserving develop's set_exchange_rate(ref_doc=doc) change.
One portable raw query is intentionally kept (as on the source branch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:38:01 +05:30
Mihir Kandoi
588dfac4cd refactor(postgres): port Mode of Payment doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:38:00 +05:30
Mihir Kandoi
ac26c01e52 refactor(postgres): port Cashier Closing doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:38:00 +05:30
Mihir Kandoi
c0d2bd7bce refactor(postgres): port Invoice Discounting doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:37:59 +05:30
Mihir Kandoi
4d03e915f7 refactor(postgres): port Bank Transaction doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:37:58 +05:30
Mihir Kandoi
09beed9cc3 refactor(postgres): port Payment Order doctype queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:37:57 +05:30
Mihir Kandoi
0737a4cfbb Merge pull request #56051 from mihir-kandoi/pg-accounts-statements
refactor(postgres): port Accounts statement & ledger report queries to the query builder
2026-06-17 17:55:47 +05:30
Mihir Kandoi
1332ad7583 refactor(postgres): port Share Ledger report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:36:01 +05:30
Mihir Kandoi
058be399c3 refactor(postgres): port Asset Depreciation Ledger report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:36:01 +05:30
Mihir Kandoi
e482c846c8 refactor(postgres): port General Ledger report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:36:00 +05:30
Mihir Kandoi
6a60f072a8 refactor(postgres): port Dimension-wise Account Balance report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:59 +05:30
Mihir Kandoi
18c1f0f04d refactor(postgres): port Trial Balance report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:58 +05:30
Mihir Kandoi
217c107549 refactor(postgres): port Consolidated Trial Balance report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:57 +05:30
Mihir Kandoi
44ca5878b8 refactor(postgres): port Consolidated Financial Statement report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:56 +05:30
Mihir Kandoi
66e82c56b1 refactor(postgres): port Gross Profit report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:55 +05:30
Mihir Kandoi
65c0d35f2e refactor(postgres): port Budget Variance report query to the query builder
The raw get_fiscal_years query had no ORDER BY (de-facto oldest-first); the
get_all port adds explicit order_by="name asc" so the Fiscal Year doctype
default (name DESC) does not reverse the report column order / cumulative values.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:55 +05:30
Mihir Kandoi
e9eca10927 refactor(postgres): port Cash Flow report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:54 +05:30
Mihir Kandoi
6849d292f8 refactor(postgres): port financial statements helper queries to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:52 +05:30
Mihir Kandoi
261b7fe7aa Merge pull request #56047 from mihir-kandoi/pg-accounts-registers
refactor(postgres): port Accounts register report queries to the query builder
2026-06-17 17:09:03 +05:30
Mihir Kandoi
30568d36d0 refactor(postgres): port Accounts Receivable report query to the query builder
Most queries are straight raw-SQL -> query-builder ports. One rider:
get_future_payments_from_journal_entry sums future amounts with no GROUP BY,
so its non-aggregated identity columns (invoice_no/party/future_date/future_ref)
are wrapped in Max() to satisfy postgres strict GROUP BY. The summed amount is
unchanged; the attributed invoice/party label stays within MariaDB's existing
arbitrary-row indeterminacy for that already-aggregated single-row query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:49:48 +05:30
Mihir Kandoi
8527e78820 refactor(postgres): port POS Register report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:46:32 +05:30
Mihir Kandoi
ae4a5e82b0 refactor(postgres): port Item-wise Purchase Register report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:46:31 +05:30
Mihir Kandoi
196fce9792 refactor(postgres): port Purchase Register report query to the query builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:46:30 +05:30
Mihir Kandoi
449004d29a refactor(postgres): port Sales Register report query to the query builder
Also sort the distinct income / unrealized P&L account lists in python:
frame drops ORDER BY for distinct queries on postgres, so the generated
account-column order must be pinned in python to stay deterministic on
both backends.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:46:29 +05:30
Mihir Kandoi
bae3668bd0 Merge pull request #56045 from mihir-kandoi/pg-final-fixes
fix(postgres): final fix-class changes (asset GROUP BY + accounts-controller boolean)
2026-06-17 16:32:13 +05:30
Mihir Kandoi
8ce0e5386a Merge pull request #56042 from mihir-kandoi/codex/fix-stock-ageing-reco-ageing
fix: preserve stock ageing on non-serial reconciliation
2026-06-17 16:15:26 +05:30
Mihir Kandoi
4ba042c7c7 fix(postgres): compare Check fields with == 1 in accounts controller CASE/condition
disable_rounded_total and is_pos are smallint Check fields; postgres rejects
using them as bare boolean conditions in CASE WHEN / bitwise-AND, so compare
explicitly against 1. No-op on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:09:00 +05:30
Mihir Kandoi
59ad76c21e fix(postgres): satisfy strict GROUP BY in asset depreciation query
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:08:24 +05:30
Mihir Kandoi
1efe0be379 Merge pull request #56041 from mihir-kandoi/pg-engine-semantics
fix(postgres): assorted engine-semantics fixes (casing, null, boolean)
2026-06-17 16:07:07 +05:30
Mihir Kandoi
6bb7fa6d68 fix: preserve stock ageing on non-serial reconciliation 2026-06-17 15:55:22 +05:30
Mihir Kandoi
ef5feb613a fix(postgres): default empty balance_serial_no to "" in Available Serial No report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:42:04 +05:30
Mihir Kandoi
2fa9d7cee6 fix(postgres): match stored "Exchange Gain Or Loss" voucher_type casing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:42:02 +05:30
Mihir Kandoi
431dc208b3 Merge pull request #56040 from mihir-kandoi/pg-fn-swap
fix(postgres): replace MySQL-only SQL functions with portable equivalents
2026-06-17 15:40:43 +05:30
Mihir Kandoi
0b795a628f fix(postgres): use portable GroupConcat in Lost Opportunity report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:19:16 +05:30
Mihir Kandoi
595a4c8517 fix(postgres): replace MySQL IF() with Case in Cheques and Deposits report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:18:50 +05:30
Mihir Kandoi
9ec224c3fd fix(postgres): use CombineDatetime for work order stock-entry timestamps
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:18:49 +05:30
Mihir Kandoi
68415c341b fix(postgres): use portable DateDiff in supplier scorecard variables
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:18:48 +05:30
Mihir Kandoi
a43df3278f fix(postgres): use portable DateDiff/CurDate in Inactive Sales Items report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:18:47 +05:30
Mihir Kandoi
4d06b01abf Merge pull request #56037 from mihir-kandoi/pg-groupby-doctypes
fix(postgres): satisfy strict GROUP BY in doctype & core queries
2026-06-17 15:00:55 +05:30
ruthra kumar
a04d54b2fb Merge pull request #56034 from ruthra-kumar/remove_custom_utility_for_company_creation
refactor(test): remove custom utility for company creation
2026-06-17 14:13:22 +05:30
rohitwaghchaure
0476f318e4 Merge pull request #55807 from aerele/fix/support-#70713
fix(stock): allow partial raw material pick/transfer from work order
2026-06-17 14:10:29 +05:30
Mihir Kandoi
7fbfa35f95 fix(postgres): satisfy strict GROUP BY in serial and batch bundle
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:56 +05:30
Mihir Kandoi
bbf506e848 fix(postgres): satisfy strict GROUP BY in work order required items
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:55 +05:30
Mihir Kandoi
725fd8ca97 fix(postgres): satisfy strict GROUP BY in production plan sub-assembly queries
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:54 +05:30
Mihir Kandoi
85191d1cac fix(postgres): satisfy strict GROUP BY in production plan bom explosion
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:53 +05:30
Mihir Kandoi
93021a9d45 fix(postgres): satisfy strict GROUP BY in accounts advances service
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:52 +05:30
Mihir Kandoi
0afc6dd363 fix(postgres): satisfy strict GROUP BY in unreconcile payment
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:51 +05:30
Mihir Kandoi
6dc2e43dd6 fix(postgres): satisfy strict GROUP BY in process period closing voucher
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:50 +05:30
Mihir Kandoi
d34e4b8783 fix(postgres): satisfy strict GROUP BY in exchange rate revaluation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:49 +05:30
Mihir Kandoi
501acd0414 fix(postgres): satisfy strict GROUP BY in bank reconciliation tool
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:05:48 +05:30
Mihir Kandoi
113943f851 Merge pull request #56036 from mihir-kandoi/pg-groupby-reports
fix(postgres): satisfy strict GROUP BY in 12 reports
2026-06-17 13:39:35 +05:30
Mihir Kandoi
c124e90a89 fix(postgres): satisfy strict GROUP BY in Total Stock Summary report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:08 +05:30
Mihir Kandoi
9096b4a9df fix(postgres): satisfy strict GROUP BY in Stock And Account Value Comparison report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:07 +05:30
Mihir Kandoi
e69eaa5102 fix(postgres): satisfy strict GROUP BY in Product Bundle Balance report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:06 +05:30
Mihir Kandoi
e77b27ae99 fix(postgres): satisfy strict GROUP BY in Batch Wise Balance History report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:04 +05:30
Mihir Kandoi
60235f4b2b fix(postgres): satisfy strict GROUP BY in Payment Terms Status For Sales Order report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:03 +05:30
Mihir Kandoi
463103ebf1 fix(postgres): satisfy strict GROUP BY in Inactive Customers report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:02 +05:30
Mihir Kandoi
a3ec98a57c fix(postgres): satisfy strict GROUP BY in Process Loss Report report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:02 +05:30
Mihir Kandoi
9ab8803fed fix(postgres): satisfy strict GROUP BY in Bom Stock Analysis report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:01 +05:30
Mihir Kandoi
8a566e6ba5 fix(postgres): satisfy strict GROUP BY in Sales Pipeline Analytics report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:19:00 +05:30
Mihir Kandoi
812a06cf44 fix(postgres): satisfy strict GROUP BY in Requested Items To Order And Receive report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:18:59 +05:30
Mihir Kandoi
23778c3875 fix(postgres): satisfy strict GROUP BY in Voucher Wise Balance report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:18:57 +05:30
Mihir Kandoi
24a66d10e7 fix(postgres): satisfy strict GROUP BY in Deferred Revenue And Expense report
Wrap the non-aggregated, functionally-dependent column(s) in Max()/Min() (or add
them to GROUP BY) so the report's grouped query is valid under PostgreSQL's strict
GROUP BY. No behaviour change on MariaDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:18:56 +05:30
Mihir Kandoi
3faaa87645 Merge pull request #56033 from mihir-kandoi/pg-zero-date
fix(postgres): db-aware zero-date (0000-00-00) item end-of-life checks
2026-06-17 12:57:22 +05:30
ruthra kumar
afeaba5142 refactor(test): update assertion for new test records 2026-06-17 12:39:29 +05:30
Mihir Kandoi
1a016cbcd6 refactor(postgres): consistent zero-date pattern (address review)
Use the same explicit db-aware conditional-add as work_order/mapper.py for the
end_of_life check (shared _item_is_alive helper in reorder_item.py; inline in
stock_projected_qty.py) instead of the inline ternary that became == None on
postgres. Identical SQL, no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:35:29 +05:30
Mihir Kandoi
7532ec9f9a fix(postgres): db-aware zero-date (0000-00-00) item end-of-life checks
The "item is not discontinued" checks treat an item as alive when its
`end_of_life` is unset, in the future, or the MariaDB zero-date `'0000-00-00'`.
`'0000-00-00'` is an invalid date literal on PostgreSQL (it errors), and a
"not set" end_of_life is `NULL` there anyway — already covered by the existing
`end_of_life IS NULL` term. So the zero-date comparison is applied on MariaDB
only; PostgreSQL keeps the `IS NULL` / future-date terms. No behaviour change on
MariaDB.

Sites: work order item-master selection (`mapper.py`), reorder-level item
selection (`reorder_item.py`), and the Stock Projected Qty report.

Part of the staged MariaDB<->PostgreSQL parity rollout (one problem class).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:23:15 +05:30
Mihir Kandoi
48e66d04e6 Merge pull request #56025 from mihir-kandoi/pg-row-locking-cursor
fix(postgres): db-aware row-locking, savepoints & cursors
2026-06-17 12:19:55 +05:30
ruthra kumar
59a69fc497 refactor(test): broken test case in accounts controller 2026-06-17 10:22:00 +05:30
Mihir Kandoi
a899183087 fix(postgres): keep row locks via lock-then-read (address review)
Acquire the same row locks on postgres that MariaDB takes, via a separate plain
SELECT <pk> ... FOR UPDATE before each grouped/aggregate read (FOR UPDATE is
invalid with GROUP BY on postgres). Applied to all 6 aggregate lock sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:50:33 +05:30
ruthra kumar
3d109571ee refactor(test): remove even more dead code 2026-06-17 08:31:38 +05:30
Mihir Kandoi
d079677500 fix(postgres): db-aware row-locking, savepoints & cursors
PostgreSQL rejects `SELECT ... FOR UPDATE` when combined with `GROUP BY`,
aggregates or `DISTINCT`, has no concept of MySQL's locking semantics for those
shapes, and its server-side (unbuffered) cursors can't run nested queries
mid-iteration. This makes the row-locking / cursor paths db-aware so they keep
the exact MariaDB behaviour there and use the valid PostgreSQL form on Postgres.

One problem class, applied across the codebase:

- **`FOR UPDATE` + GROUP BY/aggregate** — keep `.for_update()` on MariaDB; on
  Postgres acquire the lock in a separate plain `SELECT ... FOR UPDATE` pass (or
  skip where the grouped read isn't a lock point). Deprecated serial/batch,
  serial-batch-bundle, pick list, stock reservation entry.
- **Unbuffered/server-side cursor** — Stock Ageing streamed via an unbuffered
  cursor and then ran nested queries; on Postgres that invalidates the cursor, so
  process the buffered result directly there.
- **Transaction savepoints** — Opening Invoice Creation Tool rolled back the whole
  transaction per failed invoice (which on Postgres also discards sibling rows and
  earlier error logs); scope each invoice to a savepoint instead.

No behaviour change on MariaDB (the locking/cursor path is unchanged there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:20:23 +05:30
ruthra kumar
6e62750c2f refactor(tests): reuse persistent master data instead of creating company per test
Replace per-test company creation in setUp() with persistent master data
from BootStrapTestData. Add Test PCV Company to test_records.json so it
becomes a persistent fixture rather than a throwaway created per test run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 08:12:54 +05:30
Diptanil Saha
faadc1620b fix(company): replaced "this company" with company name on delete transactions dialog (#56021) 2026-06-17 02:11:08 +05:30
MochaMind
f249d57b30 fix: sync translations from crowdin (#56018) 2026-06-17 00:54:16 +05:30
Mihir Kandoi
3f66541b99 Merge pull request #56020 from frappe/revert-56008-pg-manufacturing-projects
Revert "refactor(manufacturing, projects): make raw SQL portable to PostgreSQL (parity rollout 2/9)"
2026-06-16 23:50:48 +05:30
Mihir Kandoi
e7c2f8ee11 Merge pull request #56019 from frappe/revert-55994-pg-selling-buying
Revert "refactor(selling, buying): make raw SQL portable to PostgreSQL (parity rollout 1/9)"
2026-06-16 23:48:48 +05:30
Mihir Kandoi
8dacf62da0 Revert "refactor(manufacturing, projects): make raw SQL portable to PostgreSQ…"
This reverts commit 2d24eedab2.
2026-06-16 23:29:28 +05:30
Mihir Kandoi
935746e752 Revert "refactor(selling, buying): make raw SQL portable to PostgreSQL (parity rollout 1/9)" 2026-06-16 23:28:53 +05:30
Nikhil Kothari
4e2a10e496 fix: check for bank account permission when updating balance (#56016)
* fix: check for bank account permission when updating balance

* fix: add company to bank balance doctype
2026-06-16 17:23:55 +00:00
ruthra kumar
a32c784084 Merge pull request #55988 from ruthra-kumar/dropping_accountstestmixin
refactor(test): remove dependency on accounts test mixin
2026-06-16 22:02:21 +05:30
Mihir Kandoi
2d24eedab2 refactor(manufacturing, projects): make raw SQL portable to PostgreSQL (parity rollout 2/9) (#56008)
refactor(manufacturing, projects): make raw SQL portable to PostgreSQL

Convert the MariaDB-only raw `frappe.db.sql` in the Manufacturing and Projects
modules to the cross-database query builder / ORM, and fix the non-portable
constructs that remain. Every change is a no-op on MariaDB (identical rendered
SQL / identical results) and only brings PostgreSQL — standards-strict where
MySQL is lax — in line.

Areas: BOM (cost/where-used/explosion), Work Order (operations, required items,
mapper, stock report), Workstation, Production Plan sub-assembly/explosion
queries, BOM Stock Analysis / Process Loss / Work Order Stock reports; Projects
(project, task, timesheet, activity cost, project update), Daily Timesheet
Summary and Project-wise Stock Tracking reports.

Part of the staged MariaDB<->PostgreSQL parity rollout (module 2 of 9).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:20:36 +00:00
dependabot[bot]
ef1fbb7899 chore(deps): bump vite from 8.0.11 to 8.0.16 in /banking (#56007) 2026-06-16 16:00:43 +00:00
rohitwaghchaure
b5ecc9e6bd Merge pull request #55983 from rohitwaghchaure/fixed-recalculate-valuation-rate-in-pr
fix: provision to recalculate valuation rate during reposting
2026-06-16 21:21:59 +05:30
Mihir Kandoi
f195044fd1 Merge pull request #55994 from mihir-kandoi/pg-selling-buying
refactor(selling, buying): make raw SQL portable to PostgreSQL (parity rollout 1/9)
2026-06-16 21:18:57 +05:30
ruthra kumar
004087097c refactor(test): remove redundant clear method and minor fixes 2026-06-16 20:40:03 +05:30
Mihir Kandoi
992015424b Merge pull request #55978 from aerele/driver-permission
fix: update system manager permissions
2026-06-16 20:36:01 +05:30
Mihir Kandoi
a86b169d8b Merge pull request #55974 from aerele/fix/support-70978
fix(stock): update transfer status for mixed transfer flows
2026-06-16 20:34:14 +05:30
Rohit Waghchaure
e183e32619 fix: test case 2026-06-16 20:09:44 +05:30
Rohit Waghchaure
28992eb2f4 test: reset dont_execute_stock_reposts flag in tearDown
The test_prevention_of_cancelled_transaction_riv test sets
frappe.flags.dont_execute_stock_reposts = True without resetting it,
which leaked into the recalculate_valuation_rate tests and made
repost() a no-op (incoming rate stayed unchanged). Reset the flag
in tearDown so reposts run for subsequent tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 19:53:27 +05:30
Rohit Waghchaure
0ae61c4921 fix: provision to recalculate valuation rate during reposting 2026-06-16 19:47:48 +05:30
rohitwaghchaure
8190696d36 Merge pull request #55971 from rohitwaghchaure/fixed-actual-tax-amount
fix: exclude non-stock item's tax value from stock valuation
2026-06-16 19:10:50 +05:30
rohitwaghchaure
b12032485b Merge pull request #55986 from rohitwaghchaure/removed-redundant-validation
chore: removed redundant validation in SCR
2026-06-16 19:08:00 +05:30
Mihir Kandoi
be0f571d62 refactor(selling, buying): make raw SQL portable to PostgreSQL
Convert the MariaDB-only raw `frappe.db.sql` in the Selling and Buying
modules to the cross-database query builder / ORM, and fix the few
non-portable constructs that remain. Every change is a no-op on MariaDB
(identical rendered SQL / identical results) and only brings PostgreSQL —
which is standards-strict where MySQL is lax — in line.

Patterns addressed in these modules:

- Strict GROUP BY — PostgreSQL rejects SELECTing a non-aggregated column
  that isn't functionally dependent on the grouped key. Sales Order
  Analysis, Sales Analytics, Purchase Order Analysis and Procurement
  Tracker now group by the PK (1:1 with the existing key, so no behaviour
  change) or aggregate genuinely-independent columns.
- App clock vs DB clock — Sales Order Analysis computed delay against the
  database CURRENT_DATE, which differs from the app's today by a day when
  the DB runs a different timezone; switched to `nowdate()` (deterministic,
  identical on both DBs).
- Portable date math / functions — DATEDIFF and friends via the db-aware
  query-builder functions.
- Raw SQL → query builder for the remaining self-contained selling/buying
  reads (POS item search, customer naming suffix, packing-items
  availability, customer credit/acquisition reports).

Part of the staged MariaDB↔PostgreSQL parity rollout (module 1 of 9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:06:19 +05:30
Mihir Kandoi
3a1e4d14f3 Merge pull request #55985 from aerele/fix/batch-item-link-filters
fix(stock): show only batched items in batch item selector
2026-06-16 18:29:44 +05:30
ruthra kumar
1fda0dfb9b refactor(tests): replace AccountsTestMixin master data setup with direct attribute assignments
All test classes inheriting AccountsTestMixin that called create_company(),
create_item(), create_customer(), create_supplier(), create_usd_receivable_account(),
and create_usd_payable_account() in setUp() now set instance attributes directly
using master data pre-created by BootStrapTestData, eliminating redundant DB
inserts on every test run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 17:55:42 +05:30
Rohit Waghchaure
1b4487450c chore: removed redundant validation in SCR 2026-06-16 17:51:54 +05:30
Mihir Kandoi
9564f677e4 Merge pull request #55982 from frappe/codex/bom-secondary-item-modified-timestamp
fix: bump BOM secondary item modified timestamp
2026-06-16 17:39:52 +05:30
Dharanidharan2813
62f6d18143 fix(stock): show only batched items in batch item selector 2026-06-16 17:35:41 +05:30
pandiyan
1dbdf85ddc test(stock): validate completed status for mixed transfer methods 2026-06-16 17:06:26 +05:30
Mihir Kandoi
026ec8a6d9 fix: bump BOM secondary item modified timestamp 2026-06-16 16:52:16 +05:30
Mihir Kandoi
a9207f1e12 Merge pull request #55851 from frappe/feat-stock-balance-alt-uom-columns
feat: add alternate UOM balance columns to Stock Balance report
2026-06-16 16:43:03 +05:30
Nabin Hait
2a5ba9050e Merge pull request #55943 from aerele/ignore_cancelled_GLE
refactor: ignore cancelled GLE's while looking for account
2026-06-16 16:36:18 +05:30
pandiyan
02d41b1dac fix(stock): update transfer status for mixed transfer flows 2026-06-16 16:28:32 +05:30
nareshkannasln
501c8087cb fix: update system manager permissions 2026-06-16 16:08:52 +05:30
Rohit Waghchaure
13e1f84eb1 fix: exclude non-stock item's tax value from stock valuation 2026-06-16 15:35:18 +05:30
Nabin Hait
75394baa28 Merge pull request #55947 from nabinhait/fix/clearance-date-on-amend-54909
fix(accounts): clear clearance date when amending reconciled voucher
2026-06-16 15:28:22 +05:30
Nabin Hait
fb59f825ee Merge pull request #55785 from aerele/fix-payment-entry-transaction-currency
fix: set transaction currency on payment entry gl entries
2026-06-16 15:16:04 +05:30
Nabin Hait
34f78f7261 Merge pull request #55896 from nabinhait/fix/fixed-asset-turnover-ratio
fix: use net fixed assets for Fixed Asset Turnover Ratio
2026-06-16 15:13:03 +05:30
Dipen Gala
987f606b4d fix: resolve pre-commit formatting and missing UOM in stock balance test
- Reformat generator expression in add_alt_uom_columns to satisfy ruff
  line-length rule (pre-commit was auto-fixing this and failing CI)
- Create "Carton" UOM before use in test_alt_uom_balance_uses_first_alternate_uom
  to avoid LinkValidationError when "Carton" doesn't exist in test DB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 15:10:00 +05:30
Mihir Kandoi
b1b510c824 Merge pull request #55830 from aerele/fix/qi-stock-entry-inspection
fix(stock): enable quality inspection for all Stock Entry purposes
2026-06-16 15:07:47 +05:30
Mihir Kandoi
066158174e Merge pull request #55852 from umairsy/fix/allow-zero-qty-bom-secondary-items
fix(bom): allow zero qty for secondary items in BOM
2026-06-16 15:05:31 +05:30
Diptanil Saha
07d073da0d fix(accounts): removed whitelist on get_balance_on (#55956) 2026-06-16 14:34:52 +05:30
Sudharsanan11
ba1b1ee20d test(stock): add test to validate the partial transfer of raw material 2026-06-16 14:20:15 +05:30
Sudharsanan11
213adc9ebe fix(stock): allow partial raw material picking/transfer from work order
When creating a pick list for a work order with partially available stock,
the resulting Material Transfer for Manufacture stock entry was setting
fg_completed_qty = for_qty (= wo.qty), causing material_transferred_for_manufacturing
to reach wo.qty after just one partial transfer and blocking further pick lists.

Fix:
- Set fg_completed_qty = 0 on stock entries created from pick lists so the
  old SUM(fg_completed_qty) path never fires prematurely
- Recompute material_transferred_for_manufacturing after each transfer:
  use SUM(fg_completed_qty) when > 0 (direct entries / excess transfer),
  otherwise use min(transferred/required) × wo.qty (pick list flow)
- Add _validate_no_excess_transfer for pick list entries (fg_completed_qty=0)
  to prevent transferring more than pending qty; skip for return entries
  and when backflush is based on Material Transferred for Manufacture
- Remove the zero-qty prompt in pick list work_order trigger; skip the
  qty dialog in work_order.js when max transferable qty is already 0
- Hide fg_completed_qty field in Stock Entry for Material Transfer for
  Manufacture purpose since it is unused in that flow

Fixes: #70713, #63846
2026-06-16 14:20:15 +05:30
Nabin Hait
0e7d45b1af fix: minor fix
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-16 14:08:15 +05:30
ruthra kumar
7e602d5389 Merge pull request #53152 from aerele/fix_payment_entry
fix: prevent exchange rate flow from transaction to payment
2026-06-16 14:02:55 +05:30
rohitwaghchaure
529f8dc7cd Merge pull request #55826 from rohitwaghchaure/moved-files-to-services
refactor: moved files from stock_entry_handler to services
2026-06-16 13:57:08 +05:30
Sudharsanan11
609ccc3cb1 test(stock): add test to validate the quality inspection for stock entry 2026-06-16 13:11:06 +05:30
Sudharsanan11
dceb9a3c6c fix(stock): enable quality inspection for all Stock Entry purposes
- Remove `depends_on` restriction from `inspection_required` field so it
  is visible for all Stock Entry purposes, not just Manufacture
- Fix `check_item_quality_inspection` to return items for Stock Entry
  (was returning [] for unknown doctypes, blocking QI creation flow)
- Fix `inspection_type` in transaction.js to be purpose-aware: Manufacture
  and Material Receipt → "Incoming"; all other purposes → "Outgoing"
2026-06-16 12:34:55 +05:30
Shllokkk
52b406f5f1 fix(budget): add root_type filter on account field (#55934) 2026-06-16 11:43:52 +05:30
MochaMind
3dda2005d8 fix: sync translations from crowdin (#55900) 2026-06-16 11:13:36 +05:30
Jatin3128
322d4dff25 fix: clear stale payment rows on non-POS returns so they don't surface in bank reconciliation (#55903) 2026-06-16 10:42:36 +05:30
ruthra kumar
01a10fb5b0 Merge pull request #55949 from ruthra-kumar/speed_up_item_wise_inventory_account_tests
refactor(test): speed up item wise inventory test
2026-06-16 08:47:00 +05:30
ruthra kumar
4c084f7eff Merge pull request #55948 from ruthra-kumar/transaction_deletion_record_test_speed_up
refactor(test): faster transaction deletion record tests
2026-06-16 08:34:03 +05:30
ruthra kumar
627f2058b5 refactor(test): speed up item wise inventory test 2026-06-16 08:23:45 +05:30
ruthra kumar
8db4d2705a refactor(test): dont create master data in setUp 2026-06-16 08:03:47 +05:30
Nabin Hait
1a8d73cbbe fix(accounts): clear clearance date when amending reconciled voucher
The framework ignores `no_copy` while amending, so a reconciled voucher
carried a stale clearance date into its amendment even though the linked
bank transaction gets unreconciled on cancellation. Reset it via a shared
`before_insert` hook on AccountsController.

Fixes #54909
2026-06-16 00:03:41 +05:30
ruthra kumar
4ca7bc8ccf Merge pull request #55942 from ruthra-kumar/speed_up_delivery_note_tests
refactor(test): dont create company in setUp of Deliv Note
2026-06-15 20:16:16 +05:30
ervishnucs
40942401df refactor: ignore cancelled GLE's while looking for account 2026-06-15 18:22:30 +05:30
rohitwaghchaure
ca5cc4afdc Merge pull request #55928 from aerele/fix/support-#70854
fix(stock): update stock value calculation in stock balance report
2026-06-15 18:10:23 +05:30
Jatin3128
380b005659 fix: fiscal year check on validation (#55930) 2026-06-15 18:08:05 +05:30
ruthra kumar
df0ad93262 refactor(test): dont create company in setUp of Deliv Note 2026-06-15 17:51:02 +05:30
ruthra kumar
f503614cc0 Merge pull request #55929 from ruthra-kumar/parallelize_and_optimize_install_helper
ci: optimize install helper setup
2026-06-15 17:39:24 +05:30
Rohit Waghchaure
6d9beea56b refactor: moved files from stock_entry_handler to services 2026-06-15 17:28:50 +05:30
rohitwaghchaure
560d8bb674 Merge pull request #55926 from rohitwaghchaure/fixed-recalculate-rate-for-purchase-doc
fix: recalculate incoming rate in SLE for purchase documents during repost
2026-06-15 17:03:56 +05:30
Mihir Kandoi
a3e3e1b32c ci: optimize install helper setup
Cc: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:50:50 +05:30
Sudharsanan11
2492dfa558 fix(stock): update stock value calculation in stock balance report 2026-06-15 16:27:18 +05:30
ervishnucs
3b5a203d61 test: resolve failed testcases for exchage rate 2026-06-15 16:20:04 +05:30
ervishnucs
934abe5c6d fix: prevent exchange rate flow from transaction to payment 2026-06-15 16:20:04 +05:30
Rohit Waghchaure
867ee484b9 fix: recalculate incoming rate in SLE for purchase documents during repost 2026-06-15 16:09:03 +05:30
Mohammad Umair Sayed
c1bef53f92 refactor(bom): drop redundant secondary item qty None check
Float fields default to 0, so qty is never None. Per review feedback,
remove the validation entirely.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:06:24 +05:30
Diptanil Saha
2652082475 Merge pull request #55755 from diptanilsaha/feat/ces_frankfurter_v2
feat(currency exchange settings): frankfurter v2 support
2026-06-15 14:22:49 +05:30
diptanilsaha
abb579e2db fix(get_exchange_rate): using get_single_value to fetch disabled value from currency_exchange_settings 2026-06-15 13:52:34 +05:30
diptanilsaha
0c2d5488a6 fix: restricting currency_exchange_settings write permission only to system manager 2026-06-15 13:52:34 +05:30
diptanilsaha
138f683a68 test: fixed currency exchange test for frankfurter v2 api 2026-06-15 13:52:27 +05:30
diptanilsaha
479f9f63c9 fix: use frankfurter v2 by default for new install 2026-06-15 13:52:06 +05:30
diptanilsaha
56bfe6b6a6 feat(currency exchange settings): frankfurter v2 support 2026-06-15 13:52:06 +05:30
rohitwaghchaure
acae34c8e1 Merge pull request #55901 from rohitwaghchaure/fixed-regression-security-fixes
fix: regression issues related to security fixes
2026-06-15 12:20:38 +05:30
Mihir Kandoi
dcbe4a6d55 Merge pull request #55906 from raghavisruia/develop
fix: show company name in delete transactions confirmation dialog
2026-06-15 09:49:59 +05:30
Raghav Ruia
87d26a2d67 fix: show company name in delete transactions confirmation dialog
Display the actual company name in bold within the confirmation dialog
label so users immediately know which company they must type to confirm,
reducing the risk of accidental data loss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 09:45:55 +05:30
Rohit Waghchaure
e1d8d06966 refactor: consolidate duplicate get_party_bank_account into bank_account.py 2026-06-14 23:53:35 +05:30
Rohit Waghchaure
8c88cecc1f fix: regression issues related to security fixes 2026-06-14 23:42:48 +05:30
MochaMind
9aeafb8140 fix: sync translations from crowdin (#55784) 2026-06-14 17:37:37 +00:00
Nabin Hait
986af3852c fix: use net fixed assets for Fixed Asset Turnover Ratio
The Fixed Asset Turnover Ratio in the Financial Ratios report divided
Net Sales by Total Assets (the root-level Asset group), which actually
computes the Total Asset Turnover Ratio.

Populate a `fixed_asset` balance from the asset account carrying the
`Fixed Asset` account_type (mirroring how `current_asset` is derived for
`Current Asset`) and use it as the denominator, so the ratio reflects
Net Sales / Net Fixed Assets per the standard definition.

Fixes #54529
2026-06-14 20:06:32 +05:30
MochaMind
c24e9796ae chore: update POT file (#55894) 2026-06-14 13:11:21 +02:00
rohitwaghchaure
c7d42e161b Merge pull request #55877 from rohitwaghchaure/feat-allow_to_edit_stock_uom_qty_for_stock_entry
feat: Allow to edit stock UOM qty for Stock Entry
2026-06-14 09:54:24 +05:30
Raffael Meyer
701896692a ci: set disabledLabels and context for greptile (#55883) 2026-06-13 18:46:03 +00:00
Raffael Meyer
93d6be2ed7 fix(Lead): stop storing Gravatar image URLs for Leads (#55880) 2026-06-13 19:03:29 +02:00
Rohit Waghchaure
b0e9ad198f feat: Allow to edit stock UOM qty for Stock Entry 2026-06-13 21:41:05 +05:30
Dipen Gala
a9029f83c7 feat(invoices): add tooltip description to Update Stock checkbox (#55868)
* feat(invoices): add tooltip description to Update Stock checkbox

Adds a description below the Update Stock checkbox on both Sales Invoice
and Purchase Invoice so users understand when to use the field without
consulting documentation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(invoices): replace Update Stock description with hover info tooltip

Removes the inline description text and adds an ℹ icon next to the
Update Stock checkbox label on both Sales Invoice and Purchase Invoice.
Hovering the icon shows the contextual tooltip via Bootstrap tooltip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(invoices): use Frappe native tooltip-content class for Update Stock icon

Replace Bootstrap .tooltip() (pure black bg) with Frappe's own
.tooltip-content CSS class so the hover tooltip matches the rest of
the ERPNext UI — uses var(--bg-dark-gray) and var(--text-dark).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(invoices): use frappe.ui.SidebarCard for Update Stock info tooltip

Replace custom CSS tooltip with the same SidebarCard + Popper approach
Frappe's InfoCard uses for field description tooltips — gives the native
ERPNext card appearance (white card, border, shadow) on hover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(invoices): use built-in field description for Update Stock tooltip

Replace custom SidebarCard JS tooltip with Frappe's native
description + show_description_on_click field property on the
update_stock field in Sales Invoice and Purchase Invoice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove duplicate description in purchase_invoice update_stock field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert: restore custom tooltip in purchase_invoice.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert: remove all changes from purchase_invoice.js

Keep purchase_invoice.js identical to upstream develop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 20:48:03 +05:30
rohitwaghchaure
31e4da562d Merge pull request #55874 from rohitwaghchaure/fixed-permission-for-bom-comparison-tool
fix: permission in bom compare tool
2026-06-13 19:10:38 +05:30
Rohit Waghchaure
e6fdb3702a fix: permission in bom compare tool 2026-06-13 19:09:04 +05:30
rohitwaghchaure
bd60a9be90 Merge pull request #55849 from rohitwaghchaure/fixed-permissions-for-whitelist-functions
fix: permission for whitelist functions
2026-06-13 18:36:46 +05:30
Rohit Waghchaure
a64466561f fix: permission for whitelist functions 2026-06-13 17:45:37 +05:30
Mihir Kandoi
f7ff25d9a8 Merge pull request #55835 from mihir-kandoi/codex/develop-user-disable-audit-fix
fix: sync employee user status after save
2026-06-13 14:49:34 +05:30
Dipen Gala
021b807057 refactor(stock-balance): reduce alt UOM to single column, fix i18n, add tests
- Reduce from 2 alternate UOM columns to 1 (first alt UOM by idx)
- Fix broken translation strings: replace _(f"...{slot}") with
  _("...") — f-strings inside _() are never extracted by bench
  get-untranslated, breaking non-English installations
- Simplify fieldnames: alt_uom_1/alt_uom_1_bal_qty → alt_uom/alt_uom_bal_qty
- Add 4 test cases covering: single alt UOM, no alt UOM, disabled filter,
  and multiple alt UOMs (first-wins behaviour)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 11:34:28 +05:30
Diptanil Saha
c933e34914 fix: opportunity creation from contact us page (#55841) 2026-06-13 04:47:45 +00:00
Mihir Kandoi
87092961e7 Merge pull request #55853 from SandraFrappe/fix/cost-center
fix: pass source cost center to target cost center
2026-06-12 20:57:04 +05:30
Umair Sayed
bc7c0de208 refactor(bom): remove qty=0 alert from BOM Secondary Item JS
The informational toast is not required for the feature to work.
The core fix (reqd removed from JSON, validation relaxed in bom.py)
is sufficient to allow zero qty on BOM secondary items.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 16:10:56 +05:30
rohitwaghchaure
3f436985ed Merge pull request #55844 from rohitwaghchaure/fixed-job-card-permissions
fix: permissions in workstation file
2026-06-12 16:05:24 +05:30
Umair Sayed
de3df6bcef fix(manufacture): preserve user-entered rate for secondary items with zero cost allocation
When a BOM secondary item has cost_allocation_per = 0 (the default), the
previous code unconditionally computed `0 / transfer_qty = 0`, wiping any
rate the user had entered for the item. Now the allocation formula only runs
when cost_allocation_per > 0, allowing the valuation-rate fallback (or a
manually entered rate) to apply instead.

Additionally, secondary items with transfer_qty = 0 now short-circuit the
entire rate pipeline: they get rate = 0 and amount = 0 immediately, avoiding
a ZeroDivisionError and the spurious "enter basic rate" prompt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 16:04:12 +05:30
Rohit Waghchaure
cf127e8900 fix: permissions in workstation file 2026-06-12 15:37:46 +05:30
Umair Sayed
6771daf6a1 fix(bom): allow zero qty for secondary items (Co-Product, By-Product, Scrap, Additional Finished Good)
Secondary output items in a BOM do not always guarantee output during
manufacture. The actual qty is only known when manufacturing completes,
so setting zero in the BOM is a valid way to express "output is
non-deterministic".

Changes:
- Remove `reqd: 1` from the qty field in BOM Secondary Item so that 0
  is accepted as an explicit value (non_negative constraint is kept, so
  negative values are still rejected).
- Relax validate_secondary_items() in bom.py to only reject qty that is
  None/missing, not qty that is explicitly 0.
- Add a qty event handler in bom.js that shows a blue informational
  alert when the user sets qty to 0, explaining that the actual output
  will be recorded at manufacture time.

Fixes https://github.com/frappe/erpnext/issues/55401

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 14:45:02 +05:30
SandraFrappe
9ea766fc10 fix: pass source cost center to target cost center 2026-06-12 14:44:22 +05:30
Dipen Gala
2d93c5835a feat: add alternate UOM balance columns to Stock Balance report
Closes #52953

The Stock Balance report previously showed balance qty only in the item's
stock UOM. To view balance in an alternate UOM, users had to set the
"Include UOM" filter — which applies a single UOM to all items. This breaks
down when different items use different alternate UOMs (e.g., Pens in Box,
Ink in Milliliters).

This change adds a new "Show Alternate UOM Balance" checkbox filter. When
enabled, up to two alternate UOM columns are injected right after the
Balance Qty column:

  Balance Qty | Alt UOM 1 | Balance Qty (Alt UOM 1) | Alt UOM 2 | Balance Qty (Alt UOM 2)

Each row resolves its own alternate UOMs from `tabUOM Conversion Detail`
(ordered by idx, excluding the item's stock UOM). The converted balance
qty is computed as: stock qty / conversion_factor.

Items with fewer than 2 alternate UOMs leave the extra columns blank.
The existing "Include UOM" filter behaviour is unchanged.
2026-06-12 14:11:13 +05:30
rohitwaghchaure
53180fde93 Merge pull request #55845 from frappe/fix-update-stock-expense-head-warning
fix: remove unnecessary expense head warning for purchase invoices with update stock
2026-06-12 13:29:34 +05:30
Dipen Gala
224dff32df fix: remove unnecessary expense head warning for purchase invoices with update stock
When a Purchase Invoice is created with `update_stock = 1`, the system
automatically replaces the item's expense account with the correct
inventory account for perpetual inventory. This is expected behaviour,
but a `frappe.msgprint` warning was being shown to the user:

  "Expense Head changed to Stock In Hand because account Cost of Goods
   Sold is not linked to warehouse Stores or it is not the default
   inventory account."

The message is purely informational, provides no actionable guidance,
and confuses users who deliberately enable Update Stock. The underlying
account substitution logic is unchanged; only the popup is suppressed.

The two other `msgprint` calls (for the Purchase-Receipt-linked and
no-Purchase-Receipt flows) are intentionally preserved — those surface
a genuine change in behaviour that users may not expect.

Fixes: https://github.com/frappe/erpnext/issues/...
2026-06-12 12:57:58 +05:30
rohitwaghchaure
292bfa2a34 Merge pull request #55832 from rohitwaghchaure/fixed-regression-55827
fix: bom creator issue
2026-06-12 00:00:01 +05:30
Mihir Kandoi
e90896ced7 Merge pull request #55838 from aerele/fix/uom-mandatory
fix(stock): make uom mandatory in item uom table
2026-06-11 23:39:00 +05:30
Rohit Waghchaure
c360487cd1 fix: converted whitelist non class methods to class methods 2026-06-11 23:33:47 +05:30
pandiyan
a0177fdbe8 fix(stock): make uom mandatory in item uom table 2026-06-11 23:03:27 +05:30
Mihir Kandoi
64175bdb3e fix: skip unchanged employee user status sync 2026-06-11 21:34:43 +05:30
Mihir Kandoi
4fed04c6c7 fix: sync employee user status after save 2026-06-11 20:58:35 +05:30
Rohit Waghchaure
35fe9c60c7 fix: bom creator issue 2026-06-11 20:27:35 +05:30
Mihir Kandoi
878c22fa3f Merge pull request #55820 from aerele/fix/support-70979
fix: show user disable audit log
2026-06-11 20:27:01 +05:30
rohitwaghchaure
12ada21639 Merge pull request #55827 from rohitwaghchaure/fixed-bom-creator-security-issues
fix: multiple issues related to BOM Creator
2026-06-11 19:45:12 +05:30
Rohit Waghchaure
daf3f2e142 fix: multiple issues related to BOM Creator 2026-06-11 19:15:14 +05:30
rohitwaghchaure
ea3ec325e2 Merge pull request #55806 from rohitwaghchaure/refactor-stock-reservation
refactor: stock reservation feature
2026-06-11 13:53:23 +05:30
pandiyan
73d1852773 fix: show user disable audit log 2026-06-11 13:48:41 +05:30
Rohit Waghchaure
9c5f9218b5 refactor: stock reservation feature 2026-06-11 13:27:13 +05:30
ruthra kumar
a8a78a2163 Merge pull request #55695 from kaulith/fix/ar-report-respect-user-permissions
fix: apply user permissions to receivable/payable reports
2026-06-11 12:27:27 +05:30
Diptanil Saha
0b6121422d fix: added doctype filter validation for sales person wise transaction summary report (#55812) 2026-06-11 06:50:21 +00:00
Mohammad Umair Sayed
9249fa89aa fix(bom): fetch routing operations when Routing is selected (#55813)
fix(bom): fetch routing operations when routing is selected

frm.doc.operations is always an array in Frappe, so !frm.doc.operations
was always false (empty array [] is truthy in JS), causing get_routing()
to never fire when a Routing is selected on a BOM with no existing
operations.

Changed the guard to !frm.doc.operations.length so the fetch triggers
correctly when the operations table is empty.

Also wired the same fetch into the with_operations handler so that
enabling the checkbox after a Routing is already set will populate
operations without requiring the user to re-select the Routing.

Co-authored-by: Umair Sayed <umairsayed@Umairs-MacBook-Air-2.local>
2026-06-11 06:20:00 +00:00
Mihir Kandoi
5a816d19cb Merge pull request #55793 from mihir-kandoi/fix-bundle-dialog-lookup
fix(buying): resolve Get Items from Product Bundle by document name
2026-06-10 22:31:14 +05:30
Mihir Kandoi
a7d41f24a3 fix(stock): don't KeyError when neither bundle nor item_code is passed
row is a plain dict subclass, so row["item_code"] raised an unhandled
KeyError (500) when the payload had neither key. get_active_product_bundle
already returns None for falsy input, yielding an empty item list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:02:37 +05:30
Mihir Kandoi
81a1c2c8ce fix(stock): document-level permission check on the legacy bundle path
The legacy item_code path now resolves the active bundle's name via
get_active_product_bundle (same filters as the old joined query) so
frappe.has_permission can validate the specific document on both
branches. The orphaned get_product_bundle_items helper is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:51:49 +05:30
Mihir Kandoi
0c6f7fed55 fix(stock): permission check and test cleanup for bundle item fetch
The whitelisted get_items_from_product_bundle endpoint now verifies read
permission on Product Bundle (doc-level when a name is passed, doctype-
level for the legacy item_code path) so authenticated users can't
enumerate bundle components. The disabled-bundle test also restores the
disabled flag via addCleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:42:55 +05:30
Mihir Kandoi
bfee9df9aa fix: linter error 2026-06-10 18:53:32 +05:30
ravibharathi656
288f36bbd7 test: assert transaction currency and rate on payment entry gl entries 2026-06-10 16:41:39 +05:30
ravibharathi656
a3c9072812 fix: set transaction currency on payment entry gl entries 2026-06-10 16:41:26 +05:30
Mihir Kandoi
bddd1d0ebc fix(buying): resolve Get Items from Product Bundle by document name
Since Product Bundles became versioned, their names are PB-prefixed and
no longer double as the parent item code. The buying dialog kept passing
the picked bundle name as `item_code`, so the component lookup (which
filters `new_item_code`) matched nothing and the dialog silently added
no items.

The dialog now sends the selection as `product_bundle` and the endpoint
fetches that version's components by document name (rejecting
unsubmitted versions); passing `item_code` still resolves the parent
item's active version, preserving the legacy contract of the
whitelisted endpoint. The picker is also restricted to submitted
bundles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:40:12 +05:30
Nabin Hait
aa9f225c41 Merge pull request #55780 from nabinhait/refactor-je-services-internals
refactor(journal_entry): tidy the JE services and mapper internals
2026-06-10 11:54:04 +05:30
Mihir Kandoi
9c799f31ff Merge pull request #55791 from mihir-kandoi/product-bundle-disabled
feat(selling): allow disabling a Product Bundle
2026-06-10 11:42:25 +05:30
Mihir Kandoi
a60afaf91a Merge pull request #55789 from mihir-kandoi/fix-stock-ageing-unbuffered-cursor
fix: prefetch batchwise valuations before streaming SLEs in stock ageing
2026-06-10 11:37:23 +05:30
Nabin Hait
a4cff805f1 test(journal_entry): pin transaction-currency conversion in GL entries
Mutation testing on gl_composer surfaced that the foreign-row
debit/credit_in_transaction_currency conversion (amount / exchange_rate) was
unverified -- a / vs * bug survived. Assert those fields in test_multi_currency
and add a foreign-debit case so both conversion directions are now caught.
2026-06-10 11:23:19 +05:30
Nabin Hait
4f55071eda test(journal_entry): cover the untested mapper builders
Add characterization tests for get_payment_entry_against_order (the Sales/
Purchase Order advance path, previously untested) and make_inter_company_journal_entry
(previously fully uncovered).
2026-06-10 11:23:19 +05:30
Nabin Hait
43bb6c5a42 refactor(journal_entry): break up unlink_asset_reference and type/document asset service
Split AssetService.unlink_asset_reference into _is_depreciation_asset_row /
_reverse_asset_depreciation / _restore_scheduled_depreciation /
_restore_finance_book_value / _block_scrap_journal_cancel, and add return type
hints and docstrings across the service. Behaviour preserved (netted by the
asset suite).
2026-06-10 11:22:15 +05:30
Nabin Hait
34955380ee refactor(journal_entry): break up get_payment_entry and add types/docstrings to mapper
Split get_payment_entry into _reference_exchange_rate / _append_party_row /
_append_bank_row, and add return type hints and docstrings to all mapper
document builders. Behaviour preserved.
2026-06-10 11:22:15 +05:30
Nabin Hait
1714e13b39 refactor(journal_entry): tidy reference-validator and GL-composer services
Add return type hints and option-A docstrings to JournalEntryReferenceValidator,
and split JournalEntryGLComposer.compose into _set_transaction_currency and
_gl_row helpers. Behaviour preserved.
2026-06-10 11:22:15 +05:30
Nabin Hait
263c3e9dd4 Merge pull request #55779 from nabinhait/refactor-je-functions
refactor(journal_entry): smaller functions, Query Builder, type hints and docstrings
2026-06-10 11:20:05 +05:30
Mihir Kandoi
c97c2d1e02 test(selling): cover disabled Product Bundle behaviour
Resolution skips disabled bundles, transactions referencing a disabled
version are blocked, rows without an explicit version stop packing, and
the Item Where Used report surfaces the disabled flag on bundle rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:57:48 +05:30
Mihir Kandoi
cf37478870 feat(selling): allow disabling a Product Bundle
Un-deprecate the `disabled` checkbox: it is now editable (also after
submit) and parks a bundle version without ceding its active slot, so
re-enabling restores it without re-activation.

- `get_active_product_bundle` (the single resolution entry point) skips
  disabled bundles, so every consumer stops treating the item as a bundle
  while it is disabled
- the version pickers on transaction item rows and the buying "Get Items
  from Product Bundle" dialog filter out disabled bundles
- an explicitly selected disabled version blocks the transaction with a
  validation error instead of silently re-packing another version
- Product Bundle Balance report excludes disabled bundles
- list view indicator: Disabled (grey) / Active (green), falling back to
  docstatus for drafts, cancelled and inactive submitted versions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:57:48 +05:30
Mihir Kandoi
060a5c4eeb fix: prefetch batchwise valuations before streaming SLEs in stock ageing
Stock Ageing iterates stock ledger entries through an unbuffered
(streaming) cursor. _get_batchwise_valuation() lazily queried
Batch.use_batchwise_valuation from inside that loop whenever a row
carried the legacy batch_no field, and the nested query invalidated
the active streaming result set — crashing the report (or silently
dropping the remaining rows, depending on the driver version).

Resolve the valuation flags in a single query before entering the
unbuffered cursor block; the lazy lookup now only serves callers that
pass stock ledger entries in directly, where no streaming is active.

Fixes https://github.com/frappe/erpnext/issues/55786

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:49:27 +05:30
Nabin Hait
f099dbad35 refactor(journal_entry): give get_outstanding an explicit parameter list
Replace the single opaque `args` parameter of the whitelisted get_outstanding
with explicit named parameters (the supported interface), splitting the body
into _get_journal_entry_outstanding / _get_invoice_outstanding. The legacy
`args` payload is still accepted via kwargs for backward compatibility with
custom apps. Resolves the overusing-args semgrep finding.
2026-06-09 23:28:12 +05:30
Nabin Hait
cc8ce03232 test(journal_entry): cover write-off, balance and advance-unlink flows; drop dead code
Add characterization tests for the previously untested get_balance (difference
on a blank row), get_outstanding_invoices (write-off rows) and
unlink_advance_entry_reference (reference cleared on cancel). Remove the unused
get_average_exchange_rate, which has no callers in erpnext.
2026-06-09 23:07:03 +05:30
Nabin Hait
bcc1e73962 docs(journal_entry): add class and public-method docstrings
Add a class docstring plus docstrings for the lifecycle hooks and the public
API helpers (get_outstanding, get_against_jv, get_exchange_rate, etc.).
Self-evident one-line methods are intentionally left undocumented.
2026-06-09 22:44:19 +05:30
Nabin Hait
32d7250946 refactor(journal_entry): break up reporting, exchange-rate and balance methods
Decompose update_invoice_discounting, set_print_format_fields,
get_balance_for_periodic_accounting, set_exchange_rate, get_balance and
get_outstanding_invoices into focused per-row / row-building helpers (verb
prefixed, with docstrings). The nested closure in update_invoice_discounting
that ignored its row id is dropped. Behaviour preserved.
2026-06-09 22:40:35 +05:30
Nabin Hait
4c1cabb53e refactor(journal_entry): break up create_remarks and validate_against_jv
Split create_remarks into _cheque_remark / _reference_remark / _bill_remark
helpers, and validate_against_jv into _validate_jv_reference,
_validate_jv_reference_direction and _against_jv_entries. Add docstrings.
Behaviour preserved.
2026-06-09 22:30:51 +05:30
Nabin Hait
1105cb8ddf refactor(journal_entry): add missing type hints
Add return annotations to the module-level helpers and to make_gl_entries,
get_balance and set_total_amount, plus parameter types for set_total_amount
and make_gl_entries.
2026-06-09 22:24:44 +05:30
Nabin Hait
8bb4ffc6b1 refactor(journal_entry): replace raw SQL with Query Builder
Convert the five raw frappe.db.sql calls to Query Builder / ORM: the
against-JV lookup, the write-off invoice listing (get_values, now a single
query), the JV outstanding aggregate (get_outstanding), and the bill-no
lookup (get_value). Behaviour preserved.
2026-06-09 22:17:49 +05:30
Kaushal Shriwas
58582cfa09 test: cover user permission scoping in receivable report 2026-06-07 22:20:46 +05:30
Kaushal Shriwas
1ef4978a86 fix: apply user permissions to receivable/payable reports 2026-06-07 21:43:49 +05:30
258 changed files with 105676 additions and 32838 deletions

View File

@@ -4,24 +4,46 @@ set -e
cd ~ || exit
sudo apt update
sudo apt remove mysql-server mysql-client
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev
pip install frappe-bench
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
frappeuser=${FRAPPE_USER:-"frappe"}
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
# ---------------------------------------------------------------------------
# Phase 1 — parallelise the three slow, independent setup steps:
# a) system packages b) frappe-bench pip install c) frappe git fetch
# ---------------------------------------------------------------------------
sudo apt update
# apt remove/install must run sequentially but can overlap with pip and git.
sudo apt remove mysql-server mysql-client
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev &
apt_pid=$!
pip install frappe-bench &
pip_pid=$!
mkdir frappe
(
cd frappe
git init
git remote add origin "https://github.com/${frappeuser}/frappe"
git fetch origin "${frappecommitish}" --depth 1
) &
clone_pid=$!
wait $apt_pid
wait $pip_pid
wait $clone_pid
pushd frappe
git init
git remote add origin "https://github.com/${frappeuser}/frappe"
git fetch origin "${frappecommitish}" --depth 1
git checkout FETCH_HEAD
popd
# ---------------------------------------------------------------------------
# Phase 2 — bench init and site setup
# ---------------------------------------------------------------------------
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
mkdir ~/frappe-bench/sites/test_site
@@ -37,6 +59,11 @@ if [ "$DB" == "mariadb" ];then
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
# Belt-and-suspenders: also set performance variables at runtime in case
# MARIADB_EXTRA_FLAGS was not honoured by the container image.
mariadb --host 127.0.0.1 --port 3306 -u root -proot \
-e "SET GLOBAL innodb_flush_log_at_trx_commit=0; SET GLOBAL sync_binlog=0;"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
@@ -51,9 +78,11 @@ fi
install_whktml() {
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
if [ ! -f /tmp/wkhtmltox.deb ]; then
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
fi
sudo apt install /tmp/wkhtmltox.deb
}
install_whktml &
wkpid=$!

View File

@@ -59,6 +59,10 @@ jobs:
env:
TZ: 'Asia/Kolkata'
MARIADB_ROOT_PASSWORD: 'root'
# Disable durability guarantees that are unnecessary in a throwaway CI container.
# innodb_flush_log_at_trx_commit=0 avoids an fsync on every commit (biggest win).
# sync_binlog=0 skips binary-log syncs; innodb_doublewrite=0 skips the doublewrite buffer.
MARIADB_EXTRA_FLAGS: --innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --innodb-doublewrite=0
ports:
- 3306:3306
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
@@ -122,6 +126,12 @@ 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:
@@ -131,7 +141,14 @@ jobs:
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 --lightmode --app erpnext --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }} --with-coverage'
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
@@ -141,6 +158,7 @@ jobs:
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-${{ matrix.container }}
@@ -149,6 +167,7 @@ jobs:
coverage:
name: Coverage Wrap Up
needs: test
if: ${{ github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Clone

10
.greptile/config.json Normal file
View File

@@ -0,0 +1,10 @@
{
"disabledLabels": [
"conflicts"
],
"context": {
"repos": [
"frappe/frappe"
]
}
}

View File

@@ -48,7 +48,7 @@
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"usehooks-ts": "^3.1.1",
"vite": "^8.0.11"
"vite": "^8.0.16"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

View File

@@ -250,7 +250,7 @@ const ClosingBalanceForm = ({ defaultBalance, date, bankAccount, onClose }: { de
{_("Enter the closing balance you see in your bank statement for {0} as of the {1}", [bankAccount?.account_name ?? bankAccount?.name ?? '', formatDate(date, 'Do MMM YYYY')])}
</DialogDescription>
</DialogHeader>
{error && <ErrorBanner error={error} />}
{error && <div className="py-2"><ErrorBanner error={error} /></div>}
<div className="py-4">
<CurrencyFormField
name="balance"

View File

@@ -33,6 +33,16 @@ export const getErrorMessages = (error?: FrappeError | null): ParsedErrorMessage
}
})
// @ts-expect-error - some errors have _error_message
if (error?._error_message) {
eMessages.push({
// @ts-expect-error - some errors have _error_message
message: error?._error_message,
title: "Error",
indicator: "red"
})
}
if (eMessages.length === 0) {
// Get the message from the exception by removing the exc_type
const indexOfFirstColon = error?.exception?.indexOf(':')

View File

@@ -358,10 +358,10 @@
dependencies:
"@tybys/wasm-util" "^0.10.1"
"@oxc-project/types@=0.128.0":
version "0.128.0"
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.128.0.tgz#efc7524f948ff9e8ab1404ecad1823849c6fe149"
integrity sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==
"@oxc-project/types@=0.133.0":
version "0.133.0"
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.133.0.tgz#2e282ef9e1d26e06b68ccd14b73f310a3b2cf7f8"
integrity sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==
"@radix-ui/number@1.1.1":
version "1.1.1"
@@ -1042,95 +1042,95 @@
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb"
integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==
"@rolldown/binding-android-arm64@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz#3af8b2242086125934a85c1915b76e0a6a2054c1"
integrity sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==
"@rolldown/binding-android-arm64@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz#54ce8f8382213f4a314a0c2f7ba83f81ffeae592"
integrity sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==
"@rolldown/binding-darwin-arm64@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz#ae0b4467d24ecd6c6589f03d4d4699616ee9649c"
integrity sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==
"@rolldown/binding-darwin-arm64@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz#388fca1566c14c00c4b446fc3928630e7f0d95fc"
integrity sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==
"@rolldown/binding-darwin-x64@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz#23cf24b0a7b96c8990bbdd8a91e7fd3ba82b00e7"
integrity sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==
"@rolldown/binding-darwin-x64@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz#53f57de1f599ecf1db13823cfc88c18fb80954ad"
integrity sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==
"@rolldown/binding-freebsd-x64@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz#a047a770f94dc451c062b729e5d1cf82e5c6f9c4"
integrity sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==
"@rolldown/binding-freebsd-x64@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz#6f3fdda1b7aeaac9d268a526804b4fb96e4e35f1"
integrity sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==
"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz#c0b7f346cbf50301cea669a4632bc63aabe6a72c"
integrity sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==
"@rolldown/binding-linux-arm-gnueabihf@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz#d87a454bf585cc9676849377e91d6e375297326f"
integrity sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==
"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz#af56373c7996ebe6379207cd699c9f7f705e235d"
integrity sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==
"@rolldown/binding-linux-arm64-gnu@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz#419fd6bf612cf348f10528cbcd94ebab9607d8d1"
integrity sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==
"@rolldown/binding-linux-arm64-musl@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz#a8f5acd21fcffc8991aa84710e3ae603c4240ea4"
integrity sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==
"@rolldown/binding-linux-arm64-musl@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz#fcc6918696bb76844877e1e4930a18fd0d374069"
integrity sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==
"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz#1d4a89e040ff82141fc46e717cfab80b05f7c13f"
integrity sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==
"@rolldown/binding-linux-ppc64-gnu@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz#32aecb7c8dae5d4f2a8cde57a058ec86991542f8"
integrity sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==
"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz#97c21feeb2ed87d07820f0b2dcc5dd663e7a7f3b"
integrity sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==
"@rolldown/binding-linux-s390x-gnu@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz#bed9346ea81e6bb8b93cf11f5d88b77db890b763"
integrity sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==
"@rolldown/binding-linux-x64-gnu@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz#06310d40fe139ccc3c433b361120d337c66ebec2"
integrity sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==
"@rolldown/binding-linux-x64-gnu@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz#64c2d26f75dffd9b5a1f97557a00ae77250c8cb7"
integrity sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==
"@rolldown/binding-linux-x64-musl@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz#6a711258841f42609b238050cfcd5db13ac136d0"
integrity sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==
"@rolldown/binding-linux-x64-musl@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz#5a45132e8a47659eeaaf3b540c2954a97c860ff3"
integrity sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==
"@rolldown/binding-openharmony-arm64@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz#15cb644beeafdbec930d79ed45c2a7c2573eac70"
integrity sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==
"@rolldown/binding-openharmony-arm64@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz#290513068c55e849dc8457a32afee1d7b0acb309"
integrity sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==
"@rolldown/binding-wasm32-wasi@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz#ca3a56d11dfd533d743711141b3bb4c1ec10110e"
integrity sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==
"@rolldown/binding-wasm32-wasi@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz#3d9972dbf1a953d3c7afaa4a0f20ef2b2e39f31b"
integrity sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==
dependencies:
"@emnapi/core" "1.10.0"
"@emnapi/runtime" "1.10.0"
"@napi-rs/wasm-runtime" "^1.1.4"
"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz#8c2117d68331d7de59d24631146d538fc203d27c"
integrity sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==
"@rolldown/binding-win32-arm64-msvc@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz#a004ab607a16d6f03bcb555728ff888af75773ad"
integrity sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==
"@rolldown/binding-win32-x64-msvc@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz#bb5c28df3095046778cc1b020ef52fc5ee7b7e70"
integrity sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==
"@rolldown/pluginutils@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz#51cf2589596a179ebe8cbf313f1358c7b51a2fdc"
integrity sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==
"@rolldown/binding-win32-x64-msvc@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz#e2a25b34691a1cc8a1209d7de709063026dd0cdb"
integrity sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==
"@rolldown/pluginutils@1.0.0-rc.7":
version "1.0.0-rc.7"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz#0414869467f0e471a6515d4f506c85fde867e022"
integrity sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==
"@rolldown/pluginutils@^1.0.0":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz#e3fcee093fbb5ce765e1ad088ff4de2889f6f9be"
integrity sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==
"@socket.io/component-emitter@~3.1.0":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
@@ -3031,10 +3031,10 @@ ms@^2.1.3:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
nanoid@^3.3.11:
version "3.3.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
nanoid@^3.3.12:
version "3.3.12"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.12.tgz#ab3d912e217a6d0a514f00a72a16543a28982c05"
integrity sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==
natural-compare@^1.4.0:
version "1.4.0"
@@ -3119,22 +3119,17 @@ picocolors@^1.1.1:
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
picomatch@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
picomatch@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
postcss@^8.5.14:
version "8.5.14"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c"
integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==
postcss@^8.5.15:
version "8.5.15"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.15.tgz#d1eaf677a324e9ec02196da2d3fecf4a0b9a735c"
integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==
dependencies:
nanoid "^3.3.11"
nanoid "^3.3.12"
picocolors "^1.1.1"
source-map-js "^1.2.1"
@@ -3394,29 +3389,29 @@ resolve-from@^4.0.0:
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
rolldown@1.0.0-rc.18:
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.18.tgz#c597f89a4ce12e6fc918fa91e4f892b340aa92f0"
integrity sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==
rolldown@1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.3.tgz#db88a3008fb0e28230a00423727ce75ba32121ac"
integrity sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==
dependencies:
"@oxc-project/types" "=0.128.0"
"@rolldown/pluginutils" "1.0.0-rc.18"
"@oxc-project/types" "=0.133.0"
"@rolldown/pluginutils" "^1.0.0"
optionalDependencies:
"@rolldown/binding-android-arm64" "1.0.0-rc.18"
"@rolldown/binding-darwin-arm64" "1.0.0-rc.18"
"@rolldown/binding-darwin-x64" "1.0.0-rc.18"
"@rolldown/binding-freebsd-x64" "1.0.0-rc.18"
"@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.18"
"@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.18"
"@rolldown/binding-linux-arm64-musl" "1.0.0-rc.18"
"@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.18"
"@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.18"
"@rolldown/binding-linux-x64-gnu" "1.0.0-rc.18"
"@rolldown/binding-linux-x64-musl" "1.0.0-rc.18"
"@rolldown/binding-openharmony-arm64" "1.0.0-rc.18"
"@rolldown/binding-wasm32-wasi" "1.0.0-rc.18"
"@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.18"
"@rolldown/binding-win32-x64-msvc" "1.0.0-rc.18"
"@rolldown/binding-android-arm64" "1.0.3"
"@rolldown/binding-darwin-arm64" "1.0.3"
"@rolldown/binding-darwin-x64" "1.0.3"
"@rolldown/binding-freebsd-x64" "1.0.3"
"@rolldown/binding-linux-arm-gnueabihf" "1.0.3"
"@rolldown/binding-linux-arm64-gnu" "1.0.3"
"@rolldown/binding-linux-arm64-musl" "1.0.3"
"@rolldown/binding-linux-ppc64-gnu" "1.0.3"
"@rolldown/binding-linux-s390x-gnu" "1.0.3"
"@rolldown/binding-linux-x64-gnu" "1.0.3"
"@rolldown/binding-linux-x64-musl" "1.0.3"
"@rolldown/binding-openharmony-arm64" "1.0.3"
"@rolldown/binding-wasm32-wasi" "1.0.3"
"@rolldown/binding-win32-arm64-msvc" "1.0.3"
"@rolldown/binding-win32-x64-msvc" "1.0.3"
scheduler@^0.27.0:
version "0.27.0"
@@ -3540,18 +3535,10 @@ tapable@^2.3.3:
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160"
integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
tinyglobby@^0.2.15:
version "0.2.15"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2"
integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
dependencies:
fdir "^6.5.0"
picomatch "^4.0.3"
tinyglobby@^0.2.16:
version "0.2.16"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6"
integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==
tinyglobby@^0.2.15, tinyglobby@^0.2.17:
version "0.2.17"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631"
integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==
dependencies:
fdir "^6.5.0"
picomatch "^4.0.4"
@@ -3725,16 +3712,16 @@ vfile@^6.0.0:
"@types/unist" "^3.0.0"
vfile-message "^4.0.0"
vite@^8.0.11:
version "8.0.11"
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.11.tgz#d128fe82a0dd24da5127d20560735f1cd7ade0a6"
integrity sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==
vite@^8.0.16:
version "8.0.16"
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.16.tgz#ae073866c06563d6634a90169a496e11bd84f1a6"
integrity sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==
dependencies:
lightningcss "^1.32.0"
picomatch "^4.0.4"
postcss "^8.5.14"
rolldown "1.0.0-rc.18"
tinyglobby "^0.2.16"
postcss "^8.5.15"
rolldown "1.0.3"
tinyglobby "^0.2.17"
optionalDependencies:
fsevents "~2.3.3"

View File

@@ -22,11 +22,13 @@ class TestAdvancePaymentLedgerEntry(ERPNextTestSuite, AccountsTestMixin):
"""
def setUp(self):
self.create_company()
self.create_usd_receivable_account()
self.create_usd_payable_account()
self.create_item()
self.clear_old_entries()
self.company = "_Test Company"
self.customer = "_Test Customer"
self.supplier = "_Test Supplier"
self.item = "_Test Item"
self.cash = "Cash - _TC"
self.debtors_usd = "_Test Receivable USD - _TC"
self.creditors_usd = "_Test Payable USD - _TC"
def create_sales_order(self, qty=1, rate=100, currency="INR", do_not_submit=False):
"""

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_rename": 1,
"creation": "2026-04-11 19:48:13.622253",
"doctype": "DocType",
@@ -7,7 +8,8 @@
"field_order": [
"bank_account",
"date",
"balance"
"balance",
"company"
],
"fields": [
{
@@ -31,12 +33,20 @@
"in_list_view": 1,
"label": "Balance",
"reqd": 1
},
{
"fetch_from": "bank_account.company",
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company",
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-04-11 19:49:45.374695",
"modified": "2026-06-16 22:17:48.007982",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Account Balance",

View File

@@ -16,6 +16,7 @@ class BankAccountBalance(Document):
balance: DF.Currency
bank_account: DF.Link
company: DF.Link | None
date: DF.Date
# end: auto-generated types

View File

@@ -94,6 +94,7 @@ class BankClearance(Document):
invalid_document = []
invalid_cheque_date = []
entries_to_update = []
self.check_permission("write")
def validate_entry(d):
is_valid = True

View File

@@ -8,7 +8,7 @@ import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Sum
from frappe.query_builder.functions import Max, Sum
from frappe.utils import cint, create_batch, flt
from erpnext import get_default_cost_center
@@ -518,6 +518,7 @@ def create_internal_transfer(
"""
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
bank_transaction.check_permission("write")
bank_account = frappe.get_cached_value("Bank Account", bank_transaction.bank_account, "account")
company = frappe.get_cached_value("Account", bank_account, "company")
@@ -778,7 +779,6 @@ def create_bulk_payment_entry_and_reconcile(
"""
Create a payment entry and reconcile it with the bank transaction
"""
output = []
for bank_transaction_name in bank_transaction_names:
@@ -1410,12 +1410,14 @@ def get_je_matching_query(
Sum(getattr(jea, amount_field)).as_("paid_amount"),
ConstantColumn("Journal Entry").as_("doctype"),
je.name,
je.cheque_no.as_("reference_no"),
je.cheque_date.as_("reference_date"),
je.pay_to_recd_from.as_("party"),
jea.party_type,
je.posting_date,
jea.account_currency.as_("currency"),
# non-grouped columns are constant per grouped JE name (party_type/currency come from the
# single bank-account line) -> Max() keeps the GROUP BY valid on postgres with the same value
Max(je.cheque_no).as_("reference_no"),
Max(je.cheque_date).as_("reference_date"),
Max(je.pay_to_recd_from).as_("party"),
Max(jea.party_type).as_("party_type"),
Max(je.posting_date).as_("posting_date"),
Max(jea.account_currency).as_("currency"),
)
.where(je.docstatus == 1)
.where(je.voucher_type != "Opening Entry")
@@ -1423,7 +1425,7 @@ def get_je_matching_query(
.where(jea.account == common_filters.bank_account)
.where(filter_by_date)
.groupby(je.name)
.orderby(je.cheque_date if cint(filter_by_reference_date) else je.posting_date)
.orderby(Max(je.cheque_date) if cint(filter_by_reference_date) else Max(je.posting_date))
)
if frappe.flags.auto_reconcile_vouchers is True:

View File

@@ -17,9 +17,10 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()
self.clear_old_entries()
self.company = "_Test Company"
self.customer = "_Test Customer"
self.bank = "HDFC - _TC"
self.debit_to = "Debtors - _TC"
bank_dt = qb.DocType("Bank")
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
self.create_bank_account()

View File

@@ -26,9 +26,9 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()
self.clear_old_entries()
self.company = "_Test Company"
self.customer = "_Test Customer"
self.bank = "HDFC - _TC"
bank_dt = qb.DocType("Bank")
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
self.create_bank_account()

View File

@@ -5,6 +5,8 @@ import frappe
from frappe import _
from frappe.model.docstatus import DocStatus
from frappe.model.document import Document
from frappe.query_builder import Tuple
from frappe.query_builder.functions import Abs, Max, Sum
from frappe.utils import flt, getdate
@@ -374,6 +376,7 @@ def unreconcile_transaction(transaction_name: str | int):
Else, cancel the individual entries
"""
transaction = frappe.get_doc("Bank Transaction", transaction_name)
transaction.check_permission("write")
vouchers_to_cancel = []
@@ -401,6 +404,7 @@ def unreconcile_transaction_entry(bank_transaction_id: str | int, voucher_type:
"""
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_id)
bank_transaction.check_permission("write")
# Find the voucher in the bank transaction and depending on the action, either remove it or cancel the voucher
for entry in bank_transaction.payment_entries:
@@ -476,30 +480,28 @@ def get_clearance_details(transaction, payment_entry, bt_allocations, gl_entries
def get_related_bank_gl_entries(docs):
# nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
if not docs:
return {}
result = frappe.db.sql(
"""
SELECT
gle.voucher_type AS doctype,
gle.voucher_no AS docname,
gle.account AS gl_account,
SUM(ABS(gle.credit_in_account_currency - gle.debit_in_account_currency)) AS amount
FROM
`tabGL Entry` gle
LEFT JOIN
`tabAccount` ac ON ac.name = gle.account
WHERE
ac.account_type = 'Bank'
AND (gle.voucher_type, gle.voucher_no) IN %(docs)s
AND gle.is_cancelled = 0
GROUP BY
gle.voucher_type, gle.voucher_no, gle.account
""",
{"docs": docs},
as_dict=True,
gle = frappe.qb.DocType("GL Entry")
ac = frappe.qb.DocType("Account")
result = (
frappe.qb.from_(gle)
.left_join(ac)
.on(ac.name == gle.account)
.select(
gle.voucher_type.as_("doctype"),
gle.voucher_no.as_("docname"),
gle.account.as_("gl_account"),
Sum(Abs(gle.credit_in_account_currency - gle.debit_in_account_currency)).as_("amount"),
)
.where(
(ac.account_type == "Bank")
& Tuple(gle.voucher_type, gle.voucher_no).isin([Tuple(vt, vn) for vt, vn in docs])
& (gle.is_cancelled == 0)
)
.groupby(gle.voucher_type, gle.voucher_no, gle.account)
.run(as_dict=True)
)
entries = {}
@@ -521,31 +523,32 @@ def get_total_allocated_amount(docs):
if not docs:
return {}
# nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
result = frappe.db.sql(
"""
SELECT total, latest_date, gl_account, payment_document, payment_entry FROM (
SELECT
ROW_NUMBER() OVER w AS rownum,
SUM(btp.allocated_amount) OVER(PARTITION BY ba.account, btp.payment_document, btp.payment_entry) AS total,
FIRST_VALUE(bt.date) OVER w AS latest_date,
ba.account AS gl_account,
btp.payment_document,
btp.payment_entry
FROM
`tabBank Transaction Payments` btp
LEFT JOIN `tabBank Transaction` bt ON bt.name=btp.parent
LEFT JOIN `tabBank Account` ba ON ba.name=bt.bank_account
WHERE
(btp.payment_document, btp.payment_entry) IN %(docs)s
AND bt.docstatus = 1
WINDOW w AS (PARTITION BY ba.account, btp.payment_document, btp.payment_entry ORDER BY bt.date DESC)
) temp
WHERE
rownum = 1
""",
dict(docs=docs),
as_dict=True,
# The original window query (ROW_NUMBER/FIRST_VALUE + rownum = 1) just collapses to one
# row per (account, payment_document, payment_entry) with the partition's allocation total
# and most recent transaction date — i.e. a plain GROUP BY with SUM and MAX.
btp = frappe.qb.DocType("Bank Transaction Payments")
bt = frappe.qb.DocType("Bank Transaction")
ba = frappe.qb.DocType("Bank Account")
result = (
frappe.qb.from_(btp)
.left_join(bt)
.on(bt.name == btp.parent)
.left_join(ba)
.on(ba.name == bt.bank_account)
.select(
Sum(btp.allocated_amount).as_("total"),
Max(bt.date).as_("latest_date"),
ba.account.as_("gl_account"),
btp.payment_document,
btp.payment_entry,
)
.where(
Tuple(btp.payment_document, btp.payment_entry).isin([Tuple(pd, pe) for pd, pe in docs])
& (bt.docstatus == 1)
)
.groupby(ba.account, btp.payment_document, btp.payment_entry)
.run(as_dict=True)
)
payment_allocation_details = {}

View File

@@ -104,6 +104,36 @@ class TestBankTransaction(ERPNextTestSuite):
self.assertEqual(bank_transaction.unallocated_amount, 1700)
self.assertEqual(bank_transaction.payment_entries, [])
# Amending a reconciled payment entry must not carry over its clearance date
def test_clearance_date_cleared_on_amend(self):
bank_transaction = frappe.get_doc(
"Bank Transaction",
dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G"),
)
payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700))
vouchers = json.dumps(
[
{
"payment_doctype": "Payment Entry",
"payment_name": payment.name,
"amount": bank_transaction.unallocated_amount,
}
]
)
reconcile_vouchers(bank_transaction.name, vouchers)
self.assertTrue(frappe.db.get_value("Payment Entry", payment.name, "clearance_date"))
payment.reload()
payment.cancel()
amended = frappe.copy_doc(payment)
amended.amended_from = payment.name
amended.docstatus = 0
amended.insert()
self.assertFalse(amended.clearance_date)
# Check if ERPNext can correctly filter a linked payments based on the debit/credit amount
def test_debit_credit_output(self):
bank_transaction = frappe.get_doc(

View File

@@ -11,9 +11,11 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestBankTransactionRule(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()
self.clear_old_entries()
self.company = "_Test Company"
self.customer = "_Test Customer"
self.bank = "HDFC - _TC"
self.debit_to = "Debtors - _TC"
self.cash = "Cash - _TC"
bank_dt = qb.DocType("Bank")
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
self.create_bank_account()

View File

@@ -17,6 +17,7 @@ frappe.ui.form.on("Budget", {
filters: {
is_group: 0,
company: frm.doc.company,
root_type: ["in", ["Income", "Expense"]],
},
};
});

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 flt
@@ -43,13 +44,17 @@ class CashierClosing(Document):
self.make_calculations()
def get_outstanding(self):
values = frappe.db.sql(
"""
select sum(outstanding_amount)
from `tabSales Invoice`
where posting_date=%s and posting_time>=%s and posting_time<=%s and owner=%s
""",
(self.date, self.from_time, self.time, self.user),
si = frappe.qb.DocType("Sales Invoice")
values = (
frappe.qb.from_(si)
.select(Sum(si.outstanding_amount))
.where(
(si.posting_date == self.date)
& (si.posting_time >= self.from_time)
& (si.posting_time <= self.time)
& (si.owner == self.user)
)
.run()
)
self.outstanding_amount = flt(values[0][0] if values else 0)

View File

@@ -11,22 +11,28 @@ frappe.ui.form.on("Currency Exchange Settings", {
},
callback: function (r) {
if (r && r.message) {
let result = [],
params = {};
if (frm.doc.service_provider == "exchangerate.host") {
let result = ["result"];
let params = {
result = ["result"];
params = {
date: "{transaction_date}",
from: "{from_currency}",
to: "{to_currency}",
};
add_param(frm, r.message, params, result);
} else if (["frankfurter.app", "frankfurter.dev"].includes(frm.doc.service_provider)) {
let result = ["rates", "{to_currency}"];
let params = {
result = ["rates", "{to_currency}"];
params = {
base: "{from_currency}",
symbols: "{to_currency}",
};
add_param(frm, r.message, params, result);
} else if (frm.doc.service_provider == "frankfurter.dev - v2") {
result = ["rate"];
params = {
date: "{transaction_date}",
};
}
add_param(frm, r.message, params, result);
}
},
});

View File

@@ -78,7 +78,7 @@
"fieldname": "service_provider",
"fieldtype": "Select",
"label": "Service Provider",
"options": "frankfurter.dev\nexchangerate.host\nCustom",
"options": "frankfurter.dev\nexchangerate.host\nfrankfurter.dev - v2\nCustom",
"reqd": 1
},
{
@@ -101,11 +101,10 @@
"label": "Use HTTP Protocol"
}
],
"hide_toolbar": 0,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-03-16 13:28:21.075743",
"modified": "2026-06-15 11:25:55.873110",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Currency Exchange Settings",
@@ -122,24 +121,11 @@
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Accounts Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Accounts User",
"share": 1,
"write": 1
"share": 1
}
],
"row_format": "Dynamic",

View File

@@ -29,7 +29,7 @@ class CurrencyExchangeSettings(Document):
disabled: DF.Check
req_params: DF.Table[CurrencyExchangeSettingsDetails]
result_key: DF.Table[CurrencyExchangeSettingsResult]
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "Custom"]
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "frankfurter.dev - v2", "Custom"]
url: DF.Data | None
use_http: DF.Check
# end: auto-generated types
@@ -70,6 +70,14 @@ class CurrencyExchangeSettings(Document):
self.append("req_params", {"key": "base", "value": "{from_currency}"})
self.append("req_params", {"key": "symbols", "value": "{to_currency}"})
elif self.service_provider == "frankfurter.dev - v2":
self.set("result_key", [])
self.set("req_params", [])
self.api_endpoint = get_api_endpoint(self.service_provider, self.use_http)
self.append("result_key", {"key": "rate"})
self.append("req_params", {"key": "date", "value": "{transaction_date}"})
def validate_parameters(self):
params = {}
for row in self.req_params:
@@ -105,13 +113,20 @@ class CurrencyExchangeSettings(Document):
@frappe.whitelist()
def get_api_endpoint(service_provider: str | None = None, use_http: bool = False):
if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev", "frankfurter.app"]:
if service_provider and service_provider in [
"exchangerate.host",
"frankfurter.dev",
"frankfurter.app",
"frankfurter.dev - v2",
]:
if service_provider == "exchangerate.host":
api = "api.exchangerate.host/convert"
elif service_provider == "frankfurter.app":
api = "api.frankfurter.app/{transaction_date}"
elif service_provider == "frankfurter.dev":
api = "api.frankfurter.dev/v1/{transaction_date}"
elif service_provider == "frankfurter.dev - v2":
api = "api.frankfurter.dev/v2/rate/{from_currency}/{to_currency}"
protocol = "https://"
if use_http:

View File

@@ -8,7 +8,7 @@ from frappe import _, qb
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.query_builder import Criterion, Order
from frappe.query_builder.functions import NullIf, Sum
from frappe.query_builder.functions import Max, NullIf, Sum
from frappe.utils import flt, get_link_to_form
import erpnext
@@ -188,12 +188,18 @@ class ExchangeRateRevaluation(Document):
accounts = [x[0] for x in res]
if accounts:
having_clause = (qb.Field("balance") != qb.Field("balance_in_account_currency")) & (
(qb.Field("balance_in_account_currency") != 0) | (qb.Field("balance") != 0)
)
gle = qb.DocType("GL Entry")
# balance expressions reused in both SELECT and HAVING; postgres can't reference a
# SELECT alias inside HAVING, so the aggregate expression must be repeated there.
balance = Sum(gle.debit) - Sum(gle.credit)
balance_in_account_currency = Sum(gle.debit_in_account_currency) - Sum(
gle.credit_in_account_currency
)
having_clause = (balance != balance_in_account_currency) & (
(balance_in_account_currency != 0) | (balance != 0)
)
# conditions
conditions = []
conditions.append(gle.account.isin(accounts))
@@ -209,17 +215,15 @@ class ExchangeRateRevaluation(Document):
qb.from_(gle)
.select(
gle.account,
gle.party_type,
gle.party,
gle.account_currency,
(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency)).as_(
"balance_in_account_currency"
),
(Sum(gle.debit) - Sum(gle.credit)).as_("balance"),
(Sum(gle.debit) - Sum(gle.credit) == 0)
^ (Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency) == 0).as_(
"zero_balance"
),
# grouped by NullIf(party_type/party, ""); the bare columns + account_currency are
# constant per group -> Max() keeps the GROUP BY valid on postgres with the same value.
Max(gle.party_type).as_("party_type"),
Max(gle.party).as_("party"),
Max(gle.account_currency).as_("account_currency"),
balance_in_account_currency.as_("balance_in_account_currency"),
balance.as_("balance"),
# zero_balance is recomputed in Python below (after rounding), so the SQL value is
# unused -- dropped (it used MySQL's XOR operator, which postgres lacks).
)
.where(Criterion.all(conditions))
.groupby(gle.account, NullIf(gle.party_type, ""), NullIf(gle.party, ""))

View File

@@ -15,11 +15,11 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_usd_receivable_account()
self.create_item()
self.create_customer()
self.clear_old_entries()
self.company = "_Test Company"
self.item = "_Test Item"
self.customer = "_Test Customer"
self.cost_center = "Main - _TC"
self.debtors_usd = "_Test Receivable USD - _TC"
self.set_system_and_company_settings()
def set_system_and_company_settings(self):

View File

@@ -319,56 +319,48 @@ class InvoiceDiscounting(AccountsController):
@frappe.whitelist()
def get_invoices(filters: str):
filters = frappe._dict(json.loads(filters))
cond = []
if filters.customer:
cond.append("customer=%(customer)s")
if filters.from_date:
cond.append("posting_date >= %(from_date)s")
if filters.to_date:
cond.append("posting_date <= %(to_date)s")
if filters.min_amount:
cond.append("base_grand_total >= %(min_amount)s")
if filters.max_amount:
cond.append("base_grand_total <= %(max_amount)s")
si = frappe.qb.DocType("Sales Invoice")
di = frappe.qb.DocType("Discounted Invoice")
where_condition = ""
if cond:
where_condition += " and " + " and ".join(cond)
discounted = frappe.qb.from_(di).select(di.sales_invoice).where(di.docstatus == 1)
return frappe.db.sql(
"""
select
name as sales_invoice,
customer,
posting_date,
outstanding_amount,
debit_to
from `tabSales Invoice` si
where
docstatus = 1
and outstanding_amount > 0
%s
and not exists(select di.name from `tabDiscounted Invoice` di
where di.docstatus=1 and di.sales_invoice=si.name)
"""
% where_condition,
filters,
as_dict=1,
query = (
frappe.qb.from_(si)
.select(
si.name.as_("sales_invoice"),
si.customer,
si.posting_date,
si.outstanding_amount,
si.debit_to,
)
.where((si.docstatus == 1) & (si.outstanding_amount > 0) & si.name.notin(discounted))
)
if filters.customer:
query = query.where(si.customer == filters.customer)
if filters.from_date:
query = query.where(si.posting_date >= filters.from_date)
if filters.to_date:
query = query.where(si.posting_date <= filters.to_date)
if filters.min_amount:
query = query.where(si.base_grand_total >= filters.min_amount)
if filters.max_amount:
query = query.where(si.base_grand_total <= filters.max_amount)
return query.run(as_dict=1)
def get_party_account_based_on_invoice_discounting(sales_invoice):
party_account = None
invoice_discounting = frappe.db.sql(
"""
select par.accounts_receivable_discounted, par.accounts_receivable_unpaid, par.status
from `tabInvoice Discounting` par, `tabDiscounted Invoice` ch
where par.name=ch.parent
and par.docstatus=1
and ch.sales_invoice = %s
""",
(sales_invoice),
as_dict=1,
par = frappe.qb.DocType("Invoice Discounting")
ch = frappe.qb.DocType("Discounted Invoice")
invoice_discounting = (
frappe.qb.from_(par)
.inner_join(ch)
.on(par.name == ch.parent)
.select(par.accounts_receivable_discounted, par.accounts_receivable_unpaid, par.status)
.where((par.docstatus == 1) & (ch.sales_invoice == sales_invoice))
.run(as_dict=1)
)
if invoice_discounting:
if invoice_discounting[0].status == "Disbursed":

View File

@@ -409,18 +409,16 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
}
get_outstanding(doctype, docname, company, child) {
var args = {
doctype: doctype,
docname: docname,
party: child.party,
account: child.account,
account_currency: child.account_currency,
company: company,
};
return frappe.call({
method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_outstanding",
args: { args: args },
args: {
doctype: doctype,
docname: docname,
company: company,
account: child.account,
party: child.party,
account_currency: child.account_currency,
},
callback: function (r) {
if (r.message) {
$.each(r.message, function (field, value) {

View File

@@ -8,6 +8,7 @@ import frappe
from frappe import _, msgprint, scrub
from frappe.core.doctype.submission_queue.submission_queue import queue_submission
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.utils import comma_and, cstr, flt, fmt_money, formatdate, get_link_to_form, nowdate
import erpnext
@@ -43,6 +44,14 @@ class StockAccountInvalidTransaction(frappe.ValidationError):
class JournalEntry(AccountsController):
"""Double-entry accounting voucher for manual and system-generated postings.
Besides plain journal entries it also backs depreciation, asset disposal,
exchange gain/loss, deferred revenue/expense, inter-company and periodic
accounting entries: it validates the account rows (party, references,
currency) and posts the corresponding GL entries on submit.
"""
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@@ -128,6 +137,7 @@ class JournalEntry(AccountsController):
super().__init__(*args, **kwargs)
def validate(self):
"""Validate the account rows (party, references, currency, stock) and build derived fields."""
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
from erpnext.accounts.doctype.journal_entry.services.reference_validator import (
JournalEntryReferenceValidator,
@@ -188,28 +198,33 @@ class JournalEntry(AccountsController):
validate_docs_for_deferred_accounting([self.name], [])
def submit(self):
"""Submit inline, or queue submission in the background for large entries."""
if len(self.accounts) > 100 and not self.meta.queue_in_background:
queue_submission(self, "_submit")
else:
return self._submit()
def before_cancel(self):
"""Block cancellation when a submitted Asset Value Adjustment is linked to this entry."""
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
AssetService(self).has_asset_adjustment_entry()
def cancel(self):
"""Cancel inline, or queue cancellation in the background for large entries."""
if len(self.accounts) > 100:
queue_submission(self, "_cancel")
else:
return self._cancel()
def before_submit(self):
"""Ensure total debit equals total credit before submission (skipped on data import)."""
# Do not validate while importing via data import
if not frappe.flags.in_import:
self.validate_total_debit_and_credit()
def on_submit(self):
"""Post GL entries and propagate the submission to assets, inter-company JE and invoice discounting."""
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
self.validate_cheque_info()
@@ -221,18 +236,16 @@ class JournalEntry(AccountsController):
JournalTaxWithholding(self).on_submit()
@frappe.whitelist()
def get_balance_for_periodic_accounting(self):
def get_balance_for_periodic_accounting(self) -> None:
"""Rebuild the entry rows from the stock-vs-ledger difference of each stock account."""
self.validate_company_for_periodic_accounting()
stock_accounts = self.get_stock_accounts_for_periodic_accounting()
self.set("accounts", [])
for account in stock_accounts:
account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(
for account in self.get_stock_accounts_for_periodic_accounting():
account_bal, stock_bal, _warehouse_list = get_stock_and_account_balance(
account, self.posting_date, self.company
)
difference_value = flt(stock_bal - account_bal, self.precision("difference"))
if difference_value == 0:
frappe.msgprint(
_("No difference found for stock account {0}").format(frappe.bold(account)),
@@ -240,23 +253,26 @@ class JournalEntry(AccountsController):
)
continue
self.append(
"accounts",
{
"account": account,
"debit_in_account_currency": difference_value if difference_value > 0 else 0,
"credit_in_account_currency": abs(difference_value) if difference_value < 0 else 0,
},
)
self._append_periodic_difference_rows(account, difference_value)
self.append(
"accounts",
{
"account": self.periodic_entry_difference_account,
"credit_in_account_currency": difference_value if difference_value > 0 else 0,
"debit_in_account_currency": abs(difference_value) if difference_value < 0 else 0,
},
)
def _append_periodic_difference_rows(self, account: str, difference_value: float) -> None:
"""Append the stock account row and its offsetting difference-account row."""
self.append(
"accounts",
{
"account": account,
"debit_in_account_currency": difference_value if difference_value > 0 else 0,
"credit_in_account_currency": abs(difference_value) if difference_value < 0 else 0,
},
)
self.append(
"accounts",
{
"account": self.periodic_entry_difference_account,
"credit_in_account_currency": difference_value if difference_value > 0 else 0,
"debit_in_account_currency": abs(difference_value) if difference_value < 0 else 0,
},
)
def validate_company_for_periodic_accounting(self):
if erpnext.is_perpetual_inventory_enabled(self.company):
@@ -302,6 +318,7 @@ class JournalEntry(AccountsController):
self.repost_accounting_entries()
def on_cancel(self):
"""Reverse GL entries and unlink asset, inter-company and advance references on cancel."""
# Cancel tax withholding entries
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
@@ -385,49 +402,44 @@ class JournalEntry(AccountsController):
self.name,
)
def update_invoice_discounting(self):
def _validate_invoice_discounting_status(inv_disc, id_status, expected_status, row_id):
id_link = get_link_to_form("Invoice Discounting", inv_disc)
if id_status != expected_status:
frappe.throw(
_("Row #{0}: Status must be {1} for Invoice Discounting {2}").format(
d.idx, expected_status, id_link
)
)
def update_invoice_discounting(self) -> None:
"""Advance each linked Invoice Discounting to its next status on submit/cancel."""
discounting_names = {
row.reference_name for row in self.accounts if row.reference_type == "Invoice Discounting"
}
for name in discounting_names:
inv_disc = frappe.get_doc("Invoice Discounting", name)
if status := self._get_next_invoice_discounting_status(inv_disc):
inv_disc.set_status(status=status)
invoice_discounting_list = list(
set([d.reference_name for d in self.accounts if d.reference_type == "Invoice Discounting"])
)
for inv_disc in invoice_discounting_list:
inv_disc_doc = frappe.get_doc("Invoice Discounting", inv_disc)
status = None
for d in self.accounts:
if d.account == inv_disc_doc.short_term_loan and d.reference_name == inv_disc:
if self.docstatus == 1:
if d.credit > 0:
_validate_invoice_discounting_status(
inv_disc, inv_disc_doc.status, "Sanctioned", d.idx
)
status = "Disbursed"
elif d.debit > 0:
_validate_invoice_discounting_status(
inv_disc, inv_disc_doc.status, "Disbursed", d.idx
)
status = "Settled"
else:
if d.credit > 0:
_validate_invoice_discounting_status(
inv_disc, inv_disc_doc.status, "Disbursed", d.idx
)
status = "Sanctioned"
elif d.debit > 0:
_validate_invoice_discounting_status(
inv_disc, inv_disc_doc.status, "Settled", d.idx
)
status = "Disbursed"
break
if status:
inv_disc_doc.set_status(status=status)
def _get_next_invoice_discounting_status(self, inv_disc) -> str | None:
"""Validate the current status and return the next one from the loan account row."""
for row in self.accounts:
if row.account != inv_disc.short_term_loan or row.reference_name != inv_disc.name:
continue
submitting = self.docstatus == 1
if row.credit > 0:
expected, next_status = (
("Sanctioned", "Disbursed") if submitting else ("Disbursed", "Sanctioned")
)
elif row.debit > 0:
expected, next_status = ("Disbursed", "Settled") if submitting else ("Settled", "Disbursed")
else:
return None
self._validate_invoice_discounting_status(inv_disc, expected, row.idx)
return next_status
return None
def _validate_invoice_discounting_status(self, inv_disc, expected_status: str, row_idx: int) -> None:
"""Throw unless the Invoice Discounting is in the status expected for this transition."""
if inv_disc.status != expected_status:
frappe.throw(
_("Row #{0}: Status must be {1} for Invoice Discounting {2}").format(
row_idx, expected_status, get_link_to_form("Invoice Discounting", inv_disc.name)
)
)
def unlink_advance_entry_reference(self):
for d in self.get("accounts"):
@@ -543,62 +555,76 @@ class JournalEntry(AccountsController):
self.voucher_type == "Exchange Gain Or Loss" and self.multi_currency and self.is_system_generated
)
def validate_against_jv(self):
for d in self.get("accounts"):
if d.reference_type == "Journal Entry":
account_root_type = frappe.get_cached_value("Account", d.account, "root_type")
if (
account_root_type == "Asset"
and flt(d.debit) > 0
and not self.system_generated_gain_loss()
):
frappe.throw(
_(
"Row #{0}: For {1}, you can select reference document only if account gets credited"
).format(d.idx, d.account)
)
elif (
account_root_type == "Liability"
and flt(d.credit) > 0
and not self.system_generated_gain_loss()
):
frappe.throw(
_(
"Row #{0}: For {1}, you can select reference document only if account gets debited"
).format(d.idx, d.account)
)
def validate_against_jv(self) -> None:
"""Validate every account row that references another Journal Entry."""
for row in self.get("accounts"):
if row.reference_type == "Journal Entry":
self._validate_jv_reference(row)
if d.reference_name == self.name:
frappe.throw(_("You can not enter current voucher in 'Against Journal Entry' column"))
def _validate_jv_reference(self, row) -> None:
"""Validate a single 'Against Journal Entry' row: direction, no self-reference,
and the presence of an unmatched entry on the referenced Journal Entry."""
self._validate_jv_reference_direction(row)
against_entries = frappe.db.sql(
"""select * from `tabJournal Entry Account`
where account = %s and docstatus = 1 and parent = %s
and (reference_type is null or reference_type in ('', 'Sales Order', 'Purchase Order'))
""",
(d.account, d.reference_name),
as_dict=True,
if row.reference_name == self.name:
frappe.throw(_("You can not enter current voucher in 'Against Journal Entry' column"))
against_entries = self._get_against_jv_entries(row)
if not against_entries:
if self.voucher_type != "Exchange Gain Or Loss":
frappe.throw(
_(
"Journal Entry {0} does not have account {1} or already matched against other voucher"
).format(row.reference_name, row.account)
)
return
if not against_entries:
if self.voucher_type != "Exchange Gain Or Loss":
frappe.throw(
_(
"Journal Entry {0} does not have account {1} or already matched against other voucher"
).format(d.reference_name, d.account)
)
else:
dr_or_cr = "debit" if flt(d.credit) > 0 else "credit"
valid = False
for jvd in against_entries:
if flt(jvd[dr_or_cr]) > 0:
valid = True
if not valid and not self.system_generated_gain_loss():
frappe.throw(
_("Against Journal Entry {0} does not have any unmatched {1} entry").format(
d.reference_name, dr_or_cr
)
)
dr_or_cr = "debit" if flt(row.credit) > 0 else "credit"
has_unmatched_entry = any(flt(entry[dr_or_cr]) > 0 for entry in against_entries)
if not has_unmatched_entry and not self.system_generated_gain_loss():
frappe.throw(
_("Against Journal Entry {0} does not have any unmatched {1} entry").format(
row.reference_name, dr_or_cr
)
)
def _validate_jv_reference_direction(self, row) -> None:
"""An asset account can reference a JE only when credited, a liability only when debited."""
if self.system_generated_gain_loss():
return
account_root_type = frappe.get_cached_value("Account", row.account, "root_type")
if account_root_type == "Asset" and flt(row.debit) > 0:
frappe.throw(
_(
"Row #{0}: For {1}, you can select reference document only if account gets credited"
).format(row.idx, row.account)
)
if account_root_type == "Liability" and flt(row.credit) > 0:
frappe.throw(
_("Row #{0}: For {1}, you can select reference document only if account gets debited").format(
row.idx, row.account
)
)
def _get_against_jv_entries(self, row) -> list[dict]:
"""Submitted Journal Entry Account rows on the referenced JE for the same account
that are not themselves linked to an order."""
jea = frappe.qb.DocType("Journal Entry Account")
return (
frappe.qb.from_(jea)
.select(jea.star)
.where(
(jea.account == row.account)
& (jea.docstatus == 1)
& (jea.parent == row.reference_name)
& (
jea.reference_type.isnull()
| jea.reference_type.isin(["", "Sales Order", "Purchase Order"])
)
)
.run(as_dict=True)
)
def set_against_account(self):
accounts_debited, accounts_credited = [], []
@@ -686,131 +712,142 @@ class JournalEntry(AccountsController):
d.debit = flt(d.debit_in_account_currency * flt(d.exchange_rate), d.precision("debit"))
d.credit = flt(d.credit_in_account_currency * flt(d.exchange_rate), d.precision("credit"))
def set_exchange_rate(self):
for d in self.get("accounts"):
if d.account_currency == self.company_currency:
d.exchange_rate = 1
elif (
not d.exchange_rate
or d.exchange_rate == 1
or (
d.reference_type in ("Sales Invoice", "Purchase Invoice")
and d.reference_name
and self.posting_date
)
):
ignore_exchange_rate = False
if self.get("flags") and self.flags.get("ignore_exchange_rate"):
ignore_exchange_rate = True
def set_exchange_rate(self) -> None:
"""Resolve a mandatory exchange rate for every account row."""
for row in self.get("accounts"):
self._set_row_exchange_rate(row)
if not row.exchange_rate:
frappe.throw(_("Row {0}: Exchange Rate is mandatory").format(row.idx))
if not ignore_exchange_rate:
# Modified to include the posting date for which to retreive the exchange rate
d.exchange_rate = get_exchange_rate(
self.posting_date,
d.account,
d.account_currency,
self.company,
d.reference_type,
d.reference_name,
d.debit,
d.credit,
d.exchange_rate,
)
if not d.exchange_rate:
frappe.throw(_("Row {0}: Exchange Rate is mandatory").format(d.idx))
def create_remarks(self):
r = []
if self.flags.skip_remarks_creation:
def _set_row_exchange_rate(self, row) -> None:
"""Set a row's exchange rate: 1 for company currency, otherwise fetched when stale."""
if row.account_currency == self.company_currency:
row.exchange_rate = 1
return
if self.get("custom_remark"):
return
if self.cheque_no:
if self.cheque_date:
r.append(_("Reference #{0} dated {1}").format(self.cheque_no, formatdate(self.cheque_date)))
else:
msgprint(_("Please enter Reference date"), raise_exception=frappe.MandatoryError)
for d in self.get("accounts"):
if d.reference_type == "Sales Invoice" and d.credit:
r.append(
_("{0} against Sales Invoice {1}").format(
fmt_money(flt(d.credit), currency=self.company_currency), d.reference_name
)
)
if d.reference_type == "Sales Order" and d.credit:
r.append(
_("{0} against Sales Order {1}").format(
fmt_money(flt(d.credit), currency=self.company_currency), d.reference_name
)
)
if d.reference_type == "Purchase Invoice" and d.debit:
bill_no = frappe.db.sql(
"""select bill_no, bill_date
from `tabPurchase Invoice` where name=%s""",
d.reference_name,
)
if (
bill_no
and bill_no[0][0]
and bill_no[0][0].lower().strip() not in ["na", "not applicable", "none"]
):
r.append(
_("{0} against Bill {1} dated {2}").format(
fmt_money(flt(d.debit), currency=self.company_currency),
bill_no[0][0],
bill_no[0][1] and formatdate(bill_no[0][1].strftime("%Y-%m-%d")),
)
)
if d.reference_type == "Purchase Order" and d.debit:
r.append(
_("{0} against Purchase Order {1}").format(
fmt_money(flt(d.credit), currency=self.company_currency), d.reference_name
)
)
if r:
self.remark = ("\n").join(r) # User Remarks is not mandatory
def set_print_format_fields(self):
bank_amount = party_amount = total_amount = 0.0
currency = bank_account_currency = party_account_currency = pay_to_recd_from = None
party_type = None
for d in self.get("accounts"):
if d.party_type in ["Customer", "Supplier"] and d.party:
party_type = d.party_type
if not pay_to_recd_from:
pay_to_recd_from = d.party
if pay_to_recd_from and pay_to_recd_from == d.party:
party_amount += flt(d.debit_in_account_currency) or flt(d.credit_in_account_currency)
party_account_currency = d.account_currency
elif frappe.get_cached_value("Account", d.account, "account_type") in ["Bank", "Cash"]:
bank_amount += flt(d.debit_in_account_currency) or flt(d.credit_in_account_currency)
bank_account_currency = d.account_currency
if party_type and pay_to_recd_from:
self.pay_to_recd_from = frappe.db.get_value(
party_type, pay_to_recd_from, "customer_name" if party_type == "Customer" else "supplier_name"
needs_refresh = (
not row.exchange_rate
or row.exchange_rate == 1
or (
row.reference_type in ("Sales Invoice", "Purchase Invoice")
and row.reference_name
and self.posting_date
)
if bank_amount:
total_amount = bank_amount
currency = bank_account_currency
)
if not needs_refresh or self.flags.get("ignore_exchange_rate"):
return
# Includes the posting date for which to retrieve the exchange rate
row.exchange_rate = get_exchange_rate(
self.posting_date,
row.account,
row.account_currency,
self.company,
row.reference_type,
row.reference_name,
row.debit,
row.credit,
row.exchange_rate,
)
def create_remarks(self) -> None:
"""Build the auto remark from the cheque reference and each account row's linked
document, unless remark creation is skipped or a custom remark is set."""
if self.flags.skip_remarks_creation or self.get("custom_remark"):
return
remarks = []
if cheque_remark := self._get_cheque_remark():
remarks.append(cheque_remark)
for row in self.get("accounts"):
if reference_remark := self._get_reference_remark(row):
remarks.append(reference_remark)
if remarks:
self.remark = "\n".join(remarks) # User Remarks is not mandatory
def _get_cheque_remark(self) -> str | None:
"""Remark line for the cheque reference; raises if the cheque date is missing."""
if not self.cheque_no:
return None
if not self.cheque_date:
msgprint(_("Please enter Reference date"), raise_exception=frappe.MandatoryError)
return _("Reference #{0} dated {1}").format(self.cheque_no, formatdate(self.cheque_date))
def _get_reference_remark(self, row) -> str | None:
"""Remark line for a single account row's linked Invoice/Order, or None."""
if row.reference_type == "Sales Invoice" and row.credit:
return _("{0} against Sales Invoice {1}").format(
fmt_money(flt(row.credit), currency=self.company_currency), row.reference_name
)
if row.reference_type == "Sales Order" and row.credit:
return _("{0} against Sales Order {1}").format(
fmt_money(flt(row.credit), currency=self.company_currency), row.reference_name
)
if row.reference_type == "Purchase Invoice" and row.debit:
return self._get_bill_remark(row)
if row.reference_type == "Purchase Order" and row.debit:
return _("{0} against Purchase Order {1}").format(
fmt_money(flt(row.credit), currency=self.company_currency), row.reference_name
)
return None
def _get_bill_remark(self, row) -> str | None:
"""Remark line referencing the supplier bill number/date of a Purchase Invoice row."""
bill_no, bill_date = frappe.db.get_value(
"Purchase Invoice", row.reference_name, ["bill_no", "bill_date"]
) or (None, None)
if not bill_no or bill_no.lower().strip() in ["na", "not applicable", "none"]:
return None
return _("{0} against Bill {1} dated {2}").format(
fmt_money(flt(row.debit), currency=self.company_currency),
bill_no,
bill_date and formatdate(bill_date.strftime("%Y-%m-%d")),
)
def set_print_format_fields(self) -> None:
"""Populate pay_to_recd_from and the total amount/currency shown on the print format."""
amounts = self._get_party_and_bank_amounts()
total_amount, currency = 0.0, None
if amounts.party_type and amounts.pay_to_recd_from:
self.pay_to_recd_from = frappe.db.get_value(
amounts.party_type,
amounts.pay_to_recd_from,
"customer_name" if amounts.party_type == "Customer" else "supplier_name",
)
if amounts.bank_amount:
total_amount, currency = amounts.bank_amount, amounts.bank_account_currency
else:
total_amount = party_amount
currency = party_account_currency
total_amount, currency = amounts.party_amount, amounts.party_account_currency
self.set_total_amount(total_amount, currency)
def set_total_amount(self, amt, currency):
def _get_party_and_bank_amounts(self) -> frappe._dict:
"""Sum the party and bank/cash amounts, with their currencies, across the account rows."""
totals = frappe._dict(
bank_amount=0.0,
party_amount=0.0,
bank_account_currency=None,
party_account_currency=None,
pay_to_recd_from=None,
party_type=None,
)
for row in self.get("accounts"):
amount = flt(row.debit_in_account_currency) or flt(row.credit_in_account_currency)
if row.party_type in ["Customer", "Supplier"] and row.party:
totals.party_type = row.party_type
totals.pay_to_recd_from = totals.pay_to_recd_from or row.party
if totals.pay_to_recd_from == row.party:
totals.party_amount += amount
totals.party_account_currency = row.account_currency
elif frappe.get_cached_value("Account", row.account, "account_type") in ["Bank", "Cash"]:
totals.bank_amount += amount
totals.bank_account_currency = row.account_currency
return totals
def set_total_amount(self, amt: float, currency: str) -> None:
self.total_amount = amt
self.total_amount_currency = currency
from frappe.utils import money_in_words
@@ -822,7 +859,7 @@ class JournalEntry(AccountsController):
return JournalEntryGLComposer(self).compose()
def make_gl_entries(self, cancel=0, adv_adj=0):
def make_gl_entries(self, cancel: int = 0, adv_adj: int = 0) -> None:
from erpnext.accounts.general_ledger import make_gl_entries
merge_entries = frappe.get_single_value("Accounts Settings", "merge_similar_account_heads")
@@ -846,94 +883,109 @@ class JournalEntry(AccountsController):
cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
@frappe.whitelist()
def get_balance(self, difference_account: str | None = None):
def get_balance(self, difference_account: str | None = None) -> None:
"""Balance the entry by placing any difference on a blank (or newly added) row."""
if not self.get("accounts"):
msgprint(_("'Entries' cannot be empty"), raise_exception=True)
else:
self.total_debit, self.total_credit = 0, 0
diff = flt(self.difference, self.precision("difference"))
return
# If any row without amount, set the diff on that row
if diff:
blank_row = None
for d in self.get("accounts"):
if not d.credit_in_account_currency and not d.debit_in_account_currency and diff != 0:
blank_row = d
self.total_debit, self.total_credit = 0, 0
diff = flt(self.difference, self.precision("difference"))
if diff:
self._apply_difference_to_blank_row(diff, difference_account)
if not blank_row:
blank_row = self.append(
"accounts",
{
"account": difference_account,
"cost_center": erpnext.get_default_cost_center(self.company),
},
)
self.set_total_debit_credit()
self.validate_total_debit_and_credit()
blank_row.exchange_rate = 1
if diff > 0:
blank_row.credit_in_account_currency = diff
blank_row.credit = diff
elif diff < 0:
blank_row.debit_in_account_currency = abs(diff)
blank_row.debit = abs(diff)
def _apply_difference_to_blank_row(self, diff: float, difference_account: str | None) -> None:
"""Set the balancing difference on the last amountless row, adding one if none exists."""
blank_row = None
for row in self.get("accounts"):
if not row.credit_in_account_currency and not row.debit_in_account_currency:
blank_row = row
self.set_total_debit_credit()
self.validate_total_debit_and_credit()
if not blank_row:
blank_row = self.append(
"accounts",
{
"account": difference_account,
"cost_center": erpnext.get_default_cost_center(self.company),
},
)
blank_row.exchange_rate = 1
if diff > 0:
blank_row.credit_in_account_currency = diff
blank_row.credit = diff
elif diff < 0:
blank_row.debit_in_account_currency = abs(diff)
blank_row.debit = abs(diff)
@frappe.whitelist()
def get_outstanding_invoices(self):
def get_outstanding_invoices(self) -> None:
"""Populate the entry with a write-off row per outstanding invoice plus a balancing row."""
self.set("accounts", [])
total = 0
for d in self.get_values():
total += flt(d.outstanding_amount, self.precision("credit", "accounts"))
jd1 = self.append("accounts", {})
jd1.account = d.account
jd1.party = d.party
for invoice in self.get_values():
total += flt(invoice.outstanding_amount, self.precision("credit", "accounts"))
self._append_outstanding_invoice_row(invoice)
if self.write_off_based_on == "Accounts Receivable":
jd1.party_type = "Customer"
jd1.credit_in_account_currency = flt(
d.outstanding_amount, self.precision("credit", "accounts")
)
jd1.reference_type = "Sales Invoice"
jd1.reference_name = cstr(d.name)
elif self.write_off_based_on == "Accounts Payable":
jd1.party_type = "Supplier"
jd1.debit_in_account_currency = flt(d.outstanding_amount, self.precision("debit", "accounts"))
jd1.reference_type = "Purchase Invoice"
jd1.reference_name = cstr(d.name)
jd2 = self.append("accounts", {})
balancing_row = self.append("accounts", {})
if self.write_off_based_on == "Accounts Receivable":
jd2.debit_in_account_currency = total
balancing_row.debit_in_account_currency = total
elif self.write_off_based_on == "Accounts Payable":
jd2.credit_in_account_currency = total
balancing_row.credit_in_account_currency = total
self.validate_total_debit_and_credit()
def get_values(self):
cond = (
f" and outstanding_amount <= {flt(self.write_off_amount)}"
if flt(self.write_off_amount) > 0
else ""
)
def _append_outstanding_invoice_row(self, invoice) -> None:
"""Append a party row for a single outstanding invoice per the write-off basis."""
row = self.append("accounts", {})
row.account = invoice.account
row.party = invoice.party
if self.write_off_based_on == "Accounts Receivable":
return frappe.db.sql(
"""select name, debit_to as account, customer as party, outstanding_amount
from `tabSales Invoice` where docstatus = 1 and company = {}
and outstanding_amount > 0 {}""".format("%s", cond),
self.company,
as_dict=True,
row.party_type = "Customer"
row.credit_in_account_currency = flt(
invoice.outstanding_amount, self.precision("credit", "accounts")
)
row.reference_type = "Sales Invoice"
row.reference_name = cstr(invoice.name)
elif self.write_off_based_on == "Accounts Payable":
return frappe.db.sql(
"""select name, credit_to as account, supplier as party, outstanding_amount
from `tabPurchase Invoice` where docstatus = 1 and company = {}
and outstanding_amount > 0 {}""".format("%s", cond),
self.company,
as_dict=True,
row.party_type = "Supplier"
row.debit_in_account_currency = flt(
invoice.outstanding_amount, self.precision("debit", "accounts")
)
row.reference_type = "Purchase Invoice"
row.reference_name = cstr(invoice.name)
def get_values(self):
if self.write_off_based_on == "Accounts Receivable":
doctype, account_field, party_field = "Sales Invoice", "debit_to", "customer"
elif self.write_off_based_on == "Accounts Payable":
doctype, account_field, party_field = "Purchase Invoice", "credit_to", "supplier"
else:
return
invoice = frappe.qb.DocType(doctype)
query = (
frappe.qb.from_(invoice)
.select(
invoice.name,
invoice[account_field].as_("account"),
invoice[party_field].as_("party"),
invoice.outstanding_amount,
)
.where(
(invoice.docstatus == 1)
& (invoice.company == self.company)
& (invoice.outstanding_amount > 0)
)
)
if flt(self.write_off_amount) > 0:
query = query.where(invoice.outstanding_amount <= flt(self.write_off_amount))
return query.run(as_dict=True)
def validate_credit_debit_note(self):
if self.stock_entry:
@@ -962,7 +1014,7 @@ def get_default_bank_cash_account(
account: str | None = None,
*,
fetch_balance: bool = True,
):
) -> dict:
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
if mode_of_payment:
@@ -1017,7 +1069,8 @@ def get_against_jv(
start: int,
page_len: int,
filters: dict,
):
) -> list:
"""Link-field search for submitted Journal Entries having an unreferenced row on an account."""
if not frappe.db.has_column("Journal Entry", searchfield):
return []
@@ -1048,67 +1101,97 @@ def get_against_jv(
@frappe.whitelist()
def get_outstanding(args: str | dict):
def get_outstanding(
doctype: str | None = None,
docname: str | None = None,
company: str | None = None,
account: str | None = None,
party: str | None = None,
account_currency: str | None = None,
**kwargs,
) -> dict | None:
"""Return the outstanding amount and side to set when referencing a JV / Invoice.
The named parameters are the supported interface. The legacy `args` payload dict
(captured via kwargs) is still accepted for backward compatibility with callers,
including custom apps, and is unpacked into the named parameters below.
"""
if not frappe.has_permission("Account"):
frappe.msgprint(_("No Permission"), raise_exception=1)
if isinstance(args, str):
args = json.loads(args)
if legacy_payload := kwargs.get("args"):
if isinstance(legacy_payload, str):
legacy_payload = json.loads(legacy_payload)
doctype = legacy_payload.get("doctype")
docname = legacy_payload.get("docname")
company = legacy_payload.get("company")
account = legacy_payload.get("account")
party = legacy_payload.get("party")
account_currency = legacy_payload.get("account_currency")
company_currency = erpnext.get_company_currency(args.get("company"))
due_date = None
if doctype == "Journal Entry":
return _get_journal_entry_outstanding(docname, account, party)
if args.get("doctype") == "Journal Entry":
condition = " and party=%(party)s" if args.get("party") else ""
if doctype in ("Sales Invoice", "Purchase Invoice"):
return _get_invoice_outstanding(doctype, docname, company, account_currency)
against_jv_amount = frappe.db.sql(
f"""
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabJournal Entry Account` where parent=%(docname)s and account=%(account)s {condition}
and (reference_type is null or reference_type = '')""",
args,
def _get_journal_entry_outstanding(docname: str, account: str | None, party: str | None) -> dict:
"""Unreferenced debit-minus-credit balance for an account on a Journal Entry."""
jea = frappe.qb.DocType("Journal Entry Account")
query = (
frappe.qb.from_(jea)
.select(Sum(jea.debit_in_account_currency) - Sum(jea.credit_in_account_currency))
.where(
(jea.parent == docname)
& (jea.account == account)
& (jea.reference_type.isnull() | (jea.reference_type == ""))
)
)
if party:
query = query.where(jea.party == party)
result = query.run()
balance = flt(result[0][0]) if result else 0
amount_field = "credit_in_account_currency" if balance > 0 else "debit_in_account_currency"
return {amount_field: abs(balance)}
def _get_invoice_outstanding(doctype: str, docname: str, company: str, account_currency: str | None) -> dict:
"""Outstanding amount, side, party and exchange rate for a Sales/Purchase Invoice."""
party_type = "Customer" if doctype == "Sales Invoice" else "Supplier"
invoice = frappe.db.get_value(
doctype,
docname,
["outstanding_amount", "conversion_rate", scrub(party_type), "due_date"],
as_dict=1,
)
company_currency = erpnext.get_company_currency(company)
exchange_rate = invoice.conversion_rate if account_currency != company_currency else 1
outstanding_is_positive = flt(invoice.outstanding_amount) > 0
if doctype == "Sales Invoice":
amount_field = (
"credit_in_account_currency" if outstanding_is_positive else "debit_in_account_currency"
)
else:
amount_field = (
"debit_in_account_currency" if outstanding_is_positive else "credit_in_account_currency"
)
against_jv_amount = flt(against_jv_amount[0][0]) if against_jv_amount else 0
amount_field = "credit_in_account_currency" if against_jv_amount > 0 else "debit_in_account_currency"
return {amount_field: abs(against_jv_amount)}
elif args.get("doctype") in ("Sales Invoice", "Purchase Invoice"):
party_type = "Customer" if args.get("doctype") == "Sales Invoice" else "Supplier"
invoice = frappe.db.get_value(
args["doctype"],
args["docname"],
["outstanding_amount", "conversion_rate", scrub(party_type), "due_date"],
as_dict=1,
)
due_date = invoice.get("due_date")
exchange_rate = invoice.conversion_rate if (args.get("account_currency") != company_currency) else 1
if args["doctype"] == "Sales Invoice":
amount_field = (
"credit_in_account_currency"
if flt(invoice.outstanding_amount) > 0
else "debit_in_account_currency"
)
else:
amount_field = (
"debit_in_account_currency"
if flt(invoice.outstanding_amount) > 0
else "credit_in_account_currency"
)
return {
amount_field: abs(flt(invoice.outstanding_amount)),
"exchange_rate": exchange_rate,
"party_type": party_type,
"party": invoice.get(scrub(party_type)),
"reference_due_date": due_date,
}
return {
amount_field: abs(flt(invoice.outstanding_amount)),
"exchange_rate": exchange_rate,
"party_type": party_type,
"party": invoice.get(scrub(party_type)),
"reference_due_date": invoice.get("due_date"),
}
@frappe.whitelist()
def get_party_account_and_currency(company: str, party_type: str, party: str):
def get_party_account_and_currency(company: str, party_type: str, party: str) -> dict:
"""Return the receivable/payable account for a party and its account currency."""
if not frappe.has_permission("Account"):
frappe.msgprint(_("No Permission"), raise_exception=1)
@@ -1128,7 +1211,7 @@ def get_account_details_and_party_type(
debit: float | str | None = None,
credit: float | str | None = None,
exchange_rate: float | str | None = None,
):
) -> dict:
"""Returns dict of account details and party type to be set in Journal Entry on selection of account."""
if not frappe.has_permission("Account"):
frappe.msgprint(_("No Permission"), raise_exception=1)
@@ -1186,7 +1269,8 @@ def get_exchange_rate(
debit: float | str | None = None,
credit: float | str | None = None,
exchange_rate: str | float | None = None,
):
) -> float:
"""Resolve the exchange rate for an account row, by reference, balance or settings."""
# Ensure exchange_rate is always numeric to avoid calculation errors
if isinstance(exchange_rate, str):
exchange_rate = flt(exchange_rate) or 1
@@ -1219,14 +1303,3 @@ def get_exchange_rate(
# don't return None or 0 as it is multipled with a value and that value could be lost
return exchange_rate or 1
@frappe.whitelist()
def get_average_exchange_rate(account: str):
exchange_rate = 0
bank_balance_in_account_currency = get_balance_on(account)
if bank_balance_in_account_currency:
bank_balance_in_company_currency = get_balance_on(account, in_account_currency=False)
exchange_rate = bank_balance_in_company_currency / bank_balance_in_account_currency
return exchange_rate

View File

@@ -24,7 +24,8 @@ def get_payment_entry_against_order(
debit_in_account_currency: str | float | None = None,
journal_entry: bool = False,
bank_account: str | None = None,
):
) -> dict | Document:
"""Build an advance-payment Journal Entry against an unbilled Sales/Purchase Order."""
ref_doc = frappe.get_doc(dt, dn)
if flt(ref_doc.per_billed, 2) > 0:
@@ -74,7 +75,8 @@ def get_payment_entry_against_invoice(
debit_in_account_currency: str | None = None,
journal_entry: bool = False,
bank_account: str | None = None,
):
) -> dict | Document:
"""Build a payment Journal Entry against a Sales/Purchase Invoice's outstanding amount."""
ref_doc = frappe.get_doc(dt, dn)
if dt == "Sales Invoice":
party_type = "Customer"
@@ -110,32 +112,54 @@ def get_payment_entry_against_invoice(
)
def get_payment_entry(ref_doc, args):
from erpnext.accounts.doctype.journal_entry.journal_entry import (
get_default_bank_cash_account,
get_exchange_rate,
)
def get_payment_entry(ref_doc, args: dict) -> dict | Document:
"""Build a Bank Entry Journal Entry paying `ref_doc`, with a party row and a bank row.
Returns the Journal Entry document when `args["journal_entry"]` is truthy, otherwise its
dict (for client calls).
"""
je = frappe.new_doc("Journal Entry")
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
"Company", ref_doc.company, "cost_center"
)
exchange_rate = 1
if args.get("party_account"):
# Modified to include the posting date for which the exchange rate is required.
# Assumed to be the posting date in the reference document
exchange_rate = get_exchange_rate(
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
args.get("party_account"),
args.get("party_account_currency"),
ref_doc.company,
ref_doc.doctype,
ref_doc.name,
)
exchange_rate = _reference_exchange_rate(ref_doc, args)
je = frappe.new_doc("Journal Entry")
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
party_row = _append_party_row(je, ref_doc, args, cost_center, exchange_rate)
bank_row = _append_bank_row(je, ref_doc, args, cost_center, exchange_rate)
party_row = je.append(
if party_row.account_currency != ref_doc.company_currency or (
bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency
):
je.multi_currency = 1
je.set_amounts_in_company_currency()
je.set_total_debit_credit()
return je if args.get("journal_entry") else je.as_dict()
def _reference_exchange_rate(ref_doc, args: dict) -> float:
"""Exchange rate of the party account on the reference document's posting date."""
if not args.get("party_account"):
return 1
from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate
return get_exchange_rate(
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
args.get("party_account"),
args.get("party_account_currency"),
ref_doc.company,
ref_doc.doctype,
ref_doc.name,
)
def _append_party_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
"""Append the party (debtor/creditor) row that records the advance/payment."""
return je.append(
"accounts",
{
"account": args.get("party_account"),
@@ -153,14 +177,19 @@ def get_payment_entry(ref_doc, args):
},
)
bank_row = je.append("accounts")
# Make it bank_details
def _append_bank_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
"""Append the bank/cash row, defaulting the account and converting the amount to it."""
from erpnext.accounts.doctype.journal_entry.journal_entry import (
get_default_bank_cash_account,
get_exchange_rate,
)
bank_row = je.append("accounts")
bank_account = get_default_bank_cash_account(ref_doc.company, "Bank", account=args.get("bank_account"))
if bank_account:
bank_row.update(bank_account)
# Modified to include the posting date for which the exchange rate is required.
# Assumed to be the posting date of the reference date
# posting date assumed to be the reference document's posting/transaction date
bank_row.exchange_rate = get_exchange_rate(
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
bank_account["account"],
@@ -171,26 +200,17 @@ def get_payment_entry(ref_doc, args):
bank_row.cost_center = cost_center
amount = args.get("debit_in_account_currency") or args.get("amount")
if bank_row.account_currency == args.get("party_account_currency"):
bank_row.set(args.get("amount_field_bank"), amount)
else:
bank_row.set(args.get("amount_field_bank"), amount * exchange_rate)
# Multi currency check again
if party_row.account_currency != ref_doc.company_currency or (
bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency
):
je.multi_currency = 1
je.set_amounts_in_company_currency()
je.set_total_debit_credit()
return je if args.get("journal_entry") else je.as_dict()
return bank_row
@frappe.whitelist()
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str):
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str) -> dict:
"""Build the counterpart Journal Entry in another company, linked back to `name`."""
journal_entry = frappe.new_doc("Journal Entry")
journal_entry.voucher_type = voucher_type
journal_entry.company = company
@@ -200,7 +220,8 @@ def make_inter_company_journal_entry(name: str, voucher_type: str, company: str)
@frappe.whitelist()
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None):
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None) -> Document:
"""Map a submitted Journal Entry to a reversing one (debits and credits swapped)."""
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
if existing_reverse:
frappe.throw(
@@ -211,7 +232,7 @@ def make_reverse_journal_entry(source_name: str, target_doc: str | Document | No
from frappe.model.mapper import get_mapped_doc
def post_process(source, target):
def post_process(source, target) -> None:
target.reversal_of = source.name
doclist = get_mapped_doc(

View File

@@ -20,10 +20,11 @@ class AssetService:
Journal Entries tied to asset scrapping or value adjustments.
"""
def __init__(self, doc):
def __init__(self, doc) -> None:
self.doc = doc
def validate_depr_account_and_depr_entry_voucher_type(self):
def validate_depr_account_and_depr_entry_voucher_type(self) -> None:
"""A depreciation account requires voucher type Depreciation Entry and an Expense account."""
for d in self.doc.get("accounts"):
if d.account_type == "Depreciation":
if self.doc.voucher_type != "Depreciation Entry":
@@ -34,7 +35,8 @@ class AssetService:
if frappe.get_cached_value("Account", d.account, "root_type") != "Expense":
frappe.throw(_("Account {0} should be of type Expense").format(d.account))
def has_asset_adjustment_entry(self):
def has_asset_adjustment_entry(self) -> None:
"""Block cancellation while a submitted Asset Value Adjustment links to this entry."""
if self.doc.flags.get("via_asset_value_adjustment"):
return
@@ -48,11 +50,13 @@ class AssetService:
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
)
def update_asset_value(self):
def update_asset_value(self) -> None:
"""Apply the entry's effect to its linked assets on submit (depreciation or disposal)."""
self.update_asset_on_depreciation()
self.update_asset_on_disposal()
def update_asset_on_depreciation(self):
def update_asset_on_depreciation(self) -> None:
"""Reduce each depreciated asset's value and link the depreciation schedule row."""
if self.doc.voucher_type != "Depreciation Entry":
return
@@ -73,7 +77,8 @@ class AssetService:
asset.set_status()
asset.set_total_booked_depreciations()
def update_value_after_depreciation(self, asset, depr_amount):
def update_value_after_depreciation(self, asset, depr_amount: float) -> None:
"""Subtract the depreciation amount from the asset's relevant finance book."""
fb_idx = 1
if self.doc.finance_book:
for fb_row in asset.get("finance_books"):
@@ -86,7 +91,8 @@ class AssetService:
"Asset Finance Book", fb_row.name, "value_after_depreciation", fb_row.value_after_depreciation
)
def update_journal_entry_link_on_depr_schedule(self, asset, je_row):
def update_journal_entry_link_on_depr_schedule(self, asset, je_row) -> None:
"""Stamp this entry onto the matching (date + amount) depreciation schedule row."""
depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book)
for d in depr_schedule or []:
if (
@@ -96,7 +102,8 @@ class AssetService:
):
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name)
def update_asset_on_disposal(self):
def update_asset_on_disposal(self) -> None:
"""Mark each referenced asset disposed (date + scrap entry) on an Asset Disposal."""
if self.doc.voucher_type == "Asset Disposal":
disposed_assets = []
for d in self.doc.get("accounts"):
@@ -117,62 +124,74 @@ class AssetService:
asset_doc.set_status()
disposed_assets.append(d.reference_name)
def unlink_asset_reference(self):
def unlink_asset_reference(self) -> None:
"""On cancel, reverse depreciation links and block cancelling an asset-scrap entry."""
for d in self.doc.get("accounts"):
if (
self.doc.voucher_type == "Depreciation Entry"
and d.reference_type == "Asset"
and d.reference_name
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
and d.debit
):
asset = frappe.get_doc("Asset", d.reference_name)
if asset.calculate_depreciation:
je_found = False
for fb_row in asset.get("finance_books"):
if je_found:
break
depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book)
for s in depr_schedule or []:
if s.journal_entry == self.doc.name:
s.db_set("journal_entry", None)
fb_row.value_after_depreciation += d.debit
fb_row.db_update()
je_found = True
break
if not je_found:
fb_idx = 1
if self.doc.finance_book:
for fb_row in asset.get("finance_books"):
if fb_row.finance_book == self.doc.finance_book:
fb_idx = fb_row.idx
break
fb_row = asset.get("finance_books")[fb_idx - 1]
fb_row.value_after_depreciation += d.debit
fb_row.db_update()
asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit)
asset.set_status()
asset.set_total_booked_depreciations()
if self._is_depreciation_asset_row(d):
self._reverse_asset_depreciation(d)
elif (
self.doc.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name
):
journal_entry_for_scrap = frappe.db.get_value(
"Asset", d.reference_name, "journal_entry_for_scrap"
)
self._block_scrap_journal_cancel(d)
if journal_entry_for_scrap == self.doc.name:
frappe.throw(
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
)
def _is_depreciation_asset_row(self, d) -> bool:
return bool(
self.doc.voucher_type == "Depreciation Entry"
and d.reference_type == "Asset"
and d.reference_name
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
and d.debit
)
def unlink_asset_adjustment_entry(self):
def _reverse_asset_depreciation(self, d) -> None:
"""Add the depreciation amount back to the asset and unlink its schedule row."""
asset = frappe.get_doc("Asset", d.reference_name)
if asset.calculate_depreciation and not self._restore_scheduled_depreciation(asset, d.debit):
self._restore_finance_book_value(asset, d.debit)
asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit)
asset.set_status()
asset.set_total_booked_depreciations()
def _restore_scheduled_depreciation(self, asset, debit: float) -> bool:
"""Unlink this entry from the depreciation schedule and credit back its finance book.
Returns True if a matching scheduled depreciation was found.
"""
for fb_row in asset.get("finance_books"):
depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book)
for s in depr_schedule or []:
if s.journal_entry == self.doc.name:
s.db_set("journal_entry", None)
fb_row.value_after_depreciation += debit
fb_row.db_update()
return True
return False
def _restore_finance_book_value(self, asset, debit: float) -> None:
"""Credit the depreciation amount back to the relevant finance book when no schedule matched."""
fb_idx = 1
if self.doc.finance_book:
for fb_row in asset.get("finance_books"):
if fb_row.finance_book == self.doc.finance_book:
fb_idx = fb_row.idx
break
fb_row = asset.get("finance_books")[fb_idx - 1]
fb_row.value_after_depreciation += debit
fb_row.db_update()
def _block_scrap_journal_cancel(self, d) -> None:
"""Prevent cancelling a plain Journal Entry that is an asset's scrap voucher."""
journal_entry_for_scrap = frappe.db.get_value("Asset", d.reference_name, "journal_entry_for_scrap")
if journal_entry_for_scrap == self.doc.name:
frappe.throw(
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
)
def unlink_asset_adjustment_entry(self) -> None:
"""Detach this entry from any Asset Value Adjustment that referenced it."""
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
(
frappe.qb.update(AssetValueAdjustment)

View File

@@ -18,86 +18,88 @@ class JournalEntryGLComposer(BaseGLComposer):
from the first foreign-currency row (mirroring the former build_gl_map).
"""
def compose(self):
doc = self.doc
gl_map = []
company_currency = erpnext.get_company_currency(doc.company)
doc.transaction_currency = company_currency
doc.transaction_exchange_rate = 1
if doc.multi_currency:
for row in doc.get("accounts"):
if row.account_currency != company_currency:
# Journal assumes the first foreign currency as transaction currency
doc.transaction_currency = row.account_currency
doc.transaction_exchange_rate = row.exchange_rate
break
def compose(self) -> list:
"""Project the Journal Entry's non-zero account rows into GL dicts."""
self._set_transaction_currency()
advance_doctypes = get_advance_payment_doctypes()
for d in doc.get("accounts"):
if d.debit or d.credit or (doc.voucher_type == "Exchange Gain Or Loss"):
r = [d.user_remark, doc.remark]
r = [x for x in r if x]
remarks = "\n".join(r)
row = {
"account": d.account,
"party_type": d.party_type,
"due_date": doc.due_date,
"party": d.party,
"against": d.against_account,
"debit": flt(d.debit, d.precision("debit")),
"credit": flt(d.credit, d.precision("credit")),
"account_currency": d.account_currency,
"debit_in_account_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
),
"credit_in_account_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
),
"transaction_currency": doc.transaction_currency,
"transaction_exchange_rate": doc.transaction_exchange_rate,
"debit_in_transaction_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
"credit_in_transaction_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
"against_voucher_type": d.reference_type,
"against_voucher": d.reference_name,
"remarks": remarks,
"voucher_detail_no": d.reference_detail_no,
"cost_center": d.cost_center,
"project": d.project,
"finance_book": doc.finance_book,
"advance_voucher_type": d.advance_voucher_type,
"advance_voucher_no": d.advance_voucher_no,
}
if d.reference_type in advance_doctypes:
row.update(
{
"against_voucher_type": doc.doctype,
"against_voucher": doc.name,
"advance_voucher_type": d.reference_type,
"advance_voucher_no": d.reference_name,
}
)
# set flag to skip party validation
account_type = frappe.get_cached_value("Account", d.account, "account_type")
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
frappe.flags.party_not_required = True
gl_map.append(
self.get_gl_dict(
row,
item=d,
)
)
gl_map = []
for d in self.doc.get("accounts"):
if d.debit or d.credit or self.doc.voucher_type == "Exchange Gain Or Loss":
gl_map.append(self.get_gl_dict(self._gl_row(d, advance_doctypes), item=d))
return gl_map
def _set_transaction_currency(self) -> None:
"""Company currency, or the first foreign-currency row, becomes the transaction currency."""
doc = self.doc
doc.transaction_currency = erpnext.get_company_currency(doc.company)
doc.transaction_exchange_rate = 1
if not doc.multi_currency:
return
for row in doc.get("accounts"):
if row.account_currency != doc.transaction_currency:
# Journal assumes the first foreign currency as transaction currency
doc.transaction_currency = row.account_currency
doc.transaction_exchange_rate = row.exchange_rate
break
def _gl_row(self, d, advance_doctypes: list) -> dict:
"""Build the GL dict for a single account row."""
doc = self.doc
remarks = "\n".join(x for x in [d.user_remark, doc.remark] if x)
row = {
"account": d.account,
"party_type": d.party_type,
"due_date": doc.due_date,
"party": d.party,
"against": d.against_account,
"debit": flt(d.debit, d.precision("debit")),
"credit": flt(d.credit, d.precision("credit")),
"account_currency": d.account_currency,
"debit_in_account_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
),
"credit_in_account_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
),
"transaction_currency": doc.transaction_currency,
"transaction_exchange_rate": doc.transaction_exchange_rate,
"debit_in_transaction_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
"credit_in_transaction_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
"against_voucher_type": d.reference_type,
"against_voucher": d.reference_name,
"remarks": remarks,
"voucher_detail_no": d.reference_detail_no,
"cost_center": d.cost_center,
"project": d.project,
"finance_book": doc.finance_book,
"advance_voucher_type": d.advance_voucher_type,
"advance_voucher_no": d.advance_voucher_no,
}
if d.reference_type in advance_doctypes:
row.update(
{
"against_voucher_type": doc.doctype,
"against_voucher": doc.name,
"advance_voucher_type": d.reference_type,
"advance_voucher_no": d.reference_name,
}
)
# set flag to skip party validation
account_type = frappe.get_cached_value("Account", d.account, "account_type")
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
frappe.flags.party_not_required = True
return row

View File

@@ -29,10 +29,11 @@ class JournalEntryReferenceValidator:
orders and invoices.
"""
def __init__(self, doc):
def __init__(self, doc) -> None:
self.doc = doc
def validate(self):
def validate(self) -> None:
"""Validate every reference-bearing row, then the referenced orders and invoices."""
self.doc.reference_totals = {}
self.doc.reference_types = {}
self.doc.reference_accounts = {}
@@ -47,23 +48,24 @@ class JournalEntryReferenceValidator:
self._validate_orders()
self._validate_invoices()
def _normalize_reference_fields(self, row):
def _normalize_reference_fields(self, row) -> None:
if not row.reference_type:
row.reference_name = None
if not row.reference_name:
row.reference_type = None
def _has_party_reference(self, row):
def _has_party_reference(self, row) -> bool:
return bool(
row.reference_type and row.reference_name and row.reference_type in REFERENCE_PARTY_ACCOUNT_FIELDS
)
def _reference_amount_field(self, row):
def _reference_amount_field(self, row) -> str:
if row.reference_type in ("Sales Order", "Sales Invoice"):
return "credit_in_account_currency"
return "debit_in_account_currency"
def _validate_order_direction(self, row):
def _validate_order_direction(self, row) -> None:
"""An order can only be linked on the side that records an advance."""
if row.reference_type == "Sales Order" and flt(row.debit) > 0:
frappe.throw(
_("Row {0}: Debit entry can not be linked with a {1}").format(row.idx, row.reference_type)
@@ -73,7 +75,8 @@ class JournalEntryReferenceValidator:
_("Row {0}: Credit entry can not be linked with a {1}").format(row.idx, row.reference_type)
)
def _register_reference(self, row):
def _register_reference(self, row) -> None:
"""Aggregate the row's amount, type and account onto the per-reference lookups."""
if row.reference_name not in self.doc.reference_totals:
self.doc.reference_totals[row.reference_name] = 0.0
if self.doc.voucher_type not in ("Deferred Revenue", "Deferred Expense"):
@@ -81,7 +84,8 @@ class JournalEntryReferenceValidator:
self.doc.reference_types[row.reference_name] = row.reference_type
self.doc.reference_accounts[row.reference_name] = row.account
def _validate_reference_party_and_account(self, row):
def _validate_reference_party_and_account(self, row) -> None:
"""Reject a missing reference, then check party/account against the linked document."""
party_fields = REFERENCE_PARTY_ACCOUNT_FIELDS[row.reference_type]
against_voucher = frappe.db.get_value(
row.reference_type, row.reference_name, [scrub(f) for f in party_fields]
@@ -94,7 +98,7 @@ class JournalEntryReferenceValidator:
elif row.reference_type in ("Sales Order", "Purchase Order"):
self._validate_order_party(row, against_voucher)
def _validate_invoice_party_and_account(self, row, against_voucher, party_fields):
def _validate_invoice_party_and_account(self, row, against_voucher, party_fields) -> None:
party_account, against_party = self._resolve_invoice_party_account(row, against_voucher)
if self.doc.voucher_type == "Exchange Gain Or Loss":
return
@@ -105,7 +109,9 @@ class JournalEntryReferenceValidator:
)
)
def _resolve_invoice_party_account(self, row, against_voucher):
def _resolve_invoice_party_account(self, row, against_voucher) -> tuple:
"""Expected (party_account, party) for an invoice row, honouring deferred booking
and invoice-discounting accounts."""
if self.doc.voucher_type in ("Deferred Revenue", "Deferred Expense") and row.reference_detail_no:
debit_or_credit = "Debit" if row.debit else "Credit"
party_account = get_deferred_booking_accounts(
@@ -120,7 +126,7 @@ class JournalEntryReferenceValidator:
party_account = against_voucher[1]
return party_account, against_voucher[0]
def _validate_order_party(self, row, against_voucher):
def _validate_order_party(self, row, against_voucher) -> None:
if against_voucher != row.party:
frappe.throw(
_("Row {0}: {1} {2} does not match with {3}").format(
@@ -128,8 +134,8 @@ class JournalEntryReferenceValidator:
)
)
def _validate_orders(self):
"""Validate totals, closed and docstatus for orders"""
def _validate_orders(self) -> None:
"""Validate totals, closed and docstatus for referenced orders."""
for reference_name, total in self.doc.reference_totals.items():
reference_type = self.doc.reference_types[reference_name]
account = self.doc.reference_accounts[reference_name]
@@ -140,7 +146,7 @@ class JournalEntryReferenceValidator:
self._validate_order_status(order, reference_type, reference_name)
self._validate_order_advance_total(order, account, total, reference_type, reference_name)
def _validate_order_status(self, order, reference_type, reference_name):
def _validate_order_status(self, order, reference_type, reference_name) -> None:
if order.docstatus != 1:
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
if flt(order.per_billed) >= 100:
@@ -148,7 +154,8 @@ class JournalEntryReferenceValidator:
if cstr(order.status) == "Closed":
frappe.throw(_("{0} {1} is closed").format(reference_type, reference_name))
def _validate_order_advance_total(self, order, account, total, reference_type, reference_name):
def _validate_order_advance_total(self, order, account, total, reference_type, reference_name) -> None:
"""The advance paid against an order cannot exceed its grand total."""
account_currency = get_account_currency(account)
if account_currency == self.doc.company_currency:
voucher_total = order.base_grand_total
@@ -167,8 +174,8 @@ class JournalEntryReferenceValidator:
)
)
def _validate_invoices(self):
"""Validate totals and docstatus for invoices"""
def _validate_invoices(self) -> None:
"""Validate totals and docstatus for referenced invoices."""
if self.doc.voucher_type in ("Debit Note", "Credit Note"):
return
for reference_name, total in self.doc.reference_totals.items():
@@ -178,7 +185,8 @@ class JournalEntryReferenceValidator:
invoice = frappe.get_doc(reference_type, reference_name)
self._validate_invoice_outstanding(invoice, total, reference_type, reference_name)
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name):
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name) -> None:
"""Payment booked against an invoice cannot exceed its outstanding amount."""
if invoice.docstatus != 1:
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))

View File

@@ -169,8 +169,11 @@ class TestJournalEntry(ERPNextTestSuite):
"debit_in_account_currency",
"credit",
"credit_in_account_currency",
"debit_in_transaction_currency",
"credit_in_transaction_currency",
]
# Transaction currency is USD (first foreign row); the INR row is converted at 1/50.
self.expected_gle = [
{
"account": "_Test Bank - _TC",
@@ -179,6 +182,8 @@ class TestJournalEntry(ERPNextTestSuite):
"debit_in_account_currency": 0,
"credit": 5000,
"credit_in_account_currency": 5000,
"debit_in_transaction_currency": 0,
"credit_in_transaction_currency": 100,
},
{
"account": "_Test Bank USD - _TC",
@@ -187,6 +192,8 @@ class TestJournalEntry(ERPNextTestSuite):
"debit_in_account_currency": 100,
"credit": 0,
"credit_in_account_currency": 0,
"debit_in_transaction_currency": 100,
"credit_in_transaction_currency": 0,
},
]
@@ -203,6 +210,52 @@ class TestJournalEntry(ERPNextTestSuite):
self.assertFalse(gle)
def test_multi_currency_transaction_currency_on_foreign_debit(self):
"""Pin debit_in_transaction_currency for a foreign-currency debit row.
Transaction currency is USD (the first foreign row); the INR debit row must be
converted at 1/exchange_rate, so 5000 INR -> 100 USD. Guards the / vs * direction.
"""
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.multi_currency = 1
jv.append(
"accounts",
{
"account": "_Test Bank USD - _TC",
"cost_center": "_Test Cost Center - _TC",
"credit_in_account_currency": 100,
"exchange_rate": 50,
},
)
jv.append(
"accounts",
{
"account": "_Test Bank - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit_in_account_currency": 5000,
"exchange_rate": 1,
},
)
jv.submit()
self.voucher_no = jv.name
self.fields = ["account", "debit_in_transaction_currency", "credit_in_transaction_currency"]
self.expected_gle = [
{
"account": "_Test Bank - _TC",
"debit_in_transaction_currency": 100,
"credit_in_transaction_currency": 0,
},
{
"account": "_Test Bank USD - _TC",
"debit_in_transaction_currency": 0,
"credit_in_transaction_currency": 100,
},
]
self.check_gl_entries()
def test_reverse_journal_entry(self):
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
@@ -688,6 +741,95 @@ class TestJournalEntry(ERPNextTestSuite):
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
self.assertEqual(jv.reference_accounts[invoice.name], "Debtors - _TC")
def test_get_balance_places_difference_on_blank_row(self):
"""Characterize: get_balance puts the unbalanced difference on an amountless row."""
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}) # amountless row
jv.set_total_debit_credit()
self.assertEqual(jv.difference, 100)
jv.get_balance()
blank_row = jv.accounts[1]
self.assertEqual(blank_row.credit_in_account_currency, 100)
self.assertEqual(jv.total_debit, jv.total_credit)
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
invoice = create_sales_invoice(rate=700)
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.voucher_type = "Write Off Entry"
jv.write_off_based_on = "Accounts Receivable"
jv.write_off_amount = 1000
jv.get_outstanding_invoices()
invoice_rows = [row for row in jv.accounts if row.reference_name == invoice.name]
self.assertTrue(invoice_rows)
self.assertEqual(invoice_rows[0].party_type, "Customer")
self.assertEqual(invoice_rows[0].reference_type, "Sales Invoice")
self.assertEqual(flt(invoice_rows[0].credit_in_account_currency), 700)
def test_unlink_advance_entry_reference_on_cancel(self):
"""Characterize: cancelling an advance JE against an invoice clears the row's reference."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=700)
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
advance_row = jv.accounts[1]
advance_row.party_type = "Customer"
advance_row.party = "_Test Customer"
advance_row.is_advance = "Yes"
advance_row.reference_type = "Sales Invoice"
advance_row.reference_name = invoice.name
jv.submit()
jv.cancel()
jv.reload()
self.assertFalse(jv.accounts[1].reference_type)
self.assertFalse(jv.accounts[1].reference_name)
def test_get_payment_entry_against_order_builds_advance_je(self):
"""Characterize the mapper: an advance Bank Entry JE is built against an unbilled order."""
from erpnext.accounts.doctype.journal_entry.mapper import get_payment_entry_against_order
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
sales_order = make_sales_order()
je = get_payment_entry_against_order("Sales Order", sales_order.name, journal_entry=True)
self.assertEqual(je.voucher_type, "Bank Entry")
party_rows = [row for row in je.accounts if row.party_type == "Customer"]
self.assertTrue(party_rows)
self.assertEqual(party_rows[0].reference_type, "Sales Order")
self.assertEqual(party_rows[0].reference_name, sales_order.name)
self.assertEqual(party_rows[0].is_advance, "Yes")
def test_make_inter_company_journal_entry_builds_linked_draft(self):
"""Characterize the mapper: the counterpart JE carries the company and back-reference."""
from erpnext.accounts.doctype.journal_entry.mapper import make_inter_company_journal_entry
source = make_journal_entry("_Test Cash - _TC", "_Test Bank - _TC", 100, submit=True)
result = make_inter_company_journal_entry(
source.name, "Inter Company Journal Entry", "_Test Company 1"
)
self.assertEqual(result.get("voucher_type"), "Inter Company Journal Entry")
self.assertEqual(result.get("company"), "_Test Company 1")
self.assertEqual(result.get("inter_company_journal_entry_reference"), source.name)
def make_journal_entry(
account1,

View File

@@ -12,10 +12,11 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestLedgerHealth(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()
self.company = "_Test Company"
self.customer = "_Test Customer"
self.debit_to = "Debtors - _TC"
self.income_account = "Sales - _TC"
self.configure_monitoring_tool()
self.clear_old_entries()
def configure_monitoring_tool(self):
monitor_settings = frappe.get_doc("Ledger Health Monitor")

View File

@@ -52,12 +52,11 @@ class ModeofPayment(Document):
def validate_pos_mode_of_payment(self):
if not self.enabled:
pos_profiles = frappe.db.sql(
"""SELECT sip.parent FROM `tabSales Invoice Payment` sip
WHERE sip.parenttype = 'POS Profile' and sip.mode_of_payment = %s""",
(self.name),
pos_profiles = frappe.get_all(
"Sales Invoice Payment",
filters={"parenttype": "POS Profile", "mode_of_payment": self.name},
pluck="parent",
)
pos_profiles = list(map(lambda x: x[0], pos_profiles))
if pos_profiles:
message = _(

View File

@@ -270,6 +270,13 @@ def start_import(invoices):
errors = 0
names = []
for idx, d in enumerate(invoices):
# Scope each invoice to a savepoint so a failure only undoes that invoice.
# A plain rollback() would discard the whole transaction — including invoices
# imported earlier in this batch and the error logs of earlier failures (the
# latter only survive on mariadb because the Error Log table is MyISAM; on
# postgres they would be lost). Rolling back to a savepoint keeps both.
savepoint = f"opening_invoice_{frappe.generate_hash(length=8)}"
frappe.db.savepoint(savepoint)
try:
invoice_number = None
if d.invoice_number:
@@ -284,7 +291,7 @@ def start_import(invoices):
names.append(doc.name)
except Exception:
errors += 1
frappe.db.rollback()
frappe.db.rollback(save_point=savepoint)
doc.log_error("Opening invoice creation failed")
if errors:
frappe.msgprint(

View File

@@ -9,8 +9,8 @@ import frappe
from frappe import ValidationError, _, qb, scrub, throw
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.query_builder import Tuple
from frappe.query_builder.functions import Count
from frappe.query_builder import Case, Tuple
from frappe.query_builder.functions import Abs, Count, Max
from frappe.utils import cint, comma_or, flt, getdate, nowdate
from frappe.utils.data import comma_and, fmt_money, get_link_to_form
from pypika.functions import Coalesce, Sum
@@ -766,13 +766,19 @@ class PaymentEntry(AccountsController):
def validate_journal_entry(self):
for d in self.get("references"):
if d.allocated_amount and d.reference_doctype == "Journal Entry":
je_accounts = frappe.db.sql(
"""select debit, credit from `tabJournal Entry Account`
where account = %s and party=%s and docstatus = 1 and parent = %s
and (reference_type is null or reference_type in ("", "Sales Order", "Purchase Order"))
""",
(self.party_account, self.party, d.reference_name),
as_dict=True,
je_accounts = frappe.get_all(
"Journal Entry Account",
filters={
"account": self.party_account,
"party": self.party,
"docstatus": 1,
"parent": d.reference_name,
},
or_filters=[
["reference_type", "is", "not set"],
["reference_type", "in", ["Sales Order", "Purchase Order"]],
],
fields=["debit", "credit"],
)
if not je_accounts:
@@ -857,27 +863,17 @@ class PaymentEntry(AccountsController):
)
base_outstanding = flt(allocated_amount * conversion_rate, base_outstanding_precision)
ps = frappe.qb.DocType("Payment Schedule")
if cancel:
frappe.db.sql(
"""
UPDATE `tabPayment Schedule`
SET
paid_amount = `paid_amount` - %s,
base_paid_amount = `base_paid_amount` - %s,
discounted_amount = `discounted_amount` - %s,
outstanding = `outstanding` + %s,
base_outstanding = `base_outstanding` - %s
WHERE parent = %s and payment_term = %s""",
(
allocated_amount - discounted_amt,
base_paid_amount,
discounted_amt,
allocated_amount,
base_outstanding,
key[1],
key[0],
),
)
(
frappe.qb.update(ps)
.set(ps.paid_amount, ps.paid_amount - (allocated_amount - discounted_amt))
.set(ps.base_paid_amount, ps.base_paid_amount - base_paid_amount)
.set(ps.discounted_amount, ps.discounted_amount - discounted_amt)
.set(ps.outstanding, ps.outstanding + allocated_amount)
.set(ps.base_outstanding, ps.base_outstanding - base_outstanding)
.where((ps.parent == key[1]) & (ps.payment_term == key[0]))
).run()
else:
if allocated_amount > outstanding:
frappe.throw(
@@ -887,26 +883,15 @@ class PaymentEntry(AccountsController):
)
if allocated_amount and outstanding:
frappe.db.sql(
"""
UPDATE `tabPayment Schedule`
SET
paid_amount = `paid_amount` + %s,
base_paid_amount = `base_paid_amount` + %s,
discounted_amount = `discounted_amount` + %s,
outstanding = `outstanding` - %s,
base_outstanding = `base_outstanding` - %s
WHERE parent = %s and payment_term = %s""",
(
allocated_amount - discounted_amt,
base_paid_amount,
discounted_amt,
allocated_amount,
base_outstanding,
key[1],
key[0],
),
)
(
frappe.qb.update(ps)
.set(ps.paid_amount, ps.paid_amount + (allocated_amount - discounted_amt))
.set(ps.base_paid_amount, ps.base_paid_amount + base_paid_amount)
.set(ps.discounted_amount, ps.discounted_amount + discounted_amt)
.set(ps.outstanding, ps.outstanding - allocated_amount)
.set(ps.base_outstanding, ps.base_outstanding - base_outstanding)
.where((ps.parent == key[1]) & (ps.payment_term == key[0]))
).run()
def get_allocated_amount_in_transaction_currency(
self, allocated_amount, reference_doctype, reference_docname
@@ -1216,11 +1201,7 @@ class PaymentEntry(AccountsController):
# Clear the reference document which doesn't have allocated amount on validate so that form can be loaded fast
def clear_unallocated_reference_document_rows(self):
self.set("references", self.get("references", {"allocated_amount": ["not in", [0, None, ""]]}))
frappe.db.sql(
"""delete from `tabPayment Entry Reference`
where parent = %s and allocated_amount = 0""",
self.name,
)
frappe.db.delete("Payment Entry Reference", {"parent": self.name, "allocated_amount": 0})
def set_title(self):
if frappe.flags.in_import and self.title:
@@ -1876,7 +1857,7 @@ def get_matched_payment_request_of_references(references=None):
PR.reference_doctype,
PR.reference_name,
PR.outstanding_amount.as_("allocated_amount"),
PR.name.as_("payment_request"),
Max(PR.name).as_("payment_request"), # count == 1 below ⇒ one row per group; postgres-safe
Count("*").as_("count"),
)
.where(Tuple(PR.reference_doctype, PR.reference_name, PR.outstanding_amount).isin(refs))
@@ -2315,12 +2296,7 @@ def get_orders_to_be_billed(
if not voucher_type:
return []
# dynamic dimension filters
condition = ""
active_dimensions = get_dimensions(True)[0]
for dim in active_dimensions:
if filters.get(dim.fieldname):
condition += f" and {dim.fieldname}={frappe.db.escape(filters.get(dim.fieldname))}"
if party_account_currency == company_currency:
grand_total_field = "base_grand_total"
@@ -2329,38 +2305,38 @@ def get_orders_to_be_billed(
grand_total_field = "grand_total"
rounded_total_field = "rounded_total"
orders = frappe.db.sql(
"""
select
name as voucher_no,
if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) as invoice_amount,
(if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) - advance_paid) as outstanding_amount,
transaction_date as posting_date
from
`tab{voucher_type}`
where
{party_type} = %s
and docstatus = 1
and company = %s
and status != "Closed"
and if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) > advance_paid
and abs(100 - per_billed) > 0.01
{condition}
order by
transaction_date, name
""".format(
**{
"rounded_total_field": rounded_total_field,
"grand_total_field": grand_total_field,
"voucher_type": voucher_type,
"party_type": scrub(party_type),
"condition": condition,
}
),
(party, company),
as_dict=True,
voucher = frappe.qb.DocType(voucher_type)
invoice_amount = (
Case()
.when(voucher[rounded_total_field] != 0, voucher[rounded_total_field])
.else_(voucher[grand_total_field])
)
query = (
frappe.qb.from_(voucher)
.select(
voucher.name.as_("voucher_no"),
invoice_amount.as_("invoice_amount"),
(invoice_amount - voucher.advance_paid).as_("outstanding_amount"),
voucher.transaction_date.as_("posting_date"),
)
.where(
(voucher[scrub(party_type)] == party)
& (voucher.docstatus == 1)
& (voucher.company == company)
& (voucher.status != "Closed")
& (invoice_amount > voucher.advance_paid)
& (Abs(100 - voucher.per_billed) > 0.01)
)
)
# dynamic dimension filters
for dim in active_dimensions:
if filters.get(dim.fieldname):
query = query.where(voucher[dim.fieldname] == filters.get(dim.fieldname))
orders = query.orderby(voucher.transaction_date).orderby(voucher.name).run(as_dict=True)
order_list = []
for d in orders:
if (
@@ -2409,8 +2385,8 @@ def get_negative_outstanding_invoices(
return frappe.db.sql(
"""
select
"{voucher_type}" as voucher_type, name as voucher_no, {account} as account,
if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) as invoice_amount,
'{voucher_type}' as voucher_type, name as voucher_no, {account} as account,
coalesce(nullif({rounded_total_field}, 0), {grand_total_field}) as invoice_amount,
outstanding_amount, posting_date,
due_date, conversion_rate as exchange_rate
from
@@ -2780,7 +2756,7 @@ def get_payment_entry(
pe, doc, discount_amount, base_total_discount_loss, party_account_currency
)
pe.set_exchange_rate(ref_doc=doc)
pe.set_exchange_rate()
pe.set_amounts()
# If PE is created from PR directly, then no need to find open PRs for the references
@@ -3272,27 +3248,28 @@ def get_reference_as_per_payment_terms(
def get_paid_amount(dt, dn, party_type, party, account, due_date):
gle = frappe.qb.DocType("GL Entry")
if party_type == "Customer":
dr_or_cr = "credit_in_account_currency - debit_in_account_currency"
dr_or_cr = gle.credit_in_account_currency - gle.debit_in_account_currency
else:
dr_or_cr = "debit_in_account_currency - credit_in_account_currency"
dr_or_cr = gle.debit_in_account_currency - gle.credit_in_account_currency
paid_amount = frappe.db.sql(
f"""
select ifnull(sum({dr_or_cr}), 0) as paid_amount
from `tabGL Entry`
where against_voucher_type = %s
and against_voucher = %s
and party_type = %s
and party = %s
and account = %s
and due_date = %s
and {dr_or_cr} > 0
""",
(dt, dn, party_type, party, account, due_date),
paid_amount = (
frappe.qb.from_(gle)
.select(Sum(dr_or_cr))
.where(
(gle.against_voucher_type == dt)
& (gle.against_voucher == dn)
& (gle.party_type == party_type)
& (gle.party == party)
& (gle.account == account)
& (gle.due_date == due_date)
& (dr_or_cr > 0)
)
.run()
)
return paid_amount[0][0] if paid_amount else 0
return (paid_amount[0][0] or 0) if paid_amount else 0
@frappe.whitelist()

View File

@@ -34,8 +34,14 @@ class PaymentEntryGLComposer(BaseGLComposer):
self.add_deductions_gl_entries(gl_entries)
self.add_tax_gl_entries(gl_entries)
add_regional_gl_entries(gl_entries, doc)
self.set_transaction_currency_and_rate_in_gl_map(gl_entries, doc)
return gl_entries
def set_transaction_currency_and_rate_in_gl_map(self, gl_entries, doc):
for gle in gl_entries:
gle.setdefault("transaction_currency", doc.transaction_currency)
gle.setdefault("transaction_exchange_rate", doc.transaction_exchange_rate)
def add_party_gl_entries(self, gl_entries):
doc = self.doc
if not doc.party_account:

View File

@@ -532,6 +532,8 @@ class TestPaymentEntry(ERPNextTestSuite):
si.submit()
pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC", bank_amount=4700)
pe.source_exchange_rate = 50
pe.set_amounts()
pe.reference_no = si.name
pe.reference_date = nowdate()
@@ -607,6 +609,8 @@ class TestPaymentEntry(ERPNextTestSuite):
pe = get_payment_entry(
"Sales Invoice", si.name, party_amount=20, bank_account="_Test Bank - _TC", bank_amount=900
)
pe.source_exchange_rate = 50
pe.set_amounts()
pe.reference_no = "1"
pe.reference_date = "2016-01-01"
@@ -1033,14 +1037,17 @@ class TestPaymentEntry(ERPNextTestSuite):
gle.credit_in_account_currency,
gle.debit_in_transaction_currency,
gle.credit_in_transaction_currency,
gle.transaction_currency,
gle.transaction_exchange_rate,
)
.orderby(gle.account)
.where(gle.voucher_no == payment_entry.name)
.run()
)
# transaction currency/rate come from the paid-from USD account (company currency is INR)
expected_gl_entries = (
(paid_from, 0.0, 8440.0, 0.0, 100.0, 0.0, 100.0),
("_Test Payable USD - _TC", 8440.0, 0.0, 100.0, 0.0, 100.0, 0.0),
(paid_from, 0.0, 8440.0, 0.0, 100.0, 0.0, 100.0, "USD", 84.4),
("_Test Payable USD - _TC", 8440.0, 0.0, 100.0, 0.0, 100.0, 0.0, "USD", 84.4),
)
self.assertEqual(gl_entries, expected_gl_entries)

View File

@@ -10,76 +10,22 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.tests.utils import ERPNextTestSuite
class TestPaymentLedgerEntry(ERPNextTestSuite):
def setUp(self):
self.ple = qb.DocType("Payment Ledger Entry")
self.create_company()
self.create_item()
self.create_customer()
self.clear_old_entries()
def create_company(self):
company_name = "_Test Payment Ledger"
company = None
if frappe.db.exists("Company", company_name):
company = frappe.get_doc("Company", company_name)
else:
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": company_name,
"country": "India",
"default_currency": "INR",
"create_chart_of_accounts_based_on": "Standard Template",
"chart_of_accounts": "Standard",
}
)
company = company.save()
self.company = company.name
self.cost_center = company.cost_center
self.warehouse = "All Warehouses - _PL"
self.income_account = "Sales - _PL"
self.expense_account = "Cost of Goods Sold - _PL"
self.debit_to = "Debtors - _PL"
self.creditors = "Creditors - _PL"
# create bank account
if frappe.db.exists("Account", "HDFC - _PL"):
self.bank = "HDFC - _PL"
else:
bank_acc = frappe.get_doc(
{
"doctype": "Account",
"account_name": "HDFC",
"parent_account": "Bank Accounts - _PL",
"company": self.company,
}
)
bank_acc.save()
self.bank = bank_acc.name
def create_item(self):
item_name = "_Test PL Item"
item = create_item(
item_code=item_name, is_stock_item=0, company=self.company, warehouse=self.warehouse
)
self.item = item if isinstance(item, str) else item.item_code
def create_customer(self):
name = "_Test PL Customer"
if frappe.db.exists("Customer", name):
self.customer = name
else:
customer = frappe.new_doc("Customer")
customer.customer_name = name
customer.type = "Individual"
customer.save()
self.customer = customer.name
self.company = "_Test Company"
self.cost_center = "Main - _TC"
self.warehouse = "Stores - _TC"
self.income_account = "Sales - _TC"
self.expense_account = "Cost of Goods Sold - _TC"
self.debit_to = "Debtors - _TC"
self.creditors = "Creditors - _TC"
self.bank = "Cash - _TC"
self.item = "_Test Item"
self.customer = "_Test Customer"
def create_sales_invoice(
self, qty=1, rate=100, posting_date=None, do_not_save=False, do_not_submit=False
@@ -152,18 +98,6 @@ class TestPaymentLedgerEntry(ERPNextTestSuite):
)
return so
def clear_old_entries(self):
doctype_list = [
"GL Entry",
"Payment Ledger Entry",
"Sales Invoice",
"Purchase Invoice",
"Payment Entry",
"Journal Entry",
]
for doctype in doctype_list:
qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
def create_journal_entry(self, acc1=None, acc2=None, amount=0, posting_date=None, cost_center=None):
je = frappe.new_doc("Journal Entry")
je.posting_date = posting_date or nowdate()

View File

@@ -60,23 +60,32 @@ class PaymentOrder(Document):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_mop_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
return frappe.db.sql(
""" select mode_of_payment from `tabPayment Order Reference`
where parent = %(parent)s and mode_of_payment like %(txt)s
limit %(page_len)s offset %(start)s""",
{"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt},
return frappe.get_all(
"Payment Order Reference",
filters={"parent": filters.get("parent"), "mode_of_payment": ["like", f"%{txt}%"]},
fields=["mode_of_payment"],
limit_start=start,
limit_page_length=page_len,
order_by="", # match the original query (no ORDER BY); avoid get_all's default sort
as_list=True,
)
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_supplier_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
return frappe.db.sql(
""" select supplier from `tabPayment Order Reference`
where parent = %(parent)s and supplier like %(txt)s and
(payment_reference is null or payment_reference='')
limit %(page_len)s offset %(start)s""",
{"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt},
return frappe.get_all(
"Payment Order Reference",
filters={
"parent": filters.get("parent"),
"supplier": ["like", f"%{txt}%"],
"payment_reference": ["is", "not set"],
},
fields=["supplier"],
limit_start=start,
limit_page_length=page_len,
order_by="", # match the original query (no ORDER BY); avoid get_all's default sort
as_list=True,
)

View File

@@ -11,11 +11,12 @@ from erpnext import get_company_currency
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.doctype.bank_account.bank_account import get_party_bank_account
from erpnext.accounts.doctype.payment_entry.payment_entry import (
get_payment_entry,
)
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.accounts.party import get_party_account, get_party_bank_account
from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import get_account_currency, get_advance_payment_doctypes, get_currency_precision
from erpnext.utilities import payment_app_import_guard
@@ -628,11 +629,9 @@ class PaymentRequest(Document):
def check_if_payment_entry_exists(self):
if self.status == "Paid":
if frappe.get_all(
if frappe.db.exists(
"Payment Entry Reference",
filters={"reference_name": self.reference_name, "docstatus": ["<", 2]},
fields=["parent"],
limit=1,
{"reference_name": self.reference_name, "docstatus": ["<", 2]},
):
frappe.throw(_("Payment Entry already exists"), title=_("Error"))
@@ -1211,10 +1210,11 @@ def get_dummy_message(doc):
@frappe.whitelist()
def get_subscription_details(reference_doctype: str, reference_name: str):
if reference_doctype == "Sales Invoice":
subscriptions = frappe.db.sql(
"""SELECT parent as sub_name FROM `tabSubscription Invoice` WHERE invoice=%s""",
reference_name,
as_dict=1,
subscriptions = frappe.get_all(
"Subscription Invoice",
filters={"invoice": reference_name},
fields=["parent as sub_name"],
order_by="", # match the original query (no ORDER BY); avoid get_all's default sort
)
subscription_plans = []
for subscription in subscriptions:

View File

@@ -332,7 +332,12 @@ class TestPaymentRequest(ERPNextTestSuite):
return_doc=1,
)
pe = pr.set_as_paid()
pe = pr.create_payment_entry(submit=False)
pe.source_exchange_rate = 50
pe.target_exchange_rate = 50
pe.set_amounts()
pe.insert(ignore_permissions=True)
pe.submit()
expected_gle = dict(
(d[0], d)
@@ -418,7 +423,12 @@ class TestPaymentRequest(ERPNextTestSuite):
pr = make_payment_request(dt=po_doc.doctype, dn=po_doc.name, recipient_id="nabin@erpnext.com")
pr = frappe.get_doc(pr).save().submit()
pe = pr.create_payment_entry()
pe = pr.create_payment_entry(submit=False)
pe.target_exchange_rate = 80
pe.paid_amount = 800
pe.set_amounts()
pe.insert(ignore_permissions=True)
pe.submit()
self.assertEqual(pe.base_paid_amount, 800)
self.assertEqual(pe.paid_amount, 800)
self.assertEqual(pe.base_received_amount, 800)

View File

@@ -18,7 +18,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 1)
def test_closing_entry(self):
company = create_company()
cost_center = create_cost_center("Test Cost Center 1")
jv1 = make_journal_entry(
@@ -27,10 +26,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center,
company=company,
company="Test PCV Company",
save=False,
)
jv1.company = company
jv1.company = "Test PCV Company"
jv1.save()
jv1.submit()
@@ -40,10 +39,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
account1="Cost of Goods Sold - TPC",
account2="Cash - TPC",
cost_center=cost_center,
company=company,
company="Test PCV Company",
save=False,
)
jv2.company = company
jv2.company = "Test PCV Company"
jv2.save()
jv2.submit()
@@ -67,14 +66,13 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
self.assertEqual(pcv_gle, expected_gle)
def test_cost_center_wise_posting(self):
company = create_company()
surplus_account = create_account()
cost_center1 = create_cost_center("Main")
cost_center2 = create_cost_center("Western Branch")
create_sales_invoice(
company=company,
company="Test PCV Company",
cost_center=cost_center1,
income_account="Sales - TPC",
expense_account="Cost of Goods Sold - TPC",
@@ -85,7 +83,7 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
posting_date="2021-03-15",
)
create_sales_invoice(
company=company,
company="Test PCV Company",
cost_center=cost_center2,
income_account="Sales - TPC",
expense_account="Cost of Goods Sold - TPC",
@@ -130,12 +128,11 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
)
def test_period_closing_with_finance_book_entries(self):
company = create_company()
surplus_account = create_account()
cost_center = create_cost_center("Test Cost Center 1")
create_sales_invoice(
company=company,
company="Test PCV Company",
income_account="Sales - TPC",
expense_account="Cost of Goods Sold - TPC",
cost_center=cost_center,
@@ -152,9 +149,9 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
amount=400,
cost_center=cost_center,
posting_date="2021-03-15",
company=company,
company="Test PCV Company",
)
jv.company = company
jv.company = "Test PCV Company"
jv.finance_book = create_finance_book().name
jv.save()
jv.submit()
@@ -181,7 +178,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
self.assertSequenceEqual(pcv_gle, expected_gle)
def test_gl_entries_restrictions(self):
company = create_company()
cost_center = create_cost_center("Test Cost Center 1")
self.make_period_closing_voucher(posting_date="2021-03-31")
@@ -192,16 +188,15 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center,
company=company,
company="Test PCV Company",
save=False,
)
jv1.company = company
jv1.company = "Test PCV Company"
jv1.save()
self.assertRaises(frappe.ValidationError, jv1.submit)
def test_closing_balance_with_dimensions_and_test_reposting_entry(self):
company = create_company()
cost_center1 = create_cost_center("Test Cost Center 1")
cost_center2 = create_cost_center("Test Cost Center 2")
@@ -211,10 +206,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center1,
company=company,
company="Test PCV Company",
save=False,
)
jv1.company = company
jv1.company = "Test PCV Company"
jv1.save()
jv1.submit()
@@ -224,10 +219,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center2,
company=company,
company="Test PCV Company",
save=False,
)
jv2.company = company
jv2.company = "Test PCV Company"
jv2.save()
jv2.submit()
@@ -254,11 +249,11 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center2,
company=company,
company="Test PCV Company",
save=False,
)
jv3.company = company
jv3.company = "Test PCV Company"
jv3.save()
jv3.submit()
@@ -293,12 +288,12 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
self.assertEqual(cc2_closing_balance.credit, 500)
self.assertEqual(cc2_closing_balance.credit_in_account_currency, 500)
warehouse = frappe.db.get_value("Warehouse", {"company": company}, "name")
warehouse = frappe.db.get_value("Warehouse", {"company": "Test PCV Company"}, "name")
repost_doc = frappe.get_doc(
{
"doctype": "Repost Item Valuation",
"company": company,
"company": "Test PCV Company",
"posting_date": "2020-03-15",
"based_on": "Item and Warehouse",
"item_code": "Test Item 1",
@@ -339,7 +334,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
{"enable_immutable_ledger": 1},
)
def test_immutable_ledger_reverse_entry_uses_passed_posting_date_after_pcv(self):
company = create_company()
cost_center = create_cost_center("Test Cost Center 1")
jv = make_journal_entry(
@@ -348,10 +342,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center,
company=company,
company="Test PCV Company",
save=False,
)
jv.company = company
jv.company = "Test PCV Company"
jv.save()
jv.submit()
@@ -377,19 +371,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)
def create_company():
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": "Test PCV Company",
"country": "United States",
"default_currency": "USD",
}
)
company.insert(ignore_if_duplicate=True)
return company.name
def create_account():
account = frappe.get_doc(
{

View File

@@ -553,7 +553,8 @@ def process_individual_date(docname: str, date, report_type, parentfield):
Sum(gle.credit).as_("credit"),
Sum(gle.debit_in_account_currency).as_("debit_in_account_currency"),
Sum(gle.credit_in_account_currency).as_("credit_in_account_currency"),
gle.account_currency,
# account_currency is constant per grouped account -> Max() keeps the GROUP BY postgres-valid
Max(gle.account_currency).as_("account_currency"),
).where(
(gle.company.eq(company))
& (gle.is_cancelled.eq(0))

View File

@@ -25,10 +25,8 @@ class TestProcessStatementOfAccounts(ERPNextTestSuite, AccountsTestMixin):
update_modified=False,
)
self.create_company()
self.create_customer()
self.company = "_Test Company"
self.create_customer(customer_name="Other Customer")
self.clear_old_entries()
self.si = create_sales_invoice()
create_sales_invoice(customer="Other Customer")

View File

@@ -614,10 +614,12 @@
{
"default": "0",
"depends_on": "eval:doc.items.every((item) => !item.pr_detail)",
"description": "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Purchase Receipt is created separately.",
"fieldname": "update_stock",
"fieldtype": "Check",
"label": "Update Stock",
"print_hide": 1
"print_hide": 1,
"show_description_on_click": 1
},
{
"fieldname": "scan_barcode",
@@ -1690,7 +1692,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-05-28 12:36:55.215363",
"modified": "2026-06-13 18:36:46.704623",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -51,16 +51,6 @@ class ExpenseAccountService:
if doc.update_stock and item.warehouse and (not item.from_warehouse):
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
if for_validate and item.expense_account and item.expense_account != _inv_dict["account"]:
msg = _(
"Row {0}: Expense Head changed to {1} because account {2} is not linked to warehouse {3} or it is not the default inventory account"
).format(
item.idx,
frappe.bold(_inv_dict["account"]),
frappe.bold(item.expense_account),
frappe.bold(item.warehouse),
)
frappe.msgprint(msg, title=_("Expense Head Changed"))
item.expense_account = _inv_dict["account"]
else:
# check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not

View File

@@ -158,6 +158,7 @@ def start_repost(account_repost_doc: str | None = None) -> None:
frappe.flags.through_repost_accounting_ledger = True
if account_repost_doc:
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
repost_doc.check_permission("write")
if repost_doc.docstatus == 1:
# Prevent repost on invoices with deferred accounting

View File

@@ -715,6 +715,7 @@
{
"default": "0",
"depends_on": "eval:doc.items.every((item) => !item.dn_detail)",
"description": "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Delivery Note is created separately.",
"fieldname": "update_stock",
"fieldtype": "Check",
"hide_days": 1,
@@ -722,7 +723,8 @@
"label": "Update Stock",
"oldfieldname": "update_stock",
"oldfieldtype": "Check",
"print_hide": 1
"print_hide": 1,
"show_description_on_click": 1
},
{
"fieldname": "scan_barcode",

View File

@@ -412,8 +412,8 @@ class SalesInvoice(SellingController):
validate_account_head(item.idx, item.income_account, self.company, _("Income"))
def before_save(self):
POSService(self).update_paid_amount()
POSService(self).set_account_for_mode_of_payment()
POSService(self).set_paid_amount()
def before_submit(self):
self.add_remarks()

View File

@@ -114,10 +114,17 @@ class POSService:
return pos
def set_paid_amount(self) -> None:
def update_paid_amount(self) -> None:
doc = self.doc
paid_amount = 0.0
base_paid_amount = 0.0
if not cint(doc.is_pos) and doc.is_return:
doc.set("payments", [])
doc.paid_amount = paid_amount
doc.base_paid_amount = base_paid_amount
return
for data in doc.payments:
data.base_amount = flt(data.amount * doc.conversion_rate, doc.precision("base_paid_amount"))
paid_amount += data.amount

View File

@@ -16,12 +16,14 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()
self.create_supplier()
self.create_usd_receivable_account()
self.create_item()
self.clear_old_entries()
self.company = "_Test Company"
self.customer = "_Test Customer"
self.supplier = "_Test Supplier"
self.item = "_Test Item"
self.debit_to = "Debtors - _TC"
self.cost_center = "Main - _TC"
self.cash = "Cash - _TC"
self.debtors_usd = "_Test Receivable USD - _TC"
def create_sales_invoice(self, do_not_submit=False):
si = create_sales_invoice(
@@ -372,7 +374,6 @@ class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
self.assertEqual(so.advance_paid, 0)
def test_06_unreconcile_advance_from_payment_entry(self):
self.enable_advance_as_liability()
so1 = self.create_sales_order()
so2 = self.create_sales_order()
@@ -423,7 +424,11 @@ class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
self.disable_advance_as_liability()
def test_07_adv_from_so_to_invoice(self):
self.enable_advance_as_liability()
frappe.db.set_value("Company", self.company, "book_advance_payments_in_separate_party_account", True)
frappe.db.set_value(
"Company", self.company, "default_advance_received_account", "Advance Received - _TC"
)
so = self.create_sales_order()
pe = self.create_payment_entry()
pe.paid_amount = 1000

View File

@@ -7,7 +7,7 @@ import frappe
from frappe import _, qb
from frappe.model.document import Document
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Abs, Sum
from frappe.query_builder.functions import Abs, Max, Sum
from frappe.utils.data import comma_and
from erpnext.accounts.utils import (
@@ -72,7 +72,7 @@ class UnreconcilePayment(Document):
alloc.party,
)
frappe.db.set_value("Unreconcile Payment Entries", alloc.name, "unlinked", True)
frappe.db.set_value("Unreconcile Payment Entries", alloc.name, "unlinked", 1)
@frappe.whitelist()
@@ -120,18 +120,20 @@ def get_linked_payments_for_doc(
res = (
qb.from_(ple)
.select(
ple.account,
ple.party_type,
ple.party,
ple.company,
ple.voucher_type.as_("reference_doctype"),
Max(ple.account).as_("account"),
Max(ple.party_type).as_("party_type"),
Max(ple.party).as_("party"),
Max(ple.company).as_("company"),
Max(ple.voucher_type).as_("reference_doctype"),
ple.voucher_no.as_("reference_name"),
Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"),
ple.account_currency,
Max(ple.account_currency).as_("account_currency"),
)
.where(Criterion.all(criteria))
.groupby(ple.voucher_no, ple.against_voucher_no)
.having(qb.Field("allocated_amount") > 0)
.having(Abs(Sum(ple.amount_in_account_currency)) > 0)
# deterministic order across backends (postgres GROUP BY does not imply ordering)
.orderby(ple.voucher_no)
.run(as_dict=True)
)
return res
@@ -146,17 +148,19 @@ def get_linked_payments_for_doc(
query = (
qb.from_(ple)
.select(
ple.company,
ple.account,
ple.party_type,
ple.party,
ple.against_voucher_type.as_("reference_doctype"),
Max(ple.company).as_("company"),
Max(ple.account).as_("account"),
Max(ple.party_type).as_("party_type"),
Max(ple.party).as_("party"),
Max(ple.against_voucher_type).as_("reference_doctype"),
ple.against_voucher_no.as_("reference_name"),
Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"),
ple.account_currency,
Max(ple.account_currency).as_("account_currency"),
)
.where(Criterion.all(criteria))
.groupby(ple.against_voucher_no)
# deterministic order across backends (postgres GROUP BY does not imply ordering)
.orderby(ple.against_voucher_no)
)
res = query.run(as_dict=True)
@@ -180,15 +184,18 @@ def get_linked_advances(company, docname):
return (
qb.from_(adv)
.select(
adv.company,
adv.against_voucher_type.as_("reference_doctype"),
# non-grouped columns are constant per against_voucher_no -> Max() is unchanged and postgres-valid
Max(adv.company).as_("company"),
Max(adv.against_voucher_type).as_("reference_doctype"),
adv.against_voucher_no.as_("reference_name"),
Abs(Sum(adv.amount)).as_("allocated_amount"),
adv.currency,
Max(adv.currency).as_("currency"),
)
.where(Criterion.all(criteria))
.having(qb.Field("allocated_amount") > 0)
.having(Abs(Sum(adv.amount)) > 0)
.groupby(adv.against_voucher_no)
# deterministic order across backends (postgres GROUP BY does not imply ordering)
.orderby(adv.against_voucher_no)
.run(as_dict=True)
)

View File

@@ -509,11 +509,6 @@ def get_party_advance_account(party_type, party, company):
return account
@frappe.whitelist()
def get_party_bank_account(party_type: str, party: str):
return frappe.db.get_value("Bank Account", {"party_type": party_type, "party": party, "is_default": 1})
def get_party_account_currency(party_type, party, company):
def generator():
party_account = get_party_account(party_type, party, company)
@@ -548,11 +543,19 @@ def get_party_gle_currency(party_type, party, company):
def get_party_gle_account(party_type, party, company):
def generator():
existing_gle_account = frappe.db.sql(
"""select account from `tabGL Entry`
where docstatus=1 and company=%(company)s and party_type=%(party_type)s and party=%(party)s
limit 1""",
{"company": company, "party_type": party_type, "party": party},
gl = qb.DocType("GL Entry")
existing_gle_account = (
qb.from_(gl)
.select(gl.account)
.where(
(gl.docstatus == 1)
& (gl.company == company)
& (gl.party_type == party_type)
& (gl.party == party)
& (gl.is_cancelled == 0)
)
.limit(1)
.run()
)
return existing_gle_account[0][0] if existing_gle_account else None

View File

@@ -9,11 +9,10 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()
self.create_item()
self.create_supplier(currency="USD", supplier_name="Test Supplier2")
self.create_usd_payable_account()
self.company = "_Test Company"
self.item = "_Test Item"
self.supplier = "_Test Supplier 2"
self.creditors_usd = "_Test Payable USD - _TC"
def test_accounts_payable_for_foreign_currency_supplier(self):
pi = self.create_purchase_invoice(do_not_submit=True)

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, Substring, Sum
from frappe.query_builder.functions import Date, Max, Substring, Sum
from frappe.utils import cint, cstr, flt, getdate, nowdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
@@ -427,32 +427,21 @@ class ReceivablePayableReport:
self.delivery_notes = frappe._dict()
# delivery note link inside sales invoice
# nosemgrep
si_against_dn = frappe.db.sql(
"""
select parent, delivery_note
from `tabSales Invoice Item`
where docstatus=1 and parent in (%s)
"""
% (",".join(["%s"] * len(self.invoices))),
tuple(self.invoices),
as_dict=1,
si_against_dn = frappe.get_all(
"Sales Invoice Item",
filters={"docstatus": 1, "parent": ["in", list(self.invoices)]},
fields=["parent", "delivery_note"],
)
for d in si_against_dn:
if d.delivery_note:
self.delivery_notes.setdefault(d.parent, set()).add(d.delivery_note)
# nosemgrep
dn_against_si = frappe.db.sql(
"""
select distinct parent, against_sales_invoice
from `tabDelivery Note Item`
where against_sales_invoice in (%s)
"""
% (",".join(["%s"] * len(self.invoices))),
tuple(self.invoices),
as_dict=1,
dn_against_si = frappe.get_all(
"Delivery Note Item",
filters={"against_sales_invoice": ["in", list(self.invoices)]},
fields=["parent", "against_sales_invoice"],
distinct=True,
)
for d in dn_against_si:
@@ -476,14 +465,10 @@ class ReceivablePayableReport:
# Get Sales Team
if self.filters.show_sales_person:
# nosemgrep
sales_team = frappe.db.sql(
"""
select parent, sales_person
from `tabSales Team`
where parenttype = 'Sales Invoice'
""",
as_dict=1,
sales_team = frappe.get_all(
"Sales Team",
filters={"parenttype": "Sales Invoice"},
fields=["parent", "sales_person"],
)
for d in sales_team:
self.invoice_details.setdefault(d.parent, {}).setdefault("sales_team", []).append(
@@ -548,22 +533,31 @@ class ReceivablePayableReport:
def get_payment_terms(self, row):
# build payment_terms for row
# nosemgrep
payment_terms_details = frappe.db.sql(
f"""
select
si.name, si.party_account_currency, si.currency, si.conversion_rate,
si.total_advance, ps.due_date, ps.payment_term, ps.payment_amount, ps.base_payment_amount,
ps.description, ps.paid_amount, ps.base_paid_amount, ps.discounted_amount
from `tab{row.voucher_type}` si, `tabPayment Schedule` ps
where
si.name = ps.parent and ps.parenttype = '{row.voucher_type}' and
si.name = %s and
si.is_return = 0
order by ps.paid_amount desc, due_date
""",
row.voucher_no,
as_dict=1,
si = frappe.qb.DocType(row.voucher_type)
ps = frappe.qb.DocType("Payment Schedule")
payment_terms_details = (
frappe.qb.from_(si)
.inner_join(ps)
.on(si.name == ps.parent)
.select(
si.name,
si.party_account_currency,
si.currency,
si.conversion_rate,
si.total_advance,
ps.due_date,
ps.payment_term,
ps.payment_amount,
ps.base_payment_amount,
ps.description,
ps.paid_amount,
ps.base_paid_amount,
ps.discounted_amount,
)
.where((ps.parenttype == row.voucher_type) & (si.name == row.voucher_no) & (si.is_return == 0))
.orderby(ps.paid_amount, order=frappe.qb.desc)
.orderby(ps.due_date)
.run(as_dict=1)
)
original_row = frappe._dict(row)
@@ -661,7 +655,6 @@ class ReceivablePayableReport:
def get_future_payments_from_payment_entry(self):
pe = frappe.qb.DocType("Payment Entry")
pe_ref = frappe.qb.DocType("Payment Entry Reference")
ifelse = query_builder.CustomFunction("IF", ["condition", "then", "else"])
return (
frappe.qb.from_(pe)
@@ -674,11 +667,14 @@ class ReceivablePayableReport:
(pe.posting_date).as_("future_date"),
(pe_ref.allocated_amount).as_("future_amount"),
(pe.reference_no).as_("future_ref"),
ifelse(
# CASE is portable; MySQL's IF() does not exist on postgres
query_builder.Case()
.when(
pe.payment_type == "Receive",
pe.source_exchange_rate * pe_ref.allocated_amount,
pe.target_exchange_rate * pe_ref.allocated_amount,
).as_("future_amount_in_base_currency"),
)
.else_(pe.target_exchange_rate * pe_ref.allocated_amount)
.as_("future_amount_in_base_currency"),
)
.where(
(pe.docstatus < 2)
@@ -695,11 +691,13 @@ class ReceivablePayableReport:
.inner_join(jea)
.on(jea.parent == je.name)
.select(
jea.reference_name.as_("invoice_no"),
jea.party,
jea.party_type,
je.posting_date.as_("future_date"),
je.cheque_no.as_("future_ref"),
# 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"),
)
.where(
(je.docstatus < 2)
@@ -712,30 +710,25 @@ class ReceivablePayableReport:
if self.filters.get("party"):
if self.account_type == "Payable":
query = query.select(
Sum(jea.debit_in_account_currency - jea.credit_in_account_currency).as_("future_amount")
)
query = query.select(Sum(jea.debit - jea.credit).as_("future_amount_in_base_currency"))
future_amount = Sum(jea.debit_in_account_currency - jea.credit_in_account_currency)
future_amount_in_base_currency = Sum(jea.debit - jea.credit)
else:
query = query.select(
Sum(jea.credit_in_account_currency - jea.debit_in_account_currency).as_("future_amount")
)
query = query.select(Sum(jea.credit - jea.debit).as_("future_amount_in_base_currency"))
future_amount = Sum(jea.credit_in_account_currency - jea.debit_in_account_currency)
future_amount_in_base_currency = Sum(jea.credit - jea.debit)
else:
query = query.select(
Sum(jea.debit if self.account_type == "Payable" else jea.credit).as_(
"future_amount_in_base_currency"
)
)
query = query.select(
Sum(
jea.debit_in_account_currency
if self.account_type == "Payable"
else jea.credit_in_account_currency
).as_("future_amount")
future_amount_in_base_currency = Sum(jea.debit if self.account_type == "Payable" else jea.credit)
future_amount = Sum(
jea.debit_in_account_currency
if self.account_type == "Payable"
else jea.credit_in_account_currency
)
query = query.having(qb.Field("future_amount") > 0)
query = query.select(
future_amount.as_("future_amount"),
future_amount_in_base_currency.as_("future_amount_in_base_currency"),
)
# 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)
def allocate_future_payments(self, row):
@@ -891,16 +884,19 @@ class ReceivablePayableReport:
if self.filters.get("sales_person"):
lft, rgt = frappe.db.get_value("Sales Person", self.filters.get("sales_person"), ["lft", "rgt"])
# nosemgrep
records = frappe.db.sql(
"""
select distinct parent, parenttype
from `tabSales Team` steam
where parenttype in ('Customer', 'Sales Invoice')
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.isin(["Customer", "Sales Invoice"])
& steam.sales_person.isin(
frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt))
)
)
.run(as_dict=1)
)
self.sales_person_records = frappe._dict()
@@ -927,8 +923,28 @@ class ReceivablePayableReport:
if self.filters.project:
self.qb_selection_filter.append(self.ple.project.isin(self.filters.project))
self.add_user_permission_filters()
self.add_accounting_dimensions_filters()
def add_user_permission_filters(self):
# Party is a dynamic link, so match conditions cannot auto-apply Customer/Supplier user permissions
from frappe.core.doctype.user_permission.user_permission import get_user_permissions
from frappe.permissions import get_allowed_docs_for_doctype
user_permissions = get_user_permissions()
if not user_permissions:
return
for party_type in self.party_type:
if party_type not in user_permissions:
continue
allowed_parties = get_allowed_docs_for_doctype(user_permissions[party_type], party_type)
self.qb_selection_filter.append(
(self.ple.party_type != party_type) | self.ple.party.isin(allowed_parties or [""])
)
def get_cost_center_conditions(self):
cost_center_list = get_cost_centers_with_children(self.filters.cost_center)
self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list))

View File

@@ -12,11 +12,17 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()
self.create_item()
self.create_usd_receivable_account()
self.clear_old_entries()
self.company = "_Test Company"
self.company_abbr = "_TC"
self.customer = "_Test Customer"
self.item = "_Test Item"
self.cost_center = "Main - _TC"
self.warehouse = "Stores - _TC"
self.income_account = "Sales - _TC"
self.expense_account = "Cost of Goods Sold - _TC"
self.debit_to = "Debtors - _TC"
self.cash = "Cash - _TC"
self.debtors_usd = "_Test Receivable USD - _TC"
def create_sales_invoice(self, no_payment_schedule=False, do_not_submit=False, **args):
frappe.set_user("Administrator")
@@ -1243,3 +1249,44 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
self.assertEqual(len(report[1]), 1)
row = report[1][0]
self.assertEqual([si.name, project.name, 60], [row.voucher_no, row.project, row.outstanding])
def test_accounts_receivable_respects_user_permissions(self):
# Party is a dynamic link on Payment Ledger Entry, so user permissions on Customer
# must be applied explicitly. The report should only show permitted customers.
original_customer = self.customer
second_customer = "_Test AR Perm Customer"
# create_customer overrides self.customer, so build the restricted invoice first
self.create_customer(customer_name=second_customer)
self.create_sales_invoice(no_payment_schedule=True)
self.customer = original_customer
allowed_invoice = self.create_sales_invoice(no_payment_schedule=True)
test_user = "test_ar_user_permission@example.com"
if not frappe.db.exists("User", test_user):
user = frappe.new_doc("User")
user.email = test_user
user.first_name = "AR Perm"
user.append("roles", {"role": "Accounts User"})
user.save()
frappe.permissions.add_user_permission("Customer", original_customer, test_user)
filters = {
"company": self.company,
"party_type": "Customer",
"report_date": today(),
"range": "30, 60, 90, 120",
}
frappe.set_user(test_user)
try:
report = execute(filters)
finally:
frappe.set_user("Administrator")
parties = {row.party for row in report[1]}
self.assertIn(original_customer, parties)
self.assertNotIn(second_customer, parties)
self.assertEqual(allowed_invoice.customer, original_customer)

View File

@@ -11,10 +11,11 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.maxDiff = None
self.create_company()
self.create_customer()
self.create_item()
self.clear_old_entries()
self.company = "_Test Company"
self.customer = "_Test Customer"
self.item = "_Test Item"
self.debit_to = "Debtors - _TC"
self.cost_center = "Main - _TC"
def test_01_receivable_summary_output(self):
"""

View File

@@ -15,10 +15,7 @@ def execute(filters=None):
def get_data(filters):
data = []
depreciation_accounts = frappe.db.sql_list(
""" select name from tabAccount
where ifnull(account_type, '') = 'Depreciation' """
)
depreciation_accounts = frappe.get_all("Account", filters={"account_type": "Depreciation"}, pluck="name")
filters_data = [
["company", "=", filters.get("company")],
@@ -33,10 +30,8 @@ def get_data(filters):
filters_data.append(["against_voucher", "=", filters.get("asset")])
if filters.get("asset_category"):
assets = frappe.db.sql_list(
"""select name from tabAsset
where asset_category = %s and docstatus=1""",
filters.get("asset_category"),
assets = frappe.get_all(
"Asset", filters={"asset_category": filters.get("asset_category"), "docstatus": 1}, pluck="name"
)
filters_data.append(["against_voucher", "in", assets])

View File

@@ -0,0 +1,18 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from erpnext.accounts.report.asset_depreciation_ledger.asset_depreciation_ledger import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestAssetDepreciationLedger(ERPNextTestSuite):
def test_report_executes(self):
# Smoke-guards the raw-SQL -> query-builder port: the report query must compile and run on
# both MariaDB and postgres.
company = frappe.db.get_value("Company", {}, "name")
columns, *_rest = execute(
frappe._dict({"company": company, "from_date": "2020-01-01", "to_date": "2030-12-31"})
)
self.assertTrue(columns)

View File

@@ -3,7 +3,7 @@
import frappe
from frappe import _
from frappe.query_builder import CustomFunction
from frappe.query_builder.custom import MonthName
from frappe.utils import add_months, flt, formatdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
@@ -113,7 +113,6 @@ def build_budget_map(budget_records, filters):
def get_actual_transactions(dimension_name, filters):
budget_against = frappe.scrub(filters.get("budget_against"))
monthname = CustomFunction("MONTHNAME", ["date"])
gle = frappe.qb.DocType("GL Entry")
budget = frappe.qb.DocType("Budget")
@@ -126,7 +125,7 @@ def get_actual_transactions(dimension_name, filters):
gle.debit,
gle.credit,
gle.fiscal_year,
monthname(gle.posting_date).as_("month_name"),
MonthName(gle.posting_date).as_("month_name"),
budget[budget_against].as_("budget_against"),
)
.where(
@@ -137,7 +136,10 @@ def get_actual_transactions(dimension_name, filters):
& (gle.is_cancelled == 0)
& (budget[budget_against] == dimension_name)
)
.groupby(gle.name)
# budget[budget_against] is selected from the Budget table, which is not functionally
# dependent on the grouped GL Entry PK, so postgres requires it in the GROUP BY. The WHERE
# pins it to dimension_name (a constant), so grouping by it does not change the result.
.groupby(gle.name, budget[budget_against])
.orderby(gle.fiscal_year)
)
@@ -157,15 +159,11 @@ def get_actual_transactions(dimension_name, filters):
def get_budget_distributions(budget):
return frappe.db.sql(
"""
SELECT start_date, end_date, amount, percent
FROM `tabBudget Distribution`
WHERE parent = %s
ORDER BY start_date ASC
""",
(budget.name,),
as_dict=True,
return frappe.get_all(
"Budget Distribution",
filters={"parent": budget.name},
fields=["start_date", "end_date", "amount", "percent"],
order_by="start_date asc",
)
@@ -351,20 +349,16 @@ def get_columns(filters):
def get_fiscal_years(filters):
fiscal_year = frappe.db.sql(
"""
select
name
from
`tabFiscal Year`
where
name between %(from_fiscal_year)s and %(to_fiscal_year)s
""",
{"from_fiscal_year": filters["from_fiscal_year"], "to_fiscal_year": filters["to_fiscal_year"]},
return frappe.get_all(
"Fiscal Year",
filters={"name": ["between", [filters["from_fiscal_year"], filters["to_fiscal_year"]]]},
fields=["name"],
# the raw query had no ORDER BY (de-facto oldest-first); get_all would otherwise apply the
# Fiscal Year doctype default (name DESC) and reverse column order / cumulative-mode values.
order_by="name asc",
as_list=True,
)
return fiscal_year
def get_cost_center_with_children(cost_centers):
"""Expand each cost center to include itself and all its descendants."""

View File

@@ -0,0 +1,27 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from erpnext.accounts.report.budget_variance_report.budget_variance_report import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestBudgetVarianceReport(ERPNextTestSuite):
def test_report_executes(self):
# Smoke-guards the raw-SQL -> query-builder port: the report query must compile and run on
# both MariaDB and postgres.
company = frappe.db.get_value("Company", {}, "name")
fy = frappe.db.get_value("Fiscal Year", {}, "name", order_by="year_start_date desc")
columns, *_rest = execute(
frappe._dict(
{
"company": company,
"from_fiscal_year": fy,
"to_fiscal_year": fy,
"period": "Yearly",
"budget_against": "Cost Center",
}
)
)
self.assertTrue(columns)

View File

@@ -7,6 +7,7 @@ from datetime import timedelta
import frappe
from frappe import _
from frappe.query_builder import DocType
from frappe.query_builder.functions import Sum
from frappe.utils import cstr, flt
from pypika import Order
@@ -213,37 +214,43 @@ def get_account_type_based_data(company, account_type, period_list, accumulated_
def get_account_type_based_gl_data(company, filters=None):
cond = ""
filters = frappe._dict(filters or {})
gle = frappe.qb.DocType("GL Entry")
account = frappe.qb.DocType("Account")
query = (
frappe.qb.from_(gle)
.select(Sum(gle.credit) - Sum(gle.debit))
.where(
(gle.company == company)
& (gle.posting_date >= filters.start_date)
& (gle.posting_date <= filters.end_date)
& (gle.voucher_type != "Period Closing Voucher")
& gle.account.isin(
frappe.qb.from_(account)
.select(account.name)
.where(account.account_type == filters.account_type)
)
)
)
if filters.include_default_book_entries:
company_fb = frappe.get_cached_value("Company", company, "default_finance_book")
cond = """ AND (finance_book in ({}, {}, '') OR finance_book IS NULL)
""".format(
frappe.db.escape(filters.finance_book),
frappe.db.escape(company_fb),
query = query.where(
gle.finance_book.isin([filters.finance_book, company_fb, ""]) | gle.finance_book.isnull()
)
else:
cond = " AND (finance_book in (%s, '') OR finance_book IS NULL)" % (
frappe.db.escape(cstr(filters.finance_book))
query = query.where(
gle.finance_book.isin([cstr(filters.finance_book), ""]) | gle.finance_book.isnull()
)
if filters.get("cost_center"):
filters.cost_center = get_cost_centers_with_children(filters.cost_center)
cond += " and cost_center in %(cost_center)s"
cost_centers = get_cost_centers_with_children(filters.cost_center)
query = query.where(gle.cost_center.isin(cost_centers))
gl_sum = frappe.db.sql_list(
f"""
select sum(credit) - sum(debit)
from `tabGL Entry`
where company=%(company)s and posting_date >= %(start_date)s and posting_date <= %(end_date)s
and voucher_type != 'Period Closing Voucher'
and account in ( SELECT name FROM tabAccount WHERE account_type = %(account_type)s) {cond}
""",
filters,
)
return gl_sum[0] if gl_sum and gl_sum[0] else 0
gl_sum = query.run()
return gl_sum[0][0] if gl_sum and gl_sum[0][0] else 0
def get_start_date(period, accumulated_values, company):
@@ -367,11 +374,10 @@ def get_net_income(company, period_list, filters):
from_date, to_date = get_opening_range_using_fiscal_year(company, period_list)
for root_type in ["Income", "Expense"]:
for root in frappe.db.sql(
"""select lft, rgt from tabAccount
where root_type=%s and ifnull(parent_account, '') = ''""",
root_type,
as_dict=1,
for root in frappe.get_all(
"Account",
filters={"root_type": root_type, "parent_account": ["is", "not set"]},
fields=["lft", "rgt"],
):
set_gl_entries_by_account(
company,

View File

@@ -0,0 +1,27 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from erpnext.accounts.report.cash_flow.cash_flow import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestCashFlow(ERPNextTestSuite):
def test_report_executes(self):
# Smoke-guards the raw-SQL -> query-builder port: the report query must compile and run on
# both MariaDB and postgres.
company = frappe.db.get_value("Company", {}, "name")
fy = frappe.db.get_value("Fiscal Year", {}, "name", order_by="year_start_date desc")
columns, *_rest = execute(
frappe._dict(
{
"company": company,
"from_fiscal_year": fy,
"to_fiscal_year": fy,
"filter_based_on": "Fiscal Year",
"periodicity": "Yearly",
}
)
)
self.assertTrue(columns)

View File

@@ -3,7 +3,7 @@
import frappe
from frappe import _, qb
from frappe.query_builder import CustomFunction
from frappe.query_builder import Case
from frappe.query_builder.custom import ConstantColumn
@@ -93,7 +93,6 @@ def get_amounts_not_reflected_in_system_for_bank_reconciliation_statement(filter
.run(as_dict=1)
)
ifelse = CustomFunction("IF", ["condition", "then", "else"])
pe = qb.DocType("Payment Entry")
doctype_name = ConstantColumn("Payment Entry")
payments = (
@@ -101,7 +100,10 @@ def get_amounts_not_reflected_in_system_for_bank_reconciliation_statement(filter
.select(
doctype_name.as_("doctype"),
pe.name,
ifelse(pe.paid_from.eq(filters.account), pe.paid_amount, pe.received_amount).as_("amount"),
Case()
.when(pe.paid_from.eq(filters.account), pe.paid_amount)
.else_(pe.received_amount)
.as_("amount"),
pe.payment_type,
pe.party_type,
pe.posting_date,

View File

@@ -0,0 +1,24 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.utils import nowdate
from erpnext.accounts.report.cheques_and_deposits_incorrectly_cleared.cheques_and_deposits_incorrectly_cleared import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestChequesAndDepositsIncorrectlyCleared(ERPNextTestSuite):
def test_report_executes_with_case_amount(self):
# Exercises the Payment Entry branch whose amount column uses a db-aware CASE expression
# (previously a MySQL-only IF()). IF() does not compile on postgres, so running the report
# query guards the portability fix on both databases.
company = frappe.db.get_value("Company", {}, "name")
account = frappe.db.get_value(
"Account", {"account_type": "Bank", "company": company, "is_group": 0}, "name"
)
columns, data = execute(frappe._dict({"account": account, "report_date": nowdate()}))
self.assertTrue(columns)
self.assertIsInstance(data, list)

View File

@@ -347,11 +347,10 @@ def get_data(companies, root_type, balance_must_be, fiscal_year, filters=None, i
filters.end_date = end_date
gl_entries_by_account = {}
for root in frappe.db.sql(
"""select lft, rgt from tabAccount
where root_type=%s and ifnull(parent_account, '') = ''""",
root_type,
as_dict=1,
for root in frappe.get_all(
"Account",
filters={"root_type": root_type, "parent_account": ["is", "not set"]},
fields=["lft", "rgt"],
):
set_gl_entries_by_account(
start_date,
@@ -512,9 +511,11 @@ def get_companies(filters):
def get_subsidiary_companies(company):
lft, rgt = frappe.get_cached_value("Company", company, ["lft", "rgt"])
return frappe.db.sql_list(
f"""select name from `tabCompany`
where lft >= {lft} and rgt <= {rgt} order by lft, rgt"""
return frappe.get_all(
"Company",
filters={"lft": [">=", lft], "rgt": ["<=", rgt]},
pluck="name",
order_by="lft, rgt",
)
@@ -604,14 +605,10 @@ def set_gl_entries_by_account(
company_lft, company_rgt = frappe.get_cached_value("Company", filters.get("company"), ["lft", "rgt"])
companies = frappe.db.sql(
""" select name, default_currency from `tabCompany`
where lft >= %(company_lft)s and rgt <= %(company_rgt)s""",
{
"company_lft": company_lft,
"company_rgt": company_rgt,
},
as_dict=1,
companies = frappe.get_all(
"Company",
filters={"lft": [">=", company_lft], "rgt": ["<=", company_rgt]},
fields=["name", "default_currency"],
)
currency_info = frappe._dict(

View File

@@ -126,12 +126,22 @@ def get_data(filters) -> list[list]:
def get_company_wise_tb_data(filters, reporting_currency, ignore_reporting_currency):
accounts = frappe.db.sql(
"""select name, account_number, parent_account, account_name, root_type, report_type, account_type, is_group, lft, rgt
from `tabAccount` where company=%s order by lft""",
filters.company,
as_dict=True,
accounts = frappe.get_all(
"Account",
filters={"company": filters.company},
fields=[
"name",
"account_number",
"parent_account",
"account_name",
"root_type",
"report_type",
"account_type",
"is_group",
"lft",
"rgt",
],
order_by="lft",
)
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")

View File

@@ -11,10 +11,12 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestCustomerLedgerSummary(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()
self.create_item()
self.clear_old_entries()
self.company = "_Test Company"
self.customer = "_Test Customer"
self.item = "_Test Item"
self.debit_to = "Debtors - _TC"
self.cost_center = "Main - _TC"
self.cash = "Cash - _TC"
def create_sales_invoice(self, do_not_submit=False, **args):
si = create_sales_invoice(

View File

@@ -3,7 +3,8 @@
import frappe
from frappe import _, qb
from frappe.query_builder import Column, functions
from frappe.query_builder import functions
from frappe.query_builder.custom import ConstantColumn
from frappe.utils import add_days, date_diff, flt, get_first_day, get_last_day, getdate, rounded
from erpnext.accounts.report.financial_statements import get_period_list
@@ -300,8 +301,10 @@ class Deferred_Revenue_and_Expense_Report:
Get all sales and purchase invoices which has deferred revenue/expense items
"""
gle = qb.DocType("GL Entry")
# column doesn't have an alias option
posted = Column("posted")
# a literal marker: real GL rows are "posted" (dummy/simulated future entries use "not").
# ConstantColumn renders a single-quoted string literal, valid on both backends -- a plain
# Column rendered as "posted", which MySQL reads as the string but postgres as an identifier.
posted = ConstantColumn("posted").as_("posted")
if self.filters.type == "Revenue":
inv = qb.DocType("Sales Invoice")
@@ -327,13 +330,15 @@ class Deferred_Revenue_and_Expense_Report:
)
.select(
inv.name.as_("doc"),
inv.posting_date,
# non-grouped columns are constant per grouped invoice / invoice item -> Max() keeps the
# GROUP BY valid on postgres while returning the same value MySQL picked.
functions.Max(inv.posting_date).as_("posting_date"),
inv_item.name.as_("item"),
inv_item.item_name,
inv_item.service_start_date,
inv_item.service_end_date,
inv_item.base_net_amount,
deferred_account_field,
functions.Max(inv_item.item_name).as_("item_name"),
functions.Max(inv_item.service_start_date).as_("service_start_date"),
functions.Max(inv_item.service_end_date).as_("service_end_date"),
functions.Max(inv_item.base_net_amount).as_("base_net_amount"),
functions.Max(deferred_account_field).as_(deferred_account_field.name),
gle.posting_date.as_("gle_posting_date"),
functions.Sum(gle.debit).as_("debit"),
functions.Sum(gle.credit).as_("credit"),

View File

@@ -61,11 +61,16 @@ class TestDeferredRevenueAndExpense(ERPNextTestSuite, AccountsTestMixin):
)
def setUp(self):
self.create_company()
self.create_customer("_Test Customer")
self.create_supplier("_Test Furniture Supplier")
self.company = "_Test Company"
self.company_abbr = "_TC"
self.customer = "_Test Customer"
self.supplier = "_Test Supplier"
self.warehouse = "Stores - _TC"
self.debit_to = "Debtors - _TC"
self.cost_center = "Main - _TC"
self.income_account = "Sales - _TC"
self.expense_account = "Cost of Goods Sold - _TC"
self.setup_deferred_accounts_and_items()
self.clear_old_entries()
@ERPNextTestSuite.change_settings("Accounts Settings", {"book_deferred_entries_based_on": "Months"})
def test_deferred_revenue(self):

View File

@@ -4,7 +4,7 @@
import frappe
from frappe import _
from frappe.utils import cstr, flt
from frappe.utils import flt
import erpnext
from erpnext.accounts.report.financial_statements import (
@@ -31,18 +31,23 @@ def execute(filters=None):
def get_data(filters, dimension_list):
company_currency = erpnext.get_company_currency(filters.company)
acc = frappe.db.sql(
"""
select
name, account_number, parent_account, lft, rgt, root_type,
report_type, account_name, include_in_gross, account_type, is_group
from
`tabAccount`
where
company=%s
order by lft""",
(filters.company),
as_dict=True,
acc = frappe.get_all(
"Account",
filters={"company": filters.company},
fields=[
"name",
"account_number",
"parent_account",
"lft",
"rgt",
"root_type",
"report_type",
"account_name",
"include_in_gross",
"account_type",
"is_group",
],
order_by="lft",
)
if not acc:
@@ -50,16 +55,17 @@ def get_data(filters, dimension_list):
accounts, accounts_by_name, parent_children_map = filter_accounts(acc)
min_lft, max_rgt = frappe.db.sql(
"""select min(lft), max(rgt) from `tabAccount`
where company=%s""",
(filters.company),
lft_rgt = frappe.get_all(
"Account",
filters={"company": filters.company},
fields=[{"MIN": "lft", "as": "min_lft"}, {"MAX": "rgt", "as": "max_rgt"}],
)[0]
min_lft, max_rgt = lft_rgt.min_lft, lft_rgt.max_rgt
account = frappe.db.sql_list(
"""select name from `tabAccount`
where lft >= %s and rgt <= %s and company = %s""",
(min_lft, max_rgt, filters.company),
account = frappe.get_all(
"Account",
filters={"lft": [">=", min_lft], "rgt": ["<=", max_rgt], "company": filters.company},
pluck="name",
)
gl_entries_by_account = {}
@@ -75,42 +81,34 @@ def get_data(filters, dimension_list):
def set_gl_entries_by_account(dimension_list, filters, account, gl_entries_by_account):
condition = get_condition(filters.get("dimension"))
if account:
condition += " and account in ({})".format(", ".join([frappe.db.escape(d) for d in account]))
dimension_field = frappe.scrub(filters.get("dimension"))
gl_filters = {
"company": filters.get("company"),
"from_date": filters.get("from_date"),
"to_date": filters.get("to_date"),
"finance_book": cstr(filters.get("finance_book")),
dimension_field: ["in", list(set(dimension_list))],
"posting_date": ["between", [filters.get("from_date"), filters.get("to_date")]],
"is_cancelled": 0,
}
if account:
gl_filters["account"] = ["in", account]
gl_filters["dimensions"] = tuple(set(dimension_list))
if filters.get("include_default_book_entries"):
gl_filters["company_fb"] = frappe.get_cached_value("Company", filters.company, "default_finance_book")
gl_entries = frappe.db.sql(
"""
select
posting_date, account, {dimension}, debit, credit, is_opening, fiscal_year,
debit_in_account_currency, credit_in_account_currency, account_currency
from
`tabGL Entry`
where
company=%(company)s
{condition}
and posting_date >= %(from_date)s
and posting_date <= %(to_date)s
and is_cancelled = 0
order by account, posting_date""".format(
dimension=frappe.scrub(filters.get("dimension")), condition=condition
),
gl_filters,
as_dict=True,
) # nosec
gl_entries = frappe.get_all(
"GL Entry",
filters=gl_filters,
fields=[
"posting_date",
"account",
dimension_field,
"debit",
"credit",
"is_opening",
"fiscal_year",
"debit_in_account_currency",
"credit_in_account_currency",
"account_currency",
],
order_by="account, posting_date",
)
for entry in gl_entries:
gl_entries_by_account.setdefault(entry.account, []).append(entry)
@@ -178,14 +176,6 @@ def accumulate_values_into_parents(accounts, accounts_by_name, dimension_list):
].get(frappe.scrub(dimension), 0.0) + d.get(frappe.scrub(dimension), 0.0)
def get_condition(dimension):
conditions = []
conditions.append(f"{frappe.scrub(dimension)} in %(dimensions)s")
return " and {}".format(" and ".join(conditions)) if conditions else ""
def get_dimensions(filters):
meta = frappe.get_meta(filters.get("dimension"), cached=False)
query_filters = {}

View File

@@ -71,6 +71,7 @@ def get_ratios_data(filters, period_list, years):
assets, liabilities, income, expense = get_gl_data(filters, period_list, years)
current_asset, total_asset = {}, {}
fixed_asset = {}
current_liability, total_liability = {}, {}
net_sales, total_income = {}, {}
cogs, total_expense = {}, {}
@@ -93,6 +94,7 @@ def get_ratios_data(filters, period_list, years):
quick_asset,
total_quick_asset,
],
[fixed_asset, total_asset, "Fixed Asset", year, assets, "Asset", {}, 0],
[
current_liability,
total_liability,
@@ -112,7 +114,7 @@ def get_ratios_data(filters, period_list, years):
add_solvency_ratios(
data, years, total_asset, total_liability, net_sales, cogs, total_income, total_expense
)
add_turnover_ratios(data, years, period_list, filters, total_asset, net_sales, cogs, direct_expense)
add_turnover_ratios(data, years, period_list, filters, fixed_asset, net_sales, cogs, direct_expense)
return data
@@ -193,7 +195,7 @@ def add_solvency_ratios(
data.append(return_on_equity_ratio)
def add_turnover_ratios(data, years, period_list, filters, total_asset, net_sales, cogs, direct_expense):
def add_turnover_ratios(data, years, period_list, filters, fixed_asset, net_sales, cogs, direct_expense):
precision = frappe.db.get_single_value("System Settings", "float_precision")
data.append({"ratio": _("Turnover Ratios")})
@@ -208,7 +210,7 @@ def add_turnover_ratios(data, years, period_list, filters, total_asset, net_sale
)
ratio_data = [
[_("Fixed Asset Turnover Ratio"), net_sales, total_asset],
[_("Fixed Asset Turnover Ratio"), net_sales, fixed_asset],
[_("Debtor Turnover Ratio"), net_sales, avg_debtors],
[_("Creditor Turnover Ratio"), direct_expense, avg_creditors],
[_("Inventory Turnover Ratio"), cogs, avg_stock],

View File

@@ -0,0 +1,73 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
import frappe
from frappe.utils import today
from erpnext.accounts.report.financial_ratios.financial_ratios import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestFinancialRatios(ERPNextTestSuite):
def setUp(self):
self.company = "_Test Company"
self.abbr = "_TC"
# The report matches the group accounts by their account_type, which the
# standard chart of accounts does not set on group accounts by default.
self.set_account_type("Fixed Assets", "Fixed Asset")
self.set_account_type("Direct Income", "Direct Income")
def set_account_type(self, account_name, account_type):
frappe.db.set_value("Account", f"{account_name} - {self.abbr}", "account_type", account_type)
def test_fixed_asset_turnover_uses_net_fixed_assets(self):
# Acquire a fixed asset worth 10,000 funded by equity.
self.make_journal_entry("Buildings", "Capital Stock", 10000)
# Book sales of 20,000 collected in cash. Total assets now = 30,000
# (Buildings 10,000 + Cash 20,000), while net fixed assets stay at 10,000.
self.make_journal_entry("Cash", "Sales", 20000)
columns, data = execute(self.get_report_filters())
year_key = columns[1]["fieldname"]
ratio_row = next((row for row in data if row.get("ratio") == "Fixed Asset Turnover Ratio"), None)
self.assertIsNotNone(ratio_row, "Fixed Asset Turnover Ratio row not found in report output")
# Net Sales / Net Fixed Assets = 20,000 / 10,000 = 2.0
# (the old behaviour divided by total assets, giving 20,000 / 30,000 = 0.667)
self.assertEqual(ratio_row[year_key], 2.0)
def get_report_filters(self):
active_fy = frappe.db.get_value(
"Fiscal Year",
{"disabled": 0, "year_start_date": ("<=", today()), "year_end_date": (">=", today())},
["name", "year_start_date", "year_end_date"],
as_dict=True,
)
return frappe._dict(
company=self.company,
from_fiscal_year=active_fy.name,
to_fiscal_year=active_fy.name,
period_start_date=active_fy.year_start_date,
period_end_date=active_fy.year_end_date,
filter_based_on="Fiscal Year",
periodicity="Yearly",
)
def make_journal_entry(self, debit_account, credit_account, amount):
journal_entry = frappe.new_doc("Journal Entry")
journal_entry.posting_date = today()
journal_entry.company = self.company
for account, debit, credit in (
(debit_account, amount, 0),
(credit_account, 0, amount),
):
journal_entry.append(
"accounts",
{
"account": f"{account} - {self.abbr}",
"debit_in_account_currency": debit,
"credit_in_account_currency": credit,
},
)
journal_entry.insert()
journal_entry.submit()

View File

@@ -179,11 +179,10 @@ def get_data(
company_currency = get_appropriate_currency(company, filters)
gl_entries_by_account = {}
for root in frappe.db.sql(
"""select lft, rgt from tabAccount
where root_type=%s and ifnull(parent_account, '') = ''""",
root_type,
as_dict=1,
for root in frappe.get_all(
"Account",
filters={"root_type": root_type, "parent_account": ["is", "not set"]},
fields=["lft", "rgt"],
):
set_gl_entries_by_account(
company,
@@ -373,13 +372,23 @@ def add_total_row(out, root_type, balance_must_be, period_list, company_currency
def get_accounts(company, root_type):
return frappe.db.sql(
"""
select name, account_number, parent_account, lft, rgt, root_type, report_type, account_name, include_in_gross, account_type, is_group, lft, rgt
from `tabAccount`
where company=%s and root_type=%s order by lft""",
(company, root_type),
as_dict=True,
return frappe.get_all(
"Account",
filters={"company": company, "root_type": root_type},
fields=[
"name",
"account_number",
"parent_account",
"lft",
"rgt",
"root_type",
"report_type",
"account_name",
"include_in_gross",
"account_type",
"is_group",
],
order_by="lft",
)
@@ -529,7 +538,11 @@ def get_accounting_entries(
gl_entry.credit_in_account_currency
if not group_by_account
else Sum(gl_entry.credit_in_account_currency).as_("credit_in_account_currency"),
gl_entry.account_currency,
# when grouping by account the non-aggregated columns must be aggregated for postgres;
# account_currency is constant per account so Max() returns the same value.
gl_entry.account_currency
if not group_by_account
else Max(gl_entry.account_currency).as_("account_currency"),
)
.where(gl_entry.company == filters.company)
)
@@ -547,15 +560,29 @@ def get_accounting_entries(
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
if doctype == "GL Entry":
query = query.select(gl_entry.posting_date, gl_entry.is_opening, gl_entry.fiscal_year)
# aggregate the non-grouped columns when grouping by account (postgres requirement)
if group_by_account:
query = query.select(
Max(gl_entry.posting_date).as_("posting_date"),
Max(gl_entry.is_opening).as_("is_opening"),
Max(gl_entry.fiscal_year).as_("fiscal_year"),
)
else:
query = query.select(gl_entry.posting_date, gl_entry.is_opening, gl_entry.fiscal_year)
query = query.where(gl_entry.is_cancelled == 0)
query = query.where(gl_entry.posting_date <= to_date)
query = query.force_index("posting_date_company_index")
# FORCE INDEX is MySQL-only; postgres has no index hints (its planner uses the index anyway)
if frappe.db.db_type != "postgres":
query = query.force_index("posting_date_company_index")
if ignore_opening_entries and not ignore_is_opening:
query = query.where(gl_entry.is_opening == "No")
else:
query = query.select(gl_entry.closing_date.as_("posting_date"))
query = query.select(
Max(gl_entry.closing_date).as_("posting_date")
if group_by_account
else gl_entry.closing_date.as_("posting_date")
)
query = query.where(gl_entry.period_closing_voucher == period_closing_voucher)
query = apply_additional_conditions(doctype, query, from_date, ignore_closing_entries, filters)

View File

@@ -12,7 +12,13 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestGeneralAndPaymentLedger(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.company = "_Test Company"
self.debit_to = "Debtors - _TC"
self.expense_account = "Cost of Goods Sold - _TC"
self.cost_center = "Main - _TC"
self.income_account = "Sales - _TC"
self.warehouse = "Stores - _TC"
self.creditors = "Creditors - _TC"
self.cleanup()
def cleanup(self):

View File

@@ -35,7 +35,7 @@ def execute(filters=None):
if filters and filters.get("print_in_account_currency") and not filters.get("account"):
frappe.throw(_("Select an account to print in account currency"))
for acc in frappe.db.sql("""select name, is_group from tabAccount""", as_dict=1):
for acc in frappe.get_all("Account", fields=["name", "is_group"]):
account_details.setdefault(acc.name, acc)
if filters.get("party"):
@@ -650,10 +650,8 @@ def get_result_as_list(data, filters):
def get_supplier_invoice_details():
inv_details = {}
for d in frappe.db.sql(
""" select name, bill_no from `tabPurchase Invoice`
where docstatus = 1 and bill_no is not null and bill_no != '' """,
as_dict=1,
for d in frappe.get_all(
"Purchase Invoice", filters={"docstatus": 1, "bill_no": ["is", "set"]}, fields=["name", "bill_no"]
):
inv_details[d.name] = d.bill_no

View File

@@ -14,7 +14,6 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestGeneralLedger(ERPNextTestSuite):
def setUp(self):
self.company = "_Test Company"
self.clear_old_entries()
def clear_old_entries(self):
doctype_list = [

View File

@@ -713,20 +713,25 @@ class GrossProfitGenerator:
)
def get_returned_invoice_items(self):
returned_invoices = frappe.db.sql(
"""
select
si.name, si_item.item_code, si_item.stock_qty as qty, si_item.base_net_amount as base_amount, si.return_against
from
`tabSales Invoice` si, `tabSales Invoice Item` si_item
where
si.name = si_item.parent
and si.docstatus = 1
and si.is_return = 1
and si.posting_date between %(from_date)s and %(to_date)s
""",
{"from_date": self.filters.from_date, "to_date": self.filters.to_date},
as_dict=1,
si = frappe.qb.DocType("Sales Invoice")
si_item = frappe.qb.DocType("Sales Invoice Item")
returned_invoices = (
frappe.qb.from_(si)
.inner_join(si_item)
.on(si.name == si_item.parent)
.select(
si.name,
si_item.item_code,
si_item.stock_qty.as_("qty"),
si_item.base_net_amount.as_("base_amount"),
si.return_against,
)
.where(
(si.docstatus == 1)
& (si.is_return == 1)
& si.posting_date.between(self.filters.from_date, self.filters.to_date)
)
.run(as_dict=1)
)
self.returned_invoices = frappe._dict()
@@ -1241,7 +1246,4 @@ class GrossProfitGenerator:
).setdefault(d.parent_item, []).append(d)
def load_non_stock_items(self):
self.non_stock_items = frappe.db.sql_list(
"""select name from tabItem
where is_stock_item=0"""
)
self.non_stock_items = frappe.get_all("Item", filters={"is_stock_item": 0}, pluck="name")

View File

@@ -14,73 +14,17 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestGrossProfit(ERPNextTestSuite):
def setUp(self):
self.create_company()
self.create_item()
self.create_bundle()
self.create_customer()
self.create_sales_invoice()
self.clear_old_entries()
def create_company(self):
company_name = "_Test Gross Profit"
abbr = "_GP"
if frappe.db.exists("Company", company_name):
company = frappe.get_doc("Company", company_name)
else:
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": company_name,
"country": "India",
"default_currency": "INR",
"create_chart_of_accounts_based_on": "Standard Template",
"chart_of_accounts": "Standard",
}
)
company = company.save()
self.company = company.name
self.cost_center = company.cost_center
self.warehouse = "Stores - " + abbr
self.finished_warehouse = "Finished Goods - " + abbr
self.income_account = "Sales - " + abbr
self.expense_account = "Cost of Goods Sold - " + abbr
self.debit_to = "Debtors - " + abbr
self.creditors = "Creditors - " + abbr
def create_item(self):
item = create_item(
item_code="_Test GP Item", is_stock_item=1, company=self.company, warehouse=self.warehouse
)
self.item = item if isinstance(item, str) else item.item_code
def create_bundle(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
item2 = create_item(
item_code="_Test GP Item 2", is_stock_item=1, company=self.company, warehouse=self.warehouse
)
self.item2 = item2 if isinstance(item2, str) else item2.item_code
# This will be parent item
bundle = create_item(
item_code="_Test GP bundle", is_stock_item=0, company=self.company, warehouse=self.warehouse
)
self.bundle = bundle if isinstance(bundle, str) else bundle.item_code
# Create Product Bundle
self.product_bundle = make_product_bundle(parent=self.bundle, items=[self.item, self.item2])
def create_customer(self):
name = "_Test GP Customer"
if frappe.db.exists("Customer", name):
self.customer = name
else:
customer = frappe.new_doc("Customer")
customer.customer_name = name
customer.type = "Individual"
customer.save()
self.customer = customer.name
self.company = "_Test Company"
self.cost_center = "Main - _TC"
self.warehouse = "Stores - _TC"
self.finished_warehouse = "Finished Goods - _TC"
self.income_account = "Sales - _TC"
self.expense_account = "Cost of Goods Sold - _TC"
self.debit_to = "Debtors - _TC"
self.item = "_Test Item"
self.item2 = "_Test Item Home Desktop 100"
self.bundle = "_Test Product Bundle Item"
self.customer = "_Test Customer"
def create_sales_invoice(
self, qty=1, rate=100, posting_date=None, do_not_save=False, do_not_submit=False
@@ -214,7 +158,7 @@ class TestGrossProfit(ERPNextTestSuite):
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
"item_code": self.item,
"item_name": self.item,
"warehouse": "Stores - _GP",
"warehouse": "Stores - _TC",
"qty": 1.0,
"avg._selling_rate": 100.0,
"valuation_rate": 150.0,
@@ -243,7 +187,7 @@ class TestGrossProfit(ERPNextTestSuite):
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
"item_code": self.item,
"item_name": self.item,
"warehouse": "Stores - _GP",
"warehouse": "Stores - _TC",
"qty": 1.0,
"avg._selling_rate": 100.0,
"valuation_rate": 100.0,
@@ -275,7 +219,7 @@ class TestGrossProfit(ERPNextTestSuite):
"item_code": self.item2,
"s_warehouse": "",
"t_warehouse": self.finished_warehouse,
"qty": 1,
"qty": 2,
"basic_rate": 100,
"conversion_factor": item.conversion_factor or 1.0,
"transfer_qty": flt(item.qty) * (flt(item.conversion_factor) or 1.0),
@@ -375,7 +319,7 @@ class TestGrossProfit(ERPNextTestSuite):
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
"item_code": self.item,
"item_name": self.item,
"warehouse": "Stores - _GP",
"warehouse": "Stores - _TC",
"qty": 4.0,
"avg._selling_rate": 100.0,
"valuation_rate": 125.0,
@@ -416,10 +360,10 @@ class TestGrossProfit(ERPNextTestSuite):
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
"item_code": self.item,
"item_name": self.item,
"warehouse": "Stores - _GP",
"warehouse": "Stores - _TC",
"qty": 0.0,
"avg._selling_rate": 100,
"valuation_rate": 0.0,
"avg._selling_rate": 100.0,
"valuation_rate": 100.0,
"selling_amount": 0.0,
"buying_amount": 0.0,
"gross_profit": 0.0,
@@ -439,7 +383,7 @@ class TestGrossProfit(ERPNextTestSuite):
"""
# Make Cr Note
sinv = self.create_sales_invoice(
qty=-1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True
qty=-1, rate=200, posting_date=nowdate(), do_not_save=True, do_not_submit=True
)
sinv.is_return = 1
sinv.items[0].allow_zero_valuation_rate = 1
@@ -462,14 +406,14 @@ class TestGrossProfit(ERPNextTestSuite):
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
"item_code": self.item,
"item_name": self.item,
"warehouse": "Stores - _GP",
"warehouse": "Stores - _TC",
"qty": -1.0,
"avg._selling_rate": 100.0,
"valuation_rate": 0.0,
"selling_amount": -100.0,
"buying_amount": 0.0,
"avg._selling_rate": 200.0,
"valuation_rate": 100.0,
"selling_amount": -200.0,
"buying_amount": -100.0,
"gross_profit": -100.0,
"gross_profit_%": -100.0,
"gross_profit_%": -50.0,
}
gp_entry = [x for x in data if x.parent_invoice == sinv.name]
report_output = {k: v for k, v in gp_entry[0].items() if k in expected_entry}
@@ -555,7 +499,7 @@ class TestGrossProfit(ERPNextTestSuite):
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
"item_code": self.item,
"item_name": self.item,
"warehouse": "Stores - _GP",
"warehouse": "Stores - _TC",
"qty": 4.0,
"avg._selling_rate": 800.0,
"valuation_rate": 700.0,
@@ -618,7 +562,7 @@ class TestGrossProfit(ERPNextTestSuite):
def test_gross_profit_groupby_invoices(self):
create_sales_invoice(
qty=1,
rate=100,
rate=200,
company=self.company,
customer=self.customer,
item_code=self.item,
@@ -640,10 +584,10 @@ class TestGrossProfit(ERPNextTestSuite):
_, data = execute(filters=filters)
total = data[-1]
self.assertEqual(total.selling_amount, 100.0)
self.assertEqual(total.buying_amount, 0.0)
self.assertEqual(total.selling_amount, 200.0)
self.assertEqual(total.buying_amount, 100.0)
self.assertEqual(total.gross_profit, 100.0)
self.assertEqual(total.get("gross_profit_%"), 100.0)
self.assertEqual(total.get("gross_profit_%"), 50.0)
def test_profit_for_later_period_return(self):
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
@@ -652,7 +596,7 @@ class TestGrossProfit(ERPNextTestSuite):
return_inv_date = add_days(month_end_date, 1)
# create sales invoice on month start date
sinv = self.create_sales_invoice(qty=1, rate=100, do_not_save=True, do_not_submit=True)
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_save=True, do_not_submit=True)
sinv.set_posting_time = 1
sinv.posting_date = sales_inv_date
sinv.save().submit()
@@ -671,10 +615,10 @@ class TestGrossProfit(ERPNextTestSuite):
_, data = execute(filters=filters)
total = data[-1]
self.assertEqual(total.selling_amount, 100.0)
self.assertEqual(total.buying_amount, 0.0)
self.assertEqual(total.selling_amount, 200.0)
self.assertEqual(total.buying_amount, 100.0)
self.assertEqual(total.gross_profit, 100.0)
self.assertEqual(total.get("gross_profit_%"), 100.0)
self.assertEqual(total.get("gross_profit_%"), 50.0)
# extend filters upto returned period
filters.update({"to_date": return_inv_date})
@@ -692,10 +636,10 @@ class TestGrossProfit(ERPNextTestSuite):
_, data = execute(filters=filters)
total = data[-1]
self.assertEqual(total.selling_amount, -100.0)
self.assertEqual(total.buying_amount, 0.0)
self.assertEqual(total.selling_amount, -200.0)
self.assertEqual(total.buying_amount, -100.0)
self.assertEqual(total.gross_profit, -100.0)
self.assertEqual(total.get("gross_profit_%"), -100.0)
self.assertEqual(total.get("gross_profit_%"), -50.0)
def test_sales_person_wise_gross_profit(self):
sales_person = make_sales_person("_Test Sales Person")
@@ -726,10 +670,10 @@ class TestGrossProfit(ERPNextTestSuite):
_, data = execute(filters=filters)
total = data[-1]
self.assertEqual(total[5], 1000.0)
self.assertEqual(total[6], 0.0)
self.assertEqual(total[7], 1000.0)
self.assertEqual(total[8], 100.0)
self.assertEqual(total[5], 1000.0) # selling amount
self.assertEqual(total[6], 1000.0) # buying amount
self.assertEqual(total[7], 0.0) # gross profit
self.assertEqual(total[8], 0.0) # gross profit %
def test_drop_ship(self):
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice

View File

@@ -4,7 +4,7 @@
import frappe
from frappe import _
from frappe.query_builder import CustomFunction
from frappe.query_builder.functions import CurDate, DateDiff
from frappe.utils import cint
@@ -102,11 +102,11 @@ def get_sales_details(filters):
child_doctype = "Sales Order Item" if filters["based_on"] == "Sales Order" else "Sales Invoice Item"
child = frappe.qb.DocType(child_doctype)
date_diff = CustomFunction("DATEDIFF", ["d1", "d2"])
current_date = CustomFunction("CURRENT_DATE", [])
date_col = parent.transaction_date if filters["based_on"] == "Sales Order" else parent.posting_date
days_since_last_order = date_diff(current_date(), date_col)
# DateDiff is cross-database (DATEDIFF on MariaDB, date subtraction on postgres); CurDate()
# renders the bare CURRENT_DATE keyword. Yields the integer number of days.
days_since_last_order = DateDiff(CurDate(), date_col)
sales_data = (
frappe.qb.from_(parent)

View File

@@ -0,0 +1,32 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.utils import add_days, today
from erpnext.accounts.report.inactive_sales_items.inactive_sales_items import execute
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
class TestInactiveSalesItems(ERPNextTestSuite):
def test_days_since_last_order_is_computed(self):
# Exercises the date-arithmetic path (DATEDIFF/CURRENT_DATE on mariadb, date subtraction on
# postgres) which must produce the same integer day count on both databases.
item = make_item("_Test Inactive Sales Item").name
old_date = add_days(today(), -120)
so = make_sales_order(item=item, qty=3, rate=150, transaction_date=old_date)
so.items[0].delivery_date = add_days(old_date, 7)
so.save()
so.submit()
columns, data = execute(frappe._dict({"based_on": "Sales Order", "days": 30}))
self.assertTrue(columns)
row = next((r for r in data if r.get("item") == item and r.get("days_since_last_order")), None)
self.assertIsNotNone(row, "Inactive item should appear in the report")
self.assertGreaterEqual(row["days_since_last_order"], 30)
def test_report_runs_for_sales_invoice(self):
columns, _data = execute(frappe._dict({"based_on": "Sales Invoice", "days": 30}))
self.assertTrue(columns)

View File

@@ -376,7 +376,7 @@ def get_items(filters, additional_table_columns):
def get_aii_accounts():
return dict(frappe.db.sql("select name, stock_received_but_not_billed from tabCompany"))
return dict(frappe.get_all("Company", fields=["name", "stock_received_but_not_billed"], as_list=True))
def get_purchase_receipts_against_purchase_order(item_list):
@@ -384,16 +384,11 @@ def get_purchase_receipts_against_purchase_order(item_list):
po_item_rows = list(set(d.po_detail for d in item_list))
if po_item_rows:
purchase_receipts = frappe.db.sql(
"""
select parent, purchase_order_item
from `tabPurchase Receipt Item`
where docstatus=1 and purchase_order_item in (%s)
group by purchase_order_item, parent
"""
% (", ".join(["%s"] * len(po_item_rows))),
tuple(po_item_rows),
as_dict=1,
purchase_receipts = frappe.get_all(
"Purchase Receipt Item",
filters={"docstatus": 1, "purchase_order_item": ["in", po_item_rows]},
fields=["parent", "purchase_order_item"],
group_by="purchase_order_item, parent",
)
for pr in purchase_receipts:

View File

@@ -9,9 +9,9 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestItemWisePurchaseRegister(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_supplier()
self.create_item()
self.company = "_Test Company"
self.supplier = "_Test Supplier"
self.item = "_Test Item"
def create_purchase_invoice(self, do_not_submit=False):
pi = make_purchase_invoice(

View File

@@ -9,9 +9,11 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()
self.create_item()
self.company = "_Test Company"
self.customer = "_Test Customer"
self.item = "_Test Item"
self.debit_to = "Debtors - _TC"
self.cost_center = "Main - _TC"
def create_sales_invoice(self, item=None, taxes=None, do_not_submit=False):
si = create_sales_invoice(

View File

@@ -9,42 +9,12 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestPaymentLedger(ERPNextTestSuite):
def setUp(self):
self.create_company()
self.cleanup()
def cleanup(self):
doctypes = []
doctypes.append(qb.DocType("GL Entry"))
doctypes.append(qb.DocType("Payment Ledger Entry"))
doctypes.append(qb.DocType("Sales Invoice"))
doctypes.append(qb.DocType("Payment Entry"))
for doctype in doctypes:
qb.from_(doctype).delete().where(doctype.company == self.company).run()
def create_company(self):
name = "Test Payment Ledger"
company = None
if frappe.db.exists("Company", name):
company = frappe.get_doc("Company", name)
else:
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": name,
"country": "India",
"default_currency": "INR",
"create_chart_of_accounts_based_on": "Standard Template",
"chart_of_accounts": "Standard",
}
)
company = company.save()
self.company = company.name
self.cost_center = company.cost_center
self.warehouse = "All Warehouses" + " - " + company.abbr
self.income_account = company.default_income_account
self.expense_account = company.default_expense_account
self.debit_to = company.default_receivable_account
self.company = "_Test Company"
self.cost_center = "Main - _TC"
self.warehouse = "Stores - _TC"
self.income_account = "Sales - _TC"
self.expense_account = "Cost of Goods Sold - _TC"
self.debit_to = "Debtors - _TC"
def test_unpaid_invoice_outstanding(self):
sinv = create_sales_invoice(

View File

@@ -4,6 +4,8 @@
import frappe
from frappe import _
from frappe.query_builder import Case
from frappe.query_builder.functions import IfNull
from erpnext.accounts.report.sales_register.sales_register import get_mode_of_payments
@@ -46,40 +48,47 @@ def execute(filters=None):
def get_pos_entries(filters, group_by_field):
conditions = get_conditions(filters)
order_by = "p.posting_date"
select_mop_field, from_sales_invoice_payment, group_by_mop_condition = "", "", ""
if group_by_field == "mode_of_payment":
select_mop_field = (
", sip.mode_of_payment, sip.base_amount - IF(sip.type='Cash', p.change_amount, 0) as paid_amount"
p = frappe.qb.DocType("POS Invoice")
query = (
frappe.qb.from_(p)
.select(
p.posting_date,
p.name.as_("pos_invoice"),
p.pos_profile,
p.company,
p.owner,
p.customer,
p.is_return,
p.base_grand_total.as_("grand_total"),
)
from_sales_invoice_payment = ", `tabSales Invoice Payment` sip"
group_by_mop_condition = "sip.parent = p.name AND ifnull(sip.base_amount - IF(sip.type='Cash', p.change_amount, 0), 0) != 0 AND"
order_by += ", sip.mode_of_payment"
elif group_by_field:
order_by += f", p.{group_by_field}"
select_mop_field = ", p.base_paid_amount - p.change_amount as paid_amount "
# nosemgrep
return frappe.db.sql(
f"""
SELECT
p.posting_date, p.name as pos_invoice, p.pos_profile, p.company,
p.owner, p.customer, p.is_return, p.base_grand_total as grand_total {select_mop_field}
FROM
`tabPOS Invoice` p {from_sales_invoice_payment}
WHERE
p.docstatus = 1 and
{group_by_mop_condition}
{conditions}
ORDER BY
{order_by}
""",
filters,
as_dict=1,
.where(p.docstatus == 1)
)
for condition in get_conditions(filters, p):
query = query.where(condition)
if group_by_field == "mode_of_payment":
sip = frappe.qb.DocType("Sales Invoice Payment")
paid_amount = sip.base_amount - Case().when(sip.type == "Cash", p.change_amount).else_(0)
query = (
query.inner_join(sip)
.on(sip.parent == p.name)
.select(sip.mode_of_payment, paid_amount.as_("paid_amount"))
.where(IfNull(paid_amount, 0) != 0)
.orderby(p.posting_date)
.orderby(sip.mode_of_payment)
)
elif group_by_field:
query = (
query.select((p.base_paid_amount - p.change_amount).as_("paid_amount"))
.orderby(p.posting_date)
.orderby(p[group_by_field])
)
else:
query = query.orderby(p.posting_date)
return query.run(as_dict=1)
def concat_mode_of_payments(pos_entries):
mode_of_payments = get_mode_of_payments(set(d.pos_invoice for d in pos_entries))
@@ -127,27 +136,34 @@ def validate_filters(filters):
frappe.throw(_("Can not filter based on Payment Method, if grouped by Payment Method"))
def get_conditions(filters):
conditions = "company = %(company)s AND posting_date >= %(from_date)s AND posting_date <= %(to_date)s"
def get_conditions(filters, p):
conditions = [
p.company == filters.get("company"),
p.posting_date >= filters.get("from_date"),
p.posting_date <= filters.get("to_date"),
]
if filters.get("pos_profile"):
conditions += " AND pos_profile = %(pos_profile)s"
conditions.append(p.pos_profile == filters.get("pos_profile"))
if filters.get("owner"):
conditions += " AND owner = %(owner)s"
conditions.append(p.owner == filters.get("owner"))
if filters.get("customer"):
conditions += " AND customer = %(customer)s"
conditions.append(p.customer == filters.get("customer"))
if filters.get("is_return"):
conditions += " AND is_return = %(is_return)s"
conditions.append(p.is_return == filters.get("is_return"))
if filters.get("mode_of_payment"):
conditions += """
AND EXISTS(
SELECT name FROM `tabSales Invoice Payment` sip
WHERE parent=p.name AND ifnull(sip.mode_of_payment, '') = %(mode_of_payment)s
)"""
sip = frappe.qb.DocType("Sales Invoice Payment")
conditions.append(
p.name.isin(
frappe.qb.from_(sip)
.select(sip.parent)
.where(IfNull(sip.mode_of_payment, "") == filters.get("mode_of_payment"))
)
)
return conditions

View File

@@ -0,0 +1,20 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.utils import add_days, today
from erpnext.accounts.report.pos_register.pos_register import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestPOSRegister(ERPNextTestSuite):
def test_report_executes(self):
# Smoke-guards the raw-SQL -> query-builder port: the report's POS Invoice query must
# compile and run on both MariaDB and postgres (it returns columns + a row list either way).
company = frappe.db.get_value("Company", {}, "name")
columns, data = execute(
frappe._dict({"company": company, "from_date": add_days(today(), -365), "to_date": today()})
)
self.assertTrue(columns)
self.assertIsInstance(data, list)

View File

@@ -14,9 +14,11 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestProfitAndLossStatement(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()
self.create_item()
self.company = "_Test Company"
self.customer = "_Test Customer"
self.item = "_Test Item"
self.debit_to = "Debtors - _TC"
self.cost_center = "Main - _TC"
def create_sales_invoice(self, qty=1, rate=150, no_payment_schedule=False, do_not_submit=False):
frappe.set_user("Administrator")

View File

@@ -4,7 +4,9 @@
import frappe
from frappe import _, msgprint
from frappe.query_builder import Case
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Sum
from frappe.utils import flt, getdate
from pypika.terms import Bracket, LiteralValue, Order
@@ -307,14 +309,17 @@ def get_account_columns(invoice_list, include_payments):
unrealized_profit_loss_account_columns = []
if invoice_list:
expense_accounts = frappe.db.sql_list(
"""select distinct expense_account
from `tabPurchase Invoice Item` where docstatus = 1
and (expense_account is not null and expense_account != '')
and parenttype='Purchase Invoice'
and parent in (%s) order by expense_account"""
% ", ".join(["%s"] * len(invoice_list)),
tuple([inv.name for inv in invoice_list]),
expense_accounts = frappe.get_all(
"Purchase Invoice Item",
filters={
"docstatus": 1,
"expense_account": ["is", "set"],
"parenttype": "Purchase Invoice",
"parent": ["in", [inv.name for inv in invoice_list]],
},
pluck="expense_account",
distinct=True,
order_by="expense_account",
)
purchase_taxes_query = get_taxes_query(invoice_list, "Purchase Taxes and Charges", "Purchase Invoice")
@@ -326,13 +331,16 @@ def get_account_columns(invoice_list, include_payments):
advance_tax_accounts = advance_taxes_query.run(as_dict=True, pluck="account_head")
tax_accounts = set(tax_accounts + advance_tax_accounts)
unrealized_profit_loss_accounts = frappe.db.sql_list(
"""SELECT distinct unrealized_profit_loss_account
from `tabPurchase Invoice` where docstatus = 1 and name in (%s)
and ifnull(unrealized_profit_loss_account, '') != ''
order by unrealized_profit_loss_account"""
% ", ".join(["%s"] * len(invoice_list)),
tuple(inv.name for inv in invoice_list),
unrealized_profit_loss_accounts = frappe.get_all(
"Purchase Invoice",
filters={
"docstatus": 1,
"name": ["in", [inv.name for inv in invoice_list]],
"unrealized_profit_loss_account": ["is", "set"],
},
pluck="unrealized_profit_loss_account",
distinct=True,
order_by="unrealized_profit_loss_account",
)
for account in expense_accounts:
@@ -454,16 +462,11 @@ def get_payments(filters):
def get_invoice_expense_map(invoice_list):
expense_details = frappe.db.sql(
"""
select parent, expense_account, sum(base_net_amount) as amount
from `tabPurchase Invoice Item`
where parent in (%s) and parenttype='Purchase Invoice'
group by parent, expense_account
"""
% ", ".join(["%s"] * len(invoice_list)),
tuple(inv.name for inv in invoice_list),
as_dict=1,
expense_details = frappe.get_all(
"Purchase Invoice Item",
filters={"parent": ["in", [inv.name for inv in invoice_list]], "parenttype": "Purchase Invoice"},
fields=["parent", "expense_account", {"SUM": "base_net_amount", "as": "amount"}],
group_by="parent, expense_account",
)
invoice_expense_map = {}
@@ -475,13 +478,16 @@ def get_invoice_expense_map(invoice_list):
def get_internal_invoice_map(invoice_list):
unrealized_amount_details = frappe.db.sql(
"""SELECT name, unrealized_profit_loss_account,
base_net_total as amount from `tabPurchase Invoice` where name in (%s)
and is_internal_supplier = 1 and company = represents_company"""
% ", ".join(["%s"] * len(invoice_list)),
tuple(inv.name for inv in invoice_list),
as_dict=1,
pi = frappe.qb.DocType("Purchase Invoice")
unrealized_amount_details = (
frappe.qb.from_(pi)
.select(pi.name, pi.unrealized_profit_loss_account, pi.base_net_total.as_("amount"))
.where(
pi.name.isin([inv.name for inv in invoice_list])
& (pi.is_internal_supplier == 1)
& (pi.company == pi.represents_company)
)
.run(as_dict=1)
)
internal_invoice_map = {}
@@ -493,18 +499,23 @@ def get_internal_invoice_map(invoice_list):
def get_invoice_tax_map(invoice_list, invoice_expense_map, expense_accounts, include_payments=False):
tax_details = frappe.db.sql(
"""
select parent, account_head, case add_deduct_tax when "Add" then sum(base_tax_amount_after_discount_amount)
else sum(base_tax_amount_after_discount_amount) * -1 end as tax_amount
from `tabPurchase Taxes and Charges`
where parent in (%s) and category in ('Total', 'Valuation and Total')
and base_tax_amount_after_discount_amount != 0 and parenttype='Purchase Invoice'
group by parent, account_head, add_deduct_tax
"""
% ", ".join(["%s"] * len(invoice_list)),
tuple(inv.name for inv in invoice_list),
as_dict=1,
ptc = frappe.qb.DocType("Purchase Taxes and Charges")
tax_amount = (
Case()
.when(ptc.add_deduct_tax == "Add", Sum(ptc.base_tax_amount_after_discount_amount))
.else_(Sum(ptc.base_tax_amount_after_discount_amount) * -1)
)
tax_details = (
frappe.qb.from_(ptc)
.select(ptc.parent, ptc.account_head, tax_amount.as_("tax_amount"))
.where(
ptc.parent.isin([inv.name for inv in invoice_list])
& ptc.category.isin(["Total", "Valuation and Total"])
& (ptc.base_tax_amount_after_discount_amount != 0)
& (ptc.parenttype == "Purchase Invoice")
)
.groupby(ptc.parent, ptc.account_head, ptc.add_deduct_tax)
.run(as_dict=1)
)
if include_payments:
@@ -525,15 +536,10 @@ def get_invoice_tax_map(invoice_list, invoice_expense_map, expense_accounts, inc
def get_invoice_po_pr_map(invoice_list):
pi_items = frappe.db.sql(
"""
select parent, purchase_order, purchase_receipt, po_detail, project
from `tabPurchase Invoice Item`
where parent in (%s) and parenttype='Purchase Invoice'
"""
% ", ".join(["%s"] * len(invoice_list)),
tuple(inv.name for inv in invoice_list),
as_dict=1,
pi_items = frappe.get_all(
"Purchase Invoice Item",
filters={"parent": ["in", [inv.name for inv in invoice_list]], "parenttype": "Purchase Invoice"},
fields=["parent", "purchase_order", "purchase_receipt", "po_detail", "project"],
)
invoice_po_pr_map = {}
@@ -547,10 +553,11 @@ def get_invoice_po_pr_map(invoice_list):
if d.purchase_receipt:
pr_list = [d.purchase_receipt]
elif d.po_detail:
pr_list = frappe.db.sql_list(
"""select distinct parent from `tabPurchase Receipt Item`
where docstatus=1 and purchase_order_item=%s""",
d.po_detail,
pr_list = frappe.get_all(
"Purchase Receipt Item",
filters={"docstatus": 1, "purchase_order_item": d.po_detail},
pluck="parent",
distinct=True,
)
if pr_list:
@@ -565,12 +572,8 @@ def get_invoice_po_pr_map(invoice_list):
def get_account_details(invoice_list):
account_map = {}
accounts = list(set([inv.credit_to for inv in invoice_list]))
for acc in frappe.db.sql(
"""select name, parent_account from tabAccount
where name in (%s)"""
% ", ".join(["%s"] * len(accounts)),
tuple(accounts),
as_dict=1,
for acc in frappe.get_all(
"Account", filters={"name": ["in", accounts]}, fields=["name", "parent_account"]
):
account_map[acc.name] = acc.parent_account

View File

@@ -346,12 +346,15 @@ def get_account_columns(invoice_list, include_payments):
unrealized_profit_loss_account_columns = []
if invoice_list:
income_accounts = frappe.db.sql_list(
"""select distinct income_account
from `tabSales Invoice Item` where docstatus = 1 and parent in (%s)
order by income_account"""
% ", ".join(["%s"] * len(invoice_list)),
tuple(inv.name for inv in invoice_list),
# frappe drops ORDER BY for distinct queries on postgres (db_query), so sort in python to keep
# the generated account-column order deterministic and identical on both backends.
income_accounts = sorted(
frappe.get_all(
"Sales Invoice Item",
filters={"docstatus": 1, "parent": ["in", [inv.name for inv in invoice_list]]},
pluck="income_account",
distinct=True,
)
)
sales_taxes_query = get_taxes_query(invoice_list, "Sales Taxes and Charges", "Sales Invoice")
@@ -363,14 +366,18 @@ def get_account_columns(invoice_list, include_payments):
advance_tax_accounts = advance_taxes_query.run(as_dict=True, pluck="account_head")
tax_accounts = set(tax_accounts + advance_tax_accounts)
unrealized_profit_loss_accounts = frappe.db.sql_list(
"""SELECT distinct unrealized_profit_loss_account
from `tabSales Invoice` where docstatus = 1 and name in (%s)
and is_internal_customer = 1
and ifnull(unrealized_profit_loss_account, '') != ''
order by unrealized_profit_loss_account"""
% ", ".join(["%s"] * len(invoice_list)),
tuple(inv.name for inv in invoice_list),
unrealized_profit_loss_accounts = sorted(
frappe.get_all(
"Sales Invoice",
filters={
"docstatus": 1,
"name": ["in", [inv.name for inv in invoice_list]],
"is_internal_customer": 1,
"unrealized_profit_loss_account": ["is", "set"],
},
pluck="unrealized_profit_loss_account",
distinct=True,
)
)
for account in income_accounts:
@@ -494,12 +501,11 @@ def get_payments(filters):
def get_invoice_income_map(invoice_list):
income_details = frappe.db.sql(
"""select parent, income_account, sum(base_net_amount) as amount
from `tabSales Invoice Item` where parent in (%s) group by parent, income_account"""
% ", ".join(["%s"] * len(invoice_list)),
tuple(inv.name for inv in invoice_list),
as_dict=1,
income_details = frappe.get_all(
"Sales Invoice Item",
filters={"parent": ["in", [inv.name for inv in invoice_list]]},
fields=["parent", "income_account", {"SUM": "base_net_amount", "as": "amount"}],
group_by="parent, income_account",
)
invoice_income_map = {}
@@ -511,13 +517,16 @@ def get_invoice_income_map(invoice_list):
def get_internal_invoice_map(invoice_list):
unrealized_amount_details = frappe.db.sql(
"""SELECT name, unrealized_profit_loss_account,
base_net_total as amount from `tabSales Invoice` where name in (%s)
and is_internal_customer = 1 and company = represents_company"""
% ", ".join(["%s"] * len(invoice_list)),
tuple(inv.name for inv in invoice_list),
as_dict=1,
si = frappe.qb.DocType("Sales Invoice")
unrealized_amount_details = (
frappe.qb.from_(si)
.select(si.name, si.unrealized_profit_loss_account, si.base_net_total.as_("amount"))
.where(
si.name.isin([inv.name for inv in invoice_list])
& (si.is_internal_customer == 1)
& (si.company == si.represents_company)
)
.run(as_dict=1)
)
internal_invoice_map = {}
@@ -529,14 +538,15 @@ def get_internal_invoice_map(invoice_list):
def get_invoice_tax_map(invoice_list, invoice_income_map, income_accounts, include_payments=False):
tax_details = frappe.db.sql(
"""select parent, account_head,
sum(base_tax_amount_after_discount_amount) as tax_amount
from `tabSales Taxes and Charges` where parent in (%s) and parenttype = 'Sales Invoice'
group by parent, account_head"""
% ", ".join(["%s"] * len(invoice_list)),
tuple(inv.name for inv in invoice_list),
as_dict=1,
tax_details = frappe.get_all(
"Sales Taxes and Charges",
filters={"parent": ["in", [inv.name for inv in invoice_list]], "parenttype": "Sales Invoice"},
fields=[
"parent",
"account_head",
{"SUM": "base_tax_amount_after_discount_amount", "as": "tax_amount"},
],
group_by="parent, account_head",
)
if include_payments:
@@ -557,13 +567,11 @@ def get_invoice_tax_map(invoice_list, invoice_income_map, income_accounts, inclu
def get_invoice_so_dn_map(invoice_list):
si_items = frappe.db.sql(
"""select parent, sales_order, delivery_note, so_detail
from `tabSales Invoice Item` where parent in (%s)
and (sales_order != '' or delivery_note != '')"""
% ", ".join(["%s"] * len(invoice_list)),
tuple(inv.name for inv in invoice_list),
as_dict=1,
si_items = frappe.get_all(
"Sales Invoice Item",
filters={"parent": ["in", [inv.name for inv in invoice_list]]},
or_filters=[["sales_order", "!=", ""], ["delivery_note", "!=", ""]],
fields=["parent", "sales_order", "delivery_note", "so_detail"],
)
invoice_so_dn_map = {}
@@ -577,10 +585,11 @@ def get_invoice_so_dn_map(invoice_list):
if d.delivery_note:
delivery_note_list = [d.delivery_note]
elif d.sales_order:
delivery_note_list = frappe.db.sql_list(
"""select distinct parent from `tabDelivery Note Item`
where docstatus=1 and so_detail=%s""",
d.so_detail,
delivery_note_list = frappe.get_all(
"Delivery Note Item",
filters={"docstatus": 1, "so_detail": d.so_detail},
pluck="parent",
distinct=True,
)
if delivery_note_list:
@@ -592,13 +601,11 @@ def get_invoice_so_dn_map(invoice_list):
def get_invoice_cc_wh_map(invoice_list):
si_items = frappe.db.sql(
"""select parent, cost_center, warehouse
from `tabSales Invoice Item` where parent in (%s)
and (cost_center != '' or warehouse != '')"""
% ", ".join(["%s"] * len(invoice_list)),
tuple(inv.name for inv in invoice_list),
as_dict=1,
si_items = frappe.get_all(
"Sales Invoice Item",
filters={"parent": ["in", [inv.name for inv in invoice_list]]},
or_filters=[["cost_center", "!=", ""], ["warehouse", "!=", ""]],
fields=["parent", "cost_center", "warehouse"],
)
invoice_cc_wh_map = {}
@@ -619,12 +626,11 @@ def get_invoice_cc_wh_map(invoice_list):
def get_mode_of_payments(invoice_list):
mode_of_payments = {}
if invoice_list:
inv_mop = frappe.db.sql(
"""select parent, mode_of_payment
from `tabSales Invoice Payment` where parent in (%s) group by parent, mode_of_payment"""
% ", ".join(["%s"] * len(invoice_list)),
tuple(invoice_list),
as_dict=1,
inv_mop = frappe.get_all(
"Sales Invoice Payment",
filters={"parent": ["in", list(invoice_list)]},
fields=["parent", "mode_of_payment"],
group_by="parent, mode_of_payment",
)
for d in inv_mop:

View File

@@ -10,9 +10,13 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_customer()
self.create_item()
self.company = "_Test Company"
self.customer = "_Test Customer"
self.item = "_Test Item"
self.debit_to = "Debtors - _TC"
self.cost_center = "Main - _TC"
self.income_account = "Sales - _TC"
self.cash = "Cash - _TC"
self.create_child_cost_center()
def create_child_cost_center(self):

View File

@@ -62,15 +62,10 @@ def get_columns(filters):
def get_all_transfers(date, shareholder):
condition = " "
# if company:
# condition = 'AND company = %(company)s '
return frappe.db.sql(
f"""SELECT * FROM `tabShare Transfer`
WHERE ((DATE(date) <= %(date)s AND from_shareholder = %(shareholder)s {condition})
OR (DATE(date) <= %(date)s AND to_shareholder = %(shareholder)s {condition}))
AND docstatus = 1
ORDER BY date""",
{"date": date, "shareholder": shareholder},
as_dict=1,
return frappe.get_all(
"Share Transfer",
filters={"date": ["<=", date], "docstatus": 1},
or_filters=[["from_shareholder", "=", shareholder], ["to_shareholder", "=", shareholder]],
fields=["*"],
order_by="date",
)

View File

@@ -9,10 +9,9 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestSupplierLedgerSummary(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.create_supplier()
self.create_item()
self.clear_old_entries()
self.company = "_Test Company"
self.supplier = "_Test Supplier"
self.item = "_Test Item"
def create_purchase_invoice(self, do_not_submit=False):
frappe.set_user("Administrator")

View File

@@ -20,8 +20,7 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestTaxWithholdingDetails(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
self.create_company()
self.clear_old_entries()
self.company = "_Test Company"
create_records()
def test_tax_withholding_for_customers(self):

View File

@@ -4,7 +4,7 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
from frappe.query_builder.functions import Max, Sum
from frappe.utils import add_days, cstr, flt, formatdate, getdate
import erpnext
@@ -82,12 +82,21 @@ def validate_filters(filters):
def get_data(filters):
accounts = frappe.db.sql(
"""select name, account_number, parent_account, account_name, root_type, report_type, is_group, lft, rgt
from `tabAccount` where company=%s order by lft""",
filters.company,
as_dict=True,
accounts = frappe.get_all(
"Account",
filters={"company": filters.company},
fields=[
"name",
"account_number",
"parent_account",
"account_name",
"root_type",
"report_type",
"is_group",
"lft",
"rgt",
],
order_by="lft",
)
company_currency = filters.presentation_currency or erpnext.get_company_currency(filters.company)
@@ -240,7 +249,8 @@ def get_opening_balance(
frappe.qb.from_(closing_balance)
.select(
closing_balance.account,
closing_balance.account_currency,
# account_currency is constant per grouped account -> Max() keeps the GROUP BY postgres-valid
Max(closing_balance.account_currency).as_("account_currency"),
Sum(closing_balance.debit).as_("debit"),
Sum(closing_balance.credit).as_("credit"),
Sum(closing_balance.debit_in_account_currency).as_("debit_in_account_currency"),

View File

@@ -3,7 +3,7 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
from frappe.query_builder.functions import Max, Sum
def execute(filters=None):
@@ -43,7 +43,13 @@ def get_data(filters):
gle = frappe.qb.DocType("GL Entry")
query = (
frappe.qb.from_(gle)
.select(gle.voucher_type, gle.voucher_no, Sum(gle.debit).as_("debit"), Sum(gle.credit).as_("credit"))
# voucher_type is constant per grouped voucher_no -> Max() keeps the GROUP BY valid on postgres
.select(
Max(gle.voucher_type).as_("voucher_type"),
gle.voucher_no,
Sum(gle.debit).as_("debit"),
Sum(gle.credit).as_("credit"),
)
.where(gle.is_cancelled == 0)
.groupby(gle.voucher_no)
)

View File

@@ -12,7 +12,7 @@ import frappe
from frappe import _
from frappe.query_builder import Criterion
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Abs, Sum
from frappe.query_builder.functions import Abs, Max, Sum
from frappe.utils import flt
import erpnext
@@ -150,7 +150,7 @@ def calculate_total_advance_from_ledger(doc) -> list:
adv = frappe.qb.DocType("Advance Payment Ledger Entry")
return (
frappe.qb.from_(adv)
.select(Abs(Sum(adv.amount)).as_("amount"), adv.currency.as_("account_currency"))
.select(Abs(Sum(adv.amount)).as_("amount"), Max(adv.currency).as_("account_currency"))
.where(adv.company == doc.company)
.where(adv.delinked == 0)
.where(adv.against_voucher_type == doc.doctype)

View File

@@ -141,7 +141,7 @@ def make_exchange_gain_loss_journal(
.where(
(je.docstatus == 1)
& (je.name.isin(parents))
& (je.voucher_type == "Exchange Gain or Loss")
& (je.voucher_type == "Exchange Gain Or Loss")
)
.run()
)

View File

@@ -80,6 +80,8 @@ class TestUtils(ERPNextTestSuite):
purchase_invoice.submit()
payment_entry = get_payment_entry(purchase_invoice.doctype, purchase_invoice.name)
payment_entry.target_exchange_rate = 82.32
payment_entry.set_amounts()
payment_entry.paid_amount = 15725
payment_entry.deductions = []
payment_entry.save()

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