mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-02 21:26:55 +00:00
Compare commits
402 Commits
v14-baseli
...
l10n_devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3e3d97a72 | ||
|
|
42c6768b4c | ||
|
|
2e13691ffa | ||
|
|
ae80d29dcf | ||
|
|
a33f7ead24 | ||
|
|
817ecaa92f | ||
|
|
8fd98ccbe2 | ||
|
|
bc82197bd1 | ||
|
|
9c53a91b82 | ||
|
|
21a9f2754e | ||
|
|
f0434cadd4 | ||
|
|
0ba43a17c1 | ||
|
|
4feaacc649 | ||
|
|
7034dc71e7 | ||
|
|
ed72732bb2 | ||
|
|
b12725c4b2 | ||
|
|
20928bd600 | ||
|
|
15862566a8 | ||
|
|
e446f54f2e | ||
|
|
cf9f16a921 | ||
|
|
6cffa0faeb | ||
|
|
a075437db7 | ||
|
|
9f4914e08f | ||
|
|
c9d712fa49 | ||
|
|
3345336a5c | ||
|
|
ceadc4f269 | ||
|
|
caa4358057 | ||
|
|
36f56fa1c3 | ||
|
|
5b738b7b0d | ||
|
|
f85f6be3cf | ||
|
|
6591ae195d | ||
|
|
7229957107 | ||
|
|
50b6f50b88 | ||
|
|
0888405640 | ||
|
|
087fb29d51 | ||
|
|
2bab709ac4 | ||
|
|
5298438905 | ||
|
|
e0b0926dff | ||
|
|
a3d22f4a51 | ||
|
|
3f9b8fe37e | ||
|
|
820f5498e7 | ||
|
|
71f02d412a | ||
|
|
f9ac05f4a1 | ||
|
|
c7fed29569 | ||
|
|
5514c64b7c | ||
|
|
2a8d26c0a7 | ||
|
|
56e7690e64 | ||
|
|
6e57bd325f | ||
|
|
8d70385019 | ||
|
|
f95baa54de | ||
|
|
3092c920ff | ||
|
|
55646667be | ||
|
|
9865f63613 | ||
|
|
08876ae07a | ||
|
|
489a799bc4 | ||
|
|
0790d2e6df | ||
|
|
04b94ed61f | ||
|
|
334b8ab09a | ||
|
|
7592f568ae | ||
|
|
bd21f506a1 | ||
|
|
2eec826219 | ||
|
|
4716084a41 | ||
|
|
81e838c4f8 | ||
|
|
c8eebd3a96 | ||
|
|
b6bdf81ce8 | ||
|
|
64db8072d8 | ||
|
|
cabdb7417d | ||
|
|
b4d3a879d2 | ||
|
|
040b33070b | ||
|
|
dae3a21b61 | ||
|
|
0769484fd6 | ||
|
|
683ef19b8a | ||
|
|
0e8ae7548d | ||
|
|
7f05b8ce58 | ||
|
|
e92a9c706b | ||
|
|
18d1947154 | ||
|
|
a69590b609 | ||
|
|
5adbc7baba | ||
|
|
7e7fd610cb | ||
|
|
ece8c9538d | ||
|
|
b77f6168d9 | ||
|
|
14f862f80c | ||
|
|
f1e91b6be6 | ||
|
|
835a050cfb | ||
|
|
2d3a1f5fab | ||
|
|
14091a8996 | ||
|
|
cc9d94efe8 | ||
|
|
c17517d22a | ||
|
|
4c9520bb1f | ||
|
|
7248053c6a | ||
|
|
c98ca6d2cc | ||
|
|
0a05dd4426 | ||
|
|
99fbd61bd9 | ||
|
|
b9e321c106 | ||
|
|
27f5235e67 | ||
|
|
694328aab6 | ||
|
|
cb4f3588fa | ||
|
|
7835f11f96 | ||
|
|
2e72c13aee | ||
|
|
898a70d340 | ||
|
|
435998cc4e | ||
|
|
ca7c6ca6da | ||
|
|
d10504af03 | ||
|
|
63cf379dbf | ||
|
|
65e3394481 | ||
|
|
8928b42d5d | ||
|
|
c47a95a4d2 | ||
|
|
5f83887334 | ||
|
|
04468c3c33 | ||
|
|
f9029f8644 | ||
|
|
8b3c3d9fef | ||
|
|
2333afcd1e | ||
|
|
4062e92c17 | ||
|
|
7023817a71 | ||
|
|
c1fb7d0545 | ||
|
|
cab1b129c0 | ||
|
|
0f812e0686 | ||
|
|
171f12c2eb | ||
|
|
9cea43b006 | ||
|
|
15adc92e76 | ||
|
|
ba1e8f0005 | ||
|
|
3eaea74a51 | ||
|
|
d84eb9a97b | ||
|
|
48aef307f9 | ||
|
|
e5569f681a | ||
|
|
145a0b154e | ||
|
|
e99be23b57 | ||
|
|
5cc866e840 | ||
|
|
9e5b492db1 | ||
|
|
4cc2902b99 | ||
|
|
b09889643f | ||
|
|
4e88157ed7 | ||
|
|
5e1296a0b9 | ||
|
|
7ccd729cc5 | ||
|
|
0aed70153b | ||
|
|
bdd6a63556 | ||
|
|
e51ffb1bf1 | ||
|
|
52d5085360 | ||
|
|
1969c9ca47 | ||
|
|
83278d6f3b | ||
|
|
d4da9a3d7d | ||
|
|
99152b8300 | ||
|
|
4afbd4d3d9 | ||
|
|
e3e62a2211 | ||
|
|
8b7780d494 | ||
|
|
c38363c16d | ||
|
|
304a247dc8 | ||
|
|
8abef22a49 | ||
|
|
75ba81c79a | ||
|
|
222842b7d1 | ||
|
|
376a5a2aee | ||
|
|
47ee1d126d | ||
|
|
e179100afd | ||
|
|
44aa01b115 | ||
|
|
1a8ef852f1 | ||
|
|
e0bf3713ea | ||
|
|
eadaf37606 | ||
|
|
baae9bfb22 | ||
|
|
4f3dcd9e39 | ||
|
|
7a9e901e5e | ||
|
|
bb184f90a7 | ||
|
|
b2ad93be81 | ||
|
|
65cb89cc40 | ||
|
|
26a646aae5 | ||
|
|
7d5efaf124 | ||
|
|
0e8b152c68 | ||
|
|
028cc2cf49 | ||
|
|
249d519d02 | ||
|
|
e9aac23913 | ||
|
|
f4088d48a1 | ||
|
|
2c3285286c | ||
|
|
8e560f1d1c | ||
|
|
02460b4684 | ||
|
|
2a1461c754 | ||
|
|
ee8e6e806f | ||
|
|
cccfdc72c9 | ||
|
|
196482348d | ||
|
|
237605889f | ||
|
|
293f737e4a | ||
|
|
38ebfd7bd6 | ||
|
|
9ec2945e6e | ||
|
|
cb9ea22b6f | ||
|
|
8aec16376e | ||
|
|
7bc121e308 | ||
|
|
0f65004626 | ||
|
|
1fdc3875b6 | ||
|
|
bea8c7bea2 | ||
|
|
4ea9125902 | ||
|
|
ef877c4001 | ||
|
|
bb0a46db9e | ||
|
|
ae155e916a | ||
|
|
cd6a8952e6 | ||
|
|
726edab495 | ||
|
|
343557cf24 | ||
|
|
bc28cfe182 | ||
|
|
f47141a3b7 | ||
|
|
ea5be1f7a5 | ||
|
|
4960ca12fa | ||
|
|
b52a8f5a77 | ||
|
|
86eff05303 | ||
|
|
bd874d09ef | ||
|
|
04c710bec2 | ||
|
|
875fc72842 | ||
|
|
e4d6c0854b | ||
|
|
527765001c | ||
|
|
bd9aa8db68 | ||
|
|
7af8ca58d2 | ||
|
|
adbd8276cf | ||
|
|
beb2974317 | ||
|
|
e5c0bd7931 | ||
|
|
705f308ef7 | ||
|
|
f8ce46f127 | ||
|
|
039314c306 | ||
|
|
f42198fb3c | ||
|
|
7139639e77 | ||
|
|
f4ad1541bd | ||
|
|
49ecab6514 | ||
|
|
3104369d79 | ||
|
|
116b7bf672 | ||
|
|
7f44583a94 | ||
|
|
c482a3b699 | ||
|
|
bfe01476be | ||
|
|
981e90e4da | ||
|
|
9cf356f6f5 | ||
|
|
bbc4d2ccab | ||
|
|
f493417c3d | ||
|
|
8271b29e42 | ||
|
|
fad904d68b | ||
|
|
9738228d9c | ||
|
|
d072909451 | ||
|
|
c00e5050cc | ||
|
|
e6f8f8f7e9 | ||
|
|
9406ec49de | ||
|
|
603404775b | ||
|
|
f26cb793b1 | ||
|
|
31e2d4ac5a | ||
|
|
6eeadbdbef | ||
|
|
497ca14747 | ||
|
|
a2f8063804 | ||
|
|
adae0bd732 | ||
|
|
a1daad8d4f | ||
|
|
27d5165755 | ||
|
|
088b8ff69b | ||
|
|
58e5755780 | ||
|
|
04cbb5da75 | ||
|
|
300471da12 | ||
|
|
3c067502f3 | ||
|
|
63325cb976 | ||
|
|
3b25878d71 | ||
|
|
ba7b6a47c5 | ||
|
|
e36e6bbb96 | ||
|
|
b6382dce52 | ||
|
|
26583ae357 | ||
|
|
06fb20d02d | ||
|
|
c976b86714 | ||
|
|
a98474cab0 | ||
|
|
9f229d614e | ||
|
|
cb1642c7f6 | ||
|
|
59b49120b7 | ||
|
|
76b31d9269 | ||
|
|
c58a4026a7 | ||
|
|
b926b846b1 | ||
|
|
36e0b71602 | ||
|
|
8f3eb6cb31 | ||
|
|
7a798dcba9 | ||
|
|
813dcca29a | ||
|
|
7f6a234cf7 | ||
|
|
85853fce12 | ||
|
|
17ef5d6034 | ||
|
|
b9f330a158 | ||
|
|
35de9deb0a | ||
|
|
94a0c102a3 | ||
|
|
847fd8aa33 | ||
|
|
5afabb089d | ||
|
|
6425b9afaf | ||
|
|
f560767eb0 | ||
|
|
e79c24791f | ||
|
|
a26329b2a0 | ||
|
|
78a64cd79b | ||
|
|
1492c9fbc3 | ||
|
|
5b3e6e4714 | ||
|
|
0db4af22e0 | ||
|
|
3b0e1fbb79 | ||
|
|
1c92dac274 | ||
|
|
d389a03c15 | ||
|
|
dcdbf9df17 | ||
|
|
55afd95b20 | ||
|
|
5a32866b93 | ||
|
|
b8be1c8efd | ||
|
|
8447f551e7 | ||
|
|
6184c057db | ||
|
|
3d7bcd1f6a | ||
|
|
710d0667fa | ||
|
|
460bb9e5d0 | ||
|
|
1202e79a16 | ||
|
|
8dfabf0e19 | ||
|
|
a36065931d | ||
|
|
bd57e43446 | ||
|
|
16a6a4913e | ||
|
|
b0331f13f1 | ||
|
|
3a1b47435f | ||
|
|
a3c5ef6aa3 | ||
|
|
5c17c7d285 | ||
|
|
f41e8208d8 | ||
|
|
4feb9f9910 | ||
|
|
944eeb5921 | ||
|
|
f3785f10a2 | ||
|
|
6b0f3cd243 | ||
|
|
5110e7f0fd | ||
|
|
c643fe5274 | ||
|
|
790560ebf8 | ||
|
|
44458b0ba5 | ||
|
|
8c0b4a99cf | ||
|
|
01811ccf85 | ||
|
|
09a3eb8509 | ||
|
|
298df4d3aa | ||
|
|
c97eac34bf | ||
|
|
4a572311bc | ||
|
|
1dde2b5f1e | ||
|
|
d7a81affc2 | ||
|
|
91dae91769 | ||
|
|
8c03029f28 | ||
|
|
c26ad9fc36 | ||
|
|
0431b20945 | ||
|
|
4952ce0cac | ||
|
|
dc2d3c433d | ||
|
|
65539d44b8 | ||
|
|
9157c9a67b | ||
|
|
f645e51338 | ||
|
|
b93a3bca16 | ||
|
|
6e955bdf3f | ||
|
|
0978d0304f | ||
|
|
2b966b69ce | ||
|
|
864fe50b24 | ||
|
|
b6165844ed | ||
|
|
70142d147e | ||
|
|
e52b9825e3 | ||
|
|
93c186fea7 | ||
|
|
63ea907881 | ||
|
|
dead28e50e | ||
|
|
8446be6518 | ||
|
|
e61d299e63 | ||
|
|
a7c1ebacbe | ||
|
|
87af67febe | ||
|
|
f2adb64f3b | ||
|
|
d2abb569d4 | ||
|
|
ba88667d99 | ||
|
|
03ecd2fd3a | ||
|
|
a90db9a223 | ||
|
|
18c4a20ad4 | ||
|
|
31ee3f1923 | ||
|
|
d46b3f3627 | ||
|
|
51bd2727a0 | ||
|
|
5a62746dd3 | ||
|
|
9cad192ccb | ||
|
|
5d217295e5 | ||
|
|
e005d7021b | ||
|
|
db76533c16 | ||
|
|
04fd425fb6 | ||
|
|
3398e05190 | ||
|
|
286ac77a05 | ||
|
|
3b23e039e4 | ||
|
|
9aef148a44 | ||
|
|
0b35d394c5 | ||
|
|
79bd6a9b7d | ||
|
|
7da4bc46bf | ||
|
|
851dfb16be | ||
|
|
abb7fec598 | ||
|
|
04617b40b4 | ||
|
|
78de0c976a | ||
|
|
364250467f | ||
|
|
403788324a | ||
|
|
6e23e49f23 | ||
|
|
6595a32d90 | ||
|
|
2092909f21 | ||
|
|
25bcd12e92 | ||
|
|
68330843d8 | ||
|
|
993578dc2f | ||
|
|
6d97a5d543 | ||
|
|
495677ceb7 | ||
|
|
b5405a02cc | ||
|
|
655dea37dd | ||
|
|
e9d4e2cedd | ||
|
|
ddb07bcc0a | ||
|
|
06592a49c8 | ||
|
|
047014f2b5 | ||
|
|
7c8ef4cfc6 | ||
|
|
ec739b213d | ||
|
|
119e0caafb | ||
|
|
752aefbdfd | ||
|
|
3c749ec785 | ||
|
|
c7d6b6c0c4 | ||
|
|
7688a7653e | ||
|
|
55c6d16d69 | ||
|
|
d4ec544b25 | ||
|
|
e2dc38433e | ||
|
|
8b28aa8992 | ||
|
|
34fbcc9514 | ||
|
|
16c71fa102 | ||
|
|
4fa8a12bcb | ||
|
|
2373db06ec | ||
|
|
90aba582ec |
49
.github/POSTGRES_COMPATIBILITY.md
vendored
49
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -45,7 +45,9 @@ Flag a changed query that uses any of these:
|
||||
- **`HAVING` referencing a `SELECT` alias** — PostgreSQL rejects output-column aliases in
|
||||
`HAVING` (regardless of whether the query has a `GROUP BY`; MariaDB allows them). Repeat the
|
||||
underlying expression in `HAVING`, or move a non-aggregate predicate into `WHERE`.
|
||||
- **`SELECT DISTINCT … ORDER BY <expr not in the select list>`** — add the expr to the select.
|
||||
- **`SELECT DISTINCT … ORDER BY <expr not in the select list>`** — add the expr to the select
|
||||
**only if it is single-valued per distinct row**; otherwise it grows the `DISTINCT` key and the
|
||||
MariaDB row count (see §3) — drop the SQL `ORDER BY` and sort in Python instead.
|
||||
- **Single-quoted column alias** `AS 'x'` — PostgreSQL reads `'x'` as a string literal. Use an
|
||||
unquoted (or double-quoted) alias.
|
||||
- **`varchar | varchar`** (bitwise OR misused as a coalesce) — errors on PostgreSQL. Use
|
||||
@@ -119,7 +121,7 @@ These don't error, so a one-engine CI stays green. Flag them:
|
||||
|
||||
---
|
||||
|
||||
## 3. The `GROUP BY` row-count trap (the single most important rule)
|
||||
## 3. The row-count trap — `GROUP BY` **and** `DISTINCT` (the single most important rule)
|
||||
|
||||
When making a loose `GROUP BY` PostgreSQL-valid, **do not add a non-functionally-dependent
|
||||
column to the `GROUP BY` just to satisfy PostgreSQL** — that turns one group row into N and
|
||||
@@ -140,6 +142,14 @@ versa) to make a number "more correct" — that changes the MariaDB value. The w
|
||||
MariaDB's prior one-value-per-group output; a different aggregate is a product change, out of
|
||||
scope for a portability fix.
|
||||
|
||||
**The same trap applies to `SELECT DISTINCT`.** To satisfy PostgreSQL's "an `ORDER BY` expr must
|
||||
appear in the select list under `DISTINCT`" rule, **do not blindly add the ordered column to the
|
||||
select** — if it is not single-valued per existing distinct row, the `DISTINCT` key grows and
|
||||
MariaDB returns **more rows** (a regression), exactly as adding a non-FD column to `GROUP BY` does.
|
||||
Add it only when it is functionally dependent on the existing select columns; otherwise drop the
|
||||
SQL `ORDER BY` and **sort in Python** (`key=str.casefold`, per §2) so the distinct row set is
|
||||
unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 4. False positives — do NOT flag these
|
||||
@@ -167,17 +177,44 @@ These are auto-handled by the framework and are **not** breaks:
|
||||
frappe#40075). Such a handler must wrap the fallible insert in `frappe.db.savepoint(name)` +
|
||||
`rollback(save_point=name)` — unless it re-`throw`s with no DB call before the throw, or the
|
||||
insert uses `ignore_if_duplicate=True` / `autoname="hash"` (→ `ON CONFLICT DO NOTHING`).
|
||||
- **Recover the txn with a *scoped* savepoint, not a full `frappe.db.rollback()`, if any prior work
|
||||
must survive.** A full rollback un-poisons the txn but also discards every row the handler committed
|
||||
*before* the failure — which MariaDB kept (it has no statement-abort), so it's a **silent MariaDB
|
||||
regression**. **"The background job / whitelist entrypoint owns the txn" does NOT make a full rollback
|
||||
safe** if it did multiple inserts in a loop first — it drops the partial results MariaDB retained. A
|
||||
full rollback is safe only when it (a) immediately re-`throw`s/`raise`s (MariaDB rolls back anyway),
|
||||
(b) has nothing successful before it (a single op), or (c) the batch is genuinely meant to be
|
||||
**atomic** (a partial result is an invalid state → rollback + mark *Failed* is correct). Otherwise use
|
||||
a **per-iteration / per-record savepoint** — and keep the function's success/`None` return contract:
|
||||
do **not** return the doc when the savepoint was rolled back.
|
||||
|
||||
---
|
||||
|
||||
## 6. Refactors and raw-SQL→ORM conversions are not automatically 1:1
|
||||
|
||||
A commit labeled a **refactor** or a **raw-`frappe.db.sql` → `frappe.qb`/ORM conversion** is meant
|
||||
to preserve behaviour — but it easily doesn't, and the change passes the static checker and a
|
||||
one-engine green run. **Diff the `WHERE`/predicate, the `JOIN`/`ON` conditions, and the resulting
|
||||
row set — not just the `SELECT` shape.** A conversion that silently widens or narrows the filter
|
||||
changes the rows touched on **both** engines and is a regression hiding under a "refactor" label.
|
||||
|
||||
Real example: an `UPDATE` whose bound was `posting_datetime > X` gained an
|
||||
`OR (posting_datetime == X AND creation > args.creation)` branch during a "`sql` → `qb` refactor",
|
||||
widening the rows updated on both engines. Even when such a change is a deliberate bug-fix it must
|
||||
be called out and tested — it is **not** the no-op the refactor label implies. Confirm the
|
||||
converted query touches exactly the same rows with the same values MariaDB produced before.
|
||||
|
||||
---
|
||||
|
||||
## How to review
|
||||
|
||||
For every changed query: does it (a) use a construct from §1 (would error on PostgreSQL), or
|
||||
(b) match a divergence in §2/§3 (different result across engines)? If so, comment with the
|
||||
For every changed query: does it (a) use a construct from §1 (would error on PostgreSQL),
|
||||
(b) match a divergence in §2/§3 (different result across engines), or (c) change the row set under
|
||||
a refactor/conversion label (§6)? If so, comment with the
|
||||
portable fix and confirm it leaves **MariaDB output unchanged**. Skip the §4 false positives.
|
||||
Prefer a comment that names the rule (e.g. "loose GROUP BY — Max()-wrap, don't add to GROUP BY:
|
||||
splits the row count") so the fix is unambiguous.
|
||||
|
||||
The static pre-commit checker (`.github/helper/postgres_compat.py`) catches the *mechanical*
|
||||
§1 breaks; the **semantic** §2/§3 divergences are exactly what a reviewer (and this guide) must
|
||||
cover, because no static check can see them.
|
||||
§1 breaks; the **semantic** §2/§3 divergences and the §6 refactor/conversion row-set changes are
|
||||
exactly what a reviewer (and this guide) must cover, because no static check can see them.
|
||||
|
||||
12
.github/helper/install.sh
vendored
12
.github/helper/install.sh
vendored
@@ -297,14 +297,10 @@ if [ "$DB" == "postgres" ];then
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
|
||||
|
||||
# Disposable CI DB: durability off for speed (postgres fsyncs every commit by default, which
|
||||
# dominates a commit-heavy suite). All reloadable, no restart. The postgres workflow runs a
|
||||
# service-container DB and never calls start-db.sh, so the flags must be applied here.
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -U postgres \
|
||||
-c "ALTER SYSTEM SET synchronous_commit = 'off'" \
|
||||
-c "ALTER SYSTEM SET fsync = 'off'" \
|
||||
-c "ALTER SYSTEM SET full_page_writes = 'off'" \
|
||||
-c "SELECT pg_reload_conf()";
|
||||
# Durability-off for speed (no fsync/synchronous_commit/full_page_writes) is applied by
|
||||
# start-db.sh's postgres `-o` flags on every start — setup job AND each test shard — so it is
|
||||
# NOT repeated here. The postgres workflow runs in-runner via start-db.sh, not a service
|
||||
# container.
|
||||
fi
|
||||
|
||||
cd ~/frappe-bench || exit
|
||||
|
||||
7
.github/workflows/patch.yml
vendored
7
.github/workflows/patch.yml
vendored
@@ -66,7 +66,7 @@ jobs:
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
# The v14 baseline backup is a fixed published file — cache it instead of re-downloading
|
||||
# ~100MB from frappe.io every run.
|
||||
# it from the GitHub release every run.
|
||||
- name: Cache erpnext v14 backup
|
||||
id: cache-v14
|
||||
uses: actions/cache@v4
|
||||
@@ -76,7 +76,10 @@ jobs:
|
||||
|
||||
- name: Download erpnext v14 backup
|
||||
if: steps.cache-v14.outputs.cache-hit != 'true'
|
||||
run: wget -O ~/erpnext-v14.sql.gz https://frappe.io/files/erpnext-v14.sql.gz
|
||||
run: |
|
||||
curl -fSL --retry 5 --retry-all-errors --retry-delay 5 \
|
||||
-o ~/erpnext-v14.sql.gz \
|
||||
https://github.com/frappe/erpnext/releases/download/v14-baseline/erpnext-v14.sql.gz
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
|
||||
7
.github/workflows/server-tests-mariadb.yml
vendored
7
.github/workflows/server-tests-mariadb.yml
vendored
@@ -107,6 +107,13 @@ jobs:
|
||||
SKIP_SYSTEM_SETUP: "1"
|
||||
SKIP_WKHTMLTOX_SETUP: "1"
|
||||
|
||||
- name: Warm up test data
|
||||
run: |
|
||||
su -m "${ERPNEXT_CI_USER:-frappe}" -s /bin/bash <<'EOF'
|
||||
cd ~/frappe-bench/
|
||||
bench --site test_site run-tests --lightmode --module erpnext.tests.bootstrap_test_data
|
||||
EOF
|
||||
|
||||
# Clean shutdown (consistent InnoDB datadir), then stage it inside the bench for packaging.
|
||||
- name: Stop DB and stage datadir
|
||||
run: |
|
||||
|
||||
13
.github/workflows/server-tests-postgres.yml
vendored
13
.github/workflows/server-tests-postgres.yml
vendored
@@ -105,10 +105,19 @@ jobs:
|
||||
FRAPPE_BRANCH: develop
|
||||
BENCH_CACHE_DIR: /home/runner/bench-cache
|
||||
|
||||
- name: Warm up test data
|
||||
run: |
|
||||
cd ~/frappe-bench/
|
||||
bench --site test_site run-tests --lightmode --module erpnext.tests.bootstrap_test_data
|
||||
|
||||
- name: Stop DB and stage datadir
|
||||
run: |
|
||||
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin | sort -V | tail -1)
|
||||
"$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop || true
|
||||
# Clean shutdown so the baked datadir is consistent. Do NOT swallow a failed stop with
|
||||
# `|| true`: moving and tarring a still-running cluster ships a torn datadir the shards
|
||||
# cannot crash-recover (full_page_writes is off). Fail the job instead — mirrors the
|
||||
# MariaDB sister's "don't bake a dirty datadir" guard.
|
||||
"$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop
|
||||
mv /home/runner/pgdata /home/runner/frappe-bench/pgdata
|
||||
|
||||
- name: Package bench for test shards
|
||||
@@ -128,7 +137,7 @@ jobs:
|
||||
compression-level: 0
|
||||
|
||||
test:
|
||||
name: Python Unit Tests (PG)
|
||||
name: Python Unit Tests
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -14,35 +14,35 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"chrono-node": "^2.9.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"frappe-react-sdk": "^1.15.0",
|
||||
"frappe-react-sdk": "^1.17.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"jotai": "^2.20.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"jotai": "^2.20.1",
|
||||
"jotai-family": "^1.0.2",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lucide-react": "^1.14.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.6",
|
||||
"radix-ui": "^1.6.1",
|
||||
"react": "^19.2.7",
|
||||
"react-currency-input-field": "^4.0.5",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-hotkeys-hook": "^5.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.15.0",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-router": "^8.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"safe-expr-eval": "^1.0.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
@@ -51,15 +51,15 @@
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0"
|
||||
"typescript-eslint": "^8.62.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy, useEffect } from 'react'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
|
||||
import { FrappeProvider } from 'frappe-react-sdk'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import BankReconciliation from '@/pages/BankReconciliation'
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useFrappeGetCall, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
||||
@@ -26,6 +25,7 @@ import { Form } from "@/components/ui/form"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { DateField } from "@/components/ui/form-elements"
|
||||
import { Empty, EmptyMedia, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty"
|
||||
import MarkdownRenderer from "@/components/ui/markdown"
|
||||
|
||||
const BankClearanceSummary = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
@@ -203,14 +203,14 @@ const BankClearanceSummaryView = () => {
|
||||
[accountCurrency, bankAccount, companyID, mutate, onCopy],
|
||||
)
|
||||
|
||||
const content = _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useMultiFileUploadProgress } from "@/hooks/useMultiFileUploadProgress"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react"
|
||||
import { evaluateAmountFormula } from "@/lib/amountFormula"
|
||||
import { flt, formatCurrency } from "@/lib/numbers"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
@@ -215,38 +216,13 @@ const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: Unreconci
|
||||
})
|
||||
} else {
|
||||
|
||||
/**
|
||||
* The debit and credit amounts can also be expressions - like "transaction_amount * 0.5"
|
||||
* So we need to compute the value of the expression
|
||||
* We can use the eval function to do this. But we need to expose certain variables to the expression.
|
||||
* One of them is transaction_amount which is the unallocated amount of the selected transaction
|
||||
* @param expression - The expression to compute
|
||||
* @returns The computed value
|
||||
*/
|
||||
const computeExpression = (expression: string) => {
|
||||
|
||||
const script = `
|
||||
const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0}
|
||||
${expression};
|
||||
`
|
||||
|
||||
let value = 0;
|
||||
|
||||
try {
|
||||
value = window.eval(script);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
value = 0;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
const transactionAmount = selectedTransaction.unallocated_amount ?? 0
|
||||
if (!acc?.debit && !acc?.credit) {
|
||||
hasTotallyEmptyRowEarlier = true;
|
||||
}
|
||||
|
||||
const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0
|
||||
const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0
|
||||
const computedDebit = acc?.debit ? flt(evaluateAmountFormula(acc.debit, transactionAmount), 2) : 0
|
||||
const computedCredit = acc?.credit ? flt(evaluateAmountFormula(acc.credit, transactionAmount), 2) : 0
|
||||
|
||||
totalDebits = flt(totalDebits + computedDebit, 2)
|
||||
totalCredits = flt(totalCredits + computedCredit, 2)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useFrappeGetCall } from "frappe-react-sdk"
|
||||
@@ -19,6 +18,7 @@ import _ from "@/lib/translate"
|
||||
import { toast } from "sonner"
|
||||
import { useCopyToClipboard } from "usehooks-ts"
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import MarkdownRenderer from "@/components/ui/markdown"
|
||||
|
||||
const BankReconciliationStatement = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
@@ -189,14 +189,14 @@ const BankReconciliationStatementView = () => {
|
||||
return data.message.result.filter((row: BankClearanceSummaryEntry) => Boolean(row.payment_entry))
|
||||
}, [data])
|
||||
|
||||
const content = _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
|
||||
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
|
||||
@@ -23,6 +22,7 @@ import { useCallback, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import { Empty, EmptyTitle, EmptyHeader, EmptyMedia, EmptyDescription, EmptyContent } from "@/components/ui/empty"
|
||||
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
|
||||
import MarkdownRenderer from "@/components/ui/markdown"
|
||||
|
||||
const BankTransactions = () => {
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
@@ -243,14 +243,14 @@ const BankTransactionListView = () => {
|
||||
|
||||
}, [data, search, amountFilter, typeFilter, status])
|
||||
|
||||
const content = _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
|
||||
return <div className="space-y-2 py-2">
|
||||
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
</span>
|
||||
|
||||
<Button size='md' variant='subtle' asChild>
|
||||
<Link to="/statement-importer">
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
|
||||
@@ -18,6 +17,7 @@ import { PartyPopper } from "lucide-react"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import _ from "@/lib/translate"
|
||||
import { Empty, EmptyTitle, EmptyDescription, EmptyMedia, EmptyHeader } from "@/components/ui/empty"
|
||||
import MarkdownRenderer from "@/components/ui/markdown"
|
||||
|
||||
const IncorrectlyClearedEntries = () => {
|
||||
const companyID = useCurrentCompany()
|
||||
@@ -177,22 +177,22 @@ const IncorrectlyClearedEntriesView = () => {
|
||||
[accountCurrency, onClearClick],
|
||||
)
|
||||
|
||||
const content = _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
|
||||
|
||||
const entriesContent = _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
|
||||
}} />
|
||||
<span className="text-p-sm">
|
||||
<MarkdownRenderer content={content} />
|
||||
<br />
|
||||
{data && data.message.result.length > 0 && <span>
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
<MarkdownRenderer content={entriesContent} />
|
||||
<br />
|
||||
{_("You can reset the clearing dates of these entries here.")}
|
||||
</span>}
|
||||
</Paragraph>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { H4, Paragraph } from "@/components/ui/typography"
|
||||
import { today } from "@/lib/date"
|
||||
import { evaluateAmountFormula } from "@/lib/amountFormula"
|
||||
import _ from "@/lib/translate"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
|
||||
@@ -445,11 +446,10 @@ const AmountFormulaRenderer = ({ value }: { value?: string }) => {
|
||||
// If it's a string and cannot be a number, then show it as a formula
|
||||
|
||||
if (isNaN(Number(value))) {
|
||||
|
||||
let calculatedValue = "";
|
||||
|
||||
try {
|
||||
calculatedValue = window.eval(`const transaction_amount = 200; ${value}`);
|
||||
calculatedValue = String(evaluateAmountFormula(value ?? "", 200));
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
calculatedValue = "Error";
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { useFrappeEventListener, useFrappePostCall } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useSetAtom } from 'jotai'
|
||||
|
||||
26
banking/src/lib/amountFormula.ts
Normal file
26
banking/src/lib/amountFormula.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Parser } from 'safe-expr-eval'
|
||||
|
||||
const parser = new Parser()
|
||||
|
||||
const PLAIN_NUMBER_PATTERN = /^-?\d+(\.\d+)?$/
|
||||
|
||||
export function evaluateAmountFormula(expression: string, transactionAmount: number): number {
|
||||
const trimmed = expression.trim()
|
||||
if (!trimmed) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (PLAIN_NUMBER_PATTERN.test(trimmed)) {
|
||||
return Number(trimmed)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = parser.parse(trimmed).evaluate({ transaction_amount: transactionAmount })
|
||||
if (typeof result !== 'number' || !Number.isFinite(result)) {
|
||||
return 0
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
2655
banking/yarn.lock
2655
banking/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -179,7 +179,12 @@ def normalize_ctx_input(T: type) -> callable:
|
||||
|
||||
def decorator(func: callable):
|
||||
# conserve annotations for frappe.utils.typing_validations
|
||||
@functools.wraps(func, assigned=(a for a in functools.WRAPPER_ASSIGNMENTS if a != "__annotations__"))
|
||||
@functools.wraps(
|
||||
func,
|
||||
assigned=(
|
||||
a for a in functools.WRAPPER_ASSIGNMENTS if a not in ("__annotations__", "__annotate__")
|
||||
),
|
||||
)
|
||||
def wrapper(ctx: T | Document | dict | str, *args, **kwargs):
|
||||
if isinstance(ctx, Document):
|
||||
ctx = T(**ctx.as_dict())
|
||||
|
||||
@@ -582,6 +582,7 @@ def make_gl_entries(
|
||||
frappe.db.commit()
|
||||
except Exception as e:
|
||||
if frappe.in_test:
|
||||
frappe.db.rollback()
|
||||
doc.log_error(f"Error while processing deferred accounting for Invoice {doc.name}")
|
||||
raise e
|
||||
else:
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
"account_type": "Stock",
|
||||
"account_category": "Stock Assets"
|
||||
},
|
||||
"Stock Delivered But Not Billed": {
|
||||
"account_type": "Stock Delivered But Not Billed",
|
||||
"account_category": "Stock Assets"
|
||||
},
|
||||
"account_type": "Stock",
|
||||
"account_category": "Stock Assets"
|
||||
},
|
||||
@@ -223,10 +227,6 @@
|
||||
"Stock Received But Not Billed": {
|
||||
"account_type": "Stock Received But Not Billed",
|
||||
"account_category": "Trade Payables"
|
||||
},
|
||||
"Stock Delivered But Not Billed": {
|
||||
"account_type": "Stock Delivered But Not Billed",
|
||||
"account_category": "Trade Payables"
|
||||
}
|
||||
},
|
||||
"Duties and Taxes": {
|
||||
|
||||
@@ -829,7 +829,9 @@ def compute_final_transactions(transaction_rows: list, date_format: str, amount_
|
||||
|
||||
if amount_format == 'Amount column has "CR"/"DR" values':
|
||||
amount = transaction_row.get("amount")
|
||||
float_amount = get_float_amount(amount)
|
||||
|
||||
# If the amount column has CR/DR in it - we should remove any signs (negative or positive) from the amount
|
||||
float_amount = abs(get_float_amount(amount) or 0)
|
||||
if "cr" in amount.lower():
|
||||
return 0, float_amount
|
||||
else:
|
||||
@@ -932,14 +934,18 @@ def extract_pdf_tables(content: bytes, password: str | None = None) -> list[dict
|
||||
from pypdf import PdfReader
|
||||
|
||||
reader = PdfReader(io.BytesIO(content))
|
||||
if reader.is_encrypted and (not password or not reader.decrypt(password)):
|
||||
frappe.throw(
|
||||
_(
|
||||
"This PDF is password protected. Please set the correct statement password on the"
|
||||
" Bank Account and try again."
|
||||
),
|
||||
title=_("Password Required"),
|
||||
)
|
||||
if reader.is_encrypted:
|
||||
# Try opening the PDF with a password - if no password is provided, try with a blank password
|
||||
if not password:
|
||||
password = ""
|
||||
if not reader.decrypt(password):
|
||||
frappe.throw(
|
||||
_(
|
||||
"This PDF is password protected. Please set the correct statement password on the"
|
||||
" Bank Account and try again."
|
||||
),
|
||||
title=_("Password Required"),
|
||||
)
|
||||
|
||||
text_settings = {"vertical_strategy": "text", "horizontal_strategy": "text"}
|
||||
tables = []
|
||||
|
||||
@@ -47,6 +47,7 @@ def create_bank_entries(columns: str, data: str | list, bank_account: str):
|
||||
for key, value in header_map.items():
|
||||
fields.update({key: d[int(value) - 1]})
|
||||
|
||||
frappe.db.savepoint("bank_entry")
|
||||
try:
|
||||
bank_transaction = frappe.get_doc({"doctype": "Bank Transaction"})
|
||||
bank_transaction.update(fields)
|
||||
@@ -56,7 +57,8 @@ def create_bank_entries(columns: str, data: str | list, bank_account: str):
|
||||
bank_transaction.submit()
|
||||
success += 1
|
||||
except Exception:
|
||||
bank_transaction.log_error("Bank entry creation failed")
|
||||
frappe.db.rollback(save_point="bank_entry")
|
||||
frappe.log_error(title="Bank entry creation failed")
|
||||
errors += 1
|
||||
|
||||
return {"success": success, "errors": errors}
|
||||
|
||||
@@ -9,6 +9,48 @@ from frappe.model.document import Document
|
||||
|
||||
from erpnext.accounts.doctype.bank_transaction.bank_transaction import BankTransaction
|
||||
|
||||
PLAIN_NUMBER_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
|
||||
# Tokens accepted by safe-expr-eval on the frontend (must stay in sync).
|
||||
ALLOWED_FORMULA_TOKEN = re.compile(r"\s+|transaction_amount|\d+(?:\.\d+)?|[+\-*/%^()]")
|
||||
PYTHON_ONLY_OPERATORS = ("**", "//")
|
||||
|
||||
|
||||
def _is_expr_eval_formula(formula: str) -> bool:
|
||||
position = 0
|
||||
while position < len(formula):
|
||||
match = ALLOWED_FORMULA_TOKEN.match(formula, position)
|
||||
if not match:
|
||||
return False
|
||||
position = match.end()
|
||||
|
||||
return formula.count("(") == formula.count(")")
|
||||
|
||||
|
||||
def validate_amount_formula(formula: str) -> None:
|
||||
if not formula:
|
||||
return
|
||||
|
||||
stripped = formula.strip()
|
||||
if PLAIN_NUMBER_PATTERN.match(stripped):
|
||||
return
|
||||
|
||||
if any(operator in stripped for operator in PYTHON_ONLY_OPERATORS):
|
||||
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
|
||||
|
||||
if not _is_expr_eval_formula(stripped):
|
||||
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
|
||||
|
||||
# expr-eval uses ^ for exponentiation; translate for a smoke-test evaluation only.
|
||||
python_formula = stripped.replace("^", "**")
|
||||
|
||||
try:
|
||||
result = frappe.safe_eval(python_formula, eval_globals=None, eval_locals={"transaction_amount": 1})
|
||||
except Exception:
|
||||
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
|
||||
|
||||
if not isinstance(result, (int | float)):
|
||||
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
|
||||
|
||||
|
||||
class BankTransactionRule(Document):
|
||||
# begin: auto-generated types
|
||||
@@ -86,6 +128,11 @@ class BankTransactionRule(Document):
|
||||
frappe.throw(
|
||||
_("The last account row must not have any debit or credit amounts set.")
|
||||
)
|
||||
else:
|
||||
if account.debit:
|
||||
validate_amount_formula(account.debit)
|
||||
if account.credit:
|
||||
validate_amount_formula(account.credit)
|
||||
|
||||
# Validate regex
|
||||
for rule in self.description_rules:
|
||||
|
||||
@@ -231,3 +231,45 @@ class TestBankTransactionRule(ERPNextTestSuite, AccountsTestMixin):
|
||||
doc = self._rule("bad_rx", [{"check": "Regex", "value": "["}])
|
||||
with self.assertRaises(ValidationError):
|
||||
doc.insert()
|
||||
|
||||
def _multiple_accounts_rule(self, prefix: str, accounts, **fields):
|
||||
return self._rule(
|
||||
prefix,
|
||||
[{"check": "Contains", "value": "x"}],
|
||||
classify_as="Bank Entry",
|
||||
bank_entry_type="Multiple Accounts",
|
||||
accounts=accounts,
|
||||
**fields,
|
||||
)
|
||||
|
||||
def test_validate_bank_entry_multiple_valid_amount_formulas(self):
|
||||
doc = self._multiple_accounts_rule(
|
||||
"be_formula",
|
||||
accounts=[
|
||||
{"account": self.bank, "debit": "200", "credit": ""},
|
||||
{"account": self.cash, "debit": "", "credit": "transaction_amount * 0.25"},
|
||||
{"account": self.cash, "debit": "", "credit": ""},
|
||||
],
|
||||
)
|
||||
doc.insert()
|
||||
self.assertTrue(doc.name)
|
||||
|
||||
def test_validate_bank_entry_multiple_invalid_amount_formulas(self):
|
||||
malicious_formulas = [
|
||||
"__import__('os')",
|
||||
"eval('1+1')",
|
||||
"open('/etc/passwd')",
|
||||
"transaction_amount ** 2",
|
||||
"transaction_amount // 2",
|
||||
]
|
||||
for formula in malicious_formulas:
|
||||
with self.subTest(formula=formula):
|
||||
doc = self._multiple_accounts_rule(
|
||||
"be_bad_formula",
|
||||
accounts=[
|
||||
{"account": self.bank, "debit": formula, "credit": ""},
|
||||
{"account": self.cash, "debit": "", "credit": ""},
|
||||
],
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
doc.insert()
|
||||
|
||||
@@ -601,6 +601,7 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
|
||||
.select(gl.voucher_type, gl.voucher_no)
|
||||
.where(Criterion.all(conditions))
|
||||
.orderby(gl.posting_date, order=Order.desc)
|
||||
.orderby(gl.name, order=Order.desc)
|
||||
.limit(1)
|
||||
.run()[0]
|
||||
)
|
||||
@@ -615,6 +616,7 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
|
||||
(gl.voucher_type == voucher_type) & (gl.voucher_no == voucher_no) & (gl.account == account)
|
||||
)
|
||||
.orderby(gl.posting_date, order=Order.desc)
|
||||
.orderby(gl.name, order=Order.desc)
|
||||
.limit(1)
|
||||
.run()[0][0]
|
||||
)
|
||||
|
||||
@@ -107,6 +107,9 @@ def auto_create_fiscal_year():
|
||||
)
|
||||
|
||||
for d in fiscal_year:
|
||||
# savepoint so a duplicate-year INSERT (Fiscal Year autoname=field:year) that aborts the
|
||||
# statement doesn't poison the whole scheduler transaction on Postgres and kill the next iteration
|
||||
frappe.db.savepoint("auto_create_fiscal_year")
|
||||
try:
|
||||
current_fy = frappe.get_doc("Fiscal Year", d[0])
|
||||
|
||||
@@ -127,7 +130,7 @@ def auto_create_fiscal_year():
|
||||
|
||||
new_fy.insert(ignore_permissions=True)
|
||||
except frappe.NameError:
|
||||
pass
|
||||
frappe.db.rollback(save_point="auto_create_fiscal_year")
|
||||
|
||||
|
||||
def get_from_and_to_date(fiscal_year):
|
||||
|
||||
@@ -471,6 +471,25 @@ def on_doctype_update():
|
||||
frappe.db.add_index("GL Entry", ["posting_date", "company"])
|
||||
frappe.db.add_index("GL Entry", ["party_type", "party"])
|
||||
|
||||
if frappe.db.db_type == "postgres":
|
||||
# Postgres-only partial/covering indexes for the financial reports (General Ledger, Trial
|
||||
# Balance, Balance Sheet, P&L), which always filter `is_cancelled = 0` and scope by company.
|
||||
# `where`/`include` are no-ops on MariaDB and its optimizer ignores these anyway, so they are
|
||||
# added only on postgres to avoid dead write overhead on this insert-hot table.
|
||||
frappe.db.add_index(
|
||||
"GL Entry",
|
||||
["company", "posting_date", "account"],
|
||||
index_name="gle_active_detail",
|
||||
where="is_cancelled = 0",
|
||||
)
|
||||
frappe.db.add_index(
|
||||
"GL Entry",
|
||||
["company", "account", "posting_date"],
|
||||
index_name="gle_active_cover",
|
||||
where="is_cancelled = 0",
|
||||
include=["debit", "credit"],
|
||||
)
|
||||
|
||||
|
||||
def rename_gle_sle_docs():
|
||||
for doctype in ["GL Entry", "Stock Ledger Entry"]:
|
||||
|
||||
@@ -29,7 +29,7 @@ frappe.ui.form.on("Journal Entry", {
|
||||
|
||||
refresh(frm) {
|
||||
if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
|
||||
frm.set_read_only();
|
||||
erpnext.journal_entry.lock_reversal_entry(frm);
|
||||
}
|
||||
|
||||
erpnext.toggle_naming_series();
|
||||
@@ -232,6 +232,13 @@ Object.assign(erpnext.journal_entry, {
|
||||
}
|
||||
},
|
||||
|
||||
lock_reversal_entry(frm) {
|
||||
frm.fields
|
||||
.filter((field) => field.has_input)
|
||||
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
|
||||
frm.set_df_property("accounts", "read_only", 1);
|
||||
},
|
||||
|
||||
add_custom_buttons(frm) {
|
||||
if (frm.doc.docstatus > 0) {
|
||||
frm.add_custom_button(
|
||||
|
||||
@@ -65,6 +65,7 @@ def start_merge(docname):
|
||||
total = len(ledger_merge.merge_accounts)
|
||||
for row in ledger_merge.merge_accounts:
|
||||
if not row.merged:
|
||||
frappe.db.savepoint("ledger_merge_row")
|
||||
try:
|
||||
merge_account(
|
||||
row.account,
|
||||
@@ -79,8 +80,7 @@ def start_merge(docname):
|
||||
{"ledger_merge": ledger_merge.name, "current": successful_merges, "total": total},
|
||||
)
|
||||
except Exception:
|
||||
if not frappe.in_test:
|
||||
frappe.db.rollback()
|
||||
frappe.db.rollback(save_point="ledger_merge_row")
|
||||
ledger_merge.log_error("Ledger merge failed")
|
||||
finally:
|
||||
if successful_merges == total:
|
||||
|
||||
@@ -514,10 +514,12 @@ class PaymentEntry(AccountsController):
|
||||
invoice_names.add((ref.reference_doctype, ref.reference_name))
|
||||
|
||||
for doctype, name in invoice_names:
|
||||
frappe.db.savepoint("subscription_update")
|
||||
try:
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
doc.refresh_subscription_status()
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point="subscription_update")
|
||||
frappe.log_error(_("Failed to update subscription status for {0} {1}").format(doctype, name))
|
||||
|
||||
def set_missing_values(self):
|
||||
@@ -2793,6 +2795,9 @@ def get_open_payment_requests_for_references(references=None):
|
||||
.where(PR.docstatus == 1)
|
||||
.where(PR.outstanding_amount > 0) # to avoid old PRs with 0 outstanding amount
|
||||
.orderby(Coalesce(PR.transaction_date, PR.creation), order=frappe.qb.asc)
|
||||
# unique tiebreaker so PRs sharing a transaction_date allocate in the same order on both engines
|
||||
.orderby(PR.creation, order=frappe.qb.asc)
|
||||
.orderby(PR.name, order=frappe.qb.asc)
|
||||
).run(as_dict=True)
|
||||
|
||||
if not response:
|
||||
|
||||
@@ -360,12 +360,15 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
|
||||
self.make_period_closing_voucher(posting_date="2021-03-31")
|
||||
|
||||
# Passed posting_date is after PCV end date, so cancellation should not fail.
|
||||
make_reverse_gl_entries(
|
||||
voucher_type="Journal Entry",
|
||||
voucher_no=jv.name,
|
||||
posting_date="2022-01-01",
|
||||
)
|
||||
frappe.db.set_value("Company", "Test PCV Company", "accounts_frozen_till_date", "2021-12-31")
|
||||
|
||||
try:
|
||||
make_reverse_gl_entries(
|
||||
voucher_type="Journal Entry",
|
||||
voucher_no=jv.name,
|
||||
)
|
||||
finally:
|
||||
frappe.db.set_value("Company", "Test PCV Company", "accounts_frozen_till_date", None)
|
||||
|
||||
totals_after_cancel = frappe.get_all(
|
||||
"GL Entry",
|
||||
|
||||
@@ -663,7 +663,6 @@ class POSInvoice(SalesInvoice):
|
||||
def set_pos_fields(self, for_validate=False):
|
||||
"""Set retail related fields from POS Profiles"""
|
||||
from erpnext.stock.get_item_details import (
|
||||
ItemDetailsCtx,
|
||||
get_pos_profile,
|
||||
get_pos_profile_item_details_,
|
||||
)
|
||||
@@ -736,7 +735,7 @@ class POSInvoice(SalesInvoice):
|
||||
for item in self.get("items"):
|
||||
if item.get("item_code"):
|
||||
profile_details = get_pos_profile_item_details_(
|
||||
ItemDetailsCtx(item.as_dict()), profile.get("company"), profile
|
||||
frappe._dict(item.as_dict()), profile.get("company"), profile
|
||||
)
|
||||
for fname, val in profile_details.items():
|
||||
if (not for_validate) or (for_validate and not item.get(fname)):
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h2 class="text-center">{{ _("GENERAL LEDGER") }}</h2>
|
||||
<h2 class="text-center">{{ _("STATEMENT OF ACCOUNTS") }}</h2>
|
||||
<div>
|
||||
{% if filters.party[0] == filters.party_name[0] %}
|
||||
<h5 style="float: left;">{{ _("Customer: ") }} <b>{{ filters.party_name[0] }}</b></h5>
|
||||
|
||||
@@ -33,9 +33,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
|
||||
self.make_item_gl_entries(gl_entries)
|
||||
|
||||
disable_sdbnb_in_sr = frappe.get_cached_value("Company", doc.company, "disable_sdbnb_in_sr")
|
||||
disable_sdbnb_in_sr, is_sdbnb_enabled = frappe.get_cached_value(
|
||||
"Company", doc.company, ["disable_sdbnb_in_sr", "enable_stock_delivered_but_not_billed"]
|
||||
)
|
||||
|
||||
if not (doc.is_return and disable_sdbnb_in_sr):
|
||||
if is_sdbnb_enabled and not (doc.is_return and disable_sdbnb_in_sr):
|
||||
self.stock_delivered_but_not_billed_gl_entries(gl_entries)
|
||||
|
||||
self.make_precision_loss_gl_entry(gl_entries)
|
||||
|
||||
@@ -126,13 +126,13 @@ class POSService:
|
||||
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
|
||||
|
||||
def _apply_pos_item_defaults(self, pos, for_validate: bool) -> None:
|
||||
from erpnext.stock.get_item_details import ItemDetailsCtx, get_pos_profile_item_details_
|
||||
from erpnext.stock.get_item_details import get_pos_profile_item_details_
|
||||
|
||||
for item in self.doc.get("items"):
|
||||
if not item.get("item_code"):
|
||||
continue
|
||||
profile_details = get_pos_profile_item_details_(
|
||||
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
|
||||
frappe._dict(item.as_dict()), pos, pos, update_data=True
|
||||
)
|
||||
for fname, val in profile_details.items():
|
||||
if (not for_validate) or (for_validate and not item.get(fname)):
|
||||
|
||||
@@ -1576,14 +1576,14 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
|
||||
|
||||
def test_stock_delivered_but_not_billed_gl_on_invoice(self):
|
||||
company = "_Test Company with perpetual inventory"
|
||||
company = "_Test SDBNB Company"
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
make_purchase_receipt(
|
||||
company=company,
|
||||
item_code="_Test FG Item",
|
||||
warehouse="Stores - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
warehouse="Stores - _TSDBNB",
|
||||
cost_center="Main - _TSDBNB",
|
||||
qty=5,
|
||||
rate=100,
|
||||
)
|
||||
@@ -1591,13 +1591,13 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
dn = create_delivery_note(
|
||||
company=company,
|
||||
item_code="_Test FG Item",
|
||||
warehouse="Stores - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
warehouse="Stores - _TSDBNB",
|
||||
cost_center="Main - _TSDBNB",
|
||||
qty=2,
|
||||
rate=300,
|
||||
)
|
||||
# A perpetual-inventory Delivery Note books the cost to the SDBNB account
|
||||
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - TCP1")
|
||||
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - _TSDBNB")
|
||||
|
||||
si = make_sales_invoice(dn.name)
|
||||
si.insert()
|
||||
@@ -1609,9 +1609,9 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
fields=["account", "debit", "credit"],
|
||||
)
|
||||
sdbnb_credit = sum(
|
||||
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - TCP1"
|
||||
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - _TSDBNB"
|
||||
)
|
||||
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - TCP1")
|
||||
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - _TSDBNB")
|
||||
|
||||
# Billing reverses SDBNB and recognises the cost in COGS for an equal amount
|
||||
self.assertTrue(sdbnb_credit > 0)
|
||||
|
||||
@@ -640,13 +640,15 @@ def make_reverse_gl_entries(
|
||||
partial_cancel=partial_cancel,
|
||||
)
|
||||
validate_accounting_period(gl_entries)
|
||||
check_freezing_date(gl_entries[0]["posting_date"], gl_entries[0]["company"], adv_adj)
|
||||
|
||||
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
|
||||
|
||||
# For reverse entries, use the posting_date parameter if provided and valid
|
||||
# Otherwise fall back to original posting_date
|
||||
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
|
||||
if immutable_ledger_enabled:
|
||||
validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate()
|
||||
else:
|
||||
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
|
||||
|
||||
check_freezing_date(validation_date, gl_entries[0]["company"], adv_adj)
|
||||
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
|
||||
|
||||
if partial_cancel:
|
||||
@@ -715,7 +717,7 @@ def make_reverse_gl_entries(
|
||||
|
||||
if immutable_ledger_enabled:
|
||||
new_gle["is_cancelled"] = 0
|
||||
new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate()
|
||||
new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate()
|
||||
elif posting_date:
|
||||
new_gle["posting_date"] = posting_date
|
||||
|
||||
|
||||
@@ -174,7 +174,17 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
},
|
||||
|
||||
get_datatable_options(options) {
|
||||
return Object.assign(options, { checkboxColumn: true });
|
||||
return Object.assign(options, {
|
||||
checkboxColumn: true,
|
||||
events: {
|
||||
onCheckRow: () => erpnext.accounts.toggle_create_pe_primary_action(frappe.query_report),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
after_refresh: function (report) {
|
||||
report.datatable?.rowmanager?.checkAll(false);
|
||||
report.page.clear_primary_action();
|
||||
},
|
||||
|
||||
onload: function (report) {
|
||||
@@ -186,20 +196,27 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
if (frappe.boot.sysdefaults.default_ageing_range) {
|
||||
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
|
||||
}
|
||||
|
||||
if (frappe.model.can_create("Payment Entry")) {
|
||||
report.page.add_inner_button(
|
||||
__("Create Payment Entries"),
|
||||
function () {
|
||||
erpnext.accounts.create_payment_entries_from_payable_report(report);
|
||||
},
|
||||
__("Actions")
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
frappe.provide("erpnext.accounts");
|
||||
|
||||
erpnext.accounts.toggle_create_pe_primary_action = function (report) {
|
||||
if (!report || !report.datatable || !frappe.model.can_create("Payment Entry")) return;
|
||||
|
||||
const has_purchase_invoice = report.datatable.rowmanager
|
||||
.getCheckedRows()
|
||||
.some((i) => report.datatable.datamanager.data[i]?.voucher_type === "Purchase Invoice");
|
||||
|
||||
if (has_purchase_invoice) {
|
||||
report.page.set_primary_action(__("Create Payment Entries"), () =>
|
||||
erpnext.accounts.create_payment_entries_from_payable_report(report)
|
||||
);
|
||||
} else {
|
||||
report.page.clear_primary_action();
|
||||
}
|
||||
};
|
||||
|
||||
erpnext.accounts.create_payment_entries_from_payable_report = function (report) {
|
||||
const datatable = report.datatable;
|
||||
if (!datatable) return;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"generate_csv": 0,
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-06-25 12:03:36.559152",
|
||||
"modified": "2026-07-01 13:37:41.185347",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Payable",
|
||||
@@ -40,6 +40,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.report.accounts_payable_summary.accounts_payable_summary import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestAccountsPayableSummary(ERPNextTestSuite):
|
||||
"""Payable Summary is a thin wrapper over AccountsReceivableSummary with
|
||||
account_type=Payable; these tests lock the supplier-side output: invoiced,
|
||||
advance, paid, outstanding, ageing buckets and the optional GL-balance /
|
||||
future-payment columns."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
self.maxDiff = None
|
||||
self.company = "_Test Company"
|
||||
self.supplier = "_Test Supplier"
|
||||
|
||||
def _filters(self, **overrides):
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"supplier": self.supplier,
|
||||
"posting_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
filters.update(overrides)
|
||||
return filters
|
||||
|
||||
def _make_invoice(self, rate=200):
|
||||
return make_purchase_invoice(
|
||||
company=self.company,
|
||||
supplier=self.supplier,
|
||||
qty=1,
|
||||
rate=rate,
|
||||
price_list_rate=rate,
|
||||
posting_date=today(),
|
||||
)
|
||||
|
||||
def _expected_row(self, pi, **overrides):
|
||||
supplier_group = frappe.db.get_value("Supplier", self.supplier, "supplier_group")
|
||||
row = {
|
||||
"party_type": "Supplier",
|
||||
"advance": 0,
|
||||
"party": self.supplier,
|
||||
"invoiced": 200.0,
|
||||
"paid": 0.0,
|
||||
"credit_note": 0.0,
|
||||
"outstanding": 200.0,
|
||||
"range1": 200.0,
|
||||
"range2": 0.0,
|
||||
"range3": 0.0,
|
||||
"range4": 0.0,
|
||||
"range5": 0.0,
|
||||
"total_due": 200.0,
|
||||
"future_amount": 0.0,
|
||||
"sales_person": [],
|
||||
"currency": pi.currency,
|
||||
"supplier_group": supplier_group,
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
def test_01_payable_summary_output(self):
|
||||
"""Invoiced -> advance -> partial payment progression for a single supplier."""
|
||||
filters = self._filters()
|
||||
pi = self._make_invoice()
|
||||
|
||||
expected = self._expected_row(pi)
|
||||
rows = execute(filters)[1]
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertDictEqual(rows[0], expected)
|
||||
|
||||
# advance payment: pay 50 but allocate nothing against the invoice
|
||||
pe = get_payment_entry(pi.doctype, pi.name)
|
||||
pe.paid_amount = 50
|
||||
pe.references[0].allocated_amount = 0
|
||||
pe.save().submit()
|
||||
|
||||
expected.update({"advance": 50.0, "outstanding": 150.0, "range1": 150.0, "total_due": 150.0})
|
||||
rows = execute(filters)[1]
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertDictEqual(rows[0], expected)
|
||||
|
||||
# partial payment allocated against the invoice
|
||||
pe = get_payment_entry(pi.doctype, pi.name)
|
||||
pe.paid_amount = 125
|
||||
pe.references[0].allocated_amount = 125
|
||||
pe.save().submit()
|
||||
|
||||
expected.update(
|
||||
{"advance": 50.0, "paid": 125.0, "outstanding": 25.0, "range1": 25.0, "total_due": 25.0}
|
||||
)
|
||||
rows = execute(filters)[1]
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertDictEqual(rows[0], expected)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Buying Settings", {"supp_master_name": "Naming Series"})
|
||||
def test_02_gl_balance_and_future_payment_columns(self):
|
||||
"""Naming-series naming adds party_name; show_gl_balance / show_future_payments
|
||||
add their columns; a fully-paid invoice drops out of the report."""
|
||||
filters = self._filters()
|
||||
pi = self._make_invoice()
|
||||
|
||||
pe = get_payment_entry(pi.doctype, pi.name)
|
||||
pe.paid_amount = 150
|
||||
pe.references[0].allocated_amount = 150
|
||||
pe.save().submit()
|
||||
|
||||
expected = self._expected_row(
|
||||
pi,
|
||||
party_name=frappe.db.get_value("Supplier", self.supplier, "supplier_name"),
|
||||
paid=150.0,
|
||||
outstanding=50.0,
|
||||
range1=50.0,
|
||||
total_due=50.0,
|
||||
)
|
||||
rows = execute(filters)[1]
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertDictEqual(rows[0], expected)
|
||||
|
||||
# GL balance reconciliation columns
|
||||
filters.update({"show_gl_balance": True})
|
||||
expected.update({"gl_balance": 50.0, "diff": 0.0})
|
||||
rows = execute(filters)[1]
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertDictEqual(rows[0], expected)
|
||||
|
||||
# future payment columns
|
||||
filters.update({"show_future_payments": True})
|
||||
expected.update({"remaining_balance": 50.0})
|
||||
rows = execute(filters)[1]
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertDictEqual(rows[0], expected)
|
||||
|
||||
# clear the remaining balance -> supplier drops out of the summary entirely
|
||||
get_payment_entry(pi.doctype, pi.name).save().submit()
|
||||
rows = execute(filters)[1]
|
||||
self.assertEqual(len(rows), 0)
|
||||
@@ -17,7 +17,7 @@
|
||||
"generate_csv": 0,
|
||||
"idx": 5,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-06-25 12:03:28.812092",
|
||||
"modified": "2026-07-01 13:37:44.167999",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Receivable",
|
||||
@@ -34,6 +34,6 @@
|
||||
"role": "Accounts User"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency):
|
||||
return chart
|
||||
|
||||
|
||||
def execute_synced_report(filters):
|
||||
def execute_snapshot_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if not (conn := get_latest_sync("GL Entry")):
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.report.bank_clearance_summary.bank_clearance_summary import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
BANK_ACCOUNT = "_Test Bank - _TC"
|
||||
|
||||
|
||||
class TestBankClearanceSummary(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"account": BANK_ACCOUNT,
|
||||
"company": "_Test Company",
|
||||
"from_date": "2026-01-01",
|
||||
"to_date": "2026-12-31",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def find_row(self, data, payment_entry):
|
||||
for row in data:
|
||||
if row[1] == payment_entry:
|
||||
return row
|
||||
return None
|
||||
|
||||
def test_uncleared_then_cleared_journal_entry(self):
|
||||
je = make_journal_entry(BANK_ACCOUNT, "Sales - _TC", 5000, submit=True, posting_date="2026-06-01")
|
||||
|
||||
# Uncleared: the bank row appears with the debit amount and no clearance date
|
||||
row = self.find_row(self.run_report(), je.name)
|
||||
self.assertIsNotNone(row, "Journal Entry not listed in Bank Clearance Summary")
|
||||
self.assertEqual(row[0], "Journal Entry")
|
||||
self.assertEqual(frappe.utils.getdate(row[2]), frappe.utils.getdate("2026-06-01"))
|
||||
self.assertIsNone(row[4]) # clearance_date empty -> uncleared
|
||||
self.assertEqual(row[5], "Sales - _TC") # against account
|
||||
self.assertEqual(row[6], 5000) # debit - credit on the bank account
|
||||
|
||||
# Cleared: set the clearance date on the Journal Entry and re-run
|
||||
frappe.db.set_value("Journal Entry", je.name, "clearance_date", "2026-06-05")
|
||||
|
||||
row = self.find_row(self.run_report(), je.name)
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(frappe.utils.getdate(row[4]), frappe.utils.getdate("2026-06-05"))
|
||||
self.assertEqual(row[6], 5000)
|
||||
|
||||
def test_date_filter_excludes_out_of_range_entries(self):
|
||||
je = make_journal_entry(BANK_ACCOUNT, "Sales - _TC", 3000, submit=True, posting_date="2026-06-10")
|
||||
|
||||
# Within range: present
|
||||
self.assertIsNotNone(self.find_row(self.run_report(), je.name))
|
||||
|
||||
# Window entirely after the posting date (from_date lower bound): excluded
|
||||
after = self.run_report(from_date="2026-07-01", to_date="2026-12-31")
|
||||
self.assertIsNone(self.find_row(after, je.name))
|
||||
|
||||
# Window ending before the posting date (to_date upper bound): excluded
|
||||
before = self.run_report(from_date="2026-01-01", to_date="2026-06-09")
|
||||
self.assertIsNone(self.find_row(before, je.name))
|
||||
@@ -31,7 +31,7 @@ def get_report_filters(report_filters):
|
||||
]
|
||||
|
||||
if report_filters.get("purchase_invoice"):
|
||||
filters.append(["Purchase Invoice", "per_received", "in", [report_filters.get("purchase_invoice")]])
|
||||
filters.append(["Purchase Invoice", "name", "=", report_filters.get("purchase_invoice")])
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.report.billed_items_to_be_received.billed_items_to_be_received import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestBilledItemsToBeReceived(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"posting_date": today(),
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def get_rows_for(self, data, pi_name):
|
||||
return [row for row in data if row.get("name") == pi_name]
|
||||
|
||||
def test_billed_but_not_received_item_appears(self):
|
||||
pi = make_purchase_invoice(
|
||||
supplier="_Test Supplier",
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
update_stock=0,
|
||||
)
|
||||
|
||||
rows = self.get_rows_for(self.run_report(), pi.name)
|
||||
self.assertEqual(len(rows), 1)
|
||||
|
||||
row = rows[0]
|
||||
self.assertEqual(row.get("supplier"), "_Test Supplier")
|
||||
self.assertEqual(row.get("company"), "_Test Company")
|
||||
self.assertEqual(row.get("item_code"), "_Test Item")
|
||||
self.assertEqual(row.get("qty"), 5)
|
||||
self.assertEqual(row.get("received_qty"), 0)
|
||||
self.assertEqual(row.get("rate"), 200)
|
||||
self.assertEqual(row.get("amount"), 1000)
|
||||
|
||||
def test_stock_updating_invoice_is_excluded(self):
|
||||
"""update_stock=1 means the item is already received; it must not appear."""
|
||||
pi = make_purchase_invoice(
|
||||
supplier="_Test Supplier",
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
update_stock=1,
|
||||
)
|
||||
|
||||
rows = self.get_rows_for(self.run_report(), pi.name)
|
||||
self.assertEqual(len(rows), 0)
|
||||
|
||||
def test_fully_received_invoice_drops_off(self):
|
||||
"""When per_received reaches 100 the invoice is fully received and drops off."""
|
||||
pi = make_purchase_invoice(
|
||||
supplier="_Test Supplier",
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
update_stock=0,
|
||||
)
|
||||
|
||||
# Present while nothing has been received.
|
||||
self.assertEqual(len(self.get_rows_for(self.run_report(), pi.name)), 1)
|
||||
|
||||
frappe.db.set_value("Purchase Invoice", pi.name, "per_received", 100)
|
||||
|
||||
# Absent once fully received.
|
||||
self.assertEqual(len(self.get_rows_for(self.run_report(), pi.name)), 0)
|
||||
|
||||
def test_posting_date_upper_bound_filter(self):
|
||||
"""A PI posted after the filter's posting_date must be excluded."""
|
||||
pi = make_purchase_invoice(
|
||||
supplier="_Test Supplier",
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
update_stock=0,
|
||||
)
|
||||
|
||||
rows = self.get_rows_for(self.run_report(posting_date="2000-01-01"), pi.name)
|
||||
self.assertEqual(len(rows), 0)
|
||||
|
||||
def test_purchase_invoice_filter_scopes_to_that_invoice(self):
|
||||
"""The optional purchase_invoice filter must narrow to that invoice only."""
|
||||
pi = make_purchase_invoice(
|
||||
supplier="_Test Supplier", item_code="_Test Item", qty=5, rate=200, update_stock=0
|
||||
)
|
||||
other = make_purchase_invoice(
|
||||
supplier="_Test Supplier", item_code="_Test Item", qty=3, rate=200, update_stock=0
|
||||
)
|
||||
|
||||
names = {row.get("name") for row in self.run_report(purchase_invoice=pi.name)}
|
||||
self.assertEqual(names, {pi.name})
|
||||
self.assertNotIn(other.name, names)
|
||||
@@ -2,26 +2,116 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import nowdate
|
||||
|
||||
from erpnext.accounts.doctype.budget.test_budget import make_budget, set_total_expense_zero
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.report.budget_variance_report.budget_variance_report import execute
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
ACCOUNT = "_Test Account Cost for Goods Sold - _TC"
|
||||
COST_CENTER = "_Test Cost Center - _TC"
|
||||
COST_CENTER_2 = "_Test Cost Center 2 - _TC"
|
||||
|
||||
|
||||
class TestBudgetVarianceReport(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.fy = get_fiscal_year(nowdate())[0]
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"from_fiscal_year": self.fy,
|
||||
"to_fiscal_year": self.fy,
|
||||
"period": "Yearly",
|
||||
"budget_against": "Cost Center",
|
||||
**extra,
|
||||
}
|
||||
)
|
||||
return execute(filters)[1]
|
||||
|
||||
def report_row(self, data, dimension, account=ACCOUNT):
|
||||
row = next(
|
||||
(r for r in data if r["budget_against"] == dimension and r["account"] == account),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(row, f"No report row for {dimension} / {account}")
|
||||
return row
|
||||
|
||||
def field(self, label):
|
||||
return frappe.scrub(f"{label} {self.fy}")
|
||||
|
||||
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,
|
||||
"company": "_Test Company",
|
||||
"from_fiscal_year": self.fy,
|
||||
"to_fiscal_year": self.fy,
|
||||
"period": "Yearly",
|
||||
"budget_against": "Cost Center",
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertTrue(columns)
|
||||
|
||||
def test_budget_amount_shown_with_zero_actual(self):
|
||||
# neutralise any committed actuals so the exact Actual/Variance assertions hold
|
||||
set_total_expense_zero(nowdate(), "cost_center")
|
||||
make_budget(
|
||||
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
|
||||
)
|
||||
|
||||
row = self.report_row(self.run_report(), COST_CENTER)
|
||||
self.assertEqual(row[self.field("Budget")], 120000)
|
||||
self.assertEqual(row[self.field("Actual")], 0)
|
||||
self.assertEqual(row[self.field("Variance")], 120000)
|
||||
|
||||
def test_actual_expense_updates_actual_and_variance(self):
|
||||
# zero out pre-committed actuals: keeps Actual exact and avoids the budget's
|
||||
# "Stop" action rejecting the journal entry when prior actuals already exist
|
||||
set_total_expense_zero(nowdate(), "cost_center")
|
||||
make_budget(
|
||||
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
|
||||
)
|
||||
# book an actual expense well within the annual budget so the "Stop" action does not block it
|
||||
make_journal_entry(ACCOUNT, "_Test Bank - _TC", 50000, cost_center=COST_CENTER, submit=True)
|
||||
|
||||
row = self.report_row(self.run_report(), COST_CENTER)
|
||||
self.assertEqual(row[self.field("Actual")], 50000)
|
||||
self.assertEqual(row[self.field("Variance")], 70000) # 120000 - 50000
|
||||
|
||||
def test_budget_against_filter_limits_dimensions(self):
|
||||
make_budget(
|
||||
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
|
||||
)
|
||||
make_budget(
|
||||
budget_against="Cost Center", cost_center=COST_CENTER_2, budget_amount=80000, submit_budget=1
|
||||
)
|
||||
|
||||
data = self.run_report(budget_against_filter=[COST_CENTER])
|
||||
dimensions = {row["budget_against"] for row in data}
|
||||
self.assertEqual(dimensions, {COST_CENTER})
|
||||
|
||||
def test_monthly_period_totals(self):
|
||||
# zero out pre-committed actuals so total_actual reflects only this test's entry
|
||||
set_total_expense_zero(nowdate(), "cost_center")
|
||||
make_budget(
|
||||
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
|
||||
)
|
||||
make_journal_entry(ACCOUNT, "_Test Bank - _TC", 50000, cost_center=COST_CENTER, submit=True)
|
||||
|
||||
row = self.report_row(self.run_report(period="Monthly"), COST_CENTER)
|
||||
# totals roll up the per-month columns across the year
|
||||
self.assertEqual(row["total_budget"], 120000)
|
||||
self.assertEqual(row["total_actual"], 50000)
|
||||
self.assertEqual(row["total_variance"], 70000)
|
||||
|
||||
def test_no_budget_returns_no_rows(self):
|
||||
# a dimension without any budget produces no report rows
|
||||
data = self.run_report(budget_against_filter=["_Test Write Off Cost Center - _TC"])
|
||||
self.assertEqual(data, [])
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe.utils.formatters import format_value
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.calculated_discount_mismatch.calculated_discount_mismatch import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestCalculatedDiscountMismatch(ERPNextTestSuite):
|
||||
"""Integrity detector: flag transactions whose stored ``discount_amount`` was tampered
|
||||
after the fact (a Version records the change) while ``additional_discount_percentage``
|
||||
stayed the same, so the stored amount no longer matches the percentage-derived value.
|
||||
"""
|
||||
|
||||
def run_report(self, docname: str) -> dict | None:
|
||||
"""Run the (filter-less) report and return the row for ``docname``, if any."""
|
||||
_columns, data = execute(frappe._dict({}))
|
||||
return next((row for row in data if row["docname"] == docname), None)
|
||||
|
||||
def create_discounted_invoice(self) -> "frappe.Document":
|
||||
"""Draft Sales Invoice (rate 1000) with a 10% additional discount.
|
||||
|
||||
The controller derives ``discount_amount`` = 10% of the grand total = 100.00,
|
||||
so the stored amount is consistent with the percentage.
|
||||
"""
|
||||
invoice = create_sales_invoice(rate=1000, qty=1, do_not_submit=1)
|
||||
invoice.additional_discount_percentage = 10
|
||||
invoice.save()
|
||||
invoice.reload()
|
||||
return invoice
|
||||
|
||||
def test_consistent_discount_is_not_flagged(self):
|
||||
"""A submitted invoice whose discount_amount matches its percentage is not reported."""
|
||||
invoice = self.create_discounted_invoice()
|
||||
invoice.submit()
|
||||
invoice.reload()
|
||||
|
||||
self.assertEqual(invoice.discount_amount, 100.0)
|
||||
self.assertIsNone(self.run_report(invoice.name))
|
||||
|
||||
def test_tampered_discount_is_flagged(self):
|
||||
"""Directly overwriting discount_amount (leaving the percentage intact) is reported.
|
||||
|
||||
This reproduces the real-world integrity breach: a Version records the
|
||||
``discount_amount`` change, its ``new`` value equals the current stored amount, and
|
||||
``additional_discount_percentage`` was not touched -- exactly the shape the report
|
||||
queries for.
|
||||
"""
|
||||
invoice = self.create_discounted_invoice()
|
||||
consistent_amount = invoice.discount_amount # 100.00, matches the 10% percentage
|
||||
tampered_amount = 250.0
|
||||
|
||||
discount_field = frappe.get_meta("Sales Invoice").get_field("discount_amount")
|
||||
# Format exactly as the report does so version.new == format_value(current amount).
|
||||
suspected = format_value(consistent_amount, df=discount_field, currency=invoice.currency)
|
||||
actual = format_value(tampered_amount, df=discount_field, currency=invoice.currency)
|
||||
|
||||
# Tamper the stored amount directly, bypassing the controller that would recompute it.
|
||||
frappe.db.set_value("Sales Invoice", invoice.name, "discount_amount", tampered_amount)
|
||||
self.record_discount_change(invoice.name, suspected, actual)
|
||||
|
||||
row = self.run_report(invoice.name)
|
||||
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row["doctype"], "Sales Invoice")
|
||||
self.assertEqual(row["actual_discount_percentage"], 10.0)
|
||||
self.assertEqual(row["actual_discount_amount"], actual)
|
||||
self.assertEqual(row["suspected_discount_amount"], suspected)
|
||||
|
||||
def record_discount_change(self, docname: str, old: str, new: str) -> None:
|
||||
"""Insert the Version audit row a direct discount_amount edit would have produced."""
|
||||
version = frappe.new_doc("Version")
|
||||
version.ref_doctype = "Sales Invoice"
|
||||
version.docname = docname
|
||||
version.data = json.dumps({"changed": [["discount_amount", old, new]]}, separators=(",", ":"))
|
||||
version.flags.ignore_version = True
|
||||
version.insert(ignore_permissions=True)
|
||||
@@ -582,7 +582,12 @@ def prepare_data(accounts, start_date, end_date, balance_must_be, companies, com
|
||||
total += flt(row[company])
|
||||
|
||||
row["has_value"] = has_value
|
||||
row["total"] = total
|
||||
# when accumulating into the group company, that company's column already consolidates its
|
||||
# descendants, so summing every company column would double-count; use the group total directly.
|
||||
if filters.get("accumulated_in_group_company"):
|
||||
row["total"] = flt(row.get(filters.company, 0.0), 3)
|
||||
else:
|
||||
row["total"] = total
|
||||
|
||||
data.append(row)
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt, today
|
||||
|
||||
from erpnext.accounts.report.consolidated_financial_statement.consolidated_financial_statement import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
PARENT_COMPANY = "Parent Group Company India"
|
||||
CHILD_COMPANY = "Child Company India"
|
||||
|
||||
|
||||
class TestConsolidatedFinancialStatement(ERPNextTestSuite):
|
||||
"""Consolidation is exercised via the bootstrap group of companies
|
||||
(`Parent Group Company India` with child `Child Company India`). Income and
|
||||
expense posted in the child company must surface in the report that is run
|
||||
for the parent (group) company."""
|
||||
|
||||
def setUp(self):
|
||||
self.fiscal_year = get_fiscal_year(today(), company=PARENT_COMPANY)[0]
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": PARENT_COMPANY,
|
||||
"filter_based_on": "Fiscal Year",
|
||||
"from_fiscal_year": self.fiscal_year,
|
||||
"to_fiscal_year": self.fiscal_year,
|
||||
"periodicity": "Yearly",
|
||||
"include_default_book_entries": 1,
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def post_journal_entry(self, debit_account, credit_account, amount):
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.posting_date = today()
|
||||
je.company = CHILD_COMPANY
|
||||
je.set(
|
||||
"accounts",
|
||||
[
|
||||
{"account": debit_account, "debit_in_account_currency": amount},
|
||||
{"account": credit_account, "credit_in_account_currency": amount},
|
||||
],
|
||||
)
|
||||
je.save()
|
||||
je.submit()
|
||||
return je
|
||||
|
||||
def get_row(self, data, account_name_fragment, last_match=False):
|
||||
"""Return the first (or last) row whose account_name contains the fragment.
|
||||
|
||||
Pass ``last_match=True`` to get the leaf/most-specific match when the fragment
|
||||
is also a prefix of a parent group account (parents precede children in tree order).
|
||||
"""
|
||||
found = None
|
||||
for row in data:
|
||||
if account_name_fragment in str(row.get("account_name") or ""):
|
||||
if not last_match:
|
||||
return row
|
||||
found = row
|
||||
return found
|
||||
|
||||
def test_profit_and_loss_reflects_child_company_income(self):
|
||||
amount = 7000
|
||||
self.post_journal_entry("Cash - CCI", "Sales - CCI", amount)
|
||||
|
||||
data = self.run_report(report="Profit and Loss Statement", accumulated_in_group_company=0)
|
||||
|
||||
self.assertTrue(data, "Report returned no rows")
|
||||
|
||||
# child's Sales account is mapped onto the parent chart (Sales - PGCI)
|
||||
sales_row = self.get_row(data, "Sales", last_match=True)
|
||||
self.assertIsNotNone(sales_row, "Sales row missing from consolidated P&L")
|
||||
# >= so a pre-existing Sales balance in the fiscal year doesn't make this brittle
|
||||
self.assertGreaterEqual(flt(sales_row.get(CHILD_COMPANY)), amount)
|
||||
|
||||
total_income_row = self.get_row(data, "Total Income (Credit)")
|
||||
self.assertIsNotNone(total_income_row, "Total Income row missing")
|
||||
self.assertGreaterEqual(flt(total_income_row.get("total")), amount)
|
||||
|
||||
def test_profit_and_loss_reflects_child_company_expense(self):
|
||||
amount = 3000
|
||||
self.post_journal_entry("Marketing Expenses - CCI", "Cash - CCI", amount)
|
||||
|
||||
data = self.run_report(report="Profit and Loss Statement", accumulated_in_group_company=0)
|
||||
|
||||
expense_row = self.get_row(data, "Marketing Expenses", last_match=True)
|
||||
self.assertIsNotNone(expense_row, "Marketing Expenses row missing from consolidated P&L")
|
||||
self.assertGreaterEqual(flt(expense_row.get(CHILD_COMPANY)), amount)
|
||||
|
||||
total_expense_row = self.get_row(data, "Total Expense (Debit)")
|
||||
self.assertIsNotNone(total_expense_row, "Total Expense row missing")
|
||||
self.assertGreaterEqual(flt(total_expense_row.get("total")), amount)
|
||||
|
||||
def test_accumulated_in_group_company_rolls_up_to_parent(self):
|
||||
"""With `accumulated_in_group_company`, the child's amount is also
|
||||
accumulated into the parent company column."""
|
||||
amount = 5000
|
||||
self.post_journal_entry("Cash - CCI", "Sales - CCI", amount)
|
||||
|
||||
data = self.run_report(report="Profit and Loss Statement", accumulated_in_group_company=1)
|
||||
|
||||
sales_row = self.get_row(data, "Sales", last_match=True)
|
||||
self.assertIsNotNone(sales_row)
|
||||
child_value = flt(sales_row.get(CHILD_COMPANY))
|
||||
self.assertGreaterEqual(child_value, amount)
|
||||
# parent column picks up the child value when accumulated
|
||||
self.assertEqual(flt(sales_row.get(PARENT_COMPANY)), child_value)
|
||||
# the total equals the consolidated (group) value, not the sum of parent + child
|
||||
# columns -- this is the regression guard for the double-count fix
|
||||
self.assertEqual(flt(sales_row.get("total")), child_value)
|
||||
|
||||
def test_balance_sheet_executes_and_returns_rows(self):
|
||||
# posting income leaves a balancing entry in the child's Cash (Asset) account
|
||||
amount = 4000
|
||||
self.post_journal_entry("Cash - CCI", "Sales - CCI", amount)
|
||||
|
||||
data = self.run_report(report="Balance Sheet", accumulated_in_group_company=0)
|
||||
|
||||
self.assertTrue(data, "Balance Sheet returned no rows")
|
||||
cash_row = self.get_row(data, "Cash")
|
||||
self.assertIsNotNone(cash_row, "Cash asset row missing from consolidated Balance Sheet")
|
||||
self.assertGreaterEqual(flt(cash_row.get(CHILD_COMPANY)), amount)
|
||||
@@ -0,0 +1,94 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.report.custom_financial_statement.custom_financial_statement import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestCustomFinancialStatement(ERPNextTestSuite):
|
||||
"""The report renders a Financial Report Template through FinancialReportEngine.
|
||||
These tests exercise its own entry point: a template with an account-data row
|
||||
and a calculated row, and the guard that returns nothing without a template."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
self.company = "_Test Company"
|
||||
self.expense_account = "_Test Account Cost for Goods Sold - _TC"
|
||||
self.cash_account = "Cash - _TC"
|
||||
|
||||
def _make_template(self):
|
||||
# rows filter by exact account name so the value is isolated from other data
|
||||
template_name = f"Test Custom FS {frappe.generate_hash()[:8]}"
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "Financial Report Template",
|
||||
"template_name": template_name,
|
||||
"report_type": "Profit and Loss Statement",
|
||||
"rows": [
|
||||
{
|
||||
"reference_code": "EXP",
|
||||
"display_name": "Test Expense",
|
||||
"indentation_level": 0,
|
||||
"data_source": "Account Data",
|
||||
"balance_type": "Closing Balance",
|
||||
"calculation_formula": f'["name", "=", "{self.expense_account}"]',
|
||||
},
|
||||
{
|
||||
"reference_code": "EXP_X2",
|
||||
"display_name": "Expense Doubled",
|
||||
"indentation_level": 0,
|
||||
"data_source": "Calculated Amount",
|
||||
"calculation_formula": "EXP * 2",
|
||||
},
|
||||
],
|
||||
}
|
||||
).insert()
|
||||
|
||||
def _filters(self, template_name):
|
||||
return frappe._dict(
|
||||
{
|
||||
"company": self.company,
|
||||
"report_template": template_name,
|
||||
"from_fiscal_year": "2024",
|
||||
"to_fiscal_year": "2024",
|
||||
"period_start_date": "2024-01-01",
|
||||
"period_end_date": "2024-12-31",
|
||||
"filter_based_on": "Date Range",
|
||||
"periodicity": "Yearly",
|
||||
"accumulated_values": 0,
|
||||
}
|
||||
)
|
||||
|
||||
def test_account_and_calculated_rows(self):
|
||||
make_journal_entry(
|
||||
self.expense_account,
|
||||
self.cash_account,
|
||||
2000,
|
||||
posting_date="2024-06-15",
|
||||
company=self.company,
|
||||
submit=True,
|
||||
)
|
||||
template = self._make_template()
|
||||
|
||||
columns, data = execute(self._filters(template.template_name))[:2]
|
||||
self.assertTrue(columns)
|
||||
|
||||
rows = {row.get("account_name"): row for row in data}
|
||||
self.assertIn("Test Expense", rows)
|
||||
self.assertIn("Expense Doubled", rows)
|
||||
|
||||
period_keys = rows["Test Expense"].get("_segment_info", {}).get("period_keys", [])
|
||||
self.assertTrue(period_keys, "expected at least one period key in _segment_info")
|
||||
period_key = period_keys[0]
|
||||
|
||||
# the account-data row picks up the posted expense; the calculated row doubles it
|
||||
self.assertEqual(flt(rows["Test Expense"][period_key]), 2000.0)
|
||||
self.assertEqual(flt(rows["Expense Doubled"][period_key]), 4000.0)
|
||||
|
||||
def test_no_template_returns_nothing(self):
|
||||
"""Without a report_template the report short-circuits and returns None."""
|
||||
self.assertIsNone(execute(frappe._dict({"company": self.company})))
|
||||
@@ -0,0 +1,89 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.report.delivered_items_to_be_billed.delivered_items_to_be_billed import execute
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestDeliveredItemsToBeBilled(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"posting_date": "2026-06-30",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def stock_up_item(self):
|
||||
make_stock_entry(
|
||||
item_code="_Test Item",
|
||||
target="Stores - _TC",
|
||||
qty=20,
|
||||
basic_rate=100,
|
||||
posting_date="2026-05-25",
|
||||
)
|
||||
|
||||
def test_unbilled_delivery_note_appears(self):
|
||||
self.stock_up_item()
|
||||
dn = create_delivery_note(
|
||||
item_code="_Test Item",
|
||||
warehouse="Stores - _TC",
|
||||
qty=5,
|
||||
rate=300,
|
||||
customer="_Test Customer",
|
||||
posting_date="2026-06-01",
|
||||
)
|
||||
|
||||
rows = self.run_report(delivery_note=dn.name)
|
||||
self.assertEqual(len(rows), 1)
|
||||
|
||||
row = rows[0]
|
||||
self.assertEqual(row.name, dn.name)
|
||||
self.assertEqual(row.customer, "_Test Customer")
|
||||
self.assertEqual(row.item_code, "_Test Item")
|
||||
self.assertEqual(row.amount, 1500)
|
||||
self.assertEqual(row.billed_amount, 0)
|
||||
self.assertEqual(row.returned_amount, 0)
|
||||
self.assertEqual(row.pending_amount, 1500)
|
||||
|
||||
def test_fully_billed_delivery_note_drops_out(self):
|
||||
self.stock_up_item()
|
||||
dn = create_delivery_note(
|
||||
item_code="_Test Item",
|
||||
warehouse="Stores - _TC",
|
||||
qty=5,
|
||||
rate=300,
|
||||
customer="_Test Customer",
|
||||
posting_date="2026-06-01",
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.run_report(delivery_note=dn.name)), 1)
|
||||
|
||||
si = make_sales_invoice(dn.name)
|
||||
si.posting_date = "2026-06-02"
|
||||
si.set_posting_time = 1
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
self.assertEqual(self.run_report(delivery_note=dn.name), [])
|
||||
|
||||
def test_date_filter_excludes_later_delivery_notes(self):
|
||||
self.stock_up_item()
|
||||
dn = create_delivery_note(
|
||||
item_code="_Test Item",
|
||||
warehouse="Stores - _TC",
|
||||
qty=5,
|
||||
rate=300,
|
||||
customer="_Test Customer",
|
||||
posting_date="2026-07-15",
|
||||
)
|
||||
|
||||
rows = self.run_report(delivery_note=dn.name, posting_date="2026-06-30")
|
||||
self.assertEqual(rows, [])
|
||||
@@ -0,0 +1,82 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.report.dimension_wise_accounts_balance_report.dimension_wise_accounts_balance_report import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestDimensionWiseAccountsBalance(ERPNextTestSuite):
|
||||
"""Balances accounts one column per value of an accounting dimension (here
|
||||
Cost Center). Locks the two behaviours that matter: an entry lands in its
|
||||
own dimension column as debit - credit, and children roll up into parents."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
self.company = "_Test Company"
|
||||
self.expense_account = "_Test Account Cost for Goods Sold - _TC"
|
||||
self.cash_account = "Cash - _TC"
|
||||
|
||||
def _make_cost_center(self, name):
|
||||
full_name = f"{name} - _TC"
|
||||
if not frappe.db.exists("Cost Center", full_name):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Cost Center",
|
||||
"cost_center_name": name,
|
||||
"parent_cost_center": "_Test Company - _TC",
|
||||
"company": self.company,
|
||||
"is_group": 0,
|
||||
}
|
||||
).insert()
|
||||
return full_name
|
||||
|
||||
def _filters(self, **overrides):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": self.company,
|
||||
"dimension": "Cost Center",
|
||||
"fiscal_year": get_fiscal_year(today(), company=self.company)[0],
|
||||
}
|
||||
)
|
||||
filters.update(overrides)
|
||||
return filters
|
||||
|
||||
def test_dimension_column_and_rollup(self):
|
||||
# a dedicated cost center isolates our column from any other posted data
|
||||
cost_center = self._make_cost_center("Test Dimension CC")
|
||||
make_journal_entry(
|
||||
self.expense_account,
|
||||
self.cash_account,
|
||||
300,
|
||||
cost_center=cost_center,
|
||||
posting_date=today(),
|
||||
submit=True,
|
||||
)
|
||||
|
||||
columns, data = execute(self._filters())
|
||||
column = frappe.scrub(cost_center)
|
||||
self.assertIn(column, [c["fieldname"] for c in columns])
|
||||
|
||||
rows = {row["account"]: row for row in data}
|
||||
|
||||
# the entry shows as debit - credit under its own dimension column
|
||||
self.assertEqual(rows[self.expense_account][column], 300.0)
|
||||
self.assertEqual(rows[self.cash_account][column], -300.0)
|
||||
|
||||
# and rolls up into each account's parent (isolated to our cost center)
|
||||
expense_parent = frappe.db.get_value("Account", self.expense_account, "parent_account")
|
||||
cash_parent = frappe.db.get_value("Account", self.cash_account, "parent_account")
|
||||
self.assertEqual(rows[expense_parent][column], 300.0)
|
||||
self.assertEqual(rows[cash_parent][column], -300.0)
|
||||
|
||||
def test_requires_fiscal_year(self):
|
||||
filters = self._filters()
|
||||
filters.pop("fiscal_year")
|
||||
self.assertRaises(frappe.ValidationError, execute, filters)
|
||||
@@ -17,7 +17,7 @@
|
||||
"generate_csv": 0,
|
||||
"idx": 4,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-06-22 13:38:35.057216",
|
||||
"modified": "2026-07-01 13:36:06.682661",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "General Ledger",
|
||||
@@ -37,6 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -820,7 +820,7 @@ def get_columns(filters):
|
||||
return columns
|
||||
|
||||
|
||||
def execute_synced_report(filters):
|
||||
def execute_snapshot_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if conn := get_latest_sync("GL Entry"):
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.report.gross_and_net_profit_report.gross_and_net_profit_report import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
BANK = "_Test Bank - _TC"
|
||||
INCOME_PARENT = "Income - _TC"
|
||||
EXPENSE_PARENT = "Expenses - _TC"
|
||||
# bootstrap leaf accounts that already have include_in_gross = 0 (no creation needed)
|
||||
NON_GROSS_INCOME = "_Test Account Sales - _TC"
|
||||
NON_GROSS_EXPENSE = "_Test Account Cost for Goods Sold - _TC"
|
||||
# an isolated fiscal year so other accounts contribute nothing to the totals
|
||||
FY = "_Test Fiscal Year 2049"
|
||||
DATE = "2049-06-01"
|
||||
|
||||
|
||||
class TestGrossAndNetProfitReport(ERPNextTestSuite):
|
||||
def run_report(self, from_fiscal_year=FY, to_fiscal_year=FY):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"filter_based_on": "Fiscal Year",
|
||||
"from_fiscal_year": from_fiscal_year,
|
||||
"to_fiscal_year": to_fiscal_year,
|
||||
"period_start_date": "2049-01-01",
|
||||
"period_end_date": "2049-12-31",
|
||||
"periodicity": "Yearly",
|
||||
"accumulated_values": 0,
|
||||
"presentation_currency": None,
|
||||
}
|
||||
)
|
||||
return execute(filters)[1]
|
||||
|
||||
def make_account(self, name, parent, include_in_gross):
|
||||
account = create_account(account_name=name, parent_account=parent, company="_Test Company")
|
||||
frappe.db.set_value("Account", account, "include_in_gross", include_in_gross)
|
||||
return account
|
||||
|
||||
def book_income(self, account, amount):
|
||||
make_journal_entry(BANK, account, amount, posting_date=DATE, submit=True)
|
||||
|
||||
def book_expense(self, account, amount):
|
||||
make_journal_entry(account, BANK, amount, posting_date=DATE, submit=True)
|
||||
|
||||
def report_row(self, data, account):
|
||||
return next(row for row in data if row.get("account") == account)
|
||||
|
||||
def test_gross_profit_excludes_non_gross_accounts(self):
|
||||
# reuse bootstrap accounts for the non-gross (include_in_gross = 0) side
|
||||
gross_income = self.make_account("_Test GNP Gross Income", INCOME_PARENT, include_in_gross=1)
|
||||
gross_expense = self.make_account("_Test GNP Gross Expense", EXPENSE_PARENT, include_in_gross=1)
|
||||
|
||||
self.book_income(gross_income, 10000)
|
||||
self.book_income(NON_GROSS_INCOME, 2000)
|
||||
self.book_expense(gross_expense, 4000)
|
||||
self.book_expense(NON_GROSS_EXPENSE, 1000)
|
||||
|
||||
data = self.run_report()
|
||||
# gross profit only counts include_in_gross accounts: 10000 - 4000
|
||||
self.assertEqual(self.report_row(data, "'Gross Profit'")["total"], 6000)
|
||||
# net profit counts everything: (10000 + 2000) - (4000 + 1000)
|
||||
self.assertEqual(self.report_row(data, "'Net Profit'")["total"], 7000)
|
||||
|
||||
def test_net_profit_equals_gross_when_all_included(self):
|
||||
income = self.make_account("_Test GNP All Income", INCOME_PARENT, include_in_gross=1)
|
||||
expense = self.make_account("_Test GNP All Expense", EXPENSE_PARENT, include_in_gross=1)
|
||||
|
||||
self.book_income(income, 9000)
|
||||
self.book_expense(expense, 5000)
|
||||
|
||||
data = self.run_report()
|
||||
self.assertEqual(self.report_row(data, "'Gross Profit'")["total"], 4000)
|
||||
self.assertEqual(self.report_row(data, "'Net Profit'")["total"], 4000)
|
||||
|
||||
def test_nothing_included_in_gross_when_no_entries(self):
|
||||
# a fiscal year with no income/expense entries yields the placeholder row
|
||||
data = self.run_report(
|
||||
from_fiscal_year="_Test Fiscal Year 2048", to_fiscal_year="_Test Fiscal Year 2048"
|
||||
)
|
||||
self.assertEqual(data[0]["account"], "'Nothing is included in gross'")
|
||||
@@ -562,7 +562,12 @@ class GrossProfitGenerator:
|
||||
row.base_amount = packed_item.base_amount
|
||||
|
||||
# get buying amount
|
||||
if row.item_code in product_bundles:
|
||||
if row.is_debit_note:
|
||||
# Rate adjustment debit notes have no stock movement, so buying amount is zero
|
||||
if not grouped_by_invoice:
|
||||
row.qty = 0
|
||||
row.buying_amount = 0
|
||||
elif row.item_code in product_bundles:
|
||||
row.buying_amount = flt(
|
||||
self.get_buying_amount_from_product_bundle(row, product_bundles[row.item_code]),
|
||||
self.currency_precision,
|
||||
@@ -895,7 +900,11 @@ class GrossProfitGenerator:
|
||||
if row.cost_center:
|
||||
query = query.where(purchase_invoice_item.cost_center == row.cost_center)
|
||||
|
||||
query = query.orderby(purchase_invoice.posting_date, order=frappe.qb.desc).limit(1)
|
||||
query = (
|
||||
query.orderby(purchase_invoice.posting_date, order=frappe.qb.desc)
|
||||
.orderby(purchase_invoice.name, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
)
|
||||
last_purchase_rate = query.run()
|
||||
|
||||
return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0
|
||||
@@ -956,6 +965,7 @@ class GrossProfitGenerator:
|
||||
SalesInvoice.customer_group,
|
||||
SalesInvoice.customer_name,
|
||||
SalesInvoice.territory,
|
||||
SalesInvoice.is_debit_note,
|
||||
SalesInvoiceItem.item_code,
|
||||
SalesInvoice.base_net_total.as_("invoice_base_net_total"),
|
||||
SalesInvoiceItem.item_name,
|
||||
@@ -1136,6 +1146,7 @@ class GrossProfitGenerator:
|
||||
"posting_time": row.posting_time,
|
||||
"project": row.project,
|
||||
"update_stock": row.update_stock,
|
||||
"is_debit_note": row.is_debit_note,
|
||||
"customer": row.customer,
|
||||
"customer_group": row.customer_group,
|
||||
"customer_name": row.customer_name,
|
||||
@@ -1174,6 +1185,7 @@ class GrossProfitGenerator:
|
||||
"description": item.description,
|
||||
"warehouse": item.warehouse or row.warehouse,
|
||||
"update_stock": row.update_stock,
|
||||
"is_debit_note": row.is_debit_note,
|
||||
"item_group": "",
|
||||
"brand": "",
|
||||
"dn_detail": row.dn_detail,
|
||||
|
||||
@@ -700,6 +700,160 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
self.assertIsNone(data[1].buying_rate)
|
||||
self.assertEqual(data[1]["gross_profit_%"], 20)
|
||||
|
||||
def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None):
|
||||
"""Create a rate adjustment debit note with no stock movement."""
|
||||
dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True)
|
||||
if item_code:
|
||||
dn.items[0].item_code = item_code
|
||||
dn.items[0].item_name = item_code
|
||||
dn.is_debit_note = 1
|
||||
dn.return_against = against_invoice.name
|
||||
dn.items[0].allow_zero_valuation_rate = 1
|
||||
return dn.save().submit()
|
||||
|
||||
def test_debit_note_has_zero_buying_amount_and_full_gross_profit(self):
|
||||
"""
|
||||
Rate adjustment debit note (is_debit_note=1) should show buying_amount=0
|
||||
since there is no stock movement. Gross profit equals the adjustment amount
|
||||
and gross profit % equals 100%.
|
||||
"""
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=self.item,
|
||||
target=self.warehouse,
|
||||
qty=1,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
|
||||
sinv.update_stock = 1
|
||||
sinv = sinv.save().submit()
|
||||
|
||||
debit_note = self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=nowdate(),
|
||||
to_date=nowdate(),
|
||||
group_by="Invoice",
|
||||
)
|
||||
|
||||
columns, data = execute(filters=filters)
|
||||
|
||||
dn_item_rows = [
|
||||
x for x in data if x.get("parent_invoice") == debit_note.name and x.get("indent") == 1.0
|
||||
]
|
||||
self.assertEqual(len(dn_item_rows), 1)
|
||||
|
||||
dn_row = dn_item_rows[0]
|
||||
self.assertEqual(dn_row.buying_amount, 0.0)
|
||||
self.assertEqual(dn_row.selling_amount, 20.0)
|
||||
self.assertEqual(dn_row.gross_profit, 20.0)
|
||||
self.assertEqual(dn_row["gross_profit_%"], 100.0)
|
||||
|
||||
def test_original_invoice_unaffected_by_rate_adjustment_debit_note(self):
|
||||
"""
|
||||
The original invoice's GP should be derived solely from its own selling
|
||||
amount and COGS — the rate adjustment debit note must not alter it.
|
||||
"""
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=self.item,
|
||||
target=self.warehouse,
|
||||
qty=1,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
|
||||
sinv.update_stock = 1
|
||||
sinv = sinv.save().submit()
|
||||
|
||||
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=nowdate(),
|
||||
to_date=nowdate(),
|
||||
group_by="Invoice",
|
||||
)
|
||||
|
||||
columns, data = execute(filters=filters)
|
||||
|
||||
sinv_item_rows = [x for x in data if x.get("parent_invoice") == sinv.name and x.get("indent") == 1.0]
|
||||
self.assertEqual(len(sinv_item_rows), 1)
|
||||
|
||||
sinv_row = sinv_item_rows[0]
|
||||
self.assertEqual(sinv_row.selling_amount, 200.0)
|
||||
self.assertEqual(sinv_row.buying_amount, 100.0)
|
||||
self.assertEqual(sinv_row.gross_profit, 100.0)
|
||||
self.assertEqual(sinv_row["gross_profit_%"], 50.0)
|
||||
|
||||
def test_debit_note_qty_not_inflated_in_grouped_report(self):
|
||||
"""
|
||||
When grouped by Item Code, the debit note (qty=0) must not inflate
|
||||
the group's qty or buying_amount. The selling amount and average
|
||||
selling rate correctly reflect the rate adjustment.
|
||||
"""
|
||||
item = create_item("_Test Rate Adjustment Debit Note Item")
|
||||
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=item.item_code,
|
||||
target=self.warehouse,
|
||||
qty=1,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
sinv = create_sales_invoice(
|
||||
qty=1,
|
||||
rate=200,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
item_code=item.item_code,
|
||||
item_name=item.item_code,
|
||||
cost_center=self.cost_center,
|
||||
warehouse=self.warehouse,
|
||||
debit_to=self.debit_to,
|
||||
parent_cost_center=self.cost_center,
|
||||
update_stock=1,
|
||||
currency="INR",
|
||||
income_account=self.income_account,
|
||||
expense_account=self.expense_account,
|
||||
)
|
||||
|
||||
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20, item_code=item.item_code)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=nowdate(),
|
||||
to_date=nowdate(),
|
||||
group_by="Item Code",
|
||||
)
|
||||
|
||||
columns, data = execute(filters=filters)
|
||||
|
||||
# group_by="Item Code" column order:
|
||||
# [item_code, item_name, brand, description, qty, base_rate,
|
||||
# buying_rate, base_amount, buying_amount, gross_profit, gross_profit_percent, currency]
|
||||
item_row = next((row for row in data if row[0] == item.item_code), None)
|
||||
self.assertIsNotNone(item_row)
|
||||
|
||||
qty, base_rate, buying_amount, base_amount, gross_profit, gp_percent = (
|
||||
item_row[4],
|
||||
item_row[5],
|
||||
item_row[8],
|
||||
item_row[7],
|
||||
item_row[9],
|
||||
item_row[10],
|
||||
)
|
||||
|
||||
self.assertEqual(qty, 1.0) # debit note adds qty=0, not inflated
|
||||
self.assertEqual(buying_amount, 100.0) # only original invoice COGS
|
||||
self.assertEqual(base_amount, 220.0) # 200 (original) + 20 (adjustment)
|
||||
self.assertEqual(base_rate, 220.0) # avg selling rate = 220/1
|
||||
self.assertEqual(gross_profit, 120.0) # 220 - 100
|
||||
self.assertAlmostEqual(gp_percent, 54.545, places=2) # 120/220 * 100
|
||||
|
||||
|
||||
def make_sales_person(sales_person_name="_Test Sales Person"):
|
||||
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):
|
||||
|
||||
@@ -84,7 +84,8 @@ def build_query_filters(filters: dict | None = None) -> list:
|
||||
qb_filters = []
|
||||
if filters:
|
||||
if filters.account:
|
||||
qb_filters.append(qb.Field("account").isin(filters.account))
|
||||
accounts = filters.account if isinstance(filters.account, list | tuple) else [filters.account]
|
||||
qb_filters.append(qb.Field("account").isin(accounts))
|
||||
|
||||
if filters.voucher_no:
|
||||
qb_filters.append(qb.Field("voucher_no").eq(filters.voucher_no))
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.report.invalid_ledger_entries.invalid_ledger_entries import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestInvalidLedgerEntries(ERPNextTestSuite):
|
||||
"""Tests for the Invalid Ledger Entries integrity report.
|
||||
|
||||
The report flags vouchers that still have *active* ledger entries
|
||||
(GL Entry with is_cancelled=0 or Payment Ledger Entry with delinked=0)
|
||||
in the given period, but whose source voucher document is no longer
|
||||
submitted (docstatus != 1). Such orphaned ledgers indicate corruption.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.debit_account = "_Test Bank - _TC"
|
||||
self.credit_account = "_Test Cash - _TC"
|
||||
self.from_date = "2026-01-01"
|
||||
self.to_date = "2026-12-31"
|
||||
self.posting_date = "2026-06-01"
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": self.company,
|
||||
"from_date": self.from_date,
|
||||
"to_date": self.to_date,
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def make_submitted_jv(self):
|
||||
return make_journal_entry(
|
||||
self.debit_account,
|
||||
self.credit_account,
|
||||
amount=500,
|
||||
posting_date=self.posting_date,
|
||||
company=self.company,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
def test_healthy_voucher_not_flagged(self):
|
||||
"""A normal balanced, submitted Journal Entry must NOT be flagged."""
|
||||
jv = self.make_submitted_jv()
|
||||
|
||||
# It genuinely posted active GL entries, so it is in scope of the scan.
|
||||
self.assertTrue(
|
||||
frappe.db.exists(
|
||||
"GL Entry",
|
||||
{"voucher_no": jv.name, "is_cancelled": 0, "company": self.company},
|
||||
)
|
||||
)
|
||||
|
||||
flagged = {row.get("voucher_no") for row in self.run_report()}
|
||||
self.assertNotIn(jv.name, flagged)
|
||||
|
||||
def test_orphaned_gl_entries_flagged(self):
|
||||
"""A voucher whose document was set non-submitted while its GL entries
|
||||
remain active (is_cancelled=0) must be flagged as invalid."""
|
||||
jv = self.make_submitted_jv()
|
||||
|
||||
# Corrupt the state: mark the source document as cancelled (docstatus=2)
|
||||
# without cancelling/removing its GL Entries. This is the exact orphaned
|
||||
# ledger condition the report detects.
|
||||
frappe.db.set_value("Journal Entry", jv.name, "docstatus", 2, update_modified=False)
|
||||
|
||||
data = self.run_report()
|
||||
|
||||
matching = [
|
||||
row
|
||||
for row in data
|
||||
if row.get("voucher_no") == jv.name and row.get("voucher_type") == "Journal Entry"
|
||||
]
|
||||
self.assertEqual(len(matching), 1, "Orphaned voucher should be flagged exactly once")
|
||||
self.assertEqual(matching[0]["voucher_type"], "Journal Entry")
|
||||
self.assertEqual(matching[0]["voucher_no"], jv.name)
|
||||
|
||||
def test_voucher_no_filter_scopes_scan(self):
|
||||
"""The voucher_no filter must restrict the scan to that voucher only."""
|
||||
orphan = self.make_submitted_jv()
|
||||
other = self.make_submitted_jv()
|
||||
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
|
||||
frappe.db.set_value("Journal Entry", other.name, "docstatus", 2, update_modified=False)
|
||||
|
||||
flagged = {row.get("voucher_no") for row in self.run_report(voucher_no=orphan.name)}
|
||||
self.assertIn(orphan.name, flagged)
|
||||
self.assertNotIn(other.name, flagged)
|
||||
|
||||
def test_account_filter_scopes_scan(self):
|
||||
"""The account filter (a MultiSelectList, so a list) must restrict the
|
||||
scan to vouchers touching one of the given accounts."""
|
||||
orphan = self.make_submitted_jv()
|
||||
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
|
||||
|
||||
# Filtering on an account the voucher touches -> flagged.
|
||||
flagged = {row.get("voucher_no") for row in self.run_report(account=[self.debit_account])}
|
||||
self.assertIn(orphan.name, flagged)
|
||||
|
||||
# Filtering on an unrelated account -> not in scope.
|
||||
unrelated = "Creditors - _TC"
|
||||
flagged = {row.get("voucher_no") for row in self.run_report(account=[unrelated])}
|
||||
self.assertNotIn(orphan.name, flagged)
|
||||
|
||||
def test_account_filter_accepts_a_scalar(self):
|
||||
"""A scalar (non-list) account filter must not crash the query."""
|
||||
orphan = self.make_submitted_jv()
|
||||
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
|
||||
|
||||
flagged = {row.get("voucher_no") for row in self.run_report(account=self.debit_account)}
|
||||
self.assertIn(orphan.name, flagged)
|
||||
|
||||
def test_period_filter_excludes_out_of_range(self):
|
||||
"""Vouchers posted outside the from/to window must not be scanned."""
|
||||
orphan = self.make_submitted_jv()
|
||||
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
|
||||
|
||||
flagged = {
|
||||
row.get("voucher_no") for row in self.run_report(from_date="2025-01-01", to_date="2025-12-31")
|
||||
}
|
||||
self.assertNotIn(orphan.name, flagged)
|
||||
|
||||
def test_cancelled_gl_entries_not_flagged(self):
|
||||
"""If the ledger entries are properly cancelled (is_cancelled=1), the
|
||||
voucher is out of scope even when its document is non-submitted."""
|
||||
jv = self.make_submitted_jv()
|
||||
|
||||
gle = qb.DocType("GL Entry")
|
||||
qb.update(gle).set(gle.is_cancelled, 1).where(gle.voucher_no == jv.name).run()
|
||||
frappe.db.set_value("Journal Entry", jv.name, "docstatus", 2, update_modified=False)
|
||||
|
||||
flagged = {row.get("voucher_no") for row in self.run_report()}
|
||||
self.assertNotIn(jv.name, flagged)
|
||||
|
||||
def test_missing_filters_raises(self):
|
||||
"""validate_filters must guard mandatory inputs."""
|
||||
self.assertRaises(frappe.ValidationError, execute, None)
|
||||
|
||||
bad = frappe._dict({"from_date": self.from_date, "to_date": self.to_date})
|
||||
self.assertRaises(frappe.ValidationError, execute, bad)
|
||||
|
||||
reversed_dates = frappe._dict(
|
||||
{"company": self.company, "from_date": self.to_date, "to_date": self.from_date}
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, execute, reversed_dates)
|
||||
@@ -21,6 +21,13 @@ def execute(filters=None):
|
||||
entries = get_entries(filters)
|
||||
invoice_details = get_invoice_posting_date_map(filters)
|
||||
|
||||
# Only four range columns are defined (range1-range4, the last being "90 Above").
|
||||
# Three thresholds yield exactly four buckets, so payments more than 90 days after
|
||||
# the invoice land in range4 instead of an unread range5.
|
||||
report_filters = frappe._dict(filters)
|
||||
report_filters.range = "30, 60, 90"
|
||||
report = ReceivablePayableReport(report_filters)
|
||||
|
||||
data = []
|
||||
for d in entries:
|
||||
invoice = invoice_details.get(d.against_voucher_no) or frappe._dict()
|
||||
@@ -29,7 +36,9 @@ def execute(filters=None):
|
||||
d.update({"range1": 0, "range2": 0, "range3": 0, "range4": 0, "outstanding": payment_amount})
|
||||
|
||||
if d.against_voucher_no:
|
||||
ReceivablePayableReport(filters).get_ageing_data(invoice.posting_date, d)
|
||||
# age the payment by how long after the invoice it was made (payment date - invoice date)
|
||||
report.age_as_on = getdate(d.posting_date)
|
||||
report.get_ageing_data(invoice.posting_date, d)
|
||||
|
||||
row = [
|
||||
d.voucher_type,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import getdate
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.payment_period_based_on_invoice_date.payment_period_based_on_invoice_date import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestPaymentPeriodBasedOnInvoiceDate(ERPNextTestSuite):
|
||||
"""Depth tests for the Payment Period Based On Invoice Date report.
|
||||
|
||||
The report lists Payment Ledger Entries against invoices and buckets the paid
|
||||
amount by the payment period -- how long after the invoice the payment was made
|
||||
(payment date - invoice date) -- into ranges: range1 (0-30), range2 (30-60),
|
||||
range3 (60-90), range4 (90 Above).
|
||||
"""
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"payment_type": "Incoming",
|
||||
"party_type": "Customer",
|
||||
"from_date": "2026-01-01",
|
||||
"to_date": "2026-12-31",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
columns, data = execute(filters)
|
||||
fieldnames = [c["fieldname"] for c in columns]
|
||||
# Map each positional row to a dict keyed by column fieldname so assertions
|
||||
# stay correct even if a column is inserted or reordered.
|
||||
return columns, [dict(zip(fieldnames, row, strict=False)) for row in data]
|
||||
|
||||
def find_payment_row(self, data, payment_name):
|
||||
for row in data:
|
||||
if row["payment_entry"] == payment_name:
|
||||
return row
|
||||
return None
|
||||
|
||||
def pay_invoice(self, invoice, payment_date):
|
||||
pe = get_payment_entry("Sales Invoice", invoice.name)
|
||||
pe.posting_date = payment_date
|
||||
pe.reference_no = "1"
|
||||
pe.reference_date = payment_date
|
||||
pe.submit()
|
||||
return pe
|
||||
|
||||
def test_paid_amount_lands_in_0_30_bucket(self):
|
||||
# invoice 2026-06-01, paid 2026-06-20 -> 19 days after -> 0-30 bucket
|
||||
invoice = create_sales_invoice(customer="_Test Customer", rate=1000, posting_date="2026-06-01")
|
||||
payment = self.pay_invoice(invoice, "2026-06-20")
|
||||
|
||||
_columns, data = self.run_report()
|
||||
|
||||
row = self.find_payment_row(data, payment.name)
|
||||
self.assertIsNotNone(row, "Payment row not found in report output")
|
||||
|
||||
self.assertEqual(row["party_type"], "Customer")
|
||||
self.assertEqual(row["posting_date"], getdate("2026-06-20"))
|
||||
self.assertEqual(row["invoice"], invoice.name)
|
||||
self.assertEqual(row["invoice_posting_date"], getdate("2026-06-01"))
|
||||
self.assertEqual(row["amount"], 1000)
|
||||
self.assertEqual(row["age"], 19) # age = payment date - invoice date
|
||||
|
||||
# Buckets: 0-30 filled, others empty.
|
||||
self.assertEqual(row["range1"], 1000) # 0-30
|
||||
self.assertEqual(row["range2"], 0) # 30-60
|
||||
self.assertEqual(row["range3"], 0) # 60-90
|
||||
self.assertEqual(row["range4"], 0) # 90 Above
|
||||
|
||||
def test_paid_amount_lands_in_30_60_bucket(self):
|
||||
# invoice 2026-06-01, paid 2026-07-16 -> 45 days after -> 30-60 bucket
|
||||
invoice = create_sales_invoice(customer="_Test Customer 1", rate=1000, posting_date="2026-06-01")
|
||||
payment = self.pay_invoice(invoice, "2026-07-16")
|
||||
|
||||
_columns, data = self.run_report()
|
||||
|
||||
row = self.find_payment_row(data, payment.name)
|
||||
self.assertIsNotNone(row, "Payment row not found in report output")
|
||||
|
||||
self.assertEqual(row["amount"], 1000)
|
||||
self.assertEqual(row["age"], 45)
|
||||
# Buckets: 30-60 filled, others empty.
|
||||
self.assertEqual(row["range1"], 0)
|
||||
self.assertEqual(row["range2"], 1000)
|
||||
self.assertEqual(row["range3"], 0)
|
||||
self.assertEqual(row["range4"], 0)
|
||||
|
||||
def test_payment_over_90_days_lands_in_90_above_bucket(self):
|
||||
# invoice 2026-01-01, paid 2026-06-01 -> 151 days after -> "90 Above" bucket.
|
||||
# Regression guard: with four range columns, a payment older than the last
|
||||
# threshold must fall into range4 rather than an unread range5 (showing 0).
|
||||
invoice = create_sales_invoice(customer="_Test Customer 2", rate=1000, posting_date="2026-01-01")
|
||||
payment = self.pay_invoice(invoice, "2026-06-01")
|
||||
|
||||
_columns, data = self.run_report()
|
||||
|
||||
row = self.find_payment_row(data, payment.name)
|
||||
self.assertIsNotNone(row, "Payment row not found in report output")
|
||||
|
||||
self.assertEqual(row["amount"], 1000)
|
||||
self.assertEqual(row["age"], 151)
|
||||
self.assertEqual(row["range1"], 0)
|
||||
self.assertEqual(row["range2"], 0)
|
||||
self.assertEqual(row["range3"], 0)
|
||||
self.assertEqual(row["range4"], 1000) # 90 Above captures the full amount
|
||||
|
||||
def test_columns_expose_expected_age_buckets(self):
|
||||
columns, _data = self.run_report()
|
||||
labels_by_fieldname = {c["fieldname"]: c["label"] for c in columns}
|
||||
self.assertEqual(labels_by_fieldname["range1"], "0-30")
|
||||
self.assertEqual(labels_by_fieldname["range2"], "30-60")
|
||||
self.assertEqual(labels_by_fieldname["range3"], "60-90")
|
||||
self.assertEqual(labels_by_fieldname["range4"], "90 Above")
|
||||
# Sales Invoice link for Incoming payments.
|
||||
invoice_col = next(c for c in columns if c["fieldname"] == "invoice")
|
||||
self.assertEqual(invoice_col["options"], "Sales Invoice")
|
||||
|
||||
def test_invalid_payment_type_party_type_combo_throws(self):
|
||||
# Incoming + Supplier is invalid.
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
self.run_report,
|
||||
payment_type="Incoming",
|
||||
party_type="Supplier",
|
||||
)
|
||||
# Outgoing + Customer is invalid.
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
self.run_report,
|
||||
payment_type="Outgoing",
|
||||
party_type="Customer",
|
||||
)
|
||||
@@ -17,7 +17,7 @@
|
||||
"generate_csv": 0,
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-06-22 13:38:15.898375",
|
||||
"modified": "2026-07-01 13:36:14.934965",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Profit and Loss Statement",
|
||||
@@ -37,6 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur
|
||||
return chart
|
||||
|
||||
|
||||
def execute_synced_report(filters):
|
||||
def execute_snapshot_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if not (conn := get_latest_sync("GL Entry")):
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.profitability_analysis.profitability_analysis import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
INCOME = "Sales - _TC"
|
||||
EXPENSE = "_Test Account Cost for Goods Sold - _TC"
|
||||
BANK = "_Test Bank - _TC"
|
||||
|
||||
|
||||
class TestProfitabilityAnalysis(ERPNextTestSuite):
|
||||
def run_report(self, fiscal_year="_Test Fiscal Year 2026", **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"based_on": "Cost Center",
|
||||
"fiscal_year": fiscal_year,
|
||||
"from_date": "2026-01-01",
|
||||
"to_date": "2026-12-31",
|
||||
**extra,
|
||||
}
|
||||
)
|
||||
return execute(filters)[1]
|
||||
|
||||
def make_cc(self, name, **args):
|
||||
create_cost_center(cost_center_name=name, **args)
|
||||
return name + " - _TC"
|
||||
|
||||
def row(self, data, account):
|
||||
return next(r for r in data if r.get("account") == account)
|
||||
|
||||
def book_income(self, cost_center, amount, posting_date="2026-06-01"):
|
||||
create_sales_invoice(
|
||||
cost_center=cost_center, income_account=INCOME, rate=amount, qty=1, posting_date=posting_date
|
||||
)
|
||||
|
||||
def book_expense(self, cost_center, amount, posting_date="2026-06-01"):
|
||||
make_journal_entry(
|
||||
EXPENSE, BANK, amount, cost_center=cost_center, posting_date=posting_date, submit=True
|
||||
)
|
||||
|
||||
def test_income_expense_and_gross_profit(self):
|
||||
# a dedicated leaf cost center keeps these exact assertions free of GL that
|
||||
# other tests may book against a shared cost center in the same fiscal year
|
||||
cc = self.make_cc("_Test PA Income Expense")
|
||||
self.book_income(cc, 10000)
|
||||
self.book_expense(cc, 4000)
|
||||
|
||||
row = self.row(self.run_report(), cc)
|
||||
self.assertEqual(row["income"], 10000)
|
||||
self.assertEqual(row["expense"], 4000)
|
||||
self.assertEqual(row["gross_profit_loss"], 6000)
|
||||
|
||||
def test_parent_cost_center_accumulates_children(self):
|
||||
parent = self.make_cc("_Test PA Parent", is_group=1)
|
||||
child_1 = self.make_cc("_Test PA Child 1", parent_cost_center=parent)
|
||||
child_2 = self.make_cc("_Test PA Child 2", parent_cost_center=parent)
|
||||
|
||||
self.book_income(child_1, 10000)
|
||||
self.book_expense(child_2, 3000)
|
||||
|
||||
data = self.run_report()
|
||||
self.assertEqual(self.row(data, child_1)["income"], 10000)
|
||||
self.assertEqual(self.row(data, child_2)["expense"], 3000)
|
||||
|
||||
parent_row = self.row(data, parent)
|
||||
self.assertEqual(parent_row["income"], 10000)
|
||||
self.assertEqual(parent_row["expense"], 3000)
|
||||
self.assertEqual(parent_row["gross_profit_loss"], 7000)
|
||||
|
||||
def test_date_range_excludes_out_of_period_entries(self):
|
||||
cc = self.make_cc("_Test PA Date Range")
|
||||
self.book_income(cc, 10000, posting_date="2025-06-01")
|
||||
|
||||
# the 2025 income must not appear in a 2026 report (zero-value rows are dropped)
|
||||
accounts_2026 = {r.get("account") for r in self.run_report()}
|
||||
self.assertNotIn(cc, accounts_2026)
|
||||
|
||||
row_2025 = self.row(
|
||||
self.run_report(
|
||||
fiscal_year="_Test Fiscal Year 2025", from_date="2025-01-01", to_date="2025-12-31"
|
||||
),
|
||||
cc,
|
||||
)
|
||||
self.assertEqual(row_2025["income"], 10000)
|
||||
|
||||
def test_total_row_sums_income_and_expense(self):
|
||||
cc = "_Test Cost Center - _TC"
|
||||
self.book_income(cc, 10000)
|
||||
self.book_expense(cc, 4000)
|
||||
|
||||
data = self.run_report()
|
||||
# the report appends a blank separator row and a totals row at the end
|
||||
total_row = data[-1]
|
||||
# the report wraps the (possibly translated) "Total" label in single quotes
|
||||
self.assertEqual(total_row["account"], "'" + frappe._("Total") + "'")
|
||||
# total is built from direct (non-accumulated) values, so it stays internally consistent
|
||||
self.assertEqual(total_row["gross_profit_loss"], total_row["income"] - total_row["expense"])
|
||||
# and it includes this test's bookings
|
||||
self.assertGreaterEqual(total_row["income"], 10000)
|
||||
self.assertGreaterEqual(total_row["expense"], 4000)
|
||||
@@ -0,0 +1,171 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.report.purchase_invoice_trends.purchase_invoice_trends import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
FISCAL_YEAR = "_Test Fiscal Year 2026"
|
||||
COMPANY = "_Test Company"
|
||||
SUPPLIER = "_Test Supplier"
|
||||
ITEM = "_Test Item"
|
||||
POSTING_DATE = "2026-06-01"
|
||||
|
||||
|
||||
def make_dated_purchase_invoice(qty, rate):
|
||||
# make_purchase_invoice ignores posting_date unless posting time is explicitly set, so build the
|
||||
# invoice unsubmitted, pin the posting date, then submit to land it in the intended period bucket.
|
||||
pi = make_purchase_invoice(
|
||||
supplier=SUPPLIER, item_code=ITEM, qty=qty, rate=rate, posting_date=POSTING_DATE, do_not_submit=1
|
||||
)
|
||||
pi.set_posting_time = 1
|
||||
pi.posting_date = POSTING_DATE
|
||||
pi.submit()
|
||||
return pi
|
||||
|
||||
|
||||
class TestPurchaseInvoiceTrends(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": COMPANY,
|
||||
"fiscal_year": FISCAL_YEAR,
|
||||
"period": "Yearly",
|
||||
"based_on": "Item",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
columns, data = execute(filters)
|
||||
labels = [c.split(":")[0] if isinstance(c, str) else c.get("label") for c in columns]
|
||||
return labels, data
|
||||
|
||||
@staticmethod
|
||||
def _cell(labels, row, label):
|
||||
return row[labels.index(label)]
|
||||
|
||||
def _find_row(self, data, key):
|
||||
for row in data:
|
||||
if row and row[0] == key:
|
||||
return row
|
||||
return None
|
||||
|
||||
def test_yearly_item_qty_and_amount(self):
|
||||
labels_before, data_before = self.run_report()
|
||||
before = self._find_row(data_before, ITEM)
|
||||
|
||||
qty, rate = 4, 250
|
||||
make_dated_purchase_invoice(qty, rate)
|
||||
|
||||
labels, data = self.run_report()
|
||||
self.assertIn("Item", labels)
|
||||
self.assertIn("Item Name", labels)
|
||||
self.assertIn("Currency", labels)
|
||||
self.assertIn("Total(Qty)", labels)
|
||||
self.assertIn("Total(Amt)", labels)
|
||||
# Yearly period bucket uses the fiscal year name as the label prefix
|
||||
self.assertIn(f"{FISCAL_YEAR} (Qty)", labels)
|
||||
self.assertIn(f"{FISCAL_YEAR} (Amt)", labels)
|
||||
|
||||
row = self._find_row(data, ITEM)
|
||||
self.assertIsNotNone(row)
|
||||
|
||||
before_qty = self._cell(labels_before, before, f"{FISCAL_YEAR} (Qty)") if before else 0
|
||||
before_amt = self._cell(labels_before, before, f"{FISCAL_YEAR} (Amt)") if before else 0
|
||||
before_tqty = self._cell(labels_before, before, "Total(Qty)") if before else 0
|
||||
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
|
||||
|
||||
self.assertEqual(self._cell(labels, row, f"{FISCAL_YEAR} (Qty)") - before_qty, qty)
|
||||
self.assertEqual(self._cell(labels, row, f"{FISCAL_YEAR} (Amt)") - before_amt, qty * rate)
|
||||
self.assertEqual(self._cell(labels, row, "Total(Qty)") - before_tqty, qty)
|
||||
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
|
||||
|
||||
def test_monthly_bucket(self):
|
||||
labels_before, data_before = self.run_report(period="Monthly")
|
||||
before = self._find_row(data_before, ITEM)
|
||||
|
||||
qty, rate = 3, 100
|
||||
make_dated_purchase_invoice(qty, rate)
|
||||
|
||||
labels, data = self.run_report(period="Monthly")
|
||||
# posting_date 2026-06-01 -> June bucket
|
||||
self.assertIn("Jun (Qty)", labels)
|
||||
self.assertIn("Jun (Amt)", labels)
|
||||
|
||||
row = self._find_row(data, ITEM)
|
||||
before_qty = self._cell(labels_before, before, "Jun (Qty)") if before else 0
|
||||
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
|
||||
|
||||
self.assertEqual(self._cell(labels, row, "Jun (Qty)") - before_qty, qty)
|
||||
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
|
||||
|
||||
def test_quarterly_bucket(self):
|
||||
labels_before, data_before = self.run_report(period="Quarterly")
|
||||
before = self._find_row(data_before, ITEM)
|
||||
|
||||
qty, rate = 2, 150
|
||||
make_dated_purchase_invoice(qty, rate)
|
||||
|
||||
labels, data = self.run_report(period="Quarterly")
|
||||
# 2026-06-01 falls in the Apr-Jun quarter
|
||||
self.assertIn("Apr-Jun (Qty)", labels)
|
||||
self.assertIn("Apr-Jun (Amt)", labels)
|
||||
|
||||
row = self._find_row(data, ITEM)
|
||||
before_qty = self._cell(labels_before, before, "Apr-Jun (Qty)") if before else 0
|
||||
before_amt = self._cell(labels_before, before, "Apr-Jun (Amt)") if before else 0
|
||||
|
||||
self.assertEqual(self._cell(labels, row, "Apr-Jun (Qty)") - before_qty, qty)
|
||||
self.assertEqual(self._cell(labels, row, "Apr-Jun (Amt)") - before_amt, qty * rate)
|
||||
|
||||
def test_based_on_supplier(self):
|
||||
labels_before, data_before = self.run_report(based_on="Supplier")
|
||||
before = self._find_row(data_before, SUPPLIER)
|
||||
|
||||
qty, rate = 5, 200
|
||||
make_dated_purchase_invoice(qty, rate)
|
||||
|
||||
labels, data = self.run_report(based_on="Supplier")
|
||||
self.assertIn("Supplier", labels)
|
||||
self.assertIn("Supplier Name", labels)
|
||||
self.assertIn("Supplier Group", labels)
|
||||
|
||||
row = self._find_row(data, SUPPLIER)
|
||||
self.assertIsNotNone(row)
|
||||
|
||||
before_tqty = self._cell(labels_before, before, "Total(Qty)") if before else 0
|
||||
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
|
||||
|
||||
self.assertEqual(self._cell(labels, row, "Total(Qty)") - before_tqty, qty)
|
||||
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
|
||||
|
||||
def test_group_by_item_under_supplier(self):
|
||||
labels_before, data_before = self.run_report(based_on="Supplier", group_by="Item")
|
||||
# group_by inserts an "Item" column; the item breakdown row carries the item key there
|
||||
item_idx = labels_before.index("Item")
|
||||
before = None
|
||||
for r in data_before:
|
||||
if r and r[0] != SUPPLIER and r[item_idx] == ITEM:
|
||||
before = r
|
||||
break
|
||||
|
||||
qty, rate = 6, 300
|
||||
make_dated_purchase_invoice(qty, rate)
|
||||
|
||||
labels, data = self.run_report(based_on="Supplier", group_by="Item")
|
||||
self.assertIn("Item", labels)
|
||||
|
||||
item_idx = labels.index("Item")
|
||||
row = None
|
||||
for r in data:
|
||||
if r and r[0] != SUPPLIER and r[0] != "'Total'" and r[item_idx] == ITEM:
|
||||
row = r
|
||||
break
|
||||
self.assertIsNotNone(row)
|
||||
|
||||
before_tqty = self._cell(labels_before, before, "Total(Qty)") if before else 0
|
||||
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
|
||||
|
||||
self.assertEqual(self._cell(labels, row, "Total(Qty)") - before_tqty, qty)
|
||||
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
|
||||
@@ -0,0 +1,101 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.report.received_items_to_be_billed.received_items_to_be_billed import execute
|
||||
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice as make_pi_from_pr
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestReceivedItemsToBeBilled(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"posting_date": "2026-06-30",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def get_row(self, data, purchase_receipt):
|
||||
matches = [row for row in data if row.get("name") == purchase_receipt]
|
||||
return matches[0] if matches else None
|
||||
|
||||
def test_unbilled_receipt_appears_with_pending_amount(self):
|
||||
pr = make_purchase_receipt(
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
supplier="_Test Supplier",
|
||||
posting_date="2026-06-01",
|
||||
)
|
||||
|
||||
row = self.get_row(self.run_report(), pr.name)
|
||||
|
||||
self.assertIsNotNone(row, "Unbilled Purchase Receipt should appear in the report")
|
||||
self.assertEqual(row.get("supplier"), "_Test Supplier")
|
||||
self.assertEqual(row.get("item_code"), "_Test Item")
|
||||
self.assertEqual(row.get("amount"), 1000.0)
|
||||
self.assertEqual(row.get("billed_amount"), 0.0)
|
||||
self.assertEqual(row.get("returned_amount"), 0.0)
|
||||
self.assertEqual(row.get("pending_amount"), 1000.0)
|
||||
|
||||
def test_billed_receipt_drops_out_of_report(self):
|
||||
pr = make_purchase_receipt(
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
supplier="_Test Supplier",
|
||||
posting_date="2026-06-01",
|
||||
)
|
||||
|
||||
self.assertIsNotNone(self.get_row(self.run_report(), pr.name))
|
||||
|
||||
pi = make_pi_from_pr(pr.name)
|
||||
pi.set_posting_time = 1
|
||||
pi.posting_date = "2026-06-02"
|
||||
pi.submit()
|
||||
|
||||
self.assertIsNone(
|
||||
self.get_row(self.run_report(), pr.name),
|
||||
"Fully billed Purchase Receipt should no longer appear in the report",
|
||||
)
|
||||
|
||||
def test_reference_field_filter_limits_to_single_receipt(self):
|
||||
first_pr = make_purchase_receipt(
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
supplier="_Test Supplier",
|
||||
posting_date="2026-06-01",
|
||||
)
|
||||
second_pr = make_purchase_receipt(
|
||||
item_code="_Test Item",
|
||||
qty=3,
|
||||
rate=100,
|
||||
supplier="_Test Supplier",
|
||||
posting_date="2026-06-01",
|
||||
)
|
||||
|
||||
data = self.run_report(purchase_receipt=first_pr.name)
|
||||
|
||||
self.assertIsNotNone(self.get_row(data, first_pr.name))
|
||||
self.assertIsNone(self.get_row(data, second_pr.name))
|
||||
|
||||
def test_posting_date_cutoff_excludes_later_receipts(self):
|
||||
pr = make_purchase_receipt(
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
supplier="_Test Supplier",
|
||||
posting_date="2026-06-15",
|
||||
)
|
||||
|
||||
self.assertIsNone(
|
||||
self.get_row(self.run_report(posting_date="2026-06-01"), pr.name),
|
||||
"Receipt dated after the cutoff should be excluded",
|
||||
)
|
||||
self.assertIsNotNone(self.get_row(self.run_report(posting_date="2026-06-30"), pr.name))
|
||||
@@ -0,0 +1,118 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.sales_invoice_trends.sales_invoice_trends import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
FISCAL_YEAR = "_Test Fiscal Year 2026"
|
||||
POSTING_DATE = "2026-06-01"
|
||||
|
||||
|
||||
class TestSalesInvoiceTrends(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"fiscal_year": FISCAL_YEAR,
|
||||
"based_on": "Item",
|
||||
"period": "Yearly",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
columns, data = execute(filters)
|
||||
labels = [c.split(":")[0] if isinstance(c, str) else c.get("label") for c in columns]
|
||||
return labels, data
|
||||
|
||||
def _cell(self, data, key_label, key_value, col_label, labels):
|
||||
"""Return the value at column `col_label` for the row whose first-column
|
||||
value equals `key_value`, or 0 if that row does not exist yet."""
|
||||
key_idx = labels.index(key_label)
|
||||
col_idx = labels.index(col_label)
|
||||
for row in data:
|
||||
if row[key_idx] == key_value:
|
||||
return row[col_idx] or 0
|
||||
return 0
|
||||
|
||||
def test_yearly_item_amount_and_total(self):
|
||||
# Yearly period => a single "<FY> (Qty)"/"(Amt)" bucket, plus Total(Qty)/Total(Amt).
|
||||
labels, before = self.run_report()
|
||||
qty_col = f"{FISCAL_YEAR} (Qty)"
|
||||
amt_col = f"{FISCAL_YEAR} (Amt)"
|
||||
before_qty = self._cell(before, "Item", "_Test Item", qty_col, labels)
|
||||
before_amt = self._cell(before, "Item", "_Test Item", amt_col, labels)
|
||||
before_tot_qty = self._cell(before, "Item", "_Test Item", "Total(Qty)", labels)
|
||||
before_tot_amt = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels)
|
||||
|
||||
create_sales_invoice(item="_Test Item", qty=4, rate=200, posting_date=POSTING_DATE)
|
||||
|
||||
labels, after = self.run_report()
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", qty_col, labels) - before_qty, 4)
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", amt_col, labels) - before_amt, 800)
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Qty)", labels) - before_tot_qty, 4)
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_tot_amt, 800)
|
||||
|
||||
def test_monthly_lands_in_june_bucket(self):
|
||||
# Monthly period => one bucket per month; a 2026-06-01 invoice hits "Jun (Qty)"/"(Amt)".
|
||||
labels, before = self.run_report(period="Monthly")
|
||||
before_qty = self._cell(before, "Item", "_Test Item", "Jun (Qty)", labels)
|
||||
before_amt = self._cell(before, "Item", "_Test Item", "Jun (Amt)", labels)
|
||||
before_tot = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels)
|
||||
|
||||
create_sales_invoice(item="_Test Item", qty=3, rate=100, posting_date=POSTING_DATE)
|
||||
|
||||
labels, after = self.run_report(period="Monthly")
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Qty)", labels) - before_qty, 3)
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Amt)", labels) - before_amt, 300)
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_tot, 300)
|
||||
# Nothing should leak into an unrelated month.
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jan (Amt)", labels), 0)
|
||||
|
||||
def test_quarterly_lands_in_apr_jun_bucket(self):
|
||||
# Quarterly period over a Jan-Dec fiscal year => Apr-Jun is the 2nd quarter; June lands there.
|
||||
labels, before = self.run_report(period="Quarterly")
|
||||
before_qty = self._cell(before, "Item", "_Test Item", "Apr-Jun (Qty)", labels)
|
||||
before_amt = self._cell(before, "Item", "_Test Item", "Apr-Jun (Amt)", labels)
|
||||
|
||||
create_sales_invoice(item="_Test Item", qty=5, rate=50, posting_date=POSTING_DATE)
|
||||
|
||||
labels, after = self.run_report(period="Quarterly")
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Apr-Jun (Qty)", labels) - before_qty, 5)
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Apr-Jun (Amt)", labels) - before_amt, 250)
|
||||
# Jan-Mar quarter must stay untouched.
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jan-Mar (Amt)", labels), 0)
|
||||
|
||||
def test_based_on_customer_total(self):
|
||||
# based_on=Customer => first column is "Customer"; the customer's Total(Amt) reflects the sale.
|
||||
labels, before = self.run_report(based_on="Customer")
|
||||
before_tot_qty = self._cell(before, "Customer", "_Test Customer", "Total(Qty)", labels)
|
||||
before_tot_amt = self._cell(before, "Customer", "_Test Customer", "Total(Amt)", labels)
|
||||
|
||||
create_sales_invoice(
|
||||
customer="_Test Customer", item="_Test Item", qty=2, rate=300, posting_date=POSTING_DATE
|
||||
)
|
||||
|
||||
labels, after = self.run_report(based_on="Customer")
|
||||
self.assertEqual(
|
||||
self._cell(after, "Customer", "_Test Customer", "Total(Qty)", labels) - before_tot_qty, 2
|
||||
)
|
||||
self.assertEqual(
|
||||
self._cell(after, "Customer", "_Test Customer", "Total(Amt)", labels) - before_tot_amt, 600
|
||||
)
|
||||
|
||||
def test_group_by_item_under_customer(self):
|
||||
# based_on=Customer + group_by=Item inserts an "Item" breakdown column before the period
|
||||
# buckets; the per-item detail row carries the item key and the amount for that customer/item.
|
||||
labels, before = self.run_report(based_on="Customer", group_by="Item")
|
||||
# In group_by mode the detail rows key off the group_by column ("Item"), so snapshot by item.
|
||||
before_amt = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels)
|
||||
|
||||
create_sales_invoice(
|
||||
customer="_Test Customer", item="_Test Item", qty=6, rate=100, posting_date=POSTING_DATE
|
||||
)
|
||||
|
||||
labels, after = self.run_report(based_on="Customer", group_by="Item")
|
||||
self.assertIn("Item", labels)
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_amt, 600)
|
||||
@@ -15,8 +15,6 @@ def execute(filters=None):
|
||||
|
||||
columns = get_columns(filters)
|
||||
|
||||
filters.get("date")
|
||||
|
||||
data = []
|
||||
|
||||
if not filters.get("shareholder"):
|
||||
@@ -24,7 +22,7 @@ def execute(filters=None):
|
||||
else:
|
||||
share_type, no_of_shares, rate, amount = 1, 2, 3, 4
|
||||
|
||||
all_shares = get_all_shares(filters.get("shareholder"))
|
||||
all_shares = get_all_shares(filters.get("shareholder"), filters.get("date"), filters.get("company"))
|
||||
for share_entry in all_shares:
|
||||
row = False
|
||||
for datum in data:
|
||||
@@ -63,5 +61,47 @@ def get_columns(filters):
|
||||
return columns
|
||||
|
||||
|
||||
def get_all_shares(shareholder):
|
||||
return frappe.get_doc("Shareholder", shareholder).share_balance
|
||||
def get_all_shares(shareholder, date, company=None):
|
||||
"""Share movements for the shareholder up to (and including) `date`, signed by direction:
|
||||
shares received are positive, shares transferred/sold out are negative.
|
||||
|
||||
The shareholder and company predicates are pushed into the query so only the
|
||||
relevant transfers are fetched instead of scanning the whole table."""
|
||||
share_transfer = frappe.qb.DocType("Share Transfer")
|
||||
query = (
|
||||
frappe.qb.from_(share_transfer)
|
||||
.select(
|
||||
share_transfer.share_type,
|
||||
share_transfer.no_of_shares,
|
||||
share_transfer.rate,
|
||||
share_transfer.amount,
|
||||
share_transfer.from_shareholder,
|
||||
share_transfer.to_shareholder,
|
||||
)
|
||||
.where((share_transfer.docstatus == 1) & (share_transfer.date <= date))
|
||||
.where(
|
||||
(share_transfer.to_shareholder == shareholder) | (share_transfer.from_shareholder == shareholder)
|
||||
)
|
||||
.orderby(share_transfer.date)
|
||||
)
|
||||
|
||||
if company:
|
||||
query = query.where(share_transfer.company == company)
|
||||
|
||||
transfers = query.run(as_dict=True)
|
||||
|
||||
shares = []
|
||||
for transfer in transfers:
|
||||
if transfer.to_shareholder == shareholder:
|
||||
shares.append(transfer)
|
||||
elif transfer.from_shareholder == shareholder:
|
||||
shares.append(
|
||||
frappe._dict(
|
||||
share_type=transfer.share_type,
|
||||
no_of_shares=-transfer.no_of_shares,
|
||||
rate=transfer.rate,
|
||||
amount=-transfer.amount,
|
||||
)
|
||||
)
|
||||
|
||||
return shares
|
||||
|
||||
201
erpnext/accounts/report/share_balance/test_share_balance.py
Normal file
201
erpnext/accounts/report/share_balance/test_share_balance.py
Normal file
@@ -0,0 +1,201 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.report.share_balance.share_balance import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
|
||||
class TestShareBalanceReport(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.share_type = create_share_type("_Test Share Balance Equity")
|
||||
self.shareholder = create_shareholder("_Test Share Balance Holder", COMPANY)
|
||||
|
||||
def test_date_filter_is_mandatory(self):
|
||||
self.assertRaises(frappe.ValidationError, execute, frappe._dict({"shareholder": self.shareholder}))
|
||||
|
||||
def test_no_shareholder_returns_empty_data(self):
|
||||
# `shareholder` is optional; without it the report yields no rows.
|
||||
columns, data = execute(frappe._dict({"date": "2026-06-01", "company": COMPANY}))
|
||||
self.assertEqual(data, [])
|
||||
self.assertEqual(len(columns), 5)
|
||||
|
||||
def test_balance_after_issue(self):
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
share_type=self.share_type,
|
||||
from_no=1,
|
||||
to_no=100,
|
||||
no_of_shares=100,
|
||||
rate=10,
|
||||
date="2026-06-01",
|
||||
)
|
||||
|
||||
row = self.get_row(date="2026-06-05")
|
||||
self.assertEqual(row[0], self.shareholder)
|
||||
self.assertEqual(row[1], self.share_type)
|
||||
self.assertEqual(row[2], 100) # no_of_shares
|
||||
self.assertEqual(row[3], 10) # average rate
|
||||
self.assertEqual(row[4], 1000) # amount = 100 * 10
|
||||
|
||||
def test_company_filter_scopes_transfers(self):
|
||||
# the transfer is booked under `_Test Company`
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
share_type=self.share_type,
|
||||
from_no=1,
|
||||
to_no=100,
|
||||
no_of_shares=100,
|
||||
rate=10,
|
||||
date="2026-06-01",
|
||||
)
|
||||
|
||||
# matching company: the holding shows up
|
||||
self.assertEqual(self.get_row(date="2026-06-05")[2], 100)
|
||||
|
||||
# a different company must not surface this shareholder's transfer
|
||||
other_company_data = execute(
|
||||
frappe._dict(
|
||||
{"date": "2026-06-05", "company": "_Test Company 1", "shareholder": self.shareholder}
|
||||
)
|
||||
)[1]
|
||||
self.assertEqual(other_company_data, [])
|
||||
|
||||
def test_balance_increases_on_second_issue(self):
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
share_type=self.share_type,
|
||||
from_no=1,
|
||||
to_no=100,
|
||||
no_of_shares=100,
|
||||
rate=10,
|
||||
date="2026-06-01",
|
||||
)
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
share_type=self.share_type,
|
||||
from_no=101,
|
||||
to_no=200,
|
||||
no_of_shares=100,
|
||||
rate=20,
|
||||
date="2026-06-10",
|
||||
)
|
||||
|
||||
# The report groups by share type, summing shares and amount and
|
||||
# recomputing the average rate: (1000 + 2000) / 200 = 15.
|
||||
row = self.get_row(date="2026-06-15")
|
||||
self.assertEqual(row[2], 200)
|
||||
self.assertEqual(row[3], 15)
|
||||
self.assertEqual(row[4], 3000)
|
||||
|
||||
def test_balance_reduces_after_transfer_out(self):
|
||||
other_holder = create_shareholder("_Test Share Balance Holder 2", COMPANY)
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
share_type=self.share_type,
|
||||
from_no=1,
|
||||
to_no=100,
|
||||
no_of_shares=100,
|
||||
rate=10,
|
||||
date="2026-06-01",
|
||||
)
|
||||
create_share_transfer(
|
||||
transfer_type="Transfer",
|
||||
from_shareholder=self.shareholder,
|
||||
to_shareholder=other_holder,
|
||||
share_type=self.share_type,
|
||||
from_no=1,
|
||||
to_no=40,
|
||||
no_of_shares=40,
|
||||
rate=10,
|
||||
date="2026-06-10",
|
||||
)
|
||||
|
||||
row = self.get_row(date="2026-06-15")
|
||||
self.assertEqual(row[2], 60) # 100 issued - 40 transferred out
|
||||
self.assertEqual(row[4], 600)
|
||||
|
||||
other_row = self.get_row(date="2026-06-15", shareholder=other_holder)
|
||||
self.assertEqual(other_row[2], 40)
|
||||
self.assertEqual(other_row[4], 400)
|
||||
|
||||
def test_as_on_date_before_issue_shows_no_holding(self):
|
||||
# the report is as-on `date`: before any share transfer, the shareholder holds nothing
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
share_type=self.share_type,
|
||||
from_no=1,
|
||||
to_no=100,
|
||||
no_of_shares=100,
|
||||
rate=10,
|
||||
date="2026-06-01",
|
||||
)
|
||||
|
||||
data = execute(
|
||||
frappe._dict({"date": "2026-05-01", "company": COMPANY, "shareholder": self.shareholder})
|
||||
)[1]
|
||||
self.assertEqual(data, [])
|
||||
|
||||
def test_as_on_date_reflects_holding_up_to_that_date(self):
|
||||
# two issues on different dates; an as-on date between them sees only the first
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
share_type=self.share_type,
|
||||
from_no=1,
|
||||
to_no=100,
|
||||
no_of_shares=100,
|
||||
rate=10,
|
||||
date="2026-06-01",
|
||||
)
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
share_type=self.share_type,
|
||||
from_no=101,
|
||||
to_no=200,
|
||||
no_of_shares=100,
|
||||
rate=20,
|
||||
date="2026-06-10",
|
||||
)
|
||||
|
||||
self.assertEqual(self.get_row(date="2026-06-05")[2], 100) # only the first issue
|
||||
self.assertEqual(self.get_row(date="2026-06-15")[2], 200) # both issues
|
||||
|
||||
def get_row(self, date, shareholder=None):
|
||||
filters = frappe._dict(
|
||||
{"date": date, "company": COMPANY, "shareholder": shareholder or self.shareholder}
|
||||
)
|
||||
data = execute(filters)[1]
|
||||
holdings = [r for r in data if r[1] == self.share_type]
|
||||
self.assertEqual(len(holdings), 1, f"Expected one row for share type, got: {data}")
|
||||
return holdings[0]
|
||||
|
||||
|
||||
def create_share_type(title):
|
||||
if not frappe.db.exists("Share Type", title):
|
||||
frappe.get_doc({"doctype": "Share Type", "title": title}).insert()
|
||||
return title
|
||||
|
||||
|
||||
def create_shareholder(title, company):
|
||||
shareholder = frappe.get_doc({"doctype": "Shareholder", "title": title, "company": company}).insert()
|
||||
return shareholder.name
|
||||
|
||||
|
||||
def create_share_transfer(**kwargs):
|
||||
kwargs.setdefault("company", COMPANY)
|
||||
kwargs.setdefault("asset_account", "Cash - _TC")
|
||||
kwargs.setdefault("equity_or_liability_account", "Creditors - _TC")
|
||||
transfer = frappe.get_doc({"doctype": "Share Transfer", **kwargs})
|
||||
transfer.submit()
|
||||
return transfer
|
||||
171
erpnext/accounts/report/share_ledger/test_share_ledger.py
Normal file
171
erpnext/accounts/report/share_ledger/test_share_ledger.py
Normal file
@@ -0,0 +1,171 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.report.share_ledger.share_ledger import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
# The report returns legacy positional columns (no fieldnames); name the indices once
|
||||
# here so a column reorder needs a single edit instead of silently shifting assertions.
|
||||
COL_SHAREHOLDER = 0
|
||||
COL_DATE = 1
|
||||
COL_TRANSFER_TYPE = 2
|
||||
COL_SHARE_TYPE = 3
|
||||
COL_NO_OF_SHARES = 4
|
||||
COL_RATE = 5
|
||||
COL_AMOUNT = 6
|
||||
COL_COMPANY = 7
|
||||
COL_SHARE_TRANSFER = 8
|
||||
|
||||
|
||||
class TestShareLedger(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.shareholder = self.create_shareholder("_Test Share Ledger Holder")
|
||||
# Issue 100 shares on 2026-06-01, then another 50 on 2026-06-10.
|
||||
self.first = self.issue_shares(date="2026-06-01", from_no=1, to_no=100, rate=10)
|
||||
self.second = self.issue_shares(date="2026-06-10", from_no=101, to_no=150, rate=12)
|
||||
|
||||
def test_ledger_lists_all_transfers_upto_date(self):
|
||||
data = self.run_report(shareholder=self.shareholder, date="2026-06-30")
|
||||
|
||||
self.assertEqual(len(data), 2)
|
||||
|
||||
first_row, second_row = data
|
||||
self.assertEqual(first_row[COL_SHAREHOLDER], self.shareholder)
|
||||
self.assertEqual(first_row[COL_DATE], frappe.utils.getdate("2026-06-01"))
|
||||
self.assertEqual(first_row[COL_TRANSFER_TYPE], "Issue")
|
||||
self.assertEqual(first_row[COL_SHARE_TYPE], "Equity")
|
||||
self.assertEqual(first_row[COL_NO_OF_SHARES], 100)
|
||||
self.assertEqual(first_row[COL_RATE], 10)
|
||||
self.assertEqual(first_row[COL_AMOUNT], 1000)
|
||||
self.assertEqual(first_row[COL_COMPANY], COMPANY)
|
||||
self.assertEqual(first_row[COL_SHARE_TRANSFER], self.first)
|
||||
|
||||
self.assertEqual(second_row[COL_DATE], frappe.utils.getdate("2026-06-10"))
|
||||
self.assertEqual(second_row[COL_NO_OF_SHARES], 50)
|
||||
self.assertEqual(second_row[COL_RATE], 12)
|
||||
self.assertEqual(second_row[COL_AMOUNT], 600)
|
||||
self.assertEqual(second_row[COL_SHARE_TRANSFER], self.second)
|
||||
|
||||
def test_running_balance_of_shares(self):
|
||||
data = self.run_report(shareholder=self.shareholder, date="2026-06-30")
|
||||
|
||||
# The ledger records each transfer's raw no_of_shares (always positive); it does
|
||||
# not sign by direction. With only incoming "Issue" rows here, summing them is a
|
||||
# valid running total. (Directional balances are the Share Balance report's job.)
|
||||
running = 0
|
||||
balances = []
|
||||
for row in data:
|
||||
running += row[COL_NO_OF_SHARES]
|
||||
balances.append(running)
|
||||
|
||||
self.assertEqual(balances, [100, 150])
|
||||
|
||||
def test_as_on_date_between_transfers_shows_only_first(self):
|
||||
data = self.run_report(shareholder=self.shareholder, date="2026-06-05")
|
||||
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0][COL_SHARE_TRANSFER], self.first)
|
||||
self.assertEqual(data[0][COL_NO_OF_SHARES], 100)
|
||||
|
||||
def test_transfer_type_label_when_shareholder_is_seller(self):
|
||||
buyer = self.create_shareholder("_Test Share Ledger Buyer")
|
||||
transfer = self.make_transfer(
|
||||
from_shareholder=self.shareholder,
|
||||
to_shareholder=buyer,
|
||||
date="2026-06-15",
|
||||
from_no=1,
|
||||
to_no=40,
|
||||
rate=10,
|
||||
)
|
||||
|
||||
row = self.transfer_row(self.run_report(shareholder=self.shareholder, date="2026-06-30"), transfer)
|
||||
# seller side: the label names the counterparty it went "to"
|
||||
self.assertEqual(row[COL_TRANSFER_TYPE], f"Transfer to {buyer}")
|
||||
|
||||
def test_transfer_type_label_when_shareholder_is_buyer(self):
|
||||
seller = self.create_shareholder("_Test Share Ledger Seller")
|
||||
# the seller must own shares before it can transfer them
|
||||
self.issue_shares(date="2026-06-12", from_no=201, to_no=300, rate=10, shareholder=seller)
|
||||
transfer = self.make_transfer(
|
||||
from_shareholder=seller,
|
||||
to_shareholder=self.shareholder,
|
||||
date="2026-06-15",
|
||||
from_no=201,
|
||||
to_no=240,
|
||||
rate=10,
|
||||
)
|
||||
|
||||
row = self.transfer_row(self.run_report(shareholder=self.shareholder, date="2026-06-30"), transfer)
|
||||
# buyer side: the label names the counterparty it came "from"
|
||||
self.assertEqual(row[COL_TRANSFER_TYPE], f"Transfer from {seller}")
|
||||
|
||||
def test_missing_date_throws(self):
|
||||
self.assertRaises(frappe.ValidationError, execute, frappe._dict(shareholder=self.shareholder))
|
||||
|
||||
def test_missing_shareholder_returns_no_rows(self):
|
||||
data = self.run_report(date="2026-06-30")
|
||||
self.assertEqual(data, [])
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict({"company": COMPANY, **extra})
|
||||
return execute(filters)[1]
|
||||
|
||||
def transfer_row(self, data, transfer_name):
|
||||
row = next((r for r in data if r[COL_SHARE_TRANSFER] == transfer_name), None)
|
||||
self.assertIsNotNone(row, f"Share Transfer {transfer_name} missing from ledger")
|
||||
return row
|
||||
|
||||
def create_shareholder(self, title):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shareholder",
|
||||
"title": title,
|
||||
"company": COMPANY,
|
||||
}
|
||||
).insert()
|
||||
return doc.name
|
||||
|
||||
def issue_shares(self, date, from_no, to_no, rate, shareholder=None):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Share Transfer",
|
||||
"transfer_type": "Issue",
|
||||
"date": date,
|
||||
"to_shareholder": shareholder or self.shareholder,
|
||||
"share_type": "Equity",
|
||||
"from_no": from_no,
|
||||
"to_no": to_no,
|
||||
"no_of_shares": to_no - from_no + 1,
|
||||
"rate": rate,
|
||||
"company": COMPANY,
|
||||
"asset_account": "Cash - _TC",
|
||||
"equity_or_liability_account": "Creditors - _TC",
|
||||
}
|
||||
)
|
||||
doc.submit()
|
||||
return doc.name
|
||||
|
||||
def make_transfer(self, from_shareholder, to_shareholder, date, from_no, to_no, rate):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Share Transfer",
|
||||
"transfer_type": "Transfer",
|
||||
"date": date,
|
||||
"from_shareholder": from_shareholder,
|
||||
"to_shareholder": to_shareholder,
|
||||
"share_type": "Equity",
|
||||
"from_no": from_no,
|
||||
"to_no": to_no,
|
||||
"no_of_shares": to_no - from_no + 1,
|
||||
"rate": rate,
|
||||
"company": COMPANY,
|
||||
"asset_account": "Cash - _TC",
|
||||
"equity_or_liability_account": "Creditors - _TC",
|
||||
}
|
||||
)
|
||||
doc.submit()
|
||||
return doc.name
|
||||
@@ -17,7 +17,7 @@
|
||||
"generate_csv": 0,
|
||||
"idx": 4,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-06-22 13:38:42.740436",
|
||||
"modified": "2026-07-01 17:32:21.801141",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Trial Balance",
|
||||
@@ -37,6 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -583,7 +583,7 @@ def hide_group_accounts(data):
|
||||
return non_group_accounts_data
|
||||
|
||||
|
||||
def execute_synced_report(filters):
|
||||
def execute_snapshot_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if conn := get_latest_sync("GL Entry"):
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.report.voucher_wise_balance.voucher_wise_balance import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestVoucherWiseBalance(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"from_date": "2026-01-01",
|
||||
"to_date": "2026-12-31",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def find_row(self, data, voucher_no):
|
||||
for row in data:
|
||||
if row.get("voucher_no") == voucher_no:
|
||||
return row
|
||||
return None
|
||||
|
||||
def test_balanced_voucher_not_flagged(self):
|
||||
jv = make_journal_entry(
|
||||
"Sales - _TC", "_Test Bank - _TC", 1000, submit=True, posting_date="2026-06-01"
|
||||
)
|
||||
|
||||
data = self.run_report()
|
||||
self.assertIsNone(
|
||||
self.find_row(data, jv.name),
|
||||
msg="A balanced voucher (debit == credit) must not be flagged.",
|
||||
)
|
||||
|
||||
def test_imbalanced_voucher_flagged(self):
|
||||
jv = make_journal_entry(
|
||||
"Sales - _TC", "_Test Bank - _TC", 1000, submit=True, posting_date="2026-06-01"
|
||||
)
|
||||
|
||||
# Tamper one GL Entry: drop the debit side so debit != credit for this voucher.
|
||||
gle_name = frappe.db.get_value(
|
||||
"GL Entry",
|
||||
{"voucher_no": jv.name, "is_cancelled": 0, "debit": [">", 0]},
|
||||
"name",
|
||||
)
|
||||
self.assertIsNotNone(gle_name, msg="Expected a debit GL Entry for the journal entry.")
|
||||
frappe.db.set_value("GL Entry", gle_name, {"debit": 400, "debit_in_account_currency": 400})
|
||||
|
||||
data = self.run_report()
|
||||
row = self.find_row(data, jv.name)
|
||||
self.assertIsNotNone(row, msg="An imbalanced voucher must be flagged by the report.")
|
||||
|
||||
self.assertEqual(row.get("voucher_type"), "Journal Entry")
|
||||
self.assertEqual(row.get("credit"), 1000)
|
||||
self.assertEqual(row.get("debit"), 400)
|
||||
self.assertNotEqual(
|
||||
row.get("debit"), row.get("credit"), msg="Flagged rows must have debit != credit."
|
||||
)
|
||||
@@ -12,7 +12,6 @@ from frappe.utils import cint, flt, parse_json
|
||||
import erpnext
|
||||
from erpnext.stock.get_item_details import (
|
||||
NOT_APPLICABLE_TAX,
|
||||
ItemDetailsCtx,
|
||||
_get_item_tax_template,
|
||||
_get_item_tax_template_from_item_group,
|
||||
get_item_tax_map,
|
||||
@@ -350,7 +349,7 @@ def set_balance_in_account_currency(
|
||||
|
||||
|
||||
def set_child_tax_template_and_map(item, child_item, parent_doc) -> None:
|
||||
ctx = ItemDetailsCtx(
|
||||
ctx = frappe._dict(
|
||||
{
|
||||
"item_code": item.item_code,
|
||||
"posting_date": parent_doc.transaction_date,
|
||||
|
||||
329
erpnext/accounts/workspace/accounts_setup/accounts_setup.json
Normal file
329
erpnext/accounts/workspace/accounts_setup/accounts_setup.json
Normal file
@@ -0,0 +1,329 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"charts": [],
|
||||
"content": "[]",
|
||||
"creation": "2026-06-14 12:44:31.994274",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "database",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Accounts Setup",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 13:43:50.138704",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
"name": "Accounts Setup",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
"public": 1,
|
||||
"quick_lists": [],
|
||||
"roles": [],
|
||||
"sequence_id": 55.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "database",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Setup",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Chart of Accounts",
|
||||
"link_to": "Account",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Chart of Cost Centers",
|
||||
"link_to": "Cost Center",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Account Category",
|
||||
"link_to": "Account Category",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounting Dimension",
|
||||
"link_to": "Accounting Dimension",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Currency",
|
||||
"link_to": "Currency",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Currency Exchange",
|
||||
"link_to": "Currency Exchange",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Finance Book",
|
||||
"link_to": "Finance Book",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Mode of Payment",
|
||||
"link_to": "Mode of Payment",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Term",
|
||||
"link_to": "Payment Term",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Journal Entry Template",
|
||||
"link_to": "Journal Entry Template",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Terms and Conditions",
|
||||
"link_to": "Terms and Conditions",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Company",
|
||||
"link_to": "Company",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Fiscal Year",
|
||||
"link_to": "Fiscal Year",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Taxes",
|
||||
"link_to": "Sales Taxes and Charges Template",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "lock-keyhole-open",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Opening & Closing",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "COA Importer",
|
||||
"link_to": "Chart of Accounts Importer",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Opening Invoice Tool",
|
||||
"link_to": "Opening Invoice Creation Tool",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounting Period",
|
||||
"link_to": "Accounting Period",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "FX Revaluation",
|
||||
"link_to": "Exchange Rate Revaluation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Period Closing Voucher",
|
||||
"link_to": "Period Closing Voucher",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "settings",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounts Settings",
|
||||
"link_to": "Accounts Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Currency Exchange Settings",
|
||||
"link_to": "Currency Exchange Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Accounts Setup",
|
||||
"type": "Workspace"
|
||||
}
|
||||
222
erpnext/accounts/workspace/banking/banking.json
Normal file
222
erpnext/accounts/workspace/banking/banking.json
Normal file
@@ -0,0 +1,222 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"charts": [],
|
||||
"content": "[]",
|
||||
"creation": "2026-06-11 11:51:22.767176",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "circle-dollar-sign",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Banking",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 13:43:50.924019",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Banking",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
"public": 1,
|
||||
"quick_lists": [],
|
||||
"roles": [],
|
||||
"sequence_id": 49.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "book-open-check",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank Clearance",
|
||||
"link_to": "Bank Clearance",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "tool",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank Reconciliation",
|
||||
"link_to": "Bank Reconciliation Tool",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "clipboard-check",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Reconciliation Statement",
|
||||
"link_to": "Bank Reconciliation Statement",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "split",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Unreconcile Payment",
|
||||
"link_to": "Unreconcile Payment",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "link",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Process Payment Reconciliation",
|
||||
"link_to": "Process Payment Reconciliation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "database",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Setup",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank",
|
||||
"link_to": "Bank",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank Account",
|
||||
"link_to": "Bank Account",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank Account Type",
|
||||
"link_to": "Bank Account Type",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank Account Subtype",
|
||||
"link_to": "Bank Account Subtype",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank Guarantee",
|
||||
"link_to": "Bank Guarantee",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Plaid Settings",
|
||||
"link_to": "Plaid Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "scroll-text",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Dunning",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dunning",
|
||||
"link_to": "Dunning",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dunning Type",
|
||||
"link_to": "Dunning Type",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Banking",
|
||||
"type": "Workspace"
|
||||
}
|
||||
104
erpnext/accounts/workspace/budgeting/budgeting.json
Normal file
104
erpnext/accounts/workspace/budgeting/budgeting.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"charts": [],
|
||||
"content": "[]",
|
||||
"creation": "2026-06-14 14:38:20.315394",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "accounting",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Budgeting",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-07-02 04:24:48.116724",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Budgeting",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
"public": 1,
|
||||
"quick_lists": [],
|
||||
"roles": [],
|
||||
"sequence_id": 57.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "briefcase-business",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Budget",
|
||||
"link_to": "Budget",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "badge-cent",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Cost Center",
|
||||
"link_to": "Cost Center",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "accounting",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounting Dimension",
|
||||
"link_to": "Accounting Dimension",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "notepad-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Cost Center Allocation",
|
||||
"link_to": "Cost Center Allocation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "sheet",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Budget Variance",
|
||||
"link_to": "Budget Variance Report",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Budgeting",
|
||||
"type": "Workspace"
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "table",
|
||||
"icon": "sheet",
|
||||
"idx": 1,
|
||||
"indicator_color": "",
|
||||
"is_hidden": 0,
|
||||
@@ -266,9 +266,10 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-05-18 09:49:45.138296",
|
||||
"modified": "2026-06-14 13:44:08.095321",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
"name": "Financial Reports",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
@@ -279,6 +280,417 @@
|
||||
"roles": [],
|
||||
"sequence_id": 5.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "accounting",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Financial Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Balance Sheet",
|
||||
"link_to": "Balance Sheet",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Profit and Loss",
|
||||
"link_to": "Profit and Loss Statement",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Cash Flow",
|
||||
"link_to": "Cash Flow",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Trial Balance",
|
||||
"link_to": "Trial Balance",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Consolidated Report",
|
||||
"link_to": "Consolidated Financial Statement",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Custom Financial Statement",
|
||||
"link_to": "Custom Financial Statement",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Financial Report Template",
|
||||
"link_to": "Financial Report Template",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "book-text",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Ledgers",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "General Ledger",
|
||||
"link_to": "General Ledger",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Customer Ledger",
|
||||
"link_to": "Customer Ledger Summary",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Ledger",
|
||||
"link_to": "Supplier Ledger Summary",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Registers",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounts Receivable",
|
||||
"link_to": "Accounts Receivable",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounts Payable",
|
||||
"link_to": "Accounts Payable",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "AR Summary",
|
||||
"link_to": "Accounts Receivable Summary",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "AP Summary",
|
||||
"link_to": "Accounts Payable Summary",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Register",
|
||||
"link_to": "Sales Register",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Register",
|
||||
"link_to": "Purchase Register",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item-wise sales Register",
|
||||
"link_to": "Item-wise Sales Register",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item-wise Purchase Register",
|
||||
"link_to": "Item-wise Purchase Register",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "dollar-sign",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Profitability",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Gross Profit",
|
||||
"link_to": "Gross Profit",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Profitability Analysis",
|
||||
"link_to": "Profitability Analysis",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Invoice Trends",
|
||||
"link_to": "Sales Invoice Trends",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Invoice Trends",
|
||||
"link_to": "Purchase Invoice Trends",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "scroll-text",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Other Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Trial Balance for Party",
|
||||
"link_to": "Trial Balance for Party",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Period Based On Invoice Date",
|
||||
"link_to": "Payment Period Based On Invoice Date",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Partners Commission",
|
||||
"link_to": "Sales Partners Commission",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Customer Credit Balance",
|
||||
"link_to": "Customer Credit Balance",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Payment Summary",
|
||||
"link_to": "Sales Payment Summary",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Address And Contacts",
|
||||
"link_to": "Address And Contacts",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "UAE VAT 201",
|
||||
"link_to": "UAE VAT 201",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Financial Reports",
|
||||
"type": "Workspace"
|
||||
}
|
||||
|
||||
@@ -587,9 +587,10 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-01-23 11:05:47.246213",
|
||||
"modified": "2026-06-14 13:44:08.471142",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
"name": "Invoicing",
|
||||
"number_cards": [
|
||||
{
|
||||
@@ -617,6 +618,354 @@
|
||||
"roles": [],
|
||||
"sequence_id": 2.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "home",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
"link_to": "Invoicing",
|
||||
"link_type": "Workspace",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
"link_to": "Accounts",
|
||||
"link_type": "Dashboard",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "list-tree",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Chart of Accounts",
|
||||
"link_to": "Account",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "arrow-left-to-line",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Receivables",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Customer",
|
||||
"link_to": "Customer",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Invoice",
|
||||
"link_to": "Sales Invoice",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Credit Note",
|
||||
"link_to": "Sales Invoice",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"route_options": "{\"is_return\": 1}",
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounts Receivable",
|
||||
"link_to": "Accounts Receivable",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "arrow-right-from-line",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Payables",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier",
|
||||
"link_to": "Supplier",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Invoice",
|
||||
"link_to": "Purchase Invoice",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Debit Note",
|
||||
"link_to": "Purchase Invoice",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"route_options": "{\"is_return\": 1}",
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounts Payable",
|
||||
"link_to": "Accounts Payable",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "money-coins-1",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Payments",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Entry",
|
||||
"link_to": "Payment Entry",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Journal Entry",
|
||||
"link_to": "Journal Entry",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Request",
|
||||
"link_to": "Payment Request",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Order",
|
||||
"link_to": "Payment Order",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Reconciliation",
|
||||
"link_to": "Payment Reconciliation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Unreconcile Payment",
|
||||
"link_to": "Unreconcile Payment",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Process Payment Reconciliation",
|
||||
"link_to": "Process Payment Reconciliation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Repost Accounting Ledger",
|
||||
"link_to": "Repost Accounting Ledger",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Repost Payment Ledger",
|
||||
"link_to": "Repost Payment Ledger",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "sheet",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "General Ledger",
|
||||
"link_to": "General Ledger",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Trial Balance",
|
||||
"link_to": "Trial Balance",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Financial Reports",
|
||||
"link_to": "Financial Reports",
|
||||
"link_type": "Workspace",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "settings",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Settings",
|
||||
"link_to": "Accounts Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Invoicing",
|
||||
"type": "Workspace"
|
||||
}
|
||||
|
||||
240
erpnext/accounts/workspace/payments/payments.json
Normal file
240
erpnext/accounts/workspace/payments/payments.json
Normal file
@@ -0,0 +1,240 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"charts": [],
|
||||
"content": "[]",
|
||||
"creation": "2026-06-11 11:51:21.886461",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "receipt-text",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Payments",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 13:43:50.184761",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
"name": "Payments",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
"public": 1,
|
||||
"quick_lists": [],
|
||||
"roles": [],
|
||||
"sequence_id": 47.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
"link_to": "Payments",
|
||||
"link_type": "Dashboard",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "money-coins-1",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Payments",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Entry",
|
||||
"link_to": "Payment Entry",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Journal Entry",
|
||||
"link_to": "Journal Entry",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Request",
|
||||
"link_to": "Payment Request",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Order",
|
||||
"link_to": "Payment Order",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Payment Reconciliation",
|
||||
"link_to": "Payment Reconciliation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Unreconcile Payment",
|
||||
"link_to": "Unreconcile Payment",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Process Payment Reconciliation",
|
||||
"link_to": "Process Payment Reconciliation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Repost Accounting Ledger",
|
||||
"link_to": "Repost Accounting Ledger",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Repost Payment Ledger",
|
||||
"link_to": "Repost Payment Ledger",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "sheet",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounts Receivable",
|
||||
"link_to": "Accounts Receivable",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounts Payable",
|
||||
"link_to": "Accounts Payable",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "General Ledger",
|
||||
"link_to": "General Ledger",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Trial Balance",
|
||||
"link_to": "Trial Balance",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Financial Reports",
|
||||
"link_to": "Financial Reports",
|
||||
"link_type": "Workspace",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Payments",
|
||||
"type": "Workspace"
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"charts": [],
|
||||
"content": "[]",
|
||||
"creation": "2026-06-11 11:51:22.831729",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "money-coins-1",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Share Management",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 13:43:51.040978",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Share Management",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
"public": 1,
|
||||
"quick_lists": [],
|
||||
"roles": [],
|
||||
"sequence_id": 50.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "customer",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Shareholder",
|
||||
"link_to": "Shareholder",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "move-horizontal",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Share Transfer",
|
||||
"link_to": "Share Transfer",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "list",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Share Ledger",
|
||||
"link_to": "Share Ledger",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "notepad-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Share Balance",
|
||||
"link_to": "Share Balance",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Share Management",
|
||||
"type": "Workspace"
|
||||
}
|
||||
121
erpnext/accounts/workspace/subscriptions/subscriptions.json
Normal file
121
erpnext/accounts/workspace/subscriptions/subscriptions.json
Normal file
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"charts": [],
|
||||
"content": "[]",
|
||||
"creation": "2026-06-14 14:08:36.817393",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "accounting",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Subscriptions",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 14:08:36.999272",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Subscriptions",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
"public": 1,
|
||||
"quick_lists": [],
|
||||
"roles": [],
|
||||
"sequence_id": 56.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "circle-dollar-sign",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Subscription",
|
||||
"link_to": "Subscription",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "receipt-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Subscription Plan",
|
||||
"link_to": "Subscription Plan",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "settings",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Subscription Settings",
|
||||
"link_to": "Subscription Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "database",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Setup",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Customer",
|
||||
"link_to": "Customer",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier",
|
||||
"link_to": "Supplier",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item",
|
||||
"link_to": "Item",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Subscriptions",
|
||||
"type": "Workspace"
|
||||
}
|
||||
188
erpnext/accounts/workspace/taxes/taxes.json
Normal file
188
erpnext/accounts/workspace/taxes/taxes.json
Normal file
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"charts": [],
|
||||
"content": "[]",
|
||||
"creation": "2026-06-11 11:51:22.649582",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "money-coins-1",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Taxes",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 13:43:50.894825",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
"name": "Taxes",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
"public": 1,
|
||||
"quick_lists": [],
|
||||
"roles": [],
|
||||
"sequence_id": 48.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "panel-bottom-close",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Tax Template",
|
||||
"link_to": "Sales Taxes and Charges Template",
|
||||
"link_type": "DocType",
|
||||
"navigate_to_tab": "",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "panel-top-close",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Tax Template",
|
||||
"link_to": "Purchase Taxes and Charges Template",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "stock",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item Tax Template",
|
||||
"link_to": "Item Tax Template",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "database",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Setup",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "triangle",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Tax Category",
|
||||
"link_to": "Tax Category",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "book-open-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Tax Rule",
|
||||
"link_to": "Tax Rule",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "book-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Tax Withholding Category",
|
||||
"link_to": "Tax Withholding Category",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Tax Withholding Group",
|
||||
"link_to": "Tax Withholding Group",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "notebook-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Deduction Certificate",
|
||||
"link_to": "Lower Deduction Certificate",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "sheet",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Reports",
|
||||
"link_to": "",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "TDS Computation Summary",
|
||||
"link_to": "TDS Computation Summary",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Tax Withholding Details",
|
||||
"link_to": "Tax Withholding Details",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Taxes",
|
||||
"type": "Workspace"
|
||||
}
|
||||
@@ -93,6 +93,11 @@ frappe.ui.form.on("Asset", {
|
||||
frappe.ui.form.trigger("Asset", "asset_type");
|
||||
frm.toggle_display("next_depreciation_date", frm.doc.docstatus < 1);
|
||||
|
||||
if (frm.doc.docstatus < 1 && frm.doc.calculate_depreciation && frm.doc.is_fully_depreciated) {
|
||||
// Is Fully Depreciated is read-only while depreciation is calculated, so keep it unchecked
|
||||
frm.set_value("is_fully_depreciated", 0);
|
||||
}
|
||||
|
||||
let has_create_buttons = false;
|
||||
if (frm.doc.docstatus == 1) {
|
||||
if (["Submitted", "Partially Depreciated"].includes(frm.doc.status)) {
|
||||
@@ -727,6 +732,10 @@ frappe.ui.form.on("Asset", {
|
||||
|
||||
calculate_depreciation: function (frm) {
|
||||
frm.toggle_reqd("finance_books", frm.doc.calculate_depreciation);
|
||||
if (frm.doc.calculate_depreciation && frm.doc.is_fully_depreciated) {
|
||||
// Is Fully Depreciated is read-only while depreciation is calculated, so keep it unchecked
|
||||
frm.set_value("is_fully_depreciated", 0);
|
||||
}
|
||||
if (frm.doc.item_code && frm.doc.calculate_depreciation && frm.doc.net_purchase_amount) {
|
||||
frm.trigger("set_finance_book");
|
||||
} else {
|
||||
|
||||
@@ -450,10 +450,11 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:(doc.asset_type == \"Existing Asset\" && !doc.calculate_depreciation) || doc.calculate_depreciation",
|
||||
"fieldname": "is_fully_depreciated",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Fully Depreciated"
|
||||
"label": "Is Fully Depreciated",
|
||||
"read_only_depends_on": "eval:doc.calculate_depreciation"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.docstatus > 0",
|
||||
|
||||
@@ -132,6 +132,10 @@ class Asset(AccountsController):
|
||||
self.validate_gross_and_purchase_amount()
|
||||
self.validate_finance_books()
|
||||
|
||||
if self.calculate_depreciation:
|
||||
# Is Fully Depreciated is only applicable to manually entered existing assets
|
||||
self.is_fully_depreciated = 0
|
||||
|
||||
def before_save(self):
|
||||
self.total_asset_cost = self.net_purchase_amount + self.additional_asset_cost
|
||||
self.status = self.get_status()
|
||||
|
||||
@@ -187,6 +187,7 @@ def make_depreciation_entry(
|
||||
for d in depr_schedule_doc.get("depreciation_schedule")[
|
||||
(sch_start_idx or 0) : (sch_end_idx or len(depr_schedule_doc.get("depreciation_schedule")))
|
||||
]:
|
||||
frappe.db.savepoint("depr_entry")
|
||||
try:
|
||||
_make_journal_entry_for_depreciation(
|
||||
depr_schedule_doc,
|
||||
@@ -202,6 +203,7 @@ def make_depreciation_entry(
|
||||
accounting_dimensions,
|
||||
)
|
||||
except Exception as e:
|
||||
frappe.db.rollback(save_point="depr_entry")
|
||||
depr_posting_error = e
|
||||
|
||||
asset.reload()
|
||||
|
||||
@@ -500,7 +500,7 @@ def get_target_item_details(item_code: str | None = None, company: str | None =
|
||||
item_group_defaults = get_item_group_defaults(item.name, company)
|
||||
brand_defaults = get_brand_defaults(item.name, company)
|
||||
out.cost_center = get_default_cost_center(
|
||||
ItemDetailsCtx({"item_code": item.name, "company": company}),
|
||||
frappe._dict({"item_code": item.name, "company": company}),
|
||||
item_defaults,
|
||||
item_group_defaults,
|
||||
brand_defaults,
|
||||
|
||||
@@ -139,6 +139,7 @@ class AssetMovement(Document):
|
||||
.select(asm_item.target_location, asm_item.to_employee)
|
||||
.where((asm_item.asset == asset) & (asm.company == self.company) & (asm.docstatus == 1))
|
||||
.orderby(asm.transaction_date, order=frappe.qb.desc)
|
||||
.orderby(asm.name, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
.run()
|
||||
)
|
||||
|
||||
@@ -199,9 +199,10 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2025-12-31 16:22:38.132729",
|
||||
"modified": "2026-06-14 13:44:08.417956",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"module_onboarding": "Asset Onboarding",
|
||||
"name": "Assets",
|
||||
"number_cards": [],
|
||||
"owner": "Administrator",
|
||||
@@ -212,6 +213,294 @@
|
||||
"roles": [],
|
||||
"sequence_id": 7.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "home",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
"link_to": "Assets",
|
||||
"link_type": "Workspace",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
"link_to": "Asset",
|
||||
"link_type": "Dashboard",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "laptop",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset",
|
||||
"link_to": "Asset",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "trending-down",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Depreciation Schedule",
|
||||
"link_to": "Asset Depreciation Schedule",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "sprout",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Capitalization",
|
||||
"link_to": "Asset Capitalization",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "move-horizontal",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Movement",
|
||||
"link_to": "Asset Movement",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "getting-started",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Maintenance",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Maintenance Team",
|
||||
"link_to": "Asset Maintenance Team",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Maintenance",
|
||||
"link_to": "Asset Maintenance",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Maintenance Log",
|
||||
"link_to": "Asset Maintenance Log",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Value Adjustment",
|
||||
"link_to": "Asset Value Adjustment",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Repair",
|
||||
"link_to": "Asset Repair",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "sheet",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Fixed Asset Register",
|
||||
"link_to": "Fixed Asset Register",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Depreciation Ledger",
|
||||
"link_to": "Asset Depreciation Ledger",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Depreciations and Balances",
|
||||
"link_to": "Asset Depreciations and Balances",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Maintenance",
|
||||
"link_to": "Asset Maintenance",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Activity",
|
||||
"link_to": "Asset Activity",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "database",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Setup",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item",
|
||||
"link_to": "Item",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Asset Category",
|
||||
"link_to": "Asset Category",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Location",
|
||||
"link_to": "Location",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "settings",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Settings",
|
||||
"link_to": "Accounts Settings",
|
||||
"link_type": "DocType",
|
||||
"navigate_to_tab": "assets_tab",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link",
|
||||
"url": ""
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Assets",
|
||||
"type": "Workspace"
|
||||
}
|
||||
|
||||
@@ -232,9 +232,11 @@ def make_subcontracting_order(
|
||||
target_doc.save()
|
||||
|
||||
if submit and frappe.has_permission(target_doc.doctype, "submit", target_doc):
|
||||
frappe.db.savepoint("submit_subcontracting_order")
|
||||
try:
|
||||
target_doc.submit()
|
||||
except Exception as e:
|
||||
frappe.db.rollback(save_point="submit_subcontracting_order")
|
||||
target_doc.add_comment("Comment", _("Submit Action Failed") + "<br><br>" + str(e))
|
||||
|
||||
if notify:
|
||||
|
||||
@@ -1531,11 +1531,11 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
(via the standard item lookup the form uses) without going through
|
||||
the Sales Order → Purchase Order mapping pipeline.
|
||||
"""
|
||||
from erpnext.stock.get_item_details import ItemDetailsCtx, get_item_details
|
||||
from erpnext.stock.get_item_details import get_item_details
|
||||
|
||||
item = make_item("_Test Drop Ship From Master", {"is_stock_item": 1, "delivered_by_supplier": 1})
|
||||
|
||||
ctx = ItemDetailsCtx(
|
||||
ctx = frappe._dict(
|
||||
{
|
||||
"item_code": item.item_code,
|
||||
"doctype": "Purchase Order",
|
||||
|
||||
@@ -55,9 +55,16 @@ class SupplierScorecard(Document):
|
||||
self.update_standing()
|
||||
|
||||
def on_update(self):
|
||||
score = make_all_scorecards(self.name)
|
||||
if score > 0:
|
||||
self.save()
|
||||
# Guard against recursion: the save() below re-enters on_update().
|
||||
if self.flags.in_rescore:
|
||||
return
|
||||
if make_all_scorecards(self.name) > 0:
|
||||
# New periods were created; re-save to refresh score and standings.
|
||||
self.flags.in_rescore = True
|
||||
try:
|
||||
self.save()
|
||||
finally:
|
||||
self.flags.in_rescore = False
|
||||
|
||||
def validate_standings(self):
|
||||
# Standings must form a continuous chain of bands covering 0 to 100 with no gaps or overlaps
|
||||
@@ -405,16 +412,10 @@ def get_default_scorecard_standing():
|
||||
def make_default_records():
|
||||
install_variable_docs = get_default_scorecard_variables()
|
||||
for d in install_variable_docs:
|
||||
try:
|
||||
d["doctype"] = "Supplier Scorecard Variable"
|
||||
frappe.get_doc(d).insert()
|
||||
except frappe.NameError:
|
||||
pass
|
||||
d["doctype"] = "Supplier Scorecard Variable"
|
||||
frappe.get_doc(d).insert(ignore_if_duplicate=True)
|
||||
|
||||
install_standing_docs = get_default_scorecard_standing()
|
||||
for d in install_standing_docs:
|
||||
try:
|
||||
d["doctype"] = "Supplier Scorecard Standing"
|
||||
frappe.get_doc(d).insert()
|
||||
except frappe.NameError:
|
||||
pass
|
||||
d["doctype"] = "Supplier Scorecard Standing"
|
||||
frappe.get_doc(d).insert(ignore_if_duplicate=True)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import (
|
||||
create_pr_against_po,
|
||||
create_purchase_order,
|
||||
)
|
||||
from erpnext.buying.report.item_wise_purchase_history.item_wise_purchase_history import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestItemWisePurchaseHistory(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"from_date": "2026-01-01",
|
||||
"to_date": "2026-12-31",
|
||||
**extra,
|
||||
}
|
||||
)
|
||||
return execute(filters)
|
||||
|
||||
def po_row(self, po_name, **extra):
|
||||
data = self.run_report(**extra)[1]
|
||||
return next(row for row in data if row["purchase_order"] == po_name)
|
||||
|
||||
def test_purchase_order_line_shown_with_values(self):
|
||||
po = create_purchase_order(qty=10, rate=500, transaction_date="2026-06-01")
|
||||
|
||||
row = self.po_row(po.name)
|
||||
self.assertEqual(row["item_code"], "_Test Item")
|
||||
self.assertEqual(row["quantity"], 10)
|
||||
self.assertEqual(row["rate"], 500)
|
||||
self.assertEqual(row["amount"], 5000)
|
||||
self.assertEqual(row["supplier"], "_Test Supplier")
|
||||
|
||||
def test_draft_purchase_order_excluded(self):
|
||||
po = create_purchase_order(transaction_date="2026-06-01", do_not_submit=True)
|
||||
|
||||
names = {row["purchase_order"] for row in self.run_report()[1]}
|
||||
self.assertNotIn(po.name, names)
|
||||
|
||||
def test_date_range_filters_on_transaction_date(self):
|
||||
po = create_purchase_order(transaction_date="2026-06-01")
|
||||
|
||||
in_range = {
|
||||
row["purchase_order"] for row in self.run_report(from_date="2026-05-01", to_date="2026-07-01")[1]
|
||||
}
|
||||
self.assertIn(po.name, in_range)
|
||||
|
||||
out_of_range = {
|
||||
row["purchase_order"] for row in self.run_report(from_date="2026-01-01", to_date="2026-03-01")[1]
|
||||
}
|
||||
self.assertNotIn(po.name, out_of_range)
|
||||
|
||||
def test_item_code_filter(self):
|
||||
po = create_purchase_order(
|
||||
transaction_date="2026-06-01",
|
||||
rm_items=[
|
||||
{"item_code": "_Test Item", "qty": 5, "rate": 500, "warehouse": "_Test Warehouse - _TC"},
|
||||
{"item_code": "_Test Item 2", "qty": 3, "rate": 200, "warehouse": "_Test Warehouse - _TC"},
|
||||
],
|
||||
)
|
||||
|
||||
rows = self.run_report(item_code="_Test Item 2")[1]
|
||||
self.assertEqual({row["item_code"] for row in rows}, {"_Test Item 2"})
|
||||
# the filtered-out line of the same order must not leak in
|
||||
self.assertTrue(all(row["purchase_order"] == po.name for row in rows))
|
||||
|
||||
def test_item_group_filter(self):
|
||||
# _Test Item is in _Test Item Group; _Test FG Item is in _Test Item Group Desktops
|
||||
po_test_group = create_purchase_order(item_code="_Test Item", transaction_date="2026-06-01")
|
||||
po_other_group = create_purchase_order(item_code="_Test FG Item", transaction_date="2026-06-01")
|
||||
|
||||
names = {row["purchase_order"] for row in self.run_report(item_group="_Test Item Group")[1]}
|
||||
self.assertIn(po_test_group.name, names)
|
||||
self.assertNotIn(po_other_group.name, names)
|
||||
|
||||
def test_supplier_filter(self):
|
||||
create_purchase_order(supplier="_Test Supplier", transaction_date="2026-06-01")
|
||||
create_purchase_order(supplier="_Test Supplier 1", transaction_date="2026-06-01")
|
||||
|
||||
suppliers = {row["supplier"] for row in self.run_report(supplier="_Test Supplier")[1]}
|
||||
self.assertEqual(suppliers, {"_Test Supplier"})
|
||||
|
||||
def test_received_quantity_reflects_receipt(self):
|
||||
po = create_purchase_order(qty=10, rate=500, transaction_date="2026-06-01")
|
||||
create_pr_against_po(po.name, received_qty=4)
|
||||
|
||||
self.assertEqual(self.po_row(po.name)["received_qty"], 4)
|
||||
|
||||
def test_billed_amount_reflects_invoice(self):
|
||||
po = create_purchase_order(qty=10, rate=500, transaction_date="2026-06-01")
|
||||
pi = make_purchase_invoice(po.name)
|
||||
pi.insert()
|
||||
pi.submit()
|
||||
|
||||
self.assertEqual(self.po_row(po.name)["billed_amt"], 5000)
|
||||
|
||||
def test_amounts_reported_in_company_currency(self):
|
||||
# a USD order must report rate/amount converted to the company's currency (base_* fields)
|
||||
po = create_purchase_order(
|
||||
do_not_save=True,
|
||||
currency="USD",
|
||||
qty=10,
|
||||
rate=100,
|
||||
transaction_date="2026-06-01",
|
||||
)
|
||||
po.conversion_rate = 80
|
||||
po.insert()
|
||||
po.submit()
|
||||
|
||||
row = self.po_row(po.name)
|
||||
self.assertEqual(row["rate"], 8000) # 100 USD * 80
|
||||
self.assertEqual(row["amount"], 80000) # 10 * 100 USD * 80
|
||||
|
||||
def test_chart_aggregates_amount_per_item(self):
|
||||
create_purchase_order(item_code="_Test Item", qty=2, rate=500, transaction_date="2026-06-01")
|
||||
create_purchase_order(item_code="_Test Item", qty=3, rate=500, transaction_date="2026-06-01")
|
||||
|
||||
chart = self.run_report(item_code="_Test Item")[3]
|
||||
labels = chart["data"]["labels"]
|
||||
values = chart["data"]["datasets"][0]["values"]
|
||||
self.assertIn("_Test Item", labels)
|
||||
# 2*500 + 3*500 aggregated for the item
|
||||
self.assertEqual(values[labels.index("_Test Item")], 2500)
|
||||
@@ -0,0 +1,93 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.buying.report.purchase_analytics.purchase_analytics import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
SUPPLIER = "_Test Supplier"
|
||||
SUPPLIER_GROUP = "_Test Supplier Group"
|
||||
# A historical window that ordinary test fixtures don't post into.
|
||||
FROM_DATE = "2019-04-01"
|
||||
TO_DATE = "2019-06-30"
|
||||
|
||||
|
||||
class TestPurchaseAnalytics(ERPNextTestSuite):
|
||||
"""purchase_analytics reuses the shared Analytics engine; these tests lock its
|
||||
wiring (doc_type=Purchase Order) across the Supplier Group / Item Group trees."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def _filters(self, **overrides):
|
||||
filters = {
|
||||
"doc_type": "Purchase Order",
|
||||
"value_quantity": "Value",
|
||||
"range": "Monthly",
|
||||
"company": COMPANY,
|
||||
"from_date": FROM_DATE,
|
||||
"to_date": TO_DATE,
|
||||
}
|
||||
filters.update(overrides)
|
||||
return frappe._dict(filters)
|
||||
|
||||
def _rows(self, filters):
|
||||
return {row["entity"]: row for row in execute(filters)[1]}
|
||||
|
||||
def make_po(self, qty=4, rate=250):
|
||||
return create_purchase_order(
|
||||
company=COMPANY, supplier=SUPPLIER, qty=qty, rate=rate, transaction_date="2019-04-10"
|
||||
)
|
||||
|
||||
def test_supplier_group_tree_rolls_up_to_root(self):
|
||||
filters = self._filters(tree_type="Supplier Group")
|
||||
base = self._rows(filters)
|
||||
base_group = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0))
|
||||
|
||||
po = self.make_po(qty=4, rate=250)
|
||||
rows = self._rows(filters)
|
||||
|
||||
# supplier is remapped to its group; the root sits at indent 0
|
||||
self.assertIn(SUPPLIER_GROUP, rows)
|
||||
self.assertIn("All Supplier Groups", rows)
|
||||
self.assertNotIn(SUPPLIER, rows)
|
||||
self.assertEqual(rows["All Supplier Groups"]["indent"], 0)
|
||||
|
||||
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_group, flt(po.base_net_total), places=2)
|
||||
self.assertGreaterEqual(flt(rows["All Supplier Groups"]["total"]), flt(po.base_net_total))
|
||||
|
||||
def test_item_group_tree_rolls_up_to_root(self):
|
||||
item_group = frappe.db.get_value("Item", "_Test Item", "item_group")
|
||||
filters = self._filters(tree_type="Item Group")
|
||||
base = self._rows(filters)
|
||||
base_group = flt(base.get(item_group, {}).get("total", 0.0))
|
||||
|
||||
po = self.make_po(qty=4, rate=250)
|
||||
rows = self._rows(filters)
|
||||
|
||||
self.assertIn(item_group, rows)
|
||||
self.assertIn("All Item Groups", rows)
|
||||
# the raw item code must not leak as its own entity; the root sits at indent 0
|
||||
self.assertNotIn("_Test Item", rows)
|
||||
self.assertEqual(rows["All Item Groups"]["indent"], 0)
|
||||
self.assertAlmostEqual(rows[item_group]["total"] - base_group, flt(po.base_net_total), places=2)
|
||||
self.assertGreaterEqual(flt(rows["All Item Groups"]["total"]), flt(po.base_net_total))
|
||||
|
||||
def test_supplier_group_by_quantity(self):
|
||||
filters = self._filters(tree_type="Supplier Group", value_quantity="Quantity")
|
||||
base = self._rows(filters)
|
||||
base_qty = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0))
|
||||
base_root_qty = flt(base.get("All Supplier Groups", {}).get("total", 0.0))
|
||||
|
||||
po = self.make_po(qty=7, rate=100)
|
||||
rows = self._rows(filters)
|
||||
|
||||
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_qty, flt(po.total_qty), places=2)
|
||||
# the quantity must roll up to the root too, not just the leaf group
|
||||
self.assertAlmostEqual(
|
||||
rows["All Supplier Groups"]["total"] - base_root_qty, flt(po.total_qty), places=2
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.buying.report.subcontract_order_summary.subcontract_order_summary import execute
|
||||
from erpnext.controllers.tests.test_subcontracting_controller import (
|
||||
get_subcontracting_order,
|
||||
make_bom_for_subcontracted_items,
|
||||
make_raw_materials,
|
||||
make_service_items,
|
||||
make_subcontracted_items,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
FG_ITEM = "Subcontracted Item SA7"
|
||||
|
||||
|
||||
class TestSubcontractOrderSummary(ERPNextTestSuite):
|
||||
"""The report lists Subcontracting Order finished items with their ordered and
|
||||
received quantities within the transaction-date window."""
|
||||
|
||||
def setUp(self):
|
||||
make_subcontracted_items()
|
||||
make_raw_materials()
|
||||
make_service_items()
|
||||
make_bom_for_subcontracted_items()
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{"company": "_Test Company", "from_date": add_days(today(), -1), "to_date": add_days(today(), 1)}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def test_subcontracting_order_is_listed(self):
|
||||
sco = get_subcontracting_order()
|
||||
|
||||
rows = [r for r in self.run_report(name=sco.name) if r.get("item_code") == FG_ITEM]
|
||||
self.assertTrue(rows, "Subcontracting Order finished item missing from report")
|
||||
self.assertEqual(rows[0]["qty"], 10)
|
||||
self.assertEqual(rows[0]["received_qty"], 0) # nothing received yet
|
||||
|
||||
def test_out_of_range_date_excludes_order(self):
|
||||
sco = get_subcontracting_order()
|
||||
|
||||
data = self.run_report(name=sco.name, from_date="2019-01-01", to_date="2019-01-31")
|
||||
self.assertEqual(data, [])
|
||||
@@ -0,0 +1,66 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.buying.report.supplier_quotation_comparison.supplier_quotation_comparison import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
ITEM = "_Test Item"
|
||||
|
||||
|
||||
class TestSupplierQuotationComparison(ERPNextTestSuite):
|
||||
"""The report lists Supplier Quotation item lines so quotes for the same item can
|
||||
be compared across suppliers."""
|
||||
|
||||
def make_quotation(self, supplier, qty, rate, uom=None):
|
||||
item = {"item_code": ITEM, "qty": qty, "rate": rate, "warehouse": "_Test Warehouse - _TC"}
|
||||
if uom:
|
||||
item["uom"] = uom
|
||||
sq = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Supplier Quotation",
|
||||
"supplier": supplier,
|
||||
"company": COMPANY,
|
||||
"currency": "INR",
|
||||
"transaction_date": "2026-06-01",
|
||||
"items": [item],
|
||||
}
|
||||
)
|
||||
sq.insert()
|
||||
sq.submit()
|
||||
return sq
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict({"company": COMPANY, "from_date": "2026-01-01", "to_date": "2026-12-31"})
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def test_no_filters_returns_empty(self):
|
||||
self.assertEqual(execute(None)[1], [])
|
||||
|
||||
def test_quotation_line_listed_with_price(self):
|
||||
# _Test UOM 1 converts at 10 stock units per qty, so price_per_unit
|
||||
# (amount / stock_qty) diverges from base_rate and the division path is tested
|
||||
sq = self.make_quotation("_Test Supplier", qty=10, rate=100, uom="_Test UOM 1")
|
||||
|
||||
rows = [r for r in self.run_report(item_code=ITEM) if r.get("quotation") == sq.name]
|
||||
self.assertTrue(rows, "Supplier Quotation line missing from report")
|
||||
row = rows[0]
|
||||
self.assertEqual(row["supplier_name"], "_Test Supplier")
|
||||
self.assertEqual(row["qty"], 10)
|
||||
self.assertEqual(row["base_rate"], 100)
|
||||
self.assertEqual(row["base_amount"], 1000)
|
||||
# 1000 amount / (10 qty * 10 conversion) = 10, distinct from the 100 base_rate
|
||||
self.assertEqual(row["price_per_unit"], 10)
|
||||
|
||||
def test_compares_multiple_suppliers_for_item(self):
|
||||
sq1 = self.make_quotation("_Test Supplier", qty=10, rate=100)
|
||||
sq2 = self.make_quotation("_Test Supplier 1", qty=10, rate=120)
|
||||
|
||||
quotes = {r["quotation"]: r for r in self.run_report(item_code=ITEM)}
|
||||
self.assertIn(sq1.name, quotes)
|
||||
self.assertIn(sq2.name, quotes)
|
||||
self.assertEqual(quotes[sq1.name]["base_rate"], 100)
|
||||
self.assertEqual(quotes[sq2.name]["base_rate"], 120)
|
||||
@@ -341,17 +341,6 @@
|
||||
"onboard": 1,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 1,
|
||||
"label": "Item Wise Consumption",
|
||||
"link_count": 0,
|
||||
"link_to": "Item Wise Consumption",
|
||||
"link_type": "Report",
|
||||
"onboard": 1,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
@@ -512,9 +501,10 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-01-02 14:55:59.078773",
|
||||
"modified": "2026-06-14 13:43:50.509039",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"module_onboarding": "Buying Onboarding",
|
||||
"name": "Buying",
|
||||
"number_cards": [
|
||||
{
|
||||
@@ -538,6 +528,403 @@
|
||||
"roles": [],
|
||||
"sequence_id": 5.0,
|
||||
"shortcuts": [],
|
||||
"sidebar_items": [
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "home",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
"link_to": "Buying",
|
||||
"link_type": "Workspace",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
"link_to": "Buying",
|
||||
"link_type": "Dashboard",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "notepad-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Material Request",
|
||||
"link_to": "Material Request",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "git-pull-request-arrow",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Request for Quotation",
|
||||
"link_to": "Request for Quotation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "book-open-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Quotation",
|
||||
"link_to": "Supplier Quotation",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "receipt-text",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Order",
|
||||
"link_to": "Purchase Order",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "liabilities",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Invoice",
|
||||
"link_to": "Purchase Invoice",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "database",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Setup",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier",
|
||||
"link_to": "Supplier",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Group",
|
||||
"link_to": "Supplier Group",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item",
|
||||
"link_to": "Item",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Price List",
|
||||
"link_to": "Price List",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Address",
|
||||
"link_to": "Address",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Contacts",
|
||||
"link_to": "Contact",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Scorecard",
|
||||
"link_to": "Supplier Scorecard",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Scorecard Criteria",
|
||||
"link_to": "Supplier Scorecard Criteria",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Scorecard Variable",
|
||||
"link_to": "Supplier Scorecard Variable",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Scorecard Standing",
|
||||
"link_to": "Supplier Scorecard Standing",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "sheet",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Analytics",
|
||||
"link_to": "Purchase Analytics",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Order Analysis",
|
||||
"link_to": "Purchase Order Analysis",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Requested Items to Order and Receive",
|
||||
"link_to": "Requested Items to Order and Receive",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Items To Be Requested",
|
||||
"link_to": "Items To Be Requested",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item-wise Purchase History",
|
||||
"link_to": "Item-wise Purchase History",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Receipt Trends ",
|
||||
"link_to": "Purchase Receipt Trends",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Invoice Trends",
|
||||
"link_to": "Purchase Invoice Trends",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Order Trends",
|
||||
"link_to": "Purchase Order Trends",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Procurement Tracker",
|
||||
"link_to": "Procurement Tracker",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item Wise Consumption",
|
||||
"link_to": "Item Wise Consumption",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Quotation Comparison",
|
||||
"link_to": "Supplier Quotation Comparison",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Supplier Addresses And Contacts",
|
||||
"link_to": "Address And Contacts",
|
||||
"link_type": "Report",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "settings",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Settings",
|
||||
"link_to": "Buying Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"standard": 1,
|
||||
"title": "Buying",
|
||||
"type": "Workspace"
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ from erpnext.controllers.sales_and_purchase_return import validate_return
|
||||
from erpnext.setup.utils import get_exchange_rate
|
||||
from erpnext.stock.doctype.item.item import get_uom_conv_factor
|
||||
from erpnext.stock.get_item_details import (
|
||||
ItemDetailsCtx,
|
||||
get_item_details,
|
||||
)
|
||||
from erpnext.utilities.regional import temporary_flag
|
||||
@@ -782,7 +781,7 @@ class AccountsController(TransactionBase):
|
||||
|
||||
for item in self.get("items"):
|
||||
if item.get("item_code"):
|
||||
ctx: ItemDetailsCtx = ItemDetailsCtx(parent_dict.copy())
|
||||
ctx: frappe._dict = frappe._dict(parent_dict.copy())
|
||||
ctx.update(item.as_dict())
|
||||
|
||||
ctx.update(
|
||||
|
||||
@@ -25,7 +25,7 @@ from pypika import Order
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.utils import build_qb_match_conditions
|
||||
from erpnext.stock.get_item_details import ItemDetailsCtx, _get_item_tax_template
|
||||
from erpnext.stock.get_item_details import _get_item_tax_template
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
from erpnext.utilities.query import get_filter_conditions_qb
|
||||
|
||||
@@ -1056,7 +1056,7 @@ def get_tax_template(doctype: str, txt: str, searchfield: str, start: int, page_
|
||||
valid_from = filters.get("valid_from")
|
||||
valid_from = valid_from[1] if isinstance(valid_from, list) else valid_from
|
||||
|
||||
ctx = ItemDetailsCtx(
|
||||
ctx = frappe._dict(
|
||||
{
|
||||
"item_code": filters.get("item_code"),
|
||||
"posting_date": valid_from,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user