Compare commits

..

1 Commits

Author SHA1 Message Date
Nabin Hait
e0114d56db test: cover Stock Balance include-zero and ageing checkbox filters
Add coverage for two untested Stock Balance checkbox filters: zero-balance
items are hidden unless 'include zero stock items' is on, and the stock
ageing columns appear only when 'show stock ageing data' is on.
2026-06-23 10:56:04 +05:30
476 changed files with 65050 additions and 77949 deletions

View File

@@ -1,183 +0,0 @@
# PostgreSQL compatibility — review guide
ERPNext targets **both MariaDB and PostgreSQL from a single codebase**. The full server
test suite passes on both, but the PostgreSQL CI job is **label-gated** (it does not run on
every PR), so until it is required this guide is the always-on guard. Greptile loads it as
review context (`.greptile/config.json`).
When reviewing a PR, flag any **new or changed query** (raw `frappe.db.sql`, `frappe.qb`,
`frappe.get_all/get_list/get_value`, report SQL) that would **error on PostgreSQL** or
**return different results on the two engines**.
## The one rule that governs everything
**MariaDB behaviour must not change; PostgreSQL is brought into line with MariaDB — never the
reverse.** A "fix" that changes the value, row count, or ordering MariaDB produced is a
regression, even if the new behaviour looks more correct. The only accepted MariaDB-output
change is replacing a genuinely *undefined/arbitrary* result with a deterministic one (row
count preserved) — and that should be called out explicitly.
There are two failure modes to watch for:
1. **Hard breaks** — PostgreSQL raises an exception; MariaDB is green. Easy to catch in CI,
but the gated job may not run.
2. **Silent divergences** — both engines succeed but return *different* results. CI on one
engine stays green; the bug only shows on a PostgreSQL site. These are the dangerous ones.
---
## 1. Hard breaks — would error on PostgreSQL
Flag a changed query that uses any of these:
- **Loose `GROUP BY`** — selecting/ordering a column that is neither in `GROUP BY` nor wrapped
in an aggregate. MariaDB tolerates it; PostgreSQL errors (`must appear in the GROUP BY
clause or be used in an aggregate function`). This **also covers an aggregate (`Sum`/`Count`/…)
selected alongside bare columns with NO `.groupby()` at all** — MariaDB silently collapses
every row into one arbitrary-valued row (often a *wrong-output* bug there too), PostgreSQL
errors. Fix: add the bare column to `GROUP BY` **if it is functionally dependent on the group
key**, otherwise wrap it in `Max()`/`Min()`. **See §3 — the row-count trap — before suggesting
"add it to GROUP BY".**
- **MySQL-only functions** — `TIMESTAMP(date,time)`, `TIMEDIFF`, `STR_TO_DATE`, `DATE_FORMAT`,
`DATE_ADD/SUB`, `GROUP_CONCAT`, `PERIOD_DIFF`, SQL `IF(cond,a,b)`. Use the portable
`frappe.query_builder.functions` equivalents (`CombineDatetime`, `DateDiff`, `Case`,
`GroupConcat`, …) or a precomputed column (e.g. `posting_datetime`).
- **`UPDATE … JOIN`** — not valid on PostgreSQL. Rewrite as `UPDATE … WHERE name IN (subquery)`.
- **`HAVING` referencing a `SELECT` alias** — PostgreSQL rejects output-column aliases in
`HAVING` (regardless of whether the query has a `GROUP BY`; MariaDB allows them). Repeat the
underlying expression in `HAVING`, or move a non-aggregate predicate into `WHERE`.
- **`SELECT DISTINCT … ORDER BY <expr not in the select list>`** — add the expr to the select.
- **Single-quoted column alias** `AS 'x'` — PostgreSQL reads `'x'` as a string literal. Use an
unquoted (or double-quoted) alias.
- **`varchar | varchar`** (bitwise OR misused as a coalesce) — errors on PostgreSQL. Use
`Coalesce(...)`.
- **Capital-cased identifiers** used as column/field names in `get_value(dt, dn, "Status")`,
`get_all(dt, fields=["Account"])`, and similar — PostgreSQL quotes the identifier and matches
it case-sensitively; a stored column named `status`/`account` won't match `"Status"`/`"Account"`
(`column "Account" does not exist`). Use the exact stored (lower-case) fieldname.
- **Boolean passed where an integer column is expected** — `frappe.db.set_value(dt, dn,
check_field, True)`, `doc.db_set(field, False)`, or `frappe.qb.update(dt).set(check_field, True)`
emit `SET col = true`, which PostgreSQL rejects on a `smallint`/`Check` column
(`column is of type smallint but expression is of type boolean`). Pass `1`/`0`.
- **`.like()`/`.ilike()` (or raw `LIKE`) on a NON-text column** — `idx`, `docstatus`, a date, etc.
frappe maps `.like()` → `ILIKE`, and PostgreSQL has no `bigint ILIKE text` operator (`operator
does not exist: bigint ~~* unknown`). Cast the column to text first — **`Cast_(col, "varchar")`**,
not `Cast(col, "char")` (see below). MariaDB coerces the int implicitly, so the cast is a no-op there.
- **`CAST(… AS CHAR)` / `Cast(x, "char")`** — on PostgreSQL bare `CHAR` is `character(1)`, so
`CAST(12 AS CHAR)` → `'1'` (silently truncates multi-digit values); MariaDB gives the full string.
Use `VARCHAR` / `Cast_(x, "varchar")`.
- **`.rlike()` / raw `RLIKE`** — frappe rewrites `REGEXP` → `~*` on PostgreSQL but does **not**
translate `RLIKE` (no such PostgreSQL operator). Use `.regexp()` (or `.like()` for a simple prefix).
- **`IfNull`/`Coalesce` of a typed column with a different-typed literal** — `IfNull(asset.disposal_date, 0)`
renders `COALESCE("disposal_date", 0)`, coalescing a **DATE** with an **integer**. PostgreSQL requires
`COALESCE` args to share a type (`DatatypeMismatch: COALESCE types date and integer cannot be matched`);
MariaDB's `IFNULL` is permissive. The common shape is `IfNull(date_col, 0) != 0 / == 0` as a presence test —
replace with `date_col.isnotnull()` / `date_col.isnull()` (identical, and valid on both). Otherwise coalesce
to a **same-type** default (`Coalesce(date_col, '1900-01-01')`, `Coalesce(text_col, '')`).
- **Division by a possibly-zero divisor** — `Sum(a) / Sum(b)`, `x / col`, etc. where the
divisor can be `0`/empty. MariaDB returns `NULL` for division by zero; PostgreSQL raises
`division by zero` and aborts the query. Wrap the divisor in `NullIf(divisor, 0)` — that
yields `NULL` on both engines, matching MariaDB's value. (Only the *literal* `/ 0` is a parse
constant; the trap is a divisor that is an aggregate or column the data can drive to zero.)
---
## 2. Silent divergences — succeeds on both, returns different results
These don't error, so a one-engine CI stays green. Flag them:
- **Case sensitivity on text equality** — `==`, `.isin()`, `Strpos`/`Locate` on free-text
columns are case-**sensitive** on PostgreSQL but case-**insensitive** under MariaDB's default
collation. `Lower()` both sides. *(Not `.like()`/`["like", …]` — those already render as
`ILIKE` on PostgreSQL; see §4.)*
- **Case sensitivity in a doc-`name` lookup** — lower-casing a value then using it as a
document name in `get_value`/`get_doc`/`exists` misses on PostgreSQL (names are
case-sensitive). Keep original case for the identifier; lower-case only comparison operands.
- **Empty string vs NULL** — PostgreSQL stores a blank link/data field as `NULL` on some paths
while MariaDB keeps `''`; `Concat`/`Concat_ws` then diverge. Prefer the stored full value, or
`Coalesce(col, '')` per argument.
- **NULL ordering** — MariaDB sorts `NULL` first, PostgreSQL sorts it last. For
`ORDER BY … LIMIT 1`/`[0]` on a nullable column, guard with `Coalesce`/`isnotnull()`.
- **`ORDER BY … LIMIT 1` with no unique tiebreaker** — when rows tie on the ordered column the
two engines may pick different rows. Add a `creation`/`name` tiebreaker **only if it does not
change MariaDB's current pick** (see §4).
- **Integer division** — `int / int` truncates on PostgreSQL but is decimal on MariaDB, e.g.
`COUNT(...) / COUNT(...) * 100` → `0`, or `manufacturing_time_in_mins / 1440` flooring a
lead-time to whole days. Force float: multiply by `100.0`, or make a literal a float
(`/ 1440` → `/ 1440.0`), or cast an operand. (Only SQL-level `/` on integer **columns/literals**
— Python `/` is already float.)
- **`DISTINCT` list ordering** — `frappe.get_all(distinct=True, order_by=…)` /
`SELECT DISTINCT … ORDER BY`: frappe's `db_query` **silently drops `ORDER BY` for distinct
queries on PostgreSQL**, so the result is unordered there. Sort in Python instead — and use
`key=str.casefold`, because bare `sorted()` is case-sensitive (ASCII) while MariaDB's
collation is case-insensitive, so a plain sort reorders MariaDB's output.
- **Engine-specific function rewrites** — e.g. a PostgreSQL `regexp_replace` branch
reimplementing MariaDB's `CAST(SUBSTRING_INDEX(name,' ',-1) AS UNSIGNED)` (leading digits of
the last whitespace token). Verify the rewrite matches MariaDB on edge cases (`"X - 3a"→3`,
`"X - 1.5"→1`) by diffing both engines on literal rows.
- **`UnixTimestamp(date)` / date→epoch** is timezone-dependent (midnight in the DB session TZ),
so a strict `epoch <= now` bound is flaky on PostgreSQL.
---
## 3. The `GROUP BY` row-count trap (the single most important rule)
When making a loose `GROUP BY` PostgreSQL-valid, **do not add a non-functionally-dependent
column to the `GROUP BY` just to satisfy PostgreSQL** — that turns one group row into N and
**changes the MariaDB row count** (a regression). The classic traps are adding the **child/row
primary key** or an **editable per-row field**. Instead **`Max()`/`Min()`-wrap** the offending
column: the row count is preserved and the value goes from arbitrary (MariaDB's old loose pick)
to deterministic.
**Judge functional dependence by the source table, not the column name:**
- A column from a **master joined on the group key** (`t3.x` where `t1.key = t3.name`) is FD →
safe to keep in `GROUP BY`.
- A descriptive field on the **transaction** table (`t1.supplier_name`, `t1.territory`,
`t1.item_name` — fetched/editable, can differ across historical rows for the same key) is
**not** FD even though it looks master-derived → `Max()`-wrap it.
Conversely, do **not** suggest changing a `Max()`/`Min()`-wrapped column to `Sum()` (or vice
versa) to make a number "more correct" — that changes the MariaDB value. The wrap reproduces
MariaDB's prior one-value-per-group output; a different aggregate is a product change, out of
scope for a portability fix.
---
## 4. False positives — do NOT flag these
These are auto-handled by the framework and are **not** breaks:
- **`.like()` / `["like", …]`** already renders as `ILIKE` on PostgreSQL — not a
case-sensitivity bug. *(Exception: `.like()` on a **non-text** column — `idx`, `docstatus` —
is a hard break, `bigint ILIKE`; see §1.)*
- **Raw `ifnull(...)`** inside `frappe.db.sql()` is rewritten to `coalesce(...)` on all engines.
- **Backticks**, **`LOCATE`**, **`REGEXP`** / **`.regexp()`** in raw SQL are auto-translated on
PostgreSQL (`REGEXP` → `~*`). **But `RLIKE` / `.rlike()` is NOT translated** — that one is a
hard break (see §1).
- **An `ORDER BY … LIMIT 1` tie where the two engines already agree**, or where adding a
tiebreaker would *change* MariaDB's current pick — leave it; "fixing" it would either change
MariaDB or has no observable effect.
---
## 5. Transaction / runtime (not query-shape, still PostgreSQL-only)
- **Catch-and-continue inserts** — on PostgreSQL a failed `insert()` aborts the **whole
transaction**, so code that swallows a duplicate and keeps going dies on the next statement
with `InFailedSqlTransaction` (frappe dropped its blanket per-statement savepoint in
frappe#40075). Such a handler must wrap the fallible insert in `frappe.db.savepoint(name)` +
`rollback(save_point=name)` — unless it re-`throw`s with no DB call before the throw, or the
insert uses `ignore_if_duplicate=True` / `autoname="hash"` (→ `ON CONFLICT DO NOTHING`).
---
## How to review
For every changed query: does it (a) use a construct from §1 (would error on PostgreSQL), or
(b) match a divergence in §2/§3 (different result across engines)? If so, comment with the
portable fix and confirm it leaves **MariaDB output unchanged**. Skip the §4 false positives.
Prefer a comment that names the rule (e.g. "loose GROUP BY — Max()-wrap, don't add to GROUP BY:
splits the row count") so the fix is unambiguous.
The static pre-commit checker (`.github/helper/postgres_compat.py`) catches the *mechanical*
§1 breaks; the **semantic** §2/§3 divergences are exactly what a reviewer (and this guide) must
cover, because no static check can see them.

View File

@@ -1,72 +0,0 @@
#!/bin/bash
#
# Hydrate a test shard from the setup job's artifact.
#
# The bench (apps, venv, node_modules, sites) is already on disk at ~/frappe-bench — the
# workflow untar'd it from the artifact the setup job built. So there is NO bench init, no
# asset build, and no reinstall here: just bring the DB up on the baked datadir and start redis
# so tests can run. The whole point is that the expensive work happened ONCE in the setup job.
#
set -e
ci_user="${ERPNEXT_CI_USER:-frappe}"
db_host="${DB_HOST:-127.0.0.1}"
# Re-exec as the ci user (uid 1001) so bench/cache ownership matches the artifact, same as
# install.sh. The workflow untar'd as root with -p, so the files are already owned by ci.
if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then
exec su -m "$ci_user" -s /bin/bash -c \
"ERPNEXT_CI_USER='$ci_user' DB_HOST='$db_host' bash '$0'"
fi
cd ~/frappe-bench
# Start the DB on the datadir baked into the artifact. It's already populated (the setup job
# reinstalled into this very datadir), so there is NO restore — the server comes up on the
# existing files. This is what replaces the per-shard SQL replay.
bash ~/frappe-bench/start-db.sh
# Bring up redis (lightmode unit tests need cache + queue). In the self-hosted container we use the
# full `bench start` (web/workers too, like install.sh). On the bare GitHub Postgres shard
# `bench start` (honcho) lagged — it blocks the redis procs behind web/worker procs the lightmode
# suite never uses, so the wait below burned its full timeout (~4m). There, start the two redis
# instances directly: fast and deterministic.
if [ "${DB:-mariadb}" = "postgres" ]; then
# Start redis directly as daemons — reliable and persists across steps. Do NOT route it through
# `bench start`: honcho tears the whole process group down if any one Procfile proc dies on the
# bare shard, which took redis with it (redis @ 13000 refused in Run Tests). Keeping redis
# independent is what makes it survive. The web server (for PDF tests) is NOT started here — a
# backgrounded server doesn't survive into the next step; it's started inside the Run Tests step.
for conf in redis_cache redis_queue; do
[ -f ~/frappe-bench/config/$conf.conf ] && redis-server ~/frappe-bench/config/$conf.conf --daemonize yes
done
else
bench start >> ~/frappe-bench/bench_start.log 2>&1 &
fi
# Wait for redis, failing fast instead of silently burning minutes if it never comes up.
cfg=~/frappe-bench/sites/common_site_config.json
if [ -f "$cfg" ]; then
ports=$(python - "$cfg" <<'PY'
import json, re, sys
try:
cfg = json.load(open(sys.argv[1]))
except Exception:
sys.exit(0)
for key in ("redis_cache", "redis_queue"):
m = re.search(r":(\d+)", str(cfg.get(key, "")))
if m:
print(m.group(1))
PY
)
for port in $ports; do
up=0
for _ in $(seq 1 60); do
if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then exec 3>&- 3<&-; up=1; break; fi
sleep 1
done
[ "$up" = "1" ] || { echo "redis did not come up on port $port"; exit 1; }
done
fi
echo "Hydrated: DB up on baked datadir, redis up — ready for tests."

View File

@@ -7,106 +7,21 @@ cd ~ || exit
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
frappeuser=${FRAPPE_USER:-"frappe"}
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
db_host=${DB_HOST:-"127.0.0.1"}
db_user_host=${DB_USER_HOST:-"localhost"}
wkhtmltox_deb=${WKHTMLTOX_DEB:-"/tmp/wkhtmltox.deb"}
bench_cache_dir=${BENCH_CACHE_DIR:-}
run_as_ci_user_if_needed() {
if [ "$(id -u)" != "0" ] || [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ] || [ "${ERPNEXT_CI_NON_ROOT:-0}" = "1" ]; then
return
fi
local missing_packages=()
if ! command -v pkg-config >/dev/null 2>&1; then
missing_packages+=("pkg-config")
fi
if ! command -v mariadb_config >/dev/null 2>&1 && ! command -v mysql_config >/dev/null 2>&1; then
missing_packages+=("libmariadb-dev")
fi
if ! command -v crontab >/dev/null 2>&1; then
missing_packages+=("cron")
fi
if [ "${#missing_packages[@]}" -gt 0 ]; then
apt-get update
apt-get install -y --no-install-recommends "${missing_packages[@]}"
fi
local ci_user="${ERPNEXT_CI_USER:-frappe}"
if ! id "$ci_user" >/dev/null 2>&1; then
useradd --home-dir "$HOME" --no-create-home --shell /bin/bash "$ci_user"
fi
rm -rf ~/frappe ~/frappe-bench
local ci_dirs=(
"$HOME"
"$GITHUB_WORKSPACE"
"$HOME/.cache"
"${PIP_CACHE_DIR:-$HOME/.cache/pip}"
"${npm_config_cache:-$HOME/.npm}"
"${YARN_CACHE_FOLDER:-$HOME/.cache/yarn}"
"$HOME/.yarn"
"${UV_CACHE_DIR:-$HOME/.cache/uv}"
"$(dirname "$wkhtmltox_deb")"
)
if [ -n "$bench_cache_dir" ]; then
ci_dirs+=("$bench_cache_dir")
fi
# Create + own (non-recursively) the home/cache/workspace dirs before dropping to
# the ci user. We deliberately do NOT wipe the yarn/uv caches here so a persistent
# cache (mounted volume or baked image layer) stays warm across runs.
mkdir -p "${ci_dirs[@]}" "$HOME/.yarn"
chown "$ci_user:$ci_user" "${ci_dirs[@]}" "$HOME/.yarn"
export ERPNEXT_CI_NON_ROOT=1
exec su -m "$ci_user" -s /bin/bash -c "cd '$HOME' && bash '$GITHUB_WORKSPACE/.github/helper/install.sh'"
}
run_as_ci_user_if_needed
run_ci_step() {
local label=$1
shift
echo "::group::${label}"
date -u
timeout --foreground "${CI_INSTALL_STEP_TIMEOUT:-600}" "$@"
local exit_code=$?
date -u
echo "::endgroup::"
return "$exit_code"
}
if [ -n "${GITHUB_WORKSPACE:-}" ]; then
git config --global --add safe.directory "$GITHUB_WORKSPACE" || true
git config --global --add safe.directory "$GITHUB_WORKSPACE/.git" || true
fi
rm -rf ~/frappe ~/frappe-bench
# ---------------------------------------------------------------------------
# Phase 1 — parallelise the three slow, independent setup steps:
# a) system packages b) frappe-bench pip install c) frappe git fetch
# ---------------------------------------------------------------------------
if [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ]; then
sudo apt-get update
sudo apt update
# apt remove/install must run sequentially but can overlap with pip and git.
sudo apt-get remove -y mysql-server mysql-client
sudo apt-get install -y libcups2-dev redis-server mariadb-client libmariadb-dev &
apt_pid=$!
# apt remove/install must run sequentially but can overlap with pip and git.
sudo apt remove mysql-server mysql-client
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev &
apt_pid=$!
pip install frappe-bench &
pip_pid=$!
else
apt_pid=
pip_pid=
fi
pip install frappe-bench &
pip_pid=$!
mkdir frappe
(
@@ -117,180 +32,43 @@ mkdir frappe
) &
clone_pid=$!
if [ -n "$apt_pid" ]; then wait $apt_pid; fi
if [ -n "$pip_pid" ]; then wait $pip_pid; fi
wait $apt_pid
wait $pip_pid
wait $clone_pid
pushd frappe
git checkout FETCH_HEAD
popd
frappe_sha=$(git -C frappe rev-parse HEAD)
get_bench_cache_archive() {
if [ -z "$bench_cache_dir" ]; then
return
fi
mkdir -p "$bench_cache_dir"
# Keyed on tool versions only (NOT the frappe SHA): any recent base bench works, because
# restore_warm_bench fast-forwards it to the exact live develop SHA. This is what lets a
# constantly-moving develop still hit the cache.
local cache_key
cache_key=$(
{
uname -m
python --version
node --version
bench --version
} | sha256sum | awk '{print $1}'
)
echo "${bench_cache_dir}/frappe-bench-base-${cache_key}.tar.zst"
}
restore_warm_bench() {
bench_cache_archive=$(get_bench_cache_archive)
[ -n "$bench_cache_archive" ] && [ -f "$bench_cache_archive" ] || return 1
echo "Restoring base bench from ${bench_cache_archive}"
tar --use-compress-program=unzstd -xf "$bench_cache_archive" -C ~ || return 1
[ -d ~/frappe-bench/apps/frappe/.git ] || return 1
mkdir -p ~/frappe-bench/sites ~/frappe-bench/logs
[ -f ~/frappe-bench/sites/apps.txt ] || printf "frappe\n" > ~/frappe-bench/sites/apps.txt
[ -f ~/frappe-bench/sites/common_site_config.json ] || printf "{}\n" > ~/frappe-bench/sites/common_site_config.json
# Fast-forward the restored frappe to the EXACT live develop SHA fetched in phase 1, then
# rebuild only what changed. The editable install means the venv tracks the new code with
# no reinstall. Any failure returns non-zero so the caller falls back to a full bench init.
if ! (
cd ~/frappe-bench/apps/frappe || exit 1
# Phase 1 already fetched ~/frappe to the exact live develop SHA. Fetch that commit
# straight from it (bench init names the remote 'upstream', not 'origin', and points
# it at this local clone — so a plain `git fetch origin` does not work).
git fetch --no-tags "$HOME/frappe" HEAD || exit 1
git checkout --force FETCH_HEAD || exit 1
); then
echo "Fast-forward to ${frappe_sha} failed; falling back to full init"
rm -rf ~/frappe-bench
return 1
fi
# Pick up any frappe dependency changes since the base was built (cached → fast if none),
# so a develop commit that bumped requirements doesn't leave a stale venv.
if ! ~/frappe-bench/env/bin/python -m pip install -q -e ~/frappe-bench/apps/frappe; then
echo "frappe dependency refresh failed; falling back to full init"
rm -rf ~/frappe-bench
return 1
fi
( cd ~/frappe-bench && CI=Yes bench build --app frappe ) || { rm -rf ~/frappe-bench; return 1; }
return 0
}
save_warm_bench() {
if [ -z "${bench_cache_archive:-}" ] || [ -f "$bench_cache_archive" ]; then
return
fi
if [ -n "$bench_cache_dir" ] && [ ! -w "$bench_cache_dir" ]; then
echo "Skipping warm bench save because ${bench_cache_dir} is not writable"
return
fi
local tmp_archive
tmp_archive="${bench_cache_archive}.${$}.tmp"
echo "Saving warm bench to ${bench_cache_archive}"
# Keep sites/common_site_config.json (the redis ports live there — dropping it makes the
# restore path fall back to a default redis port that bench start never bound, so reinstall
# fails with "redis ... connection refused"). Only the rebuildable sites/assets is excluded;
# restore_warm_bench runs `bench build` to regenerate it.
tar \
--use-compress-program="zstd -T0 -3" \
--exclude="frappe-bench/logs" \
--exclude="frappe-bench/sites/assets" \
-cf "$tmp_archive" \
-C ~ frappe-bench
mv "$tmp_archive" "$bench_cache_archive"
}
# ---------------------------------------------------------------------------
# Phase 2 — bench init and site setup
# ---------------------------------------------------------------------------
install_whktml() {
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
if [ ! -f "$wkhtmltox_deb" ]; then
wget -O "$wkhtmltox_deb" https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
fi
sudo apt-get install -y "$wkhtmltox_deb"
}
if [ "${SKIP_WKHTMLTOX_SETUP:-0}" != "1" ]; then
install_whktml &
wkpid=$!
else
wkpid=
fi
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
if ! restore_warm_bench; then
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
cd ~/frappe-bench || exit
sed -i 's/watch:/# watch:/g' Procfile
sed -i 's/schedule:/# schedule:/g' Procfile
sed -i 's/socketio:/# socketio:/g' Procfile
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
CI=Yes bench build --app frappe
save_warm_bench
fi
if [ -n "$wkpid" ]; then wait $wkpid; fi
mkdir -p ~/frappe-bench/sites/test_site
mkdir ~/frappe-bench/sites/test_site
if [ "$DB" == "mariadb" ];then
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_mariadb.json" ~/frappe-bench/sites/test_site/site_config.json
if [ "$db_host" != "127.0.0.1" ]; then
sed -i "s/\"db_host\": \"127.0.0.1\"/\"db_host\": \"${db_host}\"/" ~/frappe-bench/sites/test_site/site_config.json
fi
else
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_postgres.json" ~/frappe-bench/sites/test_site/site_config.json
fi
if [ "$DB" == "mariadb" ];then
for _ in {1..60}; do
if mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent; then
break
fi
sleep 1
done
mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
# Throwaway-DB durability tuning at runtime. (innodb_doublewrite is read-only on MariaDB
# 10.6, so it can't be disabled here — would need a server startup flag.)
mariadb --host "$db_host" --port 3306 -u root -proot \
# Belt-and-suspenders: also set performance variables at runtime in case
# MARIADB_EXTRA_FLAGS was not honoured by the container image.
mariadb --host 127.0.0.1 --port 3306 -u root -proot \
-e "SET GLOBAL innodb_flush_log_at_trx_commit=0; SET GLOBAL sync_binlog=0;"
# Opt-in DDL speedup: a shared tablespace avoids a create+fsync per DocType table during
# reinstall — a big win under disk contention. But ROW_FORMAT=DYNAMIC must be accepted in
# the system tablespace on this MariaDB. Enable with CI_INNODB_SHARED_TABLESPACE=1; if
# reinstall then errors on table creation, unset it (off by default — zero risk).
if [ "${CI_INNODB_SHARED_TABLESPACE:-0}" = "1" ]; then
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL innodb_file_per_table=0;"
fi
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'${db_user_host}' IDENTIFIED BY 'test_frappe'"
mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
mariadb --host "$db_host" --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'${db_user_host}'"
mariadb --host "$db_host" --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
fi
if [ "$DB" == "postgres" ];then
@@ -298,57 +76,32 @@ if [ "$DB" == "postgres" ];then
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
fi
install_whktml() {
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
if [ ! -f /tmp/wkhtmltox.deb ]; then
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
fi
sudo apt install /tmp/wkhtmltox.deb
}
install_whktml &
wkpid=$!
cd ~/frappe-bench || exit
run_ci_step "Get payments app" bench get-app payments --branch develop
sed -i 's/watch:/# watch:/g' Procfile
sed -i 's/schedule:/# schedule:/g' Procfile
sed -i 's/socketio:/# socketio:/g' Procfile
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
# Opt-in: skip building erpnext's frontend assets. Server tests don't need them, but PDF
# tests (print formats) do — they pass only if the PDF renderer ignores missing assets.
# Enable with CI_SKIP_ERPNEXT_ASSETS=1 to test; if PDF tests fail, unset it.
erpnext_get_app_args=()
if [ "${CI_SKIP_ERPNEXT_ASSETS:-0}" = "1" ]; then erpnext_get_app_args=(--skip-assets); fi
run_ci_step "Get erpnext app" bench get-app erpnext "${GITHUB_WORKSPACE}" "${erpnext_get_app_args[@]}"
bench get-app payments --branch develop
bench get-app erpnext "${GITHUB_WORKSPACE}"
if [ "$TYPE" == "server" ]; then run_ci_step "Setup dev requirements" bench setup requirements --dev; fi
if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
bench start >> ~/frappe-bench/bench_start.log 2>&1 &
wait $wkpid
# Under heavy concurrency, gunicorn's startup can delay redis coming up. reinstall and the
# tests need redis, so wait for it (best-effort, bounded) instead of racing — contention
# then slows the job rather than failing it.
wait_for_redis() {
local cfg=~/frappe-bench/sites/common_site_config.json
[ -f "$cfg" ] || return 0
local ports port
ports=$(python - "$cfg" <<'PY'
import json, re, sys
try:
cfg = json.load(open(sys.argv[1]))
except Exception:
sys.exit(0)
for key in ("redis_cache", "redis_queue"):
match = re.search(r":(\d+)", str(cfg.get(key, "")))
if match:
print(match.group(1))
PY
)
for port in $ports; do
local up=0
for _ in $(seq 1 120); do
if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then
exec 3>&- 3<&-; up=1
break
fi
sleep 1
done
# Fail clearly instead of letting reinstall die later on a vague socket-connection error
# when redis never bound.
[ "$up" = "1" ] || { echo "redis did not come up on port $port"; return 1; }
done
}
wait_for_redis
# Site setup: build the schema (~1000 DocTypes) into the DB. This is the single-threaded-Python
# bottleneck, but the fan-out amortises it — it runs once here in the setup job, and the test
# shards start the DB on the baked datadir instead of repeating the reinstall.
run_ci_step "Reinstall test site" bench --site test_site reinstall --yes
bench start &>> ~/frappe-bench/bench_start.log &
CI=Yes bench build --app frappe &
bench --site test_site reinstall --yes

View File

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

View File

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

View File

@@ -1,79 +0,0 @@
#!/bin/bash
#
# Run MariaDB INSIDE the runner container, on a datadir we control. Because the datadir can be
# packaged into the bench artifact, test shards start an already-loaded server instead of
# replaying a SQL dump (the ~60s hydrate restore). Each shard gets its own copy → isolation kept.
#
# CI_DB_DATADIR picks the path:
# - setup job: /home/ci/db-data (OUTSIDE the bench, so install.sh's `rm -rf ~/frappe-bench`
# doesn't wipe it; it's moved into the bench just before packaging)
# - test shard: ~/frappe-bench/mariadb-data (where the artifact untar'd it)
#
# Idempotent: inits a fresh datadir if absent (setup), else starts on the existing one (shards).
#
set -e
ci_user="${ERPNEXT_CI_USER:-frappe}"
# Re-exec as the ci user so mariadbd and the datadir are owned consistently (root mariadbd is
# refused anyway). Mirrors install.sh's user switch.
if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then
exec su -m "$ci_user" -s /bin/bash -c \
"ERPNEXT_CI_USER='$ci_user' CI_DB_DATADIR='${CI_DB_DATADIR:-}' bash '$0'"
fi
# --- PostgreSQL (GitHub-hosted CI): run in-runner on a PGDATA so it bakes into the artifact,
# same idea as the mariadb datadir. Trust auth (throwaway CI) skips password setup; durability
# off for speed. Postgres is preinstalled on ubuntu-latest under /usr/lib/postgresql/<ver>/bin.
if [ "${DB:-mariadb}" = "postgres" ]; then
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin 2>/dev/null | sort -V | tail -1)
[ -n "$PG_BIN" ] && export PATH="$PG_BIN:$PATH"
PGDATA="${CI_DB_DATADIR:-$HOME/frappe-bench/pgdata}"
if [ ! -d "$PGDATA/base" ]; then
initdb -D "$PGDATA" -U postgres --auth-local=trust --auth-host=trust >/dev/null
echo "host all all 127.0.0.1/32 trust" >> "$PGDATA/pg_hba.conf"
fi
pg_ctl -D "$PGDATA" -w -o "-p 5432 -c listen_addresses=127.0.0.1 -c unix_socket_directories=$PGDATA -c fsync=off -c synchronous_commit=off -c full_page_writes=off" start
echo "PostgreSQL up in-runner (pgdata=$PGDATA)"
exit 0
fi
# --- MariaDB ---
DATADIR="${CI_DB_DATADIR:-$HOME/frappe-bench/mariadb-data}"
SOCK="$DATADIR/mysqld.sock"
fresh=0
if [ ! -d "$DATADIR/mysql" ]; then
mkdir -p "$DATADIR"
mariadb-install-db --no-defaults --datadir="$DATADIR" \
--auth-root-authentication-method=normal --skip-test-db >/dev/null 2>&1
fresh=1
fi
# Throwaway-CI durability off; bind TCP 127.0.0.1:3306 so bench/install.sh connect as usual.
mariadbd --no-defaults --datadir="$DATADIR" --socket="$SOCK" --pid-file="$DATADIR/mysqld.pid" \
--port=3306 --bind-address=127.0.0.1 \
--innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --skip-log-bin \
> "$HOME/mariadb.log" 2>&1 &
up=0
for _ in $(seq 1 60); do
if mariadb-admin --socket="$SOCK" ping --silent 2>/dev/null; then up=1; break; fi
sleep 1
done
# Fail loudly instead of letting the loop fall through (exit 0 of the last `sleep`) into SQL that
# would error with a vague socket-connection failure.
[ "$up" = "1" ] || { echo "mariadbd did not come up on $SOCK"; cat "$HOME/mariadb.log" 2>/dev/null; exit 1; }
if [ "$fresh" = "1" ]; then
# A fresh datadir has only a password-less root@localhost. Give it the password install.sh
# uses, plus a TCP-reachable root@127.0.0.1, so the rest of install.sh works unchanged.
mariadb --no-defaults --socket="$SOCK" -u root <<'SQL'
ALTER USER 'root'@'localhost' IDENTIFIED BY 'root';
CREATE USER IF NOT EXISTS 'root'@'127.0.0.1' IDENTIFIED BY 'root';
GRANT ALL PRIVILEGES ON *.* TO 'root'@'127.0.0.1' WITH GRANT OPTION;
FLUSH PRIVILEGES;
SQL
fi
echo "MariaDB up in-container (datadir=$DATADIR, fresh=$fresh)"

View File

@@ -65,19 +65,6 @@ jobs:
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
# The v14 baseline backup is a fixed published file — cache it instead of re-downloading
# ~100MB from frappe.io every run.
- name: Cache erpnext v14 backup
id: cache-v14
uses: actions/cache@v4
with:
path: ~/erpnext-v14.sql.gz
key: erpnext-v14-sql-gz
- name: Download erpnext v14 backup
if: steps.cache-v14.outputs.cache-hit != 'true'
run: wget -O ~/erpnext-v14.sql.gz https://frappe.io/files/erpnext-v14.sql.gz
- name: Cache pip
uses: actions/cache@v4
with:
@@ -126,20 +113,12 @@ jobs:
jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json
mv tmp.json ~/frappe-bench/sites/test_site/site_config.json
bench --site test_site --force restore ~/erpnext-v14.sql.gz
wget https://frappe.io/files/erpnext-v14.sql.gz
bench --site test_site --force restore ~/frappe-bench/erpnext-v14.sql.gz
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git
# Start every bench process except the background workers. If workers run during a
# migrate, they pick up the orphan-link cleanup jobs it enqueues and race its schema
# changes, which fails with MySQL 1412 "Table definition has changed". Redis and the
# other services stay up; the queued jobs simply wait and are harmless here.
function start_bench_without_workers() {
local procs
procs=$(awk -F: '/^[a-z_]+:/ && $1 !~ /worker/ {print $1}' ~/frappe-bench/Procfile)
honcho start -f ~/frappe-bench/Procfile $procs &>> ~/frappe-bench/bench_start.log &
}
function update_to_version() {
version=$1
@@ -155,11 +134,10 @@ 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
start_bench_without_workers
bench start &>> ~/frappe-bench/bench_start.log &
bench --site test_site migrate
}
@@ -176,7 +154,7 @@ jobs:
rm -rf ~/frappe-bench/env
bench -v setup env
bench pip install -e ./apps/erpnext
start_bench_without_workers
bench start &>> ~/frappe-bench/bench_start.log &
bench --site test_site migrate

View File

@@ -1,12 +1,7 @@
name: Server (Postgres)
on:
schedule:
# 03:00 AM IST daily (21:30 UTC the previous day)
- cron: "30 21 * * *"
pull_request:
# 'labeled' so adding the 'postgres' label to an already-open PR re-triggers the run.
types: [opened, reopened, synchronize, labeled]
paths-ignore:
- '**.js'
- '**.md'
@@ -14,7 +9,7 @@ on:
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
workflow_dispatch:
types: [opened, labelled, synchronize, reopened]
concurrency:
group: server-postgres-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
@@ -23,31 +18,41 @@ concurrency:
permissions:
contents: read
# Postgres CI stays on GitHub-hosted (free, full-speed VM per shard) but follows the same fan-out
# we built for MariaDB: build the bench + reinstall ONCE in the setup job, bake the PostgreSQL
# PGDATA into the artifact, and have 4 test shards start Postgres on that datadir — no per-shard
# clone/build/reinstall/restore. Python is pinned so the venv transplants between VMs.
env:
TZ: 'Asia/Kolkata'
NODE_ENV: "production"
PYTHON_VERSION: '3.14'
jobs:
setup:
name: Build & reinstall (setup)
test:
if: ${{ contains(github.event.pull_request.labels.*.name, 'postgres') }}
runs-on: ubuntu-latest
# Runs on the daily schedule (and workflow_dispatch). On PRs it runs ONLY when the PR carries
# the 'postgres' label — the test job needs setup, so it's skipped too when this is.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'postgres')
timeout-minutes: 40
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
container: [1]
name: Python Unit Tests
services:
postgres:
image: postgres:13.3
env:
POSTGRES_PASSWORD: travis
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Clone
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
python-version: '3.14'
- name: Check for valid Python & Merge Conflicts
run: |
@@ -66,124 +71,48 @@ jobs:
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
- name: Cache deps (uv/pip/npm/yarn)
- name: Cache pip
uses: actions/cache@v4
with:
path: |
~/.cache/uv
~/.cache/pip
~/.npm
~/.cache/yarn
key: ${{ runner.os }}-deps-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }}
restore-keys: ${{ runner.os }}-deps-
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
# Warm-bench cache (the big one): install.sh saves the built base bench — frappe + env +
# node_modules + assets — here as frappe-bench-base-*.tar.zst. Later runs restore it and only
# fast-forward to the live develop SHA + rebuild the delta, so the bench BUILD is near-free and
# only the test_site reinstall (per-run DB, uncacheable) stays slow — matching the self-hosted
# box. The first run after a deps change populates it; every run after that is fast.
- name: Cache warm bench (base build)
- name: Cache node modules
uses: actions/cache@v4
with:
path: ~/bench-cache
key: ${{ runner.os }}-warmbench-v2-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }}
restore-keys: ${{ runner.os }}-warmbench-v2-
# Postgres runs in-runner on a PGDATA OUTSIDE the bench (install.sh wipes ~/frappe-bench);
# after the reinstall it's moved into the bench so it ships in the artifact.
- name: Start DB
run: bash ${GITHUB_WORKSPACE}/.github/helper/start-db.sh
env:
DB: postgres
CI_DB_DATADIR: /home/runner/pgdata
cache-name: cache-node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env:
DB: postgres
TYPE: server
FRAPPE_BRANCH: develop
BENCH_CACHE_DIR: /home/runner/bench-cache
- name: Stop DB and stage datadir
run: |
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin | sort -V | tail -1)
"$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop || true
mv /home/runner/pgdata /home/runner/frappe-bench/pgdata
- name: Package bench for test shards
run: |
cp "${GITHUB_WORKSPACE}/.github/helper/hydrate.sh" /home/runner/frappe-bench/hydrate.sh
cp "${GITHUB_WORKSPACE}/.github/helper/start-db.sh" /home/runner/frappe-bench/start-db.sh
tar czpf "${GITHUB_WORKSPACE}/bench.tar.gz" -C /home/runner \
--exclude='.git' --exclude='node_modules' frappe-bench
ls -lh "${GITHUB_WORKSPACE}/bench.tar.gz"
- name: Upload bench artifact
uses: actions/upload-artifact@v4
with:
name: bench-pg
path: bench.tar.gz
retention-days: 1
compression-level: 0
test:
name: Python Unit Tests (PG)
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
container: [1, 2, 3, 4]
steps:
- name: Download bench artifact
uses: actions/download-artifact@v4
with:
name: bench-pg
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
# The bench CLI (frappe-bench) and redis are global/system tools — not in the bench tarball.
# The setup runner got them via install.sh; the MariaDB shards get them from the arc5 image.
# GitHub-hosted PG shards install them here (cheap vs the build+reinstall that setup did once).
- name: Install shard runtime (bench CLI + redis + wkhtmltopdf)
run: |
pip install frappe-bench
command -v redis-server >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq redis-server; }
# wkhtmltopdf (patched-qt build) for print-format / PDF tests — same .deb install.sh uses.
if ! command -v wkhtmltopdf >/dev/null; then
wget -qO /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
sudo apt-get install -y -qq /tmp/wkhtmltox.deb
fi
- name: Untar bench
run: |
tar xzpf "${GITHUB_WORKSPACE}/bench.tar.gz" -C /home/runner
ls -ld /home/runner/frappe-bench
- name: Hydrate (start Postgres on the baked datadir)
run: bash /home/runner/frappe-bench/hydrate.sh
env:
DB: postgres
DB_HOST: 127.0.0.1
- name: Run Tests
run: |
cd ~/frappe-bench/
# print-format / PDF tests are engine-independent (they exercise wkhtmltopdf rendering,
# not postgres SQL — the MariaDB CI already covers them). They only fetch the static asset
# bundles from http://test_site:8000/assets/..., so a plain static file server over sites/
# satisfies wkhtmltopdf without the frappe web server (which never bound on a bare runner).
( cd ~/frappe-bench/sites && nohup python3 -m http.server 8000 --bind 127.0.0.1 > ~/frappe-bench/web.log 2>&1 & )
for _ in $(seq 1 15); do (exec 3<>/dev/tcp/127.0.0.1/8000) 2>/dev/null && { exec 3>&- 3<&-; break; }; sleep 1; done
bench --site test_site run-parallel-tests --lightmode --app erpnext \
--total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }}
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator
env:
TYPE: server
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -3,6 +3,8 @@ import inspect
from typing import TypeVar
import frappe
from frappe.model.document import Document
from frappe.utils.user import is_website_user
__version__ = "17.0.0-dev"
@@ -153,8 +155,6 @@ def allow_regional(fn):
def check_app_permission():
from frappe.utils.user import is_website_user
if frappe.session.user == "Administrator":
return True
@@ -175,8 +175,6 @@ def normalize_ctx_input(T: type) -> callable:
- Casting the result to the specified type T
"""
from frappe.model.document import Document
def decorator(func: callable):
# conserve annotations for frappe.utils.typing_validations
@functools.wraps(func, assigned=(a for a in functools.WRAPPER_ASSIGNMENTS if a != "__annotations__"))

View File

@@ -1,7 +1,6 @@
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,
@@ -54,24 +53,20 @@ def validate_service_stop_date(doc):
def build_conditions(process_type, account, company):
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
conditions = ""
deferred_account = (
"item.deferred_revenue_account" if process_type == "Income" else "item.deferred_expense_account"
)
if account:
return deferred_account == account
conditions += f"AND {deferred_account}={frappe.db.escape(account)}"
elif company:
return parent.company == company
conditions += f"AND p.company = {frappe.db.escape(company)}"
return None
return conditions
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=None):
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=""):
# 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:
@@ -80,25 +75,17 @@ 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
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)
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
# For each invoice, book deferred expense
for invoice in invoices:
@@ -109,7 +96,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=None):
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=""):
# 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:
@@ -118,25 +105,17 @@ 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
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)
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
for invoice in invoices:
doc = frappe.get_doc("Sales Invoice", invoice)
@@ -157,39 +136,26 @@ def get_booking_dates(doc, item, posting_date=None, prev_posting_date=None):
)
if not prev_posting_date:
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_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,
)
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)
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,
)
if prev_gl_via_je:
@@ -311,47 +277,26 @@ def get_already_booked_amount(doc, item):
total_credit_debit, total_credit_debit_currency = "credit", "credit_in_account_currency"
deferred_account = "deferred_expense_account"
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)
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,
)
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)
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,
)
already_booked_amount = gl_entries_details[0].total_credit if gl_entries_details else 0

View File

@@ -137,7 +137,7 @@ def get_charts_for_country(country: str, with_standard: bool = False):
def _get_chart_name(content):
if content:
content = frappe.parse_json(content)
content = json.loads(content)
if (
content and content.get("disabled", "No") == "No"
) or frappe.local.flags.allow_unverified_charts:

View File

@@ -224,7 +224,7 @@ def disable_dimension(doc: str):
def toggle_disabling(doc):
doc = frappe.parse_json(doc)
doc = json.loads(doc)
if doc.get("disabled"):
df = {"read_only": 1}

View File

@@ -10,7 +10,7 @@ frappe.ui.form.on("Accounts Settings", {
},
};
});
if (!frm.naming_controller) frm.naming_controller = new frappe.ui.NamingSeriesController(frm);
if (!frm.naming_controller) frm.naming_controller = new erpnext.NamingSeriesController(frm);
frm.naming_controller.render_table("transaction_naming_html", get_transactions(frm));
},

View File

@@ -87,7 +87,6 @@
"period_closing_settings_section",
"ignore_account_closing_balance",
"use_legacy_controller_for_pcv",
"pcv_job_timeout",
"column_break_25",
"reports_tab",
"remarks_section",
@@ -613,14 +612,6 @@
"fieldtype": "Check",
"label": "Use legacy controller for Period Closing Voucher"
},
{
"default": "3600",
"depends_on": "eval: !doc.use_legacy_controller_for_pcv",
"description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher",
"fieldname": "pcv_job_timeout",
"fieldtype": "Int",
"label": "PCV Job Timeout (seconds)"
},
{
"description": "Users with this role will be notified if the asset depreciation gets failed",
"fieldname": "role_to_notify_on_depreciation_failure",
@@ -765,7 +756,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-24 12:59:41.868865",
"modified": "2026-06-03 13:11:54.721495",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -90,7 +90,6 @@ class AccountsSettings(Document):
make_payment_via_journal_entry: DF.Check
merge_similar_account_heads: DF.Check
over_billing_allowance: DF.Currency
pcv_job_timeout: DF.Int
preview_mode: DF.Check
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
receivable_payable_remarks_length: DF.Int

View File

@@ -7,7 +7,7 @@ from frappe import _, msgprint
from frappe.model.document import Document
from frappe.query_builder import Case
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Coalesce, Max, Sum
from frappe.query_builder.functions import Coalesce, Sum
from frappe.utils import cint, flt, fmt_money, getdate
from pypika import Order
@@ -195,17 +195,14 @@ def get_payment_entries_for_bank_clearance(
.select(
ConstantColumn("Journal Entry").as_("payment_document"),
journal_entry.name.as_("payment_entry"),
# 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"),
journal_entry.cheque_no.as_("cheque_number"),
journal_entry.cheque_date,
Sum(journal_entry_account.debit_in_account_currency).as_("debit"),
Sum(journal_entry_account.credit_in_account_currency).as_("credit"),
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"),
journal_entry.posting_date,
journal_entry_account.against_account,
journal_entry.clearance_date,
journal_entry_account.account_currency,
)
.where(
(journal_entry_account.account == account)
@@ -218,13 +215,12 @@ 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" if frappe.db.db_type != "postgres" else None))
(journal_entry.clearance_date.isnull()) | (journal_entry.clearance_date == "0000-00-00")
)
journal_entries = (
journal_entry_query.groupby(journal_entry_account.account, journal_entry.name)
.orderby(Max(journal_entry.posting_date))
.orderby(journal_entry.posting_date)
.orderby(journal_entry.name, order=Order.desc)
).run(as_dict=True)
@@ -294,8 +290,7 @@ 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" if frappe.db.db_type != "postgres" else None))
(pe.clearance_date.isnull()) | (pe.clearance_date == "0000-00-00")
)
payment_entries = (payment_entry_query.orderby(pe.posting_date).orderby(pe.name, order=Order.desc)).run(
@@ -332,8 +327,7 @@ 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" if frappe.db.db_type != "postgres" else None))
(pi.clearance_date.isnull()) | (pi.clearance_date == "0000-00-00")
)
paid_purchase_invoices = (
@@ -373,8 +367,7 @@ 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" if frappe.db.db_type != "postgres" else None))
(si_payment.clearance_date.isnull()) | (si_payment.clearance_date == "0000-00-00")
)
pos_sales_invoices = (

View File

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

View File

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

View File

@@ -1058,9 +1058,9 @@ def get_auto_reconcile_message(partially_reconciled, reconciled):
@frappe.whitelist()
def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str | list, is_new_voucher: bool = False):
def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str, is_new_voucher: bool = False):
# updated clear date of all the vouchers based on the bank transaction
vouchers = frappe.parse_json(vouchers)
vouchers = json.loads(vouchers)
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
transaction.add_payment_entries(vouchers, is_new_voucher)
transaction.validate_duplicate_references()

View File

@@ -221,12 +221,12 @@
"default": "0",
"fieldname": "import_mt940_fromat",
"fieldtype": "Check",
"label": "Import MT940 Format"
"label": "Import MT940 Fromat"
}
],
"hide_toolbar": 1,
"links": [],
"modified": "2026-06-19 14:18:00.000000",
"modified": "2026-05-31 00:41:11.251215",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Statement Import",

View File

@@ -290,7 +290,7 @@ def update_mapping_db(bank, template_options):
for d in bank.bank_transaction_mapping:
d.delete()
for d in frappe.parse_json(template_options)["column_to_field_map"].items():
for d in json.loads(template_options)["column_to_field_map"].items():
bank.append("bank_transaction_mapping", {"bank_transaction_field": d[1], "file_field": d[0]})
bank.save()

View File

@@ -1183,7 +1183,8 @@ def update_pdf_tables(statement_import_id: str, tables: list | str):
if doc.status == "Completed":
frappe.throw(_("This statement has already been imported."), title=_("Already Imported"))
tables = frappe.parse_json(tables)
if isinstance(tables, str):
tables = json.loads(tables)
doc.apply_pdf_tables(tables)
@@ -1203,7 +1204,8 @@ def reextract_pdf_table(statement_import_id: str, page: int, table_index: int, b
if doc.status == "Completed":
frappe.throw(_("This statement has already been imported."), title=_("Already Imported"))
bbox = frappe.parse_json(bbox)
if isinstance(bbox, str):
bbox = json.loads(bbox)
page = int(page)
table_index = int(table_index)
@@ -1288,7 +1290,8 @@ def update_column_mapping(statement_import_id: str, column_mapping: list | str):
if doc.status == "Completed":
frappe.throw(_("This statement has already been imported."), title=_("Already Imported"))
column_mapping = frappe.parse_json(column_mapping)
if isinstance(column_mapping, str):
column_mapping = json.loads(column_mapping)
doc.apply_column_mapping(column_mapping)
doc.save()

View File

@@ -35,12 +35,12 @@ def upload_bank_statement():
@frappe.whitelist()
def create_bank_entries(columns: str, data: str | list, bank_account: str):
def create_bank_entries(columns: str, data: str, bank_account: str):
header_map = get_header_mapping(columns, bank_account)
success = 0
errors = 0
for d in frappe.parse_json(data):
for d in json.loads(data):
if all(item is None for item in d) is True:
continue
fields = {}
@@ -66,7 +66,7 @@ def get_header_mapping(columns, bank_account):
mapping = get_bank_mapping(bank_account)
header_map = {}
for column in frappe.parse_json(columns):
for column in json.loads(columns):
if column["content"] in mapping:
header_map.update({mapping[column["content"]]: column["colIndex"]})

View File

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

View File

@@ -5,11 +5,9 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Coalesce, Sum
from frappe.query_builder.functions import 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,
@@ -117,26 +115,23 @@ class Budget(Document):
if not account:
return
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)
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,
)
if existing_budget:
@@ -162,9 +157,9 @@ class Budget(Document):
frappe.throw(_("Account {0} does not belong to company {1}").format(self.account, self.company))
elif account_details.report_type != "Profit and Loss":
frappe.throw(
_(
"Budget cannot be assigned against {0}, as its Root Type is not of Income or Expense"
).format(self.account)
_("Budget cannot be assigned against {0}, as it's not an Income or Expense account").format(
self.account
)
)
def set_null_value(self):
@@ -358,8 +353,8 @@ class Budget(Document):
if self.should_regenerate_budget_distribution():
return
total_amount = sum(flt(d.amount) for d in self.budget_distribution)
total_percent = sum(flt(d.percent) for d in self.budget_distribution)
total_amount = sum(d.amount for d in self.budget_distribution)
total_percent = sum(d.percent for d in self.budget_distribution)
if flt(abs(total_amount - self.budget_amount), 2) > 0.10:
frappe.throw(
@@ -386,24 +381,17 @@ 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 = 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()
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),
)
if not budget_exists:
@@ -446,52 +434,50 @@ 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")
params.is_tree = bool(frappe.get_cached_value("DocType", doctype, "is_tree"))
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.budget_against_field = budget_against
params.budget_against_doctype = doctype
b = frappe.qb.DocType("Budget")
query = (
frappe.qb.from_(b)
.select(
budget_records = frappe.db.sql(
f"""
SELECT
b.name,
getattr(b, budget_against).as_("budget_against"),
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,
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"),
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,
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,
)
.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)
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
if budget_records:
validate_budget_records(params, budget_records, expense_amount)
@@ -688,27 +674,15 @@ def get_actions(params, budget):
def get_requested_amount(params):
item_code = params.get("item_code")
condition = get_other_condition(params, "Material Request")
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)
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,
)
return data[0][0] if data else 0
@@ -716,43 +690,37 @@ def get_requested_amount(params):
def get_ordered_amount(params):
item_code = params.get("item_code")
condition = get_other_condition(params, "Purchase Order")
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)
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,
)
return data[0][0] if data else 0
def get_other_condition(params, child, parent, for_doc):
conditions = [child.expense_account == params.expense_account]
def get_other_condition(params, for_doc):
condition = f"expense_account = {frappe.db.escape(params.expense_account)}"
budget_against_field = params.get("budget_against_field")
if budget_against_field and params.get(budget_against_field):
conditions.append(child[budget_against_field] == params.get(budget_against_field))
condition += (
f" and child.{budget_against_field} = {frappe.db.escape(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")
conditions.append(parent[date_field][str(start_date) : str(end_date)])
condition += f" and parent.{date_field} between {frappe.db.escape(str(start_date))} and {frappe.db.escape(str(end_date))}"
return conditions
return condition
def get_actual_expense(params):
@@ -760,19 +728,11 @@ 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 ""
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"))
date_condition = (
f"and gle.posting_date between '{params.budget_start_date}' and '{params.budget_end_date}'"
)
if params.is_tree:
lft_rgt = frappe.db.get_value(
@@ -780,27 +740,35 @@ def get_actual_expense(params):
)
params.update(lft_rgt)
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])
)
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}
)
)
"""
else:
conditions.append(gle[budget_against_field] == params.get(budget_against_field))
condition2 = f"""
and gle.{budget_against_field} = %({budget_against_field})s
"""
amount = flt(
frappe.qb.from_(gle)
.select(Sum(gle.debit) - Sum(gle.credit))
.where(Criterion.all(conditions))
.run()[0][0]
)
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
return amount

View File

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

View File

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

View File

@@ -75,10 +75,7 @@ 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
@@ -456,7 +453,6 @@ 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
@@ -468,7 +464,8 @@ def unset_existing_data(company):
"Sales Taxes and Charges Template",
"Purchase Taxes and Charges Template",
]:
frappe.get_query(doctype, delete=True, filters={"company": company}, ignore_permissions=False).run()
dt = frappe.qb.DocType(doctype)
frappe.qb.from_(dt).where(dt.company == company).delete().run()
def set_default_accounts(company):

View File

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

View File

@@ -173,66 +173,3 @@ 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")

View File

@@ -248,7 +248,8 @@ def get_dunning_letter_text(dunning_type: str, doc: str | dict, language: str |
DOCTYPE = "Dunning Letter Text"
FIELDS = ["body_text", "closing_text", "language"]
doc = frappe.parse_json(doc)
if isinstance(doc, str):
doc = json.loads(doc)
if not language:
language = doc.get("language")

View File

@@ -605,10 +605,7 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
last_exchange_rate = (
qb.from_(gl)
.select(
(gl.debit - gl.credit)
/ NullIf(gl.debit_in_account_currency - gl.credit_in_account_currency, 0)
)
.select((gl.debit - gl.credit) / (gl.debit_in_account_currency - gl.credit_in_account_currency))
.where(
(gl.voucher_type == voucher_type) & (gl.voucher_no == voucher_no) & (gl.account == account)
)
@@ -629,8 +626,6 @@ def get_account_details(
party: str | None = None,
rounding_loss_allowance: float = 0.0,
):
frappe.has_permission("Account", doc=account, throw=True)
if not (company and posting_date):
frappe.throw(_("Company and Posting Date is mandatory"))

View File

@@ -12,9 +12,8 @@ from typing import Any, Union
import frappe
from frappe import _
from frappe.database.operator_map import OPERATOR_MAP
from frappe.model import numeric_fieldtypes
from frappe.query_builder import Case
from frappe.query_builder.functions import Cast_, Sum
from frappe.query_builder.functions import Sum
from frappe.utils import cstr, date_diff, flt, getdate
from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder
from pypika.terms import Bracket, LiteralValue
@@ -865,15 +864,8 @@ class FilterExpressionParser:
field = getattr(table, field_name, None)
operator_fn = OPERATOR_MAP.get(operator.casefold())
if "like" in operator.casefold():
if "%" not in value:
value = f"%{value}%"
# Postgres has no LIKE/ILIKE operator for non-text columns; MariaDB implicitly casts
# the numeric column to text. Cast a numeric/Check Account field to varchar so the
# match runs on both engines and reproduces MariaDB's result.
meta_field = frappe.get_meta("Account").get_field(field_name)
if meta_field and meta_field.fieldtype in numeric_fieldtypes:
field = Cast_(field, "varchar")
if "like" in operator.casefold() and "%" not in value:
value = f"%{value}%"
return operator_fn(field, value)
@@ -1032,7 +1024,8 @@ class FormulaFieldUpdater:
def get_filtered_accounts(company: str, account_rows: str | list):
frappe.has_permission("Financial Report Template", ptype="read", throw=True)
account_rows = [frappe._dict(row) for row in frappe.parse_json(account_rows)]
if isinstance(account_rows, str):
account_rows = json.loads(account_rows, object_hook=frappe._dict)
return DataCollector.get_filtered_accounts(company, account_rows)

View File

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

View File

@@ -7,7 +7,6 @@ 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
@@ -332,12 +331,10 @@ 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:
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()
balance = frappe.db.sql(
"""select sum(debit) - sum(credit)
from `tabGL Entry` where is_cancelled = 0 and account = %s""",
account,
)[0][0]
if (balance_must_be == "Debit" and flt(balance) < 0) or (
@@ -351,48 +348,44 @@ 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:
conditions &= (gle.party_type == party_type) & (gle.party == party)
party_condition = " and party_type={} and party={}".format(
frappe.db.escape(party_type), frappe.db.escape(party)
)
else:
party_condition = ""
if against_voucher_type == "Sales Invoice":
party_account = frappe.get_cached_value(against_voucher_type, against_voucher, "debit_to")
conditions &= gle.account.isin([account, party_account])
account_condition = f"and account in ({frappe.db.escape(account)}, {frappe.db.escape(party_account)})"
else:
conditions &= gle.account == account
account_condition = f" and account = {frappe.db.escape(account)}"
# get final outstanding amt
bal = flt(
frappe.qb.from_(gle)
.select(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
.where(conditions)
.run()[0][0]
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]
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.qb.from_(gle)
.select(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
.where(je_conditions)
.run()[0][0]
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]
)
if not against_voucher_amount:
@@ -487,14 +480,10 @@ def rename_temporarily_named_docs(doctype):
oldname = doc.name
set_name_from_naming_options(autoname, doc)
newname = doc.name
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()
frappe.db.sql(
f"UPDATE `tab{doctype}` SET name = %s, to_rename = 0, modified = %s where name = %s",
(newname, now(), oldname),
)
for hook_type in ("on_gle_rename", "on_sle_rename"):
for hook in frappe.get_hooks(hook_type):

View File

@@ -26,17 +26,12 @@ class TestGLEntry(ERPNextTestSuite):
jv.flags.ignore_validate = True
jv.submit()
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",
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,
)
self.assertTrue(round_off_entry)
@@ -60,9 +55,8 @@ class TestGLEntry(ERPNextTestSuite):
)
self.assertTrue(all(entry.to_rename == 1 for entry in gl_entries))
series = frappe.qb.DocType("Series")
old_naming_series_current_value = (
frappe.qb.from_(series).select(series["current"]).where(series.name == naming_series).run()
old_naming_series_current_value = frappe.db.sql(
"SELECT current from tabSeries where name = %s", naming_series
)[0][0]
rename_gle_sle_docs()
@@ -79,8 +73,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.qb.from_(series).select(series["current"]).where(series.name == naming_series).run()
new_naming_series_current_value = frappe.db.sql(
"SELECT current from tabSeries where name = %s", naming_series
)[0][0]
self.assertEqual(old_naming_series_current_value + 2, new_naming_series_current_value)

View File

@@ -317,8 +317,8 @@ class InvoiceDiscounting(AccountsController):
@frappe.whitelist()
def get_invoices(filters: str | dict):
filters = frappe._dict(frappe.parse_json(filters))
def get_invoices(filters: str):
filters = frappe._dict(json.loads(filters))
si = frappe.qb.DocType("Sales Invoice")
di = frappe.qb.DocType("Discounted Invoice")

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,6 @@ 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,
@@ -150,9 +149,6 @@ 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()
@@ -893,7 +889,7 @@ class JournalEntry(AccountsController):
msgprint(_("'Entries' cannot be empty"), raise_exception=True)
return
self.set_total_debit_credit()
self.total_debit, self.total_credit = 0, 0
diff = flt(self.difference, self.precision("difference"))
if diff:
self._apply_difference_to_blank_row(diff, difference_account)

View File

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

View File

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

View File

@@ -39,32 +39,28 @@ def get_loyalty_point_entries(customer, loyalty_program, company, expiry_date=No
if not expiry_date:
expiry_date = today()
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",
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,
)
def get_redemption_details(customer, loyalty_program, company):
return frappe._dict(
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,
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),
)
)

View File

@@ -74,31 +74,29 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
},
setup_company_filters: function (frm) {
frm.events.apply_company_query_filter(frm, "cost_center", "invoices", { is_group: 0 });
frm.events.apply_company_query_filter(frm, "project", "invoices");
frm.events.apply_company_query_filter(frm, "project");
frm.events.apply_company_query_filter(frm, "cost_center", undefined, { is_group: 0 });
frm.events.apply_company_query_filter(frm, "temporary_opening_account", "invoices", {
account_type: "Temporary",
is_group: 0,
});
},
apply_company_query_filter: function (frm, field_name, child_doctype = null, filters = {}) {
const query = function (doc) {
frm.set_query("cost_center", "invoices", function (doc, cdt, cdn) {
return {
filters: {
company: doc.company,
...filters,
},
};
};
});
if (child_doctype) {
frm.set_query(field_name, child_doctype, query);
} else {
frm.set_query(field_name, query);
}
frm.set_query("cost_center", function (doc) {
return {
filters: {
company: doc.company,
},
};
});
frm.set_query("temporary_opening_account", "invoices", function (doc, cdt, cdn) {
return {
filters: {
company: doc.company,
},
};
});
},
company: function (frm) {
@@ -122,6 +120,11 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
},
invoice_type: function (frm) {
$.each(frm.doc.invoices, (idx, row) => {
row.party_type = frm.doc.invoice_type == "Sales" ? "Customer" : "Supplier";
frappe.model.set_value(row.doctype, row.name, "party", "");
frappe.model.set_value(row.doctype, row.name, "party_name", "");
});
frm.clear_table("invoices");
frm.refresh_fields();
frm.trigger("update_party_labels");
@@ -216,19 +219,7 @@ frappe.ui.form.on("Opening Invoice Creation Tool Item", {
});
},
invoices_add: (frm, cdt, cdn) => {
const row = frappe.get_doc(cdt, cdn);
const field_copy = [];
["project", "cost_center"].forEach((fieldname) => {
if (frm.doc[fieldname]) {
frappe.model.set_value(cdt, cdn, fieldname, frm.doc[fieldname]);
} else {
field_copy.push(fieldname);
}
});
frm.script_manager.copy_from_first_row("invoices", row, field_copy);
invoices_add: (frm) => {
frm.trigger("update_invoice_table");
},
});

View File

@@ -133,17 +133,6 @@ class OpeningInvoiceCreationTool(Document):
if not row.get(scrub(d)):
frappe.throw(mandatory_error_msg.format(row.idx, d, self.invoice_type))
self.validate_temporary_opening_account(row)
def validate_temporary_opening_account(self, row):
account_type = frappe.get_cached_value("Account", row.temporary_opening_account, "account_type")
if account_type != "Temporary":
frappe.throw(
_("Row #{0}: {1} account is not of type {2}").format(
row.idx, row.temporary_opening_account, "Temporary"
)
)
def get_invoices(self):
invoices = []
for row in self.invoices:
@@ -214,7 +203,6 @@ class OpeningInvoiceCreationTool(Document):
"description": row.item_name or "Opening Invoice Item",
income_expense_account_field: row.temporary_opening_account,
"cost_center": cost_center,
"project": row.get("project") or self.get("project"),
}
)

View File

@@ -2,12 +2,10 @@
# See license.txt
import frappe
from frappe.utils import add_days, today
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
get_temporary_opening_account,
)
from erpnext.projects.doctype.project.test_project import make_project
from erpnext.tests.utils import ERPNextTestSuite
@@ -16,26 +14,21 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
self,
invoice_type="Sales",
company=None,
invoices=None,
project=None,
cost_center=None,
party_1=None,
party_2=None,
invoice_number=None,
department=None,
return_doc=False,
):
doc = frappe.get_single("Opening Invoice Creation Tool")
args = get_opening_invoice_creation_dict(
invoice_type=invoice_type,
company=company,
invoices=invoices,
project=project,
cost_center=cost_center,
party_1=party_1,
party_2=party_2,
invoice_number=invoice_number,
department=department,
)
doc.update(args)
if return_doc:
return doc
return doc.make_invoices()
def test_opening_sales_invoice_creation(self):
@@ -44,8 +37,8 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
self.assertEqual(len(invoices), 2)
expected_value = {
"keys": ["customer", "outstanding_amount", "status"],
0: ["_Test Customer", 200, "Overdue"],
1: ["_Test Customer 1", 200, "Overdue"],
0: ["_Test Customer", 300, "Overdue"],
1: ["_Test Customer 1", 250, "Overdue"],
}
self.check_expected_values(invoices, expected_value)
@@ -62,34 +55,48 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
for field_idx, field in enumerate(expected_value["keys"]):
self.assertEqual(si.get(field, ""), expected_value[invoice_idx][field_idx])
def test_opening_invoice_requires_temporary_account_type(self):
doc = self.make_invoices(company="_Test Opening Invoice Company", return_doc=True)
doc.invoices[0].temporary_opening_account = "Sales - _TOIC"
self.assertRaises(frappe.ValidationError, doc.make_invoices)
def test_opening_purchase_invoice_creation(self):
invoices = self.make_invoices(invoice_type="Purchase", company="_Test Opening Invoice Company")
self.assertEqual(len(invoices), 2)
expected_value = {
"keys": ["supplier", "outstanding_amount", "status"],
0: ["_Test Supplier", 200, "Overdue"],
1: ["_Test Supplier 1", 200, "Overdue"],
0: ["_Test Supplier", 300, "Overdue"],
1: ["_Test Supplier 1", 250, "Overdue"],
}
self.check_expected_values(invoices, expected_value, "Purchase")
def test_opening_sales_invoice_creation_with_missing_debit_account(self):
company = "_Test Opening Invoice Company"
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
old_default_receivable_account = frappe.db.get_value(
"Company", "_Test Opening Invoice Company", "default_receivable_account"
)
frappe.db.set_value("Company", "_Test Opening Invoice Company", "default_receivable_account", "")
old_default_receivable_account = frappe.db.get_value("Company", company, "default_receivable_account")
frappe.db.set_value("Company", company, "default_receivable_account", "")
self.make_invoices(
company="_Test Opening Invoice Company",
invoices=[{"party": party_1}, {"party": party_2}],
)
if not frappe.db.exists("Cost Center", "_Test Opening Invoice Company - _TOIC"):
cc = frappe.get_doc(
{
"doctype": "Cost Center",
"cost_center_name": "_Test Opening Invoice Company",
"is_group": 1,
"company": "_Test Opening Invoice Company",
}
)
cc.insert(ignore_mandatory=True)
cc2 = frappe.get_doc(
{
"doctype": "Cost Center",
"cost_center_name": "Main",
"is_group": 0,
"company": "_Test Opening Invoice Company",
"parent_cost_center": cc.name,
}
)
cc2.insert()
frappe.db.set_value("Company", company, "cost_center", "Main - _TOIC")
self.make_invoices(company="_Test Opening Invoice Company", party_1=party_1, party_2=party_2)
# Check if missing debit account error raised
error_log = frappe.db.exists(
@@ -99,107 +106,71 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
self.assertTrue(error_log)
# teardown
frappe.db.set_value(
"Company",
"_Test Opening Invoice Company",
"default_receivable_account",
old_default_receivable_account,
)
frappe.db.set_value("Company", company, "default_receivable_account", old_default_receivable_account)
def test_renaming_of_invoice_using_invoice_number_field(self):
company = "_Test Opening Invoice Company"
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
invoices = self.make_invoices(
company="_Test Opening Invoice Company",
invoices=[
{"party": party_1, "invoice_number": "TEST-NEW-INV-11"},
{"party": party_2},
],
self.make_invoices(
company=company, party_1=party_1, party_2=party_2, invoice_number="TEST-NEW-INV-11"
)
self.assertEqual(invoices[0], "TEST-NEW-INV-11")
sales_inv1 = frappe.get_all("Sales Invoice", filters={"customer": "Customer A"})[0].get("name")
sales_inv2 = frappe.get_all("Sales Invoice", filters={"customer": "Customer B"})[0].get("name")
self.assertEqual(sales_inv1, "TEST-NEW-INV-11")
# teardown
for inv in [sales_inv1, sales_inv2]:
doc = frappe.get_doc("Sales Invoice", inv)
doc.cancel()
def test_opening_invoice_with_accounting_dimension(self):
invoices = self.make_invoices(
invoice_type="Sales", company="_Test Opening Invoice Company", department="Sales - _TOIC"
)
for invoice in invoices:
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
def test_opening_entry_project_linking(self):
doc = self.make_invoices(
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True
)
project_1 = make_project(
{"project_name": "Test Opening Invoice projecty 01", "company": "_Test Opening Invoice Company"}
)
project_2 = make_project(
{"project_name": "Test Opening Invoice projecty 02", "company": "_Test Opening Invoice Company"}
)
doc.invoices[0].project = project_1.name
doc.invoices[1].project = project_2.name
invoices = doc.make_invoices()
sales_invoice_1 = frappe.get_doc("Sales Invoice", invoices[0])
sales_invoice_2 = frappe.get_doc("Sales Invoice", invoices[1])
self.assertEqual(sales_invoice_1.items[0].project, project_1.name)
self.assertEqual(sales_invoice_2.items[0].project, project_2.name)
expected_value = {
"keys": ["customer", "outstanding_amount", "status", "department"],
0: ["_Test Customer", 300, "Overdue", "Sales - _TOIC"],
1: ["_Test Customer 1", 250, "Overdue", "Sales - _TOIC"],
}
self.check_expected_values(invoices, expected_value, invoice_type="Sales")
def get_opening_invoice_creation_dict(**args):
party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier"
company = args.get("company", "_Test Company")
default_invoices = []
default_invoice_rows = [
{
"qty": 1.0,
"outstanding_amount": 200,
"party": f"_Test {party}",
"item_name": "Opening Item",
"due_date": add_days(today(), -10),
"posting_date": add_days(today(), -15),
"temporary_opening_account": get_temporary_opening_account(company),
},
{
"qty": 1.0,
"outstanding_amount": 200,
"party": f"_Test {party} 1",
"item_name": "Opening Item",
"due_date": add_days(today(), -10),
"posting_date": add_days(today(), -15),
"temporary_opening_account": get_temporary_opening_account(company),
},
]
for row in args.get("invoices") or default_invoice_rows:
default_invoices.append(
{
"qty": row.get("qty") or 1.0,
"outstanding_amount": row.get("outstanding_amount") or 200,
"party": row.get("party") or f"_Test {party}",
"item_name": row.get("item_name") or "Opening Item",
"due_date": row.get("due_date") or add_days(today(), -10),
"posting_date": row.get("posting_date") or add_days(today(), -15),
"temporary_opening_account": row.get("temporary_opening_account")
or get_temporary_opening_account(company),
"invoice_number": row.get("invoice_number"),
"project": row.get("project"),
"cost_center": row.get("cost_center"),
}
)
invoice_dict = frappe._dict(
{
"company": company,
"invoice_type": args.get("invoice_type", "Sales"),
"project": args.get("project"),
"cost_center": args.get("cost_center"),
"invoices": default_invoices,
"invoices": [
{
"qty": 1.0,
"outstanding_amount": 300,
"party": args.get("party_1") or f"_Test {party}",
"item_name": "Opening Item",
"due_date": "2016-09-10",
"posting_date": "2016-09-05",
"temporary_opening_account": get_temporary_opening_account(company),
"invoice_number": args.get("invoice_number"),
},
{
"qty": 2.0,
"outstanding_amount": 250,
"party": args.get("party_2") or f"_Test {party} 1",
"item_name": "Opening Item",
"due_date": "2016-09-10",
"posting_date": "2016-09-05",
"temporary_opening_account": get_temporary_opening_account(company),
"invoice_number": None,
},
],
}
)
invoice_dict.update(args)
invoice_dict.invoices = default_invoices
return invoice_dict

View File

@@ -21,8 +21,7 @@
"qty",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project"
"dimension_col_break"
],
"fields": [
{
@@ -126,17 +125,11 @@
"fieldtype": "Data",
"in_list_view": 1,
"label": "Party Name"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
}
],
"istable": 1,
"links": [],
"modified": "2026-04-29 17:08:15.617047",
"modified": "2026-03-20 02:11:42.023575",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Opening Invoice Creation Tool Item",

View File

@@ -26,7 +26,6 @@ class OpeningInvoiceCreationToolItem(Document):
party_name: DF.Data | None
party_type: DF.Link | None
posting_date: DF.Date | None
project: DF.Link | None
qty: DF.Data | None
supplier_invoice_date: DF.Date | None
temporary_opening_account: DF.Link | None

View File

@@ -754,21 +754,17 @@ frappe.ui.form.on("Payment Entry", {
frm.set_paid_amount_based_on_received_amount = true;
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.paid_amount && frm.doc.source_exchange_rate) {
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
if (frm.doc.base_received_amount && frm.doc.source_exchange_rate) {
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
// target exchange rate should always be same as source if both account currencies is same
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate);
frm.set_value("received_amount", frm.doc.paid_amount);
} else {
const target_rate =
flt(frm.doc.target_exchange_rate) ||
(company_currency == frm.doc.paid_to_account_currency ? 1 : 0);
if (target_rate) {
frm.set_value("received_amount", flt(frm.doc.base_received_amount) / target_rate);
}
frm.set_value(
"paid_amount",
flt(frm.doc.base_paid_amount) / flt(frm.doc.source_exchange_rate)
);
}
// set_unallocated_amount is called by below method,
@@ -784,23 +780,18 @@ frappe.ui.form.on("Payment Entry", {
target_exchange_rate: function (frm) {
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.received_amount && frm.doc.target_exchange_rate) {
frm.set_value(
"base_received_amount",
flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate)
);
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
if (frm.doc.base_paid_amount && frm.doc.target_exchange_rate) {
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
if (
!frm.doc.source_exchange_rate &&
frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency
) {
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
frm.set_value("paid_amount", frm.doc.received_amount);
} else {
const source_rate =
flt(frm.doc.source_exchange_rate) ||
(company_currency == frm.doc.paid_from_account_currency ? 1 : 0);
if (source_rate) {
frm.set_value("paid_amount", flt(frm.doc.base_paid_amount) / source_rate);
}
frm.set_value(
"received_amount",
flt(frm.doc.base_received_amount) / flt(frm.doc.target_exchange_rate)
);
}
// set_unallocated_amount is called by below method,

View File

@@ -1191,9 +1191,9 @@ class PaymentEntry(AccountsController):
continue
if tax.add_deduct_tax == "Add":
included_taxes += flt(tax.base_tax_amount)
included_taxes += tax.base_tax_amount
else:
included_taxes -= flt(tax.base_tax_amount)
included_taxes -= tax.base_tax_amount
return included_taxes
@@ -1805,7 +1805,8 @@ class PaymentEntry(AccountsController):
if not self.references or not matched_payment_requests:
return
matched_payment_requests = frappe.parse_json(matched_payment_requests)
if isinstance(matched_payment_requests, str):
matched_payment_requests = json.loads(matched_payment_requests)
# modify matched_payment_requests
# like (reference_doctype, reference_name, allocated_amount): payment_request
@@ -2010,7 +2011,8 @@ def validate_inclusive_tax(tax, doc):
@frappe.whitelist()
def get_outstanding_reference_documents(args: str | dict, validate: bool = False):
args = frappe.parse_json(args)
if isinstance(args, str):
args = json.loads(args)
if args.get("party_type") == "Member":
return

View File

@@ -818,11 +818,12 @@ class TestPaymentEntry(ERPNextTestSuite):
self.assertEqual(expected_gle[gle.account][3], gle.against_voucher)
def get_gle(self, voucher_no):
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",
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,
)
def test_payment_entry_write_off_difference(self):
@@ -917,19 +918,13 @@ class TestPaymentEntry(ERPNextTestSuite):
"Debtors - _TC": {"cost_center": cost_center},
}
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",
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,
)
self.assertTrue(gl_entries)
@@ -960,19 +955,13 @@ class TestPaymentEntry(ERPNextTestSuite):
"Creditors - _TC": {"cost_center": cost_center},
}
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",
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,
)
self.assertTrue(gl_entries)
@@ -1124,27 +1113,6 @@ 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()
@@ -1781,18 +1749,9 @@ 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)
# 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])
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])
def test_reverse_payment_reconciliation(self):
customer = create_customer(frappe.generate_hash(length=10), "INR")

View File

@@ -6,10 +6,8 @@ import frappe
from frappe import _, msgprint, qb
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.permissions import get_allowed_docs_for_doctype, get_user_permissions
from frappe.query_builder import Case, Criterion
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import IfNull
from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today
import erpnext
@@ -75,7 +73,6 @@ class PaymentReconciliation(Document):
self.accounting_dimension_filter_conditions = []
self.ple_posting_date_filter = []
self.dimensions = get_dimensions(with_cost_center_and_project=True)[0]
self.user_permissions = get_user_permissions(frappe.session.user)
def load_from_db(self):
# 'modified' attribute is required for `run_doc_method` to work properly.
@@ -156,22 +153,6 @@ class PaymentReconciliation(Document):
self.add_payment_entries(non_reconciled_payments)
def get_permitted_dimension_values(self, document_type, reference_doctype):
return get_allowed_docs_for_doctype(self.user_permissions.get(document_type, []), reference_doctype)
def validate_permitted_dimension_value(self, document_type, value, allowed):
if value and allowed and value not in allowed:
frappe.throw(
_("You do not have enough permission to access {0}: {1}").format(_(document_type), value),
frappe.PermissionError,
)
def get_user_permission_dimension_condition(self, field, allowed):
value_condition = field.isin(allowed)
if frappe.get_system_settings("apply_strict_user_permissions"):
return value_condition
return (IfNull(field, "") == "") | value_condition
def get_payment_entries(self):
party_account = [self.receivable_payable_account]
@@ -195,13 +176,8 @@ class PaymentReconciliation(Document):
dimensions = {}
for x in self.dimensions:
dimension = x.fieldname
allowed = self.get_permitted_dimension_values(x.document_type, "Payment Entry")
if value := self.get(dimension):
self.validate_permitted_dimension_value(x.document_type, value, allowed)
dimensions[dimension] = value
elif allowed:
dimensions[dimension] = allowed
if self.get(dimension):
dimensions.update({dimension: self.get(dimension)})
condition.update({"accounting_dimensions": dimensions})
payment_entries = get_advance_payment_entries_for_regional(
@@ -225,12 +201,8 @@ class PaymentReconciliation(Document):
# Dimension filters
for x in self.dimensions:
dimension = x.fieldname
allowed = self.get_permitted_dimension_values(x.document_type, "Journal Entry Account")
if value := self.get(dimension):
self.validate_permitted_dimension_value(x.document_type, value, allowed)
conditions.append(jea[dimension] == value)
elif allowed:
conditions.append(self.get_user_permission_dimension_condition(jea[dimension], allowed))
if self.get(dimension):
conditions.append(jea[dimension] == self.get(dimension))
if self.payment_name:
conditions.append(je.name.like(f"%%{self.payment_name}%%"))
@@ -776,15 +748,8 @@ class PaymentReconciliation(Document):
ple = qb.DocType("Payment Ledger Entry")
for x in self.dimensions:
dimension = x.fieldname
if frappe.db.has_column("Payment Ledger Entry", dimension):
allowed = self.get_permitted_dimension_values(x.document_type, "Payment Ledger Entry")
if value := self.get(dimension):
self.validate_permitted_dimension_value(x.document_type, value, allowed)
self.accounting_dimension_filter_conditions.append(ple[dimension] == value)
elif allowed:
self.accounting_dimension_filter_conditions.append(
self.get_user_permission_dimension_condition(ple[dimension], allowed)
)
if self.get(dimension) and frappe.db.has_column("Payment Ledger Entry", dimension):
self.accounting_dimension_filter_conditions.append(ple[dimension] == self.get(dimension))
def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False):
self.common_filter_conditions.clear()

View File

@@ -3,7 +3,7 @@
import frappe
from frappe.utils import add_days, add_years, cint, flt, getdate, nowdate, today
from frappe.utils import add_days, add_years, flt, getdate, nowdate, today
from frappe.utils.data import getdate as convert_to_date
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
@@ -1102,101 +1102,6 @@ class TestPaymentReconciliation(ERPNextTestSuite):
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
self.assertCountEqual(payment_vouchers, [je2.name, pe2.name])
def test_user_permission_on_accounting_dimension_filters_vouchers(self):
test_user = "test@example.com"
permitted_ccs = ["_Test Cost Center - _TC", "_Test Cost Center 2 - _TC"]
restricted_cc = "_Test Write Off Cost Center - _TC"
existing_apply_strict_user_permissions = cint(
frappe.db.get_single_value("System Settings", "apply_strict_user_permissions")
)
self.addCleanup(
frappe.db.set_single_value,
"System Settings",
"apply_strict_user_permissions",
existing_apply_strict_user_permissions,
)
transaction_date = nowdate()
rate = 100
def make_invoice(cost_center):
si = self.create_sales_invoice(
qty=1, rate=rate, posting_date=transaction_date, do_not_submit=True
)
si.cost_center = cost_center
for row in si.items:
row.cost_center = cost_center
return si.submit()
def make_payment(cost_center):
pe = self.create_payment_entry(posting_date=transaction_date, amount=rate)
pe.cost_center = cost_center
return pe.save().submit()
def make_journal(cost_center):
je = self.create_journal_entry(
self.bank, self.debit_to, 100, transaction_date, cost_center=cost_center
)
je.accounts[1].party_type = "Customer"
je.accounts[1].party = self.customer
return je.save().submit()
# Vouchers tagged with the two permitted cost centers
si_allowed = make_invoice(permitted_ccs[0])
pe_allowed = make_payment(permitted_ccs[1])
je_allowed = make_journal(permitted_ccs[0])
# Vouchers tagged with the restricted cost center
si_restricted = make_invoice(restricted_cc)
pe_restricted = make_payment(restricted_cc)
je_restricted = make_journal(restricted_cc)
# Payment entry with a BLANK cost center
pe_blank = make_payment(None)
for cc in permitted_ccs:
frappe.permissions.add_user_permission("Cost Center", cc, test_user)
# Without strict user permissions
frappe.db.set_single_value("System Settings", "apply_strict_user_permissions", 0)
with self.set_user(test_user):
pr = self.create_payment_reconciliation()
pr.get_unreconciled_entries()
invoice_numbers = [x.get("invoice_number") for x in pr.get("invoices")]
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
self.assertIn(si_allowed.name, invoice_numbers)
self.assertIn(pe_allowed.name, payment_vouchers)
self.assertIn(je_allowed.name, payment_vouchers)
self.assertIn(pe_blank.name, payment_vouchers)
self.assertNotIn(si_restricted.name, invoice_numbers)
self.assertNotIn(pe_restricted.name, payment_vouchers)
self.assertNotIn(je_restricted.name, payment_vouchers)
# With strict user permissions
frappe.db.set_single_value("System Settings", "apply_strict_user_permissions", 1)
with self.set_user(test_user):
pr = self.create_payment_reconciliation()
pr.get_unreconciled_entries()
invoice_numbers = [x.get("invoice_number") for x in pr.get("invoices")]
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
self.assertIn(si_allowed.name, invoice_numbers)
self.assertIn(pe_allowed.name, payment_vouchers)
self.assertIn(je_allowed.name, payment_vouchers)
self.assertNotIn(pe_blank.name, payment_vouchers)
self.assertNotIn(si_restricted.name, invoice_numbers)
self.assertNotIn(pe_restricted.name, payment_vouchers)
self.assertNotIn(je_restricted.name, payment_vouchers)
# with restricted dimension as a filter
with self.set_user(test_user):
pr = self.create_payment_reconciliation()
pr.cost_center = restricted_cc
self.assertRaises(frappe.PermissionError, pr.get_unreconciled_entries)
for cc in permitted_ccs:
frappe.permissions.remove_user_permission("Cost Center", cc, test_user)
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{

View File

@@ -740,7 +740,7 @@ def make_payment_request(**args):
# Schedule-based PRs are allowed only if no Payment Entry exists for this document.
# Any existing Payment Entry forces legacy (amount-based) flow.
selected_payment_schedules = frappe.parse_json(args.get("schedules")) if args.get("schedules") else []
selected_payment_schedules = json.loads(args.get("schedules")) if args.get("schedules") else []
# Backend guard:
# If any Payment Entry exists, schedule-based PRs are not allowed.
@@ -931,7 +931,7 @@ def apply_payment_references(pr, payment_reference):
def set_payment_references(payment_schedules):
payment_schedules = frappe.parse_json(payment_schedules) if payment_schedules else []
payment_schedules = json.loads(payment_schedules) if payment_schedules else []
payment_reference = []
for row in payment_schedules:

View File

@@ -347,11 +347,12 @@ class TestPaymentRequest(ERPNextTestSuite):
]
)
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",
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,
)
self.assertTrue(gl_entries)

View File

@@ -73,10 +73,7 @@ class PeriodClosingVoucher(AccountsController):
if not previous_fiscal_year:
return
# 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_start_date = previous_fiscal_year[0][1]
previous_fiscal_year_closed = frappe.db.exists(
"Period Closing Voucher",
{
@@ -290,43 +287,40 @@ 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):
gle = frappe.qb.DocType("GL Entry")
account = frappe.qb.DocType("Account")
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)
)
)
)
date_condition = ""
if only_opening_entries:
query = query.where(gle.is_opening == "Yes")
date_condition = "is_opening = 'Yes'"
else:
query = query.where(
gle.posting_date.between(self.period_start_date, self.period_end_date)
& (gle.is_opening == "No")
)
date_condition = f"posting_date BETWEEN '{self.period_start_date}' AND '{self.period_end_date}' and is_opening = 'No'"
return query.run(as_dict=1, as_iterator=as_iterator)
# 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,
)
def set_account_balance_dict(self, gle, acc_bal_dict):
key = self.get_key(gle)

View File

@@ -55,19 +55,15 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
("Sales - TPC", 400.0, 0.0),
)
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_gle = frappe.db.sql(
"""
select account, debit, credit from `tabGL Entry` where voucher_no=%s order by account
""",
(pcv.name),
)
pcv.reload()
self.assertEqual(pcv.gle_processing_status, "Completed")
self.assertEqual(tuple(pcv_gle), expected_gle)
self.assertEqual(pcv_gle, expected_gle)
def test_cost_center_wise_posting(self):
surplus_account = create_account()
@@ -110,16 +106,14 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
("Sales - TPC", 200.0, 0.0, cost_center2),
)
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,
)
]
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),
)
self.assertSequenceEqual(pcv_gle, expected_gle)
@@ -172,19 +166,16 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
("Sales - TPC", 400.0, 0.0, jv.finance_book),
)
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,
)
]
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),
)
# compare order-independently: postgres and MariaDB order NULL finance_book differently
self.assertSequenceEqual(sorted(pcv_gle, key=str), sorted(expected_gle, key=str))
self.assertSequenceEqual(pcv_gle, expected_gle)
def test_gl_entries_restrictions(self):
cost_center = create_cost_center("Test Cost Center 1")
@@ -367,10 +358,14 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
posting_date="2022-01-01",
)
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"}],
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,
)[0]
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)

View File

@@ -295,7 +295,7 @@ def get_payments(invoices):
.groupby(SalesInvoicePayment.mode_of_payment)
.select(
SalesInvoicePayment.mode_of_payment,
fn.Max(SalesInvoicePayment.account).as_("account"),
SalesInvoicePayment.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.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time).as_("timestamp"),
fn.Timestamp(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.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time) >= start)
& (fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time) <= end)
(fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time) >= start)
& (fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time) <= end)
)
)
)

View File

@@ -1425,23 +1425,19 @@
"width": "50%"
},
{
"depends_on": "eval:doc.sales_partner",
"fetch_from": "sales_partner.commission_rate",
"fetch_if_empty": 1,
"fieldname": "commission_rate",
"fieldtype": "Float",
"label": "Commission Rate (%)",
"no_copy": 1,
"oldfieldname": "commission_rate",
"oldfieldtype": "Currency",
"print_hide": 1
},
{
"depends_on": "eval:doc.sales_partner",
"fieldname": "total_commission",
"fieldtype": "Currency",
"label": "Total Commission",
"no_copy": 1,
"oldfieldname": "total_commission",
"oldfieldtype": "Currency",
"options": "Company:company:default_currency",
@@ -1532,11 +1528,9 @@
"print_hide": 1
},
{
"depends_on": "eval:doc.sales_partner",
"fieldname": "amount_eligible_for_commission",
"fieldtype": "Currency",
"label": "Amount Eligible for Commission",
"no_copy": 1,
"options": "Company:company:default_currency",
"read_only": 1
},
@@ -1642,7 +1636,7 @@
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2026-06-21 12:46:13.250145",
"modified": "2026-05-28 12:22:50.253090",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",

View File

@@ -6,7 +6,7 @@ import frappe
from frappe import _, bold
from frappe.model.document import Document
from frappe.model.mapper import map_child_doc, map_doc
from frappe.query_builder.functions import IfNull, Lower, Sum
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate
from frappe.utils.nestedset import get_descendants_of
@@ -505,20 +505,19 @@ class POSInvoice(SalesInvoice):
if d.get("serial_no"):
serial_nos = get_serial_nos(d.serial_no)
for sr in serial_nos:
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()
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%"),
)
if not serial_no_exists:
@@ -964,9 +963,15 @@ def get_bundle_availability(bundle_item_code, warehouse):
def get_bin_qty(item_code, warehouse):
actual_qty = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty")
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,
)
return actual_qty or 0
return bin_qty[0].actual_qty or 0 if bin_qty else 0
def get_pos_reserved_qty(item_code, warehouse):
@@ -1036,7 +1041,8 @@ def make_sales_return(source_name: str, target_doc: Document | str | None = None
def make_merge_log(invoices: str | list):
import json
invoices = frappe.parse_json(invoices)
if isinstance(invoices, str):
invoices = json.loads(invoices)
if len(invoices) == 0:
frappe.throw(_("At least one invoice has to be selected."))

View File

@@ -118,21 +118,14 @@ class POSProfile(Document):
def validate_default_profile(self):
for row in self.applicable_for_users:
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()
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),
)
if row.default and res:
@@ -242,18 +235,15 @@ def get_item_groups(pos_profile):
for data in pos_profile.get("item_groups"):
item_groups.extend(
[
d.name
"%s" % frappe.db.escape(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 = list(permitted_item_groups)
item_groups = ["%s" % frappe.db.escape(d) for d in 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))
@@ -275,11 +265,10 @@ 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.get_all(
group_type,
filters={"lft": [">=", lft], "rgt": ["<=", rgt]},
fields=["name", "lft", "rgt"],
order_by="lft",
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,
)
@@ -289,33 +278,40 @@ 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")
pf = frappe.qb.DocType("POS Profile")
pfu = frappe.qb.DocType("POS Profile User")
args = {
"user": user,
"start": start,
"company": company,
"page_len": page_len,
"txt": "%%%s%%" % txt,
}
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()
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,
)
if not pos_profile:
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()
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,
)
return pos_profile

View File

@@ -341,7 +341,8 @@ def apply_pricing_rule(args: str | dict, doc: str | dict | Document | None = Non
}
"""
args = frappe.parse_json(args)
if isinstance(args, str):
args = json.loads(args)
args = frappe._dict(args)
@@ -396,7 +397,8 @@ def get_pricing_rule_for_item(args, doc=None, for_validate=False):
get_product_discount_rule,
)
doc = frappe.parse_json(doc)
if isinstance(doc, str):
doc = json.loads(doc)
if doc:
doc = frappe.get_doc(doc)
@@ -626,7 +628,9 @@ def remove_pricing_rule_for_item(
get_pricing_rule_items,
)
item_details = frappe._dict(frappe.parse_json(item_details))
if isinstance(item_details, str):
item_details = json.loads(item_details)
item_details = frappe._dict(item_details)
for d in get_applied_pricing_rules(pricing_rules):
if not d or not frappe.db.exists("Pricing Rule", d):
@@ -667,7 +671,8 @@ def remove_pricing_rule_for_item(
@frappe.whitelist()
def remove_pricing_rules(item_list: str | list):
item_list = frappe.parse_json(item_list)
if isinstance(item_list, str):
item_list = json.loads(item_list)
out = []
for item in item_list:

View File

@@ -114,7 +114,7 @@ def _get_pricing_rules(apply_on, args, values):
if apply_on_field == "item_code":
if args.get("uom", None):
item_conditions += (
" and ({child_doc}.uom={item_uom} or COALESCE({child_doc}.uom, '')='')".format(
" and ({child_doc}.uom={item_uom} or IFNULL({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 COALESCE({child_doc}.uom, '')='')".format(
item_conditions += " and ({child_doc}.uom={item_uom} or IFNULL({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 coalesce(`tabPricing Rule`.for_price_list, '') in (%(price_list)s, '')"
conditions += " and ifnull(`tabPricing Rule`.for_price_list, '') in (%(price_list)s, '')"
values["price_list"] = args.get("price_list")
pricing_rules = (
@@ -195,8 +195,10 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
except TypeError:
frappe.throw(_("Invalid {0}").format(args.get(field)))
parent_groups = frappe.get_all(
parenttype, filters={"lft": ["<=", lft], "rgt": [">=", rgt]}, pluck="name"
parent_groups = frappe.db.sql_list(
"""select name from `tab{}`
where lft<={} and rgt>={}""".format(parenttype, "%s", "%s"),
(lft, rgt),
)
if parenttype in ["Customer Group", "Item Group", "Territory"]:
@@ -215,14 +217,14 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
if parent_groups:
if allow_blank:
parent_groups.append("")
condition = "coalesce({table}.{field}, '') in ({parent_groups})".format(
condition = "ifnull({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"coalesce({table}.{field}, '') = ''"
condition = f"ifnull({table}.{field}, '') = ''"
return condition
@@ -230,10 +232,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 coalesce(`tabPricing Rule`.{field}, '') in (%({field})s, '')"
conditions += f" and ifnull(`tabPricing Rule`.{field}, '') in (%({field})s, '')"
values[field] = args.get(field)
else:
conditions += f" and coalesce(`tabPricing Rule`.{field}, '') = ''"
conditions += f" and ifnull(`tabPricing Rule`.{field}, '') = ''"
for parenttype in ["Customer Group", "Territory", "Supplier Group"]:
group_condition = _get_tree_conditions(args, parenttype, "`tabPricing Rule`")
@@ -246,8 +248,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 coalesce(`tabPricing Rule`.valid_from, '2000-01-01')
and coalesce(`tabPricing Rule`.valid_upto, '2500-12-31')"""
conditions += """ and %(transaction_date)s between ifnull(`tabPricing Rule`.valid_from, '2000-01-01')
and ifnull(`tabPricing Rule`.valid_upto, '2500-12-31')"""
values["transaction_date"] = date
if args.get("doctype") in [
@@ -262,9 +264,9 @@ def get_other_conditions(conditions, values, args):
"POS Invoice",
"POS Invoice Item",
]:
conditions += """ and coalesce(`tabPricing Rule`.selling, 0) = 1"""
conditions += """ and ifnull(`tabPricing Rule`.selling, 0) = 1"""
else:
conditions += """ and coalesce(`tabPricing Rule`.buying, 0) = 1"""
conditions += """ and ifnull(`tabPricing Rule`.buying, 0) = 1"""
return conditions
@@ -636,7 +638,7 @@ def remove_free_item(doc):
def get_applied_pricing_rules(pricing_rules):
if pricing_rules:
if pricing_rules.startswith("["):
return frappe.parse_json(pricing_rules)
return json.loads(pricing_rules)
else:
return pricing_rules.split(",")
@@ -756,16 +758,21 @@ def validate_coupon_code(coupon_name):
def update_coupon_code_count(coupon_name, transaction_type):
coupon = frappe.get_doc("Coupon Code", coupon_name)
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
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
)
)
)
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)
elif transaction_type == "cancelled":
if coupon.used > 0:
coupon.used = coupon.used - 1
coupon.save(ignore_permissions=True)

View File

@@ -431,9 +431,7 @@ 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, 1).where(
ppa.name.isin(allocation_names)
).run() # smallint, not bool
qb.update(ppa).set(ppa.reconciled, True).where(ppa.name.isin(allocation_names)).run()
# Update reconciled count
reconciled_count = frappe.db.count(
@@ -479,7 +477,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", 1)
frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", True)
frappe.db.set_value("Process Payment Reconciliation", doc, "status", "Completed")
else:
if frappe.db.get_value("Process Payment Reconciliation", doc, "status") != "Paused":
@@ -503,7 +501,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", 1)
frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", True)
frappe.db.set_value("Process Payment Reconciliation", doc, "status", "Completed")
@@ -542,7 +540,8 @@ def check_multi_currency(pr_doc):
def is_any_doc_running(for_filter: str | dict | None = None) -> str | None:
running_doc = None
if for_filter:
for_filter = frappe.parse_json(for_filter)
if isinstance(for_filter, str):
for_filter = json.loads(for_filter)
running_doc = frappe.db.get_value(
"Process Payment Reconciliation",

View File

@@ -95,8 +95,6 @@ def start_pcv_processing(docname: str):
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
if normal_balances := (
qb.from_(ppcvd)
@@ -123,7 +121,7 @@ def start_pcv_processing(docname: str):
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
timeout=timeout,
timeout="3600",
is_async=True,
enqueue_after_commit=True,
docname=docname,
@@ -249,8 +247,6 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
@frappe.whitelist()
def schedule_next_date(docname: str):
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
if to_process := (
qb.from_(ppcvd)
@@ -276,7 +272,7 @@ def schedule_next_date(docname: str):
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
timeout=timeout,
timeout="3600",
is_async=True,
enqueue_after_commit=True,
docname=docname,
@@ -306,7 +302,7 @@ def schedule_next_date(docname: str):
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries",
queue="long",
timeout=timeout,
timeout="3600",
is_async=True,
job_name=job_name,
enqueue_after_commit=True,

View File

@@ -16,7 +16,6 @@
"categorize_by",
"cost_center",
"territory",
"show_opening_entries",
"ignore_exchange_rate_revaluation_journals",
"ignore_cr_dr_notes",
"column_break_14",
@@ -415,17 +414,10 @@
"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": "2026-06-01 15:37:07.660442",
"modified": "2025-10-07 12:19:20.719898",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Statement Of Accounts",

View File

@@ -6,6 +6,7 @@ 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
@@ -19,7 +20,6 @@ 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,7 +75,6 @@ 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
@@ -271,7 +270,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": doc.show_opening_entries,
"show_opening_entries": 0,
"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,
@@ -366,19 +365,15 @@ 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"])
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)
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,
)
sales_person_records = frappe._dict()
for d in records:
@@ -473,30 +468,31 @@ def get_customer_emails(customer_name: str, primary_mandatory: str | int, billin
frappe.has_permission("Customer", "read", customer_name, throw=True)
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)
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,
)
for condition in get_match_conditions_qb("Contact", table=contact):
query = query.where(condition)
billing_email = query.run()
if len(billing_email) == 0 or (billing_email[0][0] is None):
if billing_and_primary:
frappe.throw(_("No billing email found for customer: {0}").format(customer_name))

View File

@@ -50,7 +50,8 @@ def make_purchase_receipt(
):
if args is None:
args = {}
args = frappe.parse_json(args)
if isinstance(args, str):
args = json.loads(args)
def post_parent_process(source_parent, target_parent):
remove_items_with_zero_qty(target_parent)

View File

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

View File

@@ -55,13 +55,10 @@ 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.exists(
"GL Entry",
{
"voucher_type": "Purchase Receipt",
"voucher_no": item.purchase_receipt,
"account": stock_not_billed_account,
},
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),
)
if negative_expense_booked_in_pr:

View File

@@ -395,14 +395,10 @@ 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.get_all(
"GL Entry",
filters={
"voucher_type": "Purchase Receipt",
"voucher_no": item.purchase_receipt,
"account": ["in", valuation_tax_accounts],
},
pluck="name",
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),
)
(
@@ -590,10 +586,6 @@ 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):
@@ -628,7 +620,8 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
tax.idx, _(tax.category)
)
)
valuation_tax[tax.name] = capitalized_valuation_tax.get(tax.name, 0.0)
valuation_tax.setdefault(tax.name, 0)
valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(base_amount)
if doc.is_opening == "No" and doc.negative_expense_to_be_booked and valuation_tax:
total_valuation_amount = sum(valuation_tax.values())

View File

@@ -3,7 +3,6 @@
import frappe
from frappe.query_builder.functions import Sum
from frappe.utils import add_days, cint, flt, getdate, nowdate, today
import erpnext
@@ -124,10 +123,11 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
"_Test Account Discount - _TC": [0, 168.03],
"Round Off - _TC": [0, 0.3],
}
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=["account", "debit", "credit"],
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,
)
for d in gl_entries:
self.assertEqual([d.debit, d.credit], expected_gl_entries.get(d.account))
@@ -317,11 +317,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
self.check_gle_for_pi(pi.name)
def check_gle_for_pi(self, pi):
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",
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,
)
self.assertTrue(gl_entries)
@@ -341,83 +342,6 @@ 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}
)
@@ -537,11 +461,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
self.assertTrue(pi.status, "Unpaid")
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=["account", "debit", "credit"],
order_by="account asc",
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,
)
self.assertTrue(gl_entries)
@@ -550,11 +475,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
["Creditors - TCP1", 0, 250],
]
# 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),
)
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)
def test_purchase_invoice_calculation(self):
pi = frappe.copy_doc(self.globalTestRecords["Purchase Invoice"][0])
@@ -622,24 +546,21 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pi.load_from_db()
self.assertTrue(
frappe.get_all(
"Journal Entry Account",
filters={
"reference_type": "Purchase Invoice",
"reference_name": pi.name,
"debit_in_account_currency": 300,
},
pluck="name",
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,
)
)
pi.cancel()
self.assertFalse(
frappe.get_all(
"Journal Entry Account",
filters={"reference_type": "Purchase Invoice", "reference_name": pi.name},
pluck="name",
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_type='Purchase Invoice' and reference_name=%s""",
pi.name,
)
)
@@ -683,14 +604,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pi.load_from_db()
self.assertTrue(
frappe.get_all(
"Journal Entry Account",
filters={
"reference_type": "Purchase Invoice",
"reference_name": pi.name,
"debit_in_account_currency": 300,
},
pluck="name",
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,
)
)
@@ -699,10 +616,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pi.cancel()
self.assertFalse(
frappe.get_all(
"Journal Entry Account",
filters={"reference_type": "Purchase Invoice", "reference_name": pi.name},
pluck="name",
frappe.db.sql(
"select name from `tabJournal Entry Account` where reference_type='Purchase Invoice' and "
"reference_name=%s",
pi.name,
)
)
@@ -712,12 +629,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
else:
project = frappe.get_doc("Project", {"project_name": "_Test Project for Purchase"})
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 = frappe.db.sql(
f"""select sum(base_net_amount)
from `tabPurchase Invoice Item`
where project = '{project.name}'
and docstatus=1"""
)
existing_purchase_cost = existing_purchase_cost and existing_purchase_cost[0].base_net_amount or 0
existing_purchase_cost = existing_purchase_cost and existing_purchase_cost[0][0] or 0
pi = make_purchase_invoice(currency="USD", conversion_rate=60, project=project.name)
self.assertEqual(
@@ -761,11 +679,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
)
# check gl entries for return
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",
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,
)
self.assertTrue(gl_entries)
@@ -854,18 +773,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
conversion_rate=50,
)
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",
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,
)
self.assertTrue(gl_entries)
@@ -907,10 +821,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
# cancel
pi.cancel()
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": pi.name},
pluck="name",
gle = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
pi.name,
)
self.assertFalse(gle)
@@ -928,18 +842,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
expense_account="_Test Account Cost for Goods Sold - TCP1",
)
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",
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,
)
self.assertTrue(gl_entries)
@@ -968,16 +877,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
expense_account="_Test Account Cost for Goods Sold - TCP1",
)
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",
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,
)
stock_in_hand_account = get_inventory_account(pi.company, pi.get("items")[0].warehouse)
@@ -1239,19 +1145,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
"_Test Account Cost for Goods Sold - _TC": {"cost_center": cost_center},
}
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",
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,
)
self.assertTrue(gl_entries)
@@ -1268,19 +1168,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
"_Test Account Cost for Goods Sold - _TC": {"cost_center": cost_center},
}
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",
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,
)
self.assertTrue(gl_entries)
@@ -1315,20 +1209,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
"_Test Account Cost for Goods Sold - _TC": {"project": item_project.name},
}
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",
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,
)
self.assertTrue(gl_entries)
@@ -1382,15 +1269,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
[deferred_account, 23.07, 0.0, "2019-03-15"],
]
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",
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,
)
for i, gle in enumerate(gl_entries):
@@ -1465,14 +1350,14 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
["_Test Payable USD - _TC", -37500.0],
]
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)
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,
)
for i, gle in enumerate(gl_entries):
@@ -1536,14 +1421,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
["_Test Payable USD - _TC", -36500.0],
]
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)
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,
)
for i, gle in enumerate(gl_entries):
@@ -1552,21 +1436,18 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
expected_gle = [["_Test Payable USD - _TC", 70000.0], ["Cash - _TC", -70000.0]]
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)
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,
)
# 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),
)
for i, gle in enumerate(gl_entries):
self.assertEqual(expected_gle[i][0], gle.account)
self.assertEqual(expected_gle[i][1], gle.balance)
total_debit_amount = frappe.db.get_all(
"Journal Entry Account",
@@ -1665,18 +1546,19 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
[tds_account, 0, 3000],
]
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",
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,
)
# 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),
)
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)
# Create Purchase Invoice against Purchase Order
purchase_invoice = get_mapped_purchase_invoice(po.name)
@@ -1690,21 +1572,19 @@ 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]]
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)
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,
)
# 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),
)
for i, gle in enumerate(gl_entries):
self.assertEqual(expected_gle[i][0], gle.account)
self.assertEqual(expected_gle[i][1], gle.amount)
payment_entry.load_from_db()
tax_allocated = sum(
@@ -2596,11 +2476,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pi.insert()
pi.submit()
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_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_expected_values = [
@@ -2613,11 +2494,12 @@ 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.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=["account", "debit", "credit"],
order_by="account asc",
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_expected_values = [
["Asset Received But Not Billed - _TC", 5000, 0],
@@ -3046,24 +2928,6 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
# Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail
self.assertRaises(frappe.ValidationError, pi.submit)
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
def test_non_stock_item_over_billing_against_po_is_blocked(self):
service_item = create_item(
"_Test Service Item Non Stock PI",
is_stock_item=0,
is_purchase_item=1,
).name
po = create_purchase_order(item_code=service_item, qty=5, rate=100, do_not_save=False)
po.submit()
pi = make_pi_from_po(po.name)
pi.items[0].qty = 10 # overbill by 100 %
pi.save()
with self.assertRaises(frappe.ValidationError):
pi.submit()
def test_discount_percentage_not_set_when_amount_is_manually_set(self):
pi = make_purchase_invoice(do_not_save=True)
discount_amount = 7
@@ -3183,25 +3047,17 @@ def check_gl_entries(
gl_entries = query.run(as_dict=True)
# 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 []
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)
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
if additional_columns:
j = 4
for col in additional_columns:
doc.assertEqual(expected_gle[i][j], gle[col])
j += 1
def create_tax_witholding_category(category_name, company, account):

View File

@@ -11,16 +11,13 @@
"add_deduct_tax",
"charge_type",
"row_id",
"included_in_print_rate",
"included_in_paid_amount",
"col_break1",
"account_head",
"description",
"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",
"set_by_item_tax_template",
"section_break_10",
"rate",
"accounting_dimensions_section",
@@ -81,15 +78,6 @@
"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",
@@ -284,21 +272,13 @@
"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": "2026-06-21 17:13:05.586544",
"modified": "2025-11-24 18:22:56.886010",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Taxes and Charges",

View File

@@ -17,7 +17,6 @@ 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

View File

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

View File

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

View File

@@ -1747,7 +1747,6 @@
"width": "50%"
},
{
"depends_on": "eval:doc.sales_partner",
"fetch_from": "sales_partner.commission_rate",
"fetch_if_empty": 1,
"fieldname": "commission_rate",
@@ -1755,19 +1754,16 @@
"hide_days": 1,
"hide_seconds": 1,
"label": "Commission Rate (%)",
"no_copy": 1,
"oldfieldname": "commission_rate",
"oldfieldtype": "Currency",
"print_hide": 1
},
{
"depends_on": "eval:doc.sales_partner",
"fieldname": "total_commission",
"fieldtype": "Currency",
"hide_days": 1,
"hide_seconds": 1,
"label": "Total Commission",
"no_copy": 1,
"oldfieldname": "total_commission",
"oldfieldtype": "Currency",
"options": "Company:company:default_currency",
@@ -1974,11 +1970,9 @@
"read_only": 1
},
{
"depends_on": "eval:doc.sales_partner",
"fieldname": "amount_eligible_for_commission",
"fieldtype": "Currency",
"label": "Amount Eligible for Commission",
"no_copy": 1,
"options": "Company:company:default_currency",
"read_only": 1
},
@@ -2360,7 +2354,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2026-06-21 12:46:13.250145",
"modified": "2026-05-28 12:15:12.486443",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -25,35 +25,30 @@ class FixedAssetService:
if doc.doctype != "Sales Invoice":
return
for item in doc.get("items"):
if item.is_fixed_asset:
self._validate_fixed_asset_item(item)
for d in doc.get("items"):
if not d.is_fixed_asset:
continue
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 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"),
)
)
if asset_status == "Sold":
frappe.throw(_("Row #{0}: Asset {1} is already sold").format(item.idx, item.asset))
def set_income_account_for_fixed_assets(self) -> None:
for item in self.doc.items:

