mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-21 11:52:30 +00:00
Compare commits
189 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1d3b241ae | ||
|
|
9cdaa738f1 | ||
|
|
56d065b919 | ||
|
|
66912173bd | ||
|
|
1b1ea7f2aa | ||
|
|
fd00cebbd2 | ||
|
|
fe6c276e72 | ||
|
|
613b3c16c2 | ||
|
|
666becc670 | ||
|
|
628b932d55 | ||
|
|
b5c09fa993 | ||
|
|
66f99668a9 | ||
|
|
9c5b063884 | ||
|
|
52ea9641ce | ||
|
|
7396fca4be | ||
|
|
38994ec915 | ||
|
|
a1412a61c9 | ||
|
|
62703de452 | ||
|
|
d2c8df9451 | ||
|
|
05aa68b53b | ||
|
|
169a4c82a4 | ||
|
|
0e730bf1c7 | ||
|
|
2c18c16be6 | ||
|
|
01374db8da | ||
|
|
5d8f653476 | ||
|
|
55716a509f | ||
|
|
c782658056 | ||
|
|
41812462b4 | ||
|
|
0829cb45a8 | ||
|
|
762ce5c684 | ||
|
|
5aa62d1cda | ||
|
|
3abadc7a5f | ||
|
|
1a348d8e6e | ||
|
|
1eed77e605 | ||
|
|
490e125267 | ||
|
|
1a2a9b6cfc | ||
|
|
93b28f3337 | ||
|
|
1a9fcfb0cc | ||
|
|
a8aa73baae | ||
|
|
65202d2be1 | ||
|
|
1cc2890f3e | ||
|
|
e834098c28 | ||
|
|
655d6dac87 | ||
|
|
36a9c7a0d3 | ||
|
|
b35c5c1568 | ||
|
|
e64e9866fb | ||
|
|
b292be34cb | ||
|
|
0b42241682 | ||
|
|
df1b4431b6 | ||
|
|
112b773193 | ||
|
|
b4d83e542a | ||
|
|
9bc240c4f0 | ||
|
|
1b7486f61a | ||
|
|
2eaa635ab6 | ||
|
|
733a837c18 | ||
|
|
a886d0b445 | ||
|
|
410a772510 | ||
|
|
2404074b9d | ||
|
|
5f02ff5f8c | ||
|
|
6dad1b1ae6 | ||
|
|
a00874fa95 | ||
|
|
c84d4a282d | ||
|
|
30ba950abd | ||
|
|
ef3d444a60 | ||
|
|
4e94b75b5d | ||
|
|
831d25bed7 | ||
|
|
37ec2d0edd | ||
|
|
85ba1231f8 | ||
|
|
a24b690a14 | ||
|
|
8dd37b6df0 | ||
|
|
de70f2cceb | ||
|
|
89059a990f | ||
|
|
a9a371e4a4 | ||
|
|
bd54c7fea8 | ||
|
|
0c502eaa18 | ||
|
|
e3958ad7bb | ||
|
|
bc313dc09d | ||
|
|
5c716b0547 | ||
|
|
b3871a212c | ||
|
|
e9a9224ec4 | ||
|
|
b08de1f1e5 | ||
|
|
18400b58ce | ||
|
|
e46be25e9f | ||
|
|
570c67bb34 | ||
|
|
e71b066eec | ||
|
|
eee826dfb6 | ||
|
|
8a665709d2 | ||
|
|
267086153b | ||
|
|
66b28cf456 | ||
|
|
d389014e57 | ||
|
|
54c45d7b22 | ||
|
|
e9a26b5086 | ||
|
|
872c86e223 | ||
|
|
d54938fa64 | ||
|
|
4555c323af | ||
|
|
1d0edf1b9a | ||
|
|
2b6f2c2f9c | ||
|
|
00ba64baae | ||
|
|
3dc128881c | ||
|
|
d40c36a4b1 | ||
|
|
7b50f67b55 | ||
|
|
b40445fe44 | ||
|
|
77121f2a41 | ||
|
|
a80de9bd01 | ||
|
|
be605adbc1 | ||
|
|
29323cb0b1 | ||
|
|
17324ec45b | ||
|
|
f5d05b969b | ||
|
|
c788106011 | ||
|
|
8b56b7ba0e | ||
|
|
2035fac494 | ||
|
|
e5c9e7abdc | ||
|
|
b4f2b6cab2 | ||
|
|
373476042e | ||
|
|
c066880978 | ||
|
|
7a1def07e9 | ||
|
|
0051950afb | ||
|
|
97279c7e26 | ||
|
|
0be5ba6bac | ||
|
|
3a480c08b1 | ||
|
|
a67b489d17 | ||
|
|
3df7a28476 | ||
|
|
694ebb53ec | ||
|
|
9643720858 | ||
|
|
bcd72a7fec | ||
|
|
768425ebf1 | ||
|
|
5d031c0a04 | ||
|
|
476054f684 | ||
|
|
570ef45e46 | ||
|
|
2462be0e61 | ||
|
|
d3631860db | ||
|
|
3b734f4d5d | ||
|
|
f3307b3ca9 | ||
|
|
82e1221dc9 | ||
|
|
4d055d374a | ||
|
|
8e3fbab94a | ||
|
|
6f9954bb62 | ||
|
|
3f53af8b1f | ||
|
|
9469889bd5 | ||
|
|
6e61ee8d70 | ||
|
|
dcf076aad6 | ||
|
|
edd18fd650 | ||
|
|
62209348a4 | ||
|
|
537225494c | ||
|
|
bbb3181c6e | ||
|
|
20b14395e3 | ||
|
|
6ac699d3bb | ||
|
|
e6e5591088 | ||
|
|
635c51acf7 | ||
|
|
e605675e11 | ||
|
|
bf58393fda | ||
|
|
88ce356d62 | ||
|
|
396feadace | ||
|
|
c0dab55fcc | ||
|
|
cb47745d8c | ||
|
|
f4b827cb3d | ||
|
|
dc9ae20db8 | ||
|
|
6cb42ab8b1 | ||
|
|
35e06045bd | ||
|
|
6c37acc180 | ||
|
|
42c121a750 | ||
|
|
1e027364e3 | ||
|
|
d2fee32eb3 | ||
|
|
21912402c0 | ||
|
|
43b355eaf6 | ||
|
|
175aac4156 | ||
|
|
30650f298b | ||
|
|
3110ab1c57 | ||
|
|
40110d83c9 | ||
|
|
dbc831e008 | ||
|
|
686437bd54 | ||
|
|
58d5f39e0a | ||
|
|
c7dbedbfdc | ||
|
|
8e21af0a63 | ||
|
|
87e498cd7d | ||
|
|
f8aa4c730c | ||
|
|
a335838691 | ||
|
|
1f075d4bbf | ||
|
|
7f441864d6 | ||
|
|
2b28b7e694 | ||
|
|
56d9cbabbf | ||
|
|
4481efec17 | ||
|
|
84a1a51023 | ||
|
|
98f45221e6 | ||
|
|
846e0a9f06 | ||
|
|
6185507614 | ||
|
|
d051407126 | ||
|
|
3d91e021a3 | ||
|
|
a6310351fd |
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."
|
||||
342
.github/helper/install.sh
vendored
342
.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,76 +117,247 @@ 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;
|
||||
|
||||
# Disposable CI DB: durability off for speed (postgres fsyncs every commit by default, which
|
||||
# dominates a commit-heavy suite). All reloadable, no restart. The postgres workflow runs a
|
||||
# service-container DB and never calls start-db.sh, so the flags must be applied here.
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -U postgres \
|
||||
-c "ALTER SYSTEM SET synchronous_commit = 'off'" \
|
||||
-c "ALTER SYSTEM SET fsync = 'off'" \
|
||||
-c "ALTER SYSTEM SET full_page_writes = 'off'" \
|
||||
-c "SELECT pg_reload_conf()";
|
||||
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)"
|
||||
2
.github/workflows/linters.yml
vendored
2
.github/workflows/linters.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
cache: pip
|
||||
|
||||
- name: Install and Run Pre-commit
|
||||
uses: pre-commit/action@v3.0.0
|
||||
uses: pre-commit/action@v3.0.1
|
||||
|
||||
semgrep:
|
||||
name: semgrep
|
||||
|
||||
1
.github/workflows/patch.yml
vendored
1
.github/workflows/patch.yml
vendored
@@ -134,6 +134,7 @@ jobs:
|
||||
|
||||
# Resetup env and install apps
|
||||
pgrep honcho | xargs kill
|
||||
sleep 10
|
||||
rm -rf ~/frappe-bench/env
|
||||
bench -v setup env --python python$2
|
||||
bench pip install -e ./apps/erpnext
|
||||
|
||||
@@ -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
|
||||
|
||||
203
.github/workflows/server-tests-mariadb.yml
vendored
203
.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,88 @@ 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"
|
||||
|
||||
- name: Warm up test data
|
||||
run: |
|
||||
su -m "${ERPNEXT_CI_USER:-frappe}" -s /bin/bash <<'EOF'
|
||||
cd ~/frappe-bench/
|
||||
bench --site test_site run-tests --lightmode --module erpnext.tests.bootstrap_test_data
|
||||
EOF
|
||||
|
||||
# Clean shutdown (consistent InnoDB datadir), then stage it inside the bench for packaging.
|
||||
- name: Stop DB and stage datadir
|
||||
run: |
|
||||
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 +190,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 +203,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:
|
||||
|
||||
@@ -48,7 +48,6 @@ repos:
|
||||
cypress/.*|
|
||||
.*node_modules.*|
|
||||
.*boilerplate.*|
|
||||
erpnext/public/js/controllers/.*|
|
||||
erpnext/templates/pages/order.js|
|
||||
erpnext/templates/includes/.*
|
||||
)$
|
||||
|
||||
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "16.23.1"
|
||||
__version__ = "16.26.2"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import getdate, nowdate
|
||||
|
||||
|
||||
class OverlapError(frappe.ValidationError):
|
||||
@@ -36,8 +37,20 @@ class AccountingPeriod(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
self.validate_dates()
|
||||
self.validate_overlap()
|
||||
|
||||
def validate_dates(self):
|
||||
if getdate(self.start_date) > getdate(self.end_date):
|
||||
frappe.throw(_("Start Date cannot be after End Date"))
|
||||
|
||||
if getdate(self.end_date) > getdate(nowdate()):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Accounting Period cannot be created for a future date. End Date {0} is after today."
|
||||
).format(frappe.bold(frappe.format(self.end_date, "Date")))
|
||||
)
|
||||
|
||||
def before_insert(self):
|
||||
self.bootstrap_doctypes_for_closing()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_months, nowdate
|
||||
from frappe.utils import nowdate
|
||||
|
||||
from erpnext.accounts.doctype.accounting_period.accounting_period import (
|
||||
ClosedAccountingPeriod,
|
||||
@@ -94,7 +94,7 @@ def create_accounting_period(**args):
|
||||
|
||||
accounting_period = frappe.new_doc("Accounting Period")
|
||||
accounting_period.start_date = args.start_date or nowdate()
|
||||
accounting_period.end_date = args.end_date or add_months(nowdate(), 1)
|
||||
accounting_period.end_date = args.end_date or nowdate()
|
||||
accounting_period.company = args.company or "_Test Company"
|
||||
accounting_period.period_name = args.period_name or "_Test_Period_Name_1"
|
||||
accounting_period.append("closed_documents", {"document_type": "Sales Invoice", "closed": 1})
|
||||
|
||||
@@ -10,7 +10,7 @@ frappe.ui.form.on("Accounts Settings", {
|
||||
},
|
||||
};
|
||||
});
|
||||
if (!frm.naming_controller) frm.naming_controller = new erpnext.NamingSeriesController(frm);
|
||||
if (!frm.naming_controller) frm.naming_controller = new frappe.ui.NamingSeriesController(frm);
|
||||
|
||||
frm.naming_controller.render_table("transaction_naming_html", get_transactions(frm));
|
||||
},
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"period_closing_settings_section",
|
||||
"ignore_account_closing_balance",
|
||||
"use_legacy_controller_for_pcv",
|
||||
"pcv_job_timeout",
|
||||
"column_break_25",
|
||||
"reports_tab",
|
||||
"remarks_section",
|
||||
@@ -611,6 +612,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",
|
||||
@@ -748,7 +757,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-15 18:26:50.778723",
|
||||
"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
|
||||
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
|
||||
receivable_payable_remarks_length: DF.Int
|
||||
reconciliation_queue_size: DF.Int
|
||||
|
||||
@@ -22,11 +22,13 @@ class TestAdvancePaymentLedgerEntry(ERPNextTestSuite, AccountsTestMixin):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_usd_receivable_account()
|
||||
self.create_usd_payable_account()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.supplier = "_Test Supplier"
|
||||
self.item = "_Test Item"
|
||||
self.cash = "Cash - _TC"
|
||||
self.debtors_usd = "_Test Receivable USD - _TC"
|
||||
self.creditors_usd = "_Test Payable USD - _TC"
|
||||
|
||||
def create_sales_order(self, qty=1, rate=100, currency="INR", do_not_submit=False):
|
||||
"""
|
||||
|
||||
@@ -9,6 +9,13 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
|
||||
|
||||
frappe.ui.form.on("Bank Guarantee", {
|
||||
setup: function (frm) {
|
||||
frm.set_query("reference_doctype", function () {
|
||||
return {
|
||||
filters: {
|
||||
name: ["in", ["Sales Order", "Purchase Order"]],
|
||||
},
|
||||
};
|
||||
});
|
||||
frm.set_query("bank_account", function () {
|
||||
return {
|
||||
filters: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "ACC-BG-.YYYY.-.#####",
|
||||
"creation": "2016-12-17 10:43:35.731631",
|
||||
"doctype": "DocType",
|
||||
@@ -50,8 +51,7 @@
|
||||
"fieldname": "reference_doctype",
|
||||
"fieldtype": "Link",
|
||||
"label": "Reference Document Type",
|
||||
"options": "DocType",
|
||||
"read_only": 1
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_docname",
|
||||
@@ -60,14 +60,14 @@
|
||||
"options": "reference_doctype"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.bg_type == \"Receiving\"",
|
||||
"depends_on": "eval: doc.reference_doctype == \"Sales Order\"",
|
||||
"fieldname": "customer",
|
||||
"fieldtype": "Link",
|
||||
"label": "Customer",
|
||||
"options": "Customer"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.bg_type == \"Providing\"",
|
||||
"depends_on": "eval: doc.reference_doctype == \"Purchase Order\"",
|
||||
"fieldname": "supplier",
|
||||
"fieldtype": "Link",
|
||||
"label": "Supplier",
|
||||
@@ -218,10 +218,11 @@
|
||||
"grid_page_length": 50,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-29 11:52:33.550847",
|
||||
"modified": "2026-05-25 18:12:10.768835",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Guarantee",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -64,7 +64,7 @@ def get_bank_transactions(
|
||||
filters.append(["date", "<=", to_date])
|
||||
if from_date:
|
||||
filters.append(["date", ">=", from_date])
|
||||
transactions = frappe.get_all(
|
||||
transactions = frappe.get_list(
|
||||
"Bank Transaction",
|
||||
fields=[
|
||||
"date",
|
||||
@@ -93,6 +93,7 @@ def get_bank_transactions(
|
||||
@frappe.whitelist()
|
||||
def get_account_balance(bank_account: str, till_date: str | date, company: str):
|
||||
# returns account balance till the specified date
|
||||
frappe.has_permission("Bank Account", "read", bank_account, throw=True)
|
||||
account = frappe.db.get_value("Bank Account", bank_account, "account")
|
||||
filters = frappe._dict(
|
||||
{
|
||||
|
||||
@@ -17,9 +17,10 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.bank = "HDFC - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
bank_dt = qb.DocType("Bank")
|
||||
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
||||
self.create_bank_account()
|
||||
|
||||
@@ -26,9 +26,9 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.bank = "HDFC - _TC"
|
||||
bank_dt = qb.DocType("Bank")
|
||||
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
||||
self.create_bank_account()
|
||||
|
||||
@@ -11,9 +11,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestBankTransactionRule(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.bank = "HDFC - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cash = "Cash - _TC"
|
||||
bank_dt = qb.DocType("Bank")
|
||||
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
||||
self.create_bank_account()
|
||||
|
||||
@@ -136,6 +136,9 @@ function set_total_budget_amount(frm) {
|
||||
function toggle_distribution_fields(frm) {
|
||||
const grid = frm.fields_dict.budget_distribution.grid;
|
||||
|
||||
frm.set_df_property("budget_distribution", "cannot_add_rows", true);
|
||||
frm.set_df_property("budget_distribution", "cannot_delete_rows", true);
|
||||
|
||||
["amount", "percent"].forEach((field) => {
|
||||
grid.update_docfield_property(field, "read_only", frm.doc.distribute_equally);
|
||||
});
|
||||
|
||||
@@ -159,9 +159,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):
|
||||
@@ -355,8 +355,8 @@ class Budget(Document):
|
||||
if self.should_regenerate_budget_distribution():
|
||||
return
|
||||
|
||||
total_amount = sum(d.amount for d in self.budget_distribution)
|
||||
total_percent = sum(d.percent for d in self.budget_distribution)
|
||||
total_amount = sum(flt(d.amount) for d in self.budget_distribution)
|
||||
total_percent = sum(flt(d.percent) for d in self.budget_distribution)
|
||||
|
||||
if flt(abs(total_amount - self.budget_amount), 2) > 0.10:
|
||||
frappe.throw(
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"in_list_view": 1,
|
||||
"label": "Start Date",
|
||||
"read_only": 1,
|
||||
"reqd": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
@@ -25,26 +26,29 @@
|
||||
"fieldtype": "Date",
|
||||
"in_list_view": 1,
|
||||
"label": "End Date",
|
||||
"read_only": 1
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Amount"
|
||||
"label": "Amount",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "percent",
|
||||
"fieldtype": "Percent",
|
||||
"in_list_view": 1,
|
||||
"label": "Percent"
|
||||
"label": "Percent",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-03 13:18:28.398198",
|
||||
"modified": "2026-06-18 11:23:17.669733",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Budget Distribution",
|
||||
|
||||
@@ -15,12 +15,12 @@ class BudgetDistribution(Document):
|
||||
from frappe.types import DF
|
||||
|
||||
amount: DF.Currency
|
||||
end_date: DF.Date | None
|
||||
end_date: DF.Date
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
percent: DF.Percent
|
||||
start_date: DF.Date | None
|
||||
start_date: DF.Date
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
@@ -75,7 +75,10 @@ def validate_company(company):
|
||||
|
||||
@frappe.whitelist()
|
||||
def import_coa(file_name, company):
|
||||
frappe.only_for("Accounts Manager")
|
||||
|
||||
# delete existing data for accounts
|
||||
frappe.has_permission("Company", "write", company, throw=True)
|
||||
unset_existing_data(company)
|
||||
|
||||
# create accounts
|
||||
@@ -451,6 +454,7 @@ def unset_existing_data(company):
|
||||
fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", [])
|
||||
linked = [{"fieldname": name} for name in fieldnames]
|
||||
update_values = {d.get("fieldname"): "" for d in linked}
|
||||
|
||||
frappe.db.set_value("Company", company, update_values, update_values)
|
||||
|
||||
# remove accounts data from various doctypes
|
||||
@@ -462,8 +466,7 @@ def unset_existing_data(company):
|
||||
"Sales Taxes and Charges Template",
|
||||
"Purchase Taxes and Charges Template",
|
||||
]:
|
||||
dt = frappe.qb.DocType(doctype)
|
||||
frappe.qb.from_(dt).where(dt.company == company).delete().run()
|
||||
frappe.get_query(doctype, delete=True, filters={"company": company}, ignore_permissions=False).run()
|
||||
|
||||
|
||||
def set_default_accounts(company):
|
||||
|
||||
@@ -616,6 +616,10 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
|
||||
def get_account_details(
|
||||
company, posting_date, account, party_type=None, party=None, rounding_loss_allowance: float | None = None
|
||||
):
|
||||
if not account:
|
||||
return
|
||||
frappe.has_permission("Account", doc=account, throw=True)
|
||||
|
||||
if not (company and posting_date):
|
||||
frappe.throw(_("Company and Posting Date is mandatory"))
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_usd_receivable_account()
|
||||
self.create_item()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.item = "_Test Item"
|
||||
self.customer = "_Test Customer"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.debtors_usd = "_Test Receivable USD - _TC"
|
||||
self.set_system_and_company_settings()
|
||||
|
||||
def set_system_and_company_settings(self):
|
||||
|
||||
@@ -19,6 +19,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
|
||||
validate_docs_for_voucher_types,
|
||||
)
|
||||
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import JournalTaxWithholding
|
||||
from erpnext.accounts.general_ledger import validate_opening_entry_against_pcv
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.utils import (
|
||||
cancel_exchange_gain_loss_journal,
|
||||
@@ -131,6 +132,9 @@ class JournalEntry(AccountsController):
|
||||
if not self.is_opening:
|
||||
self.is_opening = "No"
|
||||
|
||||
if self.is_opening == "Yes":
|
||||
validate_opening_entry_against_pcv(self.company)
|
||||
|
||||
self.clearance_date = None
|
||||
|
||||
self.validate_party()
|
||||
|
||||
@@ -12,10 +12,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestLedgerHealth(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.configure_monitoring_tool()
|
||||
self.clear_old_entries()
|
||||
|
||||
def configure_monitoring_tool(self):
|
||||
monitor_settings = frappe.get_doc("Ledger Health Monitor")
|
||||
|
||||
@@ -74,29 +74,31 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
|
||||
},
|
||||
|
||||
setup_company_filters: function (frm) {
|
||||
frm.set_query("cost_center", "invoices", function (doc, cdt, cdn) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
frm.events.apply_company_query_filter(frm, "cost_center", "invoices", { is_group: 0 });
|
||||
frm.events.apply_company_query_filter(frm, "project", "invoices");
|
||||
frm.events.apply_company_query_filter(frm, "project");
|
||||
frm.events.apply_company_query_filter(frm, "cost_center", undefined, { is_group: 0 });
|
||||
frm.events.apply_company_query_filter(frm, "temporary_opening_account", "invoices", {
|
||||
account_type: "Temporary",
|
||||
is_group: 0,
|
||||
});
|
||||
},
|
||||
|
||||
frm.set_query("cost_center", function (doc) {
|
||||
apply_company_query_filter: function (frm, field_name, child_doctype = null, filters = {}) {
|
||||
const query = function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
...filters,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
frm.set_query("temporary_opening_account", "invoices", function (doc, cdt, cdn) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (child_doctype) {
|
||||
frm.set_query(field_name, child_doctype, query);
|
||||
} else {
|
||||
frm.set_query(field_name, query);
|
||||
}
|
||||
},
|
||||
|
||||
company: function (frm) {
|
||||
@@ -120,11 +122,6 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
|
||||
},
|
||||
|
||||
invoice_type: function (frm) {
|
||||
$.each(frm.doc.invoices, (idx, row) => {
|
||||
row.party_type = frm.doc.invoice_type == "Sales" ? "Customer" : "Supplier";
|
||||
frappe.model.set_value(row.doctype, row.name, "party", "");
|
||||
frappe.model.set_value(row.doctype, row.name, "party_name", "");
|
||||
});
|
||||
frm.clear_table("invoices");
|
||||
frm.refresh_fields();
|
||||
frm.trigger("update_party_labels");
|
||||
@@ -219,7 +216,19 @@ frappe.ui.form.on("Opening Invoice Creation Tool Item", {
|
||||
});
|
||||
},
|
||||
|
||||
invoices_add: (frm) => {
|
||||
invoices_add: (frm, cdt, cdn) => {
|
||||
const row = frappe.get_doc(cdt, cdn);
|
||||
const field_copy = [];
|
||||
|
||||
["project", "cost_center"].forEach((fieldname) => {
|
||||
if (frm.doc[fieldname]) {
|
||||
frappe.model.set_value(cdt, cdn, fieldname, frm.doc[fieldname]);
|
||||
} else {
|
||||
field_copy.push(fieldname);
|
||||
}
|
||||
});
|
||||
|
||||
frm.script_manager.copy_from_first_row("invoices", row, field_copy);
|
||||
frm.trigger("update_invoice_table");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -133,6 +133,17 @@ class OpeningInvoiceCreationTool(Document):
|
||||
if not row.get(scrub(d)):
|
||||
frappe.throw(mandatory_error_msg.format(row.idx, d, self.invoice_type))
|
||||
|
||||
self.validate_temporary_opening_account(row)
|
||||
|
||||
def validate_temporary_opening_account(self, row):
|
||||
account_type = frappe.get_cached_value("Account", row.temporary_opening_account, "account_type")
|
||||
if account_type != "Temporary":
|
||||
frappe.throw(
|
||||
_("Row #{0}: {1} account is not of type {2}").format(
|
||||
row.idx, row.temporary_opening_account, "Temporary"
|
||||
)
|
||||
)
|
||||
|
||||
def get_invoices(self):
|
||||
invoices = []
|
||||
for row in self.invoices:
|
||||
@@ -203,6 +214,7 @@ class OpeningInvoiceCreationTool(Document):
|
||||
"description": row.item_name or "Opening Invoice Item",
|
||||
income_expense_account_field: row.temporary_opening_account,
|
||||
"cost_center": cost_center,
|
||||
"project": row.get("project") or self.get("project"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
|
||||
get_temporary_opening_account,
|
||||
)
|
||||
from erpnext.projects.doctype.project.test_project import make_project
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -14,21 +16,26 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
|
||||
self,
|
||||
invoice_type="Sales",
|
||||
company=None,
|
||||
party_1=None,
|
||||
party_2=None,
|
||||
invoice_number=None,
|
||||
invoices=None,
|
||||
project=None,
|
||||
cost_center=None,
|
||||
department=None,
|
||||
return_doc=False,
|
||||
):
|
||||
doc = frappe.get_single("Opening Invoice Creation Tool")
|
||||
args = get_opening_invoice_creation_dict(
|
||||
invoice_type=invoice_type,
|
||||
company=company,
|
||||
party_1=party_1,
|
||||
party_2=party_2,
|
||||
invoice_number=invoice_number,
|
||||
invoices=invoices,
|
||||
project=project,
|
||||
cost_center=cost_center,
|
||||
department=department,
|
||||
)
|
||||
doc.update(args)
|
||||
|
||||
if return_doc:
|
||||
return doc
|
||||
|
||||
return doc.make_invoices()
|
||||
|
||||
def test_opening_sales_invoice_creation(self):
|
||||
@@ -37,8 +44,8 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
|
||||
self.assertEqual(len(invoices), 2)
|
||||
expected_value = {
|
||||
"keys": ["customer", "outstanding_amount", "status"],
|
||||
0: ["_Test Customer", 300, "Overdue"],
|
||||
1: ["_Test Customer 1", 250, "Overdue"],
|
||||
0: ["_Test Customer", 200, "Overdue"],
|
||||
1: ["_Test Customer 1", 200, "Overdue"],
|
||||
}
|
||||
self.check_expected_values(invoices, expected_value)
|
||||
|
||||
@@ -55,48 +62,34 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
|
||||
for field_idx, field in enumerate(expected_value["keys"]):
|
||||
self.assertEqual(si.get(field, ""), expected_value[invoice_idx][field_idx])
|
||||
|
||||
def test_opening_invoice_requires_temporary_account_type(self):
|
||||
doc = self.make_invoices(company="_Test Opening Invoice Company", return_doc=True)
|
||||
doc.invoices[0].temporary_opening_account = "Sales - _TOIC"
|
||||
self.assertRaises(frappe.ValidationError, doc.make_invoices)
|
||||
|
||||
def test_opening_purchase_invoice_creation(self):
|
||||
invoices = self.make_invoices(invoice_type="Purchase", company="_Test Opening Invoice Company")
|
||||
|
||||
self.assertEqual(len(invoices), 2)
|
||||
expected_value = {
|
||||
"keys": ["supplier", "outstanding_amount", "status"],
|
||||
0: ["_Test Supplier", 300, "Overdue"],
|
||||
1: ["_Test Supplier 1", 250, "Overdue"],
|
||||
0: ["_Test Supplier", 200, "Overdue"],
|
||||
1: ["_Test Supplier 1", 200, "Overdue"],
|
||||
}
|
||||
self.check_expected_values(invoices, expected_value, "Purchase")
|
||||
|
||||
def test_opening_sales_invoice_creation_with_missing_debit_account(self):
|
||||
company = "_Test Opening Invoice Company"
|
||||
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
|
||||
|
||||
old_default_receivable_account = frappe.db.get_value("Company", company, "default_receivable_account")
|
||||
frappe.db.set_value("Company", company, "default_receivable_account", "")
|
||||
old_default_receivable_account = frappe.db.get_value(
|
||||
"Company", "_Test Opening Invoice Company", "default_receivable_account"
|
||||
)
|
||||
frappe.db.set_value("Company", "_Test Opening Invoice Company", "default_receivable_account", "")
|
||||
|
||||
if not frappe.db.exists("Cost Center", "_Test Opening Invoice Company - _TOIC"):
|
||||
cc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Cost Center",
|
||||
"cost_center_name": "_Test Opening Invoice Company",
|
||||
"is_group": 1,
|
||||
"company": "_Test Opening Invoice Company",
|
||||
}
|
||||
)
|
||||
cc.insert(ignore_mandatory=True)
|
||||
cc2 = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Cost Center",
|
||||
"cost_center_name": "Main",
|
||||
"is_group": 0,
|
||||
"company": "_Test Opening Invoice Company",
|
||||
"parent_cost_center": cc.name,
|
||||
}
|
||||
)
|
||||
cc2.insert()
|
||||
|
||||
frappe.db.set_value("Company", company, "cost_center", "Main - _TOIC")
|
||||
|
||||
self.make_invoices(company="_Test Opening Invoice Company", party_1=party_1, party_2=party_2)
|
||||
self.make_invoices(
|
||||
company="_Test Opening Invoice Company",
|
||||
invoices=[{"party": party_1}, {"party": party_2}],
|
||||
)
|
||||
|
||||
# Check if missing debit account error raised
|
||||
error_log = frappe.db.exists(
|
||||
@@ -106,71 +99,107 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
|
||||
self.assertTrue(error_log)
|
||||
|
||||
# teardown
|
||||
frappe.db.set_value("Company", company, "default_receivable_account", old_default_receivable_account)
|
||||
|
||||
def test_renaming_of_invoice_using_invoice_number_field(self):
|
||||
company = "_Test Opening Invoice Company"
|
||||
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
|
||||
self.make_invoices(
|
||||
company=company, party_1=party_1, party_2=party_2, invoice_number="TEST-NEW-INV-11"
|
||||
frappe.db.set_value(
|
||||
"Company",
|
||||
"_Test Opening Invoice Company",
|
||||
"default_receivable_account",
|
||||
old_default_receivable_account,
|
||||
)
|
||||
|
||||
sales_inv1 = frappe.get_all("Sales Invoice", filters={"customer": "Customer A"})[0].get("name")
|
||||
sales_inv2 = frappe.get_all("Sales Invoice", filters={"customer": "Customer B"})[0].get("name")
|
||||
self.assertEqual(sales_inv1, "TEST-NEW-INV-11")
|
||||
def test_renaming_of_invoice_using_invoice_number_field(self):
|
||||
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
|
||||
invoices = self.make_invoices(
|
||||
company="_Test Opening Invoice Company",
|
||||
invoices=[
|
||||
{"party": party_1, "invoice_number": "TEST-NEW-INV-11"},
|
||||
{"party": party_2},
|
||||
],
|
||||
)
|
||||
|
||||
# teardown
|
||||
for inv in [sales_inv1, sales_inv2]:
|
||||
doc = frappe.get_doc("Sales Invoice", inv)
|
||||
doc.cancel()
|
||||
self.assertEqual(invoices[0], "TEST-NEW-INV-11")
|
||||
|
||||
def test_opening_invoice_with_accounting_dimension(self):
|
||||
invoices = self.make_invoices(
|
||||
invoice_type="Sales", company="_Test Opening Invoice Company", department="Sales - _TOIC"
|
||||
)
|
||||
|
||||
expected_value = {
|
||||
"keys": ["customer", "outstanding_amount", "status", "department"],
|
||||
0: ["_Test Customer", 300, "Overdue", "Sales - _TOIC"],
|
||||
1: ["_Test Customer 1", 250, "Overdue", "Sales - _TOIC"],
|
||||
}
|
||||
self.check_expected_values(invoices, expected_value, invoice_type="Sales")
|
||||
for invoice in invoices:
|
||||
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
|
||||
|
||||
def test_opening_entry_project_linking(self):
|
||||
doc = self.make_invoices(
|
||||
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True
|
||||
)
|
||||
project_1 = make_project(
|
||||
{"project_name": "Test Opening Invoice projecty 01", "company": "_Test Opening Invoice Company"}
|
||||
)
|
||||
project_2 = make_project(
|
||||
{"project_name": "Test Opening Invoice projecty 02", "company": "_Test Opening Invoice Company"}
|
||||
)
|
||||
doc.invoices[0].project = project_1.name
|
||||
doc.invoices[1].project = project_2.name
|
||||
invoices = doc.make_invoices()
|
||||
sales_invoice_1 = frappe.get_doc("Sales Invoice", invoices[0])
|
||||
sales_invoice_2 = frappe.get_doc("Sales Invoice", invoices[1])
|
||||
|
||||
self.assertEqual(sales_invoice_1.items[0].project, project_1.name)
|
||||
self.assertEqual(sales_invoice_2.items[0].project, project_2.name)
|
||||
|
||||
|
||||
def get_opening_invoice_creation_dict(**args):
|
||||
party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier"
|
||||
company = args.get("company", "_Test Company")
|
||||
default_invoices = []
|
||||
default_invoice_rows = [
|
||||
{
|
||||
"qty": 1.0,
|
||||
"outstanding_amount": 200,
|
||||
"party": f"_Test {party}",
|
||||
"item_name": "Opening Item",
|
||||
"due_date": add_days(today(), -10),
|
||||
"posting_date": add_days(today(), -15),
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
},
|
||||
{
|
||||
"qty": 1.0,
|
||||
"outstanding_amount": 200,
|
||||
"party": f"_Test {party} 1",
|
||||
"item_name": "Opening Item",
|
||||
"due_date": add_days(today(), -10),
|
||||
"posting_date": add_days(today(), -15),
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
},
|
||||
]
|
||||
|
||||
for row in args.get("invoices") or default_invoice_rows:
|
||||
default_invoices.append(
|
||||
{
|
||||
"qty": row.get("qty") or 1.0,
|
||||
"outstanding_amount": row.get("outstanding_amount") or 200,
|
||||
"party": row.get("party") or f"_Test {party}",
|
||||
"item_name": row.get("item_name") or "Opening Item",
|
||||
"due_date": row.get("due_date") or add_days(today(), -10),
|
||||
"posting_date": row.get("posting_date") or add_days(today(), -15),
|
||||
"temporary_opening_account": row.get("temporary_opening_account")
|
||||
or get_temporary_opening_account(company),
|
||||
"invoice_number": row.get("invoice_number"),
|
||||
"project": row.get("project"),
|
||||
"cost_center": row.get("cost_center"),
|
||||
}
|
||||
)
|
||||
|
||||
invoice_dict = frappe._dict(
|
||||
{
|
||||
"company": company,
|
||||
"invoice_type": args.get("invoice_type", "Sales"),
|
||||
"invoices": [
|
||||
{
|
||||
"qty": 1.0,
|
||||
"outstanding_amount": 300,
|
||||
"party": args.get("party_1") or f"_Test {party}",
|
||||
"item_name": "Opening Item",
|
||||
"due_date": "2016-09-10",
|
||||
"posting_date": "2016-09-05",
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
"invoice_number": args.get("invoice_number"),
|
||||
},
|
||||
{
|
||||
"qty": 2.0,
|
||||
"outstanding_amount": 250,
|
||||
"party": args.get("party_2") or f"_Test {party} 1",
|
||||
"item_name": "Opening Item",
|
||||
"due_date": "2016-09-10",
|
||||
"posting_date": "2016-09-05",
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
"invoice_number": None,
|
||||
},
|
||||
],
|
||||
"project": args.get("project"),
|
||||
"cost_center": args.get("cost_center"),
|
||||
"invoices": default_invoices,
|
||||
}
|
||||
)
|
||||
|
||||
invoice_dict.update(args)
|
||||
invoice_dict.invoices = default_invoices
|
||||
return invoice_dict
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"qty",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
"dimension_col_break"
|
||||
"dimension_col_break",
|
||||
"project"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -125,11 +126,17 @@
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Party Name"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
}
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-20 02:11:42.023575",
|
||||
"modified": "2026-04-29 17:08:15.617047",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Opening Invoice Creation Tool Item",
|
||||
|
||||
@@ -26,6 +26,7 @@ class OpeningInvoiceCreationToolItem(Document):
|
||||
party_name: DF.Data | None
|
||||
party_type: DF.Link | None
|
||||
posting_date: DF.Date | None
|
||||
project: DF.Link | None
|
||||
qty: DF.Data | None
|
||||
supplier_invoice_date: DF.Date | None
|
||||
temporary_opening_account: DF.Link | None
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
{
|
||||
"fieldname": "advance_account",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Advance Account",
|
||||
"options": "Account"
|
||||
}
|
||||
@@ -36,14 +37,15 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:08.489183",
|
||||
"modified": "2026-05-27 14:19:00.888437",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Party Account",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1206,9 +1206,9 @@ class PaymentEntry(AccountsController):
|
||||
continue
|
||||
|
||||
if tax.add_deduct_tax == "Add":
|
||||
included_taxes += tax.base_tax_amount
|
||||
included_taxes += flt(tax.base_tax_amount)
|
||||
else:
|
||||
included_taxes -= tax.base_tax_amount
|
||||
included_taxes -= flt(tax.base_tax_amount)
|
||||
|
||||
return included_taxes
|
||||
|
||||
|
||||
@@ -1113,6 +1113,27 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(gl_entries, expected_gl_entries)
|
||||
|
||||
def test_payment_entry_with_inclusive_tax(self):
|
||||
# inclusive tax built server-side: base_tax_amount is None until apply_taxes()
|
||||
payment_entry = create_payment_entry(paid_amount=1180)
|
||||
payment_entry.append(
|
||||
"taxes",
|
||||
{
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"charge_type": "On Paid Amount",
|
||||
"rate": 18,
|
||||
"included_in_paid_amount": 1,
|
||||
"add_deduct_tax": "Add",
|
||||
"description": "Service Tax",
|
||||
},
|
||||
)
|
||||
payment_entry.save()
|
||||
payment_entry.submit()
|
||||
|
||||
# 1180 incl 18% => 1000 base + 180 tax
|
||||
self.assertEqual(flt(payment_entry.total_taxes_and_charges, 2), 180.0)
|
||||
self.assertEqual(flt(payment_entry.unallocated_amount, 2), 1000.0)
|
||||
|
||||
def test_payment_entry_against_onhold_purchase_invoice(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
|
||||
@@ -10,76 +10,22 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestPaymentLedgerEntry(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.ple = qb.DocType("Payment Ledger Entry")
|
||||
self.create_company()
|
||||
self.create_item()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
|
||||
def create_company(self):
|
||||
company_name = "_Test Payment Ledger"
|
||||
company = None
|
||||
if frappe.db.exists("Company", company_name):
|
||||
company = frappe.get_doc("Company", company_name)
|
||||
else:
|
||||
company = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Company",
|
||||
"company_name": company_name,
|
||||
"country": "India",
|
||||
"default_currency": "INR",
|
||||
"create_chart_of_accounts_based_on": "Standard Template",
|
||||
"chart_of_accounts": "Standard",
|
||||
}
|
||||
)
|
||||
company = company.save()
|
||||
|
||||
self.company = company.name
|
||||
self.cost_center = company.cost_center
|
||||
self.warehouse = "All Warehouses - _PL"
|
||||
self.income_account = "Sales - _PL"
|
||||
self.expense_account = "Cost of Goods Sold - _PL"
|
||||
self.debit_to = "Debtors - _PL"
|
||||
self.creditors = "Creditors - _PL"
|
||||
|
||||
# create bank account
|
||||
if frappe.db.exists("Account", "HDFC - _PL"):
|
||||
self.bank = "HDFC - _PL"
|
||||
else:
|
||||
bank_acc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Account",
|
||||
"account_name": "HDFC",
|
||||
"parent_account": "Bank Accounts - _PL",
|
||||
"company": self.company,
|
||||
}
|
||||
)
|
||||
bank_acc.save()
|
||||
self.bank = bank_acc.name
|
||||
|
||||
def create_item(self):
|
||||
item_name = "_Test PL Item"
|
||||
item = create_item(
|
||||
item_code=item_name, is_stock_item=0, company=self.company, warehouse=self.warehouse
|
||||
)
|
||||
self.item = item if isinstance(item, str) else item.item_code
|
||||
|
||||
def create_customer(self):
|
||||
name = "_Test PL Customer"
|
||||
if frappe.db.exists("Customer", name):
|
||||
self.customer = name
|
||||
else:
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = name
|
||||
customer.type = "Individual"
|
||||
customer.save()
|
||||
self.customer = customer.name
|
||||
self.company = "_Test Company"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.warehouse = "Stores - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.expense_account = "Cost of Goods Sold - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.creditors = "Creditors - _TC"
|
||||
self.bank = "Cash - _TC"
|
||||
self.item = "_Test Item"
|
||||
self.customer = "_Test Customer"
|
||||
|
||||
def create_sales_invoice(
|
||||
self, qty=1, rate=100, posting_date=None, do_not_save=False, do_not_submit=False
|
||||
@@ -152,18 +98,6 @@ class TestPaymentLedgerEntry(ERPNextTestSuite):
|
||||
)
|
||||
return so
|
||||
|
||||
def clear_old_entries(self):
|
||||
doctype_list = [
|
||||
"GL Entry",
|
||||
"Payment Ledger Entry",
|
||||
"Sales Invoice",
|
||||
"Purchase Invoice",
|
||||
"Payment Entry",
|
||||
"Journal Entry",
|
||||
]
|
||||
for doctype in doctype_list:
|
||||
qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
|
||||
|
||||
def create_journal_entry(self, acc1=None, acc2=None, amount=0, posting_date=None, cost_center=None):
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.posting_date = posting_date or nowdate()
|
||||
|
||||
@@ -957,6 +957,7 @@ def resend_payment_email(docname):
|
||||
@frappe.whitelist()
|
||||
def make_payment_entry(docname):
|
||||
doc = frappe.get_doc("Payment Request", docname)
|
||||
doc.check_permission("read")
|
||||
return doc.create_payment_entry(submit=False).as_dict()
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 1)
|
||||
|
||||
def test_closing_entry(self):
|
||||
company = create_company()
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
|
||||
jv1 = make_journal_entry(
|
||||
@@ -28,10 +27,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center,
|
||||
company=company,
|
||||
company="Test PCV Company",
|
||||
save=False,
|
||||
)
|
||||
jv1.company = company
|
||||
jv1.company = "Test PCV Company"
|
||||
jv1.save()
|
||||
jv1.submit()
|
||||
|
||||
@@ -41,10 +40,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cost of Goods Sold - TPC",
|
||||
account2="Cash - TPC",
|
||||
cost_center=cost_center,
|
||||
company=company,
|
||||
company="Test PCV Company",
|
||||
save=False,
|
||||
)
|
||||
jv2.company = company
|
||||
jv2.company = "Test PCV Company"
|
||||
jv2.save()
|
||||
jv2.submit()
|
||||
|
||||
@@ -68,14 +67,13 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
self.assertEqual(pcv_gle, expected_gle)
|
||||
|
||||
def test_cost_center_wise_posting(self):
|
||||
company = create_company()
|
||||
surplus_account = create_account()
|
||||
|
||||
cost_center1 = create_cost_center("Main")
|
||||
cost_center2 = create_cost_center("Western Branch")
|
||||
|
||||
create_sales_invoice(
|
||||
company=company,
|
||||
company="Test PCV Company",
|
||||
cost_center=cost_center1,
|
||||
income_account="Sales - TPC",
|
||||
expense_account="Cost of Goods Sold - TPC",
|
||||
@@ -86,7 +84,7 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
create_sales_invoice(
|
||||
company=company,
|
||||
company="Test PCV Company",
|
||||
cost_center=cost_center2,
|
||||
income_account="Sales - TPC",
|
||||
expense_account="Cost of Goods Sold - TPC",
|
||||
@@ -131,12 +129,11 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
def test_period_closing_with_finance_book_entries(self):
|
||||
company = create_company()
|
||||
surplus_account = create_account()
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
|
||||
create_sales_invoice(
|
||||
company=company,
|
||||
company="Test PCV Company",
|
||||
income_account="Sales - TPC",
|
||||
expense_account="Cost of Goods Sold - TPC",
|
||||
cost_center=cost_center,
|
||||
@@ -153,9 +150,9 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
amount=400,
|
||||
cost_center=cost_center,
|
||||
posting_date="2021-03-15",
|
||||
company=company,
|
||||
company="Test PCV Company",
|
||||
)
|
||||
jv.company = company
|
||||
jv.company = "Test PCV Company"
|
||||
jv.finance_book = create_finance_book().name
|
||||
jv.save()
|
||||
jv.submit()
|
||||
@@ -182,7 +179,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
self.assertSequenceEqual(pcv_gle, expected_gle)
|
||||
|
||||
def test_gl_entries_restrictions(self):
|
||||
company = create_company()
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
|
||||
self.make_period_closing_voucher(posting_date="2021-03-31")
|
||||
@@ -193,16 +189,15 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center,
|
||||
company=company,
|
||||
company="Test PCV Company",
|
||||
save=False,
|
||||
)
|
||||
jv1.company = company
|
||||
jv1.company = "Test PCV Company"
|
||||
jv1.save()
|
||||
|
||||
self.assertRaises(frappe.ValidationError, jv1.submit)
|
||||
|
||||
def test_closing_balance_with_dimensions_and_test_reposting_entry(self):
|
||||
company = create_company()
|
||||
cost_center1 = create_cost_center("Test Cost Center 1")
|
||||
cost_center2 = create_cost_center("Test Cost Center 2")
|
||||
|
||||
@@ -212,10 +207,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center1,
|
||||
company=company,
|
||||
company="Test PCV Company",
|
||||
save=False,
|
||||
)
|
||||
jv1.company = company
|
||||
jv1.company = "Test PCV Company"
|
||||
jv1.save()
|
||||
jv1.submit()
|
||||
|
||||
@@ -225,10 +220,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center2,
|
||||
company=company,
|
||||
company="Test PCV Company",
|
||||
save=False,
|
||||
)
|
||||
jv2.company = company
|
||||
jv2.company = "Test PCV Company"
|
||||
jv2.save()
|
||||
jv2.submit()
|
||||
|
||||
@@ -255,11 +250,11 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center2,
|
||||
company=company,
|
||||
company="Test PCV Company",
|
||||
save=False,
|
||||
)
|
||||
|
||||
jv3.company = company
|
||||
jv3.company = "Test PCV Company"
|
||||
jv3.save()
|
||||
jv3.submit()
|
||||
|
||||
@@ -294,12 +289,12 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
self.assertEqual(cc2_closing_balance.credit, 500)
|
||||
self.assertEqual(cc2_closing_balance.credit_in_account_currency, 500)
|
||||
|
||||
warehouse = frappe.db.get_value("Warehouse", {"company": company}, "name")
|
||||
warehouse = frappe.db.get_value("Warehouse", {"company": "Test PCV Company"}, "name")
|
||||
|
||||
repost_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Repost Item Valuation",
|
||||
"company": company,
|
||||
"company": "Test PCV Company",
|
||||
"posting_date": "2020-03-15",
|
||||
"based_on": "Item and Warehouse",
|
||||
"item_code": "Test Item 1",
|
||||
@@ -340,7 +335,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
{"enable_immutable_ledger": 1},
|
||||
)
|
||||
def test_immutable_ledger_reverse_entry_uses_passed_posting_date_after_pcv(self):
|
||||
company = create_company()
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
|
||||
jv = make_journal_entry(
|
||||
@@ -349,10 +343,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center,
|
||||
company=company,
|
||||
company="Test PCV Company",
|
||||
save=False,
|
||||
)
|
||||
jv.company = company
|
||||
jv.company = "Test PCV Company"
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
@@ -378,19 +372,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)
|
||||
|
||||
|
||||
def create_company():
|
||||
company = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Company",
|
||||
"company_name": "Test PCV Company",
|
||||
"country": "United States",
|
||||
"default_currency": "USD",
|
||||
}
|
||||
)
|
||||
company.insert(ignore_if_duplicate=True)
|
||||
return company.name
|
||||
|
||||
|
||||
def create_account():
|
||||
account = frappe.get_doc(
|
||||
{
|
||||
|
||||
@@ -92,9 +92,11 @@ class ProcessPeriodClosingVoucher(Document):
|
||||
@frappe.whitelist()
|
||||
def start_pcv_processing(docname: str):
|
||||
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
frappe.has_permission("Process Period Closing Voucher", "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,
|
||||
|
||||
@@ -416,7 +416,6 @@ def get_context(customer, doc):
|
||||
return {
|
||||
"doc": template_doc,
|
||||
"customer": frappe.get_doc("Customer", customer),
|
||||
"frappe": frappe.utils,
|
||||
}
|
||||
|
||||
|
||||
@@ -466,6 +465,8 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr
|
||||
when Is Billing Contact checked
|
||||
and Primary email- email with Is Primary checked"""
|
||||
|
||||
frappe.has_permission("Customer", "read", customer_name, throw=True)
|
||||
|
||||
billing_email = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
@@ -509,6 +510,7 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr
|
||||
@frappe.whitelist()
|
||||
def download_statements(document_name):
|
||||
doc = frappe.get_doc("Process Statement Of Accounts", document_name)
|
||||
doc.check_permission("read")
|
||||
report = get_report_pdf(doc)
|
||||
if report:
|
||||
frappe.local.response.filename = doc.name + ".pdf"
|
||||
|
||||
@@ -21,10 +21,8 @@ class TestProcessStatementOfAccounts(ERPNextTestSuite, AccountsTestMixin):
|
||||
letterhead.is_default = 0
|
||||
letterhead.save()
|
||||
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.company = "_Test Company"
|
||||
self.create_customer(customer_name="Other Customer")
|
||||
self.clear_old_entries()
|
||||
self.si = create_sales_invoice()
|
||||
create_sales_invoice(customer="Other Customer")
|
||||
|
||||
|
||||
@@ -1433,6 +1433,10 @@ class PurchaseInvoice(BuyingController):
|
||||
# tax table gl entries
|
||||
valuation_tax = {}
|
||||
|
||||
# Amount of each valuation charge actually capitalized into stock/asset valuation, keyed by
|
||||
# tax row name - a non-stock item's share of a spread-across-all-items charge is excluded.
|
||||
capitalized_valuation_tax = self.get_capitalized_valuation_tax()
|
||||
|
||||
for tax in self.get("taxes"):
|
||||
amount, base_amount = self.get_tax_amounts(tax, None)
|
||||
if tax.category in ("Total", "Valuation and Total") and flt(base_amount):
|
||||
@@ -1469,8 +1473,7 @@ class PurchaseInvoice(BuyingController):
|
||||
tax.idx, _(tax.category)
|
||||
)
|
||||
)
|
||||
valuation_tax.setdefault(tax.name, 0)
|
||||
valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(base_amount)
|
||||
valuation_tax[tax.name] = capitalized_valuation_tax.get(tax.name, 0.0)
|
||||
|
||||
if self.is_opening == "No" and self.negative_expense_to_be_booked and valuation_tax:
|
||||
# credit valuation tax amount in "Expenses Included In Valuation"
|
||||
|
||||
@@ -2928,6 +2928,24 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
# Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail
|
||||
self.assertRaises(frappe.ValidationError, pi.submit)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
|
||||
def test_non_stock_item_over_billing_against_po_is_blocked(self):
|
||||
service_item = create_item(
|
||||
"_Test Service Item Non Stock PI",
|
||||
is_stock_item=0,
|
||||
is_purchase_item=1,
|
||||
).name
|
||||
|
||||
po = create_purchase_order(item_code=service_item, qty=5, rate=100, do_not_save=False)
|
||||
po.submit()
|
||||
|
||||
pi = make_pi_from_po(po.name)
|
||||
pi.items[0].qty = 10 # overbill by 100 %
|
||||
pi.save()
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
pi.submit()
|
||||
|
||||
def test_discount_percentage_not_set_when_amount_is_manually_set(self):
|
||||
pi = make_purchase_invoice(do_not_save=True)
|
||||
discount_amount = 7
|
||||
@@ -3008,6 +3026,14 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
|
||||
party_link.delete()
|
||||
|
||||
def test_purchase_invoice_cancellation_post_account_freezing_date(self):
|
||||
pi = make_purchase_invoice()
|
||||
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", add_days(getdate(), 1))
|
||||
try:
|
||||
self.assertRaises(frappe.ValidationError, pi.cancel)
|
||||
finally:
|
||||
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
|
||||
|
||||
|
||||
def set_advance_flag(company, flag, default_account):
|
||||
frappe.db.set_value(
|
||||
|
||||
@@ -11,13 +11,16 @@
|
||||
"add_deduct_tax",
|
||||
"charge_type",
|
||||
"row_id",
|
||||
"included_in_print_rate",
|
||||
"included_in_paid_amount",
|
||||
"col_break1",
|
||||
"account_head",
|
||||
"description",
|
||||
"section_break_mvae",
|
||||
"is_tax_withholding_account",
|
||||
"set_by_item_tax_template",
|
||||
"allocate_full_amount_to_stock_items",
|
||||
"column_break_odzz",
|
||||
"included_in_print_rate",
|
||||
"included_in_paid_amount",
|
||||
"section_break_10",
|
||||
"rate",
|
||||
"accounting_dimensions_section",
|
||||
@@ -78,6 +81,15 @@
|
||||
"oldfieldname": "row_id",
|
||||
"oldfieldtype": "Data"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"depends_on": "eval:doc.charge_type=='Actual' && ['Valuation', 'Valuation and Total'].includes(doc.category)",
|
||||
"description": "If checked, the entire amount (e.g. Freight) is allocated to the valuation of stock & asset items only. If unchecked, the amount is distributed across all items and the portion belonging to non-stock items is not added to valuation.",
|
||||
"fieldname": "allocate_full_amount_to_stock_items",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allocate Full Amount to Stock Items",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If checked, the tax amount will be considered as already included in the Print Rate / Print Amount",
|
||||
@@ -272,13 +284,21 @@
|
||||
"label": "Don't Recompute Tax",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_mvae",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_odzz",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-24 18:22:56.886010",
|
||||
"modified": "2026-06-21 17:08:57.096729",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Taxes and Charges",
|
||||
|
||||
@@ -17,6 +17,7 @@ class PurchaseTaxesandCharges(Document):
|
||||
account_currency: DF.Link | None
|
||||
account_head: DF.Link
|
||||
add_deduct_tax: DF.Literal["Add", "Deduct"]
|
||||
allocate_full_amount_to_stock_items: DF.Check
|
||||
base_net_amount: DF.Currency
|
||||
base_tax_amount: DF.Currency
|
||||
base_tax_amount_after_discount_amount: DF.Currency
|
||||
|
||||
@@ -460,8 +460,8 @@ class SalesInvoice(SellingController):
|
||||
validate_account_head(item.idx, item.income_account, self.company, _("Income"))
|
||||
|
||||
def before_save(self):
|
||||
self.set_account_for_mode_of_payment()
|
||||
self.set_paid_amount()
|
||||
self.set_account_for_mode_of_payment()
|
||||
|
||||
def before_submit(self):
|
||||
self.add_remarks()
|
||||
@@ -900,6 +900,13 @@ class SalesInvoice(SellingController):
|
||||
def set_paid_amount(self):
|
||||
paid_amount = 0.0
|
||||
base_paid_amount = 0.0
|
||||
|
||||
if not cint(self.is_pos) and self.is_return:
|
||||
self.set("payments", [])
|
||||
self.paid_amount = paid_amount
|
||||
self.base_paid_amount = base_paid_amount
|
||||
return
|
||||
|
||||
for data in self.payments:
|
||||
data.base_amount = flt(data.amount * self.conversion_rate, self.precision("base_paid_amount"))
|
||||
paid_amount += data.amount
|
||||
|
||||
@@ -2150,11 +2150,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))
|
||||
|
||||
@@ -3865,6 +3868,51 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertTrue("cannot overbill" in str(err.exception).lower())
|
||||
dn.cancel()
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
|
||||
def test_non_stock_item_over_billing_against_so_is_blocked(self):
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
service_item = create_item(
|
||||
"_Test Service Item Non Stock SI",
|
||||
is_stock_item=0,
|
||||
).name
|
||||
|
||||
so = make_sales_order(item_code=service_item, qty=5, rate=100)
|
||||
so.submit()
|
||||
|
||||
si = make_si_from_so(so.name)
|
||||
si.items[0].qty = 10 # overbill by 100 %
|
||||
si.save()
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
si.submit()
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
|
||||
def test_non_stock_item_over_billing_against_so_from_quotation_is_blocked(self):
|
||||
from erpnext.selling.doctype.quotation.quotation import make_sales_order as make_so_from_quotation
|
||||
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
|
||||
|
||||
service_item = create_item(
|
||||
"_Test Service Item Non Stock SI Quot",
|
||||
is_stock_item=0,
|
||||
).name
|
||||
|
||||
quotation = make_quotation(item_code=service_item, qty=5, rate=100)
|
||||
|
||||
so = make_so_from_quotation(quotation.name)
|
||||
so.delivery_date = frappe.utils.add_days(frappe.utils.today(), 7)
|
||||
so.insert()
|
||||
so.submit()
|
||||
|
||||
si = make_si_from_so(so.name)
|
||||
si.items[0].qty = 10 # overbill by 100 %
|
||||
si.save()
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
si.submit()
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings",
|
||||
{
|
||||
|
||||
@@ -16,12 +16,14 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_supplier()
|
||||
self.create_usd_receivable_account()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.supplier = "_Test Supplier"
|
||||
self.item = "_Test Item"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.cash = "Cash - _TC"
|
||||
self.debtors_usd = "_Test Receivable USD - _TC"
|
||||
|
||||
def create_sales_invoice(self, do_not_submit=False):
|
||||
si = create_sales_invoice(
|
||||
@@ -372,7 +374,6 @@ class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertEqual(so.advance_paid, 0)
|
||||
|
||||
def test_06_unreconcile_advance_from_payment_entry(self):
|
||||
self.enable_advance_as_liability()
|
||||
so1 = self.create_sales_order()
|
||||
so2 = self.create_sales_order()
|
||||
|
||||
@@ -423,7 +424,11 @@ class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.disable_advance_as_liability()
|
||||
|
||||
def test_07_adv_from_so_to_invoice(self):
|
||||
self.enable_advance_as_liability()
|
||||
frappe.db.set_value("Company", self.company, "book_advance_payments_in_separate_party_account", True)
|
||||
frappe.db.set_value(
|
||||
"Company", self.company, "default_advance_received_account", "Advance Received - _TC"
|
||||
)
|
||||
|
||||
so = self.create_sales_order()
|
||||
pe = self.create_payment_entry()
|
||||
pe.paid_amount = 1000
|
||||
|
||||
@@ -716,7 +716,7 @@ def make_reverse_gl_entries(
|
||||
partial_cancel=partial_cancel,
|
||||
)
|
||||
validate_accounting_period(gl_entries)
|
||||
check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
|
||||
check_freezing_date(gl_entries[0]["posting_date"], gl_entries[0]["company"], adv_adj)
|
||||
|
||||
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
|
||||
|
||||
@@ -821,13 +821,24 @@ def check_freezing_date(posting_date, company, adv_adj=False):
|
||||
)
|
||||
|
||||
|
||||
def validate_against_pcv(is_opening, posting_date, company):
|
||||
if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
|
||||
def validate_opening_entry_against_pcv(company):
|
||||
if frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
|
||||
frappe.throw(
|
||||
_("Opening Entry can not be created after Period Closing Voucher is created."),
|
||||
_(
|
||||
"A Period Closing Voucher is already submitted and an Opening Entry can no longer be created. {0} to learn more."
|
||||
).format(
|
||||
'<a href="https://docs.frappe.io/erpnext/period-closing-voucher#14-pcv-and-opening-entries" target="_blank" rel="noopener">'
|
||||
+ _("Read the docs")
|
||||
+ "</a>"
|
||||
),
|
||||
title=_("Invalid Opening Entry"),
|
||||
)
|
||||
|
||||
|
||||
def validate_against_pcv(is_opening, posting_date, company):
|
||||
if is_opening:
|
||||
validate_opening_entry_against_pcv(company)
|
||||
|
||||
last_pcv_date = frappe.db.get_value(
|
||||
"Period Closing Voucher", {"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"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{% 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,
|
||||
"docstatus": 0,
|
||||
"doctype": "Letter Head",
|
||||
"footer_align": "Left",
|
||||
"footer_image_height": 0.0,
|
||||
"footer_image_width": 0.0,
|
||||
"footer_source": "Image",
|
||||
"idx": 0,
|
||||
"image_height": 0.0,
|
||||
"image_width": 0.0,
|
||||
"is_default": 0,
|
||||
"letter_head_for": "DocType",
|
||||
"letter_head_name": "Company Letterhead",
|
||||
"modified": "2026-06-24 17:49:52.350750",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Company Letterhead",
|
||||
"owner": "Administrator",
|
||||
"source": "HTML",
|
||||
"standard": "Yes"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"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{% 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,
|
||||
"docstatus": 0,
|
||||
"doctype": "Letter Head",
|
||||
"footer_align": "Left",
|
||||
"footer_image_height": 0.0,
|
||||
"footer_image_width": 0.0,
|
||||
"footer_source": "Image",
|
||||
"idx": 0,
|
||||
"image_height": 0.0,
|
||||
"image_width": 0.0,
|
||||
"is_default": 0,
|
||||
"letter_head_for": "DocType",
|
||||
"letter_head_name": "Company Letterhead - Grey",
|
||||
"modified": "2026-06-24 18:23:05.120521",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Company Letterhead - Grey",
|
||||
"owner": "Administrator",
|
||||
"source": "HTML",
|
||||
"standard": "Yes"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"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{% 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,
|
||||
"docstatus": 0,
|
||||
"doctype": "Letter Head",
|
||||
"footer_align": "Left",
|
||||
"footer_image_height": 0.0,
|
||||
"footer_image_width": 0.0,
|
||||
"footer_source": "Image",
|
||||
"idx": 0,
|
||||
"image_height": 0.0,
|
||||
"image_width": 0.0,
|
||||
"is_default": 0,
|
||||
"letter_head_for": "Report",
|
||||
"letter_head_name": "Company Letterhead Report",
|
||||
"modified": "2026-06-24 18:06:39.820968",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Company Letterhead Report",
|
||||
"owner": "Administrator",
|
||||
"source": "HTML",
|
||||
"standard": "Yes"
|
||||
}
|
||||
@@ -927,6 +927,15 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
|
||||
if party_type == "Supplier":
|
||||
info["total_unpaid"] = -1 * info["total_unpaid"]
|
||||
|
||||
if info["total_unpaid"] < 0:
|
||||
info["balance_label"] = (
|
||||
"Total Advance Paid" if party_type == "Supplier" else "Total Advance Received"
|
||||
)
|
||||
info["balance_amount"] = abs(info["total_unpaid"])
|
||||
else:
|
||||
info["balance_label"] = "Total Unpaid"
|
||||
info["balance_amount"] = info["total_unpaid"]
|
||||
|
||||
company_wise_info.append(info)
|
||||
|
||||
return company_wise_info
|
||||
|
||||
@@ -9,11 +9,10 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.create_supplier(currency="USD", supplier_name="Test Supplier2")
|
||||
self.create_usd_payable_account()
|
||||
self.company = "_Test Company"
|
||||
self.item = "_Test Item"
|
||||
self.supplier = "_Test Supplier 2"
|
||||
self.creditors_usd = "_Test Payable USD - _TC"
|
||||
|
||||
def test_accounts_payable_for_foreign_currency_supplier(self):
|
||||
pi = self.create_purchase_invoice(do_not_submit=True)
|
||||
|
||||
@@ -12,11 +12,17 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.create_usd_receivable_account()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.company_abbr = "_TC"
|
||||
self.customer = "_Test Customer"
|
||||
self.item = "_Test Item"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.warehouse = "Stores - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.expense_account = "Cost of Goods Sold - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cash = "Cash - _TC"
|
||||
self.debtors_usd = "_Test Receivable USD - _TC"
|
||||
|
||||
def create_sales_invoice(self, no_payment_schedule=False, do_not_submit=False, **args):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
@@ -11,10 +11,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.maxDiff = None
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.item = "_Test Item"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
|
||||
def test_01_receivable_summary_output(self):
|
||||
"""
|
||||
|
||||
@@ -84,7 +84,13 @@ def build_budget_map(budget_records, filters):
|
||||
budget_distributions = get_budget_distributions(budget)
|
||||
|
||||
for row in budget_distributions:
|
||||
if not row.start_date or not row.end_date:
|
||||
continue
|
||||
|
||||
months = get_months_in_range(row.start_date, row.end_date)
|
||||
if not months:
|
||||
continue
|
||||
|
||||
monthly_budget = flt(row.amount) / len(months)
|
||||
|
||||
for month_date in months:
|
||||
|
||||
@@ -12,10 +12,12 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestCustomerLedgerSummary(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.item = "_Test Item"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.cash = "Cash - _TC"
|
||||
|
||||
def create_sales_invoice(self, do_not_submit=False, **args):
|
||||
si = create_sales_invoice(
|
||||
|
||||
@@ -61,11 +61,16 @@ class TestDeferredRevenueAndExpense(ERPNextTestSuite, AccountsTestMixin):
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer("_Test Customer")
|
||||
self.create_supplier("_Test Furniture Supplier")
|
||||
self.company = "_Test Company"
|
||||
self.company_abbr = "_TC"
|
||||
self.customer = "_Test Customer"
|
||||
self.supplier = "_Test Supplier"
|
||||
self.warehouse = "Stores - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.expense_account = "Cost of Goods Sold - _TC"
|
||||
self.setup_deferred_accounts_and_items()
|
||||
self.clear_old_entries()
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"book_deferred_entries_based_on": "Months"})
|
||||
def test_deferred_revenue(self):
|
||||
|
||||
@@ -76,6 +76,7 @@ def get_ratios_data(filters, period_list, years):
|
||||
cogs, total_expense = {}, {}
|
||||
quick_asset = {}
|
||||
direct_expense = {}
|
||||
fixed_asset = {}
|
||||
|
||||
for year in years:
|
||||
total_quick_asset = 0
|
||||
@@ -93,6 +94,7 @@ def get_ratios_data(filters, period_list, years):
|
||||
quick_asset,
|
||||
total_quick_asset,
|
||||
],
|
||||
[fixed_asset, total_asset, "Fixed Asset", year, assets, "Asset", {}, 0],
|
||||
[
|
||||
current_liability,
|
||||
total_liability,
|
||||
@@ -112,7 +114,7 @@ def get_ratios_data(filters, period_list, years):
|
||||
add_solvency_ratios(
|
||||
data, years, total_asset, total_liability, net_sales, cogs, total_income, total_expense
|
||||
)
|
||||
add_turnover_ratios(data, years, period_list, filters, total_asset, net_sales, cogs, direct_expense)
|
||||
add_turnover_ratios(data, years, period_list, filters, fixed_asset, net_sales, cogs, direct_expense)
|
||||
|
||||
return data
|
||||
|
||||
@@ -193,7 +195,7 @@ def add_solvency_ratios(
|
||||
data.append(return_on_equity_ratio)
|
||||
|
||||
|
||||
def add_turnover_ratios(data, years, period_list, filters, total_asset, net_sales, cogs, direct_expense):
|
||||
def add_turnover_ratios(data, years, period_list, filters, fixed_asset, net_sales, cogs, direct_expense):
|
||||
precision = frappe.db.get_single_value("System Settings", "float_precision")
|
||||
data.append({"ratio": _("Turnover Ratios")})
|
||||
|
||||
@@ -208,7 +210,7 @@ def add_turnover_ratios(data, years, period_list, filters, total_asset, net_sale
|
||||
)
|
||||
|
||||
ratio_data = [
|
||||
[_("Fixed Asset Turnover Ratio"), net_sales, total_asset],
|
||||
[_("Fixed Asset Turnover Ratio"), net_sales, fixed_asset],
|
||||
[_("Debtor Turnover Ratio"), net_sales, avg_debtors],
|
||||
[_("Creditor Turnover Ratio"), direct_expense, avg_creditors],
|
||||
[_("Inventory Turnover Ratio"), cogs, avg_stock],
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.report.financial_ratios.financial_ratios import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestFinancialRatios(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.abbr = "_TC"
|
||||
# The report matches the group accounts by their account_type, which the
|
||||
# standard chart of accounts does not set on group accounts by default.
|
||||
self.set_account_type("Fixed Assets", "Fixed Asset")
|
||||
self.set_account_type("Direct Income", "Direct Income")
|
||||
|
||||
def set_account_type(self, account_name, account_type):
|
||||
frappe.db.set_value("Account", f"{account_name} - {self.abbr}", "account_type", account_type)
|
||||
|
||||
def test_fixed_asset_turnover_uses_net_fixed_assets(self):
|
||||
# Acquire a fixed asset worth 10,000 funded by equity.
|
||||
self.make_journal_entry("Buildings", "Capital Stock", 10000)
|
||||
# Book sales of 20,000 collected in cash. Total assets now = 30,000
|
||||
# (Buildings 10,000 + Cash 20,000), while net fixed assets stay at 10,000.
|
||||
self.make_journal_entry("Cash", "Sales", 20000)
|
||||
|
||||
columns, data = execute(self.get_report_filters())
|
||||
year_key = columns[1]["fieldname"]
|
||||
ratio_row = next((row for row in data if row.get("ratio") == "Fixed Asset Turnover Ratio"), None)
|
||||
self.assertIsNotNone(ratio_row, "Fixed Asset Turnover Ratio row not found in report output")
|
||||
|
||||
# Net Sales / Net Fixed Assets = 20,000 / 10,000 = 2.0
|
||||
# (the old behaviour divided by total assets, giving 20,000 / 30,000 = 0.667)
|
||||
self.assertEqual(ratio_row[year_key], 2.0)
|
||||
|
||||
def get_report_filters(self):
|
||||
active_fy = frappe.db.get_value(
|
||||
"Fiscal Year",
|
||||
{"disabled": 0, "year_start_date": ("<=", today()), "year_end_date": (">=", today())},
|
||||
["name", "year_start_date", "year_end_date"],
|
||||
as_dict=True,
|
||||
)
|
||||
return frappe._dict(
|
||||
company=self.company,
|
||||
from_fiscal_year=active_fy.name,
|
||||
to_fiscal_year=active_fy.name,
|
||||
period_start_date=active_fy.year_start_date,
|
||||
period_end_date=active_fy.year_end_date,
|
||||
filter_based_on="Fiscal Year",
|
||||
periodicity="Yearly",
|
||||
)
|
||||
|
||||
def make_journal_entry(self, debit_account, credit_account, amount):
|
||||
journal_entry = frappe.new_doc("Journal Entry")
|
||||
journal_entry.posting_date = today()
|
||||
journal_entry.company = self.company
|
||||
for account, debit, credit in (
|
||||
(debit_account, amount, 0),
|
||||
(credit_account, 0, amount),
|
||||
):
|
||||
journal_entry.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": f"{account} - {self.abbr}",
|
||||
"debit_in_account_currency": debit,
|
||||
"credit_in_account_currency": credit,
|
||||
},
|
||||
)
|
||||
journal_entry.insert()
|
||||
journal_entry.submit()
|
||||
@@ -12,7 +12,13 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestGeneralAndPaymentLedger(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.company = "_Test Company"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.expense_account = "Cost of Goods Sold - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.warehouse = "Stores - _TC"
|
||||
self.creditors = "Creditors - _TC"
|
||||
self.cleanup()
|
||||
|
||||
def cleanup(self):
|
||||
|
||||
@@ -14,7 +14,6 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
class TestGeneralLedger(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.clear_old_entries()
|
||||
|
||||
def clear_old_entries(self):
|
||||
doctype_list = [
|
||||
|
||||
@@ -562,7 +562,12 @@ class GrossProfitGenerator:
|
||||
row.base_amount = packed_item.base_amount
|
||||
|
||||
# get buying amount
|
||||
if row.item_code in product_bundles:
|
||||
if row.is_debit_note:
|
||||
# Rate adjustment debit notes have no stock movement, so buying amount is zero
|
||||
if not grouped_by_invoice:
|
||||
row.qty = 0
|
||||
row.buying_amount = 0
|
||||
elif row.item_code in product_bundles:
|
||||
row.buying_amount = flt(
|
||||
self.get_buying_amount_from_product_bundle(row, product_bundles[row.item_code]),
|
||||
self.currency_precision,
|
||||
@@ -951,6 +956,7 @@ class GrossProfitGenerator:
|
||||
SalesInvoice.customer_group,
|
||||
SalesInvoice.customer_name,
|
||||
SalesInvoice.territory,
|
||||
SalesInvoice.is_debit_note,
|
||||
SalesInvoiceItem.item_code,
|
||||
SalesInvoice.base_net_total.as_("invoice_base_net_total"),
|
||||
SalesInvoiceItem.item_name,
|
||||
@@ -1131,6 +1137,7 @@ class GrossProfitGenerator:
|
||||
"posting_time": row.posting_time,
|
||||
"project": row.project,
|
||||
"update_stock": row.update_stock,
|
||||
"is_debit_note": row.is_debit_note,
|
||||
"customer": row.customer,
|
||||
"customer_group": row.customer_group,
|
||||
"customer_name": row.customer_name,
|
||||
@@ -1169,6 +1176,7 @@ class GrossProfitGenerator:
|
||||
"description": item.description,
|
||||
"warehouse": item.warehouse or row.warehouse,
|
||||
"update_stock": row.update_stock,
|
||||
"is_debit_note": row.is_debit_note,
|
||||
"item_group": "",
|
||||
"brand": "",
|
||||
"dn_detail": row.dn_detail,
|
||||
|
||||
@@ -14,73 +14,17 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestGrossProfit(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_item()
|
||||
self.create_bundle()
|
||||
self.create_customer()
|
||||
self.create_sales_invoice()
|
||||
self.clear_old_entries()
|
||||
|
||||
def create_company(self):
|
||||
company_name = "_Test Gross Profit"
|
||||
abbr = "_GP"
|
||||
if frappe.db.exists("Company", company_name):
|
||||
company = frappe.get_doc("Company", company_name)
|
||||
else:
|
||||
company = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Company",
|
||||
"company_name": company_name,
|
||||
"country": "India",
|
||||
"default_currency": "INR",
|
||||
"create_chart_of_accounts_based_on": "Standard Template",
|
||||
"chart_of_accounts": "Standard",
|
||||
}
|
||||
)
|
||||
company = company.save()
|
||||
|
||||
self.company = company.name
|
||||
self.cost_center = company.cost_center
|
||||
self.warehouse = "Stores - " + abbr
|
||||
self.finished_warehouse = "Finished Goods - " + abbr
|
||||
self.income_account = "Sales - " + abbr
|
||||
self.expense_account = "Cost of Goods Sold - " + abbr
|
||||
self.debit_to = "Debtors - " + abbr
|
||||
self.creditors = "Creditors - " + abbr
|
||||
|
||||
def create_item(self):
|
||||
item = create_item(
|
||||
item_code="_Test GP Item", is_stock_item=1, company=self.company, warehouse=self.warehouse
|
||||
)
|
||||
self.item = item if isinstance(item, str) else item.item_code
|
||||
|
||||
def create_bundle(self):
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
|
||||
item2 = create_item(
|
||||
item_code="_Test GP Item 2", is_stock_item=1, company=self.company, warehouse=self.warehouse
|
||||
)
|
||||
self.item2 = item2 if isinstance(item2, str) else item2.item_code
|
||||
|
||||
# This will be parent item
|
||||
bundle = create_item(
|
||||
item_code="_Test GP bundle", is_stock_item=0, company=self.company, warehouse=self.warehouse
|
||||
)
|
||||
self.bundle = bundle if isinstance(bundle, str) else bundle.item_code
|
||||
|
||||
# Create Product Bundle
|
||||
self.product_bundle = make_product_bundle(parent=self.bundle, items=[self.item, self.item2])
|
||||
|
||||
def create_customer(self):
|
||||
name = "_Test GP Customer"
|
||||
if frappe.db.exists("Customer", name):
|
||||
self.customer = name
|
||||
else:
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = name
|
||||
customer.type = "Individual"
|
||||
customer.save()
|
||||
self.customer = customer.name
|
||||
self.company = "_Test Company"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.warehouse = "Stores - _TC"
|
||||
self.finished_warehouse = "Finished Goods - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.expense_account = "Cost of Goods Sold - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.item = "_Test Item"
|
||||
self.item2 = "_Test Item Home Desktop 100"
|
||||
self.bundle = "_Test Product Bundle Item"
|
||||
self.customer = "_Test Customer"
|
||||
|
||||
def create_sales_invoice(
|
||||
self, qty=1, rate=100, posting_date=None, do_not_save=False, do_not_submit=False
|
||||
@@ -214,7 +158,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _GP",
|
||||
"warehouse": "Stores - _TC",
|
||||
"qty": 1.0,
|
||||
"avg._selling_rate": 100.0,
|
||||
"valuation_rate": 150.0,
|
||||
@@ -243,7 +187,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _GP",
|
||||
"warehouse": "Stores - _TC",
|
||||
"qty": 1.0,
|
||||
"avg._selling_rate": 100.0,
|
||||
"valuation_rate": 100.0,
|
||||
@@ -275,7 +219,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"item_code": self.item2,
|
||||
"s_warehouse": "",
|
||||
"t_warehouse": self.finished_warehouse,
|
||||
"qty": 1,
|
||||
"qty": 2,
|
||||
"basic_rate": 100,
|
||||
"conversion_factor": item.conversion_factor or 1.0,
|
||||
"transfer_qty": flt(item.qty) * (flt(item.conversion_factor) or 1.0),
|
||||
@@ -375,7 +319,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _GP",
|
||||
"warehouse": "Stores - _TC",
|
||||
"qty": 4.0,
|
||||
"avg._selling_rate": 100.0,
|
||||
"valuation_rate": 125.0,
|
||||
@@ -416,10 +360,10 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _GP",
|
||||
"warehouse": "Stores - _TC",
|
||||
"qty": 0.0,
|
||||
"avg._selling_rate": 100,
|
||||
"valuation_rate": 0.0,
|
||||
"avg._selling_rate": 100.0,
|
||||
"valuation_rate": 100.0,
|
||||
"selling_amount": 0.0,
|
||||
"buying_amount": 0.0,
|
||||
"gross_profit": 0.0,
|
||||
@@ -439,7 +383,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"""
|
||||
# Make Cr Note
|
||||
sinv = self.create_sales_invoice(
|
||||
qty=-1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True
|
||||
qty=-1, rate=200, posting_date=nowdate(), do_not_save=True, do_not_submit=True
|
||||
)
|
||||
sinv.is_return = 1
|
||||
sinv.items[0].allow_zero_valuation_rate = 1
|
||||
@@ -462,14 +406,14 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _GP",
|
||||
"warehouse": "Stores - _TC",
|
||||
"qty": -1.0,
|
||||
"avg._selling_rate": 100.0,
|
||||
"valuation_rate": 0.0,
|
||||
"selling_amount": -100.0,
|
||||
"buying_amount": 0.0,
|
||||
"avg._selling_rate": 200.0,
|
||||
"valuation_rate": 100.0,
|
||||
"selling_amount": -200.0,
|
||||
"buying_amount": -100.0,
|
||||
"gross_profit": -100.0,
|
||||
"gross_profit_%": -100.0,
|
||||
"gross_profit_%": -50.0,
|
||||
}
|
||||
gp_entry = [x for x in data if x.parent_invoice == sinv.name]
|
||||
report_output = {k: v for k, v in gp_entry[0].items() if k in expected_entry}
|
||||
@@ -555,7 +499,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _GP",
|
||||
"warehouse": "Stores - _TC",
|
||||
"qty": 4.0,
|
||||
"avg._selling_rate": 800.0,
|
||||
"valuation_rate": 700.0,
|
||||
@@ -618,7 +562,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
def test_gross_profit_groupby_invoices(self):
|
||||
create_sales_invoice(
|
||||
qty=1,
|
||||
rate=100,
|
||||
rate=200,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
item_code=self.item,
|
||||
@@ -640,10 +584,10 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
_, data = execute(filters=filters)
|
||||
total = data[-1]
|
||||
|
||||
self.assertEqual(total.selling_amount, 100.0)
|
||||
self.assertEqual(total.buying_amount, 0.0)
|
||||
self.assertEqual(total.selling_amount, 200.0)
|
||||
self.assertEqual(total.buying_amount, 100.0)
|
||||
self.assertEqual(total.gross_profit, 100.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), 100.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), 50.0)
|
||||
|
||||
def test_profit_for_later_period_return(self):
|
||||
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
|
||||
@@ -652,7 +596,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
return_inv_date = add_days(month_end_date, 1)
|
||||
|
||||
# create sales invoice on month start date
|
||||
sinv = self.create_sales_invoice(qty=1, rate=100, do_not_save=True, do_not_submit=True)
|
||||
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_save=True, do_not_submit=True)
|
||||
sinv.set_posting_time = 1
|
||||
sinv.posting_date = sales_inv_date
|
||||
sinv.save().submit()
|
||||
@@ -671,10 +615,10 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
_, data = execute(filters=filters)
|
||||
total = data[-1]
|
||||
|
||||
self.assertEqual(total.selling_amount, 100.0)
|
||||
self.assertEqual(total.buying_amount, 0.0)
|
||||
self.assertEqual(total.selling_amount, 200.0)
|
||||
self.assertEqual(total.buying_amount, 100.0)
|
||||
self.assertEqual(total.gross_profit, 100.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), 100.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), 50.0)
|
||||
|
||||
# extend filters upto returned period
|
||||
filters.update({"to_date": return_inv_date})
|
||||
@@ -692,10 +636,10 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
_, data = execute(filters=filters)
|
||||
total = data[-1]
|
||||
|
||||
self.assertEqual(total.selling_amount, -100.0)
|
||||
self.assertEqual(total.buying_amount, 0.0)
|
||||
self.assertEqual(total.selling_amount, -200.0)
|
||||
self.assertEqual(total.buying_amount, -100.0)
|
||||
self.assertEqual(total.gross_profit, -100.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), -100.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), -50.0)
|
||||
|
||||
def test_sales_person_wise_gross_profit(self):
|
||||
sales_person = make_sales_person("_Test Sales Person")
|
||||
@@ -726,10 +670,10 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
_, data = execute(filters=filters)
|
||||
total = data[-1]
|
||||
|
||||
self.assertEqual(total[5], 1000.0)
|
||||
self.assertEqual(total[6], 0.0)
|
||||
self.assertEqual(total[7], 1000.0)
|
||||
self.assertEqual(total[8], 100.0)
|
||||
self.assertEqual(total[5], 1000.0) # selling amount
|
||||
self.assertEqual(total[6], 1000.0) # buying amount
|
||||
self.assertEqual(total[7], 0.0) # gross profit
|
||||
self.assertEqual(total[8], 0.0) # gross profit %
|
||||
|
||||
def test_drop_ship(self):
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
|
||||
@@ -756,6 +700,160 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
self.assertIsNone(data[1].buying_rate)
|
||||
self.assertEqual(data[1]["gross_profit_%"], 20)
|
||||
|
||||
def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None):
|
||||
"""Create a rate adjustment debit note with no stock movement."""
|
||||
dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True)
|
||||
if item_code:
|
||||
dn.items[0].item_code = item_code
|
||||
dn.items[0].item_name = item_code
|
||||
dn.is_debit_note = 1
|
||||
dn.return_against = against_invoice.name
|
||||
dn.items[0].allow_zero_valuation_rate = 1
|
||||
return dn.save().submit()
|
||||
|
||||
def test_debit_note_has_zero_buying_amount_and_full_gross_profit(self):
|
||||
"""
|
||||
Rate adjustment debit note (is_debit_note=1) should show buying_amount=0
|
||||
since there is no stock movement. Gross profit equals the adjustment amount
|
||||
and gross profit % equals 100%.
|
||||
"""
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=self.item,
|
||||
target=self.warehouse,
|
||||
qty=1,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
|
||||
sinv.update_stock = 1
|
||||
sinv = sinv.save().submit()
|
||||
|
||||
debit_note = self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=nowdate(),
|
||||
to_date=nowdate(),
|
||||
group_by="Invoice",
|
||||
)
|
||||
|
||||
columns, data = execute(filters=filters)
|
||||
|
||||
dn_item_rows = [
|
||||
x for x in data if x.get("parent_invoice") == debit_note.name and x.get("indent") == 1.0
|
||||
]
|
||||
self.assertEqual(len(dn_item_rows), 1)
|
||||
|
||||
dn_row = dn_item_rows[0]
|
||||
self.assertEqual(dn_row.buying_amount, 0.0)
|
||||
self.assertEqual(dn_row.selling_amount, 20.0)
|
||||
self.assertEqual(dn_row.gross_profit, 20.0)
|
||||
self.assertEqual(dn_row["gross_profit_%"], 100.0)
|
||||
|
||||
def test_original_invoice_unaffected_by_rate_adjustment_debit_note(self):
|
||||
"""
|
||||
The original invoice's GP should be derived solely from its own selling
|
||||
amount and COGS — the rate adjustment debit note must not alter it.
|
||||
"""
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=self.item,
|
||||
target=self.warehouse,
|
||||
qty=1,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
|
||||
sinv.update_stock = 1
|
||||
sinv = sinv.save().submit()
|
||||
|
||||
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=nowdate(),
|
||||
to_date=nowdate(),
|
||||
group_by="Invoice",
|
||||
)
|
||||
|
||||
columns, data = execute(filters=filters)
|
||||
|
||||
sinv_item_rows = [x for x in data if x.get("parent_invoice") == sinv.name and x.get("indent") == 1.0]
|
||||
self.assertEqual(len(sinv_item_rows), 1)
|
||||
|
||||
sinv_row = sinv_item_rows[0]
|
||||
self.assertEqual(sinv_row.selling_amount, 200.0)
|
||||
self.assertEqual(sinv_row.buying_amount, 100.0)
|
||||
self.assertEqual(sinv_row.gross_profit, 100.0)
|
||||
self.assertEqual(sinv_row["gross_profit_%"], 50.0)
|
||||
|
||||
def test_debit_note_qty_not_inflated_in_grouped_report(self):
|
||||
"""
|
||||
When grouped by Item Code, the debit note (qty=0) must not inflate
|
||||
the group's qty or buying_amount. The selling amount and average
|
||||
selling rate correctly reflect the rate adjustment.
|
||||
"""
|
||||
item = create_item("_Test Rate Adjustment Debit Note Item")
|
||||
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=item.item_code,
|
||||
target=self.warehouse,
|
||||
qty=1,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
sinv = create_sales_invoice(
|
||||
qty=1,
|
||||
rate=200,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
item_code=item.item_code,
|
||||
item_name=item.item_code,
|
||||
cost_center=self.cost_center,
|
||||
warehouse=self.warehouse,
|
||||
debit_to=self.debit_to,
|
||||
parent_cost_center=self.cost_center,
|
||||
update_stock=1,
|
||||
currency="INR",
|
||||
income_account=self.income_account,
|
||||
expense_account=self.expense_account,
|
||||
)
|
||||
|
||||
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20, item_code=item.item_code)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_date=nowdate(),
|
||||
to_date=nowdate(),
|
||||
group_by="Item Code",
|
||||
)
|
||||
|
||||
columns, data = execute(filters=filters)
|
||||
|
||||
# group_by="Item Code" column order:
|
||||
# [item_code, item_name, brand, description, qty, base_rate,
|
||||
# buying_rate, base_amount, buying_amount, gross_profit, gross_profit_percent, currency]
|
||||
item_row = next((row for row in data if row[0] == item.item_code), None)
|
||||
self.assertIsNotNone(item_row)
|
||||
|
||||
qty, base_rate, buying_amount, base_amount, gross_profit, gp_percent = (
|
||||
item_row[4],
|
||||
item_row[5],
|
||||
item_row[8],
|
||||
item_row[7],
|
||||
item_row[9],
|
||||
item_row[10],
|
||||
)
|
||||
|
||||
self.assertEqual(qty, 1.0) # debit note adds qty=0, not inflated
|
||||
self.assertEqual(buying_amount, 100.0) # only original invoice COGS
|
||||
self.assertEqual(base_amount, 220.0) # 200 (original) + 20 (adjustment)
|
||||
self.assertEqual(base_rate, 220.0) # avg selling rate = 220/1
|
||||
self.assertEqual(gross_profit, 120.0) # 220 - 100
|
||||
self.assertAlmostEqual(gp_percent, 54.545, places=2) # 120/220 * 100
|
||||
|
||||
|
||||
def make_sales_person(sales_person_name="_Test Sales Person"):
|
||||
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):
|
||||
|
||||
@@ -9,9 +9,9 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestItemWisePurchaseRegister(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_supplier()
|
||||
self.create_item()
|
||||
self.company = "_Test Company"
|
||||
self.supplier = "_Test Supplier"
|
||||
self.item = "_Test Item"
|
||||
|
||||
def create_purchase_invoice(self, do_not_submit=False):
|
||||
pi = make_purchase_invoice(
|
||||
|
||||
@@ -9,9 +9,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.item = "_Test Item"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
|
||||
def create_sales_invoice(self, item=None, taxes=None, do_not_submit=False):
|
||||
si = create_sales_invoice(
|
||||
|
||||
@@ -9,42 +9,12 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestPaymentLedger(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.cleanup()
|
||||
|
||||
def cleanup(self):
|
||||
doctypes = []
|
||||
doctypes.append(qb.DocType("GL Entry"))
|
||||
doctypes.append(qb.DocType("Payment Ledger Entry"))
|
||||
doctypes.append(qb.DocType("Sales Invoice"))
|
||||
doctypes.append(qb.DocType("Payment Entry"))
|
||||
|
||||
for doctype in doctypes:
|
||||
qb.from_(doctype).delete().where(doctype.company == self.company).run()
|
||||
|
||||
def create_company(self):
|
||||
name = "Test Payment Ledger"
|
||||
company = None
|
||||
if frappe.db.exists("Company", name):
|
||||
company = frappe.get_doc("Company", name)
|
||||
else:
|
||||
company = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Company",
|
||||
"company_name": name,
|
||||
"country": "India",
|
||||
"default_currency": "INR",
|
||||
"create_chart_of_accounts_based_on": "Standard Template",
|
||||
"chart_of_accounts": "Standard",
|
||||
}
|
||||
)
|
||||
company = company.save()
|
||||
self.company = company.name
|
||||
self.cost_center = company.cost_center
|
||||
self.warehouse = "All Warehouses" + " - " + company.abbr
|
||||
self.income_account = company.default_income_account
|
||||
self.expense_account = company.default_expense_account
|
||||
self.debit_to = company.default_receivable_account
|
||||
self.company = "_Test Company"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.warehouse = "Stores - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.expense_account = "Cost of Goods Sold - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
|
||||
def test_unpaid_invoice_outstanding(self):
|
||||
sinv = create_sales_invoice(
|
||||
|
||||
@@ -14,9 +14,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestProfitAndLossStatement(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.item = "_Test Item"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
|
||||
def create_sales_invoice(self, qty=1, rate=150, no_payment_schedule=False, do_not_submit=False):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.utils import flt, getdate
|
||||
from pypika.terms import Bracket, LiteralValue, Order
|
||||
@@ -125,17 +126,32 @@ def _execute(filters=None, additional_table_columns=None):
|
||||
row.update({frappe.scrub(tax_acc): tax_amount})
|
||||
|
||||
# total tax, grand total, rounded total & outstanding amount
|
||||
|
||||
outstanding_precision = (
|
||||
get_field_precision(
|
||||
frappe.get_meta("Purchase Invoice").get_field("outstanding_amount"),
|
||||
currency=company_currency,
|
||||
)
|
||||
or 2
|
||||
)
|
||||
row.update(
|
||||
{
|
||||
"total_tax": total_tax,
|
||||
"grand_total": inv.base_grand_total,
|
||||
"rounded_total": inv.base_rounded_total,
|
||||
"outstanding_amount": inv.outstanding_amount,
|
||||
}
|
||||
)
|
||||
|
||||
if inv.doctype == "Purchase Invoice":
|
||||
row.update({"debit": inv.base_grand_total, "credit": 0.0})
|
||||
row.update(
|
||||
{
|
||||
"debit": inv.base_grand_total,
|
||||
"credit": 0.0,
|
||||
"outstanding_amount": flt(
|
||||
(inv.outstanding_amount * (inv.conversion_rate or 1)), outstanding_precision
|
||||
),
|
||||
}
|
||||
)
|
||||
else:
|
||||
row.update({"debit": 0.0, "credit": inv.base_grand_total})
|
||||
data.append(row)
|
||||
@@ -395,6 +411,7 @@ def get_invoices(filters, additional_query_columns):
|
||||
pi.base_rounded_total,
|
||||
pi.outstanding_amount,
|
||||
pi.mode_of_payment,
|
||||
pi.conversion_rate,
|
||||
)
|
||||
.where(pi.docstatus == 1)
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# MIT License. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_months, today
|
||||
from frappe.utils import add_months, flt, today
|
||||
|
||||
from erpnext.accounts.report.purchase_register.purchase_register import execute
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
@@ -67,6 +67,35 @@ class TestPurchaseRegister(ERPNextTestSuite):
|
||||
self.assertEqual(first_row.total_tax, 100)
|
||||
self.assertEqual(first_row.grand_total, 1100)
|
||||
|
||||
def test_purchase_currency_conversion(self):
|
||||
usd_creditors = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Account",
|
||||
"account_name": "USD Creditors",
|
||||
"parent_account": "Accounts Payable - _TC",
|
||||
"company": "_Test Company",
|
||||
"account_type": "Payable",
|
||||
"root_type": "Liability",
|
||||
"report_type": "Balance Sheet",
|
||||
"account_currency": "USD",
|
||||
}
|
||||
).insert()
|
||||
foreign_invoice = make_purchase_invoice()
|
||||
foreign_invoice.db_set("currency", "USD")
|
||||
foreign_invoice.db_set("conversion_rate", 80)
|
||||
foreign_invoice.db_set("credit_to", usd_creditors.name)
|
||||
foreign_invoice.db_set("outstanding_amount", 100.236)
|
||||
local_invoice = make_purchase_invoice()
|
||||
local_invoice.db_set("currency", "INR")
|
||||
local_invoice.db_set("conversion_rate", 1)
|
||||
local_invoice.db_set("outstanding_amount", 200.456)
|
||||
columns, data, *_ = execute(frappe._dict({"company": foreign_invoice.company}))
|
||||
outstanding_precision = 2
|
||||
|
||||
data_by_name = {x.get("voucher_no"): x.get("outstanding_amount") for x in data}
|
||||
self.assertEqual(data_by_name.get(foreign_invoice.name), flt((100.236 * 80), outstanding_precision))
|
||||
self.assertEqual(data_by_name.get(local_invoice.name), flt(200.456, outstanding_precision))
|
||||
|
||||
def test_purchase_register_ledger_view(self):
|
||||
filters = frappe._dict(
|
||||
company="_Test Company 6",
|
||||
|
||||
@@ -141,17 +141,31 @@ def _execute(filters, additional_table_columns=None):
|
||||
|
||||
# total tax, grand total, outstanding amount & rounded total
|
||||
|
||||
outstanding_precision = (
|
||||
get_field_precision(
|
||||
frappe.get_meta("Sales Invoice").get_field("outstanding_amount"),
|
||||
currency=company_currency,
|
||||
)
|
||||
or 2
|
||||
)
|
||||
row.update(
|
||||
{
|
||||
"tax_total": total_tax,
|
||||
"grand_total": inv.base_grand_total,
|
||||
"rounded_total": inv.base_rounded_total,
|
||||
"outstanding_amount": inv.outstanding_amount,
|
||||
}
|
||||
)
|
||||
|
||||
if inv.doctype == "Sales Invoice":
|
||||
row.update({"debit": inv.base_grand_total, "credit": 0.0})
|
||||
row.update(
|
||||
{
|
||||
"debit": inv.base_grand_total,
|
||||
"credit": 0.0,
|
||||
"outstanding_amount": flt(
|
||||
(inv.outstanding_amount * (inv.conversion_rate or 1)), outstanding_precision
|
||||
),
|
||||
}
|
||||
)
|
||||
else:
|
||||
row.update({"debit": 0.0, "credit": inv.base_grand_total})
|
||||
data.append(row)
|
||||
@@ -437,6 +451,7 @@ def get_invoices(filters, additional_query_columns):
|
||||
si.is_internal_customer,
|
||||
si.represents_company,
|
||||
si.company,
|
||||
si.conversion_rate,
|
||||
)
|
||||
.where(si.docstatus == 1)
|
||||
)
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import frappe
|
||||
from frappe.utils import getdate, today
|
||||
from frappe.utils import add_days, flt, getdate, today
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.sales_register.sales_register import execute
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.selling.doctype.customer.test_customer import make_customer
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.item = "_Test Item"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.cash = "Cash - _TC"
|
||||
self.create_child_cost_center()
|
||||
|
||||
def create_child_cost_center(self):
|
||||
@@ -212,3 +217,25 @@ class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
|
||||
}
|
||||
result_output = {k: v for k, v in filtered_output[0].items() if k in expected_result}
|
||||
self.assertDictEqual(result_output, expected_result)
|
||||
|
||||
def test_outstanding_currency_conversion(self):
|
||||
foreign_invoice = create_sales_invoice(
|
||||
customer="_Test Customer",
|
||||
posting_date=add_days(today(), -1),
|
||||
qty=1,
|
||||
rate=100,
|
||||
)
|
||||
foreign_invoice.db_set("currency", "USD")
|
||||
foreign_invoice.db_set("conversion_rate", 80)
|
||||
foreign_invoice.db_set("outstanding_amount", 100.236)
|
||||
make_customer("_Test Customer2")
|
||||
local_invoice = create_sales_invoice(
|
||||
customer="_Test Customer2", currency="INR", conversion_rate=1, qty=1, rate=200
|
||||
)
|
||||
local_invoice.db_set("outstanding_amount", 200.456)
|
||||
columns, data, *_ = execute(frappe._dict({"company": foreign_invoice.company}))
|
||||
outstanding_precision = 2
|
||||
|
||||
data_by_name = {x.get("voucher_no"): x.get("outstanding_amount") for x in data}
|
||||
self.assertEqual(data_by_name.get(foreign_invoice.name), flt((100.236 * 80), outstanding_precision))
|
||||
self.assertEqual(data_by_name.get(local_invoice.name), flt(200.456, outstanding_precision))
|
||||
|
||||
@@ -9,10 +9,9 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestSupplierLedgerSummary(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_supplier()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
self.supplier = "_Test Supplier"
|
||||
self.item = "_Test Item"
|
||||
|
||||
def create_purchase_invoice(self, do_not_submit=False):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
@@ -20,8 +20,7 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestTaxWithholdingDetails(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.clear_old_entries()
|
||||
self.company = "_Test Company"
|
||||
create_records()
|
||||
|
||||
def test_tax_withholding_for_customers(self):
|
||||
|
||||
@@ -146,7 +146,6 @@ def get_appropriate_company(filters):
|
||||
return company
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_invoiced_item_gross_margin(sales_invoice=None, item_code=None, company=None, with_item_data=False):
|
||||
from erpnext.accounts.report.gross_profit.gross_profit import GrossProfitGenerator
|
||||
|
||||
|
||||
@@ -93,6 +93,11 @@ frappe.ui.form.on("Asset", {
|
||||
frappe.ui.form.trigger("Asset", "asset_type");
|
||||
frm.toggle_display("next_depreciation_date", frm.doc.docstatus < 1);
|
||||
|
||||
if (frm.doc.docstatus < 1 && frm.doc.calculate_depreciation && frm.doc.is_fully_depreciated) {
|
||||
// Is Fully Depreciated is read-only while depreciation is calculated, so keep it unchecked
|
||||
frm.set_value("is_fully_depreciated", 0);
|
||||
}
|
||||
|
||||
let has_create_buttons = false;
|
||||
if (frm.doc.docstatus == 1) {
|
||||
if (["Submitted", "Partially Depreciated"].includes(frm.doc.status)) {
|
||||
@@ -726,6 +731,10 @@ frappe.ui.form.on("Asset", {
|
||||
|
||||
calculate_depreciation: function (frm) {
|
||||
frm.toggle_reqd("finance_books", frm.doc.calculate_depreciation);
|
||||
if (frm.doc.calculate_depreciation && frm.doc.is_fully_depreciated) {
|
||||
// Is Fully Depreciated is read-only while depreciation is calculated, so keep it unchecked
|
||||
frm.set_value("is_fully_depreciated", 0);
|
||||
}
|
||||
if (frm.doc.item_code && frm.doc.calculate_depreciation && frm.doc.net_purchase_amount) {
|
||||
frm.trigger("set_finance_book");
|
||||
} else {
|
||||
|
||||
@@ -450,10 +450,11 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:(doc.asset_type == \"Existing Asset\" && !doc.calculate_depreciation) || doc.calculate_depreciation",
|
||||
"fieldname": "is_fully_depreciated",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Fully Depreciated"
|
||||
"label": "Is Fully Depreciated",
|
||||
"read_only_depends_on": "eval:doc.calculate_depreciation"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.docstatus > 0",
|
||||
|
||||
@@ -132,6 +132,10 @@ class Asset(AccountsController):
|
||||
self.validate_gross_and_purchase_amount()
|
||||
self.validate_finance_books()
|
||||
|
||||
if self.calculate_depreciation:
|
||||
# Is Fully Depreciated is only applicable to manually entered existing assets
|
||||
self.is_fully_depreciated = 0
|
||||
|
||||
def before_save(self):
|
||||
self.total_asset_cost = self.net_purchase_amount + self.additional_asset_cost
|
||||
self.status = self.get_status()
|
||||
|
||||
@@ -100,6 +100,9 @@ class AssetCapitalization(StockController):
|
||||
self.set_asset_values()
|
||||
self.calculate_totals()
|
||||
self.set_title()
|
||||
# Asset Capitalization overrides validate() without calling super(), so the shared
|
||||
# mandatory inventory dimension check must be invoked explicitly here.
|
||||
self.validate_inventory_dimension_mandatory()
|
||||
|
||||
def on_update(self):
|
||||
if self.stock_items:
|
||||
|
||||
@@ -30,10 +30,7 @@ class BulkTransactionLog(Document):
|
||||
def load_from_db(self):
|
||||
log_detail = qb.DocType("Bulk Transaction Log Detail")
|
||||
|
||||
has_records = frappe.db.sql(
|
||||
"select exists (select * from `tabBulk Transaction Log Detail` where date = %s);",
|
||||
(self.name,),
|
||||
)[0][0]
|
||||
has_records = frappe.db.exists("Bulk Transaction Log Detail", {"date": self.name})
|
||||
if not has_records:
|
||||
raise frappe.DoesNotExistError
|
||||
|
||||
|
||||
@@ -1,11 +1,76 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import nowtime, random_string
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestBulkTransactionLog(ERPNextTestSuite):
|
||||
pass
|
||||
def _make_log_doc(self, date):
|
||||
# "Bulk Transaction Log" is a virtual doctype named by date; build the doc
|
||||
# in-memory and drive load_from_db() directly to exercise the converted query.
|
||||
doc = frappe.new_doc("Bulk Transaction Log")
|
||||
doc.name = date
|
||||
return doc
|
||||
|
||||
def _insert_detail(self, date, status="Success"):
|
||||
detail = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Bulk Transaction Log Detail",
|
||||
"from_doctype": "Sales Order",
|
||||
"to_doctype": "Sales Invoice",
|
||||
"transaction_name": "_Test BTLD " + random_string(8),
|
||||
"date": date,
|
||||
"time": nowtime(),
|
||||
"transaction_status": status,
|
||||
}
|
||||
)
|
||||
# transaction_name is a Dynamic Link (options=from_doctype); the converted
|
||||
# query never reads it, so skip link validation rather than create real txns.
|
||||
detail.insert(ignore_permissions=True, ignore_links=True)
|
||||
return detail
|
||||
|
||||
def test_load_raises_when_no_detail_rows(self):
|
||||
# A date with zero Bulk Transaction Log Detail rows must not resolve to a log.
|
||||
date = "2024-01-01"
|
||||
self.assertFalse(
|
||||
frappe.db.exists("Bulk Transaction Log Detail", {"date": date}),
|
||||
"precondition: no detail rows for this date",
|
||||
)
|
||||
|
||||
doc = self._make_log_doc(date)
|
||||
self.assertRaises(frappe.DoesNotExistError, doc.load_from_db)
|
||||
|
||||
def test_load_succeeds_and_aggregates_after_detail_inserted(self):
|
||||
date = "2024-02-02"
|
||||
|
||||
# Initially absent -> load_from_db must raise.
|
||||
self.assertRaises(frappe.DoesNotExistError, self._make_log_doc(date).load_from_db)
|
||||
|
||||
# Insert detail rows for this date: 2 succeeded, 1 failed.
|
||||
self._insert_detail(date, "Success")
|
||||
self._insert_detail(date, "Success")
|
||||
self._insert_detail(date, "Failed")
|
||||
|
||||
# Now the exists() check passes and load_from_db() populates aggregates.
|
||||
doc = self._make_log_doc(date)
|
||||
doc.load_from_db()
|
||||
|
||||
self.assertEqual(doc.date, date)
|
||||
self.assertEqual(doc.succeeded, 2)
|
||||
self.assertEqual(doc.failed, 1)
|
||||
self.assertEqual(doc.log_entries, 3)
|
||||
|
||||
def test_load_isolated_per_date(self):
|
||||
# Detail rows on a different date must not satisfy the lookup for our date.
|
||||
other_date = "2024-03-03"
|
||||
self._insert_detail(other_date, "Success")
|
||||
|
||||
target_date = "2024-04-04"
|
||||
self.assertFalse(
|
||||
frappe.db.exists("Bulk Transaction Log Detail", {"date": target_date}),
|
||||
"target date has no rows; rows on another date must not leak in",
|
||||
)
|
||||
self.assertRaises(frappe.DoesNotExistError, self._make_log_doc(target_date).load_from_db)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
frappe.ui.form.on("Buying Settings", {
|
||||
refresh(frm) {
|
||||
if (!frm.naming_controller) frm.naming_controller = new erpnext.NamingSeriesController(frm);
|
||||
if (!frm.naming_controller) frm.naming_controller = new frappe.ui.NamingSeriesController(frm);
|
||||
|
||||
const display = frm.doc.supp_master_name === "Naming Series";
|
||||
frm.set_df_property("naming_series_details", "hidden", !display);
|
||||
|
||||
@@ -68,6 +68,31 @@ frappe.ui.form.on("Supplier", {
|
||||
});
|
||||
|
||||
frm.make_methods = {
|
||||
"Purchase Order": () =>
|
||||
frappe.model.with_doctype("Purchase Order", function () {
|
||||
const po = frappe.model.get_new_doc("Purchase Order");
|
||||
po.supplier = frm.doc.name;
|
||||
frappe.set_route("Form", "Purchase Order", po.name);
|
||||
}),
|
||||
"Purchase Invoice": () =>
|
||||
frappe.model.with_doctype("Purchase Invoice", function () {
|
||||
const pi = frappe.model.get_new_doc("Purchase Invoice");
|
||||
pi.supplier = frm.doc.name;
|
||||
frappe.set_route("Form", "Purchase Invoice", pi.name);
|
||||
}),
|
||||
"Request for Quotation": () =>
|
||||
frappe.model.with_doctype("Request for Quotation", function () {
|
||||
const rfq = frappe.model.get_new_doc("Request for Quotation");
|
||||
const row = frappe.model.add_child(rfq, "suppliers");
|
||||
row.supplier = frm.doc.name;
|
||||
frappe.set_route("Form", "Request for Quotation", rfq.name);
|
||||
}),
|
||||
"Supplier Quotation": () =>
|
||||
frappe.model.with_doctype("Supplier Quotation", function () {
|
||||
const sq = frappe.model.get_new_doc("Supplier Quotation");
|
||||
sq.supplier = frm.doc.name;
|
||||
frappe.set_route("Form", "Supplier Quotation", sq.name);
|
||||
}),
|
||||
"Bank Account": () => erpnext.utils.make_bank_account(frm.doc.doctype, frm.doc.name),
|
||||
"Pricing Rule": () => frm.trigger("make_pricing_rule"),
|
||||
};
|
||||
@@ -117,6 +142,20 @@ frappe.ui.form.on("Supplier", {
|
||||
__("View")
|
||||
);
|
||||
|
||||
for (const doctype in frm.make_methods) {
|
||||
frm.add_custom_button(__(doctype), frm.make_methods[doctype], __("Create"));
|
||||
}
|
||||
|
||||
if (frm.doc.supplier_group) {
|
||||
frm.add_custom_button(
|
||||
__("Get Supplier Group Details"),
|
||||
function () {
|
||||
frm.trigger("get_supplier_group_details");
|
||||
},
|
||||
__("Actions")
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
cint(frappe.defaults.get_default("enable_common_party_accounting")) &&
|
||||
frappe.model.can_create("Party Link")
|
||||
@@ -173,6 +212,8 @@ frappe.ui.form.on("Supplier", {
|
||||
frm.toggle_reqd("represents_company", true);
|
||||
} else {
|
||||
frm.toggle_reqd("represents_company", false);
|
||||
frm.set_value("represents_company", "");
|
||||
frm.set_value("companies", []);
|
||||
}
|
||||
},
|
||||
show_party_link_dialog: function (frm) {
|
||||
|
||||
@@ -11,72 +11,76 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"naming_series",
|
||||
"supplier_type",
|
||||
"supplier_name",
|
||||
"supplier_type",
|
||||
"alias",
|
||||
"gender",
|
||||
"column_break0",
|
||||
"supplier_group",
|
||||
"country",
|
||||
"is_transporter",
|
||||
"image",
|
||||
"defaults_section",
|
||||
"default_currency",
|
||||
"default_bank_account",
|
||||
"column_break_10",
|
||||
"default_price_list",
|
||||
"column_break2",
|
||||
"supplier_details",
|
||||
"column_break_30",
|
||||
"website",
|
||||
"language",
|
||||
"customer_numbers",
|
||||
"payment_terms",
|
||||
"contact_and_address_tab",
|
||||
"address_contacts",
|
||||
"address_html",
|
||||
"column_break1",
|
||||
"contact_html",
|
||||
"primary_address_and_contact_detail_section",
|
||||
"column_break_44",
|
||||
"supplier_primary_address",
|
||||
"primary_address",
|
||||
"column_break_mglr",
|
||||
"supplier_primary_contact",
|
||||
"mobile_no",
|
||||
"email_id",
|
||||
"tax_tab",
|
||||
"tax_id",
|
||||
"tax_category",
|
||||
"column_break_27",
|
||||
"tax_withholding_category",
|
||||
"tax_withholding_group",
|
||||
"accounting_tab",
|
||||
"payment_terms",
|
||||
"default_accounts_section",
|
||||
"accounts",
|
||||
"internal_supplier_section",
|
||||
"is_internal_supplier",
|
||||
"represents_company",
|
||||
"column_break_16",
|
||||
"section_break_pgad",
|
||||
"companies",
|
||||
"tax_tab",
|
||||
"taxation_section",
|
||||
"tax_id",
|
||||
"tax_category",
|
||||
"column_break_27",
|
||||
"tax_withholding_category",
|
||||
"tax_withholding_group",
|
||||
"settings_tab",
|
||||
"invoice_settings_section",
|
||||
"is_transporter",
|
||||
"allow_purchase_invoice_creation_without_purchase_order",
|
||||
"allow_purchase_invoice_creation_without_purchase_receipt",
|
||||
"column_break_54",
|
||||
"disabled",
|
||||
"rfq_and_purchase_order_settings_section",
|
||||
"is_frozen",
|
||||
"block_supplier_section",
|
||||
"on_hold",
|
||||
"hold_type",
|
||||
"release_date",
|
||||
"rfq_and_purchase_order_settings_section",
|
||||
"warn_rfqs",
|
||||
"prevent_rfqs",
|
||||
"column_break_oxjw",
|
||||
"warn_pos",
|
||||
"prevent_pos",
|
||||
"block_supplier_section",
|
||||
"on_hold",
|
||||
"hold_type",
|
||||
"column_break_59",
|
||||
"release_date",
|
||||
"portal_users_tab",
|
||||
"portal_users",
|
||||
"more_info_tab",
|
||||
"column_break2",
|
||||
"website",
|
||||
"language",
|
||||
"column_break_30",
|
||||
"supplier_details",
|
||||
"section_break_jqla",
|
||||
"customer_numbers",
|
||||
"dashboard_tab"
|
||||
],
|
||||
"fields": [
|
||||
@@ -101,6 +105,14 @@
|
||||
"oldfieldtype": "Data",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "alias",
|
||||
"fieldtype": "Data",
|
||||
"in_global_search": 1,
|
||||
"label": "Alias",
|
||||
"no_copy": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "country",
|
||||
"fieldtype": "Link",
|
||||
@@ -110,21 +122,24 @@
|
||||
{
|
||||
"fieldname": "default_bank_account",
|
||||
"fieldtype": "Link",
|
||||
"label": "Default Company Bank Account",
|
||||
"label": "Company Bank Account",
|
||||
"options": "Bank Account"
|
||||
},
|
||||
{
|
||||
"description": "Supplier's tax identification number (e.g. PAN, VAT, GST)",
|
||||
"fieldname": "tax_id",
|
||||
"fieldtype": "Data",
|
||||
"label": "Tax ID"
|
||||
},
|
||||
{
|
||||
"description": "Determines which tax rules apply to this supplier",
|
||||
"fieldname": "tax_category",
|
||||
"fieldtype": "Link",
|
||||
"label": "Tax Category",
|
||||
"options": "Tax Category"
|
||||
},
|
||||
{
|
||||
"description": "TDS / withholding tax category applied when paying this supplier",
|
||||
"fieldname": "tax_withholding_category",
|
||||
"fieldtype": "Link",
|
||||
"label": "Tax Withholding Category",
|
||||
@@ -132,15 +147,18 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Enable to make this supplier selectable as a transporter on Delivery Notes and Stock Entries",
|
||||
"fieldname": "is_transporter",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Transporter"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Used for inter-company transactions",
|
||||
"fieldname": "is_internal_supplier",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Internal Supplier"
|
||||
"label": "Is Internal Supplier",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "is_internal_supplier",
|
||||
@@ -192,6 +210,7 @@
|
||||
{
|
||||
"bold": 1,
|
||||
"default": "0",
|
||||
"description": "Disabled suppliers are hidden from selection in new transactions but remain in historical records",
|
||||
"fieldname": "disabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disabled"
|
||||
@@ -232,7 +251,7 @@
|
||||
"depends_on": "represents_company",
|
||||
"fieldname": "companies",
|
||||
"fieldtype": "Table",
|
||||
"label": "Allowed To Transact With",
|
||||
"label": "Allowed to transact with",
|
||||
"options": "Allowed To Transact With"
|
||||
},
|
||||
{
|
||||
@@ -258,21 +277,24 @@
|
||||
{
|
||||
"fieldname": "payment_terms",
|
||||
"fieldtype": "Link",
|
||||
"label": "Default Payment Terms Template",
|
||||
"label": "Payment Terms Template",
|
||||
"options": "Payment Terms Template"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "When enabled, transactions with this supplier will be blocked based on the Hold Type below",
|
||||
"fieldname": "on_hold",
|
||||
"fieldtype": "Check",
|
||||
"label": "Block Supplier"
|
||||
"label": "Block Supplier",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
{
|
||||
"default": "All",
|
||||
"depends_on": "eval:doc.on_hold",
|
||||
"fieldname": "hold_type",
|
||||
"fieldtype": "Select",
|
||||
"label": "Hold Type",
|
||||
"options": "\nAll\nInvoices\nPayments"
|
||||
"options": "All\nInvoices\nPayments"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.on_hold",
|
||||
@@ -307,14 +329,13 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Mention if non-standard payable account",
|
||||
"description": "Override the default payable / advance accounts on a per-company basis. Leave blank to use each company's defaults from Company settings.",
|
||||
"fieldname": "accounts",
|
||||
"fieldtype": "Table",
|
||||
"label": "Accounts",
|
||||
"label": "Per-Company Accounts",
|
||||
"options": "Party Account"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "supplier_details",
|
||||
"fieldname": "column_break2",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -329,7 +350,7 @@
|
||||
"oldfieldtype": "Data"
|
||||
},
|
||||
{
|
||||
"description": "Statutory info and other general information about your Supplier",
|
||||
"description": "General information about your Supplier",
|
||||
"fieldname": "supplier_details",
|
||||
"fieldtype": "Text",
|
||||
"label": "Supplier Details",
|
||||
@@ -342,6 +363,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Frozen suppliers block ledger entries until unfrozen. Use this to temporarily lock accounting activity without disabling the supplier.",
|
||||
"fieldname": "is_frozen",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Frozen"
|
||||
@@ -350,13 +372,13 @@
|
||||
"default": "0",
|
||||
"fieldname": "allow_purchase_invoice_creation_without_purchase_order",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Purchase Invoice Creation Without Purchase Order"
|
||||
"label": "Allow purchase invoice creation without purchase order"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "allow_purchase_invoice_creation_without_purchase_receipt",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Purchase Invoice Creation Without Purchase Receipt"
|
||||
"label": "Allow purchase invoice creation without purchase receipt"
|
||||
},
|
||||
{
|
||||
"fieldname": "primary_address_and_contact_detail_section",
|
||||
@@ -367,7 +389,7 @@
|
||||
"description": "Reselect, if the chosen contact is edited after save",
|
||||
"fieldname": "supplier_primary_contact",
|
||||
"fieldtype": "Link",
|
||||
"label": "Supplier Primary Contact",
|
||||
"label": "Primary Contact",
|
||||
"no_copy": 1,
|
||||
"options": "Contact"
|
||||
},
|
||||
@@ -382,17 +404,13 @@
|
||||
"fetch_from": "supplier_primary_contact.email_id",
|
||||
"fieldname": "email_id",
|
||||
"fieldtype": "Read Only",
|
||||
"label": "Email Id",
|
||||
"label": "Email ID",
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_44",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "primary_address",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Primary Address",
|
||||
"label": "Primary Address Preview",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -400,7 +418,7 @@
|
||||
"description": "Reselect, if the chosen address is edited after save",
|
||||
"fieldname": "supplier_primary_address",
|
||||
"fieldtype": "Link",
|
||||
"label": "Supplier Primary Address",
|
||||
"label": "Primary Address",
|
||||
"no_copy": 1,
|
||||
"options": "Address"
|
||||
},
|
||||
@@ -436,10 +454,11 @@
|
||||
"label": "Tax"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "is_internal_supplier",
|
||||
"fieldname": "internal_supplier_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Internal Supplier Accounting"
|
||||
"hide_border": 1,
|
||||
"label": "Internal Supplier Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_16",
|
||||
@@ -458,10 +477,6 @@
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Block Supplier"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_59",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "default_accounts_section",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -483,12 +498,14 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"description": "Account / customer numbers assigned to your companies by this supplier (for reconciliation on their statements)",
|
||||
"fieldname": "customer_numbers",
|
||||
"fieldtype": "Table",
|
||||
"label": "Customer Numbers",
|
||||
"options": "Customer Number At Supplier"
|
||||
},
|
||||
{
|
||||
"description": "Used to pick the correct rate row inside the Tax Withholding Category for this supplier (e.g. Company vs Individual rates)",
|
||||
"fieldname": "tax_withholding_group",
|
||||
"fieldtype": "Link",
|
||||
"label": "Tax Withholding Group",
|
||||
@@ -504,11 +521,34 @@
|
||||
{
|
||||
"fieldname": "rfq_and_purchase_order_settings_section",
|
||||
"fieldtype": "Section Break",
|
||||
"hidden": 1,
|
||||
"label": "RFQ and Purchase Order Settings"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_oxjw",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "taxation_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Tax Identification"
|
||||
},
|
||||
{
|
||||
"fieldname": "invoice_settings_section",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "more_info_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "More Info"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_jqla",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_pgad",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -522,7 +562,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2026-05-29 16:52:59.441272",
|
||||
"modified": "2026-06-27 16:12:33.190257",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier",
|
||||
@@ -582,7 +622,7 @@
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "supplier_group",
|
||||
"search_fields": "supplier_group, alias",
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
|
||||
@@ -39,6 +39,7 @@ class Supplier(TransactionBase):
|
||||
from erpnext.utilities.doctype.portal_user.portal_user import PortalUser
|
||||
|
||||
accounts: DF.Table[PartyAccount]
|
||||
alias: DF.Data | None
|
||||
allow_purchase_invoice_creation_without_purchase_order: DF.Check
|
||||
allow_purchase_invoice_creation_without_purchase_receipt: DF.Check
|
||||
companies: DF.Table[AllowedToTransactWith]
|
||||
@@ -50,7 +51,7 @@ class Supplier(TransactionBase):
|
||||
disabled: DF.Check
|
||||
email_id: DF.ReadOnly | None
|
||||
gender: DF.Link | None
|
||||
hold_type: DF.Literal["", "All", "Invoices", "Payments"]
|
||||
hold_type: DF.Literal["All", "Invoices", "Payments"]
|
||||
image: DF.AttachImage | None
|
||||
is_frozen: DF.Check
|
||||
is_internal_supplier: DF.Check
|
||||
@@ -88,7 +89,6 @@ class Supplier(TransactionBase):
|
||||
|
||||
def before_save(self):
|
||||
if not self.on_hold:
|
||||
self.hold_type = ""
|
||||
self.release_date = ""
|
||||
elif self.on_hold and not self.hold_type:
|
||||
self.hold_type = "All"
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
frappe.listview_settings["Supplier"] = {
|
||||
add_fields: ["supplier_name", "supplier_group", "image", "on_hold"],
|
||||
add_fields: ["supplier_name", "supplier_group", "image", "on_hold", "disabled", "is_frozen"],
|
||||
get_indicator: function (doc) {
|
||||
if (cint(doc.on_hold)) {
|
||||
return [__("On Hold"), "red"];
|
||||
if (cint(doc.disabled)) {
|
||||
return [__("Disabled"), "gray", "disabled,=,1"];
|
||||
} else if (cint(doc.on_hold)) {
|
||||
return [__("On Hold"), "red", "on_hold,=,1"];
|
||||
} else if (cint(doc.is_frozen)) {
|
||||
return [__("Frozen"), "orange", "is_frozen,=,1"];
|
||||
} else {
|
||||
return [__("Active"), "green", "disabled,=,0|on_hold,=,0|is_frozen,=,0"];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4153,6 +4153,7 @@ def update_child_qty_rate(
|
||||
# if rate is greater than price_list_rate, set margin
|
||||
# or set discount
|
||||
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,
|
||||
@@ -4160,14 +4161,11 @@ def update_child_qty_rate(
|
||||
)
|
||||
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)
|
||||
|
||||
child_item.flags.ignore_validate_update_after_submit = True
|
||||
if new_child_flag:
|
||||
|
||||
@@ -414,39 +414,29 @@ class BuyingController(SubcontractingController):
|
||||
stock_and_asset_items = []
|
||||
stock_and_asset_items = self.get_stock_items() + self.get_asset_items()
|
||||
|
||||
stock_and_asset_items_qty, stock_and_asset_items_amount = 0, 0
|
||||
last_item_idx = 1
|
||||
for d in self.get("items"):
|
||||
if d.item_code:
|
||||
stock_and_asset_items_qty += flt(d.qty)
|
||||
stock_and_asset_items_amount += flt(d.base_net_amount)
|
||||
(
|
||||
tax_accounts,
|
||||
total_valuation_amount,
|
||||
all_item_charges,
|
||||
stock_item_charges,
|
||||
) = self.get_tax_details()
|
||||
|
||||
last_item_idx = d.idx
|
||||
# Pre-compute each item's share of the "Actual" valuation charges (keyed by row idx).
|
||||
actual_charge_per_item = self.distribute_actual_tax_amount(
|
||||
stock_and_asset_items, all_item_charges, stock_item_charges
|
||||
)
|
||||
|
||||
tax_accounts, total_valuation_amount, total_actual_tax_amount = self.get_tax_details()
|
||||
remaining_amount = total_actual_tax_amount
|
||||
last_item_idx = max((d.idx for d in self.get("items")), default=1)
|
||||
|
||||
for i, item in enumerate(self.get("items")):
|
||||
if item.item_code and (item.qty or item.get("rejected_qty")):
|
||||
item_tax_amount, actual_tax_amount = 0.0, 0.0
|
||||
if i == (last_item_idx - 1):
|
||||
# dump any rounding remainder of the On Net Total valuation on the last item
|
||||
item_tax_amount = total_valuation_amount
|
||||
actual_tax_amount = remaining_amount
|
||||
else:
|
||||
# calculate item tax amount
|
||||
item_tax_amount = self.get_item_tax_amount(item, tax_accounts)
|
||||
total_valuation_amount -= item_tax_amount
|
||||
|
||||
if total_actual_tax_amount:
|
||||
actual_tax_amount = self.get_item_actual_tax_amount(
|
||||
item,
|
||||
total_actual_tax_amount,
|
||||
stock_and_asset_items_amount,
|
||||
stock_and_asset_items_qty,
|
||||
)
|
||||
|
||||
remaining_amount -= actual_tax_amount
|
||||
|
||||
# This code is required here to calculate the correct valuation for stock items
|
||||
if item.item_code not in stock_and_asset_items:
|
||||
item.valuation_rate = 0.0
|
||||
@@ -454,7 +444,8 @@ class BuyingController(SubcontractingController):
|
||||
|
||||
# Item tax amount is the total tax amount applied on that item and actual tax type amount
|
||||
item.item_tax_amount = flt(
|
||||
item_tax_amount + actual_tax_amount, self.precision("item_tax_amount", item)
|
||||
item_tax_amount + actual_charge_per_item.get(item.idx, 0.0),
|
||||
self.precision("item_tax_amount", item),
|
||||
)
|
||||
|
||||
self.round_floats_in(item)
|
||||
@@ -503,7 +494,11 @@ class BuyingController(SubcontractingController):
|
||||
def get_tax_details(self):
|
||||
tax_accounts = []
|
||||
total_valuation_amount = 0.0
|
||||
total_actual_tax_amount = 0.0
|
||||
# Per-row "Actual" valuation charge amounts, kept separate (not pooled) so each can be
|
||||
# distributed individually - this keeps the per-item item_tax_amount in lockstep with the
|
||||
# per-tax-row amount capitalized in the GL (see get_capitalized_valuation_tax).
|
||||
all_item_charges = []
|
||||
stock_item_charges = []
|
||||
|
||||
for d in self.get("taxes"):
|
||||
if d.category not in ["Valuation", "Valuation and Total"]:
|
||||
@@ -516,10 +511,13 @@ class BuyingController(SubcontractingController):
|
||||
if d.charge_type == "On Net Total":
|
||||
total_valuation_amount += amount
|
||||
tax_accounts.append(d.account_head)
|
||||
elif d.charge_type == "Actual" and d.get("allocate_full_amount_to_stock_items"):
|
||||
# Capitalize the full amount onto stock/asset items only (e.g. Freight)
|
||||
stock_item_charges.append(amount)
|
||||
else:
|
||||
total_actual_tax_amount += amount
|
||||
all_item_charges.append(amount)
|
||||
|
||||
return tax_accounts, total_valuation_amount, total_actual_tax_amount
|
||||
return tax_accounts, total_valuation_amount, all_item_charges, stock_item_charges
|
||||
|
||||
def get_item_tax_amount(self, item, tax_accounts):
|
||||
item_tax_amount = 0.0
|
||||
@@ -540,16 +538,81 @@ class BuyingController(SubcontractingController):
|
||||
|
||||
return item_tax_amount
|
||||
|
||||
def get_item_actual_tax_amount(
|
||||
self, item, actual_tax_amount, stock_and_asset_items_amount, stock_and_asset_items_qty
|
||||
):
|
||||
item_proportion = (
|
||||
flt(item.base_net_amount) / stock_and_asset_items_amount
|
||||
if stock_and_asset_items_amount
|
||||
else flt(item.qty) / stock_and_asset_items_qty
|
||||
def distribute_actual_tax_amount(self, stock_and_asset_items, all_item_charges, stock_item_charges):
|
||||
"""Distribute "Actual" valuation charges to each item, keyed by row idx.
|
||||
|
||||
Each charge is spread individually (not pooled together) so the resulting per-item
|
||||
item_tax_amount decomposes exactly into the per-tax-row amount capitalized in the GL
|
||||
(see get_capitalized_valuation_tax) - pooling first and spreading the aggregate can drift
|
||||
by rounding for multiple charges over unevenly valued items. A charge in `all_item_charges`
|
||||
is spread across every item by net amount; a non-stock item's share is computed but never
|
||||
capitalized (e.g. a genuine tax). A charge in `stock_item_charges` (flagged
|
||||
`allocate_full_amount_to_stock_items`) is spread across stock/asset items only, so the whole
|
||||
charge is capitalized (e.g. Freight).
|
||||
"""
|
||||
all_items = [d for d in self.get("items") if d.item_code]
|
||||
stock_items = [d for d in all_items if d.item_code in stock_and_asset_items]
|
||||
|
||||
charge_per_item = {}
|
||||
for charge in all_item_charges:
|
||||
self._spread_charge_over_items(charge_per_item, charge, all_items)
|
||||
for charge in stock_item_charges:
|
||||
self._spread_charge_over_items(charge_per_item, charge, stock_items)
|
||||
return charge_per_item
|
||||
|
||||
def _spread_charge_over_items(self, charge_per_item, total_charge, items):
|
||||
"""Add each item's proportional share of `total_charge` into `charge_per_item`.
|
||||
Proportion is by net amount (falling back to qty); any rounding remainder is assigned
|
||||
to the last item in the group."""
|
||||
if not total_charge or not items:
|
||||
return
|
||||
|
||||
total_amount = sum(flt(d.base_net_amount) for d in items)
|
||||
total_qty = sum(flt(d.qty) for d in items)
|
||||
|
||||
# Nothing to proportion against (all rows have zero amount and zero qty)
|
||||
if not total_amount and not total_qty:
|
||||
return
|
||||
|
||||
remaining = total_charge
|
||||
for d in items[:-1]:
|
||||
proportion = flt(d.base_net_amount) / total_amount if total_amount else flt(d.qty) / total_qty
|
||||
charge = flt(proportion * total_charge, self.precision("item_tax_amount", d))
|
||||
charge_per_item[d.idx] = charge_per_item.get(d.idx, 0.0) + charge
|
||||
remaining -= charge
|
||||
|
||||
last = items[-1]
|
||||
charge_per_item[last.idx] = charge_per_item.get(last.idx, 0.0) + flt(
|
||||
remaining, self.precision("item_tax_amount", last)
|
||||
)
|
||||
|
||||
return flt(item_proportion * actual_tax_amount, self.precision("item_tax_amount", item))
|
||||
def get_capitalized_valuation_tax(self):
|
||||
stock_and_asset_items = self.get_stock_items() + self.get_asset_items()
|
||||
all_items = [d for d in self.get("items") if d.item_code]
|
||||
stock_item_idx = {d.idx for d in all_items if d.item_code in stock_and_asset_items}
|
||||
|
||||
capitalized = {}
|
||||
for tax in self.get("taxes"):
|
||||
if tax.category not in ("Valuation", "Valuation and Total"):
|
||||
continue
|
||||
|
||||
amount = flt(tax.base_tax_amount_after_discount_amount) * (
|
||||
-1 if tax.get("add_deduct_tax") == "Deduct" else 1
|
||||
)
|
||||
if not amount:
|
||||
continue
|
||||
|
||||
if tax.charge_type == "Actual" and not tax.get("allocate_full_amount_to_stock_items"):
|
||||
# Spread across all items; only the stock/asset items' share is capitalized.
|
||||
charge_per_item = {}
|
||||
self._spread_charge_over_items(charge_per_item, amount, all_items)
|
||||
amount = sum(
|
||||
charge for item_idx, charge in charge_per_item.items() if item_idx in stock_item_idx
|
||||
)
|
||||
|
||||
capitalized[tax.name] = amount
|
||||
|
||||
return capitalized
|
||||
|
||||
def set_incoming_rate(self):
|
||||
"""
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Case
|
||||
from frappe.utils import cstr, flt
|
||||
|
||||
from erpnext.utilities.product import get_item_codes_by_attributes
|
||||
@@ -129,6 +130,53 @@ def validate_is_incremental(numeric_attribute, attribute, value, item):
|
||||
)
|
||||
|
||||
|
||||
def get_attribute_value_renames(item_attribute):
|
||||
"""Return old to new attribute value mappings for renamed Item Attribute Value rows."""
|
||||
if item_attribute.numeric_values:
|
||||
return {}
|
||||
|
||||
db_value = item_attribute.get_doc_before_save()
|
||||
if not db_value:
|
||||
return {}
|
||||
|
||||
old_values = {d.name: d.attribute_value for d in db_value.item_attribute_values}
|
||||
renames = {}
|
||||
|
||||
for row in item_attribute.item_attribute_values:
|
||||
if row.name in old_values and old_values[row.name] != row.attribute_value:
|
||||
renames[old_values[row.name]] = row.attribute_value
|
||||
|
||||
return renames
|
||||
|
||||
|
||||
def update_variant_attribute_values(item_attribute):
|
||||
"""Propagate renamed Item Attribute Values to Item Variant Attribute on variant items."""
|
||||
value_map = get_attribute_value_renames(item_attribute)
|
||||
if not value_map:
|
||||
return
|
||||
|
||||
item_variant_table = frappe.qb.DocType("Item Variant Attribute")
|
||||
item_table = frappe.qb.DocType("Item")
|
||||
attribute_value = item_variant_table.attribute_value
|
||||
attribute_value_case = Case()
|
||||
|
||||
for old_value, new_value in value_map.items():
|
||||
attribute_value_case = attribute_value_case.when(attribute_value == old_value, new_value)
|
||||
|
||||
(
|
||||
frappe.qb.update(item_variant_table)
|
||||
.join(item_table)
|
||||
.on(item_table.name == item_variant_table.parent)
|
||||
.set(attribute_value, attribute_value_case.else_(attribute_value))
|
||||
.where(item_table.variant_of.isnotnull())
|
||||
.where(item_table.variant_of != "")
|
||||
.where(item_variant_table.attribute == item_attribute.name)
|
||||
.where(attribute_value.isin(list(value_map)))
|
||||
).run()
|
||||
|
||||
frappe.flags.attribute_values = None
|
||||
|
||||
|
||||
def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True):
|
||||
allow_rename_attribute_value = frappe.db.get_single_value(
|
||||
"Item Variant Settings", "allow_rename_attribute_value"
|
||||
|
||||
@@ -216,11 +216,8 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
|
||||
group = "Customer Group" if filters.get("customer") else "Supplier Group"
|
||||
item_rules_list = frappe.get_all(
|
||||
"Party Specific Item",
|
||||
filters={
|
||||
"party": ["!=", party],
|
||||
"party_type": party_type,
|
||||
},
|
||||
fields=["restrict_based_on", "based_on_value"],
|
||||
filters={"party_type": party_type},
|
||||
fields=["party", "restrict_based_on", "based_on_value"],
|
||||
)
|
||||
|
||||
party_group_rules_list = frappe.get_all(
|
||||
@@ -229,21 +226,30 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
|
||||
fields=["party as party_group", "restrict_based_on", "based_on_value"],
|
||||
)
|
||||
current_party_group = frappe.get_value(party_type, party, frappe.scrub(group))
|
||||
|
||||
restricted_items = defaultdict(set)
|
||||
allowed_items = defaultdict(set)
|
||||
|
||||
for rule in item_rules_list:
|
||||
restrict_based_on = "name" if rule.restrict_based_on == "Item" else rule.restrict_based_on
|
||||
|
||||
if rule.party == party:
|
||||
allowed_items[restrict_based_on].add(rule.based_on_value)
|
||||
else:
|
||||
restricted_items[restrict_based_on].add(rule.based_on_value)
|
||||
|
||||
for rule in party_group_rules_list:
|
||||
if current_party_group != rule.party_group:
|
||||
item_rules_list.append(rule)
|
||||
restrict_based_on = "name" if rule.restrict_based_on == "Item" else rule.restrict_based_on
|
||||
|
||||
filters_dict = {}
|
||||
for rule in item_rules_list:
|
||||
if rule["restrict_based_on"] == "Item":
|
||||
rule["restrict_based_on"] = "name"
|
||||
filters_dict[rule.restrict_based_on] = []
|
||||
if current_party_group == rule.party_group:
|
||||
allowed_items[restrict_based_on].add(rule.based_on_value)
|
||||
else:
|
||||
restricted_items[restrict_based_on].add(rule.based_on_value)
|
||||
|
||||
for rule in item_rules_list:
|
||||
filters_dict[rule.restrict_based_on].append(rule.based_on_value)
|
||||
|
||||
for filter in filters_dict:
|
||||
filters[scrub(filter)] = ["not in", filters_dict[filter]]
|
||||
for field, restricted_values in restricted_items.items():
|
||||
values_to_exclude = restricted_values - allowed_items[field]
|
||||
if values_to_exclude:
|
||||
filters[scrub(field)] = ["not in", list(values_to_exclude)]
|
||||
|
||||
if filters.get("customer"):
|
||||
del filters["customer"]
|
||||
|
||||
@@ -144,7 +144,7 @@ def validate_returned_items(doc):
|
||||
ref.rate
|
||||
and flt(d.rate) > ref.rate
|
||||
and doc.doctype in ("Delivery Note", "Sales Invoice")
|
||||
and get_valuation_method(ref.item_code, doc.company) != "Moving Average"
|
||||
and get_valuation_method(d.item_code, doc.company) != "Moving Average"
|
||||
):
|
||||
frappe.throw(
|
||||
_("Row # {0}: Rate cannot be greater than the rate used in {1} {2}").format(
|
||||
@@ -445,6 +445,8 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
|
||||
doc.pricing_rules = []
|
||||
doc.return_against = source.name
|
||||
doc.set_warehouse = ""
|
||||
if doctype == "Sales Invoice":
|
||||
doc.is_debit_note = 0
|
||||
if doctype == "Sales Invoice" or doctype == "POS Invoice":
|
||||
doc.is_pos = source.is_pos
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ status_map = {
|
||||
],
|
||||
[
|
||||
"Partially Ordered",
|
||||
"eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.docstatus == 1 and self.material_request_type not in ['Material Transfer', 'Customer Provided']",
|
||||
"eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.per_received < 100 and self.docstatus == 1 and self.material_request_type not in ['Material Transfer', 'Customer Provided']",
|
||||
],
|
||||
],
|
||||
"POS Opening Entry": [
|
||||
@@ -186,7 +186,8 @@ class StatusUpdater(Document):
|
||||
"""
|
||||
|
||||
def on_discard(self):
|
||||
self.db_set("status", "Cancelled")
|
||||
if self.meta.has_field("status"):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
def update_prevdoc_status(self):
|
||||
self.update_qty()
|
||||
@@ -382,15 +383,17 @@ class StatusUpdater(Document):
|
||||
|
||||
def fetch_items_with_pending_qty(self, args, item_field, items):
|
||||
doctype = frappe.qb.DocType(args["target_dt"])
|
||||
item_field = doctype[item_field]
|
||||
item_field_col = doctype[item_field]
|
||||
target_ref_field = doctype[args["target_ref_field"]]
|
||||
target_field = doctype[args["target_field"]]
|
||||
|
||||
return (
|
||||
is_qty_check = "qty" in args["target_ref_field"]
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(doctype)
|
||||
.select(
|
||||
doctype.name,
|
||||
item_field.as_("item_code"),
|
||||
item_field_col.as_("item_code"),
|
||||
target_ref_field,
|
||||
target_field,
|
||||
doctype.parenttype,
|
||||
@@ -399,9 +402,18 @@ class StatusUpdater(Document):
|
||||
.where(target_ref_field < target_field)
|
||||
.where(doctype.name.isin(items))
|
||||
.where(doctype.docstatus == 1)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
if is_qty_check:
|
||||
item_table = frappe.qb.DocType("Item")
|
||||
query = (
|
||||
query.join(item_table)
|
||||
.on(item_table.name == item_field_col)
|
||||
.where(item_table.is_stock_item == 1)
|
||||
)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
def check_overflow_with_allowance(self, item, args):
|
||||
"""
|
||||
Checks if there is overflow considering a relaxation allowance.
|
||||
|
||||
@@ -22,12 +22,22 @@ from erpnext.controllers.sales_and_purchase_return import (
|
||||
filter_serial_batches,
|
||||
make_serial_batch_bundle_for_return,
|
||||
)
|
||||
|
||||
# Re-exported for backward compatibility; canonical home is erpnext.exceptions.
|
||||
from erpnext.exceptions import (
|
||||
BatchExpiredError,
|
||||
QualityInspectionNotSubmittedError,
|
||||
QualityInspectionRejectedError,
|
||||
QualityInspectionRequiredError,
|
||||
)
|
||||
from erpnext.setup.doctype.brand.brand import get_brand_defaults
|
||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
from erpnext.stock import get_warehouse_account_map
|
||||
from erpnext.stock.doctype.batch.batch import get_batch_qty
|
||||
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import (
|
||||
get_evaluated_inventory_dimension,
|
||||
get_mandatory_dimension_fields,
|
||||
get_mandatory_inventory_dimensions,
|
||||
)
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
@@ -36,21 +46,41 @@ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle impor
|
||||
)
|
||||
from erpnext.stock.stock_ledger import get_items_to_be_repost
|
||||
|
||||
# Purposes whose inward (t_warehouse) row is inspected.
|
||||
QI_INCOMING_PURPOSES = (
|
||||
"Material Receipt",
|
||||
"Repack",
|
||||
"Receive from Customer",
|
||||
"Subcontracting Return",
|
||||
)
|
||||
|
||||
class QualityInspectionRequiredError(frappe.ValidationError):
|
||||
pass
|
||||
# Purposes whose outgoing (s_warehouse) row is inspected. This is an explicit
|
||||
# allow-list rather than "everything that isn't incoming" so a new purpose can't
|
||||
# silently start requiring a QI. Material Consumption for Manufacture is left out
|
||||
# on purpose: an inspection_required BOM inspects the manufactured output (handled
|
||||
# by the "Manufacture" finished-good rule), not each consumed raw material.
|
||||
# Keep this in sync with erpnext.stock.qi_* helpers in transaction.js.
|
||||
QI_OUTGOING_PURPOSES = (
|
||||
"Material Issue",
|
||||
"Material Transfer",
|
||||
"Material Transfer for Manufacture",
|
||||
"Send to Subcontractor",
|
||||
"Subcontracting Delivery",
|
||||
"Disassemble",
|
||||
)
|
||||
|
||||
|
||||
class QualityInspectionRejectedError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class QualityInspectionNotSubmittedError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class BatchExpiredError(frappe.ValidationError):
|
||||
pass
|
||||
def stock_entry_row_requires_inspection(purpose, row):
|
||||
"""Check if this Stock Entry row need a Quality Inspection."""
|
||||
if row.get("type") or row.get("is_legacy_scrap_item"):
|
||||
return False
|
||||
if purpose == "Manufacture":
|
||||
return bool(row.is_finished_item)
|
||||
if purpose in QI_INCOMING_PURPOSES:
|
||||
return bool(row.t_warehouse)
|
||||
if purpose in QI_OUTGOING_PURPOSES:
|
||||
return bool(row.s_warehouse and row.s_warehouse != row.t_warehouse)
|
||||
return False
|
||||
|
||||
|
||||
class StockController(AccountsController):
|
||||
@@ -72,6 +102,7 @@ class StockController(AccountsController):
|
||||
self.validate_internal_transfer()
|
||||
self.validate_putaway_capacity()
|
||||
self.reset_conversion_factor()
|
||||
self.validate_inventory_dimension_mandatory()
|
||||
|
||||
def on_update(self):
|
||||
super().on_update()
|
||||
@@ -1148,6 +1179,50 @@ class StockController(AccountsController):
|
||||
|
||||
return item_account_wise_cost
|
||||
|
||||
def validate_inventory_dimension_mandatory(self):
|
||||
# Mandatory inventory dimensions are enforced here (instead of via field-level `reqd`)
|
||||
# so we can skip service rows and never block a document that is being cancelled.
|
||||
if self.docstatus >= 2:
|
||||
return
|
||||
|
||||
for table_field in ["items", "packed_items", "supplied_items"]:
|
||||
rows = self.get(table_field)
|
||||
if rows:
|
||||
self.validate_mandatory_dimensions_in_table(rows)
|
||||
|
||||
def validate_mandatory_dimensions_in_table(self, rows):
|
||||
child_doctype = rows[0].doctype
|
||||
dimensions = get_mandatory_inventory_dimensions(child_doctype)
|
||||
if not dimensions:
|
||||
return
|
||||
|
||||
child_meta = frappe.get_meta(child_doctype)
|
||||
for dimension in dimensions:
|
||||
mandatory_fields = get_mandatory_dimension_fields(child_doctype, dimension)
|
||||
for row in rows:
|
||||
if mandatory_fields and not self.is_service_item_row(row):
|
||||
self.validate_mandatory_dimension_row(row, dimension, mandatory_fields, child_meta)
|
||||
|
||||
def is_service_item_row(self, row) -> bool:
|
||||
item_code = row.get("item_code")
|
||||
return bool(item_code) and not frappe.get_cached_value("Item", item_code, "is_stock_item")
|
||||
|
||||
def validate_mandatory_dimension_row(self, row, dimension, mandatory_fields, child_meta):
|
||||
for fieldname, condition in mandatory_fields:
|
||||
if not child_meta.has_field(fieldname) or row.get(fieldname):
|
||||
continue
|
||||
|
||||
if condition and not frappe.safe_eval(condition, {"doc": row, "parent": self}):
|
||||
continue
|
||||
|
||||
frappe.throw(
|
||||
_("Row #{0}: {1} is mandatory for the Inventory Dimension {2}.").format(
|
||||
row.idx,
|
||||
bold(_(child_meta.get_label(fieldname))),
|
||||
bold(dimension.name),
|
||||
)
|
||||
)
|
||||
|
||||
def update_inventory_dimensions(self, row, sl_dict) -> None:
|
||||
# To handle delivery note and sales invoice
|
||||
if row.get("item_row"):
|
||||
@@ -1438,8 +1513,8 @@ class StockController(AccountsController):
|
||||
"Item", row.item_code, inspection_required_fieldname
|
||||
):
|
||||
qi_required = True
|
||||
elif self.doctype == "Stock Entry" and row.t_warehouse:
|
||||
qi_required = True # inward stock needs inspection
|
||||
elif self.doctype == "Stock Entry":
|
||||
qi_required = stock_entry_row_requires_inspection(self.purpose, row)
|
||||
|
||||
if row.get("type") or row.get("is_legacy_scrap_item"):
|
||||
continue
|
||||
@@ -1800,6 +1875,9 @@ class StockController(AccountsController):
|
||||
"remarks": remarks,
|
||||
}
|
||||
|
||||
if project:
|
||||
gl_entry.update({"project": project})
|
||||
|
||||
if voucher_detail_no:
|
||||
gl_entry.update({"voucher_detail_no": voucher_detail_no})
|
||||
|
||||
@@ -2163,7 +2241,7 @@ def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str
|
||||
|
||||
inspection_fieldname = inspection_fieldname_map.get(doctype)
|
||||
if inspection_fieldname is None:
|
||||
return []
|
||||
return items if doctype == "Stock Entry" else []
|
||||
|
||||
allow_after_transaction = cint(docstatus) == 1 and frappe.get_single_value(
|
||||
"Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery"
|
||||
|
||||
@@ -743,7 +743,14 @@ class SubcontractingInwardController:
|
||||
"name": ["in", list(data.keys())],
|
||||
"docstatus": 1,
|
||||
},
|
||||
fields=["rate", "name", "required_qty", "received_qty"],
|
||||
fields=[
|
||||
"rate",
|
||||
"name",
|
||||
"required_qty",
|
||||
"received_qty",
|
||||
"returned_qty",
|
||||
"consumed_qty",
|
||||
],
|
||||
)
|
||||
|
||||
doc_updates = {}
|
||||
@@ -751,13 +758,17 @@ class SubcontractingInwardController:
|
||||
current_qty = flt(data[d.name].transfer_qty) * (1 if self._action == "submit" else -1)
|
||||
current_rate = flt(data[d.name].rate)
|
||||
|
||||
# Calculate weighted average rate
|
||||
old_total = d.rate * d.received_qty
|
||||
# Weighted average rate must be computed on the on-hand balance
|
||||
balance_qty = d.received_qty - d.returned_qty - d.consumed_qty
|
||||
old_total = d.rate * balance_qty
|
||||
current_total = current_rate * current_qty
|
||||
|
||||
new_balance_qty = balance_qty + current_qty
|
||||
d.received_qty = d.received_qty + current_qty
|
||||
d.rate = (
|
||||
flt((old_total + current_total) / d.received_qty, precision) if d.received_qty else 0.0
|
||||
flt((old_total + current_total) / new_balance_qty, precision)
|
||||
if new_balance_qty > 0
|
||||
else 0.0
|
||||
)
|
||||
|
||||
if not d.required_qty and not d.received_qty:
|
||||
|
||||
@@ -32,11 +32,12 @@ from erpnext.utilities.regional import temporary_flag
|
||||
class calculate_taxes_and_totals:
|
||||
def __init__(self, doc: Document):
|
||||
self.doc = doc
|
||||
frappe.flags.round_off_applicable_accounts = []
|
||||
frappe.flags.round_off_applicable_accounts = (
|
||||
get_round_off_applicable_accounts(self.doc.company, [], self.doc) or []
|
||||
)
|
||||
frappe.flags.round_row_wise_tax = frappe.get_single_value("Accounts Settings", "round_row_wise_tax")
|
||||
|
||||
self._items = self.filter_rows() if self.doc.doctype == "Quotation" else self.doc.get("items")
|
||||
get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts)
|
||||
self.calculate()
|
||||
|
||||
def filter_rows(self):
|
||||
@@ -164,83 +165,85 @@ class calculate_taxes_and_totals:
|
||||
|
||||
self.doc.conversion_rate = flt(self.doc.conversion_rate)
|
||||
|
||||
def calculate_item_values(self):
|
||||
if self.doc.get("is_consolidated"):
|
||||
def calculate_item_rate(self, item):
|
||||
if not item.price_list_rate:
|
||||
remove_margin(item)
|
||||
remove_discount(item)
|
||||
item.rate_with_margin = 0
|
||||
return
|
||||
|
||||
if not self.discount_amount_applied:
|
||||
do_not_round_fields = ["valuation_rate", "incoming_rate"]
|
||||
has_pricing_rules = item.pricing_rules and not self.doc.ignore_pricing_rule
|
||||
if has_pricing_rules:
|
||||
remove_margin(item)
|
||||
|
||||
for item in self.doc.items:
|
||||
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
|
||||
for d in get_applied_pricing_rules(item.pricing_rules):
|
||||
pricing_rule = frappe.get_cached_doc("Pricing Rule", d)
|
||||
|
||||
if item.discount_percentage == 100:
|
||||
item.rate = 0.0
|
||||
elif item.price_list_rate:
|
||||
if not item.rate or (item.pricing_rules and item.discount_percentage > 0):
|
||||
item.rate = flt(
|
||||
item.price_list_rate * (1.0 - (item.discount_percentage / 100.0)),
|
||||
item.precision("rate"),
|
||||
)
|
||||
|
||||
item.discount_amount = item.price_list_rate * (item.discount_percentage / 100.0)
|
||||
|
||||
elif item.discount_amount and item.pricing_rules:
|
||||
item.rate = item.price_list_rate - item.discount_amount
|
||||
|
||||
if item.doctype in [
|
||||
"Quotation Item",
|
||||
"Sales Order Item",
|
||||
"Delivery Note Item",
|
||||
"Sales Invoice Item",
|
||||
"POS Invoice Item",
|
||||
"Purchase Invoice Item",
|
||||
"Purchase Order Item",
|
||||
"Purchase Receipt Item",
|
||||
]:
|
||||
item.rate_with_margin, item.base_rate_with_margin = self.calculate_margin(item)
|
||||
if flt(item.rate_with_margin) > 0:
|
||||
item.rate = flt(
|
||||
item.rate_with_margin * (1.0 - (item.discount_percentage / 100.0)),
|
||||
item.precision("rate"),
|
||||
)
|
||||
|
||||
if item.discount_amount and not item.discount_percentage:
|
||||
item.rate = item.rate_with_margin - item.discount_amount
|
||||
else:
|
||||
item.discount_amount = flt(
|
||||
item.rate_with_margin - item.rate, item.precision("discount_amount")
|
||||
)
|
||||
|
||||
elif flt(item.price_list_rate) > 0:
|
||||
item.discount_amount = flt(
|
||||
item.price_list_rate - item.rate, item.precision("discount_amount")
|
||||
)
|
||||
elif flt(item.price_list_rate) > 0 and not item.discount_amount:
|
||||
item.discount_amount = flt(
|
||||
item.price_list_rate - item.rate, item.precision("discount_amount")
|
||||
if not (
|
||||
pricing_rule.margin_type
|
||||
and pricing_rule.margin_rate_or_amount
|
||||
and (
|
||||
pricing_rule.margin_type == "Percentage" or pricing_rule.currency == self.doc.currency
|
||||
)
|
||||
|
||||
item.net_rate = item.rate
|
||||
|
||||
if (
|
||||
not item.qty
|
||||
and self.doc.get("is_return")
|
||||
and self.doc.get("doctype") != "Purchase Receipt"
|
||||
):
|
||||
item.amount = flt(-1 * item.rate, item.precision("amount"))
|
||||
elif not item.qty and self.doc.get("is_debit_note"):
|
||||
item.amount = flt(item.rate, item.precision("amount"))
|
||||
else:
|
||||
item.amount = flt(item.rate * item.qty, item.precision("amount"))
|
||||
continue
|
||||
|
||||
item.net_amount = item.amount
|
||||
item.margin_type = pricing_rule.margin_type
|
||||
item.margin_rate_or_amount = pricing_rule.margin_rate_or_amount
|
||||
|
||||
self._set_in_company_currency(
|
||||
item, ["price_list_rate", "rate", "net_rate", "amount", "net_amount"]
|
||||
)
|
||||
item.rate_with_margin = get_rate_with_margin(item)
|
||||
if item.discount_percentage > 0:
|
||||
item.discount_amount = flt(
|
||||
item.rate_with_margin * item.discount_percentage / 100.0, item.precision("discount_amount")
|
||||
)
|
||||
|
||||
item.item_tax_amount = 0.0
|
||||
calculated_rate = flt(item.rate_with_margin - item.discount_amount, item.precision("rate"))
|
||||
|
||||
# if rate is 0 or pricing rules are applicable, calculated rate is preferred
|
||||
if has_pricing_rules or not item.rate:
|
||||
item.rate = calculated_rate
|
||||
return
|
||||
|
||||
# discount and margin are correct, exit early
|
||||
if item.rate == calculated_rate:
|
||||
return
|
||||
|
||||
# item rate does not match calculated rate. prefer item rate, reset margin / discount
|
||||
if item.rate > item.price_list_rate:
|
||||
item.margin_type = "Amount"
|
||||
item.margin_rate_or_amount = flt(
|
||||
item.rate - item.price_list_rate, item.precision("margin_rate_or_amount")
|
||||
)
|
||||
item.rate_with_margin = item.rate
|
||||
remove_discount(item)
|
||||
return
|
||||
|
||||
item.rate_with_margin = item.price_list_rate
|
||||
item.discount_amount = flt(item.rate_with_margin - item.rate, item.precision("discount_amount"))
|
||||
item.discount_percentage = 0
|
||||
remove_margin(item)
|
||||
|
||||
def calculate_item_values(self):
|
||||
if self.doc.get("is_consolidated") or self.discount_amount_applied:
|
||||
return
|
||||
|
||||
do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"]
|
||||
for item in self.doc.items:
|
||||
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
|
||||
self.calculate_item_rate(item)
|
||||
|
||||
item.net_rate = item.rate
|
||||
if not item.qty and self.doc.get("is_return") and self.doc.get("doctype") != "Purchase Receipt":
|
||||
item.amount = flt(-1 * item.rate, item.precision("amount"))
|
||||
elif not item.qty and self.doc.get("is_debit_note"):
|
||||
item.amount = flt(item.rate, item.precision("amount"))
|
||||
else:
|
||||
item.amount = flt(item.rate * item.qty, item.precision("amount"))
|
||||
item.net_amount = item.amount
|
||||
self._set_in_company_currency(
|
||||
item, ["price_list_rate", "rate_with_margin", "rate", "net_rate", "amount", "net_amount"]
|
||||
)
|
||||
item.item_tax_amount = 0.0
|
||||
|
||||
def _set_in_company_currency(self, doc, fields):
|
||||
"""set values in base currency"""
|
||||
@@ -1134,48 +1137,6 @@ class calculate_taxes_and_totals:
|
||||
|
||||
self.calculate_outstanding_amount()
|
||||
|
||||
def calculate_margin(self, item):
|
||||
rate_with_margin = 0.0
|
||||
base_rate_with_margin = 0.0
|
||||
if item.price_list_rate:
|
||||
if item.pricing_rules and not self.doc.ignore_pricing_rule:
|
||||
has_margin = False
|
||||
for d in get_applied_pricing_rules(item.pricing_rules):
|
||||
pricing_rule = frappe.get_cached_doc("Pricing Rule", d)
|
||||
|
||||
if pricing_rule.margin_rate_or_amount and (
|
||||
(
|
||||
pricing_rule.currency == self.doc.currency
|
||||
and pricing_rule.margin_type in ["Amount", "Percentage"]
|
||||
)
|
||||
or pricing_rule.margin_type == "Percentage"
|
||||
):
|
||||
item.margin_type = pricing_rule.margin_type
|
||||
item.margin_rate_or_amount = pricing_rule.margin_rate_or_amount
|
||||
has_margin = True
|
||||
|
||||
if not has_margin:
|
||||
item.margin_type = None
|
||||
item.margin_rate_or_amount = 0.0
|
||||
|
||||
if not item.pricing_rules and flt(item.rate) > flt(item.price_list_rate):
|
||||
item.margin_type = "Amount"
|
||||
item.margin_rate_or_amount = flt(
|
||||
item.rate - item.price_list_rate, item.precision("margin_rate_or_amount")
|
||||
)
|
||||
item.rate_with_margin = item.rate
|
||||
|
||||
elif item.margin_type and item.margin_rate_or_amount:
|
||||
margin_value = (
|
||||
item.margin_rate_or_amount
|
||||
if item.margin_type == "Amount"
|
||||
else flt(item.price_list_rate) * flt(item.margin_rate_or_amount) / 100
|
||||
)
|
||||
rate_with_margin = flt(item.price_list_rate) + flt(margin_value)
|
||||
base_rate_with_margin = flt(rate_with_margin) * flt(self.doc.conversion_rate)
|
||||
|
||||
return rate_with_margin, base_rate_with_margin
|
||||
|
||||
def set_item_wise_tax_breakup(self):
|
||||
self.doc.other_charges_calculation = get_itemised_tax_breakup_html(self.doc)
|
||||
|
||||
@@ -1210,6 +1171,29 @@ class calculate_taxes_and_totals:
|
||||
)
|
||||
|
||||
|
||||
def remove_discount(item):
|
||||
item.discount_percentage = 0.0
|
||||
item.discount_amount = 0.0
|
||||
|
||||
|
||||
def remove_margin(item):
|
||||
item.margin_type = None
|
||||
item.margin_rate_or_amount = 0.0
|
||||
|
||||
|
||||
def get_rate_with_margin(item):
|
||||
if not item.margin_type:
|
||||
return item.price_list_rate
|
||||
|
||||
if item.margin_type == "Percentage":
|
||||
return flt(
|
||||
item.price_list_rate * (1 + (item.margin_rate_or_amount / 100.0)),
|
||||
item.precision("rate_with_margin"),
|
||||
)
|
||||
|
||||
return flt(item.price_list_rate + item.margin_rate_or_amount, item.precision("rate_with_margin"))
|
||||
|
||||
|
||||
def get_itemised_tax_breakup_html(doc):
|
||||
if not doc.taxes:
|
||||
return
|
||||
@@ -1240,14 +1224,16 @@ def get_itemised_tax_breakup_html(doc):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_round_off_applicable_accounts(company, account_list):
|
||||
def get_round_off_applicable_accounts(
|
||||
company: str, account_list: list | str, doc: str | dict | Document | None = None
|
||||
):
|
||||
# required to set correct region
|
||||
with temporary_flag("company", company):
|
||||
return get_regional_round_off_accounts(company, account_list)
|
||||
return get_regional_round_off_accounts(company, account_list, doc)
|
||||
|
||||
|
||||
@erpnext.allow_regional
|
||||
def get_regional_round_off_accounts(company, account_list):
|
||||
def get_regional_round_off_accounts(company, account_list, doc=None):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user