mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-24 11:29:48 +00:00
Compare commits
484 Commits
chore/ar-a
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b0e1b61f2 | ||
|
|
67d314f32f | ||
|
|
8bd8b28207 | ||
|
|
314aa303e5 | ||
|
|
3da7eefebb | ||
|
|
13b6c4a165 | ||
|
|
eba851b4b5 | ||
|
|
b919a7abff | ||
|
|
a0cc645725 | ||
|
|
882f83ffaf | ||
|
|
afca370fa8 | ||
|
|
c1006e79a4 | ||
|
|
8fd0813614 | ||
|
|
ead694c9cb | ||
|
|
21541e3ad3 | ||
|
|
9d28bea453 | ||
|
|
40960a5ff9 | ||
|
|
022845e4e7 | ||
|
|
1e37c4b9ac | ||
|
|
32e971e374 | ||
|
|
1f86b57f94 | ||
|
|
c989e424f0 | ||
|
|
49e3830e7f | ||
|
|
b356dbd59e | ||
|
|
a0bbca166f | ||
|
|
4fb781ae54 | ||
|
|
2b1a477fc8 | ||
|
|
e4e6e52a4d | ||
|
|
c868de324d | ||
|
|
934b1ff7dd | ||
|
|
0ab812c3ec | ||
|
|
116ef44ddb | ||
|
|
8591a0b6ad | ||
|
|
8960e3ff4a | ||
|
|
3859919263 | ||
|
|
20e6a6e149 | ||
|
|
3d00c93822 | ||
|
|
9fdeb5f991 | ||
|
|
4aef3aa5b3 | ||
|
|
df6437c49d | ||
|
|
247d574283 | ||
|
|
3b1b315bf4 | ||
|
|
07a86b33e6 | ||
|
|
abe9e8becc | ||
|
|
54cfeaf357 | ||
|
|
71685532bd | ||
|
|
727f8d0967 | ||
|
|
334f1cc6f0 | ||
|
|
d48396cb11 | ||
|
|
affd2fd95d | ||
|
|
297153264b | ||
|
|
27ec5eabc6 | ||
|
|
7b659ee6af | ||
|
|
4de1064ef6 | ||
|
|
f751f80158 | ||
|
|
c79febf403 | ||
|
|
453b5cee21 | ||
|
|
d7e9a97f8a | ||
|
|
2f4e78f09e | ||
|
|
733e24faef | ||
|
|
6a237f323f | ||
|
|
d032e93f87 | ||
|
|
d1ffac36c1 | ||
|
|
331f383777 | ||
|
|
d225532595 | ||
|
|
ce4e56336e | ||
|
|
359717115f | ||
|
|
fc9544435e | ||
|
|
553b55b2f0 | ||
|
|
ca908b69cf | ||
|
|
48d5e8732b | ||
|
|
28b5efcbe1 | ||
|
|
b0b20edd3e | ||
|
|
060b0df55e | ||
|
|
75030bab0f | ||
|
|
34293d107b | ||
|
|
8a20c9f681 | ||
|
|
f11f8cb005 | ||
|
|
16b27ecdd1 | ||
|
|
bde630b888 | ||
|
|
9f1915800f | ||
|
|
dc4eee49cc | ||
|
|
b4aae9dea1 | ||
|
|
295dec24db | ||
|
|
edf1341f42 | ||
|
|
90ef4f4776 | ||
|
|
3251b40365 | ||
|
|
ff737df55f | ||
|
|
50c4ee4ccb | ||
|
|
ad237e5ec5 | ||
|
|
fadad2d1c4 | ||
|
|
f026d1dac8 | ||
|
|
a1ed913eba | ||
|
|
3ed305c75c | ||
|
|
da4cf77d97 | ||
|
|
13f9130d42 | ||
|
|
98e8d5690e | ||
|
|
32216bd75b | ||
|
|
d694ad9428 | ||
|
|
eee1fdf276 | ||
|
|
a120bf8363 | ||
|
|
d48cffd1a5 | ||
|
|
92047e896c | ||
|
|
624844d52f | ||
|
|
c24fc063fc | ||
|
|
43d2c7335d | ||
|
|
8f69697212 | ||
|
|
3f832d4ee0 | ||
|
|
d48a1e0d16 | ||
|
|
aa7402b1e3 | ||
|
|
a218b8db8c | ||
|
|
9b8c363bed | ||
|
|
23bbcca97e | ||
|
|
5008b82f90 | ||
|
|
9436ab7f19 | ||
|
|
c38bab7e5e | ||
|
|
eafb0019bf | ||
|
|
2d54f651cd | ||
|
|
d0184e07b3 | ||
|
|
943c6d210a | ||
|
|
0b1d06d46d | ||
|
|
2fe0601a2e | ||
|
|
c5ff32aa2f | ||
|
|
7d205c89ea | ||
|
|
8cb94ebedb | ||
|
|
afed7884d4 | ||
|
|
bcd850c808 | ||
|
|
eaab71a99e | ||
|
|
324f72ce4d | ||
|
|
98f5116a09 | ||
|
|
b4c9827318 | ||
|
|
95b82eeba8 | ||
|
|
b26c09ce8a | ||
|
|
19ba681e16 | ||
|
|
1b3cde9d44 | ||
|
|
daee9cc89c | ||
|
|
ee0635246f | ||
|
|
4ba1f5214e | ||
|
|
fc0dee5730 | ||
|
|
f1b6a7d690 | ||
|
|
ef81caeb3d | ||
|
|
d68f7ea9d1 | ||
|
|
4c6b030a4b | ||
|
|
bbb7384ea5 | ||
|
|
017e09eaac | ||
|
|
14b1250ea7 | ||
|
|
dd4371e35b | ||
|
|
618ee6ddeb | ||
|
|
61c2e7ad6e | ||
|
|
7256fc98e9 | ||
|
|
5e16d41387 | ||
|
|
599b1bab60 | ||
|
|
73459af908 | ||
|
|
cfaaf43381 | ||
|
|
7b84478c70 | ||
|
|
bae0263990 | ||
|
|
c1a3685d14 | ||
|
|
3e2d61262a | ||
|
|
4a690c86d2 | ||
|
|
b129daedc8 | ||
|
|
23f1fc6235 | ||
|
|
19466b24b0 | ||
|
|
63c5dccb4b | ||
|
|
8218875733 | ||
|
|
e422c4d2ab | ||
|
|
a342db38de | ||
|
|
57f5186dff | ||
|
|
8fc7cb0117 | ||
|
|
48d49cdcd2 | ||
|
|
f001d13447 | ||
|
|
6ef9020134 | ||
|
|
c8e294b416 | ||
|
|
9abdf8527e | ||
|
|
cf075bd67e | ||
|
|
e26a499923 | ||
|
|
53491e2008 | ||
|
|
29c29fd335 | ||
|
|
c188ed59ec | ||
|
|
8fef286327 | ||
|
|
ea45d41314 | ||
|
|
ef53319183 | ||
|
|
b8bbcda047 | ||
|
|
d65098fe24 | ||
|
|
8f1c703871 | ||
|
|
7f6004bfd9 | ||
|
|
d0988dc32c | ||
|
|
dbd1388b40 | ||
|
|
6395d968ad | ||
|
|
a8b6bcacc5 | ||
|
|
130c2594e1 | ||
|
|
f78683c14b | ||
|
|
73166979a2 | ||
|
|
dffe4bd22d | ||
|
|
806f30fa87 | ||
|
|
54d3200efa | ||
|
|
2221f2c6f1 | ||
|
|
0ff0343588 | ||
|
|
d9d94da9f5 | ||
|
|
b2ee8cb1b9 | ||
|
|
16e45c41f5 | ||
|
|
0e0575f27b | ||
|
|
549a24f7b9 | ||
|
|
f95e91323e | ||
|
|
a46a6bf921 | ||
|
|
c820591089 | ||
|
|
57d0cebfb8 | ||
|
|
d7eb54b153 | ||
|
|
0beb29321e | ||
|
|
f595b3c0eb | ||
|
|
3cd2a36117 | ||
|
|
bac4f1de52 | ||
|
|
0e25a77a62 | ||
|
|
f1a7b14e25 | ||
|
|
b760b9d935 | ||
|
|
72046d3688 | ||
|
|
b97a0c9a13 | ||
|
|
e076a78003 | ||
|
|
2e5310f8a0 | ||
|
|
07aa0fe6c1 | ||
|
|
81a0709dbd | ||
|
|
ed1261ef8d | ||
|
|
8a5f659681 | ||
|
|
362126a627 | ||
|
|
26d0821c93 | ||
|
|
cc354c4e94 | ||
|
|
442ba48341 | ||
|
|
97acd4b33b | ||
|
|
1de903143a | ||
|
|
db3f70c0e7 | ||
|
|
d6c926a416 | ||
|
|
9389ce6d9a | ||
|
|
c4bbf22c62 | ||
|
|
1772ccc61a | ||
|
|
bc9503bdbf | ||
|
|
851e70b0f3 | ||
|
|
345cbc97e1 | ||
|
|
dd2f3d42a5 | ||
|
|
a927397eac | ||
|
|
5a0e7f57a3 | ||
|
|
9fe2202f39 | ||
|
|
f66ef869fc | ||
|
|
42fffc4857 | ||
|
|
de70333f8f | ||
|
|
404d4413b5 | ||
|
|
a7d9078bf4 | ||
|
|
0e88f59196 | ||
|
|
ed6a682779 | ||
|
|
016097da2e | ||
|
|
acbf453def | ||
|
|
7f8fa5b5a2 | ||
|
|
3d9b704730 | ||
|
|
debe1855c6 | ||
|
|
598864f0be | ||
|
|
a483177690 | ||
|
|
469d58d1f4 | ||
|
|
f8359c91b2 | ||
|
|
59dd3fe84e | ||
|
|
100eefc146 | ||
|
|
51448a2bda | ||
|
|
d707fb541d | ||
|
|
ea025b6b61 | ||
|
|
025f0db7d7 | ||
|
|
0d8abba0d8 | ||
|
|
f8ae4f99af | ||
|
|
f8f6c444c8 | ||
|
|
a1f7bf8195 | ||
|
|
08dd8cb9da | ||
|
|
f14610e31b | ||
|
|
3ce0c23513 | ||
|
|
e8acc00921 | ||
|
|
74368bc744 | ||
|
|
b85c79c3e0 | ||
|
|
3f360dde3a | ||
|
|
be213d9d3d | ||
|
|
d065a18d16 | ||
|
|
af2f53bee1 | ||
|
|
08f39c5345 | ||
|
|
957f9d866a | ||
|
|
8138f5aecd | ||
|
|
465446bb79 | ||
|
|
7793e31e4e | ||
|
|
147a8672b4 | ||
|
|
f95e32a581 | ||
|
|
c4d2228b36 | ||
|
|
9c3f09927f | ||
|
|
083858d450 | ||
|
|
f793027800 | ||
|
|
ce2e7fb7ee | ||
|
|
7fe79b115d | ||
|
|
9fb08153d6 | ||
|
|
52d7f56922 | ||
|
|
e9c391608c | ||
|
|
0b4e52e8d7 | ||
|
|
8b4845d272 | ||
|
|
efa4d76c50 | ||
|
|
f83a80de48 | ||
|
|
4255059846 | ||
|
|
cfedcc06c8 | ||
|
|
79cbefb088 | ||
|
|
911a27e8e6 | ||
|
|
810e93758e | ||
|
|
4d29bfbe07 | ||
|
|
554c196870 | ||
|
|
8c1c8a3cee | ||
|
|
b811dba5c2 | ||
|
|
afb7c25141 | ||
|
|
c76c0d85ba | ||
|
|
a65aa27225 | ||
|
|
5505ae43d4 | ||
|
|
e29535f29c | ||
|
|
9bf1e847d2 | ||
|
|
bfffed0f52 | ||
|
|
a22b83a97f | ||
|
|
46c1b49be1 | ||
|
|
aa73606ed2 | ||
|
|
57ea0ff6aa | ||
|
|
17108d8a37 | ||
|
|
85556913d6 | ||
|
|
4062f72bdb | ||
|
|
4479d7ff18 | ||
|
|
5b8ba4bd52 | ||
|
|
7f81ffca23 | ||
|
|
28f6994520 | ||
|
|
6c96606c18 | ||
|
|
ff4adce91b | ||
|
|
48bbf66422 | ||
|
|
f9732efb23 | ||
|
|
3e801a2067 | ||
|
|
d61720c3e2 | ||
|
|
d955122c88 | ||
|
|
b0d9208561 | ||
|
|
f03a81b943 | ||
|
|
497a0abb07 | ||
|
|
884f57d5f6 | ||
|
|
9dcd561778 | ||
|
|
0bcafa1fde | ||
|
|
a5f21331a4 | ||
|
|
be21f56771 | ||
|
|
fbcec6e75f | ||
|
|
5fcaa54f04 | ||
|
|
c18ca7af22 | ||
|
|
1cfae33fb0 | ||
|
|
5548c3a713 | ||
|
|
928bbf22d2 | ||
|
|
57e44b3a5f | ||
|
|
a01da137ba | ||
|
|
09f03e34d0 | ||
|
|
afbaaafd00 | ||
|
|
71a07ee7af | ||
|
|
8134199a57 | ||
|
|
8479a8b4d3 | ||
|
|
9a612d0164 | ||
|
|
3a6b32bcf9 | ||
|
|
81dea34dd3 | ||
|
|
be05e01bd7 | ||
|
|
61927b61fe | ||
|
|
b8e699b226 | ||
|
|
f8550838a3 | ||
|
|
9e15e52847 | ||
|
|
f24ea74ef8 | ||
|
|
a954539b53 | ||
|
|
f8120d1818 | ||
|
|
d5d2e3406b | ||
|
|
a80be19081 | ||
|
|
9ce1b02e6e | ||
|
|
f4d9869d7b | ||
|
|
6b1e339ed4 | ||
|
|
fe13c0709b | ||
|
|
c86aa3e3ad | ||
|
|
60e05bdaa6 | ||
|
|
4f42f52306 | ||
|
|
e85f2c4fbc | ||
|
|
bbc684aa80 | ||
|
|
cb97c3a55a | ||
|
|
cb6fc640ce | ||
|
|
3d44b4d98c | ||
|
|
dd7891e18f | ||
|
|
ea665d1a9b | ||
|
|
6255495cc4 | ||
|
|
8c1a1aafe6 | ||
|
|
0a9aa448c1 | ||
|
|
02f7cba20a | ||
|
|
96d4c48357 | ||
|
|
db2e2105ab | ||
|
|
e2fbc48b9a | ||
|
|
e21c946f14 | ||
|
|
a0394fc00c | ||
|
|
4180e29af4 | ||
|
|
39eb34f333 | ||
|
|
c6f9415e9d | ||
|
|
6f225920d0 | ||
|
|
f768778d81 | ||
|
|
c541bc9239 | ||
|
|
817c5007d9 | ||
|
|
900c71840c | ||
|
|
dfd0c85ba4 | ||
|
|
37540d90bf | ||
|
|
8caaac96b6 | ||
|
|
9f02c47592 | ||
|
|
7f47c218ce | ||
|
|
ae11b3b848 | ||
|
|
64e177df8b | ||
|
|
413ec60a3e | ||
|
|
6733681e93 | ||
|
|
5104007d12 | ||
|
|
4bc3420b21 | ||
|
|
fc9608d14d | ||
|
|
facb27c3f4 | ||
|
|
68a1fe1480 | ||
|
|
e34a64ecee | ||
|
|
060cd9f320 | ||
|
|
eb6530208b | ||
|
|
98e012095a | ||
|
|
e8bebba915 | ||
|
|
d3c0d9b283 | ||
|
|
1cfb41e1c4 | ||
|
|
0e244dd83a | ||
|
|
4c29d5630d | ||
|
|
4806b82add | ||
|
|
5a80278d1e | ||
|
|
c4e1fe274b | ||
|
|
1a56f3b032 | ||
|
|
08375a9e2f | ||
|
|
fa378e2d7a | ||
|
|
006a65e873 | ||
|
|
e7b135b51e | ||
|
|
dabc94ed06 | ||
|
|
1f4702bde7 | ||
|
|
4708ac4e3d | ||
|
|
996a02180b | ||
|
|
d8a2f53a29 | ||
|
|
13d06e77b4 | ||
|
|
5787951ed1 | ||
|
|
3f6f3abf69 | ||
|
|
055c58364a | ||
|
|
cfa6d286ad | ||
|
|
b9b402f2ec | ||
|
|
b04a9e25ff | ||
|
|
b1c6666d02 | ||
|
|
fe0465f16e | ||
|
|
3ba8f690a4 | ||
|
|
47a9c54b70 | ||
|
|
336307f287 | ||
|
|
79421bcfcc | ||
|
|
e23a7883f3 | ||
|
|
deff5848ed | ||
|
|
b579dbc1e6 | ||
|
|
41da9eb7fc | ||
|
|
1cc98a82ba | ||
|
|
eb7f7f2124 | ||
|
|
fcd312f205 | ||
|
|
3d4b50d37d | ||
|
|
5de87f473e | ||
|
|
31849f6029 | ||
|
|
1f06f2e3a0 | ||
|
|
3038ad8abe | ||
|
|
dc202ac4a2 | ||
|
|
ca07982ee0 | ||
|
|
f269f6a8d8 | ||
|
|
2ca1bdd8a7 | ||
|
|
cf338bb757 | ||
|
|
d37e5cd97d | ||
|
|
526f91f6b5 | ||
|
|
4465ebaeb5 | ||
|
|
88cb132fd1 | ||
|
|
d23677636d | ||
|
|
8e0ba50c4d | ||
|
|
08abf96047 | ||
|
|
813b42d706 | ||
|
|
8ce63dac65 | ||
|
|
8e9680afce | ||
|
|
a09e875109 | ||
|
|
42c61915c4 | ||
|
|
37a6ebd431 | ||
|
|
279c8dea06 | ||
|
|
b1b6ae98ed | ||
|
|
e91bcd6dd6 | ||
|
|
35e55d3e13 | ||
|
|
6f9a8ff101 | ||
|
|
8e627db785 | ||
|
|
b2eb6a69c1 | ||
|
|
d0f1239d2b | ||
|
|
b5a84c5e65 | ||
|
|
b1de654dfd |
183
.github/POSTGRES_COMPATIBILITY.md
vendored
Normal file
183
.github/POSTGRES_COMPATIBILITY.md
vendored
Normal file
@@ -0,0 +1,183 @@
|
||||
# PostgreSQL compatibility — review guide
|
||||
|
||||
ERPNext targets **both MariaDB and PostgreSQL from a single codebase**. The full server
|
||||
test suite passes on both, but the PostgreSQL CI job is **label-gated** (it does not run on
|
||||
every PR), so until it is required this guide is the always-on guard. Greptile loads it as
|
||||
review context (`.greptile/config.json`).
|
||||
|
||||
When reviewing a PR, flag any **new or changed query** (raw `frappe.db.sql`, `frappe.qb`,
|
||||
`frappe.get_all/get_list/get_value`, report SQL) that would **error on PostgreSQL** or
|
||||
**return different results on the two engines**.
|
||||
|
||||
## The one rule that governs everything
|
||||
|
||||
**MariaDB behaviour must not change; PostgreSQL is brought into line with MariaDB — never the
|
||||
reverse.** A "fix" that changes the value, row count, or ordering MariaDB produced is a
|
||||
regression, even if the new behaviour looks more correct. The only accepted MariaDB-output
|
||||
change is replacing a genuinely *undefined/arbitrary* result with a deterministic one (row
|
||||
count preserved) — and that should be called out explicitly.
|
||||
|
||||
There are two failure modes to watch for:
|
||||
1. **Hard breaks** — PostgreSQL raises an exception; MariaDB is green. Easy to catch in CI,
|
||||
but the gated job may not run.
|
||||
2. **Silent divergences** — both engines succeed but return *different* results. CI on one
|
||||
engine stays green; the bug only shows on a PostgreSQL site. These are the dangerous ones.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hard breaks — would error on PostgreSQL
|
||||
|
||||
Flag a changed query that uses any of these:
|
||||
|
||||
- **Loose `GROUP BY`** — selecting/ordering a column that is neither in `GROUP BY` nor wrapped
|
||||
in an aggregate. MariaDB tolerates it; PostgreSQL errors (`must appear in the GROUP BY
|
||||
clause or be used in an aggregate function`). This **also covers an aggregate (`Sum`/`Count`/…)
|
||||
selected alongside bare columns with NO `.groupby()` at all** — MariaDB silently collapses
|
||||
every row into one arbitrary-valued row (often a *wrong-output* bug there too), PostgreSQL
|
||||
errors. Fix: add the bare column to `GROUP BY` **if it is functionally dependent on the group
|
||||
key**, otherwise wrap it in `Max()`/`Min()`. **See §3 — the row-count trap — before suggesting
|
||||
"add it to GROUP BY".**
|
||||
- **MySQL-only functions** — `TIMESTAMP(date,time)`, `TIMEDIFF`, `STR_TO_DATE`, `DATE_FORMAT`,
|
||||
`DATE_ADD/SUB`, `GROUP_CONCAT`, `PERIOD_DIFF`, SQL `IF(cond,a,b)`. Use the portable
|
||||
`frappe.query_builder.functions` equivalents (`CombineDatetime`, `DateDiff`, `Case`,
|
||||
`GroupConcat`, …) or a precomputed column (e.g. `posting_datetime`).
|
||||
- **`UPDATE … JOIN`** — not valid on PostgreSQL. Rewrite as `UPDATE … WHERE name IN (subquery)`.
|
||||
- **`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.
|
||||
- **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
|
||||
`Coalesce(...)`.
|
||||
- **Capital-cased identifiers** used as column/field names in `get_value(dt, dn, "Status")`,
|
||||
`get_all(dt, fields=["Account"])`, and similar — PostgreSQL quotes the identifier and matches
|
||||
it case-sensitively; a stored column named `status`/`account` won't match `"Status"`/`"Account"`
|
||||
(`column "Account" does not exist`). Use the exact stored (lower-case) fieldname.
|
||||
- **Boolean passed where an integer column is expected** — `frappe.db.set_value(dt, dn,
|
||||
check_field, True)`, `doc.db_set(field, False)`, or `frappe.qb.update(dt).set(check_field, True)`
|
||||
emit `SET col = true`, which PostgreSQL rejects on a `smallint`/`Check` column
|
||||
(`column is of type smallint but expression is of type boolean`). Pass `1`/`0`.
|
||||
- **`.like()`/`.ilike()` (or raw `LIKE`) on a NON-text column** — `idx`, `docstatus`, a date, etc.
|
||||
frappe maps `.like()` → `ILIKE`, and PostgreSQL has no `bigint ILIKE text` operator (`operator
|
||||
does not exist: bigint ~~* unknown`). Cast the column to text first — **`Cast_(col, "varchar")`**,
|
||||
not `Cast(col, "char")` (see below). MariaDB coerces the int implicitly, so the cast is a no-op there.
|
||||
- **`CAST(… AS CHAR)` / `Cast(x, "char")`** — on PostgreSQL bare `CHAR` is `character(1)`, so
|
||||
`CAST(12 AS CHAR)` → `'1'` (silently truncates multi-digit values); MariaDB gives the full string.
|
||||
Use `VARCHAR` / `Cast_(x, "varchar")`.
|
||||
- **`.rlike()` / raw `RLIKE`** — frappe rewrites `REGEXP` → `~*` on PostgreSQL but does **not**
|
||||
translate `RLIKE` (no such PostgreSQL operator). Use `.regexp()` (or `.like()` for a simple prefix).
|
||||
- **`IfNull`/`Coalesce` of a typed column with a different-typed literal** — `IfNull(asset.disposal_date, 0)`
|
||||
renders `COALESCE("disposal_date", 0)`, coalescing a **DATE** with an **integer**. PostgreSQL requires
|
||||
`COALESCE` args to share a type (`DatatypeMismatch: COALESCE types date and integer cannot be matched`);
|
||||
MariaDB's `IFNULL` is permissive. The common shape is `IfNull(date_col, 0) != 0 / == 0` as a presence test —
|
||||
replace with `date_col.isnotnull()` / `date_col.isnull()` (identical, and valid on both). Otherwise coalesce
|
||||
to a **same-type** default (`Coalesce(date_col, '1900-01-01')`, `Coalesce(text_col, '')`).
|
||||
- **Division by a possibly-zero divisor** — `Sum(a) / Sum(b)`, `x / col`, etc. where the
|
||||
divisor can be `0`/empty. MariaDB returns `NULL` for division by zero; PostgreSQL raises
|
||||
`division by zero` and aborts the query. Wrap the divisor in `NullIf(divisor, 0)` — that
|
||||
yields `NULL` on both engines, matching MariaDB's value. (Only the *literal* `/ 0` is a parse
|
||||
constant; the trap is a divisor that is an aggregate or column the data can drive to zero.)
|
||||
|
||||
---
|
||||
|
||||
## 2. Silent divergences — succeeds on both, returns different results
|
||||
|
||||
These don't error, so a one-engine CI stays green. Flag them:
|
||||
|
||||
- **Case sensitivity on text equality** — `==`, `.isin()`, `Strpos`/`Locate` on free-text
|
||||
columns are case-**sensitive** on PostgreSQL but case-**insensitive** under MariaDB's default
|
||||
collation. `Lower()` both sides. *(Not `.like()`/`["like", …]` — those already render as
|
||||
`ILIKE` on PostgreSQL; see §4.)*
|
||||
- **Case sensitivity in a doc-`name` lookup** — lower-casing a value then using it as a
|
||||
document name in `get_value`/`get_doc`/`exists` misses on PostgreSQL (names are
|
||||
case-sensitive). Keep original case for the identifier; lower-case only comparison operands.
|
||||
- **Empty string vs NULL** — PostgreSQL stores a blank link/data field as `NULL` on some paths
|
||||
while MariaDB keeps `''`; `Concat`/`Concat_ws` then diverge. Prefer the stored full value, or
|
||||
`Coalesce(col, '')` per argument.
|
||||
- **NULL ordering** — MariaDB sorts `NULL` first, PostgreSQL sorts it last. For
|
||||
`ORDER BY … LIMIT 1`/`[0]` on a nullable column, guard with `Coalesce`/`isnotnull()`.
|
||||
- **`ORDER BY … LIMIT 1` with no unique tiebreaker** — when rows tie on the ordered column the
|
||||
two engines may pick different rows. Add a `creation`/`name` tiebreaker **only if it does not
|
||||
change MariaDB's current pick** (see §4).
|
||||
- **Integer division** — `int / int` truncates on PostgreSQL but is decimal on MariaDB, e.g.
|
||||
`COUNT(...) / COUNT(...) * 100` → `0`, or `manufacturing_time_in_mins / 1440` flooring a
|
||||
lead-time to whole days. Force float: multiply by `100.0`, or make a literal a float
|
||||
(`/ 1440` → `/ 1440.0`), or cast an operand. (Only SQL-level `/` on integer **columns/literals**
|
||||
— Python `/` is already float.)
|
||||
- **`DISTINCT` list ordering** — `frappe.get_all(distinct=True, order_by=…)` /
|
||||
`SELECT DISTINCT … ORDER BY`: frappe's `db_query` **silently drops `ORDER BY` for distinct
|
||||
queries on PostgreSQL**, so the result is unordered there. Sort in Python instead — and use
|
||||
`key=str.casefold`, because bare `sorted()` is case-sensitive (ASCII) while MariaDB's
|
||||
collation is case-insensitive, so a plain sort reorders MariaDB's output.
|
||||
- **Engine-specific function rewrites** — e.g. a PostgreSQL `regexp_replace` branch
|
||||
reimplementing MariaDB's `CAST(SUBSTRING_INDEX(name,' ',-1) AS UNSIGNED)` (leading digits of
|
||||
the last whitespace token). Verify the rewrite matches MariaDB on edge cases (`"X - 3a"→3`,
|
||||
`"X - 1.5"→1`) by diffing both engines on literal rows.
|
||||
- **`UnixTimestamp(date)` / date→epoch** is timezone-dependent (midnight in the DB session TZ),
|
||||
so a strict `epoch <= now` bound is flaky on PostgreSQL.
|
||||
|
||||
---
|
||||
|
||||
## 3. The `GROUP BY` row-count trap (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
|
||||
**changes the MariaDB row count** (a regression). The classic traps are adding the **child/row
|
||||
primary key** or an **editable per-row field**. Instead **`Max()`/`Min()`-wrap** the offending
|
||||
column: the row count is preserved and the value goes from arbitrary (MariaDB's old loose pick)
|
||||
to deterministic.
|
||||
|
||||
**Judge functional dependence by the source table, not the column name:**
|
||||
- A column from a **master joined on the group key** (`t3.x` where `t1.key = t3.name`) is FD →
|
||||
safe to keep in `GROUP BY`.
|
||||
- A descriptive field on the **transaction** table (`t1.supplier_name`, `t1.territory`,
|
||||
`t1.item_name` — fetched/editable, can differ across historical rows for the same key) is
|
||||
**not** FD even though it looks master-derived → `Max()`-wrap it.
|
||||
|
||||
Conversely, do **not** suggest changing a `Max()`/`Min()`-wrapped column to `Sum()` (or vice
|
||||
versa) to make a number "more correct" — that changes the MariaDB value. The wrap reproduces
|
||||
MariaDB's prior one-value-per-group output; a different aggregate is a product change, out of
|
||||
scope for a portability fix.
|
||||
|
||||
---
|
||||
|
||||
## 4. False positives — do NOT flag these
|
||||
|
||||
These are auto-handled by the framework and are **not** breaks:
|
||||
|
||||
- **`.like()` / `["like", …]`** already renders as `ILIKE` on PostgreSQL — not a
|
||||
case-sensitivity bug. *(Exception: `.like()` on a **non-text** column — `idx`, `docstatus` —
|
||||
is a hard break, `bigint ILIKE`; see §1.)*
|
||||
- **Raw `ifnull(...)`** inside `frappe.db.sql()` is rewritten to `coalesce(...)` on all engines.
|
||||
- **Backticks**, **`LOCATE`**, **`REGEXP`** / **`.regexp()`** in raw SQL are auto-translated on
|
||||
PostgreSQL (`REGEXP` → `~*`). **But `RLIKE` / `.rlike()` is NOT translated** — that one is a
|
||||
hard break (see §1).
|
||||
- **An `ORDER BY … LIMIT 1` tie where the two engines already agree**, or where adding a
|
||||
tiebreaker would *change* MariaDB's current pick — leave it; "fixing" it would either change
|
||||
MariaDB or has no observable effect.
|
||||
|
||||
---
|
||||
|
||||
## 5. Transaction / runtime (not query-shape, still PostgreSQL-only)
|
||||
|
||||
- **Catch-and-continue inserts** — on PostgreSQL a failed `insert()` aborts the **whole
|
||||
transaction**, so code that swallows a duplicate and keeps going dies on the next statement
|
||||
with `InFailedSqlTransaction` (frappe dropped its blanket per-statement savepoint in
|
||||
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`).
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
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.
|
||||
72
.github/helper/hydrate.sh
vendored
Executable file
72
.github/helper/hydrate.sh
vendored
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Hydrate a test shard from the setup job's artifact.
|
||||
#
|
||||
# The bench (apps, venv, node_modules, sites) is already on disk at ~/frappe-bench — the
|
||||
# workflow untar'd it from the artifact the setup job built. So there is NO bench init, no
|
||||
# asset build, and no reinstall here: just bring the DB up on the baked datadir and start redis
|
||||
# so tests can run. The whole point is that the expensive work happened ONCE in the setup job.
|
||||
#
|
||||
set -e
|
||||
|
||||
ci_user="${ERPNEXT_CI_USER:-frappe}"
|
||||
db_host="${DB_HOST:-127.0.0.1}"
|
||||
|
||||
# Re-exec as the ci user (uid 1001) so bench/cache ownership matches the artifact, same as
|
||||
# install.sh. The workflow untar'd as root with -p, so the files are already owned by ci.
|
||||
if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then
|
||||
exec su -m "$ci_user" -s /bin/bash -c \
|
||||
"ERPNEXT_CI_USER='$ci_user' DB_HOST='$db_host' bash '$0'"
|
||||
fi
|
||||
|
||||
cd ~/frappe-bench
|
||||
|
||||
# Start the DB on the datadir baked into the artifact. It's already populated (the setup job
|
||||
# reinstalled into this very datadir), so there is NO restore — the server comes up on the
|
||||
# existing files. This is what replaces the per-shard SQL replay.
|
||||
bash ~/frappe-bench/start-db.sh
|
||||
|
||||
# Bring up redis (lightmode unit tests need cache + queue). In the self-hosted container we use the
|
||||
# full `bench start` (web/workers too, like install.sh). On the bare GitHub Postgres shard
|
||||
# `bench start` (honcho) lagged — it blocks the redis procs behind web/worker procs the lightmode
|
||||
# suite never uses, so the wait below burned its full timeout (~4m). There, start the two redis
|
||||
# instances directly: fast and deterministic.
|
||||
if [ "${DB:-mariadb}" = "postgres" ]; then
|
||||
# Start redis directly as daemons — reliable and persists across steps. Do NOT route it through
|
||||
# `bench start`: honcho tears the whole process group down if any one Procfile proc dies on the
|
||||
# bare shard, which took redis with it (redis @ 13000 refused in Run Tests). Keeping redis
|
||||
# independent is what makes it survive. The web server (for PDF tests) is NOT started here — a
|
||||
# backgrounded server doesn't survive into the next step; it's started inside the Run Tests step.
|
||||
for conf in redis_cache redis_queue; do
|
||||
[ -f ~/frappe-bench/config/$conf.conf ] && redis-server ~/frappe-bench/config/$conf.conf --daemonize yes
|
||||
done
|
||||
else
|
||||
bench start >> ~/frappe-bench/bench_start.log 2>&1 &
|
||||
fi
|
||||
|
||||
# Wait for redis, failing fast instead of silently burning minutes if it never comes up.
|
||||
cfg=~/frappe-bench/sites/common_site_config.json
|
||||
if [ -f "$cfg" ]; then
|
||||
ports=$(python - "$cfg" <<'PY'
|
||||
import json, re, sys
|
||||
try:
|
||||
cfg = json.load(open(sys.argv[1]))
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
for key in ("redis_cache", "redis_queue"):
|
||||
m = re.search(r":(\d+)", str(cfg.get(key, "")))
|
||||
if m:
|
||||
print(m.group(1))
|
||||
PY
|
||||
)
|
||||
for port in $ports; do
|
||||
up=0
|
||||
for _ in $(seq 1 60); do
|
||||
if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then exec 3>&- 3<&-; up=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
[ "$up" = "1" ] || { echo "redis did not come up on port $port"; exit 1; }
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Hydrated: DB up on baked datadir, redis up — ready for tests."
|
||||
333
.github/helper/install.sh
vendored
333
.github/helper/install.sh
vendored
@@ -7,21 +7,106 @@ cd ~ || exit
|
||||
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
|
||||
frappeuser=${FRAPPE_USER:-"frappe"}
|
||||
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
|
||||
db_host=${DB_HOST:-"127.0.0.1"}
|
||||
db_user_host=${DB_USER_HOST:-"localhost"}
|
||||
wkhtmltox_deb=${WKHTMLTOX_DEB:-"/tmp/wkhtmltox.deb"}
|
||||
bench_cache_dir=${BENCH_CACHE_DIR:-}
|
||||
|
||||
run_as_ci_user_if_needed() {
|
||||
if [ "$(id -u)" != "0" ] || [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ] || [ "${ERPNEXT_CI_NON_ROOT:-0}" = "1" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local missing_packages=()
|
||||
if ! command -v pkg-config >/dev/null 2>&1; then
|
||||
missing_packages+=("pkg-config")
|
||||
fi
|
||||
if ! command -v mariadb_config >/dev/null 2>&1 && ! command -v mysql_config >/dev/null 2>&1; then
|
||||
missing_packages+=("libmariadb-dev")
|
||||
fi
|
||||
if ! command -v crontab >/dev/null 2>&1; then
|
||||
missing_packages+=("cron")
|
||||
fi
|
||||
|
||||
if [ "${#missing_packages[@]}" -gt 0 ]; then
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends "${missing_packages[@]}"
|
||||
fi
|
||||
|
||||
local ci_user="${ERPNEXT_CI_USER:-frappe}"
|
||||
|
||||
if ! id "$ci_user" >/dev/null 2>&1; then
|
||||
useradd --home-dir "$HOME" --no-create-home --shell /bin/bash "$ci_user"
|
||||
fi
|
||||
|
||||
rm -rf ~/frappe ~/frappe-bench
|
||||
|
||||
local ci_dirs=(
|
||||
"$HOME"
|
||||
"$GITHUB_WORKSPACE"
|
||||
"$HOME/.cache"
|
||||
"${PIP_CACHE_DIR:-$HOME/.cache/pip}"
|
||||
"${npm_config_cache:-$HOME/.npm}"
|
||||
"${YARN_CACHE_FOLDER:-$HOME/.cache/yarn}"
|
||||
"$HOME/.yarn"
|
||||
"${UV_CACHE_DIR:-$HOME/.cache/uv}"
|
||||
"$(dirname "$wkhtmltox_deb")"
|
||||
)
|
||||
if [ -n "$bench_cache_dir" ]; then
|
||||
ci_dirs+=("$bench_cache_dir")
|
||||
fi
|
||||
|
||||
# Create + own (non-recursively) the home/cache/workspace dirs before dropping to
|
||||
# the ci user. We deliberately do NOT wipe the yarn/uv caches here so a persistent
|
||||
# cache (mounted volume or baked image layer) stays warm across runs.
|
||||
mkdir -p "${ci_dirs[@]}" "$HOME/.yarn"
|
||||
chown "$ci_user:$ci_user" "${ci_dirs[@]}" "$HOME/.yarn"
|
||||
|
||||
export ERPNEXT_CI_NON_ROOT=1
|
||||
exec su -m "$ci_user" -s /bin/bash -c "cd '$HOME' && bash '$GITHUB_WORKSPACE/.github/helper/install.sh'"
|
||||
}
|
||||
|
||||
run_as_ci_user_if_needed
|
||||
|
||||
run_ci_step() {
|
||||
local label=$1
|
||||
shift
|
||||
|
||||
echo "::group::${label}"
|
||||
date -u
|
||||
timeout --foreground "${CI_INSTALL_STEP_TIMEOUT:-600}" "$@"
|
||||
local exit_code=$?
|
||||
date -u
|
||||
echo "::endgroup::"
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
if [ -n "${GITHUB_WORKSPACE:-}" ]; then
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE" || true
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE/.git" || true
|
||||
fi
|
||||
|
||||
rm -rf ~/frappe ~/frappe-bench
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — parallelise the three slow, independent setup steps:
|
||||
# a) system packages b) frappe-bench pip install c) frappe git fetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
sudo apt update
|
||||
if [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ]; then
|
||||
sudo apt-get update
|
||||
|
||||
# apt remove/install must run sequentially but can overlap with pip and git.
|
||||
sudo apt remove mysql-server mysql-client
|
||||
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev &
|
||||
apt_pid=$!
|
||||
# apt remove/install must run sequentially but can overlap with pip and git.
|
||||
sudo apt-get remove -y mysql-server mysql-client
|
||||
sudo apt-get install -y libcups2-dev redis-server mariadb-client libmariadb-dev &
|
||||
apt_pid=$!
|
||||
|
||||
pip install frappe-bench &
|
||||
pip_pid=$!
|
||||
pip install frappe-bench &
|
||||
pip_pid=$!
|
||||
else
|
||||
apt_pid=
|
||||
pip_pid=
|
||||
fi
|
||||
|
||||
mkdir frappe
|
||||
(
|
||||
@@ -32,43 +117,180 @@ mkdir frappe
|
||||
) &
|
||||
clone_pid=$!
|
||||
|
||||
wait $apt_pid
|
||||
wait $pip_pid
|
||||
if [ -n "$apt_pid" ]; then wait $apt_pid; fi
|
||||
if [ -n "$pip_pid" ]; then wait $pip_pid; fi
|
||||
wait $clone_pid
|
||||
|
||||
pushd frappe
|
||||
git checkout FETCH_HEAD
|
||||
popd
|
||||
frappe_sha=$(git -C frappe rev-parse HEAD)
|
||||
|
||||
get_bench_cache_archive() {
|
||||
if [ -z "$bench_cache_dir" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$bench_cache_dir"
|
||||
|
||||
# Keyed on tool versions only (NOT the frappe SHA): any recent base bench works, because
|
||||
# restore_warm_bench fast-forwards it to the exact live develop SHA. This is what lets a
|
||||
# constantly-moving develop still hit the cache.
|
||||
local cache_key
|
||||
cache_key=$(
|
||||
{
|
||||
uname -m
|
||||
python --version
|
||||
node --version
|
||||
bench --version
|
||||
} | sha256sum | awk '{print $1}'
|
||||
)
|
||||
|
||||
echo "${bench_cache_dir}/frappe-bench-base-${cache_key}.tar.zst"
|
||||
}
|
||||
|
||||
restore_warm_bench() {
|
||||
bench_cache_archive=$(get_bench_cache_archive)
|
||||
[ -n "$bench_cache_archive" ] && [ -f "$bench_cache_archive" ] || return 1
|
||||
|
||||
echo "Restoring base bench from ${bench_cache_archive}"
|
||||
tar --use-compress-program=unzstd -xf "$bench_cache_archive" -C ~ || return 1
|
||||
[ -d ~/frappe-bench/apps/frappe/.git ] || return 1
|
||||
mkdir -p ~/frappe-bench/sites ~/frappe-bench/logs
|
||||
[ -f ~/frappe-bench/sites/apps.txt ] || printf "frappe\n" > ~/frappe-bench/sites/apps.txt
|
||||
[ -f ~/frappe-bench/sites/common_site_config.json ] || printf "{}\n" > ~/frappe-bench/sites/common_site_config.json
|
||||
|
||||
# Fast-forward the restored frappe to the EXACT live develop SHA fetched in phase 1, then
|
||||
# rebuild only what changed. The editable install means the venv tracks the new code with
|
||||
# no reinstall. Any failure returns non-zero so the caller falls back to a full bench init.
|
||||
if ! (
|
||||
cd ~/frappe-bench/apps/frappe || exit 1
|
||||
# Phase 1 already fetched ~/frappe to the exact live develop SHA. Fetch that commit
|
||||
# straight from it (bench init names the remote 'upstream', not 'origin', and points
|
||||
# it at this local clone — so a plain `git fetch origin` does not work).
|
||||
git fetch --no-tags "$HOME/frappe" HEAD || exit 1
|
||||
git checkout --force FETCH_HEAD || exit 1
|
||||
); then
|
||||
echo "Fast-forward to ${frappe_sha} failed; falling back to full init"
|
||||
rm -rf ~/frappe-bench
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Pick up any frappe dependency changes since the base was built (cached → fast if none),
|
||||
# so a develop commit that bumped requirements doesn't leave a stale venv.
|
||||
if ! ~/frappe-bench/env/bin/python -m pip install -q -e ~/frappe-bench/apps/frappe; then
|
||||
echo "frappe dependency refresh failed; falling back to full init"
|
||||
rm -rf ~/frappe-bench
|
||||
return 1
|
||||
fi
|
||||
|
||||
( cd ~/frappe-bench && CI=Yes bench build --app frappe ) || { rm -rf ~/frappe-bench; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
save_warm_bench() {
|
||||
if [ -z "${bench_cache_archive:-}" ] || [ -f "$bench_cache_archive" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$bench_cache_dir" ] && [ ! -w "$bench_cache_dir" ]; then
|
||||
echo "Skipping warm bench save because ${bench_cache_dir} is not writable"
|
||||
return
|
||||
fi
|
||||
|
||||
local tmp_archive
|
||||
tmp_archive="${bench_cache_archive}.${$}.tmp"
|
||||
|
||||
echo "Saving warm bench to ${bench_cache_archive}"
|
||||
# Keep sites/common_site_config.json (the redis ports live there — dropping it makes the
|
||||
# restore path fall back to a default redis port that bench start never bound, so reinstall
|
||||
# fails with "redis ... connection refused"). Only the rebuildable sites/assets is excluded;
|
||||
# restore_warm_bench runs `bench build` to regenerate it.
|
||||
tar \
|
||||
--use-compress-program="zstd -T0 -3" \
|
||||
--exclude="frappe-bench/logs" \
|
||||
--exclude="frappe-bench/sites/assets" \
|
||||
-cf "$tmp_archive" \
|
||||
-C ~ frappe-bench
|
||||
mv "$tmp_archive" "$bench_cache_archive"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — bench init and site setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
|
||||
install_whktml() {
|
||||
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
|
||||
if [ ! -f "$wkhtmltox_deb" ]; then
|
||||
wget -O "$wkhtmltox_deb" https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
fi
|
||||
sudo apt-get install -y "$wkhtmltox_deb"
|
||||
}
|
||||
if [ "${SKIP_WKHTMLTOX_SETUP:-0}" != "1" ]; then
|
||||
install_whktml &
|
||||
wkpid=$!
|
||||
else
|
||||
wkpid=
|
||||
fi
|
||||
|
||||
mkdir ~/frappe-bench/sites/test_site
|
||||
if ! restore_warm_bench; then
|
||||
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
|
||||
|
||||
cd ~/frappe-bench || exit
|
||||
|
||||
sed -i 's/watch:/# watch:/g' Procfile
|
||||
sed -i 's/schedule:/# schedule:/g' Procfile
|
||||
sed -i 's/socketio:/# socketio:/g' Procfile
|
||||
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
|
||||
|
||||
CI=Yes bench build --app frappe
|
||||
save_warm_bench
|
||||
fi
|
||||
|
||||
if [ -n "$wkpid" ]; then wait $wkpid; fi
|
||||
|
||||
mkdir -p ~/frappe-bench/sites/test_site
|
||||
|
||||
if [ "$DB" == "mariadb" ];then
|
||||
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_mariadb.json" ~/frappe-bench/sites/test_site/site_config.json
|
||||
if [ "$db_host" != "127.0.0.1" ]; then
|
||||
sed -i "s/\"db_host\": \"127.0.0.1\"/\"db_host\": \"${db_host}\"/" ~/frappe-bench/sites/test_site/site_config.json
|
||||
fi
|
||||
else
|
||||
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_postgres.json" ~/frappe-bench/sites/test_site/site_config.json
|
||||
fi
|
||||
|
||||
|
||||
if [ "$DB" == "mariadb" ];then
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
for _ in {1..60}; do
|
||||
if mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent
|
||||
|
||||
# Belt-and-suspenders: also set performance variables at runtime in case
|
||||
# MARIADB_EXTRA_FLAGS was not honoured by the container image.
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot \
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
|
||||
# Throwaway-DB durability tuning at runtime. (innodb_doublewrite is read-only on MariaDB
|
||||
# 10.6, so it can't be disabled here — would need a server startup flag.)
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot \
|
||||
-e "SET GLOBAL innodb_flush_log_at_trx_commit=0; SET GLOBAL sync_binlog=0;"
|
||||
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
|
||||
# Opt-in DDL speedup: a shared tablespace avoids a create+fsync per DocType table during
|
||||
# reinstall — a big win under disk contention. But ROW_FORMAT=DYNAMIC must be accepted in
|
||||
# the system tablespace on this MariaDB. Enable with CI_INNODB_SHARED_TABLESPACE=1; if
|
||||
# reinstall then errors on table creation, unset it (off by default — zero risk).
|
||||
if [ "${CI_INNODB_SHARED_TABLESPACE:-0}" = "1" ]; then
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL innodb_file_per_table=0;"
|
||||
fi
|
||||
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'${db_user_host}' IDENTIFIED BY 'test_frappe'"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'${db_user_host}'"
|
||||
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
|
||||
fi
|
||||
|
||||
if [ "$DB" == "postgres" ];then
|
||||
@@ -76,32 +298,57 @@ if [ "$DB" == "postgres" ];then
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
|
||||
fi
|
||||
|
||||
|
||||
install_whktml() {
|
||||
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
|
||||
if [ ! -f /tmp/wkhtmltox.deb ]; then
|
||||
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
fi
|
||||
sudo apt install /tmp/wkhtmltox.deb
|
||||
}
|
||||
install_whktml &
|
||||
wkpid=$!
|
||||
|
||||
|
||||
cd ~/frappe-bench || exit
|
||||
|
||||
sed -i 's/watch:/# watch:/g' Procfile
|
||||
sed -i 's/schedule:/# schedule:/g' Procfile
|
||||
sed -i 's/socketio:/# socketio:/g' Procfile
|
||||
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
|
||||
run_ci_step "Get payments app" bench get-app payments --branch develop
|
||||
|
||||
bench get-app payments --branch develop
|
||||
bench get-app erpnext "${GITHUB_WORKSPACE}"
|
||||
# Opt-in: skip building erpnext's frontend assets. Server tests don't need them, but PDF
|
||||
# tests (print formats) do — they pass only if the PDF renderer ignores missing assets.
|
||||
# Enable with CI_SKIP_ERPNEXT_ASSETS=1 to test; if PDF tests fail, unset it.
|
||||
erpnext_get_app_args=()
|
||||
if [ "${CI_SKIP_ERPNEXT_ASSETS:-0}" = "1" ]; then erpnext_get_app_args=(--skip-assets); fi
|
||||
run_ci_step "Get erpnext app" bench get-app erpnext "${GITHUB_WORKSPACE}" "${erpnext_get_app_args[@]}"
|
||||
|
||||
if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
|
||||
if [ "$TYPE" == "server" ]; then run_ci_step "Setup dev requirements" bench setup requirements --dev; fi
|
||||
|
||||
wait $wkpid
|
||||
bench start >> ~/frappe-bench/bench_start.log 2>&1 &
|
||||
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
CI=Yes bench build --app frappe &
|
||||
bench --site test_site reinstall --yes
|
||||
# Under heavy concurrency, gunicorn's startup can delay redis coming up. reinstall and the
|
||||
# tests need redis, so wait for it (best-effort, bounded) instead of racing — contention
|
||||
# then slows the job rather than failing it.
|
||||
wait_for_redis() {
|
||||
local cfg=~/frappe-bench/sites/common_site_config.json
|
||||
[ -f "$cfg" ] || return 0
|
||||
local ports port
|
||||
ports=$(python - "$cfg" <<'PY'
|
||||
import json, re, sys
|
||||
try:
|
||||
cfg = json.load(open(sys.argv[1]))
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
for key in ("redis_cache", "redis_queue"):
|
||||
match = re.search(r":(\d+)", str(cfg.get(key, "")))
|
||||
if match:
|
||||
print(match.group(1))
|
||||
PY
|
||||
)
|
||||
for port in $ports; do
|
||||
local up=0
|
||||
for _ in $(seq 1 120); do
|
||||
if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then
|
||||
exec 3>&- 3<&-; up=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
# Fail clearly instead of letting reinstall die later on a vague socket-connection error
|
||||
# when redis never bound.
|
||||
[ "$up" = "1" ] || { echo "redis did not come up on port $port"; return 1; }
|
||||
done
|
||||
}
|
||||
wait_for_redis
|
||||
|
||||
# Site setup: build the schema (~1000 DocTypes) into the DB. This is the single-threaded-Python
|
||||
# bottleneck, but the fan-out amortises it — it runs once here in the setup job, and the test
|
||||
# shards start the DB on the baked datadir instead of repeating the reinstall.
|
||||
run_ci_step "Reinstall test site" bench --site test_site reinstall --yes
|
||||
|
||||
241
.github/helper/postgres_compat.py
vendored
Executable file
241
.github/helper/postgres_compat.py
vendored
Executable file
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static guard against MySQL-only SQL that breaks on PostgreSQL.
|
||||
|
||||
The Postgres test job is label-gated, so it does not run on every PR. This pre-commit
|
||||
hook is the always-on first line of defence: it flags the *mechanical* Postgres breaks
|
||||
that static analysis can catch reliably with a low false-positive rate.
|
||||
|
||||
It deliberately does NOT try to catch the *semantic* divergences (loose GROUP BY,
|
||||
case-sensitive ==/IN, NULL ordering, ORDER BY ... LIMIT 1 tiebreakers, integer-division
|
||||
intent, division by a possibly-zero divisor, savepoint discipline) — those genuinely need
|
||||
the test suite or a human/Greptile reviewer. Run the full suite on a Postgres site for those.
|
||||
|
||||
Escape hatch: put `# pg-ok` anywhere on the offending statement's line span (e.g. on a
|
||||
`SHOW INDEX` query that lives inside an `if frappe.db.db_type == "mariadb":` branch).
|
||||
|
||||
Usage: postgres_compat.py <file.py> [<file.py> ...] (pre-commit passes staged files)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
|
||||
IGNORE = "pg-ok"
|
||||
|
||||
# Strings are only scanned for the patterns below when they have real SQL *structure*
|
||||
# (not just an English word like "select" or "from"), to keep false positives near zero.
|
||||
SQL_HINT = re.compile(
|
||||
r"\bselect\b[\s\S]{0,800}\bfrom\b" # SELECT ... FROM
|
||||
r"|\bupdate\b[\s\S]{0,400}\bset\b" # UPDATE ... SET
|
||||
r"|\bdelete\s+from\b"
|
||||
r"|\binsert\s+into\b"
|
||||
r"|\bshow\s+(?:index|tables|columns)\b"
|
||||
r"|\bfrom\s+[\"'`]?tab", # FROM `tabDocType`
|
||||
re.I,
|
||||
)
|
||||
|
||||
# MySQL-only constructs with NO frappe auto-translation. (frappe.db.sql already rewrites
|
||||
# ifnull->coalesce on all engines and backtick/locate/REGEXP on Postgres, and .like()
|
||||
# renders ILIKE — so those are NOT listed here; flagging them would be false positives.)
|
||||
SQL_PATTERNS: list[tuple[re.Pattern, str]] = [
|
||||
(re.compile(r"\btimestamp\s*\(\s*[^,()]+,", re.I),
|
||||
"timestamp(date, time) is MySQL-only -> use CombineDatetime() or a precomputed datetime column"),
|
||||
(re.compile(r"\btimediff\s*\(", re.I),
|
||||
"timediff() is MySQL-only -> compute the delta in Python"),
|
||||
(re.compile(r"\bstr_to_date\s*\(", re.I),
|
||||
"str_to_date() is MySQL-only -> parse in Python and pass a real date"),
|
||||
(re.compile(r"\bdate_format\s*\(", re.I),
|
||||
"date_format() is MySQL-only -> filter on a date range instead"),
|
||||
(re.compile(r"\bdate_(add|sub)\s*\(", re.I),
|
||||
"date_add()/date_sub() are MySQL-only -> use Python date math or interval arithmetic"),
|
||||
(re.compile(r"\bgroup_concat\s*\(", re.I),
|
||||
"group_concat() is MySQL-only -> use GroupConcat (string_agg) or aggregate in Python"),
|
||||
(re.compile(r"\bperiod_diff\s*\(", re.I),
|
||||
"period_diff() is MySQL-only -> compute in Python"),
|
||||
(re.compile(r"\bshow\s+index\b", re.I),
|
||||
"SHOW INDEX is MySQL-only -> use frappe.db.has_index() / get_column_index()"),
|
||||
(re.compile(r"\bshow\s+(tables|columns)\b", re.I),
|
||||
"SHOW TABLES/COLUMNS is MySQL-only -> use frappe.db.get_tables()/table_columns / information-schema helpers"),
|
||||
(re.compile(r"\bas\s+'[^']+'", re.I),
|
||||
"single-quoted column alias breaks on Postgres -> use a bare or double-quoted alias"),
|
||||
(re.compile(r"\bif\s*\(", re.I),
|
||||
"SQL IF() is MySQL-only -> use CASE WHEN ... THEN ... ELSE ... END (frappe.qb.Case())"),
|
||||
(re.compile(r"\brlike\b", re.I),
|
||||
"RLIKE is MySQL-only -> frappe rewrites REGEXP->~* on Postgres but NOT RLIKE; use REGEXP / .regexp() / ~"),
|
||||
(re.compile(r"\bcast\s*\(.+?\bas\s+char\b", re.I | re.S), # .+? spans nested parens, e.g. CAST(ABS(x) AS CHAR)
|
||||
"CAST(... AS CHAR) is character(1) on Postgres and truncates -> CAST AS VARCHAR (frappe Cast_(x, 'varchar'))"),
|
||||
]
|
||||
|
||||
# UPDATE ... JOIN: both keywords in the same SQL string.
|
||||
UPDATE_JOIN = (re.compile(r"\bupdate\b", re.I), re.compile(r"\bjoin\b", re.I))
|
||||
|
||||
MYSQL_RESULT_KEYS = {"Column_name", "Key_name", "Seq_in_index", "Non_unique", "Index_type"}
|
||||
|
||||
SET_BOOL_FUNCS = {"set_value", "db_set"}
|
||||
|
||||
# query-builder cast helpers: pypika Cast / frappe Cast_. A "char" target type is character(1)
|
||||
# on Postgres (truncates); "varchar" is the full-length cast.
|
||||
CAST_FUNCS = {"Cast", "Cast_"}
|
||||
|
||||
# frappe.get_all / get_list: frappe's db_query SILENTLY drops ORDER BY for `distinct` queries on
|
||||
# Postgres (the ORDER BY column must appear in the SELECT-DISTINCT list), so `distinct=True` together
|
||||
# with a literal `order_by` is a no-op on PG and the result comes back unordered.
|
||||
DISTINCT_ORDER_FUNCS = {"get_all", "get_list"}
|
||||
|
||||
|
||||
def _docstring_ids(tree: ast.AST) -> set[int]:
|
||||
"""ids of Constant nodes that are docstrings (so prose describing the rules isn't flagged)."""
|
||||
ids: set[int] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
body = getattr(node, "body", None)
|
||||
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) and isinstance(body[0].value.value, str):
|
||||
ids.add(id(body[0].value))
|
||||
return ids
|
||||
|
||||
|
||||
class Visitor(ast.NodeVisitor):
|
||||
def __init__(self, lines: list[str], docstrings: set[int]):
|
||||
self.lines = lines
|
||||
self.docstrings = docstrings
|
||||
self.violations: list[tuple[int, str]] = []
|
||||
|
||||
def _ignored(self, node: ast.AST) -> bool:
|
||||
start = getattr(node, "lineno", 1)
|
||||
end = getattr(node, "end_lineno", start) or start
|
||||
# honour `# pg-ok` anywhere on the node's line span, the line just above (the enclosing
|
||||
# call, e.g. `frappe.db.sql( # pg-ok`), or the line just below (a multi-line call's `) # pg-ok`).
|
||||
lo = max(0, start - 2)
|
||||
return any(IGNORE in self.lines[i] for i in range(lo, min(end + 1, len(self.lines))))
|
||||
|
||||
def _flag(self, node: ast.AST, msg: str) -> None:
|
||||
if not self._ignored(node):
|
||||
self.violations.append((getattr(node, "lineno", 1), msg))
|
||||
|
||||
def _scan_sql(self, text: str, node: ast.AST) -> None:
|
||||
if not SQL_HINT.search(text):
|
||||
return
|
||||
for pattern, msg in SQL_PATTERNS:
|
||||
if pattern.search(text):
|
||||
self._flag(node, msg)
|
||||
if UPDATE_JOIN[0].search(text) and UPDATE_JOIN[1].search(text):
|
||||
self._flag(node, "UPDATE ... JOIN is MySQL-only -> use a correlated subquery (WHERE ... IN/EXISTS)")
|
||||
|
||||
def visit_Constant(self, node: ast.Constant) -> None:
|
||||
# plain string literals, incl. `"...".format()` and `"..." % (...)` templates
|
||||
if isinstance(node.value, str) and id(node) not in self.docstrings:
|
||||
self._scan_sql(node.value, node)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_JoinedStr(self, node: ast.JoinedStr) -> None:
|
||||
# f-string: scan its STATIC text (interpolated values become a placeholder) so MySQL-isms
|
||||
# in dynamic SQL are caught, without flagging safe interpolation of identifiers.
|
||||
text = "".join(
|
||||
v.value if isinstance(v, ast.Constant) and isinstance(v.value, str) else " ? "
|
||||
for v in node.values
|
||||
)
|
||||
self._scan_sql(text, node)
|
||||
# don't recurse: child literal chunks would otherwise be re-scanned individually
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None:
|
||||
fn = node.func
|
||||
name = fn.attr if isinstance(fn, ast.Attribute) else (fn.id if isinstance(fn, ast.Name) else "")
|
||||
|
||||
# row.get("Column_name") — MySQL SHOW INDEX result key
|
||||
if name == "get" and node.args and isinstance(node.args[0], ast.Constant) and node.args[0].value in MYSQL_RESULT_KEYS:
|
||||
self._flag(node, f'"{node.args[0].value}" is a MySQL SHOW INDEX result key -> use frappe.db.has_index()/get_column_index()')
|
||||
|
||||
# set_value(..., True) / db_set("field", True) on a Check (int) column.
|
||||
# Only the field *value* arg carries bool->smallint risk — NOT trailing flags like
|
||||
# update_modified. db_set(field, value, update_modified, ...) -> value at args[1] (or a dict
|
||||
# at args[0]); set_value(dt, dn, field, value, ...) -> value at args[3] (or a dict at args[2]).
|
||||
if name in SET_BOOL_FUNCS:
|
||||
value_idx, dict_idx = (1, 0) if name == "db_set" else (3, 2)
|
||||
dict_arg = (
|
||||
node.args[dict_idx]
|
||||
if len(node.args) > dict_idx and isinstance(node.args[dict_idx], ast.Dict)
|
||||
else None
|
||||
)
|
||||
if dict_arg is not None:
|
||||
for v in dict_arg.values:
|
||||
if isinstance(v, ast.Constant) and isinstance(v.value, bool):
|
||||
self._flag(node, f"{name}(...) sets an int/Check column with a bool in a dict -> pass 1/0 (Postgres rejects bool->smallint)")
|
||||
elif len(node.args) > value_idx:
|
||||
a = node.args[value_idx]
|
||||
if isinstance(a, ast.Constant) and isinstance(a.value, bool):
|
||||
self._flag(node, f"{name}(..., {a.value}) sets an int/Check column with a bool -> pass 1/0 (Postgres rejects bool->smallint)")
|
||||
|
||||
# frappe.get_all/get_list(..., distinct=True, order_by="<col>") -> ORDER BY is silently dropped
|
||||
# for distinct queries on Postgres, so the result is unordered there. Sort in python instead
|
||||
# (e.g. sorted(frappe.get_all(..., distinct=True), key=str.casefold)). An empty order_by="" (the
|
||||
# explicit "suppress the injected default" idiom) and a dynamic/variable order_by are not flagged.
|
||||
if name in DISTINCT_ORDER_FUNCS:
|
||||
has_distinct = any(
|
||||
kw.arg == "distinct" and isinstance(kw.value, ast.Constant) and kw.value.value
|
||||
for kw in node.keywords
|
||||
)
|
||||
order_kw = next((kw for kw in node.keywords if kw.arg == "order_by"), None)
|
||||
has_literal_order = (
|
||||
order_kw is not None
|
||||
and isinstance(order_kw.value, ast.Constant)
|
||||
and isinstance(order_kw.value.value, str)
|
||||
and order_kw.value.value.strip()
|
||||
)
|
||||
if has_distinct and has_literal_order:
|
||||
self._flag(node, f"{name}(distinct=True, order_by=...) -> frappe drops ORDER BY for distinct queries on Postgres; sort in python instead, e.g. sorted(..., key=str.casefold)")
|
||||
|
||||
# query-builder .rlike(...): pypika emits the MySQL-only RLIKE operator, which frappe does
|
||||
# NOT translate for Postgres (it rewrites only REGEXP -> ~*).
|
||||
if name == "rlike":
|
||||
self._flag(node, ".rlike() emits MySQL-only RLIKE (not translated on Postgres) -> use .regexp() (rewritten to ~*) or .like()")
|
||||
|
||||
# Cast(col, "char") / Cast_(col, "char"): on Postgres a bare CHAR is character(1) and truncates
|
||||
# (e.g. CAST(12 AS CHAR) -> '1'); use "varchar" for a full-length string cast.
|
||||
if name in CAST_FUNCS:
|
||||
for arg in (*node.args, *(kw.value for kw in node.keywords)):
|
||||
if isinstance(arg, ast.Constant) and isinstance(arg.value, str) and arg.value.strip().lower() == "char":
|
||||
self._flag(node, f"{name}(..., 'char') is character(1) on Postgres and truncates -> use 'varchar'")
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Subscript(self, node: ast.Subscript) -> None:
|
||||
key = node.slice
|
||||
if isinstance(key, ast.Constant) and key.value in MYSQL_RESULT_KEYS:
|
||||
self._flag(node, f'"{key.value}" is a MySQL SHOW INDEX result key -> use frappe.db.has_index()/get_column_index()')
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def check_file(path: str) -> list[str]:
|
||||
try:
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-security-file-traversal -- dev-only lint tool; `path` is a source file supplied by pre-commit, not user input
|
||||
src = open(path, encoding="utf-8").read()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return []
|
||||
try:
|
||||
tree = ast.parse(src, filename=path)
|
||||
except SyntaxError:
|
||||
return [] # check-ast hook reports real syntax errors
|
||||
v = Visitor(src.splitlines(), _docstring_ids(tree))
|
||||
v.visit(tree)
|
||||
return [f"{path}:{line}: [pg-compat] {msg}" for line, msg in sorted(set(v.violations))]
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
out: list[str] = []
|
||||
for path in argv:
|
||||
if path.endswith(".py"):
|
||||
out.extend(check_file(path))
|
||||
if out:
|
||||
print("\n".join(out))
|
||||
print(
|
||||
f"\n{len(out)} PostgreSQL-incompatibility issue(s). Fix them, or add `# pg-ok` to a "
|
||||
"line that is intentionally MariaDB-only (e.g. inside an `if frappe.db.db_type == 'mariadb':` branch)."
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
2
.github/helper/site_config_postgres.json
vendored
2
.github/helper/site_config_postgres.json
vendored
@@ -13,6 +13,6 @@
|
||||
"root_login": "postgres",
|
||||
"root_password": "travis",
|
||||
"host_name": "http://test_site:8000",
|
||||
"install_apps": ["erpnext"],
|
||||
"install_apps": ["payments", "erpnext"],
|
||||
"throttle_user_limit": 100
|
||||
}
|
||||
|
||||
79
.github/helper/start-db.sh
vendored
Executable file
79
.github/helper/start-db.sh
vendored
Executable file
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Run MariaDB INSIDE the runner container, on a datadir we control. Because the datadir can be
|
||||
# packaged into the bench artifact, test shards start an already-loaded server instead of
|
||||
# replaying a SQL dump (the ~60s hydrate restore). Each shard gets its own copy → isolation kept.
|
||||
#
|
||||
# CI_DB_DATADIR picks the path:
|
||||
# - setup job: /home/ci/db-data (OUTSIDE the bench, so install.sh's `rm -rf ~/frappe-bench`
|
||||
# doesn't wipe it; it's moved into the bench just before packaging)
|
||||
# - test shard: ~/frappe-bench/mariadb-data (where the artifact untar'd it)
|
||||
#
|
||||
# Idempotent: inits a fresh datadir if absent (setup), else starts on the existing one (shards).
|
||||
#
|
||||
set -e
|
||||
|
||||
ci_user="${ERPNEXT_CI_USER:-frappe}"
|
||||
|
||||
# Re-exec as the ci user so mariadbd and the datadir are owned consistently (root mariadbd is
|
||||
# refused anyway). Mirrors install.sh's user switch.
|
||||
if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then
|
||||
exec su -m "$ci_user" -s /bin/bash -c \
|
||||
"ERPNEXT_CI_USER='$ci_user' CI_DB_DATADIR='${CI_DB_DATADIR:-}' bash '$0'"
|
||||
fi
|
||||
|
||||
# --- PostgreSQL (GitHub-hosted CI): run in-runner on a PGDATA so it bakes into the artifact,
|
||||
# same idea as the mariadb datadir. Trust auth (throwaway CI) skips password setup; durability
|
||||
# off for speed. Postgres is preinstalled on ubuntu-latest under /usr/lib/postgresql/<ver>/bin.
|
||||
if [ "${DB:-mariadb}" = "postgres" ]; then
|
||||
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin 2>/dev/null | sort -V | tail -1)
|
||||
[ -n "$PG_BIN" ] && export PATH="$PG_BIN:$PATH"
|
||||
PGDATA="${CI_DB_DATADIR:-$HOME/frappe-bench/pgdata}"
|
||||
if [ ! -d "$PGDATA/base" ]; then
|
||||
initdb -D "$PGDATA" -U postgres --auth-local=trust --auth-host=trust >/dev/null
|
||||
echo "host all all 127.0.0.1/32 trust" >> "$PGDATA/pg_hba.conf"
|
||||
fi
|
||||
pg_ctl -D "$PGDATA" -w -o "-p 5432 -c listen_addresses=127.0.0.1 -c unix_socket_directories=$PGDATA -c fsync=off -c synchronous_commit=off -c full_page_writes=off" start
|
||||
echo "PostgreSQL up in-runner (pgdata=$PGDATA)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- MariaDB ---
|
||||
DATADIR="${CI_DB_DATADIR:-$HOME/frappe-bench/mariadb-data}"
|
||||
SOCK="$DATADIR/mysqld.sock"
|
||||
fresh=0
|
||||
|
||||
if [ ! -d "$DATADIR/mysql" ]; then
|
||||
mkdir -p "$DATADIR"
|
||||
mariadb-install-db --no-defaults --datadir="$DATADIR" \
|
||||
--auth-root-authentication-method=normal --skip-test-db >/dev/null 2>&1
|
||||
fresh=1
|
||||
fi
|
||||
|
||||
# Throwaway-CI durability off; bind TCP 127.0.0.1:3306 so bench/install.sh connect as usual.
|
||||
mariadbd --no-defaults --datadir="$DATADIR" --socket="$SOCK" --pid-file="$DATADIR/mysqld.pid" \
|
||||
--port=3306 --bind-address=127.0.0.1 \
|
||||
--innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --skip-log-bin \
|
||||
> "$HOME/mariadb.log" 2>&1 &
|
||||
|
||||
up=0
|
||||
for _ in $(seq 1 60); do
|
||||
if mariadb-admin --socket="$SOCK" ping --silent 2>/dev/null; then up=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
# Fail loudly instead of letting the loop fall through (exit 0 of the last `sleep`) into SQL that
|
||||
# would error with a vague socket-connection failure.
|
||||
[ "$up" = "1" ] || { echo "mariadbd did not come up on $SOCK"; cat "$HOME/mariadb.log" 2>/dev/null; exit 1; }
|
||||
|
||||
if [ "$fresh" = "1" ]; then
|
||||
# A fresh datadir has only a password-less root@localhost. Give it the password install.sh
|
||||
# uses, plus a TCP-reachable root@127.0.0.1, so the rest of install.sh works unchanged.
|
||||
mariadb --no-defaults --socket="$SOCK" -u root <<'SQL'
|
||||
ALTER USER 'root'@'localhost' IDENTIFIED BY 'root';
|
||||
CREATE USER IF NOT EXISTS 'root'@'127.0.0.1' IDENTIFIED BY 'root';
|
||||
GRANT ALL PRIVILEGES ON *.* TO 'root'@'127.0.0.1' WITH GRANT OPTION;
|
||||
FLUSH PRIVILEGES;
|
||||
SQL
|
||||
fi
|
||||
|
||||
echo "MariaDB up in-container (datadir=$DATADIR, fresh=$fresh)"
|
||||
30
.github/workflows/patch.yml
vendored
30
.github/workflows/patch.yml
vendored
@@ -65,6 +65,19 @@ jobs:
|
||||
- name: Add to Hosts
|
||||
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.
|
||||
- name: Cache erpnext v14 backup
|
||||
id: cache-v14
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/erpnext-v14.sql.gz
|
||||
key: erpnext-v14-sql-gz
|
||||
|
||||
- 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
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -113,12 +126,20 @@ jobs:
|
||||
jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json
|
||||
mv tmp.json ~/frappe-bench/sites/test_site/site_config.json
|
||||
|
||||
wget https://frappe.io/files/erpnext-v14.sql.gz
|
||||
bench --site test_site --force restore ~/frappe-bench/erpnext-v14.sql.gz
|
||||
bench --site test_site --force restore ~/erpnext-v14.sql.gz
|
||||
|
||||
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
|
||||
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git
|
||||
|
||||
# Start every bench process except the background workers. If workers run during a
|
||||
# migrate, they pick up the orphan-link cleanup jobs it enqueues and race its schema
|
||||
# changes, which fails with MySQL 1412 "Table definition has changed". Redis and the
|
||||
# other services stay up; the queued jobs simply wait and are harmless here.
|
||||
function start_bench_without_workers() {
|
||||
local procs
|
||||
procs=$(awk -F: '/^[a-z_]+:/ && $1 !~ /worker/ {print $1}' ~/frappe-bench/Procfile)
|
||||
honcho start -f ~/frappe-bench/Procfile $procs &>> ~/frappe-bench/bench_start.log &
|
||||
}
|
||||
|
||||
function update_to_version() {
|
||||
version=$1
|
||||
@@ -134,10 +155,11 @@ jobs:
|
||||
|
||||
# Resetup env and install apps
|
||||
pgrep honcho | xargs kill
|
||||
sleep 10
|
||||
rm -rf ~/frappe-bench/env
|
||||
bench -v setup env --python python$2
|
||||
bench pip install -e ./apps/erpnext
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
start_bench_without_workers
|
||||
|
||||
bench --site test_site migrate
|
||||
}
|
||||
@@ -154,7 +176,7 @@ jobs:
|
||||
rm -rf ~/frappe-bench/env
|
||||
bench -v setup env
|
||||
bench pip install -e ./apps/erpnext
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
start_bench_without_workers
|
||||
|
||||
bench --site test_site migrate
|
||||
|
||||
|
||||
185
.github/workflows/server-tests-postgres.yml
vendored
185
.github/workflows/server-tests-postgres.yml
vendored
@@ -1,7 +1,12 @@
|
||||
name: Server (Postgres)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 03:00 AM IST daily (21:30 UTC the previous day)
|
||||
- cron: "30 21 * * *"
|
||||
pull_request:
|
||||
# 'labeled' so adding the 'postgres' label to an already-open PR re-triggers the run.
|
||||
types: [opened, reopened, synchronize, labeled]
|
||||
paths-ignore:
|
||||
- '**.js'
|
||||
- '**.md'
|
||||
@@ -9,7 +14,7 @@ on:
|
||||
- 'crowdin.yml'
|
||||
- '.coderabbit.yml'
|
||||
- '.mergify.yml'
|
||||
types: [opened, labelled, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: server-postgres-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
|
||||
@@ -18,41 +23,31 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Postgres CI stays on GitHub-hosted (free, full-speed VM per shard) but follows the same fan-out
|
||||
# we built for MariaDB: build the bench + reinstall ONCE in the setup job, bake the PostgreSQL
|
||||
# PGDATA into the artifact, and have 4 test shards start Postgres on that datadir — no per-shard
|
||||
# clone/build/reinstall/restore. Python is pinned so the venv transplants between VMs.
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
NODE_ENV: "production"
|
||||
PYTHON_VERSION: '3.14'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: ${{ contains(github.event.pull_request.labels.*.name, 'postgres') }}
|
||||
setup:
|
||||
name: Build & reinstall (setup)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
container: [1]
|
||||
|
||||
name: Python Unit Tests
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:13.3
|
||||
env:
|
||||
POSTGRES_PASSWORD: travis
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
# Runs on the daily schedule (and workflow_dispatch). On PRs it runs ONLY when the PR carries
|
||||
# the 'postgres' label — the test job needs setup, so it's skipped too when this is.
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'postgres')
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.14'
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Check for valid Python & Merge Conflicts
|
||||
run: |
|
||||
@@ -71,48 +66,124 @@ jobs:
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
- name: Cache pip
|
||||
- name: Cache deps (uv/pip/npm/yarn)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
path: |
|
||||
~/.cache/uv
|
||||
~/.cache/pip
|
||||
~/.npm
|
||||
~/.cache/yarn
|
||||
key: ${{ runner.os }}-deps-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-deps-
|
||||
|
||||
- name: Cache node modules
|
||||
# Warm-bench cache (the big one): install.sh saves the built base bench — frappe + env +
|
||||
# node_modules + assets — here as frappe-bench-base-*.tar.zst. Later runs restore it and only
|
||||
# fast-forward to the live develop SHA + rebuild the delta, so the bench BUILD is near-free and
|
||||
# only the test_site reinstall (per-run DB, uncacheable) stays slow — matching the self-hosted
|
||||
# box. The first run after a deps change populates it; every run after that is fast.
|
||||
- name: Cache warm bench (base build)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/bench-cache
|
||||
key: ${{ runner.os }}-warmbench-v2-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-warmbench-v2-
|
||||
|
||||
# Postgres runs in-runner on a PGDATA OUTSIDE the bench (install.sh wipes ~/frappe-bench);
|
||||
# after the reinstall it's moved into the bench so it ships in the artifact.
|
||||
- name: Start DB
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/start-db.sh
|
||||
env:
|
||||
cache-name: cache-node-modules
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-${{ env.cache-name }}-
|
||||
${{ runner.os }}-build-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
DB: postgres
|
||||
CI_DB_DATADIR: /home/runner/pgdata
|
||||
|
||||
- name: Install
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
||||
env:
|
||||
DB: postgres
|
||||
TYPE: server
|
||||
FRAPPE_BRANCH: develop
|
||||
BENCH_CACHE_DIR: /home/runner/bench-cache
|
||||
|
||||
- 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
|
||||
mv /home/runner/pgdata /home/runner/frappe-bench/pgdata
|
||||
|
||||
- name: Package bench for test shards
|
||||
run: |
|
||||
cp "${GITHUB_WORKSPACE}/.github/helper/hydrate.sh" /home/runner/frappe-bench/hydrate.sh
|
||||
cp "${GITHUB_WORKSPACE}/.github/helper/start-db.sh" /home/runner/frappe-bench/start-db.sh
|
||||
tar czpf "${GITHUB_WORKSPACE}/bench.tar.gz" -C /home/runner \
|
||||
--exclude='.git' --exclude='node_modules' frappe-bench
|
||||
ls -lh "${GITHUB_WORKSPACE}/bench.tar.gz"
|
||||
|
||||
- name: Upload bench artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bench-pg
|
||||
path: bench.tar.gz
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
test:
|
||||
name: Python Unit Tests (PG)
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
container: [1, 2, 3, 4]
|
||||
steps:
|
||||
- name: Download bench artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: bench-pg
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
# The bench CLI (frappe-bench) and redis are global/system tools — not in the bench tarball.
|
||||
# The setup runner got them via install.sh; the MariaDB shards get them from the arc5 image.
|
||||
# GitHub-hosted PG shards install them here (cheap vs the build+reinstall that setup did once).
|
||||
- name: Install shard runtime (bench CLI + redis + wkhtmltopdf)
|
||||
run: |
|
||||
pip install frappe-bench
|
||||
command -v redis-server >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq redis-server; }
|
||||
# wkhtmltopdf (patched-qt build) for print-format / PDF tests — same .deb install.sh uses.
|
||||
if ! command -v wkhtmltopdf >/dev/null; then
|
||||
wget -qO /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
sudo apt-get install -y -qq /tmp/wkhtmltox.deb
|
||||
fi
|
||||
|
||||
- name: Untar bench
|
||||
run: |
|
||||
tar xzpf "${GITHUB_WORKSPACE}/bench.tar.gz" -C /home/runner
|
||||
ls -ld /home/runner/frappe-bench
|
||||
|
||||
- name: Hydrate (start Postgres on the baked datadir)
|
||||
run: bash /home/runner/frappe-bench/hydrate.sh
|
||||
env:
|
||||
DB: postgres
|
||||
DB_HOST: 127.0.0.1
|
||||
|
||||
- name: Run Tests
|
||||
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator
|
||||
run: |
|
||||
cd ~/frappe-bench/
|
||||
# print-format / PDF tests are engine-independent (they exercise wkhtmltopdf rendering,
|
||||
# not postgres SQL — the MariaDB CI already covers them). They only fetch the static asset
|
||||
# bundles from http://test_site:8000/assets/..., so a plain static file server over sites/
|
||||
# satisfies wkhtmltopdf without the frappe web server (which never bound on a bare runner).
|
||||
( cd ~/frappe-bench/sites && nohup python3 -m http.server 8000 --bind 127.0.0.1 > ~/frappe-bench/web.log 2>&1 & )
|
||||
for _ in $(seq 1 15); do (exec 3<>/dev/tcp/127.0.0.1/8000) 2>/dev/null && { exec 3>&- 3<&-; break; }; sleep 1; done
|
||||
bench --site test_site run-parallel-tests --lightmode --app erpnext \
|
||||
--total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }}
|
||||
env:
|
||||
TYPE: server
|
||||
CI_BUILD_ID: ${{ github.run_id }}
|
||||
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -66,6 +66,18 @@ repos:
|
||||
- id: ruff-format
|
||||
name: "Run ruff formatter"
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: postgres-compat
|
||||
name: "PostgreSQL compatibility (static check)"
|
||||
description: "Flags MySQL-only SQL that breaks on Postgres; the label-gated PG test job is the backstop for semantic divergences."
|
||||
entry: .github/helper/postgres_compat.py
|
||||
language: script
|
||||
files: ^erpnext/.*\.py$
|
||||
# patches/ are historical, version-gated migrations (skipped on fresh Postgres installs);
|
||||
# out of scope for the always-on gate.
|
||||
exclude: ^erpnext/patches/
|
||||
|
||||
ci:
|
||||
autoupdate_schedule: weekly
|
||||
skip: []
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.email import sendmail_to_system_managers
|
||||
from frappe.query_builder.functions import IfNull, Sum
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
add_months,
|
||||
@@ -53,20 +54,24 @@ def validate_service_stop_date(doc):
|
||||
|
||||
|
||||
def build_conditions(process_type, account, company):
|
||||
conditions = ""
|
||||
deferred_account = (
|
||||
"item.deferred_revenue_account" if process_type == "Income" else "item.deferred_expense_account"
|
||||
)
|
||||
if process_type == "Income":
|
||||
item = frappe.qb.DocType("Sales Invoice Item")
|
||||
parent = frappe.qb.DocType("Sales Invoice")
|
||||
deferred_account = item.deferred_revenue_account
|
||||
else:
|
||||
item = frappe.qb.DocType("Purchase Invoice Item")
|
||||
parent = frappe.qb.DocType("Purchase Invoice")
|
||||
deferred_account = item.deferred_expense_account
|
||||
|
||||
if account:
|
||||
conditions += f"AND {deferred_account}={frappe.db.escape(account)}"
|
||||
return deferred_account == account
|
||||
elif company:
|
||||
conditions += f"AND p.company = {frappe.db.escape(company)}"
|
||||
return parent.company == company
|
||||
|
||||
return conditions
|
||||
return None
|
||||
|
||||
|
||||
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=""):
|
||||
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=None):
|
||||
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
|
||||
|
||||
if not start_date:
|
||||
@@ -75,17 +80,25 @@ def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_d
|
||||
end_date = add_days(today(), -1)
|
||||
|
||||
# check for the purchase invoice for which GL entries has to be done
|
||||
invoices = frappe.db.sql_list(
|
||||
f"""
|
||||
select distinct item.parent
|
||||
from `tabPurchase Invoice Item` item, `tabPurchase Invoice` p
|
||||
where item.service_start_date<=%s and item.service_end_date>=%s
|
||||
and item.enable_deferred_expense = 1 and item.parent=p.name
|
||||
and item.docstatus = 1 and ifnull(item.amount, 0) > 0
|
||||
{conditions}
|
||||
""",
|
||||
(end_date, start_date),
|
||||
) # nosec
|
||||
item = frappe.qb.DocType("Purchase Invoice Item")
|
||||
parent = frappe.qb.DocType("Purchase Invoice")
|
||||
query = (
|
||||
frappe.qb.from_(item)
|
||||
.inner_join(parent)
|
||||
.on(item.parent == parent.name)
|
||||
.select(item.parent)
|
||||
.distinct()
|
||||
.where(
|
||||
(item.service_start_date <= end_date)
|
||||
& (item.service_end_date >= start_date)
|
||||
& (item.enable_deferred_expense == 1)
|
||||
& (item.docstatus == 1)
|
||||
& (IfNull(item.amount, 0) > 0)
|
||||
)
|
||||
)
|
||||
if conditions is not None:
|
||||
query = query.where(conditions)
|
||||
invoices = query.run(pluck=True)
|
||||
|
||||
# For each invoice, book deferred expense
|
||||
for invoice in invoices:
|
||||
@@ -96,7 +109,7 @@ def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_d
|
||||
send_mail(deferred_process)
|
||||
|
||||
|
||||
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=""):
|
||||
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=None):
|
||||
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
|
||||
|
||||
if not start_date:
|
||||
@@ -105,17 +118,25 @@ def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_da
|
||||
end_date = add_days(today(), -1)
|
||||
|
||||
# check for the sales invoice for which GL entries has to be done
|
||||
invoices = frappe.db.sql_list(
|
||||
f"""
|
||||
select distinct item.parent
|
||||
from `tabSales Invoice Item` item, `tabSales Invoice` p
|
||||
where item.service_start_date<=%s and item.service_end_date>=%s
|
||||
and item.enable_deferred_revenue = 1 and item.parent=p.name
|
||||
and item.docstatus = 1 and ifnull(item.amount, 0) > 0
|
||||
{conditions}
|
||||
""",
|
||||
(end_date, start_date),
|
||||
) # nosec
|
||||
item = frappe.qb.DocType("Sales Invoice Item")
|
||||
parent = frappe.qb.DocType("Sales Invoice")
|
||||
query = (
|
||||
frappe.qb.from_(item)
|
||||
.inner_join(parent)
|
||||
.on(item.parent == parent.name)
|
||||
.select(item.parent)
|
||||
.distinct()
|
||||
.where(
|
||||
(item.service_start_date <= end_date)
|
||||
& (item.service_end_date >= start_date)
|
||||
& (item.enable_deferred_revenue == 1)
|
||||
& (item.docstatus == 1)
|
||||
& (IfNull(item.amount, 0) > 0)
|
||||
)
|
||||
)
|
||||
if conditions is not None:
|
||||
query = query.where(conditions)
|
||||
invoices = query.run(pluck=True)
|
||||
|
||||
for invoice in invoices:
|
||||
doc = frappe.get_doc("Sales Invoice", invoice)
|
||||
@@ -136,26 +157,39 @@ def get_booking_dates(doc, item, posting_date=None, prev_posting_date=None):
|
||||
)
|
||||
|
||||
if not prev_posting_date:
|
||||
prev_gl_entry = frappe.db.sql(
|
||||
"""
|
||||
select name, posting_date from `tabGL Entry` where company=%s and account=%s and
|
||||
voucher_type=%s and voucher_no=%s and voucher_detail_no=%s
|
||||
and is_cancelled = 0
|
||||
order by posting_date desc limit 1
|
||||
""",
|
||||
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
|
||||
as_dict=True,
|
||||
prev_gl_entry = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"company": doc.company,
|
||||
"account": item.get(deferred_account),
|
||||
"voucher_type": doc.doctype,
|
||||
"voucher_no": doc.name,
|
||||
"voucher_detail_no": item.name,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
fields=["name", "posting_date"],
|
||||
order_by="posting_date desc",
|
||||
limit=1,
|
||||
)
|
||||
|
||||
prev_gl_via_je = frappe.db.sql(
|
||||
"""
|
||||
SELECT p.name, p.posting_date FROM `tabJournal Entry` p, `tabJournal Entry Account` c
|
||||
WHERE p.name = c.parent and p.company=%s and c.account=%s
|
||||
and c.reference_type=%s and c.reference_name=%s
|
||||
and c.reference_detail_no=%s and c.docstatus < 2 order by posting_date desc limit 1
|
||||
""",
|
||||
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
|
||||
as_dict=True,
|
||||
je = frappe.qb.DocType("Journal Entry")
|
||||
jea = frappe.qb.DocType("Journal Entry Account")
|
||||
prev_gl_via_je = (
|
||||
frappe.qb.from_(je)
|
||||
.inner_join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.name, je.posting_date)
|
||||
.where(
|
||||
(je.company == doc.company)
|
||||
& (jea.account == item.get(deferred_account))
|
||||
& (jea.reference_type == doc.doctype)
|
||||
& (jea.reference_name == doc.name)
|
||||
& (jea.reference_detail_no == item.name)
|
||||
& (jea.docstatus < 2)
|
||||
)
|
||||
.orderby(je.posting_date, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
if prev_gl_via_je:
|
||||
@@ -277,26 +311,47 @@ def get_already_booked_amount(doc, item):
|
||||
total_credit_debit, total_credit_debit_currency = "credit", "credit_in_account_currency"
|
||||
deferred_account = "deferred_expense_account"
|
||||
|
||||
gl_entries_details = frappe.db.sql(
|
||||
"""
|
||||
select sum({}) as total_credit, sum({}) as total_credit_in_account_currency, voucher_detail_no
|
||||
from `tabGL Entry` where company=%s and account=%s and voucher_type=%s and voucher_no=%s and voucher_detail_no=%s
|
||||
and is_cancelled = 0
|
||||
group by voucher_detail_no
|
||||
""".format(total_credit_debit, total_credit_debit_currency),
|
||||
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
|
||||
as_dict=True,
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
gl_entries_details = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(
|
||||
Sum(gle[total_credit_debit]).as_("total_credit"),
|
||||
Sum(gle[total_credit_debit_currency]).as_("total_credit_in_account_currency"),
|
||||
gle.voucher_detail_no,
|
||||
)
|
||||
.where(
|
||||
(gle.company == doc.company)
|
||||
& (gle.account == item.get(deferred_account))
|
||||
& (gle.voucher_type == doc.doctype)
|
||||
& (gle.voucher_no == doc.name)
|
||||
& (gle.voucher_detail_no == item.name)
|
||||
& (gle.is_cancelled == 0)
|
||||
)
|
||||
.groupby(gle.voucher_detail_no)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
journal_entry_details = frappe.db.sql(
|
||||
"""
|
||||
SELECT sum(c.{}) as total_credit, sum(c.{}) as total_credit_in_account_currency, reference_detail_no
|
||||
FROM `tabJournal Entry` p , `tabJournal Entry Account` c WHERE p.name = c.parent and
|
||||
p.company = %s and c.account=%s and c.reference_type=%s and c.reference_name=%s and c.reference_detail_no=%s
|
||||
and p.docstatus < 2 group by reference_detail_no
|
||||
""".format(total_credit_debit, total_credit_debit_currency),
|
||||
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
|
||||
as_dict=True,
|
||||
je = frappe.qb.DocType("Journal Entry")
|
||||
jea = frappe.qb.DocType("Journal Entry Account")
|
||||
journal_entry_details = (
|
||||
frappe.qb.from_(je)
|
||||
.inner_join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(
|
||||
Sum(jea[total_credit_debit]).as_("total_credit"),
|
||||
Sum(jea[total_credit_debit_currency]).as_("total_credit_in_account_currency"),
|
||||
jea.reference_detail_no,
|
||||
)
|
||||
.where(
|
||||
(je.company == doc.company)
|
||||
& (jea.account == item.get(deferred_account))
|
||||
& (jea.reference_type == doc.doctype)
|
||||
& (jea.reference_name == doc.name)
|
||||
& (jea.reference_detail_no == item.name)
|
||||
& (je.docstatus < 2)
|
||||
)
|
||||
.groupby(jea.reference_detail_no)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
already_booked_amount = gl_entries_details[0].total_credit if gl_entries_details else 0
|
||||
|
||||
@@ -10,7 +10,7 @@ frappe.ui.form.on("Accounts Settings", {
|
||||
},
|
||||
};
|
||||
});
|
||||
if (!frm.naming_controller) frm.naming_controller = new erpnext.NamingSeriesController(frm);
|
||||
if (!frm.naming_controller) frm.naming_controller = new frappe.ui.NamingSeriesController(frm);
|
||||
|
||||
frm.naming_controller.render_table("transaction_naming_html", get_transactions(frm));
|
||||
},
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"period_closing_settings_section",
|
||||
"ignore_account_closing_balance",
|
||||
"use_legacy_controller_for_pcv",
|
||||
"pcv_job_timeout",
|
||||
"column_break_25",
|
||||
"reports_tab",
|
||||
"remarks_section",
|
||||
@@ -612,6 +613,14 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "Use legacy controller for Period Closing Voucher"
|
||||
},
|
||||
{
|
||||
"default": "3600",
|
||||
"depends_on": "eval: !doc.use_legacy_controller_for_pcv",
|
||||
"description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher",
|
||||
"fieldname": "pcv_job_timeout",
|
||||
"fieldtype": "Int",
|
||||
"label": "PCV Job Timeout (seconds)"
|
||||
},
|
||||
{
|
||||
"description": "Users with this role will be notified if the asset depreciation gets failed",
|
||||
"fieldname": "role_to_notify_on_depreciation_failure",
|
||||
@@ -756,7 +765,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-03 13:11:54.721495",
|
||||
"modified": "2026-06-24 12:59:41.868865",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
@@ -90,6 +90,7 @@ class AccountsSettings(Document):
|
||||
make_payment_via_journal_entry: DF.Check
|
||||
merge_similar_account_heads: DF.Check
|
||||
over_billing_allowance: DF.Currency
|
||||
pcv_job_timeout: DF.Int
|
||||
preview_mode: DF.Check
|
||||
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
|
||||
receivable_payable_remarks_length: DF.Int
|
||||
|
||||
@@ -7,7 +7,7 @@ from frappe import _, msgprint
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Coalesce, Sum
|
||||
from frappe.query_builder.functions import Coalesce, Max, Sum
|
||||
from frappe.utils import cint, flt, fmt_money, getdate
|
||||
from pypika import Order
|
||||
|
||||
@@ -195,14 +195,17 @@ def get_payment_entries_for_bank_clearance(
|
||||
.select(
|
||||
ConstantColumn("Journal Entry").as_("payment_document"),
|
||||
journal_entry.name.as_("payment_entry"),
|
||||
journal_entry.cheque_no.as_("cheque_number"),
|
||||
journal_entry.cheque_date,
|
||||
# non-grouped columns are constant per grouped JE name / account (against_account is
|
||||
# arbitrary per group on MySQL) -> Max() keeps the GROUP BY valid on postgres with the
|
||||
# same value MySQL picked.
|
||||
Max(journal_entry.cheque_no).as_("cheque_number"),
|
||||
Max(journal_entry.cheque_date).as_("cheque_date"),
|
||||
Sum(journal_entry_account.debit_in_account_currency).as_("debit"),
|
||||
Sum(journal_entry_account.credit_in_account_currency).as_("credit"),
|
||||
journal_entry.posting_date,
|
||||
journal_entry_account.against_account,
|
||||
journal_entry.clearance_date,
|
||||
journal_entry_account.account_currency,
|
||||
Max(journal_entry.posting_date).as_("posting_date"),
|
||||
Max(journal_entry_account.against_account).as_("against_account"),
|
||||
Max(journal_entry.clearance_date).as_("clearance_date"),
|
||||
Max(journal_entry_account.account_currency).as_("account_currency"),
|
||||
)
|
||||
.where(
|
||||
(journal_entry_account.account == account)
|
||||
@@ -215,12 +218,13 @@ def get_payment_entries_for_bank_clearance(
|
||||
|
||||
if not include_reconciled_entries:
|
||||
journal_entry_query = journal_entry_query.where(
|
||||
(journal_entry.clearance_date.isnull()) | (journal_entry.clearance_date == "0000-00-00")
|
||||
(journal_entry.clearance_date.isnull())
|
||||
| (journal_entry.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
|
||||
)
|
||||
|
||||
journal_entries = (
|
||||
journal_entry_query.groupby(journal_entry_account.account, journal_entry.name)
|
||||
.orderby(journal_entry.posting_date)
|
||||
.orderby(Max(journal_entry.posting_date))
|
||||
.orderby(journal_entry.name, order=Order.desc)
|
||||
).run(as_dict=True)
|
||||
|
||||
@@ -290,7 +294,8 @@ def get_payment_entries_for_bank_clearance(
|
||||
|
||||
if not include_reconciled_entries:
|
||||
payment_entry_query = payment_entry_query.where(
|
||||
(pe.clearance_date.isnull()) | (pe.clearance_date == "0000-00-00")
|
||||
(pe.clearance_date.isnull())
|
||||
| (pe.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
|
||||
)
|
||||
|
||||
payment_entries = (payment_entry_query.orderby(pe.posting_date).orderby(pe.name, order=Order.desc)).run(
|
||||
@@ -327,7 +332,8 @@ def get_payment_entries_for_bank_clearance(
|
||||
|
||||
if not include_reconciled_entries:
|
||||
paid_purchase_invoices_query = paid_purchase_invoices_query.where(
|
||||
(pi.clearance_date.isnull()) | (pi.clearance_date == "0000-00-00")
|
||||
(pi.clearance_date.isnull())
|
||||
| (pi.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
|
||||
)
|
||||
|
||||
paid_purchase_invoices = (
|
||||
@@ -367,7 +373,8 @@ def get_payment_entries_for_bank_clearance(
|
||||
|
||||
if not include_reconciled_entries:
|
||||
pos_sales_invoices_query = pos_sales_invoices_query.where(
|
||||
(si_payment.clearance_date.isnull()) | (si_payment.clearance_date == "0000-00-00")
|
||||
(si_payment.clearance_date.isnull())
|
||||
| (si_payment.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
|
||||
)
|
||||
|
||||
pos_sales_invoices = (
|
||||
|
||||
@@ -9,6 +9,13 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
|
||||
|
||||
frappe.ui.form.on("Bank Guarantee", {
|
||||
setup: function (frm) {
|
||||
frm.set_query("reference_doctype", function () {
|
||||
return {
|
||||
filters: {
|
||||
name: ["in", ["Sales Order", "Purchase Order"]],
|
||||
},
|
||||
};
|
||||
});
|
||||
frm.set_query("bank_account", function () {
|
||||
return {
|
||||
filters: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "ACC-BG-.YYYY.-.#####",
|
||||
"creation": "2016-12-17 10:43:35.731631",
|
||||
"doctype": "DocType",
|
||||
@@ -50,8 +51,7 @@
|
||||
"fieldname": "reference_doctype",
|
||||
"fieldtype": "Link",
|
||||
"label": "Reference Document Type",
|
||||
"options": "DocType",
|
||||
"read_only": 1
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_docname",
|
||||
@@ -60,14 +60,14 @@
|
||||
"options": "reference_doctype"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.bg_type == \"Receiving\"",
|
||||
"depends_on": "eval: doc.reference_doctype == \"Sales Order\"",
|
||||
"fieldname": "customer",
|
||||
"fieldtype": "Link",
|
||||
"label": "Customer",
|
||||
"options": "Customer"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.bg_type == \"Providing\"",
|
||||
"depends_on": "eval: doc.reference_doctype == \"Purchase Order\"",
|
||||
"fieldname": "supplier",
|
||||
"fieldtype": "Link",
|
||||
"label": "Supplier",
|
||||
@@ -218,10 +218,11 @@
|
||||
"grid_page_length": 50,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-29 11:52:33.550847",
|
||||
"modified": "2026-05-25 18:12:10.768835",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Guarantee",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -221,12 +221,12 @@
|
||||
"default": "0",
|
||||
"fieldname": "import_mt940_fromat",
|
||||
"fieldtype": "Check",
|
||||
"label": "Import MT940 Fromat"
|
||||
"label": "Import MT940 Format"
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-31 00:41:11.251215",
|
||||
"modified": "2026-06-19 14:18:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Statement Import",
|
||||
|
||||
@@ -136,6 +136,9 @@ function set_total_budget_amount(frm) {
|
||||
function toggle_distribution_fields(frm) {
|
||||
const grid = frm.fields_dict.budget_distribution.grid;
|
||||
|
||||
frm.set_df_property("budget_distribution", "cannot_add_rows", true);
|
||||
frm.set_df_property("budget_distribution", "cannot_delete_rows", true);
|
||||
|
||||
["amount", "percent"].forEach((field) => {
|
||||
grid.update_docfield_property(field, "read_only", frm.doc.distribute_equally);
|
||||
});
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder.functions import Coalesce, Sum
|
||||
from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate
|
||||
from frappe.utils.data import get_first_day
|
||||
from pypika.terms import ExistsCriterion
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
@@ -115,23 +117,26 @@ class Budget(Document):
|
||||
if not account:
|
||||
return
|
||||
|
||||
existing_budget = frappe.db.sql(
|
||||
f"""
|
||||
SELECT name, account
|
||||
FROM `tabBudget`
|
||||
WHERE
|
||||
docstatus < 2
|
||||
AND company = %s
|
||||
AND {budget_against_field} = %s
|
||||
AND account = %s
|
||||
AND name != %s
|
||||
AND (
|
||||
(SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
|
||||
AND (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
|
||||
)
|
||||
""",
|
||||
(self.company, budget_against, account, self.name, self.budget_end_date, self.budget_start_date),
|
||||
as_dict=True,
|
||||
budget = frappe.qb.DocType("Budget")
|
||||
fy_from = frappe.qb.DocType("Fiscal Year").as_("fy_from")
|
||||
fy_to = frappe.qb.DocType("Fiscal Year").as_("fy_to")
|
||||
existing_budget = (
|
||||
frappe.qb.from_(budget)
|
||||
.inner_join(fy_from)
|
||||
.on(fy_from.name == budget.from_fiscal_year)
|
||||
.inner_join(fy_to)
|
||||
.on(fy_to.name == budget.to_fiscal_year)
|
||||
.select(budget.name, budget.account)
|
||||
.where(
|
||||
(budget.docstatus < 2)
|
||||
& (budget.company == self.company)
|
||||
& (budget[budget_against_field] == budget_against)
|
||||
& (budget.account == account)
|
||||
& (budget.name != self.name)
|
||||
& (fy_from.year_start_date <= self.budget_end_date)
|
||||
& (fy_to.year_end_date >= self.budget_start_date)
|
||||
)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
if existing_budget:
|
||||
@@ -157,9 +162,9 @@ class Budget(Document):
|
||||
frappe.throw(_("Account {0} does not belong to company {1}").format(self.account, self.company))
|
||||
elif account_details.report_type != "Profit and Loss":
|
||||
frappe.throw(
|
||||
_("Budget cannot be assigned against {0}, as it's not an Income or Expense account").format(
|
||||
self.account
|
||||
)
|
||||
_(
|
||||
"Budget cannot be assigned against {0}, as its Root Type is not of Income or Expense"
|
||||
).format(self.account)
|
||||
)
|
||||
|
||||
def set_null_value(self):
|
||||
@@ -353,8 +358,8 @@ class Budget(Document):
|
||||
if self.should_regenerate_budget_distribution():
|
||||
return
|
||||
|
||||
total_amount = sum(d.amount for d in self.budget_distribution)
|
||||
total_percent = sum(d.percent for d in self.budget_distribution)
|
||||
total_amount = sum(flt(d.amount) for d in self.budget_distribution)
|
||||
total_percent = sum(flt(d.percent) for d in self.budget_distribution)
|
||||
|
||||
if flt(abs(total_amount - self.budget_amount), 2) > 0.10:
|
||||
frappe.throw(
|
||||
@@ -381,17 +386,24 @@ def validate_expense_against_budget(params, expense_amount=0):
|
||||
posting_fiscal_year = get_fiscal_year(posting_date, company=params.get("company"))[0]
|
||||
year_start_date, year_end_date = get_fiscal_year_date_range(posting_fiscal_year, posting_fiscal_year)
|
||||
|
||||
budget_exists = frappe.db.sql(
|
||||
"""
|
||||
select name
|
||||
from `tabBudget`
|
||||
where company = %s
|
||||
and docstatus = 1
|
||||
and (SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
|
||||
and (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
|
||||
limit 1
|
||||
""",
|
||||
(params.company, year_end_date, year_start_date),
|
||||
budget = frappe.qb.DocType("Budget")
|
||||
fy_from = frappe.qb.DocType("Fiscal Year").as_("fy_from")
|
||||
fy_to = frappe.qb.DocType("Fiscal Year").as_("fy_to")
|
||||
budget_exists = (
|
||||
frappe.qb.from_(budget)
|
||||
.inner_join(fy_from)
|
||||
.on(fy_from.name == budget.from_fiscal_year)
|
||||
.inner_join(fy_to)
|
||||
.on(fy_to.name == budget.to_fiscal_year)
|
||||
.select(budget.name)
|
||||
.where(
|
||||
(budget.company == params.company)
|
||||
& (budget.docstatus == 1)
|
||||
& (fy_from.year_start_date <= year_end_date)
|
||||
& (fy_to.year_end_date >= year_start_date)
|
||||
)
|
||||
.limit(1)
|
||||
.run()
|
||||
)
|
||||
|
||||
if not budget_exists:
|
||||
@@ -434,50 +446,52 @@ def validate_expense_against_budget(params, expense_amount=0):
|
||||
and (frappe.get_cached_value("Account", params.account, "root_type") == "Expense")
|
||||
):
|
||||
doctype = dimension.get("document_type")
|
||||
|
||||
if frappe.get_cached_value("DocType", doctype, "is_tree"):
|
||||
lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"])
|
||||
condition = f"""and exists(select name from `tab{doctype}`
|
||||
where lft<={lft} and rgt>={rgt} and name=b.{budget_against})""" # nosec
|
||||
params.is_tree = True
|
||||
else:
|
||||
condition = f"and b.{budget_against}={frappe.db.escape(params.get(budget_against))}"
|
||||
params.is_tree = False
|
||||
|
||||
params.is_tree = bool(frappe.get_cached_value("DocType", doctype, "is_tree"))
|
||||
params.budget_against_field = budget_against
|
||||
params.budget_against_doctype = doctype
|
||||
|
||||
budget_records = frappe.db.sql(
|
||||
f"""
|
||||
SELECT
|
||||
b = frappe.qb.DocType("Budget")
|
||||
query = (
|
||||
frappe.qb.from_(b)
|
||||
.select(
|
||||
b.name,
|
||||
b.{budget_against} AS budget_against,
|
||||
getattr(b, budget_against).as_("budget_against"),
|
||||
b.budget_amount,
|
||||
b.from_fiscal_year,
|
||||
b.to_fiscal_year,
|
||||
b.budget_start_date,
|
||||
b.budget_end_date,
|
||||
IFNULL(b.applicable_on_material_request, 0) AS for_material_request,
|
||||
IFNULL(b.applicable_on_purchase_order, 0) AS for_purchase_order,
|
||||
IFNULL(b.applicable_on_booking_actual_expenses, 0) AS for_actual_expenses,
|
||||
Coalesce(b.applicable_on_material_request, 0).as_("for_material_request"),
|
||||
Coalesce(b.applicable_on_purchase_order, 0).as_("for_purchase_order"),
|
||||
Coalesce(b.applicable_on_booking_actual_expenses, 0).as_("for_actual_expenses"),
|
||||
b.action_if_annual_budget_exceeded,
|
||||
b.action_if_accumulated_monthly_budget_exceeded,
|
||||
b.action_if_annual_budget_exceeded_on_mr,
|
||||
b.action_if_accumulated_monthly_budget_exceeded_on_mr,
|
||||
b.action_if_annual_budget_exceeded_on_po,
|
||||
b.action_if_accumulated_monthly_budget_exceeded_on_po
|
||||
FROM
|
||||
`tabBudget` b
|
||||
WHERE
|
||||
b.company = %s
|
||||
AND b.docstatus = 1
|
||||
AND %s BETWEEN b.budget_start_date AND b.budget_end_date
|
||||
AND b.account = %s
|
||||
{condition}
|
||||
""",
|
||||
(params.company, params.posting_date, params.account),
|
||||
as_dict=True,
|
||||
) # nosec
|
||||
b.action_if_accumulated_monthly_budget_exceeded_on_po,
|
||||
)
|
||||
.where(b.company == params.company)
|
||||
.where(b.docstatus == 1)
|
||||
.where(b.budget_start_date <= params.posting_date)
|
||||
.where(b.budget_end_date >= params.posting_date)
|
||||
.where(b.account == params.account)
|
||||
)
|
||||
|
||||
if params.is_tree:
|
||||
lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"])
|
||||
dim = frappe.qb.DocType(doctype)
|
||||
query = query.where(
|
||||
ExistsCriterion(
|
||||
frappe.qb.from_(dim)
|
||||
.select(dim.name)
|
||||
.where((dim.lft <= lft) & (dim.rgt >= rgt) & (dim.name == getattr(b, budget_against)))
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = query.where(getattr(b, budget_against) == params.get(budget_against))
|
||||
|
||||
budget_records = query.run(as_dict=True)
|
||||
|
||||
if budget_records:
|
||||
validate_budget_records(params, budget_records, expense_amount)
|
||||
@@ -674,15 +688,27 @@ def get_actions(params, budget):
|
||||
|
||||
def get_requested_amount(params):
|
||||
item_code = params.get("item_code")
|
||||
condition = get_other_condition(params, "Material Request")
|
||||
|
||||
data = frappe.db.sql(
|
||||
""" select ifnull((sum(child.stock_qty - child.ordered_qty) * rate), 0) as amount
|
||||
from `tabMaterial Request Item` child, `tabMaterial Request` parent where parent.name = child.parent and
|
||||
child.item_code = %s and parent.docstatus = 1 and child.stock_qty > child.ordered_qty and {} and
|
||||
parent.material_request_type = 'Purchase' and parent.status != 'Stopped'""".format(condition),
|
||||
item_code,
|
||||
as_list=1,
|
||||
child = frappe.qb.DocType("Material Request Item")
|
||||
parent = frappe.qb.DocType("Material Request")
|
||||
|
||||
data = (
|
||||
frappe.qb.from_(child)
|
||||
.join(parent)
|
||||
.on(parent.name == child.parent)
|
||||
.select(
|
||||
# rate inside the aggregate: Sum(qty * rate) is the correct requested amount and is PG-valid
|
||||
Coalesce(Sum((child.stock_qty - child.ordered_qty) * child.rate), 0).as_("amount")
|
||||
)
|
||||
.where(
|
||||
(child.item_code == item_code)
|
||||
& (parent.docstatus == 1)
|
||||
& (child.stock_qty > child.ordered_qty)
|
||||
& Criterion.all(get_other_condition(params, child, parent, "Material Request"))
|
||||
& (parent.material_request_type == "Purchase")
|
||||
& (parent.status != "Stopped")
|
||||
)
|
||||
.run(as_list=1)
|
||||
)
|
||||
|
||||
return data[0][0] if data else 0
|
||||
@@ -690,37 +716,43 @@ def get_requested_amount(params):
|
||||
|
||||
def get_ordered_amount(params):
|
||||
item_code = params.get("item_code")
|
||||
condition = get_other_condition(params, "Purchase Order")
|
||||
|
||||
data = frappe.db.sql(
|
||||
f""" select ifnull(sum(child.amount - child.billed_amt), 0) as amount
|
||||
from `tabPurchase Order Item` child, `tabPurchase Order` parent where
|
||||
parent.name = child.parent and child.item_code = %s and parent.docstatus = 1 and child.amount > child.billed_amt
|
||||
and parent.status != 'Closed' and {condition}""",
|
||||
item_code,
|
||||
as_list=1,
|
||||
child = frappe.qb.DocType("Purchase Order Item")
|
||||
parent = frappe.qb.DocType("Purchase Order")
|
||||
|
||||
data = (
|
||||
frappe.qb.from_(child)
|
||||
.join(parent)
|
||||
.on(parent.name == child.parent)
|
||||
.select(Coalesce(Sum(child.amount - child.billed_amt), 0).as_("amount"))
|
||||
.where(
|
||||
(child.item_code == item_code)
|
||||
& (parent.docstatus == 1)
|
||||
& (child.amount > child.billed_amt)
|
||||
& (parent.status != "Closed")
|
||||
& Criterion.all(get_other_condition(params, child, parent, "Purchase Order"))
|
||||
)
|
||||
.run(as_list=1)
|
||||
)
|
||||
|
||||
return data[0][0] if data else 0
|
||||
|
||||
|
||||
def get_other_condition(params, for_doc):
|
||||
condition = f"expense_account = {frappe.db.escape(params.expense_account)}"
|
||||
def get_other_condition(params, child, parent, for_doc):
|
||||
conditions = [child.expense_account == params.expense_account]
|
||||
budget_against_field = params.get("budget_against_field")
|
||||
|
||||
if budget_against_field and params.get(budget_against_field):
|
||||
condition += (
|
||||
f" and child.{budget_against_field} = {frappe.db.escape(params.get(budget_against_field))}"
|
||||
)
|
||||
conditions.append(child[budget_against_field] == params.get(budget_against_field))
|
||||
|
||||
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
|
||||
|
||||
start_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date")
|
||||
end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date")
|
||||
|
||||
condition += f" and parent.{date_field} between {frappe.db.escape(str(start_date))} and {frappe.db.escape(str(end_date))}"
|
||||
conditions.append(parent[date_field][str(start_date) : str(end_date)])
|
||||
|
||||
return condition
|
||||
return conditions
|
||||
|
||||
|
||||
def get_actual_expense(params):
|
||||
@@ -728,11 +760,19 @@ def get_actual_expense(params):
|
||||
params.budget_against_doctype = frappe.unscrub(params.budget_against_field)
|
||||
|
||||
budget_against_field = params.get("budget_against_field")
|
||||
condition1 = " and gle.posting_date <= %(month_end_date)s" if params.get("month_end_date") else ""
|
||||
|
||||
date_condition = (
|
||||
f"and gle.posting_date between '{params.budget_start_date}' and '{params.budget_end_date}'"
|
||||
)
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
|
||||
conditions = [
|
||||
gle.is_cancelled == 0,
|
||||
gle.account == params.get("account"),
|
||||
gle.posting_date[str(params.budget_start_date) : str(params.budget_end_date)],
|
||||
gle.company == params.get("company"),
|
||||
gle.docstatus == 1,
|
||||
]
|
||||
|
||||
if params.get("month_end_date"):
|
||||
conditions.append(gle.posting_date <= params.get("month_end_date"))
|
||||
|
||||
if params.is_tree:
|
||||
lft_rgt = frappe.db.get_value(
|
||||
@@ -740,35 +780,27 @@ def get_actual_expense(params):
|
||||
)
|
||||
params.update(lft_rgt)
|
||||
|
||||
condition2 = f"""
|
||||
and exists(
|
||||
select name from `tab{params.budget_against_doctype}`
|
||||
where lft >= %(lft)s and rgt <= %(rgt)s
|
||||
and name = gle.{budget_against_field}
|
||||
tree = frappe.qb.DocType(params.budget_against_doctype)
|
||||
conditions.append(
|
||||
ExistsCriterion(
|
||||
frappe.qb.from_(tree)
|
||||
.select(tree.name)
|
||||
.where(
|
||||
(tree.lft >= params.get("lft"))
|
||||
& (tree.rgt <= params.get("rgt"))
|
||||
& (tree.name == gle[budget_against_field])
|
||||
)
|
||||
)
|
||||
"""
|
||||
)
|
||||
else:
|
||||
condition2 = f"""
|
||||
and gle.{budget_against_field} = %({budget_against_field})s
|
||||
"""
|
||||
conditions.append(gle[budget_against_field] == params.get(budget_against_field))
|
||||
|
||||
amount = flt(
|
||||
frappe.db.sql(
|
||||
f"""
|
||||
select sum(gle.debit) - sum(gle.credit)
|
||||
from `tabGL Entry` gle
|
||||
where
|
||||
is_cancelled = 0
|
||||
and gle.account = %(account)s
|
||||
{condition1}
|
||||
{date_condition}
|
||||
and gle.company = %(company)s
|
||||
and gle.docstatus = 1
|
||||
{condition2}
|
||||
""",
|
||||
params,
|
||||
)[0][0]
|
||||
) # nosec
|
||||
frappe.qb.from_(gle)
|
||||
.select(Sum(gle.debit) - Sum(gle.credit))
|
||||
.where(Criterion.all(conditions))
|
||||
.run()[0][0]
|
||||
)
|
||||
|
||||
return amount
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"in_list_view": 1,
|
||||
"label": "Start Date",
|
||||
"read_only": 1,
|
||||
"reqd": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
@@ -25,26 +26,29 @@
|
||||
"fieldtype": "Date",
|
||||
"in_list_view": 1,
|
||||
"label": "End Date",
|
||||
"read_only": 1
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Amount"
|
||||
"label": "Amount",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "percent",
|
||||
"fieldtype": "Percent",
|
||||
"in_list_view": 1,
|
||||
"label": "Percent"
|
||||
"label": "Percent",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-03 13:18:28.398198",
|
||||
"modified": "2026-06-18 11:23:17.669733",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Budget Distribution",
|
||||
|
||||
@@ -15,12 +15,12 @@ class BudgetDistribution(Document):
|
||||
from frappe.types import DF
|
||||
|
||||
amount: DF.Currency
|
||||
end_date: DF.Date | None
|
||||
end_date: DF.Date
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
percent: DF.Percent
|
||||
start_date: DF.Date | None
|
||||
start_date: DF.Date
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
@@ -75,7 +75,10 @@ def validate_company(company: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def import_coa(file_name: str, company: str):
|
||||
frappe.only_for("Accounts Manager")
|
||||
|
||||
# delete existing data for accounts
|
||||
frappe.has_permission("Company", "write", company, throw=True)
|
||||
unset_existing_data(company)
|
||||
|
||||
# create accounts
|
||||
@@ -453,6 +456,7 @@ def unset_existing_data(company):
|
||||
fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", [])
|
||||
linked = [{"fieldname": name} for name in fieldnames]
|
||||
update_values = {d.get("fieldname"): "" for d in linked}
|
||||
|
||||
frappe.db.set_value("Company", company, update_values, update_values)
|
||||
|
||||
# remove accounts data from various doctypes
|
||||
@@ -464,8 +468,7 @@ def unset_existing_data(company):
|
||||
"Sales Taxes and Charges Template",
|
||||
"Purchase Taxes and Charges Template",
|
||||
]:
|
||||
dt = frappe.qb.DocType(doctype)
|
||||
frappe.qb.from_(dt).where(dt.company == company).delete().run()
|
||||
frappe.get_query(doctype, delete=True, filters={"company": company}, ignore_permissions=False).run()
|
||||
|
||||
|
||||
def set_default_accounts(company):
|
||||
|
||||
@@ -84,10 +84,10 @@ class CostCenter(NestedSet):
|
||||
return frappe.db.get_value("GL Entry", {"cost_center": self.name})
|
||||
|
||||
def check_if_child_exists(self):
|
||||
return frappe.db.sql(
|
||||
"select name from `tabCost Center` where \
|
||||
parent_cost_center = %s and docstatus != 2",
|
||||
self.name,
|
||||
return frappe.get_all(
|
||||
"Cost Center",
|
||||
filters={"parent_cost_center": self.name, "docstatus": ["!=", 2]},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
def if_allocation_exists_against_cost_center(self):
|
||||
|
||||
@@ -173,3 +173,66 @@ class TestCouponCode(ERPNextTestSuite):
|
||||
|
||||
# Clean up
|
||||
coupon.delete()
|
||||
|
||||
def test_validate_coupon_code_rejections(self):
|
||||
from frappe.utils import add_days, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.pricing_rule.utils import validate_coupon_code
|
||||
|
||||
pricing_rule = frappe.db.get_value("Pricing Rule", {"title": "_Test Pricing Rule for _Test Item"})
|
||||
|
||||
def make_coupon(name, **kwargs):
|
||||
frappe.delete_doc_if_exists("Coupon Code", name)
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "Coupon Code",
|
||||
"coupon_name": name,
|
||||
"coupon_code": name,
|
||||
"coupon_type": "Promotional",
|
||||
"pricing_rule": pricing_rule,
|
||||
**kwargs,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
with self.subTest("validity not yet started"):
|
||||
make_coupon("_Test Coupon Future", valid_from=add_days(nowdate(), 5))
|
||||
self.assertRaises(frappe.ValidationError, validate_coupon_code, "_Test Coupon Future")
|
||||
|
||||
with self.subTest("validity expired"):
|
||||
make_coupon("_Test Coupon Expired", valid_upto=add_days(nowdate(), -5))
|
||||
self.assertRaises(frappe.ValidationError, validate_coupon_code, "_Test Coupon Expired")
|
||||
|
||||
with self.subTest("maximum use exhausted"):
|
||||
make_coupon("_Test Coupon Exhausted", maximum_use=2, used=2)
|
||||
self.assertRaises(frappe.ValidationError, validate_coupon_code, "_Test Coupon Exhausted")
|
||||
|
||||
with self.subTest("valid coupon passes"):
|
||||
make_coupon("_Test Coupon Valid", maximum_use=5, used=1, valid_upto=add_days(nowdate(), 5))
|
||||
validate_coupon_code("_Test Coupon Valid") # no raise
|
||||
|
||||
def test_update_coupon_code_count_cancel_and_exhaust(self):
|
||||
from erpnext.accounts.doctype.pricing_rule.utils import update_coupon_code_count
|
||||
|
||||
pricing_rule = frappe.db.get_value("Pricing Rule", {"title": "_Test Pricing Rule for _Test Item"})
|
||||
frappe.delete_doc_if_exists("Coupon Code", "_Test Coupon Count")
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Coupon Code",
|
||||
"coupon_name": "_Test Coupon Count",
|
||||
"coupon_code": "_Test Coupon Count",
|
||||
"coupon_type": "Promotional",
|
||||
"pricing_rule": pricing_rule,
|
||||
"maximum_use": 2,
|
||||
"used": 1,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
# cancelling a transaction releases one use
|
||||
update_coupon_code_count("_Test Coupon Count", "cancelled")
|
||||
self.assertEqual(frappe.db.get_value("Coupon Code", "_Test Coupon Count", "used"), 0)
|
||||
|
||||
# using up to the maximum is allowed, beyond it is rejected
|
||||
update_coupon_code_count("_Test Coupon Count", "used")
|
||||
update_coupon_code_count("_Test Coupon Count", "used")
|
||||
self.assertEqual(frappe.db.get_value("Coupon Code", "_Test Coupon Count", "used"), 2)
|
||||
self.assertRaises(frappe.ValidationError, update_coupon_code_count, "_Test Coupon Count", "used")
|
||||
|
||||
@@ -605,7 +605,10 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
|
||||
|
||||
last_exchange_rate = (
|
||||
qb.from_(gl)
|
||||
.select((gl.debit - gl.credit) / (gl.debit_in_account_currency - gl.credit_in_account_currency))
|
||||
.select(
|
||||
(gl.debit - gl.credit)
|
||||
/ NullIf(gl.debit_in_account_currency - gl.credit_in_account_currency, 0)
|
||||
)
|
||||
.where(
|
||||
(gl.voucher_type == voucher_type) & (gl.voucher_no == voucher_no) & (gl.account == account)
|
||||
)
|
||||
@@ -626,6 +629,8 @@ def get_account_details(
|
||||
party: str | None = None,
|
||||
rounding_loss_allowance: float = 0.0,
|
||||
):
|
||||
frappe.has_permission("Account", doc=account, throw=True)
|
||||
|
||||
if not (company and posting_date):
|
||||
frappe.throw(_("Company and Posting Date is mandatory"))
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@ from typing import Any, Union
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.database.operator_map import OPERATOR_MAP
|
||||
from frappe.model import numeric_fieldtypes
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.query_builder.functions import Cast_, Sum
|
||||
from frappe.utils import cstr, date_diff, flt, getdate
|
||||
from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder
|
||||
from pypika.terms import Bracket, LiteralValue
|
||||
@@ -864,8 +865,15 @@ class FilterExpressionParser:
|
||||
field = getattr(table, field_name, None)
|
||||
operator_fn = OPERATOR_MAP.get(operator.casefold())
|
||||
|
||||
if "like" in operator.casefold() and "%" not in value:
|
||||
value = f"%{value}%"
|
||||
if "like" in operator.casefold():
|
||||
if "%" not in value:
|
||||
value = f"%{value}%"
|
||||
# Postgres has no LIKE/ILIKE operator for non-text columns; MariaDB implicitly casts
|
||||
# the numeric column to text. Cast a numeric/Check Account field to varchar so the
|
||||
# match runs on both engines and reproduces MariaDB's result.
|
||||
meta_field = frappe.get_meta("Account").get_field(field_name)
|
||||
if meta_field and meta_field.fieldtype in numeric_fieldtypes:
|
||||
field = Cast_(field, "varchar")
|
||||
|
||||
return operator_fn(field, value)
|
||||
|
||||
|
||||
@@ -72,10 +72,8 @@ class FiscalYear(Document):
|
||||
|
||||
if existing_fiscal_years:
|
||||
for existing in existing_fiscal_years:
|
||||
company_for_existing = frappe.db.sql_list(
|
||||
"""select company from `tabFiscal Year Company`
|
||||
where parent=%s""",
|
||||
existing.name,
|
||||
company_for_existing = frappe.get_all(
|
||||
"Fiscal Year Company", filters={"parent": existing.name}, pluck="company"
|
||||
)
|
||||
|
||||
overlap = False
|
||||
|
||||
@@ -7,6 +7,7 @@ from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.model.naming import set_name_from_naming_options
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import create_batch, flt, fmt_money, now
|
||||
|
||||
import erpnext
|
||||
@@ -331,10 +332,12 @@ def validate_balance_type(account, adv_adj=False):
|
||||
if not adv_adj and account:
|
||||
balance_must_be = frappe.get_cached_value("Account", account, "balance_must_be")
|
||||
if balance_must_be:
|
||||
balance = frappe.db.sql(
|
||||
"""select sum(debit) - sum(credit)
|
||||
from `tabGL Entry` where is_cancelled = 0 and account = %s""",
|
||||
account,
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
balance = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(Sum(gle.debit) - Sum(gle.credit))
|
||||
.where((gle.is_cancelled == 0) & (gle.account == account))
|
||||
.run()
|
||||
)[0][0]
|
||||
|
||||
if (balance_must_be == "Debit" and flt(balance) < 0) or (
|
||||
@@ -348,44 +351,48 @@ def validate_balance_type(account, adv_adj=False):
|
||||
def update_outstanding_amt(
|
||||
account, party_type, party, against_voucher_type, against_voucher, on_cancel=False
|
||||
):
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
|
||||
conditions = (
|
||||
(gle.against_voucher_type == against_voucher_type)
|
||||
& (gle.against_voucher == against_voucher)
|
||||
& (gle.voucher_type != "Invoice Discounting")
|
||||
)
|
||||
if party_type and party:
|
||||
party_condition = " and party_type={} and party={}".format(
|
||||
frappe.db.escape(party_type), frappe.db.escape(party)
|
||||
)
|
||||
else:
|
||||
party_condition = ""
|
||||
conditions &= (gle.party_type == party_type) & (gle.party == party)
|
||||
|
||||
if against_voucher_type == "Sales Invoice":
|
||||
party_account = frappe.get_cached_value(against_voucher_type, against_voucher, "debit_to")
|
||||
account_condition = f"and account in ({frappe.db.escape(account)}, {frappe.db.escape(party_account)})"
|
||||
conditions &= gle.account.isin([account, party_account])
|
||||
else:
|
||||
account_condition = f" and account = {frappe.db.escape(account)}"
|
||||
conditions &= gle.account == account
|
||||
|
||||
# get final outstanding amt
|
||||
bal = flt(
|
||||
frappe.db.sql(
|
||||
f"""
|
||||
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
|
||||
from `tabGL Entry`
|
||||
where against_voucher_type=%s and against_voucher=%s
|
||||
and voucher_type != 'Invoice Discounting'
|
||||
{party_condition} {account_condition}""",
|
||||
(against_voucher_type, against_voucher),
|
||||
)[0][0]
|
||||
frappe.qb.from_(gle)
|
||||
.select(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
|
||||
.where(conditions)
|
||||
.run()[0][0]
|
||||
or 0.0
|
||||
)
|
||||
|
||||
if against_voucher_type == "Purchase Invoice":
|
||||
bal = -bal
|
||||
elif against_voucher_type == "Journal Entry":
|
||||
je_conditions = (
|
||||
(gle.voucher_type == "Journal Entry")
|
||||
& (gle.voucher_no == against_voucher)
|
||||
& (gle.account == account)
|
||||
& (gle.against_voucher.isnull() | (gle.against_voucher == ""))
|
||||
)
|
||||
if party_type and party:
|
||||
je_conditions &= (gle.party_type == party_type) & (gle.party == party)
|
||||
|
||||
against_voucher_amount = flt(
|
||||
frappe.db.sql(
|
||||
f"""
|
||||
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
|
||||
from `tabGL Entry` where voucher_type = 'Journal Entry' and voucher_no = %s
|
||||
and account = %s and (against_voucher is null or against_voucher='') {party_condition}""",
|
||||
(against_voucher, account),
|
||||
)[0][0]
|
||||
frappe.qb.from_(gle)
|
||||
.select(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
|
||||
.where(je_conditions)
|
||||
.run()[0][0]
|
||||
)
|
||||
|
||||
if not against_voucher_amount:
|
||||
@@ -480,10 +487,14 @@ def rename_temporarily_named_docs(doctype):
|
||||
oldname = doc.name
|
||||
set_name_from_naming_options(autoname, doc)
|
||||
newname = doc.name
|
||||
frappe.db.sql(
|
||||
f"UPDATE `tab{doctype}` SET name = %s, to_rename = 0, modified = %s where name = %s",
|
||||
(newname, now(), oldname),
|
||||
)
|
||||
dt = frappe.qb.DocType(doctype)
|
||||
(
|
||||
frappe.qb.update(dt)
|
||||
.set(dt.name, newname)
|
||||
.set(dt.to_rename, 0)
|
||||
.set(dt.modified, now())
|
||||
.where(dt.name == oldname)
|
||||
).run()
|
||||
|
||||
for hook_type in ("on_gle_rename", "on_sle_rename"):
|
||||
for hook in frappe.get_hooks(hook_type):
|
||||
|
||||
@@ -26,12 +26,17 @@ class TestGLEntry(ERPNextTestSuite):
|
||||
jv.flags.ignore_validate = True
|
||||
jv.submit()
|
||||
|
||||
round_off_entry = frappe.db.sql(
|
||||
"""select name from `tabGL Entry`
|
||||
where voucher_type='Journal Entry' and voucher_no = %s
|
||||
and account='_Test Write Off - _TC' and cost_center='_Test Cost Center - _TC'
|
||||
and debit = 0 and credit = '.01'""",
|
||||
jv.name,
|
||||
round_off_entry = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"voucher_type": "Journal Entry",
|
||||
"voucher_no": jv.name,
|
||||
"account": "_Test Write Off - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"debit": 0,
|
||||
"credit": 0.01,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
self.assertTrue(round_off_entry)
|
||||
@@ -55,8 +60,9 @@ class TestGLEntry(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
self.assertTrue(all(entry.to_rename == 1 for entry in gl_entries))
|
||||
old_naming_series_current_value = frappe.db.sql(
|
||||
"SELECT current from tabSeries where name = %s", naming_series
|
||||
series = frappe.qb.DocType("Series")
|
||||
old_naming_series_current_value = (
|
||||
frappe.qb.from_(series).select(series["current"]).where(series.name == naming_series).run()
|
||||
)[0][0]
|
||||
|
||||
rename_gle_sle_docs()
|
||||
@@ -73,8 +79,8 @@ class TestGLEntry(ERPNextTestSuite):
|
||||
all(new.name != old.name for new, old in zip(gl_entries, new_gl_entries, strict=False))
|
||||
)
|
||||
|
||||
new_naming_series_current_value = frappe.db.sql(
|
||||
"SELECT current from tabSeries where name = %s", naming_series
|
||||
new_naming_series_current_value = (
|
||||
frappe.qb.from_(series).select(series["current"]).where(series.name == naming_series).run()
|
||||
)[0][0]
|
||||
self.assertEqual(old_naming_series_current_value + 2, new_naming_series_current_value)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
|
||||
)
|
||||
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import JournalTaxWithholding
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.services.gl_validator import validate_opening_entry_against_pcv
|
||||
from erpnext.accounts.utils import (
|
||||
cancel_exchange_gain_loss_journal,
|
||||
get_account_currency,
|
||||
@@ -149,6 +150,9 @@ class JournalEntry(AccountsController):
|
||||
if not self.is_opening:
|
||||
self.is_opening = "No"
|
||||
|
||||
if self.is_opening == "Yes":
|
||||
validate_opening_entry_against_pcv(self.company)
|
||||
|
||||
self.clearance_date = None
|
||||
|
||||
self.validate_party()
|
||||
@@ -889,7 +893,7 @@ class JournalEntry(AccountsController):
|
||||
msgprint(_("'Entries' cannot be empty"), raise_exception=True)
|
||||
return
|
||||
|
||||
self.total_debit, self.total_credit = 0, 0
|
||||
self.set_total_debit_credit()
|
||||
diff = flt(self.difference, self.precision("difference"))
|
||||
if diff:
|
||||
self._apply_difference_to_blank_row(diff, difference_account)
|
||||
|
||||
@@ -43,18 +43,18 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
|
||||
if test_voucher.doctype == "Journal Entry":
|
||||
self.assertTrue(
|
||||
frappe.db.sql(
|
||||
"""select name from `tabJournal Entry Account`
|
||||
where account = %s and docstatus = 1 and parent = %s""",
|
||||
("Debtors - _TC", test_voucher.name),
|
||||
frappe.get_all(
|
||||
"Journal Entry Account",
|
||||
filters={"account": "Debtors - _TC", "docstatus": 1, "parent": test_voucher.name},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertFalse(
|
||||
frappe.db.sql(
|
||||
"""select name from `tabJournal Entry Account`
|
||||
where reference_type = %s and reference_name = %s""",
|
||||
(test_voucher.doctype, test_voucher.name),
|
||||
frappe.get_all(
|
||||
"Journal Entry Account",
|
||||
filters={"reference_type": test_voucher.doctype, "reference_name": test_voucher.name},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -69,10 +69,14 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
submitted_voucher = frappe.get_doc(test_voucher.doctype, test_voucher.name)
|
||||
|
||||
self.assertTrue(
|
||||
frappe.db.sql(
|
||||
f"""select name from `tabJournal Entry Account`
|
||||
where reference_type = %s and reference_name = %s and {dr_or_cr}=400""",
|
||||
(submitted_voucher.doctype, submitted_voucher.name),
|
||||
frappe.get_all(
|
||||
"Journal Entry Account",
|
||||
filters={
|
||||
"reference_type": submitted_voucher.doctype,
|
||||
"reference_name": submitted_voucher.name,
|
||||
dr_or_cr: 400,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -82,24 +86,20 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
|
||||
def advance_paid_testcase(self, base_jv, test_voucher, dr_or_cr):
|
||||
# Test advance paid field
|
||||
advance_paid = frappe.db.sql(
|
||||
"""select advance_paid from `tab{}`
|
||||
where name={}""".format(test_voucher.doctype, "%s"),
|
||||
(test_voucher.name),
|
||||
)
|
||||
advance_paid = frappe.db.get_value(test_voucher.doctype, test_voucher.name, "advance_paid")
|
||||
payment_against_order = base_jv.get("accounts")[0].get(dr_or_cr)
|
||||
|
||||
self.assertEqual(flt(advance_paid[0][0]), flt(payment_against_order))
|
||||
self.assertEqual(flt(advance_paid), flt(payment_against_order))
|
||||
|
||||
def cancel_against_voucher_testcase(self, test_voucher):
|
||||
if test_voucher.doctype == "Journal Entry":
|
||||
# if test_voucher is a Journal Entry, test cancellation of test_voucher
|
||||
test_voucher.cancel()
|
||||
self.assertFalse(
|
||||
frappe.db.sql(
|
||||
"""select name from `tabJournal Entry Account`
|
||||
where reference_type='Journal Entry' and reference_name=%s""",
|
||||
test_voucher.name,
|
||||
frappe.get_all(
|
||||
"Journal Entry Account",
|
||||
filters={"reference_type": "Journal Entry", "reference_name": test_voucher.name},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -202,10 +202,10 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
# cancel
|
||||
jv.cancel()
|
||||
|
||||
gle = frappe.db.sql(
|
||||
"""select name from `tabGL Entry`
|
||||
where voucher_type='Sales Invoice' and voucher_no=%s""",
|
||||
jv.name,
|
||||
gle = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": jv.name},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
self.assertFalse(gle)
|
||||
@@ -526,9 +526,16 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
|
||||
gl_entries = query.run(as_dict=True)
|
||||
|
||||
for i in range(len(self.expected_gle)):
|
||||
# MariaDB and Postgres collate `account` differently, so the DB ordering isn't portable;
|
||||
# sort both sides identically before the positional comparison.
|
||||
def _key(row):
|
||||
return tuple(str(row[f]) for f in self.fields)
|
||||
|
||||
gl_entries = sorted(gl_entries, key=_key)
|
||||
expected_gle = sorted(self.expected_gle, key=_key)
|
||||
for i in range(len(expected_gle)):
|
||||
for field in self.fields:
|
||||
self.assertEqual(self.expected_gle[i][field], gl_entries[i][field])
|
||||
self.assertEqual(expected_gle[i][field], gl_entries[i][field])
|
||||
|
||||
def test_negative_debit_and_credit_with_same_account_head(self):
|
||||
from erpnext.accounts.general_ledger import process_gl_map
|
||||
@@ -764,6 +771,29 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
self.assertEqual(blank_row.credit_in_account_currency, 100)
|
||||
self.assertEqual(jv.total_debit, jv.total_credit)
|
||||
|
||||
def test_get_balance_recomputes_difference_ignoring_client_value(self):
|
||||
"""get_balance computes its own difference instead of trusting a stale client-sent value."""
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
jv.company = "_Test Company"
|
||||
jv.posting_date = nowdate()
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Cash - _TC",
|
||||
"debit_in_account_currency": 100,
|
||||
"debit": 100,
|
||||
"exchange_rate": 1,
|
||||
},
|
||||
)
|
||||
jv.append("accounts", {"account": "_Test Bank - _TC", "exchange_rate": 1})
|
||||
# a stale/incorrect value as the client might send; get_balance must not rely on it
|
||||
jv.difference = 0
|
||||
|
||||
jv.get_balance()
|
||||
self.assertEqual(jv.accounts[1].credit_in_account_currency, 100)
|
||||
self.assertEqual(jv.total_debit, jv.total_credit)
|
||||
self.assertEqual(jv.difference, 0)
|
||||
|
||||
def test_get_outstanding_invoices_builds_write_off_rows(self):
|
||||
"""Characterize: get_outstanding_invoices adds a party row for each outstanding invoice."""
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
{
|
||||
"bold": 1,
|
||||
"columns": 4,
|
||||
"fetch_from": "bank_account.account",
|
||||
"fieldname": "account",
|
||||
"fieldtype": "Link",
|
||||
"in_global_search": 1,
|
||||
|
||||
@@ -39,28 +39,32 @@ def get_loyalty_point_entries(customer, loyalty_program, company, expiry_date=No
|
||||
if not expiry_date:
|
||||
expiry_date = today()
|
||||
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select name, loyalty_points, expiry_date, loyalty_program_tier, invoice_type, invoice
|
||||
from `tabLoyalty Point Entry`
|
||||
where customer=%s and loyalty_program=%s
|
||||
and expiry_date>=%s and loyalty_points>0 and company=%s
|
||||
order by expiry_date
|
||||
""",
|
||||
(customer, loyalty_program, expiry_date, company),
|
||||
as_dict=1,
|
||||
return frappe.get_all(
|
||||
"Loyalty Point Entry",
|
||||
filters={
|
||||
"customer": customer,
|
||||
"loyalty_program": loyalty_program,
|
||||
"expiry_date": [">=", expiry_date],
|
||||
"loyalty_points": [">", 0],
|
||||
"company": company,
|
||||
},
|
||||
fields=["name", "loyalty_points", "expiry_date", "loyalty_program_tier", "invoice_type", "invoice"],
|
||||
order_by="expiry_date",
|
||||
)
|
||||
|
||||
|
||||
def get_redemption_details(customer, loyalty_program, company):
|
||||
return frappe._dict(
|
||||
frappe.db.sql(
|
||||
"""
|
||||
select redeem_against, sum(loyalty_points)
|
||||
from `tabLoyalty Point Entry`
|
||||
where customer=%s and loyalty_program=%s and loyalty_points<0 and company=%s
|
||||
group by redeem_against
|
||||
""",
|
||||
(customer, loyalty_program, company),
|
||||
frappe.get_all(
|
||||
"Loyalty Point Entry",
|
||||
filters={
|
||||
"customer": customer,
|
||||
"loyalty_program": loyalty_program,
|
||||
"loyalty_points": ["<", 0],
|
||||
"company": company,
|
||||
},
|
||||
fields=["redeem_against", {"SUM": "loyalty_points", "as": "loyalty_points"}],
|
||||
group_by="redeem_against",
|
||||
as_list=True,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -74,29 +74,31 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
|
||||
},
|
||||
|
||||
setup_company_filters: function (frm) {
|
||||
frm.set_query("cost_center", "invoices", function (doc, cdt, cdn) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
frm.events.apply_company_query_filter(frm, "cost_center", "invoices", { is_group: 0 });
|
||||
frm.events.apply_company_query_filter(frm, "project", "invoices");
|
||||
frm.events.apply_company_query_filter(frm, "project");
|
||||
frm.events.apply_company_query_filter(frm, "cost_center", undefined, { is_group: 0 });
|
||||
frm.events.apply_company_query_filter(frm, "temporary_opening_account", "invoices", {
|
||||
account_type: "Temporary",
|
||||
is_group: 0,
|
||||
});
|
||||
},
|
||||
|
||||
frm.set_query("cost_center", function (doc) {
|
||||
apply_company_query_filter: function (frm, field_name, child_doctype = null, filters = {}) {
|
||||
const query = function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
...filters,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
frm.set_query("temporary_opening_account", "invoices", function (doc, cdt, cdn) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (child_doctype) {
|
||||
frm.set_query(field_name, child_doctype, query);
|
||||
} else {
|
||||
frm.set_query(field_name, query);
|
||||
}
|
||||
},
|
||||
|
||||
company: function (frm) {
|
||||
@@ -120,11 +122,6 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
|
||||
},
|
||||
|
||||
invoice_type: function (frm) {
|
||||
$.each(frm.doc.invoices, (idx, row) => {
|
||||
row.party_type = frm.doc.invoice_type == "Sales" ? "Customer" : "Supplier";
|
||||
frappe.model.set_value(row.doctype, row.name, "party", "");
|
||||
frappe.model.set_value(row.doctype, row.name, "party_name", "");
|
||||
});
|
||||
frm.clear_table("invoices");
|
||||
frm.refresh_fields();
|
||||
frm.trigger("update_party_labels");
|
||||
@@ -219,7 +216,19 @@ frappe.ui.form.on("Opening Invoice Creation Tool Item", {
|
||||
});
|
||||
},
|
||||
|
||||
invoices_add: (frm) => {
|
||||
invoices_add: (frm, cdt, cdn) => {
|
||||
const row = frappe.get_doc(cdt, cdn);
|
||||
const field_copy = [];
|
||||
|
||||
["project", "cost_center"].forEach((fieldname) => {
|
||||
if (frm.doc[fieldname]) {
|
||||
frappe.model.set_value(cdt, cdn, fieldname, frm.doc[fieldname]);
|
||||
} else {
|
||||
field_copy.push(fieldname);
|
||||
}
|
||||
});
|
||||
|
||||
frm.script_manager.copy_from_first_row("invoices", row, field_copy);
|
||||
frm.trigger("update_invoice_table");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -133,6 +133,17 @@ class OpeningInvoiceCreationTool(Document):
|
||||
if not row.get(scrub(d)):
|
||||
frappe.throw(mandatory_error_msg.format(row.idx, d, self.invoice_type))
|
||||
|
||||
self.validate_temporary_opening_account(row)
|
||||
|
||||
def validate_temporary_opening_account(self, row):
|
||||
account_type = frappe.get_cached_value("Account", row.temporary_opening_account, "account_type")
|
||||
if account_type != "Temporary":
|
||||
frappe.throw(
|
||||
_("Row #{0}: {1} account is not of type {2}").format(
|
||||
row.idx, row.temporary_opening_account, "Temporary"
|
||||
)
|
||||
)
|
||||
|
||||
def get_invoices(self):
|
||||
invoices = []
|
||||
for row in self.invoices:
|
||||
@@ -203,6 +214,7 @@ class OpeningInvoiceCreationTool(Document):
|
||||
"description": row.item_name or "Opening Invoice Item",
|
||||
income_expense_account_field: row.temporary_opening_account,
|
||||
"cost_center": cost_center,
|
||||
"project": row.get("project") or self.get("project"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
|
||||
get_temporary_opening_account,
|
||||
)
|
||||
from erpnext.projects.doctype.project.test_project import make_project
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -14,21 +16,26 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
|
||||
self,
|
||||
invoice_type="Sales",
|
||||
company=None,
|
||||
party_1=None,
|
||||
party_2=None,
|
||||
invoice_number=None,
|
||||
invoices=None,
|
||||
project=None,
|
||||
cost_center=None,
|
||||
department=None,
|
||||
return_doc=False,
|
||||
):
|
||||
doc = frappe.get_single("Opening Invoice Creation Tool")
|
||||
args = get_opening_invoice_creation_dict(
|
||||
invoice_type=invoice_type,
|
||||
company=company,
|
||||
party_1=party_1,
|
||||
party_2=party_2,
|
||||
invoice_number=invoice_number,
|
||||
invoices=invoices,
|
||||
project=project,
|
||||
cost_center=cost_center,
|
||||
department=department,
|
||||
)
|
||||
doc.update(args)
|
||||
|
||||
if return_doc:
|
||||
return doc
|
||||
|
||||
return doc.make_invoices()
|
||||
|
||||
def test_opening_sales_invoice_creation(self):
|
||||
@@ -37,8 +44,8 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
|
||||
self.assertEqual(len(invoices), 2)
|
||||
expected_value = {
|
||||
"keys": ["customer", "outstanding_amount", "status"],
|
||||
0: ["_Test Customer", 300, "Overdue"],
|
||||
1: ["_Test Customer 1", 250, "Overdue"],
|
||||
0: ["_Test Customer", 200, "Overdue"],
|
||||
1: ["_Test Customer 1", 200, "Overdue"],
|
||||
}
|
||||
self.check_expected_values(invoices, expected_value)
|
||||
|
||||
@@ -55,48 +62,34 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
|
||||
for field_idx, field in enumerate(expected_value["keys"]):
|
||||
self.assertEqual(si.get(field, ""), expected_value[invoice_idx][field_idx])
|
||||
|
||||
def test_opening_invoice_requires_temporary_account_type(self):
|
||||
doc = self.make_invoices(company="_Test Opening Invoice Company", return_doc=True)
|
||||
doc.invoices[0].temporary_opening_account = "Sales - _TOIC"
|
||||
self.assertRaises(frappe.ValidationError, doc.make_invoices)
|
||||
|
||||
def test_opening_purchase_invoice_creation(self):
|
||||
invoices = self.make_invoices(invoice_type="Purchase", company="_Test Opening Invoice Company")
|
||||
|
||||
self.assertEqual(len(invoices), 2)
|
||||
expected_value = {
|
||||
"keys": ["supplier", "outstanding_amount", "status"],
|
||||
0: ["_Test Supplier", 300, "Overdue"],
|
||||
1: ["_Test Supplier 1", 250, "Overdue"],
|
||||
0: ["_Test Supplier", 200, "Overdue"],
|
||||
1: ["_Test Supplier 1", 200, "Overdue"],
|
||||
}
|
||||
self.check_expected_values(invoices, expected_value, "Purchase")
|
||||
|
||||
def test_opening_sales_invoice_creation_with_missing_debit_account(self):
|
||||
company = "_Test Opening Invoice Company"
|
||||
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
|
||||
|
||||
old_default_receivable_account = frappe.db.get_value("Company", company, "default_receivable_account")
|
||||
frappe.db.set_value("Company", company, "default_receivable_account", "")
|
||||
old_default_receivable_account = frappe.db.get_value(
|
||||
"Company", "_Test Opening Invoice Company", "default_receivable_account"
|
||||
)
|
||||
frappe.db.set_value("Company", "_Test Opening Invoice Company", "default_receivable_account", "")
|
||||
|
||||
if not frappe.db.exists("Cost Center", "_Test Opening Invoice Company - _TOIC"):
|
||||
cc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Cost Center",
|
||||
"cost_center_name": "_Test Opening Invoice Company",
|
||||
"is_group": 1,
|
||||
"company": "_Test Opening Invoice Company",
|
||||
}
|
||||
)
|
||||
cc.insert(ignore_mandatory=True)
|
||||
cc2 = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Cost Center",
|
||||
"cost_center_name": "Main",
|
||||
"is_group": 0,
|
||||
"company": "_Test Opening Invoice Company",
|
||||
"parent_cost_center": cc.name,
|
||||
}
|
||||
)
|
||||
cc2.insert()
|
||||
|
||||
frappe.db.set_value("Company", company, "cost_center", "Main - _TOIC")
|
||||
|
||||
self.make_invoices(company="_Test Opening Invoice Company", party_1=party_1, party_2=party_2)
|
||||
self.make_invoices(
|
||||
company="_Test Opening Invoice Company",
|
||||
invoices=[{"party": party_1}, {"party": party_2}],
|
||||
)
|
||||
|
||||
# Check if missing debit account error raised
|
||||
error_log = frappe.db.exists(
|
||||
@@ -106,71 +99,107 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
|
||||
self.assertTrue(error_log)
|
||||
|
||||
# teardown
|
||||
frappe.db.set_value("Company", company, "default_receivable_account", old_default_receivable_account)
|
||||
|
||||
def test_renaming_of_invoice_using_invoice_number_field(self):
|
||||
company = "_Test Opening Invoice Company"
|
||||
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
|
||||
self.make_invoices(
|
||||
company=company, party_1=party_1, party_2=party_2, invoice_number="TEST-NEW-INV-11"
|
||||
frappe.db.set_value(
|
||||
"Company",
|
||||
"_Test Opening Invoice Company",
|
||||
"default_receivable_account",
|
||||
old_default_receivable_account,
|
||||
)
|
||||
|
||||
sales_inv1 = frappe.get_all("Sales Invoice", filters={"customer": "Customer A"})[0].get("name")
|
||||
sales_inv2 = frappe.get_all("Sales Invoice", filters={"customer": "Customer B"})[0].get("name")
|
||||
self.assertEqual(sales_inv1, "TEST-NEW-INV-11")
|
||||
def test_renaming_of_invoice_using_invoice_number_field(self):
|
||||
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
|
||||
invoices = self.make_invoices(
|
||||
company="_Test Opening Invoice Company",
|
||||
invoices=[
|
||||
{"party": party_1, "invoice_number": "TEST-NEW-INV-11"},
|
||||
{"party": party_2},
|
||||
],
|
||||
)
|
||||
|
||||
# teardown
|
||||
for inv in [sales_inv1, sales_inv2]:
|
||||
doc = frappe.get_doc("Sales Invoice", inv)
|
||||
doc.cancel()
|
||||
self.assertEqual(invoices[0], "TEST-NEW-INV-11")
|
||||
|
||||
def test_opening_invoice_with_accounting_dimension(self):
|
||||
invoices = self.make_invoices(
|
||||
invoice_type="Sales", company="_Test Opening Invoice Company", department="Sales - _TOIC"
|
||||
)
|
||||
|
||||
expected_value = {
|
||||
"keys": ["customer", "outstanding_amount", "status", "department"],
|
||||
0: ["_Test Customer", 300, "Overdue", "Sales - _TOIC"],
|
||||
1: ["_Test Customer 1", 250, "Overdue", "Sales - _TOIC"],
|
||||
}
|
||||
self.check_expected_values(invoices, expected_value, invoice_type="Sales")
|
||||
for invoice in invoices:
|
||||
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
|
||||
|
||||
def test_opening_entry_project_linking(self):
|
||||
doc = self.make_invoices(
|
||||
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True
|
||||
)
|
||||
project_1 = make_project(
|
||||
{"project_name": "Test Opening Invoice projecty 01", "company": "_Test Opening Invoice Company"}
|
||||
)
|
||||
project_2 = make_project(
|
||||
{"project_name": "Test Opening Invoice projecty 02", "company": "_Test Opening Invoice Company"}
|
||||
)
|
||||
doc.invoices[0].project = project_1.name
|
||||
doc.invoices[1].project = project_2.name
|
||||
invoices = doc.make_invoices()
|
||||
sales_invoice_1 = frappe.get_doc("Sales Invoice", invoices[0])
|
||||
sales_invoice_2 = frappe.get_doc("Sales Invoice", invoices[1])
|
||||
|
||||
self.assertEqual(sales_invoice_1.items[0].project, project_1.name)
|
||||
self.assertEqual(sales_invoice_2.items[0].project, project_2.name)
|
||||
|
||||
|
||||
def get_opening_invoice_creation_dict(**args):
|
||||
party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier"
|
||||
company = args.get("company", "_Test Company")
|
||||
default_invoices = []
|
||||
default_invoice_rows = [
|
||||
{
|
||||
"qty": 1.0,
|
||||
"outstanding_amount": 200,
|
||||
"party": f"_Test {party}",
|
||||
"item_name": "Opening Item",
|
||||
"due_date": add_days(today(), -10),
|
||||
"posting_date": add_days(today(), -15),
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
},
|
||||
{
|
||||
"qty": 1.0,
|
||||
"outstanding_amount": 200,
|
||||
"party": f"_Test {party} 1",
|
||||
"item_name": "Opening Item",
|
||||
"due_date": add_days(today(), -10),
|
||||
"posting_date": add_days(today(), -15),
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
},
|
||||
]
|
||||
|
||||
for row in args.get("invoices") or default_invoice_rows:
|
||||
default_invoices.append(
|
||||
{
|
||||
"qty": row.get("qty") or 1.0,
|
||||
"outstanding_amount": row.get("outstanding_amount") or 200,
|
||||
"party": row.get("party") or f"_Test {party}",
|
||||
"item_name": row.get("item_name") or "Opening Item",
|
||||
"due_date": row.get("due_date") or add_days(today(), -10),
|
||||
"posting_date": row.get("posting_date") or add_days(today(), -15),
|
||||
"temporary_opening_account": row.get("temporary_opening_account")
|
||||
or get_temporary_opening_account(company),
|
||||
"invoice_number": row.get("invoice_number"),
|
||||
"project": row.get("project"),
|
||||
"cost_center": row.get("cost_center"),
|
||||
}
|
||||
)
|
||||
|
||||
invoice_dict = frappe._dict(
|
||||
{
|
||||
"company": company,
|
||||
"invoice_type": args.get("invoice_type", "Sales"),
|
||||
"invoices": [
|
||||
{
|
||||
"qty": 1.0,
|
||||
"outstanding_amount": 300,
|
||||
"party": args.get("party_1") or f"_Test {party}",
|
||||
"item_name": "Opening Item",
|
||||
"due_date": "2016-09-10",
|
||||
"posting_date": "2016-09-05",
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
"invoice_number": args.get("invoice_number"),
|
||||
},
|
||||
{
|
||||
"qty": 2.0,
|
||||
"outstanding_amount": 250,
|
||||
"party": args.get("party_2") or f"_Test {party} 1",
|
||||
"item_name": "Opening Item",
|
||||
"due_date": "2016-09-10",
|
||||
"posting_date": "2016-09-05",
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
"invoice_number": None,
|
||||
},
|
||||
],
|
||||
"project": args.get("project"),
|
||||
"cost_center": args.get("cost_center"),
|
||||
"invoices": default_invoices,
|
||||
}
|
||||
)
|
||||
|
||||
invoice_dict.update(args)
|
||||
invoice_dict.invoices = default_invoices
|
||||
return invoice_dict
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"qty",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
"dimension_col_break"
|
||||
"dimension_col_break",
|
||||
"project"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -125,11 +126,17 @@
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Party Name"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
}
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-20 02:11:42.023575",
|
||||
"modified": "2026-04-29 17:08:15.617047",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Opening Invoice Creation Tool Item",
|
||||
|
||||
@@ -26,6 +26,7 @@ class OpeningInvoiceCreationToolItem(Document):
|
||||
party_name: DF.Data | None
|
||||
party_type: DF.Link | None
|
||||
posting_date: DF.Date | None
|
||||
project: DF.Link | None
|
||||
qty: DF.Data | None
|
||||
supplier_invoice_date: DF.Date | None
|
||||
temporary_opening_account: DF.Link | None
|
||||
|
||||
@@ -754,17 +754,21 @@ frappe.ui.form.on("Payment Entry", {
|
||||
frm.set_paid_amount_based_on_received_amount = true;
|
||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||
|
||||
if (frm.doc.base_received_amount && frm.doc.source_exchange_rate) {
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
if (frm.doc.paid_amount && frm.doc.source_exchange_rate) {
|
||||
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
|
||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
|
||||
// target exchange rate should always be same as source if both account currencies is same
|
||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate);
|
||||
frm.set_value("received_amount", frm.doc.paid_amount);
|
||||
} else {
|
||||
frm.set_value(
|
||||
"paid_amount",
|
||||
flt(frm.doc.base_paid_amount) / flt(frm.doc.source_exchange_rate)
|
||||
);
|
||||
const target_rate =
|
||||
flt(frm.doc.target_exchange_rate) ||
|
||||
(company_currency == frm.doc.paid_to_account_currency ? 1 : 0);
|
||||
if (target_rate) {
|
||||
frm.set_value("received_amount", flt(frm.doc.base_received_amount) / target_rate);
|
||||
}
|
||||
}
|
||||
|
||||
// set_unallocated_amount is called by below method,
|
||||
@@ -780,18 +784,23 @@ frappe.ui.form.on("Payment Entry", {
|
||||
target_exchange_rate: function (frm) {
|
||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||
|
||||
if (frm.doc.base_paid_amount && frm.doc.target_exchange_rate) {
|
||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
if (
|
||||
!frm.doc.source_exchange_rate &&
|
||||
frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency
|
||||
) {
|
||||
if (frm.doc.received_amount && frm.doc.target_exchange_rate) {
|
||||
frm.set_value(
|
||||
"base_received_amount",
|
||||
flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate)
|
||||
);
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
|
||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
|
||||
frm.set_value("paid_amount", frm.doc.received_amount);
|
||||
} else {
|
||||
frm.set_value(
|
||||
"received_amount",
|
||||
flt(frm.doc.base_received_amount) / flt(frm.doc.target_exchange_rate)
|
||||
);
|
||||
const source_rate =
|
||||
flt(frm.doc.source_exchange_rate) ||
|
||||
(company_currency == frm.doc.paid_from_account_currency ? 1 : 0);
|
||||
if (source_rate) {
|
||||
frm.set_value("paid_amount", flt(frm.doc.base_paid_amount) / source_rate);
|
||||
}
|
||||
}
|
||||
|
||||
// set_unallocated_amount is called by below method,
|
||||
|
||||
@@ -1191,9 +1191,9 @@ class PaymentEntry(AccountsController):
|
||||
continue
|
||||
|
||||
if tax.add_deduct_tax == "Add":
|
||||
included_taxes += tax.base_tax_amount
|
||||
included_taxes += flt(tax.base_tax_amount)
|
||||
else:
|
||||
included_taxes -= tax.base_tax_amount
|
||||
included_taxes -= flt(tax.base_tax_amount)
|
||||
|
||||
return included_taxes
|
||||
|
||||
|
||||
@@ -818,12 +818,11 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
self.assertEqual(expected_gle[gle.account][3], gle.against_voucher)
|
||||
|
||||
def get_gle(self, voucher_no):
|
||||
return frappe.db.sql(
|
||||
"""select account, debit, credit, against_voucher
|
||||
from `tabGL Entry` where voucher_type='Payment Entry' and voucher_no=%s
|
||||
order by account asc""",
|
||||
voucher_no,
|
||||
as_dict=1,
|
||||
return frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Payment Entry", "voucher_no": voucher_no},
|
||||
fields=["account", "debit", "credit", "against_voucher"],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
def test_payment_entry_write_off_difference(self):
|
||||
@@ -918,13 +917,19 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
"Debtors - _TC": {"cost_center": cost_center},
|
||||
}
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, cost_center, account_currency, debit, credit,
|
||||
debit_in_account_currency, credit_in_account_currency
|
||||
from `tabGL Entry` where voucher_type='Payment Entry' and voucher_no=%s
|
||||
order by account asc""",
|
||||
pe.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Payment Entry", "voucher_no": pe.name},
|
||||
fields=[
|
||||
"account",
|
||||
"cost_center",
|
||||
"account_currency",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
@@ -955,13 +960,19 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
"Creditors - _TC": {"cost_center": cost_center},
|
||||
}
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, cost_center, account_currency, debit, credit,
|
||||
debit_in_account_currency, credit_in_account_currency
|
||||
from `tabGL Entry` where voucher_type='Payment Entry' and voucher_no=%s
|
||||
order by account asc""",
|
||||
pe.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Payment Entry", "voucher_no": pe.name},
|
||||
fields=[
|
||||
"account",
|
||||
"cost_center",
|
||||
"account_currency",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
@@ -1113,6 +1124,27 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(gl_entries, expected_gl_entries)
|
||||
|
||||
def test_payment_entry_with_inclusive_tax(self):
|
||||
# inclusive tax built server-side: base_tax_amount is None until apply_taxes()
|
||||
payment_entry = create_payment_entry(paid_amount=1180)
|
||||
payment_entry.append(
|
||||
"taxes",
|
||||
{
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"charge_type": "On Paid Amount",
|
||||
"rate": 18,
|
||||
"included_in_paid_amount": 1,
|
||||
"add_deduct_tax": "Add",
|
||||
"description": "Service Tax",
|
||||
},
|
||||
)
|
||||
payment_entry.save()
|
||||
payment_entry.submit()
|
||||
|
||||
# 1180 incl 18% => 1000 base + 180 tax
|
||||
self.assertEqual(flt(payment_entry.total_taxes_and_charges, 2), 180.0)
|
||||
self.assertEqual(flt(payment_entry.unallocated_amount, 2), 1000.0)
|
||||
|
||||
def test_payment_entry_against_onhold_purchase_invoice(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
@@ -1749,9 +1781,18 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
.where((gle.voucher_no == self.voucher_no) & (gle.is_cancelled == 0))
|
||||
.orderby(gle.account, gle.debit, gle.credit, order=frappe.qb.desc)
|
||||
).run(as_dict=True)
|
||||
for row in range(len(self.expected_gle)):
|
||||
for field in ["account", "debit", "credit"]:
|
||||
self.assertEqual(self.expected_gle[row][field], gl_entries[row][field])
|
||||
# MariaDB and Postgres collate `account` differently, so the DB ordering isn't portable;
|
||||
# sort both sides identically before the positional comparison.
|
||||
fields = ["account", "debit", "credit"]
|
||||
|
||||
def _key(row):
|
||||
return tuple(str(row[f]) for f in fields)
|
||||
|
||||
gl_entries = sorted(gl_entries, key=_key)
|
||||
expected_gle = sorted(self.expected_gle, key=_key)
|
||||
for row in range(len(expected_gle)):
|
||||
for field in fields:
|
||||
self.assertEqual(expected_gle[row][field], gl_entries[row][field])
|
||||
|
||||
def test_reverse_payment_reconciliation(self):
|
||||
customer = create_customer(frappe.generate_hash(length=10), "INR")
|
||||
|
||||
@@ -6,8 +6,10 @@ import frappe
|
||||
from frappe import _, msgprint, qb
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.permissions import get_allowed_docs_for_doctype, get_user_permissions
|
||||
from frappe.query_builder import Case, Criterion
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import IfNull
|
||||
from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today
|
||||
|
||||
import erpnext
|
||||
@@ -73,6 +75,7 @@ class PaymentReconciliation(Document):
|
||||
self.accounting_dimension_filter_conditions = []
|
||||
self.ple_posting_date_filter = []
|
||||
self.dimensions = get_dimensions(with_cost_center_and_project=True)[0]
|
||||
self.user_permissions = get_user_permissions(frappe.session.user)
|
||||
|
||||
def load_from_db(self):
|
||||
# 'modified' attribute is required for `run_doc_method` to work properly.
|
||||
@@ -153,6 +156,22 @@ class PaymentReconciliation(Document):
|
||||
|
||||
self.add_payment_entries(non_reconciled_payments)
|
||||
|
||||
def get_permitted_dimension_values(self, document_type, reference_doctype):
|
||||
return get_allowed_docs_for_doctype(self.user_permissions.get(document_type, []), reference_doctype)
|
||||
|
||||
def validate_permitted_dimension_value(self, document_type, value, allowed):
|
||||
if value and allowed and value not in allowed:
|
||||
frappe.throw(
|
||||
_("You do not have enough permission to access {0}: {1}").format(_(document_type), value),
|
||||
frappe.PermissionError,
|
||||
)
|
||||
|
||||
def get_user_permission_dimension_condition(self, field, allowed):
|
||||
value_condition = field.isin(allowed)
|
||||
if frappe.get_system_settings("apply_strict_user_permissions"):
|
||||
return value_condition
|
||||
return (IfNull(field, "") == "") | value_condition
|
||||
|
||||
def get_payment_entries(self):
|
||||
party_account = [self.receivable_payable_account]
|
||||
|
||||
@@ -176,8 +195,13 @@ class PaymentReconciliation(Document):
|
||||
dimensions = {}
|
||||
for x in self.dimensions:
|
||||
dimension = x.fieldname
|
||||
if self.get(dimension):
|
||||
dimensions.update({dimension: self.get(dimension)})
|
||||
allowed = self.get_permitted_dimension_values(x.document_type, "Payment Entry")
|
||||
if value := self.get(dimension):
|
||||
self.validate_permitted_dimension_value(x.document_type, value, allowed)
|
||||
dimensions[dimension] = value
|
||||
elif allowed:
|
||||
dimensions[dimension] = allowed
|
||||
|
||||
condition.update({"accounting_dimensions": dimensions})
|
||||
|
||||
payment_entries = get_advance_payment_entries_for_regional(
|
||||
@@ -201,8 +225,12 @@ class PaymentReconciliation(Document):
|
||||
# Dimension filters
|
||||
for x in self.dimensions:
|
||||
dimension = x.fieldname
|
||||
if self.get(dimension):
|
||||
conditions.append(jea[dimension] == self.get(dimension))
|
||||
allowed = self.get_permitted_dimension_values(x.document_type, "Journal Entry Account")
|
||||
if value := self.get(dimension):
|
||||
self.validate_permitted_dimension_value(x.document_type, value, allowed)
|
||||
conditions.append(jea[dimension] == value)
|
||||
elif allowed:
|
||||
conditions.append(self.get_user_permission_dimension_condition(jea[dimension], allowed))
|
||||
|
||||
if self.payment_name:
|
||||
conditions.append(je.name.like(f"%%{self.payment_name}%%"))
|
||||
@@ -748,8 +776,15 @@ class PaymentReconciliation(Document):
|
||||
ple = qb.DocType("Payment Ledger Entry")
|
||||
for x in self.dimensions:
|
||||
dimension = x.fieldname
|
||||
if self.get(dimension) and frappe.db.has_column("Payment Ledger Entry", dimension):
|
||||
self.accounting_dimension_filter_conditions.append(ple[dimension] == self.get(dimension))
|
||||
if frappe.db.has_column("Payment Ledger Entry", dimension):
|
||||
allowed = self.get_permitted_dimension_values(x.document_type, "Payment Ledger Entry")
|
||||
if value := self.get(dimension):
|
||||
self.validate_permitted_dimension_value(x.document_type, value, allowed)
|
||||
self.accounting_dimension_filter_conditions.append(ple[dimension] == value)
|
||||
elif allowed:
|
||||
self.accounting_dimension_filter_conditions.append(
|
||||
self.get_user_permission_dimension_condition(ple[dimension], allowed)
|
||||
)
|
||||
|
||||
def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False):
|
||||
self.common_filter_conditions.clear()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, add_years, flt, getdate, nowdate, today
|
||||
from frappe.utils import add_days, add_years, cint, flt, getdate, nowdate, today
|
||||
from frappe.utils.data import getdate as convert_to_date
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
@@ -1102,6 +1102,101 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
|
||||
self.assertCountEqual(payment_vouchers, [je2.name, pe2.name])
|
||||
|
||||
def test_user_permission_on_accounting_dimension_filters_vouchers(self):
|
||||
test_user = "test@example.com"
|
||||
permitted_ccs = ["_Test Cost Center - _TC", "_Test Cost Center 2 - _TC"]
|
||||
restricted_cc = "_Test Write Off Cost Center - _TC"
|
||||
existing_apply_strict_user_permissions = cint(
|
||||
frappe.db.get_single_value("System Settings", "apply_strict_user_permissions")
|
||||
)
|
||||
self.addCleanup(
|
||||
frappe.db.set_single_value,
|
||||
"System Settings",
|
||||
"apply_strict_user_permissions",
|
||||
existing_apply_strict_user_permissions,
|
||||
)
|
||||
transaction_date = nowdate()
|
||||
rate = 100
|
||||
|
||||
def make_invoice(cost_center):
|
||||
si = self.create_sales_invoice(
|
||||
qty=1, rate=rate, posting_date=transaction_date, do_not_submit=True
|
||||
)
|
||||
si.cost_center = cost_center
|
||||
for row in si.items:
|
||||
row.cost_center = cost_center
|
||||
return si.submit()
|
||||
|
||||
def make_payment(cost_center):
|
||||
pe = self.create_payment_entry(posting_date=transaction_date, amount=rate)
|
||||
pe.cost_center = cost_center
|
||||
return pe.save().submit()
|
||||
|
||||
def make_journal(cost_center):
|
||||
je = self.create_journal_entry(
|
||||
self.bank, self.debit_to, 100, transaction_date, cost_center=cost_center
|
||||
)
|
||||
je.accounts[1].party_type = "Customer"
|
||||
je.accounts[1].party = self.customer
|
||||
return je.save().submit()
|
||||
|
||||
# Vouchers tagged with the two permitted cost centers
|
||||
si_allowed = make_invoice(permitted_ccs[0])
|
||||
pe_allowed = make_payment(permitted_ccs[1])
|
||||
je_allowed = make_journal(permitted_ccs[0])
|
||||
|
||||
# Vouchers tagged with the restricted cost center
|
||||
si_restricted = make_invoice(restricted_cc)
|
||||
pe_restricted = make_payment(restricted_cc)
|
||||
je_restricted = make_journal(restricted_cc)
|
||||
|
||||
# Payment entry with a BLANK cost center
|
||||
pe_blank = make_payment(None)
|
||||
|
||||
for cc in permitted_ccs:
|
||||
frappe.permissions.add_user_permission("Cost Center", cc, test_user)
|
||||
|
||||
# Without strict user permissions
|
||||
frappe.db.set_single_value("System Settings", "apply_strict_user_permissions", 0)
|
||||
with self.set_user(test_user):
|
||||
pr = self.create_payment_reconciliation()
|
||||
pr.get_unreconciled_entries()
|
||||
|
||||
invoice_numbers = [x.get("invoice_number") for x in pr.get("invoices")]
|
||||
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
|
||||
self.assertIn(si_allowed.name, invoice_numbers)
|
||||
self.assertIn(pe_allowed.name, payment_vouchers)
|
||||
self.assertIn(je_allowed.name, payment_vouchers)
|
||||
self.assertIn(pe_blank.name, payment_vouchers)
|
||||
self.assertNotIn(si_restricted.name, invoice_numbers)
|
||||
self.assertNotIn(pe_restricted.name, payment_vouchers)
|
||||
self.assertNotIn(je_restricted.name, payment_vouchers)
|
||||
|
||||
# With strict user permissions
|
||||
frappe.db.set_single_value("System Settings", "apply_strict_user_permissions", 1)
|
||||
with self.set_user(test_user):
|
||||
pr = self.create_payment_reconciliation()
|
||||
pr.get_unreconciled_entries()
|
||||
|
||||
invoice_numbers = [x.get("invoice_number") for x in pr.get("invoices")]
|
||||
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
|
||||
self.assertIn(si_allowed.name, invoice_numbers)
|
||||
self.assertIn(pe_allowed.name, payment_vouchers)
|
||||
self.assertIn(je_allowed.name, payment_vouchers)
|
||||
self.assertNotIn(pe_blank.name, payment_vouchers)
|
||||
self.assertNotIn(si_restricted.name, invoice_numbers)
|
||||
self.assertNotIn(pe_restricted.name, payment_vouchers)
|
||||
self.assertNotIn(je_restricted.name, payment_vouchers)
|
||||
|
||||
# with restricted dimension as a filter
|
||||
with self.set_user(test_user):
|
||||
pr = self.create_payment_reconciliation()
|
||||
pr.cost_center = restricted_cc
|
||||
self.assertRaises(frappe.PermissionError, pr.get_unreconciled_entries)
|
||||
|
||||
for cc in permitted_ccs:
|
||||
frappe.permissions.remove_user_permission("Cost Center", cc, test_user)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings",
|
||||
{
|
||||
|
||||
@@ -347,12 +347,11 @@ class TestPaymentRequest(ERPNextTestSuite):
|
||||
]
|
||||
)
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, debit, credit, against_voucher
|
||||
from `tabGL Entry` where voucher_type='Payment Entry' and voucher_no=%s
|
||||
order by account asc""",
|
||||
pe.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Payment Entry", "voucher_no": pe.name},
|
||||
fields=["account", "debit", "credit", "against_voucher"],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
|
||||
@@ -73,7 +73,10 @@ class PeriodClosingVoucher(AccountsController):
|
||||
if not previous_fiscal_year:
|
||||
return
|
||||
|
||||
previous_fiscal_year_start_date = previous_fiscal_year[0][1]
|
||||
# get_fiscal_year() returns a single (name, start_date, end_date) tuple, so the start date
|
||||
# is [1]; the old [0][1] read the 2nd char of the name ('T'), which MariaDB silently
|
||||
# coerced to NULL but postgres rejects as an invalid date.
|
||||
previous_fiscal_year_start_date = previous_fiscal_year[1]
|
||||
previous_fiscal_year_closed = frappe.db.exists(
|
||||
"Period Closing Voucher",
|
||||
{
|
||||
@@ -287,41 +290,44 @@ class PeriodClosingVoucher(AccountsController):
|
||||
self.accounting_dimension_fields = default_dimensions + get_accounting_dimensions()
|
||||
|
||||
def get_gl_entries_for_current_period(self, report_type, only_opening_entries=False, as_iterator=False):
|
||||
date_condition = ""
|
||||
if only_opening_entries:
|
||||
date_condition = "is_opening = 'Yes'"
|
||||
else:
|
||||
date_condition = f"posting_date BETWEEN '{self.period_start_date}' AND '{self.period_end_date}' and is_opening = 'No'"
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
account = frappe.qb.DocType("Account")
|
||||
|
||||
# nosemgrep
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
name,
|
||||
posting_date,
|
||||
account,
|
||||
account_currency,
|
||||
debit_in_account_currency,
|
||||
credit_in_account_currency,
|
||||
debit,
|
||||
credit,
|
||||
{}
|
||||
FROM `tabGL Entry`
|
||||
WHERE
|
||||
{}
|
||||
AND company = %s
|
||||
AND voucher_type != 'Period Closing Voucher'
|
||||
AND EXISTS(SELECT name FROM `tabAccount` WHERE name = account AND report_type = %s)
|
||||
AND is_cancelled = 0
|
||||
""".format(
|
||||
", ".join(self.accounting_dimension_fields),
|
||||
date_condition,
|
||||
),
|
||||
(self.company, report_type),
|
||||
as_dict=1,
|
||||
as_iterator=as_iterator,
|
||||
fields = [
|
||||
gle.name,
|
||||
gle.posting_date,
|
||||
gle.account,
|
||||
gle.account_currency,
|
||||
gle.debit_in_account_currency,
|
||||
gle.credit_in_account_currency,
|
||||
gle.debit,
|
||||
gle.credit,
|
||||
]
|
||||
fields += [gle[dimension] for dimension in self.accounting_dimension_fields]
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(*fields)
|
||||
.where(
|
||||
(gle.company == self.company)
|
||||
& (gle.voucher_type != "Period Closing Voucher")
|
||||
& (gle.is_cancelled == 0)
|
||||
& gle.account.isin(
|
||||
frappe.qb.from_(account).select(account.name).where(account.report_type == report_type)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if only_opening_entries:
|
||||
query = query.where(gle.is_opening == "Yes")
|
||||
else:
|
||||
query = query.where(
|
||||
gle.posting_date.between(self.period_start_date, self.period_end_date)
|
||||
& (gle.is_opening == "No")
|
||||
)
|
||||
|
||||
return query.run(as_dict=1, as_iterator=as_iterator)
|
||||
|
||||
def set_account_balance_dict(self, gle, acc_bal_dict):
|
||||
key = self.get_key(gle)
|
||||
|
||||
|
||||
@@ -55,15 +55,19 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
("Sales - TPC", 400.0, 0.0),
|
||||
)
|
||||
|
||||
pcv_gle = frappe.db.sql(
|
||||
"""
|
||||
select account, debit, credit from `tabGL Entry` where voucher_no=%s order by account
|
||||
""",
|
||||
(pcv.name),
|
||||
)
|
||||
pcv_gle = [
|
||||
tuple(row)
|
||||
for row in frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": pcv.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
order_by="account",
|
||||
as_list=True,
|
||||
)
|
||||
]
|
||||
pcv.reload()
|
||||
self.assertEqual(pcv.gle_processing_status, "Completed")
|
||||
self.assertEqual(pcv_gle, expected_gle)
|
||||
self.assertEqual(tuple(pcv_gle), expected_gle)
|
||||
|
||||
def test_cost_center_wise_posting(self):
|
||||
surplus_account = create_account()
|
||||
@@ -106,14 +110,16 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
("Sales - TPC", 200.0, 0.0, cost_center2),
|
||||
)
|
||||
|
||||
pcv_gle = frappe.db.sql(
|
||||
"""
|
||||
select account, debit, credit, cost_center
|
||||
from `tabGL Entry` where voucher_no=%s
|
||||
order by account, cost_center
|
||||
""",
|
||||
(pcv.name),
|
||||
)
|
||||
pcv_gle = [
|
||||
tuple(row)
|
||||
for row in frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": pcv.name},
|
||||
fields=["account", "debit", "credit", "cost_center"],
|
||||
order_by="account, cost_center",
|
||||
as_list=True,
|
||||
)
|
||||
]
|
||||
|
||||
self.assertSequenceEqual(pcv_gle, expected_gle)
|
||||
|
||||
@@ -166,16 +172,19 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
("Sales - TPC", 400.0, 0.0, jv.finance_book),
|
||||
)
|
||||
|
||||
pcv_gle = frappe.db.sql(
|
||||
"""
|
||||
select account, debit, credit, finance_book
|
||||
from `tabGL Entry` where voucher_no=%s
|
||||
order by account, finance_book
|
||||
""",
|
||||
(pcv.name),
|
||||
)
|
||||
pcv_gle = [
|
||||
tuple(row)
|
||||
for row in frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": pcv.name},
|
||||
fields=["account", "debit", "credit", "finance_book"],
|
||||
order_by="account, finance_book",
|
||||
as_list=True,
|
||||
)
|
||||
]
|
||||
|
||||
self.assertSequenceEqual(pcv_gle, expected_gle)
|
||||
# compare order-independently: postgres and MariaDB order NULL finance_book differently
|
||||
self.assertSequenceEqual(sorted(pcv_gle, key=str), sorted(expected_gle, key=str))
|
||||
|
||||
def test_gl_entries_restrictions(self):
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
@@ -358,14 +367,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
posting_date="2022-01-01",
|
||||
)
|
||||
|
||||
totals_after_cancel = frappe.db.sql(
|
||||
"""
|
||||
select sum(debit) as total_debit, sum(credit) as total_credit
|
||||
from `tabGL Entry`
|
||||
where voucher_type=%s and voucher_no=%s and is_cancelled=0
|
||||
""",
|
||||
("Journal Entry", jv.name),
|
||||
as_dict=True,
|
||||
totals_after_cancel = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Journal Entry", "voucher_no": jv.name, "is_cancelled": 0},
|
||||
fields=[{"SUM": "debit", "as": "total_debit"}, {"SUM": "credit", "as": "total_credit"}],
|
||||
)[0]
|
||||
|
||||
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)
|
||||
|
||||
@@ -295,7 +295,7 @@ def get_payments(invoices):
|
||||
.groupby(SalesInvoicePayment.mode_of_payment)
|
||||
.select(
|
||||
SalesInvoicePayment.mode_of_payment,
|
||||
SalesInvoicePayment.account,
|
||||
fn.Max(SalesInvoicePayment.account).as_("account"),
|
||||
fn.Sum(SalesInvoicePayment.amount).as_("amount"),
|
||||
)
|
||||
)
|
||||
@@ -419,7 +419,7 @@ def build_invoice_query(invoice_doctype, user, pos_profile, start, end):
|
||||
InvoiceDocType.account_for_change_amount,
|
||||
InvoiceDocType.is_return,
|
||||
InvoiceDocType.return_against,
|
||||
fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time).as_("timestamp"),
|
||||
fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time).as_("timestamp"),
|
||||
ConstantColumn(invoice_doctype).as_("doctype"),
|
||||
)
|
||||
.where(
|
||||
@@ -428,8 +428,8 @@ def build_invoice_query(invoice_doctype, user, pos_profile, start, end):
|
||||
& (InvoiceDocType.is_pos == 1)
|
||||
& (InvoiceDocType.pos_profile == pos_profile)
|
||||
& (
|
||||
(fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time) >= start)
|
||||
& (fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time) <= end)
|
||||
(fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time) >= start)
|
||||
& (fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time) <= end)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1425,19 +1425,23 @@
|
||||
"width": "50%"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.sales_partner",
|
||||
"fetch_from": "sales_partner.commission_rate",
|
||||
"fetch_if_empty": 1,
|
||||
"fieldname": "commission_rate",
|
||||
"fieldtype": "Float",
|
||||
"label": "Commission Rate (%)",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "commission_rate",
|
||||
"oldfieldtype": "Currency",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.sales_partner",
|
||||
"fieldname": "total_commission",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Total Commission",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "total_commission",
|
||||
"oldfieldtype": "Currency",
|
||||
"options": "Company:company:default_currency",
|
||||
@@ -1528,9 +1532,11 @@
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.sales_partner",
|
||||
"fieldname": "amount_eligible_for_commission",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Amount Eligible for Commission",
|
||||
"no_copy": 1,
|
||||
"options": "Company:company:default_currency",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -1636,7 +1642,7 @@
|
||||
"icon": "fa fa-file-text",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-28 12:22:50.253090",
|
||||
"modified": "2026-06-21 12:46:13.250145",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice",
|
||||
|
||||
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe import _, bold
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import map_child_doc, map_doc
|
||||
from frappe.query_builder.functions import IfNull, Sum
|
||||
from frappe.query_builder.functions import IfNull, Lower, Sum
|
||||
from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate
|
||||
from frappe.utils.nestedset import get_descendants_of
|
||||
|
||||
@@ -505,19 +505,20 @@ class POSInvoice(SalesInvoice):
|
||||
if d.get("serial_no"):
|
||||
serial_nos = get_serial_nos(d.serial_no)
|
||||
for sr in serial_nos:
|
||||
serial_no_exists = frappe.db.sql(
|
||||
"""
|
||||
SELECT name
|
||||
FROM `tabPOS Invoice Item`
|
||||
WHERE
|
||||
parent = %s
|
||||
and (serial_no = %s
|
||||
or serial_no like %s
|
||||
or serial_no like %s
|
||||
or serial_no like %s
|
||||
)
|
||||
""",
|
||||
(self.return_against, sr, sr + "\n%", "%\n" + sr, "%\n" + sr + "\n%"),
|
||||
POI = frappe.qb.DocType("POS Invoice Item")
|
||||
s = sr.lower()
|
||||
serial_no_exists = (
|
||||
frappe.qb.from_(POI)
|
||||
.select(POI.name)
|
||||
.where(POI.parent == self.return_against)
|
||||
.where(
|
||||
(Lower(POI.serial_no) == s)
|
||||
| Lower(POI.serial_no).like(f"{s}\n%")
|
||||
| Lower(POI.serial_no).like(f"%\n{s}")
|
||||
| Lower(POI.serial_no).like(f"%\n{s}\n%")
|
||||
)
|
||||
.limit(1)
|
||||
.run()
|
||||
)
|
||||
|
||||
if not serial_no_exists:
|
||||
@@ -963,15 +964,9 @@ def get_bundle_availability(bundle_item_code, warehouse):
|
||||
|
||||
|
||||
def get_bin_qty(item_code, warehouse):
|
||||
bin_qty = frappe.db.sql(
|
||||
"""select actual_qty from `tabBin`
|
||||
where item_code = %s and warehouse = %s
|
||||
limit 1""",
|
||||
(item_code, warehouse),
|
||||
as_dict=1,
|
||||
)
|
||||
actual_qty = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty")
|
||||
|
||||
return bin_qty[0].actual_qty or 0 if bin_qty else 0
|
||||
return actual_qty or 0
|
||||
|
||||
|
||||
def get_pos_reserved_qty(item_code, warehouse):
|
||||
|
||||
@@ -118,14 +118,21 @@ class POSProfile(Document):
|
||||
|
||||
def validate_default_profile(self):
|
||||
for row in self.applicable_for_users:
|
||||
res = frappe.db.sql(
|
||||
"""select pf.name
|
||||
from
|
||||
`tabPOS Profile User` pfu, `tabPOS Profile` pf
|
||||
where
|
||||
pf.name = pfu.parent and pfu.user = %s and pf.name != %s and pf.company = %s
|
||||
and pfu.default=1 and pf.disabled = 0""",
|
||||
(row.user, self.name, self.company),
|
||||
pfu = frappe.qb.DocType("POS Profile User")
|
||||
pf = frappe.qb.DocType("POS Profile")
|
||||
res = (
|
||||
frappe.qb.from_(pfu)
|
||||
.inner_join(pf)
|
||||
.on(pf.name == pfu.parent)
|
||||
.select(pf.name)
|
||||
.where(
|
||||
(pfu.user == row.user)
|
||||
& (pf.name != self.name)
|
||||
& (pf.company == self.company)
|
||||
& (pfu.default == 1)
|
||||
& (pf.disabled == 0)
|
||||
)
|
||||
.run()
|
||||
)
|
||||
|
||||
if row.default and res:
|
||||
@@ -235,15 +242,18 @@ def get_item_groups(pos_profile):
|
||||
for data in pos_profile.get("item_groups"):
|
||||
item_groups.extend(
|
||||
[
|
||||
"%s" % frappe.db.escape(d.name)
|
||||
d.name
|
||||
for d in get_child_nodes("Item Group", data.item_group)
|
||||
if not permitted_item_groups or d.name in permitted_item_groups
|
||||
]
|
||||
)
|
||||
|
||||
if not item_groups and permitted_item_groups:
|
||||
item_groups = ["%s" % frappe.db.escape(d) for d in permitted_item_groups]
|
||||
item_groups = list(permitted_item_groups)
|
||||
|
||||
# Return raw Item Group names; the callers parameterize them via the query builder
|
||||
# (item_group.isin(...)) / frappe.get_all, which escapes them once. Pre-escaping here would
|
||||
# double-escape (item_group IN ('''X''')) and match nothing.
|
||||
return list(set(item_groups))
|
||||
|
||||
|
||||
@@ -265,10 +275,11 @@ def get_permitted_nodes(group_type):
|
||||
|
||||
def get_child_nodes(group_type, root):
|
||||
lft, rgt = frappe.db.get_value(group_type, root, ["lft", "rgt"])
|
||||
return frappe.db.sql(
|
||||
f""" Select name, lft, rgt from `tab{group_type}` where
|
||||
lft >= {lft} and rgt <= {rgt} order by lft""",
|
||||
as_dict=1,
|
||||
return frappe.get_all(
|
||||
group_type,
|
||||
filters={"lft": [">=", lft], "rgt": ["<=", rgt]},
|
||||
fields=["name", "lft", "rgt"],
|
||||
order_by="lft",
|
||||
)
|
||||
|
||||
|
||||
@@ -278,40 +289,33 @@ def pos_profile_query(doctype: str, txt: str, searchfield: str, start: int, page
|
||||
user = frappe.session["user"]
|
||||
company = filters.get("company") or frappe.defaults.get_user_default("company")
|
||||
|
||||
args = {
|
||||
"user": user,
|
||||
"start": start,
|
||||
"company": company,
|
||||
"page_len": page_len,
|
||||
"txt": "%%%s%%" % txt,
|
||||
}
|
||||
pf = frappe.qb.DocType("POS Profile")
|
||||
pfu = frappe.qb.DocType("POS Profile User")
|
||||
|
||||
pos_profile = frappe.db.sql(
|
||||
"""select pf.name
|
||||
from
|
||||
`tabPOS Profile` pf, `tabPOS Profile User` pfu
|
||||
where
|
||||
pfu.parent = pf.name and pfu.user = %(user)s and pf.company = %(company)s
|
||||
and (pf.name like %(txt)s)
|
||||
and pf.disabled = 0 limit %(page_len)s offset %(start)s""",
|
||||
args,
|
||||
pos_profile = (
|
||||
frappe.qb.from_(pf)
|
||||
.inner_join(pfu)
|
||||
.on(pfu.parent == pf.name)
|
||||
.select(pf.name)
|
||||
.where((pfu.user == user) & (pf.company == company) & pf.name.like(f"%{txt}%") & (pf.disabled == 0))
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
.run()
|
||||
)
|
||||
|
||||
if not pos_profile:
|
||||
del args["user"]
|
||||
|
||||
pos_profile = frappe.db.sql(
|
||||
"""select pf.name
|
||||
from
|
||||
`tabPOS Profile` pf left join `tabPOS Profile User` pfu
|
||||
on
|
||||
pf.name = pfu.parent
|
||||
where
|
||||
ifnull(pfu.user, '') = ''
|
||||
and pf.company = %(company)s
|
||||
and pf.name like %(txt)s
|
||||
and pf.disabled = 0""",
|
||||
args,
|
||||
pos_profile = (
|
||||
frappe.qb.from_(pf)
|
||||
.left_join(pfu)
|
||||
.on(pf.name == pfu.parent)
|
||||
.select(pf.name)
|
||||
.where(
|
||||
(pfu.user.isnull() | (pfu.user == ""))
|
||||
& (pf.company == company)
|
||||
& pf.name.like(f"%{txt}%")
|
||||
& (pf.disabled == 0)
|
||||
)
|
||||
.run()
|
||||
)
|
||||
|
||||
return pos_profile
|
||||
|
||||
@@ -114,7 +114,7 @@ def _get_pricing_rules(apply_on, args, values):
|
||||
if apply_on_field == "item_code":
|
||||
if args.get("uom", None):
|
||||
item_conditions += (
|
||||
" and ({child_doc}.uom={item_uom} or IFNULL({child_doc}.uom, '')='')".format(
|
||||
" and ({child_doc}.uom={item_uom} or COALESCE({child_doc}.uom, '')='')".format(
|
||||
child_doc=child_doc, item_uom=frappe.db.escape(args.get("uom"))
|
||||
)
|
||||
)
|
||||
@@ -127,7 +127,7 @@ def _get_pricing_rules(apply_on, args, values):
|
||||
elif apply_on_field == "item_group":
|
||||
item_conditions = _get_tree_conditions(args, "Item Group", child_doc, False)
|
||||
if args.get("uom", None):
|
||||
item_conditions += " and ({child_doc}.uom={item_uom} or IFNULL({child_doc}.uom, '')='')".format(
|
||||
item_conditions += " and ({child_doc}.uom={item_uom} or COALESCE({child_doc}.uom, '')='')".format(
|
||||
child_doc=child_doc, item_uom=frappe.db.escape(args.get("uom"))
|
||||
)
|
||||
|
||||
@@ -139,7 +139,7 @@ def _get_pricing_rules(apply_on, args, values):
|
||||
if not args.price_list:
|
||||
args.price_list = None
|
||||
|
||||
conditions += " and ifnull(`tabPricing Rule`.for_price_list, '') in (%(price_list)s, '')"
|
||||
conditions += " and coalesce(`tabPricing Rule`.for_price_list, '') in (%(price_list)s, '')"
|
||||
values["price_list"] = args.get("price_list")
|
||||
|
||||
pricing_rules = (
|
||||
@@ -195,10 +195,8 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
|
||||
except TypeError:
|
||||
frappe.throw(_("Invalid {0}").format(args.get(field)))
|
||||
|
||||
parent_groups = frappe.db.sql_list(
|
||||
"""select name from `tab{}`
|
||||
where lft<={} and rgt>={}""".format(parenttype, "%s", "%s"),
|
||||
(lft, rgt),
|
||||
parent_groups = frappe.get_all(
|
||||
parenttype, filters={"lft": ["<=", lft], "rgt": [">=", rgt]}, pluck="name"
|
||||
)
|
||||
|
||||
if parenttype in ["Customer Group", "Item Group", "Territory"]:
|
||||
@@ -217,14 +215,14 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
|
||||
if parent_groups:
|
||||
if allow_blank:
|
||||
parent_groups.append("")
|
||||
condition = "ifnull({table}.{field}, '') in ({parent_groups})".format(
|
||||
condition = "coalesce({table}.{field}, '') in ({parent_groups})".format(
|
||||
table=table, field=field, parent_groups=", ".join(frappe.db.escape(d) for d in parent_groups)
|
||||
)
|
||||
|
||||
frappe.flags.tree_conditions[key] = condition
|
||||
|
||||
elif allow_blank:
|
||||
condition = f"ifnull({table}.{field}, '') = ''"
|
||||
condition = f"coalesce({table}.{field}, '') = ''"
|
||||
|
||||
return condition
|
||||
|
||||
@@ -232,10 +230,10 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
|
||||
def get_other_conditions(conditions, values, args):
|
||||
for field in ["company", "customer", "supplier", "campaign", "sales_partner"]:
|
||||
if args.get(field):
|
||||
conditions += f" and ifnull(`tabPricing Rule`.{field}, '') in (%({field})s, '')"
|
||||
conditions += f" and coalesce(`tabPricing Rule`.{field}, '') in (%({field})s, '')"
|
||||
values[field] = args.get(field)
|
||||
else:
|
||||
conditions += f" and ifnull(`tabPricing Rule`.{field}, '') = ''"
|
||||
conditions += f" and coalesce(`tabPricing Rule`.{field}, '') = ''"
|
||||
|
||||
for parenttype in ["Customer Group", "Territory", "Supplier Group"]:
|
||||
group_condition = _get_tree_conditions(args, parenttype, "`tabPricing Rule`")
|
||||
@@ -248,8 +246,8 @@ def get_other_conditions(conditions, values, args):
|
||||
or frappe.get_value(args.get("doctype"), args.get("name"), "posting_date", ignore=True)
|
||||
)
|
||||
if date:
|
||||
conditions += """ and %(transaction_date)s between ifnull(`tabPricing Rule`.valid_from, '2000-01-01')
|
||||
and ifnull(`tabPricing Rule`.valid_upto, '2500-12-31')"""
|
||||
conditions += """ and %(transaction_date)s between coalesce(`tabPricing Rule`.valid_from, '2000-01-01')
|
||||
and coalesce(`tabPricing Rule`.valid_upto, '2500-12-31')"""
|
||||
values["transaction_date"] = date
|
||||
|
||||
if args.get("doctype") in [
|
||||
@@ -264,9 +262,9 @@ def get_other_conditions(conditions, values, args):
|
||||
"POS Invoice",
|
||||
"POS Invoice Item",
|
||||
]:
|
||||
conditions += """ and ifnull(`tabPricing Rule`.selling, 0) = 1"""
|
||||
conditions += """ and coalesce(`tabPricing Rule`.selling, 0) = 1"""
|
||||
else:
|
||||
conditions += """ and ifnull(`tabPricing Rule`.buying, 0) = 1"""
|
||||
conditions += """ and coalesce(`tabPricing Rule`.buying, 0) = 1"""
|
||||
|
||||
return conditions
|
||||
|
||||
@@ -758,21 +756,16 @@ def validate_coupon_code(coupon_name):
|
||||
|
||||
def update_coupon_code_count(coupon_name, transaction_type):
|
||||
coupon = frappe.get_doc("Coupon Code", coupon_name)
|
||||
if coupon:
|
||||
if transaction_type == "used":
|
||||
if not coupon.maximum_use:
|
||||
coupon.used = coupon.used + 1
|
||||
coupon.save(ignore_permissions=True)
|
||||
elif coupon.used < coupon.maximum_use:
|
||||
coupon.used = coupon.used + 1
|
||||
coupon.save(ignore_permissions=True)
|
||||
else:
|
||||
frappe.throw(
|
||||
_("{0} Coupon used are {1}. Allowed quantity is exhausted").format(
|
||||
coupon.coupon_code, coupon.used
|
||||
)
|
||||
if transaction_type == "used":
|
||||
if coupon.maximum_use and coupon.used >= coupon.maximum_use:
|
||||
frappe.throw(
|
||||
_("{0} Coupon used are {1}. Allowed quantity is exhausted").format(
|
||||
coupon.coupon_code, coupon.used
|
||||
)
|
||||
elif transaction_type == "cancelled":
|
||||
if coupon.used > 0:
|
||||
coupon.used = coupon.used - 1
|
||||
coupon.save(ignore_permissions=True)
|
||||
)
|
||||
coupon.used = coupon.used + 1
|
||||
coupon.save(ignore_permissions=True)
|
||||
elif transaction_type == "cancelled":
|
||||
if coupon.used > 0:
|
||||
coupon.used = coupon.used - 1
|
||||
coupon.save(ignore_permissions=True)
|
||||
|
||||
@@ -431,7 +431,9 @@ def reconcile(doc: None | str = None) -> None:
|
||||
# Update reconciled flag
|
||||
allocation_names = [x.name for x in allocations]
|
||||
ppa = qb.DocType("Process Payment Reconciliation Log Allocations")
|
||||
qb.update(ppa).set(ppa.reconciled, True).where(ppa.name.isin(allocation_names)).run()
|
||||
qb.update(ppa).set(ppa.reconciled, 1).where(
|
||||
ppa.name.isin(allocation_names)
|
||||
).run() # smallint, not bool
|
||||
|
||||
# Update reconciled count
|
||||
reconciled_count = frappe.db.count(
|
||||
@@ -477,7 +479,7 @@ def reconcile(doc: None | str = None) -> None:
|
||||
finally:
|
||||
if reconciled_entries == total_allocations:
|
||||
frappe.db.set_value("Process Payment Reconciliation Log", log, "status", "Reconciled")
|
||||
frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", True)
|
||||
frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", 1)
|
||||
frappe.db.set_value("Process Payment Reconciliation", doc, "status", "Completed")
|
||||
else:
|
||||
if frappe.db.get_value("Process Payment Reconciliation", doc, "status") != "Paused":
|
||||
@@ -501,7 +503,7 @@ def reconcile(doc: None | str = None) -> None:
|
||||
)
|
||||
else:
|
||||
frappe.db.set_value("Process Payment Reconciliation Log", log, "status", "Reconciled")
|
||||
frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", True)
|
||||
frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", 1)
|
||||
frappe.db.set_value("Process Payment Reconciliation", doc, "status", "Completed")
|
||||
|
||||
|
||||
|
||||
@@ -95,6 +95,8 @@ def start_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
if normal_balances := (
|
||||
qb.from_(ppcvd)
|
||||
@@ -121,7 +123,7 @@ def start_pcv_processing(docname: str):
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
timeout="3600",
|
||||
timeout=timeout,
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
@@ -247,6 +249,8 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
|
||||
|
||||
@frappe.whitelist()
|
||||
def schedule_next_date(docname: str):
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
if to_process := (
|
||||
qb.from_(ppcvd)
|
||||
@@ -272,7 +276,7 @@ def schedule_next_date(docname: str):
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
timeout="3600",
|
||||
timeout=timeout,
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
@@ -302,7 +306,7 @@ def schedule_next_date(docname: str):
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries",
|
||||
queue="long",
|
||||
timeout="3600",
|
||||
timeout=timeout,
|
||||
is_async=True,
|
||||
job_name=job_name,
|
||||
enqueue_after_commit=True,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"categorize_by",
|
||||
"cost_center",
|
||||
"territory",
|
||||
"show_opening_entries",
|
||||
"ignore_exchange_rate_revaluation_journals",
|
||||
"ignore_cr_dr_notes",
|
||||
"column_break_14",
|
||||
@@ -414,10 +415,17 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Print Format",
|
||||
"options": "Print Format"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:(doc.report == 'General Ledger');",
|
||||
"fieldname": "show_opening_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Opening Entries"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2025-10-07 12:19:20.719898",
|
||||
"modified": "2026-06-01 15:37:07.660442",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Statement Of Accounts",
|
||||
|
||||
@@ -6,7 +6,6 @@ import copy
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.desk.reportview import get_match_cond
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import add_days, add_months, format_date, getdate, today
|
||||
from frappe.utils.jinja import validate_template
|
||||
@@ -20,6 +19,7 @@ from erpnext.accounts.report.accounts_receivable_summary.accounts_receivable_sum
|
||||
execute as get_ageing,
|
||||
)
|
||||
from erpnext.accounts.report.general_ledger.general_ledger import execute as get_soa
|
||||
from erpnext.utilities.query import get_match_conditions_qb
|
||||
|
||||
|
||||
class ProcessStatementOfAccounts(Document):
|
||||
@@ -75,6 +75,7 @@ class ProcessStatementOfAccounts(Document):
|
||||
sender: DF.Link | None
|
||||
show_future_payments: DF.Check
|
||||
show_net_values_in_party_account: DF.Check
|
||||
show_opening_entries: DF.Check
|
||||
show_remarks: DF.Check
|
||||
start_date: DF.Date | None
|
||||
subject: DF.Data | None
|
||||
@@ -270,7 +271,7 @@ def get_gl_filters(doc, entry, tax_id, presentation_currency):
|
||||
"categorize_by": doc.categorize_by,
|
||||
"currency": doc.currency,
|
||||
"project": [p.project_name for p in doc.project],
|
||||
"show_opening_entries": 0,
|
||||
"show_opening_entries": doc.show_opening_entries,
|
||||
"include_default_book_entries": 0,
|
||||
"tax_id": tax_id if tax_id else None,
|
||||
"show_net_values_in_party_account": doc.show_net_values_in_party_account,
|
||||
@@ -365,15 +366,19 @@ def get_customers_based_on_territory_or_customer_group(customer_collection, coll
|
||||
|
||||
def get_customers_based_on_sales_person(sales_person):
|
||||
lft, rgt = frappe.db.get_value("Sales Person", sales_person, ["lft", "rgt"])
|
||||
records = frappe.db.sql(
|
||||
"""
|
||||
select distinct parent, parenttype
|
||||
from `tabSales Team` steam
|
||||
where parenttype = 'Customer'
|
||||
and exists(select name from `tabSales Person` where lft >= %s and rgt <= %s and name = steam.sales_person)
|
||||
""",
|
||||
(lft, rgt),
|
||||
as_dict=1,
|
||||
steam = frappe.qb.DocType("Sales Team")
|
||||
sp = frappe.qb.DocType("Sales Person")
|
||||
records = (
|
||||
frappe.qb.from_(steam)
|
||||
.select(steam.parent, steam.parenttype)
|
||||
.distinct()
|
||||
.where(
|
||||
(steam.parenttype == "Customer")
|
||||
& steam.sales_person.isin(
|
||||
frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt))
|
||||
)
|
||||
)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
sales_person_records = frappe._dict()
|
||||
for d in records:
|
||||
@@ -468,31 +473,30 @@ def get_customer_emails(customer_name: str, primary_mandatory: str | int, billin
|
||||
|
||||
frappe.has_permission("Customer", "read", customer_name, throw=True)
|
||||
|
||||
billing_email = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
email.email_id
|
||||
FROM
|
||||
`tabContact Email` AS email
|
||||
JOIN
|
||||
`tabDynamic Link` AS link
|
||||
ON
|
||||
email.parent=link.parent
|
||||
JOIN
|
||||
`tabContact` AS contact
|
||||
ON
|
||||
contact.name=link.parent
|
||||
WHERE
|
||||
link.link_doctype='Customer'
|
||||
and link.link_name=%s
|
||||
and contact.is_billing_contact=1
|
||||
{mcond}
|
||||
ORDER BY
|
||||
contact.creation desc
|
||||
""".format(mcond=get_match_cond("Contact")),
|
||||
customer_name,
|
||||
email = frappe.qb.DocType("Contact Email")
|
||||
link = frappe.qb.DocType("Dynamic Link")
|
||||
contact = frappe.qb.DocType("Contact")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(email)
|
||||
.join(link)
|
||||
.on(email.parent == link.parent)
|
||||
.join(contact)
|
||||
.on(contact.name == link.parent)
|
||||
.select(email.email_id)
|
||||
.where(
|
||||
(link.link_doctype == "Customer")
|
||||
& (link.link_name == customer_name)
|
||||
& (contact.is_billing_contact == 1)
|
||||
)
|
||||
.orderby(contact.creation, order=frappe.qb.desc)
|
||||
)
|
||||
|
||||
for condition in get_match_conditions_qb("Contact", table=contact):
|
||||
query = query.where(condition)
|
||||
|
||||
billing_email = query.run()
|
||||
|
||||
if len(billing_email) == 0 or (billing_email[0][0] is None):
|
||||
if billing_and_primary:
|
||||
frappe.throw(_("No billing email found for customer: {0}").format(customer_name))
|
||||
|
||||
@@ -524,16 +524,11 @@ class PurchaseInvoice(BuyingController):
|
||||
def check_prev_docstatus(self):
|
||||
for d in self.get("items"):
|
||||
if d.purchase_order:
|
||||
submitted = frappe.db.sql(
|
||||
"select name from `tabPurchase Order` where docstatus = 1 and name = %s", d.purchase_order
|
||||
)
|
||||
submitted = frappe.db.exists("Purchase Order", {"docstatus": 1, "name": d.purchase_order})
|
||||
if not submitted:
|
||||
frappe.throw(_("Purchase Order {0} is not submitted").format(d.purchase_order))
|
||||
if d.purchase_receipt:
|
||||
submitted = frappe.db.sql(
|
||||
"select name from `tabPurchase Receipt` where docstatus = 1 and name = %s",
|
||||
d.purchase_receipt,
|
||||
)
|
||||
submitted = frappe.db.exists("Purchase Receipt", {"docstatus": 1, "name": d.purchase_receipt})
|
||||
if not submitted:
|
||||
frappe.throw(_("Purchase Receipt {0} is not submitted").format(d.purchase_receipt))
|
||||
|
||||
@@ -801,25 +796,20 @@ class PurchaseInvoice(BuyingController):
|
||||
if cint(frappe.get_single_value("Accounts Settings", "check_supplier_invoice_uniqueness")):
|
||||
fiscal_year = get_fiscal_year(self.posting_date, company=self.company, as_dict=True)
|
||||
|
||||
pi = frappe.db.sql(
|
||||
"""select name from `tabPurchase Invoice`
|
||||
where
|
||||
bill_no = %(bill_no)s
|
||||
and supplier = %(supplier)s
|
||||
and name != %(name)s
|
||||
and docstatus < 2
|
||||
and posting_date between %(year_start_date)s and %(year_end_date)s""",
|
||||
{
|
||||
pi = frappe.get_all(
|
||||
"Purchase Invoice",
|
||||
filters={
|
||||
"bill_no": self.bill_no,
|
||||
"supplier": self.supplier,
|
||||
"name": self.name,
|
||||
"year_start_date": fiscal_year.year_start_date,
|
||||
"year_end_date": fiscal_year.year_end_date,
|
||||
"name": ["!=", self.name],
|
||||
"docstatus": ["<", 2],
|
||||
"posting_date": ["between", [fiscal_year.year_start_date, fiscal_year.year_end_date]],
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
if pi:
|
||||
pi = pi[0][0]
|
||||
pi = pi[0]
|
||||
|
||||
frappe.throw(
|
||||
_("Supplier Invoice No exists in Purchase Invoice {0}").format(
|
||||
|
||||
@@ -55,10 +55,13 @@ class ExpenseAccountService:
|
||||
else:
|
||||
# check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not
|
||||
if item.purchase_receipt:
|
||||
negative_expense_booked_in_pr = frappe.db.sql(
|
||||
"""select name from `tabGL Entry`
|
||||
where voucher_type='Purchase Receipt' and voucher_no=%s and account = %s""",
|
||||
(item.purchase_receipt, stock_not_billed_account),
|
||||
negative_expense_booked_in_pr = frappe.db.exists(
|
||||
"GL Entry",
|
||||
{
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": item.purchase_receipt,
|
||||
"account": stock_not_billed_account,
|
||||
},
|
||||
)
|
||||
|
||||
if negative_expense_booked_in_pr:
|
||||
|
||||
@@ -395,10 +395,14 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
):
|
||||
# Post reverse entry for Stock-Received-But-Not-Billed if booked in Purchase Receipt
|
||||
if item.purchase_receipt and valuation_tax_accounts:
|
||||
negative_expense_booked_in_pr = frappe.db.sql(
|
||||
"""select name from `tabGL Entry`
|
||||
where voucher_type='Purchase Receipt' and voucher_no=%s and account in %s""",
|
||||
(item.purchase_receipt, valuation_tax_accounts),
|
||||
negative_expense_booked_in_pr = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": item.purchase_receipt,
|
||||
"account": ["in", valuation_tax_accounts],
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
(
|
||||
@@ -586,6 +590,10 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
tax_service = TaxService(doc)
|
||||
valuation_tax = {}
|
||||
|
||||
# Amount of each valuation charge actually capitalized into stock/asset valuation, keyed by
|
||||
# tax row name - a non-stock item's share of a spread-across-all-items charge is excluded.
|
||||
capitalized_valuation_tax = doc.get_capitalized_valuation_tax()
|
||||
|
||||
for tax in doc.get("taxes"):
|
||||
amount, base_amount = tax_service.get_tax_amounts(tax, None)
|
||||
if tax.category in ("Total", "Valuation and Total") and flt(base_amount):
|
||||
@@ -620,8 +628,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
tax.idx, _(tax.category)
|
||||
)
|
||||
)
|
||||
valuation_tax.setdefault(tax.name, 0)
|
||||
valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(base_amount)
|
||||
valuation_tax[tax.name] = capitalized_valuation_tax.get(tax.name, 0.0)
|
||||
|
||||
if doc.is_opening == "No" and doc.negative_expense_to_be_booked and valuation_tax:
|
||||
total_valuation_amount = sum(valuation_tax.values())
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import add_days, cint, flt, getdate, nowdate, today
|
||||
|
||||
import erpnext
|
||||
@@ -123,11 +124,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
"_Test Account Discount - _TC": [0, 168.03],
|
||||
"Round Off - _TC": [0, 0.3],
|
||||
}
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, debit, credit from `tabGL Entry`
|
||||
where voucher_type = 'Purchase Invoice' and voucher_no = %s""",
|
||||
pi.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
)
|
||||
for d in gl_entries:
|
||||
self.assertEqual([d.debit, d.credit], expected_gl_entries.get(d.account))
|
||||
@@ -317,12 +317,11 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
self.check_gle_for_pi(pi.name)
|
||||
|
||||
def check_gle_for_pi(self, pi):
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, sum(debit) as debit, sum(credit) as credit
|
||||
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
|
||||
group by account""",
|
||||
pi,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi},
|
||||
fields=["account", {"SUM": "debit", "as": "debit"}, {"SUM": "credit", "as": "credit"}],
|
||||
group_by="account",
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
@@ -342,6 +341,83 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
self.assertEqual(expected_values[gle.account][1], gle.debit)
|
||||
self.assertEqual(expected_values[gle.account][2], gle.credit)
|
||||
|
||||
def test_full_actual_charge_capitalized_on_stock_items_only(self):
|
||||
"""On a stock-updating Purchase Invoice, an actual valuation charge (e.g. Freight) with
|
||||
"Allocate Full Amount to Stock Items" checked is fully capitalized onto stock/asset items
|
||||
only. For 2 stock items + 1 service item (each net 100) and a 30 freight charge, the charge
|
||||
is distributed over the 200 stock net only (15 per stock item) and the entire 30 is
|
||||
capitalized; nothing is lost to the non-stock item."""
|
||||
from erpnext.stock import get_warehouse_account_map
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
|
||||
|
||||
company = "_Test Company with perpetual inventory"
|
||||
warehouse = "Stores - TCP1"
|
||||
|
||||
stock_item1 = make_item(properties={"is_stock_item": 1}).name
|
||||
stock_item2 = make_item(properties={"is_stock_item": 1}).name
|
||||
service_item = make_item(properties={"is_stock_item": 0}).name
|
||||
|
||||
pi = frappe.new_doc("Purchase Invoice")
|
||||
pi.company = company
|
||||
pi.supplier = "_Test Supplier"
|
||||
pi.currency = "INR"
|
||||
pi.update_stock = 1
|
||||
pi.credit_to = "Creditors - TCP1"
|
||||
# Order matters: stock, service, stock (service item in the middle)
|
||||
for code in (stock_item1, service_item, stock_item2):
|
||||
pi.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": code,
|
||||
"qty": 1,
|
||||
"rate": 100,
|
||||
"warehouse": warehouse,
|
||||
"cost_center": "Main - TCP1",
|
||||
"expense_account": "Cost of Goods Sold - TCP1",
|
||||
},
|
||||
)
|
||||
|
||||
pi.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "Actual",
|
||||
"account_head": "_Test Account Shipping Charges - TCP1",
|
||||
"category": "Valuation and Total",
|
||||
"cost_center": "Main - TCP1",
|
||||
"description": "Freight",
|
||||
"tax_amount": 30,
|
||||
# Default behavior: allocate the full amount to stock/asset items only
|
||||
"allocate_full_amount_to_stock_items": 1,
|
||||
},
|
||||
)
|
||||
|
||||
pi.insert()
|
||||
|
||||
# 30 freight / 200 stock net = 15 per stock item. The service item carries nothing.
|
||||
self.assertAlmostEqual(pi.items[0].item_tax_amount, 15.0, places=2)
|
||||
self.assertAlmostEqual(pi.items[1].item_tax_amount, 0.0, places=2)
|
||||
self.assertAlmostEqual(pi.items[2].item_tax_amount, 15.0, places=2)
|
||||
|
||||
pi.submit()
|
||||
|
||||
gl_entries = get_gl_entries("Purchase Invoice", pi.name, skip_cancelled=True, as_dict=True)
|
||||
# Sum per account - the same account can appear in multiple GL rows (e.g. the stock account
|
||||
# is debited once per item), so aggregate rather than keeping only the last row.
|
||||
gl_map = {}
|
||||
for row in gl_entries:
|
||||
acc = gl_map.setdefault(row.account, {"debit": 0.0, "credit": 0.0})
|
||||
acc["debit"] += row.debit
|
||||
acc["credit"] += row.credit
|
||||
|
||||
warehouse_account = get_warehouse_account_map(company)
|
||||
stock_account = warehouse_account[warehouse]["account"]
|
||||
|
||||
# Stock asset = 200 (goods) + 30 (the entire freight charge)
|
||||
self.assertAlmostEqual(gl_map[stock_account]["debit"], 230.0, places=2)
|
||||
# The whole freight charge (30) is capitalized
|
||||
self.assertAlmostEqual(gl_map["_Test Account Shipping Charges - TCP1"]["credit"], 30.0, places=2)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
|
||||
)
|
||||
@@ -461,12 +537,11 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
|
||||
self.assertTrue(pi.status, "Unpaid")
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, debit, credit
|
||||
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
|
||||
order by account asc""",
|
||||
pi.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
order_by="account asc",
|
||||
)
|
||||
self.assertTrue(gl_entries)
|
||||
|
||||
@@ -475,10 +550,11 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
["Creditors - TCP1", 0, 250],
|
||||
]
|
||||
|
||||
for i, gle in enumerate(gl_entries):
|
||||
self.assertEqual(expected_values[i][0], gle.account)
|
||||
self.assertEqual(expected_values[i][1], gle.debit)
|
||||
self.assertEqual(expected_values[i][2], gle.credit)
|
||||
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
|
||||
self.assertEqual(
|
||||
sorted((gle.account, gle.debit, gle.credit) for gle in gl_entries),
|
||||
sorted((e[0], e[1], e[2]) for e in expected_values),
|
||||
)
|
||||
|
||||
def test_purchase_invoice_calculation(self):
|
||||
pi = frappe.copy_doc(self.globalTestRecords["Purchase Invoice"][0])
|
||||
@@ -546,21 +622,24 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
pi.load_from_db()
|
||||
|
||||
self.assertTrue(
|
||||
frappe.db.sql(
|
||||
"""select name from `tabJournal Entry Account`
|
||||
where reference_type='Purchase Invoice'
|
||||
and reference_name=%s and debit_in_account_currency=300""",
|
||||
pi.name,
|
||||
frappe.get_all(
|
||||
"Journal Entry Account",
|
||||
filters={
|
||||
"reference_type": "Purchase Invoice",
|
||||
"reference_name": pi.name,
|
||||
"debit_in_account_currency": 300,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
pi.cancel()
|
||||
|
||||
self.assertFalse(
|
||||
frappe.db.sql(
|
||||
"""select name from `tabJournal Entry Account`
|
||||
where reference_type='Purchase Invoice' and reference_name=%s""",
|
||||
pi.name,
|
||||
frappe.get_all(
|
||||
"Journal Entry Account",
|
||||
filters={"reference_type": "Purchase Invoice", "reference_name": pi.name},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -604,10 +683,14 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
pi.load_from_db()
|
||||
|
||||
self.assertTrue(
|
||||
frappe.db.sql(
|
||||
"select name from `tabJournal Entry Account` where reference_type='Purchase Invoice' and "
|
||||
"reference_name=%s and debit_in_account_currency=300",
|
||||
pi.name,
|
||||
frappe.get_all(
|
||||
"Journal Entry Account",
|
||||
filters={
|
||||
"reference_type": "Purchase Invoice",
|
||||
"reference_name": pi.name,
|
||||
"debit_in_account_currency": 300,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -616,10 +699,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
pi.cancel()
|
||||
|
||||
self.assertFalse(
|
||||
frappe.db.sql(
|
||||
"select name from `tabJournal Entry Account` where reference_type='Purchase Invoice' and "
|
||||
"reference_name=%s",
|
||||
pi.name,
|
||||
frappe.get_all(
|
||||
"Journal Entry Account",
|
||||
filters={"reference_type": "Purchase Invoice", "reference_name": pi.name},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -629,13 +712,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
else:
|
||||
project = frappe.get_doc("Project", {"project_name": "_Test Project for Purchase"})
|
||||
|
||||
existing_purchase_cost = frappe.db.sql(
|
||||
f"""select sum(base_net_amount)
|
||||
from `tabPurchase Invoice Item`
|
||||
where project = '{project.name}'
|
||||
and docstatus=1"""
|
||||
existing_purchase_cost = frappe.get_all(
|
||||
"Purchase Invoice Item",
|
||||
filters={"project": project.name, "docstatus": 1},
|
||||
fields=[{"SUM": "base_net_amount", "as": "base_net_amount"}],
|
||||
)
|
||||
existing_purchase_cost = existing_purchase_cost and existing_purchase_cost[0][0] or 0
|
||||
existing_purchase_cost = existing_purchase_cost and existing_purchase_cost[0].base_net_amount or 0
|
||||
|
||||
pi = make_purchase_invoice(currency="USD", conversion_rate=60, project=project.name)
|
||||
self.assertEqual(
|
||||
@@ -679,12 +761,11 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
)
|
||||
|
||||
# check gl entries for return
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, debit, credit
|
||||
from `tabGL Entry` where voucher_type=%s and voucher_no=%s
|
||||
order by account desc""",
|
||||
("Purchase Invoice", return_pi.name),
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Purchase Invoice", "voucher_no": return_pi.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
order_by="account desc",
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
@@ -773,13 +854,18 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
conversion_rate=50,
|
||||
)
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, account_currency, debit, credit,
|
||||
debit_in_account_currency, credit_in_account_currency
|
||||
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
|
||||
order by account asc""",
|
||||
pi.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
|
||||
fields=[
|
||||
"account",
|
||||
"account_currency",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
@@ -821,10 +907,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
# cancel
|
||||
pi.cancel()
|
||||
|
||||
gle = frappe.db.sql(
|
||||
"""select name from `tabGL Entry`
|
||||
where voucher_type='Sales Invoice' and voucher_no=%s""",
|
||||
pi.name,
|
||||
gle = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": pi.name},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
self.assertFalse(gle)
|
||||
@@ -842,13 +928,18 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
expense_account="_Test Account Cost for Goods Sold - TCP1",
|
||||
)
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, account_currency, debit, credit,
|
||||
debit_in_account_currency, credit_in_account_currency
|
||||
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
|
||||
order by account asc""",
|
||||
pi.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
|
||||
fields=[
|
||||
"account",
|
||||
"account_currency",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
@@ -877,13 +968,16 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
expense_account="_Test Account Cost for Goods Sold - TCP1",
|
||||
)
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, account_currency, sum(debit) as debit,
|
||||
sum(credit) as credit, debit_in_account_currency, credit_in_account_currency
|
||||
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
|
||||
group by account, voucher_no order by account asc;""",
|
||||
pi.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
|
||||
fields=[
|
||||
"account",
|
||||
{"SUM": "debit", "as": "debit"},
|
||||
{"SUM": "credit", "as": "credit"},
|
||||
],
|
||||
group_by="account, voucher_no",
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
stock_in_hand_account = get_inventory_account(pi.company, pi.get("items")[0].warehouse)
|
||||
@@ -1145,13 +1239,19 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
"_Test Account Cost for Goods Sold - _TC": {"cost_center": cost_center},
|
||||
}
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, cost_center, account_currency, debit, credit,
|
||||
debit_in_account_currency, credit_in_account_currency
|
||||
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
|
||||
order by account asc""",
|
||||
pi.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
|
||||
fields=[
|
||||
"account",
|
||||
"cost_center",
|
||||
"account_currency",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
@@ -1168,13 +1268,19 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
"_Test Account Cost for Goods Sold - _TC": {"cost_center": cost_center},
|
||||
}
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, cost_center, account_currency, debit, credit,
|
||||
debit_in_account_currency, credit_in_account_currency
|
||||
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
|
||||
order by account asc""",
|
||||
pi.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
|
||||
fields=[
|
||||
"account",
|
||||
"cost_center",
|
||||
"account_currency",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
@@ -1209,13 +1315,20 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
"_Test Account Cost for Goods Sold - _TC": {"project": item_project.name},
|
||||
}
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, cost_center, project, account_currency, debit, credit,
|
||||
debit_in_account_currency, credit_in_account_currency
|
||||
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
|
||||
order by account asc""",
|
||||
pi.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
|
||||
fields=[
|
||||
"account",
|
||||
"cost_center",
|
||||
"project",
|
||||
"account_currency",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
@@ -1269,13 +1382,15 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
[deferred_account, 23.07, 0.0, "2019-03-15"],
|
||||
]
|
||||
|
||||
gl_entries = gl_entries = frappe.db.sql(
|
||||
"""select account, debit, credit, posting_date
|
||||
from `tabGL Entry`
|
||||
where voucher_type='Journal Entry' and voucher_detail_no=%s and posting_date <= %s
|
||||
order by posting_date asc, account asc""",
|
||||
(pi.items[0].name, pi.posting_date),
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"voucher_type": "Journal Entry",
|
||||
"voucher_detail_no": pi.items[0].name,
|
||||
"posting_date": ["<=", pi.posting_date],
|
||||
},
|
||||
fields=["account", "debit", "credit", "posting_date"],
|
||||
order_by="posting_date asc, account asc",
|
||||
)
|
||||
|
||||
for i, gle in enumerate(gl_entries):
|
||||
@@ -1350,14 +1465,14 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
["_Test Payable USD - _TC", -37500.0],
|
||||
]
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""
|
||||
select account, sum(debit - credit) as balance from `tabGL Entry`
|
||||
where voucher_no=%s
|
||||
group by account
|
||||
order by account asc""",
|
||||
(pi.name),
|
||||
as_dict=1,
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
gl_entries = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(gle.account, Sum(gle.debit - gle.credit).as_("balance"))
|
||||
.where(gle.voucher_no == pi.name)
|
||||
.groupby(gle.account)
|
||||
.orderby(gle.account)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
for i, gle in enumerate(gl_entries):
|
||||
@@ -1421,13 +1536,14 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
["_Test Payable USD - _TC", -36500.0],
|
||||
]
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""
|
||||
select account, sum(debit - credit) as balance from `tabGL Entry`
|
||||
where voucher_no=%s
|
||||
group by account order by account asc""",
|
||||
(pi_2.name),
|
||||
as_dict=1,
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
gl_entries = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(gle.account, Sum(gle.debit - gle.credit).as_("balance"))
|
||||
.where(gle.voucher_no == pi_2.name)
|
||||
.groupby(gle.account)
|
||||
.orderby(gle.account)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
for i, gle in enumerate(gl_entries):
|
||||
@@ -1436,18 +1552,21 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
|
||||
expected_gle = [["_Test Payable USD - _TC", 70000.0], ["Cash - _TC", -70000.0]]
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""
|
||||
select account, sum(debit - credit) as balance from `tabGL Entry`
|
||||
where voucher_no=%s and is_cancelled=0
|
||||
group by account order by account asc""",
|
||||
(pay.name),
|
||||
as_dict=1,
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
gl_entries = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(gle.account, Sum(gle.debit - gle.credit).as_("balance"))
|
||||
.where((gle.voucher_no == pay.name) & (gle.is_cancelled == 0))
|
||||
.groupby(gle.account)
|
||||
.orderby(gle.account)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
for i, gle in enumerate(gl_entries):
|
||||
self.assertEqual(expected_gle[i][0], gle.account)
|
||||
self.assertEqual(expected_gle[i][1], gle.balance)
|
||||
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
|
||||
self.assertEqual(
|
||||
sorted((gle.account, gle.balance) for gle in gl_entries),
|
||||
sorted((e[0], e[1]) for e in expected_gle),
|
||||
)
|
||||
|
||||
total_debit_amount = frappe.db.get_all(
|
||||
"Journal Entry Account",
|
||||
@@ -1546,19 +1665,18 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
[tds_account, 0, 3000],
|
||||
]
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, debit, credit
|
||||
from `tabGL Entry`
|
||||
where voucher_type='Payment Entry' and voucher_no=%s
|
||||
order by account asc""",
|
||||
(payment_entry.name),
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Payment Entry", "voucher_no": payment_entry.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
for i, gle in enumerate(gl_entries):
|
||||
self.assertEqual(expected_gle[i][0], gle.account)
|
||||
self.assertEqual(expected_gle[i][1], gle.debit)
|
||||
self.assertEqual(expected_gle[i][2], gle.credit)
|
||||
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
|
||||
self.assertEqual(
|
||||
sorted((gle.account, gle.debit, gle.credit) for gle in gl_entries),
|
||||
sorted((e[0], e[1], e[2]) for e in expected_gle),
|
||||
)
|
||||
|
||||
# Create Purchase Invoice against Purchase Order
|
||||
purchase_invoice = get_mapped_purchase_invoice(po.name)
|
||||
@@ -1572,19 +1690,21 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
# Zero net effect on final TDS payable on invoice
|
||||
expected_gle = [["_Test Account Cost for Goods Sold - _TC", 30000], ["Creditors - _TC", -30000]]
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, sum(debit - credit) as amount
|
||||
from `tabGL Entry`
|
||||
where voucher_type='Purchase Invoice' and voucher_no=%s
|
||||
group by account
|
||||
order by account asc""",
|
||||
(purchase_invoice.name),
|
||||
as_dict=1,
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
gl_entries = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(gle.account, Sum(gle.debit - gle.credit).as_("amount"))
|
||||
.where((gle.voucher_type == "Purchase Invoice") & (gle.voucher_no == purchase_invoice.name))
|
||||
.groupby(gle.account)
|
||||
.orderby(gle.account)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
for i, gle in enumerate(gl_entries):
|
||||
self.assertEqual(expected_gle[i][0], gle.account)
|
||||
self.assertEqual(expected_gle[i][1], gle.amount)
|
||||
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
|
||||
self.assertEqual(
|
||||
sorted((gle.account, gle.amount) for gle in gl_entries),
|
||||
sorted((e[0], e[1]) for e in expected_gle),
|
||||
)
|
||||
|
||||
payment_entry.load_from_db()
|
||||
tax_allocated = sum(
|
||||
@@ -2476,12 +2596,11 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
pi.insert()
|
||||
pi.submit()
|
||||
|
||||
pr_gl_entries = frappe.db.sql(
|
||||
"""select account, debit, credit
|
||||
from `tabGL Entry` where voucher_type='Purchase Receipt' and voucher_no=%s
|
||||
order by account asc""",
|
||||
pr.name,
|
||||
as_dict=1,
|
||||
pr_gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
pr_expected_values = [
|
||||
@@ -2494,12 +2613,11 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
self.assertEqual(pr_expected_values[i][1], gle.debit)
|
||||
self.assertEqual(pr_expected_values[i][2], gle.credit)
|
||||
|
||||
pi_gl_entries = frappe.db.sql(
|
||||
"""select account, debit, credit
|
||||
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
|
||||
order by account asc""",
|
||||
pi.name,
|
||||
as_dict=1,
|
||||
pi_gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
order_by="account asc",
|
||||
)
|
||||
pi_expected_values = [
|
||||
["Asset Received But Not Billed - _TC", 5000, 0],
|
||||
@@ -2928,6 +3046,24 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
# Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail
|
||||
self.assertRaises(frappe.ValidationError, pi.submit)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
|
||||
def test_non_stock_item_over_billing_against_po_is_blocked(self):
|
||||
service_item = create_item(
|
||||
"_Test Service Item Non Stock PI",
|
||||
is_stock_item=0,
|
||||
is_purchase_item=1,
|
||||
).name
|
||||
|
||||
po = create_purchase_order(item_code=service_item, qty=5, rate=100, do_not_save=False)
|
||||
po.submit()
|
||||
|
||||
pi = make_pi_from_po(po.name)
|
||||
pi.items[0].qty = 10 # overbill by 100 %
|
||||
pi.save()
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
pi.submit()
|
||||
|
||||
def test_discount_percentage_not_set_when_amount_is_manually_set(self):
|
||||
pi = make_purchase_invoice(do_not_save=True)
|
||||
discount_amount = 7
|
||||
@@ -3047,17 +3183,25 @@ def check_gl_entries(
|
||||
|
||||
gl_entries = query.run(as_dict=True)
|
||||
|
||||
for i, gle in enumerate(gl_entries):
|
||||
doc.assertEqual(expected_gle[i][0], gle.account)
|
||||
doc.assertEqual(expected_gle[i][1], gle.debit)
|
||||
doc.assertEqual(expected_gle[i][2], gle.credit)
|
||||
doc.assertEqual(getdate(expected_gle[i][3]), gle.posting_date)
|
||||
# MariaDB and Postgres collate `account` differently, so the DB row order isn't portable.
|
||||
# Match each actual GL row against the expected set instead of comparing positionally; like the
|
||||
# original loop (which iterated the actual rows), extra expected rows are tolerated.
|
||||
cols = additional_columns or []
|
||||
|
||||
if additional_columns:
|
||||
j = 4
|
||||
for col in additional_columns:
|
||||
doc.assertEqual(expected_gle[i][j], gle[col])
|
||||
j += 1
|
||||
def _key(account, debit, credit, posting_date, extras):
|
||||
return (account, flt(debit), flt(credit), getdate(posting_date), *(str(v) for v in extras))
|
||||
|
||||
remaining = {}
|
||||
for e in expected_gle:
|
||||
k = _key(e[0], e[1], e[2], e[3], e[4 : 4 + len(cols)])
|
||||
remaining[k] = remaining.get(k, 0) + 1
|
||||
|
||||
for gle in gl_entries:
|
||||
k = _key(gle.account, gle.debit, gle.credit, gle.posting_date, [gle[c] for c in cols])
|
||||
doc.assertGreater(
|
||||
remaining.get(k, 0), 0, msg=f"Unexpected GL entry {k}; expected one of {list(remaining)}"
|
||||
)
|
||||
remaining[k] -= 1
|
||||
|
||||
|
||||
def create_tax_witholding_category(category_name, company, account):
|
||||
|
||||
@@ -11,13 +11,16 @@
|
||||
"add_deduct_tax",
|
||||
"charge_type",
|
||||
"row_id",
|
||||
"included_in_print_rate",
|
||||
"included_in_paid_amount",
|
||||
"col_break1",
|
||||
"account_head",
|
||||
"description",
|
||||
"is_tax_withholding_account",
|
||||
"section_break_uhfl",
|
||||
"set_by_item_tax_template",
|
||||
"is_tax_withholding_account",
|
||||
"allocate_full_amount_to_stock_items",
|
||||
"column_break_zqtz",
|
||||
"included_in_print_rate",
|
||||
"included_in_paid_amount",
|
||||
"section_break_10",
|
||||
"rate",
|
||||
"accounting_dimensions_section",
|
||||
@@ -78,6 +81,15 @@
|
||||
"oldfieldname": "row_id",
|
||||
"oldfieldtype": "Data"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"depends_on": "eval:doc.charge_type=='Actual' && ['Valuation', 'Valuation and Total'].includes(doc.category)",
|
||||
"description": "If checked, the entire amount (e.g. Freight) is allocated to the valuation of stock & asset items only. If unchecked, the amount is distributed across all items and the portion belonging to non-stock items is not added to valuation.",
|
||||
"fieldname": "allocate_full_amount_to_stock_items",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allocate Full Amount to Stock Items",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If checked, the tax amount will be considered as already included in the Print Rate / Print Amount",
|
||||
@@ -272,13 +284,21 @@
|
||||
"label": "Don't Recompute Tax",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_uhfl",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_zqtz",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-24 18:22:56.886010",
|
||||
"modified": "2026-06-21 17:13:05.586544",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Taxes and Charges",
|
||||
|
||||
@@ -17,6 +17,7 @@ class PurchaseTaxesandCharges(Document):
|
||||
account_currency: DF.Link | None
|
||||
account_head: DF.Link
|
||||
add_deduct_tax: DF.Literal["Add", "Deduct"]
|
||||
allocate_full_amount_to_stock_items: DF.Check
|
||||
base_net_amount: DF.Currency
|
||||
base_tax_amount: DF.Currency
|
||||
base_tax_amount_after_discount_amount: DF.Currency
|
||||
|
||||
@@ -69,6 +69,7 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
qb.from_(gl)
|
||||
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
|
||||
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
|
||||
.groupby(gl.voucher_no)
|
||||
.run()
|
||||
)
|
||||
|
||||
@@ -82,6 +83,7 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
qb.from_(gl)
|
||||
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
|
||||
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
|
||||
.groupby(gl.voucher_no)
|
||||
.run()
|
||||
)
|
||||
|
||||
|
||||
@@ -200,106 +200,11 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
set_purchase_references(target)
|
||||
|
||||
def update_details(source_doc, target_doc, source_parent):
|
||||
def _validate_address_link(address, link_doctype, link_name):
|
||||
return frappe.db.get_value(
|
||||
"Dynamic Link",
|
||||
{
|
||||
"parent": address,
|
||||
"parenttype": "Address",
|
||||
"link_doctype": link_doctype,
|
||||
"link_name": link_name,
|
||||
},
|
||||
"parent",
|
||||
)
|
||||
|
||||
target_doc.inter_company_invoice_reference = source_doc.name
|
||||
if target_doc.doctype in ["Purchase Invoice", "Purchase Order"]:
|
||||
currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency")
|
||||
target_doc.company = details.get("company")
|
||||
target_doc.supplier = details.get("party")
|
||||
target_doc.is_internal_supplier = 1
|
||||
target_doc.ignore_pricing_rule = 1
|
||||
target_doc.buying_price_list = source_doc.selling_price_list
|
||||
|
||||
# Invert Addresses
|
||||
if source_doc.company_address and _validate_address_link(
|
||||
source_doc.company_address, "Supplier", details.get("party")
|
||||
):
|
||||
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
|
||||
if source_doc.dispatch_address_name and _validate_address_link(
|
||||
source_doc.dispatch_address_name, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc,
|
||||
"dispatch_address",
|
||||
"dispatch_address_display",
|
||||
source_doc.dispatch_address_name,
|
||||
)
|
||||
if source_doc.shipping_address_name and _validate_address_link(
|
||||
source_doc.shipping_address_name, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc,
|
||||
"shipping_address",
|
||||
"shipping_address_display",
|
||||
source_doc.shipping_address_name,
|
||||
)
|
||||
if source_doc.customer_address and _validate_address_link(
|
||||
source_doc.customer_address, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "billing_address", "billing_address_display", source_doc.customer_address
|
||||
)
|
||||
|
||||
if currency:
|
||||
target_doc.currency = currency
|
||||
|
||||
update_taxes(
|
||||
target_doc,
|
||||
party=target_doc.supplier,
|
||||
party_type="Supplier",
|
||||
company=target_doc.company,
|
||||
doctype=target_doc.doctype,
|
||||
party_address=target_doc.supplier_address,
|
||||
company_address=target_doc.shipping_address,
|
||||
)
|
||||
|
||||
_apply_purchase_party_details(target_doc, source_doc, details)
|
||||
else:
|
||||
currency = frappe.db.get_value("Customer", details.get("party"), "default_currency")
|
||||
target_doc.company = details.get("company")
|
||||
target_doc.customer = details.get("party")
|
||||
target_doc.selling_price_list = source_doc.buying_price_list
|
||||
|
||||
if source_doc.supplier_address and _validate_address_link(
|
||||
source_doc.supplier_address, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "company_address", "company_address_display", source_doc.supplier_address
|
||||
)
|
||||
if source_doc.shipping_address and _validate_address_link(
|
||||
source_doc.shipping_address, "Customer", details.get("party")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address
|
||||
)
|
||||
if source_doc.shipping_address and _validate_address_link(
|
||||
source_doc.shipping_address, "Customer", details.get("party")
|
||||
):
|
||||
update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
|
||||
|
||||
if currency:
|
||||
target_doc.currency = currency
|
||||
|
||||
update_taxes(
|
||||
target_doc,
|
||||
party=target_doc.customer,
|
||||
party_type="Customer",
|
||||
company=target_doc.company,
|
||||
doctype=target_doc.doctype,
|
||||
party_address=target_doc.customer_address,
|
||||
company_address=target_doc.company_address,
|
||||
shipping_address_name=target_doc.shipping_address_name,
|
||||
)
|
||||
_apply_sales_party_details(target_doc, source_doc, details)
|
||||
|
||||
def update_item(source, target, source_parent):
|
||||
target.qty = flt(source.qty) - received_items.get(source.name, 0.0)
|
||||
@@ -378,6 +283,97 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
return doclist
|
||||
|
||||
|
||||
def _get_linked_address(address, link_doctype, link_name):
|
||||
return frappe.db.get_value(
|
||||
"Dynamic Link",
|
||||
{
|
||||
"parent": address,
|
||||
"parenttype": "Address",
|
||||
"link_doctype": link_doctype,
|
||||
"link_name": link_name,
|
||||
},
|
||||
"parent",
|
||||
)
|
||||
|
||||
|
||||
def _apply_purchase_party_details(target_doc, source_doc, details):
|
||||
currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency")
|
||||
target_doc.company = details.get("company")
|
||||
target_doc.supplier = details.get("party")
|
||||
target_doc.is_internal_supplier = 1
|
||||
target_doc.ignore_pricing_rule = 1
|
||||
target_doc.buying_price_list = source_doc.selling_price_list
|
||||
|
||||
# Invert Addresses
|
||||
if source_doc.company_address and _get_linked_address(
|
||||
source_doc.company_address, "Supplier", details.get("party")
|
||||
):
|
||||
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
|
||||
if source_doc.dispatch_address_name and _get_linked_address(
|
||||
source_doc.dispatch_address_name, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "dispatch_address", "dispatch_address_display", source_doc.dispatch_address_name
|
||||
)
|
||||
if source_doc.shipping_address_name and _get_linked_address(
|
||||
source_doc.shipping_address_name, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "shipping_address", "shipping_address_display", source_doc.shipping_address_name
|
||||
)
|
||||
if source_doc.customer_address and _get_linked_address(
|
||||
source_doc.customer_address, "Company", details.get("company")
|
||||
):
|
||||
update_address(target_doc, "billing_address", "billing_address_display", source_doc.customer_address)
|
||||
|
||||
if currency:
|
||||
target_doc.currency = currency
|
||||
|
||||
update_taxes(
|
||||
target_doc,
|
||||
party=target_doc.supplier,
|
||||
party_type="Supplier",
|
||||
company=target_doc.company,
|
||||
doctype=target_doc.doctype,
|
||||
party_address=target_doc.supplier_address,
|
||||
company_address=target_doc.shipping_address,
|
||||
)
|
||||
|
||||
|
||||
def _apply_sales_party_details(target_doc, source_doc, details):
|
||||
currency = frappe.db.get_value("Customer", details.get("party"), "default_currency")
|
||||
target_doc.company = details.get("company")
|
||||
target_doc.customer = details.get("party")
|
||||
target_doc.selling_price_list = source_doc.buying_price_list
|
||||
|
||||
if source_doc.supplier_address and _get_linked_address(
|
||||
source_doc.supplier_address, "Company", details.get("company")
|
||||
):
|
||||
update_address(target_doc, "company_address", "company_address_display", source_doc.supplier_address)
|
||||
if source_doc.shipping_address and _get_linked_address(
|
||||
source_doc.shipping_address, "Customer", details.get("party")
|
||||
):
|
||||
update_address(target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address)
|
||||
if source_doc.shipping_address and _get_linked_address(
|
||||
source_doc.shipping_address, "Customer", details.get("party")
|
||||
):
|
||||
update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
|
||||
|
||||
if currency:
|
||||
target_doc.currency = currency
|
||||
|
||||
update_taxes(
|
||||
target_doc,
|
||||
party=target_doc.customer,
|
||||
party_type="Customer",
|
||||
company=target_doc.company,
|
||||
doctype=target_doc.doctype,
|
||||
party_address=target_doc.customer_address,
|
||||
company_address=target_doc.company_address,
|
||||
shipping_address_name=target_doc.shipping_address_name,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
|
||||
reference_field = "inter_company_invoice_reference"
|
||||
|
||||
@@ -1747,6 +1747,7 @@
|
||||
"width": "50%"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.sales_partner",
|
||||
"fetch_from": "sales_partner.commission_rate",
|
||||
"fetch_if_empty": 1,
|
||||
"fieldname": "commission_rate",
|
||||
@@ -1754,16 +1755,19 @@
|
||||
"hide_days": 1,
|
||||
"hide_seconds": 1,
|
||||
"label": "Commission Rate (%)",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "commission_rate",
|
||||
"oldfieldtype": "Currency",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.sales_partner",
|
||||
"fieldname": "total_commission",
|
||||
"fieldtype": "Currency",
|
||||
"hide_days": 1,
|
||||
"hide_seconds": 1,
|
||||
"label": "Total Commission",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "total_commission",
|
||||
"oldfieldtype": "Currency",
|
||||
"options": "Company:company:default_currency",
|
||||
@@ -1970,9 +1974,11 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.sales_partner",
|
||||
"fieldname": "amount_eligible_for_commission",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Amount Eligible for Commission",
|
||||
"no_copy": 1,
|
||||
"options": "Company:company:default_currency",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -2354,7 +2360,7 @@
|
||||
"link_fieldname": "consolidated_invoice"
|
||||
}
|
||||
],
|
||||
"modified": "2026-05-28 12:15:12.486443",
|
||||
"modified": "2026-06-21 12:46:13.250145",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice",
|
||||
|
||||
@@ -25,30 +25,35 @@ class FixedAssetService:
|
||||
if doc.doctype != "Sales Invoice":
|
||||
return
|
||||
|
||||
for d in doc.get("items"):
|
||||
if not d.is_fixed_asset:
|
||||
continue
|
||||
for item in doc.get("items"):
|
||||
if item.is_fixed_asset:
|
||||
self._validate_fixed_asset_item(item)
|
||||
|
||||
if d.asset:
|
||||
if not doc.is_return:
|
||||
asset_status = frappe.db.get_value("Asset", d.asset, "status")
|
||||
if doc.update_stock:
|
||||
frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale"))
|
||||
elif asset_status in ("Scrapped", "Cancelled", "Capitalized"):
|
||||
frappe.throw(
|
||||
_("Row #{0}: Asset {1} cannot be sold, it is already {2}").format(
|
||||
d.idx, d.asset, asset_status
|
||||
)
|
||||
)
|
||||
elif asset_status == "Sold" and not doc.is_return:
|
||||
frappe.throw(_("Row #{0}: Asset {1} is already sold").format(d.idx, d.asset))
|
||||
elif not doc.return_against:
|
||||
frappe.throw(_("Row #{0}: Return Against is required for returning asset").format(d.idx))
|
||||
else:
|
||||
frappe.throw(
|
||||
_("Row #{0}: You must select an Asset for Item {1}.").format(d.idx, d.item_code),
|
||||
title=_("Missing Asset"),
|
||||
def _validate_fixed_asset_item(self, item) -> None:
|
||||
doc = self.doc
|
||||
if not item.asset:
|
||||
frappe.throw(
|
||||
_("Row #{0}: You must select an Asset for Item {1}.").format(item.idx, item.item_code),
|
||||
title=_("Missing Asset"),
|
||||
)
|
||||
|
||||
if doc.is_return:
|
||||
if not doc.return_against:
|
||||
frappe.throw(_("Row #{0}: Return Against is required for returning asset").format(item.idx))
|
||||
return
|
||||
|
||||
if doc.update_stock:
|
||||
frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale"))
|
||||
|
||||
asset_status = frappe.db.get_value("Asset", item.asset, "status")
|
||||
if asset_status in ("Scrapped", "Cancelled", "Capitalized"):
|
||||
frappe.throw(
|
||||
_("Row #{0}: Asset {1} cannot be sold, it is already {2}").format(
|
||||
item.idx, item.asset, asset_status
|
||||
)
|
||||
)
|
||||
if asset_status == "Sold":
|
||||
frappe.throw(_("Row #{0}: Asset {1} is already sold").format(item.idx, item.asset))
|
||||
|
||||
def set_income_account_for_fixed_assets(self) -> None:
|
||||
for item in self.doc.items:
|
||||
|
||||
@@ -93,54 +93,7 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
if enable_discount_accounting:
|
||||
for item in doc.get("items"):
|
||||
if item.get("discount_amount") and item.get("discount_account"):
|
||||
discount_amount = item.discount_amount * item.qty
|
||||
income_account = (
|
||||
item.income_account
|
||||
if (not item.enable_deferred_revenue or doc.is_return)
|
||||
else item.deferred_revenue_account
|
||||
)
|
||||
|
||||
account_currency = get_account_currency(item.discount_account)
|
||||
gl_entries.append(
|
||||
doc.get_gl_dict(
|
||||
{
|
||||
"account": item.discount_account,
|
||||
"against": doc.customer,
|
||||
"debit": flt(
|
||||
discount_amount * doc.get("conversion_rate"),
|
||||
item.precision("discount_amount"),
|
||||
),
|
||||
"debit_in_transaction_currency": flt(
|
||||
discount_amount, item.precision("discount_amount")
|
||||
),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
account_currency = get_account_currency(income_account)
|
||||
gl_entries.append(
|
||||
doc.get_gl_dict(
|
||||
{
|
||||
"account": income_account,
|
||||
"against": doc.customer,
|
||||
"credit": flt(
|
||||
discount_amount * doc.get("conversion_rate"),
|
||||
item.precision("discount_amount"),
|
||||
),
|
||||
"credit_in_transaction_currency": flt(
|
||||
discount_amount, item.precision("discount_amount")
|
||||
),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
self._append_item_discount_gl_entries(item, gl_entries)
|
||||
|
||||
if (
|
||||
(enable_discount_accounting or doc.get("is_cash_or_non_trade_discount"))
|
||||
@@ -159,81 +112,143 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
)
|
||||
)
|
||||
|
||||
def _append_item_discount_gl_entries(self, item, gl_entries) -> None:
|
||||
doc = self.doc
|
||||
discount_amount = item.discount_amount * item.qty
|
||||
income_account = (
|
||||
item.income_account
|
||||
if (not item.enable_deferred_revenue or doc.is_return)
|
||||
else item.deferred_revenue_account
|
||||
)
|
||||
|
||||
account_currency = get_account_currency(item.discount_account)
|
||||
gl_entries.append(
|
||||
doc.get_gl_dict(
|
||||
{
|
||||
"account": item.discount_account,
|
||||
"against": doc.customer,
|
||||
"debit": flt(
|
||||
discount_amount * doc.get("conversion_rate"),
|
||||
item.precision("discount_amount"),
|
||||
),
|
||||
"debit_in_transaction_currency": flt(discount_amount, item.precision("discount_amount")),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
account_currency = get_account_currency(income_account)
|
||||
gl_entries.append(
|
||||
doc.get_gl_dict(
|
||||
{
|
||||
"account": income_account,
|
||||
"against": doc.customer,
|
||||
"credit": flt(
|
||||
discount_amount * doc.get("conversion_rate"),
|
||||
item.precision("discount_amount"),
|
||||
),
|
||||
"credit_in_transaction_currency": flt(discount_amount, item.precision("discount_amount")),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
def stock_delivered_but_not_billed_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
if doc.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(doc.company)):
|
||||
return
|
||||
|
||||
for item in doc.get("items"):
|
||||
if not item.delivery_note and not item.dn_detail:
|
||||
continue
|
||||
booking = self._get_sdbnb_booking_for_item(item)
|
||||
if booking:
|
||||
self._append_sdbnb_gl_entries(item, booking, gl_entries)
|
||||
|
||||
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
|
||||
continue
|
||||
def _get_sdbnb_booking_for_item(self, item) -> dict | None:
|
||||
"""SDBNB account and valuation to reverse for a billed-from-delivery-note item, if any."""
|
||||
if not item.delivery_note and not item.dn_detail:
|
||||
return None
|
||||
|
||||
dn_expense_account = frappe.get_cached_value(
|
||||
"Delivery Note Item", item.dn_detail, "expense_account"
|
||||
)
|
||||
if (
|
||||
not dn_expense_account
|
||||
or frappe.get_cached_value("Account", dn_expense_account, "account_type")
|
||||
!= "Stock Delivered But Not Billed"
|
||||
or not item.expense_account
|
||||
or dn_expense_account == item.expense_account
|
||||
):
|
||||
continue
|
||||
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
|
||||
return None
|
||||
|
||||
delivery_note = item.delivery_note or frappe.get_cached_value(
|
||||
"Delivery Note Item", item.dn_detail, "parent"
|
||||
)
|
||||
if not delivery_note:
|
||||
continue
|
||||
dn_expense_account = frappe.get_cached_value("Delivery Note Item", item.dn_detail, "expense_account")
|
||||
if not self._is_sdbnb_reversal(dn_expense_account, item):
|
||||
return None
|
||||
|
||||
item_g = frappe.get_cached_value(
|
||||
"Stock Ledger Entry",
|
||||
delivery_note = item.delivery_note or frappe.get_cached_value(
|
||||
"Delivery Note Item", item.dn_detail, "parent"
|
||||
)
|
||||
if not delivery_note:
|
||||
return None
|
||||
|
||||
item_g = frappe.get_cached_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_no": delivery_note,
|
||||
"voucher_detail_no": item.dn_detail,
|
||||
"item_code": item.item_code,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
["stock_value_difference", "actual_qty"],
|
||||
as_dict=True,
|
||||
)
|
||||
if not item_g or not flt(item_g.actual_qty):
|
||||
return None
|
||||
|
||||
valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty)
|
||||
return {
|
||||
"dn_expense_account": dn_expense_account,
|
||||
"valuation_amount": valuation_rate * item.stock_qty,
|
||||
}
|
||||
|
||||
def _is_sdbnb_reversal(self, dn_expense_account, item) -> bool:
|
||||
"""True when the DN booked to an SDBNB account distinct from the item's expense account."""
|
||||
return bool(
|
||||
dn_expense_account
|
||||
and frappe.get_cached_value("Account", dn_expense_account, "account_type")
|
||||
== "Stock Delivered But Not Billed"
|
||||
and item.expense_account
|
||||
and dn_expense_account != item.expense_account
|
||||
)
|
||||
|
||||
def _append_sdbnb_gl_entries(self, item, booking, gl_entries) -> None:
|
||||
dn_expense_account = booking["dn_expense_account"]
|
||||
valuation_amount = booking["valuation_amount"]
|
||||
dn_account_currency = get_account_currency(dn_expense_account)
|
||||
item_account_currency = get_account_currency(item.expense_account)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"voucher_no": delivery_note,
|
||||
"voucher_detail_no": item.dn_detail,
|
||||
"item_code": item.item_code,
|
||||
"is_cancelled": 0,
|
||||
"account": dn_expense_account,
|
||||
"against": item.expense_account,
|
||||
"credit": flt(valuation_amount),
|
||||
"credit_in_account_currency": flt(valuation_amount),
|
||||
"cost_center": item.cost_center,
|
||||
},
|
||||
["stock_value_difference", "actual_qty"],
|
||||
as_dict=True,
|
||||
dn_account_currency,
|
||||
item=item,
|
||||
)
|
||||
|
||||
if not item_g or not flt(item_g.actual_qty):
|
||||
continue
|
||||
valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty)
|
||||
valuation_amount = valuation_rate * item.stock_qty
|
||||
dn_account_currency = get_account_currency(dn_expense_account)
|
||||
item_account_currency = get_account_currency(item.expense_account)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": dn_expense_account,
|
||||
"against": item.expense_account,
|
||||
"credit": flt(valuation_amount),
|
||||
"credit_in_account_currency": flt(valuation_amount),
|
||||
"cost_center": item.cost_center,
|
||||
},
|
||||
dn_account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": item.expense_account,
|
||||
"against": dn_expense_account,
|
||||
"debit": flt(valuation_amount),
|
||||
"debit_in_account_currency": flt(valuation_amount),
|
||||
"cost_center": item.cost_center,
|
||||
},
|
||||
item_account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": item.expense_account,
|
||||
"against": dn_expense_account,
|
||||
"debit": flt(valuation_amount),
|
||||
"debit_in_account_currency": flt(valuation_amount),
|
||||
"cost_center": item.cost_center,
|
||||
},
|
||||
item_account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
def make_customer_gl_entry(self, gl_entries):
|
||||
doc = self.doc
|
||||
@@ -250,10 +265,6 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
)
|
||||
|
||||
if grand_total and not doc.is_internal_transfer():
|
||||
against_voucher = doc.name
|
||||
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
|
||||
against_voucher = doc.return_against
|
||||
|
||||
# Did not use base_grand_total to book rounding loss gle
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
@@ -264,11 +275,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
"due_date": doc.due_date,
|
||||
"against": doc.against_income_account,
|
||||
"debit": base_grand_total,
|
||||
"debit_in_account_currency": base_grand_total
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else grand_total,
|
||||
"debit_in_account_currency": self._get_amount_in_account_currency(
|
||||
doc.party_account_currency, base_grand_total, grand_total
|
||||
),
|
||||
"debit_in_transaction_currency": grand_total,
|
||||
"against_voucher": against_voucher,
|
||||
"against_voucher": self._resolve_against_voucher(),
|
||||
"against_voucher_type": doc.doctype,
|
||||
"cost_center": doc.cost_center,
|
||||
"project": doc.project,
|
||||
@@ -296,10 +307,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
"account": tax.account_head,
|
||||
"against": doc.customer,
|
||||
"credit": flt(base_amount, tax.precision("tax_amount_after_discount_amount")),
|
||||
"credit_in_account_currency": (
|
||||
flt(base_amount, tax.precision("base_tax_amount_after_discount_amount"))
|
||||
if account_currency == doc.company_currency
|
||||
else flt(amount, tax.precision("tax_amount_after_discount_amount"))
|
||||
"credit_in_account_currency": self._get_amount_in_account_currency(
|
||||
account_currency,
|
||||
flt(base_amount, tax.precision("base_tax_amount_after_discount_amount")),
|
||||
flt(amount, tax.precision("tax_amount_after_discount_amount")),
|
||||
),
|
||||
"credit_in_transaction_currency": flt(
|
||||
amount, tax.precision("tax_amount_after_discount_amount")
|
||||
@@ -341,53 +352,57 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
)
|
||||
|
||||
for item in doc.get("items"):
|
||||
if (
|
||||
if not (
|
||||
flt(item.base_net_amount, item.precision("base_net_amount"))
|
||||
or item.is_fixed_asset
|
||||
or enable_discount_accounting
|
||||
):
|
||||
# Do not book income for transfer within same company
|
||||
if doc.is_internal_transfer():
|
||||
continue
|
||||
continue
|
||||
|
||||
if item.is_fixed_asset and item.asset:
|
||||
self.get_gl_entries_for_fixed_asset(item, gl_entries)
|
||||
else:
|
||||
income_account = (
|
||||
item.income_account
|
||||
if (not item.enable_deferred_revenue or doc.is_return)
|
||||
else item.deferred_revenue_account
|
||||
)
|
||||
# Do not book income for transfer within same company
|
||||
if doc.is_internal_transfer():
|
||||
continue
|
||||
|
||||
amount, base_amount = tax_service.get_amount_and_base_amount(
|
||||
item, enable_discount_accounting
|
||||
)
|
||||
|
||||
account_currency = get_account_currency(income_account)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": income_account,
|
||||
"against": doc.customer,
|
||||
"credit": flt(base_amount, item.precision("base_net_amount")),
|
||||
"credit_in_account_currency": (
|
||||
flt(base_amount, item.precision("base_net_amount"))
|
||||
if account_currency == doc.company_currency
|
||||
else flt(amount, item.precision("net_amount"))
|
||||
),
|
||||
"credit_in_transaction_currency": flt(amount, item.precision("net_amount")),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
if item.is_fixed_asset and item.asset:
|
||||
self.get_gl_entries_for_fixed_asset(item, gl_entries)
|
||||
else:
|
||||
self._append_item_income_gl_entry(item, gl_entries, tax_service, enable_discount_accounting)
|
||||
|
||||
# expense account gl entries
|
||||
if cint(doc.update_stock) and erpnext.is_perpetual_inventory_enabled(doc.company):
|
||||
gl_entries += super(SalesInvoice, doc).get_gl_entries()
|
||||
|
||||
def _append_item_income_gl_entry(self, item, gl_entries, tax_service, enable_discount_accounting) -> None:
|
||||
doc = self.doc
|
||||
income_account = (
|
||||
item.income_account
|
||||
if (not item.enable_deferred_revenue or doc.is_return)
|
||||
else item.deferred_revenue_account
|
||||
)
|
||||
|
||||
amount, base_amount = tax_service.get_amount_and_base_amount(item, enable_discount_accounting)
|
||||
|
||||
account_currency = get_account_currency(income_account)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": income_account,
|
||||
"against": doc.customer,
|
||||
"credit": flt(base_amount, item.precision("base_net_amount")),
|
||||
"credit_in_account_currency": self._get_amount_in_account_currency(
|
||||
account_currency,
|
||||
flt(base_amount, item.precision("base_net_amount")),
|
||||
flt(amount, item.precision("net_amount")),
|
||||
),
|
||||
"credit_in_transaction_currency": flt(amount, item.precision("net_amount")),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
def get_gl_entries_for_fixed_asset(self, item, gl_entries):
|
||||
doc = self.doc
|
||||
asset = frappe.get_cached_doc("Asset", item.asset)
|
||||
@@ -461,10 +476,6 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
if skip_change_gl_entries and payment_mode.account == doc.account_for_change_amount:
|
||||
payment_mode.base_amount -= flt(doc.change_amount)
|
||||
|
||||
against_voucher = doc.name
|
||||
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
|
||||
against_voucher = doc.return_against
|
||||
|
||||
if payment_mode.base_amount:
|
||||
# POS, make payment entries
|
||||
gl_entries.append(
|
||||
@@ -475,11 +486,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
"party": doc.customer,
|
||||
"against": payment_mode.account,
|
||||
"credit": payment_mode.base_amount,
|
||||
"credit_in_account_currency": payment_mode.base_amount
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else payment_mode.amount,
|
||||
"credit_in_account_currency": self._get_amount_in_account_currency(
|
||||
doc.party_account_currency, payment_mode.base_amount, payment_mode.amount
|
||||
),
|
||||
"credit_in_transaction_currency": payment_mode.amount,
|
||||
"against_voucher": against_voucher,
|
||||
"against_voucher": self._resolve_against_voucher(),
|
||||
"against_voucher_type": doc.doctype,
|
||||
"cost_center": doc.cost_center,
|
||||
},
|
||||
@@ -495,9 +506,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
"account": payment_mode.account,
|
||||
"against": doc.customer,
|
||||
"debit": payment_mode.base_amount,
|
||||
"debit_in_account_currency": payment_mode.base_amount
|
||||
if payment_mode_account_currency == doc.company_currency
|
||||
else payment_mode.amount,
|
||||
"debit_in_account_currency": self._get_amount_in_account_currency(
|
||||
payment_mode_account_currency,
|
||||
payment_mode.base_amount,
|
||||
payment_mode.amount,
|
||||
),
|
||||
"debit_in_transaction_currency": payment_mode.amount,
|
||||
"cost_center": doc.cost_center,
|
||||
},
|
||||
@@ -525,9 +538,9 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
"party": doc.customer,
|
||||
"against": doc.account_for_change_amount,
|
||||
"debit": flt(doc.base_change_amount),
|
||||
"debit_in_account_currency": flt(doc.base_change_amount)
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else flt(doc.change_amount),
|
||||
"debit_in_account_currency": self._get_amount_in_account_currency(
|
||||
doc.party_account_currency, flt(doc.base_change_amount), flt(doc.change_amount)
|
||||
),
|
||||
"debit_in_transaction_currency": flt(doc.change_amount),
|
||||
"against_voucher": doc.return_against
|
||||
if cint(doc.is_return) and doc.return_against
|
||||
@@ -570,10 +583,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
"party": doc.customer,
|
||||
"against": doc.write_off_account,
|
||||
"credit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
|
||||
"credit_in_account_currency": (
|
||||
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount"))
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else flt(doc.write_off_amount, doc.precision("write_off_amount"))
|
||||
"credit_in_account_currency": self._get_amount_in_account_currency(
|
||||
doc.party_account_currency,
|
||||
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
|
||||
flt(doc.write_off_amount, doc.precision("write_off_amount")),
|
||||
),
|
||||
"credit_in_transaction_currency": flt(
|
||||
doc.write_off_amount, doc.precision("write_off_amount")
|
||||
@@ -593,10 +606,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
"account": doc.write_off_account,
|
||||
"against": doc.customer,
|
||||
"debit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
|
||||
"debit_in_account_currency": (
|
||||
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount"))
|
||||
if write_off_account_currency == doc.company_currency
|
||||
else flt(doc.write_off_amount, doc.precision("write_off_amount"))
|
||||
"debit_in_account_currency": self._get_amount_in_account_currency(
|
||||
write_off_account_currency,
|
||||
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
|
||||
flt(doc.write_off_amount, doc.precision("write_off_amount")),
|
||||
),
|
||||
"debit_in_transaction_currency": flt(
|
||||
doc.write_off_amount, doc.precision("write_off_amount")
|
||||
@@ -659,3 +672,14 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
def _get_amount_in_account_currency(self, account_currency, base_amount, transaction_amount):
|
||||
"""Base amount when the account is in company currency, else the transaction amount."""
|
||||
return base_amount if account_currency == self.doc.company_currency else transaction_amount
|
||||
|
||||
def _resolve_against_voucher(self) -> str:
|
||||
"""Settle against the original invoice for returns not kept on their own outstanding."""
|
||||
doc = self.doc
|
||||
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
|
||||
return doc.return_against
|
||||
return doc.name
|
||||
|
||||
@@ -13,36 +13,54 @@ def validate_inter_company_party(
|
||||
if not party:
|
||||
return
|
||||
|
||||
if doctype in ["Sales Invoice", "Sales Order"]:
|
||||
partytype, ref_partytype, internal = "Customer", "Supplier", "is_internal_customer"
|
||||
ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order"
|
||||
else:
|
||||
partytype, ref_partytype, internal = "Supplier", "Customer", "is_internal_supplier"
|
||||
ref_doc = "Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order"
|
||||
config = _get_inter_company_party_config(doctype)
|
||||
|
||||
if inter_company_reference:
|
||||
doc = frappe.get_doc(ref_doc, inter_company_reference)
|
||||
ref_party = doc.supplier if doctype in ["Sales Invoice", "Sales Order"] else doc.customer
|
||||
if frappe.db.get_value(partytype, {"represents_company": doc.company}, "name") != party:
|
||||
frappe.throw(_("Invalid {0} for Inter Company Transaction.").format(_(partytype)))
|
||||
if frappe.get_cached_value(ref_partytype, ref_party, "represents_company") != company:
|
||||
frappe.throw(_("Invalid Company for Inter Company Transaction."))
|
||||
_validate_against_reference(config, party, company, inter_company_reference)
|
||||
elif frappe.db.get_value(config.partytype, {"name": party, config.internal: 1}, "name") == party:
|
||||
_validate_internal_party_company(config.partytype, party, company)
|
||||
|
||||
elif frappe.db.get_value(partytype, {"name": party, internal: 1}, "name") == party:
|
||||
companies = [
|
||||
d.company
|
||||
for d in frappe.get_all(
|
||||
"Allowed To Transact With",
|
||||
fields=["company"],
|
||||
filters={"parenttype": partytype, "parent": party},
|
||||
)
|
||||
]
|
||||
if company not in companies:
|
||||
frappe.throw(
|
||||
_(
|
||||
"{0} not allowed to transact with {1}. Please change the Company or add the Company in the 'Allowed To Transact With'-Section in the Customer record."
|
||||
).format(_(partytype), company)
|
||||
)
|
||||
|
||||
def _get_inter_company_party_config(doctype: str) -> "frappe._dict":
|
||||
if doctype in ["Sales Invoice", "Sales Order"]:
|
||||
return frappe._dict(
|
||||
partytype="Customer",
|
||||
ref_partytype="Supplier",
|
||||
internal="is_internal_customer",
|
||||
ref_doc="Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order",
|
||||
)
|
||||
return frappe._dict(
|
||||
partytype="Supplier",
|
||||
ref_partytype="Customer",
|
||||
internal="is_internal_supplier",
|
||||
ref_doc="Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order",
|
||||
)
|
||||
|
||||
|
||||
def _validate_against_reference(config, party: str, company: str, inter_company_reference: str) -> None:
|
||||
doc = frappe.get_doc(config.ref_doc, inter_company_reference)
|
||||
ref_party = doc.supplier if config.partytype == "Customer" else doc.customer
|
||||
if frappe.db.get_value(config.partytype, {"represents_company": doc.company}, "name") != party:
|
||||
frappe.throw(_("Invalid {0} for Inter Company Transaction.").format(_(config.partytype)))
|
||||
if frappe.get_cached_value(config.ref_partytype, ref_party, "represents_company") != company:
|
||||
frappe.throw(_("Invalid Company for Inter Company Transaction."))
|
||||
|
||||
|
||||
def _validate_internal_party_company(partytype: str, party: str, company: str) -> None:
|
||||
companies = [
|
||||
d.company
|
||||
for d in frappe.get_all(
|
||||
"Allowed To Transact With",
|
||||
fields=["company"],
|
||||
filters={"parenttype": partytype, "parent": party},
|
||||
)
|
||||
]
|
||||
if company not in companies:
|
||||
frappe.throw(
|
||||
_(
|
||||
"{0} not allowed to transact with {1}. Please change the Company or add the Company in the 'Allowed To Transact With'-Section in the Customer record."
|
||||
).format(_(partytype), company)
|
||||
)
|
||||
|
||||
|
||||
def update_linked_doc(doctype: str, name: str, inter_company_reference: str | None) -> None:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"""POS helpers for Sales Invoice."""
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
from frappe import _
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
|
||||
|
||||
@@ -13,106 +13,140 @@ class PartialPaymentValidationError(frappe.ValidationError):
|
||||
|
||||
|
||||
class POSService:
|
||||
def __init__(self, doc):
|
||||
def __init__(self, doc) -> None:
|
||||
self.doc = doc
|
||||
|
||||
def set_pos_fields(self, for_validate: bool = False) -> frappe.Document | None:
|
||||
"""Populate POS-profile fields on the invoice; return the profile or None."""
|
||||
def set_pos_fields(self, for_validate: bool = False) -> frappe.Document | dict | None:
|
||||
"""Populate POS-profile fields on the invoice; return the profile, {} or None."""
|
||||
doc = self.doc
|
||||
if cint(doc.is_pos) != 1:
|
||||
return None
|
||||
|
||||
self._set_default_change_amount_account()
|
||||
|
||||
if not self._ensure_pos_profile():
|
||||
return None
|
||||
|
||||
pos = frappe.get_doc("POS Profile", doc.pos_profile) if doc.pos_profile else {}
|
||||
if pos:
|
||||
self._apply_pos_profile(pos, for_validate)
|
||||
|
||||
return pos
|
||||
|
||||
def _set_default_change_amount_account(self) -> None:
|
||||
doc = self.doc
|
||||
if not doc.account_for_change_amount:
|
||||
doc.account_for_change_amount = frappe.get_cached_value(
|
||||
"Company", doc.company, "default_cash_account"
|
||||
)
|
||||
|
||||
from erpnext.stock.get_item_details import (
|
||||
ItemDetailsCtx,
|
||||
get_pos_profile,
|
||||
get_pos_profile_item_details_,
|
||||
)
|
||||
def _ensure_pos_profile(self) -> bool:
|
||||
"""Auto-pick a POS Profile for the company; return False if none could be found."""
|
||||
doc = self.doc
|
||||
if doc.pos_profile or doc.flags.ignore_pos_profile:
|
||||
return True
|
||||
|
||||
if not doc.pos_profile and not doc.flags.ignore_pos_profile:
|
||||
pos_profile = get_pos_profile(doc.company) or {}
|
||||
if not pos_profile:
|
||||
return None
|
||||
doc.pos_profile = pos_profile.get("name")
|
||||
from erpnext.stock.get_item_details import get_pos_profile
|
||||
|
||||
pos = {}
|
||||
if doc.pos_profile:
|
||||
pos = frappe.get_doc("POS Profile", doc.pos_profile)
|
||||
pos_profile = get_pos_profile(doc.company) or {}
|
||||
if not pos_profile:
|
||||
return False
|
||||
|
||||
if pos:
|
||||
if not for_validate:
|
||||
update_multi_mode_option(doc, pos)
|
||||
doc.tax_category = pos.get("tax_category")
|
||||
doc.pos_profile = pos_profile.get("name")
|
||||
return True
|
||||
|
||||
if not for_validate and not doc.customer:
|
||||
doc.customer = pos.customer
|
||||
def _apply_pos_profile(self, pos, for_validate: bool) -> None:
|
||||
doc = self.doc
|
||||
if not for_validate:
|
||||
self._apply_editable_pos_defaults(pos)
|
||||
|
||||
if not for_validate:
|
||||
doc.ignore_pricing_rule = pos.ignore_pricing_rule
|
||||
if pos.get("account_for_change_amount"):
|
||||
doc.account_for_change_amount = pos.get("account_for_change_amount")
|
||||
|
||||
if pos.get("account_for_change_amount"):
|
||||
doc.account_for_change_amount = pos.get("account_for_change_amount")
|
||||
self._copy_pos_profile_fields(pos, for_validate)
|
||||
|
||||
for fieldname in (
|
||||
"currency",
|
||||
"letter_head",
|
||||
"tc_name",
|
||||
"company",
|
||||
"select_print_heading",
|
||||
"write_off_account",
|
||||
"taxes_and_charges",
|
||||
"write_off_cost_center",
|
||||
"apply_discount_on",
|
||||
"cost_center",
|
||||
):
|
||||
if (not for_validate) or (for_validate and not doc.get(fieldname)):
|
||||
doc.set(fieldname, pos.get(fieldname))
|
||||
if pos.get("company_address"):
|
||||
doc.company_address = pos.get("company_address")
|
||||
|
||||
if pos.get("company_address"):
|
||||
doc.company_address = pos.get("company_address")
|
||||
self._set_selling_price_list(pos)
|
||||
|
||||
if doc.customer:
|
||||
customer_price_list, customer_group = frappe.get_value(
|
||||
"Customer", doc.customer, ["default_price_list", "customer_group"]
|
||||
)
|
||||
customer_group_price_list = frappe.get_value(
|
||||
"Customer Group", customer_group, "default_price_list"
|
||||
)
|
||||
selling_price_list = (
|
||||
customer_price_list or customer_group_price_list or pos.get("selling_price_list")
|
||||
)
|
||||
else:
|
||||
selling_price_list = pos.get("selling_price_list")
|
||||
if not for_validate:
|
||||
self._set_update_stock_from_profile(pos)
|
||||
|
||||
if selling_price_list:
|
||||
doc.set("selling_price_list", selling_price_list)
|
||||
self._apply_pos_item_defaults(pos, for_validate)
|
||||
self._set_terms_and_taxes(pos)
|
||||
|
||||
if not for_validate:
|
||||
dn_flag = any(d.get("dn_detail") for d in doc.get("items"))
|
||||
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
|
||||
def _apply_editable_pos_defaults(self, pos) -> None:
|
||||
"""Profile defaults the user may override; only applied outside validation."""
|
||||
doc = self.doc
|
||||
update_multi_mode_option(doc, pos)
|
||||
doc.tax_category = pos.get("tax_category")
|
||||
if not doc.customer:
|
||||
doc.customer = pos.customer
|
||||
doc.ignore_pricing_rule = pos.ignore_pricing_rule
|
||||
|
||||
for item in doc.get("items"):
|
||||
if item.get("item_code"):
|
||||
profile_details = get_pos_profile_item_details_(
|
||||
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
|
||||
)
|
||||
for fname, val in profile_details.items():
|
||||
if (not for_validate) or (for_validate and not item.get(fname)):
|
||||
item.set(fname, val)
|
||||
def _copy_pos_profile_fields(self, pos, for_validate: bool) -> None:
|
||||
doc = self.doc
|
||||
for fieldname in (
|
||||
"currency",
|
||||
"letter_head",
|
||||
"tc_name",
|
||||
"company",
|
||||
"select_print_heading",
|
||||
"write_off_account",
|
||||
"taxes_and_charges",
|
||||
"write_off_cost_center",
|
||||
"apply_discount_on",
|
||||
"cost_center",
|
||||
):
|
||||
if (not for_validate) or (for_validate and not doc.get(fieldname)):
|
||||
doc.set(fieldname, pos.get(fieldname))
|
||||
|
||||
if doc.tc_name and not doc.terms:
|
||||
doc.terms = frappe.db.get_value("Terms and Conditions", doc.tc_name, "terms")
|
||||
def _set_selling_price_list(self, pos) -> None:
|
||||
doc = self.doc
|
||||
if doc.customer:
|
||||
customer_price_list, customer_group = frappe.get_value(
|
||||
"Customer", doc.customer, ["default_price_list", "customer_group"]
|
||||
)
|
||||
customer_group_price_list = frappe.get_value(
|
||||
"Customer Group", customer_group, "default_price_list"
|
||||
)
|
||||
selling_price_list = (
|
||||
customer_price_list or customer_group_price_list or pos.get("selling_price_list")
|
||||
)
|
||||
else:
|
||||
selling_price_list = pos.get("selling_price_list")
|
||||
|
||||
if doc.taxes_and_charges and not len(doc.get("taxes")):
|
||||
from erpnext.accounts.services.taxes import TaxService
|
||||
if selling_price_list:
|
||||
doc.set("selling_price_list", selling_price_list)
|
||||
|
||||
TaxService(doc).set_taxes()
|
||||
def _set_update_stock_from_profile(self, pos) -> None:
|
||||
doc = self.doc
|
||||
dn_flag = any(d.get("dn_detail") for d in doc.get("items"))
|
||||
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
|
||||
|
||||
return pos
|
||||
def _apply_pos_item_defaults(self, pos, for_validate: bool) -> None:
|
||||
from erpnext.stock.get_item_details import ItemDetailsCtx, get_pos_profile_item_details_
|
||||
|
||||
for item in self.doc.get("items"):
|
||||
if not item.get("item_code"):
|
||||
continue
|
||||
profile_details = get_pos_profile_item_details_(
|
||||
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
|
||||
)
|
||||
for fname, val in profile_details.items():
|
||||
if (not for_validate) or (for_validate and not item.get(fname)):
|
||||
item.set(fname, val)
|
||||
|
||||
def _set_terms_and_taxes(self, pos) -> None:
|
||||
doc = self.doc
|
||||
if doc.tc_name and not doc.terms:
|
||||
doc.terms = frappe.db.get_value("Terms and Conditions", doc.tc_name, "terms")
|
||||
|
||||
if doc.taxes_and_charges and not len(doc.get("taxes")):
|
||||
from erpnext.accounts.services.taxes import TaxService
|
||||
|
||||
TaxService(doc).set_taxes()
|
||||
|
||||
def update_paid_amount(self) -> None:
|
||||
doc = self.doc
|
||||
@@ -144,6 +178,7 @@ class POSService:
|
||||
doc.paid_amount = 0
|
||||
|
||||
def validate_pos_return(self) -> None:
|
||||
"""Ensure POS return payments are not less than the (negative) invoice total."""
|
||||
doc = self.doc
|
||||
if doc.is_consolidated:
|
||||
return
|
||||
@@ -160,6 +195,7 @@ class POSService:
|
||||
frappe.throw(_("At least one mode of payment is required for POS invoice."))
|
||||
|
||||
def validate_pos(self) -> None:
|
||||
"""On a POS return, paid amount plus write-off cannot exceed the grand total."""
|
||||
doc = self.doc
|
||||
if doc.is_return:
|
||||
invoice_total = doc.rounded_total or doc.grand_total
|
||||
@@ -180,6 +216,7 @@ class POSService:
|
||||
self.validate_pos_opening_entry()
|
||||
|
||||
def validate_full_payment(self) -> None:
|
||||
"""Block partial payment on a submitted POS invoice unless the profile allows it."""
|
||||
doc = self.doc
|
||||
allow_partial_payment = frappe.db.get_value("POS Profile", doc.pos_profile, "allow_partial_payment")
|
||||
invoice_total = flt(doc.rounded_total) or flt(doc.grand_total)
|
||||
@@ -196,6 +233,7 @@ class POSService:
|
||||
)
|
||||
|
||||
def validate_pos_opening_entry(self) -> None:
|
||||
"""Require exactly one current, open POS Opening Entry for the profile."""
|
||||
doc = self.doc
|
||||
opening_entries = frappe.get_all(
|
||||
"POS Opening Entry",
|
||||
@@ -281,38 +319,6 @@ class POSService:
|
||||
if entry.amount > 0:
|
||||
frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx))
|
||||
|
||||
def get_warehouse(self) -> str | None:
|
||||
doc = self.doc
|
||||
POSProfile = frappe.qb.DocType("POS Profile")
|
||||
|
||||
user_query = (
|
||||
frappe.qb.from_(POSProfile)
|
||||
.select(POSProfile.name, POSProfile.warehouse)
|
||||
.where(POSProfile.company == doc.company)
|
||||
.where(
|
||||
(POSProfile.user == frappe.session["user"])
|
||||
| ((POSProfile.user.isnull() | (POSProfile.user == "")) & (frappe.session["user"] == ""))
|
||||
)
|
||||
)
|
||||
user_pos_profile = user_query.run()
|
||||
warehouse = user_pos_profile[0][1] if user_pos_profile else None
|
||||
|
||||
if not warehouse:
|
||||
global_query = (
|
||||
frappe.qb.from_(POSProfile)
|
||||
.select(POSProfile.name, POSProfile.warehouse)
|
||||
.where(POSProfile.company == doc.company)
|
||||
.where(POSProfile.user.isnull() | (POSProfile.user == ""))
|
||||
)
|
||||
global_pos_profile = global_query.run()
|
||||
|
||||
if global_pos_profile:
|
||||
warehouse = global_pos_profile[0][1]
|
||||
elif not user_pos_profile:
|
||||
msgprint(_("POS Profile required to make POS Entry"), raise_exception=True)
|
||||
|
||||
return warehouse
|
||||
|
||||
|
||||
def get_bank_cash_account(mode_of_payment: str, company: str) -> dict:
|
||||
account = frappe.db.get_value(
|
||||
@@ -369,61 +375,43 @@ def update_multi_mode_option(doc, pos_profile) -> None:
|
||||
|
||||
|
||||
def get_all_mode_of_payments(doc) -> list:
|
||||
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
|
||||
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(ModeOfPaymentAccount)
|
||||
.join(ModeOfPayment)
|
||||
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
|
||||
.select(
|
||||
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
|
||||
)
|
||||
.where(ModeOfPaymentAccount.company == doc.company)
|
||||
.where(ModeOfPayment.enabled == 1)
|
||||
)
|
||||
|
||||
return query.run(as_dict=1)
|
||||
"""All enabled modes of payment with their default accounts for the doc's company."""
|
||||
query, mopa, mop = _enabled_mode_of_payment_query(doc.company)
|
||||
return query.select(mopa.default_account, mopa.parent, mop.type.as_("type")).run(as_dict=1)
|
||||
|
||||
|
||||
def get_mode_of_payments_info(mode_of_payments: list, company: str) -> dict:
|
||||
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
|
||||
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(ModeOfPaymentAccount)
|
||||
.join(ModeOfPayment)
|
||||
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
|
||||
.select(
|
||||
ModeOfPaymentAccount.default_account,
|
||||
ModeOfPaymentAccount.parent.as_("mop"),
|
||||
ModeOfPayment.type.as_("type"),
|
||||
)
|
||||
.where(ModeOfPaymentAccount.company == company)
|
||||
.where(ModeOfPayment.enabled == 1)
|
||||
.where(ModeOfPayment.name.isin(mode_of_payments))
|
||||
.groupby(ModeOfPayment.name)
|
||||
"""Map each of the named modes of payment to its account info for the company."""
|
||||
query, mopa, mop = _enabled_mode_of_payment_query(company)
|
||||
data = (
|
||||
query.select(mopa.default_account, mopa.parent.as_("mop"), mop.type.as_("type"))
|
||||
.where(mop.name.isin(mode_of_payments))
|
||||
# group by all selected columns so postgres accepts it (one row per mode of payment)
|
||||
.groupby(mopa.default_account, mopa.parent, mop.type)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
data = query.run(as_dict=1)
|
||||
|
||||
return {row.get("mop"): row for row in data}
|
||||
|
||||
|
||||
def get_mode_of_payment_info(mode_of_payment: str, company: str) -> list:
|
||||
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
|
||||
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(ModeOfPayment)
|
||||
.join(ModeOfPaymentAccount)
|
||||
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
|
||||
.select(
|
||||
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
|
||||
)
|
||||
.where(ModeOfPaymentAccount.company == company)
|
||||
.where(ModeOfPayment.enabled == 1)
|
||||
.where(ModeOfPayment.name == mode_of_payment)
|
||||
"""Account info for a single mode of payment in the company."""
|
||||
query, mopa, mop = _enabled_mode_of_payment_query(company)
|
||||
return (
|
||||
query.select(mopa.default_account, mopa.parent, mop.type.as_("type"))
|
||||
.where(mop.name == mode_of_payment)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
return query.run(as_dict=1)
|
||||
|
||||
def _enabled_mode_of_payment_query(company: str):
|
||||
"""Base query joining enabled modes of payment to their accounts for a company."""
|
||||
mopa = frappe.qb.DocType("Mode of Payment Account")
|
||||
mop = frappe.qb.DocType("Mode of Payment")
|
||||
query = (
|
||||
frappe.qb.from_(mopa)
|
||||
.join(mop)
|
||||
.on(mopa.parent == mop.name)
|
||||
.where(mopa.company == company)
|
||||
.where(mop.enabled == 1)
|
||||
)
|
||||
return query, mopa, mop
|
||||
|
||||
@@ -21,45 +21,52 @@ class StatusService:
|
||||
doc.status = "Draft"
|
||||
return
|
||||
|
||||
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
|
||||
total = get_total_in_party_account_currency(doc)
|
||||
|
||||
if not status:
|
||||
if doc.docstatus == 2:
|
||||
status = "Cancelled"
|
||||
elif doc.docstatus == 1:
|
||||
if doc.is_internal_transfer():
|
||||
doc.status = "Internal Transfer"
|
||||
elif is_overdue(doc, total):
|
||||
doc.status = "Overdue"
|
||||
elif 0 < outstanding_amount < total:
|
||||
doc.status = "Partly Paid"
|
||||
elif outstanding_amount > 0 and getdate(doc.due_date) >= getdate():
|
||||
doc.status = "Unpaid"
|
||||
elif doc.is_return == 0 and frappe.db.get_value(
|
||||
"Sales Invoice", {"is_return": 1, "return_against": doc.name, "docstatus": 1}
|
||||
):
|
||||
doc.status = "Credit Note Issued"
|
||||
elif doc.is_return == 1:
|
||||
doc.status = "Return"
|
||||
elif outstanding_amount <= 0:
|
||||
doc.status = "Paid"
|
||||
else:
|
||||
doc.status = "Submitted"
|
||||
|
||||
if (
|
||||
doc.status in ("Unpaid", "Partly Paid", "Overdue")
|
||||
and doc.is_discounted
|
||||
and get_discounting_status(doc.name) == "Disbursed"
|
||||
):
|
||||
doc.status += " and Discounted"
|
||||
|
||||
doc.status = self._get_submitted_status()
|
||||
else:
|
||||
doc.status = "Draft"
|
||||
|
||||
if update:
|
||||
doc.db_set("status", doc.status, update_modified=update_modified)
|
||||
|
||||
def _get_submitted_status(self) -> str:
|
||||
"""Status of a submitted invoice, with the invoice-discounting suffix applied."""
|
||||
doc = self.doc
|
||||
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
|
||||
total = get_total_in_party_account_currency(doc)
|
||||
|
||||
status = self._get_payment_status(outstanding_amount, total)
|
||||
if (
|
||||
status in ("Unpaid", "Partly Paid", "Overdue")
|
||||
and doc.is_discounted
|
||||
and get_discounting_status(doc.name) == "Disbursed"
|
||||
):
|
||||
status += " and Discounted"
|
||||
return status
|
||||
|
||||
def _get_payment_status(self, outstanding_amount: float, total: float) -> str:
|
||||
doc = self.doc
|
||||
if doc.is_internal_transfer():
|
||||
return "Internal Transfer"
|
||||
if is_overdue(doc, total):
|
||||
return "Overdue"
|
||||
if 0 < outstanding_amount < total:
|
||||
return "Partly Paid"
|
||||
if outstanding_amount > 0 and getdate(doc.due_date) >= getdate():
|
||||
return "Unpaid"
|
||||
if doc.is_return == 0 and frappe.db.get_value(
|
||||
"Sales Invoice", {"is_return": 1, "return_against": doc.name, "docstatus": 1}
|
||||
):
|
||||
return "Credit Note Issued"
|
||||
if doc.is_return == 1:
|
||||
return "Return"
|
||||
if outstanding_amount <= 0:
|
||||
return "Paid"
|
||||
return "Submitted"
|
||||
|
||||
def set_indicator(self) -> None:
|
||||
doc = self.doc
|
||||
if doc.outstanding_amount < 0:
|
||||
|
||||
@@ -99,23 +99,24 @@ class TimesheetBillingService:
|
||||
doc.total_billing_hours = sum(flt(ts.billing_hours) for ts in doc.timesheets)
|
||||
|
||||
def _update_time_sheet_detail(self, timesheet, args, sales_invoice: str | None) -> None:
|
||||
doc = self.doc
|
||||
for data in timesheet.time_logs:
|
||||
if (
|
||||
(doc.project and args.timesheet_detail == data.name)
|
||||
or (not doc.project and not data.sales_invoice and args.timesheet_detail == data.name)
|
||||
or (
|
||||
not sales_invoice
|
||||
and data.sales_invoice == doc.name
|
||||
and args.timesheet_detail == data.name
|
||||
)
|
||||
or (
|
||||
doc.is_return
|
||||
and doc.return_against
|
||||
and data.sales_invoice
|
||||
and data.sales_invoice == doc.return_against
|
||||
and not sales_invoice
|
||||
and args.timesheet_detail == data.name
|
||||
)
|
||||
):
|
||||
if args.timesheet_detail == data.name and self._should_set_sales_invoice(data, sales_invoice):
|
||||
data.sales_invoice = sales_invoice
|
||||
|
||||
def _should_set_sales_invoice(self, time_log, sales_invoice: str | None) -> bool:
|
||||
"""Whether this time log's sales-invoice link should be (re)set to sales_invoice."""
|
||||
doc = self.doc
|
||||
if doc.project:
|
||||
return True
|
||||
if not time_log.sales_invoice:
|
||||
return True
|
||||
if not sales_invoice and time_log.sales_invoice == doc.name:
|
||||
# clearing the link on cancellation of this invoice
|
||||
return True
|
||||
# clearing the link on a return raised against the original invoice
|
||||
return bool(
|
||||
doc.is_return
|
||||
and doc.return_against
|
||||
and not sales_invoice
|
||||
and time_log.sales_invoice == doc.return_against
|
||||
)
|
||||
|
||||
@@ -20,6 +20,12 @@ from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
|
||||
unlink_payment_on_cancel_of_invoice,
|
||||
)
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction
|
||||
from erpnext.accounts.doctype.sales_invoice.services.pos import (
|
||||
POSService,
|
||||
get_all_mode_of_payments,
|
||||
get_mode_of_payment_info,
|
||||
get_mode_of_payments_info,
|
||||
)
|
||||
from erpnext.accounts.utils import PaymentEntryUnlinkError
|
||||
from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
|
||||
from erpnext.assets.doctype.asset.test_asset import create_asset
|
||||
@@ -739,12 +745,11 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, debit, credit
|
||||
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
|
||||
order by account asc""",
|
||||
si.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
@@ -775,10 +780,10 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
# cancel
|
||||
si.cancel()
|
||||
|
||||
gle = frappe.db.sql(
|
||||
"""select * from `tabGL Entry`
|
||||
where voucher_type='Sales Invoice' and voucher_no=%s""",
|
||||
si.name,
|
||||
gle = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
|
||||
fields=["*"],
|
||||
)
|
||||
|
||||
self.assertTrue(gle)
|
||||
@@ -1195,12 +1200,11 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, debit, credit
|
||||
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
|
||||
order by account asc""",
|
||||
si.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
@@ -1223,10 +1227,10 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
# cancel
|
||||
si.cancel()
|
||||
|
||||
gle = frappe.db.sql(
|
||||
"""select * from `tabGL Entry`
|
||||
where voucher_type='Sales Invoice' and voucher_no=%s""",
|
||||
si.name,
|
||||
gle = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
|
||||
fields=["*"],
|
||||
)
|
||||
|
||||
self.assertTrue(gle)
|
||||
@@ -1346,6 +1350,101 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(pos.grand_total, 100.0)
|
||||
self.assertEqual(pos.write_off_amount, 0)
|
||||
|
||||
def test_set_pos_fields_populates_invoice_from_profile(self):
|
||||
terms = frappe.db.exists("Terms and Conditions", "_Test POS Terms")
|
||||
if not terms:
|
||||
terms = (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Terms and Conditions",
|
||||
"title": "_Test POS Terms",
|
||||
"terms": "POS terms and conditions",
|
||||
"selling": 1,
|
||||
}
|
||||
)
|
||||
.insert()
|
||||
.name
|
||||
)
|
||||
|
||||
profile = make_pos_profile()
|
||||
profile.customer = "_Test Customer"
|
||||
profile.tax_category = "_Test Tax Category 1"
|
||||
profile.account_for_change_amount = "Cash - _TC"
|
||||
profile.ignore_pricing_rule = 1
|
||||
profile.update_stock = 1
|
||||
profile.apply_discount_on = "Grand Total"
|
||||
profile.tc_name = terms
|
||||
profile.taxes_and_charges = "_Test Sales Taxes and Charges Template - _TC"
|
||||
profile.save()
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.is_pos = 1
|
||||
si.pos_profile = profile.name
|
||||
si.customer = None
|
||||
si.taxes = []
|
||||
|
||||
POSService(si).set_pos_fields(for_validate=False)
|
||||
|
||||
self.assertEqual(si.customer, "_Test Customer")
|
||||
self.assertEqual(si.tax_category, "_Test Tax Category 1")
|
||||
self.assertEqual(si.ignore_pricing_rule, 1)
|
||||
self.assertEqual(si.account_for_change_amount, "Cash - _TC")
|
||||
self.assertEqual(si.taxes_and_charges, "_Test Sales Taxes and Charges Template - _TC")
|
||||
self.assertEqual(si.apply_discount_on, "Grand Total")
|
||||
self.assertEqual(si.update_stock, 1)
|
||||
self.assertEqual(si.terms, "POS terms and conditions")
|
||||
self.assertTrue(si.get("payments"))
|
||||
self.assertTrue(si.get("taxes"))
|
||||
|
||||
def test_set_pos_fields_for_validate_preserves_existing_values(self):
|
||||
profile = make_pos_profile()
|
||||
profile.tax_category = "_Test Tax Category 1"
|
||||
profile.save()
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.is_pos = 1
|
||||
si.pos_profile = profile.name
|
||||
si.apply_discount_on = "Net Total"
|
||||
existing_customer = si.customer
|
||||
|
||||
POSService(si).set_pos_fields(for_validate=True)
|
||||
|
||||
# for_validate must not overwrite a field the user already set
|
||||
self.assertEqual(si.apply_discount_on, "Net Total")
|
||||
# for_validate skips mode-of-payment fetch and profile-driven customer/tax_category
|
||||
self.assertFalse(si.get("payments"))
|
||||
self.assertEqual(si.customer, existing_customer)
|
||||
self.assertFalse(si.tax_category)
|
||||
|
||||
def test_set_pos_fields_uses_profile_price_list_without_customer(self):
|
||||
profile = make_pos_profile(selling_price_list="_Test Price List")
|
||||
profile.customer = None
|
||||
profile.save()
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.is_pos = 1
|
||||
si.pos_profile = profile.name
|
||||
si.customer = None
|
||||
|
||||
POSService(si).set_pos_fields(for_validate=False)
|
||||
|
||||
self.assertEqual(si.selling_price_list, "_Test Price List")
|
||||
|
||||
def test_pos_service_mode_of_payment_queries(self):
|
||||
make_pos_profile() # ensures a Cash mode-of-payment account for _Test Company
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
|
||||
single = get_mode_of_payment_info("Cash", "_Test Company")
|
||||
self.assertTrue(single)
|
||||
self.assertEqual(single[0].parent, "Cash")
|
||||
|
||||
all_modes = get_all_mode_of_payments(si)
|
||||
self.assertTrue(any(row.parent == "Cash" for row in all_modes))
|
||||
|
||||
grouped = get_mode_of_payments_info(["Cash"], "_Test Company")
|
||||
self.assertIn("Cash", grouped)
|
||||
self.assertEqual(grouped["Cash"].mop, "Cash")
|
||||
|
||||
def test_auto_write_off_amount(self):
|
||||
make_pos_profile(
|
||||
company="_Test Company with perpetual inventory",
|
||||
@@ -1476,16 +1575,84 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
|
||||
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
|
||||
|
||||
def test_stock_delivered_but_not_billed_gl_on_invoice(self):
|
||||
company = "_Test Company with perpetual inventory"
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
make_purchase_receipt(
|
||||
company=company,
|
||||
item_code="_Test FG Item",
|
||||
warehouse="Stores - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
qty=5,
|
||||
rate=100,
|
||||
)
|
||||
|
||||
dn = create_delivery_note(
|
||||
company=company,
|
||||
item_code="_Test FG Item",
|
||||
warehouse="Stores - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
qty=2,
|
||||
rate=300,
|
||||
)
|
||||
# A perpetual-inventory Delivery Note books the cost to the SDBNB account
|
||||
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - TCP1")
|
||||
|
||||
si = make_sales_invoice(dn.name)
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": si.name, "is_cancelled": 0},
|
||||
fields=["account", "debit", "credit"],
|
||||
)
|
||||
sdbnb_credit = sum(
|
||||
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - TCP1"
|
||||
)
|
||||
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - TCP1")
|
||||
|
||||
# Billing reverses SDBNB and recognises the cost in COGS for an equal amount
|
||||
self.assertTrue(sdbnb_credit > 0)
|
||||
self.assertEqual(sdbnb_credit, cogs_debit)
|
||||
|
||||
def test_get_gle_for_change_amount(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.services.gl_composer import SalesInvoiceGLComposer
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.is_pos = 1
|
||||
si.party_account_currency = "INR"
|
||||
|
||||
# no change amount -> no entries
|
||||
si.change_amount = 0
|
||||
self.assertEqual(SalesInvoiceGLComposer(si).get_gle_for_change_amount(), [])
|
||||
|
||||
# change amount without an account -> mandatory error
|
||||
si.change_amount = 10
|
||||
si.base_change_amount = 10
|
||||
si.account_for_change_amount = None
|
||||
self.assertRaises(frappe.ValidationError, SalesInvoiceGLComposer(si).get_gle_for_change_amount)
|
||||
|
||||
# change amount with an account -> debit-to debited, change account credited
|
||||
si.account_for_change_amount = "Cash - _TC"
|
||||
entries = SalesInvoiceGLComposer(si).get_gle_for_change_amount()
|
||||
self.assertEqual(len(entries), 2)
|
||||
debit_entry = next(entry for entry in entries if entry["account"] == si.debit_to)
|
||||
credit_entry = next(entry for entry in entries if entry["account"] == "Cash - _TC")
|
||||
self.assertEqual(debit_entry["party"], si.customer)
|
||||
self.assertEqual(flt(debit_entry["debit"]), 10.0)
|
||||
self.assertEqual(flt(credit_entry["credit"]), 10.0)
|
||||
|
||||
def validate_pos_gl_entry(self, si, pos, cash_amount, validate_without_change_gle=False):
|
||||
if validate_without_change_gle:
|
||||
cash_amount -= pos.change_amount
|
||||
|
||||
# check stock ledger entries
|
||||
sle = frappe.db.sql(
|
||||
"""select * from `tabStock Ledger Entry`
|
||||
where voucher_type = 'Sales Invoice' and voucher_no = %s""",
|
||||
si.name,
|
||||
as_dict=1,
|
||||
sle = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
|
||||
fields=["*"],
|
||||
)[0]
|
||||
self.assertTrue(sle)
|
||||
self.assertEqual(
|
||||
@@ -1493,12 +1660,11 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
# check gl entries
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, debit, credit
|
||||
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
|
||||
order by account asc, debit asc, credit asc""",
|
||||
si.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
order_by="account asc, debit asc, credit asc",
|
||||
)
|
||||
self.assertTrue(gl_entries)
|
||||
|
||||
@@ -1525,15 +1691,15 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(expected_gl_entries[i][2], gle.credit)
|
||||
|
||||
si.cancel()
|
||||
gle = frappe.db.sql(
|
||||
"""select * from `tabGL Entry`
|
||||
where voucher_type='Sales Invoice' and voucher_no=%s""",
|
||||
si.name,
|
||||
gle = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
|
||||
fields=["*"],
|
||||
)
|
||||
|
||||
self.assertTrue(gle)
|
||||
|
||||
frappe.db.sql("delete from `tabPOS Profile`")
|
||||
frappe.db.delete("POS Profile")
|
||||
|
||||
def test_bin_details_of_packed_item(self):
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
@@ -1600,12 +1766,11 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, debit, credit
|
||||
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
|
||||
order by account asc""",
|
||||
si.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
order_by="account asc",
|
||||
)
|
||||
self.assertTrue(gl_entries)
|
||||
|
||||
@@ -1620,12 +1785,11 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
def test_sales_invoice_gl_entry_with_perpetual_inventory_non_stock_item(self):
|
||||
si = create_sales_invoice(item="_Test Non Stock Item")
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, debit, credit
|
||||
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
|
||||
order by account asc""",
|
||||
si.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
order_by="account asc",
|
||||
)
|
||||
self.assertTrue(gl_entries)
|
||||
|
||||
@@ -1679,18 +1843,18 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
si.load_from_db()
|
||||
|
||||
self.assertTrue(
|
||||
frappe.db.sql(
|
||||
"""select name from `tabJournal Entry Account`
|
||||
where reference_name=%s""",
|
||||
si.name,
|
||||
frappe.get_all(
|
||||
"Journal Entry Account",
|
||||
filters={"reference_name": si.name},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
frappe.db.sql(
|
||||
"""select name from `tabJournal Entry Account`
|
||||
where reference_name=%s and credit_in_account_currency=300""",
|
||||
si.name,
|
||||
frappe.get_all(
|
||||
"Journal Entry Account",
|
||||
filters={"reference_name": si.name, "credit_in_account_currency": 300},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2002,13 +2166,18 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
conversion_rate=50,
|
||||
)
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, account_currency, debit, credit,
|
||||
debit_in_account_currency, credit_in_account_currency
|
||||
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
|
||||
order by account asc""",
|
||||
si.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
|
||||
fields=[
|
||||
"account",
|
||||
"account_currency",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
@@ -2043,10 +2212,10 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
# cancel
|
||||
si.cancel()
|
||||
|
||||
gle = frappe.db.sql(
|
||||
"""select name from `tabGL Entry`
|
||||
where voucher_type='Sales Invoice' and voucher_no=%s""",
|
||||
si.name,
|
||||
gle = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
self.assertTrue(gle)
|
||||
@@ -2073,14 +2242,16 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
)
|
||||
si.submit()
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select transaction_currency, transaction_exchange_rate,
|
||||
debit_in_transaction_currency, credit_in_transaction_currency
|
||||
from `tabGL Entry`
|
||||
where voucher_type='Sales Invoice' and voucher_no=%s and account = 'Sales - _TC'
|
||||
order by account asc""",
|
||||
si.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name, "account": "Sales - _TC"},
|
||||
fields=[
|
||||
"transaction_currency",
|
||||
"transaction_exchange_rate",
|
||||
"debit_in_transaction_currency",
|
||||
"credit_in_transaction_currency",
|
||||
],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
expected_gle = {
|
||||
@@ -2425,12 +2596,11 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
]
|
||||
)
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, debit, credit
|
||||
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
|
||||
order by account asc""",
|
||||
si.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
for gle in gl_entries:
|
||||
@@ -2482,13 +2652,12 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
"Sales - _TC": [0.0, 1272.20],
|
||||
}
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, sum(debit) as debit, sum(credit) as credit
|
||||
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
|
||||
group by account
|
||||
order by account asc""",
|
||||
si.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
|
||||
fields=["account", {"SUM": "debit", "as": "debit"}, {"SUM": "credit", "as": "credit"}],
|
||||
group_by="account",
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
for gle in gl_entries:
|
||||
@@ -2549,13 +2718,12 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
]
|
||||
)
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, sum(debit) as debit, sum(credit) as credit
|
||||
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
|
||||
group by account
|
||||
order by account asc""",
|
||||
si.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
|
||||
fields=["account", {"SUM": "debit", "as": "debit"}, {"SUM": "credit", "as": "credit"}],
|
||||
group_by="account",
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
debit_credit_diff = 0
|
||||
@@ -2565,7 +2733,9 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(expected_values[gle.account][2], gle.credit)
|
||||
debit_credit_diff += gle.debit - gle.credit
|
||||
|
||||
self.assertEqual(debit_credit_diff, 0)
|
||||
# Postgres returns DECIMAL columns as float (DEC2FLOAT), so a debit-credit sum carries a
|
||||
# tiny FP residue where MariaDB's DECIMAL arithmetic is exact; assert it's ~0.
|
||||
self.assertAlmostEqual(debit_credit_diff, 0)
|
||||
|
||||
round_off_gle = frappe.db.get_value(
|
||||
"GL Entry",
|
||||
@@ -2649,13 +2819,19 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
"Sales - _TC": {"cost_center": cost_center},
|
||||
}
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, cost_center, account_currency, debit, credit,
|
||||
debit_in_account_currency, credit_in_account_currency
|
||||
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
|
||||
order by account asc""",
|
||||
si.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
|
||||
fields=[
|
||||
"account",
|
||||
"cost_center",
|
||||
"account_currency",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
@@ -2692,13 +2868,20 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
"Sales - _TC": {"project": item_project.name},
|
||||
}
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, cost_center, project, account_currency, debit, credit,
|
||||
debit_in_account_currency, credit_in_account_currency
|
||||
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
|
||||
order by account asc""",
|
||||
sales_invoice.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": sales_invoice.name},
|
||||
fields=[
|
||||
"account",
|
||||
"cost_center",
|
||||
"project",
|
||||
"account_currency",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
@@ -2715,13 +2898,19 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
"Sales - _TC": {"cost_center": cost_center},
|
||||
}
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, cost_center, account_currency, debit, credit,
|
||||
debit_in_account_currency, credit_in_account_currency
|
||||
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
|
||||
order by account asc""",
|
||||
si.name,
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
|
||||
fields=[
|
||||
"account",
|
||||
"cost_center",
|
||||
"account_currency",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
],
|
||||
order_by="account asc",
|
||||
)
|
||||
|
||||
self.assertTrue(gl_entries)
|
||||
@@ -3497,6 +3686,49 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(expected_values[i][2], schedule.accumulated_depreciation_amount)
|
||||
self.assertTrue(schedule.journal_entry)
|
||||
|
||||
def test_fixed_asset_sale_validations(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.services.fixed_assets import FixedAssetService
|
||||
|
||||
asset = create_asset(item_code="Macbook Pro", calculate_depreciation=0, submit=1)
|
||||
|
||||
def asset_invoice(asset_name, **kwargs):
|
||||
si = create_sales_invoice(
|
||||
item_code="Macbook Pro", asset=asset_name, qty=1, rate=90000, do_not_save=True, **kwargs
|
||||
)
|
||||
si.items[0].is_fixed_asset = 1
|
||||
return si
|
||||
|
||||
with self.subTest("item without an asset is rejected"):
|
||||
si = asset_invoice(None)
|
||||
self.assertRaises(frappe.ValidationError, FixedAssetService(si).validate_fixed_asset)
|
||||
|
||||
with self.subTest("update stock on an asset sale is rejected"):
|
||||
si = asset_invoice(asset.name, update_stock=1)
|
||||
self.assertRaises(frappe.ValidationError, FixedAssetService(si).validate_fixed_asset)
|
||||
|
||||
with self.subTest("return without return-against is rejected"):
|
||||
si = asset_invoice(asset.name, is_return=1)
|
||||
self.assertRaises(frappe.ValidationError, FixedAssetService(si).validate_fixed_asset)
|
||||
|
||||
for bad_status in ("Sold", "Scrapped", "Cancelled", "Capitalized"):
|
||||
with self.subTest(f"selling a {bad_status} asset is rejected"):
|
||||
frappe.db.set_value("Asset", asset.name, "status", bad_status)
|
||||
si = asset_invoice(asset.name)
|
||||
self.assertRaises(frappe.ValidationError, FixedAssetService(si).validate_fixed_asset)
|
||||
frappe.db.set_value("Asset", asset.name, "status", "Submitted")
|
||||
|
||||
def test_fixed_asset_restore_note_text(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.services.fixed_assets import FixedAssetService
|
||||
|
||||
asset = frappe._dict(doctype="Asset", name="_Test Asset For Note")
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
|
||||
si.is_return = 1
|
||||
self.assertIn("returned", FixedAssetService(si)._get_note_for_asset_return(asset))
|
||||
|
||||
si.is_return = 0
|
||||
self.assertIn("restored", FixedAssetService(si)._get_note_for_asset_return(asset))
|
||||
|
||||
def test_sales_invoice_against_supplier(self):
|
||||
from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
|
||||
make_customer,
|
||||
@@ -3710,6 +3942,27 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
|
||||
party_link.delete()
|
||||
|
||||
def test_status_indicator(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.services.status import StatusService
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
cases = [
|
||||
# outstanding, due_date, is_return -> indicator color, title
|
||||
(-50, nowdate(), 0, "gray", "Credit Note Issued"),
|
||||
(100, add_days(nowdate(), 5), 0, "orange", "Unpaid"),
|
||||
(100, add_days(nowdate(), -5), 0, "red", "Overdue"),
|
||||
(0, nowdate(), 1, "gray", "Return"),
|
||||
(0, nowdate(), 0, "green", "Paid"),
|
||||
]
|
||||
for outstanding, due_date, is_return, color, title in cases:
|
||||
with self.subTest(title=title):
|
||||
si.outstanding_amount = outstanding
|
||||
si.due_date = due_date
|
||||
si.is_return = is_return
|
||||
StatusService(si).set_indicator()
|
||||
self.assertEqual(si.indicator_color, color)
|
||||
self.assertEqual(si.indicator_title, title)
|
||||
|
||||
def test_payment_statuses(self):
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
|
||||
|
||||
@@ -3873,6 +4126,51 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertIn("cannot overbill", str(err.exception).lower())
|
||||
dn.cancel()
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
|
||||
def test_non_stock_item_over_billing_against_so_is_blocked(self):
|
||||
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice as make_si_from_so
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
service_item = create_item(
|
||||
"_Test Service Item Non Stock SI",
|
||||
is_stock_item=0,
|
||||
).name
|
||||
|
||||
so = make_sales_order(item_code=service_item, qty=5, rate=100)
|
||||
so.submit()
|
||||
|
||||
si = make_si_from_so(so.name)
|
||||
si.items[0].qty = 10 # overbill by 100 %
|
||||
si.save()
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
si.submit()
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
|
||||
def test_non_stock_item_over_billing_against_so_from_quotation_is_blocked(self):
|
||||
from erpnext.selling.doctype.quotation.mapper import make_sales_order as make_so_from_quotation
|
||||
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
|
||||
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice as make_si_from_so
|
||||
|
||||
service_item = create_item(
|
||||
"_Test Service Item Non Stock SI Quot",
|
||||
is_stock_item=0,
|
||||
).name
|
||||
|
||||
quotation = make_quotation(item_code=service_item, qty=5, rate=100)
|
||||
|
||||
so = make_so_from_quotation(quotation.name)
|
||||
so.delivery_date = frappe.utils.add_days(frappe.utils.today(), 7)
|
||||
so.insert()
|
||||
so.submit()
|
||||
|
||||
si = make_si_from_so(so.name)
|
||||
si.items[0].qty = 10 # overbill by 100 %
|
||||
si.save()
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
si.submit()
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings",
|
||||
{
|
||||
@@ -3935,13 +4233,15 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
[deferred_account, 2022.47, 0.0, "2019-03-15"],
|
||||
]
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, debit, credit, posting_date
|
||||
from `tabGL Entry`
|
||||
where voucher_type='Journal Entry' and voucher_detail_no=%s and posting_date <= %s
|
||||
order by posting_date asc, account asc""",
|
||||
(si.items[0].name, si.posting_date),
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"voucher_type": "Journal Entry",
|
||||
"voucher_detail_no": si.items[0].name,
|
||||
"posting_date": ["<=", si.posting_date],
|
||||
},
|
||||
fields=["account", "debit", "credit", "posting_date"],
|
||||
order_by="posting_date asc, account asc",
|
||||
)
|
||||
|
||||
for i, gle in enumerate(gl_entries):
|
||||
@@ -4583,7 +4883,8 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
{"account": "Temporary Opening - _TC", "debit": 0.0, "credit": 138.09, "is_opening": "Yes"},
|
||||
]
|
||||
self.assertEqual(len(actual), 4)
|
||||
self.assertEqual(expected, actual)
|
||||
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
|
||||
self.assertCountEqual(actual, expected)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"enable_common_party_accounting": True})
|
||||
def test_common_party_with_foreign_currency_jv(self):
|
||||
@@ -4888,7 +5189,7 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
|
||||
def test_pos_sales_invoice_creation_during_pos_invoice_mode(self):
|
||||
# Deleting all opening entry
|
||||
frappe.db.sql("delete from `tabPOS Opening Entry`")
|
||||
frappe.db.delete("POS Opening Entry")
|
||||
|
||||
with self.change_settings("POS Settings", {"invoice_type": "POS Invoice"}):
|
||||
pos_profile = make_pos_profile()
|
||||
@@ -5258,6 +5559,11 @@ def check_gl_entries(doc, voucher_no, expected_gle, posting_date, voucher_type="
|
||||
|
||||
doc.assertGreater(len(gl_entries), 0)
|
||||
|
||||
# MariaDB and Postgres collate `account` differently, so the DB ordering isn't portable;
|
||||
# sort both sides identically (by the compared values) before the positional check.
|
||||
gl_entries = sorted(gl_entries, key=lambda g: (g.account, g.debit, g.credit))
|
||||
expected_gle = sorted(expected_gle, key=lambda e: (e[0], e[1], e[2]))
|
||||
|
||||
for i, gle in enumerate(gl_entries):
|
||||
doc.assertEqual(expected_gle[i][0], gle.account)
|
||||
doc.assertEqual(expected_gle[i][1], gle.debit)
|
||||
@@ -5405,17 +5711,21 @@ def create_sales_invoice_against_cost_center(**args):
|
||||
|
||||
|
||||
def get_outstanding_amount(against_voucher_type, against_voucher, account, party, party_type):
|
||||
bal = flt(
|
||||
frappe.db.sql(
|
||||
"""
|
||||
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
|
||||
from `tabGL Entry`
|
||||
where against_voucher_type=%s and against_voucher=%s
|
||||
and account = %s and party = %s and party_type = %s""",
|
||||
(against_voucher_type, against_voucher, account, party, party_type),
|
||||
)[0][0]
|
||||
or 0.0
|
||||
balance = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"against_voucher_type": against_voucher_type,
|
||||
"against_voucher": against_voucher,
|
||||
"account": account,
|
||||
"party": party,
|
||||
"party_type": party_type,
|
||||
},
|
||||
fields=[
|
||||
{"SUM": "debit_in_account_currency", "as": "debit"},
|
||||
{"SUM": "credit_in_account_currency", "as": "credit"},
|
||||
],
|
||||
)
|
||||
bal = flt(balance[0].debit) - flt(balance[0].credit)
|
||||
|
||||
if against_voucher_type == "Purchase Invoice":
|
||||
bal = bal * -1
|
||||
|
||||
@@ -56,11 +56,14 @@ def valdiate_taxes_and_charges_template(doc):
|
||||
# doc.is_default = 1
|
||||
|
||||
if doc.is_default == 1:
|
||||
frappe.db.sql(
|
||||
f"""update `tab{doc.doctype}` set is_default = 0
|
||||
where is_default = 1 and name != %s and company = %s""",
|
||||
(doc.name, doc.company),
|
||||
)
|
||||
template = frappe.qb.DocType(doc.doctype)
|
||||
(
|
||||
frappe.qb.update(template)
|
||||
.set(template.is_default, 0)
|
||||
.where(
|
||||
(template.is_default == 1) & (template.name != doc.name) & (template.company == doc.company)
|
||||
)
|
||||
).run()
|
||||
|
||||
validate_disabled(doc)
|
||||
|
||||
|
||||
@@ -424,7 +424,7 @@ class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.disable_advance_as_liability()
|
||||
|
||||
def test_07_adv_from_so_to_invoice(self):
|
||||
frappe.db.set_value("Company", self.company, "book_advance_payments_in_separate_party_account", True)
|
||||
frappe.db.set_value("Company", self.company, "book_advance_payments_in_separate_party_account", 1)
|
||||
frappe.db.set_value(
|
||||
"Company", self.company, "default_advance_received_account", "Advance Received - _TC"
|
||||
)
|
||||
|
||||
@@ -672,7 +672,7 @@ def make_reverse_gl_entries(
|
||||
)
|
||||
|
||||
if not immutable_ledger_enabled:
|
||||
query = query.set(gle.is_cancelled, True)
|
||||
query = query.set(gle.is_cancelled, 1) # smallint column; postgres rejects boolean true
|
||||
|
||||
query.run()
|
||||
else:
|
||||
@@ -683,12 +683,14 @@ def make_reverse_gl_entries(
|
||||
if not all(gle_names):
|
||||
set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"])
|
||||
else:
|
||||
frappe.db.sql(
|
||||
"""UPDATE `tabGL Entry` SET is_cancelled = 1,
|
||||
modified=%s, modified_by=%s
|
||||
where name in %s and is_cancelled = 0""",
|
||||
(now(), frappe.session.user, tuple(gle_names)),
|
||||
)
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
(
|
||||
frappe.qb.update(gle)
|
||||
.set(gle.is_cancelled, 1)
|
||||
.set(gle.modified, now())
|
||||
.set(gle.modified_by, frappe.session.user)
|
||||
.where(gle.name.isin(gle_names) & (gle.is_cancelled == 0))
|
||||
).run()
|
||||
|
||||
for entry in gl_entries:
|
||||
new_gle = copy.deepcopy(entry)
|
||||
@@ -725,9 +727,11 @@ def set_as_cancel(voucher_type, voucher_no):
|
||||
"""
|
||||
Set is_cancelled=1 in all original gl entries for the voucher
|
||||
"""
|
||||
frappe.db.sql(
|
||||
"""UPDATE `tabGL Entry` SET is_cancelled = 1,
|
||||
modified=%s, modified_by=%s
|
||||
where voucher_type=%s and voucher_no=%s and is_cancelled = 0""",
|
||||
(now(), frappe.session.user, voucher_type, voucher_no),
|
||||
)
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
(
|
||||
frappe.qb.update(gle)
|
||||
.set(gle.is_cancelled, 1)
|
||||
.set(gle.modified, now())
|
||||
.set(gle.modified_by, frappe.session.user)
|
||||
.where((gle.voucher_type == voucher_type) & (gle.voucher_no == voucher_no) & (gle.is_cancelled == 0))
|
||||
).run()
|
||||
|
||||
@@ -900,16 +900,13 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
|
||||
d.company, {"grand_total": d.grand_total, "base_grand_total": d.base_grand_total}
|
||||
)
|
||||
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
company_wise_total_unpaid = frappe._dict(
|
||||
frappe.db.sql(
|
||||
"""
|
||||
select company, sum(debit_in_account_currency) - sum(credit_in_account_currency)
|
||||
from `tabGL Entry`
|
||||
where party_type = %s and party=%s
|
||||
and is_cancelled = 0
|
||||
group by company""",
|
||||
(party_type, party),
|
||||
)
|
||||
frappe.qb.from_(gle)
|
||||
.select(gle.company, Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
|
||||
.where((gle.party_type == party_type) & (gle.party == party) & (gle.is_cancelled == 0))
|
||||
.groupby(gle.company)
|
||||
.run()
|
||||
)
|
||||
|
||||
for d in companies:
|
||||
@@ -938,6 +935,15 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
|
||||
if party_type == "Supplier":
|
||||
info["total_unpaid"] = -1 * info["total_unpaid"]
|
||||
|
||||
if info["total_unpaid"] < 0:
|
||||
info["balance_label"] = (
|
||||
"Total Advance Paid" if party_type == "Supplier" else "Total Advance Received"
|
||||
)
|
||||
info["balance_amount"] = abs(info["total_unpaid"])
|
||||
else:
|
||||
info["balance_label"] = "Total Unpaid"
|
||||
info["balance_amount"] = info["total_unpaid"]
|
||||
|
||||
company_wise_info.append(info)
|
||||
|
||||
return company_wise_info
|
||||
|
||||
@@ -54,84 +54,6 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
|
||||
pi = pi.submit()
|
||||
return pi
|
||||
|
||||
def test_invoice_partially_paid_via_journal_entry(self):
|
||||
pi = self.create_purchase_invoice() # outstanding 300
|
||||
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.company = self.company
|
||||
je.posting_date = today()
|
||||
je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "Creditors - _TC",
|
||||
"party_type": "Supplier",
|
||||
"party": self.supplier,
|
||||
"debit": 120,
|
||||
"debit_in_account_currency": 120,
|
||||
"reference_type": "Purchase Invoice",
|
||||
"reference_name": pi.name,
|
||||
"cost_center": "Main - _TC",
|
||||
},
|
||||
)
|
||||
je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "Cash - _TC",
|
||||
"credit": 120,
|
||||
"credit_in_account_currency": 120,
|
||||
"cost_center": "Main - _TC",
|
||||
},
|
||||
)
|
||||
je.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Supplier",
|
||||
"party": [self.supplier],
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
row = next(row for row in execute(filters)[1] if row.voucher_no == pi.name)
|
||||
self.assertEqual(row.paid, 120)
|
||||
self.assertEqual(row.outstanding, 180)
|
||||
|
||||
def test_show_remarks_includes_invoice_remark(self):
|
||||
pi = self.create_purchase_invoice(do_not_submit=True)
|
||||
pi.remarks = "AP test remark"
|
||||
pi.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Supplier",
|
||||
"party": [self.supplier],
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"show_remarks": 1,
|
||||
}
|
||||
row = next(row for row in execute(filters)[1] if row.voucher_no == pi.name)
|
||||
self.assertIn("AP test remark", row.remarks or "")
|
||||
|
||||
def test_group_by_supplier_totals(self):
|
||||
self.create_purchase_invoice() # outstanding 300
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Supplier",
|
||||
"party": [self.supplier],
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"group_by_party": True,
|
||||
}
|
||||
report = execute(filters)[1]
|
||||
|
||||
# a per-supplier subtotal row plus a grand total row
|
||||
party_subtotal = next(
|
||||
row for row in report if row.get("party") == self.supplier and not row.get("voucher_no")
|
||||
)
|
||||
grand_total = next(row for row in report if row.get("party") == "Total")
|
||||
self.assertEqual(party_subtotal.get("invoiced"), 300)
|
||||
self.assertEqual(grand_total.get("outstanding"), 300)
|
||||
|
||||
def test_payment_terms_template_filters(self):
|
||||
from erpnext.controllers.accounts_controller import get_payment_terms
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from collections import OrderedDict
|
||||
import frappe
|
||||
from frappe import _, qb, query_builder, scrub
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder.functions import Date, Max, Substring, Sum
|
||||
from frappe.query_builder.functions import Date, Substring, Sum
|
||||
from frappe.utils import cint, cstr, flt, getdate, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
@@ -691,13 +691,11 @@ class ReceivablePayableReport:
|
||||
.inner_join(jea)
|
||||
.on(jea.parent == je.name)
|
||||
.select(
|
||||
# Sum() below makes this an implicit aggregate (no GROUP BY); the non-aggregated columns
|
||||
# are arbitrary per the single group on MySQL -> Max() keeps it valid on postgres.
|
||||
Max(jea.reference_name).as_("invoice_no"),
|
||||
Max(jea.party).as_("party"),
|
||||
Max(jea.party_type).as_("party_type"),
|
||||
Max(je.posting_date).as_("future_date"),
|
||||
Max(je.cheque_no).as_("future_ref"),
|
||||
jea.reference_name.as_("invoice_no"),
|
||||
jea.party,
|
||||
jea.party_type,
|
||||
je.posting_date.as_("future_date"),
|
||||
je.cheque_no.as_("future_ref"),
|
||||
)
|
||||
.where(
|
||||
(je.docstatus < 2)
|
||||
@@ -727,6 +725,14 @@ class ReceivablePayableReport:
|
||||
future_amount.as_("future_amount"),
|
||||
future_amount_in_base_currency.as_("future_amount_in_base_currency"),
|
||||
)
|
||||
# One row per (future-payment JE, invoice, party): group by the JE name (primary key, so the
|
||||
# JE-level posting_date/cheque_no are deterministic) plus the per-reference dimensions, summing
|
||||
# amounts across JE Account rows that hit the same invoice. Without this GROUP BY the implicit
|
||||
# single-group aggregate collapsed every future JE payment into one row keyed by an arbitrary
|
||||
# invoice, mis-allocating the whole sum.
|
||||
query = query.groupby(
|
||||
je.name, jea.reference_name, jea.party, jea.party_type, je.posting_date, je.cheque_no
|
||||
)
|
||||
# use the aggregate expression in HAVING; postgres can't reference a SELECT alias there
|
||||
query = query.having(future_amount > 0)
|
||||
return query.run(as_dict=True)
|
||||
|
||||
@@ -568,119 +568,6 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
report = execute(filters)
|
||||
self.assertEqual(report[1], [])
|
||||
|
||||
def pay_invoice_via_journal_entry(self, si, amount):
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.company = self.company
|
||||
je.posting_date = today()
|
||||
je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": self.cash,
|
||||
"debit": amount,
|
||||
"debit_in_account_currency": amount,
|
||||
"cost_center": self.cost_center,
|
||||
},
|
||||
)
|
||||
je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": self.debit_to,
|
||||
"party_type": "Customer",
|
||||
"party": self.customer,
|
||||
"credit": amount,
|
||||
"credit_in_account_currency": amount,
|
||||
"reference_type": "Sales Invoice",
|
||||
"reference_name": si.name,
|
||||
"cost_center": self.cost_center,
|
||||
},
|
||||
)
|
||||
return je.save().submit()
|
||||
|
||||
def ar_rows(self):
|
||||
filters = {"company": self.company, "report_date": today(), "range": "30, 60, 90, 120"}
|
||||
return execute(filters)[1]
|
||||
|
||||
def test_invoice_partially_paid_via_journal_entry(self):
|
||||
si = self.create_sales_invoice(no_payment_schedule=True) # outstanding 100
|
||||
self.pay_invoice_via_journal_entry(si, 40)
|
||||
|
||||
row = next(row for row in self.ar_rows() if row.voucher_no == si.name)
|
||||
self.assertEqual(row.paid, 40)
|
||||
self.assertEqual(row.outstanding, 60)
|
||||
|
||||
def test_invoice_fully_paid_via_journal_entry(self):
|
||||
si = self.create_sales_invoice(no_payment_schedule=True) # outstanding 100
|
||||
self.pay_invoice_via_journal_entry(si, 100)
|
||||
|
||||
# a fully settled invoice drops out of the receivable report
|
||||
self.assertEqual([row for row in self.ar_rows() if row.voucher_no == si.name], [])
|
||||
|
||||
def test_credit_note_via_journal_entry_shows_negative_outstanding(self):
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.company = self.company
|
||||
je.voucher_type = "Credit Note"
|
||||
je.posting_date = today()
|
||||
je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": self.income_account,
|
||||
"debit": 100,
|
||||
"debit_in_account_currency": 100,
|
||||
"cost_center": self.cost_center,
|
||||
},
|
||||
)
|
||||
je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": self.debit_to,
|
||||
"party_type": "Customer",
|
||||
"party": self.customer,
|
||||
"credit": 100,
|
||||
"credit_in_account_currency": 100,
|
||||
"cost_center": self.cost_center,
|
||||
},
|
||||
)
|
||||
je = je.save().submit()
|
||||
|
||||
row = next(row for row in self.ar_rows() if row.voucher_no == je.name)
|
||||
self.assertEqual(row.outstanding, -100)
|
||||
|
||||
def test_show_remarks_includes_invoice_remark(self):
|
||||
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
|
||||
si.remarks = "AR test remark"
|
||||
si.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"show_remarks": 1,
|
||||
}
|
||||
row = next(row for row in execute(filters)[1] if row.voucher_no == si.name)
|
||||
self.assertIn("AR test remark", row.remarks or "")
|
||||
|
||||
def test_show_delivery_notes_links_delivery_note(self):
|
||||
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
|
||||
|
||||
make_stock_entry(item_code=self.item, qty=5, to_warehouse=self.warehouse, basic_rate=100)
|
||||
dn = create_delivery_note(
|
||||
customer=self.customer, item=self.item, warehouse=self.warehouse, cost_center=self.cost_center
|
||||
)
|
||||
si = make_sales_invoice(dn.name)
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"show_delivery_notes": 1,
|
||||
}
|
||||
row = next(row for row in execute(filters)[1] if row.voucher_no == si.name)
|
||||
self.assertIn(dn.name, row.delivery_notes or "")
|
||||
|
||||
def test_group_by_party(self):
|
||||
si1 = self.create_sales_invoice(do_not_submit=True)
|
||||
si1.posting_date = add_days(today(), -1)
|
||||
@@ -812,6 +699,61 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
[row.invoiced, row.paid, row.outstanding, row.remaining_balance, row.future_amount],
|
||||
)
|
||||
|
||||
def test_future_payments_from_journal_entry(self):
|
||||
# A single future-dated Journal Entry paying two different invoices must surface as one
|
||||
# future-payment row PER invoice, not collapse the whole sum onto one arbitrary invoice
|
||||
# (regression: the implicit single-group aggregate filed all future JE payments under one key).
|
||||
si_a = self.create_sales_invoice(no_payment_schedule=True)
|
||||
si_b = self.create_sales_invoice(no_payment_schedule=True)
|
||||
|
||||
je = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Journal Entry",
|
||||
"voucher_type": "Journal Entry",
|
||||
"company": self.company,
|
||||
"posting_date": add_days(today(), 1),
|
||||
"accounts": [
|
||||
{
|
||||
"account": self.debit_to,
|
||||
"party_type": "Customer",
|
||||
"party": self.customer,
|
||||
"reference_type": "Sales Invoice",
|
||||
"reference_name": si_a.name,
|
||||
"credit_in_account_currency": 50,
|
||||
"credit": 50,
|
||||
},
|
||||
{
|
||||
"account": self.debit_to,
|
||||
"party_type": "Customer",
|
||||
"party": self.customer,
|
||||
"reference_type": "Sales Invoice",
|
||||
"reference_name": si_b.name,
|
||||
"credit_in_account_currency": 50,
|
||||
"credit": 50,
|
||||
},
|
||||
{"account": self.cash, "debit_in_account_currency": 100, "debit": 100},
|
||||
],
|
||||
}
|
||||
)
|
||||
je.insert().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"show_future_payments": True,
|
||||
}
|
||||
report = execute(filters)[1]
|
||||
rows_a = [row for row in report if row.voucher_no == si_a.name]
|
||||
rows_b = [row for row in report if row.voucher_no == si_b.name]
|
||||
|
||||
# exactly one report row per invoice, each keeping its own future payment; the bug collapsed
|
||||
# both into a single row and allocated the whole 100 to one arbitrary invoice
|
||||
self.assertEqual(len(rows_a), 1)
|
||||
self.assertEqual(len(rows_b), 1)
|
||||
self.assertEqual(rows_a[0].future_amount, 50.0)
|
||||
self.assertEqual(rows_b[0].future_amount, 50.0)
|
||||
|
||||
def test_sales_person(self):
|
||||
sales_person = frappe.get_doc(
|
||||
{"doctype": "Sales Person", "sales_person_name": "John Clark", "enabled": True}
|
||||
|
||||
@@ -82,7 +82,7 @@ def get_asset_categories_for_grouped_by_category(filters):
|
||||
asset_capitalization = frappe.qb.DocType("Asset Capitalization")
|
||||
|
||||
disposal_in_period = (
|
||||
(IfNull(asset.disposal_date, 0) != 0)
|
||||
(asset.disposal_date.isnotnull())
|
||||
& (asset.disposal_date >= filters.from_date)
|
||||
& (asset.disposal_date <= filters.to_date)
|
||||
)
|
||||
@@ -92,7 +92,7 @@ def get_asset_categories_for_grouped_by_category(filters):
|
||||
frappe.qb.terms.Case()
|
||||
.when(
|
||||
(asset.purchase_date < filters.from_date)
|
||||
& ((IfNull(asset.disposal_date, 0) == 0) | (asset.disposal_date >= filters.from_date)),
|
||||
& ((asset.disposal_date.isnull()) | (asset.disposal_date >= filters.from_date)),
|
||||
asset.net_purchase_amount,
|
||||
)
|
||||
.else_(0)
|
||||
@@ -209,9 +209,7 @@ def get_assets_for_grouped_by_category(filters):
|
||||
frappe.qb.terms.Case()
|
||||
.when(
|
||||
(gl_entry.posting_date < filters.from_date)
|
||||
& (
|
||||
(IfNull(asset.disposal_date, 0) == 0) | (asset.disposal_date >= filters.from_date)
|
||||
),
|
||||
& ((asset.disposal_date.isnull()) | (asset.disposal_date >= filters.from_date)),
|
||||
gl_entry.debit,
|
||||
)
|
||||
.else_(0)
|
||||
@@ -222,7 +220,7 @@ def get_assets_for_grouped_by_category(filters):
|
||||
Sum(
|
||||
frappe.qb.terms.Case()
|
||||
.when(
|
||||
(gl_entry.posting_date <= filters.to_date) & (IfNull(asset.disposal_date, 0) == 0),
|
||||
(gl_entry.posting_date <= filters.to_date) & (asset.disposal_date.isnull()),
|
||||
gl_entry.credit,
|
||||
)
|
||||
.else_(0)
|
||||
@@ -233,7 +231,7 @@ def get_assets_for_grouped_by_category(filters):
|
||||
Sum(
|
||||
frappe.qb.terms.Case()
|
||||
.when(
|
||||
(IfNull(asset.disposal_date, 0) != 0)
|
||||
(asset.disposal_date.isnotnull())
|
||||
& (asset.disposal_date >= filters.from_date)
|
||||
& (asset.disposal_date <= filters.to_date)
|
||||
& (gl_entry.posting_date <= asset.disposal_date),
|
||||
@@ -249,10 +247,7 @@ def get_assets_for_grouped_by_category(filters):
|
||||
.when(
|
||||
(gl_entry.posting_date >= filters.from_date)
|
||||
& (gl_entry.posting_date <= filters.to_date)
|
||||
& (
|
||||
(IfNull(asset.disposal_date, 0) == 0)
|
||||
| (gl_entry.posting_date <= asset.disposal_date)
|
||||
),
|
||||
& ((asset.disposal_date.isnull()) | (gl_entry.posting_date <= asset.disposal_date)),
|
||||
gl_entry.debit,
|
||||
)
|
||||
.else_(0)
|
||||
@@ -282,7 +277,7 @@ def get_assets_for_grouped_by_category(filters):
|
||||
Sum(
|
||||
frappe.qb.terms.Case()
|
||||
.when(
|
||||
(IfNull(asset.disposal_date, 0) != 0) & (asset.disposal_date < filters.from_date),
|
||||
(asset.disposal_date.isnotnull()) & (asset.disposal_date < filters.from_date),
|
||||
0,
|
||||
)
|
||||
.else_(asset.opening_accumulated_depreciation)
|
||||
@@ -476,7 +471,7 @@ def get_asset_details_for_grouped_by_category(filters):
|
||||
asset_capitalization = frappe.qb.DocType("Asset Capitalization")
|
||||
|
||||
disposal_in_period = (
|
||||
(IfNull(asset.disposal_date, 0) != 0)
|
||||
(asset.disposal_date.isnotnull())
|
||||
& (asset.disposal_date >= filters.from_date)
|
||||
& (asset.disposal_date <= filters.to_date)
|
||||
)
|
||||
@@ -500,9 +495,7 @@ def get_asset_details_for_grouped_by_category(filters):
|
||||
frappe.qb.terms.Case()
|
||||
.when(
|
||||
(asset.purchase_date < filters.from_date)
|
||||
& (
|
||||
(IfNull(asset.disposal_date, 0) == 0) | (asset.disposal_date >= filters.from_date)
|
||||
),
|
||||
& ((asset.disposal_date.isnull()) | (asset.disposal_date >= filters.from_date)),
|
||||
asset.net_purchase_amount,
|
||||
)
|
||||
.else_(0)
|
||||
@@ -599,9 +592,7 @@ def get_assets_for_grouped_by_asset(filters):
|
||||
frappe.qb.terms.Case()
|
||||
.when(
|
||||
(gl_entry.posting_date < filters.from_date)
|
||||
& (
|
||||
(IfNull(asset.disposal_date, 0) == 0) | (asset.disposal_date >= filters.from_date)
|
||||
),
|
||||
& ((asset.disposal_date.isnull()) | (asset.disposal_date >= filters.from_date)),
|
||||
gl_entry.debit,
|
||||
)
|
||||
.else_(0)
|
||||
@@ -612,7 +603,7 @@ def get_assets_for_grouped_by_asset(filters):
|
||||
Sum(
|
||||
frappe.qb.terms.Case()
|
||||
.when(
|
||||
(gl_entry.posting_date <= filters.to_date) & (IfNull(asset.disposal_date, 0) == 0),
|
||||
(gl_entry.posting_date <= filters.to_date) & (asset.disposal_date.isnull()),
|
||||
gl_entry.credit,
|
||||
)
|
||||
.else_(0)
|
||||
@@ -623,7 +614,7 @@ def get_assets_for_grouped_by_asset(filters):
|
||||
Sum(
|
||||
frappe.qb.terms.Case()
|
||||
.when(
|
||||
(IfNull(asset.disposal_date, 0) != 0)
|
||||
(asset.disposal_date.isnotnull())
|
||||
& (asset.disposal_date >= filters.from_date)
|
||||
& (asset.disposal_date <= filters.to_date)
|
||||
& (gl_entry.posting_date <= asset.disposal_date),
|
||||
@@ -639,10 +630,7 @@ def get_assets_for_grouped_by_asset(filters):
|
||||
.when(
|
||||
(gl_entry.posting_date >= filters.from_date)
|
||||
& (gl_entry.posting_date <= filters.to_date)
|
||||
& (
|
||||
(IfNull(asset.disposal_date, 0) == 0)
|
||||
| (gl_entry.posting_date <= asset.disposal_date)
|
||||
),
|
||||
& ((asset.disposal_date.isnull()) | (gl_entry.posting_date <= asset.disposal_date)),
|
||||
gl_entry.debit,
|
||||
)
|
||||
.else_(0)
|
||||
@@ -672,7 +660,7 @@ def get_assets_for_grouped_by_asset(filters):
|
||||
Sum(
|
||||
frappe.qb.terms.Case()
|
||||
.when(
|
||||
(IfNull(asset.disposal_date, 0) != 0) & (asset.disposal_date < filters.from_date),
|
||||
(asset.disposal_date.isnotnull()) & (asset.disposal_date < filters.from_date),
|
||||
0,
|
||||
)
|
||||
.else_(asset.opening_accumulated_depreciation)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.report.asset_depreciations_and_balances.asset_depreciations_and_balances import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestAssetDepreciationsAndBalancesReport(ERPNextTestSuite):
|
||||
def test_report_runs_on_both_engines(self):
|
||||
"""The report compared IfNull(asset.disposal_date, 0) against 0 -- coalescing a DATE
|
||||
column with integer 0. Postgres rejects that (COALESCE types date and integer cannot be
|
||||
matched) at plan time, so the whole report errored there regardless of data. It must run
|
||||
on both engines."""
|
||||
for group_by in ("Asset Category", "Asset"):
|
||||
filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
from_date="2020-01-01",
|
||||
to_date="2030-12-31",
|
||||
group_by=group_by,
|
||||
)
|
||||
result = execute(filters)
|
||||
self.assertIsInstance(result[1], list)
|
||||
@@ -84,7 +84,13 @@ def build_budget_map(budget_records, filters):
|
||||
budget_distributions = get_budget_distributions(budget)
|
||||
|
||||
for row in budget_distributions:
|
||||
if not row.start_date or not row.end_date:
|
||||
continue
|
||||
|
||||
months = get_months_in_range(row.start_date, row.end_date)
|
||||
if not months:
|
||||
continue
|
||||
|
||||
monthly_budget = flt(row.amount) / len(months)
|
||||
|
||||
for month_date in months:
|
||||
|
||||
@@ -164,7 +164,8 @@ def get_gl_entries(filters, accounting_dimensions):
|
||||
|
||||
if filters.get("show_remarks"):
|
||||
if remarks_length := frappe.get_single_value("Accounts Settings", "general_ledger_remarks_length"):
|
||||
select_fields += f",substr(remarks, 1, {remarks_length}) as 'remarks'"
|
||||
# bare alias, not 'remarks' — Postgres treats a single-quoted alias as a string literal
|
||||
select_fields += f",substr(remarks, 1, {remarks_length}) as remarks"
|
||||
else:
|
||||
select_fields += """,remarks"""
|
||||
|
||||
|
||||
@@ -15,6 +15,42 @@ class TestGeneralLedger(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
|
||||
def test_gl_report_runs_with_remarks_length(self):
|
||||
# general_ledger_remarks_length adds `substr(remarks, 1, n) as remarks` to the raw SQL; the
|
||||
# alias must be unquoted to be valid on Postgres (a single-quoted alias is a string literal there).
|
||||
from frappe.utils import today
|
||||
|
||||
frappe.db.set_single_value("Accounts Settings", "general_ledger_remarks_length", 50)
|
||||
self.addCleanup(frappe.db.set_single_value, "Accounts Settings", "general_ledger_remarks_length", 0)
|
||||
|
||||
si = create_sales_invoice(company=self.company)
|
||||
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
|
||||
|
||||
columns, data = execute(
|
||||
frappe._dict(
|
||||
{
|
||||
"company": self.company,
|
||||
"from_date": today(),
|
||||
"to_date": today(),
|
||||
"group_by": "Group by Voucher (Consolidated)",
|
||||
# required to reach the `substr(remarks, 1, n) as remarks` branch under test
|
||||
"show_remarks": True,
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertTrue(columns)
|
||||
self.assertTrue(data)
|
||||
self.assertTrue(any("remarks" in row for row in data))
|
||||
|
||||
@staticmethod
|
||||
def _cancel_and_delete(doctype, name):
|
||||
if not frappe.db.exists(doctype, name):
|
||||
return
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
if doc.docstatus == 1:
|
||||
doc.cancel()
|
||||
frappe.delete_doc(doctype, name, force=1)
|
||||
|
||||
def clear_old_entries(self):
|
||||
doctype_list = [
|
||||
"GL Entry",
|
||||
@@ -134,17 +170,17 @@ class TestGeneralLedger(ERPNextTestSuite):
|
||||
revaluation_jv.submit()
|
||||
|
||||
# check the balance of the account
|
||||
balance = frappe.db.sql(
|
||||
"""
|
||||
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
|
||||
from `tabGL Entry`
|
||||
where account = %s
|
||||
group by account
|
||||
""",
|
||||
account.name,
|
||||
balance = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"account": account.name},
|
||||
fields=[
|
||||
{"SUM": "debit_in_account_currency", "as": "debit"},
|
||||
{"SUM": "credit_in_account_currency", "as": "credit"},
|
||||
],
|
||||
group_by="account",
|
||||
)
|
||||
|
||||
self.assertEqual(balance[0][0], 100)
|
||||
self.assertEqual(flt(balance[0].debit) - flt(balance[0].credit), 100)
|
||||
|
||||
# check if general ledger shows correct balance
|
||||
columns, data = execute(
|
||||
|
||||
@@ -309,17 +309,22 @@ def get_account_columns(invoice_list, include_payments):
|
||||
unrealized_profit_loss_account_columns = []
|
||||
|
||||
if invoice_list:
|
||||
expense_accounts = frappe.get_all(
|
||||
"Purchase Invoice Item",
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"expense_account": ["is", "set"],
|
||||
"parenttype": "Purchase Invoice",
|
||||
"parent": ["in", [inv.name for inv in invoice_list]],
|
||||
},
|
||||
pluck="expense_account",
|
||||
distinct=True,
|
||||
order_by="expense_account",
|
||||
# frappe drops ORDER BY for distinct queries on postgres (db_query), so sort in python with
|
||||
# casefold to keep the generated account-column order deterministic and identical on both
|
||||
# backends, matching MariaDB's case-insensitive collation (the original ORDER BY).
|
||||
expense_accounts = sorted(
|
||||
frappe.get_all(
|
||||
"Purchase Invoice Item",
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"expense_account": ["is", "set"],
|
||||
"parenttype": "Purchase Invoice",
|
||||
"parent": ["in", [inv.name for inv in invoice_list]],
|
||||
},
|
||||
pluck="expense_account",
|
||||
distinct=True,
|
||||
),
|
||||
key=str.casefold,
|
||||
)
|
||||
|
||||
purchase_taxes_query = get_taxes_query(invoice_list, "Purchase Taxes and Charges", "Purchase Invoice")
|
||||
@@ -331,16 +336,18 @@ def get_account_columns(invoice_list, include_payments):
|
||||
advance_tax_accounts = advance_taxes_query.run(as_dict=True, pluck="account_head")
|
||||
tax_accounts = set(tax_accounts + advance_tax_accounts)
|
||||
|
||||
unrealized_profit_loss_accounts = frappe.get_all(
|
||||
"Purchase Invoice",
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"name": ["in", [inv.name for inv in invoice_list]],
|
||||
"unrealized_profit_loss_account": ["is", "set"],
|
||||
},
|
||||
pluck="unrealized_profit_loss_account",
|
||||
distinct=True,
|
||||
order_by="unrealized_profit_loss_account",
|
||||
unrealized_profit_loss_accounts = sorted(
|
||||
frappe.get_all(
|
||||
"Purchase Invoice",
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"name": ["in", [inv.name for inv in invoice_list]],
|
||||
"unrealized_profit_loss_account": ["is", "set"],
|
||||
},
|
||||
pluck="unrealized_profit_loss_account",
|
||||
distinct=True,
|
||||
),
|
||||
key=str.casefold,
|
||||
)
|
||||
|
||||
for account in expense_accounts:
|
||||
|
||||
@@ -24,6 +24,29 @@ class TestPurchaseRegister(ERPNextTestSuite):
|
||||
self.assertEqual(first_row.total_tax, 100)
|
||||
self.assertEqual(first_row.grand_total, 1100)
|
||||
|
||||
def test_expense_account_columns_sorted_case_insensitively(self):
|
||||
# The dynamic expense-account columns must follow MariaDB's case-insensitive collation order and
|
||||
# be identical on both engines. frappe drops ORDER BY for distinct queries on postgres, so the
|
||||
# report sorts in python with casefold; plain sorted() would be case-sensitive ("ZZZ" < "aaa").
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
|
||||
company = "_Test Company"
|
||||
lower = create_account(
|
||||
account_name="aaa Test Expense", parent_account="Expenses - _TC", company=company
|
||||
)
|
||||
upper = create_account(
|
||||
account_name="ZZZ Test Expense", parent_account="Expenses - _TC", company=company
|
||||
)
|
||||
for account in (upper, lower): # submit in non-casefold order
|
||||
make_purchase_invoice(company=company, expense_account=account)
|
||||
|
||||
filters = frappe._dict(company=company, from_date=add_months(today(), -1), to_date=today())
|
||||
columns = execute(filters)[0]
|
||||
labels = [col["label"] for col in columns if col.get("label") in (lower, upper)]
|
||||
|
||||
self.assertEqual(labels, sorted([lower, upper], key=str.casefold))
|
||||
|
||||
def test_purchase_register_ignores_tax_rows_from_other_doctype(self):
|
||||
filters = frappe._dict(company="_Test Company 6", from_date=add_months(today(), -1), to_date=today())
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"name": "Sales Partners Commission",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"query": "SELECT\n sales_partner as \"Sales Partner:Link / Sales Partner:220\",\n sum(base_net_total) as \"Invoiced Amount (Excl. Tax):Currency:220\",\n sum(amount_eligible_for_commission) as \"Amount Eligible for Commission:Currency:220\",\n sum(total_commission) as \"Total Commission:Currency:170\",\n sum(total_commission)*100 / sum(amount_eligible_for_commission) as \"Average Commission Rate:Percent:220\"\nFROM\n (\n SELECT\n sales_partner,\n base_net_total,\n total_commission,\n amount_eligible_for_commission\n FROM\n `tabSales Invoice` \n WHERE\n docstatus = 1\n AND IFNULL(base_net_total, 0) > 0\n AND IFNULL(total_commission, 0) > 0\n\n UNION ALL\n\n SELECT\n sales_partner,\n base_net_total,\n total_commission,\n amount_eligible_for_commission\n FROM\n `tabPOS Invoice`\n WHERE\n docstatus = 1\n AND IFNULL(base_net_total, 0) > 0\n AND IFNULL(total_commission, 0) > 0\n ) AS sub\nGROUP BY\n sales_partner\nORDER BY\n \"Total Commission:Currency:120\"",
|
||||
"query": "SELECT\n sales_partner as \"Sales Partner:Link / Sales Partner:220\",\n sum(base_net_total) as \"Invoiced Amount (Excl. Tax):Currency:220\",\n sum(amount_eligible_for_commission) as \"Amount Eligible for Commission:Currency:220\",\n sum(total_commission) as \"Total Commission:Currency:170\",\n sum(total_commission)*100 / NULLIF(sum(amount_eligible_for_commission), 0) as \"Average Commission Rate:Percent:220\"\nFROM\n (\n SELECT\n sales_partner,\n base_net_total,\n total_commission,\n amount_eligible_for_commission\n FROM\n `tabSales Invoice` \n WHERE\n docstatus = 1\n AND IFNULL(base_net_total, 0) > 0\n AND IFNULL(total_commission, 0) > 0\n\n UNION ALL\n\n SELECT\n sales_partner,\n base_net_total,\n total_commission,\n amount_eligible_for_commission\n FROM\n `tabPOS Invoice`\n WHERE\n docstatus = 1\n AND IFNULL(base_net_total, 0) > 0\n AND IFNULL(total_commission, 0) > 0\n ) AS sub\nGROUP BY\n sales_partner",
|
||||
"ref_doctype": "Sales Invoice",
|
||||
"report_name": "Sales Partners Commission",
|
||||
"report_type": "Query Report",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Coalesce, Max, Sum
|
||||
from frappe.utils import cstr
|
||||
|
||||
|
||||
@@ -100,178 +101,275 @@ def get_sales_payment_data(filters, columns):
|
||||
return data
|
||||
|
||||
|
||||
def get_conditions(filters):
|
||||
conditions = "1=1"
|
||||
def apply_conditions(query, a, filters):
|
||||
"""Apply the same filters get_conditions() used to build, as parameterized qb .where() clauses.
|
||||
|
||||
`a` is the field source for the Sales Invoice columns -- either the `tabSales Invoice`
|
||||
DocType or a subquery aliased `a` that selects those columns. This mirrors the previous
|
||||
raw SQL where every predicate was keyed on the `a` alias.
|
||||
"""
|
||||
if filters.get("from_date"):
|
||||
conditions += " and a.posting_date >= %(from_date)s"
|
||||
query = query.where(a.posting_date >= filters.get("from_date"))
|
||||
if filters.get("to_date"):
|
||||
conditions += " and a.posting_date <= %(to_date)s"
|
||||
query = query.where(a.posting_date <= filters.get("to_date"))
|
||||
if filters.get("company"):
|
||||
conditions += " and a.company=%(company)s"
|
||||
query = query.where(a.company == filters.get("company"))
|
||||
if filters.get("customer"):
|
||||
conditions += " and a.customer = %(customer)s"
|
||||
query = query.where(a.customer == filters.get("customer"))
|
||||
if filters.get("owner"):
|
||||
conditions += " and a.owner = %(owner)s"
|
||||
query = query.where(a.owner == filters.get("owner"))
|
||||
if filters.get("is_pos"):
|
||||
conditions += " and a.is_pos = %(is_pos)s"
|
||||
return conditions
|
||||
query = query.where(a.is_pos == filters.get("is_pos"))
|
||||
return query
|
||||
|
||||
|
||||
def get_pos_invoice_data(filters):
|
||||
conditions = get_conditions(filters)
|
||||
result = frappe.db.sql(
|
||||
""
|
||||
"SELECT "
|
||||
'posting_date, owner, sum(net_total) as "net_total", sum(total_taxes) as "total_taxes", '
|
||||
'sum(paid_amount) as "paid_amount", sum(outstanding_amount) as "outstanding_amount", '
|
||||
"mode_of_payment, warehouse, cost_center "
|
||||
"FROM ("
|
||||
"SELECT "
|
||||
'parent, item_code, sum(amount) as "base_total", warehouse, cost_center '
|
||||
"from `tabSales Invoice Item` group by parent"
|
||||
") t1 "
|
||||
"left join "
|
||||
"(select parent, mode_of_payment from `tabSales Invoice Payment` group by parent) t3 "
|
||||
"on (t3.parent = t1.parent) "
|
||||
"JOIN ("
|
||||
"SELECT "
|
||||
'docstatus, company, is_pos, name, posting_date, owner, sum(base_total) as "base_total", '
|
||||
'sum(net_total) as "net_total", sum(total_taxes_and_charges) as "total_taxes", '
|
||||
'sum(base_paid_amount) as "paid_amount", sum(outstanding_amount) as "outstanding_amount" '
|
||||
"FROM `tabSales Invoice` "
|
||||
"GROUP BY name"
|
||||
") a "
|
||||
"ON ("
|
||||
"t1.parent = a.name and t1.base_total = a.base_total) "
|
||||
"WHERE a.docstatus = 1"
|
||||
f" AND {conditions} "
|
||||
"GROUP BY "
|
||||
"owner, posting_date, warehouse",
|
||||
filters,
|
||||
as_dict=1,
|
||||
sii = frappe.qb.DocType("Sales Invoice Item")
|
||||
sip = frappe.qb.DocType("Sales Invoice Payment")
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
|
||||
# t1: one row per invoice with the summed item base_total. warehouse/cost_center are line-level and
|
||||
# not grouped, so they are arbitrary per invoice -- Max() makes that pick deterministic and valid on
|
||||
# Postgres (item_code was selected but never consumed downstream, so it is dropped).
|
||||
t1 = (
|
||||
frappe.qb.from_(sii)
|
||||
.select(
|
||||
sii.parent,
|
||||
Sum(sii.amount).as_("base_total"),
|
||||
Max(sii.warehouse).as_("warehouse"),
|
||||
Max(sii.cost_center).as_("cost_center"),
|
||||
)
|
||||
.groupby(sii.parent)
|
||||
)
|
||||
return result
|
||||
|
||||
# t3: mode_of_payment per invoice (arbitrary across an invoice's payment lines -> Max() to be valid)
|
||||
t3 = (
|
||||
frappe.qb.from_(sip)
|
||||
.select(sip.parent, Max(sip.mode_of_payment).as_("mode_of_payment"))
|
||||
.groupby(sip.parent)
|
||||
)
|
||||
|
||||
# a: invoice-level aggregates. Grouped by the primary key (si.name), so the other plain si columns
|
||||
# (incl. customer, needed by the customer filter) are functionally dependent and valid on Postgres.
|
||||
a = (
|
||||
frappe.qb.from_(si)
|
||||
.select(
|
||||
si.docstatus,
|
||||
si.company,
|
||||
si.customer,
|
||||
si.is_pos,
|
||||
si.name,
|
||||
si.posting_date,
|
||||
si.owner,
|
||||
Sum(si.base_total).as_("base_total"),
|
||||
Sum(si.net_total).as_("net_total"),
|
||||
Sum(si.total_taxes_and_charges).as_("total_taxes"),
|
||||
Sum(si.base_paid_amount).as_("paid_amount"),
|
||||
Sum(si.outstanding_amount).as_("outstanding_amount"),
|
||||
)
|
||||
.groupby(si.name)
|
||||
)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(t1)
|
||||
.left_join(t3)
|
||||
.on(t3.parent == t1.parent)
|
||||
.join(a)
|
||||
.on((t1.parent == a.name) & (t1.base_total == a.base_total))
|
||||
.select(
|
||||
a.posting_date,
|
||||
a.owner,
|
||||
Sum(a.net_total).as_("net_total"),
|
||||
Sum(a.total_taxes).as_("total_taxes"),
|
||||
Sum(a.paid_amount).as_("paid_amount"),
|
||||
Sum(a.outstanding_amount).as_("outstanding_amount"),
|
||||
# mode_of_payment/cost_center are not in the outer GROUP BY -> Max() (deterministic, both engines)
|
||||
Max(t3.mode_of_payment).as_("mode_of_payment"),
|
||||
t1.warehouse,
|
||||
Max(t1.cost_center).as_("cost_center"),
|
||||
)
|
||||
.where(a.docstatus == 1)
|
||||
.groupby(a.owner, a.posting_date, t1.warehouse)
|
||||
)
|
||||
query = apply_conditions(query, a, filters)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_sales_invoice_data(filters):
|
||||
conditions = get_conditions(filters)
|
||||
return frappe.db.sql(
|
||||
f"""
|
||||
select
|
||||
a.posting_date, a.owner,
|
||||
sum(a.net_total) as "net_total",
|
||||
sum(a.total_taxes_and_charges) as "total_taxes",
|
||||
sum(a.base_paid_amount) as "paid_amount",
|
||||
sum(a.outstanding_amount) as "outstanding_amount"
|
||||
from `tabSales Invoice` a
|
||||
where a.docstatus = 1
|
||||
and {conditions}
|
||||
group by
|
||||
a.owner, a.posting_date
|
||||
""",
|
||||
filters,
|
||||
as_dict=1,
|
||||
a = frappe.qb.DocType("Sales Invoice")
|
||||
query = (
|
||||
frappe.qb.from_(a)
|
||||
.select(
|
||||
a.posting_date,
|
||||
a.owner,
|
||||
Sum(a.net_total).as_("net_total"),
|
||||
Sum(a.total_taxes_and_charges).as_("total_taxes"),
|
||||
Sum(a.base_paid_amount).as_("paid_amount"),
|
||||
Sum(a.outstanding_amount).as_("outstanding_amount"),
|
||||
)
|
||||
.where(a.docstatus == 1)
|
||||
.groupby(a.owner, a.posting_date)
|
||||
)
|
||||
query = apply_conditions(query, a, filters)
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_mode_of_payments(filters):
|
||||
mode_of_payments = {}
|
||||
invoice_list = get_invoices(filters)
|
||||
invoice_list_names = ",".join("'" + invoice["name"] + "'" for invoice in invoice_list)
|
||||
invoice_names = [invoice["name"] for invoice in invoice_list]
|
||||
if invoice_list:
|
||||
inv_mop = frappe.db.sql(
|
||||
f"""select a.owner,a.posting_date, ifnull(b.mode_of_payment, '') as mode_of_payment
|
||||
from `tabSales Invoice` a, `tabSales Invoice Payment` b
|
||||
where a.name = b.parent
|
||||
and a.docstatus = 1
|
||||
and a.name in ({invoice_list_names})
|
||||
union
|
||||
select a.owner,a.posting_date, ifnull(b.mode_of_payment, '') as mode_of_payment
|
||||
from `tabSales Invoice` a, `tabPayment Entry` b,`tabPayment Entry Reference` c
|
||||
where a.name = c.reference_name
|
||||
and b.name = c.parent
|
||||
and b.docstatus = 1
|
||||
and a.name in ({invoice_list_names})
|
||||
union
|
||||
select a.owner, a.posting_date,
|
||||
ifnull(a.voucher_type,'') as mode_of_payment
|
||||
from `tabJournal Entry` a, `tabJournal Entry Account` b
|
||||
where a.name = b.parent
|
||||
and a.docstatus = 1
|
||||
and b.reference_type = 'Sales Invoice'
|
||||
and b.reference_name in ({invoice_list_names})
|
||||
""",
|
||||
as_dict=1,
|
||||
# Branch 1: payments recorded directly on the Sales Invoice
|
||||
si1 = frappe.qb.DocType("Sales Invoice")
|
||||
sip = frappe.qb.DocType("Sales Invoice Payment")
|
||||
branch1 = (
|
||||
frappe.qb.from_(si1)
|
||||
.join(sip)
|
||||
.on(si1.name == sip.parent)
|
||||
.select(si1.owner, si1.posting_date, Coalesce(sip.mode_of_payment, "").as_("mode_of_payment"))
|
||||
.where(si1.docstatus == 1)
|
||||
.where(si1.name.isin(invoice_names))
|
||||
)
|
||||
|
||||
# Branch 2: payments via Payment Entry referencing the invoice
|
||||
si2 = frappe.qb.DocType("Sales Invoice")
|
||||
pe = frappe.qb.DocType("Payment Entry")
|
||||
per = frappe.qb.DocType("Payment Entry Reference")
|
||||
branch2 = (
|
||||
frappe.qb.from_(si2)
|
||||
.join(per)
|
||||
.on(si2.name == per.reference_name)
|
||||
.join(pe)
|
||||
.on(pe.name == per.parent)
|
||||
.select(si2.owner, si2.posting_date, Coalesce(pe.mode_of_payment, "").as_("mode_of_payment"))
|
||||
.where(pe.docstatus == 1)
|
||||
.where(si2.name.isin(invoice_names))
|
||||
)
|
||||
|
||||
# Branch 3: payments via Journal Entry referencing the invoice
|
||||
je = frappe.qb.DocType("Journal Entry")
|
||||
jea = frappe.qb.DocType("Journal Entry Account")
|
||||
branch3 = (
|
||||
frappe.qb.from_(je)
|
||||
.join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.owner, je.posting_date, Coalesce(je.voucher_type, "").as_("mode_of_payment"))
|
||||
.where(je.docstatus == 1)
|
||||
.where(jea.reference_type == "Sales Invoice")
|
||||
.where(jea.reference_name.isin(invoice_names))
|
||||
)
|
||||
|
||||
# bare UNION => de-duplicated rows across the three branches
|
||||
inv_mop = (branch1.union(branch2).union(branch3)).run(as_dict=True)
|
||||
for d in inv_mop:
|
||||
mode_of_payments.setdefault(d["owner"] + cstr(d["posting_date"]), []).append(d.mode_of_payment)
|
||||
return mode_of_payments
|
||||
|
||||
|
||||
def get_invoices(filters):
|
||||
conditions = get_conditions(filters)
|
||||
return frappe.db.sql(
|
||||
f"""select a.name
|
||||
from `tabSales Invoice` a
|
||||
where a.docstatus = 1 and {conditions}""",
|
||||
filters,
|
||||
as_dict=1,
|
||||
)
|
||||
a = frappe.qb.DocType("Sales Invoice")
|
||||
query = frappe.qb.from_(a).select(a.name).where(a.docstatus == 1)
|
||||
query = apply_conditions(query, a, filters)
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_mode_of_payment_details(filters):
|
||||
mode_of_payment_details = {}
|
||||
invoice_list = get_invoices(filters)
|
||||
invoice_list_names = ",".join("'" + invoice["name"] + "'" for invoice in invoice_list)
|
||||
invoice_names = [invoice["name"] for invoice in invoice_list]
|
||||
if invoice_list:
|
||||
inv_mop_detail = frappe.db.sql(
|
||||
f"""
|
||||
select t.owner,
|
||||
t.posting_date,
|
||||
t.mode_of_payment,
|
||||
sum(t.paid_amount) as paid_amount
|
||||
from (
|
||||
select a.owner, a.posting_date,
|
||||
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(b.base_amount) as paid_amount
|
||||
from `tabSales Invoice` a, `tabSales Invoice Payment` b
|
||||
where a.name = b.parent
|
||||
and a.docstatus = 1
|
||||
and a.name in ({invoice_list_names})
|
||||
group by a.owner, a.posting_date, mode_of_payment
|
||||
union
|
||||
select a.owner,a.posting_date,
|
||||
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(c.allocated_amount) as paid_amount
|
||||
from `tabSales Invoice` a, `tabPayment Entry` b,`tabPayment Entry Reference` c
|
||||
where a.name = c.reference_name
|
||||
and b.name = c.parent
|
||||
and b.docstatus = 1
|
||||
and a.name in ({invoice_list_names})
|
||||
group by a.owner, a.posting_date, mode_of_payment
|
||||
union
|
||||
select a.owner, a.posting_date,
|
||||
ifnull(a.voucher_type,'') as mode_of_payment, sum(b.credit)
|
||||
from `tabJournal Entry` a, `tabJournal Entry Account` b
|
||||
where a.name = b.parent
|
||||
and a.docstatus = 1
|
||||
and b.reference_type = 'Sales Invoice'
|
||||
and b.reference_name in ({invoice_list_names})
|
||||
group by a.owner, a.posting_date, mode_of_payment
|
||||
) t
|
||||
group by t.owner, t.posting_date, t.mode_of_payment
|
||||
""",
|
||||
as_dict=1,
|
||||
# Branch 1: amounts paid directly on the Sales Invoice
|
||||
si1 = frappe.qb.DocType("Sales Invoice")
|
||||
sip = frappe.qb.DocType("Sales Invoice Payment")
|
||||
mop1 = Coalesce(sip.mode_of_payment, "")
|
||||
branch1 = (
|
||||
frappe.qb.from_(si1)
|
||||
.join(sip)
|
||||
.on(si1.name == sip.parent)
|
||||
.select(
|
||||
si1.owner,
|
||||
si1.posting_date,
|
||||
mop1.as_("mode_of_payment"),
|
||||
Sum(sip.base_amount).as_("paid_amount"),
|
||||
)
|
||||
.where(si1.docstatus == 1)
|
||||
.where(si1.name.isin(invoice_names))
|
||||
.groupby(si1.owner, si1.posting_date, mop1)
|
||||
)
|
||||
|
||||
inv_change_amount = frappe.db.sql(
|
||||
f"""select a.owner, a.posting_date,
|
||||
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(a.base_change_amount) as change_amount
|
||||
from `tabSales Invoice` a, `tabSales Invoice Payment` b
|
||||
where a.name = b.parent
|
||||
and a.name in ({invoice_list_names})
|
||||
and b.type = 'Cash'
|
||||
and a.base_change_amount > 0
|
||||
group by a.owner, a.posting_date, mode_of_payment""",
|
||||
as_dict=1,
|
||||
# Branch 2: amounts allocated via Payment Entry
|
||||
si2 = frappe.qb.DocType("Sales Invoice")
|
||||
pe = frappe.qb.DocType("Payment Entry")
|
||||
per = frappe.qb.DocType("Payment Entry Reference")
|
||||
mop2 = Coalesce(pe.mode_of_payment, "")
|
||||
branch2 = (
|
||||
frappe.qb.from_(si2)
|
||||
.join(per)
|
||||
.on(si2.name == per.reference_name)
|
||||
.join(pe)
|
||||
.on(pe.name == per.parent)
|
||||
.select(
|
||||
si2.owner,
|
||||
si2.posting_date,
|
||||
mop2.as_("mode_of_payment"),
|
||||
Sum(per.allocated_amount).as_("paid_amount"),
|
||||
)
|
||||
.where(pe.docstatus == 1)
|
||||
.where(si2.name.isin(invoice_names))
|
||||
.groupby(si2.owner, si2.posting_date, mop2)
|
||||
)
|
||||
|
||||
# Branch 3: amounts credited via Journal Entry
|
||||
je = frappe.qb.DocType("Journal Entry")
|
||||
jea = frappe.qb.DocType("Journal Entry Account")
|
||||
mop3 = Coalesce(je.voucher_type, "")
|
||||
branch3 = (
|
||||
frappe.qb.from_(je)
|
||||
.join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(
|
||||
je.owner, je.posting_date, mop3.as_("mode_of_payment"), Sum(jea.credit).as_("paid_amount")
|
||||
)
|
||||
.where(je.docstatus == 1)
|
||||
.where(jea.reference_type == "Sales Invoice")
|
||||
.where(jea.reference_name.isin(invoice_names))
|
||||
.groupby(je.owner, je.posting_date, mop3)
|
||||
)
|
||||
|
||||
# bare UNION => de-duplicated rows; wrapped as subquery `t` for the outer re-aggregation
|
||||
t = branch1.union(branch2).union(branch3)
|
||||
inv_mop_detail = (
|
||||
frappe.qb.from_(t)
|
||||
.select(
|
||||
t.owner,
|
||||
t.posting_date,
|
||||
t.mode_of_payment,
|
||||
Sum(t.paid_amount).as_("paid_amount"),
|
||||
)
|
||||
.groupby(t.owner, t.posting_date, t.mode_of_payment)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
# change amount paid back in cash, subtracted from the matching mode-of-payment detail below
|
||||
sic = frappe.qb.DocType("Sales Invoice")
|
||||
sipc = frappe.qb.DocType("Sales Invoice Payment")
|
||||
mopc = Coalesce(sipc.mode_of_payment, "")
|
||||
inv_change_amount = (
|
||||
frappe.qb.from_(sic)
|
||||
.join(sipc)
|
||||
.on(sic.name == sipc.parent)
|
||||
.select(
|
||||
sic.owner,
|
||||
sic.posting_date,
|
||||
mopc.as_("mode_of_payment"),
|
||||
Sum(sic.base_change_amount).as_("change_amount"),
|
||||
)
|
||||
.where(sic.name.isin(invoice_names))
|
||||
.where(sipc.type == "Cash")
|
||||
.where(sic.base_change_amount > 0)
|
||||
.groupby(sic.owner, sic.posting_date, mopc)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
for d in inv_change_amount:
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
from frappe.utils import flt, today
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.report.sales_payment_summary.sales_payment_summary import (
|
||||
get_mode_of_payment_details,
|
||||
get_mode_of_payments,
|
||||
get_pos_invoice_data,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -102,6 +103,33 @@ class TestSalesPaymentSummary(ERPNextTestSuite):
|
||||
|
||||
self.assertGreater(cc_init_amount, cc_final_amount)
|
||||
|
||||
def test_get_pos_invoice_data(self):
|
||||
"""The POS path (is_pos filter -> get_pos_invoice_data) used nested loose-GROUP-BY subqueries
|
||||
that raised on Postgres; it now aggregates deterministically and runs identically on both
|
||||
engines."""
|
||||
si = create_sales_invoice_record()
|
||||
si.is_pos = 1
|
||||
si.append(
|
||||
"payments",
|
||||
{"mode_of_payment": "Cash", "account": "_Test Cash - _TC", "amount": 10000},
|
||||
)
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
filters = frappe._dict(
|
||||
{"is_pos": 1, "company": "_Test Company", "from_date": today(), "to_date": today()}
|
||||
)
|
||||
data = get_pos_invoice_data(filters)
|
||||
|
||||
# the POS invoice's paid amount is aggregated; previously this query raised GroupingError on PG
|
||||
self.assertTrue(data)
|
||||
self.assertTrue(any(flt(row.get("paid_amount")) >= 10000 for row in data))
|
||||
|
||||
# customer filter must work: a.customer was not selected by the invoice subquery before the fix,
|
||||
# so the filter errored on both engines. With the invoice's customer it still returns its payment.
|
||||
filters["customer"] = si.customer
|
||||
self.assertTrue(any(flt(row.get("paid_amount")) >= 10000 for row in get_pos_invoice_data(filters)))
|
||||
|
||||
|
||||
def get_filters():
|
||||
return {"from_date": "1900-01-01", "to_date": today(), "company": "_Test Company"}
|
||||
|
||||
@@ -347,14 +347,17 @@ def get_account_columns(invoice_list, include_payments):
|
||||
|
||||
if invoice_list:
|
||||
# frappe drops ORDER BY for distinct queries on postgres (db_query), so sort in python to keep
|
||||
# the generated account-column order deterministic and identical on both backends.
|
||||
# the generated account-column order deterministic and identical on both backends. casefold
|
||||
# reproduces MariaDB's case-insensitive collation order (the original raw SQL ORDER BY); plain
|
||||
# sorted() would be case-sensitive and reorder columns vs the pre-effort MariaDB output.
|
||||
income_accounts = sorted(
|
||||
frappe.get_all(
|
||||
"Sales Invoice Item",
|
||||
filters={"docstatus": 1, "parent": ["in", [inv.name for inv in invoice_list]]},
|
||||
pluck="income_account",
|
||||
distinct=True,
|
||||
)
|
||||
),
|
||||
key=str.casefold,
|
||||
)
|
||||
|
||||
sales_taxes_query = get_taxes_query(invoice_list, "Sales Taxes and Charges", "Sales Invoice")
|
||||
@@ -377,7 +380,8 @@ def get_account_columns(invoice_list, include_payments):
|
||||
},
|
||||
pluck="unrealized_profit_loss_account",
|
||||
distinct=True,
|
||||
)
|
||||
),
|
||||
key=str.casefold,
|
||||
)
|
||||
|
||||
for account in income_accounts:
|
||||
|
||||
@@ -55,6 +55,39 @@ class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
|
||||
si = si.submit()
|
||||
return si
|
||||
|
||||
def _ensure_income_account(self, account_name):
|
||||
name = f"{account_name} - _TC"
|
||||
if not frappe.db.exists("Account", name):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Account",
|
||||
"account_name": account_name,
|
||||
"parent_account": "Income - _TC",
|
||||
"company": self.company,
|
||||
"root_type": "Income",
|
||||
"report_type": "Profit and Loss",
|
||||
"account_type": "Income Account",
|
||||
}
|
||||
).insert()
|
||||
return name
|
||||
|
||||
def test_income_account_columns_sorted_case_insensitively(self):
|
||||
# The dynamic income-account columns must follow MariaDB's case-insensitive collation order and
|
||||
# be identical on both engines. Plain python sorted() is case-sensitive (ASCII), so "ZZZ" would
|
||||
# sort before "aaa"; casefold restores the pre-effort MariaDB order on both backends.
|
||||
lower = self._ensure_income_account("aaa Test Income")
|
||||
upper = self._ensure_income_account("ZZZ Test Income")
|
||||
for account in (upper, lower): # submit in non-casefold order
|
||||
si = self.create_sales_invoice(do_not_submit=True)
|
||||
si.items[0].income_account = account
|
||||
si.submit()
|
||||
|
||||
filters = frappe._dict({"from_date": today(), "to_date": today(), "company": self.company})
|
||||
columns = execute(filters)[0]
|
||||
labels = [col["label"] for col in columns if col.get("label") in (lower, upper)]
|
||||
|
||||
self.assertEqual(labels, sorted([lower, upper], key=str.casefold))
|
||||
|
||||
def test_basic_report_output(self):
|
||||
si = self.create_sales_invoice(rate=98)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"name": "Trial Balance (Simple)",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"query": "select fiscal_year as \"Fiscal Year:Data:80\",\n\tcompany as \"Company:Data:220\",\n\tposting_date as \"Posting Date:Date:100\",\n\taccount as \"Account:Data:380\",\n\tsum(debit) as \"Debit:Currency:140\",\n\tsum(credit) as \"Credit:Currency:140\",\n\tfinance_book as \"Finance Book:Link/Finance Book:140\"\nfrom `tabGL Entry`\nwhere is_cancelled = 0 and company = %(company)s\ngroup by fiscal_year, company, posting_date, account\norder by fiscal_year, company, posting_date, account",
|
||||
"query": "select fiscal_year as \"Fiscal Year:Data:80\",\n\tcompany as \"Company:Data:220\",\n\tposting_date as \"Posting Date:Date:100\",\n\taccount as \"Account:Data:380\",\n\tsum(debit) as \"Debit:Currency:140\",\n\tsum(credit) as \"Credit:Currency:140\",\n\tmax(finance_book) as \"Finance Book:Link/Finance Book:140\"\nfrom `tabGL Entry`\nwhere is_cancelled = 0 and company = %(company)s\ngroup by fiscal_year, company, posting_date, account\norder by fiscal_year, company, posting_date, account",
|
||||
"ref_doctype": "GL Entry",
|
||||
"report_name": "Trial Balance (Simple)",
|
||||
"report_type": "Query Report",
|
||||
|
||||
@@ -146,7 +146,6 @@ def get_appropriate_company(filters):
|
||||
return company
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_invoiced_item_gross_margin(
|
||||
sales_invoice: str | None = None,
|
||||
item_code: str | None = None,
|
||||
|
||||
@@ -12,7 +12,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Abs, Max, Sum
|
||||
from frappe.query_builder.functions import Abs, IfNull, Max, Sum
|
||||
from frappe.utils import flt
|
||||
|
||||
import erpnext
|
||||
@@ -481,8 +481,18 @@ def get_common_query(
|
||||
if condition.get("cost_center"):
|
||||
common_filter_conditions.append(payment_entry.cost_center == condition["cost_center"])
|
||||
if condition.get("accounting_dimensions"):
|
||||
apply_strict_user_permissions = frappe.get_system_settings("apply_strict_user_permissions")
|
||||
for field, val in condition.get("accounting_dimensions").items():
|
||||
common_filter_conditions.append(payment_entry[field] == val)
|
||||
if isinstance(val, list | tuple | set):
|
||||
value_condition = payment_entry[field].isin(val)
|
||||
if apply_strict_user_permissions:
|
||||
common_filter_conditions.append(value_condition)
|
||||
else:
|
||||
common_filter_conditions.append(
|
||||
(IfNull(payment_entry[field], "") == "") | value_condition
|
||||
)
|
||||
else:
|
||||
common_filter_conditions.append(payment_entry[field] == val)
|
||||
if condition.get("minimum_payment_amount"):
|
||||
common_filter_conditions.append(
|
||||
payment_entry.unallocated_amount.gte(condition["minimum_payment_amount"])
|
||||
|
||||
@@ -37,25 +37,22 @@ def validate_disabled_accounts(gl_map):
|
||||
|
||||
|
||||
def validate_accounting_period(gl_map):
|
||||
accounting_periods = frappe.db.sql(
|
||||
""" SELECT
|
||||
ap.name as name, ap.exempted_role as exempted_role
|
||||
FROM
|
||||
`tabAccounting Period` ap, `tabClosed Document` cd
|
||||
WHERE
|
||||
ap.name = cd.parent
|
||||
AND ap.company = %(company)s
|
||||
AND ap.disabled = 0
|
||||
AND cd.closed = 1
|
||||
AND cd.document_type = %(voucher_type)s
|
||||
AND %(date)s between ap.start_date and ap.end_date
|
||||
""",
|
||||
{
|
||||
"date": gl_map[0].posting_date,
|
||||
"company": gl_map[0].company,
|
||||
"voucher_type": gl_map[0].voucher_type,
|
||||
},
|
||||
as_dict=1,
|
||||
ap = frappe.qb.DocType("Accounting Period")
|
||||
cd = frappe.qb.DocType("Closed Document")
|
||||
accounting_periods = (
|
||||
frappe.qb.from_(ap)
|
||||
.inner_join(cd)
|
||||
.on(ap.name == cd.parent)
|
||||
.select(ap.name.as_("name"), ap.exempted_role.as_("exempted_role"))
|
||||
.where(
|
||||
(ap.company == gl_map[0].company)
|
||||
& (ap.disabled == 0)
|
||||
& (cd.closed == 1)
|
||||
& (cd.document_type == gl_map[0].voucher_type)
|
||||
& (ap.start_date <= gl_map[0].posting_date)
|
||||
& (ap.end_date >= gl_map[0].posting_date)
|
||||
)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
if accounting_periods:
|
||||
@@ -81,13 +78,11 @@ def validate_cwip_accounts(gl_map):
|
||||
for ac in frappe.db.get_all("Asset Category", "enable_cwip_accounting")
|
||||
)
|
||||
if cwip_enabled:
|
||||
cwip_accounts = [
|
||||
d[0]
|
||||
for d in frappe.db.sql(
|
||||
"""select name from tabAccount
|
||||
where account_type = 'Capital Work in Progress' and is_group=0"""
|
||||
)
|
||||
]
|
||||
cwip_accounts = frappe.get_all(
|
||||
"Account",
|
||||
filters={"account_type": "Capital Work in Progress", "is_group": 0},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
for entry in gl_map:
|
||||
if entry.account in cwip_accounts:
|
||||
@@ -122,13 +117,24 @@ def check_freezing_date(posting_date, company, adv_adj=False):
|
||||
)
|
||||
|
||||
|
||||
def validate_against_pcv(is_opening, posting_date, company):
|
||||
if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
|
||||
def validate_opening_entry_against_pcv(company):
|
||||
if frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
|
||||
frappe.throw(
|
||||
_("Opening Entry can not be created after Period Closing Voucher is created."),
|
||||
_(
|
||||
"A Period Closing Voucher is already submitted and an Opening Entry can no longer be created. {0} to learn more."
|
||||
).format(
|
||||
'<a href="https://docs.frappe.io/erpnext/period-closing-voucher#14-pcv-and-opening-entries" target="_blank" rel="noopener">'
|
||||
+ _("Read the docs")
|
||||
+ "</a>"
|
||||
),
|
||||
title=_("Invalid Opening Entry"),
|
||||
)
|
||||
|
||||
|
||||
def validate_against_pcv(is_opening, posting_date, company):
|
||||
if is_opening:
|
||||
validate_opening_entry_against_pcv(company)
|
||||
|
||||
last_pcv_date = frappe.db.get_value(
|
||||
"Period Closing Voucher", {"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}]
|
||||
)
|
||||
|
||||
@@ -302,6 +302,7 @@ def linked_order_has_payment_schedule(po_or_so) -> list:
|
||||
return frappe.get_all("Payment Schedule", filters={"parent": po_or_so})
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_terms(
|
||||
terms_template: str,
|
||||
posting_date: DateTimeLikeObject | None = None,
|
||||
|
||||
@@ -177,6 +177,7 @@ class TaxService:
|
||||
return amount, base_amount
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_tax_rate(account_head: str) -> dict:
|
||||
return frappe.get_cached_value("Account", account_head, ["tax_rate", "account_name"], as_dict=True)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from frappe.desk.reportview import build_match_conditions
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.model.naming import determine_consecutive_week_number
|
||||
from frappe.query_builder import AliasedQuery, Case, Criterion, Field, Table
|
||||
from frappe.query_builder.functions import Count, IfNull, Max, Round, Sum
|
||||
from frappe.query_builder.functions import Count, IfNull, Max, Min, Round, Sum
|
||||
from frappe.query_builder.utils import DocType
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
@@ -411,10 +411,9 @@ def get_count_on(account, fieldname, date):
|
||||
else:
|
||||
dr_or_cr = "debit" if fieldname == "invoiced_amount" else "credit"
|
||||
cr_or_dr = "credit" if fieldname == "invoiced_amount" else "debit"
|
||||
select_fields = (
|
||||
"ifnull(sum(credit-debit),0)"
|
||||
if fieldname == "invoiced_amount"
|
||||
else "ifnull(sum(debit-credit),0)"
|
||||
gl = frappe.qb.DocType("GL Entry")
|
||||
amount_expr = (
|
||||
Sum(gl.credit - gl.debit) if fieldname == "invoiced_amount" else Sum(gl.debit - gl.credit)
|
||||
)
|
||||
|
||||
if (
|
||||
@@ -422,14 +421,21 @@ def get_count_on(account, fieldname, date):
|
||||
or (gle.against_voucher_type in ["Sales Order", "Purchase Order"])
|
||||
or (gle.against_voucher == gle.voucher_no and gle.get(dr_or_cr) > 0)
|
||||
):
|
||||
payment_amount = frappe.db.sql(
|
||||
f"""
|
||||
SELECT {select_fields}
|
||||
FROM `tabGL Entry` gle
|
||||
WHERE docstatus < 2 and posting_date <= %(date)s and against_voucher = %(voucher_no)s
|
||||
and party = %(party)s and name != %(name)s""",
|
||||
{"date": date, "voucher_no": gle.voucher_no, "party": gle.party, "name": gle.name},
|
||||
)[0][0]
|
||||
payment_amount = (
|
||||
(
|
||||
frappe.qb.from_(gl)
|
||||
.select(amount_expr)
|
||||
.where(
|
||||
(gl.docstatus < 2)
|
||||
& (gl.posting_date <= date)
|
||||
& (gl.against_voucher == gle.voucher_no)
|
||||
& (gl.party == gle.party)
|
||||
& (gl.name != gle.name)
|
||||
)
|
||||
.run()[0][0]
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
outstanding_amount = flt(gle.get(dr_or_cr)) - flt(gle.get(cr_or_dr)) - payment_amount
|
||||
currency_precision = get_currency_precision() or 2
|
||||
@@ -1169,26 +1175,27 @@ def get_company_default(company: str, fieldname: str, ignore_validation: bool =
|
||||
|
||||
|
||||
def fix_total_debit_credit():
|
||||
vouchers = frappe.db.sql(
|
||||
"""select voucher_type, voucher_no,
|
||||
sum(debit) - sum(credit) as diff
|
||||
from `tabGL Entry`
|
||||
group by voucher_type, voucher_no
|
||||
having sum(debit) != sum(credit)""",
|
||||
as_dict=1,
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
vouchers = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(gle.voucher_type, gle.voucher_no, (Sum(gle.debit) - Sum(gle.credit)).as_("diff"))
|
||||
.groupby(gle.voucher_type, gle.voucher_no)
|
||||
.having(Sum(gle.debit) != Sum(gle.credit))
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
for d in vouchers:
|
||||
if abs(d.diff) > 0:
|
||||
dr_or_cr = d.voucher_type == "Sales Invoice" and "credit" or "debit"
|
||||
|
||||
frappe.db.sql(
|
||||
"""update `tabGL Entry` set {} = {} + {}
|
||||
where voucher_type = {} and voucher_no = {} and {} > 0 limit 1""".format(
|
||||
dr_or_cr, dr_or_cr, "%s", "%s", "%s", dr_or_cr
|
||||
),
|
||||
(d.diff, d.voucher_type, d.voucher_no),
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
name = frappe.db.get_value(
|
||||
"GL Entry",
|
||||
{"voucher_type": d.voucher_type, "voucher_no": d.voucher_no, dr_or_cr: [">", 0]},
|
||||
"name",
|
||||
)
|
||||
if name:
|
||||
frappe.qb.update(gle).set(gle[dr_or_cr], gle[dr_or_cr] + d.diff).where(gle.name == name).run()
|
||||
|
||||
|
||||
def get_currency_precision():
|
||||
@@ -1230,11 +1237,12 @@ def get_held_invoices(party_type, party):
|
||||
held_invoices = None
|
||||
|
||||
if party_type == "Supplier":
|
||||
held_invoices = frappe.db.sql(
|
||||
"select name from `tabPurchase Invoice` where on_hold = 1 and release_date IS NOT NULL and release_date > CURDATE()",
|
||||
as_dict=1,
|
||||
held_invoices = frappe.get_all(
|
||||
"Purchase Invoice",
|
||||
filters={"on_hold": 1, "release_date": [">", nowdate()]},
|
||||
pluck="name",
|
||||
)
|
||||
held_invoices = set(d["name"] for d in held_invoices)
|
||||
held_invoices = set(held_invoices)
|
||||
|
||||
return held_invoices
|
||||
|
||||
@@ -1742,13 +1750,15 @@ def sort_stock_vouchers_by_posting_date(
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
voucher_nos = [v[1] for v in stock_vouchers]
|
||||
|
||||
# only voucher_type/voucher_no are used downstream; order by Min() of the (per-voucher constant)
|
||||
# posting_datetime so postgres accepts the GROUP BY without selecting non-aggregated columns
|
||||
sles = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(sle.voucher_type, sle.voucher_no, sle.posting_date, sle.posting_time, sle.creation)
|
||||
.select(sle.voucher_type, sle.voucher_no)
|
||||
.where((sle.is_cancelled == 0) & (sle.voucher_no.isin(voucher_nos)))
|
||||
.groupby(sle.voucher_type, sle.voucher_no)
|
||||
.orderby(sle.posting_datetime)
|
||||
.orderby(sle.creation)
|
||||
.orderby(Min(sle.posting_datetime))
|
||||
.orderby(Min(sle.creation))
|
||||
)
|
||||
|
||||
if company:
|
||||
@@ -1769,25 +1779,37 @@ def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, f
|
||||
|
||||
SLE = DocType("Stock Ledger Entry")
|
||||
|
||||
conditions = (SLE.posting_datetime >= posting_datetime) & (SLE.is_cancelled == 0)
|
||||
if for_items:
|
||||
conditions &= SLE.item_code.isin(for_items)
|
||||
if for_warehouses:
|
||||
conditions &= SLE.warehouse.isin(for_warehouses)
|
||||
if company:
|
||||
conditions &= SLE.company == company
|
||||
|
||||
# These SLE rows must stay locked for the duration of the repost so a concurrent stock
|
||||
# transaction can't modify them mid-flight (the original DISTINCT ... FOR UPDATE did this).
|
||||
# MariaDB carries the lock on the grouped query below; postgres rejects FOR UPDATE alongside
|
||||
# GROUP BY, so lock the matching rows in a separate pass first -- the row locks are held until
|
||||
# the surrounding transaction ends, giving the same protection.
|
||||
if frappe.db.db_type == "postgres":
|
||||
frappe.qb.from_(SLE).select(SLE.name).where(conditions).for_update().run()
|
||||
|
||||
# distinct vouchers in chronological order; expressed as GROUP BY + Min() so it's valid on
|
||||
# postgres (SELECT DISTINCT can't ORDER BY non-selected cols, and FOR UPDATE is invalid with both).
|
||||
# posting_datetime is constant per voucher, so the ordering is unchanged vs the DISTINCT form.
|
||||
query = (
|
||||
frappe.qb.from_(SLE)
|
||||
.select(SLE.voucher_type, SLE.voucher_no)
|
||||
.distinct()
|
||||
.where(SLE.posting_datetime >= posting_datetime)
|
||||
.where(SLE.is_cancelled == 0)
|
||||
.orderby(SLE.posting_datetime)
|
||||
.orderby(SLE.creation)
|
||||
.for_update()
|
||||
.where(conditions)
|
||||
.groupby(SLE.voucher_type, SLE.voucher_no)
|
||||
.orderby(Min(SLE.posting_datetime))
|
||||
.orderby(Min(SLE.creation))
|
||||
)
|
||||
|
||||
if for_items:
|
||||
query = query.where(SLE.item_code.isin(for_items))
|
||||
|
||||
if for_warehouses:
|
||||
query = query.where(SLE.warehouse.isin(for_warehouses))
|
||||
|
||||
if company:
|
||||
query = query.where(SLE.company == company)
|
||||
# lock scanned rows on MariaDB; on postgres they were already locked above
|
||||
if frappe.db.db_type != "postgres":
|
||||
query = query.for_update()
|
||||
|
||||
future_stock_vouchers = query.run(as_dict=True)
|
||||
|
||||
@@ -1809,14 +1831,11 @@ def get_voucherwise_gl_entries(future_stock_vouchers, posting_date):
|
||||
|
||||
voucher_nos = [d[1] for d in future_stock_vouchers]
|
||||
|
||||
gles = frappe.db.sql(
|
||||
"""
|
||||
select name, account, credit, debit, cost_center, project, voucher_type, voucher_no
|
||||
from `tabGL Entry`
|
||||
where
|
||||
posting_date >= {} and voucher_no in ({})""".format("%s", ", ".join(["%s"] * len(voucher_nos))),
|
||||
tuple([posting_date, *voucher_nos]),
|
||||
as_dict=1,
|
||||
gles = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"posting_date": [">=", posting_date], "voucher_no": ["in", voucher_nos]},
|
||||
fields=["name", "account", "credit", "debit", "cost_center", "project", "voucher_type", "voucher_no"],
|
||||
limit=0,
|
||||
)
|
||||
|
||||
for d in gles:
|
||||
@@ -2235,7 +2254,7 @@ def delink_original_entry(pl_entry, partial_cancel=False):
|
||||
qb.update(ple)
|
||||
.set(ple.modified, now())
|
||||
.set(ple.modified_by, frappe.session.user)
|
||||
.set(ple.delinked, True)
|
||||
.set(ple.delinked, 1) # smallint column; postgres rejects boolean true
|
||||
.where(
|
||||
(ple.company == pl_entry.company)
|
||||
& (ple.account_type == pl_entry.account_type)
|
||||
@@ -2350,8 +2369,10 @@ class QueryPaymentLedger:
|
||||
.where(Criterion.all(self.dimensions_filter))
|
||||
.where(Criterion.all(self.voucher_posting_date))
|
||||
.groupby(ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
|
||||
.orderby(ple.invoice_date, ple.voucher_no)
|
||||
.having(qb.Field("amount_in_account_currency") > 0)
|
||||
# order by the select aliases (postgres can't ORDER BY a non-existent ple column)
|
||||
.orderby(qb.Field("invoice_date"), qb.Field("voucher_no"))
|
||||
# postgres HAVING can't reference a select alias; use the aggregate expression
|
||||
.having(Sum(ple.amount_in_account_currency) > 0)
|
||||
.limit(self.limit)
|
||||
.run()
|
||||
)
|
||||
@@ -2365,18 +2386,21 @@ class QueryPaymentLedger:
|
||||
query_voucher_amount = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
ple.account,
|
||||
# columns that are constant per (voucher_type, voucher_no, party_type, party) are
|
||||
# wrapped in Max() so the query is valid on postgres (which, unlike MariaDB, requires
|
||||
# every non-aggregated column to be grouped or aggregated)
|
||||
Max(ple.account).as_("account"),
|
||||
ple.voucher_type,
|
||||
ple.voucher_no,
|
||||
ple.party_type,
|
||||
ple.party,
|
||||
ple.posting_date,
|
||||
ple.due_date,
|
||||
ple.account_currency.as_("currency"),
|
||||
ple.cost_center.as_("cost_center"),
|
||||
Max(ple.posting_date).as_("posting_date"),
|
||||
Max(ple.due_date).as_("due_date"),
|
||||
Max(ple.account_currency).as_("currency"),
|
||||
Max(ple.cost_center).as_("cost_center"),
|
||||
Sum(ple.amount).as_("amount"),
|
||||
Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
|
||||
ple.remarks,
|
||||
Max(ple.remarks).as_("remarks"),
|
||||
)
|
||||
.where(ple.delinked == 0)
|
||||
.where(Criterion.all(filter_on_voucher_no))
|
||||
@@ -2390,14 +2414,15 @@ class QueryPaymentLedger:
|
||||
query_voucher_outstanding = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
ple.account,
|
||||
# Max() on columns constant per group keeps this valid on postgres (see above)
|
||||
Max(ple.account).as_("account"),
|
||||
ple.against_voucher_type.as_("voucher_type"),
|
||||
ple.against_voucher_no.as_("voucher_no"),
|
||||
ple.party_type,
|
||||
ple.party,
|
||||
ple.posting_date,
|
||||
ple.due_date,
|
||||
ple.account_currency.as_("currency"),
|
||||
Max(ple.posting_date).as_("posting_date"),
|
||||
Max(ple.due_date).as_("due_date"),
|
||||
Max(ple.account_currency).as_("currency"),
|
||||
Sum(ple.amount).as_("amount"),
|
||||
Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
|
||||
)
|
||||
@@ -2446,17 +2471,19 @@ class QueryPaymentLedger:
|
||||
|
||||
# build CTE filter
|
||||
# only fetch invoices
|
||||
# The combined CTE query has no GROUP BY, so these are row filters. MariaDB tolerates HAVING
|
||||
# on a select alias here, but postgres does not; express them as WHERE on the source column.
|
||||
if self.get_invoices:
|
||||
self.cte_query_voucher_amount_and_outstanding = (
|
||||
self.cte_query_voucher_amount_and_outstanding.having(
|
||||
qb.Field("outstanding_in_account_currency") > 0
|
||||
self.cte_query_voucher_amount_and_outstanding.where(
|
||||
Table("outstanding").amount_in_account_currency > 0
|
||||
)
|
||||
)
|
||||
# only fetch payments
|
||||
elif self.get_payments:
|
||||
self.cte_query_voucher_amount_and_outstanding = (
|
||||
self.cte_query_voucher_amount_and_outstanding.having(
|
||||
qb.Field("outstanding_in_account_currency") < 0
|
||||
self.cte_query_voucher_amount_and_outstanding.where(
|
||||
Table("outstanding").amount_in_account_currency < 0
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -735,12 +735,16 @@ class Asset(AccountsController):
|
||||
frappe.throw(_("Asset cannot be cancelled, as it is already {0}").format(self.status))
|
||||
|
||||
def cancel_movement_entries(self):
|
||||
movements = frappe.db.sql(
|
||||
"""SELECT asm.name, asm.docstatus
|
||||
FROM `tabAsset Movement` asm, `tabAsset Movement Item` asm_item
|
||||
WHERE asm_item.parent=asm.name and asm_item.asset=%s and asm.docstatus=1""",
|
||||
self.name,
|
||||
as_dict=1,
|
||||
# filter the parent Asset Movement's docstatus (as the original SQL did), not the child row's
|
||||
asm = frappe.qb.DocType("Asset Movement")
|
||||
asm_item = frappe.qb.DocType("Asset Movement Item")
|
||||
movements = (
|
||||
frappe.qb.from_(asm_item)
|
||||
.inner_join(asm)
|
||||
.on(asm_item.parent == asm.name)
|
||||
.select(asm.name)
|
||||
.where((asm_item.asset == self.name) & (asm.docstatus == 1))
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
for movement in movements:
|
||||
@@ -860,15 +864,18 @@ class Asset(AccountsController):
|
||||
cwip_enabled = is_cwip_accounting_enabled(self.asset_category)
|
||||
cwip_account = self.get_cwip_account(cwip_enabled=cwip_enabled)
|
||||
|
||||
query = """SELECT name FROM `tabGL Entry` WHERE voucher_no = %s and account = %s"""
|
||||
if asset_bought_with_invoice:
|
||||
# with invoice purchase either expense or cwip has been booked
|
||||
expense_booked = frappe.db.sql(query, (purchase_document, fixed_asset_account), as_dict=1)
|
||||
expense_booked = frappe.db.exists(
|
||||
"GL Entry", {"voucher_no": purchase_document, "account": fixed_asset_account}
|
||||
)
|
||||
if expense_booked:
|
||||
# if expense is already booked from invoice then do not make gl entries regardless of cwip enabled/disabled
|
||||
return False
|
||||
|
||||
cwip_booked = frappe.db.sql(query, (purchase_document, cwip_account), as_dict=1)
|
||||
cwip_booked = frappe.db.exists(
|
||||
"GL Entry", {"voucher_no": purchase_document, "account": cwip_account}
|
||||
)
|
||||
if cwip_booked:
|
||||
# if cwip is booked from invoice then make gl entries regardless of cwip enabled/disabled
|
||||
return True
|
||||
@@ -878,10 +885,11 @@ class Asset(AccountsController):
|
||||
# if cwip account isn't available do not make gl entries
|
||||
return False
|
||||
|
||||
cwip_booked = frappe.db.sql(query, (purchase_document, cwip_account), as_dict=1)
|
||||
# if cwip is not booked from receipt then do not make gl entries
|
||||
# if cwip is booked from receipt then make gl entries
|
||||
return cwip_booked
|
||||
return bool(
|
||||
frappe.db.exists("GL Entry", {"voucher_no": purchase_document, "account": cwip_account})
|
||||
)
|
||||
|
||||
def get_purchase_document(self):
|
||||
asset_bought_with_invoice = self.purchase_invoice and frappe.db.get_value(
|
||||
@@ -1074,11 +1082,15 @@ def make_post_gl_entry():
|
||||
|
||||
for asset_category in asset_categories:
|
||||
if cint(asset_category.enable_cwip_accounting):
|
||||
assets = frappe.db.sql_list(
|
||||
""" select name from `tabAsset`
|
||||
where asset_category = %s and ifnull(booked_fixed_asset, 0) = 0
|
||||
and available_for_use_date = %s and docstatus = 1""",
|
||||
(asset_category.name, nowdate()),
|
||||
assets = frappe.get_all(
|
||||
"Asset",
|
||||
filters={
|
||||
"asset_category": asset_category.name,
|
||||
"booked_fixed_asset": 0,
|
||||
"available_for_use_date": nowdate(),
|
||||
"docstatus": 1,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
for asset in assets:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user