View File

@@ -93,7 +93,54 @@ class SalesInvoiceGLComposer(BaseGLComposer):
if enable_discount_accounting:
for item in doc.get("items"):
if item.get("discount_amount") and item.get("discount_account"):
self._append_item_discount_gl_entries(item, gl_entries)
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,
)
)
if (
(enable_discount_accounting or doc.get("is_cash_or_non_trade_discount"))
@@ -112,143 +159,81 @@ 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"):
booking = self._get_sdbnb_booking_for_item(item)
if booking:
self._append_sdbnb_gl_entries(item, booking, gl_entries)
if not item.delivery_note and not item.dn_detail:
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
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
continue
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
return None
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
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(
{
"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,
dn_expense_account = frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "expense_account"
)
)
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,
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
delivery_note = item.delivery_note or frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "parent"
)
if not delivery_note:
continue
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):
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,
)
)
)
def make_customer_gl_entry(self, gl_entries):
doc = self.doc
@@ -265,6 +250,10 @@ 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(
@@ -275,11 +264,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"due_date": doc.due_date,
"against": doc.against_income_account,
"debit": base_grand_total,
"debit_in_account_currency": self._get_amount_in_account_currency(
doc.party_account_currency, base_grand_total, grand_total
),
"debit_in_account_currency": base_grand_total
if doc.party_account_currency == doc.company_currency
else grand_total,
"debit_in_transaction_currency": grand_total,
"against_voucher": self._resolve_against_voucher(),
"against_voucher": against_voucher,
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
"project": doc.project,
@@ -307,10 +296,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": 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_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_transaction_currency": flt(
amount, tax.precision("tax_amount_after_discount_amount")
@@ -352,57 +341,53 @@ class SalesInvoiceGLComposer(BaseGLComposer):
)
for item in doc.get("items"):
if not (
if (
flt(item.base_net_amount, item.precision("base_net_amount"))
or item.is_fixed_asset
or enable_discount_accounting
):
continue
# Do not book income for transfer within same company
if doc.is_internal_transfer():
continue
# Do not book income for transfer within same company
if doc.is_internal_transfer():
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
)
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)
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,
)
)
# 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)
@@ -476,6 +461,10 @@ 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(
@@ -486,11 +475,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"party": doc.customer,
"against": payment_mode.account,
"credit": payment_mode.base_amount,
"credit_in_account_currency": self._get_amount_in_account_currency(
doc.party_account_currency, payment_mode.base_amount, payment_mode.amount
),
"credit_in_account_currency": payment_mode.base_amount
if doc.party_account_currency == doc.company_currency
else payment_mode.amount,
"credit_in_transaction_currency": payment_mode.amount,
"against_voucher": self._resolve_against_voucher(),
"against_voucher": against_voucher,
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
},
@@ -506,11 +495,9 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"account": payment_mode.account,
"against": doc.customer,
"debit": payment_mode.base_amount,
"debit_in_account_currency": self._get_amount_in_account_currency(
payment_mode_account_currency,
payment_mode.base_amount,
payment_mode.amount,
),
"debit_in_account_currency": payment_mode.base_amount
if payment_mode_account_currency == doc.company_currency
else payment_mode.amount,
"debit_in_transaction_currency": payment_mode.amount,
"cost_center": doc.cost_center,
},
@@ -538,9 +525,9 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"party": doc.customer,
"against": doc.account_for_change_amount,
"debit": flt(doc.base_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_account_currency": flt(doc.base_change_amount)
if doc.party_account_currency == doc.company_currency
else 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
@@ -583,10 +570,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": 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_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_transaction_currency": flt(
doc.write_off_amount, doc.precision("write_off_amount")
@@ -606,10 +593,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": 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_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_transaction_currency": flt(
doc.write_off_amount, doc.precision("write_off_amount")
@@ -672,14 +659,3 @@ class SalesInvoiceGLComposer(BaseGLComposer):
item=doc,
)
)
def _get_amount_in_account_currency(self, account_currency, base_amount, transaction_amount):
"""Base amount when the account is in company currency, else the transaction amount."""
return base_amount if account_currency == self.doc.company_currency else transaction_amount
def _resolve_against_voucher(self) -> str:
"""Settle against the original invoice for returns not kept on their own outstanding."""
doc = self.doc
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
return doc.return_against
return doc.name

View File

@@ -13,54 +13,36 @@ def validate_inter_company_party(
if not party:
return
config = _get_inter_company_party_config(doctype)
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"
if inter_company_reference:
_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)
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."))
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)
)
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 update_linked_doc(doctype: str, name: str, inter_company_reference: str | None) -> None:

