mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-23 11:09:41 +00:00
Compare commits
393 Commits
chore/paym
...
l10n_devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff737df55f | ||
|
|
50c4ee4ccb | ||
|
|
ad237e5ec5 | ||
|
|
fadad2d1c4 | ||
|
|
f026d1dac8 | ||
|
|
a1ed913eba | ||
|
|
3ed305c75c | ||
|
|
da4cf77d97 | ||
|
|
13f9130d42 | ||
|
|
98e8d5690e | ||
|
|
32216bd75b | ||
|
|
d694ad9428 | ||
|
|
a120bf8363 | ||
|
|
d48cffd1a5 | ||
|
|
624844d52f | ||
|
|
c24fc063fc | ||
|
|
43d2c7335d | ||
|
|
8f69697212 | ||
|
|
3f832d4ee0 | ||
|
|
d48a1e0d16 | ||
|
|
aa7402b1e3 | ||
|
|
a218b8db8c | ||
|
|
9b8c363bed | ||
|
|
23bbcca97e | ||
|
|
5008b82f90 | ||
|
|
9436ab7f19 | ||
|
|
c38bab7e5e | ||
|
|
eafb0019bf | ||
|
|
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 |
152
.github/POSTGRES_COMPATIBILITY.md
vendored
Normal file
152
.github/POSTGRES_COMPATIBILITY.md
vendored
Normal file
@@ -0,0 +1,152 @@
|
||||
# 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`). Fix: add it 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")` and
|
||||
similar — PostgreSQL folds unquoted identifiers to lower case; a stored column named
|
||||
`status` won't match `"Status"`. Use the exact stored case.
|
||||
- **Boolean passed where an integer column is expected** — `frappe.db.set_value(dt, dn,
|
||||
check_field, True)` emits `SET col = true`, which PostgreSQL rejects on a `smallint`
|
||||
(`DatatypeMismatch`). Pass `1`/`0`.
|
||||
|
||||
---
|
||||
|
||||
## 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** — `COUNT(...) / COUNT(...) * 100` truncates to `0` on PostgreSQL
|
||||
(integer/integer) but is decimal on MariaDB. Multiply by `100.0` first.
|
||||
- **`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.
|
||||
- **Raw `ifnull(...)`** inside `frappe.db.sql()` is rewritten to `coalesce(...)` on all engines.
|
||||
- **Backticks**, **`LOCATE`**, **`REGEXP`** in raw SQL are auto-translated on PostgreSQL.
|
||||
- **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.
|
||||
8
.github/helper/install.sh
vendored
8
.github/helper/install.sh
vendored
@@ -74,6 +74,14 @@ fi
|
||||
if [ "$DB" == "postgres" ];then
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
|
||||
# CI databases are disposable, so trade durability for speed: postgres fsyncs on every commit
|
||||
# by default, which dominates a commit-heavy test suite. These are all reload-time settings
|
||||
# (no restart needed). MariaDB CI is unaffected (DB != postgres).
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -U postgres \
|
||||
-c "ALTER SYSTEM SET synchronous_commit = 'off'" \
|
||||
-c "ALTER SYSTEM SET fsync = 'off'" \
|
||||
-c "ALTER SYSTEM SET full_page_writes = 'off'" \
|
||||
-c "SELECT pg_reload_conf()";
|
||||
fi
|
||||
|
||||
|
||||
|
||||
221
.github/helper/postgres_compat.py
vendored
Executable file
221
.github/helper/postgres_compat.py
vendored
Executable file
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static guard against MySQL-only SQL that breaks on PostgreSQL.
|
||||
|
||||
The Postgres test job is label-gated, so it does not run on every PR. This pre-commit
|
||||
hook is the always-on first line of defence: it flags the *mechanical* Postgres breaks
|
||||
that static analysis can catch reliably with a low false-positive rate.
|
||||
|
||||
It deliberately does NOT try to catch the *semantic* divergences (loose GROUP BY,
|
||||
case-sensitive ==/IN, NULL ordering, ORDER BY ... LIMIT 1 tiebreakers, integer-division
|
||||
intent, savepoint discipline) — those genuinely need the test suite. Run the full suite
|
||||
on a Postgres site for those.
|
||||
|
||||
Escape hatch: put `# pg-ok` anywhere on the offending statement's line span (e.g. on a
|
||||
`SHOW INDEX` query that lives inside an `if frappe.db.db_type == "mariadb":` branch).
|
||||
|
||||
Usage: postgres_compat.py <file.py> [<file.py> ...] (pre-commit passes staged files)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
|
||||
IGNORE = "pg-ok"
|
||||
|
||||
# Strings are only scanned for the patterns below when they have real SQL *structure*
|
||||
# (not just an English word like "select" or "from"), to keep false positives near zero.
|
||||
SQL_HINT = re.compile(
|
||||
r"\bselect\b[\s\S]{0,800}\bfrom\b" # SELECT ... FROM
|
||||
r"|\bupdate\b[\s\S]{0,400}\bset\b" # UPDATE ... SET
|
||||
r"|\bdelete\s+from\b"
|
||||
r"|\binsert\s+into\b"
|
||||
r"|\bshow\s+(?:index|tables|columns)\b"
|
||||
r"|\bfrom\s+[\"'`]?tab", # FROM `tabDocType`
|
||||
re.I,
|
||||
)
|
||||
|
||||
# MySQL-only constructs with NO frappe auto-translation. (frappe.db.sql already rewrites
|
||||
# ifnull->coalesce on all engines and backtick/locate/REGEXP on Postgres, and .like()
|
||||
# renders ILIKE — so those are NOT listed here; flagging them would be false positives.)
|
||||
SQL_PATTERNS: list[tuple[re.Pattern, str]] = [
|
||||
(re.compile(r"\btimestamp\s*\(\s*[^,()]+,", re.I),
|
||||
"timestamp(date, time) is MySQL-only -> use CombineDatetime() or a precomputed datetime column"),
|
||||
(re.compile(r"\btimediff\s*\(", re.I),
|
||||
"timediff() is MySQL-only -> compute the delta in Python"),
|
||||
(re.compile(r"\bstr_to_date\s*\(", re.I),
|
||||
"str_to_date() is MySQL-only -> parse in Python and pass a real date"),
|
||||
(re.compile(r"\bdate_format\s*\(", re.I),
|
||||
"date_format() is MySQL-only -> filter on a date range instead"),
|
||||
(re.compile(r"\bdate_(add|sub)\s*\(", re.I),
|
||||
"date_add()/date_sub() are MySQL-only -> use Python date math or interval arithmetic"),
|
||||
(re.compile(r"\bgroup_concat\s*\(", re.I),
|
||||
"group_concat() is MySQL-only -> use GroupConcat (string_agg) or aggregate in Python"),
|
||||
(re.compile(r"\bperiod_diff\s*\(", re.I),
|
||||
"period_diff() is MySQL-only -> compute in Python"),
|
||||
(re.compile(r"\bshow\s+index\b", re.I),
|
||||
"SHOW INDEX is MySQL-only -> use frappe.db.has_index() / get_column_index()"),
|
||||
(re.compile(r"\bshow\s+(tables|columns)\b", re.I),
|
||||
"SHOW TABLES/COLUMNS is MySQL-only -> use frappe.db.get_tables()/table_columns / information-schema helpers"),
|
||||
(re.compile(r"\bas\s+'[^']+'", re.I),
|
||||
"single-quoted column alias breaks on Postgres -> use a bare or double-quoted alias"),
|
||||
(re.compile(r"\bif\s*\(", re.I),
|
||||
"SQL IF() is MySQL-only -> use CASE WHEN ... THEN ... ELSE ... END (frappe.qb.Case())"),
|
||||
]
|
||||
|
||||
# UPDATE ... JOIN: both keywords in the same SQL string.
|
||||
UPDATE_JOIN = (re.compile(r"\bupdate\b", re.I), re.compile(r"\bjoin\b", re.I))
|
||||
|
||||
MYSQL_RESULT_KEYS = {"Column_name", "Key_name", "Seq_in_index", "Non_unique", "Index_type"}
|
||||
|
||||
SET_BOOL_FUNCS = {"set_value", "db_set"}
|
||||
|
||||
# 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)")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
14
.github/workflows/patch.yml
vendored
14
.github/workflows/patch.yml
vendored
@@ -119,6 +119,15 @@ jobs:
|
||||
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 +143,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 +164,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
|
||||
|
||||
|
||||
107
.github/workflows/server-tests-postgres.yml
vendored
107
.github/workflows/server-tests-postgres.yml
vendored
@@ -1,51 +1,80 @@
|
||||
name: Server (Postgres)
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [frappe-framework-change]
|
||||
pull_request:
|
||||
# 'labeled' is required so adding the 'postgres' label to an open PR triggers this run
|
||||
# (the job itself is gated on that label below)
|
||||
types: [opened, reopened, synchronize, labeled]
|
||||
paths-ignore:
|
||||
- '**.js'
|
||||
- '**.css'
|
||||
- '**.svg'
|
||||
- '**.md'
|
||||
- '**.html'
|
||||
- 'crowdin.yml'
|
||||
- '.coderabbit.yml'
|
||||
- '.mergify.yml'
|
||||
types: [opened, labelled, synchronize, reopened]
|
||||
schedule:
|
||||
# Run everday at midnight UTC / 5:30 IST
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
user:
|
||||
description: 'Frappe Framework repository user (add your username for forks)'
|
||||
required: true
|
||||
default: 'frappe'
|
||||
type: string
|
||||
branch:
|
||||
description: 'Frappe Framework branch'
|
||||
default: 'develop'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: server-postgres-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: ${{ contains(github.event.pull_request.labels.*.name, 'postgres') }}
|
||||
# Opt-in on PRs: only runs when the PR carries the 'postgres' label. Scheduled / manual /
|
||||
# framework-dispatch runs always execute (no PR labels to gate on).
|
||||
if: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'postgres') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
NODE_ENV: "production"
|
||||
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
container: [1]
|
||||
|
||||
name: Python Unit Tests
|
||||
matrix:
|
||||
container: [1, 2, 3, 4]
|
||||
|
||||
# Distinct from the MariaDB job's "Python Unit Tests" so its check contexts do NOT collide with
|
||||
# the required "Python Unit Tests (1..4)" status checks -- this keeps Postgres non-required for now.
|
||||
name: Postgres Unit Tests
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:13.3
|
||||
env:
|
||||
POSTGRES_PASSWORD: travis
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -104,15 +133,65 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Cache wkhtmltopdf
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/wkhtmltox.deb
|
||||
key: wkhtmltox-0.12.6.1-2-jammy-amd64
|
||||
|
||||
- name: Install
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
||||
env:
|
||||
DB: postgres
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
|
||||
|
||||
- name: Run Tests
|
||||
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator
|
||||
run: |
|
||||
cd ~/frappe-bench/
|
||||
coverage_flag=""
|
||||
if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
|
||||
bench --site test_site run-parallel-tests --lightmode --app erpnext \
|
||||
--total-builds ${{ strategy.job-total }} \
|
||||
--build-number ${{ matrix.container }} \
|
||||
$coverage_flag
|
||||
env:
|
||||
TYPE: server
|
||||
CI_BUILD_ID: ${{ github.run_id }}
|
||||
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
|
||||
|
||||
|
||||
- name: Show bench output
|
||||
if: ${{ always() }}
|
||||
run: cat ~/frappe-bench/bench_start.log || true
|
||||
|
||||
- name: Upload coverage data
|
||||
if: ${{ env.WITH_COVERAGE == 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-postgres-${{ matrix.container }}
|
||||
path: /home/runner/frappe-bench/sites/coverage.xml
|
||||
|
||||
coverage:
|
||||
name: Coverage Wrap Up
|
||||
needs: test
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: coverage-postgres-*
|
||||
|
||||
- name: Upload coverage data
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
name: Postgres
|
||||
flags: postgres
|
||||
# explicit glob: download-artifact extracts each shard into its own coverage-postgres-N/ dir
|
||||
files: coverage-postgres-*/coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
verbose: true
|
||||
|
||||
@@ -6,5 +6,20 @@
|
||||
"repos": [
|
||||
"frappe/frappe"
|
||||
]
|
||||
},
|
||||
"instructions": "ERPNext runs on both MariaDB and PostgreSQL from one codebase, but the PostgreSQL test job is label-gated and may not run on this PR, so review every new or changed database query (raw frappe.db.sql, frappe.qb, frappe.get_all/get_list/get_value, and report SQL) for cross-engine compatibility. PRIME RULE: MariaDB output must never change; PostgreSQL is bent to match MariaDB, never the reverse, so a change to the value, row count, or ordering MariaDB produced is a regression even if it looks more correct (the only accepted change is replacing an arbitrary/undefined result with a deterministic one, row count preserved, and it should be called out). Flag a changed query that (1) would ERROR on PostgreSQL: loose GROUP BY (selecting/ordering a column neither grouped nor aggregated), MySQL-only functions (TIMESTAMP(date,time), TIMEDIFF, STR_TO_DATE, DATE_FORMAT, DATE_ADD/DATE_SUB, GROUP_CONCAT, PERIOD_DIFF, SQL IF()), UPDATE..JOIN, HAVING on a SELECT alias, SELECT DISTINCT with an ORDER BY expr not in the select list, single-quoted column aliases, varchar bitwise OR, capital-cased identifiers in get_value(dt,dn,'Status'), or set_value/db_set of a Check field with a Python bool instead of 1/0; or (2) would SILENTLY DIVERGE across engines: case-sensitive ==/.isin()/Strpos on USER-ENTERED free-text columns such as Data/Small Text/Long Text but NOT on Link/Select/name columns where exact-case matching is intended (PostgreSQL is case-sensitive, use Lower() both sides), lowercasing a value used as a document-name lookup, empty-string vs NULL in Concat/Concat_ws, NULL ordering (PostgreSQL sorts NULLs last) in ORDER BY..LIMIT 1, integer division (multiply by 100.0), get_all(distinct=True, order_by=...) (frappe DROPS the ORDER BY for distinct queries on PostgreSQL, so sort in Python with key=str.casefold), an engine-specific function rewrite that does not match MariaDB on edge cases, or UnixTimestamp(date)/date-to-epoch math that is timezone-dependent (a strict epoch <= now bound is flaky on PostgreSQL). Also flag CATCH-AND-CONTINUE inserts: on PostgreSQL a failed insert aborts the WHOLE transaction (InFailedSqlTransaction), so code that swallows a duplicate/unique error and keeps going in the same transaction must wrap the fallible insert in frappe.db.savepoint(name) + rollback(save_point=name), unless it re-throws with no DB call before the throw or the insert uses ignore_if_duplicate=True or autoname='hash'. GROUP BY ROW-COUNT TRAP (most important): to make a loose GROUP BY PostgreSQL-valid, do NOT add a non-functionally-dependent column (the classic traps are the child/row primary key or an editable per-row field) to GROUP BY because that splits one row into N and changes the MariaDB row count; Max()/Min()-wrap it instead (row count preserved, value arbitrary to deterministic). Judge functional dependence by the SOURCE TABLE: a column from a master joined on the group key is FD and safe in GROUP BY, but a descriptive field on the transaction table (e.g. t1.supplier_name, t1.territory) is NOT FD and must be wrapped. Do NOT suggest changing a Max()-wrapped column to Sum() to make a number more correct, that changes MariaDB's value. DO NOT FLAG these false positives: .like()/['like'] (already ILIKE on PostgreSQL), raw ifnull/backticks/LOCATE/REGEXP inside frappe.db.sql (auto-translated by the framework), or an ORDER BY..LIMIT 1 tie where adding a tiebreaker would change MariaDB's current pick. Full catalog with examples and portable fixes is in .github/POSTGRES_COMPATIBILITY.md.",
|
||||
"customContext": {
|
||||
"files": [
|
||||
{
|
||||
"scope": [
|
||||
"**/*.py",
|
||||
"**/*.js",
|
||||
"**/*.sql",
|
||||
"**/report/**/*.json"
|
||||
],
|
||||
"path": ".github/POSTGRES_COMPATIBILITY.md",
|
||||
"description": "MariaDB <-> PostgreSQL parity rules for ERPNext: query constructs that error on PostgreSQL or silently diverge across the two engines, the GROUP BY row-count trap, the false positives not to flag, and the rule that MariaDB output must not change. Apply to every changed database query in this PR."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
@@ -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")
|
||||
|
||||
@@ -626,6 +626,8 @@ def get_account_details(
|
||||
party: str | None = None,
|
||||
rounding_loss_allowance: float = 0.0,
|
||||
):
|
||||
frappe.has_permission("Account", doc=account, throw=True)
|
||||
|
||||
if not (company and posting_date):
|
||||
frappe.throw(_("Company and Posting Date is mandatory"))
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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],
|
||||
@@ -3047,17 +3165,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3935,13 +4188,15 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
[deferred_account, 2022.47, 0.0, "2019-03-15"],
|
||||
]
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select account, debit, credit, posting_date
|
||||
from `tabGL Entry`
|
||||
where voucher_type='Journal Entry' and voucher_detail_no=%s and posting_date <= %s
|
||||
order by posting_date asc, account asc""",
|
||||
(si.items[0].name, si.posting_date),
|
||||
as_dict=1,
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"voucher_type": "Journal Entry",
|
||||
"voucher_detail_no": si.items[0].name,
|
||||
"posting_date": ["<=", si.posting_date],
|
||||
},
|
||||
fields=["account", "debit", "credit", "posting_date"],
|
||||
order_by="posting_date asc, account asc",
|
||||
)
|
||||
|
||||
for i, gle in enumerate(gl_entries):
|
||||
@@ -4583,7 +4838,8 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
{"account": "Temporary Opening - _TC", "debit": 0.0, "credit": 138.09, "is_opening": "Yes"},
|
||||
]
|
||||
self.assertEqual(len(actual), 4)
|
||||
self.assertEqual(expected, actual)
|
||||
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
|
||||
self.assertCountEqual(actual, expected)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"enable_common_party_accounting": True})
|
||||
def test_common_party_with_foreign_currency_jv(self):
|
||||
@@ -4888,7 +5144,7 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
|
||||
def test_pos_sales_invoice_creation_during_pos_invoice_mode(self):
|
||||
# Deleting all opening entry
|
||||
frappe.db.sql("delete from `tabPOS Opening Entry`")
|
||||
frappe.db.delete("POS Opening Entry")
|
||||
|
||||
with self.change_settings("POS Settings", {"invoice_type": "POS Invoice"}):
|
||||
pos_profile = make_pos_profile()
|
||||
@@ -5258,6 +5514,11 @@ def check_gl_entries(doc, voucher_no, expected_gle, posting_date, voucher_type="
|
||||
|
||||
doc.assertGreater(len(gl_entries), 0)
|
||||
|
||||
# MariaDB and Postgres collate `account` differently, so the DB ordering isn't portable;
|
||||
# sort both sides identically (by the compared values) before the positional check.
|
||||
gl_entries = sorted(gl_entries, key=lambda g: (g.account, g.debit, g.credit))
|
||||
expected_gle = sorted(expected_gle, key=lambda e: (e[0], e[1], e[2]))
|
||||
|
||||
for i, gle in enumerate(gl_entries):
|
||||
doc.assertEqual(expected_gle[i][0], gle.account)
|
||||
doc.assertEqual(expected_gle[i][1], gle.debit)
|
||||
@@ -5405,17 +5666,21 @@ def create_sales_invoice_against_cost_center(**args):
|
||||
|
||||
|
||||
def get_outstanding_amount(against_voucher_type, against_voucher, account, party, party_type):
|
||||
bal = flt(
|
||||
frappe.db.sql(
|
||||
"""
|
||||
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
|
||||
from `tabGL Entry`
|
||||
where against_voucher_type=%s and against_voucher=%s
|
||||
and account = %s and party = %s and party_type = %s""",
|
||||
(against_voucher_type, against_voucher, account, party, party_type),
|
||||
)[0][0]
|
||||
or 0.0
|
||||
balance = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"against_voucher_type": against_voucher_type,
|
||||
"against_voucher": against_voucher,
|
||||
"account": account,
|
||||
"party": party,
|
||||
"party_type": party_type,
|
||||
},
|
||||
fields=[
|
||||
{"SUM": "debit_in_account_currency", "as": "debit"},
|
||||
{"SUM": "credit_in_account_currency", "as": "credit"},
|
||||
],
|
||||
)
|
||||
bal = flt(balance[0].debit) - flt(balance[0].credit)
|
||||
|
||||
if against_voucher_type == "Purchase Invoice":
|
||||
bal = bal * -1
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -699,6 +699,61 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
[row.invoiced, row.paid, row.outstanding, row.remaining_balance, row.future_amount],
|
||||
)
|
||||
|
||||
def test_future_payments_from_journal_entry(self):
|
||||
# A single future-dated Journal Entry paying two different invoices must surface as one
|
||||
# future-payment row PER invoice, not collapse the whole sum onto one arbitrary invoice
|
||||
# (regression: the implicit single-group aggregate filed all future JE payments under one key).
|
||||
si_a = self.create_sales_invoice(no_payment_schedule=True)
|
||||
si_b = self.create_sales_invoice(no_payment_schedule=True)
|
||||
|
||||
je = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Journal Entry",
|
||||
"voucher_type": "Journal Entry",
|
||||
"company": self.company,
|
||||
"posting_date": add_days(today(), 1),
|
||||
"accounts": [
|
||||
{
|
||||
"account": self.debit_to,
|
||||
"party_type": "Customer",
|
||||
"party": self.customer,
|
||||
"reference_type": "Sales Invoice",
|
||||
"reference_name": si_a.name,
|
||||
"credit_in_account_currency": 50,
|
||||
"credit": 50,
|
||||
},
|
||||
{
|
||||
"account": self.debit_to,
|
||||
"party_type": "Customer",
|
||||
"party": self.customer,
|
||||
"reference_type": "Sales Invoice",
|
||||
"reference_name": si_b.name,
|
||||
"credit_in_account_currency": 50,
|
||||
"credit": 50,
|
||||
},
|
||||
{"account": self.cash, "debit_in_account_currency": 100, "debit": 100},
|
||||
],
|
||||
}
|
||||
)
|
||||
je.insert().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"show_future_payments": True,
|
||||
}
|
||||
report = execute(filters)[1]
|
||||
rows_a = [row for row in report if row.voucher_no == si_a.name]
|
||||
rows_b = [row for row in report if row.voucher_no == si_b.name]
|
||||
|
||||
# exactly one report row per invoice, each keeping its own future payment; the bug collapsed
|
||||
# both into a single row and allocated the whole 100 to one arbitrary invoice
|
||||
self.assertEqual(len(rows_a), 1)
|
||||
self.assertEqual(len(rows_b), 1)
|
||||
self.assertEqual(rows_a[0].future_amount, 50.0)
|
||||
self.assertEqual(rows_b[0].future_amount, 50.0)
|
||||
|
||||
def test_sales_person(self):
|
||||
sales_person = frappe.get_doc(
|
||||
{"doctype": "Sales Person", "sales_person_name": "John Clark", "enabled": True}
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}]
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -85,8 +85,8 @@ class TestAsset(AssetSetup):
|
||||
self.assertRaises(frappe.ValidationError, asset.save)
|
||||
|
||||
def test_validate_item(self):
|
||||
asset = create_asset(item_code="MacBook Pro", do_not_save=1)
|
||||
item = frappe.get_doc("Item", "MacBook Pro")
|
||||
asset = create_asset(item_code="Macbook Pro", do_not_save=1)
|
||||
item = frappe.get_doc("Item", "Macbook Pro")
|
||||
|
||||
item.disabled = 1
|
||||
item.save()
|
||||
@@ -140,7 +140,7 @@ class TestAsset(AssetSetup):
|
||||
)
|
||||
|
||||
gle = get_gl_entries("Purchase Invoice", pi.name)
|
||||
self.assertSequenceEqual(gle, expected_gle)
|
||||
self.assertCountEqual(gle, expected_gle)
|
||||
|
||||
pi.cancel()
|
||||
asset.cancel()
|
||||
@@ -283,7 +283,7 @@ class TestAsset(AssetSetup):
|
||||
)
|
||||
|
||||
gle = get_gl_entries("Journal Entry", asset.journal_entry_for_scrap)
|
||||
self.assertSequenceEqual(gle, expected_gle)
|
||||
self.assertCountEqual(gle, expected_gle)
|
||||
|
||||
restore_asset(asset.name)
|
||||
second_asset_depr_schedule.load_from_db()
|
||||
@@ -362,7 +362,7 @@ class TestAsset(AssetSetup):
|
||||
("Debtors - _TC", 25000.0, 0.0),
|
||||
)
|
||||
gle = get_gl_entries("Sales Invoice", si.name)
|
||||
self.assertSequenceEqual(gle, expected_gle)
|
||||
self.assertCountEqual(gle, expected_gle)
|
||||
|
||||
si.cancel()
|
||||
self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Partially Depreciated")
|
||||
@@ -436,7 +436,7 @@ class TestAsset(AssetSetup):
|
||||
)
|
||||
|
||||
gle = get_gl_entries("Sales Invoice", si.name)
|
||||
self.assertSequenceEqual(gle, expected_gle)
|
||||
self.assertCountEqual(gle, expected_gle)
|
||||
|
||||
def test_asset_with_maintenance_required_status_after_sale(self):
|
||||
asset = create_asset(
|
||||
@@ -577,7 +577,7 @@ class TestAsset(AssetSetup):
|
||||
)
|
||||
|
||||
pr_gle = get_gl_entries("Purchase Receipt", pr.name)
|
||||
self.assertSequenceEqual(pr_gle, expected_gle)
|
||||
self.assertCountEqual(pr_gle, expected_gle)
|
||||
|
||||
pi = make_invoice(pr.name)
|
||||
pi.submit()
|
||||
@@ -590,7 +590,7 @@ class TestAsset(AssetSetup):
|
||||
)
|
||||
|
||||
pi_gle = get_gl_entries("Purchase Invoice", pi.name)
|
||||
self.assertSequenceEqual(pi_gle, expected_gle)
|
||||
self.assertCountEqual(pi_gle, expected_gle)
|
||||
|
||||
asset = frappe.db.get_value("Asset", {"purchase_receipt": pr.name, "docstatus": 0}, "name")
|
||||
|
||||
@@ -617,7 +617,7 @@ class TestAsset(AssetSetup):
|
||||
expected_gle = (("_Test Fixed Asset - _TC", 5250.0, 0.0), ("CWIP Account - _TC", 0.0, 5250.0))
|
||||
|
||||
gle = get_gl_entries("Asset", asset_doc.name)
|
||||
self.assertSequenceEqual(gle, expected_gle)
|
||||
self.assertCountEqual(gle, expected_gle)
|
||||
|
||||
def test_asset_cwip_toggling_cases(self):
|
||||
cwip = frappe.db.get_value("Asset Category", "Computers", "enable_cwip_accounting")
|
||||
@@ -1732,14 +1732,18 @@ class TestDepreciationBasics(AssetSetup):
|
||||
("_Test Depreciations - _TC", 30000.0, 0.0),
|
||||
)
|
||||
|
||||
gle = frappe.db.sql(
|
||||
"""select account, debit, credit from `tabGL Entry`
|
||||
where against_voucher_type='Asset' and against_voucher = %s
|
||||
order by account""",
|
||||
asset.name,
|
||||
)
|
||||
gle = [
|
||||
tuple(row)
|
||||
for row in frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"against_voucher_type": "Asset", "against_voucher": asset.name},
|
||||
fields=["account", "debit", "credit"],
|
||||
order_by="account",
|
||||
as_list=True,
|
||||
)
|
||||
]
|
||||
|
||||
self.assertSequenceEqual(gle, expected_gle)
|
||||
self.assertCountEqual(gle, expected_gle)
|
||||
self.assertEqual(asset.get("value_after_depreciation"), 70000)
|
||||
|
||||
def test_expected_value_change(self):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cint, flt, now_datetime
|
||||
|
||||
from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
|
||||
@@ -549,34 +550,33 @@ def create_depreciation_asset(**args):
|
||||
|
||||
|
||||
def get_actual_gle_dict(name):
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
diff = Sum(gle.debit - gle.credit)
|
||||
return dict(
|
||||
frappe.db.sql(
|
||||
"""
|
||||
select account, sum(debit-credit) as diff
|
||||
from `tabGL Entry`
|
||||
where voucher_type = 'Asset Capitalization' and voucher_no = %s
|
||||
group by account
|
||||
having diff != 0
|
||||
""",
|
||||
name,
|
||||
)
|
||||
frappe.qb.from_(gle)
|
||||
.select(gle.account, diff.as_("diff"))
|
||||
.where((gle.voucher_type == "Asset Capitalization") & (gle.voucher_no == name))
|
||||
.groupby(gle.account)
|
||||
.having(diff != 0)
|
||||
.run()
|
||||
)
|
||||
|
||||
|
||||
def get_actual_sle_dict(name):
|
||||
sles = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
item_code, warehouse,
|
||||
sum(actual_qty) as actual_qty,
|
||||
sum(stock_value_difference) as stock_value_difference
|
||||
from `tabStock Ledger Entry`
|
||||
where voucher_type = 'Asset Capitalization' and voucher_no = %s
|
||||
group by item_code, warehouse
|
||||
having actual_qty != 0
|
||||
""",
|
||||
name,
|
||||
as_dict=1,
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
actual_qty = Sum(sle.actual_qty)
|
||||
sles = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(
|
||||
sle.item_code,
|
||||
sle.warehouse,
|
||||
actual_qty.as_("actual_qty"),
|
||||
Sum(sle.stock_value_difference).as_("stock_value_difference"),
|
||||
)
|
||||
.where((sle.voucher_type == "Asset Capitalization") & (sle.voucher_no == name))
|
||||
.groupby(sle.item_code, sle.warehouse)
|
||||
.having(actual_qty != 0)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
sle_dict = {}
|
||||
|
||||
@@ -79,11 +79,14 @@ def assign_tasks(asset_maintenance_name, assign_to_member, maintenance_task, nex
|
||||
"description": maintenance_task,
|
||||
"date": next_due_date,
|
||||
}
|
||||
if not frappe.db.sql(
|
||||
"""select owner from `tabToDo`
|
||||
where reference_type=%(doctype)s and reference_name=%(name)s and status='Open'
|
||||
and owner=%(assign_to)s""",
|
||||
args,
|
||||
if not frappe.db.exists(
|
||||
"ToDo",
|
||||
{
|
||||
"reference_type": args["doctype"],
|
||||
"reference_name": args["name"],
|
||||
"status": "Open",
|
||||
"owner": args["assign_to"],
|
||||
},
|
||||
):
|
||||
# assign_to function expects a list
|
||||
args["assign_to"] = [args["assign_to"]]
|
||||
@@ -187,13 +190,9 @@ def get_team_members(
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_maintenance_log(asset_name: str):
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select maintenance_status, count(asset_name) as count, asset_name
|
||||
from `tabAsset Maintenance Log`
|
||||
where asset_name=%s
|
||||
group by maintenance_status
|
||||
""",
|
||||
(asset_name,),
|
||||
as_dict=1,
|
||||
return frappe.get_all(
|
||||
"Asset Maintenance Log",
|
||||
filters={"asset_name": asset_name},
|
||||
fields=["maintenance_status", {"COUNT": "asset_name", "as": "count"}, "asset_name"],
|
||||
group_by="maintenance_status, asset_name",
|
||||
)
|
||||
|
||||
@@ -18,6 +18,36 @@ class TestAssetMaintenance(ERPNextTestSuite):
|
||||
self.asset_name = frappe.db.get_value("Asset", {"purchase_receipt": self.pr.name}, "name")
|
||||
self.asset_doc = frappe.get_doc("Asset", self.asset_name)
|
||||
|
||||
def test_get_maintenance_log_counts_by_status(self):
|
||||
"""get_maintenance_log uses a v16 dict aggregate field spec
|
||||
({"COUNT": "asset_name", "as": "count"}); confirm it runs and returns correct per-status counts
|
||||
on both engines (the whitelisted endpoint was previously untested)."""
|
||||
from erpnext.assets.doctype.asset_maintenance.asset_maintenance import get_maintenance_log
|
||||
|
||||
self.asset_doc.available_for_use_date = nowdate()
|
||||
self.asset_doc.purchase_date = nowdate()
|
||||
self.asset_doc.save()
|
||||
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Asset Maintenance",
|
||||
"asset_name": self.asset_name,
|
||||
"maintenance_team": "Team Awesome",
|
||||
"company": "_Test Company",
|
||||
"asset_maintenance_tasks": get_maintenance_tasks(),
|
||||
}
|
||||
).insert()
|
||||
|
||||
rows = get_maintenance_log(self.asset_name)
|
||||
# the dict aggregate spec did not crash and returned grouped rows...
|
||||
self.assertTrue(rows)
|
||||
self.assertTrue(all("maintenance_status" in r for r in rows))
|
||||
# ...and the per-status counts sum to the total number of logs for this asset
|
||||
self.assertEqual(
|
||||
sum(r["count"] for r in rows),
|
||||
frappe.db.count("Asset Maintenance Log", {"asset_name": self.asset_name}),
|
||||
)
|
||||
|
||||
def test_create_asset_maintenance_with_log(self):
|
||||
month_end_date = get_last_day(nowdate())
|
||||
|
||||
|
||||
@@ -127,24 +127,20 @@ class AssetMovement(Document):
|
||||
|
||||
def get_latest_location_and_custodian(self, asset):
|
||||
current_location, current_employee = "", ""
|
||||
cond = "1=1"
|
||||
|
||||
# latest entry corresponds to current document's location, employee when transaction date > previous dates
|
||||
# In case of cancellation it corresponds to previous latest document's location, employee
|
||||
args = {"asset": asset, "company": self.company}
|
||||
latest_movement_entry = frappe.db.sql(
|
||||
f"""
|
||||
SELECT asm_item.target_location, asm_item.to_employee
|
||||
FROM `tabAsset Movement Item` asm_item
|
||||
JOIN `tabAsset Movement` asm ON asm_item.parent = asm.name
|
||||
WHERE
|
||||
asm_item.asset = %(asset)s AND
|
||||
asm.company = %(company)s AND
|
||||
asm.docstatus = 1 AND {cond}
|
||||
ORDER BY asm.transaction_date DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
args,
|
||||
asm = frappe.qb.DocType("Asset Movement")
|
||||
asm_item = frappe.qb.DocType("Asset Movement Item")
|
||||
latest_movement_entry = (
|
||||
frappe.qb.from_(asm_item)
|
||||
.inner_join(asm)
|
||||
.on(asm_item.parent == asm.name)
|
||||
.select(asm_item.target_location, asm_item.to_employee)
|
||||
.where((asm_item.asset == asset) & (asm.company == self.company) & (asm.docstatus == 1))
|
||||
.orderby(asm.transaction_date, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
.run()
|
||||
)
|
||||
|
||||
if latest_movement_entry:
|
||||
|
||||
@@ -215,17 +215,12 @@ def get_children(doctype: str, parent: str | None = None, location: str | None =
|
||||
if parent is None or parent == "All Locations":
|
||||
parent = ""
|
||||
|
||||
return frappe.db.sql(
|
||||
f"""
|
||||
select
|
||||
name as value,
|
||||
is_group as expandable
|
||||
from
|
||||
`tabLocation` comp
|
||||
where
|
||||
ifnull(parent_location, "")={frappe.db.escape(parent)}
|
||||
""",
|
||||
as_dict=1,
|
||||
filters = {"parent_location": parent} if parent else {"parent_location": ["is", "not set"]}
|
||||
|
||||
return frappe.get_all(
|
||||
"Location",
|
||||
filters=filters,
|
||||
fields=["name as value", "is_group as expandable"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -395,32 +395,30 @@ def get_group_by_data(
|
||||
|
||||
|
||||
def get_purchase_receipt_supplier_map():
|
||||
pr = frappe.qb.DocType("Purchase Receipt")
|
||||
pri = frappe.qb.DocType("Purchase Receipt Item")
|
||||
return frappe._dict(
|
||||
frappe.db.sql(
|
||||
""" Select
|
||||
pr.name, pr.supplier
|
||||
FROM `tabPurchase Receipt` pr, `tabPurchase Receipt Item` pri
|
||||
WHERE
|
||||
pri.parent = pr.name
|
||||
AND pri.is_fixed_asset=1
|
||||
AND pr.docstatus=1
|
||||
AND pr.is_return=0"""
|
||||
)
|
||||
frappe.qb.from_(pr)
|
||||
.inner_join(pri)
|
||||
.on(pri.parent == pr.name)
|
||||
.select(pr.name, pr.supplier)
|
||||
.distinct()
|
||||
.where((pri.is_fixed_asset == 1) & (pr.docstatus == 1) & (pr.is_return == 0))
|
||||
.run()
|
||||
)
|
||||
|
||||
|
||||
def get_purchase_invoice_supplier_map():
|
||||
pi = frappe.qb.DocType("Purchase Invoice")
|
||||
pii = frappe.qb.DocType("Purchase Invoice Item")
|
||||
return frappe._dict(
|
||||
frappe.db.sql(
|
||||
""" Select
|
||||
pi.name, pi.supplier
|
||||
FROM `tabPurchase Invoice` pi, `tabPurchase Invoice Item` pii
|
||||
WHERE
|
||||
pii.parent = pi.name
|
||||
AND pii.is_fixed_asset=1
|
||||
AND pi.docstatus=1
|
||||
AND pi.is_return=0"""
|
||||
)
|
||||
frappe.qb.from_(pi)
|
||||
.inner_join(pii)
|
||||
.on(pii.parent == pi.name)
|
||||
.select(pi.name, pi.supplier)
|
||||
.distinct()
|
||||
.where((pii.is_fixed_asset == 1) & (pi.docstatus == 1) & (pi.is_return == 0))
|
||||
.run()
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.assets.doctype.asset.test_asset import AssetSetup, create_asset
|
||||
from erpnext.assets.report.fixed_asset_register.fixed_asset_register import execute
|
||||
|
||||
|
||||
class TestFixedAssetRegister(AssetSetup):
|
||||
def test_report_lists_submitted_asset(self):
|
||||
"""Exercises the report's converted queries -- including the depreciation aggregate that groups
|
||||
by asset.name (must be valid on Postgres) -- by asserting a submitted asset is listed."""
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro",
|
||||
purchase_date="2020-01-01",
|
||||
available_for_use_date="2020-06-06",
|
||||
location="Test Location",
|
||||
submit=1,
|
||||
)
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"status": "In Location",
|
||||
"filter_based_on": "Date Range",
|
||||
"from_date": "2020-01-01",
|
||||
"to_date": "2030-12-31",
|
||||
"date_based_on": "Purchase Date",
|
||||
}
|
||||
)
|
||||
data = execute(filters)[1]
|
||||
asset_ids = {row.get("asset_id") for row in data}
|
||||
self.assertIn(asset.name, asset_ids)
|
||||
@@ -30,10 +30,7 @@ class BulkTransactionLog(Document):
|
||||
def load_from_db(self):
|
||||
log_detail = qb.DocType("Bulk Transaction Log Detail")
|
||||
|
||||
has_records = frappe.db.sql(
|
||||
"select exists (select * from `tabBulk Transaction Log Detail` where date = %s);",
|
||||
(self.name,),
|
||||
)[0][0]
|
||||
has_records = frappe.db.exists("Bulk Transaction Log Detail", {"date": self.name})
|
||||
if not has_records:
|
||||
raise frappe.DoesNotExistError
|
||||
|
||||
|
||||
@@ -1,11 +1,76 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import nowtime, random_string
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestBulkTransactionLog(ERPNextTestSuite):
|
||||
pass
|
||||
def _make_log_doc(self, date):
|
||||
# "Bulk Transaction Log" is a virtual doctype named by date; build the doc
|
||||
# in-memory and drive load_from_db() directly to exercise the converted query.
|
||||
doc = frappe.new_doc("Bulk Transaction Log")
|
||||
doc.name = date
|
||||
return doc
|
||||
|
||||
def _insert_detail(self, date, status="Success"):
|
||||
detail = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Bulk Transaction Log Detail",
|
||||
"from_doctype": "Sales Order",
|
||||
"to_doctype": "Sales Invoice",
|
||||
"transaction_name": "_Test BTLD " + random_string(8),
|
||||
"date": date,
|
||||
"time": nowtime(),
|
||||
"transaction_status": status,
|
||||
}
|
||||
)
|
||||
# transaction_name is a Dynamic Link (options=from_doctype); the converted
|
||||
# query never reads it, so skip link validation rather than create real txns.
|
||||
detail.insert(ignore_permissions=True, ignore_links=True)
|
||||
return detail
|
||||
|
||||
def test_load_raises_when_no_detail_rows(self):
|
||||
# A date with zero Bulk Transaction Log Detail rows must not resolve to a log.
|
||||
date = "2024-01-01"
|
||||
self.assertFalse(
|
||||
frappe.db.exists("Bulk Transaction Log Detail", {"date": date}),
|
||||
"precondition: no detail rows for this date",
|
||||
)
|
||||
|
||||
doc = self._make_log_doc(date)
|
||||
self.assertRaises(frappe.DoesNotExistError, doc.load_from_db)
|
||||
|
||||
def test_load_succeeds_and_aggregates_after_detail_inserted(self):
|
||||
date = "2024-02-02"
|
||||
|
||||
# Initially absent -> load_from_db must raise.
|
||||
self.assertRaises(frappe.DoesNotExistError, self._make_log_doc(date).load_from_db)
|
||||
|
||||
# Insert detail rows for this date: 2 succeeded, 1 failed.
|
||||
self._insert_detail(date, "Success")
|
||||
self._insert_detail(date, "Success")
|
||||
self._insert_detail(date, "Failed")
|
||||
|
||||
# Now the exists() check passes and load_from_db() populates aggregates.
|
||||
doc = self._make_log_doc(date)
|
||||
doc.load_from_db()
|
||||
|
||||
self.assertEqual(doc.date, date)
|
||||
self.assertEqual(doc.succeeded, 2)
|
||||
self.assertEqual(doc.failed, 1)
|
||||
self.assertEqual(doc.log_entries, 3)
|
||||
|
||||
def test_load_isolated_per_date(self):
|
||||
# Detail rows on a different date must not satisfy the lookup for our date.
|
||||
other_date = "2024-03-03"
|
||||
self._insert_detail(other_date, "Success")
|
||||
|
||||
target_date = "2024-04-04"
|
||||
self.assertFalse(
|
||||
frappe.db.exists("Bulk Transaction Log Detail", {"date": target_date}),
|
||||
"target date has no rows; rows on another date must not leak in",
|
||||
)
|
||||
self.assertRaises(frappe.DoesNotExistError, self._make_log_doc(target_date).load_from_db)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
frappe.ui.form.on("Buying Settings", {
|
||||
refresh(frm) {
|
||||
if (!frm.naming_controller) frm.naming_controller = new erpnext.NamingSeriesController(frm);
|
||||
if (!frm.naming_controller) frm.naming_controller = new frappe.ui.NamingSeriesController(frm);
|
||||
|
||||
const display = frm.doc.supp_master_name === "Naming Series";
|
||||
frm.set_df_property("naming_series_details", "hidden", !display);
|
||||
|
||||
@@ -477,10 +477,8 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
item_doc.save()
|
||||
else:
|
||||
# update valid from
|
||||
frappe.db.sql(
|
||||
"""UPDATE `tabItem Tax` set valid_from = CURRENT_DATE
|
||||
where parent = %(item)s and item_tax_template = %(tax)s""",
|
||||
{"item": item, "tax": tax_template},
|
||||
frappe.db.set_value(
|
||||
"Item Tax", {"parent": item, "item_tax_template": tax_template}, "valid_from", nowdate()
|
||||
)
|
||||
|
||||
po = create_purchase_order(item_code=item, qty=1, do_not_save=1)
|
||||
@@ -527,10 +525,8 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
self.assertEqual(po.taxes[1].total, 840)
|
||||
|
||||
# teardown
|
||||
frappe.db.sql(
|
||||
"""UPDATE `tabItem Tax` set valid_from = NULL
|
||||
where parent = %(item)s and item_tax_template = %(tax)s""",
|
||||
{"item": item, "tax": tax_template},
|
||||
frappe.db.set_value(
|
||||
"Item Tax", {"parent": item, "item_tax_template": tax_template}, "valid_from", None
|
||||
)
|
||||
po.cancel()
|
||||
po.delete()
|
||||
@@ -652,7 +648,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
|
||||
def test_purchase_order_on_hold(self):
|
||||
po = create_purchase_order(item_code="_Test Product Bundle Item")
|
||||
po.db_set("Status", "On Hold")
|
||||
po.db_set("status", "On Hold")
|
||||
pi = make_pi_from_po(po.name)
|
||||
pr = make_purchase_receipt(po.name)
|
||||
self.assertRaises(frappe.ValidationError, pr.submit)
|
||||
|
||||
@@ -43,6 +43,7 @@ def make_supplier_quotation_from_rfq(
|
||||
"name": "request_for_quotation_item",
|
||||
"parent": "request_for_quotation",
|
||||
"project_name": "project",
|
||||
"cost_center": "cost_center",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -110,6 +111,7 @@ def create_rfq_items(sq_doc, supplier, data):
|
||||
"material_request_item",
|
||||
"stock_qty",
|
||||
"uom",
|
||||
"cost_center",
|
||||
]:
|
||||
args[field] = data.get(field)
|
||||
|
||||
@@ -176,6 +178,7 @@ def get_item_from_material_requests_based_on_supplier(
|
||||
["name", "material_request_item"],
|
||||
["parent", "material_request"],
|
||||
["uom", "uom"],
|
||||
["cost_center", "cost_center"],
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ from erpnext.controllers.accounts_controller import InvalidQtyError
|
||||
from erpnext.crm.doctype.opportunity.mapper import make_request_for_quotation as make_rfq
|
||||
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
|
||||
from erpnext.templates.pages.rfq import check_supplier_has_docname_access
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -59,6 +60,42 @@ class TestRequestforQuotation(ERPNextTestSuite):
|
||||
self.assertEqual(rfq.get("suppliers")[0].quote_status, "Received")
|
||||
self.assertEqual(rfq.get("suppliers")[1].quote_status, "Pending")
|
||||
|
||||
def test_duplicate_supplier_rejected(self):
|
||||
rfq = frappe.new_doc("Request for Quotation")
|
||||
rfq.transaction_date = nowdate()
|
||||
rfq.company = "_Test Company"
|
||||
rfq.message_for_supplier = "Please quote"
|
||||
rfq.append("suppliers", {"supplier": "_Test Supplier"})
|
||||
rfq.append("suppliers", {"supplier": "_Test Supplier"})
|
||||
rfq.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"qty": 5,
|
||||
"uom": "_Test UOM",
|
||||
"stock_uom": "_Test UOM",
|
||||
"conversion_factor": 1.0,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"schedule_date": nowdate(),
|
||||
},
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, rfq.insert)
|
||||
|
||||
def test_rfq_blocked_for_supplier_with_prevent_rfqs(self):
|
||||
frappe.db.set_value("Supplier", "_Test Supplier", "prevent_rfqs", 1)
|
||||
rfq = make_request_for_quotation(
|
||||
supplier_data=[{"supplier": "_Test Supplier", "supplier_name": "_Test Supplier"}],
|
||||
do_not_save=True,
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, rfq.save)
|
||||
|
||||
def test_rfq_status_lifecycle(self):
|
||||
rfq = make_request_for_quotation()
|
||||
self.assertEqual(rfq.status, "Submitted")
|
||||
|
||||
rfq.cancel()
|
||||
self.assertEqual(rfq.status, "Cancelled")
|
||||
|
||||
def test_make_supplier_quotation(self):
|
||||
rfq = make_request_for_quotation()
|
||||
|
||||
@@ -250,6 +287,41 @@ class TestRequestforQuotation(ERPNextTestSuite):
|
||||
self.assertEqual(sq.items[0].qty, 0)
|
||||
self.assertEqual(sq.items[0].item_code, rfq.items[0].item_code)
|
||||
|
||||
def test_cost_center_flows_from_mr_to_rfq(self):
|
||||
from erpnext.stock.doctype.material_request.mapper import (
|
||||
make_request_for_quotation as mr_make_rfq,
|
||||
)
|
||||
|
||||
mr = make_material_request(cost_center="_Test Cost Center - _TC")
|
||||
rfq = mr_make_rfq(mr.name)
|
||||
|
||||
self.assertEqual(rfq.items[0].cost_center, "_Test Cost Center - _TC")
|
||||
|
||||
def test_cost_center_flows_from_rfq_to_supplier_quotation(self):
|
||||
rfq = make_request_for_quotation(do_not_submit=True)
|
||||
rfq.items[0].cost_center = "_Test Cost Center - _TC"
|
||||
rfq.save()
|
||||
rfq.submit()
|
||||
|
||||
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.get("suppliers")[0].supplier)
|
||||
|
||||
self.assertEqual(sq.items[0].cost_center, "_Test Cost Center - _TC")
|
||||
|
||||
def test_cost_center_flows_end_to_end_mr_rfq_sq(self):
|
||||
from erpnext.stock.doctype.material_request.mapper import (
|
||||
make_request_for_quotation as mr_make_rfq,
|
||||
)
|
||||
|
||||
mr = make_material_request(cost_center="_Test Cost Center - _TC")
|
||||
rfq = mr_make_rfq(mr.name)
|
||||
rfq.append("suppliers", {"supplier": "_Test Supplier", "supplier_name": "_Test Supplier"})
|
||||
rfq.insert()
|
||||
rfq.submit()
|
||||
|
||||
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier="_Test Supplier")
|
||||
|
||||
self.assertEqual(sq.items[0].cost_center, "_Test Cost Center - _TC")
|
||||
|
||||
|
||||
def make_request_for_quotation(**args):
|
||||
"""
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
"col_break4",
|
||||
"material_request",
|
||||
"material_request_item",
|
||||
"section_break_24",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
"dimension_col_break",
|
||||
"project_name",
|
||||
"section_break_23",
|
||||
"page_break"
|
||||
@@ -253,15 +255,26 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "section_break_24",
|
||||
"fieldname": "accounting_dimensions_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Accounting Dimensions"
|
||||
},
|
||||
{
|
||||
"fieldname": "cost_center",
|
||||
"fieldtype": "Link",
|
||||
"label": "Cost Center",
|
||||
"options": "Cost Center",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "dimension_col_break",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-31 19:46:27.884592",
|
||||
"modified": "2026-06-15 00:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Request for Quotation Item",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"field_order": [
|
||||
"naming_series",
|
||||
"supplier_name",
|
||||
"alias",
|
||||
"supplier_type",
|
||||
"gender",
|
||||
"column_break0",
|
||||
@@ -540,6 +541,13 @@
|
||||
{
|
||||
"fieldname": "section_break_pgad",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "alias",
|
||||
"fieldtype": "Data",
|
||||
"in_global_search": 1,
|
||||
"label": "Alias",
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -553,7 +561,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2026-05-29 16:52:59.441272",
|
||||
"modified": "2026-06-22 12:23:09.241125",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier",
|
||||
@@ -613,7 +621,7 @@
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "supplier_group",
|
||||
"search_fields": "supplier_group, alias",
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
|
||||
@@ -39,6 +39,7 @@ class Supplier(TransactionBase):
|
||||
from erpnext.utilities.doctype.portal_user.portal_user import PortalUser
|
||||
|
||||
accounts: DF.Table[PartyAccount]
|
||||
alias: DF.Data | None
|
||||
allow_purchase_invoice_creation_without_purchase_order: DF.Check
|
||||
allow_purchase_invoice_creation_without_purchase_receipt: DF.Check
|
||||
companies: DF.Table[AllowedToTransactWith]
|
||||
|
||||
@@ -243,11 +243,16 @@ def get_list_context(context=None):
|
||||
|
||||
|
||||
def set_expired_status():
|
||||
# Only submitted quotations past their validity should be expired
|
||||
frappe.db.set_value(
|
||||
"Supplier Quotation",
|
||||
filters={"status": ["not in", ["Cancelled", "Stopped"]], "valid_till": ["<", nowdate()]},
|
||||
fieldname="status",
|
||||
value="Expired",
|
||||
{
|
||||
"docstatus": 1,
|
||||
"status": ["not in", ["Cancelled", "Stopped"]],
|
||||
"valid_till": ["<", nowdate()],
|
||||
},
|
||||
"status",
|
||||
"Expired",
|
||||
update_modified=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,12 @@ import frappe
|
||||
from frappe.tests import change_settings
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.buying.doctype.request_for_quotation.mapper import make_supplier_quotation_from_rfq
|
||||
from erpnext.buying.doctype.request_for_quotation.test_request_for_quotation import (
|
||||
make_request_for_quotation,
|
||||
)
|
||||
from erpnext.buying.doctype.supplier_quotation.mapper import make_purchase_order
|
||||
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import set_expired_status
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -17,6 +22,56 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.load_test_records("Supplier Quotation")
|
||||
|
||||
def test_valid_till_before_transaction_date_rejected(self):
|
||||
rfq = make_request_for_quotation()
|
||||
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[0].supplier)
|
||||
sq.transaction_date = today()
|
||||
sq.valid_till = add_days(today(), -1)
|
||||
self.assertRaises(frappe.ValidationError, sq.insert)
|
||||
|
||||
def test_set_expired_status_expires_only_submitted_past_quotations(self):
|
||||
rfq = make_request_for_quotation()
|
||||
|
||||
expired = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[0].supplier)
|
||||
expired.transaction_date = add_days(today(), -10)
|
||||
expired.valid_till = add_days(today(), -2)
|
||||
expired.insert()
|
||||
expired.submit()
|
||||
|
||||
valid = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[1].supplier)
|
||||
valid.valid_till = add_days(today(), 10)
|
||||
valid.insert()
|
||||
valid.submit()
|
||||
|
||||
# A past-validity draft must not be expired - "Expired" applies to submitted quotations only
|
||||
draft = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[0].supplier)
|
||||
draft.transaction_date = add_days(today(), -10)
|
||||
draft.valid_till = add_days(today(), -2)
|
||||
draft.insert()
|
||||
|
||||
set_expired_status()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Supplier Quotation", expired.name, "status"), "Expired")
|
||||
self.assertEqual(frappe.db.get_value("Supplier Quotation", valid.name, "status"), "Submitted")
|
||||
self.assertEqual(frappe.db.get_value("Supplier Quotation", draft.name, "status"), "Draft")
|
||||
|
||||
def test_submit_and_cancel_updates_rfq_quote_status(self):
|
||||
rfq = make_request_for_quotation()
|
||||
supplier_row = rfq.suppliers[0]
|
||||
|
||||
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=supplier_row.supplier)
|
||||
sq.submit()
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Request for Quotation Supplier", supplier_row.name, "quote_status"),
|
||||
"Received",
|
||||
)
|
||||
|
||||
sq.cancel()
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Request for Quotation Supplier", supplier_row.name, "quote_status"),
|
||||
"Pending",
|
||||
)
|
||||
|
||||
def test_update_child_supplier_quotation_add_item(self):
|
||||
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
sq.submit()
|
||||
|
||||
@@ -60,25 +60,20 @@ class SupplierScorecard(Document):
|
||||
self.save()
|
||||
|
||||
def validate_standings(self):
|
||||
# Check that there are no overlapping scores and check that there are no missing scores
|
||||
score = 0
|
||||
for c1 in self.standings:
|
||||
for c2 in self.standings:
|
||||
if c1 != c2:
|
||||
if c1.max_grade > c2.min_grade and c1.min_grade < c2.max_grade:
|
||||
throw(
|
||||
_("Overlap in scoring between {0} and {1}").format(
|
||||
c1.standing_name, c2.standing_name
|
||||
)
|
||||
)
|
||||
if c2.min_grade == score:
|
||||
score = c2.max_grade
|
||||
if score < 100:
|
||||
throw(
|
||||
_(
|
||||
"Unable to find score starting at {0}. You need to have standing scores covering 0 to 100"
|
||||
).format(score)
|
||||
)
|
||||
# Standings must form a continuous chain of bands covering 0 to 100 with no gaps or overlaps
|
||||
expected_min = 0
|
||||
for standing in sorted(self.standings, key=lambda s: s.min_grade or 0):
|
||||
if standing.min_grade >= standing.max_grade:
|
||||
throw(
|
||||
_("Standing {0} must have a minimum grade lower than its maximum grade").format(
|
||||
standing.standing_name
|
||||
)
|
||||
)
|
||||
if standing.min_grade != expected_min:
|
||||
throw(_("Standing scores must be continuous and cover 0 to 100 without gaps or overlaps"))
|
||||
expected_min = standing.max_grade
|
||||
if expected_min < 100:
|
||||
throw(_("Standing scores must cover the full range from 0 to 100"))
|
||||
|
||||
def validate_criteria_weights(self):
|
||||
weight = 0
|
||||
@@ -119,22 +114,29 @@ class SupplierScorecard(Document):
|
||||
self.supplier_score = 100
|
||||
|
||||
def update_standing(self):
|
||||
# Get the setup document
|
||||
|
||||
highest_grade = max((s.max_grade for s in self.standings if s.max_grade), default=0)
|
||||
for standing in self.standings:
|
||||
if (not standing.min_grade or (standing.min_grade <= self.supplier_score)) and (
|
||||
not standing.max_grade or (standing.max_grade > self.supplier_score)
|
||||
):
|
||||
self.status = standing.standing_name
|
||||
self.indicator_color = standing.standing_color
|
||||
self.notify_supplier = standing.notify_supplier
|
||||
self.notify_employee = standing.notify_employee
|
||||
self.employee_link = standing.employee_link
|
||||
if self.score_within_standing(standing, highest_grade):
|
||||
self.apply_standing(standing)
|
||||
|
||||
# Update supplier standing info
|
||||
for fieldname in ("prevent_pos", "prevent_rfqs", "warn_rfqs", "warn_pos"):
|
||||
self.set(fieldname, standing.get(fieldname))
|
||||
frappe.db.set_value("Supplier", self.supplier, fieldname, self.get(fieldname))
|
||||
def score_within_standing(self, standing, highest_grade):
|
||||
score = self.supplier_score
|
||||
above_min = not standing.min_grade or standing.min_grade <= score
|
||||
if standing.max_grade and standing.max_grade == highest_grade:
|
||||
# Top band is inclusive of its upper bound so a perfect score still maps to a standing
|
||||
return above_min and score <= standing.max_grade
|
||||
return above_min and (not standing.max_grade or standing.max_grade > score)
|
||||
|
||||
def apply_standing(self, standing):
|
||||
self.status = standing.standing_name
|
||||
self.indicator_color = standing.standing_color
|
||||
self.notify_supplier = standing.notify_supplier
|
||||
self.notify_employee = standing.notify_employee
|
||||
self.employee_link = standing.employee_link
|
||||
|
||||
for fieldname in ("prevent_pos", "prevent_rfqs", "warn_rfqs", "warn_pos"):
|
||||
self.set(fieldname, standing.get(fieldname))
|
||||
frappe.db.set_value("Supplier", self.supplier, fieldname, self.get(fieldname))
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, getdate, nowdate
|
||||
|
||||
from erpnext.buying.doctype.supplier_scorecard.supplier_scorecard import (
|
||||
get_scorecard_date,
|
||||
make_all_scorecards,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -18,6 +23,72 @@ class TestSupplierScorecard(ERPNextTestSuite):
|
||||
d.weight = 0
|
||||
self.assertRaises(frappe.ValidationError, my_doc.insert)
|
||||
|
||||
def test_overlapping_standings_are_rejected(self):
|
||||
doc = make_supplier_scorecard()
|
||||
# "Poor" (30-50) stretched to 60 now overlaps "Average" (50-80)
|
||||
doc.standings[1].max_grade = 60
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_standings)
|
||||
|
||||
def test_standings_must_cover_full_range(self):
|
||||
doc = make_supplier_scorecard()
|
||||
# "Excellent" capped at 90 leaves the 90-100 band uncovered
|
||||
doc.standings[3].max_grade = 90
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_standings)
|
||||
|
||||
def test_inverted_standing_band_rejected(self):
|
||||
doc = make_supplier_scorecard()
|
||||
doc.standings = []
|
||||
doc.append("standings", {"standing_name": "Inverted", "min_grade": 60, "max_grade": 40})
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_standings)
|
||||
|
||||
def test_perfect_score_maps_to_top_standing(self):
|
||||
# A perfect score (the upper bound of the top band) must still resolve to a standing
|
||||
supplier = create_test_supplier("_Test Supplier SC Perfect")
|
||||
doc = make_supplier_scorecard()
|
||||
doc.supplier = supplier
|
||||
doc.supplier_score = 100
|
||||
doc.update_standing()
|
||||
self.assertEqual(doc.status, "Excellent")
|
||||
|
||||
def test_total_score_defaults_to_100_without_periods(self):
|
||||
doc = make_supplier_scorecard()
|
||||
doc.name = "_Test Scorecard Without Periods"
|
||||
doc.calculate_total_score()
|
||||
self.assertEqual(doc.supplier_score, 100)
|
||||
|
||||
def test_update_standing_propagates_blocking_flags_to_supplier(self):
|
||||
supplier = create_test_supplier("_Test Supplier SC Standing")
|
||||
doc = make_supplier_scorecard()
|
||||
doc.supplier = supplier
|
||||
doc.supplier_score = 20 # falls in the "Very Poor" (0-30) band
|
||||
doc.update_standing()
|
||||
|
||||
self.assertEqual(doc.status, "Very Poor")
|
||||
self.assertEqual(doc.prevent_pos, 1)
|
||||
self.assertEqual(doc.prevent_rfqs, 1)
|
||||
self.assertEqual(frappe.db.get_value("Supplier", supplier, "prevent_pos"), 1)
|
||||
self.assertEqual(frappe.db.get_value("Supplier", supplier, "prevent_rfqs"), 1)
|
||||
|
||||
def test_scorecard_period_end_dates(self):
|
||||
start = getdate("2024-01-01")
|
||||
self.assertEqual(get_scorecard_date("Per Week", start), getdate("2024-01-08"))
|
||||
self.assertEqual(get_scorecard_date("Per Month", start), getdate("2024-01-31"))
|
||||
self.assertEqual(get_scorecard_date("Per Year", start), getdate("2024-12-31"))
|
||||
|
||||
def test_make_all_scorecards_is_idempotent(self):
|
||||
supplier = create_test_supplier("_Test Supplier SC Idempotent")
|
||||
frappe.db.set_value("Supplier", supplier, "creation", add_days(nowdate(), -75))
|
||||
|
||||
frappe.delete_doc_if_exists("Supplier Scorecard", supplier)
|
||||
doc = make_supplier_scorecard()
|
||||
doc.supplier = supplier
|
||||
doc.name = supplier
|
||||
doc.insert() # on_update generates the period scorecards
|
||||
|
||||
created = frappe.db.count("Supplier Scorecard Period", {"scorecard": doc.name, "docstatus": 1})
|
||||
self.assertGreater(created, 0)
|
||||
self.assertEqual(make_all_scorecards(doc.name), 0)
|
||||
|
||||
|
||||
def make_supplier_scorecard():
|
||||
my_doc = frappe.get_doc(valid_scorecard[0])
|
||||
@@ -32,6 +103,18 @@ def make_supplier_scorecard():
|
||||
return my_doc
|
||||
|
||||
|
||||
def create_test_supplier(supplier_name):
|
||||
if not frappe.db.exists("Supplier", supplier_name):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Supplier",
|
||||
"supplier_name": supplier_name,
|
||||
"supplier_group": "_Test Supplier Group",
|
||||
}
|
||||
).insert()
|
||||
return supplier_name
|
||||
|
||||
|
||||
valid_scorecard = [
|
||||
{
|
||||
"standings": [
|
||||
|
||||
@@ -82,7 +82,6 @@ class SupplierScorecardPeriod(Document):
|
||||
).format(crit.criteria_name),
|
||||
frappe.ValidationError,
|
||||
)
|
||||
crit.score = 0
|
||||
|
||||
def calculate_score(self):
|
||||
myscore = 0
|
||||
|
||||
@@ -1,8 +1,65 @@
|
||||
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestSupplierScorecardPeriod(ERPNextTestSuite):
|
||||
pass
|
||||
def test_criteria_score_is_clamped_to_bounds(self):
|
||||
period = make_period(
|
||||
criteria=[
|
||||
{"criteria_name": "Over", "formula": "200", "max_score": 100, "weight": 50},
|
||||
{"criteria_name": "Negative", "formula": "-50", "max_score": 100, "weight": 50},
|
||||
]
|
||||
)
|
||||
period.calculate_criteria()
|
||||
|
||||
self.assertEqual(period.criteria[0].score, 100) # capped at max_score
|
||||
self.assertEqual(period.criteria[1].score, 0) # floored at zero
|
||||
|
||||
def test_invalid_criteria_formula_raises(self):
|
||||
period = make_period(
|
||||
criteria=[{"criteria_name": "Bad", "formula": "{missing} +", "max_score": 100, "weight": 100}]
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, period.calculate_criteria)
|
||||
|
||||
def test_eval_statement_substitutes_variable_values(self):
|
||||
period = make_period(
|
||||
variables=[
|
||||
{"variable_label": "A", "param_name": "a", "path": "get_total_workdays", "value": 5},
|
||||
{"variable_label": "B", "param_name": "b", "path": "get_total_workdays", "value": 0},
|
||||
]
|
||||
)
|
||||
# get_eval_statement checks `if var.value:` (truthiness), so a falsy value -
|
||||
# whether 0 or None - is substituted as "0.0", while a real value is formatted
|
||||
self.assertEqual(period.get_eval_statement("{a} + {b}"), "5.00 + 0.0")
|
||||
|
||||
def test_period_score_is_weighted_sum_of_criteria(self):
|
||||
period = make_period(
|
||||
criteria=[
|
||||
{"criteria_name": "C1", "formula": "80", "max_score": 100, "weight": 25},
|
||||
{"criteria_name": "C2", "formula": "40", "max_score": 100, "weight": 75},
|
||||
]
|
||||
)
|
||||
period.calculate_criteria()
|
||||
period.calculate_score()
|
||||
|
||||
# 80 * 0.25 + 40 * 0.75 = 50
|
||||
self.assertEqual(period.total_score, 50)
|
||||
|
||||
def test_criteria_weights_must_total_100(self):
|
||||
period = make_period(
|
||||
criteria=[{"criteria_name": "C1", "formula": "100", "max_score": 100, "weight": 60}]
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, period.validate_criteria_weights)
|
||||
|
||||
|
||||
def make_period(variables=None, criteria=None):
|
||||
period = frappe.new_doc("Supplier Scorecard Period")
|
||||
for variable in variables or []:
|
||||
period.append("variables", variable)
|
||||
for criterion in criteria or []:
|
||||
period.append("criteria", criterion)
|
||||
return period
|
||||
|
||||
@@ -8,7 +8,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import DateDiff, Sum
|
||||
from frappe.utils import getdate
|
||||
from frappe.utils import flt, getdate
|
||||
|
||||
|
||||
class VariablePathNotFound(frappe.ValidationError):
|
||||
@@ -184,16 +184,18 @@ def get_total_days_late(scorecard):
|
||||
|
||||
|
||||
def get_on_time_shipments(scorecard):
|
||||
"""Gets the number of on time shipments (counting each item) in the period (based on Purchase Receipts vs POs)"""
|
||||
"""Counts PO lines (scheduled in the period) fully received on or before their schedule date.
|
||||
|
||||
from frappe.query_builder.functions import Count
|
||||
Counting in PO-line units keeps this consistent with get_total_shipments so that
|
||||
get_late_shipments (total - on time) stays non-negative even for split deliveries.
|
||||
"""
|
||||
|
||||
PO = frappe.qb.DocType("Purchase Order")
|
||||
PO_Item = frappe.qb.DocType("Purchase Order Item")
|
||||
PR = frappe.qb.DocType("Purchase Receipt")
|
||||
PR_Item = frappe.qb.DocType("Purchase Receipt Item")
|
||||
|
||||
query = (
|
||||
rows = (
|
||||
frappe.qb.from_(PR_Item)
|
||||
.join(PR)
|
||||
.on(PR_Item.parent == PR.name)
|
||||
@@ -201,17 +203,15 @@ def get_on_time_shipments(scorecard):
|
||||
.on(PR_Item.purchase_order_item == PO_Item.name)
|
||||
.join(PO)
|
||||
.on(PO_Item.parent == PO.name)
|
||||
.select(Count(PR_Item.qty))
|
||||
.select(PO_Item.name, PO_Item.qty, Sum(PR_Item.qty).as_("received_on_time"))
|
||||
.where(PO.supplier == scorecard.supplier)
|
||||
.where(PO_Item.schedule_date[scorecard.start_date : scorecard.end_date])
|
||||
.where(PO_Item.schedule_date >= PR.posting_date)
|
||||
.where(PO_Item.qty == PR_Item.qty)
|
||||
.where(PR_Item.docstatus == 1)
|
||||
)
|
||||
.groupby(PO_Item.name, PO_Item.qty)
|
||||
).run(as_dict=True)
|
||||
|
||||
result = query.run(as_list=True)
|
||||
total_items_delivered_on_time = result[0][0] if result and result[0][0] is not None else 0
|
||||
return total_items_delivered_on_time
|
||||
return sum(1 for row in rows if flt(row.received_on_time) >= flt(row.qty))
|
||||
|
||||
|
||||
def get_late_shipments(scorecard):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user