mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-25 20:08:34 +00:00
Compare commits
234 Commits
revert-563
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
507bc0930e | ||
|
|
c3d2ebd734 | ||
|
|
142d80d7de | ||
|
|
c8f86099e2 | ||
|
|
ab1e949752 | ||
|
|
8ad90338a3 | ||
|
|
d2ff0913df | ||
|
|
05dd44246f | ||
|
|
6e22f4b063 | ||
|
|
d737c39131 | ||
|
|
38385432f6 | ||
|
|
78fd06048f | ||
|
|
1b68445313 | ||
|
|
3a3f56350f | ||
|
|
6d035274d7 | ||
|
|
df6b8cdd60 | ||
|
|
62f7374942 | ||
|
|
f571ba749f | ||
|
|
f151b2abee | ||
|
|
27a7ec82c2 | ||
|
|
c769bc24c9 | ||
|
|
fa9e775e9d | ||
|
|
fe863d2e7f | ||
|
|
a97b944bec | ||
|
|
851439e2d9 | ||
|
|
0dc649bae9 | ||
|
|
8de7a25d16 | ||
|
|
e86be20f44 | ||
|
|
fa99849e48 | ||
|
|
21dbd0007b | ||
|
|
8a581d4e4e | ||
|
|
b077491fc7 | ||
|
|
f46e9a0bb5 | ||
|
|
c100e1c94c | ||
|
|
b36eeb7813 | ||
|
|
46917cc36f | ||
|
|
70bd57d3e7 | ||
|
|
660fc4191c | ||
|
|
555ce3fc2a | ||
|
|
8cd420953c | ||
|
|
57cb133aaf | ||
|
|
548d90df4f | ||
|
|
64fef7e108 | ||
|
|
ad27dc4907 | ||
|
|
5438b0dbf1 | ||
|
|
41c7f2fd48 | ||
|
|
d451eddac2 | ||
|
|
51c4fc9dcc | ||
|
|
c5e911dd07 | ||
|
|
08664181d4 | ||
|
|
ac22fd0360 | ||
|
|
9ec043ffcb | ||
|
|
410a95cad2 | ||
|
|
10744d1332 | ||
|
|
b161e5aa79 | ||
|
|
fa9fb12c8d | ||
|
|
0e4d1da087 | ||
|
|
4efb43d977 | ||
|
|
f5bf915104 | ||
|
|
33562a6a86 | ||
|
|
4b8b52b908 | ||
|
|
95b6cf2847 | ||
|
|
647fdc4e3d | ||
|
|
0a462f8d2f | ||
|
|
b0887e03fe | ||
|
|
f4413ebda3 | ||
|
|
a975caf8f8 | ||
|
|
7124e47490 | ||
|
|
600bf9e249 | ||
|
|
e569e2f98c | ||
|
|
dd600c3a79 | ||
|
|
9b4c8a8d7f | ||
|
|
7f58c7f0ac | ||
|
|
cb0689bd1e | ||
|
|
4304f5129f | ||
|
|
e8e50edbed | ||
|
|
934b5065fc | ||
|
|
5fb16ca20c | ||
|
|
b10cf2fb65 | ||
|
|
da8ac36b92 | ||
|
|
f3315ecb34 | ||
|
|
61f0a39716 | ||
|
|
368ea75e38 | ||
|
|
bd53db61cc | ||
|
|
963bbc8729 | ||
|
|
beec05ce1c | ||
|
|
694f46f7f7 | ||
|
|
ce44d9192d | ||
|
|
6a93baacf0 | ||
|
|
bb19540816 | ||
|
|
6b4895bcc9 | ||
|
|
f40cd41801 | ||
|
|
5c536b8ad1 | ||
|
|
55862f98f4 | ||
|
|
b1c8e2cb5c | ||
|
|
adb768505a | ||
|
|
dfdfcb8ca1 | ||
|
|
fcfaa8843b | ||
|
|
3be80d8e87 | ||
|
|
f034bb55d3 | ||
|
|
71b4cc4f12 | ||
|
|
6a4afd1733 | ||
|
|
3fa7ec656b | ||
|
|
8854f0c153 | ||
|
|
9955adb2fc | ||
|
|
785c34e0ad | ||
|
|
7e8965c6be | ||
|
|
7cbebc0545 | ||
|
|
b3526db643 | ||
|
|
f707da40ec | ||
|
|
cb2679ba2c | ||
|
|
a6ede74b2d | ||
|
|
5aeb711f69 | ||
|
|
9506a9d62a | ||
|
|
63a1b7d8e5 | ||
|
|
f5bf9392a0 | ||
|
|
ddd57ca12e | ||
|
|
2c7cea2879 | ||
|
|
8ca2e99cf2 | ||
|
|
a11eb741e5 | ||
|
|
487aff80e0 | ||
|
|
68e92a893a | ||
|
|
ccd115e769 | ||
|
|
5a77df6560 | ||
|
|
04a93cabf1 | ||
|
|
460b8c5d8d | ||
|
|
76705dd736 | ||
|
|
78f38970c1 | ||
|
|
8dd05ca056 | ||
|
|
f4df5ee0bc | ||
|
|
517f97ff73 | ||
|
|
bf30b58d02 | ||
|
|
5543577ca7 | ||
|
|
7835cbaa56 | ||
|
|
cd8b740cb3 | ||
|
|
cb236dedfc | ||
|
|
5629f81809 | ||
|
|
e432f8284b | ||
|
|
c1ec503858 | ||
|
|
e14719b0ad | ||
|
|
3946bf5366 | ||
|
|
6b1e18f79e | ||
|
|
5a1abf6138 | ||
|
|
5c12bf02d8 | ||
|
|
2e75a4b830 | ||
|
|
afb2616aee | ||
|
|
ffa85a4ed6 | ||
|
|
50f2654eb1 | ||
|
|
dcc8d08521 | ||
|
|
0ff4840dcb | ||
|
|
432b4f7f86 | ||
|
|
b5687d659f | ||
|
|
d8ff9b7dbb | ||
|
|
1cf5cb2425 | ||
|
|
c6d34a18a5 | ||
|
|
91c92b5e20 | ||
|
|
b3c64107df | ||
|
|
7ec052a084 | ||
|
|
5fa1599639 | ||
|
|
f1499b210f | ||
|
|
97f128791c | ||
|
|
0e862d61d1 | ||
|
|
fe8be87200 | ||
|
|
4fc952badf | ||
|
|
668ca62ea5 | ||
|
|
ffce7aff55 | ||
|
|
2bc943c7e2 | ||
|
|
2985a8b263 | ||
|
|
3b25c2b7c2 | ||
|
|
28770e3988 | ||
|
|
ecbf8632aa | ||
|
|
348e3ac4ae | ||
|
|
e37c7e9b32 | ||
|
|
475cd83861 | ||
|
|
9d8f6d4ed9 | ||
|
|
2f0367807f | ||
|
|
ec496c42b5 | ||
|
|
a847d15748 | ||
|
|
a869b748f1 | ||
|
|
59fe10bfbd | ||
|
|
7cb03a427a | ||
|
|
9b0e1b61f2 | ||
|
|
67d314f32f | ||
|
|
8bd8b28207 | ||
|
|
314aa303e5 | ||
|
|
3da7eefebb | ||
|
|
13b6c4a165 | ||
|
|
eba851b4b5 | ||
|
|
eae05f1907 | ||
|
|
b919a7abff | ||
|
|
7cd1cca2ed | ||
|
|
a0cc645725 | ||
|
|
168c24f8f0 | ||
|
|
882f83ffaf | ||
|
|
b74abbb9c3 | ||
|
|
afca370fa8 | ||
|
|
69cb1121ed | ||
|
|
62c401badc | ||
|
|
c1006e79a4 | ||
|
|
3b8674a4a9 | ||
|
|
8fd0813614 | ||
|
|
ead694c9cb | ||
|
|
21541e3ad3 | ||
|
|
9d28bea453 | ||
|
|
40960a5ff9 | ||
|
|
022845e4e7 | ||
|
|
1e37c4b9ac | ||
|
|
32e971e374 | ||
|
|
1f86b57f94 | ||
|
|
c989e424f0 | ||
|
|
49e3830e7f | ||
|
|
b356dbd59e | ||
|
|
a0bbca166f | ||
|
|
4fb781ae54 | ||
|
|
2b1a477fc8 | ||
|
|
e4e6e52a4d | ||
|
|
c868de324d | ||
|
|
824415d50e | ||
|
|
d98b269033 | ||
|
|
8c124ed4a9 | ||
|
|
e0114d56db | ||
|
|
0602a22e4b | ||
|
|
b90a364c31 | ||
|
|
b82461bf0f | ||
|
|
b2bae839ac | ||
|
|
6ef8b41c3c | ||
|
|
674157767a | ||
|
|
bab97aaad0 | ||
|
|
14b83b46ac | ||
|
|
ecfc8cc400 | ||
|
|
53f0049e75 | ||
|
|
e59b772c36 | ||
|
|
76b0123778 | ||
|
|
8955a1edb4 |
72
.github/helper/hydrate.sh
vendored
Executable file
72
.github/helper/hydrate.sh
vendored
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Hydrate a test shard from the setup job's artifact.
|
||||
#
|
||||
# The bench (apps, venv, node_modules, sites) is already on disk at ~/frappe-bench — the
|
||||
# workflow untar'd it from the artifact the setup job built. So there is NO bench init, no
|
||||
# asset build, and no reinstall here: just bring the DB up on the baked datadir and start redis
|
||||
# so tests can run. The whole point is that the expensive work happened ONCE in the setup job.
|
||||
#
|
||||
set -e
|
||||
|
||||
ci_user="${ERPNEXT_CI_USER:-frappe}"
|
||||
db_host="${DB_HOST:-127.0.0.1}"
|
||||
|
||||
# Re-exec as the ci user (uid 1001) so bench/cache ownership matches the artifact, same as
|
||||
# install.sh. The workflow untar'd as root with -p, so the files are already owned by ci.
|
||||
if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then
|
||||
exec su -m "$ci_user" -s /bin/bash -c \
|
||||
"ERPNEXT_CI_USER='$ci_user' DB_HOST='$db_host' DB='${DB:-}' 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."
|
||||
340
.github/helper/install.sh
vendored
340
.github/helper/install.sh
vendored
@@ -7,21 +7,106 @@ cd ~ || exit
|
||||
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
|
||||
frappeuser=${FRAPPE_USER:-"frappe"}
|
||||
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
|
||||
db_host=${DB_HOST:-"127.0.0.1"}
|
||||
db_user_host=${DB_USER_HOST:-"localhost"}
|
||||
wkhtmltox_deb=${WKHTMLTOX_DEB:-"/tmp/wkhtmltox.deb"}
|
||||
bench_cache_dir=${BENCH_CACHE_DIR:-}
|
||||
|
||||
run_as_ci_user_if_needed() {
|
||||
if [ "$(id -u)" != "0" ] || [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ] || [ "${ERPNEXT_CI_NON_ROOT:-0}" = "1" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local missing_packages=()
|
||||
if ! command -v pkg-config >/dev/null 2>&1; then
|
||||
missing_packages+=("pkg-config")
|
||||
fi
|
||||
if ! command -v mariadb_config >/dev/null 2>&1 && ! command -v mysql_config >/dev/null 2>&1; then
|
||||
missing_packages+=("libmariadb-dev")
|
||||
fi
|
||||
if ! command -v crontab >/dev/null 2>&1; then
|
||||
missing_packages+=("cron")
|
||||
fi
|
||||
|
||||
if [ "${#missing_packages[@]}" -gt 0 ]; then
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends "${missing_packages[@]}"
|
||||
fi
|
||||
|
||||
local ci_user="${ERPNEXT_CI_USER:-frappe}"
|
||||
|
||||
if ! id "$ci_user" >/dev/null 2>&1; then
|
||||
useradd --home-dir "$HOME" --no-create-home --shell /bin/bash "$ci_user"
|
||||
fi
|
||||
|
||||
rm -rf ~/frappe ~/frappe-bench
|
||||
|
||||
local ci_dirs=(
|
||||
"$HOME"
|
||||
"$GITHUB_WORKSPACE"
|
||||
"$HOME/.cache"
|
||||
"${PIP_CACHE_DIR:-$HOME/.cache/pip}"
|
||||
"${npm_config_cache:-$HOME/.npm}"
|
||||
"${YARN_CACHE_FOLDER:-$HOME/.cache/yarn}"
|
||||
"$HOME/.yarn"
|
||||
"${UV_CACHE_DIR:-$HOME/.cache/uv}"
|
||||
"$(dirname "$wkhtmltox_deb")"
|
||||
)
|
||||
if [ -n "$bench_cache_dir" ]; then
|
||||
ci_dirs+=("$bench_cache_dir")
|
||||
fi
|
||||
|
||||
# Create + own (non-recursively) the home/cache/workspace dirs before dropping to
|
||||
# the ci user. We deliberately do NOT wipe the yarn/uv caches here so a persistent
|
||||
# cache (mounted volume or baked image layer) stays warm across runs.
|
||||
mkdir -p "${ci_dirs[@]}" "$HOME/.yarn"
|
||||
chown "$ci_user:$ci_user" "${ci_dirs[@]}" "$HOME/.yarn"
|
||||
|
||||
export ERPNEXT_CI_NON_ROOT=1
|
||||
exec su -m "$ci_user" -s /bin/bash -c "cd '$HOME' && bash '$GITHUB_WORKSPACE/.github/helper/install.sh'"
|
||||
}
|
||||
|
||||
run_as_ci_user_if_needed
|
||||
|
||||
run_ci_step() {
|
||||
local label=$1
|
||||
shift
|
||||
|
||||
echo "::group::${label}"
|
||||
date -u
|
||||
local exit_code=0
|
||||
timeout --foreground "${CI_INSTALL_STEP_TIMEOUT:-1800}" "$@" || exit_code=$?
|
||||
date -u
|
||||
echo "::endgroup::"
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
if [ -n "${GITHUB_WORKSPACE:-}" ]; then
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE" || true
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE/.git" || true
|
||||
fi
|
||||
|
||||
rm -rf ~/frappe ~/frappe-bench
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — parallelise the three slow, independent setup steps:
|
||||
# a) system packages b) frappe-bench pip install c) frappe git fetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
sudo apt update
|
||||
if [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ]; then
|
||||
sudo apt-get update
|
||||
|
||||
# apt remove/install must run sequentially but can overlap with pip and git.
|
||||
sudo apt remove mysql-server mysql-client
|
||||
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev &
|
||||
apt_pid=$!
|
||||
# apt remove/install must run sequentially but can overlap with pip and git.
|
||||
sudo apt-get remove -y mysql-server mysql-client
|
||||
sudo apt-get install -y libcups2-dev redis-server mariadb-client libmariadb-dev &
|
||||
apt_pid=$!
|
||||
|
||||
pip install frappe-bench &
|
||||
pip_pid=$!
|
||||
pip install frappe-bench &
|
||||
pip_pid=$!
|
||||
else
|
||||
apt_pid=
|
||||
pip_pid=
|
||||
fi
|
||||
|
||||
mkdir frappe
|
||||
(
|
||||
@@ -32,51 +117,189 @@ mkdir frappe
|
||||
) &
|
||||
clone_pid=$!
|
||||
|
||||
wait $apt_pid
|
||||
wait $pip_pid
|
||||
if [ -n "$apt_pid" ]; then wait $apt_pid; fi
|
||||
if [ -n "$pip_pid" ]; then wait $pip_pid; fi
|
||||
wait $clone_pid
|
||||
|
||||
pushd frappe
|
||||
git checkout FETCH_HEAD
|
||||
popd
|
||||
frappe_sha=$(git -C frappe rev-parse HEAD)
|
||||
|
||||
get_bench_cache_archive() {
|
||||
if [ -z "$bench_cache_dir" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$bench_cache_dir"
|
||||
|
||||
# Keyed on tool versions only (NOT the frappe SHA): any recent base bench works, because
|
||||
# restore_warm_bench fast-forwards it to the exact live develop SHA. This is what lets a
|
||||
# constantly-moving develop still hit the cache.
|
||||
local cache_key
|
||||
cache_key=$(
|
||||
{
|
||||
uname -m
|
||||
python --version
|
||||
node --version
|
||||
bench --version
|
||||
} | sha256sum | awk '{print $1}'
|
||||
)
|
||||
|
||||
echo "${bench_cache_dir}/frappe-bench-base-${cache_key}.tar.zst"
|
||||
}
|
||||
|
||||
restore_warm_bench() {
|
||||
bench_cache_archive=$(get_bench_cache_archive)
|
||||
[ -n "$bench_cache_archive" ] && [ -f "$bench_cache_archive" ] || return 1
|
||||
|
||||
echo "Restoring base bench from ${bench_cache_archive}"
|
||||
tar --use-compress-program=unzstd -xf "$bench_cache_archive" -C ~ || return 1
|
||||
[ -d ~/frappe-bench/apps/frappe/.git ] || return 1
|
||||
mkdir -p ~/frappe-bench/sites ~/frappe-bench/logs
|
||||
[ -f ~/frappe-bench/sites/apps.txt ] || printf "frappe\n" > ~/frappe-bench/sites/apps.txt
|
||||
[ -f ~/frappe-bench/sites/common_site_config.json ] || printf "{}\n" > ~/frappe-bench/sites/common_site_config.json
|
||||
|
||||
# Fast-forward the restored frappe to the EXACT live develop SHA fetched in phase 1, then
|
||||
# rebuild only what changed. The editable install means the venv tracks the new code with
|
||||
# no reinstall. Any failure returns non-zero so the caller falls back to a full bench init.
|
||||
if ! (
|
||||
cd ~/frappe-bench/apps/frappe || exit 1
|
||||
# Phase 1 already fetched ~/frappe to the exact live develop SHA. Fetch that commit
|
||||
# straight from it (bench init names the remote 'upstream', not 'origin', and points
|
||||
# it at this local clone — so a plain `git fetch origin` does not work).
|
||||
git fetch --no-tags "$HOME/frappe" HEAD || exit 1
|
||||
git checkout --force FETCH_HEAD || exit 1
|
||||
); then
|
||||
echo "Fast-forward to ${frappe_sha} failed; falling back to full init"
|
||||
rm -rf ~/frappe-bench
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Pick up any frappe dependency changes since the base was built (cached → fast if none),
|
||||
# so a develop commit that bumped requirements doesn't leave a stale venv.
|
||||
if ! ~/frappe-bench/env/bin/python -m pip install -q -e ~/frappe-bench/apps/frappe; then
|
||||
echo "frappe dependency refresh failed; falling back to full init"
|
||||
rm -rf ~/frappe-bench
|
||||
return 1
|
||||
fi
|
||||
|
||||
( cd ~/frappe-bench && CI=Yes bench build --app frappe ) || { rm -rf ~/frappe-bench; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
save_warm_bench() {
|
||||
if [ -z "${bench_cache_archive:-}" ] || [ -f "$bench_cache_archive" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$bench_cache_dir" ] && [ ! -w "$bench_cache_dir" ]; then
|
||||
echo "Skipping warm bench save because ${bench_cache_dir} is not writable"
|
||||
return
|
||||
fi
|
||||
|
||||
local tmp_archive
|
||||
tmp_archive="${bench_cache_archive}.${$}.tmp"
|
||||
|
||||
echo "Saving warm bench to ${bench_cache_archive}"
|
||||
# Keep sites/common_site_config.json (the redis ports live there — dropping it makes the
|
||||
# restore path fall back to a default redis port that bench start never bound, so reinstall
|
||||
# fails with "redis ... connection refused"). Only the rebuildable sites/assets is excluded;
|
||||
# restore_warm_bench runs `bench build` to regenerate it.
|
||||
tar \
|
||||
--use-compress-program="zstd -T0 -3" \
|
||||
--exclude="frappe-bench/logs" \
|
||||
--exclude="frappe-bench/sites/assets" \
|
||||
-cf "$tmp_archive" \
|
||||
-C ~ frappe-bench
|
||||
mv "$tmp_archive" "$bench_cache_archive"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — bench init and site setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
|
||||
install_whktml() {
|
||||
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
|
||||
if [ ! -f "$wkhtmltox_deb" ]; then
|
||||
wget -O "$wkhtmltox_deb" https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
fi
|
||||
sudo apt-get install -y "$wkhtmltox_deb"
|
||||
}
|
||||
if [ "${SKIP_WKHTMLTOX_SETUP:-0}" != "1" ]; then
|
||||
install_whktml &
|
||||
wkpid=$!
|
||||
else
|
||||
wkpid=
|
||||
fi
|
||||
|
||||
mkdir ~/frappe-bench/sites/test_site
|
||||
if ! restore_warm_bench; then
|
||||
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
|
||||
|
||||
cd ~/frappe-bench || exit
|
||||
|
||||
sed -i 's/watch:/# watch:/g' Procfile
|
||||
sed -i 's/schedule:/# schedule:/g' Procfile
|
||||
sed -i 's/socketio:/# socketio:/g' Procfile
|
||||
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
|
||||
|
||||
CI=Yes bench build --app frappe
|
||||
save_warm_bench
|
||||
fi
|
||||
|
||||
if [ -n "$wkpid" ]; then wait $wkpid; fi
|
||||
|
||||
mkdir -p ~/frappe-bench/sites/test_site
|
||||
|
||||
if [ "$DB" == "mariadb" ];then
|
||||
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_mariadb.json" ~/frappe-bench/sites/test_site/site_config.json
|
||||
if [ "$db_host" != "127.0.0.1" ]; then
|
||||
sed -i "s/\"db_host\": \"127.0.0.1\"/\"db_host\": \"${db_host}\"/" ~/frappe-bench/sites/test_site/site_config.json
|
||||
fi
|
||||
else
|
||||
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_postgres.json" ~/frappe-bench/sites/test_site/site_config.json
|
||||
fi
|
||||
|
||||
|
||||
if [ "$DB" == "mariadb" ];then
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
for _ in {1..60}; do
|
||||
if mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent
|
||||
|
||||
# Belt-and-suspenders: also set performance variables at runtime in case
|
||||
# MARIADB_EXTRA_FLAGS was not honoured by the container image.
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot \
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
|
||||
# Throwaway-DB durability tuning at runtime. (innodb_doublewrite is read-only on MariaDB
|
||||
# 10.6, so it can't be disabled here — would need a server startup flag.)
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot \
|
||||
-e "SET GLOBAL innodb_flush_log_at_trx_commit=0; SET GLOBAL sync_binlog=0;"
|
||||
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
|
||||
# Opt-in DDL speedup: a shared tablespace avoids a create+fsync per DocType table during
|
||||
# reinstall — a big win under disk contention. But ROW_FORMAT=DYNAMIC must be accepted in
|
||||
# the system tablespace on this MariaDB. Enable with CI_INNODB_SHARED_TABLESPACE=1; if
|
||||
# reinstall then errors on table creation, unset it (off by default — zero risk).
|
||||
if [ "${CI_INNODB_SHARED_TABLESPACE:-0}" = "1" ]; then
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL innodb_file_per_table=0;"
|
||||
fi
|
||||
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'${db_user_host}' IDENTIFIED BY 'test_frappe'"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'${db_user_host}'"
|
||||
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
|
||||
fi
|
||||
|
||||
if [ "$DB" == "postgres" ];then
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
|
||||
# CI databases are disposable, so trade durability for speed: postgres fsyncs on every commit
|
||||
# by default, which dominates a commit-heavy test suite. These are all reload-time settings
|
||||
# (no restart needed). MariaDB CI is unaffected (DB != postgres).
|
||||
|
||||
# Disposable CI DB: durability off for speed (postgres fsyncs every commit by default, which
|
||||
# dominates a commit-heavy suite). All reloadable, no restart. The postgres workflow runs a
|
||||
# service-container DB and never calls start-db.sh, so the flags must be applied here.
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -U postgres \
|
||||
-c "ALTER SYSTEM SET synchronous_commit = 'off'" \
|
||||
-c "ALTER SYSTEM SET fsync = 'off'" \
|
||||
@@ -84,32 +307,57 @@ if [ "$DB" == "postgres" ];then
|
||||
-c "SELECT pg_reload_conf()";
|
||||
fi
|
||||
|
||||
|
||||
install_whktml() {
|
||||
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
|
||||
if [ ! -f /tmp/wkhtmltox.deb ]; then
|
||||
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
fi
|
||||
sudo apt install /tmp/wkhtmltox.deb
|
||||
}
|
||||
install_whktml &
|
||||
wkpid=$!
|
||||
|
||||
|
||||
cd ~/frappe-bench || exit
|
||||
|
||||
sed -i 's/watch:/# watch:/g' Procfile
|
||||
sed -i 's/schedule:/# schedule:/g' Procfile
|
||||
sed -i 's/socketio:/# socketio:/g' Procfile
|
||||
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
|
||||
run_ci_step "Get payments app" bench get-app payments --branch develop
|
||||
|
||||
bench get-app payments --branch develop
|
||||
bench get-app erpnext "${GITHUB_WORKSPACE}"
|
||||
# Opt-in: skip building erpnext's frontend assets. Server tests don't need them, but PDF
|
||||
# tests (print formats) do — they pass only if the PDF renderer ignores missing assets.
|
||||
# Enable with CI_SKIP_ERPNEXT_ASSETS=1 to test; if PDF tests fail, unset it.
|
||||
erpnext_get_app_args=()
|
||||
if [ "${CI_SKIP_ERPNEXT_ASSETS:-0}" = "1" ]; then erpnext_get_app_args=(--skip-assets); fi
|
||||
run_ci_step "Get erpnext app" bench get-app erpnext "${GITHUB_WORKSPACE}" "${erpnext_get_app_args[@]}"
|
||||
|
||||
if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
|
||||
if [ "$TYPE" == "server" ]; then run_ci_step "Setup dev requirements" bench setup requirements --dev; fi
|
||||
|
||||
wait $wkpid
|
||||
bench start >> ~/frappe-bench/bench_start.log 2>&1 &
|
||||
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
CI=Yes bench build --app frappe &
|
||||
bench --site test_site reinstall --yes
|
||||
# Under heavy concurrency, gunicorn's startup can delay redis coming up. reinstall and the
|
||||
# tests need redis, so wait for it (best-effort, bounded) instead of racing — contention
|
||||
# then slows the job rather than failing it.
|
||||
wait_for_redis() {
|
||||
local cfg=~/frappe-bench/sites/common_site_config.json
|
||||
[ -f "$cfg" ] || return 0
|
||||
local ports port
|
||||
ports=$(python - "$cfg" <<'PY'
|
||||
import json, re, sys
|
||||
try:
|
||||
cfg = json.load(open(sys.argv[1]))
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
for key in ("redis_cache", "redis_queue"):
|
||||
match = re.search(r":(\d+)", str(cfg.get(key, "")))
|
||||
if match:
|
||||
print(match.group(1))
|
||||
PY
|
||||
)
|
||||
for port in $ports; do
|
||||
local up=0
|
||||
for _ in $(seq 1 120); do
|
||||
if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then
|
||||
exec 3>&- 3<&-; up=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
# Fail clearly instead of letting reinstall die later on a vague socket-connection error
|
||||
# when redis never bound.
|
||||
[ "$up" = "1" ] || { echo "redis did not come up on port $port"; return 1; }
|
||||
done
|
||||
}
|
||||
wait_for_redis
|
||||
|
||||
# Site setup: build the schema (~1000 DocTypes) into the DB. This is the single-threaded-Python
|
||||
# bottleneck, but the fan-out amortises it — it runs once here in the setup job, and the test
|
||||
# shards start the DB on the baked datadir instead of repeating the reinstall.
|
||||
run_ci_step "Reinstall test site" bench --site test_site reinstall --yes
|
||||
|
||||
79
.github/helper/start-db.sh
vendored
Executable file
79
.github/helper/start-db.sh
vendored
Executable file
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Run MariaDB INSIDE the runner container, on a datadir we control. Because the datadir can be
|
||||
# packaged into the bench artifact, test shards start an already-loaded server instead of
|
||||
# replaying a SQL dump (the ~60s hydrate restore). Each shard gets its own copy → isolation kept.
|
||||
#
|
||||
# CI_DB_DATADIR picks the path:
|
||||
# - setup job: /home/ci/db-data (OUTSIDE the bench, so install.sh's `rm -rf ~/frappe-bench`
|
||||
# doesn't wipe it; it's moved into the bench just before packaging)
|
||||
# - test shard: ~/frappe-bench/mariadb-data (where the artifact untar'd it)
|
||||
#
|
||||
# Idempotent: inits a fresh datadir if absent (setup), else starts on the existing one (shards).
|
||||
#
|
||||
set -e
|
||||
|
||||
ci_user="${ERPNEXT_CI_USER:-frappe}"
|
||||
|
||||
# Re-exec as the ci user so mariadbd and the datadir are owned consistently (root mariadbd is
|
||||
# refused anyway). Mirrors install.sh's user switch.
|
||||
if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then
|
||||
exec su -m "$ci_user" -s /bin/bash -c \
|
||||
"ERPNEXT_CI_USER='$ci_user' CI_DB_DATADIR='${CI_DB_DATADIR:-}' DB='${DB:-}' 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)"
|
||||
16
.github/workflows/patch.yml
vendored
16
.github/workflows/patch.yml
vendored
@@ -65,6 +65,19 @@ jobs:
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
# The v14 baseline backup is a fixed published file — cache it instead of re-downloading
|
||||
# ~100MB from frappe.io every run.
|
||||
- name: Cache erpnext v14 backup
|
||||
id: cache-v14
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/erpnext-v14.sql.gz
|
||||
key: erpnext-v14-sql-gz
|
||||
|
||||
- name: Download erpnext v14 backup
|
||||
if: steps.cache-v14.outputs.cache-hit != 'true'
|
||||
run: wget -O ~/erpnext-v14.sql.gz https://frappe.io/files/erpnext-v14.sql.gz
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -113,8 +126,7 @@ jobs:
|
||||
jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json
|
||||
mv tmp.json ~/frappe-bench/sites/test_site/site_config.json
|
||||
|
||||
wget https://frappe.io/files/erpnext-v14.sql.gz
|
||||
bench --site test_site --force restore ~/frappe-bench/erpnext-v14.sql.gz
|
||||
bench --site test_site --force restore ~/erpnext-v14.sql.gz
|
||||
|
||||
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
|
||||
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git
|
||||
|
||||
@@ -22,4 +22,4 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: alyf-de/po-review-action@v1.0.0
|
||||
- uses: alyf-de/po-review-action@v1.1.0
|
||||
|
||||
196
.github/workflows/server-tests-mariadb.yml
vendored
196
.github/workflows/server-tests-mariadb.yml
vendored
@@ -31,51 +31,49 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
concurrency:
|
||||
group: server-mariadb-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Shared across both jobs. Both run in the SAME CI image so the bench lives at the identical
|
||||
# path (/home/ci/frappe-bench) on the setup runner and the test shards — that's what makes the
|
||||
# packaged Python venv portable between them.
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
NODE_ENV: "production"
|
||||
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
|
||||
ERPNEXT_CI_USER: ci
|
||||
PIP_CACHE_DIR: /home/ci/.cache/pip
|
||||
npm_config_cache: /home/ci/.cache/npm
|
||||
YARN_CACHE_FOLDER: /home/ci/.cache/yarn
|
||||
UV_CACHE_DIR: /home/ci/.cache/uv
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
NODE_ENV: "production"
|
||||
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
matrix:
|
||||
container: [1, 2, 3, 4]
|
||||
|
||||
name: Python Unit Tests
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mariadb:10.6
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
MARIADB_ROOT_PASSWORD: 'root'
|
||||
# Disable durability guarantees that are unnecessary in a throwaway CI container.
|
||||
# innodb_flush_log_at_trx_commit=0 avoids an fsync on every commit (biggest win).
|
||||
# sync_binlog=0 skips binary-log syncs; innodb_doublewrite=0 skips the doublewrite buffer.
|
||||
MARIADB_EXTRA_FLAGS: --innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --innodb-doublewrite=0
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
# Build the bench (clone + pip + yarn + assets) and reinstall test_site ONCE, on a free
|
||||
# GitHub-hosted runner, then publish the whole bench (with a DB dump baked in) as an artifact.
|
||||
# The expensive, non-parallelisable work happens here exactly once instead of on every shard.
|
||||
setup:
|
||||
name: Build & reinstall (setup)
|
||||
# Dedicated scale set (fat cpu request) so the build+reinstall runs at full speed, uncontended
|
||||
# by the many thin test shards. Same CI image + /home/ci path + 127.0.0.1 DB as the shards,
|
||||
# so the packaged bench (and its venv) transplants cleanly.
|
||||
runs-on: erpnext-arc-setup
|
||||
timeout-minutes: 40
|
||||
container:
|
||||
image: ghcr.io/frappe/erpnext-ci-mariadb:py3.14-node24
|
||||
credentials:
|
||||
username: ${{ secrets.GHCR_USERNAME || github.actor }}
|
||||
password: ${{ secrets.GHCR_TOKEN || github.token }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.14'
|
||||
|
||||
- name: Check for valid Python & Merge Conflicts
|
||||
run: |
|
||||
python -m compileall -fq "${GITHUB_WORKSPACE}"
|
||||
@@ -84,53 +82,17 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
# MariaDB runs in-container on a datadir OUTSIDE the bench, because install.sh's next step
|
||||
# does `rm -rf ~/frappe-bench`. After the reinstall, the datadir is moved into the bench so
|
||||
# it ships in the artifact — test shards then start an already-loaded server (no restore).
|
||||
- name: Start DB
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/start-db.sh
|
||||
env:
|
||||
cache-name: cache-node-modules
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-${{ env.cache-name }}-
|
||||
${{ runner.os }}-build-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Cache wkhtmltopdf
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/wkhtmltox.deb
|
||||
key: wkhtmltox-0.12.6.1-2-jammy-amd64
|
||||
SKIP_SYSTEM_SETUP: "1"
|
||||
CI_DB_DATADIR: /home/ci/db-data
|
||||
|
||||
- name: Install
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
||||
@@ -139,9 +101,81 @@ jobs:
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_USER_HOST: '%'
|
||||
WKHTMLTOX_DEB: /tmp/wkhtmltox.deb
|
||||
SKIP_SYSTEM_SETUP: "1"
|
||||
SKIP_WKHTMLTOX_SETUP: "1"
|
||||
|
||||
# Clean shutdown (consistent InnoDB datadir), then stage it inside the bench for packaging.
|
||||
- name: Stop DB and stage datadir
|
||||
run: |
|
||||
mariadb-admin -h 127.0.0.1 -P 3306 -u root -proot shutdown || true
|
||||
for _ in $(seq 1 30); do [ -f /home/ci/db-data/mysqld.pid ] || break; sleep 1; done
|
||||
# Don't bake a dirty datadir — fail if mariadbd didn't finish stopping, rather than ship
|
||||
# an inconsistent datadir the shards would have to crash-recover.
|
||||
[ -f /home/ci/db-data/mysqld.pid ] && { echo "mariadbd did not shut down cleanly"; exit 1; }
|
||||
mv /home/ci/db-data /home/ci/frappe-bench/mariadb-data
|
||||
|
||||
# Package the whole bench (apps, venv, node_modules, sites, the DB dump, and hydrate.sh)
|
||||
# into one artifact for the test shards to consume.
|
||||
# Single-node hand-off: stage the bench on a node-local hostPath instead of round-tripping
|
||||
# through GitHub artifact storage (~60s/shard). Setup and shards share the same disk, so
|
||||
# the shards just untar it locally. NOTE: this assumes one node — a shard on a different
|
||||
# node could not read this path (then you'd need GitHub artifacts or an NFS/RWX volume).
|
||||
- name: Stage bench on node (hostPath)
|
||||
run: |
|
||||
cp "${GITHUB_WORKSPACE}/.github/helper/hydrate.sh" /home/ci/frappe-bench/hydrate.sh
|
||||
cp "${GITHUB_WORKSPACE}/.github/helper/start-db.sh" /home/ci/frappe-bench/start-db.sh
|
||||
mkdir -p /opt/ci-bench-staging
|
||||
# self-clean: drop bench tars from runs older than 2h
|
||||
find /opt/ci-bench-staging -maxdepth 1 -name '*.tar.gz' -mmin +120 -delete 2>/dev/null || true
|
||||
# Exclude .git/node_modules; the mariadb-data datadir IS included (the pre-loaded DB).
|
||||
tar czpf "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz" -C /home/ci \
|
||||
--exclude='.git' --exclude='node_modules' frappe-bench
|
||||
ls -lh "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz"
|
||||
|
||||
# Fan-out: each shard downloads the bench, untars it, starts MariaDB on the baked datadir, and
|
||||
# runs its slice of the suite. No clone, no build, no reinstall, no DB dump restore on the shards.
|
||||
test:
|
||||
name: Python Unit Tests
|
||||
needs: setup
|
||||
runs-on: erpnext-arc
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/frappe/erpnext-ci-mariadb:py3.14-node24
|
||||
credentials:
|
||||
username: ${{ secrets.GHCR_USERNAME || github.actor }}
|
||||
password: ${{ secrets.GHCR_TOKEN || github.token }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
container: [1, 2, 3, 4]
|
||||
|
||||
steps:
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
# Read the bench straight from the node-local hostPath the setup job staged it on — no
|
||||
# GitHub download. -p preserves the ci (uid 1001) ownership so bench runs as ci cleanly.
|
||||
- name: Untar bench from node (hostPath)
|
||||
run: |
|
||||
tar xzpf "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz" -C /home/ci
|
||||
ls -ld /home/ci/frappe-bench
|
||||
|
||||
- name: Hydrate (start DB on baked datadir + bench start)
|
||||
run: bash /home/ci/frappe-bench/hydrate.sh
|
||||
env:
|
||||
DB_HOST: 127.0.0.1
|
||||
SKIP_SYSTEM_SETUP: "1"
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
su -m "${ERPNEXT_CI_USER:-frappe}" -s /bin/bash <<'EOF'
|
||||
cd ~/frappe-bench/
|
||||
coverage_flag=""
|
||||
if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
|
||||
@@ -149,10 +183,10 @@ jobs:
|
||||
--total-builds ${{ strategy.job-total }} \
|
||||
--build-number ${{ matrix.container }} \
|
||||
$coverage_flag
|
||||
EOF
|
||||
env:
|
||||
TYPE: server
|
||||
|
||||
|
||||
- name: Show bench output
|
||||
if: ${{ always() }}
|
||||
run: cat ~/frappe-bench/bench_start.log || true
|
||||
@@ -162,11 +196,11 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-${{ matrix.container }}
|
||||
path: /home/runner/frappe-bench/sites/coverage.xml
|
||||
path: /home/ci/frappe-bench/sites/coverage.xml
|
||||
|
||||
coverage:
|
||||
name: Coverage Wrap Up
|
||||
needs: test
|
||||
needs: [test]
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
260
.github/workflows/server-tests-postgres.yml
vendored
260
.github/workflows/server-tests-postgres.yml
vendored
@@ -1,79 +1,45 @@
|
||||
name: Server (Postgres)
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [frappe-framework-change]
|
||||
schedule:
|
||||
# 03:00 AM IST daily (21:30 UTC the previous day)
|
||||
- cron: "30 21 * * *"
|
||||
pull_request:
|
||||
# 'labeled' is required so adding the 'postgres' label to an open PR triggers this run
|
||||
# (the job itself is gated on that label below)
|
||||
# 'labeled' so adding the 'postgres' label to an already-open PR re-triggers the run.
|
||||
types: [opened, reopened, synchronize, labeled]
|
||||
paths-ignore:
|
||||
- '**.js'
|
||||
- '**.css'
|
||||
- '**.svg'
|
||||
- '**.md'
|
||||
- '**.html'
|
||||
- 'crowdin.yml'
|
||||
- '.coderabbit.yml'
|
||||
- '.mergify.yml'
|
||||
schedule:
|
||||
# Run everday at midnight UTC / 5:30 IST
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
user:
|
||||
description: 'Frappe Framework repository user (add your username for forks)'
|
||||
required: true
|
||||
default: 'frappe'
|
||||
type: string
|
||||
branch:
|
||||
description: 'Frappe Framework branch'
|
||||
default: 'develop'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: server-postgres-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Postgres CI stays on GitHub-hosted (free, full-speed VM per shard) but follows the same fan-out
|
||||
# we built for MariaDB: build the bench + reinstall ONCE in the setup job, bake the PostgreSQL
|
||||
# PGDATA into the artifact, and have 4 test shards start Postgres on that datadir — no per-shard
|
||||
# clone/build/reinstall/restore. Python is pinned so the venv transplants between VMs.
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
NODE_ENV: "production"
|
||||
PYTHON_VERSION: '3.14'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
# Opt-in on PRs: only runs when the PR carries the 'postgres' label. Scheduled / manual /
|
||||
# framework-dispatch runs always execute (no PR labels to gate on).
|
||||
if: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'postgres') }}
|
||||
setup:
|
||||
name: Build & reinstall (setup)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
NODE_ENV: "production"
|
||||
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
matrix:
|
||||
container: [1, 2, 3, 4]
|
||||
|
||||
# Distinct from the MariaDB job's "Python Unit Tests" so its check contexts do NOT collide with
|
||||
# the required "Python Unit Tests (1..4)" status checks -- this keeps Postgres non-required for now.
|
||||
name: Postgres Unit Tests
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:13.3
|
||||
env:
|
||||
POSTGRES_PASSWORD: travis
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
# Runs on the daily schedule (and workflow_dispatch). On PRs it runs ONLY when the PR carries
|
||||
# the 'postgres' label — the test job needs setup, so it's skipped too when this is.
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'postgres')
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
@@ -81,7 +47,7 @@ jobs:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.14'
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Check for valid Python & Merge Conflicts
|
||||
run: |
|
||||
@@ -100,98 +66,124 @@ jobs:
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
- name: Cache pip
|
||||
- name: Cache deps (uv/pip/npm/yarn)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
path: |
|
||||
~/.cache/uv
|
||||
~/.cache/pip
|
||||
~/.npm
|
||||
~/.cache/yarn
|
||||
key: ${{ runner.os }}-deps-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-deps-
|
||||
|
||||
- name: Cache node modules
|
||||
# Warm-bench cache (the big one): install.sh saves the built base bench — frappe + env +
|
||||
# node_modules + assets — here as frappe-bench-base-*.tar.zst. Later runs restore it and only
|
||||
# fast-forward to the live develop SHA + rebuild the delta, so the bench BUILD is near-free and
|
||||
# only the test_site reinstall (per-run DB, uncacheable) stays slow — matching the self-hosted
|
||||
# box. The first run after a deps change populates it; every run after that is fast.
|
||||
- name: Cache warm bench (base build)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/bench-cache
|
||||
key: ${{ runner.os }}-warmbench-v2-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-warmbench-v2-
|
||||
|
||||
# Postgres runs in-runner on a PGDATA OUTSIDE the bench (install.sh wipes ~/frappe-bench);
|
||||
# after the reinstall it's moved into the bench so it ships in the artifact.
|
||||
- name: Start DB
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/start-db.sh
|
||||
env:
|
||||
cache-name: cache-node-modules
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-${{ env.cache-name }}-
|
||||
${{ runner.os }}-build-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Cache wkhtmltopdf
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/wkhtmltox.deb
|
||||
key: wkhtmltox-0.12.6.1-2-jammy-amd64
|
||||
DB: postgres
|
||||
CI_DB_DATADIR: /home/runner/pgdata
|
||||
|
||||
- name: Install
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
||||
env:
|
||||
DB: postgres
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
|
||||
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/
|
||||
coverage_flag=""
|
||||
if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
|
||||
# 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 }} \
|
||||
$coverage_flag
|
||||
--total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }}
|
||||
env:
|
||||
TYPE: server
|
||||
|
||||
|
||||
- name: Show bench output
|
||||
if: ${{ always() }}
|
||||
run: cat ~/frappe-bench/bench_start.log || true
|
||||
|
||||
- name: Upload coverage data
|
||||
if: ${{ env.WITH_COVERAGE == 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-postgres-${{ matrix.container }}
|
||||
path: /home/runner/frappe-bench/sites/coverage.xml
|
||||
|
||||
coverage:
|
||||
name: Coverage Wrap Up
|
||||
needs: test
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: coverage-postgres-*
|
||||
|
||||
- name: Upload coverage data
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
name: Postgres
|
||||
flags: postgres
|
||||
# explicit glob: download-artifact extracts each shard into its own coverage-postgres-N/ dir
|
||||
files: coverage-postgres-*/coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
verbose: true
|
||||
|
||||
@@ -3,8 +3,6 @@ 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"
|
||||
|
||||
@@ -155,6 +153,8 @@ def allow_regional(fn):
|
||||
|
||||
|
||||
def check_app_permission():
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
if frappe.session.user == "Administrator":
|
||||
return True
|
||||
|
||||
@@ -175,6 +175,8 @@ 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__"))
|
||||
|
||||
@@ -234,7 +234,7 @@ class Account(NestedSet):
|
||||
if not frappe.db.get_value(
|
||||
"Account", {"account_name": self.account_name, "company": ancestors[0]}, "name"
|
||||
):
|
||||
frappe.throw(_("Please add the account to root level Company - {}").format(ancestors[0]))
|
||||
frappe.throw(_("Please add the account to root level Company - {0}").format(ancestors[0]))
|
||||
elif self.parent_account:
|
||||
descendants = get_descendants_of("Company", self.company)
|
||||
if not descendants:
|
||||
@@ -671,7 +671,7 @@ def _ensure_idle_system():
|
||||
if last_gl_update > add_to_date(None, minutes=-5):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Last GL Entry update was done {}. This operation is not allowed while system is actively being used. Please wait for 5 minutes before retrying."
|
||||
"Last GL Entry update was done {0}. This operation is not allowed while system is actively being used. Please wait for 5 minutes before retrying."
|
||||
).format(pretty_date(last_gl_update)),
|
||||
title=_("System In Use"),
|
||||
)
|
||||
|
||||
@@ -236,9 +236,9 @@ frappe.treeview_settings["Account"] = {
|
||||
function () {
|
||||
let root_company = treeview.page.fields_dict.root_company.get_value();
|
||||
if (root_company) {
|
||||
frappe.throw(__("Please add the account to root level Company - {0}"), [
|
||||
root_company,
|
||||
]);
|
||||
frappe.throw(
|
||||
__("Please add the account to root level Company - {0}", [root_company])
|
||||
);
|
||||
} else {
|
||||
treeview.new_node();
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ def get_charts_for_country(country: str, with_standard: bool = False):
|
||||
|
||||
def _get_chart_name(content):
|
||||
if content:
|
||||
content = json.loads(content)
|
||||
content = frappe.parse_json(content)
|
||||
if (
|
||||
content and content.get("disabled", "No") == "No"
|
||||
) or frappe.local.flags.allow_unverified_charts:
|
||||
|
||||
@@ -224,7 +224,7 @@ def disable_dimension(doc: str):
|
||||
|
||||
|
||||
def toggle_disabling(doc):
|
||||
doc = json.loads(doc)
|
||||
doc = frappe.parse_json(doc)
|
||||
|
||||
if doc.get("disabled"):
|
||||
df = {"read_only": 1}
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"period_closing_settings_section",
|
||||
"ignore_account_closing_balance",
|
||||
"use_legacy_controller_for_pcv",
|
||||
"pcv_job_timeout",
|
||||
"column_break_25",
|
||||
"reports_tab",
|
||||
"remarks_section",
|
||||
@@ -612,6 +613,14 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "Use legacy controller for Period Closing Voucher"
|
||||
},
|
||||
{
|
||||
"default": "3600",
|
||||
"depends_on": "eval: !doc.use_legacy_controller_for_pcv",
|
||||
"description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher",
|
||||
"fieldname": "pcv_job_timeout",
|
||||
"fieldtype": "Int",
|
||||
"label": "PCV Job Timeout (seconds)"
|
||||
},
|
||||
{
|
||||
"description": "Users with this role will be notified if the asset depreciation gets failed",
|
||||
"fieldname": "role_to_notify_on_depreciation_failure",
|
||||
@@ -756,7 +765,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-03 13:11:54.721495",
|
||||
"modified": "2026-06-24 12:59:41.868865",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
@@ -90,6 +90,7 @@ class AccountsSettings(Document):
|
||||
make_payment_via_journal_entry: DF.Check
|
||||
merge_similar_account_heads: DF.Check
|
||||
over_billing_allowance: DF.Currency
|
||||
pcv_job_timeout: DF.Int
|
||||
preview_mode: DF.Check
|
||||
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
|
||||
receivable_payable_remarks_length: DF.Int
|
||||
|
||||
@@ -1058,9 +1058,9 @@ def get_auto_reconcile_message(partially_reconciled, reconciled):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str, is_new_voucher: bool = False):
|
||||
def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str | list, is_new_voucher: bool = False):
|
||||
# updated clear date of all the vouchers based on the bank transaction
|
||||
vouchers = json.loads(vouchers)
|
||||
vouchers = frappe.parse_json(vouchers)
|
||||
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
|
||||
transaction.add_payment_entries(vouchers, is_new_voucher)
|
||||
transaction.validate_duplicate_references()
|
||||
|
||||
@@ -290,7 +290,7 @@ def update_mapping_db(bank, template_options):
|
||||
for d in bank.bank_transaction_mapping:
|
||||
d.delete()
|
||||
|
||||
for d in json.loads(template_options)["column_to_field_map"].items():
|
||||
for d in frappe.parse_json(template_options)["column_to_field_map"].items():
|
||||
bank.append("bank_transaction_mapping", {"bank_transaction_field": d[1], "file_field": d[0]})
|
||||
|
||||
bank.save()
|
||||
|
||||
@@ -1183,8 +1183,7 @@ 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"))
|
||||
|
||||
if isinstance(tables, str):
|
||||
tables = json.loads(tables)
|
||||
tables = frappe.parse_json(tables)
|
||||
|
||||
doc.apply_pdf_tables(tables)
|
||||
|
||||
@@ -1204,8 +1203,7 @@ 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"))
|
||||
|
||||
if isinstance(bbox, str):
|
||||
bbox = json.loads(bbox)
|
||||
bbox = frappe.parse_json(bbox)
|
||||
|
||||
page = int(page)
|
||||
table_index = int(table_index)
|
||||
@@ -1290,8 +1288,7 @@ 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"))
|
||||
|
||||
if isinstance(column_mapping, str):
|
||||
column_mapping = json.loads(column_mapping)
|
||||
column_mapping = frappe.parse_json(column_mapping)
|
||||
|
||||
doc.apply_column_mapping(column_mapping)
|
||||
doc.save()
|
||||
|
||||
@@ -440,7 +440,7 @@ def get_clearance_details(transaction, payment_entry, bt_allocations, gl_entries
|
||||
|
||||
if bt_bank_account != gl_bank_account:
|
||||
frappe.throw(
|
||||
_("Bank Account {} in Bank Transaction {} is not matching with Bank Account {}").format(
|
||||
_("Bank Account {0} in Bank Transaction {1} is not matching with Bank Account {2}").format(
|
||||
bt_bank_account, payment_entry.payment_entry, gl_bank_account
|
||||
)
|
||||
)
|
||||
@@ -449,7 +449,7 @@ def get_clearance_details(transaction, payment_entry, bt_allocations, gl_entries
|
||||
|
||||
if gl_bank_account not in gl_entries:
|
||||
frappe.throw(
|
||||
_("{} {} is not affecting bank account {}").format(
|
||||
_("{0} {1} is not affecting bank account {2}").format(
|
||||
payment_entry.payment_document, payment_entry.payment_entry, gl_bank_account
|
||||
)
|
||||
)
|
||||
@@ -457,7 +457,7 @@ def get_clearance_details(transaction, payment_entry, bt_allocations, gl_entries
|
||||
allocable_amount = gl_entries.pop(gl_bank_account) or 0
|
||||
if allocable_amount <= 0.0:
|
||||
frappe.throw(
|
||||
_("Invalid amount in accounting entries of {} {} for Account {}: {}").format(
|
||||
_("Invalid amount in accounting entries of {0} {1} for Account {2}: {3}").format(
|
||||
payment_entry.payment_document, payment_entry.payment_entry, gl_bank_account, allocable_amount
|
||||
)
|
||||
)
|
||||
|
||||
@@ -35,12 +35,12 @@ def upload_bank_statement():
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_bank_entries(columns: str, data: str, bank_account: str):
|
||||
def create_bank_entries(columns: str, data: str | list, bank_account: str):
|
||||
header_map = get_header_mapping(columns, bank_account)
|
||||
|
||||
success = 0
|
||||
errors = 0
|
||||
for d in json.loads(data):
|
||||
for d in frappe.parse_json(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 json.loads(columns):
|
||||
for column in frappe.parse_json(columns):
|
||||
if column["content"] in mapping:
|
||||
header_map.update({mapping[column["content"]]: column["colIndex"]})
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ class BankTransactionRule(Document):
|
||||
frappe.throw(_("Party type is required to create a payment entry."))
|
||||
|
||||
if not self.party:
|
||||
frappe.throw(_("Party is required create a payment entry."))
|
||||
frappe.throw(_("Party is required to create a payment entry."))
|
||||
|
||||
if not self.account:
|
||||
frappe.throw(_("Party account is required to create a payment entry."))
|
||||
|
||||
@@ -162,9 +162,9 @@ class Budget(Document):
|
||||
frappe.throw(_("Account {0} does not belong to company {1}").format(self.account, self.company))
|
||||
elif account_details.report_type != "Profit and Loss":
|
||||
frappe.throw(
|
||||
_("Budget cannot be assigned against {0}, as it's not an Income or Expense account").format(
|
||||
self.account
|
||||
)
|
||||
_(
|
||||
"Budget cannot be assigned against {0}, as its Root Type is not of Income or Expense"
|
||||
).format(self.account)
|
||||
)
|
||||
|
||||
def set_null_value(self):
|
||||
|
||||
@@ -63,8 +63,8 @@ def validate_company(company: str):
|
||||
)
|
||||
|
||||
if parent_company and (not allow_account_creation_against_child_company):
|
||||
msg = _("{} is a child company.").format(frappe.bold(company)) + " "
|
||||
msg += _("Please import accounts against parent company or enable {} in company master.").format(
|
||||
msg = _("{0} is a child company.").format(frappe.bold(company)) + " "
|
||||
msg += _("Please import accounts against parent company or enable {0} in company master.").format(
|
||||
frappe.bold(_("Allow Account Creation Against Child Company"))
|
||||
)
|
||||
frappe.throw(msg, title=_("Wrong Company"))
|
||||
|
||||
@@ -90,7 +90,7 @@ class CurrencyExchangeSettings(Document):
|
||||
try:
|
||||
response = requests.get(api_url, params=params)
|
||||
except requests.exceptions.RequestException as e:
|
||||
frappe.throw("Error: " + str(e))
|
||||
frappe.throw(_("Error: {0}").format(str(e)))
|
||||
|
||||
response.raise_for_status()
|
||||
value = response.json()
|
||||
|
||||
@@ -85,7 +85,7 @@ class Dunning(AccountsController):
|
||||
if invoice_currency != self.currency:
|
||||
frappe.throw(
|
||||
_(
|
||||
"The currency of invoice {} ({}) is different from the currency of this dunning ({})."
|
||||
"The currency of invoice {0} ({1}) is different from the currency of this dunning ({2})."
|
||||
).format(
|
||||
frappe.get_desk_link(
|
||||
"Sales Invoice",
|
||||
@@ -248,8 +248,7 @@ def get_dunning_letter_text(dunning_type: str, doc: str | dict, language: str |
|
||||
DOCTYPE = "Dunning Letter Text"
|
||||
FIELDS = ["body_text", "closing_text", "language"]
|
||||
|
||||
if isinstance(doc, str):
|
||||
doc = json.loads(doc)
|
||||
doc = frappe.parse_json(doc)
|
||||
|
||||
if not language:
|
||||
language = doc.get("language")
|
||||
|
||||
@@ -136,7 +136,7 @@ frappe.ui.form.on("Exchange Rate Revaluation Account", {
|
||||
var get_account_details = function (frm, cdt, cdn) {
|
||||
var row = frappe.get_doc(cdt, cdn);
|
||||
if (!frm.doc.company || !frm.doc.posting_date) {
|
||||
frappe.throw(__("Please select Company and Posting Date to getting entries"));
|
||||
frappe.throw(__("Please select Company and Posting Date to get entries"));
|
||||
}
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.exchange_rate_revaluation.exchange_rate_revaluation.get_account_details",
|
||||
|
||||
@@ -73,7 +73,7 @@ class ExchangeRateRevaluation(Document):
|
||||
|
||||
def validate_mandatory(self):
|
||||
if not (self.company and self.posting_date):
|
||||
frappe.throw(_("Please select Company and Posting Date to getting entries"))
|
||||
frappe.throw(_("Please select Company and Posting Date to get entries"))
|
||||
|
||||
def before_submit(self):
|
||||
self.remove_accounts_without_gain_loss()
|
||||
@@ -350,12 +350,14 @@ class ExchangeRateRevaluation(Document):
|
||||
zero_balance_jv = self.make_jv_for_zero_balance()
|
||||
if zero_balance_jv:
|
||||
frappe.msgprint(
|
||||
f"Zero Balance Journal: {get_link_to_form('Journal Entry', zero_balance_jv.name)}"
|
||||
_("Zero Balance Journal: {0}").format(get_link_to_form("Journal Entry", zero_balance_jv.name))
|
||||
)
|
||||
|
||||
revaluation_jv = self.make_jv_for_revaluation()
|
||||
if revaluation_jv:
|
||||
frappe.msgprint(f"Revaluation Journal: {get_link_to_form('Journal Entry', revaluation_jv.name)}")
|
||||
frappe.msgprint(
|
||||
_("Revaluation Journal: {0}").format(get_link_to_form("Journal Entry", revaluation_jv.name))
|
||||
)
|
||||
|
||||
return {
|
||||
"revaluation_jv": revaluation_jv.name if revaluation_jv else None,
|
||||
|
||||
@@ -12,8 +12,9 @@ from typing import Any, Union
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.database.operator_map import OPERATOR_MAP
|
||||
from frappe.model import numeric_fieldtypes
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.query_builder.functions import Cast_, Sum
|
||||
from frappe.utils import cstr, date_diff, flt, getdate
|
||||
from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder
|
||||
from pypika.terms import Bracket, LiteralValue
|
||||
@@ -864,8 +865,15 @@ class FilterExpressionParser:
|
||||
field = getattr(table, field_name, None)
|
||||
operator_fn = OPERATOR_MAP.get(operator.casefold())
|
||||
|
||||
if "like" in operator.casefold() and "%" not in value:
|
||||
value = f"%{value}%"
|
||||
if "like" in operator.casefold():
|
||||
if "%" not in value:
|
||||
value = f"%{value}%"
|
||||
# Postgres has no LIKE/ILIKE operator for non-text columns; MariaDB implicitly casts
|
||||
# the numeric column to text. Cast a numeric/Check Account field to varchar so the
|
||||
# match runs on both engines and reproduces MariaDB's result.
|
||||
meta_field = frappe.get_meta("Account").get_field(field_name)
|
||||
if meta_field and meta_field.fieldtype in numeric_fieldtypes:
|
||||
field = Cast_(field, "varchar")
|
||||
|
||||
return operator_fn(field, value)
|
||||
|
||||
@@ -1024,8 +1032,7 @@ class FormulaFieldUpdater:
|
||||
def get_filtered_accounts(company: str, account_rows: str | list):
|
||||
frappe.has_permission("Financial Report Template", ptype="read", throw=True)
|
||||
|
||||
if isinstance(account_rows, str):
|
||||
account_rows = json.loads(account_rows, object_hook=frappe._dict)
|
||||
account_rows = [frappe._dict(row) for row in frappe.parse_json(account_rows)]
|
||||
|
||||
return DataCollector.get_filtered_accounts(company, account_rows)
|
||||
|
||||
|
||||
@@ -317,8 +317,8 @@ class InvoiceDiscounting(AccountsController):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_invoices(filters: str):
|
||||
filters = frappe._dict(json.loads(filters))
|
||||
def get_invoices(filters: str | dict):
|
||||
filters = frappe._dict(frappe.parse_json(filters))
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
di = frappe.qb.DocType("Discounted Invoice")
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ frappe.ui.form.on("Loyalty Program", {
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${__("One customer can be part of only single Loyalty Program.")}
|
||||
${__("One customer can be part of only a single Loyalty Program.")}
|
||||
</li>
|
||||
</ul>
|
||||
</td></tr>
|
||||
@@ -62,7 +62,7 @@ frappe.ui.form.on("Loyalty Program", {
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.loyalty_program_type === "Single Tier Program" && frm.doc.collection_rules.length > 1) {
|
||||
frappe.throw(
|
||||
__("Please select the Multiple Tier Program type for more than one collection rules.")
|
||||
__("Please select the Multiple Tier Program type for more than one collection rule.")
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -60,6 +60,6 @@ class ModeofPayment(Document):
|
||||
|
||||
if pos_profiles:
|
||||
message = _(
|
||||
"POS Profile {} contains Mode of Payment {}. Please remove them to disable this mode."
|
||||
"POS Profile {0} contains Mode of Payment {1}. Please remove them to disable this mode."
|
||||
).format(frappe.bold(", ".join(pos_profiles)), frappe.bold(str(self.name)))
|
||||
frappe.throw(message, title=_("Not Allowed"))
|
||||
|
||||
@@ -74,29 +74,31 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
|
||||
},
|
||||
|
||||
setup_company_filters: function (frm) {
|
||||
frm.set_query("cost_center", "invoices", function (doc, cdt, cdn) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
frm.events.apply_company_query_filter(frm, "cost_center", "invoices", { is_group: 0 });
|
||||
frm.events.apply_company_query_filter(frm, "project", "invoices");
|
||||
frm.events.apply_company_query_filter(frm, "project");
|
||||
frm.events.apply_company_query_filter(frm, "cost_center", undefined, { is_group: 0 });
|
||||
frm.events.apply_company_query_filter(frm, "temporary_opening_account", "invoices", {
|
||||
account_type: "Temporary",
|
||||
is_group: 0,
|
||||
});
|
||||
},
|
||||
|
||||
frm.set_query("cost_center", function (doc) {
|
||||
apply_company_query_filter: function (frm, field_name, child_doctype = null, filters = {}) {
|
||||
const query = function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
...filters,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
frm.set_query("temporary_opening_account", "invoices", function (doc, cdt, cdn) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (child_doctype) {
|
||||
frm.set_query(field_name, child_doctype, query);
|
||||
} else {
|
||||
frm.set_query(field_name, query);
|
||||
}
|
||||
},
|
||||
|
||||
company: function (frm) {
|
||||
@@ -120,11 +122,6 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
|
||||
},
|
||||
|
||||
invoice_type: function (frm) {
|
||||
$.each(frm.doc.invoices, (idx, row) => {
|
||||
row.party_type = frm.doc.invoice_type == "Sales" ? "Customer" : "Supplier";
|
||||
frappe.model.set_value(row.doctype, row.name, "party", "");
|
||||
frappe.model.set_value(row.doctype, row.name, "party_name", "");
|
||||
});
|
||||
frm.clear_table("invoices");
|
||||
frm.refresh_fields();
|
||||
frm.trigger("update_party_labels");
|
||||
@@ -219,7 +216,19 @@ frappe.ui.form.on("Opening Invoice Creation Tool Item", {
|
||||
});
|
||||
},
|
||||
|
||||
invoices_add: (frm) => {
|
||||
invoices_add: (frm, cdt, cdn) => {
|
||||
const row = frappe.get_doc(cdt, cdn);
|
||||
const field_copy = [];
|
||||
|
||||
["project", "cost_center"].forEach((fieldname) => {
|
||||
if (frm.doc[fieldname]) {
|
||||
frappe.model.set_value(cdt, cdn, fieldname, frm.doc[fieldname]);
|
||||
} else {
|
||||
field_copy.push(fieldname);
|
||||
}
|
||||
});
|
||||
|
||||
frm.script_manager.copy_from_first_row("invoices", row, field_copy);
|
||||
frm.trigger("update_invoice_table");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -110,7 +110,7 @@ class OpeningInvoiceCreationTool(Document):
|
||||
def validate_mandatory_invoice_fields(self, row):
|
||||
if self.create_missing_party:
|
||||
if not row.party and not row.party_name:
|
||||
frappe.throw(_("Row #{}: Either Party ID or Party Name is required").format(row.idx))
|
||||
frappe.throw(_("Row #{0}: Either Party ID or Party Name is required").format(row.idx))
|
||||
|
||||
if not row.party and row.party_name:
|
||||
row.party = self.add_party(row.party_type, row.party_name)
|
||||
@@ -120,10 +120,10 @@ class OpeningInvoiceCreationTool(Document):
|
||||
|
||||
else:
|
||||
if not row.party:
|
||||
frappe.throw(_("Row #{}: Party ID is required").format(row.idx))
|
||||
frappe.throw(_("Row #{0}: Party ID is required").format(row.idx))
|
||||
if not frappe.db.exists(row.party_type, row.party):
|
||||
frappe.throw(
|
||||
_("Row #{}: {} {} does not exist.").format(
|
||||
_("Row #{0}: {1} {2} does not exist.").format(
|
||||
row.idx, frappe.bold(row.party_type), frappe.bold(row.party)
|
||||
)
|
||||
)
|
||||
@@ -133,6 +133,17 @@ class OpeningInvoiceCreationTool(Document):
|
||||
if not row.get(scrub(d)):
|
||||
frappe.throw(mandatory_error_msg.format(row.idx, d, self.invoice_type))
|
||||
|
||||
self.validate_temporary_opening_account(row)
|
||||
|
||||
def validate_temporary_opening_account(self, row):
|
||||
account_type = frappe.get_cached_value("Account", row.temporary_opening_account, "account_type")
|
||||
if account_type != "Temporary":
|
||||
frappe.throw(
|
||||
_("Row #{0}: {1} account is not of type {2}").format(
|
||||
row.idx, row.temporary_opening_account, "Temporary"
|
||||
)
|
||||
)
|
||||
|
||||
def get_invoices(self):
|
||||
invoices = []
|
||||
for row in self.invoices:
|
||||
@@ -203,6 +214,7 @@ class OpeningInvoiceCreationTool(Document):
|
||||
"description": row.item_name or "Opening Invoice Item",
|
||||
income_expense_account_field: row.temporary_opening_account,
|
||||
"cost_center": cost_center,
|
||||
"project": row.get("project") or self.get("project"),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -295,7 +307,7 @@ def start_import(invoices):
|
||||
doc.log_error("Opening invoice creation failed")
|
||||
if errors:
|
||||
frappe.msgprint(
|
||||
_("You had {} errors while creating opening invoices. Check {} for more details").format(
|
||||
_("You had {0} errors while creating opening invoices. Check {1} for more details").format(
|
||||
errors, "<a href='/app/List/Error Log' class='variant-click'>Error Log</a>"
|
||||
),
|
||||
indicator="red",
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
|
||||
get_temporary_opening_account,
|
||||
)
|
||||
from erpnext.projects.doctype.project.test_project import make_project
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -14,21 +16,26 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
|
||||
self,
|
||||
invoice_type="Sales",
|
||||
company=None,
|
||||
party_1=None,
|
||||
party_2=None,
|
||||
invoice_number=None,
|
||||
invoices=None,
|
||||
project=None,
|
||||
cost_center=None,
|
||||
department=None,
|
||||
return_doc=False,
|
||||
):
|
||||
doc = frappe.get_single("Opening Invoice Creation Tool")
|
||||
args = get_opening_invoice_creation_dict(
|
||||
invoice_type=invoice_type,
|
||||
company=company,
|
||||
party_1=party_1,
|
||||
party_2=party_2,
|
||||
invoice_number=invoice_number,
|
||||
invoices=invoices,
|
||||
project=project,
|
||||
cost_center=cost_center,
|
||||
department=department,
|
||||
)
|
||||
doc.update(args)
|
||||
|
||||
if return_doc:
|
||||
return doc
|
||||
|
||||
return doc.make_invoices()
|
||||
|
||||
def test_opening_sales_invoice_creation(self):
|
||||
@@ -37,8 +44,8 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
|
||||
self.assertEqual(len(invoices), 2)
|
||||
expected_value = {
|
||||
"keys": ["customer", "outstanding_amount", "status"],
|
||||
0: ["_Test Customer", 300, "Overdue"],
|
||||
1: ["_Test Customer 1", 250, "Overdue"],
|
||||
0: ["_Test Customer", 200, "Overdue"],
|
||||
1: ["_Test Customer 1", 200, "Overdue"],
|
||||
}
|
||||
self.check_expected_values(invoices, expected_value)
|
||||
|
||||
@@ -55,48 +62,34 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
|
||||
for field_idx, field in enumerate(expected_value["keys"]):
|
||||
self.assertEqual(si.get(field, ""), expected_value[invoice_idx][field_idx])
|
||||
|
||||
def test_opening_invoice_requires_temporary_account_type(self):
|
||||
doc = self.make_invoices(company="_Test Opening Invoice Company", return_doc=True)
|
||||
doc.invoices[0].temporary_opening_account = "Sales - _TOIC"
|
||||
self.assertRaises(frappe.ValidationError, doc.make_invoices)
|
||||
|
||||
def test_opening_purchase_invoice_creation(self):
|
||||
invoices = self.make_invoices(invoice_type="Purchase", company="_Test Opening Invoice Company")
|
||||
|
||||
self.assertEqual(len(invoices), 2)
|
||||
expected_value = {
|
||||
"keys": ["supplier", "outstanding_amount", "status"],
|
||||
0: ["_Test Supplier", 300, "Overdue"],
|
||||
1: ["_Test Supplier 1", 250, "Overdue"],
|
||||
0: ["_Test Supplier", 200, "Overdue"],
|
||||
1: ["_Test Supplier 1", 200, "Overdue"],
|
||||
}
|
||||
self.check_expected_values(invoices, expected_value, "Purchase")
|
||||
|
||||
def test_opening_sales_invoice_creation_with_missing_debit_account(self):
|
||||
company = "_Test Opening Invoice Company"
|
||||
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
|
||||
|
||||
old_default_receivable_account = frappe.db.get_value("Company", company, "default_receivable_account")
|
||||
frappe.db.set_value("Company", company, "default_receivable_account", "")
|
||||
old_default_receivable_account = frappe.db.get_value(
|
||||
"Company", "_Test Opening Invoice Company", "default_receivable_account"
|
||||
)
|
||||
frappe.db.set_value("Company", "_Test Opening Invoice Company", "default_receivable_account", "")
|
||||
|
||||
if not frappe.db.exists("Cost Center", "_Test Opening Invoice Company - _TOIC"):
|
||||
cc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Cost Center",
|
||||
"cost_center_name": "_Test Opening Invoice Company",
|
||||
"is_group": 1,
|
||||
"company": "_Test Opening Invoice Company",
|
||||
}
|
||||
)
|
||||
cc.insert(ignore_mandatory=True)
|
||||
cc2 = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Cost Center",
|
||||
"cost_center_name": "Main",
|
||||
"is_group": 0,
|
||||
"company": "_Test Opening Invoice Company",
|
||||
"parent_cost_center": cc.name,
|
||||
}
|
||||
)
|
||||
cc2.insert()
|
||||
|
||||
frappe.db.set_value("Company", company, "cost_center", "Main - _TOIC")
|
||||
|
||||
self.make_invoices(company="_Test Opening Invoice Company", party_1=party_1, party_2=party_2)
|
||||
self.make_invoices(
|
||||
company="_Test Opening Invoice Company",
|
||||
invoices=[{"party": party_1}, {"party": party_2}],
|
||||
)
|
||||
|
||||
# Check if missing debit account error raised
|
||||
error_log = frappe.db.exists(
|
||||
@@ -106,71 +99,107 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
|
||||
self.assertTrue(error_log)
|
||||
|
||||
# teardown
|
||||
frappe.db.set_value("Company", company, "default_receivable_account", old_default_receivable_account)
|
||||
|
||||
def test_renaming_of_invoice_using_invoice_number_field(self):
|
||||
company = "_Test Opening Invoice Company"
|
||||
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
|
||||
self.make_invoices(
|
||||
company=company, party_1=party_1, party_2=party_2, invoice_number="TEST-NEW-INV-11"
|
||||
frappe.db.set_value(
|
||||
"Company",
|
||||
"_Test Opening Invoice Company",
|
||||
"default_receivable_account",
|
||||
old_default_receivable_account,
|
||||
)
|
||||
|
||||
sales_inv1 = frappe.get_all("Sales Invoice", filters={"customer": "Customer A"})[0].get("name")
|
||||
sales_inv2 = frappe.get_all("Sales Invoice", filters={"customer": "Customer B"})[0].get("name")
|
||||
self.assertEqual(sales_inv1, "TEST-NEW-INV-11")
|
||||
def test_renaming_of_invoice_using_invoice_number_field(self):
|
||||
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
|
||||
invoices = self.make_invoices(
|
||||
company="_Test Opening Invoice Company",
|
||||
invoices=[
|
||||
{"party": party_1, "invoice_number": "TEST-NEW-INV-11"},
|
||||
{"party": party_2},
|
||||
],
|
||||
)
|
||||
|
||||
# teardown
|
||||
for inv in [sales_inv1, sales_inv2]:
|
||||
doc = frappe.get_doc("Sales Invoice", inv)
|
||||
doc.cancel()
|
||||
self.assertEqual(invoices[0], "TEST-NEW-INV-11")
|
||||
|
||||
def test_opening_invoice_with_accounting_dimension(self):
|
||||
invoices = self.make_invoices(
|
||||
invoice_type="Sales", company="_Test Opening Invoice Company", department="Sales - _TOIC"
|
||||
)
|
||||
|
||||
expected_value = {
|
||||
"keys": ["customer", "outstanding_amount", "status", "department"],
|
||||
0: ["_Test Customer", 300, "Overdue", "Sales - _TOIC"],
|
||||
1: ["_Test Customer 1", 250, "Overdue", "Sales - _TOIC"],
|
||||
}
|
||||
self.check_expected_values(invoices, expected_value, invoice_type="Sales")
|
||||
for invoice in invoices:
|
||||
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
|
||||
|
||||
def test_opening_entry_project_linking(self):
|
||||
doc = self.make_invoices(
|
||||
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True
|
||||
)
|
||||
project_1 = make_project(
|
||||
{"project_name": "Test Opening Invoice projecty 01", "company": "_Test Opening Invoice Company"}
|
||||
)
|
||||
project_2 = make_project(
|
||||
{"project_name": "Test Opening Invoice projecty 02", "company": "_Test Opening Invoice Company"}
|
||||
)
|
||||
doc.invoices[0].project = project_1.name
|
||||
doc.invoices[1].project = project_2.name
|
||||
invoices = doc.make_invoices()
|
||||
sales_invoice_1 = frappe.get_doc("Sales Invoice", invoices[0])
|
||||
sales_invoice_2 = frappe.get_doc("Sales Invoice", invoices[1])
|
||||
|
||||
self.assertEqual(sales_invoice_1.items[0].project, project_1.name)
|
||||
self.assertEqual(sales_invoice_2.items[0].project, project_2.name)
|
||||
|
||||
|
||||
def get_opening_invoice_creation_dict(**args):
|
||||
party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier"
|
||||
company = args.get("company", "_Test Company")
|
||||
default_invoices = []
|
||||
default_invoice_rows = [
|
||||
{
|
||||
"qty": 1.0,
|
||||
"outstanding_amount": 200,
|
||||
"party": f"_Test {party}",
|
||||
"item_name": "Opening Item",
|
||||
"due_date": add_days(today(), -10),
|
||||
"posting_date": add_days(today(), -15),
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
},
|
||||
{
|
||||
"qty": 1.0,
|
||||
"outstanding_amount": 200,
|
||||
"party": f"_Test {party} 1",
|
||||
"item_name": "Opening Item",
|
||||
"due_date": add_days(today(), -10),
|
||||
"posting_date": add_days(today(), -15),
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
},
|
||||
]
|
||||
|
||||
for row in args.get("invoices") or default_invoice_rows:
|
||||
default_invoices.append(
|
||||
{
|
||||
"qty": row.get("qty") or 1.0,
|
||||
"outstanding_amount": row.get("outstanding_amount") or 200,
|
||||
"party": row.get("party") or f"_Test {party}",
|
||||
"item_name": row.get("item_name") or "Opening Item",
|
||||
"due_date": row.get("due_date") or add_days(today(), -10),
|
||||
"posting_date": row.get("posting_date") or add_days(today(), -15),
|
||||
"temporary_opening_account": row.get("temporary_opening_account")
|
||||
or get_temporary_opening_account(company),
|
||||
"invoice_number": row.get("invoice_number"),
|
||||
"project": row.get("project"),
|
||||
"cost_center": row.get("cost_center"),
|
||||
}
|
||||
)
|
||||
|
||||
invoice_dict = frappe._dict(
|
||||
{
|
||||
"company": company,
|
||||
"invoice_type": args.get("invoice_type", "Sales"),
|
||||
"invoices": [
|
||||
{
|
||||
"qty": 1.0,
|
||||
"outstanding_amount": 300,
|
||||
"party": args.get("party_1") or f"_Test {party}",
|
||||
"item_name": "Opening Item",
|
||||
"due_date": "2016-09-10",
|
||||
"posting_date": "2016-09-05",
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
"invoice_number": args.get("invoice_number"),
|
||||
},
|
||||
{
|
||||
"qty": 2.0,
|
||||
"outstanding_amount": 250,
|
||||
"party": args.get("party_2") or f"_Test {party} 1",
|
||||
"item_name": "Opening Item",
|
||||
"due_date": "2016-09-10",
|
||||
"posting_date": "2016-09-05",
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
"invoice_number": None,
|
||||
},
|
||||
],
|
||||
"project": args.get("project"),
|
||||
"cost_center": args.get("cost_center"),
|
||||
"invoices": default_invoices,
|
||||
}
|
||||
)
|
||||
|
||||
invoice_dict.update(args)
|
||||
invoice_dict.invoices = default_invoices
|
||||
return invoice_dict
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"qty",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
"dimension_col_break"
|
||||
"dimension_col_break",
|
||||
"project"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -125,11 +126,17 @@
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Party Name"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
}
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-20 02:11:42.023575",
|
||||
"modified": "2026-04-29 17:08:15.617047",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Opening Invoice Creation Tool Item",
|
||||
|
||||
@@ -26,6 +26,7 @@ class OpeningInvoiceCreationToolItem(Document):
|
||||
party_name: DF.Data | None
|
||||
party_type: DF.Link | None
|
||||
posting_date: DF.Date | None
|
||||
project: DF.Link | None
|
||||
qty: DF.Data | None
|
||||
supplier_invoice_date: DF.Date | None
|
||||
temporary_opening_account: DF.Link | None
|
||||
|
||||
@@ -37,7 +37,7 @@ class PartyLink(Document):
|
||||
)
|
||||
if existing_party_link:
|
||||
frappe.throw(
|
||||
_("{} {} is already linked with {} {}").format(
|
||||
_("{0} {1} is already linked with {2} {3}").format(
|
||||
self.primary_role,
|
||||
bold(self.primary_party),
|
||||
self.secondary_role,
|
||||
@@ -50,7 +50,7 @@ class PartyLink(Document):
|
||||
)
|
||||
if existing_party_link:
|
||||
frappe.throw(
|
||||
_("{} {} is already linked with another {}").format(
|
||||
_("{0} {1} is already linked with another {2}").format(
|
||||
self.secondary_role, self.secondary_party, existing_party_link[0]
|
||||
)
|
||||
)
|
||||
@@ -60,7 +60,7 @@ class PartyLink(Document):
|
||||
)
|
||||
if existing_party_link:
|
||||
frappe.throw(
|
||||
_("{} {} is already linked with another {}").format(
|
||||
_("{0} {1} is already linked with another {2}").format(
|
||||
self.primary_role, self.primary_party, existing_party_link[0]
|
||||
)
|
||||
)
|
||||
|
||||
@@ -754,17 +754,21 @@ frappe.ui.form.on("Payment Entry", {
|
||||
frm.set_paid_amount_based_on_received_amount = true;
|
||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||
|
||||
if (frm.doc.base_received_amount && frm.doc.source_exchange_rate) {
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
if (frm.doc.paid_amount && frm.doc.source_exchange_rate) {
|
||||
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
|
||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
|
||||
// target exchange rate should always be same as source if both account currencies is same
|
||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate);
|
||||
frm.set_value("received_amount", frm.doc.paid_amount);
|
||||
} else {
|
||||
frm.set_value(
|
||||
"paid_amount",
|
||||
flt(frm.doc.base_paid_amount) / flt(frm.doc.source_exchange_rate)
|
||||
);
|
||||
const target_rate =
|
||||
flt(frm.doc.target_exchange_rate) ||
|
||||
(company_currency == frm.doc.paid_to_account_currency ? 1 : 0);
|
||||
if (target_rate) {
|
||||
frm.set_value("received_amount", flt(frm.doc.base_received_amount) / target_rate);
|
||||
}
|
||||
}
|
||||
|
||||
// set_unallocated_amount is called by below method,
|
||||
@@ -780,18 +784,23 @@ frappe.ui.form.on("Payment Entry", {
|
||||
target_exchange_rate: function (frm) {
|
||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||
|
||||
if (frm.doc.base_paid_amount && frm.doc.target_exchange_rate) {
|
||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
if (
|
||||
!frm.doc.source_exchange_rate &&
|
||||
frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency
|
||||
) {
|
||||
if (frm.doc.received_amount && frm.doc.target_exchange_rate) {
|
||||
frm.set_value(
|
||||
"base_received_amount",
|
||||
flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate)
|
||||
);
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
|
||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
|
||||
frm.set_value("paid_amount", frm.doc.received_amount);
|
||||
} else {
|
||||
frm.set_value(
|
||||
"received_amount",
|
||||
flt(frm.doc.base_received_amount) / flt(frm.doc.target_exchange_rate)
|
||||
);
|
||||
const source_rate =
|
||||
flt(frm.doc.source_exchange_rate) ||
|
||||
(company_currency == frm.doc.paid_from_account_currency ? 1 : 0);
|
||||
if (source_rate) {
|
||||
frm.set_value("paid_amount", flt(frm.doc.base_paid_amount) / source_rate);
|
||||
}
|
||||
}
|
||||
|
||||
// set_unallocated_amount is called by below method,
|
||||
@@ -968,7 +977,7 @@ frappe.ui.form.on("Payment Entry", {
|
||||
let to_field = fields[key][1];
|
||||
|
||||
if (filters[from_field] && !filters[to_field]) {
|
||||
frappe.throw(__("Error: {0} is mandatory field", [to_field.replace(/_/g, " ")]));
|
||||
frappe.throw(__("Error: {0} is a mandatory field", [to_field.replace(/_/g, " ")]));
|
||||
} else if (filters[from_field] && filters[from_field] > filters[to_field]) {
|
||||
frappe.throw(
|
||||
__("{0}: {1} must be less than {2}", [
|
||||
|
||||
@@ -621,7 +621,7 @@ class PaymentEntry(AccountsController):
|
||||
|
||||
def validate_payment_type(self):
|
||||
if self.payment_type not in ("Receive", "Pay", "Internal Transfer"):
|
||||
frappe.throw(_("Payment Type must be one of Receive, Pay and Internal Transfer"))
|
||||
frappe.throw(_("Payment Type must be one of Receive, Pay, or Internal Transfer"))
|
||||
|
||||
def validate_party_details(self):
|
||||
if self.party and not frappe.db.exists(self.party_type, self.party):
|
||||
@@ -678,7 +678,7 @@ class PaymentEntry(AccountsController):
|
||||
|
||||
elif d.reference_name:
|
||||
if not frappe.db.exists(d.reference_doctype, d.reference_name):
|
||||
frappe.throw(_("{0} {1} does not exist").format(d.reference_doctype, d.reference_name))
|
||||
frappe.throw(_("{0} {1} does not exist").format(_(d.reference_doctype), d.reference_name))
|
||||
|
||||
ref_doc = frappe.get_lazy_doc(d.reference_doctype, d.reference_name)
|
||||
|
||||
@@ -1805,8 +1805,7 @@ class PaymentEntry(AccountsController):
|
||||
if not self.references or not matched_payment_requests:
|
||||
return
|
||||
|
||||
if isinstance(matched_payment_requests, str):
|
||||
matched_payment_requests = json.loads(matched_payment_requests)
|
||||
matched_payment_requests = frappe.parse_json(matched_payment_requests)
|
||||
|
||||
# modify matched_payment_requests
|
||||
# like (reference_doctype, reference_name, allocated_amount): payment_request
|
||||
@@ -2011,8 +2010,7 @@ def validate_inclusive_tax(tax, doc):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_outstanding_reference_documents(args: str | dict, validate: bool = False):
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
args = frappe.parse_json(args)
|
||||
|
||||
if args.get("party_type") == "Member":
|
||||
return
|
||||
@@ -2685,7 +2683,7 @@ def get_payment_entry(
|
||||
|
||||
# only Purchase Invoice can be blocked individually
|
||||
if doc.doctype == "Purchase Invoice" and doc.invoice_is_blocked():
|
||||
frappe.msgprint(_("{0} is on hold till {1}").format(doc.name, doc.release_date))
|
||||
frappe.msgprint(_("{0} is on hold until {1}").format(doc.name, doc.release_date))
|
||||
else:
|
||||
if doc.doctype in (
|
||||
"Sales Invoice",
|
||||
@@ -3089,7 +3087,7 @@ def apply_early_payment_discount(paid_amount, received_amount, doc, party_accoun
|
||||
if total_discount:
|
||||
currency = doc.get("currency") if is_multi_currency else doc.company_currency
|
||||
money = frappe.utils.fmt_money(total_discount, currency=currency)
|
||||
frappe.msgprint(_("Discount of {} applied as per Payment Term").format(money), alert=1)
|
||||
frappe.msgprint(_("Discount of {0} applied as per Payment Term").format(money), alert=1)
|
||||
|
||||
return paid_amount, received_amount, total_discount, valid_discounts
|
||||
|
||||
|
||||
@@ -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 = json.loads(args.get("schedules")) if args.get("schedules") else []
|
||||
selected_payment_schedules = frappe.parse_json(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 = json.loads(payment_schedules) if payment_schedules else []
|
||||
payment_schedules = frappe.parse_json(payment_schedules) if payment_schedules else []
|
||||
payment_reference = []
|
||||
|
||||
for row in payment_schedules:
|
||||
|
||||
@@ -121,13 +121,13 @@ class POSClosingEntry(StatusUpdater):
|
||||
continue
|
||||
if pos_invoice.pos_profile != self.pos_profile:
|
||||
invalid_row.setdefault("msg", []).append(
|
||||
_("POS Profile doesn't match {}").format(frappe.bold(self.pos_profile))
|
||||
_("POS Profile doesn't match {0}").format(frappe.bold(self.pos_profile))
|
||||
)
|
||||
if pos_invoice.docstatus != 1:
|
||||
invalid_row.setdefault("msg", []).append(_("POS Invoice is not submitted"))
|
||||
if pos_invoice.owner != self.user:
|
||||
invalid_row.setdefault("msg", []).append(
|
||||
_("POS Invoice isn't created by user {}").format(frappe.bold(self.owner))
|
||||
_("POS Invoice isn't created by user {0}").format(frappe.bold(self.owner))
|
||||
)
|
||||
|
||||
if invalid_row.get("msg"):
|
||||
@@ -139,7 +139,7 @@ class POSClosingEntry(StatusUpdater):
|
||||
error_list = []
|
||||
for row in invalid_rows:
|
||||
for msg in row.get("msg"):
|
||||
error_list.append(_("Row #{}: {}").format(row.get("idx"), msg))
|
||||
error_list.append(_("Row #{0}: {1}").format(row.get("idx"), msg))
|
||||
|
||||
frappe.throw(error_list, title=_("Invalid POS Invoices"), as_list=True)
|
||||
|
||||
@@ -186,13 +186,13 @@ class POSClosingEntry(StatusUpdater):
|
||||
invalid_row.setdefault("msg", []).append(_("Sales Invoice is not created using POS"))
|
||||
if sales_invoice.pos_profile != self.pos_profile:
|
||||
invalid_row.setdefault("msg", []).append(
|
||||
_("POS Profile doesn't match {}").format(frappe.bold(self.pos_profile))
|
||||
_("POS Profile doesn't match {0}").format(frappe.bold(self.pos_profile))
|
||||
)
|
||||
if sales_invoice.docstatus != 1:
|
||||
invalid_row.setdefault("msg", []).append(_("Sales Invoice is not submitted"))
|
||||
if sales_invoice.owner != self.user:
|
||||
invalid_row.setdefault("msg", []).append(
|
||||
_("Sales Invoice isn't created by user {}").format(frappe.bold(self.owner))
|
||||
_("Sales Invoice isn't created by user {0}").format(frappe.bold(self.owner))
|
||||
)
|
||||
|
||||
if invalid_row.get("msg"):
|
||||
@@ -204,7 +204,7 @@ class POSClosingEntry(StatusUpdater):
|
||||
error_list = []
|
||||
for row in invalid_rows:
|
||||
for msg in row.get("msg"):
|
||||
error_list.append(_("Row #{}: {}").format(row.get("idx"), msg))
|
||||
error_list.append(_("Row #{0}: {1}").format(row.get("idx"), msg))
|
||||
|
||||
frappe.throw(error_list, title=_("Invalid Sales Invoices"), as_list=True)
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ class POSInvoice(SalesInvoice):
|
||||
limit=1,
|
||||
)
|
||||
frappe.throw(
|
||||
_("You need to cancel POS Closing Entry {} to be able to cancel this document.").format(
|
||||
_("You need to cancel POS Closing Entry {0} to be able to cancel this document.").format(
|
||||
get_link_to_form("POS Closing Entry", pos_closing_entry[0])
|
||||
),
|
||||
title=_("Not Allowed"),
|
||||
@@ -498,7 +498,7 @@ class POSInvoice(SalesInvoice):
|
||||
if d.get("qty") > 0:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{}: You cannot add positive quantities in a return invoice. Please remove item {} to complete the return."
|
||||
"Row #{0}: You cannot add positive quantities in a return invoice. Please remove item {1} to complete the return."
|
||||
).format(d.idx, frappe.bold(d.item_code)),
|
||||
title=_("Invalid Item"),
|
||||
)
|
||||
@@ -526,7 +526,7 @@ class POSInvoice(SalesInvoice):
|
||||
bold_serial_no = frappe.bold(sr)
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{}: Serial No {} cannot be returned since it was not transacted in original invoice {}"
|
||||
"Row #{0}: Serial No {1} cannot be returned since it was not transacted in original invoice {2}"
|
||||
).format(d.idx, bold_serial_no, bold_return_against)
|
||||
)
|
||||
|
||||
@@ -541,7 +541,7 @@ class POSInvoice(SalesInvoice):
|
||||
and frappe.get_cached_value("Account", self.account_for_change_amount, "company") != self.company
|
||||
):
|
||||
frappe.throw(
|
||||
_("The selected change account {} doesn't belongs to Company {}.").format(
|
||||
_("The selected change account {0} does not belong to Company {1}.").format(
|
||||
self.account_for_change_amount, self.company
|
||||
)
|
||||
)
|
||||
@@ -571,12 +571,12 @@ class POSInvoice(SalesInvoice):
|
||||
invoice_total = self.rounded_total or self.grand_total
|
||||
total_amount_in_payments = flt(total_amount_in_payments, self.precision("grand_total"))
|
||||
if total_amount_in_payments and total_amount_in_payments < invoice_total:
|
||||
frappe.throw(_("Total payments amount can't be greater than {}").format(-invoice_total))
|
||||
frappe.throw(_("Total payments amount can't be greater than {0}").format(-invoice_total))
|
||||
|
||||
def validate_company_with_pos_company(self):
|
||||
if self.company != frappe.db.get_value("POS Profile", self.pos_profile, "company"):
|
||||
frappe.throw(
|
||||
_("Company {} does not match with POS Profile Company {}").format(
|
||||
_("Company {0} does not match with POS Profile Company {1}").format(
|
||||
self.company, frappe.db.get_value("POS Profile", self.pos_profile, "company")
|
||||
)
|
||||
)
|
||||
@@ -1036,8 +1036,7 @@ def make_sales_return(source_name: str, target_doc: Document | str | None = None
|
||||
def make_merge_log(invoices: str | list):
|
||||
import json
|
||||
|
||||
if isinstance(invoices, str):
|
||||
invoices = json.loads(invoices)
|
||||
invoices = frappe.parse_json(invoices)
|
||||
|
||||
if len(invoices) == 0:
|
||||
frappe.throw(_("At least one invoice has to be selected."))
|
||||
|
||||
@@ -70,7 +70,7 @@ class POSInvoiceMergeLog(Document):
|
||||
for d in self.pos_invoices:
|
||||
if d.customer != self.customer:
|
||||
frappe.throw(
|
||||
_("Row #{}: POS Invoice {} is not against customer {}").format(
|
||||
_("Row #{0}: POS Invoice {1} is not against customer {2}").format(
|
||||
d.idx, d.pos_invoice, self.customer
|
||||
)
|
||||
)
|
||||
@@ -85,11 +85,11 @@ class POSInvoiceMergeLog(Document):
|
||||
bold_status = frappe.bold(status)
|
||||
if docstatus != 1:
|
||||
frappe.throw(
|
||||
_("Row #{}: POS Invoice {} is not submitted yet").format(d.idx, bold_pos_invoice)
|
||||
_("Row #{0}: POS Invoice {1} is not submitted yet").format(d.idx, bold_pos_invoice)
|
||||
)
|
||||
if status == "Consolidated":
|
||||
frappe.throw(
|
||||
_("Row #{}: POS Invoice {} has been {}").format(d.idx, bold_pos_invoice, bold_status)
|
||||
_("Row #{0}: POS Invoice {1} has been {2}").format(d.idx, bold_pos_invoice, bold_status)
|
||||
)
|
||||
if (
|
||||
is_return
|
||||
@@ -101,14 +101,14 @@ class POSInvoiceMergeLog(Document):
|
||||
if return_against_status != "Consolidated":
|
||||
# if return entry is not getting merged in the current pos closing and if it is not consolidated
|
||||
msg = _(
|
||||
"Row #{}: The original Invoice {} of return invoice {} is not consolidated."
|
||||
"Row #{0}: The original Invoice {1} of return invoice {2} is not consolidated."
|
||||
).format(d.idx, bold_return_against, bold_pos_invoice)
|
||||
msg += " "
|
||||
msg += _(
|
||||
"The original invoice should be consolidated before or along with the return invoice."
|
||||
)
|
||||
msg += "<br><br>"
|
||||
msg += _("You can add the original invoice {} manually to proceed.").format(
|
||||
msg += _("You can add the original invoice {0} manually to proceed.").format(
|
||||
bold_return_against
|
||||
)
|
||||
frappe.throw(msg)
|
||||
@@ -330,7 +330,7 @@ class POSInvoiceMergeLog(Document):
|
||||
|
||||
if not dimension_value and (dimension.mandatory_for_pl or dimension.mandatory_for_bs):
|
||||
frappe.throw(
|
||||
_("Please set Accounting Dimension {} in {}").format(
|
||||
_("Please set Accounting Dimension {0} in {1}").format(
|
||||
frappe.bold(dimension.label),
|
||||
frappe.get_desk_link("POS Profile", invoice.pos_profile),
|
||||
)
|
||||
|
||||
@@ -44,22 +44,22 @@ class POSOpeningEntry(StatusUpdater):
|
||||
|
||||
def validate_pos_profile_and_cashier(self):
|
||||
if not frappe.db.exists("POS Profile", self.pos_profile):
|
||||
frappe.throw(_("POS Profile {} does not exist.").format(self.pos_profile))
|
||||
frappe.throw(_("POS Profile {0} does not exist.").format(self.pos_profile))
|
||||
|
||||
pos_profile_company, pos_profile_disabled = frappe.db.get_value(
|
||||
"POS Profile", self.pos_profile, ["company", "disabled"]
|
||||
)
|
||||
|
||||
if pos_profile_disabled:
|
||||
frappe.throw(_("POS Profile {} is disabled.").format(frappe.bold(self.pos_profile)))
|
||||
frappe.throw(_("POS Profile {0} is disabled.").format(frappe.bold(self.pos_profile)))
|
||||
|
||||
if self.company != pos_profile_company:
|
||||
frappe.throw(
|
||||
_("POS Profile {} does not belong to company {}").format(self.pos_profile, self.company)
|
||||
_("POS Profile {0} does not belong to company {1}").format(self.pos_profile, self.company)
|
||||
)
|
||||
|
||||
if not cint(frappe.db.get_value("User", self.user, "enabled")):
|
||||
frappe.throw(_("User {} is disabled. Please select valid user/cashier").format(self.user))
|
||||
frappe.throw(_("User {0} is disabled. Please select valid user/cashier").format(self.user))
|
||||
|
||||
def check_open_pos_exists(self):
|
||||
if frappe.db.exists("POS Opening Entry", {"pos_profile": self.pos_profile, "status": "Open"}):
|
||||
@@ -91,9 +91,9 @@ class POSOpeningEntry(StatusUpdater):
|
||||
|
||||
if invalid_modes:
|
||||
if invalid_modes == 1:
|
||||
msg = _("Please set default Cash or Bank account in Mode of Payment {}")
|
||||
msg = _("Please set default Cash or Bank account in Mode of Payment {0}")
|
||||
else:
|
||||
msg = _("Please set default Cash or Bank account in Mode of Payments {}")
|
||||
msg = _("Please set default Cash or Bank account in Mode of Payments {0}")
|
||||
frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account"))
|
||||
|
||||
def on_submit(self):
|
||||
|
||||
@@ -202,9 +202,9 @@ class POSProfile(Document):
|
||||
|
||||
if invalid_modes:
|
||||
if invalid_modes == 1:
|
||||
msg = _("Please set default Cash or Bank account in Mode of Payment {}")
|
||||
msg = _("Please set default Cash or Bank account in Mode of Payment {0}")
|
||||
else:
|
||||
msg = _("Please set default Cash or Bank account in Mode of Payments {}")
|
||||
msg = _("Please set default Cash or Bank account in Mode of Payments {0}")
|
||||
frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account"))
|
||||
|
||||
def on_update(self):
|
||||
|
||||
@@ -341,8 +341,7 @@ def apply_pricing_rule(args: str | dict, doc: str | dict | Document | None = Non
|
||||
}
|
||||
"""
|
||||
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
args = frappe.parse_json(args)
|
||||
|
||||
args = frappe._dict(args)
|
||||
|
||||
@@ -397,8 +396,7 @@ def get_pricing_rule_for_item(args, doc=None, for_validate=False):
|
||||
get_product_discount_rule,
|
||||
)
|
||||
|
||||
if isinstance(doc, str):
|
||||
doc = json.loads(doc)
|
||||
doc = frappe.parse_json(doc)
|
||||
|
||||
if doc:
|
||||
doc = frappe.get_doc(doc)
|
||||
@@ -628,9 +626,7 @@ def remove_pricing_rule_for_item(
|
||||
get_pricing_rule_items,
|
||||
)
|
||||
|
||||
if isinstance(item_details, str):
|
||||
item_details = json.loads(item_details)
|
||||
item_details = frappe._dict(item_details)
|
||||
item_details = frappe._dict(frappe.parse_json(item_details))
|
||||
|
||||
for d in get_applied_pricing_rules(pricing_rules):
|
||||
if not d or not frappe.db.exists("Pricing Rule", d):
|
||||
@@ -671,8 +667,7 @@ def remove_pricing_rule_for_item(
|
||||
|
||||
@frappe.whitelist()
|
||||
def remove_pricing_rules(item_list: str | list):
|
||||
if isinstance(item_list, str):
|
||||
item_list = json.loads(item_list)
|
||||
item_list = frappe.parse_json(item_list)
|
||||
|
||||
out = []
|
||||
for item in item_list:
|
||||
|
||||
@@ -152,7 +152,7 @@ def _get_pricing_rules(apply_on, args, values):
|
||||
and {child_doc}.parent = `tabPricing Rule`.name
|
||||
and `tabPricing Rule`.disable = 0 and
|
||||
`tabPricing Rule`.{transaction_type} = 1 {warehouse_cond} {conditions}
|
||||
order by `tabPricing Rule`.priority desc,
|
||||
order by coalesce(`tabPricing Rule`.priority, '') desc,
|
||||
`tabPricing Rule`.name desc""".format(
|
||||
child_doc=child_doc,
|
||||
apply_on_field=apply_on_field,
|
||||
@@ -343,7 +343,7 @@ def filter_pricing_rules(args, pricing_rules, doc=None):
|
||||
if len(pricing_rules) > 1 and not args.for_shopping_cart:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Multiple Price Rules exists with same criteria, please resolve conflict by assigning priority. Price Rules: {0}"
|
||||
"Multiple Price Rules exist with same criteria, please resolve conflict by assigning priority. Price Rules: {0}"
|
||||
).format("\n".join(d.name for d in pricing_rules)),
|
||||
MultiplePricingRuleConflict,
|
||||
)
|
||||
@@ -636,7 +636,7 @@ def remove_free_item(doc):
|
||||
def get_applied_pricing_rules(pricing_rules):
|
||||
if pricing_rules:
|
||||
if pricing_rules.startswith("["):
|
||||
return json.loads(pricing_rules)
|
||||
return frappe.parse_json(pricing_rules)
|
||||
else:
|
||||
return pricing_rules.split(",")
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ frappe.ui.form.on("Process Payment Reconciliation", {
|
||||
},
|
||||
}).then((r) => {
|
||||
if (!r.exc) {
|
||||
frappe.show_alert(__("Job Started"));
|
||||
frappe.show_alert(__("Job started"));
|
||||
frm.reload_doc();
|
||||
}
|
||||
});
|
||||
@@ -103,7 +103,7 @@ frappe.ui.form.on("Process Payment Reconciliation", {
|
||||
},
|
||||
}).then((r) => {
|
||||
if (!r.exc) {
|
||||
frappe.show_alert(__("Job Paused"));
|
||||
frappe.show_alert(__("Job paused"));
|
||||
frm.reload_doc();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -542,8 +542,7 @@ 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:
|
||||
if isinstance(for_filter, str):
|
||||
for_filter = json.loads(for_filter)
|
||||
for_filter = frappe.parse_json(for_filter)
|
||||
|
||||
running_doc = frappe.db.get_value(
|
||||
"Process Payment Reconciliation",
|
||||
|
||||
@@ -95,6 +95,8 @@ def start_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
if normal_balances := (
|
||||
qb.from_(ppcvd)
|
||||
@@ -121,7 +123,7 @@ def start_pcv_processing(docname: str):
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
timeout="3600",
|
||||
timeout=timeout,
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
@@ -247,6 +249,8 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
|
||||
|
||||
@frappe.whitelist()
|
||||
def schedule_next_date(docname: str):
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
if to_process := (
|
||||
qb.from_(ppcvd)
|
||||
@@ -272,7 +276,7 @@ def schedule_next_date(docname: str):
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
timeout="3600",
|
||||
timeout=timeout,
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
@@ -302,7 +306,7 @@ def schedule_next_date(docname: str):
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries",
|
||||
queue="long",
|
||||
timeout="3600",
|
||||
timeout=timeout,
|
||||
is_async=True,
|
||||
job_name=job_name,
|
||||
enqueue_after_commit=True,
|
||||
|
||||
@@ -17,9 +17,9 @@ frappe.ui.form.on("Process Statement Of Accounts", {
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r && r.message) {
|
||||
frappe.show_alert({ message: __("Emails Queued"), indicator: "blue" });
|
||||
frappe.show_alert({ message: __("Emails queued"), indicator: "blue" });
|
||||
} else {
|
||||
frappe.msgprint(__("No Records for these settings."));
|
||||
frappe.msgprint(__("No records for these settings."));
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -36,7 +36,7 @@ frappe.ui.form.on("Process Statement Of Accounts", {
|
||||
type: "GET",
|
||||
success: function (result) {
|
||||
if (jQuery.isEmptyObject(result)) {
|
||||
frappe.msgprint(__("No Records for these settings."));
|
||||
frappe.msgprint(__("No records for these settings."));
|
||||
} else {
|
||||
window.location = url;
|
||||
}
|
||||
@@ -161,13 +161,13 @@ frappe.ui.form.on("Process Statement Of Accounts", {
|
||||
}
|
||||
frm.refresh_field("customers");
|
||||
} else {
|
||||
frappe.throw(__("No Customers found with selected options."));
|
||||
frappe.throw(__("No customers found with selected options."));
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
frappe.throw("Enter " + frm.doc.customer_collection + " name.");
|
||||
frappe.throw(__("Enter {0} name.", [frm.doc.customer_collection]));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -156,17 +156,17 @@ class ProcessStatementOfAccounts(Document):
|
||||
)
|
||||
|
||||
if invalid_values:
|
||||
msg = _("<p>Following {0}s doesn't belong to Company {1} :</p>").format(
|
||||
msg = _("<p>Following {0}s do not belong to Company {1}:</p>").format(
|
||||
doctype, frappe.bold(self.company)
|
||||
)
|
||||
|
||||
msg += (
|
||||
"<ul>"
|
||||
+ "".join(_("<li>{}</li>").format(frappe.bold(row)) for row in invalid_values)
|
||||
+ "".join(_("<li>{0}</li>").format(frappe.bold(row)) for row in invalid_values)
|
||||
+ "</ul>"
|
||||
)
|
||||
|
||||
frappe.throw(_(msg))
|
||||
frappe.throw(msg)
|
||||
|
||||
|
||||
def get_report_pdf(doc, consolidated=True):
|
||||
|
||||
@@ -182,10 +182,9 @@ class PromotionalScheme(Document):
|
||||
frappe.delete_doc("Pricing Rule", doc)
|
||||
|
||||
frappe.msgprint(
|
||||
_("The following invalid Pricing Rules are deleted:")
|
||||
+ "<br><br><ul><li>"
|
||||
+ "</li><li>".join(invalid_pricing_rule)
|
||||
+ "</li></ul>"
|
||||
_("The following invalid Pricing Rules are deleted:{0}").format(
|
||||
"<br><br><ul><li>" + "</li><li>".join(invalid_pricing_rule) + "</li></ul>"
|
||||
)
|
||||
)
|
||||
|
||||
def get_invalid_pricing_rules(self):
|
||||
|
||||
@@ -50,8 +50,7 @@ def make_purchase_receipt(
|
||||
):
|
||||
if args is None:
|
||||
args = {}
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
args = frappe.parse_json(args)
|
||||
|
||||
def post_parent_process(source_parent, target_parent):
|
||||
remove_items_with_zero_qty(target_parent)
|
||||
|
||||
@@ -463,7 +463,7 @@ class PurchaseInvoice(BuyingController):
|
||||
):
|
||||
for d in self.get("items"):
|
||||
if not d.purchase_order:
|
||||
msg = _("Purchase Order Required for item {}").format(frappe.bold(d.item_code))
|
||||
msg = _("Purchase Order Required for item {0}").format(frappe.bold(d.item_code))
|
||||
msg += "<br><br>"
|
||||
msg += _(
|
||||
"To submit the invoice without purchase order please set {0} as {1} in {2}"
|
||||
@@ -485,7 +485,7 @@ class PurchaseInvoice(BuyingController):
|
||||
|
||||
for d in self.get("items"):
|
||||
if not d.purchase_receipt and d.item_code in stock_and_asset_items:
|
||||
msg = _("Purchase Receipt Required for item {}").format(frappe.bold(d.item_code))
|
||||
msg = _("Purchase Receipt Required for item {0}").format(frappe.bold(d.item_code))
|
||||
msg += "<br><br>"
|
||||
msg += _(
|
||||
"To submit the invoice without purchase receipt please set {0} as {1} in {2}"
|
||||
|
||||
@@ -148,7 +148,7 @@ class ExpenseAccountService:
|
||||
if not account:
|
||||
form_link = get_link_to_form("Asset Category", item.asset_category)
|
||||
throw(
|
||||
_("Please set Fixed Asset Account in {} against {}.").format(
|
||||
_("Please set Fixed Asset Account in {0} against {1}.").format(
|
||||
form_link, doc.company
|
||||
),
|
||||
title=_("Missing Account"),
|
||||
|
||||
@@ -436,7 +436,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
if (!me.frm.doc.customer) {
|
||||
frappe.throw({
|
||||
title: __("Mandatory"),
|
||||
message: __("Please Select a Customer"),
|
||||
message: __("Please select a Customer"),
|
||||
});
|
||||
}
|
||||
erpnext.utils.map_current_doc({
|
||||
|
||||
@@ -755,7 +755,7 @@ class SalesInvoice(SellingController):
|
||||
|
||||
if account.report_type != "Balance Sheet":
|
||||
msg = (
|
||||
_("Please ensure {} account is a Balance Sheet account.").format(frappe.bold(_("Debit To")))
|
||||
_("Please ensure {0} account is a Balance Sheet account.").format(frappe.bold(_("Debit To")))
|
||||
+ " "
|
||||
)
|
||||
msg += _(
|
||||
@@ -765,7 +765,7 @@ class SalesInvoice(SellingController):
|
||||
|
||||
if self.customer and account.account_type != "Receivable":
|
||||
msg = (
|
||||
_("Please ensure {} account {} is a Receivable account.").format(
|
||||
_("Please ensure {0} account {1} is a Receivable account.").format(
|
||||
frappe.bold(_("Debit To")), frappe.bold(self.debit_to)
|
||||
)
|
||||
+ " "
|
||||
|
||||
@@ -75,8 +75,8 @@ class LoyaltyService:
|
||||
invoice_list = ", ".join([d.invoice for d in against_lp_entry])
|
||||
frappe.throw(
|
||||
_(
|
||||
"{} can't be cancelled since the Loyalty Points earned has been redeemed. "
|
||||
"First cancel the {} No {}"
|
||||
"{0} cannot be cancelled since the Loyalty Points earned has been redeemed. "
|
||||
"First cancel the {1} No {2}"
|
||||
).format(doc.doctype, doc.doctype, invoice_list)
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -187,7 +187,7 @@ class POSService:
|
||||
total_amount_in_payments = sum(payment.amount for payment in doc.payments)
|
||||
invoice_total = doc.rounded_total or doc.grand_total
|
||||
if total_amount_in_payments < invoice_total:
|
||||
frappe.throw(_("Total payments amount can't be greater than {}").format(-invoice_total))
|
||||
frappe.throw(_("Total payments amount can't be greater than {0}").format(-invoice_total))
|
||||
|
||||
def validate_pos_paid_amount(self) -> None:
|
||||
doc = self.doc
|
||||
@@ -273,7 +273,7 @@ class POSService:
|
||||
pluck="pos_closing_entry",
|
||||
)
|
||||
if pos_closing_entry and pos_closing_entry[0]:
|
||||
msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format(
|
||||
msg = _("To cancel a {0} you need to cancel the POS Closing Entry {1}.").format(
|
||||
frappe.bold(_("Consolidated Sales Invoice")),
|
||||
get_link_to_form("POS Closing Entry", pos_closing_entry[0]),
|
||||
)
|
||||
@@ -362,9 +362,9 @@ def update_multi_mode_option(doc, pos_profile) -> None:
|
||||
|
||||
if invalid_modes:
|
||||
if invalid_modes == 1:
|
||||
msg = _("Please set default Cash or Bank account in Mode of Payment {}")
|
||||
msg = _("Please set default Cash or Bank account in Mode of Payment {0}")
|
||||
else:
|
||||
msg = _("Please set default Cash or Bank account in Mode of Payments {}")
|
||||
msg = _("Please set default Cash or Bank account in Mode of Payments {0}")
|
||||
frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account"))
|
||||
|
||||
if mop_refetched:
|
||||
|
||||
@@ -2321,11 +2321,14 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
def test_create_so_with_margin(self):
|
||||
si = create_sales_invoice(item_code="_Test Item", qty=1, do_not_submit=True)
|
||||
price_list_rate = flt(100) * flt(si.plc_conversion_rate)
|
||||
|
||||
si.items[0].price_list_rate = price_list_rate
|
||||
si.items[0].margin_type = "Percentage"
|
||||
si.items[0].margin_rate_or_amount = 25
|
||||
si.items[0].discount_amount = 0.0
|
||||
si.items[0].discount_percentage = 0.0
|
||||
# set rate to zero, so that it is recalculated on save
|
||||
si.items[0].rate = 0
|
||||
si.save()
|
||||
self.assertEqual(si.get("items")[0].rate, flt((price_list_rate * 25) / 100 + price_list_rate))
|
||||
|
||||
|
||||
@@ -201,9 +201,9 @@ def get_linked_advances(company, docname):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_unreconcile_doc_for_selection(selections: str | None = None):
|
||||
def create_unreconcile_doc_for_selection(selections: str | list | None = None):
|
||||
if selections:
|
||||
selections = json.loads(selections)
|
||||
selections = frappe.parse_json(selections)
|
||||
# assuming each row is a unique voucher
|
||||
for row in selections:
|
||||
unrecon = frappe.new_doc("Unreconcile Payment")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"align": "Left",
|
||||
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:middle ! important\">\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\">\n\t\t\t\t<div class=\"company-name\">{{ doc.company }}</div>\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}<br>\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\">\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ doc.doctype }}</span>\n\t\t\t\t\t<span>{{ doc.name }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>",
|
||||
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:middle ! important\">\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\">\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}<br>\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\">\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ doc.doctype }}</span>\n\t\t\t\t\t<span>{{ doc.name }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>",
|
||||
"creation": "2026-05-15 15:21:48.255627",
|
||||
"custom_css": "\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tpadding-right: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\n\t.letter-head td {\n\t\tpadding: 0px !important;\n\t}\n\t.invoice-header {\n\t\twidth: 100%;\n\t}\n\t.logo-cell {\n\t\twidth: 100px;\n\t\ttext-align: center;\n\t\tposition: relative;\n\t}\n\t.logo-container {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t}\n\t.logo-container img {\n\t\tmax-width: 90px;\n\t\tmax-height: 90px;\n\t\tdisplay: inline-block;\n\t\tborder-radius: 15px;\n\t}\n\t.company-details {\n\t\twidth: 40%;\n\t\talign-content: center;\n\t}\n\t.company-name {\n\t\tfont-size: 14px;\n\t\tfont-weight: bold;\n\t\tcolor: #171717;\n\t\tmargin-bottom: 4px;\n\t}\n\t.invoice-info-cell {\n\t\tfloat: right;\n\t\tvertical-align: top;\n\t}\n\t.invoice-info {\n\t\tmargin-bottom: 2px;\n\t}\n\t.invoice-label {\n\t\tcolor: #7c7c7c;\n\t\tdisplay: inline-block;\n\t\tmargin-right: 5px;\n\t}",
|
||||
"disabled": 0,
|
||||
@@ -16,7 +16,7 @@
|
||||
"is_default": 0,
|
||||
"letter_head_for": "DocType",
|
||||
"letter_head_name": "Company Letterhead",
|
||||
"modified": "2026-05-16 15:15:23.014622",
|
||||
"modified": "2026-06-24 17:49:52.350750",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Company Letterhead",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"align": "Left",
|
||||
"content": "<table class=\"letterhead-container\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-address\">\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t<div class=\"logo\">\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\">\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t\t<div class=\"company-name\">{{ doc.company }}</div>\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}<br>\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td style=\"vertical-align:top\">\n\t\t\t\t<div style=\"height:90px;margin-bottom:10px;text-align:right\">\n\t\t\t\t\t<div class=\"invoice-title\">{{ doc.doctype }}</div>\n\t\t\t\t\t<div class=\"invoice-number\">{{ doc.name }}</div>\n\t\t\t\t\t<br>\n\t\t\t\t</div>\n\t\t\t\t<div style=\"text-align:left;float:right\" class=\"other-details\">\n\t\t\t\t\t{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %} {% set website = company_details.website %} {% set email =\n\t\t\t\t\tcompany_details.email %} {% set phone_no = company_details.phone_no %} {% if website %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Website:\") }}</span><span class=\"contact-value\">{{ website }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Email:\") }}</span><span class=\"contact-value\">{{ email }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Contact:\") }}</span><span class=\"contact-value\">{{ phone_no }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>\n",
|
||||
"content": "<table class=\"letterhead-container\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-address\">\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t<div class=\"logo\">\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\">\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}<br>\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td style=\"vertical-align:top\">\n\t\t\t\t<div style=\"height:90px;margin-bottom:10px;text-align:right\">\n\t\t\t\t\t<div class=\"invoice-title\">{{ doc.doctype }}</div>\n\t\t\t\t\t<div class=\"invoice-number\">{{ doc.name }}</div>\n\t\t\t\t\t<br>\n\t\t\t\t</div>\n\t\t\t\t<div style=\"text-align:left;float:right\" class=\"other-details\">\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Website:\") }}</span><span class=\"contact-value\">{{ website }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Email:\") }}</span><span class=\"contact-value\">{{ email }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Contact:\") }}</span><span class=\"contact-value\">{{ phone_no }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>\n",
|
||||
"creation": "2026-05-15 15:21:48.373815",
|
||||
"custom_css": "\t.print-format-preview {\n\t\tmargin-top: 12px;\n\t}\n\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tbackground: #f8f8f8;\n\t\tpadding: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\t.letterhead-container {\n\t\twidth: 100%;\n\t}\n\t.letterhead-container .other-details {\n\t\tposition: absolute;\n\t\tright: 0;\n\t\tbottom: 0;\n\t}\n\t.logo-address {\n\t\twidth: 65%;\n\t\tvertical-align: top;\n\t}\n\n\t.letter-head .logo {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t\tmargin-bottom: 10px;\n\t}\n\n\t.letter-head .logo img {\n\t\tborder-radius: 15px;\n\t}\n\n\t.company-name {\n\t\tcolor: #171717;\n\t\tfont-weight: bold;\n\t\tline-height: 23px;\n\t\tmargin-bottom: 5px;\n\t}\n\n\t.company-address {\n\t\tcolor: #171717;\n\t\twidth: 300px;\n\t}\n\n\t.invoice-title {\n\t\tfont-weight: bold;\n\t}\n\n\t.invoice-number {\n\t\tcolor: #7c7c7c;\n\t}\n\n\t.contact-title {\n\t\tcolor: #7c7c7c;\n\t\twidth: 60px;\n\t\tdisplay: inline-block;\n\t\tvertical-align: top;\n\t\tmargin-right: 10px;\n\t}\n\n\t.contact-value {\n\t\tcolor: #171717;\n\t\tdisplay: inline-block;\n\t}\n\t.letterhead-container td {\n\t\tpadding: 0px !important;\n\t\tposition: relative;\n\t}",
|
||||
"disabled": 0,
|
||||
@@ -16,7 +16,7 @@
|
||||
"is_default": 0,
|
||||
"letter_head_for": "DocType",
|
||||
"letter_head_name": "Company Letterhead - Grey",
|
||||
"modified": "2026-05-16 15:15:19.942207",
|
||||
"modified": "2026-06-24 18:23:05.120521",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Company Letterhead - Grey",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"align": "Left",
|
||||
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:top\">\n\t\t\t\t{% set company = frappe.get_doc(\"Company\", doc.company) %}\n\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% if company.company_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company.company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\" style=\"vertical-align:top\">\n\t\t\t\t<div class=\"company-name\">{{ company.name }}</div>\n\n\t\t\t\t{% set company_address_name = frappe.db.get_value(\n\t\t\t\t\t\"Dynamic Link\",\n\t\t\t\t\t{\n\t\t\t\t\t\t\"link_doctype\": \"Company\",\n\t\t\t\t\t\t\"link_name\": company.name,\n\t\t\t\t\t\t\"parenttype\": \"Address\"\n\t\t\t\t\t},\n\t\t\t\t\t\"parent\"\n\t\t\t\t) %}\n\n\t\t\t\t{% if company_address_name %}\n\t\t\t\t\t{% set company_address = frappe.db.get_value(\n\t\t\t\t\t\t\"Address\",\n\t\t\t\t\t\tcompany_address_name,\n\t\t\t\t\t\t[\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"],\n\t\t\t\t\t\tas_dict=True\n\t\t\t\t\t) %}\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if company_address %}\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{{ company_address.address_line1 or \"\" }}\n\n\t\t\t\t\t{% if company_address.address_line2 %}\n\t\t\t\t\t\t<br>{{ company_address.address_line2 }}\n\t\t\t\t\t{% endif %}\n\n\t\t\t\t\t<br>\n\n\t\t\t\t\t{{ company_address.city or \"\" }}\n\t\t\t\t\t{% if company_address.state %}, {{ company_address.state }}{% endif %}\n\t\t\t\t\t{{ company_address.pincode or \"\" }}\n\n\t\t\t\t\t{% if company_address.country %}\n\t\t\t\t\t\t, {{ company_address.country }}\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\" style=\"vertical-align:top;text-align:right\">\n\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %}\n\t\t\t\t{% set email = frappe.db.get_value(\"Company\", doc.company, \"email\") %}\n\t\t\t\t{% set phone_no = frappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t</tr>\n\t</tbody>\n</table>",
|
||||
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:top\">\n\t\t\t\t{% if doc.company %}{% set company = frappe.get_doc(\"Company\", doc.company) %}{% else %}{% set company = frappe._dict() %}{% endif %}\n\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% if company.company_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company.company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\" style=\"vertical-align:top\">\n\t\t\t\t{% if company.name %}<div class=\"company-name\">{{ company.name }}</div>{% endif %}\n\n\t\t\t\t{% set company_address_name = frappe.db.get_value(\n\t\t\t\t\t\"Dynamic Link\",\n\t\t\t\t\t{\n\t\t\t\t\t\t\"link_doctype\": \"Company\",\n\t\t\t\t\t\t\"link_name\": company.name,\n\t\t\t\t\t\t\"parenttype\": \"Address\"\n\t\t\t\t\t},\n\t\t\t\t\t\"parent\"\n\t\t\t\t) %}\n\n\t\t\t\t{% if company_address_name %}\n\t\t\t\t\t{% set company_address = frappe.db.get_value(\n\t\t\t\t\t\t\"Address\",\n\t\t\t\t\t\tcompany_address_name,\n\t\t\t\t\t\t[\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"],\n\t\t\t\t\t\tas_dict=True\n\t\t\t\t\t) %}\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if company_address %}\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{{ company_address.address_line1 or \"\" }}\n\n\t\t\t\t\t{% if company_address.address_line2 %}\n\t\t\t\t\t\t<br>{{ company_address.address_line2 }}\n\t\t\t\t\t{% endif %}\n\n\t\t\t\t\t<br>\n\n\t\t\t\t\t{{ company_address.city or \"\" }}\n\t\t\t\t\t{% if company_address.state %}, {{ company_address.state }}{% endif %}\n\t\t\t\t\t{{ company_address.pincode or \"\" }}\n\n\t\t\t\t\t{% if company_address.country %}\n\t\t\t\t\t\t, {{ company_address.country }}\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\" style=\"vertical-align:top;text-align:right\">\n\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %}\n\t\t\t\t{% set email = frappe.db.get_value(\"Company\", doc.company, \"email\") %}\n\t\t\t\t{% set phone_no = frappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t</tr>\n\t</tbody>\n</table>",
|
||||
"creation": "2026-05-15 19:49:47.582252",
|
||||
"custom_css": ".letter-head {\n\tborder-radius: 18px;\n\tpadding: 8px 10px;\n\tmargin: 10px 0 14px;\n\tfont-family: Inter, sans-serif;\n\tfont-size: 14px;\n\tcolor: #171717;\n}\n\n.letter-head td {\n\tpadding: 0 !important;\n\tvertical-align: middle;\n}\n\n.invoice-header {\n\twidth: 100%;\n\tborder-collapse: collapse;\n\ttable-layout: fixed;\n\tborder-bottom: 1px solid #ededed;\n\tpadding-bottom: 10px;\n}\n\n.logo-cell {\n\twidth: 100px;\n\ttext-align: center;\n\twhite-space: nowrap;\n}\n\n.logo-container {\n\tdisplay: inline-block;\n\tmargin: auto;\n}\n\n.logo-container img {\n\tmax-width: 95px;\n\tmax-height: 95px;\n\tdisplay: block;\n\tborder-radius: 12px;\n}\n\n.company-details {\n\twidth: 55%;\n\tpadding-left: 10px !important;\n\tline-height: 1.5;\n}\n\n.company-name {\n\tfont-size: 14px;\n\tfont-weight: 600;\n\tcolor: #171717;\n\tmargin-bottom: 4px;\n}\n\n.company-address {\n\tfont-size: 14px;\n\tline-height: 1.5;\n\tcolor: #171717;\n}\n\n.invoice-info-cell {\n\twidth: 240px;\n\ttext-align: right;\n\tvertical-align: top !important;\n\tline-height: 1.5;\n}\n\n.document-name {\n\tfont-size: 14px;\n\tfont-weight: 600;\n\tcolor: #171717;\n\tmargin-bottom: 6px;\n}\n\n.invoice-info {\n\tfont-size: 14px;\n\tcolor: #171717;\n\tmargin-bottom: 2px;\n\tfont-variant-numeric: tabular-nums;\n}\n\n.invoice-label {\n\tcolor: #7c7c7c;\n\tfont-weight: 500;\n\tmargin-right: 4px;\n\tdisplay: inline-block;\n}",
|
||||
"disabled": 0,
|
||||
@@ -16,7 +16,7 @@
|
||||
"is_default": 0,
|
||||
"letter_head_for": "Report",
|
||||
"letter_head_name": "Company Letterhead Report",
|
||||
"modified": "2026-05-16 15:15:26.155770",
|
||||
"modified": "2026-06-24 18:06:39.820968",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Company Letterhead Report",
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
"columns": [],
|
||||
"creation": "2013-04-22 16:16:03",
|
||||
"default_print_format": "Accounts Payable Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "Payment Ledger Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"generate_csv": 0,
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:35:14.716933",
|
||||
"modified": "2026-06-25 12:03:36.559152",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Payable",
|
||||
@@ -33,5 +40,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -54,6 +54,84 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
|
||||
pi = pi.submit()
|
||||
return pi
|
||||
|
||||
def test_invoice_partially_paid_via_journal_entry(self):
|
||||
pi = self.create_purchase_invoice() # outstanding 300
|
||||
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.company = self.company
|
||||
je.posting_date = today()
|
||||
je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "Creditors - _TC",
|
||||
"party_type": "Supplier",
|
||||
"party": self.supplier,
|
||||
"debit": 120,
|
||||
"debit_in_account_currency": 120,
|
||||
"reference_type": "Purchase Invoice",
|
||||
"reference_name": pi.name,
|
||||
"cost_center": "Main - _TC",
|
||||
},
|
||||
)
|
||||
je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "Cash - _TC",
|
||||
"credit": 120,
|
||||
"credit_in_account_currency": 120,
|
||||
"cost_center": "Main - _TC",
|
||||
},
|
||||
)
|
||||
je.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Supplier",
|
||||
"party": [self.supplier],
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
row = next(row for row in execute(filters)[1] if row.voucher_no == pi.name)
|
||||
self.assertEqual(row.paid, 120)
|
||||
self.assertEqual(row.outstanding, 180)
|
||||
|
||||
def test_show_remarks_includes_invoice_remark(self):
|
||||
pi = self.create_purchase_invoice(do_not_submit=True)
|
||||
pi.remarks = "AP test remark"
|
||||
pi.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Supplier",
|
||||
"party": [self.supplier],
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"show_remarks": 1,
|
||||
}
|
||||
row = next(row for row in execute(filters)[1] if row.voucher_no == pi.name)
|
||||
self.assertIn("AP test remark", row.remarks or "")
|
||||
|
||||
def test_group_by_supplier_totals(self):
|
||||
self.create_purchase_invoice() # outstanding 300
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Supplier",
|
||||
"party": [self.supplier],
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"group_by_party": True,
|
||||
}
|
||||
report = execute(filters)[1]
|
||||
|
||||
# a per-supplier subtotal row plus a grand total row
|
||||
party_subtotal = next(
|
||||
row for row in report if row.get("party") == self.supplier and not row.get("voucher_no")
|
||||
)
|
||||
grand_total = next(row for row in report if row.get("party") == "Total")
|
||||
self.assertEqual(party_subtotal.get("invoiced"), 300)
|
||||
self.assertEqual(grand_total.get("outstanding"), 300)
|
||||
|
||||
def test_payment_terms_template_filters(self):
|
||||
from erpnext.controllers.accounts_controller import get_payment_terms
|
||||
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
"columns": [],
|
||||
"creation": "2013-04-16 11:31:13",
|
||||
"default_print_format": "Accounts Receivable Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "Payment Ledger Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"generate_csv": 0,
|
||||
"idx": 5,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:34:57.666402",
|
||||
"modified": "2026-06-25 12:03:28.812092",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Receivable",
|
||||
@@ -27,5 +34,6 @@
|
||||
"role": "Accounts User"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -568,6 +568,119 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
report = execute(filters)
|
||||
self.assertEqual(report[1], [])
|
||||
|
||||
def pay_invoice_via_journal_entry(self, si, amount):
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.company = self.company
|
||||
je.posting_date = today()
|
||||
je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": self.cash,
|
||||
"debit": amount,
|
||||
"debit_in_account_currency": amount,
|
||||
"cost_center": self.cost_center,
|
||||
},
|
||||
)
|
||||
je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": self.debit_to,
|
||||
"party_type": "Customer",
|
||||
"party": self.customer,
|
||||
"credit": amount,
|
||||
"credit_in_account_currency": amount,
|
||||
"reference_type": "Sales Invoice",
|
||||
"reference_name": si.name,
|
||||
"cost_center": self.cost_center,
|
||||
},
|
||||
)
|
||||
return je.save().submit()
|
||||
|
||||
def ar_rows(self):
|
||||
filters = {"company": self.company, "report_date": today(), "range": "30, 60, 90, 120"}
|
||||
return execute(filters)[1]
|
||||
|
||||
def test_invoice_partially_paid_via_journal_entry(self):
|
||||
si = self.create_sales_invoice(no_payment_schedule=True) # outstanding 100
|
||||
self.pay_invoice_via_journal_entry(si, 40)
|
||||
|
||||
row = next(row for row in self.ar_rows() if row.voucher_no == si.name)
|
||||
self.assertEqual(row.paid, 40)
|
||||
self.assertEqual(row.outstanding, 60)
|
||||
|
||||
def test_invoice_fully_paid_via_journal_entry(self):
|
||||
si = self.create_sales_invoice(no_payment_schedule=True) # outstanding 100
|
||||
self.pay_invoice_via_journal_entry(si, 100)
|
||||
|
||||
# a fully settled invoice drops out of the receivable report
|
||||
self.assertEqual([row for row in self.ar_rows() if row.voucher_no == si.name], [])
|
||||
|
||||
def test_credit_note_via_journal_entry_shows_negative_outstanding(self):
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.company = self.company
|
||||
je.voucher_type = "Credit Note"
|
||||
je.posting_date = today()
|
||||
je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": self.income_account,
|
||||
"debit": 100,
|
||||
"debit_in_account_currency": 100,
|
||||
"cost_center": self.cost_center,
|
||||
},
|
||||
)
|
||||
je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": self.debit_to,
|
||||
"party_type": "Customer",
|
||||
"party": self.customer,
|
||||
"credit": 100,
|
||||
"credit_in_account_currency": 100,
|
||||
"cost_center": self.cost_center,
|
||||
},
|
||||
)
|
||||
je = je.save().submit()
|
||||
|
||||
row = next(row for row in self.ar_rows() if row.voucher_no == je.name)
|
||||
self.assertEqual(row.outstanding, -100)
|
||||
|
||||
def test_show_remarks_includes_invoice_remark(self):
|
||||
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
|
||||
si.remarks = "AR test remark"
|
||||
si.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"show_remarks": 1,
|
||||
}
|
||||
row = next(row for row in execute(filters)[1] if row.voucher_no == si.name)
|
||||
self.assertIn("AR test remark", row.remarks or "")
|
||||
|
||||
def test_show_delivery_notes_links_delivery_note(self):
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
make_stock_entry(item_code=self.item, qty=5, to_warehouse=self.warehouse, basic_rate=100)
|
||||
dn = create_delivery_note(
|
||||
customer=self.customer, item=self.item, warehouse=self.warehouse, cost_center=self.cost_center
|
||||
)
|
||||
si = make_sales_invoice(dn.name)
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"show_delivery_notes": 1,
|
||||
}
|
||||
row = next(row for row in execute(filters)[1] if row.voucher_no == si.name)
|
||||
self.assertIn(dn.name, row.delivery_notes or "")
|
||||
|
||||
def test_group_by_party(self):
|
||||
si1 = self.create_sales_invoice(do_not_submit=True)
|
||||
si1.posting_date = add_days(today(), -1)
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
"columns": [],
|
||||
"creation": "2014-07-14 05:24:20.385279",
|
||||
"default_print_format": "Balance Sheet Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "GL Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"generate_csv": 0,
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:35:28.187799",
|
||||
"modified": "2026-06-22 13:38:25.236839",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Balance Sheet",
|
||||
@@ -30,5 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -4,18 +4,27 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint, flt
|
||||
from frappe.utils import add_days, cint, flt
|
||||
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
FinancialReportEngine,
|
||||
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
|
||||
)
|
||||
from erpnext.accounts.report.financial_statements import (
|
||||
accumulate_values_into_parents,
|
||||
add_total_row,
|
||||
calculate_values,
|
||||
compute_growth_view_data,
|
||||
filter_accounts,
|
||||
filter_out_zero_value_rows,
|
||||
get_accounting_entries,
|
||||
get_accounts,
|
||||
get_appropriate_currency,
|
||||
get_columns,
|
||||
get_data,
|
||||
get_filtered_list_for_consolidated_report,
|
||||
get_period_list,
|
||||
prepare_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -266,3 +275,196 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency):
|
||||
chart["currency"] = currency
|
||||
|
||||
return chart
|
||||
|
||||
|
||||
def execute_synced_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if not (conn := get_latest_sync("GL Entry")):
|
||||
frappe.throw(_("Balance Sheet requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
|
||||
|
||||
period_list = get_period_list(
|
||||
filters.from_fiscal_year,
|
||||
filters.to_fiscal_year,
|
||||
filters.period_start_date,
|
||||
filters.period_end_date,
|
||||
filters.filter_based_on,
|
||||
filters.periodicity,
|
||||
company=filters.company,
|
||||
)
|
||||
filters.period_start_date = period_list[0]["year_start_date"]
|
||||
|
||||
currency = filters.presentation_currency or frappe.get_cached_value(
|
||||
"Company", filters.company, "default_currency"
|
||||
)
|
||||
|
||||
asset = _get_data_duckdb(conn, filters, "Asset", "Debit", period_list)
|
||||
liability = _get_data_duckdb(conn, filters, "Liability", "Credit", period_list)
|
||||
equity = _get_data_duckdb(conn, filters, "Equity", "Credit", period_list)
|
||||
|
||||
provisional_profit_loss, total_credit = get_provisional_profit_loss(
|
||||
asset, liability, equity, period_list, filters.company, currency
|
||||
)
|
||||
message, opening_balance = check_opening_balance(asset, liability, equity)
|
||||
|
||||
data = []
|
||||
data.extend(asset or [])
|
||||
data.extend(liability or [])
|
||||
data.extend(equity or [])
|
||||
if opening_balance and round(opening_balance, 2) != 0:
|
||||
unclosed = {
|
||||
"account_name": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'",
|
||||
"account": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'",
|
||||
"warn_if_negative": True,
|
||||
"currency": currency,
|
||||
}
|
||||
for period in period_list:
|
||||
unclosed[period.key] = opening_balance
|
||||
if provisional_profit_loss:
|
||||
provisional_profit_loss[period.key] = provisional_profit_loss[period.key] - opening_balance
|
||||
unclosed["total"] = opening_balance
|
||||
data.append(unclosed)
|
||||
|
||||
if provisional_profit_loss:
|
||||
data.append(provisional_profit_loss)
|
||||
if total_credit:
|
||||
data.append(total_credit)
|
||||
|
||||
columns = get_columns(
|
||||
filters.periodicity, period_list, filters.accumulated_values, company=filters.company
|
||||
)
|
||||
chart = get_chart_data(filters, period_list, asset, liability, equity, currency)
|
||||
report_summary, primitive_summary = get_report_summary(
|
||||
period_list, asset, liability, equity, provisional_profit_loss, currency, filters
|
||||
)
|
||||
|
||||
if filters.get("selected_view") == "Growth":
|
||||
compute_growth_view_data(data, period_list)
|
||||
|
||||
return columns, data, message, chart, report_summary, primitive_summary
|
||||
|
||||
|
||||
def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list):
|
||||
accounts = get_accounts(filters.company, root_type)
|
||||
if not accounts:
|
||||
return None
|
||||
|
||||
accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
|
||||
company_currency = get_appropriate_currency(filters.company, filters)
|
||||
|
||||
gl_entries_by_account = {}
|
||||
_load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type)
|
||||
|
||||
calculate_values(
|
||||
accounts_by_name,
|
||||
gl_entries_by_account,
|
||||
period_list,
|
||||
filters.accumulated_values,
|
||||
False,
|
||||
)
|
||||
accumulate_values_into_parents(accounts, accounts_by_name, period_list)
|
||||
|
||||
out = prepare_data(
|
||||
accounts,
|
||||
balance_must_be,
|
||||
period_list,
|
||||
company_currency,
|
||||
accumulated_values=filters.accumulated_values,
|
||||
)
|
||||
out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values)
|
||||
|
||||
if out:
|
||||
add_total_row(out, root_type, balance_must_be, period_list, company_currency)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type):
|
||||
from erpnext.accounts.report.trial_balance.trial_balance import (
|
||||
_extra_gl_conditions,
|
||||
_fetch_gl_rows_duckdb,
|
||||
)
|
||||
from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
|
||||
|
||||
company = filters.company
|
||||
year_start_date = period_list[0]["year_start_date"]
|
||||
last_to_date = period_list[-1]["to_date"]
|
||||
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
|
||||
|
||||
leaf_accounts = [acc.name for acc in accounts if not acc.is_group]
|
||||
if not leaf_accounts:
|
||||
return
|
||||
|
||||
opening_from_date = None
|
||||
ignore_opening_entries = False
|
||||
|
||||
ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance")
|
||||
if not ignore_closing_balances:
|
||||
last_pcv_list = frappe.db.get_all(
|
||||
"Period Closing Voucher",
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"company": company,
|
||||
"period_end_date": ("<", filters.get("period_start_date") or year_start_date),
|
||||
},
|
||||
fields=["period_end_date", "name"],
|
||||
order_by="period_end_date desc",
|
||||
limit=1,
|
||||
)
|
||||
if last_pcv_list:
|
||||
last_pcv = last_pcv_list[0]
|
||||
pcv_entries = get_accounting_entries(
|
||||
"Account Closing Balance",
|
||||
None,
|
||||
last_to_date,
|
||||
filters,
|
||||
root_type=root_type,
|
||||
ignore_closing_entries=False,
|
||||
period_closing_voucher=last_pcv.name,
|
||||
)
|
||||
if filters.get("presentation_currency"):
|
||||
convert_to_presentation_currency(pcv_entries, get_currency(filters))
|
||||
for entry in pcv_entries:
|
||||
gl_entries_by_account.setdefault(entry.account, []).append(entry)
|
||||
opening_from_date = add_days(last_pcv.period_end_date, 1)
|
||||
ignore_opening_entries = True
|
||||
|
||||
extra_cond, extra_params = _extra_gl_conditions(filters)
|
||||
account_placeholders = ", ".join(["?"] * len(leaf_accounts))
|
||||
base_conds = [
|
||||
"company = ?",
|
||||
"is_cancelled = 0",
|
||||
f"account IN ({account_placeholders})",
|
||||
]
|
||||
base_params = [company, *leaf_accounts]
|
||||
if ignore_opening_entries and not ignore_is_opening:
|
||||
base_conds.append("is_opening = 'No'")
|
||||
base_conds.extend(extra_cond)
|
||||
base_params.extend(extra_params)
|
||||
|
||||
# Opening GL entries from DuckDB (entries before year_start_date)
|
||||
open_conds = [*base_conds, "posting_date < ?"]
|
||||
open_params = [*base_params, year_start_date]
|
||||
if opening_from_date:
|
||||
open_conds = [*open_conds, "posting_date >= ?"]
|
||||
open_params = [*open_params, opening_from_date]
|
||||
|
||||
opening_entries = _fetch_gl_rows_duckdb(conn, open_conds, open_params)
|
||||
if filters.get("presentation_currency"):
|
||||
convert_to_presentation_currency(opening_entries, get_currency(filters))
|
||||
synthetic_open_date = add_days(year_start_date, -1)
|
||||
for entry in opening_entries:
|
||||
entry.posting_date = synthetic_open_date
|
||||
gl_entries_by_account.setdefault(entry.account, []).append(entry)
|
||||
|
||||
# Period GL entries from DuckDB (one aggregated query per period)
|
||||
for period in period_list:
|
||||
period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"]
|
||||
period_params = [*base_params, period.from_date, period.to_date]
|
||||
|
||||
period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params)
|
||||
if filters.get("presentation_currency"):
|
||||
convert_to_presentation_currency(period_entries, get_currency(filters))
|
||||
for entry in period_entries:
|
||||
entry.posting_date = period.to_date
|
||||
gl_entries_by_account.setdefault(entry.account, []).append(entry)
|
||||
|
||||
@@ -2,12 +2,34 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.report.cash_flow.cash_flow import execute
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestCashFlow(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
|
||||
def net_change_in_cash(self):
|
||||
"""Run the report for the current fiscal year and return the Net Change in Cash total."""
|
||||
fiscal_year, year_start, year_end = get_fiscal_year(today(), company=self.company)
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_fiscal_year=fiscal_year,
|
||||
to_fiscal_year=fiscal_year,
|
||||
period_start_date=year_start,
|
||||
period_end_date=year_end,
|
||||
filter_based_on="Fiscal Year",
|
||||
periodicity="Yearly",
|
||||
accumulated_values=0,
|
||||
)
|
||||
rows = execute(filters)[1]
|
||||
row = next(row for row in rows if row.get("section") == "'Net Change in Cash'")
|
||||
return row["total"]
|
||||
|
||||
def test_report_executes(self):
|
||||
# Smoke-guards the raw-SQL -> query-builder port: the report query must compile and run on
|
||||
# both MariaDB and postgres.
|
||||
@@ -25,3 +47,31 @@ class TestCashFlow(ERPNextTestSuite):
|
||||
)
|
||||
)
|
||||
self.assertTrue(columns)
|
||||
|
||||
def test_cash_sale_increases_net_change_in_cash(self):
|
||||
"""A cash sale (debit Cash, credit Income) increases net change in cash by the amount."""
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
before = self.net_change_in_cash()
|
||||
make_journal_entry("Cash - _TC", "Sales - _TC", 500, posting_date=today(), submit=True)
|
||||
|
||||
self.assertEqual(self.net_change_in_cash() - before, 500)
|
||||
|
||||
def test_cash_purchase_of_asset_is_investing_outflow(self):
|
||||
"""Buying a fixed asset for cash is an investing outflow that reduces net change in cash."""
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
create_account(
|
||||
account_name="_Test Cash Flow Asset",
|
||||
company=self.company,
|
||||
parent_account="Fixed Assets - _TC",
|
||||
account_type="Fixed Asset",
|
||||
)
|
||||
asset_account = "_Test Cash Flow Asset - _TC"
|
||||
|
||||
before = self.net_change_in_cash()
|
||||
# debit the fixed asset, credit cash -> cash goes out
|
||||
make_journal_entry(asset_account, "Cash - _TC", 800, posting_date=today(), submit=True)
|
||||
|
||||
self.assertEqual(self.net_change_in_cash() - before, -800)
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
"columns": [],
|
||||
"creation": "2013-12-06 13:22:23",
|
||||
"default_print_format": "General Ledger Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "GL Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"generate_csv": 0,
|
||||
"idx": 4,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:34:35.246000",
|
||||
"modified": "2026-06-22 13:38:35.057216",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "General Ledger",
|
||||
@@ -30,5 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -679,8 +679,9 @@ def get_columns(filters):
|
||||
and filters["presentation_currency"] != company_currency
|
||||
):
|
||||
frappe.throw(
|
||||
_(
|
||||
f'Presentation Currency cannot be {frappe.bold(filters["presentation_currency"])} , When {frappe.bold("Show Credit / Debit in Company Currency")} is enabled.'
|
||||
_("Presentation Currency cannot be {0}, when {1} is enabled.").format(
|
||||
frappe.bold(filters["presentation_currency"]),
|
||||
frappe.bold(_("Show Credit / Debit in Company Currency")),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -817,3 +818,288 @@ def get_columns(filters):
|
||||
columns.extend([{"label": _("Remarks"), "fieldname": "remarks", "width": 400}])
|
||||
|
||||
return columns
|
||||
|
||||
|
||||
def execute_synced_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if conn := get_latest_sync("GL Entry"):
|
||||
return _execute_with_duckdb_conn(filters, conn)
|
||||
|
||||
frappe.throw(_("General Ledger requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
|
||||
|
||||
|
||||
def _execute_with_duckdb_conn(filters, conn):
|
||||
if not filters:
|
||||
return [], []
|
||||
|
||||
account_details = {}
|
||||
|
||||
if filters.get("print_in_account_currency") and not filters.get("account"):
|
||||
frappe.throw(_("Select an account to print in account currency"))
|
||||
|
||||
for acc in frappe.get_all("Account", fields=["name", "is_group"]):
|
||||
account_details.setdefault(acc.name, acc)
|
||||
|
||||
if filters.get("party"):
|
||||
filters.party = frappe.parse_json(filters.get("party"))
|
||||
|
||||
validate_filters(filters, account_details)
|
||||
validate_party(filters)
|
||||
filters = set_account_currency(filters)
|
||||
columns = get_columns(filters)
|
||||
res = get_result_duckdb(filters, account_details, conn)
|
||||
return columns, res
|
||||
|
||||
|
||||
def get_result_duckdb(filters, account_details, conn):
|
||||
accounting_dimensions = []
|
||||
if filters.get("include_dimensions"):
|
||||
accounting_dimensions = get_accounting_dimensions()
|
||||
|
||||
gl_entries = get_gl_entries_duckdb(filters, accounting_dimensions, conn)
|
||||
data = get_data_with_opening_closing(filters, account_details, accounting_dimensions, gl_entries)
|
||||
return get_result_as_list(data, filters)
|
||||
|
||||
|
||||
def get_gl_entries_duckdb(filters, accounting_dimensions, conn):
|
||||
currency_map = get_currency(filters)
|
||||
|
||||
col_names = [
|
||||
"gl_entry",
|
||||
"posting_date",
|
||||
"account",
|
||||
"party_type",
|
||||
"party",
|
||||
"voucher_type",
|
||||
"voucher_subtype",
|
||||
"voucher_no",
|
||||
"cost_center",
|
||||
"project",
|
||||
"against_voucher_type",
|
||||
"against_voucher",
|
||||
"account_currency",
|
||||
"against",
|
||||
"is_opening",
|
||||
"creation",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
]
|
||||
select_exprs = [
|
||||
"name",
|
||||
"posting_date",
|
||||
"account",
|
||||
"party_type",
|
||||
"party",
|
||||
"voucher_type",
|
||||
"voucher_subtype",
|
||||
"voucher_no",
|
||||
"cost_center",
|
||||
"project",
|
||||
"against_voucher_type",
|
||||
"against_voucher",
|
||||
"account_currency",
|
||||
"against",
|
||||
"is_opening",
|
||||
"creation",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
]
|
||||
|
||||
if filters.get("show_remarks"):
|
||||
remarks_length = frappe.get_single_value("Accounts Settings", "general_ledger_remarks_length")
|
||||
if remarks_length:
|
||||
select_exprs.append(f"substr(remarks, 1, {int(remarks_length)})")
|
||||
else:
|
||||
select_exprs.append("remarks")
|
||||
col_names.append("remarks")
|
||||
|
||||
if filters.get("add_values_in_transaction_currency"):
|
||||
select_exprs += [
|
||||
"debit_in_transaction_currency",
|
||||
"credit_in_transaction_currency",
|
||||
"transaction_currency",
|
||||
]
|
||||
col_names += [
|
||||
"debit_in_transaction_currency",
|
||||
"credit_in_transaction_currency",
|
||||
"transaction_currency",
|
||||
]
|
||||
|
||||
if accounting_dimensions:
|
||||
select_exprs += accounting_dimensions
|
||||
col_names += accounting_dimensions
|
||||
|
||||
order_by = "posting_date, account, creation"
|
||||
if filters.get("include_dimensions"):
|
||||
order_by = "posting_date, creation"
|
||||
if filters.get("categorize_by") == "Categorize by Voucher":
|
||||
order_by = "posting_date, voucher_type, voucher_no"
|
||||
if filters.get("categorize_by") == "Categorize by Account":
|
||||
order_by = "account, posting_date, creation"
|
||||
|
||||
if filters.get("include_default_book_entries"):
|
||||
filters["company_fb"] = frappe.get_cached_value(
|
||||
"Company", filters.get("company"), "default_finance_book"
|
||||
)
|
||||
|
||||
conditions, params = _build_gl_conditions_duckdb(filters)
|
||||
select_clause = ", ".join(select_exprs)
|
||||
sql = f'SELECT {select_clause} FROM "tabGL Entry" WHERE {" AND ".join(conditions)} ORDER BY {order_by}'
|
||||
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
gl_entries = [frappe._dict(zip(col_names, row, strict=False)) for row in rows]
|
||||
|
||||
party_name_map = get_party_name_map()
|
||||
for gl_entry in gl_entries:
|
||||
if gl_entry.party_type and gl_entry.party:
|
||||
gl_entry.party_name = party_name_map.get(gl_entry.party_type, {}).get(gl_entry.party)
|
||||
|
||||
if filters.get("presentation_currency"):
|
||||
return convert_to_presentation_currency(gl_entries, currency_map, filters)
|
||||
return gl_entries
|
||||
|
||||
|
||||
def _build_gl_conditions_duckdb(filters):
|
||||
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
|
||||
|
||||
conditions = ["company = ?"]
|
||||
params = [filters.company]
|
||||
|
||||
if filters.get("account"):
|
||||
filters.account = get_accounts_with_children(filters.account)
|
||||
if filters.account:
|
||||
conditions.append(f"account IN ({', '.join(['?'] * len(filters.account))})")
|
||||
params.extend(filters.account)
|
||||
|
||||
if filters.get("cost_center"):
|
||||
filters.cost_center = get_cost_centers_with_children(filters.cost_center)
|
||||
conditions.append(f"cost_center IN ({', '.join(['?'] * len(filters.cost_center))})")
|
||||
params.extend(filters.cost_center)
|
||||
|
||||
if filters.get("voucher_no"):
|
||||
conditions.append("voucher_no = ?")
|
||||
params.append(filters.voucher_no)
|
||||
|
||||
if filters.get("against_voucher_no"):
|
||||
conditions.append("against_voucher = ?")
|
||||
params.append(filters.against_voucher_no)
|
||||
|
||||
if filters.get("ignore_err"):
|
||||
err_journals = frappe.db.get_all(
|
||||
"Journal Entry",
|
||||
filters={
|
||||
"company": filters.get("company"),
|
||||
"docstatus": 1,
|
||||
"voucher_type": ("in", ["Exchange Rate Revaluation", "Exchange Gain Or Loss"]),
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
if err_journals:
|
||||
filters.update({"voucher_no_not_in": err_journals})
|
||||
|
||||
if filters.get("ignore_cr_dr_notes"):
|
||||
system_generated = frappe.db.get_all(
|
||||
"Journal Entry",
|
||||
filters={
|
||||
"company": filters.get("company"),
|
||||
"docstatus": 1,
|
||||
"voucher_type": ("in", ["Credit Note", "Debit Note"]),
|
||||
"is_system_generated": 1,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
if system_generated:
|
||||
vouchers_to_ignore = (filters.get("voucher_no_not_in") or []) + system_generated
|
||||
filters.update({"voucher_no_not_in": vouchers_to_ignore})
|
||||
|
||||
if filters.get("voucher_no_not_in"):
|
||||
vouchers = filters.voucher_no_not_in
|
||||
conditions.append(f"voucher_no NOT IN ({', '.join(['?'] * len(vouchers))})")
|
||||
params.extend(vouchers)
|
||||
|
||||
if filters.get("categorize_by") == "Categorize by Party" and not filters.get("party_type"):
|
||||
conditions.append("party_type IN ('Customer', 'Supplier')")
|
||||
|
||||
if filters.get("party_type"):
|
||||
conditions.append("party_type = ?")
|
||||
params.append(filters.party_type)
|
||||
|
||||
if filters.get("party"):
|
||||
conditions.append(f"party IN ({', '.join(['?'] * len(filters.party))})")
|
||||
params.extend(filters.party)
|
||||
|
||||
# from_date: skip when filtering by account/party to allow opening balance calc in Python
|
||||
if filters.get("disable_opening_balance_calculation"):
|
||||
if not ignore_is_opening:
|
||||
conditions.append("(posting_date >= ? OR is_opening = 'Yes')")
|
||||
else:
|
||||
conditions.append("posting_date >= ?")
|
||||
params.append(filters.from_date)
|
||||
elif not (
|
||||
filters.get("account")
|
||||
or filters.get("party")
|
||||
or filters.get("categorize_by") in ["Categorize by Account", "Categorize by Party"]
|
||||
):
|
||||
if not ignore_is_opening:
|
||||
conditions.append("(posting_date >= ? OR is_opening = 'Yes')")
|
||||
else:
|
||||
conditions.append("posting_date >= ?")
|
||||
params.append(filters.from_date)
|
||||
|
||||
if not ignore_is_opening:
|
||||
conditions.append("(posting_date <= ? OR is_opening = 'Yes')")
|
||||
else:
|
||||
conditions.append("posting_date <= ?")
|
||||
params.append(filters.to_date)
|
||||
|
||||
if filters.get("project"):
|
||||
conditions.append(f"project IN ({', '.join(['?'] * len(filters.project))})")
|
||||
params.extend(filters.project)
|
||||
|
||||
company_fb = filters.get("company_fb") or frappe.get_cached_value(
|
||||
"Company", filters.company, "default_finance_book"
|
||||
)
|
||||
if filters.get("include_default_book_entries"):
|
||||
if filters.get("finance_book"):
|
||||
if company_fb and cstr(filters.finance_book) != cstr(company_fb):
|
||||
frappe.throw(
|
||||
_("To use a different finance book, please uncheck 'Include Default FB Entries'")
|
||||
)
|
||||
fb_vals = [cstr(filters.finance_book), ""]
|
||||
else:
|
||||
fb_vals = [cstr(company_fb), ""]
|
||||
conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_vals))}) OR finance_book IS NULL)")
|
||||
params.extend(fb_vals)
|
||||
else:
|
||||
if filters.get("finance_book"):
|
||||
conditions.append("(finance_book IN (?, '') OR finance_book IS NULL)")
|
||||
params.append(cstr(filters.finance_book))
|
||||
else:
|
||||
conditions.append("(finance_book IN ('') OR finance_book IS NULL)")
|
||||
|
||||
if not filters.get("show_cancelled_entries"):
|
||||
conditions.append("is_cancelled = 0")
|
||||
|
||||
accounting_dimensions_list = get_accounting_dimensions(as_list=False)
|
||||
if accounting_dimensions_list:
|
||||
for dimension in accounting_dimensions_list:
|
||||
if not dimension.disabled and dimension.document_type != "Finance Book":
|
||||
if filters.get(dimension.fieldname):
|
||||
if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"):
|
||||
filters[dimension.fieldname] = get_dimension_with_children(
|
||||
dimension.document_type, filters.get(dimension.fieldname)
|
||||
)
|
||||
vals = (
|
||||
filters[dimension.fieldname]
|
||||
if isinstance(filters[dimension.fieldname], list)
|
||||
else [filters[dimension.fieldname]]
|
||||
)
|
||||
conditions.append(f"{dimension.fieldname} IN ({', '.join(['?'] * len(vals))})")
|
||||
params.extend(vals)
|
||||
|
||||
return conditions, params
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.utils import flt, today
|
||||
from frappe.utils import add_days, flt, today
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.general_ledger.general_ledger import execute
|
||||
@@ -63,6 +63,74 @@ class TestGeneralLedger(ERPNextTestSuite):
|
||||
for doctype in doctype_list:
|
||||
qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
|
||||
|
||||
def test_opening_total_and_closing_balances(self):
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
self.clear_old_entries()
|
||||
account = create_account(
|
||||
account_name="_Test GL Account", company=self.company, parent_account="Current Assets - _TC"
|
||||
)
|
||||
offset = create_account(
|
||||
account_name="_Test GL Offset", company=self.company, parent_account="Current Assets - _TC"
|
||||
)
|
||||
make_journal_entry(account, offset, 1000, posting_date=add_days(today(), -60), submit=True) # opening
|
||||
make_journal_entry(account, offset, 200, posting_date=today(), submit=True) # in period
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company, from_date=add_days(today(), -30), to_date=today(), account=[account]
|
||||
)
|
||||
labelled = {row.get("account"): row for row in execute(filters)[1]}
|
||||
|
||||
self.assertEqual(labelled["'Opening'"]["debit"], 1000)
|
||||
self.assertEqual(labelled["'Total'"]["debit"], 200)
|
||||
self.assertEqual(labelled["'Closing (Opening + Total)'"]["debit"], 1200)
|
||||
|
||||
def test_categorize_by_account_subtotals(self):
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
self.clear_old_entries()
|
||||
account_a = create_account(
|
||||
account_name="_Test GL Account A", company=self.company, parent_account="Current Assets - _TC"
|
||||
)
|
||||
account_b = create_account(
|
||||
account_name="_Test GL Account B", company=self.company, parent_account="Current Assets - _TC"
|
||||
)
|
||||
offset = create_account(
|
||||
account_name="_Test GL Offset", company=self.company, parent_account="Current Assets - _TC"
|
||||
)
|
||||
make_journal_entry(account_a, offset, 300, posting_date=today(), submit=True)
|
||||
make_journal_entry(account_b, offset, 400, posting_date=today(), submit=True)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=add_days(today(), -1),
|
||||
to_date=today(),
|
||||
categorize_by="Categorize by Account",
|
||||
)
|
||||
total_debits = [row["debit"] for row in execute(filters)[1] if row.get("account") == "'Total'"]
|
||||
|
||||
# each account gets its own subtotal row, then a grand total (300 + 400) at the end
|
||||
self.assertIn(300, total_debits)
|
||||
self.assertIn(400, total_debits)
|
||||
self.assertEqual(total_debits[-1], 700)
|
||||
|
||||
def test_party_filter_returns_only_that_party(self):
|
||||
self.clear_old_entries()
|
||||
create_sales_invoice(customer="_Test Customer", company=self.company, debit_to="Debtors - _TC")
|
||||
create_sales_invoice(customer="_Test Customer 1", company=self.company, debit_to="Debtors - _TC")
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=add_days(today(), -1),
|
||||
to_date=today(),
|
||||
party_type="Customer",
|
||||
party=["_Test Customer"],
|
||||
)
|
||||
parties = {row.get("party") for row in execute(filters)[1] if row.get("party")}
|
||||
self.assertEqual(parties, {"_Test Customer"})
|
||||
|
||||
def test_foreign_account_balance_after_exchange_rate_revaluation(self):
|
||||
"""
|
||||
Checks the correctness of balance after exchange rate revaluation
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
"columns": [],
|
||||
"creation": "2014-07-18 11:43:33.173207",
|
||||
"default_print_format": "P&L Statement Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "GL Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"generate_csv": 0,
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:36:04.544347",
|
||||
"modified": "2026-06-22 13:38:15.898375",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Profit and Loss Statement",
|
||||
@@ -30,5 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -11,12 +11,20 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine
|
||||
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
|
||||
)
|
||||
from erpnext.accounts.report.financial_statements import (
|
||||
accumulate_values_into_parents,
|
||||
add_total_row,
|
||||
calculate_values,
|
||||
compute_growth_view_data,
|
||||
compute_margin_view_data,
|
||||
filter_accounts,
|
||||
filter_out_zero_value_rows,
|
||||
get_accounts,
|
||||
get_appropriate_currency,
|
||||
get_columns,
|
||||
get_data,
|
||||
get_filtered_list_for_consolidated_report,
|
||||
get_period_list,
|
||||
prepare_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -197,3 +205,125 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur
|
||||
chart["currency"] = currency
|
||||
|
||||
return chart
|
||||
|
||||
|
||||
def execute_synced_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if not (conn := get_latest_sync("GL Entry")):
|
||||
frappe.throw(
|
||||
_("Profit and Loss Statement requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))
|
||||
)
|
||||
|
||||
period_list = get_period_list(
|
||||
filters.from_fiscal_year,
|
||||
filters.to_fiscal_year,
|
||||
filters.period_start_date,
|
||||
filters.period_end_date,
|
||||
filters.filter_based_on,
|
||||
filters.periodicity,
|
||||
company=filters.company,
|
||||
)
|
||||
|
||||
income = _get_data_duckdb(conn, filters, "Income", "Credit", period_list)
|
||||
expense = _get_data_duckdb(conn, filters, "Expense", "Debit", period_list)
|
||||
|
||||
net_profit_loss = get_net_profit_loss(
|
||||
income, expense, period_list, filters.company, filters.presentation_currency
|
||||
)
|
||||
|
||||
data = []
|
||||
data.extend(income or [])
|
||||
data.extend(expense or [])
|
||||
if net_profit_loss:
|
||||
data.append(net_profit_loss)
|
||||
|
||||
columns = get_columns(filters.periodicity, period_list, filters.accumulated_values, filters.company)
|
||||
|
||||
currency = filters.presentation_currency or frappe.get_cached_value(
|
||||
"Company", filters.company, "default_currency"
|
||||
)
|
||||
chart = get_chart_data(filters, period_list, income, expense, net_profit_loss, currency)
|
||||
|
||||
report_summary, primitive_summary = get_report_summary(
|
||||
period_list, filters.periodicity, income, expense, net_profit_loss, currency, filters
|
||||
)
|
||||
|
||||
if filters.get("selected_view") == "Growth":
|
||||
compute_growth_view_data(data, period_list)
|
||||
|
||||
if filters.get("selected_view") == "Margin":
|
||||
compute_margin_view_data(data, period_list, filters.accumulated_values)
|
||||
|
||||
return columns, data, None, chart, report_summary, primitive_summary
|
||||
|
||||
|
||||
def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list):
|
||||
accounts = get_accounts(filters.company, root_type)
|
||||
if not accounts:
|
||||
return None
|
||||
|
||||
accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
|
||||
company_currency = get_appropriate_currency(filters.company, filters)
|
||||
|
||||
gl_entries_by_account = {}
|
||||
_load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account)
|
||||
|
||||
calculate_values(
|
||||
accounts_by_name,
|
||||
gl_entries_by_account,
|
||||
period_list,
|
||||
filters.accumulated_values,
|
||||
False,
|
||||
)
|
||||
accumulate_values_into_parents(accounts, accounts_by_name, period_list)
|
||||
|
||||
out = prepare_data(
|
||||
accounts,
|
||||
balance_must_be,
|
||||
period_list,
|
||||
company_currency,
|
||||
accumulated_values=filters.accumulated_values,
|
||||
)
|
||||
out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values)
|
||||
|
||||
if out:
|
||||
add_total_row(out, root_type, balance_must_be, period_list, company_currency)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account):
|
||||
from erpnext.accounts.report.trial_balance.trial_balance import (
|
||||
_extra_gl_conditions,
|
||||
_fetch_gl_rows_duckdb,
|
||||
)
|
||||
from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
|
||||
|
||||
company = filters.company
|
||||
leaf_accounts = [acc.name for acc in accounts if not acc.is_group]
|
||||
if not leaf_accounts:
|
||||
return
|
||||
|
||||
extra_cond, extra_params = _extra_gl_conditions(filters)
|
||||
account_placeholders = ", ".join(["?"] * len(leaf_accounts))
|
||||
base_conds = [
|
||||
"company = ?",
|
||||
"is_cancelled = 0",
|
||||
f"account IN ({account_placeholders})",
|
||||
"voucher_type != 'Period Closing Voucher'",
|
||||
]
|
||||
base_params = [company, *leaf_accounts]
|
||||
base_conds.extend(extra_cond)
|
||||
base_params.extend(extra_params)
|
||||
|
||||
for period in period_list:
|
||||
period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"]
|
||||
period_params = [*base_params, period.from_date, period.to_date]
|
||||
|
||||
period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params)
|
||||
if filters.get("presentation_currency"):
|
||||
convert_to_presentation_currency(period_entries, get_currency(filters))
|
||||
for entry in period_entries:
|
||||
entry.posting_date = period.to_date
|
||||
gl_entries_by_account.setdefault(entry.account, []).append(entry)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# MIT License. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.report.trial_balance.trial_balance import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -66,3 +66,252 @@ class TestTrialBalance(ERPNextTestSuite):
|
||||
)
|
||||
total_row = execute(filters)[1][-1]
|
||||
self.assertEqual(total_row["debit"], total_row["credit"])
|
||||
|
||||
|
||||
class TestTrialBalanceReport(ERPNextTestSuite):
|
||||
"""Correctness tests using fresh accounts so the asserted rows are unpolluted."""
|
||||
|
||||
def make_accounts_and_entry(self, amount, posting_date):
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
debit_account = create_account(
|
||||
account_name="_Test Trial Balance Debit",
|
||||
company="_Test Company",
|
||||
parent_account="Current Assets - _TC",
|
||||
)
|
||||
credit_account = create_account(
|
||||
account_name="_Test Trial Balance Credit",
|
||||
company="_Test Company",
|
||||
parent_account="Current Assets - _TC",
|
||||
)
|
||||
make_journal_entry(debit_account, credit_account, amount, posting_date=posting_date, submit=True)
|
||||
return debit_account, credit_account
|
||||
|
||||
def rows_by_account(self, **filters):
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
|
||||
filters.setdefault("company", "_Test Company")
|
||||
filters.setdefault("fiscal_year", get_fiscal_year(today(), company="_Test Company")[0])
|
||||
data = execute(frappe._dict(filters))[1]
|
||||
return {row["account"]: row for row in data if row.get("account")}, data[-1]
|
||||
|
||||
def test_posted_entry_lands_in_period_and_total_balances(self):
|
||||
debit_account, credit_account = self.make_accounts_and_entry(500, today())
|
||||
|
||||
rows, total_row = self.rows_by_account()
|
||||
|
||||
self.assertEqual(rows[debit_account]["debit"], 500)
|
||||
self.assertEqual(rows[credit_account]["credit"], 500)
|
||||
self.assertEqual(total_row["debit"], total_row["credit"])
|
||||
|
||||
def test_entry_before_from_date_shows_as_opening_balance(self):
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
|
||||
fiscal_year, year_start, year_end = get_fiscal_year(today(), company="_Test Company")
|
||||
debit_account, credit_account = self.make_accounts_and_entry(500, year_start)
|
||||
|
||||
rows, _ = self.rows_by_account(
|
||||
fiscal_year=fiscal_year, from_date=add_days(year_start, 5), to_date=year_end
|
||||
)
|
||||
|
||||
# the entry predates the period, so it belongs in opening - not in the period columns
|
||||
self.assertEqual(rows[debit_account]["opening_debit"], 500)
|
||||
self.assertEqual(rows[debit_account]["debit"], 0)
|
||||
self.assertEqual(rows[credit_account]["opening_credit"], 500)
|
||||
|
||||
def test_show_zero_values_includes_unposted_accounts(self):
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
|
||||
account = create_account(
|
||||
account_name="_Test Trial Balance Zero",
|
||||
company="_Test Company",
|
||||
parent_account="Current Assets - _TC",
|
||||
)
|
||||
|
||||
# an account with no postings is hidden by default, shown when the filter is on
|
||||
self.assertNotIn(account, self.rows_by_account()[0])
|
||||
self.assertIn(account, self.rows_by_account(show_zero_values=1)[0])
|
||||
|
||||
def test_show_group_accounts_includes_parent_rows(self):
|
||||
self.make_accounts_and_entry(500, today())
|
||||
|
||||
# group (parent) accounts are hidden by default, shown when the filter is on
|
||||
self.assertNotIn("Current Assets - _TC", self.rows_by_account()[0])
|
||||
self.assertIn("Current Assets - _TC", self.rows_by_account(show_group_accounts=1)[0])
|
||||
|
||||
def test_show_net_values_nets_opening_and_closing(self):
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
|
||||
fiscal_year, year_start, year_end = get_fiscal_year(today(), company="_Test Company")
|
||||
account = create_account(
|
||||
account_name="_Test Trial Balance Net",
|
||||
company="_Test Company",
|
||||
parent_account="Current Assets - _TC",
|
||||
)
|
||||
offset = create_account(
|
||||
account_name="_Test Trial Balance Net Offset",
|
||||
company="_Test Company",
|
||||
parent_account="Current Assets - _TC",
|
||||
)
|
||||
# opening debit 500 (before the period), then a 300 credit within the period
|
||||
make_journal_entry(account, offset, 500, posting_date=year_start, submit=True)
|
||||
make_journal_entry(offset, account, 300, posting_date=today(), submit=True)
|
||||
|
||||
period = dict(fiscal_year=fiscal_year, from_date=add_days(year_start, 5), to_date=year_end)
|
||||
|
||||
gross = self.rows_by_account(**period)[0][account]
|
||||
self.assertEqual(gross["closing_debit"], 500)
|
||||
self.assertEqual(gross["closing_credit"], 300)
|
||||
|
||||
net = self.rows_by_account(show_net_values=1, **period)[0][account]
|
||||
self.assertEqual(net["closing_debit"], 200) # 500 debit - 300 credit
|
||||
self.assertEqual(net["closing_credit"], 0)
|
||||
|
||||
def test_opening_balance_respects_ignore_account_closing_balance(self):
|
||||
"""With a Period Closing Voucher present, opening can be read from the cached
|
||||
Account Closing Balance or recomputed from GL; both must agree."""
|
||||
self.close_fiscal_year_2021_for_pcv_company()
|
||||
|
||||
def cash_opening(ignore_closing_balance):
|
||||
frappe.db.set_single_value(
|
||||
"Accounts Settings", "ignore_account_closing_balance", ignore_closing_balance
|
||||
)
|
||||
data = execute(frappe._dict(company="Test PCV Company", fiscal_year="_Test Fiscal Year 2022"))[1]
|
||||
return next(row["opening_debit"] for row in data if row.get("account") == "Cash - TPC")
|
||||
|
||||
from_cache = cash_opening(0) # reads the Account Closing Balance
|
||||
from_gl = cash_opening(1) # recomputes from GL Entry
|
||||
|
||||
self.assertEqual(from_cache, 400)
|
||||
self.assertEqual(from_gl, 400)
|
||||
self.assertEqual(from_cache, from_gl)
|
||||
|
||||
def test_period_closing_entry_filter_includes_closing_entries(self):
|
||||
surplus = self.close_fiscal_year_2021_for_pcv_company()
|
||||
|
||||
def surplus_period_credit(include_closing):
|
||||
data = execute(
|
||||
frappe._dict(
|
||||
company="Test PCV Company",
|
||||
fiscal_year="_Test Fiscal Year 2021",
|
||||
with_period_closing_entry_for_current_period=include_closing,
|
||||
)
|
||||
)[1]
|
||||
row = next((row for row in data if row.get("account") == surplus), None)
|
||||
return row["credit"] if row else 0
|
||||
|
||||
# the closing entry posts to the surplus account only when the filter is on
|
||||
self.assertEqual(surplus_period_credit(0), 0)
|
||||
self.assertEqual(surplus_period_credit(1), 400)
|
||||
|
||||
def test_show_unclosed_fy_pl_balances_controls_pl_opening(self):
|
||||
"""P&L opening from a prior, unclosed fiscal year is excluded by default and
|
||||
included only when 'show unclosed FY P&L balances' is on."""
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.doctype.period_closing_voucher.test_period_closing_voucher import (
|
||||
create_cost_center,
|
||||
)
|
||||
|
||||
cost_center = create_cost_center("TB Unclosed CC")
|
||||
jv = make_journal_entry(
|
||||
"Cost of Goods Sold - TPC",
|
||||
"Cash - TPC",
|
||||
250,
|
||||
cost_center=cost_center,
|
||||
posting_date="2020-06-15",
|
||||
save=False,
|
||||
)
|
||||
jv.company = "Test PCV Company"
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
def cogs_opening(show_unclosed):
|
||||
data = execute(
|
||||
frappe._dict(
|
||||
company="Test PCV Company",
|
||||
fiscal_year="_Test Fiscal Year 2021",
|
||||
show_unclosed_fy_pl_balances=show_unclosed,
|
||||
)
|
||||
)[1]
|
||||
row = next((row for row in data if row.get("account") == "Cost of Goods Sold - TPC"), None)
|
||||
return row["opening_debit"] if row else 0
|
||||
|
||||
self.assertEqual(cogs_opening(0), 0) # prior-year P&L excluded by default
|
||||
self.assertEqual(cogs_opening(1), 250) # included when showing unclosed FY P&L
|
||||
|
||||
def test_include_default_book_entries_controls_default_fb_opening(self):
|
||||
"""An opening entry tagged with the company's default finance book is included in
|
||||
opening only when 'Include Default FB Entries' is on."""
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
|
||||
finance_book = (
|
||||
frappe.get_doc({"doctype": "Finance Book", "finance_book_name": "_Test TB Finance Book"})
|
||||
.insert(ignore_if_duplicate=True)
|
||||
.name
|
||||
)
|
||||
frappe.db.set_value("Company", "_Test Company", "default_finance_book", finance_book)
|
||||
|
||||
fiscal_year, year_start, year_end = get_fiscal_year(today(), company="_Test Company")
|
||||
account = create_account(
|
||||
account_name="_Test Trial Balance FB",
|
||||
company="_Test Company",
|
||||
parent_account="Current Assets - _TC",
|
||||
)
|
||||
offset = create_account(
|
||||
account_name="_Test Trial Balance FB Offset",
|
||||
company="_Test Company",
|
||||
parent_account="Current Assets - _TC",
|
||||
)
|
||||
jv = make_journal_entry(account, offset, 500, posting_date=year_start, save=False)
|
||||
jv.finance_book = finance_book
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
period = dict(fiscal_year=fiscal_year, from_date=add_days(year_start, 5), to_date=year_end)
|
||||
|
||||
with_default = self.rows_by_account(include_default_book_entries=1, **period)[0]
|
||||
self.assertEqual(with_default[account]["opening_debit"], 500)
|
||||
|
||||
without_default = self.rows_by_account(include_default_book_entries=0, **period)[0]
|
||||
self.assertEqual(without_default.get(account, {}).get("opening_debit", 0), 0)
|
||||
|
||||
def close_fiscal_year_2021_for_pcv_company(self):
|
||||
"""Post a 400 balance to Cash - TPC in FY 2021 and close it with a PCV. Returns the surplus account."""
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.doctype.period_closing_voucher.test_period_closing_voucher import (
|
||||
create_account,
|
||||
create_cost_center,
|
||||
)
|
||||
|
||||
frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 1)
|
||||
cost_center = create_cost_center("TB Opening CC")
|
||||
|
||||
jv = make_journal_entry(
|
||||
"Cash - TPC", "Sales - TPC", 400, cost_center=cost_center, posting_date="2021-06-15", save=False
|
||||
)
|
||||
jv.company = "Test PCV Company"
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
surplus = create_account()
|
||||
pcv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Period Closing Voucher",
|
||||
"transaction_date": "2021-12-31",
|
||||
"period_start_date": "2021-01-01",
|
||||
"period_end_date": "2021-12-31",
|
||||
"company": "Test PCV Company",
|
||||
"fiscal_year": "_Test Fiscal Year 2021",
|
||||
"cost_center": cost_center,
|
||||
"closing_account_head": surplus,
|
||||
"remarks": "test",
|
||||
}
|
||||
)
|
||||
pcv.insert()
|
||||
pcv.submit()
|
||||
return surplus
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
"columns": [],
|
||||
"creation": "2014-07-22 11:41:23.743564",
|
||||
"default_print_format": "Trial Balance Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "GL Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"idx": 2,
|
||||
"generate_csv": 0,
|
||||
"idx": 4,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:35:44.889062",
|
||||
"modified": "2026-06-22 13:38:42.740436",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Trial Balance",
|
||||
@@ -30,5 +37,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -581,3 +581,215 @@ def hide_group_accounts(data):
|
||||
d.update(indent=0)
|
||||
non_group_accounts_data.append(d)
|
||||
return non_group_accounts_data
|
||||
|
||||
|
||||
def execute_synced_report(filters):
|
||||
from frappe.database.duckdb.database import get_latest_sync
|
||||
|
||||
if conn := get_latest_sync("GL Entry"):
|
||||
validate_filters(filters)
|
||||
columns = get_columns()
|
||||
data = get_data_duckdb(filters, conn)
|
||||
return columns, data
|
||||
else:
|
||||
frappe.throw(_("Trial Balance requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
|
||||
|
||||
|
||||
def get_data_duckdb(filters, conn):
|
||||
# accounts and all metadata via frappe.db — only GL Entry comes from DuckDB
|
||||
accounts = frappe.db.sql(
|
||||
"""select name, account_number, parent_account, account_name, root_type, report_type, is_group, lft, rgt
|
||||
from `tabAccount` where company=%s order by lft""",
|
||||
filters.company,
|
||||
as_dict=True,
|
||||
)
|
||||
if not accounts:
|
||||
return None
|
||||
|
||||
company_currency = filters.presentation_currency or erpnext.get_company_currency(filters.company)
|
||||
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
|
||||
accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
|
||||
|
||||
gl_entries_by_account = get_period_gl_entries_duckdb(conn, filters, ignore_is_opening)
|
||||
opening_balances = get_opening_balances_duckdb(conn, filters, ignore_is_opening)
|
||||
|
||||
calculate_values(
|
||||
accounts,
|
||||
gl_entries_by_account,
|
||||
opening_balances,
|
||||
filters.get("show_net_values"),
|
||||
ignore_is_opening=ignore_is_opening,
|
||||
)
|
||||
accumulate_values_into_parents(accounts, accounts_by_name)
|
||||
|
||||
data = prepare_data(accounts, filters, parent_children_map, company_currency)
|
||||
return filter_out_zero_value_rows(
|
||||
data, parent_children_map, show_zero_values=filters.get("show_zero_values")
|
||||
)
|
||||
|
||||
|
||||
def _extra_gl_conditions(filters):
|
||||
"""Returns (conditions, params) for optional shared GL Entry filters."""
|
||||
conditions, params = [], []
|
||||
|
||||
if filters.get("cost_center"):
|
||||
cc = get_cost_centers_with_children(filters.get("cost_center"))
|
||||
conditions.append(f"cost_center IN ({', '.join(['?'] * len(cc))})")
|
||||
params.extend(cc)
|
||||
|
||||
if filters.get("project"):
|
||||
proj = filters.project if isinstance(filters.project, list) else [filters.project]
|
||||
conditions.append(f"project IN ({', '.join(['?'] * len(proj))})")
|
||||
params.extend(proj)
|
||||
|
||||
if frappe.db.count("Finance Book"):
|
||||
company_fb = frappe.get_cached_value("Company", filters.company, "default_finance_book")
|
||||
if filters.get("include_default_book_entries"):
|
||||
if filters.get("finance_book") and company_fb and cstr(filters.finance_book) != cstr(company_fb):
|
||||
frappe.throw(
|
||||
_("To use a different finance book, please uncheck 'Include Default FB Entries'")
|
||||
)
|
||||
fb_list = [cstr(filters.get("finance_book")), cstr(company_fb), ""]
|
||||
else:
|
||||
fb_list = [cstr(filters.get("finance_book")), ""]
|
||||
conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_list))}) OR finance_book IS NULL)")
|
||||
params.extend(fb_list)
|
||||
|
||||
for dim in get_accounting_dimensions(as_list=False):
|
||||
if filters.get(dim.fieldname):
|
||||
if frappe.get_cached_value("DocType", dim.document_type, "is_tree"):
|
||||
filters[dim.fieldname] = get_dimension_with_children(
|
||||
dim.document_type, filters.get(dim.fieldname)
|
||||
)
|
||||
vals = (
|
||||
filters[dim.fieldname]
|
||||
if isinstance(filters[dim.fieldname], list)
|
||||
else [filters[dim.fieldname]]
|
||||
)
|
||||
conditions.append(f"{dim.fieldname} IN ({', '.join(['?'] * len(vals))})")
|
||||
params.extend(vals)
|
||||
|
||||
return conditions, params
|
||||
|
||||
|
||||
def _fetch_gl_rows_duckdb(conn, conditions, params):
|
||||
cols = [
|
||||
"account",
|
||||
"debit",
|
||||
"credit",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
"account_currency",
|
||||
]
|
||||
sql = f"""SELECT account, SUM(debit), SUM(credit),
|
||||
SUM(debit_in_account_currency), SUM(credit_in_account_currency), account_currency
|
||||
FROM "tabGL Entry" WHERE {" AND ".join(conditions)}
|
||||
GROUP BY account, account_currency"""
|
||||
return [frappe._dict(zip(cols, row, strict=False)) for row in conn.execute(sql, params).fetchall()]
|
||||
|
||||
|
||||
def get_period_gl_entries_duckdb(conn, filters, ignore_is_opening):
|
||||
conditions = ["company = ?", "is_cancelled = 0", "posting_date >= ?", "posting_date <= ?"]
|
||||
params = [filters.company, filters.from_date, filters.to_date]
|
||||
|
||||
if not ignore_is_opening:
|
||||
conditions.append("is_opening = 'No'")
|
||||
if not flt(filters.get("with_period_closing_entry_for_current_period")):
|
||||
conditions.append("voucher_type != 'Period Closing Voucher'")
|
||||
|
||||
extra_cond, extra_params = _extra_gl_conditions(filters)
|
||||
conditions.extend(extra_cond)
|
||||
params.extend(extra_params)
|
||||
|
||||
entries = _fetch_gl_rows_duckdb(conn, conditions, params)
|
||||
if filters.get("presentation_currency"):
|
||||
convert_to_presentation_currency(entries, get_currency(filters))
|
||||
|
||||
gl_entries_by_account = {}
|
||||
for entry in entries:
|
||||
gl_entries_by_account.setdefault(entry.account, []).append(entry)
|
||||
return gl_entries_by_account
|
||||
|
||||
|
||||
def get_opening_balances_duckdb(conn, filters, ignore_is_opening):
|
||||
bs = _get_rootwise_opening_duckdb(conn, filters, "Balance Sheet", ignore_is_opening)
|
||||
pl = _get_rootwise_opening_duckdb(conn, filters, "Profit and Loss", ignore_is_opening)
|
||||
bs.update(pl)
|
||||
return bs
|
||||
|
||||
|
||||
def _get_rootwise_opening_duckdb(conn, filters, report_type, ignore_is_opening):
|
||||
accounting_dimensions = get_accounting_dimensions(as_list=False)
|
||||
ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance")
|
||||
last_pcv = ""
|
||||
|
||||
if not ignore_closing_balances:
|
||||
last_pcv = frappe.db.get_all(
|
||||
"Period Closing Voucher",
|
||||
filters={"docstatus": 1, "company": filters.company, "period_end_date": ("<", filters.from_date)},
|
||||
fields=["period_end_date", "name"],
|
||||
order_by="period_end_date desc",
|
||||
limit=1,
|
||||
)
|
||||
|
||||
if last_pcv:
|
||||
# Account Closing Balance fetched via frappe (not GL Entry)
|
||||
gle = get_opening_balance(
|
||||
"Account Closing Balance",
|
||||
filters,
|
||||
report_type,
|
||||
accounting_dimensions,
|
||||
period_closing_voucher=last_pcv[0].name,
|
||||
ignore_is_opening=ignore_is_opening,
|
||||
)
|
||||
if getdate(last_pcv[0].period_end_date) < getdate(add_days(filters.from_date, -1)):
|
||||
start_date = add_days(last_pcv[0].period_end_date, 1)
|
||||
gle += _get_gl_entry_opening_duckdb(
|
||||
conn, filters, report_type, ignore_is_opening, start_date=start_date
|
||||
)
|
||||
else:
|
||||
gle = _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening)
|
||||
|
||||
opening = frappe._dict()
|
||||
for d in gle:
|
||||
opening.setdefault(d.account, {"account": d.account, "opening_debit": 0.0, "opening_credit": 0.0})
|
||||
opening[d.account]["opening_debit"] += flt(d.debit)
|
||||
opening[d.account]["opening_credit"] += flt(d.credit)
|
||||
return opening
|
||||
|
||||
|
||||
def _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening, start_date=None):
|
||||
accounts = frappe.db.get_all("Account", filters={"report_type": report_type}, pluck="name")
|
||||
if not accounts:
|
||||
return []
|
||||
|
||||
conditions = ["company = ?", f"account IN ({', '.join(['?'] * len(accounts))})", "is_cancelled = 0"]
|
||||
params = [filters.company, *accounts]
|
||||
|
||||
if start_date:
|
||||
conditions.append("posting_date >= ? AND posting_date < ?")
|
||||
params.extend([start_date, filters.from_date])
|
||||
if not ignore_is_opening:
|
||||
conditions.append("is_opening = 'No'")
|
||||
elif not ignore_is_opening:
|
||||
conditions.append("(posting_date < ? OR is_opening = 'Yes')")
|
||||
params.append(filters.from_date)
|
||||
else:
|
||||
conditions.append("posting_date < ?")
|
||||
params.append(filters.from_date)
|
||||
|
||||
if not filters.get("show_unclosed_fy_pl_balances") and report_type == "Profit and Loss":
|
||||
conditions.append("posting_date >= ?")
|
||||
params.append(filters.year_start_date)
|
||||
|
||||
if not flt(filters.get("with_period_closing_entry_for_opening")):
|
||||
conditions.append("voucher_type != 'Period Closing Voucher'")
|
||||
|
||||
extra_cond, extra_params = _extra_gl_conditions(filters)
|
||||
conditions.extend(extra_cond)
|
||||
params.extend(extra_params)
|
||||
|
||||
gle = _fetch_gl_rows_duckdb(conn, conditions, params)
|
||||
if filters.get("presentation_currency"):
|
||||
convert_to_presentation_currency(gle, get_currency(filters))
|
||||
return gle
|
||||
|
||||
@@ -53,7 +53,7 @@ class BillingValidationService:
|
||||
|
||||
if is_overbilling_allowed and total_overbilled_amt > 0.1:
|
||||
frappe.msgprint(
|
||||
_("Overbilling of {} ignored because you have {} role.").format(
|
||||
_("Overbilling of {0} ignored because you have {1} role.").format(
|
||||
total_overbilled_amt, role_allowed_to_overbill
|
||||
),
|
||||
indicator="orange",
|
||||
@@ -148,4 +148,4 @@ class BillingValidationService:
|
||||
+ "</ul>"
|
||||
)
|
||||
message += _("<p>To allow over-billing, please set allowance in Accounts Settings.</p>")
|
||||
frappe.throw(_(message))
|
||||
frappe.throw(message)
|
||||
|
||||
@@ -30,7 +30,7 @@ class ChildItemUpdater:
|
||||
self._ordered_items: dict | None = None
|
||||
self._purchased_items: dict | None = None
|
||||
|
||||
def update(self, trans_items: str) -> None:
|
||||
def update(self, trans_items: str | list) -> None:
|
||||
"""Process item additions, edits, and deletions from trans_items JSON."""
|
||||
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items
|
||||
from erpnext.selling.doctype.quotation.mapper import get_ordered_items
|
||||
@@ -207,7 +207,7 @@ class ChildItemUpdater:
|
||||
except frappe.PermissionError:
|
||||
actions = {"create": "add", "write": "update"}
|
||||
frappe.throw(
|
||||
_("You do not have permissions to {} items in a {}.").format(
|
||||
_("You do not have permissions to {0} items in a {1}.").format(
|
||||
actions[perm_type], self.parent_doctype
|
||||
),
|
||||
title=_("Insufficient Permissions"),
|
||||
@@ -229,7 +229,7 @@ class ChildItemUpdater:
|
||||
|
||||
if not allowed:
|
||||
frappe.throw(
|
||||
_("You are not allowed to update as per the conditions set in {} Workflow.").format(
|
||||
_("You are not allowed to update as per the conditions set in {0} Workflow.").format(
|
||||
get_link_to_form("Workflow", workflow)
|
||||
),
|
||||
title=_("Insufficient Permissions"),
|
||||
@@ -512,7 +512,7 @@ def update_child_item_rate_and_discount(
|
||||
rate_unchanged = flt(child_item.get("rate")) == flt(new_data.get("rate"))
|
||||
|
||||
if not rate_unchanged and not child_item.get("qty") and allow_zero_qty:
|
||||
frappe.throw(_("Rate of '{}' items cannot be changed").format(frappe.bold(_("Unit Price"))))
|
||||
frappe.throw(_("Rate of '{0}' items cannot be changed").format(frappe.bold(_("Unit Price"))))
|
||||
|
||||
row_rate = flt(new_data.get("rate"), rate_precision)
|
||||
|
||||
@@ -534,6 +534,7 @@ def update_child_item_rate_and_discount(
|
||||
|
||||
if flt(child_item.rate) > flt(child_item.price_list_rate):
|
||||
child_item.discount_percentage = 0
|
||||
child_item.discount_amount = 0
|
||||
child_item.margin_type = "Amount"
|
||||
child_item.margin_rate_or_amount = flt(
|
||||
child_item.rate - child_item.price_list_rate,
|
||||
@@ -541,14 +542,11 @@ def update_child_item_rate_and_discount(
|
||||
)
|
||||
child_item.rate_with_margin = child_item.rate
|
||||
else:
|
||||
child_item.discount_percentage = flt(
|
||||
(1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0,
|
||||
child_item.precision("discount_percentage"),
|
||||
)
|
||||
child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate)
|
||||
child_item.margin_type = ""
|
||||
child_item.margin_rate_or_amount = 0
|
||||
child_item.rate_with_margin = 0
|
||||
child_item.rate_with_margin = child_item.price_list_rate
|
||||
child_item.discount_percentage = 0
|
||||
child_item.discount_amount = flt(child_item.rate_with_margin) - flt(child_item.rate)
|
||||
|
||||
|
||||
def update_child_item_uom_and_weight(child_item, new_data) -> None:
|
||||
|
||||
@@ -62,7 +62,7 @@ def validate_accounting_period(gl_map):
|
||||
return
|
||||
frappe.throw(
|
||||
_(
|
||||
"You cannot create or cancel any accounting entries with in the closed Accounting Period {0}"
|
||||
"You cannot create or cancel any accounting entries within the closed Accounting Period {0}"
|
||||
).format(frappe.bold(accounting_periods[0].name)),
|
||||
ClosedAccountingPeriod,
|
||||
)
|
||||
@@ -140,9 +140,9 @@ def validate_against_pcv(is_opening, posting_date, company):
|
||||
)
|
||||
|
||||
if last_pcv_date and getdate(posting_date) <= getdate(last_pcv_date):
|
||||
message = _("Books have been closed till the period ending on {0}").format(formatdate(last_pcv_date))
|
||||
message = _("Books have been closed until the period ending on {0}").format(formatdate(last_pcv_date))
|
||||
message += "</br >"
|
||||
message += _("You cannot create/amend any accounting entries till this date.")
|
||||
message += _("You cannot create/amend any accounting entries until this date.")
|
||||
frappe.throw(message, title=_("Period Closed"))
|
||||
|
||||
|
||||
|
||||
@@ -95,7 +95,9 @@ class InternalTransferService:
|
||||
for row in self.doc.get("items"):
|
||||
if not row.get(field):
|
||||
frappe.throw(
|
||||
_(f"At Row {row.idx}: The field {bold(label)} is mandatory for internal transfer"),
|
||||
_("At Row {0}: The field {1} is mandatory for internal transfer").format(
|
||||
row.idx, bold(label)
|
||||
),
|
||||
title=_("Internal Transfer Reference Missing"),
|
||||
)
|
||||
|
||||
@@ -115,7 +117,7 @@ class InternalTransferService:
|
||||
if not self.doc.get("ignore_pricing_rule") and self.is_internal_transfer():
|
||||
self.doc.ignore_pricing_rule = 1
|
||||
frappe.msgprint(
|
||||
_("Disabled pricing rules since this {} is an internal transfer").format(self.doc.doctype),
|
||||
_("Disabled pricing rules since this {0} is an internal transfer").format(self.doc.doctype),
|
||||
alert=1,
|
||||
)
|
||||
|
||||
@@ -131,7 +133,7 @@ class InternalTransferService:
|
||||
|
||||
if tax_updated:
|
||||
frappe.msgprint(
|
||||
_("Disabled tax included prices since this {} is an internal transfer").format(
|
||||
_("Disabled tax included prices since this {0} is an internal transfer").format(
|
||||
self.doc.doctype
|
||||
),
|
||||
alert=1,
|
||||
|
||||
@@ -2565,7 +2565,7 @@ def create_gain_loss_journal(
|
||||
party_account_currency = frappe.get_cached_value("Account", party_account, "account_currency")
|
||||
|
||||
if not gain_loss_account:
|
||||
frappe.throw(_("Please set default Exchange Gain/Loss Account in Company {}").format(company))
|
||||
frappe.throw(_("Please set default Exchange Gain/Loss Account in Company {0}").format(company))
|
||||
gain_loss_account_currency = get_account_currency(gain_loss_account)
|
||||
company_currency = frappe.get_cached_value("Company", company, "default_currency")
|
||||
|
||||
|
||||
@@ -327,7 +327,7 @@ class Asset(AccountsController):
|
||||
reference_doc = frappe.get_doc(reference_doc, reference_name)
|
||||
if reference_doc.get("company") != self.company:
|
||||
frappe.throw(
|
||||
_("Company of asset {0} and purchase document {1} doesn't matches.").format(
|
||||
_("Company of asset {0} and purchase document {1} does not match.").format(
|
||||
self.name, reference_doc.get("name")
|
||||
)
|
||||
)
|
||||
@@ -355,7 +355,7 @@ class Asset(AccountsController):
|
||||
)
|
||||
if cost_center_company != self.company:
|
||||
frappe.throw(
|
||||
_("Cost Center {} doesn't belong to Company {}").format(
|
||||
_("Cost Center {0} does not belong to Company {1}").format(
|
||||
frappe.bold(self.cost_center), frappe.bold(self.company)
|
||||
),
|
||||
title=_("Invalid Cost Center"),
|
||||
@@ -363,7 +363,7 @@ class Asset(AccountsController):
|
||||
if cost_center_is_group:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cost Center {} is a group cost center and group cost centers cannot be used in transactions"
|
||||
"Cost Center {0} is a group cost center and group cost centers cannot be used in transactions"
|
||||
).format(frappe.bold(self.cost_center)),
|
||||
title=_("Invalid Cost Center"),
|
||||
)
|
||||
@@ -372,7 +372,7 @@ class Asset(AccountsController):
|
||||
if not frappe.get_cached_value("Company", self.company, "depreciation_cost_center"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Please set a Cost Center for the Asset or set an Asset Depreciation Cost Center for the Company {}"
|
||||
"Please set a Cost Center for the Asset or set an Asset Depreciation Cost Center for the Company {0}"
|
||||
).format(frappe.bold(self.company)),
|
||||
title=_("Missing Cost Center"),
|
||||
)
|
||||
@@ -410,7 +410,7 @@ class Asset(AccountsController):
|
||||
for d in self.finance_books:
|
||||
if d.finance_book in finance_books:
|
||||
frappe.throw(
|
||||
_("Row #{}: Please use a different Finance Book.").format(d.idx),
|
||||
_("Row #{0}: Please use a different Finance Book.").format(d.idx),
|
||||
title=_("Duplicate Finance Book"),
|
||||
)
|
||||
else:
|
||||
@@ -418,7 +418,9 @@ class Asset(AccountsController):
|
||||
|
||||
if not d.finance_book:
|
||||
frappe.throw(
|
||||
_("Row #{}: Finance Book should not be empty since you're using multiple.").format(d.idx),
|
||||
_("Row #{0}: Finance Book should not be empty since you're using multiple.").format(
|
||||
d.idx
|
||||
),
|
||||
title=_("Missing Finance Book"),
|
||||
)
|
||||
|
||||
@@ -995,8 +997,7 @@ class Asset(AccountsController):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_depreciation_rate(self, args: str | dict | Document, on_validate: bool = False):
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
args = frappe.parse_json(args)
|
||||
|
||||
rate_field_precision = frappe.get_single_value("System Settings", "float_precision") or 2
|
||||
|
||||
@@ -1191,7 +1192,7 @@ def get_values_from_purchase_doc(
|
||||
matching_items = [item for item in purchase_doc.items if item.item_code == item_code]
|
||||
|
||||
if not matching_items:
|
||||
frappe.throw(_(f"Selected {doctype} does not contain the Item Code {item_code}"))
|
||||
frappe.throw(_("Selected {0} does not contain the Item Code {1}").format(doctype, item_code))
|
||||
|
||||
first_item = matching_items[0]
|
||||
|
||||
|
||||
@@ -162,8 +162,7 @@ def make_asset_movement(
|
||||
assets: list[dict] | str,
|
||||
purpose: str = "Transfer",
|
||||
):
|
||||
if isinstance(assets, str):
|
||||
assets = json.loads(assets)
|
||||
assets = frappe.parse_json(assets)
|
||||
|
||||
if len(assets) == 0:
|
||||
frappe.throw(_("At least one asset has to be selected."))
|
||||
|
||||
@@ -186,7 +186,7 @@ class AssetCapitalization(StockController):
|
||||
target_asset = self.get_asset_for_validation(self.target_asset)
|
||||
|
||||
if not target_asset.asset_type == "Composite Asset":
|
||||
frappe.throw(_("Target Asset {0} needs to be composite asset").format(target_asset.name))
|
||||
frappe.throw(_("Target Asset {0} needs to be a composite asset").format(target_asset.name))
|
||||
|
||||
if target_asset.item_code != self.target_item_code:
|
||||
frappe.throw(
|
||||
@@ -669,8 +669,7 @@ def get_service_item_details(ctx: ItemDetailsCtx) -> frappe._dict:
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_items_tagged_to_wip_composite_asset(params: dict | str):
|
||||
if isinstance(params, str):
|
||||
params = json.loads(params)
|
||||
params = frappe.parse_json(params)
|
||||
|
||||
fields = [
|
||||
"item_code",
|
||||
|
||||
@@ -63,7 +63,7 @@ class AssetCategory(Document):
|
||||
|
||||
for d in invalid_accounts:
|
||||
frappe.throw(
|
||||
_("Row #{}: Currency of {} - {} doesn't matches company currency.").format(
|
||||
_("Row #{0}: Currency of {1} - {2} does not match company currency.").format(
|
||||
d.idx, frappe.bold(frappe.unscrub(d.type)), frappe.bold(d.account)
|
||||
),
|
||||
title=_("Invalid Account"),
|
||||
@@ -117,10 +117,11 @@ class AssetCategory(Document):
|
||||
missing_cwip_accounts_for_company.append(get_link_to_form("Company", d.company_name))
|
||||
|
||||
if missing_cwip_accounts_for_company:
|
||||
msg = _("""To enable Capital Work in Progress Accounting,""") + " "
|
||||
msg += _("""you must select Capital Work in Progress Account in accounts table""")
|
||||
msg = _(
|
||||
"To enable Capital Work in Progress Accounting, you must select Capital Work in Progress Account in accounts table"
|
||||
)
|
||||
msg += "<br><br>"
|
||||
msg += _("You can also set default CWIP account in Company {}").format(
|
||||
msg += _("You can also set default CWIP account in Company {0}").format(
|
||||
", ".join(missing_cwip_accounts_for_company)
|
||||
)
|
||||
frappe.throw(msg, title=_("Missing Account"))
|
||||
|
||||
@@ -173,9 +173,9 @@ class DepreciationScheduleController(StraightLineMethod, WDVMethod):
|
||||
if days <= 0:
|
||||
frappe.throw(
|
||||
_(
|
||||
"""Error: This asset already has {0} depreciation periods booked.
|
||||
The `depreciation start` date must be at least {1} periods after the `available for use` date.
|
||||
Please correct the dates accordingly."""
|
||||
"Error: This asset already has {0} depreciation periods booked. "
|
||||
"The `depreciation start` date must be at least {1} periods after the `available for use` date. "
|
||||
"Please correct the dates accordingly."
|
||||
).format(
|
||||
self.asset_doc.opening_number_of_booked_depreciations,
|
||||
self.asset_doc.opening_number_of_booked_depreciations,
|
||||
|
||||
@@ -293,8 +293,8 @@ class AssetRepair(AccountsController):
|
||||
if not stock_item.serial_and_batch_bundle and frappe.get_cached_value(
|
||||
"Item", stock_item.item_code, "has_serial_no"
|
||||
):
|
||||
msg = f"Serial No Bundle is mandatory for Item {stock_item.item_code}"
|
||||
frappe.throw(_(msg), title=_("Missing Serial No Bundle"))
|
||||
msg = _("Serial No Bundle is mandatory for Item {0}").format(stock_item.item_code)
|
||||
frappe.throw(msg, title=_("Missing Serial No Bundle"))
|
||||
|
||||
if stock_item.serial_and_batch_bundle:
|
||||
values_to_update = {
|
||||
|
||||
@@ -3,11 +3,26 @@
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
|
||||
from erpnext.assets.doctype.asset.test_asset import AssetSetup, create_asset
|
||||
from erpnext.assets.doctype.asset_capitalization.test_asset_capitalization import (
|
||||
create_asset_capitalization,
|
||||
)
|
||||
from erpnext.assets.doctype.asset_value_adjustment.test_asset_value_adjustment import (
|
||||
make_asset_value_adjustment,
|
||||
)
|
||||
from erpnext.assets.report.fixed_asset_register.fixed_asset_register import execute
|
||||
|
||||
|
||||
class TestFixedAssetRegister(AssetSetup):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(company="_Test Company", **extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def report_row(self, asset_name, **extra):
|
||||
return next(row for row in self.run_report(**extra) if row["asset_id"] == asset_name)
|
||||
|
||||
def test_report_lists_submitted_asset(self):
|
||||
"""Exercises the report's converted queries -- including the depreciation aggregate that groups
|
||||
by asset.name (must be valid on Postgres) -- by asserting a submitted asset is listed."""
|
||||
@@ -18,16 +33,170 @@ class TestFixedAssetRegister(AssetSetup):
|
||||
location="Test Location",
|
||||
submit=1,
|
||||
)
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"status": "In Location",
|
||||
"filter_based_on": "Date Range",
|
||||
"from_date": "2020-01-01",
|
||||
"to_date": "2030-12-31",
|
||||
"date_based_on": "Purchase Date",
|
||||
}
|
||||
ids = {
|
||||
row["asset_id"]
|
||||
for row in self.run_report(
|
||||
status="In Location",
|
||||
filter_based_on="Date Range",
|
||||
from_date="2020-01-01",
|
||||
to_date="2030-12-31",
|
||||
date_based_on="Purchase Date",
|
||||
)
|
||||
}
|
||||
self.assertIn(asset.name, ids)
|
||||
|
||||
def test_asset_appears_with_purchase_value(self):
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro", net_purchase_amount=100000, purchase_amount=100000, submit=True
|
||||
)
|
||||
data = execute(filters)[1]
|
||||
asset_ids = {row.get("asset_id") for row in data}
|
||||
self.assertIn(asset.name, asset_ids)
|
||||
|
||||
row = self.report_row(asset.name)
|
||||
self.assertEqual(row["net_purchase_amount"], 100000)
|
||||
self.assertEqual(row["asset_value"], 100000) # no depreciation yet
|
||||
self.assertEqual(row["asset_category"], "Computers")
|
||||
|
||||
def test_asset_value_reduced_by_opening_depreciation(self):
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro",
|
||||
net_purchase_amount=100000,
|
||||
purchase_amount=100000,
|
||||
opening_accumulated_depreciation=20000,
|
||||
opening_number_of_booked_depreciations=2,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
row = self.report_row(asset.name)
|
||||
self.assertEqual(row["opening_accumulated_depreciation"], 20000)
|
||||
self.assertEqual(row["asset_value"], 80000) # 100000 - 20000
|
||||
|
||||
def test_status_in_location_filter_shows_active_asset(self):
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro", net_purchase_amount=100000, purchase_amount=100000, submit=True
|
||||
)
|
||||
|
||||
ids = {row["asset_id"] for row in self.run_report(status="In Location")}
|
||||
self.assertIn(asset.name, ids)
|
||||
|
||||
def test_asset_category_filter(self):
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro", net_purchase_amount=100000, purchase_amount=100000, submit=True
|
||||
)
|
||||
|
||||
ids = {row["asset_id"] for row in self.run_report(asset_category="Computers")}
|
||||
self.assertIn(asset.name, ids)
|
||||
|
||||
def test_group_by_asset_category_sums_values(self):
|
||||
before_net, before_value = self.computers_group_totals()
|
||||
|
||||
create_asset(item_code="Macbook Pro", net_purchase_amount=100000, purchase_amount=100000, submit=True)
|
||||
create_asset(
|
||||
item_code="Macbook Pro",
|
||||
asset_name="Macbook Pro 2",
|
||||
net_purchase_amount=50000,
|
||||
purchase_amount=50000,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
after_net, after_value = self.computers_group_totals()
|
||||
# assert on the delta so pre-existing Computers assets don't skew the totals
|
||||
self.assertEqual(after_net - before_net, 150000)
|
||||
self.assertEqual(after_value - before_value, 150000)
|
||||
|
||||
def computers_group_totals(self):
|
||||
row = next(
|
||||
(r for r in self.run_report(group_by="Asset Category") if r["asset_category"] == "Computers"),
|
||||
None,
|
||||
)
|
||||
return (row["net_purchase_amount"], row["asset_value"]) if row else (0, 0)
|
||||
|
||||
def test_booked_depreciation_reduces_asset_value(self):
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro",
|
||||
calculate_depreciation=1,
|
||||
available_for_use_date="2019-12-31",
|
||||
depreciation_start_date="2020-12-31",
|
||||
frequency_of_depreciation=12,
|
||||
total_number_of_depreciations=3,
|
||||
expected_value_after_useful_life=10000,
|
||||
net_purchase_amount=100000,
|
||||
purchase_amount=100000,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
# books one depreciation entry of (100000 - 10000) / 3 = 30000
|
||||
post_depreciation_entries(date="2021-01-01")
|
||||
|
||||
row = self.report_row(asset.name)
|
||||
self.assertEqual(row["depreciated_amount"], 30000)
|
||||
self.assertEqual(row["asset_value"], 70000) # 100000 - 30000
|
||||
|
||||
def test_revaluation_adjusts_asset_value(self):
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro", net_purchase_amount=100000, purchase_amount=100000, submit=True
|
||||
)
|
||||
|
||||
# revalue the asset upwards by 20000
|
||||
make_asset_value_adjustment(
|
||||
asset=asset.name, current_asset_value=100000, new_asset_value=120000
|
||||
).submit()
|
||||
|
||||
row = self.report_row(asset.name)
|
||||
self.assertEqual(row["asset_value"], 120000) # 100000 + 20000 revaluation
|
||||
|
||||
def test_depreciation_and_revaluation_together(self):
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro",
|
||||
calculate_depreciation=1,
|
||||
available_for_use_date="2019-12-31",
|
||||
depreciation_start_date="2020-12-31",
|
||||
frequency_of_depreciation=12,
|
||||
total_number_of_depreciations=3,
|
||||
expected_value_after_useful_life=10000,
|
||||
net_purchase_amount=100000,
|
||||
purchase_amount=100000,
|
||||
submit=True,
|
||||
)
|
||||
|
||||
# books one depreciation entry of (100000 - 10000) / 3 = 30000, leaving 70000
|
||||
post_depreciation_entries(date="2021-01-01")
|
||||
|
||||
# revalue the depreciated asset down from 70000 to 60000
|
||||
make_asset_value_adjustment(
|
||||
asset=asset.name, current_asset_value=70000, new_asset_value=60000
|
||||
).submit()
|
||||
|
||||
row = self.report_row(asset.name)
|
||||
self.assertEqual(row["depreciated_amount"], 30000)
|
||||
self.assertEqual(row["asset_value"], 60000) # 100000 - 30000 depreciation - 10000 revaluation
|
||||
|
||||
def test_sold_asset_hidden_from_in_location_and_shown_in_disposed(self):
|
||||
asset = create_asset(
|
||||
item_code="Macbook Pro", net_purchase_amount=100000, purchase_amount=100000, submit=True
|
||||
)
|
||||
|
||||
create_sales_invoice(item_code="Macbook Pro", asset=asset.name, qty=1, rate=80000)
|
||||
self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold")
|
||||
|
||||
self.assertNotIn(asset.name, {row["asset_id"] for row in self.run_report(status="In Location")})
|
||||
self.assertIn(asset.name, {row["asset_id"] for row in self.run_report(status="Disposed")})
|
||||
|
||||
def test_capitalized_asset_hidden_from_in_location_and_shown_in_disposed(self):
|
||||
consumed_asset = create_asset(
|
||||
asset_name="Consumed Asset",
|
||||
net_purchase_amount=100000,
|
||||
purchase_amount=100000,
|
||||
submit=True,
|
||||
)
|
||||
composite_asset = create_asset(
|
||||
asset_name="Composite Asset", asset_type="Composite Asset", submit=False
|
||||
)
|
||||
|
||||
create_asset_capitalization(
|
||||
target_asset=composite_asset.name, consumed_asset=consumed_asset.name, submit=1
|
||||
)
|
||||
self.assertEqual(frappe.db.get_value("Asset", consumed_asset.name, "status"), "Capitalized")
|
||||
|
||||
self.assertNotIn(
|
||||
consumed_asset.name, {row["asset_id"] for row in self.run_report(status="In Location")}
|
||||
)
|
||||
self.assertIn(consumed_asset.name, {row["asset_id"] for row in self.run_report(status="Disposed")})
|
||||
|
||||
@@ -27,8 +27,7 @@ def make_purchase_receipt(
|
||||
):
|
||||
if args is None:
|
||||
args = {}
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
args = frappe.parse_json(args)
|
||||
|
||||
has_unit_price_items = frappe.db.get_value("Purchase Order", source_name, "has_unit_price_items")
|
||||
|
||||
@@ -123,8 +122,7 @@ def make_purchase_invoice_from_portal(purchase_order_name: str):
|
||||
def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions=False, args=None):
|
||||
if args is None:
|
||||
args = {}
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
args = frappe.parse_json(args)
|
||||
|
||||
def postprocess(source, target):
|
||||
target.flags.ignore_permissions = ignore_permissions
|
||||
@@ -294,7 +292,7 @@ def get_mapped_subcontracting_order(source_name: str, target_doc: str | Document
|
||||
) or frappe.get_value("Production Plan", target_doc.production_plan, "reserve_stock")
|
||||
|
||||
if target_doc and isinstance(target_doc, str):
|
||||
target_doc = json.loads(target_doc)
|
||||
target_doc = frappe.parse_json(target_doc)
|
||||
for key in ["service_items", "items", "supplied_items"]:
|
||||
if key in target_doc:
|
||||
del target_doc[key]
|
||||
|
||||
@@ -590,16 +590,14 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
me.frm.doc.items[i].qty = my_qty;
|
||||
|
||||
frappe.msgprint(
|
||||
"Assigning " +
|
||||
d.mr_name +
|
||||
" to " +
|
||||
d.item_code +
|
||||
" (row " +
|
||||
me.frm.doc.items[i].idx +
|
||||
")"
|
||||
__("Assigning {0} to {1} (row {2})", [
|
||||
d.mr_name,
|
||||
d.item_code,
|
||||
me.frm.doc.items[i].idx,
|
||||
])
|
||||
);
|
||||
if (qty > 0) {
|
||||
frappe.msgprint("Splitting " + qty + " units of " + d.item_code);
|
||||
frappe.msgprint(__("Splitting {0} units of {1}", [qty, d.item_code]));
|
||||
var new_row = frappe.model.add_child(
|
||||
me.frm.doc,
|
||||
me.frm.doc.items[i].doctype,
|
||||
|
||||
@@ -549,11 +549,11 @@ def item_last_purchase_rate(name, conversion_rate, item_code, conversion_factor=
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def close_or_unclose_purchase_orders(names: str, status: str):
|
||||
def close_or_unclose_purchase_orders(names: str | list, status: str):
|
||||
if not frappe.has_permission("Purchase Order", "write"):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
names = json.loads(names)
|
||||
names = frappe.parse_json(names)
|
||||
for name in names:
|
||||
po = frappe.get_lazy_doc("Purchase Order", name)
|
||||
if po.docstatus == 1:
|
||||
|
||||
@@ -36,7 +36,7 @@ class SubcontractingService:
|
||||
)
|
||||
)
|
||||
if not item.fg_item_qty:
|
||||
frappe.throw(_("Row #{0}: Finished Good Item Qty can not be zero").format(item.idx))
|
||||
frappe.throw(_("Row #{0}: Finished Good Item Qty cannot be zero").format(item.idx))
|
||||
else:
|
||||
for item in doc.items:
|
||||
item.set("fg_item", None)
|
||||
|
||||
@@ -57,8 +57,7 @@ def make_supplier_quotation_from_rfq(
|
||||
# This method is used to make supplier quotation from supplier's portal.
|
||||
@frappe.whitelist()
|
||||
def create_supplier_quotation(doc: str | Document | dict):
|
||||
if isinstance(doc, str):
|
||||
doc = json.loads(doc)
|
||||
doc = frappe.parse_json(doc)
|
||||
|
||||
if frappe.session.user not in frappe.get_all(
|
||||
"Portal User", {"parent": doc.get("supplier")}, pluck="user"
|
||||
|
||||
@@ -421,7 +421,7 @@ def check_portal_enabled(reference_doctype):
|
||||
if not frappe.db.get_value("Portal Menu Item", {"reference_doctype": reference_doctype}, "enabled"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"The Access to Request for Quotation From Portal is Disabled. To Allow Access, Enable it in Portal Settings."
|
||||
"Access to Request for Quotation from the portal is disabled. To allow access, enable it in Portal Settings."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ def make_purchase_order(
|
||||
):
|
||||
if args is None:
|
||||
args = {}
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
args = frappe.parse_json(args)
|
||||
|
||||
def set_missing_values(source, target):
|
||||
target.run_method("set_missing_values")
|
||||
|
||||
@@ -43,11 +43,11 @@ class SupplierScorecardVariable(Document):
|
||||
|
||||
import_string_path(self.path)
|
||||
except AttributeError:
|
||||
frappe.throw(_("Could not find path for " + self.path), VariablePathNotFound)
|
||||
frappe.throw(_("Could not find path for {0}").format(self.path), VariablePathNotFound)
|
||||
|
||||
else:
|
||||
if not hasattr(sys.modules[__name__], self.path):
|
||||
frappe.throw(_("Could not find path for " + self.path), VariablePathNotFound)
|
||||
frappe.throw(_("Could not find path for {0}").format(self.path), VariablePathNotFound)
|
||||
|
||||
|
||||
def get_total_workdays(scorecard):
|
||||
|
||||
@@ -124,12 +124,12 @@ def check_on_hold_or_closed_status(doctype, docname) -> None:
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_linked_material_requests(items: str):
|
||||
def get_linked_material_requests(items: str | list):
|
||||
"""
|
||||
Retrieve Material Requests linked to a list of items.
|
||||
"""
|
||||
|
||||
items = json.loads(items)
|
||||
items = frappe.parse_json(items)
|
||||
mr_list = []
|
||||
|
||||
mr = frappe.qb.DocType("Material Request")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user