View File

@@ -4,7 +4,7 @@
"""POS helpers for Sales Invoice."""
import frappe
from frappe import _
from frappe import _, msgprint
from frappe.utils import cint, flt, get_link_to_form
@@ -13,140 +13,106 @@ class PartialPaymentValidationError(frappe.ValidationError):
class POSService:
def __init__(self, doc) -> None:
def __init__(self, doc):
self.doc = doc
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."""
def set_pos_fields(self, for_validate: bool = False) -> frappe.Document | 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"
)
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
from erpnext.stock.get_item_details import (
ItemDetailsCtx,
get_pos_profile,
get_pos_profile_item_details_,
)
from erpnext.stock.get_item_details import get_pos_profile
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")
pos_profile = get_pos_profile(doc.company) or {}
if not pos_profile:
return False
pos = {}
if doc.pos_profile:
pos = frappe.get_doc("POS Profile", doc.pos_profile)
doc.pos_profile = pos_profile.get("name")
return True
if pos:
if not for_validate:
update_multi_mode_option(doc, pos)
doc.tax_category = pos.get("tax_category")
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 and not doc.customer:
doc.customer = pos.customer
if pos.get("account_for_change_amount"):
doc.account_for_change_amount = pos.get("account_for_change_amount")
if not for_validate:
doc.ignore_pricing_rule = pos.ignore_pricing_rule
self._copy_pos_profile_fields(pos, for_validate)
if pos.get("account_for_change_amount"):
doc.account_for_change_amount = pos.get("account_for_change_amount")
if pos.get("company_address"):
doc.company_address = pos.get("company_address")
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))
self._set_selling_price_list(pos)
if pos.get("company_address"):
doc.company_address = pos.get("company_address")
if not for_validate:
self._set_update_stock_from_profile(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")
self._apply_pos_item_defaults(pos, for_validate)
self._set_terms_and_taxes(pos)
if selling_price_list:
doc.set("selling_price_list", selling_price_list)
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
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 _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))
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 _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.tc_name and not doc.terms:
doc.terms = frappe.db.get_value("Terms and Conditions", doc.tc_name, "terms")
if selling_price_list:
doc.set("selling_price_list", selling_price_list)
if doc.taxes_and_charges and not len(doc.get("taxes")):
from erpnext.accounts.services.taxes import TaxService
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"))
TaxService(doc).set_taxes()
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()
return pos
def update_paid_amount(self) -> None:
doc = self.doc
@@ -178,7 +144,6 @@ 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
@@ -195,7 +160,6 @@ 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
@@ -216,7 +180,6 @@ 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)
@@ -233,7 +196,6 @@ 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",
@@ -319,6 +281,38 @@ 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(
@@ -375,43 +369,61 @@ def update_multi_mode_option(doc, pos_profile) -> None:
def get_all_mode_of_payments(doc) -> list:
"""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)
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)
def get_mode_of_payments_info(mode_of_payments: list, company: str) -> dict:
"""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)
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)
)
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:
"""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)
)
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
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)
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)
)
return query, mopa, mop
return query.run(as_dict=1)

