mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-29 22:08:35 +00:00
Compare commits
52 Commits
sync_trans
...
version-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9a9224ec4 | ||
|
|
b08de1f1e5 | ||
|
|
e46be25e9f | ||
|
|
570c67bb34 | ||
|
|
e71b066eec | ||
|
|
e9a26b5086 | ||
|
|
872c86e223 | ||
|
|
d47aa4917a | ||
|
|
6569fa2b9f | ||
|
|
b1ca423e11 | ||
|
|
c1e77c04fd | ||
|
|
054b20a2ae | ||
|
|
03f6b7a50e | ||
|
|
6bcab7cfc8 | ||
|
|
39c8161011 | ||
|
|
656d1bd6e3 | ||
|
|
ecb572de92 | ||
|
|
23181b3962 | ||
|
|
4af265c48f | ||
|
|
ce97a74c5f | ||
|
|
e955b4a3b9 | ||
|
|
de42a9e86e | ||
|
|
5206b279b6 | ||
|
|
9af618d6bf | ||
|
|
3c3cde4362 | ||
|
|
d6b7791f18 | ||
|
|
ff46d20b25 | ||
|
|
d215fa7623 | ||
|
|
bf8f7ba883 | ||
|
|
6ef4a2d82c | ||
|
|
e4e796146d | ||
|
|
ea1d0cc277 | ||
|
|
fa4aa0c1b6 | ||
|
|
fdfcbf72bd | ||
|
|
fb7f820885 | ||
|
|
799d6d159c | ||
|
|
48f59a033f | ||
|
|
2807c9f08f | ||
|
|
5271773595 | ||
|
|
dd35cd1f84 | ||
|
|
77a6299e8b | ||
|
|
b79ec7cbdd | ||
|
|
927360dd1d | ||
|
|
ab090295d9 | ||
|
|
c4b7b15824 | ||
|
|
cfd3847255 | ||
|
|
dc914adb62 | ||
|
|
41bff45d7a | ||
|
|
7b494dc9e8 | ||
|
|
ed69dafbe8 | ||
|
|
4d5c665e22 | ||
|
|
e09487d140 |
72
.github/helper/hydrate.sh
vendored
72
.github/helper/hydrate.sh
vendored
@@ -1,72 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Hydrate a test shard from the setup job's artifact.
|
||||
#
|
||||
# The bench (apps, venv, node_modules, sites) is already on disk at ~/frappe-bench — the
|
||||
# workflow untar'd it from the artifact the setup job built. So there is NO bench init, no
|
||||
# asset build, and no reinstall here: just bring the DB up on the baked datadir and start redis
|
||||
# so tests can run. The whole point is that the expensive work happened ONCE in the setup job.
|
||||
#
|
||||
set -e
|
||||
|
||||
ci_user="${ERPNEXT_CI_USER:-frappe}"
|
||||
db_host="${DB_HOST:-127.0.0.1}"
|
||||
|
||||
# Re-exec as the ci user (uid 1001) so bench/cache ownership matches the artifact, same as
|
||||
# install.sh. The workflow untar'd as root with -p, so the files are already owned by ci.
|
||||
if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then
|
||||
exec su -m "$ci_user" -s /bin/bash -c \
|
||||
"ERPNEXT_CI_USER='$ci_user' DB_HOST='$db_host' 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,106 +7,21 @@ cd ~ || exit
|
||||
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
|
||||
frappeuser=${FRAPPE_USER:-"frappe"}
|
||||
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
|
||||
db_host=${DB_HOST:-"127.0.0.1"}
|
||||
db_user_host=${DB_USER_HOST:-"localhost"}
|
||||
wkhtmltox_deb=${WKHTMLTOX_DEB:-"/tmp/wkhtmltox.deb"}
|
||||
bench_cache_dir=${BENCH_CACHE_DIR:-}
|
||||
|
||||
run_as_ci_user_if_needed() {
|
||||
if [ "$(id -u)" != "0" ] || [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ] || [ "${ERPNEXT_CI_NON_ROOT:-0}" = "1" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local missing_packages=()
|
||||
if ! command -v pkg-config >/dev/null 2>&1; then
|
||||
missing_packages+=("pkg-config")
|
||||
fi
|
||||
if ! command -v mariadb_config >/dev/null 2>&1 && ! command -v mysql_config >/dev/null 2>&1; then
|
||||
missing_packages+=("libmariadb-dev")
|
||||
fi
|
||||
if ! command -v crontab >/dev/null 2>&1; then
|
||||
missing_packages+=("cron")
|
||||
fi
|
||||
|
||||
if [ "${#missing_packages[@]}" -gt 0 ]; then
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends "${missing_packages[@]}"
|
||||
fi
|
||||
|
||||
local ci_user="${ERPNEXT_CI_USER:-frappe}"
|
||||
|
||||
if ! id "$ci_user" >/dev/null 2>&1; then
|
||||
useradd --home-dir "$HOME" --no-create-home --shell /bin/bash "$ci_user"
|
||||
fi
|
||||
|
||||
rm -rf ~/frappe ~/frappe-bench
|
||||
|
||||
local ci_dirs=(
|
||||
"$HOME"
|
||||
"$GITHUB_WORKSPACE"
|
||||
"$HOME/.cache"
|
||||
"${PIP_CACHE_DIR:-$HOME/.cache/pip}"
|
||||
"${npm_config_cache:-$HOME/.npm}"
|
||||
"${YARN_CACHE_FOLDER:-$HOME/.cache/yarn}"
|
||||
"$HOME/.yarn"
|
||||
"${UV_CACHE_DIR:-$HOME/.cache/uv}"
|
||||
"$(dirname "$wkhtmltox_deb")"
|
||||
)
|
||||
if [ -n "$bench_cache_dir" ]; then
|
||||
ci_dirs+=("$bench_cache_dir")
|
||||
fi
|
||||
|
||||
# Create + own (non-recursively) the home/cache/workspace dirs before dropping to
|
||||
# the ci user. We deliberately do NOT wipe the yarn/uv caches here so a persistent
|
||||
# cache (mounted volume or baked image layer) stays warm across runs.
|
||||
mkdir -p "${ci_dirs[@]}" "$HOME/.yarn"
|
||||
chown "$ci_user:$ci_user" "${ci_dirs[@]}" "$HOME/.yarn"
|
||||
|
||||
export ERPNEXT_CI_NON_ROOT=1
|
||||
exec su -m "$ci_user" -s /bin/bash -c "cd '$HOME' && bash '$GITHUB_WORKSPACE/.github/helper/install.sh'"
|
||||
}
|
||||
|
||||
run_as_ci_user_if_needed
|
||||
|
||||
run_ci_step() {
|
||||
local label=$1
|
||||
shift
|
||||
|
||||
echo "::group::${label}"
|
||||
date -u
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ]; then
|
||||
sudo apt-get update
|
||||
sudo apt update
|
||||
|
||||
# apt remove/install must run sequentially but can overlap with pip and git.
|
||||
sudo apt-get remove -y mysql-server mysql-client
|
||||
sudo apt-get install -y libcups2-dev redis-server mariadb-client libmariadb-dev &
|
||||
apt_pid=$!
|
||||
# apt remove/install must run sequentially but can overlap with pip and git.
|
||||
sudo apt remove mysql-server mysql-client
|
||||
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev &
|
||||
apt_pid=$!
|
||||
|
||||
pip install frappe-bench &
|
||||
pip_pid=$!
|
||||
else
|
||||
apt_pid=
|
||||
pip_pid=
|
||||
fi
|
||||
pip install frappe-bench &
|
||||
pip_pid=$!
|
||||
|
||||
mkdir frappe
|
||||
(
|
||||
@@ -117,247 +32,76 @@ mkdir frappe
|
||||
) &
|
||||
clone_pid=$!
|
||||
|
||||
if [ -n "$apt_pid" ]; then wait $apt_pid; fi
|
||||
if [ -n "$pip_pid" ]; then wait $pip_pid; fi
|
||||
wait $apt_pid
|
||||
wait $pip_pid
|
||||
wait $clone_pid
|
||||
|
||||
pushd frappe
|
||||
git checkout FETCH_HEAD
|
||||
popd
|
||||
frappe_sha=$(git -C frappe rev-parse HEAD)
|
||||
|
||||
get_bench_cache_archive() {
|
||||
if [ -z "$bench_cache_dir" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$bench_cache_dir"
|
||||
|
||||
# Keyed on tool versions only (NOT the frappe SHA): any recent base bench works, because
|
||||
# restore_warm_bench fast-forwards it to the exact live develop SHA. This is what lets a
|
||||
# constantly-moving develop still hit the cache.
|
||||
local cache_key
|
||||
cache_key=$(
|
||||
{
|
||||
uname -m
|
||||
python --version
|
||||
node --version
|
||||
bench --version
|
||||
} | sha256sum | awk '{print $1}'
|
||||
)
|
||||
|
||||
echo "${bench_cache_dir}/frappe-bench-base-${cache_key}.tar.zst"
|
||||
}
|
||||
|
||||
restore_warm_bench() {
|
||||
bench_cache_archive=$(get_bench_cache_archive)
|
||||
[ -n "$bench_cache_archive" ] && [ -f "$bench_cache_archive" ] || return 1
|
||||
|
||||
echo "Restoring base bench from ${bench_cache_archive}"
|
||||
tar --use-compress-program=unzstd -xf "$bench_cache_archive" -C ~ || return 1
|
||||
[ -d ~/frappe-bench/apps/frappe/.git ] || return 1
|
||||
mkdir -p ~/frappe-bench/sites ~/frappe-bench/logs
|
||||
[ -f ~/frappe-bench/sites/apps.txt ] || printf "frappe\n" > ~/frappe-bench/sites/apps.txt
|
||||
[ -f ~/frappe-bench/sites/common_site_config.json ] || printf "{}\n" > ~/frappe-bench/sites/common_site_config.json
|
||||
|
||||
# Fast-forward the restored frappe to the EXACT live develop SHA fetched in phase 1, then
|
||||
# rebuild only what changed. The editable install means the venv tracks the new code with
|
||||
# no reinstall. Any failure returns non-zero so the caller falls back to a full bench init.
|
||||
if ! (
|
||||
cd ~/frappe-bench/apps/frappe || exit 1
|
||||
# Phase 1 already fetched ~/frappe to the exact live develop SHA. Fetch that commit
|
||||
# straight from it (bench init names the remote 'upstream', not 'origin', and points
|
||||
# it at this local clone — so a plain `git fetch origin` does not work).
|
||||
git fetch --no-tags "$HOME/frappe" HEAD || exit 1
|
||||
git checkout --force FETCH_HEAD || exit 1
|
||||
); then
|
||||
echo "Fast-forward to ${frappe_sha} failed; falling back to full init"
|
||||
rm -rf ~/frappe-bench
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Pick up any frappe dependency changes since the base was built (cached → fast if none),
|
||||
# so a develop commit that bumped requirements doesn't leave a stale venv.
|
||||
if ! ~/frappe-bench/env/bin/python -m pip install -q -e ~/frappe-bench/apps/frappe; then
|
||||
echo "frappe dependency refresh failed; falling back to full init"
|
||||
rm -rf ~/frappe-bench
|
||||
return 1
|
||||
fi
|
||||
|
||||
( cd ~/frappe-bench && CI=Yes bench build --app frappe ) || { rm -rf ~/frappe-bench; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
save_warm_bench() {
|
||||
if [ -z "${bench_cache_archive:-}" ] || [ -f "$bench_cache_archive" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$bench_cache_dir" ] && [ ! -w "$bench_cache_dir" ]; then
|
||||
echo "Skipping warm bench save because ${bench_cache_dir} is not writable"
|
||||
return
|
||||
fi
|
||||
|
||||
local tmp_archive
|
||||
tmp_archive="${bench_cache_archive}.${$}.tmp"
|
||||
|
||||
echo "Saving warm bench to ${bench_cache_archive}"
|
||||
# Keep sites/common_site_config.json (the redis ports live there — dropping it makes the
|
||||
# restore path fall back to a default redis port that bench start never bound, so reinstall
|
||||
# fails with "redis ... connection refused"). Only the rebuildable sites/assets is excluded;
|
||||
# restore_warm_bench runs `bench build` to regenerate it.
|
||||
tar \
|
||||
--use-compress-program="zstd -T0 -3" \
|
||||
--exclude="frappe-bench/logs" \
|
||||
--exclude="frappe-bench/sites/assets" \
|
||||
-cf "$tmp_archive" \
|
||||
-C ~ frappe-bench
|
||||
mv "$tmp_archive" "$bench_cache_archive"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — bench init and site setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
install_whktml() {
|
||||
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
|
||||
if [ ! -f "$wkhtmltox_deb" ]; then
|
||||
wget -O "$wkhtmltox_deb" https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
fi
|
||||
sudo apt-get install -y "$wkhtmltox_deb"
|
||||
}
|
||||
if [ "${SKIP_WKHTMLTOX_SETUP:-0}" != "1" ]; then
|
||||
install_whktml &
|
||||
wkpid=$!
|
||||
else
|
||||
wkpid=
|
||||
fi
|
||||
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
|
||||
|
||||
if ! restore_warm_bench; then
|
||||
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
|
||||
|
||||
cd ~/frappe-bench || exit
|
||||
|
||||
sed -i 's/watch:/# watch:/g' Procfile
|
||||
sed -i 's/schedule:/# schedule:/g' Procfile
|
||||
sed -i 's/socketio:/# socketio:/g' Procfile
|
||||
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
|
||||
|
||||
CI=Yes bench build --app frappe
|
||||
save_warm_bench
|
||||
fi
|
||||
|
||||
if [ -n "$wkpid" ]; then wait $wkpid; fi
|
||||
|
||||
mkdir -p ~/frappe-bench/sites/test_site
|
||||
mkdir ~/frappe-bench/sites/test_site
|
||||
|
||||
if [ "$DB" == "mariadb" ];then
|
||||
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_mariadb.json" ~/frappe-bench/sites/test_site/site_config.json
|
||||
if [ "$db_host" != "127.0.0.1" ]; then
|
||||
sed -i "s/\"db_host\": \"127.0.0.1\"/\"db_host\": \"${db_host}\"/" ~/frappe-bench/sites/test_site/site_config.json
|
||||
fi
|
||||
else
|
||||
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_postgres.json" ~/frappe-bench/sites/test_site/site_config.json
|
||||
fi
|
||||
|
||||
|
||||
if [ "$DB" == "mariadb" ];then
|
||||
for _ in {1..60}; do
|
||||
if mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
|
||||
# Throwaway-DB durability tuning at runtime. (innodb_doublewrite is read-only on MariaDB
|
||||
# 10.6, so it can't be disabled here — would need a server startup flag.)
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot \
|
||||
# Belt-and-suspenders: also set performance variables at runtime in case
|
||||
# MARIADB_EXTRA_FLAGS was not honoured by the container image.
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot \
|
||||
-e "SET GLOBAL innodb_flush_log_at_trx_commit=0; SET GLOBAL sync_binlog=0;"
|
||||
|
||||
# Opt-in DDL speedup: a shared tablespace avoids a create+fsync per DocType table during
|
||||
# reinstall — a big win under disk contention. But ROW_FORMAT=DYNAMIC must be accepted in
|
||||
# the system tablespace on this MariaDB. Enable with CI_INNODB_SHARED_TABLESPACE=1; if
|
||||
# reinstall then errors on table creation, unset it (off by default — zero risk).
|
||||
if [ "${CI_INNODB_SHARED_TABLESPACE:-0}" = "1" ]; then
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL innodb_file_per_table=0;"
|
||||
fi
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
|
||||
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'${db_user_host}' IDENTIFIED BY 'test_frappe'"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'${db_user_host}'"
|
||||
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
|
||||
fi
|
||||
|
||||
if [ "$DB" == "postgres" ];then
|
||||
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
|
||||
|
||||
run_ci_step "Get payments app" bench get-app payments --branch develop
|
||||
sed -i 's/watch:/# watch:/g' Procfile
|
||||
sed -i 's/schedule:/# schedule:/g' Procfile
|
||||
sed -i 's/socketio:/# socketio:/g' Procfile
|
||||
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
|
||||
|
||||
# Opt-in: skip building erpnext's frontend assets. Server tests don't need them, but PDF
|
||||
# tests (print formats) do — they pass only if the PDF renderer ignores missing assets.
|
||||
# Enable with CI_SKIP_ERPNEXT_ASSETS=1 to test; if PDF tests fail, unset it.
|
||||
erpnext_get_app_args=()
|
||||
if [ "${CI_SKIP_ERPNEXT_ASSETS:-0}" = "1" ]; then erpnext_get_app_args=(--skip-assets); fi
|
||||
run_ci_step "Get erpnext app" bench get-app erpnext "${GITHUB_WORKSPACE}" "${erpnext_get_app_args[@]}"
|
||||
bench get-app payments --branch develop
|
||||
bench get-app erpnext "${GITHUB_WORKSPACE}"
|
||||
|
||||
if [ "$TYPE" == "server" ]; then run_ci_step "Setup dev requirements" bench setup requirements --dev; fi
|
||||
if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
|
||||
|
||||
bench start >> ~/frappe-bench/bench_start.log 2>&1 &
|
||||
wait $wkpid
|
||||
|
||||
# Under heavy concurrency, gunicorn's startup can delay redis coming up. reinstall and the
|
||||
# tests need redis, so wait for it (best-effort, bounded) instead of racing — contention
|
||||
# then slows the job rather than failing it.
|
||||
wait_for_redis() {
|
||||
local cfg=~/frappe-bench/sites/common_site_config.json
|
||||
[ -f "$cfg" ] || return 0
|
||||
local ports port
|
||||
ports=$(python - "$cfg" <<'PY'
|
||||
import json, re, sys
|
||||
try:
|
||||
cfg = json.load(open(sys.argv[1]))
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
for key in ("redis_cache", "redis_queue"):
|
||||
match = re.search(r":(\d+)", str(cfg.get(key, "")))
|
||||
if match:
|
||||
print(match.group(1))
|
||||
PY
|
||||
)
|
||||
for port in $ports; do
|
||||
local up=0
|
||||
for _ in $(seq 1 120); do
|
||||
if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then
|
||||
exec 3>&- 3<&-; up=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
# Fail clearly instead of letting reinstall die later on a vague socket-connection error
|
||||
# when redis never bound.
|
||||
[ "$up" = "1" ] || { echo "redis did not come up on port $port"; return 1; }
|
||||
done
|
||||
}
|
||||
wait_for_redis
|
||||
|
||||
# Site setup: build the schema (~1000 DocTypes) into the DB. This is the single-threaded-Python
|
||||
# bottleneck, but the fan-out amortises it — it runs once here in the setup job, and the test
|
||||
# shards start the DB on the baked datadir instead of repeating the reinstall.
|
||||
run_ci_step "Reinstall test site" bench --site test_site reinstall --yes
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
CI=Yes bench build --app frappe &
|
||||
bench --site test_site reinstall --yes
|
||||
|
||||
79
.github/helper/start-db.sh
vendored
79
.github/helper/start-db.sh
vendored
@@ -1,79 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Run MariaDB INSIDE the runner container, on a datadir we control. Because the datadir can be
|
||||
# packaged into the bench artifact, test shards start an already-loaded server instead of
|
||||
# replaying a SQL dump (the ~60s hydrate restore). Each shard gets its own copy → isolation kept.
|
||||
#
|
||||
# CI_DB_DATADIR picks the path:
|
||||
# - setup job: /home/ci/db-data (OUTSIDE the bench, so install.sh's `rm -rf ~/frappe-bench`
|
||||
# doesn't wipe it; it's moved into the bench just before packaging)
|
||||
# - test shard: ~/frappe-bench/mariadb-data (where the artifact untar'd it)
|
||||
#
|
||||
# Idempotent: inits a fresh datadir if absent (setup), else starts on the existing one (shards).
|
||||
#
|
||||
set -e
|
||||
|
||||
ci_user="${ERPNEXT_CI_USER:-frappe}"
|
||||
|
||||
# Re-exec as the ci user so mariadbd and the datadir are owned consistently (root mariadbd is
|
||||
# refused anyway). Mirrors install.sh's user switch.
|
||||
if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then
|
||||
exec su -m "$ci_user" -s /bin/bash -c \
|
||||
"ERPNEXT_CI_USER='$ci_user' CI_DB_DATADIR='${CI_DB_DATADIR:-}' 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.1
|
||||
uses: pre-commit/action@v3.0.0
|
||||
|
||||
semgrep:
|
||||
name: semgrep
|
||||
|
||||
@@ -22,4 +22,4 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: alyf-de/po-review-action@v1.1.0
|
||||
- uses: alyf-de/po-review-action@v1.0.0
|
||||
|
||||
196
.github/workflows/server-tests-mariadb.yml
vendored
196
.github/workflows/server-tests-mariadb.yml
vendored
@@ -31,49 +31,51 @@ 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:
|
||||
# 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
|
||||
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
|
||||
|
||||
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}"
|
||||
@@ -82,17 +84,53 @@ 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
|
||||
|
||||
# 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
|
||||
- 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
|
||||
env:
|
||||
SKIP_SYSTEM_SETUP: "1"
|
||||
CI_DB_DATADIR: /home/ci/db-data
|
||||
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
|
||||
|
||||
- name: Install
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
||||
@@ -101,81 +139,9 @@ jobs:
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_USER_HOST: '%'
|
||||
WKHTMLTOX_DEB: /tmp/wkhtmltox.deb
|
||||
SKIP_SYSTEM_SETUP: "1"
|
||||
SKIP_WKHTMLTOX_SETUP: "1"
|
||||
|
||||
# Clean shutdown (consistent InnoDB datadir), then stage it inside the bench for packaging.
|
||||
- name: Stop DB and stage datadir
|
||||
run: |
|
||||
mariadb-admin -h 127.0.0.1 -P 3306 -u root -proot shutdown || true
|
||||
for _ in $(seq 1 30); do [ -f /home/ci/db-data/mysqld.pid ] || break; sleep 1; done
|
||||
# Don't bake a dirty datadir — fail if mariadbd didn't finish stopping, rather than ship
|
||||
# an inconsistent datadir the shards would have to crash-recover.
|
||||
[ -f /home/ci/db-data/mysqld.pid ] && { echo "mariadbd did not shut down cleanly"; exit 1; }
|
||||
mv /home/ci/db-data /home/ci/frappe-bench/mariadb-data
|
||||
|
||||
# Package the whole bench (apps, venv, node_modules, sites, the DB dump, and hydrate.sh)
|
||||
# into one artifact for the test shards to consume.
|
||||
# Single-node hand-off: stage the bench on a node-local hostPath instead of round-tripping
|
||||
# through GitHub artifact storage (~60s/shard). Setup and shards share the same disk, so
|
||||
# the shards just untar it locally. NOTE: this assumes one node — a shard on a different
|
||||
# node could not read this path (then you'd need GitHub artifacts or an NFS/RWX volume).
|
||||
- name: Stage bench on node (hostPath)
|
||||
run: |
|
||||
cp "${GITHUB_WORKSPACE}/.github/helper/hydrate.sh" /home/ci/frappe-bench/hydrate.sh
|
||||
cp "${GITHUB_WORKSPACE}/.github/helper/start-db.sh" /home/ci/frappe-bench/start-db.sh
|
||||
mkdir -p /opt/ci-bench-staging
|
||||
# self-clean: drop bench tars from runs older than 2h
|
||||
find /opt/ci-bench-staging -maxdepth 1 -name '*.tar.gz' -mmin +120 -delete 2>/dev/null || true
|
||||
# Exclude .git/node_modules; the mariadb-data datadir IS included (the pre-loaded DB).
|
||||
tar czpf "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz" -C /home/ci \
|
||||
--exclude='.git' --exclude='node_modules' frappe-bench
|
||||
ls -lh "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz"
|
||||
|
||||
# Fan-out: each shard downloads the bench, untars it, starts MariaDB on the baked datadir, and
|
||||
# runs its slice of the suite. No clone, no build, no reinstall, no DB dump restore on the shards.
|
||||
test:
|
||||
name: Python Unit Tests
|
||||
needs: setup
|
||||
runs-on: erpnext-arc
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/frappe/erpnext-ci-mariadb:py3.14-node24
|
||||
credentials:
|
||||
username: ${{ secrets.GHCR_USERNAME || github.actor }}
|
||||
password: ${{ secrets.GHCR_TOKEN || github.token }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
container: [1, 2, 3, 4]
|
||||
|
||||
steps:
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
# Read the bench straight from the node-local hostPath the setup job staged it on — no
|
||||
# GitHub download. -p preserves the ci (uid 1001) ownership so bench runs as ci cleanly.
|
||||
- name: Untar bench from node (hostPath)
|
||||
run: |
|
||||
tar xzpf "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz" -C /home/ci
|
||||
ls -ld /home/ci/frappe-bench
|
||||
|
||||
- name: Hydrate (start DB on baked datadir + bench start)
|
||||
run: bash /home/ci/frappe-bench/hydrate.sh
|
||||
env:
|
||||
DB_HOST: 127.0.0.1
|
||||
SKIP_SYSTEM_SETUP: "1"
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
su -m "${ERPNEXT_CI_USER:-frappe}" -s /bin/bash <<'EOF'
|
||||
cd ~/frappe-bench/
|
||||
coverage_flag=""
|
||||
if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
|
||||
@@ -183,10 +149,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
|
||||
@@ -196,11 +162,11 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-${{ matrix.container }}
|
||||
path: /home/ci/frappe-bench/sites/coverage.xml
|
||||
path: /home/runner/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,6 +48,7 @@ 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.15.1"
|
||||
__version__ = "16.25.0"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import getdate, nowdate
|
||||
|
||||
|
||||
class OverlapError(frappe.ValidationError):
|
||||
@@ -37,20 +36,8 @@ 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 nowdate
|
||||
from frappe.utils import add_months, 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 nowdate()
|
||||
accounting_period.end_date = args.end_date or add_months(nowdate(), 1)
|
||||
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,22 +10,75 @@ 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.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"
|
||||
self.create_company()
|
||||
self.create_item()
|
||||
self.create_customer()
|
||||
|
||||
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
|
||||
|
||||
def create_sales_invoice(
|
||||
self, qty=1, rate=100, posting_date=None, do_not_save=False, do_not_submit=False
|
||||
@@ -98,6 +151,18 @@ 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()
|
||||
|
||||
@@ -19,6 +19,7 @@ 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(
|
||||
@@ -27,10 +28,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center,
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
jv1.company = "Test PCV Company"
|
||||
jv1.company = company
|
||||
jv1.save()
|
||||
jv1.submit()
|
||||
|
||||
@@ -40,10 +41,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cost of Goods Sold - TPC",
|
||||
account2="Cash - TPC",
|
||||
cost_center=cost_center,
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
jv2.company = "Test PCV Company"
|
||||
jv2.company = company
|
||||
jv2.save()
|
||||
jv2.submit()
|
||||
|
||||
@@ -67,13 +68,14 @@ 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="Test PCV Company",
|
||||
company=company,
|
||||
cost_center=cost_center1,
|
||||
income_account="Sales - TPC",
|
||||
expense_account="Cost of Goods Sold - TPC",
|
||||
@@ -84,7 +86,7 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
create_sales_invoice(
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
cost_center=cost_center2,
|
||||
income_account="Sales - TPC",
|
||||
expense_account="Cost of Goods Sold - TPC",
|
||||
@@ -129,11 +131,12 @@ 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="Test PCV Company",
|
||||
company=company,
|
||||
income_account="Sales - TPC",
|
||||
expense_account="Cost of Goods Sold - TPC",
|
||||
cost_center=cost_center,
|
||||
@@ -150,9 +153,9 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
amount=400,
|
||||
cost_center=cost_center,
|
||||
posting_date="2021-03-15",
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
)
|
||||
jv.company = "Test PCV Company"
|
||||
jv.company = company
|
||||
jv.finance_book = create_finance_book().name
|
||||
jv.save()
|
||||
jv.submit()
|
||||
@@ -179,6 +182,7 @@ 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")
|
||||
@@ -189,15 +193,16 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center,
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
jv1.company = "Test PCV Company"
|
||||
jv1.company = 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")
|
||||
|
||||
@@ -207,10 +212,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center1,
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
jv1.company = "Test PCV Company"
|
||||
jv1.company = company
|
||||
jv1.save()
|
||||
jv1.submit()
|
||||
|
||||
@@ -220,10 +225,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center2,
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
jv2.company = "Test PCV Company"
|
||||
jv2.company = company
|
||||
jv2.save()
|
||||
jv2.submit()
|
||||
|
||||
@@ -250,11 +255,11 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center2,
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
|
||||
jv3.company = "Test PCV Company"
|
||||
jv3.company = company
|
||||
jv3.save()
|
||||
jv3.submit()
|
||||
|
||||
@@ -289,12 +294,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": "Test PCV Company"}, "name")
|
||||
warehouse = frappe.db.get_value("Warehouse", {"company": company}, "name")
|
||||
|
||||
repost_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Repost Item Valuation",
|
||||
"company": "Test PCV Company",
|
||||
"company": company,
|
||||
"posting_date": "2020-03-15",
|
||||
"based_on": "Item and Warehouse",
|
||||
"item_code": "Test Item 1",
|
||||
@@ -335,6 +340,7 @@ 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(
|
||||
@@ -343,10 +349,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center,
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
jv.company = "Test PCV Company"
|
||||
jv.company = company
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
@@ -372,6 +378,19 @@ 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(
|
||||
{
|
||||
|
||||
@@ -416,6 +416,7 @@ def get_context(customer, doc):
|
||||
return {
|
||||
"doc": template_doc,
|
||||
"customer": frappe.get_doc("Customer", customer),
|
||||
"frappe": frappe.utils,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2928,24 +2928,6 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
# Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail
|
||||
self.assertRaises(frappe.ValidationError, pi.submit)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
|
||||
def test_non_stock_item_over_billing_against_po_is_blocked(self):
|
||||
service_item = create_item(
|
||||
"_Test Service Item Non Stock PI",
|
||||
is_stock_item=0,
|
||||
is_purchase_item=1,
|
||||
).name
|
||||
|
||||
po = create_purchase_order(item_code=service_item, qty=5, rate=100, do_not_save=False)
|
||||
po.submit()
|
||||
|
||||
pi = make_pi_from_po(po.name)
|
||||
pi.items[0].qty = 10 # overbill by 100 %
|
||||
pi.save()
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
pi.submit()
|
||||
|
||||
def test_discount_percentage_not_set_when_amount_is_manually_set(self):
|
||||
pi = make_purchase_invoice(do_not_save=True)
|
||||
discount_amount = 7
|
||||
|
||||
@@ -2150,14 +2150,11 @@ 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))
|
||||
|
||||
@@ -3868,51 +3865,6 @@ 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",
|
||||
{
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"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,15 +927,6 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
|
||||
if party_type == "Supplier":
|
||||
info["total_unpaid"] = -1 * info["total_unpaid"]
|
||||
|
||||
if info["total_unpaid"] < 0:
|
||||
info["balance_label"] = (
|
||||
"Total Advance Paid" if party_type == "Supplier" else "Total Advance Received"
|
||||
)
|
||||
info["balance_amount"] = abs(info["total_unpaid"])
|
||||
else:
|
||||
info["balance_label"] = "Total Unpaid"
|
||||
info["balance_amount"] = info["total_unpaid"]
|
||||
|
||||
company_wise_info.append(info)
|
||||
|
||||
return company_wise_info
|
||||
|
||||
@@ -76,7 +76,6 @@ 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
|
||||
@@ -94,7 +93,6 @@ 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,
|
||||
@@ -114,7 +112,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, fixed_asset, net_sales, cogs, direct_expense)
|
||||
add_turnover_ratios(data, years, period_list, filters, total_asset, net_sales, cogs, direct_expense)
|
||||
|
||||
return data
|
||||
|
||||
@@ -195,7 +193,7 @@ def add_solvency_ratios(
|
||||
data.append(return_on_equity_ratio)
|
||||
|
||||
|
||||
def add_turnover_ratios(data, years, period_list, filters, fixed_asset, net_sales, cogs, direct_expense):
|
||||
def add_turnover_ratios(data, years, period_list, filters, total_asset, net_sales, cogs, direct_expense):
|
||||
precision = frappe.db.get_single_value("System Settings", "float_precision")
|
||||
data.append({"ratio": _("Turnover Ratios")})
|
||||
|
||||
@@ -210,7 +208,7 @@ def add_turnover_ratios(data, years, period_list, filters, fixed_asset, net_sale
|
||||
)
|
||||
|
||||
ratio_data = [
|
||||
[_("Fixed Asset Turnover Ratio"), net_sales, fixed_asset],
|
||||
[_("Fixed Asset Turnover Ratio"), net_sales, total_asset],
|
||||
[_("Debtor Turnover Ratio"), net_sales, avg_debtors],
|
||||
[_("Creditor Turnover Ratio"), direct_expense, avg_creditors],
|
||||
[_("Inventory Turnover Ratio"), cogs, avg_stock],
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# 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()
|
||||
@@ -14,17 +14,71 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestGrossProfit(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
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"
|
||||
self.create_company()
|
||||
self.create_item()
|
||||
self.create_bundle()
|
||||
self.create_customer()
|
||||
|
||||
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
|
||||
|
||||
def create_sales_invoice(
|
||||
self, qty=1, rate=100, posting_date=None, do_not_save=False, do_not_submit=False
|
||||
@@ -158,7 +212,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _TC",
|
||||
"warehouse": "Stores - _GP",
|
||||
"qty": 1.0,
|
||||
"avg._selling_rate": 100.0,
|
||||
"valuation_rate": 150.0,
|
||||
@@ -187,7 +241,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _TC",
|
||||
"warehouse": "Stores - _GP",
|
||||
"qty": 1.0,
|
||||
"avg._selling_rate": 100.0,
|
||||
"valuation_rate": 100.0,
|
||||
@@ -219,7 +273,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"item_code": self.item2,
|
||||
"s_warehouse": "",
|
||||
"t_warehouse": self.finished_warehouse,
|
||||
"qty": 2,
|
||||
"qty": 1,
|
||||
"basic_rate": 100,
|
||||
"conversion_factor": item.conversion_factor or 1.0,
|
||||
"transfer_qty": flt(item.qty) * (flt(item.conversion_factor) or 1.0),
|
||||
@@ -319,7 +373,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _TC",
|
||||
"warehouse": "Stores - _GP",
|
||||
"qty": 4.0,
|
||||
"avg._selling_rate": 100.0,
|
||||
"valuation_rate": 125.0,
|
||||
@@ -360,10 +414,10 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _TC",
|
||||
"warehouse": "Stores - _GP",
|
||||
"qty": 0.0,
|
||||
"avg._selling_rate": 100.0,
|
||||
"valuation_rate": 100.0,
|
||||
"avg._selling_rate": 100,
|
||||
"valuation_rate": 0.0,
|
||||
"selling_amount": 0.0,
|
||||
"buying_amount": 0.0,
|
||||
"gross_profit": 0.0,
|
||||
@@ -383,7 +437,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"""
|
||||
# Make Cr Note
|
||||
sinv = self.create_sales_invoice(
|
||||
qty=-1, rate=200, posting_date=nowdate(), do_not_save=True, do_not_submit=True
|
||||
qty=-1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True
|
||||
)
|
||||
sinv.is_return = 1
|
||||
sinv.items[0].allow_zero_valuation_rate = 1
|
||||
@@ -406,14 +460,14 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _TC",
|
||||
"warehouse": "Stores - _GP",
|
||||
"qty": -1.0,
|
||||
"avg._selling_rate": 200.0,
|
||||
"valuation_rate": 100.0,
|
||||
"selling_amount": -200.0,
|
||||
"buying_amount": -100.0,
|
||||
"avg._selling_rate": 100.0,
|
||||
"valuation_rate": 0.0,
|
||||
"selling_amount": -100.0,
|
||||
"buying_amount": 0.0,
|
||||
"gross_profit": -100.0,
|
||||
"gross_profit_%": -50.0,
|
||||
"gross_profit_%": -100.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}
|
||||
@@ -499,7 +553,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _TC",
|
||||
"warehouse": "Stores - _GP",
|
||||
"qty": 4.0,
|
||||
"avg._selling_rate": 800.0,
|
||||
"valuation_rate": 700.0,
|
||||
@@ -562,7 +616,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
def test_gross_profit_groupby_invoices(self):
|
||||
create_sales_invoice(
|
||||
qty=1,
|
||||
rate=200,
|
||||
rate=100,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
item_code=self.item,
|
||||
@@ -584,10 +638,10 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
_, data = execute(filters=filters)
|
||||
total = data[-1]
|
||||
|
||||
self.assertEqual(total.selling_amount, 200.0)
|
||||
self.assertEqual(total.buying_amount, 100.0)
|
||||
self.assertEqual(total.selling_amount, 100.0)
|
||||
self.assertEqual(total.buying_amount, 0.0)
|
||||
self.assertEqual(total.gross_profit, 100.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), 50.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), 100.0)
|
||||
|
||||
def test_profit_for_later_period_return(self):
|
||||
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
|
||||
@@ -596,7 +650,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=200, do_not_save=True, do_not_submit=True)
|
||||
sinv = self.create_sales_invoice(qty=1, rate=100, do_not_save=True, do_not_submit=True)
|
||||
sinv.set_posting_time = 1
|
||||
sinv.posting_date = sales_inv_date
|
||||
sinv.save().submit()
|
||||
@@ -615,10 +669,10 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
_, data = execute(filters=filters)
|
||||
total = data[-1]
|
||||
|
||||
self.assertEqual(total.selling_amount, 200.0)
|
||||
self.assertEqual(total.buying_amount, 100.0)
|
||||
self.assertEqual(total.selling_amount, 100.0)
|
||||
self.assertEqual(total.buying_amount, 0.0)
|
||||
self.assertEqual(total.gross_profit, 100.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), 50.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), 100.0)
|
||||
|
||||
# extend filters upto returned period
|
||||
filters.update({"to_date": return_inv_date})
|
||||
@@ -636,10 +690,10 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
_, data = execute(filters=filters)
|
||||
total = data[-1]
|
||||
|
||||
self.assertEqual(total.selling_amount, -200.0)
|
||||
self.assertEqual(total.buying_amount, -100.0)
|
||||
self.assertEqual(total.selling_amount, -100.0)
|
||||
self.assertEqual(total.buying_amount, 0.0)
|
||||
self.assertEqual(total.gross_profit, -100.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), -50.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), -100.0)
|
||||
|
||||
def test_sales_person_wise_gross_profit(self):
|
||||
sales_person = make_sales_person("_Test Sales Person")
|
||||
@@ -670,10 +724,10 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
_, data = execute(filters=filters)
|
||||
total = data[-1]
|
||||
|
||||
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 %
|
||||
self.assertEqual(total[5], 1000.0)
|
||||
self.assertEqual(total[6], 0.0)
|
||||
self.assertEqual(total[7], 1000.0)
|
||||
self.assertEqual(total[8], 100.0)
|
||||
|
||||
def test_drop_ship(self):
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
|
||||
|
||||
@@ -9,12 +9,42 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestPaymentLedger(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
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.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
|
||||
|
||||
def test_unpaid_invoice_outstanding(self):
|
||||
sinv = create_sales_invoice(
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
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
|
||||
@@ -126,32 +125,17 @@ 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,
|
||||
"outstanding_amount": flt(
|
||||
(inv.outstanding_amount * (inv.conversion_rate or 1)), outstanding_precision
|
||||
),
|
||||
}
|
||||
)
|
||||
row.update({"debit": inv.base_grand_total, "credit": 0.0})
|
||||
else:
|
||||
row.update({"debit": 0.0, "credit": inv.base_grand_total})
|
||||
data.append(row)
|
||||
@@ -411,7 +395,6 @@ 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, flt, today
|
||||
from frappe.utils import add_months, 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,35 +67,6 @@ 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,31 +141,17 @@ 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,
|
||||
"outstanding_amount": flt(
|
||||
(inv.outstanding_amount * (inv.conversion_rate or 1)), outstanding_precision
|
||||
),
|
||||
}
|
||||
)
|
||||
row.update({"debit": inv.base_grand_total, "credit": 0.0})
|
||||
else:
|
||||
row.update({"debit": 0.0, "credit": inv.base_grand_total})
|
||||
data.append(row)
|
||||
@@ -451,7 +437,6 @@ def get_invoices(filters, additional_query_columns):
|
||||
si.is_internal_customer,
|
||||
si.represents_company,
|
||||
si.company,
|
||||
si.conversion_rate,
|
||||
)
|
||||
.where(si.docstatus == 1)
|
||||
)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import frappe
|
||||
from frappe.utils import add_days, flt, getdate, today
|
||||
from frappe.utils import 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
|
||||
|
||||
@@ -217,25 +216,3 @@ 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))
|
||||
|
||||
@@ -100,9 +100,6 @@ 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:
|
||||
|
||||
@@ -110,7 +110,6 @@
|
||||
"fieldtype": "Data",
|
||||
"in_global_search": 1,
|
||||
"label": "Alias",
|
||||
"no_copy": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
@@ -562,7 +561,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-27 16:12:33.190257",
|
||||
"modified": "2026-06-22 12:23:09.241125",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier",
|
||||
|
||||
@@ -4153,7 +4153,6 @@ 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,
|
||||
@@ -4161,11 +4160,14 @@ 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 = 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.rate_with_margin = 0
|
||||
|
||||
child_item.flags.ignore_validate_update_after_submit = True
|
||||
if new_child_flag:
|
||||
|
||||
@@ -383,17 +383,15 @@ class StatusUpdater(Document):
|
||||
|
||||
def fetch_items_with_pending_qty(self, args, item_field, items):
|
||||
doctype = frappe.qb.DocType(args["target_dt"])
|
||||
item_field_col = doctype[item_field]
|
||||
item_field = doctype[item_field]
|
||||
target_ref_field = doctype[args["target_ref_field"]]
|
||||
target_field = doctype[args["target_field"]]
|
||||
|
||||
is_qty_check = "qty" in args["target_ref_field"]
|
||||
|
||||
query = (
|
||||
return (
|
||||
frappe.qb.from_(doctype)
|
||||
.select(
|
||||
doctype.name,
|
||||
item_field_col.as_("item_code"),
|
||||
item_field.as_("item_code"),
|
||||
target_ref_field,
|
||||
target_field,
|
||||
doctype.parenttype,
|
||||
@@ -402,18 +400,9 @@ 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.
|
||||
|
||||
@@ -36,8 +36,6 @@ 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 (
|
||||
@@ -66,7 +64,6 @@ 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()
|
||||
@@ -1143,50 +1140,6 @@ 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"):
|
||||
|
||||
@@ -165,85 +165,83 @@ class calculate_taxes_and_totals:
|
||||
|
||||
self.doc.conversion_rate = flt(self.doc.conversion_rate)
|
||||
|
||||
def calculate_item_rate(self, item):
|
||||
if not item.price_list_rate:
|
||||
remove_margin(item)
|
||||
remove_discount(item)
|
||||
item.rate_with_margin = 0
|
||||
return
|
||||
|
||||
has_pricing_rules = item.pricing_rules and not self.doc.ignore_pricing_rule
|
||||
if has_pricing_rules:
|
||||
remove_margin(item)
|
||||
|
||||
for d in get_applied_pricing_rules(item.pricing_rules):
|
||||
pricing_rule = frappe.get_cached_doc("Pricing Rule", d)
|
||||
|
||||
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
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
item.margin_type = pricing_rule.margin_type
|
||||
item.margin_rate_or_amount = pricing_rule.margin_rate_or_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")
|
||||
)
|
||||
|
||||
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:
|
||||
if self.doc.get("is_consolidated"):
|
||||
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)
|
||||
if not self.discount_amount_applied:
|
||||
do_not_round_fields = ["valuation_rate", "incoming_rate"]
|
||||
|
||||
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
|
||||
for item in self.doc.items:
|
||||
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
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", "net_rate", "amount", "net_amount"]
|
||||
)
|
||||
|
||||
item.item_tax_amount = 0.0
|
||||
|
||||
def _set_in_company_currency(self, doc, fields):
|
||||
"""set values in base currency"""
|
||||
@@ -1137,6 +1135,48 @@ 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)
|
||||
|
||||
@@ -1171,29 +1211,6 @@ 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
|
||||
|
||||
@@ -18,9 +18,39 @@ from erpnext.buying.doctype.purchase_order.test_purchase_order import (
|
||||
prepare_data_for_internal_transfer,
|
||||
)
|
||||
from erpnext.projects.doctype.project.test_project import make_project
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
def make_customer(customer_name, currency=None):
|
||||
if not frappe.db.exists("Customer", customer_name):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = customer_name
|
||||
customer.customer_type = "Individual"
|
||||
|
||||
if currency:
|
||||
customer.default_currency = currency
|
||||
customer.save()
|
||||
return customer.name
|
||||
else:
|
||||
return customer_name
|
||||
|
||||
|
||||
def make_supplier(supplier_name, currency=None):
|
||||
if not frappe.db.exists("Supplier", supplier_name):
|
||||
supplier = frappe.new_doc("Supplier")
|
||||
supplier.supplier_name = supplier_name
|
||||
supplier.supplier_type = "Individual"
|
||||
supplier.supplier_group = "All Supplier Groups"
|
||||
|
||||
if currency:
|
||||
supplier.default_currency = currency
|
||||
supplier.save()
|
||||
return supplier.name
|
||||
else:
|
||||
return supplier_name
|
||||
|
||||
|
||||
class TestAccountsController(ERPNextTestSuite):
|
||||
"""
|
||||
Test Exchange Gain/Loss booking on various scenarios.
|
||||
@@ -37,28 +67,79 @@ class TestAccountsController(ERPNextTestSuite):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.company_abbr = "_TC"
|
||||
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.debit_usd = "_Test Receivable USD - _TC"
|
||||
self.debtors_usd = "_Test Receivable USD - _TC"
|
||||
self.cash = "Cash - _TC"
|
||||
self.creditors = "Creditors - _TC"
|
||||
self.creditors_usd = "_Test Payable USD - _TC"
|
||||
self.item = "_Test Item"
|
||||
self.customer = "_Test Customer USD"
|
||||
self.supplier = "_Test Supplier USD"
|
||||
self.create_company()
|
||||
self.create_account()
|
||||
self.create_item()
|
||||
self.create_parties()
|
||||
self.clear_old_entries()
|
||||
frappe.flags.is_reverse_depr_entry = False
|
||||
|
||||
def create_company(self):
|
||||
company_name = "_Test Company"
|
||||
self.company_abbr = abbr = "_TC"
|
||||
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.debit_usd = "Debtors USD - " + abbr
|
||||
self.cash = "Cash - " + abbr
|
||||
self.creditors = "Creditors - " + abbr
|
||||
|
||||
def create_item(self):
|
||||
item = create_item(
|
||||
item_code="_Test Notebook", is_stock_item=0, company=self.company, warehouse=self.warehouse
|
||||
)
|
||||
self.item = item if isinstance(item, str) else item.item_code
|
||||
|
||||
def create_parties(self):
|
||||
self.create_customer()
|
||||
self.create_supplier()
|
||||
|
||||
def create_customer(self):
|
||||
self.customer = make_customer("_Test MC Customer USD", "USD")
|
||||
|
||||
def create_supplier(self):
|
||||
self.supplier = make_supplier("_Test MC Supplier USD", "USD")
|
||||
|
||||
def create_account(self):
|
||||
# Advance accounts are not in persistent test data — create them on demand.
|
||||
accounts = [
|
||||
frappe._dict(
|
||||
{
|
||||
"attribute_name": "debtors_usd",
|
||||
"name": "Debtors USD",
|
||||
"account_type": "Receivable",
|
||||
"account_currency": "USD",
|
||||
"parent_account": "Accounts Receivable - " + self.company_abbr,
|
||||
}
|
||||
),
|
||||
frappe._dict(
|
||||
{
|
||||
"attribute_name": "creditors_usd",
|
||||
"name": "Creditors USD",
|
||||
"account_type": "Payable",
|
||||
"account_currency": "USD",
|
||||
"parent_account": "Accounts Payable - " + self.company_abbr,
|
||||
}
|
||||
),
|
||||
# Advance accounts under Asset and Liability header
|
||||
frappe._dict(
|
||||
{
|
||||
"attribute_name": "advance_received_usd",
|
||||
@@ -104,7 +185,6 @@ class TestAccountsController(ERPNextTestSuite):
|
||||
company.save()
|
||||
|
||||
customer = frappe.get_doc("Customer", self.customer)
|
||||
customer.accounts = []
|
||||
customer.append(
|
||||
"accounts",
|
||||
{
|
||||
@@ -116,7 +196,6 @@ class TestAccountsController(ERPNextTestSuite):
|
||||
customer.save()
|
||||
|
||||
supplier = frappe.get_doc("Supplier", self.supplier)
|
||||
supplier.accounts = []
|
||||
supplier.append(
|
||||
"accounts",
|
||||
{
|
||||
@@ -242,6 +321,18 @@ class TestAccountsController(ERPNextTestSuite):
|
||||
pinv.submit()
|
||||
return pinv
|
||||
|
||||
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_payment_reconciliation(self):
|
||||
pr = frappe.new_doc("Payment Reconciliation")
|
||||
pr.company = self.company
|
||||
@@ -864,7 +955,7 @@ class TestAccountsController(ERPNextTestSuite):
|
||||
|
||||
# Create a Sales Invoice
|
||||
sinv = frappe.new_doc("Sales Invoice")
|
||||
sinv.customer = "_Test Customer"
|
||||
sinv.customer = self.customer
|
||||
sinv.company = self.company
|
||||
sinv.currency = "INR"
|
||||
sinv.taxes_and_charges = "_Test Tax - _TC"
|
||||
@@ -880,7 +971,7 @@ class TestAccountsController(ERPNextTestSuite):
|
||||
def test_19_fetch_taxes_based_on_item_tax_template_template(self):
|
||||
# Create a Sales Invoice
|
||||
sinv = frappe.new_doc("Sales Invoice")
|
||||
sinv.customer = "_Test Customer"
|
||||
sinv.customer = self.customer
|
||||
sinv.company = self.company
|
||||
sinv.currency = "INR"
|
||||
sinv.append(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields, delete_custom_fields
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
|
||||
@@ -448,7 +448,6 @@ def get_lead_details(lead, posting_date=None, company=None, doctype=None):
|
||||
out = frappe._dict()
|
||||
|
||||
lead_doc = frappe.get_doc("Lead", lead)
|
||||
lead_doc.check_permission()
|
||||
lead = lead_doc
|
||||
|
||||
out.update(
|
||||
|
||||
@@ -136,7 +136,7 @@ def make_opportunity(source_name, target_doc=None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_opportunities(prospect):
|
||||
return frappe.get_list(
|
||||
return frappe.get_all(
|
||||
"Opportunity",
|
||||
filters={"opportunity_from": "Prospect", "party_name": prospect},
|
||||
fields=[
|
||||
|
||||
2065
erpnext/locale/af.po
2065
erpnext/locale/af.po
File diff suppressed because it is too large
Load Diff
2091
erpnext/locale/ar.po
2091
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
62002
erpnext/locale/bg.po
62002
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
2979
erpnext/locale/bs.po
2979
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
2083
erpnext/locale/cs.po
2083
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
2073
erpnext/locale/da.po
2073
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
2123
erpnext/locale/de.po
2123
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
2971
erpnext/locale/eo.po
2971
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
2099
erpnext/locale/es.po
2099
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
2227
erpnext/locale/fa.po
2227
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
2065
erpnext/locale/fi.po
2065
erpnext/locale/fi.po
File diff suppressed because it is too large
Load Diff
2101
erpnext/locale/fr.po
2101
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
2299
erpnext/locale/hi.po
2299
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
2983
erpnext/locale/hr.po
2983
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
2099
erpnext/locale/hu.po
2099
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
2089
erpnext/locale/id.po
2089
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
2089
erpnext/locale/it.po
2089
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
2549
erpnext/locale/ko.po
2549
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2067
erpnext/locale/my.po
2067
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
2075
erpnext/locale/nb.po
2075
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
2099
erpnext/locale/nl.po
2099
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
2089
erpnext/locale/pl.po
2089
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
2085
erpnext/locale/pt.po
2085
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2109
erpnext/locale/ru.po
2109
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
2085
erpnext/locale/sl.po
2085
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
2113
erpnext/locale/sr.po
2113
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2983
erpnext/locale/sv.po
2983
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
2063
erpnext/locale/ta.po
2063
erpnext/locale/ta.po
File diff suppressed because it is too large
Load Diff
2107
erpnext/locale/th.po
2107
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
2103
erpnext/locale/tr.po
2103
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
62064
erpnext/locale/uz.po
62064
erpnext/locale/uz.po
File diff suppressed because it is too large
Load Diff
2107
erpnext/locale/vi.po
2107
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
2111
erpnext/locale/zh.po
2111
erpnext/locale/zh.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -694,11 +694,10 @@ frappe.ui.form.on("Job Card", {
|
||||
// ── Wire up button click handlers ─────────────────────────────────
|
||||
if (show_start) {
|
||||
wrapper.find(".jcd-btn-start").on("click", () => {
|
||||
const from_time = frappe.datetime.now_datetime();
|
||||
const has_no_employee = !frm.doc.employee || !frm.doc.employee.length;
|
||||
|
||||
if (has_no_employee) {
|
||||
// Capture the start time only when the employee dialog is submitted, not on click,
|
||||
// so the time spent selecting the operator is not counted as worked time.
|
||||
frappe.prompt(
|
||||
{
|
||||
fieldtype: "Table MultiSelect",
|
||||
@@ -708,11 +707,11 @@ frappe.ui.form.on("Job Card", {
|
||||
reqd: 1,
|
||||
filters: { status: "Active" },
|
||||
},
|
||||
(d) => frm.events.start_timer(frm, frappe.datetime.now_datetime(), d.employees),
|
||||
(d) => frm.events.start_timer(frm, from_time, d.employees),
|
||||
__("Assign Job to Employee")
|
||||
);
|
||||
} else {
|
||||
frm.events.start_timer(frm, frappe.datetime.now_datetime(), frm.doc.employee);
|
||||
frm.events.start_timer(frm, from_time, frm.doc.employee);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -485,4 +485,3 @@ erpnext.patches.v16_0.clear_procedures_from_receivable_report
|
||||
erpnext.patches.v16_0.migrate_address_contact_custom_fields
|
||||
erpnext.patches.v16_0.drop_redundant_serial_no_index_from_sabb
|
||||
execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600)
|
||||
erpnext.patches.v16_0.remove_mandatory_from_inv_dimension_fields
|
||||
|
||||
@@ -9,6 +9,7 @@ def get_inventory_dimensions():
|
||||
"source_fieldname",
|
||||
"reference_document as doctype",
|
||||
"reqd",
|
||||
"mandatory_depends_on",
|
||||
],
|
||||
order_by="creation",
|
||||
distinct=True,
|
||||
@@ -84,5 +85,5 @@ def execute():
|
||||
"Custom Field",
|
||||
{"fieldname": fieldname, "dt": dimension.doctype},
|
||||
"mandatory_depends_on",
|
||||
display_depends_on if dimension.reqd else "",
|
||||
display_depends_on if dimension.reqd else dimension.mandatory_depends_on,
|
||||
)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_documents
|
||||
|
||||
|
||||
def execute():
|
||||
"""Mandatory inventory dimensions are now enforced on the server side
|
||||
(StockController.validate_inventory_dimension_mandatory) instead of via field-level
|
||||
`reqd`/`mandatory_depends_on`. Clear those properties from the related custom fields."""
|
||||
dimensions = frappe.get_all(
|
||||
"Inventory Dimension",
|
||||
fields=[
|
||||
"source_fieldname",
|
||||
"reference_document",
|
||||
"document_type",
|
||||
"apply_to_all_doctypes",
|
||||
],
|
||||
)
|
||||
|
||||
for dimension in dimensions:
|
||||
if not dimension.source_fieldname or not dimension.reference_document:
|
||||
continue
|
||||
|
||||
# Scope to the exact doctypes where this dimension generated fields so unrelated
|
||||
# mandatory custom fields (same name/target on a different doctype) are never touched.
|
||||
if dimension.apply_to_all_doctypes:
|
||||
doctypes = [d[0] for d in get_inventory_documents()]
|
||||
elif dimension.document_type:
|
||||
doctypes = [dimension.document_type]
|
||||
else:
|
||||
continue
|
||||
|
||||
fieldname = dimension.source_fieldname
|
||||
fieldnames = [fieldname, f"to_{fieldname}", f"from_{fieldname}", f"rejected_{fieldname}"]
|
||||
|
||||
custom_fields = frappe.get_all(
|
||||
"Custom Field",
|
||||
filters={
|
||||
"dt": ("in", doctypes),
|
||||
"fieldname": ("in", fieldnames),
|
||||
"fieldtype": "Link",
|
||||
"options": dimension.reference_document,
|
||||
},
|
||||
or_filters={"reqd": 1, "mandatory_depends_on": ("is", "set")},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
for name in custom_fields:
|
||||
frappe.db.set_value(
|
||||
"Custom Field",
|
||||
name,
|
||||
{"reqd": 0, "mandatory_depends_on": ""},
|
||||
update_modified=False,
|
||||
)
|
||||
@@ -23,12 +23,15 @@ erpnext.accounts.taxes = {
|
||||
onload: function (frm) {
|
||||
if (frm.get_field("taxes")) {
|
||||
frm.set_query("account_head", "taxes", function (doc) {
|
||||
let account_type = ["Tax", "Chargeable"];
|
||||
|
||||
if (frm.cscript.tax_table == "Sales Taxes and Charges") {
|
||||
account_type.push("Expense Account");
|
||||
var account_type = ["Tax", "Chargeable", "Expense Account"];
|
||||
} else {
|
||||
account_type.push("Income Account", "Expenses Included In Valuation");
|
||||
var account_type = [
|
||||
"Tax",
|
||||
"Chargeable",
|
||||
"Income Account",
|
||||
"Expenses Included In Valuation",
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,30 +10,29 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
|
||||
apply_pricing_rule_on_item(item) {
|
||||
let effective_item_rate = item.price_list_rate;
|
||||
let item_rate = item.rate;
|
||||
if (["Sales Order", "Quotation"].includes(item.parenttype) && item.blanket_order_rate) {
|
||||
effective_item_rate = item.blanket_order_rate;
|
||||
}
|
||||
|
||||
let rate_with_margin;
|
||||
if (item.margin_type == "Percentage") {
|
||||
rate_with_margin = effective_item_rate * (1 + item.margin_rate_or_amount / 100);
|
||||
item.rate_with_margin =
|
||||
flt(effective_item_rate) + flt(effective_item_rate) * (flt(item.margin_rate_or_amount) / 100);
|
||||
} else {
|
||||
rate_with_margin = effective_item_rate + item.margin_rate_or_amount;
|
||||
item.rate_with_margin = flt(effective_item_rate) + flt(item.margin_rate_or_amount);
|
||||
}
|
||||
item.rate_with_margin = flt(rate_with_margin, precision("rate_with_margin", item));
|
||||
item.base_rate_with_margin = flt(item.rate_with_margin) * flt(this.frm.doc.conversion_rate);
|
||||
|
||||
if (item.discount_percentage) {
|
||||
item.discount_amount = flt(
|
||||
(item.rate_with_margin * item.discount_percentage) / 100,
|
||||
precision("discount_amount", item)
|
||||
);
|
||||
item_rate = flt(item.rate_with_margin, precision("rate", item));
|
||||
|
||||
if (item.discount_percentage && !item.discount_amount) {
|
||||
item.discount_amount = (flt(item.rate_with_margin) * flt(item.discount_percentage)) / 100;
|
||||
}
|
||||
|
||||
let item_rate = item.rate_with_margin;
|
||||
if (item.discount_amount) {
|
||||
item_rate = item.rate_with_margin - item.discount_amount;
|
||||
if (item.discount_amount > 0) {
|
||||
item_rate = flt(item.rate_with_margin - item.discount_amount, precision("rate", item));
|
||||
item.discount_percentage = (100 * flt(item.discount_amount)) / flt(item.rate_with_margin);
|
||||
}
|
||||
item_rate = flt(item_rate, precision("rate", item));
|
||||
|
||||
frappe.model.set_value(item.doctype, item.name, "rate", item_rate);
|
||||
}
|
||||
|
||||
@@ -952,15 +951,14 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
if (["Sales Invoice", "POS Invoice", "Purchase Invoice"].includes(this.frm.doc.doctype)) {
|
||||
let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total;
|
||||
let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total;
|
||||
let total_amount_to_pay;
|
||||
|
||||
if (this.frm.doc.party_account_currency == this.frm.doc.currency) {
|
||||
total_amount_to_pay = flt(
|
||||
var total_amount_to_pay = flt(
|
||||
grand_total - this.frm.doc.total_advance - this.frm.doc.write_off_amount,
|
||||
precision("grand_total")
|
||||
);
|
||||
} else {
|
||||
total_amount_to_pay = flt(
|
||||
var total_amount_to_pay = flt(
|
||||
flt(base_grand_total, precision("base_grand_total")) -
|
||||
this.frm.doc.total_advance -
|
||||
this.frm.doc.base_write_off_amount,
|
||||
@@ -1005,15 +1003,14 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
async set_total_amount_to_default_mop() {
|
||||
let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total;
|
||||
let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total;
|
||||
let total_amount_to_pay;
|
||||
|
||||
if (this.frm.doc.party_account_currency == this.frm.doc.currency) {
|
||||
total_amount_to_pay = flt(
|
||||
var total_amount_to_pay = flt(
|
||||
grand_total - this.frm.doc.total_advance - this.frm.doc.write_off_amount,
|
||||
precision("grand_total")
|
||||
);
|
||||
} else {
|
||||
total_amount_to_pay = flt(
|
||||
var total_amount_to_pay = flt(
|
||||
flt(base_grand_total, precision("base_grand_total")) -
|
||||
this.frm.doc.total_advance -
|
||||
this.frm.doc.base_write_off_amount,
|
||||
|
||||
@@ -13,58 +13,39 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
frappe.flags.hide_serial_batch_dialog = true;
|
||||
frappe.ui.form.on(this.frm.doctype + " Item", "rate", function (frm, cdt, cdn) {
|
||||
var item = frappe.get_doc(cdt, cdn);
|
||||
var has_margin_field = frappe.meta.has_field(cdt, "margin_type");
|
||||
|
||||
frappe.model.round_floats_in(item, [
|
||||
"rate",
|
||||
"price_list_rate",
|
||||
"margin_rate_or_amount",
|
||||
"discount_amount",
|
||||
"discount_percentage",
|
||||
]);
|
||||
frappe.model.round_floats_in(item, ["rate", "price_list_rate"]);
|
||||
|
||||
if (item.price_list_rate && !item.blanket_order_rate) {
|
||||
const rate_with_margin = get_rate_with_margin(item);
|
||||
|
||||
if (item.discount_percentage) {
|
||||
item.discount_amount = flt(
|
||||
(rate_with_margin * item.discount_percentage) / 100.0,
|
||||
precision("discount_amount", item)
|
||||
);
|
||||
}
|
||||
|
||||
const calculated_rate = flt(rate_with_margin - item.discount_amount, precision("rate", item));
|
||||
|
||||
if (calculated_rate !== item.rate) {
|
||||
if (item.rate > item.price_list_rate && has_margin_field) {
|
||||
// if rate is greater than price_list_rate, set margin
|
||||
// otherwise, set discount
|
||||
if (item.rate > item.price_list_rate) {
|
||||
item.margin_type = "Amount";
|
||||
item.margin_rate_or_amount = flt(
|
||||
item.rate - item.price_list_rate,
|
||||
precision("margin_rate_or_amount", item)
|
||||
);
|
||||
item.rate_with_margin = item.rate;
|
||||
item.discount_amount = 0;
|
||||
item.discount_percentage = 0;
|
||||
} else {
|
||||
item.margin_type = "";
|
||||
item.margin_rate_or_amount = 0;
|
||||
item.rate_with_margin = item.price_list_rate;
|
||||
item.discount_percentage = 0;
|
||||
item.discount_amount = flt(
|
||||
item.rate_with_margin - item.rate,
|
||||
precision("discount_amount", item)
|
||||
);
|
||||
}
|
||||
// or set discount
|
||||
item.discount_percentage = 0;
|
||||
item.margin_type = "Amount";
|
||||
item.margin_rate_or_amount = flt(
|
||||
item.rate - item.price_list_rate,
|
||||
precision("margin_rate_or_amount", item)
|
||||
);
|
||||
item.rate_with_margin = item.rate;
|
||||
} else {
|
||||
item.discount_percentage = flt(
|
||||
(1 - item.rate / item.price_list_rate) * 100.0,
|
||||
precision("discount_percentage", item)
|
||||
);
|
||||
item.discount_amount = flt(item.price_list_rate) - flt(item.rate);
|
||||
item.margin_type = "";
|
||||
item.margin_rate_or_amount = 0;
|
||||
item.rate_with_margin = 0;
|
||||
}
|
||||
} else {
|
||||
item.discount_percentage = 0.0;
|
||||
item.margin_type = "";
|
||||
item.margin_rate_or_amount = 0;
|
||||
item.rate_with_margin = 0;
|
||||
item.discount_amount = 0;
|
||||
item.discount_percentage = 0.0;
|
||||
}
|
||||
me.set_in_company_currency(item, ["rate_with_margin"]);
|
||||
item.base_rate_with_margin = item.rate_with_margin * flt(frm.doc.conversion_rate);
|
||||
|
||||
cur_frm.cscript.set_gross_profit(item);
|
||||
cur_frm.cscript.calculate_taxes_and_totals();
|
||||
cur_frm.cscript.calculate_stock_uom_rate(frm, cdt, cdn);
|
||||
@@ -1273,8 +1254,13 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
|
||||
var set_party_account = function (set_pricing) {
|
||||
if (["Sales Invoice", "Purchase Invoice"].includes(me.frm.doc.doctype)) {
|
||||
let party_type = me.frm.doc.doctype == "Sales Invoice" ? "Customer" : "Supplier";
|
||||
let party_account_field = me.frm.doc.doctype == "Sales Invoice" ? "debit_to" : "credit_to";
|
||||
if (me.frm.doc.doctype == "Sales Invoice") {
|
||||
var party_type = "Customer";
|
||||
var party_account_field = "debit_to";
|
||||
} else {
|
||||
var party_type = "Supplier";
|
||||
var party_account_field = "credit_to";
|
||||
}
|
||||
|
||||
var party = me.frm.doc[frappe.model.scrub(party_type)];
|
||||
if (
|
||||
@@ -1981,7 +1967,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
}
|
||||
|
||||
if (this.frm.doc.operations && this.frm.doc.operations.length > 0) {
|
||||
let item_grid = this.frm.fields_dict["operations"].grid;
|
||||
var item_grid = this.frm.fields_dict["operations"].grid;
|
||||
$.each(["base_operating_cost", "base_hour_rate"], function (i, fname) {
|
||||
if (frappe.meta.get_docfield(item_grid.doctype, fname))
|
||||
item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency);
|
||||
@@ -1989,7 +1975,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
}
|
||||
|
||||
if (this.frm.doc.secondary_items && this.frm.doc.secondary_items.length > 0) {
|
||||
let item_grid = this.frm.fields_dict["secondary_items"].grid;
|
||||
var item_grid = this.frm.fields_dict["secondary_items"].grid;
|
||||
$.each(["base_rate", "base_amount"], function (i, fname) {
|
||||
if (frappe.meta.get_docfield(item_grid.doctype, fname))
|
||||
item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency);
|
||||
@@ -2386,7 +2372,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
row_to_modify[key] = pr_row[key];
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(this.frm.doc, "is_pos") && this.frm.doc.is_pos) {
|
||||
if (this.frm.doc.hasOwnProperty("is_pos") && this.frm.doc.is_pos) {
|
||||
let r = await frappe.db.get_value("POS Profile", this.frm.doc.pos_profile, "cost_center");
|
||||
if (r.message.cost_center) {
|
||||
row_to_modify["cost_center"] = r.message.cost_center;
|
||||
@@ -2653,7 +2639,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
$.each(me.frm.doc.items || [], function (i, item) {
|
||||
if (
|
||||
item.name &&
|
||||
Object.prototype.hasOwnProperty.call(r.message, item.name) &&
|
||||
r.message.hasOwnProperty(item.name) &&
|
||||
r.message[item.name].item_tax_template
|
||||
) {
|
||||
item.item_tax_template = r.message[item.name].item_tax_template;
|
||||
@@ -3377,13 +3363,3 @@ erpnext.set_unit_price_items_note = (frm) => {
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function get_rate_with_margin(item) {
|
||||
if (!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), precision("rate", item));
|
||||
}
|
||||
|
||||
return flt(item.price_list_rate + item.margin_rate_or_amount, precision("rate", item));
|
||||
}
|
||||
|
||||
@@ -188,19 +188,11 @@ $.extend(erpnext.utils, {
|
||||
]),
|
||||
"blue"
|
||||
);
|
||||
var info = company_wise_info[0];
|
||||
var is_advance = info.balance_label !== "Total Unpaid";
|
||||
var indicator_label =
|
||||
info.balance_label === "Total Advance Paid"
|
||||
? __("Total Advance Paid: {0}", [format_currency(info.balance_amount, info.currency)])
|
||||
: info.balance_label === "Total Advance Received"
|
||||
? __("Total Advance Received: {0}", [
|
||||
format_currency(info.balance_amount, info.currency),
|
||||
])
|
||||
: __("Total Unpaid: {0}", [format_currency(info.balance_amount, info.currency)]);
|
||||
frm.dashboard.add_indicator(
|
||||
indicator_label,
|
||||
is_advance ? "green" : info.balance_amount ? "orange" : "green"
|
||||
__("Total Unpaid: {0}", [
|
||||
format_currency(company_wise_info[0].total_unpaid, company_wise_info[0].currency),
|
||||
]),
|
||||
company_wise_info[0].total_unpaid ? "orange" : "green"
|
||||
);
|
||||
|
||||
if (company_wise_info[0].loyalty_points) {
|
||||
@@ -243,14 +235,7 @@ $.extend(erpnext.utils, {
|
||||
frm.dashboard.stats_area_row.addClass("flex");
|
||||
frm.dashboard.stats_area_row.css("flex-wrap", "wrap");
|
||||
|
||||
var is_advance = info.balance_label !== "Total Unpaid";
|
||||
var color = is_advance ? "green" : info.balance_amount ? "orange" : "green";
|
||||
var balance_label_text =
|
||||
info.balance_label === "Total Advance Paid"
|
||||
? __("Total Advance Paid")
|
||||
: info.balance_label === "Total Advance Received"
|
||||
? __("Total Advance Received")
|
||||
: __("Total Unpaid");
|
||||
var color = info.total_unpaid ? "orange" : "green";
|
||||
|
||||
var indicator = $(
|
||||
'<div class="flex-column col-xs-6">' +
|
||||
@@ -264,10 +249,8 @@ $.extend(erpnext.utils, {
|
||||
'<div class="badge-link small" style="margin-bottom:10px">' +
|
||||
'<span class="indicator ' +
|
||||
color +
|
||||
'">' +
|
||||
balance_label_text +
|
||||
": " +
|
||||
format_currency(info.balance_amount, info.currency) +
|
||||
'">Total Unpaid: ' +
|
||||
format_currency(info.total_unpaid, info.currency) +
|
||||
"</span></div>" +
|
||||
"</div>"
|
||||
).appendTo(frm.dashboard.stats_area_row);
|
||||
|
||||
@@ -16,7 +16,7 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestVATAuditReport(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company SA VAT"
|
||||
make_company("_Test Company SA VAT", "_TCSV")
|
||||
|
||||
create_account(
|
||||
account_name="VAT - 0%",
|
||||
|
||||
@@ -130,7 +130,6 @@
|
||||
"fieldtype": "Data",
|
||||
"in_global_search": 1,
|
||||
"label": "Alias",
|
||||
"no_copy": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
@@ -696,7 +695,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-27 16:12:10.457900",
|
||||
"modified": "2026-06-22 12:23:19.196991",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Customer",
|
||||
|
||||
@@ -403,9 +403,9 @@ class TestQuotation(ERPNextTestSuite):
|
||||
quotation.save()
|
||||
quotation.submit()
|
||||
|
||||
self.assertEqual(quotation.payment_schedule[0].payment_amount, 500.00)
|
||||
self.assertEqual(quotation.payment_schedule[0].payment_amount, 8906.00)
|
||||
self.assertEqual(quotation.payment_schedule[0].due_date, quotation.transaction_date)
|
||||
self.assertEqual(quotation.payment_schedule[1].payment_amount, 500.00)
|
||||
self.assertEqual(quotation.payment_schedule[1].payment_amount, 8906.00)
|
||||
self.assertEqual(quotation.payment_schedule[1].due_date, add_days(quotation.transaction_date, 30))
|
||||
|
||||
sales_order = make_sales_order(quotation.name)
|
||||
@@ -425,11 +425,11 @@ class TestQuotation(ERPNextTestSuite):
|
||||
sales_order.set("taxes", [])
|
||||
sales_order.save()
|
||||
|
||||
self.assertEqual(sales_order.payment_schedule[0].payment_amount, 500.00)
|
||||
self.assertEqual(sales_order.payment_schedule[0].payment_amount, 8906.00)
|
||||
self.assertEqual(
|
||||
getdate(sales_order.payment_schedule[0].due_date), getdate(quotation.transaction_date)
|
||||
)
|
||||
self.assertEqual(sales_order.payment_schedule[1].payment_amount, 500.00)
|
||||
self.assertEqual(sales_order.payment_schedule[1].payment_amount, 8906.00)
|
||||
self.assertEqual(
|
||||
getdate(sales_order.payment_schedule[1].due_date),
|
||||
getdate(add_days(quotation.transaction_date, 30)),
|
||||
@@ -465,13 +465,11 @@ class TestQuotation(ERPNextTestSuite):
|
||||
|
||||
rate_with_margin = flt((1500 * 18.75) / 100 + 1500)
|
||||
|
||||
test_record = frappe.copy_doc(self.globalTestRecords["Quotation"][0])
|
||||
test_record = dict(self.globalTestRecords["Quotation"][0])
|
||||
|
||||
test_record.items[0].price_list_rate = 1500
|
||||
test_record.items[0].margin_type = "Percentage"
|
||||
test_record.items[0].margin_rate_or_amount = 18.75
|
||||
# set rate to zero, so that it is recalculated on save
|
||||
test_record.items[0].rate = 0
|
||||
test_record["items"][0]["price_list_rate"] = 1500
|
||||
test_record["items"][0]["margin_type"] = "Percentage"
|
||||
test_record["items"][0]["margin_rate_or_amount"] = 18.75
|
||||
|
||||
quotation = frappe.copy_doc(test_record)
|
||||
quotation.transaction_date = nowdate()
|
||||
|
||||
@@ -1473,8 +1473,6 @@ class TestSalesOrder(ERPNextTestSuite):
|
||||
so.items[0].price_list_rate = price_list_rate = 100
|
||||
so.items[0].margin_type = "Percentage"
|
||||
so.items[0].margin_rate_or_amount = 25
|
||||
# set rate to zero, so that it is recalculated on save
|
||||
so.items[0].rate = 0
|
||||
so.save()
|
||||
|
||||
new_so = frappe.copy_doc(so)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint, qb
|
||||
from frappe.query_builder import Case, Criterion
|
||||
from frappe.query_builder import Criterion
|
||||
|
||||
from erpnext import get_company_currency
|
||||
|
||||
@@ -155,60 +155,50 @@ def get_columns(filters):
|
||||
|
||||
|
||||
def get_entries(filters):
|
||||
doc_type = filters["doc_type"]
|
||||
date_field = filters["doc_type"] == "Sales Order" and "transaction_date" or "posting_date"
|
||||
if filters["doc_type"] == "Sales Order":
|
||||
qty_field = "delivered_qty"
|
||||
else:
|
||||
qty_field = "qty"
|
||||
conditions, values = get_conditions(filters, date_field)
|
||||
|
||||
date_field = "transaction_date" if doc_type == "Sales Order" else "posting_date"
|
||||
qty_field = "delivered_qty" if doc_type == "Sales Order" else "qty"
|
||||
|
||||
dt = frappe.qb.DocType(doc_type)
|
||||
dt_item = frappe.qb.DocType(f"{doc_type} Item")
|
||||
st = frappe.qb.DocType("Sales Team")
|
||||
|
||||
calc_qty = dt_item[qty_field] * dt_item.conversion_factor
|
||||
calc_net_amount = dt_item.base_net_rate * calc_qty
|
||||
|
||||
stock_qty_case = Case().when(dt.status == "Closed", calc_qty).else_(dt_item.stock_qty).as_("stock_qty")
|
||||
|
||||
base_net_amount_case = (
|
||||
Case()
|
||||
.when(dt.status == "Closed", calc_net_amount)
|
||||
.else_(dt_item.base_net_amount)
|
||||
.as_("base_net_amount")
|
||||
entries = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
dt.name, dt.customer, dt.territory, dt.{} as posting_date, dt_item.item_code,
|
||||
st.sales_person, st.allocated_percentage, dt_item.warehouse,
|
||||
CASE
|
||||
WHEN dt.status = "Closed" THEN dt_item.{} * dt_item.conversion_factor
|
||||
ELSE dt_item.stock_qty
|
||||
END as stock_qty,
|
||||
CASE
|
||||
WHEN dt.status = "Closed" THEN (dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor)
|
||||
ELSE dt_item.base_net_amount
|
||||
END as base_net_amount,
|
||||
CASE
|
||||
WHEN dt.status = "Closed" THEN ((dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor) * st.allocated_percentage/100)
|
||||
ELSE dt_item.base_net_amount * st.allocated_percentage/100
|
||||
END as contribution_amt
|
||||
FROM
|
||||
`tab{}` dt, `tab{} Item` dt_item, `tabSales Team` st
|
||||
WHERE
|
||||
st.parent = dt.name and dt.name = dt_item.parent and st.parenttype = {}
|
||||
and dt.docstatus = 1 {} order by st.sales_person, dt.name desc
|
||||
""".format(
|
||||
date_field,
|
||||
qty_field,
|
||||
qty_field,
|
||||
qty_field,
|
||||
filters["doc_type"],
|
||||
filters["doc_type"],
|
||||
"%s",
|
||||
conditions,
|
||||
),
|
||||
tuple([filters["doc_type"], *values]),
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
contribution_amt_case = (
|
||||
Case()
|
||||
.when(dt.status == "Closed", (calc_net_amount * st.allocated_percentage / 100))
|
||||
.else_(dt_item.base_net_amount * st.allocated_percentage / 100)
|
||||
.as_("contribution_amt")
|
||||
)
|
||||
|
||||
query = (
|
||||
frappe.get_query(dt, filters=filters, ignore_permissions=False)
|
||||
.join(dt_item)
|
||||
.on(dt.name == dt_item.parent)
|
||||
.join(st)
|
||||
.on(dt.name == st.parent)
|
||||
.select(
|
||||
dt.name,
|
||||
dt.customer,
|
||||
dt.territory,
|
||||
dt[date_field].as_("posting_date"),
|
||||
dt_item.item_code,
|
||||
st.sales_person,
|
||||
st.allocated_percentage,
|
||||
dt_item.warehouse,
|
||||
stock_qty_case,
|
||||
base_net_amount_case,
|
||||
contribution_amt_case,
|
||||
)
|
||||
.where(st.parenttype == doc_type)
|
||||
.where(dt.docstatus == 1)
|
||||
)
|
||||
|
||||
query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
return entries
|
||||
|
||||
|
||||
def get_conditions(filters, date_field):
|
||||
|
||||
@@ -214,14 +214,5 @@
|
||||
"doctype": "Company",
|
||||
"chart_of_accounts": "Standard",
|
||||
"create_chart_of_accounts_based_on": "Standard Template"
|
||||
},
|
||||
{
|
||||
"abbr": "TPC",
|
||||
"company_name": "Test PCV Company",
|
||||
"country": "United States",
|
||||
"default_currency": "USD",
|
||||
"doctype": "Company",
|
||||
"chart_of_accounts": "Standard",
|
||||
"create_chart_of_accounts_based_on": "Standard Template"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -318,23 +318,12 @@ class TransactionDeletionRecord(Document):
|
||||
Returns:
|
||||
list: List of child table DocType names (Table field options)
|
||||
"""
|
||||
child_tables = frappe.get_all(
|
||||
return frappe.get_all(
|
||||
"DocField",
|
||||
filters={"parent": doctype_name, "fieldtype": ["in", ["Table", "Table MultiSelect"]]},
|
||||
pluck="options",
|
||||
)
|
||||
|
||||
if not child_tables:
|
||||
return []
|
||||
|
||||
child_tables = frappe.get_all(
|
||||
"DocType",
|
||||
filters={"name": ["in", child_tables], "is_virtual": 0},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
return child_tables
|
||||
|
||||
def _get_to_delete_row_infos(self, doctype_name, company_field=None, company=None):
|
||||
"""Get child tables and document count for a To Delete list row
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ frappe.ui.form.on("Inventory Dimension", {
|
||||
"fetch_from_parent",
|
||||
"type_of_transaction",
|
||||
"condition",
|
||||
"mandatory_depends_on",
|
||||
"validate_negative_stock",
|
||||
];
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"condition",
|
||||
"conditional_mandatory_section",
|
||||
"reqd",
|
||||
"mandatory_depends_on_backend",
|
||||
"mandatory_depends_on",
|
||||
"conditional_rule_examples_section",
|
||||
"html_19"
|
||||
],
|
||||
@@ -151,6 +151,13 @@
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Conditional Rule Examples"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.apply_to_all_doctypes",
|
||||
"description": "To apply condition on parent field use parent.field_name and to apply condition on child table use doc.field_name. Here field_name could be based on the actual column name of the respective field.",
|
||||
"fieldname": "mandatory_depends_on",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Mandatory Depends On"
|
||||
},
|
||||
{
|
||||
"fieldname": "conditional_mandatory_section",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -162,13 +169,6 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "Mandatory"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.reqd",
|
||||
"description": "Python expression evaluated on the server. Use doc.fieldname for the row and parent.fieldname for the parent document. When it evaluates to true the dimension becomes mandatory. Example: doc.t_warehouse and doc.qty > 0",
|
||||
"fieldname": "mandatory_depends_on_backend",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Mandatory Depends On (Backend)"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_niy2u",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -182,7 +182,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-25 11:30:00.000000",
|
||||
"modified": "2026-04-08 10:10:16.884388",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Inventory Dimension",
|
||||
|
||||
@@ -35,7 +35,7 @@ class InventoryDimension(Document):
|
||||
document_type: DF.Link | None
|
||||
fetch_from_parent: DF.Literal[None]
|
||||
istable: DF.Check
|
||||
mandatory_depends_on_backend: DF.SmallText | None
|
||||
mandatory_depends_on: DF.SmallText | None
|
||||
reference_document: DF.Link
|
||||
reqd: DF.Check
|
||||
source_fieldname: DF.Data | None
|
||||
@@ -118,6 +118,7 @@ class InventoryDimension(Document):
|
||||
def reset_value(self):
|
||||
if self.apply_to_all_doctypes:
|
||||
self.type_of_transaction = ""
|
||||
self.mandatory_depends_on = ""
|
||||
|
||||
self.istable = 0
|
||||
for field in ["document_type", "condition"]:
|
||||
@@ -166,10 +167,15 @@ class InventoryDimension(Document):
|
||||
if label_start_with:
|
||||
label = f"{label_start_with} {self.dimension_name}"
|
||||
|
||||
# Note: `reqd` is intentionally NOT set on the custom fields. Mandatory enforcement
|
||||
# happens on the server side via StockController.validate_inventory_dimension_mandatory()
|
||||
# so that it can be gated (e.g. skip service rows) and never blocks documents that are
|
||||
# being cancelled.
|
||||
mandatory_depends_on = self.mandatory_depends_on
|
||||
if self.reqd:
|
||||
if doctype == "Stock Entry Detail":
|
||||
mandatory_depends_on = "eval:doc.s_warehouse"
|
||||
elif doctype == "Subcontracting Receipt Supplied Item":
|
||||
mandatory_depends_on = "eval:doc.reference_name"
|
||||
elif doctype == "Packed Item":
|
||||
mandatory_depends_on = "eval:doc.parent_detail_docname && ['Delivery Note', 'Sales Invoice', 'POS Invoice'].includes(parent.doctype)"
|
||||
|
||||
dimension_fields = [
|
||||
dict(
|
||||
fieldname="inventory_dimension",
|
||||
@@ -186,6 +192,13 @@ class InventoryDimension(Document):
|
||||
label=_(label),
|
||||
depends_on="eval:doc.s_warehouse" if doctype == "Stock Entry Detail" else "",
|
||||
search_index=1,
|
||||
reqd=1
|
||||
if self.reqd
|
||||
and not self.mandatory_depends_on
|
||||
and doctype
|
||||
not in ["Stock Entry Detail", "Subcontracting Receipt Supplied Item", "Packed Item"]
|
||||
else 0,
|
||||
mandatory_depends_on=mandatory_depends_on,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -198,6 +211,7 @@ class InventoryDimension(Document):
|
||||
options=self.reference_document,
|
||||
label=_("Rejected " + self.dimension_name),
|
||||
search_index=1,
|
||||
mandatory_depends_on="eval:doc.rejected_qty > 0",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -224,7 +238,9 @@ class InventoryDimension(Document):
|
||||
and not frappe.db.get_value("Custom Field", {"dt": dt, "fieldname": self.target_fieldname})
|
||||
and not field_exists(dt, self.target_fieldname)
|
||||
):
|
||||
dimension_field = dimension_fields[1].copy()
|
||||
dimension_field = dimension_fields[1]
|
||||
dimension_field["mandatory_depends_on"] = ""
|
||||
dimension_field["reqd"] = 0
|
||||
dimension_field["fieldname"] = self.target_fieldname
|
||||
custom_fields[dt] = dimension_field
|
||||
|
||||
@@ -293,6 +309,7 @@ class InventoryDimension(Document):
|
||||
options=self.reference_document,
|
||||
label=label,
|
||||
depends_on=display_depends_on,
|
||||
mandatory_depends_on=display_depends_on if self.reqd else self.mandatory_depends_on,
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -373,101 +390,6 @@ def get_document_wise_inventory_dimensions(doctype) -> dict:
|
||||
)
|
||||
|
||||
|
||||
@request_cache
|
||||
def get_mandatory_inventory_dimensions(doctype) -> list:
|
||||
"""Return the inventory dimensions applicable to `doctype` (a child doctype such as
|
||||
`Stock Entry Detail`) that need server-side mandatory enforcement.
|
||||
|
||||
A dimension qualifies only if it is configured as mandatory (`reqd`) or has a server-side
|
||||
mandatory condition (`mandatory_depends_on_backend`). Non-mandatory dimensions are never
|
||||
enforced, including the rejected dimension field on purchase rows."""
|
||||
dimensions = frappe.get_all(
|
||||
"Inventory Dimension",
|
||||
fields=[
|
||||
"name",
|
||||
"dimension_name",
|
||||
"source_fieldname",
|
||||
"reqd",
|
||||
"mandatory_depends_on_backend",
|
||||
],
|
||||
or_filters={"document_type": doctype, "apply_to_all_doctypes": 1},
|
||||
)
|
||||
|
||||
return [d for d in dimensions if d.reqd or d.mandatory_depends_on_backend]
|
||||
|
||||
|
||||
def get_mandatory_dimension_fields(doctype, dimension) -> list:
|
||||
"""For a mandatory `dimension` return the list of (fieldname, condition) tuples that must be
|
||||
filled on a row of `doctype`. `condition` is a python expression evaluated with `doc` (the row)
|
||||
and `parent`; a `None` condition means the field is unconditionally mandatory.
|
||||
|
||||
Mirrors the mandatory logic that used to live on the custom fields in `get_dimension_fields`."""
|
||||
fields = []
|
||||
source_fieldname = dimension.source_fieldname
|
||||
# `mandatory_depends_on_backend` is a raw python expression evaluated server-side
|
||||
# (with `doc` and `parent`), so it can be used as a condition directly.
|
||||
backend_condition = (dimension.mandatory_depends_on_backend or "").strip() or None
|
||||
|
||||
# Primary source dimension field
|
||||
if dimension.reqd and doctype == "Stock Entry Detail":
|
||||
fields.append((source_fieldname, "doc.s_warehouse"))
|
||||
elif dimension.reqd and doctype == "Subcontracting Receipt Supplied Item":
|
||||
fields.append((source_fieldname, "doc.reference_name"))
|
||||
elif dimension.reqd and doctype == "Packed Item":
|
||||
fields.append(
|
||||
(
|
||||
source_fieldname,
|
||||
"doc.parent_detail_docname and parent.doctype in ['Delivery Note', 'Sales Invoice', 'POS Invoice']",
|
||||
)
|
||||
)
|
||||
elif dimension.reqd:
|
||||
fields.append((source_fieldname, None))
|
||||
elif backend_condition:
|
||||
fields.append((source_fieldname, backend_condition))
|
||||
|
||||
# Rejected dimension field (only present on purchase rows). Enforced only when the dimension
|
||||
# is mandatory for the row AND there is a rejected quantity.
|
||||
if doctype in ["Purchase Invoice Item", "Purchase Receipt Item"]:
|
||||
if dimension.reqd:
|
||||
fields.append((f"rejected_{source_fieldname}", "doc.rejected_qty > 0"))
|
||||
elif backend_condition:
|
||||
fields.append((f"rejected_{source_fieldname}", f"({backend_condition}) and doc.rejected_qty > 0"))
|
||||
|
||||
# Target/transfer dimension field used for internal transfers (mirrors the old
|
||||
# `add_transfer_field` behaviour). When the dimension is `reqd` the field inherits the
|
||||
# transfer display condition, otherwise it inherits the server-side mandatory condition.
|
||||
if (dimension.reqd or backend_condition) and doctype in [
|
||||
"Stock Entry Detail",
|
||||
"Sales Invoice Item",
|
||||
"Delivery Note Item",
|
||||
"Purchase Invoice Item",
|
||||
"Purchase Receipt Item",
|
||||
]:
|
||||
if doctype in ["Purchase Invoice Item", "Purchase Receipt Item"]:
|
||||
transfer_fieldname, display_condition = (
|
||||
f"from_{source_fieldname}",
|
||||
"parent.is_internal_supplier == 1",
|
||||
)
|
||||
elif doctype == "Stock Entry Detail":
|
||||
transfer_fieldname, display_condition = f"to_{source_fieldname}", "doc.t_warehouse"
|
||||
else:
|
||||
transfer_fieldname, display_condition = (
|
||||
f"to_{source_fieldname}",
|
||||
"parent.is_internal_customer == 1",
|
||||
)
|
||||
|
||||
# The transfer field only applies to internal transfers, so its mandatory check is always
|
||||
# gated on the display condition; the backend condition narrows it further.
|
||||
if dimension.reqd:
|
||||
transfer_condition = display_condition
|
||||
else:
|
||||
transfer_condition = f"({display_condition}) and ({backend_condition})"
|
||||
|
||||
fields.append((transfer_fieldname, transfer_condition))
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@request_cache
|
||||
def get_inventory_dimensions():
|
||||
|
||||
@@ -219,75 +219,35 @@ class TestInventoryDimension(ERPNextTestSuite):
|
||||
doc.reqd = 1
|
||||
doc.save()
|
||||
|
||||
# Mandatory enforcement is now done server-side, so the custom field must NOT be `reqd`.
|
||||
self.assertFalse(
|
||||
self.assertTrue(
|
||||
frappe.db.get_value(
|
||||
"Custom Field", {"fieldname": "pallet_75", "dt": "Delivery Note Item"}, "reqd"
|
||||
"Custom Field", {"fieldname": "pallet_75", "dt": "Delivery Note Item", "reqd": 1}, "name"
|
||||
)
|
||||
)
|
||||
|
||||
item_code = "Test Mandatory Dimension Item"
|
||||
create_item(item_code)
|
||||
warehouse = create_warehouse("Mandatory Dimension Warehouse")
|
||||
|
||||
dn_doc = create_delivery_note(item_code=item_code, qty=5, warehouse=warehouse, do_not_save=True)
|
||||
|
||||
# Dimension value missing -> server-side validation should block the document.
|
||||
self.assertRaises(frappe.ValidationError, dn_doc.save)
|
||||
|
||||
if not frappe.db.exists("Pallet", "Pallet 75 Value"):
|
||||
frappe.get_doc({"doctype": "Pallet", "pallet_name": "Pallet 75 Value"}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
|
||||
dn_doc.items[0].pallet_75 = "Pallet 75 Value"
|
||||
dn_doc.save()
|
||||
|
||||
doc.reqd = 0
|
||||
doc.save()
|
||||
|
||||
def test_check_mandatory_depends_on_backend(self):
|
||||
def test_check_mandatory_depends_on_dimensions(self):
|
||||
doc = create_inventory_dimension(
|
||||
reference_document="Pallet",
|
||||
type_of_transaction="Outward",
|
||||
dimension_name="Pallet Backend",
|
||||
dimension_name="Pallet",
|
||||
apply_to_all_doctypes=0,
|
||||
document_type="Delivery Note Item",
|
||||
document_type="Stock Entry Detail",
|
||||
)
|
||||
|
||||
doc.reqd = 0
|
||||
doc.mandatory_depends_on_backend = "doc.qty > 0"
|
||||
doc.mandatory_depends_on = "t_warehouse"
|
||||
doc.save()
|
||||
|
||||
# The condition is enforced server-side, the custom field must not carry field-level `reqd`.
|
||||
self.assertFalse(
|
||||
self.assertTrue(
|
||||
frappe.db.get_value(
|
||||
"Custom Field", {"fieldname": "pallet_backend", "dt": "Delivery Note Item"}, "reqd"
|
||||
"Custom Field",
|
||||
{"fieldname": "pallet", "dt": "Stock Entry Detail", "mandatory_depends_on": "t_warehouse"},
|
||||
"name",
|
||||
)
|
||||
)
|
||||
|
||||
item_code = "Test Backend Dimension Item"
|
||||
create_item(item_code)
|
||||
warehouse = create_warehouse("Backend Dimension Warehouse")
|
||||
|
||||
dn_doc = create_delivery_note(item_code=item_code, qty=5, warehouse=warehouse, do_not_save=True)
|
||||
|
||||
# qty > 0 -> backend condition is met, so the dimension is mandatory and blocks the save.
|
||||
self.assertRaises(frappe.ValidationError, dn_doc.save)
|
||||
|
||||
if not frappe.db.exists("Pallet", "Pallet Backend Value"):
|
||||
frappe.get_doc({"doctype": "Pallet", "pallet_name": "Pallet Backend Value"}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
|
||||
dn_doc.items[0].pallet_backend = "Pallet Backend Value"
|
||||
dn_doc.save()
|
||||
|
||||
# Reset so the always-true condition does not make the dimension mandatory for
|
||||
# subsequent Delivery Note tests sharing the same test database.
|
||||
doc.mandatory_depends_on_backend = ""
|
||||
doc.save()
|
||||
|
||||
def test_for_purchase_sales_and_stock_transaction(self):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:parameter",
|
||||
"creation": "2020-12-28 17:06:00.254129",
|
||||
"doctype": "DocType",
|
||||
@@ -35,7 +34,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-19 10:55:00.000000",
|
||||
"modified": "2024-03-27 13:10:28.861722",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Quality Inspection Parameter",
|
||||
|
||||
@@ -93,7 +93,7 @@ class RepostItemValuation(Document):
|
||||
self.validate_recreate_stock_ledgers()
|
||||
|
||||
def set_default_posting_time(self):
|
||||
if self.posting_time is None:
|
||||
if not self.posting_time:
|
||||
self.posting_time = nowtime()
|
||||
|
||||
if not self.posting_date:
|
||||
@@ -346,9 +346,6 @@ class RepostItemValuation(Document):
|
||||
|
||||
def _recalculate_valuation_rate(self):
|
||||
doc = frappe.get_doc(self.voucher_type, self.voucher_no)
|
||||
if doc.get("is_internal_supplier"):
|
||||
doc.set_sales_incoming_rate_for_internal_transfer()
|
||||
|
||||
doc.update_valuation_rate()
|
||||
for item in doc.items:
|
||||
item.db_set("valuation_rate", item.valuation_rate)
|
||||
|
||||
@@ -292,9 +292,6 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
self.validate_putaway_capacity()
|
||||
self.validate_component_and_quantities()
|
||||
self.validate_finished_good_serial_batch_for_work_order()
|
||||
# Stock Entry overrides validate() without calling super(), so the shared mandatory
|
||||
# inventory dimension check must be invoked explicitly here.
|
||||
self.validate_inventory_dimension_mandatory()
|
||||
|
||||
if self.get("purpose") != "Manufacture":
|
||||
# ignore other item wh difference and empty source/target wh
|
||||
|
||||
@@ -1244,107 +1244,6 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin):
|
||||
self.assertEqual(sle[0].qty_after_transaction, 105)
|
||||
self.assertEqual(sle[0].actual_qty, 100)
|
||||
|
||||
def test_update_qty_in_future_sle_shifts_same_timestamp_later_entry(self):
|
||||
# update_qty_in_future_sle treats "future" as strictly after the current entry in the
|
||||
# (posting_datetime, creation) order. An entry sharing the exact posting timestamp but created
|
||||
# later must still have its running balance shifted; comparing posting_datetime alone would skip
|
||||
# it. The current entry itself (same timestamp, same creation) must not be shifted.
|
||||
from erpnext.stock.stock_ledger import update_qty_in_future_sle
|
||||
|
||||
item = make_item().name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
receipt1 = make_purchase_receipt(
|
||||
item_code=item,
|
||||
warehouse=warehouse,
|
||||
qty=10,
|
||||
rate=10,
|
||||
posting_date="2021-01-01",
|
||||
posting_time="02:00:00",
|
||||
)
|
||||
time.sleep(1)
|
||||
receipt2 = make_purchase_receipt(
|
||||
item_code=item,
|
||||
warehouse=warehouse,
|
||||
qty=20,
|
||||
rate=10,
|
||||
posting_date="2021-01-01",
|
||||
posting_time="02:00:00", # identical timestamp, later creation
|
||||
)
|
||||
|
||||
def sle(voucher):
|
||||
return frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": voucher.name, "is_cancelled": 0},
|
||||
["name", "posting_date", "posting_time", "creation", "qty_after_transaction"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
sle1, sle2 = sle(receipt1), sle(receipt2)
|
||||
self.assertEqual(sle1.qty_after_transaction, 10)
|
||||
self.assertEqual(sle2.qty_after_transaction, 30)
|
||||
|
||||
# Simulate a +5 qty shift originating at receipt1's ledger position.
|
||||
args = frappe._dict(
|
||||
{
|
||||
"item_code": item,
|
||||
"warehouse": warehouse,
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": receipt1.name,
|
||||
"posting_date": sle1.posting_date,
|
||||
"posting_time": sle1.posting_time,
|
||||
"creation": sle1.creation,
|
||||
"actual_qty": 5,
|
||||
}
|
||||
)
|
||||
update_qty_in_future_sle(args, allow_negative_stock=True)
|
||||
|
||||
# receipt2 (same timestamp, later creation) is shifted; receipt1 (the current entry) is not.
|
||||
self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle2.name, "qty_after_transaction"), 35)
|
||||
self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle1.name, "qty_after_transaction"), 10)
|
||||
|
||||
def test_get_next_stock_reco_respects_creation_order(self):
|
||||
# A stock reco sharing the exact posting timestamp of the current entry must only count as the
|
||||
# "next" reco when it was created after that entry. A reco created before it actually precedes
|
||||
# the entry and must not bound (truncate) the qty-shift range.
|
||||
from erpnext.stock.stock_ledger import get_next_stock_reco
|
||||
|
||||
item = make_item().name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item,
|
||||
warehouse=warehouse,
|
||||
qty=10,
|
||||
rate=100,
|
||||
posting_date="2021-01-01",
|
||||
posting_time="02:00:00",
|
||||
)
|
||||
reco_sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": reco.name, "is_cancelled": 0},
|
||||
["posting_date", "posting_time", "creation"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
base_kwargs = {
|
||||
"item_code": item,
|
||||
"warehouse": warehouse,
|
||||
"voucher_no": "SOME-OTHER-VOUCHER",
|
||||
"posting_date": reco_sle.posting_date,
|
||||
"posting_time": reco_sle.posting_time,
|
||||
}
|
||||
|
||||
# Current entry created AFTER the reco at the same timestamp -> reco precedes it -> not returned.
|
||||
after = {**base_kwargs, "creation": add_to_date(reco_sle.creation, seconds=5)}
|
||||
self.assertFalse(get_next_stock_reco(after))
|
||||
|
||||
# Current entry created BEFORE the reco at the same timestamp -> reco follows it -> returned.
|
||||
before = {**base_kwargs, "creation": add_to_date(reco_sle.creation, seconds=-5)}
|
||||
result = get_next_stock_reco(before)
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(result[0].voucher_no, reco.name)
|
||||
|
||||
@ERPNextTestSuite.change_settings("System Settings", {"float_precision": 3, "currency_precision": 2})
|
||||
def test_transfer_invariants(self):
|
||||
"""Extact stock value should be transferred."""
|
||||
|
||||
@@ -83,9 +83,6 @@ class StockReconciliation(StockController):
|
||||
self.set_total_qty_and_amount()
|
||||
self.validate_putaway_capacity()
|
||||
self.validate_inventory_dimension()
|
||||
# Stock Reconciliation overrides validate() without calling super(), so the shared
|
||||
# mandatory inventory dimension check must be invoked explicitly here.
|
||||
self.validate_inventory_dimension_mandatory()
|
||||
self.validate_uom_is_integer("stock_uom", "qty")
|
||||
|
||||
if self._action == "submit":
|
||||
@@ -1008,102 +1005,6 @@ class StockReconciliation(StockController):
|
||||
d.quantity_difference = flt(d.qty) - flt(d.current_qty)
|
||||
d.amount_difference = flt(d.amount) - flt(d.current_amount)
|
||||
|
||||
def recalculate_difference_amount_from_ledger(self):
|
||||
"""Sync the displayed current qty/rate and difference amount with the (reposted) ledger.
|
||||
|
||||
Submitted reconciliations freeze ``difference_amount`` and the per-row current values at
|
||||
submit time, but reposting/backdated transactions recompute the reconciliation's Stock Ledger
|
||||
Entries and rebuild the GL from them. Without this sync the document keeps showing stale figures
|
||||
that no longer match the GL entries. Anchoring ``amount_difference`` to the row's summed
|
||||
``stock_value_difference`` keeps the document and the GL consistent by construction.
|
||||
"""
|
||||
difference_amount = 0.0
|
||||
|
||||
for row in self.items:
|
||||
stock_value_difference = flt(get_row_stock_value_difference(self.doctype, self.name, row.name))
|
||||
|
||||
amount = flt(flt(row.qty) * flt(row.valuation_rate), row.precision("amount"))
|
||||
amount_difference = flt(stock_value_difference, row.precision("amount_difference"))
|
||||
current_amount = flt(amount - amount_difference, row.precision("current_amount"))
|
||||
|
||||
current_qty = self.get_current_qty_from_ledger(row)
|
||||
current_valuation_rate = (
|
||||
flt(current_amount / current_qty, row.precision("current_valuation_rate"))
|
||||
if current_qty
|
||||
else 0.0
|
||||
)
|
||||
|
||||
row.db_set(
|
||||
{
|
||||
"amount": amount,
|
||||
"current_qty": current_qty,
|
||||
"current_valuation_rate": current_valuation_rate,
|
||||
"current_amount": current_amount,
|
||||
"quantity_difference": flt(row.qty) - current_qty,
|
||||
"amount_difference": amount_difference,
|
||||
},
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
difference_amount += amount_difference
|
||||
|
||||
self.db_set(
|
||||
"difference_amount",
|
||||
flt(difference_amount, self.precision("difference_amount")),
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
def get_current_qty_from_ledger(self, row: StockReconciliationItem):
|
||||
"""Current (pre-reconciliation) qty for a row, recomputed from the ledger after reposting.
|
||||
|
||||
Serial/batch rows cannot have backdated qty changes inserted before a future reconciliation
|
||||
(blocked by ``check_future_entries_exists``), so their current qty is frozen and read straight
|
||||
from the current bundle. Non-serial rows can float, so read the ledger balance just before the
|
||||
reconciliation, excluding the reconciliation's own entries.
|
||||
"""
|
||||
if row.current_serial_and_batch_bundle:
|
||||
total_qty = frappe.db.get_value(
|
||||
"Serial and Batch Bundle", row.current_serial_and_batch_bundle, "total_qty"
|
||||
)
|
||||
return abs(flt(total_qty, row.precision("current_qty")))
|
||||
|
||||
reco_sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_type": self.doctype,
|
||||
"voucher_no": self.name,
|
||||
"voucher_detail_no": row.name,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
["posting_datetime", "creation"],
|
||||
as_dict=True,
|
||||
)
|
||||
if not reco_sle:
|
||||
return flt(row.current_qty, row.precision("current_qty"))
|
||||
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
previous_sle = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(sle.qty_after_transaction)
|
||||
.where(
|
||||
(sle.item_code == row.item_code)
|
||||
& (sle.warehouse == row.warehouse)
|
||||
& (sle.is_cancelled == 0)
|
||||
& (
|
||||
(sle.posting_datetime < reco_sle.posting_datetime)
|
||||
| (
|
||||
(sle.posting_datetime == reco_sle.posting_datetime)
|
||||
& (sle.creation < reco_sle.creation)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderby(sle.posting_datetime, order=frappe.qb.desc)
|
||||
.orderby(sle.creation, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
).run()
|
||||
|
||||
return flt(previous_sle[0][0], row.precision("current_qty")) if previous_sle else 0.0
|
||||
|
||||
def submit(self):
|
||||
if len(self.items) > 100:
|
||||
msgprint(
|
||||
@@ -1290,23 +1191,6 @@ def get_itemwise_batch(warehouse, posting_date, company, item_code=None):
|
||||
return itemwise_batch_data
|
||||
|
||||
|
||||
def get_row_stock_value_difference(voucher_type: str, voucher_no: str, voucher_detail_no: str):
|
||||
"""Net stock value change posted to the GL by a reconciliation row (sum of its SLEs)."""
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
result = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(Sum(sle.stock_value_difference))
|
||||
.where(
|
||||
(sle.voucher_type == voucher_type)
|
||||
& (sle.voucher_no == voucher_no)
|
||||
& (sle.voucher_detail_no == voucher_detail_no)
|
||||
& (sle.is_cancelled == 0)
|
||||
)
|
||||
).run()
|
||||
|
||||
return flt(result[0][0]) if result and result[0][0] else 0.0
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_stock_balance_for(
|
||||
item_code: str,
|
||||
|
||||
@@ -787,172 +787,6 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
sr1.load_from_db()
|
||||
self.assertEqual(sr1.difference_amount, 10000)
|
||||
|
||||
def assert_reco_difference_matches_gl(self, reco_name):
|
||||
"""The displayed Difference Amount (doc and per-row) must equal the reposted GL impact,
|
||||
i.e. the sum of the reconciliation's Stock Ledger Entry ``stock_value_difference``."""
|
||||
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
|
||||
get_row_stock_value_difference,
|
||||
)
|
||||
|
||||
reco = frappe.get_doc("Stock Reconciliation", reco_name)
|
||||
total_difference = 0.0
|
||||
|
||||
for row in reco.items:
|
||||
row_difference = flt(
|
||||
get_row_stock_value_difference("Stock Reconciliation", reco_name, row.name),
|
||||
row.precision("amount_difference"),
|
||||
)
|
||||
|
||||
self.assertEqual(flt(row.amount_difference), row_difference)
|
||||
total_difference += row_difference
|
||||
|
||||
self.assertEqual(
|
||||
flt(reco.difference_amount, reco.precision("difference_amount")),
|
||||
flt(total_difference, reco.precision("difference_amount")),
|
||||
)
|
||||
|
||||
def test_difference_amount_synced_with_gl_after_repost_non_serialized(self):
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
|
||||
item_code = self.make_item().name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
# Opening stock => 100 * 100 = 10000
|
||||
make_stock_entry(
|
||||
item_code=item_code,
|
||||
target=warehouse,
|
||||
qty=100,
|
||||
basic_rate=100,
|
||||
posting_date=add_days(nowdate(), -5),
|
||||
posting_time="10:00:00",
|
||||
)
|
||||
|
||||
# Reconcile to 100 @ 200 => difference 20000 - 10000 = 10000
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=100,
|
||||
rate=200,
|
||||
posting_date=add_days(nowdate(), -2),
|
||||
)
|
||||
self.assertEqual(reco.difference_amount, 10000)
|
||||
self.assert_reco_difference_matches_gl(reco.name)
|
||||
|
||||
# Backdated reconciliation lowers the pre-reco stock value to 50 * 50 = 2500
|
||||
create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=50,
|
||||
rate=50,
|
||||
posting_date=add_days(nowdate(), -3),
|
||||
)
|
||||
|
||||
reco.load_from_db()
|
||||
# Current is now 2500 => difference 20000 - 2500 = 17500
|
||||
self.assertEqual(reco.difference_amount, 17500)
|
||||
self.assert_reco_difference_matches_gl(reco.name)
|
||||
|
||||
def test_difference_amount_synced_with_gl_after_repost_batched(self):
|
||||
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
|
||||
make_landed_cost_voucher,
|
||||
)
|
||||
|
||||
item_code = self.make_item(
|
||||
"Test Batch Item Reco Difference Sync",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TEST-BATCH-DIFFSYNC-.###",
|
||||
},
|
||||
).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
# Receive 10 @ 100 (batch value 1000)
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=10,
|
||||
rate=100,
|
||||
posting_date=add_days(nowdate(), -5),
|
||||
)
|
||||
batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle)
|
||||
|
||||
# Reconcile the batch to 10 @ 500 => difference 5000 - 1000 = 4000
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=10,
|
||||
rate=500,
|
||||
batch_no=batch_no,
|
||||
use_serial_batch_fields=1,
|
||||
posting_date=add_days(nowdate(), -2),
|
||||
)
|
||||
difference_on_submit = reco.difference_amount
|
||||
self.assert_reco_difference_matches_gl(reco.name)
|
||||
|
||||
# Landed cost retroactively raises the receipt (and batch) valuation, reposting the reco
|
||||
make_landed_cost_voucher(
|
||||
receipt_document_type="Purchase Receipt",
|
||||
receipt_document=pr.name,
|
||||
charges=1000,
|
||||
company="_Test Company",
|
||||
)
|
||||
|
||||
reco.load_from_db()
|
||||
self.assertNotEqual(reco.difference_amount, difference_on_submit)
|
||||
self.assert_reco_difference_matches_gl(reco.name)
|
||||
|
||||
def test_difference_amount_synced_with_gl_after_repost_serialized(self):
|
||||
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
|
||||
make_landed_cost_voucher,
|
||||
)
|
||||
|
||||
item_code = self.make_item(
|
||||
"Test Serial Item Reco Difference Sync",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_serial_no": 1,
|
||||
"serial_no_series": "TSIRDS.####",
|
||||
},
|
||||
).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
# Receive 5 serial nos @ 100 (value 500)
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=5,
|
||||
rate=100,
|
||||
posting_date=add_days(nowdate(), -5),
|
||||
)
|
||||
serial_nos = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle)
|
||||
|
||||
# Reconcile the serial nos to 5 @ 500 => difference 2500 - 500 = 2000
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=5,
|
||||
rate=500,
|
||||
serial_no="\n".join(serial_nos),
|
||||
use_serial_batch_fields=1,
|
||||
posting_date=add_days(nowdate(), -2),
|
||||
)
|
||||
difference_on_submit = reco.difference_amount
|
||||
self.assert_reco_difference_matches_gl(reco.name)
|
||||
|
||||
# Landed cost retroactively raises the receipt (and serial) valuation, reposting the reco
|
||||
make_landed_cost_voucher(
|
||||
receipt_document_type="Purchase Receipt",
|
||||
receipt_document=pr.name,
|
||||
charges=1000,
|
||||
company="_Test Company",
|
||||
)
|
||||
|
||||
reco.load_from_db()
|
||||
self.assertNotEqual(reco.difference_amount, difference_on_submit)
|
||||
self.assert_reco_difference_matches_gl(reco.name)
|
||||
|
||||
def test_make_stock_zero_for_serial_batch_item(self):
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user