View File

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

View File

@@ -99,24 +99,23 @@ 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:
for data in timesheet.time_logs:
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
)
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
)
):
data.sales_invoice = sales_invoice

View File

@@ -20,12 +20,6 @@ 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
@@ -745,11 +739,12 @@ class TestSalesInvoice(ERPNextTestSuite):
si.insert()
si.submit()
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc",
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,
)
self.assertTrue(gl_entries)
@@ -780,10 +775,10 @@ class TestSalesInvoice(ERPNextTestSuite):
# cancel
si.cancel()
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["*"],
gle = frappe.db.sql(
"""select * from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
si.name,
)
self.assertTrue(gle)
@@ -1200,11 +1195,12 @@ class TestSalesInvoice(ERPNextTestSuite):
si.insert()
si.submit()
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc",
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,
)
self.assertTrue(gl_entries)
@@ -1227,10 +1223,10 @@ class TestSalesInvoice(ERPNextTestSuite):
# cancel
si.cancel()
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["*"],
gle = frappe.db.sql(
"""select * from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
si.name,
)
self.assertTrue(gle)
@@ -1350,101 +1346,6 @@ 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",
@@ -1575,84 +1476,16 @@ 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.get_all(
"Stock Ledger Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["*"],
sle = frappe.db.sql(
"""select * from `tabStock Ledger Entry`
where voucher_type = 'Sales Invoice' and voucher_no = %s""",
si.name,
as_dict=1,
)[0]
self.assertTrue(sle)
self.assertEqual(
@@ -1660,11 +1493,12 @@ class TestSalesInvoice(ERPNextTestSuite):
)
# check gl entries
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",
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,
)
self.assertTrue(gl_entries)
@@ -1691,15 +1525,15 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(expected_gl_entries[i][2], gle.credit)
si.cancel()
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["*"],
gle = frappe.db.sql(
"""select * from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
si.name,
)
self.assertTrue(gle)
frappe.db.delete("POS Profile")
frappe.db.sql("delete from `tabPOS Profile`")
def test_bin_details_of_packed_item(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
@@ -1766,11 +1600,12 @@ class TestSalesInvoice(ERPNextTestSuite):
si.insert()
si.submit()
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc",
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,
)
self.assertTrue(gl_entries)
@@ -1785,11 +1620,12 @@ 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.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc",
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,
)
self.assertTrue(gl_entries)
@@ -1843,18 +1679,18 @@ class TestSalesInvoice(ERPNextTestSuite):
si.load_from_db()
self.assertTrue(
frappe.get_all(
"Journal Entry Account",
filters={"reference_name": si.name},
pluck="name",
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_name=%s""",
si.name,
)
)
self.assertTrue(
frappe.get_all(
"Journal Entry Account",
filters={"reference_name": si.name, "credit_in_account_currency": 300},
pluck="name",
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_name=%s and credit_in_account_currency=300""",
si.name,
)
)
@@ -2166,18 +2002,13 @@ class TestSalesInvoice(ERPNextTestSuite):
conversion_rate=50,
)
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",
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,
)
self.assertTrue(gl_entries)
@@ -2212,10 +2043,10 @@ class TestSalesInvoice(ERPNextTestSuite):
# cancel
si.cancel()
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
pluck="name",
gle = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
si.name,
)
self.assertTrue(gle)
@@ -2242,16 +2073,14 @@ class TestSalesInvoice(ERPNextTestSuite):
)
si.submit()
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",
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,
)
expected_gle = {
@@ -2596,11 +2425,12 @@ class TestSalesInvoice(ERPNextTestSuite):
]
)
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc",
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,
)
for gle in gl_entries:
@@ -2652,12 +2482,13 @@ class TestSalesInvoice(ERPNextTestSuite):
"Sales - _TC": [0.0, 1272.20],
}
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",
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,
)
for gle in gl_entries:
@@ -2718,12 +2549,13 @@ class TestSalesInvoice(ERPNextTestSuite):
]
)
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",
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,
)
debit_credit_diff = 0
@@ -2733,9 +2565,7 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(expected_values[gle.account][2], gle.credit)
debit_credit_diff += gle.debit - gle.credit
# 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)
self.assertEqual(debit_credit_diff, 0)
round_off_gle = frappe.db.get_value(
"GL Entry",
@@ -2819,19 +2649,13 @@ class TestSalesInvoice(ERPNextTestSuite):
"Sales - _TC": {"cost_center": cost_center},
}
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",
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,
)
self.assertTrue(gl_entries)
@@ -2868,20 +2692,13 @@ class TestSalesInvoice(ERPNextTestSuite):
"Sales - _TC": {"project": item_project.name},
}
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",
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,
)
self.assertTrue(gl_entries)
@@ -2898,19 +2715,13 @@ class TestSalesInvoice(ERPNextTestSuite):
"Sales - _TC": {"cost_center": cost_center},
}
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",
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,
)
self.assertTrue(gl_entries)
@@ -3686,49 +3497,6 @@ 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,
@@ -3942,27 +3710,6 @@ 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
@@ -4126,51 +3873,6 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertIn("cannot overbill", str(err.exception).lower())
dn.cancel()
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
def test_non_stock_item_over_billing_against_so_is_blocked(self):
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice as make_si_from_so
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
service_item = create_item(
"_Test Service Item Non Stock SI",
is_stock_item=0,
).name
so = make_sales_order(item_code=service_item, qty=5, rate=100)
so.submit()
si = make_si_from_so(so.name)
si.items[0].qty = 10 # overbill by 100 %
si.save()
with self.assertRaises(frappe.ValidationError):
si.submit()
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
def test_non_stock_item_over_billing_against_so_from_quotation_is_blocked(self):
from erpnext.selling.doctype.quotation.mapper import make_sales_order as make_so_from_quotation
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice as make_si_from_so
service_item = create_item(
"_Test Service Item Non Stock SI Quot",
is_stock_item=0,
).name
quotation = make_quotation(item_code=service_item, qty=5, rate=100)
so = make_so_from_quotation(quotation.name)
so.delivery_date = frappe.utils.add_days(frappe.utils.today(), 7)
so.insert()
so.submit()
si = make_si_from_so(so.name)
si.items[0].qty = 10 # overbill by 100 %
si.save()
with self.assertRaises(frappe.ValidationError):
si.submit()
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{
@@ -4233,15 +3935,13 @@ class TestSalesInvoice(ERPNextTestSuite):
[deferred_account, 2022.47, 0.0, "2019-03-15"],
]
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",
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,
)
for i, gle in enumerate(gl_entries):
@@ -4883,8 +4583,7 @@ class TestSalesInvoice(ERPNextTestSuite):
{"account": "Temporary Opening - _TC", "debit": 0.0, "credit": 138.09, "is_opening": "Yes"},
]
self.assertEqual(len(actual), 4)
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
self.assertCountEqual(actual, expected)
self.assertEqual(expected, actual)
@ERPNextTestSuite.change_settings("Accounts Settings", {"enable_common_party_accounting": True})
def test_common_party_with_foreign_currency_jv(self):
@@ -5189,7 +4888,7 @@ class TestSalesInvoice(ERPNextTestSuite):
def test_pos_sales_invoice_creation_during_pos_invoice_mode(self):
# Deleting all opening entry
frappe.db.delete("POS Opening Entry")
frappe.db.sql("delete from `tabPOS Opening Entry`")
with self.change_settings("POS Settings", {"invoice_type": "POS Invoice"}):
pos_profile = make_pos_profile()
@@ -5559,11 +5258,6 @@ 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)
@@ -5711,21 +5405,17 @@ def create_sales_invoice_against_cost_center(**args):
def get_outstanding_amount(against_voucher_type, against_voucher, account, party, party_type):
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(
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
)
bal = flt(balance[0].debit) - flt(balance[0].credit)
if against_voucher_type == "Purchase Invoice":
bal = bal * -1

View File

@@ -56,14 +56,11 @@ def valdiate_taxes_and_charges_template(doc):
# doc.is_default = 1
if doc.is_default == 1:
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()
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),
)
validate_disabled(doc)

View File

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

View File

@@ -201,9 +201,9 @@ def get_linked_advances(company, docname):
@frappe.whitelist()
def create_unreconcile_doc_for_selection(selections: str | list | None = None):
def create_unreconcile_doc_for_selection(selections: str | None = None):
if selections:
selections = frappe.parse_json(selections)
selections = json.loads(selections)
# assuming each row is a unique voucher
for row in selections:
unrecon = frappe.new_doc("Unreconcile Payment")

View File

@@ -672,7 +672,7 @@ def make_reverse_gl_entries(
)
if not immutable_ledger_enabled:
query = query.set(gle.is_cancelled, 1) # smallint column; postgres rejects boolean true
query = query.set(gle.is_cancelled, True)
query.run()
else:
@@ -683,14 +683,12 @@ def make_reverse_gl_entries(
if not all(gle_names):
set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"])
else:
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()
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)),
)
for entry in gl_entries:
new_gle = copy.deepcopy(entry)
@@ -727,11 +725,9 @@ def set_as_cancel(voucher_type, voucher_no):
"""
Set is_cancelled=1 in all original gl entries for the voucher
"""
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()
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),
)

View File

@@ -900,13 +900,16 @@ 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.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()
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),
)
)
for d in companies:
@@ -935,15 +938,6 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
if party_type == "Supplier":
info["total_unpaid"] = -1 * info["total_unpaid"]
if info["total_unpaid"] < 0:
info["balance_label"] = (
"Total Advance Paid" if party_type == "Supplier" else "Total Advance Received"
)
info["balance_amount"] = abs(info["total_unpaid"])
else:
info["balance_label"] = "Total Unpaid"
info["balance_amount"] = info["total_unpaid"]
company_wise_info.append(info)
return company_wise_info

View File

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

View File

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

View File

@@ -82,7 +82,7 @@ def get_asset_categories_for_grouped_by_category(filters):
asset_capitalization = frappe.qb.DocType("Asset Capitalization")
disposal_in_period = (
(asset.disposal_date.isnotnull())
(IfNull(asset.disposal_date, 0) != 0)
& (asset.disposal_date >= filters.from_date)
& (asset.disposal_date <= filters.to_date)
)
@@ -92,7 +92,7 @@ def get_asset_categories_for_grouped_by_category(filters):
frappe.qb.terms.Case()
.when(
(asset.purchase_date < filters.from_date)
& ((asset.disposal_date.isnull()) | (asset.disposal_date >= filters.from_date)),
& ((IfNull(asset.disposal_date, 0) == 0) | (asset.disposal_date >= filters.from_date)),
asset.net_purchase_amount,
)
.else_(0)
@@ -209,7 +209,9 @@ def get_assets_for_grouped_by_category(filters):
frappe.qb.terms.Case()
.when(
(gl_entry.posting_date < filters.from_date)
& ((asset.disposal_date.isnull()) | (asset.disposal_date >= filters.from_date)),
& (
(IfNull(asset.disposal_date, 0) == 0) | (asset.disposal_date >= filters.from_date)
),
gl_entry.debit,
)
.else_(0)
@@ -220,7 +222,7 @@ def get_assets_for_grouped_by_category(filters):
Sum(
frappe.qb.terms.Case()
.when(
(gl_entry.posting_date <= filters.to_date) & (asset.disposal_date.isnull()),
(gl_entry.posting_date <= filters.to_date) & (IfNull(asset.disposal_date, 0) == 0),
gl_entry.credit,
)
.else_(0)
@@ -231,7 +233,7 @@ def get_assets_for_grouped_by_category(filters):
Sum(
frappe.qb.terms.Case()
.when(
(asset.disposal_date.isnotnull())
(IfNull(asset.disposal_date, 0) != 0)
& (asset.disposal_date >= filters.from_date)
& (asset.disposal_date <= filters.to_date)
& (gl_entry.posting_date <= asset.disposal_date),
@@ -247,7 +249,10 @@ def get_assets_for_grouped_by_category(filters):
.when(
(gl_entry.posting_date >= filters.from_date)
& (gl_entry.posting_date <= filters.to_date)
& ((asset.disposal_date.isnull()) | (gl_entry.posting_date <= asset.disposal_date)),
& (
(IfNull(asset.disposal_date, 0) == 0)
| (gl_entry.posting_date <= asset.disposal_date)
),
gl_entry.debit,
)
.else_(0)
@@ -277,7 +282,7 @@ def get_assets_for_grouped_by_category(filters):
Sum(
frappe.qb.terms.Case()
.when(
(asset.disposal_date.isnotnull()) & (asset.disposal_date < filters.from_date),
(IfNull(asset.disposal_date, 0) != 0) & (asset.disposal_date < filters.from_date),
0,
)
.else_(asset.opening_accumulated_depreciation)
@@ -471,7 +476,7 @@ def get_asset_details_for_grouped_by_category(filters):
asset_capitalization = frappe.qb.DocType("Asset Capitalization")
disposal_in_period = (
(asset.disposal_date.isnotnull())
(IfNull(asset.disposal_date, 0) != 0)
& (asset.disposal_date >= filters.from_date)
& (asset.disposal_date <= filters.to_date)
)
@@ -495,7 +500,9 @@ def get_asset_details_for_grouped_by_category(filters):
frappe.qb.terms.Case()
.when(
(asset.purchase_date < filters.from_date)
& ((asset.disposal_date.isnull()) | (asset.disposal_date >= filters.from_date)),
& (
(IfNull(asset.disposal_date, 0) == 0) | (asset.disposal_date >= filters.from_date)
),
asset.net_purchase_amount,
)
.else_(0)
@@ -592,7 +599,9 @@ def get_assets_for_grouped_by_asset(filters):
frappe.qb.terms.Case()
.when(
(gl_entry.posting_date < filters.from_date)
& ((asset.disposal_date.isnull()) | (asset.disposal_date >= filters.from_date)),
& (
(IfNull(asset.disposal_date, 0) == 0) | (asset.disposal_date >= filters.from_date)
),
gl_entry.debit,
)
.else_(0)
@@ -603,7 +612,7 @@ def get_assets_for_grouped_by_asset(filters):
Sum(
frappe.qb.terms.Case()
.when(
(gl_entry.posting_date <= filters.to_date) & (asset.disposal_date.isnull()),
(gl_entry.posting_date <= filters.to_date) & (IfNull(asset.disposal_date, 0) == 0),
gl_entry.credit,
)
.else_(0)
@@ -614,7 +623,7 @@ def get_assets_for_grouped_by_asset(filters):
Sum(
frappe.qb.terms.Case()
.when(
(asset.disposal_date.isnotnull())
(IfNull(asset.disposal_date, 0) != 0)
& (asset.disposal_date >= filters.from_date)
& (asset.disposal_date <= filters.to_date)
& (gl_entry.posting_date <= asset.disposal_date),
@@ -630,7 +639,10 @@ def get_assets_for_grouped_by_asset(filters):
.when(
(gl_entry.posting_date >= filters.from_date)
& (gl_entry.posting_date <= filters.to_date)
& ((asset.disposal_date.isnull()) | (gl_entry.posting_date <= asset.disposal_date)),
& (
(IfNull(asset.disposal_date, 0) == 0)
| (gl_entry.posting_date <= asset.disposal_date)
),
gl_entry.debit,
)
.else_(0)
@@ -660,7 +672,7 @@ def get_assets_for_grouped_by_asset(filters):
Sum(
frappe.qb.terms.Case()
.when(
(asset.disposal_date.isnotnull()) & (asset.disposal_date < filters.from_date),
(IfNull(asset.disposal_date, 0) != 0) & (asset.disposal_date < filters.from_date),
0,
)
.else_(asset.opening_accumulated_depreciation)

View File

@@ -1,26 +0,0 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from erpnext.accounts.report.asset_depreciations_and_balances.asset_depreciations_and_balances import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestAssetDepreciationsAndBalancesReport(ERPNextTestSuite):
def test_report_runs_on_both_engines(self):
"""The report compared IfNull(asset.disposal_date, 0) against 0 -- coalescing a DATE
column with integer 0. Postgres rejects that (COALESCE types date and integer cannot be
matched) at plan time, so the whole report errored there regardless of data. It must run
on both engines."""
for group_by in ("Asset Category", "Asset"):
filters = frappe._dict(
company="_Test Company",
from_date="2020-01-01",
to_date="2030-12-31",
group_by=group_by,
)
result = execute(filters)
self.assertIsInstance(result[1], list)

View File

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

View File

@@ -164,8 +164,7 @@ 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"):
# bare alias, not 'remarks' — Postgres treats a single-quoted alias as a string literal
select_fields += f",substr(remarks, 1, {remarks_length}) as remarks"
select_fields += f",substr(remarks, 1, {remarks_length}) as 'remarks'"
else:
select_fields += """,remarks"""

View File

@@ -15,42 +15,6 @@ 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",
@@ -170,17 +134,17 @@ class TestGeneralLedger(ERPNextTestSuite):
revaluation_jv.submit()
# check the balance of the account
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",
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,
)
self.assertEqual(flt(balance[0].debit) - flt(balance[0].credit), 100)
self.assertEqual(balance[0][0], 100)
# check if general ledger shows correct balance
columns, data = execute(

View File

@@ -309,22 +309,17 @@ def get_account_columns(invoice_list, include_payments):
unrealized_profit_loss_account_columns = []
if invoice_list:
# 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,
expense_accounts = frappe.get_all(
"Purchase Invoice Item",
filters={
"docstatus": 1,
"expense_account": ["is", "set"],
"parenttype": "Purchase Invoice",
"parent": ["in", [inv.name for inv in invoice_list]],
},
pluck="expense_account",
distinct=True,
order_by="expense_account",
)
purchase_taxes_query = get_taxes_query(invoice_list, "Purchase Taxes and Charges", "Purchase Invoice")
@@ -336,18 +331,16 @@ def get_account_columns(invoice_list, include_payments):
advance_tax_accounts = advance_taxes_query.run(as_dict=True, pluck="account_head")
tax_accounts = set(tax_accounts + advance_tax_accounts)
unrealized_profit_loss_accounts = 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,
unrealized_profit_loss_accounts = frappe.get_all(
"Purchase Invoice",
filters={
"docstatus": 1,
"name": ["in", [inv.name for inv in invoice_list]],
"unrealized_profit_loss_account": ["is", "set"],
},
pluck="unrealized_profit_loss_account",
distinct=True,
order_by="unrealized_profit_loss_account",
)
for account in expense_accounts:

View File

@@ -24,29 +24,6 @@ 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())

View File

@@ -16,7 +16,7 @@
"name": "Sales Partners Commission",
"owner": "Administrator",
"prepared_report": 0,
"query": "SELECT\n sales_partner as \"Sales Partner:Link / Sales Partner:220\",\n sum(base_net_total) as \"Invoiced Amount (Excl. Tax):Currency:220\",\n sum(amount_eligible_for_commission) as \"Amount Eligible for Commission:Currency:220\",\n sum(total_commission) as \"Total Commission:Currency:170\",\n sum(total_commission)*100 / NULLIF(sum(amount_eligible_for_commission), 0) as \"Average Commission Rate:Percent:220\"\nFROM\n (\n SELECT\n sales_partner,\n base_net_total,\n total_commission,\n amount_eligible_for_commission\n FROM\n `tabSales Invoice` \n WHERE\n docstatus = 1\n AND IFNULL(base_net_total, 0) > 0\n AND IFNULL(total_commission, 0) > 0\n\n UNION ALL\n\n SELECT\n sales_partner,\n base_net_total,\n total_commission,\n amount_eligible_for_commission\n FROM\n `tabPOS Invoice`\n WHERE\n docstatus = 1\n AND IFNULL(base_net_total, 0) > 0\n AND IFNULL(total_commission, 0) > 0\n ) AS sub\nGROUP BY\n sales_partner",
"query": "SELECT\n sales_partner as \"Sales Partner:Link / Sales Partner:220\",\n sum(base_net_total) as \"Invoiced Amount (Excl. Tax):Currency:220\",\n sum(amount_eligible_for_commission) as \"Amount Eligible for Commission:Currency:220\",\n sum(total_commission) as \"Total Commission:Currency:170\",\n sum(total_commission)*100 / sum(amount_eligible_for_commission) as \"Average Commission Rate:Percent:220\"\nFROM\n (\n SELECT\n sales_partner,\n base_net_total,\n total_commission,\n amount_eligible_for_commission\n FROM\n `tabSales Invoice` \n WHERE\n docstatus = 1\n AND IFNULL(base_net_total, 0) > 0\n AND IFNULL(total_commission, 0) > 0\n\n UNION ALL\n\n SELECT\n sales_partner,\n base_net_total,\n total_commission,\n amount_eligible_for_commission\n FROM\n `tabPOS Invoice`\n WHERE\n docstatus = 1\n AND IFNULL(base_net_total, 0) > 0\n AND IFNULL(total_commission, 0) > 0\n ) AS sub\nGROUP BY\n sales_partner\nORDER BY\n \"Total Commission:Currency:120\"",
"ref_doctype": "Sales Invoice",
"report_name": "Sales Partners Commission",
"report_type": "Query Report",

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