From 8fd08136141d545f1fcfd53accb499b80e8bb5b2 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 10:39:44 +0530 Subject: [PATCH 001/101] ci(postgres): scheduled fan-out Postgres CI (daily 3am IST + 'postgres' label gate) --- .github/helper/hydrate.sh | 74 ++++ .github/helper/install.sh | 367 +++++++++++++++++--- .github/helper/start-db.sh | 75 ++++ .github/workflows/server-tests-postgres.yml | 260 +++++++------- 4 files changed, 592 insertions(+), 184 deletions(-) create mode 100755 .github/helper/hydrate.sh create mode 100755 .github/helper/start-db.sh diff --git a/.github/helper/hydrate.sh b/.github/helper/hydrate.sh new file mode 100755 index 00000000000..4619d63dbeb --- /dev/null +++ b/.github/helper/hydrate.sh @@ -0,0 +1,74 @@ +#!/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 and restore the dump the setup job +# baked into the bench, then start bench so tests can run. Mirrors the DB + bench-start tail of +# install.sh. 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}" +dump="${CI_BASELINE_BACKUP:-/home/$ci_user/frappe-bench/test_site-db.sql.gz}" + +# 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' CI_BASELINE_BACKUP='$dump' 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." diff --git a/.github/helper/install.sh b/.github/helper/install.sh index b5f9b9e364b..74330abca16 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -7,21 +7,106 @@ cd ~ || exit githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}} frappeuser=${FRAPPE_USER:-"frappe"} frappecommitish=${FRAPPE_BRANCH:-$githubbranch} +db_host=${DB_HOST:-"127.0.0.1"} +db_user_host=${DB_USER_HOST:-"localhost"} +wkhtmltox_deb=${WKHTMLTOX_DEB:-"/tmp/wkhtmltox.deb"} +bench_cache_dir=${BENCH_CACHE_DIR:-} + +run_as_ci_user_if_needed() { + if [ "$(id -u)" != "0" ] || [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ] || [ "${ERPNEXT_CI_NON_ROOT:-0}" = "1" ]; then + return + fi + + local missing_packages=() + if ! command -v pkg-config >/dev/null 2>&1; then + missing_packages+=("pkg-config") + fi + if ! command -v mariadb_config >/dev/null 2>&1 && ! command -v mysql_config >/dev/null 2>&1; then + missing_packages+=("libmariadb-dev") + fi + if ! command -v crontab >/dev/null 2>&1; then + missing_packages+=("cron") + fi + + if [ "${#missing_packages[@]}" -gt 0 ]; then + apt-get update + apt-get install -y --no-install-recommends "${missing_packages[@]}" + fi + + local ci_user="${ERPNEXT_CI_USER:-frappe}" + + if ! id "$ci_user" >/dev/null 2>&1; then + useradd --home-dir "$HOME" --no-create-home --shell /bin/bash "$ci_user" + fi + + rm -rf ~/frappe ~/frappe-bench + + local ci_dirs=( + "$HOME" + "$GITHUB_WORKSPACE" + "$HOME/.cache" + "${PIP_CACHE_DIR:-$HOME/.cache/pip}" + "${npm_config_cache:-$HOME/.npm}" + "${YARN_CACHE_FOLDER:-$HOME/.cache/yarn}" + "$HOME/.yarn" + "${UV_CACHE_DIR:-$HOME/.cache/uv}" + "$(dirname "$wkhtmltox_deb")" + ) + if [ -n "$bench_cache_dir" ]; then + ci_dirs+=("$bench_cache_dir") + fi + + # Create + own (non-recursively) the home/cache/workspace dirs before dropping to + # the ci user. We deliberately do NOT wipe the yarn/uv caches here so a persistent + # cache (mounted volume or baked image layer) stays warm across runs. + mkdir -p "${ci_dirs[@]}" "$HOME/.yarn" + chown "$ci_user:$ci_user" "${ci_dirs[@]}" "$HOME/.yarn" + + export ERPNEXT_CI_NON_ROOT=1 + exec su -m "$ci_user" -s /bin/bash -c "cd '$HOME' && bash '$GITHUB_WORKSPACE/.github/helper/install.sh'" +} + +run_as_ci_user_if_needed + +run_ci_step() { + local label=$1 + shift + + echo "::group::${label}" + date -u + timeout --foreground "${CI_INSTALL_STEP_TIMEOUT:-600}" "$@" + local exit_code=$? + date -u + echo "::endgroup::" + return "$exit_code" +} + +if [ -n "${GITHUB_WORKSPACE:-}" ]; then + git config --global --add safe.directory "$GITHUB_WORKSPACE" || true + git config --global --add safe.directory "$GITHUB_WORKSPACE/.git" || true +fi + +rm -rf ~/frappe ~/frappe-bench # --------------------------------------------------------------------------- # Phase 1 — parallelise the three slow, independent setup steps: # a) system packages b) frappe-bench pip install c) frappe git fetch # --------------------------------------------------------------------------- -sudo apt update +if [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ]; then + sudo apt-get update -# apt remove/install must run sequentially but can overlap with pip and git. -sudo apt remove mysql-server mysql-client -sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev & -apt_pid=$! + # apt remove/install must run sequentially but can overlap with pip and git. + sudo apt-get remove -y mysql-server mysql-client + sudo apt-get install -y libcups2-dev redis-server mariadb-client libmariadb-dev & + apt_pid=$! -pip install frappe-bench & -pip_pid=$! + pip install frappe-bench & + pip_pid=$! +else + apt_pid= + pip_pid= +fi mkdir frappe ( @@ -32,84 +117,264 @@ mkdir frappe ) & clone_pid=$! -wait $apt_pid -wait $pip_pid +if [ -n "$apt_pid" ]; then wait $apt_pid; fi +if [ -n "$pip_pid" ]; then wait $pip_pid; fi wait $clone_pid pushd frappe git checkout FETCH_HEAD popd +frappe_sha=$(git -C frappe rev-parse HEAD) + +get_bench_cache_archive() { + if [ -z "$bench_cache_dir" ]; then + return + fi + + mkdir -p "$bench_cache_dir" + + # Keyed on tool versions only (NOT the frappe SHA): any recent base bench works, because + # restore_warm_bench fast-forwards it to the exact live develop SHA. This is what lets a + # constantly-moving develop still hit the cache. + local cache_key + cache_key=$( + { + uname -m + python --version + node --version + bench --version + } | sha256sum | awk '{print $1}' + ) + + echo "${bench_cache_dir}/frappe-bench-base-${cache_key}.tar.zst" +} + +restore_warm_bench() { + bench_cache_archive=$(get_bench_cache_archive) + [ -n "$bench_cache_archive" ] && [ -f "$bench_cache_archive" ] || return 1 + + echo "Restoring base bench from ${bench_cache_archive}" + tar --use-compress-program=unzstd -xf "$bench_cache_archive" -C ~ || return 1 + [ -d ~/frappe-bench/apps/frappe/.git ] || return 1 + mkdir -p ~/frappe-bench/sites ~/frappe-bench/logs + [ -f ~/frappe-bench/sites/apps.txt ] || printf "frappe\n" > ~/frappe-bench/sites/apps.txt + [ -f ~/frappe-bench/sites/common_site_config.json ] || printf "{}\n" > ~/frappe-bench/sites/common_site_config.json + + # Fast-forward the restored frappe to the EXACT live develop SHA fetched in phase 1, then + # rebuild only what changed. The editable install means the venv tracks the new code with + # no reinstall. Any failure returns non-zero so the caller falls back to a full bench init. + if ! ( + cd ~/frappe-bench/apps/frappe || exit 1 + # Phase 1 already fetched ~/frappe to the exact live develop SHA. Fetch that commit + # straight from it (bench init names the remote 'upstream', not 'origin', and points + # it at this local clone — so a plain `git fetch origin` does not work). + git fetch --no-tags "$HOME/frappe" HEAD || exit 1 + git checkout --force FETCH_HEAD || exit 1 + ); then + echo "Fast-forward to ${frappe_sha} failed; falling back to full init" + rm -rf ~/frappe-bench + return 1 + fi + + # Pick up any frappe dependency changes since the base was built (cached → fast if none), + # so a develop commit that bumped requirements doesn't leave a stale venv. + if ! ~/frappe-bench/env/bin/python -m pip install -q -e ~/frappe-bench/apps/frappe; then + echo "frappe dependency refresh failed; falling back to full init" + rm -rf ~/frappe-bench + return 1 + fi + + ( cd ~/frappe-bench && CI=Yes bench build --app frappe ) || { rm -rf ~/frappe-bench; return 1; } + return 0 +} + +save_warm_bench() { + if [ -z "${bench_cache_archive:-}" ] || [ -f "$bench_cache_archive" ]; then + return + fi + + if [ -n "$bench_cache_dir" ] && [ ! -w "$bench_cache_dir" ]; then + echo "Skipping warm bench save because ${bench_cache_dir} is not writable" + return + fi + + local tmp_archive + tmp_archive="${bench_cache_archive}.${$}.tmp" + + echo "Saving warm bench to ${bench_cache_archive}" + # Keep sites/common_site_config.json (the redis ports live there — dropping it makes the + # restore path fall back to a default redis port that bench start never bound, so reinstall + # fails with "redis ... connection refused"). Only the rebuildable sites/assets is excluded; + # restore_warm_bench runs `bench build` to regenerate it. + tar \ + --use-compress-program="zstd -T0 -3" \ + --exclude="frappe-bench/logs" \ + --exclude="frappe-bench/sites/assets" \ + -cf "$tmp_archive" \ + -C ~ frappe-bench + mv "$tmp_archive" "$bench_cache_archive" +} # --------------------------------------------------------------------------- # Phase 2 — bench init and site setup # --------------------------------------------------------------------------- -bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench +install_whktml() { + # Re-use the .deb if the wkhtmltopdf cache step already restored it. + if [ ! -f "$wkhtmltox_deb" ]; then + wget -O "$wkhtmltox_deb" https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb + fi + sudo apt-get install -y "$wkhtmltox_deb" +} +if [ "${SKIP_WKHTMLTOX_SETUP:-0}" != "1" ]; then + install_whktml & + wkpid=$! +else + wkpid= +fi -mkdir ~/frappe-bench/sites/test_site +if ! restore_warm_bench; then + bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench + + cd ~/frappe-bench || exit + + sed -i 's/watch:/# watch:/g' Procfile + sed -i 's/schedule:/# schedule:/g' Procfile + sed -i 's/socketio:/# socketio:/g' Procfile + sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile + + CI=Yes bench build --app frappe + save_warm_bench +fi + +if [ -n "$wkpid" ]; then wait $wkpid; fi + +mkdir -p ~/frappe-bench/sites/test_site if [ "$DB" == "mariadb" ];then cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_mariadb.json" ~/frappe-bench/sites/test_site/site_config.json + if [ "$db_host" != "127.0.0.1" ]; then + sed -i "s/\"db_host\": \"127.0.0.1\"/\"db_host\": \"${db_host}\"/" ~/frappe-bench/sites/test_site/site_config.json + fi else cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_postgres.json" ~/frappe-bench/sites/test_site/site_config.json fi if [ "$DB" == "mariadb" ];then - mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" - mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + for _ in {1..60}; do + if mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent; then + break + fi + sleep 1 + done + mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent - # Belt-and-suspenders: also set performance variables at runtime in case - # MARIADB_EXTRA_FLAGS was not honoured by the container image. - mariadb --host 127.0.0.1 --port 3306 -u root -proot \ + mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" + mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + + # Throwaway-DB durability tuning at runtime. (innodb_doublewrite is read-only on MariaDB + # 10.6, so it can't be disabled here — would need a server startup flag.) + mariadb --host "$db_host" --port 3306 -u root -proot \ -e "SET GLOBAL innodb_flush_log_at_trx_commit=0; SET GLOBAL sync_binlog=0;" - mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'" - mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe" - mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'" + # Opt-in DDL speedup: a shared tablespace avoids a create+fsync per DocType table during + # reinstall — a big win under disk contention. But ROW_FORMAT=DYNAMIC must be accepted in + # the system tablespace on this MariaDB. Enable with CI_INNODB_SHARED_TABLESPACE=1; if + # reinstall then errors on table creation, unset it (off by default — zero risk). + if [ "${CI_INNODB_SHARED_TABLESPACE:-0}" = "1" ]; then + mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL innodb_file_per_table=0;" + fi - mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES" + mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'${db_user_host}' IDENTIFIED BY 'test_frappe'" + mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE DATABASE test_frappe" + mariadb --host "$db_host" --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'${db_user_host}'" + + mariadb --host "$db_host" --port 3306 -u root -proot -e "FLUSH PRIVILEGES" fi if [ "$DB" == "postgres" ];then echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres; echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres; - # CI databases are disposable, so trade durability for speed: postgres fsyncs on every commit - # by default, which dominates a commit-heavy test suite. These are all reload-time settings - # (no restart needed). MariaDB CI is unaffected (DB != postgres). - echo "travis" | psql -h 127.0.0.1 -p 5432 -U postgres \ - -c "ALTER SYSTEM SET synchronous_commit = 'off'" \ - -c "ALTER SYSTEM SET fsync = 'off'" \ - -c "ALTER SYSTEM SET full_page_writes = 'off'" \ - -c "SELECT pg_reload_conf()"; fi - -install_whktml() { - # Re-use the .deb if the wkhtmltopdf cache step already restored it. - if [ ! -f /tmp/wkhtmltox.deb ]; then - wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb - fi - sudo apt install /tmp/wkhtmltox.deb -} -install_whktml & -wkpid=$! - - cd ~/frappe-bench || exit -sed -i 's/watch:/# watch:/g' Procfile -sed -i 's/schedule:/# schedule:/g' Procfile -sed -i 's/socketio:/# socketio:/g' Procfile -sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile +run_ci_step "Get payments app" bench get-app payments --branch develop -bench get-app payments --branch develop -bench get-app erpnext "${GITHUB_WORKSPACE}" +# Opt-in: skip building erpnext's frontend assets. Server tests don't need them, but PDF +# tests (print formats) do — they pass only if the PDF renderer ignores missing assets. +# Enable with CI_SKIP_ERPNEXT_ASSETS=1 to test; if PDF tests fail, unset it. +erpnext_get_app_args=() +if [ "${CI_SKIP_ERPNEXT_ASSETS:-0}" = "1" ]; then erpnext_get_app_args=(--skip-assets); fi +run_ci_step "Get erpnext app" bench get-app erpnext "${GITHUB_WORKSPACE}" "${erpnext_get_app_args[@]}" -if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi +if [ "$TYPE" == "server" ]; then run_ci_step "Setup dev requirements" bench setup requirements --dev; fi -wait $wkpid +bench start >> ~/frappe-bench/bench_start.log 2>&1 & -bench start &>> ~/frappe-bench/bench_start.log & -CI=Yes bench build --app frappe & -bench --site test_site reinstall --yes +# Under heavy concurrency, gunicorn's startup can delay redis coming up. reinstall and the +# tests need redis, so wait for it (best-effort, bounded) instead of racing — contention +# then slows the job rather than failing it. +wait_for_redis() { + local cfg=~/frappe-bench/sites/common_site_config.json + [ -f "$cfg" ] || return 0 + local ports port + ports=$(python - "$cfg" <<'PY' +import json, re, sys +try: + cfg = json.load(open(sys.argv[1])) +except Exception: + sys.exit(0) +for key in ("redis_cache", "redis_queue"): + match = re.search(r":(\d+)", str(cfg.get(key, ""))) + if match: + print(match.group(1)) +PY +) + for port in $ports; do + for _ in $(seq 1 120); do + if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then + exec 3>&- 3<&- + break + fi + sleep 1 + done + done +} +wait_for_redis + +# Site setup. `bench reinstall` rebuilds the entire schema in Python (~1000 DocTypes) — the +# CI bottleneck that DB tuning / tmpfs / faster cores couldn't move. Instead, restore a +# pre-baked baseline (the DB engine loads it, no Python schema-build) and `migrate` to sync +# only the drift since the baseline was built. The baseline is produced by +# .github/helper/generate-ci-baseline.sh (run nightly / at image build) from a clean +# reinstall on develop. Gated by CI_RESTORE_FROM_BACKUP so it A/Bs against plain reinstall; +# falls back to reinstall if the baseline is missing or the restore fails. +CI_BASELINE_BACKUP="${CI_BASELINE_BACKUP:-/opt/ci-baseline/test_site-database.sql.gz}" +if [ "${CI_RESTORE_FROM_BACKUP:-0}" = "1" ] && [ -f "$CI_BASELINE_BACKUP" ]; then + if [ "$DB" == "mariadb" ]; then + db_root_args=(--db-root-username root --db-root-password root) + else + db_root_args=(--db-root-username postgres --db-root-password travis) + fi + if run_ci_step "Restore baseline test site" bench --site test_site --force restore "${db_root_args[@]}" "$CI_BASELINE_BACKUP"; then + run_ci_step "Migrate test site" bench --site test_site migrate + else + run_ci_step "Reinstall test site (baseline restore failed)" bench --site test_site reinstall --yes + fi +else + run_ci_step "Reinstall test site" bench --site test_site reinstall --yes +fi + +# Refresh the baseline backup from this freshly set-up site. Run a normal job (reinstall path) +# with CI_GENERATE_BASELINE=1 and the baseline dir mounted read-write; install.sh captures the +# DB dump to CI_BASELINE_BACKUP so future runs can restore it. Nightly is enough — bench migrate +# absorbs intraday develop drift. To bake into the image instead, copy the produced .sql.gz in. +if [ "${CI_GENERATE_BASELINE:-0}" = "1" ]; then + run_ci_step "Backup baseline test site" bench --site test_site backup + latest_backup=$(ls -t ~/frappe-bench/sites/test_site/private/backups/*-database.sql.gz | head -1) + mkdir -p "$(dirname "$CI_BASELINE_BACKUP")" + cp "$latest_backup" "$CI_BASELINE_BACKUP" + echo "Baseline written to $CI_BASELINE_BACKUP ($(du -h "$latest_backup" | cut -f1))" +fi diff --git a/.github/helper/start-db.sh b/.github/helper/start-db.sh new file mode 100755 index 00000000000..6998adcfd91 --- /dev/null +++ b/.github/helper/start-db.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# +# Run MariaDB INSIDE the runner container, on a datadir we control. Because the datadir can be +# packaged into the bench artifact, test shards start an already-loaded server instead of +# replaying a SQL dump (the ~60s hydrate restore). Each shard gets its own copy → isolation kept. +# +# CI_DB_DATADIR picks the path: +# - setup job: /home/ci/db-data (OUTSIDE the bench, so install.sh's `rm -rf ~/frappe-bench` +# doesn't wipe it; it's moved into the bench just before packaging) +# - test shard: ~/frappe-bench/mariadb-data (where the artifact untar'd it) +# +# Idempotent: inits a fresh datadir if absent (setup), else starts on the existing one (shards). +# +set -e + +ci_user="${ERPNEXT_CI_USER:-frappe}" + +# Re-exec as the ci user so mariadbd and the datadir are owned consistently (root mariadbd is +# refused anyway). Mirrors install.sh's user switch. +if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then + exec su -m "$ci_user" -s /bin/bash -c \ + "ERPNEXT_CI_USER='$ci_user' CI_DB_DATADIR='${CI_DB_DATADIR:-}' bash '$0'" +fi + +# --- PostgreSQL (GitHub-hosted CI): run in-runner on a PGDATA so it bakes into the artifact, +# same idea as the mariadb datadir. Trust auth (throwaway CI) skips password setup; durability +# off for speed. Postgres is preinstalled on ubuntu-latest under /usr/lib/postgresql//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 & + +for _ in $(seq 1 60); do + mariadb-admin --socket="$SOCK" ping --silent 2>/dev/null && break + sleep 1 +done + +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)" diff --git a/.github/workflows/server-tests-postgres.yml b/.github/workflows/server-tests-postgres.yml index 3a668133a5f..3b55691bdba 100644 --- a/.github/workflows/server-tests-postgres.yml +++ b/.github/workflows/server-tests-postgres.yml @@ -1,79 +1,43 @@ name: Server (Postgres) on: - repository_dispatch: - types: [frappe-framework-change] + schedule: + # 03:00 AM IST daily (21:30 UTC the previous day) + - cron: "30 21 * * *" pull_request: - # 'labeled' is required so adding the 'postgres' label to an open PR triggers this run - # (the job itself is gated on that label below) - types: [opened, reopened, synchronize, labeled] paths-ignore: - '**.js' - - '**.css' - - '**.svg' - '**.md' - '**.html' - 'crowdin.yml' - '.coderabbit.yml' - '.mergify.yml' - schedule: - # Run everday at midnight UTC / 5:30 IST - - cron: "0 0 * * *" workflow_dispatch: - inputs: - user: - description: 'Frappe Framework repository user (add your username for forks)' - required: true - default: 'frappe' - type: string - branch: - description: 'Frappe Framework branch' - default: 'develop' - required: false - type: string - -permissions: - contents: read concurrency: group: server-postgres-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }} cancel-in-progress: true +permissions: + contents: read + +# Postgres CI stays on GitHub-hosted (free, full-speed VM per shard) but follows the same fan-out +# we built for MariaDB: build the bench + reinstall ONCE in the setup job, bake the PostgreSQL +# PGDATA into the artifact, and have 4 test shards start Postgres on that datadir — no per-shard +# clone/build/reinstall/restore. Python is pinned so the venv transplants between VMs. +env: + TZ: 'Asia/Kolkata' + NODE_ENV: "production" + PYTHON_VERSION: '3.14' + jobs: - test: - # Opt-in on PRs: only runs when the PR carries the 'postgres' label. Scheduled / manual / - # framework-dispatch runs always execute (no PR labels to gate on). - if: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'postgres') }} + setup: + name: Build & reinstall (setup) runs-on: ubuntu-latest - timeout-minutes: 60 - env: - TZ: 'Asia/Kolkata' - NODE_ENV: "production" - WITH_COVERAGE: ${{ github.event_name != 'pull_request' }} - - strategy: - fail-fast: false - - matrix: - container: [1, 2, 3, 4] - - # Distinct from the MariaDB job's "Python Unit Tests" so its check contexts do NOT collide with - # the required "Python Unit Tests (1..4)" status checks -- this keeps Postgres non-required for now. - name: Postgres Unit Tests - - services: - postgres: - image: postgres:13.3 - env: - POSTGRES_PASSWORD: travis - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - + # Runs on the daily schedule (and workflow_dispatch). On PRs it runs ONLY when the PR carries + # the 'postgres' label — the test job needs setup, so it's skipped too when this is. + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'postgres') + timeout-minutes: 40 steps: - name: Clone uses: actions/checkout@v6 @@ -81,7 +45,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v6 with: - python-version: '3.14' + python-version: ${{ env.PYTHON_VERSION }} - name: Check for valid Python & Merge Conflicts run: | @@ -100,98 +64,128 @@ jobs: - name: Add to Hosts run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts - - name: Cache pip + - name: Cache deps (uv/pip/npm/yarn) uses: actions/cache@v4 with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-pip- - ${{ runner.os }}- + path: | + ~/.cache/uv + ~/.cache/pip + ~/.npm + ~/.cache/yarn + key: ${{ runner.os }}-deps-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }} + restore-keys: ${{ runner.os }}-deps- - - name: Cache node modules + # Warm-bench cache (the big one): install.sh saves the built base bench — frappe + env + + # node_modules + assets — here as frappe-bench-base-*.tar.zst. Later runs restore it and only + # fast-forward to the live develop SHA + rebuild the delta, so the bench BUILD is near-free and + # only the test_site reinstall (per-run DB, uncacheable) stays slow — matching the self-hosted + # box. The first run after a deps change populates it; every run after that is fast. + - name: Cache warm bench (base build) uses: actions/cache@v4 + with: + path: ~/bench-cache + key: ${{ runner.os }}-warmbench-v2-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }} + restore-keys: ${{ runner.os }}-warmbench-v2- + + # Postgres runs in-runner on a PGDATA OUTSIDE the bench (install.sh wipes ~/frappe-bench); + # after the reinstall it's moved into the bench so it ships in the artifact. + - name: Start DB + run: bash ${GITHUB_WORKSPACE}/.github/helper/start-db.sh env: - cache-name: cache-node-modules - with: - path: ~/.npm - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - - - uses: actions/cache@v4 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Cache wkhtmltopdf - uses: actions/cache@v4 - with: - path: /tmp/wkhtmltox.deb - key: wkhtmltox-0.12.6.1-2-jammy-amd64 + DB: postgres + CI_DB_DATADIR: /home/runner/pgdata - name: Install run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh env: DB: postgres TYPE: server - FRAPPE_USER: ${{ github.event.inputs.user }} - FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }} + FRAPPE_BRANCH: develop + BENCH_CACHE_DIR: /home/runner/bench-cache + + - name: Stop DB and stage datadir + run: | + PG_BIN=$(ls -d /usr/lib/postgresql/*/bin | sort -V | tail -1) + "$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop || true + mv /home/runner/pgdata /home/runner/frappe-bench/pgdata + + - name: Package bench for test shards + run: | + cp "${GITHUB_WORKSPACE}/.github/helper/hydrate.sh" /home/runner/frappe-bench/hydrate.sh + cp "${GITHUB_WORKSPACE}/.github/helper/start-db.sh" /home/runner/frappe-bench/start-db.sh + tar czpf "${GITHUB_WORKSPACE}/bench.tar.gz" -C /home/runner \ + --exclude='.git' --exclude='node_modules' frappe-bench + ls -lh "${GITHUB_WORKSPACE}/bench.tar.gz" + + - name: Upload bench artifact + uses: actions/upload-artifact@v4 + with: + name: bench-pg + path: bench.tar.gz + retention-days: 1 + compression-level: 0 + + test: + name: Python Unit Tests (PG) + needs: setup + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + container: [1, 2, 3, 4] + steps: + - name: Download bench artifact + uses: actions/download-artifact@v4 + with: + name: bench-pg + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Add to Hosts + run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts + + # The bench CLI (frappe-bench) and redis are global/system tools — not in the bench tarball. + # The setup runner got them via install.sh; the MariaDB shards get them from the arc5 image. + # GitHub-hosted PG shards install them here (cheap vs the build+reinstall that setup did once). + - name: Install shard runtime (bench CLI + redis + wkhtmltopdf) + run: | + pip install frappe-bench + command -v redis-server >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq redis-server; } + # wkhtmltopdf (patched-qt build) for print-format / PDF tests — same .deb install.sh uses. + if ! command -v wkhtmltopdf >/dev/null; then + wget -qO /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb + sudo apt-get install -y -qq /tmp/wkhtmltox.deb + fi + + - name: Untar bench + run: | + tar xzpf "${GITHUB_WORKSPACE}/bench.tar.gz" -C /home/runner + ls -ld /home/runner/frappe-bench + + - name: Hydrate (start Postgres on the baked datadir) + run: bash /home/runner/frappe-bench/hydrate.sh + env: + DB: postgres + DB_HOST: 127.0.0.1 - name: Run Tests run: | cd ~/frappe-bench/ - coverage_flag="" - if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi + # print-format / PDF tests are engine-independent (they exercise wkhtmltopdf rendering, + # not postgres SQL — the MariaDB CI already covers them). They only fetch the static asset + # bundles from http://test_site:8000/assets/..., so a plain static file server over sites/ + # satisfies wkhtmltopdf without the frappe web server (which never bound on a bare runner). + ( cd ~/frappe-bench/sites && nohup python3 -m http.server 8000 --bind 127.0.0.1 > ~/frappe-bench/web.log 2>&1 & ) + for _ in $(seq 1 15); do (exec 3<>/dev/tcp/127.0.0.1/8000) 2>/dev/null && { exec 3>&- 3<&-; break; }; sleep 1; done bench --site test_site run-parallel-tests --lightmode --app erpnext \ - --total-builds ${{ strategy.job-total }} \ - --build-number ${{ matrix.container }} \ - $coverage_flag + --total-builds 4 --build-number ${{ matrix.container }} env: TYPE: server - - - name: Show bench output + - name: Show web server log if: ${{ always() }} - run: cat ~/frappe-bench/bench_start.log || true - - - name: Upload coverage data - if: ${{ env.WITH_COVERAGE == 'true' }} - uses: actions/upload-artifact@v4 - with: - name: coverage-postgres-${{ matrix.container }} - path: /home/runner/frappe-bench/sites/coverage.xml - - coverage: - name: Coverage Wrap Up - needs: test - if: ${{ github.event_name != 'pull_request' }} - runs-on: ubuntu-latest - steps: - - name: Clone - uses: actions/checkout@v6 - - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - pattern: coverage-postgres-* - - - name: Upload coverage data - uses: codecov/codecov-action@v4 - with: - name: Postgres - flags: postgres - # explicit glob: download-artifact extracts each shard into its own coverage-postgres-N/ dir - files: coverage-postgres-*/coverage.xml - token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true - verbose: true + run: cat ~/frappe-bench/web.log 2>/dev/null || true From c1006e79a4d57e5b8a860fd568e265f50adc8054 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 11:54:48 +0530 Subject: [PATCH 002/101] =?UTF-8?q?ci(postgres):=20cleanup=20=E2=80=94=20d?= =?UTF-8?q?rop=20baseline-restore=20code,=20debug=20step,=20stale=20restor?= =?UTF-8?q?e=20vars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/helper/hydrate.sh | 8 ++--- .github/helper/install.sh | 38 +++------------------ .github/workflows/server-tests-postgres.yml | 4 --- 3 files changed, 7 insertions(+), 43 deletions(-) diff --git a/.github/helper/hydrate.sh b/.github/helper/hydrate.sh index 4619d63dbeb..cadf5c0ac6d 100755 --- a/.github/helper/hydrate.sh +++ b/.github/helper/hydrate.sh @@ -4,21 +4,19 @@ # # 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 and restore the dump the setup job -# baked into the bench, then start bench so tests can run. Mirrors the DB + bench-start tail of -# install.sh. The whole point is that the expensive work happened ONCE in the setup job. +# 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}" -dump="${CI_BASELINE_BACKUP:-/home/$ci_user/frappe-bench/test_site-db.sql.gz}" # 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' CI_BASELINE_BACKUP='$dump' bash '$0'" + "ERPNEXT_CI_USER='$ci_user' DB_HOST='$db_host' bash '$0'" fi cd ~/frappe-bench diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 74330abca16..83a6cd5c8ed 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -344,37 +344,7 @@ PY } wait_for_redis -# Site setup. `bench reinstall` rebuilds the entire schema in Python (~1000 DocTypes) — the -# CI bottleneck that DB tuning / tmpfs / faster cores couldn't move. Instead, restore a -# pre-baked baseline (the DB engine loads it, no Python schema-build) and `migrate` to sync -# only the drift since the baseline was built. The baseline is produced by -# .github/helper/generate-ci-baseline.sh (run nightly / at image build) from a clean -# reinstall on develop. Gated by CI_RESTORE_FROM_BACKUP so it A/Bs against plain reinstall; -# falls back to reinstall if the baseline is missing or the restore fails. -CI_BASELINE_BACKUP="${CI_BASELINE_BACKUP:-/opt/ci-baseline/test_site-database.sql.gz}" -if [ "${CI_RESTORE_FROM_BACKUP:-0}" = "1" ] && [ -f "$CI_BASELINE_BACKUP" ]; then - if [ "$DB" == "mariadb" ]; then - db_root_args=(--db-root-username root --db-root-password root) - else - db_root_args=(--db-root-username postgres --db-root-password travis) - fi - if run_ci_step "Restore baseline test site" bench --site test_site --force restore "${db_root_args[@]}" "$CI_BASELINE_BACKUP"; then - run_ci_step "Migrate test site" bench --site test_site migrate - else - run_ci_step "Reinstall test site (baseline restore failed)" bench --site test_site reinstall --yes - fi -else - run_ci_step "Reinstall test site" bench --site test_site reinstall --yes -fi - -# Refresh the baseline backup from this freshly set-up site. Run a normal job (reinstall path) -# with CI_GENERATE_BASELINE=1 and the baseline dir mounted read-write; install.sh captures the -# DB dump to CI_BASELINE_BACKUP so future runs can restore it. Nightly is enough — bench migrate -# absorbs intraday develop drift. To bake into the image instead, copy the produced .sql.gz in. -if [ "${CI_GENERATE_BASELINE:-0}" = "1" ]; then - run_ci_step "Backup baseline test site" bench --site test_site backup - latest_backup=$(ls -t ~/frappe-bench/sites/test_site/private/backups/*-database.sql.gz | head -1) - mkdir -p "$(dirname "$CI_BASELINE_BACKUP")" - cp "$latest_backup" "$CI_BASELINE_BACKUP" - echo "Baseline written to $CI_BASELINE_BACKUP ($(du -h "$latest_backup" | cut -f1))" -fi +# 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 diff --git a/.github/workflows/server-tests-postgres.yml b/.github/workflows/server-tests-postgres.yml index 3b55691bdba..3f6505b1894 100644 --- a/.github/workflows/server-tests-postgres.yml +++ b/.github/workflows/server-tests-postgres.yml @@ -185,7 +185,3 @@ jobs: --total-builds 4 --build-number ${{ matrix.container }} env: TYPE: server - - - name: Show web server log - if: ${{ always() }} - run: cat ~/frappe-bench/web.log 2>/dev/null || true From afca370fa87b39d51ed9c6fa2eea62718e388b87 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 11:59:25 +0530 Subject: [PATCH 003/101] ci(patch): cache the v14 baseline backup instead of re-downloading it every run --- .github/workflows/patch.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml index 80325d2e087..40fc667e3d9 100644 --- a/.github/workflows/patch.yml +++ b/.github/workflows/patch.yml @@ -65,6 +65,19 @@ jobs: - name: Add to Hosts run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts + # The v14 baseline backup is a fixed published file — cache it instead of re-downloading + # ~100MB from frappe.io every run. + - name: Cache erpnext v14 backup + id: cache-v14 + uses: actions/cache@v4 + with: + path: ~/erpnext-v14.sql.gz + key: erpnext-v14-sql-gz + + - name: Download erpnext v14 backup + if: steps.cache-v14.outputs.cache-hit != 'true' + run: wget -O ~/erpnext-v14.sql.gz https://frappe.io/files/erpnext-v14.sql.gz + - name: Cache pip uses: actions/cache@v4 with: @@ -113,8 +126,7 @@ jobs: jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json mv tmp.json ~/frappe-bench/sites/test_site/site_config.json - wget https://frappe.io/files/erpnext-v14.sql.gz - bench --site test_site --force restore ~/frappe-bench/erpnext-v14.sql.gz + bench --site test_site --force restore ~/erpnext-v14.sql.gz git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git From a0cc64572543b4ad14636e120b34adcb72f53f2b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 12:26:44 +0530 Subject: [PATCH 004/101] ci(postgres): address greptile review --- .github/helper/start-db.sh | 6 +++++- .github/workflows/server-tests-postgres.yml | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/helper/start-db.sh b/.github/helper/start-db.sh index 6998adcfd91..e99d44babe1 100755 --- a/.github/helper/start-db.sh +++ b/.github/helper/start-db.sh @@ -56,10 +56,14 @@ mariadbd --no-defaults --datadir="$DATADIR" --socket="$SOCK" --pid-file="$DATADI --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 - mariadb-admin --socket="$SOCK" ping --silent 2>/dev/null && break + 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 diff --git a/.github/workflows/server-tests-postgres.yml b/.github/workflows/server-tests-postgres.yml index 3f6505b1894..8cd50f235f0 100644 --- a/.github/workflows/server-tests-postgres.yml +++ b/.github/workflows/server-tests-postgres.yml @@ -5,6 +5,8 @@ on: # 03:00 AM IST daily (21:30 UTC the previous day) - cron: "30 21 * * *" pull_request: + # 'labeled' so adding the 'postgres' label to an already-open PR re-triggers the run. + types: [opened, reopened, synchronize, labeled] paths-ignore: - '**.js' - '**.md' @@ -182,6 +184,6 @@ jobs: ( cd ~/frappe-bench/sites && nohup python3 -m http.server 8000 --bind 127.0.0.1 > ~/frappe-bench/web.log 2>&1 & ) for _ in $(seq 1 15); do (exec 3<>/dev/tcp/127.0.0.1/8000) 2>/dev/null && { exec 3>&- 3<&-; break; }; sleep 1; done bench --site test_site run-parallel-tests --lightmode --app erpnext \ - --total-builds 4 --build-number ${{ matrix.container }} + --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }} env: TYPE: server From b919a7abff69cc82bdeb4ffe829f2673b3cda6a6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 12:37:46 +0530 Subject: [PATCH 005/101] ci: wait_for_redis fail-fast (greptile) --- .github/helper/install.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 83a6cd5c8ed..263c12555bb 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -333,13 +333,17 @@ for key in ("redis_cache", "redis_queue"): 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<&- + 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 From 13b6c4a165db29d95d05cdcd01d79cdeaca0accc Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 24 Jun 2026 12:57:31 +0530 Subject: [PATCH 006/101] feat(accounts): add configurable job timeout for Process Period Closing Voucher Adds a `pcv_job_timeout` Int field (default 3600s) to Accounts Settings so admins can tune the enqueue timeout for PCV background jobs without a code change. All three `frappe.enqueue` calls in `process_period_closing_voucher.py` now read this value at runtime. Co-Authored-By: Claude Sonnet 4.6 --- .../doctype/accounts_settings/accounts_settings.json | 8 ++++++++ .../process_period_closing_voucher.py | 10 +++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 4ec9132cb70..80890de5717 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -87,6 +87,7 @@ "period_closing_settings_section", "ignore_account_closing_balance", "use_legacy_controller_for_pcv", + "pcv_job_timeout", "column_break_25", "reports_tab", "remarks_section", @@ -612,6 +613,13 @@ "fieldtype": "Check", "label": "Use legacy controller for Period Closing Voucher" }, + { + "default": "3600", + "description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher", + "fieldname": "pcv_job_timeout", + "fieldtype": "Int", + "label": "PCV Job Timeout (seconds)" + }, { "description": "Users with this role will be notified if the asset depreciation gets failed", "fieldname": "role_to_notify_on_depreciation_failure", diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py index 24c8c92c7e8..17e63c68b41 100644 --- a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py +++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py @@ -95,6 +95,8 @@ def start_pcv_processing(docname: str): frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True) frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running") + timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600 + ppcvd = qb.DocType("Process Period Closing Voucher Detail") if normal_balances := ( qb.from_(ppcvd) @@ -121,7 +123,7 @@ def start_pcv_processing(docname: str): frappe.enqueue( method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date", queue="long", - timeout="3600", + timeout=timeout, is_async=True, enqueue_after_commit=True, docname=docname, @@ -247,6 +249,8 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions): @frappe.whitelist() def schedule_next_date(docname: str): + timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600 + ppcvd = qb.DocType("Process Period Closing Voucher Detail") if to_process := ( qb.from_(ppcvd) @@ -272,7 +276,7 @@ def schedule_next_date(docname: str): frappe.enqueue( method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date", queue="long", - timeout="3600", + timeout=timeout, is_async=True, enqueue_after_commit=True, docname=docname, @@ -302,7 +306,7 @@ def schedule_next_date(docname: str): frappe.enqueue( method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries", queue="long", - timeout="3600", + timeout=timeout, is_async=True, job_name=job_name, enqueue_after_commit=True, From 3da7eefebb37bb1f1e6cc4c7c0c2add735e0e2c5 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 24 Jun 2026 13:01:42 +0530 Subject: [PATCH 007/101] refactor: patch, display depends on and json changes --- .../accounts/doctype/accounts_settings/accounts_settings.json | 3 ++- .../accounts/doctype/accounts_settings/accounts_settings.py | 1 + erpnext/patches.txt | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 80890de5717..da92cdd5b0a 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -615,6 +615,7 @@ }, { "default": "3600", + "depends_on": "eval: !doc.use_legacy_controller_for_pcv", "description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher", "fieldname": "pcv_job_timeout", "fieldtype": "Int", @@ -764,7 +765,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-06-03 13:11:54.721495", + "modified": "2026-06-24 12:59:41.868865", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index d408d1987e7..c56d39ad8d9 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -90,6 +90,7 @@ class AccountsSettings(Document): make_payment_via_journal_entry: DF.Check merge_similar_account_heads: DF.Check over_billing_allowance: DF.Currency + pcv_job_timeout: DF.Int preview_mode: DF.Check receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"] receivable_payable_remarks_length: DF.Int diff --git a/erpnext/patches.txt b/erpnext/patches.txt index c5abaf1cb19..0d1c3f01025 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -491,3 +491,4 @@ erpnext.patches.v16_0.migrate_subscription_generate_invoice_at erpnext.patches.v16_0.rename_subscription_billing_period_fields erpnext.patches.v16_0.drop_redundant_serial_no_index_from_sabb erpnext.patches.v16_0.set_default_close_opportunity_after_days +execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600) From 8bd8b28207e8fd7e179cb6afc9a7d38a57c2e9f1 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 14:38:52 +0530 Subject: [PATCH 008/101] fix: exclude virtual child doctypes from deletion in transaction deletion record --- .../transaction_deletion_record.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py index 5a3dcc5b840..043edd44ed1 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py +++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py @@ -318,12 +318,23 @@ class TransactionDeletionRecord(Document): Returns: list: List of child table DocType names (Table field options) """ - return frappe.get_all( + child_tables = 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 From 9b0e1b61f202e33beaff8b8a146d3336001dc861 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Wed, 24 Jun 2026 15:20:22 +0530 Subject: [PATCH 009/101] fix: precision issue causing COGS in inter transfer PR (#56420) --- erpnext/controllers/taxes_and_totals.py | 2 +- .../doctype/repost_item_valuation/repost_item_valuation.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index a5ca0ae7999..11b7f8d9011 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -169,7 +169,7 @@ class calculate_taxes_and_totals: return if not self.discount_amount_applied: - do_not_round_fields = ["valuation_rate", "incoming_rate"] + 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) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index dec591d7961..311396d2bd3 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -345,6 +345,9 @@ 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) From 7cb03a427ab25e9fe20cb8bb7e176197ac371d11 Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Wed, 24 Jun 2026 17:47:35 +0530 Subject: [PATCH 010/101] fix(letter-head): guard company lookups when doc has no company field --- .../letter_head/company_letterhead/company_letterhead.json | 4 ++-- .../company_letterhead___grey/company_letterhead___grey.json | 4 ++-- .../company_letterhead_report/company_letterhead_report.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json b/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json index e0b41c42e51..28b60e313c4 100644 --- a/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json +++ b/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json @@ -1,6 +1,6 @@ { "align": "Left", - "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t
\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\"Company\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t
{{ doc.company }}
\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 \"\" }}
\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\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 \"\"}}
\n\t\t\t\t{% endif %}\n\t\t\t
\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
\n\t\t\t\t\t{{ doc.doctype }}\n\t\t\t\t\t{{ doc.name }}\n\t\t\t\t
\n\t\t\t\t{% if website %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Website:\") }}\n\t\t\t\t\t{{ website }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Email:\") }}\n\t\t\t\t\t{{ email }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Contact:\") }}\n\t\t\t\t\t{{ phone_no }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t
", + "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t
\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\"Company\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% 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 \"\" }}
\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\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 \"\"}}
\n\t\t\t\t{% endif %}\n\t\t\t
\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
\n\t\t\t\t\t{{ doc.doctype }}\n\t\t\t\t\t{{ doc.name }}\n\t\t\t\t
\n\t\t\t\t{% if website %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Website:\") }}\n\t\t\t\t\t{{ website }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Email:\") }}\n\t\t\t\t\t{{ email }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Contact:\") }}\n\t\t\t\t\t{{ phone_no }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t
", "creation": "2026-05-15 15:21:48.255627", "custom_css": "\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tpadding-right: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\n\t.letter-head td {\n\t\tpadding: 0px !important;\n\t}\n\t.invoice-header {\n\t\twidth: 100%;\n\t}\n\t.logo-cell {\n\t\twidth: 100px;\n\t\ttext-align: center;\n\t\tposition: relative;\n\t}\n\t.logo-container {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t}\n\t.logo-container img {\n\t\tmax-width: 90px;\n\t\tmax-height: 90px;\n\t\tdisplay: inline-block;\n\t\tborder-radius: 15px;\n\t}\n\t.company-details {\n\t\twidth: 40%;\n\t\talign-content: center;\n\t}\n\t.company-name {\n\t\tfont-size: 14px;\n\t\tfont-weight: bold;\n\t\tcolor: #171717;\n\t\tmargin-bottom: 4px;\n\t}\n\t.invoice-info-cell {\n\t\tfloat: right;\n\t\tvertical-align: top;\n\t}\n\t.invoice-info {\n\t\tmargin-bottom: 2px;\n\t}\n\t.invoice-label {\n\t\tcolor: #7c7c7c;\n\t\tdisplay: inline-block;\n\t\tmargin-right: 5px;\n\t}", "disabled": 0, @@ -16,7 +16,7 @@ "is_default": 0, "letter_head_for": "DocType", "letter_head_name": "Company Letterhead", - "modified": "2026-05-16 15:15:23.014622", + "modified": "2026-06-24 17:49:52.350750", "modified_by": "Administrator", "module": "Accounts", "name": "Company Letterhead", diff --git a/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json b/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json index 0f0f903dad9..67c03298195 100644 --- a/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json +++ b/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json @@ -1,6 +1,6 @@ { "align": "Left", - "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\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
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t\t
{{ doc.company }}
\n\t\t\t\t
\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 \"\" }}
\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\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 \"\"}}
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
{{ doc.doctype }}
\n\t\t\t\t\t
{{ doc.name }}
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %} {% set website = company_details.website %} {% set email =\n\t\t\t\t\tcompany_details.email %} {% set phone_no = company_details.phone_no %} {% if website %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Website:\") }}{{ website }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Email:\") }}{{ email }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Contact:\") }}{{ phone_no }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n", + "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\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
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% endif %}\n\t\t\t\t
\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 \"\" }}
\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\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 \"\"}}
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
{{ doc.doctype }}
\n\t\t\t\t\t
{{ doc.name }}
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\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
\n\t\t\t\t\t\t{{ _(\"Website:\") }}{{ website }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Email:\") }}{{ email }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t
\n\t\t\t\t\t\t{{ _(\"Contact:\") }}{{ phone_no }}\n\t\t\t\t\t
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n", "creation": "2026-05-15 15:21:48.373815", "custom_css": "\t.print-format-preview {\n\t\tmargin-top: 12px;\n\t}\n\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tbackground: #f8f8f8;\n\t\tpadding: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\t.letterhead-container {\n\t\twidth: 100%;\n\t}\n\t.letterhead-container .other-details {\n\t\tposition: absolute;\n\t\tright: 0;\n\t\tbottom: 0;\n\t}\n\t.logo-address {\n\t\twidth: 65%;\n\t\tvertical-align: top;\n\t}\n\n\t.letter-head .logo {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t\tmargin-bottom: 10px;\n\t}\n\n\t.letter-head .logo img {\n\t\tborder-radius: 15px;\n\t}\n\n\t.company-name {\n\t\tcolor: #171717;\n\t\tfont-weight: bold;\n\t\tline-height: 23px;\n\t\tmargin-bottom: 5px;\n\t}\n\n\t.company-address {\n\t\tcolor: #171717;\n\t\twidth: 300px;\n\t}\n\n\t.invoice-title {\n\t\tfont-weight: bold;\n\t}\n\n\t.invoice-number {\n\t\tcolor: #7c7c7c;\n\t}\n\n\t.contact-title {\n\t\tcolor: #7c7c7c;\n\t\twidth: 60px;\n\t\tdisplay: inline-block;\n\t\tvertical-align: top;\n\t\tmargin-right: 10px;\n\t}\n\n\t.contact-value {\n\t\tcolor: #171717;\n\t\tdisplay: inline-block;\n\t}\n\t.letterhead-container td {\n\t\tpadding: 0px !important;\n\t\tposition: relative;\n\t}", "disabled": 0, @@ -16,7 +16,7 @@ "is_default": 0, "letter_head_for": "DocType", "letter_head_name": "Company Letterhead - Grey", - "modified": "2026-05-16 15:15:19.942207", + "modified": "2026-06-24 18:23:05.120521", "modified_by": "Administrator", "module": "Accounts", "name": "Company Letterhead - Grey", diff --git a/erpnext/accounts/letter_head/company_letterhead_report/company_letterhead_report.json b/erpnext/accounts/letter_head/company_letterhead_report/company_letterhead_report.json index cc158d71aed..0417c6c0433 100644 --- a/erpnext/accounts/letter_head/company_letterhead_report/company_letterhead_report.json +++ b/erpnext/accounts/letter_head/company_letterhead_report/company_letterhead_report.json @@ -1,6 +1,6 @@ { "align": "Left", - "content": "\n\t\n\t\t\n\n\t\t\t\n\n\t\t\t\n\n\t\t\t\n\n\t\t\n\t\n
\n\t\t\t\t{% set company = frappe.get_doc(\"Company\", doc.company) %}\n\n\t\t\t\t
\n\t\t\t\t\t{% if company.company_logo %}\n\t\t\t\t\t\"Company\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t
{{ company.name }}
\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
\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
{{ company_address.address_line2 }}\n\t\t\t\t\t{% endif %}\n\n\t\t\t\t\t
\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
\n\t\t\t\t{% endif %}\n\t\t\t
\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
\n\t\t\t\t\t{{ _(\"Website:\") }}\n\t\t\t\t\t{{ website }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if email %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Email:\") }}\n\t\t\t\t\t{{ email }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if phone_no %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Contact:\") }}\n\t\t\t\t\t{{ phone_no }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t
", + "content": "\n\t\n\t\t\n\n\t\t\t\n\n\t\t\t\n\n\t\t\t\n\n\t\t\n\t\n
\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
\n\t\t\t\t\t{% if company.company_logo %}\n\t\t\t\t\t\"Company\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t{% if company.name %}
{{ company.name }}
{% 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
\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
{{ company_address.address_line2 }}\n\t\t\t\t\t{% endif %}\n\n\t\t\t\t\t
\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
\n\t\t\t\t{% endif %}\n\t\t\t
\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
\n\t\t\t\t\t{{ _(\"Website:\") }}\n\t\t\t\t\t{{ website }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if email %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Email:\") }}\n\t\t\t\t\t{{ email }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if phone_no %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Contact:\") }}\n\t\t\t\t\t{{ phone_no }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t
", "creation": "2026-05-15 19:49:47.582252", "custom_css": ".letter-head {\n\tborder-radius: 18px;\n\tpadding: 8px 10px;\n\tmargin: 10px 0 14px;\n\tfont-family: Inter, sans-serif;\n\tfont-size: 14px;\n\tcolor: #171717;\n}\n\n.letter-head td {\n\tpadding: 0 !important;\n\tvertical-align: middle;\n}\n\n.invoice-header {\n\twidth: 100%;\n\tborder-collapse: collapse;\n\ttable-layout: fixed;\n\tborder-bottom: 1px solid #ededed;\n\tpadding-bottom: 10px;\n}\n\n.logo-cell {\n\twidth: 100px;\n\ttext-align: center;\n\twhite-space: nowrap;\n}\n\n.logo-container {\n\tdisplay: inline-block;\n\tmargin: auto;\n}\n\n.logo-container img {\n\tmax-width: 95px;\n\tmax-height: 95px;\n\tdisplay: block;\n\tborder-radius: 12px;\n}\n\n.company-details {\n\twidth: 55%;\n\tpadding-left: 10px !important;\n\tline-height: 1.5;\n}\n\n.company-name {\n\tfont-size: 14px;\n\tfont-weight: 600;\n\tcolor: #171717;\n\tmargin-bottom: 4px;\n}\n\n.company-address {\n\tfont-size: 14px;\n\tline-height: 1.5;\n\tcolor: #171717;\n}\n\n.invoice-info-cell {\n\twidth: 240px;\n\ttext-align: right;\n\tvertical-align: top !important;\n\tline-height: 1.5;\n}\n\n.document-name {\n\tfont-size: 14px;\n\tfont-weight: 600;\n\tcolor: #171717;\n\tmargin-bottom: 6px;\n}\n\n.invoice-info {\n\tfont-size: 14px;\n\tcolor: #171717;\n\tmargin-bottom: 2px;\n\tfont-variant-numeric: tabular-nums;\n}\n\n.invoice-label {\n\tcolor: #7c7c7c;\n\tfont-weight: 500;\n\tmargin-right: 4px;\n\tdisplay: inline-block;\n}", "disabled": 0, @@ -16,7 +16,7 @@ "is_default": 0, "letter_head_for": "Report", "letter_head_name": "Company Letterhead Report", - "modified": "2026-05-16 15:15:26.155770", + "modified": "2026-06-24 18:06:39.820968", "modified_by": "Administrator", "module": "Accounts", "name": "Company Letterhead Report", From 59fe10bfbdf7fc2df7386c1405fb2eb91cba08ad Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 24 Jun 2026 19:18:28 +0530 Subject: [PATCH 011/101] perf: make CLI faster (#56437) --- erpnext/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 7e72b2b585d..1fb45004e6f 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -3,8 +3,6 @@ import inspect from typing import TypeVar import frappe -from frappe.model.document import Document -from frappe.utils.user import is_website_user __version__ = "17.0.0-dev" @@ -155,6 +153,8 @@ def allow_regional(fn): def check_app_permission(): + from frappe.utils.user import is_website_user + if frappe.session.user == "Administrator": return True @@ -175,6 +175,8 @@ def normalize_ctx_input(T: type) -> callable: - Casting the result to the specified type T """ + from frappe.model.document import Document + def decorator(func: callable): # conserve annotations for frappe.utils.typing_validations @functools.wraps(func, assigned=(a for a in functools.WRAPPER_ASSIGNMENTS if a != "__annotations__")) From a869b748f1b38a1d808a632a68f6b27091c6aa52 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:30 +0530 Subject: [PATCH 012/101] refactor: parse native JSON request args in accounts/doctype/account/chart_of_accounts/chart_of_accounts.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../doctype/account/chart_of_accounts/chart_of_accounts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/chart_of_accounts.py b/erpnext/accounts/doctype/account/chart_of_accounts/chart_of_accounts.py index 1ee409a290c..89530b56e81 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/chart_of_accounts.py +++ b/erpnext/accounts/doctype/account/chart_of_accounts/chart_of_accounts.py @@ -137,7 +137,7 @@ def get_charts_for_country(country: str, with_standard: bool = False): def _get_chart_name(content): if content: - content = json.loads(content) + content = frappe.parse_json(content) if ( content and content.get("disabled", "No") == "No" ) or frappe.local.flags.allow_unverified_charts: From a847d15748945ac8fd7321ac1649e7a8c9ee2fe9 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:30 +0530 Subject: [PATCH 013/101] refactor: parse native JSON request args in accounts/doctype/accounting_dimension/accounting_dimension.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../doctype/accounting_dimension/accounting_dimension.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py index 5b0e3bf939b..bff12b0dda2 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py @@ -224,7 +224,7 @@ def disable_dimension(doc: str): def toggle_disabling(doc): - doc = json.loads(doc) + doc = frappe.parse_json(doc) if doc.get("disabled"): df = {"read_only": 1} From ec496c42b534d0c3373518c3d40992a05a6bd1ef Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:30 +0530 Subject: [PATCH 014/101] refactor: parse native JSON request args in accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../bank_reconciliation_tool/bank_reconciliation_tool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py index d9aab98a98f..e84136a04c8 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py @@ -1058,9 +1058,9 @@ def get_auto_reconcile_message(partially_reconciled, reconciled): @frappe.whitelist() -def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str, is_new_voucher: bool = False): +def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str | list, is_new_voucher: bool = False): # updated clear date of all the vouchers based on the bank transaction - vouchers = json.loads(vouchers) + vouchers = frappe.parse_json(vouchers) transaction = frappe.get_doc("Bank Transaction", bank_transaction_name) transaction.add_payment_entries(vouchers, is_new_voucher) transaction.validate_duplicate_references() From 2f0367807f8c088bba6e41065c4cb08630e0b657 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:30 +0530 Subject: [PATCH 015/101] refactor: parse native JSON request args in accounts/doctype/bank_statement_import/bank_statement_import.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../doctype/bank_statement_import/bank_statement_import.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py index facaf80c008..4554ab6a3a2 100644 --- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py @@ -290,7 +290,7 @@ def update_mapping_db(bank, template_options): for d in bank.bank_transaction_mapping: d.delete() - for d in json.loads(template_options)["column_to_field_map"].items(): + for d in frappe.parse_json(template_options)["column_to_field_map"].items(): bank.append("bank_transaction_mapping", {"bank_transaction_field": d[1], "file_field": d[0]}) bank.save() From 9d8f6d4ed92aa49c2a1fb014e6d08933f9653cf0 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:30 +0530 Subject: [PATCH 016/101] refactor: parse native JSON request args in accounts/doctype/bank_statement_import_log/bank_statement_import_log.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../bank_statement_import_log.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py b/erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py index f50549befa1..783ebf23870 100644 --- a/erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py +++ b/erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py @@ -1183,8 +1183,7 @@ def update_pdf_tables(statement_import_id: str, tables: list | str): if doc.status == "Completed": frappe.throw(_("This statement has already been imported."), title=_("Already Imported")) - if isinstance(tables, str): - tables = json.loads(tables) + tables = frappe.parse_json(tables) doc.apply_pdf_tables(tables) @@ -1204,8 +1203,7 @@ def reextract_pdf_table(statement_import_id: str, page: int, table_index: int, b if doc.status == "Completed": frappe.throw(_("This statement has already been imported."), title=_("Already Imported")) - if isinstance(bbox, str): - bbox = json.loads(bbox) + bbox = frappe.parse_json(bbox) page = int(page) table_index = int(table_index) @@ -1290,8 +1288,7 @@ def update_column_mapping(statement_import_id: str, column_mapping: list | str): if doc.status == "Completed": frappe.throw(_("This statement has already been imported."), title=_("Already Imported")) - if isinstance(column_mapping, str): - column_mapping = json.loads(column_mapping) + column_mapping = frappe.parse_json(column_mapping) doc.apply_column_mapping(column_mapping) doc.save() From 475cd838611c60e31d3e6a80899a84d560115f47 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:30 +0530 Subject: [PATCH 017/101] refactor: parse native JSON request args in accounts/doctype/bank_transaction/bank_transaction_upload.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../doctype/bank_transaction/bank_transaction_upload.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py index d0d0188cbd3..c2bac737a78 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py @@ -35,12 +35,12 @@ def upload_bank_statement(): @frappe.whitelist() -def create_bank_entries(columns: str, data: str, bank_account: str): +def create_bank_entries(columns: str, data: str | list, bank_account: str): header_map = get_header_mapping(columns, bank_account) success = 0 errors = 0 - for d in json.loads(data): + for d in frappe.parse_json(data): if all(item is None for item in d) is True: continue fields = {} @@ -66,7 +66,7 @@ def get_header_mapping(columns, bank_account): mapping = get_bank_mapping(bank_account) header_map = {} - for column in json.loads(columns): + for column in frappe.parse_json(columns): if column["content"] in mapping: header_map.update({mapping[column["content"]]: column["colIndex"]}) From e37c7e9b32cf7975277f47534e0b7cdff9547437 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:30 +0530 Subject: [PATCH 018/101] refactor: parse native JSON request args in accounts/doctype/dunning/dunning.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/accounts/doctype/dunning/dunning.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py index f64e957400b..0c5cf545c9c 100644 --- a/erpnext/accounts/doctype/dunning/dunning.py +++ b/erpnext/accounts/doctype/dunning/dunning.py @@ -248,8 +248,7 @@ def get_dunning_letter_text(dunning_type: str, doc: str | dict, language: str | DOCTYPE = "Dunning Letter Text" FIELDS = ["body_text", "closing_text", "language"] - if isinstance(doc, str): - doc = json.loads(doc) + doc = frappe.parse_json(doc) if not language: language = doc.get("language") From 348e3ac4ae376738715020bd2ec08b2ab1a06e18 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 019/101] fix: handle native JSON request args in financial report template Replace json.loads(object_hook=...) with frappe.parse_json and wrap each row in frappe._dict, fixing attribute access when args arrive as native JSON (list of dicts) instead of a JSON string. --- .../financial_report_template/financial_report_engine.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py b/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py index 6d44796fb1c..d113b3b4d0e 100644 --- a/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py +++ b/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py @@ -1032,8 +1032,7 @@ class FormulaFieldUpdater: def get_filtered_accounts(company: str, account_rows: str | list): frappe.has_permission("Financial Report Template", ptype="read", throw=True) - if isinstance(account_rows, str): - account_rows = json.loads(account_rows, object_hook=frappe._dict) + account_rows = [frappe._dict(row) for row in frappe.parse_json(account_rows)] return DataCollector.get_filtered_accounts(company, account_rows) From ecbf8632aa71db3ebd3c166e19dd5d0a0ac9156c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 020/101] refactor: parse native JSON request args in accounts/doctype/invoice_discounting/invoice_discounting.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../doctype/invoice_discounting/invoice_discounting.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py index 3e2f18e1f41..bf4f9e072d0 100644 --- a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py +++ b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py @@ -317,8 +317,8 @@ class InvoiceDiscounting(AccountsController): @frappe.whitelist() -def get_invoices(filters: str): - filters = frappe._dict(json.loads(filters)) +def get_invoices(filters: str | dict): + filters = frappe._dict(frappe.parse_json(filters)) si = frappe.qb.DocType("Sales Invoice") di = frappe.qb.DocType("Discounted Invoice") From 28770e3988448aa204c9b1f6a8d3d2ede885188e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 021/101] refactor: parse native JSON request args in accounts/doctype/payment_entry/payment_entry.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/accounts/doctype/payment_entry/payment_entry.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index d6b597067f6..680755343e6 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1805,8 +1805,7 @@ class PaymentEntry(AccountsController): if not self.references or not matched_payment_requests: return - if isinstance(matched_payment_requests, str): - matched_payment_requests = json.loads(matched_payment_requests) + matched_payment_requests = frappe.parse_json(matched_payment_requests) # modify matched_payment_requests # like (reference_doctype, reference_name, allocated_amount): payment_request @@ -2011,8 +2010,7 @@ def validate_inclusive_tax(tax, doc): @frappe.whitelist() def get_outstanding_reference_documents(args: str | dict, validate: bool = False): - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) if args.get("party_type") == "Member": return From 3b25c2b7c20b125296a4691937ca93ed26dd65b8 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 022/101] refactor: parse native JSON request args in accounts/doctype/payment_request/payment_request.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/accounts/doctype/payment_request/payment_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 93faa06a1a2..c4d6f11f85b 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -931,7 +931,7 @@ def apply_payment_references(pr, payment_reference): def set_payment_references(payment_schedules): - payment_schedules = json.loads(payment_schedules) if payment_schedules else [] + payment_schedules = frappe.parse_json(payment_schedules) if payment_schedules else [] payment_reference = [] for row in payment_schedules: From 2985a8b263310718775156aa2142d8e6ff7b4af3 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 023/101] refactor: parse native JSON request args in accounts/doctype/pos_invoice/pos_invoice.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/accounts/doctype/pos_invoice/pos_invoice.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 23ef9099b6d..71d04db1ea0 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -1036,8 +1036,7 @@ def make_sales_return(source_name: str, target_doc: Document | str | None = None def make_merge_log(invoices: str | list): import json - if isinstance(invoices, str): - invoices = json.loads(invoices) + invoices = frappe.parse_json(invoices) if len(invoices) == 0: frappe.throw(_("At least one invoice has to be selected.")) From 2bc943c7e212aa40776f1931ac2542c177f1fa1a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 024/101] refactor: parse native JSON request args in accounts/doctype/pricing_rule/pricing_rule.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../accounts/doctype/pricing_rule/pricing_rule.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index d691040104b..c372381850b 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -341,8 +341,7 @@ def apply_pricing_rule(args: str | dict, doc: str | dict | Document | None = Non } """ - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) args = frappe._dict(args) @@ -397,8 +396,7 @@ def get_pricing_rule_for_item(args, doc=None, for_validate=False): get_product_discount_rule, ) - if isinstance(doc, str): - doc = json.loads(doc) + doc = frappe.parse_json(doc) if doc: doc = frappe.get_doc(doc) @@ -628,9 +626,7 @@ def remove_pricing_rule_for_item( get_pricing_rule_items, ) - if isinstance(item_details, str): - item_details = json.loads(item_details) - item_details = frappe._dict(item_details) + item_details = frappe._dict(frappe.parse_json(item_details)) for d in get_applied_pricing_rules(pricing_rules): if not d or not frappe.db.exists("Pricing Rule", d): @@ -671,8 +667,7 @@ def remove_pricing_rule_for_item( @frappe.whitelist() def remove_pricing_rules(item_list: str | list): - if isinstance(item_list, str): - item_list = json.loads(item_list) + item_list = frappe.parse_json(item_list) out = [] for item in item_list: From ffce7aff55f88906b6faf3f09ca46bcd84c51481 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 025/101] refactor: parse native JSON request args in accounts/doctype/pricing_rule/utils.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/accounts/doctype/pricing_rule/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py index ca1e6f2f129..9fabb2bdc89 100644 --- a/erpnext/accounts/doctype/pricing_rule/utils.py +++ b/erpnext/accounts/doctype/pricing_rule/utils.py @@ -636,7 +636,7 @@ def remove_free_item(doc): def get_applied_pricing_rules(pricing_rules): if pricing_rules: if pricing_rules.startswith("["): - return json.loads(pricing_rules) + return frappe.parse_json(pricing_rules) else: return pricing_rules.split(",") From 668ca62ea54c266060d03cfb75fc09f455c972e9 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 026/101] refactor: parse native JSON request args in accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../process_payment_reconciliation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py index f4440345e96..21ac42a5d3a 100644 --- a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py +++ b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py @@ -542,8 +542,7 @@ def check_multi_currency(pr_doc): def is_any_doc_running(for_filter: str | dict | None = None) -> str | None: running_doc = None if for_filter: - if isinstance(for_filter, str): - for_filter = json.loads(for_filter) + for_filter = frappe.parse_json(for_filter) running_doc = frappe.db.get_value( "Process Payment Reconciliation", From 4fc952badf93de472f93b47fca02a1d0983c50ce Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 027/101] refactor: parse native JSON request args in accounts/doctype/purchase_invoice/mapper.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/accounts/doctype/purchase_invoice/mapper.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/mapper.py b/erpnext/accounts/doctype/purchase_invoice/mapper.py index 7c50121f1e5..0d0a771ea37 100644 --- a/erpnext/accounts/doctype/purchase_invoice/mapper.py +++ b/erpnext/accounts/doctype/purchase_invoice/mapper.py @@ -50,8 +50,7 @@ def make_purchase_receipt( ): if args is None: args = {} - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) def post_parent_process(source_parent, target_parent): remove_items_with_zero_qty(target_parent) From fe8be87200d8f68f723f7bbb2ec00ca93ad42d0d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 028/101] refactor: parse native JSON request args in accounts/doctype/unreconcile_payment/unreconcile_payment.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../doctype/unreconcile_payment/unreconcile_payment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py b/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py index be4b1674241..97a17c21600 100644 --- a/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py +++ b/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py @@ -201,9 +201,9 @@ def get_linked_advances(company, docname): @frappe.whitelist() -def create_unreconcile_doc_for_selection(selections: str | None = None): +def create_unreconcile_doc_for_selection(selections: str | list | None = None): if selections: - selections = json.loads(selections) + selections = frappe.parse_json(selections) # assuming each row is a unique voucher for row in selections: unrecon = frappe.new_doc("Unreconcile Payment") From 0e862d61d1129fab613d52af3f40da62860eea31 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 029/101] refactor: parse native JSON request args in accounts/services/child_item_update.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/accounts/services/child_item_update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/services/child_item_update.py b/erpnext/accounts/services/child_item_update.py index 0871c732b56..7a8ff400a65 100644 --- a/erpnext/accounts/services/child_item_update.py +++ b/erpnext/accounts/services/child_item_update.py @@ -30,7 +30,7 @@ class ChildItemUpdater: self._ordered_items: dict | None = None self._purchased_items: dict | None = None - def update(self, trans_items: str) -> None: + def update(self, trans_items: str | list) -> None: """Process item additions, edits, and deletions from trans_items JSON.""" from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items from erpnext.selling.doctype.quotation.mapper import get_ordered_items From 97f128791c78686ce2237dbd93fb7faf0c56c703 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 030/101] refactor: parse native JSON request args in assets/doctype/asset/asset.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/assets/doctype/asset/asset.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 1f0bee0f021..f2a71bb5f64 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -995,8 +995,7 @@ class Asset(AccountsController): @frappe.whitelist() def get_depreciation_rate(self, args: str | dict | Document, on_validate: bool = False): - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) rate_field_precision = frappe.get_single_value("System Settings", "float_precision") or 2 From f1499b210fef08d68b423f685638f438e305082e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 031/101] refactor: parse native JSON request args in assets/doctype/asset/mapper.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/assets/doctype/asset/mapper.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/assets/doctype/asset/mapper.py b/erpnext/assets/doctype/asset/mapper.py index 282d58a987c..aabe28179e7 100644 --- a/erpnext/assets/doctype/asset/mapper.py +++ b/erpnext/assets/doctype/asset/mapper.py @@ -162,8 +162,7 @@ def make_asset_movement( assets: list[dict] | str, purpose: str = "Transfer", ): - if isinstance(assets, str): - assets = json.loads(assets) + assets = frappe.parse_json(assets) if len(assets) == 0: frappe.throw(_("At least one asset has to be selected.")) From 5fa15996399e24d45565e2713e48471be60106c3 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 032/101] refactor: parse native JSON request args in assets/doctype/asset_capitalization/asset_capitalization.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../doctype/asset_capitalization/asset_capitalization.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py index ada205080cb..188254929d9 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py @@ -669,8 +669,7 @@ def get_service_item_details(ctx: ItemDetailsCtx) -> frappe._dict: @frappe.whitelist() def get_items_tagged_to_wip_composite_asset(params: dict | str): - if isinstance(params, str): - params = json.loads(params) + params = frappe.parse_json(params) fields = [ "item_code", From 7ec052a0845ec4f10fd791ab798ca644125ad23d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 033/101] refactor: parse native JSON request args in buying/doctype/purchase_order/mapper.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/buying/doctype/purchase_order/mapper.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/mapper.py b/erpnext/buying/doctype/purchase_order/mapper.py index e379824f33f..468ab3e2e5d 100644 --- a/erpnext/buying/doctype/purchase_order/mapper.py +++ b/erpnext/buying/doctype/purchase_order/mapper.py @@ -27,8 +27,7 @@ def make_purchase_receipt( ): if args is None: args = {} - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) has_unit_price_items = frappe.db.get_value("Purchase Order", source_name, "has_unit_price_items") @@ -123,8 +122,7 @@ def make_purchase_invoice_from_portal(purchase_order_name: str): def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions=False, args=None): if args is None: args = {} - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) def postprocess(source, target): target.flags.ignore_permissions = ignore_permissions @@ -294,7 +292,7 @@ def get_mapped_subcontracting_order(source_name: str, target_doc: str | Document ) or frappe.get_value("Production Plan", target_doc.production_plan, "reserve_stock") if target_doc and isinstance(target_doc, str): - target_doc = json.loads(target_doc) + target_doc = frappe.parse_json(target_doc) for key in ["service_items", "items", "supplied_items"]: if key in target_doc: del target_doc[key] From b3c64107dff9ddd71f9170b6fd35b5fb28cad7fd Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 034/101] refactor: parse native JSON request args in buying/doctype/purchase_order/purchase_order.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/buying/doctype/purchase_order/purchase_order.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 976ee5bbf9e..be27000db2b 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -549,11 +549,11 @@ def item_last_purchase_rate(name, conversion_rate, item_code, conversion_factor= @frappe.whitelist() -def close_or_unclose_purchase_orders(names: str, status: str): +def close_or_unclose_purchase_orders(names: str | list, status: str): if not frappe.has_permission("Purchase Order", "write"): frappe.throw(_("Not permitted"), frappe.PermissionError) - names = json.loads(names) + names = frappe.parse_json(names) for name in names: po = frappe.get_lazy_doc("Purchase Order", name) if po.docstatus == 1: From 91c92b5e20eb905a6ed2aac859a4b203a8d2fe30 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 035/101] refactor: parse native JSON request args in buying/doctype/request_for_quotation/mapper.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/buying/doctype/request_for_quotation/mapper.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/buying/doctype/request_for_quotation/mapper.py b/erpnext/buying/doctype/request_for_quotation/mapper.py index b435bb607f5..77e9f02db85 100644 --- a/erpnext/buying/doctype/request_for_quotation/mapper.py +++ b/erpnext/buying/doctype/request_for_quotation/mapper.py @@ -57,8 +57,7 @@ def make_supplier_quotation_from_rfq( # This method is used to make supplier quotation from supplier's portal. @frappe.whitelist() def create_supplier_quotation(doc: str | Document | dict): - if isinstance(doc, str): - doc = json.loads(doc) + doc = frappe.parse_json(doc) if frappe.session.user not in frappe.get_all( "Portal User", {"parent": doc.get("supplier")}, pluck="user" From c6d34a18a54efedcae20df8452e8e48ffa85e231 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:31 +0530 Subject: [PATCH 036/101] refactor: parse native JSON request args in buying/doctype/supplier_quotation/mapper.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/buying/doctype/supplier_quotation/mapper.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/buying/doctype/supplier_quotation/mapper.py b/erpnext/buying/doctype/supplier_quotation/mapper.py index aebe5d94a4c..67bd32223e6 100644 --- a/erpnext/buying/doctype/supplier_quotation/mapper.py +++ b/erpnext/buying/doctype/supplier_quotation/mapper.py @@ -15,8 +15,7 @@ def make_purchase_order( ): if args is None: args = {} - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) def set_missing_values(source, target): target.run_method("set_missing_values") From 1cf5cb242539fc37e1873f35fc42207345302192 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:32 +0530 Subject: [PATCH 037/101] refactor: parse native JSON request args in buying/utils.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/buying/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/buying/utils.py b/erpnext/buying/utils.py index f661ecb5d3d..293c084520e 100644 --- a/erpnext/buying/utils.py +++ b/erpnext/buying/utils.py @@ -124,12 +124,12 @@ def check_on_hold_or_closed_status(doctype, docname) -> None: @frappe.whitelist() -def get_linked_material_requests(items: str): +def get_linked_material_requests(items: str | list): """ Retrieve Material Requests linked to a list of items. """ - items = json.loads(items) + items = frappe.parse_json(items) mr_list = [] mr = frappe.qb.DocType("Material Request") From d8ff9b7dbb5bfceeac5eda31cb668b0e016c3d75 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:32 +0530 Subject: [PATCH 038/101] refactor: parse native JSON request args in controllers/item_variant.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/controllers/item_variant.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index a1a6c477f13..4dadc91da3b 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -45,8 +45,7 @@ def get_variant( if item_template.variant_based_on == "Manufacturer" and manufacturer: return make_variant_based_on_manufacturer(item_template, manufacturer, manufacturer_part_no) - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) attribute_args = {k: v for k, v in args.items() if k != "use_template_image"} if not attribute_args: @@ -258,8 +257,7 @@ def find_variant(template, args, variant_item_code=None): @frappe.whitelist() def create_variant(item: str, args: dict | str, use_template_image: bool = False): use_template_image = frappe.parse_json(use_template_image) - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) template = frappe.get_doc("Item", item) variant = frappe.new_doc("Item") @@ -286,10 +284,7 @@ def create_variant(item: str, args: dict | str, use_template_image: bool = False def enqueue_multiple_variant_creation(item: str, args: dict | str, use_template_image: bool = False): use_template_image = frappe.parse_json(use_template_image) # There can be innumerable attribute combinations, enqueue - if isinstance(args, str): - variants = json.loads(args) - else: - variants = args + variants = frappe.parse_json(args) variants = {key: values for key, values in variants.items() if values} if not variants: frappe.throw(_("Please select at least one attribute value")) @@ -315,8 +310,7 @@ def enqueue_multiple_variant_creation(item: str, args: dict | str, use_template_ def create_multiple_variants(item, args, use_template_image=False): count = 0 - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) args = {key: values for key, values in args.items() if values} template_item = frappe.get_doc("Item", item) @@ -483,7 +477,7 @@ def make_variant_item_code(template_item_code, template_item_name, variant): @frappe.whitelist() def create_variant_doc_for_quick_entry(template: str, args: dict | str): variant_based_on = frappe.db.get_value("Item", template, "variant_based_on") - args = json.loads(args) + args = frappe.parse_json(args) if variant_based_on == "Manufacturer": variant = get_variant(template, **args) else: From b5687d659f9ca91a9f9a75dd3e53020a6bac678f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:32 +0530 Subject: [PATCH 039/101] refactor: parse native JSON request args in controllers/queries.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/controllers/queries.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index f6e322eca6a..74b8b8af761 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -213,8 +213,7 @@ def item_query( """ doctype = "Item" - if isinstance(filters, str): - filters = json.loads(filters) + filters = frappe.parse_json(filters) if filters and isinstance(filters, dict): if filters.get("customer") or filters.get("supplier"): From 432b4f7f86c8c493f9a09cc0214ff97dcabb65f2 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:32 +0530 Subject: [PATCH 040/101] refactor: parse native JSON request args in controllers/stock_controller.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/controllers/stock_controller.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index e350f2d950c..5354c8c6f4e 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -625,8 +625,7 @@ def repost_required_for_queue(doc: StockController) -> bool: def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str | list[dict]): from erpnext.stock.services.quality_inspection_service import INSPECTION_FIELDNAME_MAP - if isinstance(items, str): - items = json.loads(items) + items = frappe.parse_json(items) inspection_fieldname = INSPECTION_FIELDNAME_MAP.get(doctype) if inspection_fieldname is None: @@ -658,8 +657,7 @@ def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str def make_quality_inspections( company: str, doctype: str, docname: str, items: str | list, inspection_type: str ): - if isinstance(items, str): - items = json.loads(items) + items = frappe.parse_json(items) inspections = [] for item in items: From 0ff4840dcb5f2afb0f4c1ff15f2fc78a3e59e646 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:32 +0530 Subject: [PATCH 041/101] refactor: parse native JSON request args in controllers/taxes_and_totals.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/controllers/taxes_and_totals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 11b7f8d9011..b598db625a4 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -342,7 +342,7 @@ class calculate_taxes_and_totals: self._set_in_company_currency(item, ["net_rate", "net_amount"]) def _load_item_tax_rate(self, item_tax_rate): - return json.loads(item_tax_rate) if item_tax_rate else {} + return frappe.parse_json(item_tax_rate) if item_tax_rate else {} def get_current_tax_fraction(self, tax, item_tax_map): """ From dcc8d08521cc7f4315f1e6b28c3f1ee008818811 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:32 +0530 Subject: [PATCH 042/101] refactor: parse native JSON request args in crm/doctype/contract_template/contract_template.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/crm/doctype/contract_template/contract_template.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/crm/doctype/contract_template/contract_template.py b/erpnext/crm/doctype/contract_template/contract_template.py index d2a77e426f4..b9dc9c8b7f3 100644 --- a/erpnext/crm/doctype/contract_template/contract_template.py +++ b/erpnext/crm/doctype/contract_template/contract_template.py @@ -35,8 +35,7 @@ class ContractTemplate(Document): @frappe.whitelist() def get_contract_template(template_name: str, doc: str | dict | Document): - if isinstance(doc, str): - doc = json.loads(doc) + doc = frappe.parse_json(doc) contract_template = frappe.get_doc("Contract Template", template_name) contract_terms = None From 50f2654eb1f5681d0e6bdfba4194ed22a35514b8 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:32 +0530 Subject: [PATCH 043/101] refactor: parse native JSON request args in crm/doctype/opportunity/opportunity.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/crm/doctype/opportunity/opportunity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index 84ac1f4d300..5932a35cde4 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -391,7 +391,7 @@ def get_item_details(item_code: str): @frappe.whitelist() def set_multiple_status(names: str | list[str], status: str): - names = json.loads(names) + names = frappe.parse_json(names) for name in names: opp = frappe.get_doc("Opportunity", name) opp.status = status From ffa85a4ed6f36899077d76c3af35c31a766a60fc Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:32 +0530 Subject: [PATCH 044/101] refactor: parse native JSON request args in crm/frappe_crm_api.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/crm/frappe_crm_api.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/crm/frappe_crm_api.py b/erpnext/crm/frappe_crm_api.py index c86876cf084..27b44222b44 100644 --- a/erpnext/crm/frappe_crm_api.py +++ b/erpnext/crm/frappe_crm_api.py @@ -69,8 +69,7 @@ def create_contacts(contacts, organization=None, link_doctype=None, link_docname def create_address(doctype, docname, address): if not address: return - if isinstance(address, str): - address = json.loads(address) + address = frappe.parse_json(address) try: _address = frappe.db.exists("Address", address.get("name")) if not _address: From afb2616aeebb9fef2d395d48f884488d348e3444 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:32 +0530 Subject: [PATCH 045/101] refactor: parse native JSON request args in edi/doctype/code_list/code_list_import.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/edi/doctype/code_list/code_list_import.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/edi/doctype/code_list/code_list_import.py b/erpnext/edi/doctype/code_list/code_list_import.py index 0f6a51fc993..beffaff190b 100644 --- a/erpnext/edi/doctype/code_list/code_list_import.py +++ b/erpnext/edi/doctype/code_list/code_list_import.py @@ -156,13 +156,15 @@ def process_genericode_import( code_column: str, title_column: str | None = None, description_column: str | None = None, - filters: str | None = None, + filters: str | list | dict | None = None, ): from erpnext.edi.doctype.common_code.common_code import import_genericode column_map = {"code": code_column, "title": title_column, "description": description_column} - return import_genericode(code_list_name, file_name, column_map, json.loads(filters) if filters else None) + return import_genericode( + code_list_name, file_name, column_map, frappe.parse_json(filters) if filters else None + ) def get_genericode_columns_and_examples(root): From 2e75a4b830595b0e8883b9f3752a305fcee40eac Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:32 +0530 Subject: [PATCH 046/101] refactor: parse native JSON request args in erpnext_integrations/doctype/plaid_settings/plaid_settings.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../doctype/plaid_settings/plaid_settings.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py index 36c2f8c7fc8..84ca5cbcff3 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py @@ -51,8 +51,8 @@ def get_plaid_configuration(): @frappe.whitelist() -def add_institution(token: str, response: str): - response = json.loads(response) +def add_institution(token: str, response: str | dict): + response = frappe.parse_json(response) plaid = PlaidConnector() access_token = plaid.get_access_token(token) @@ -81,12 +81,11 @@ def add_institution(token: str, response: str): @frappe.whitelist() def add_bank_accounts(response: str | dict, bank: str | dict, company: str): try: - response = json.loads(response) + response = frappe.parse_json(response) except TypeError: pass - if isinstance(bank, str): - bank = json.loads(bank) + bank = frappe.parse_json(bank) result = [] parent_gl_account = frappe.db.get_all( @@ -358,8 +357,8 @@ def get_company(bank_account_name): @frappe.whitelist() -def update_bank_account_ids(response: str): - data = json.loads(response) +def update_bank_account_ids(response: str | dict): + data = frappe.parse_json(response) institution_name = data["institution"]["name"] bank = frappe.get_doc("Bank", institution_name).as_dict() bank_account_name = f"{data['account']['name']} - {institution_name}" From 5c12bf02d8927d54213f2e54713ffec0e589a762 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:32 +0530 Subject: [PATCH 047/101] refactor: parse native JSON request args in manufacturing/doctype/bom/bom.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/manufacturing/doctype/bom/bom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 061fbbf43e7..0f375271607 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -585,7 +585,7 @@ class BOM(WebsiteGenerator): if isinstance(kwargs, str): import json - kwargs = json.loads(kwargs) + kwargs = frappe.parse_json(kwargs) return kwargs From 5a1abf61382eb98a54c310386b07d7ddfb4c45ec Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:33 +0530 Subject: [PATCH 048/101] refactor: parse native JSON request args in manufacturing/doctype/bom_update_tool/bom_update_tool.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../manufacturing/doctype/bom_update_tool/bom_update_tool.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py index 983dd2d4cd8..70052591b6a 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py @@ -32,8 +32,7 @@ class BOMUpdateTool(Document): def enqueue_replace_bom(boms: dict | str | None = None, args: dict | str | None = None) -> "BOMUpdateLog": """Returns a BOM Update Log (that queues a job) for BOM Replacement.""" boms = boms or args - if isinstance(boms, str): - boms = json.loads(boms) + boms = frappe.parse_json(boms) update_log = create_bom_update_log(boms=boms) return update_log From 6b1e18f79e645225120f8957646053921d7114e1 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:33 +0530 Subject: [PATCH 049/101] refactor: parse native JSON request args in manufacturing/doctype/job_card/job_card.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/manufacturing/doctype/job_card/job_card.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index f36373323d4..9f4db6c117c 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1685,8 +1685,7 @@ class JobCard(Document): @frappe.whitelist() def make_time_log(kwargs: str | dict): - if isinstance(kwargs, str): - kwargs = json.loads(kwargs) + kwargs = frappe.parse_json(kwargs) kwargs = frappe._dict(kwargs) doc = frappe.get_doc("Job Card", kwargs.job_card_id) @@ -1761,8 +1760,7 @@ def get_job_card_filter_conditions(jc, filters): Replaces the previous raw SQL ``get_filters_cond`` based filtering so that all user supplied values are passed as bound parameters via the query builder. """ - if isinstance(filters, str): - filters = json.loads(filters) + filters = frappe.parse_json(filters) if not filters: return [] From 3946bf53664e7e3cae88d4e05326d0318622b897 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:33 +0530 Subject: [PATCH 050/101] refactor: parse native JSON request args in manufacturing/doctype/production_plan/services/material_request.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../doctype/production_plan/services/material_request.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/services/material_request.py b/erpnext/manufacturing/doctype/production_plan/services/material_request.py index a17d434136a..c4f12d8f1b7 100644 --- a/erpnext/manufacturing/doctype/production_plan/services/material_request.py +++ b/erpnext/manufacturing/doctype/production_plan/services/material_request.py @@ -159,8 +159,7 @@ def get_items_for_material_requests( def _normalize_mr_doc(doc): - if isinstance(doc, str): - doc = frappe._dict(json.loads(doc)) + doc = frappe._dict(frappe.parse_json(doc)) return doc From e14719b0ade5825bfd73835bbbe117204635f998 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:33 +0530 Subject: [PATCH 051/101] refactor: parse native JSON request args in manufacturing/doctype/production_plan/services/planning_queries.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../doctype/production_plan/services/planning_queries.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/services/planning_queries.py b/erpnext/manufacturing/doctype/production_plan/services/planning_queries.py index 87ccdd2b5c7..615d840f08a 100644 --- a/erpnext/manufacturing/doctype/production_plan/services/planning_queries.py +++ b/erpnext/manufacturing/doctype/production_plan/services/planning_queries.py @@ -24,8 +24,7 @@ def get_bin_details( ): frappe.has_permission("Production Plan", "read", throw=True) - if isinstance(row, str): - row = frappe._dict(json.loads(row)) + row = frappe._dict(frappe.parse_json(row)) bin = frappe.qb.DocType("Bin") subquery = _bin_warehouse_subquery(bin, company, row, for_warehouse, all_warehouse) @@ -65,8 +64,7 @@ def _bin_qty_columns(bin): def get_warehouse_list(warehouses): warehouse_list = [] - if isinstance(warehouses, str): - warehouses = json.loads(warehouses) + warehouses = frappe.parse_json(warehouses) for row in warehouses: child_warehouses = frappe.db.get_descendants("Warehouse", row.get("warehouse")) From c1ec503858775a002aaf2bb38b3001672d0077bc Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:33 +0530 Subject: [PATCH 052/101] refactor: parse native JSON request args in manufacturing/doctype/work_order/mapper.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/manufacturing/doctype/work_order/mapper.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/mapper.py b/erpnext/manufacturing/doctype/work_order/mapper.py index 05ae74df42f..74377094470 100644 --- a/erpnext/manufacturing/doctype/work_order/mapper.py +++ b/erpnext/manufacturing/doctype/work_order/mapper.py @@ -148,8 +148,7 @@ def _new_work_order(item, bom_no, company, item_details, use_multi_level_bom): def add_variant_item(variant_items, wo_doc, bom_no, table_name="items"): - if isinstance(variant_items, str): - variant_items = json.loads(variant_items) + variant_items = frappe.parse_json(variant_items) for item in variant_items: _add_variant_row(item, wo_doc, bom_no, table_name) @@ -289,8 +288,7 @@ def _set_stock_entry_warehouses(stock_entry, work_order, purpose, target_warehou def make_job_card(work_order: str, operations: str | list, parent_bom: str | None = None): frappe.has_permission("Job Card", "create", throw=True) - if isinstance(operations, str): - operations = json.loads(operations) + operations = frappe.parse_json(operations) work_order = frappe.get_doc("Work Order", work_order) for row in operations: @@ -469,10 +467,10 @@ def get_work_order_operation_data(work_order, operation, workstation): @frappe.whitelist() -def create_pick_list(source_name: str, target_doc: str | None = None, for_qty: float | None = None): +def create_pick_list(source_name: str, target_doc: str | dict | None = None, for_qty: float | None = None): frappe.has_permission("Pick List", "create", throw=True) - for_qty = for_qty or json.loads(target_doc).get("for_qty") + for_qty = for_qty or frappe.parse_json(target_doc).get("for_qty") max_finished_goods_qty = frappe.db.get_value("Work Order", source_name, "qty") postprocess = partial( _set_pick_list_item_qty, for_qty=for_qty, max_finished_goods_qty=max_finished_goods_qty From e432f8284bd8d78670a42600452e180584ecc88e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:33 +0530 Subject: [PATCH 053/101] refactor: parse native JSON request args in projects/doctype/project/project.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/projects/doctype/project/project.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index 037e46de78f..2b567ce44e1 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -629,11 +629,11 @@ def allow_to_make_project_update(project, time, frequency): @frappe.whitelist() -def create_duplicate_project(prev_doc: str, project_name: str): +def create_duplicate_project(prev_doc: str | dict, project_name: str): """Create duplicate project based on the old project""" import json - prev_doc = json.loads(prev_doc) + prev_doc = frappe.parse_json(prev_doc) if project_name == prev_doc.get("name"): frappe.throw(_("Use a name that is different from previous project name")) From 5629f81809cf34c4986b79c22b5eefdd17d6a31a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:33 +0530 Subject: [PATCH 054/101] refactor: parse native JSON request args in projects/doctype/task/task.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/projects/doctype/task/task.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/erpnext/projects/doctype/task/task.py b/erpnext/projects/doctype/task/task.py index d7781df5f86..6cdfd50933a 100755 --- a/erpnext/projects/doctype/task/task.py +++ b/erpnext/projects/doctype/task/task.py @@ -363,8 +363,8 @@ def get_project(doctype: str, txt: str, searchfield: str, start: int, page_len: @frappe.whitelist() -def set_multiple_status(names: str, status: str): - names = json.loads(names) +def set_multiple_status(names: str | list, status: str): + names = frappe.parse_json(names) for name in names: task = frappe.get_doc("Task", name) task.status = status @@ -459,8 +459,8 @@ def add_node(): @frappe.whitelist() -def add_multiple_tasks(data: str, parent: str): - data = json.loads(data) +def add_multiple_tasks(data: str | list, parent: str): + data = frappe.parse_json(data) new_doc = {"doctype": "Task", "parent_task": parent if parent != "All Tasks" else ""} new_doc["project"] = frappe.db.get_value("Task", {"name": parent}, "project") or "" From cb236dedfcbd1d8c7004b7a7c1ed70371c215521 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:33 +0530 Subject: [PATCH 055/101] refactor: parse native JSON request args in projects/doctype/timesheet/timesheet.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/projects/doctype/timesheet/timesheet.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index 04819e68a0f..29684a12508 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -497,7 +497,7 @@ def get_activity_cost( @frappe.whitelist() -def get_events(start: str, end: str, filters: str | None = None): +def get_events(start: str, end: str, filters: str | dict | None = None): """Returns events for Gantt / Calendar view rendering. :param start: Start date-time. :param end: End date-time. @@ -505,7 +505,7 @@ def get_events(start: str, end: str, filters: str | None = None): """ from erpnext.utilities.query import get_event_conditions_qb - filters = json.loads(filters) if filters else {} + filters = frappe.parse_json(filters) if filters else {} tsd = frappe.qb.DocType("Timesheet Detail") ts = frappe.qb.DocType("Timesheet") From cd8b740cb30d332054f2a25178d517734d20da19 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:34 +0530 Subject: [PATCH 056/101] refactor: parse native JSON request args in regional/italy/utils.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/regional/italy/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/regional/italy/utils.py b/erpnext/regional/italy/utils.py index 7c8727aa7fa..b012ae9be69 100644 --- a/erpnext/regional/italy/utils.py +++ b/erpnext/regional/italy/utils.py @@ -104,7 +104,7 @@ def prepare_invoice(invoice, progressive_number): def get_conditions(filters): - filters = json.loads(filters) + filters = frappe.parse_json(filters) conditions = {"docstatus": 1, "company_tax_id": ("!=", "")} From 7835cbaa5674d93e34dd4c63359b11113aa4c0c6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:34 +0530 Subject: [PATCH 057/101] refactor: parse native JSON request args in regional/report/irs_1099/irs_1099.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/regional/report/irs_1099/irs_1099.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/regional/report/irs_1099/irs_1099.py b/erpnext/regional/report/irs_1099/irs_1099.py index cebc60d1aec..7d9b02ecf54 100644 --- a/erpnext/regional/report/irs_1099/irs_1099.py +++ b/erpnext/regional/report/irs_1099/irs_1099.py @@ -84,7 +84,7 @@ def get_columns(): @frappe.whitelist() -def irs_1099_print(filters: str): +def irs_1099_print(filters: str | dict): if not filters: frappe._dict( { @@ -93,7 +93,7 @@ def irs_1099_print(filters: str): } ) else: - filters = frappe._dict(json.loads(filters)) + filters = frappe._dict(frappe.parse_json(filters)) fiscal_year_doc = get_fiscal_year(fiscal_year=filters.fiscal_year, as_dict=True) fiscal_year = cstr(fiscal_year_doc.year_start_date.year) From 5543577ca7442d65d7b782b94ccdd08db3e68ae8 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:34 +0530 Subject: [PATCH 058/101] refactor: parse native JSON request args in selling/doctype/customer/customer.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/selling/doctype/customer/customer.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index e8ba3eab04d..cb5e770b141 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -559,8 +559,7 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False, def send_emails( customer: str, customer_outstanding: float, credit_limit: float, credit_controller_users_list: str | list ): - if isinstance(credit_controller_users_list, str): - credit_controller_users_list = json.loads(credit_controller_users_list) + credit_controller_users_list = frappe.parse_json(credit_controller_users_list) subject = _("Credit limit reached for customer {0}").format(customer) message = _("Credit limit has been crossed for customer {0} ({1}/{2})").format( customer, customer_outstanding, credit_limit From bf30b58d0272f4b276dc551344d5be464a33e524 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:34 +0530 Subject: [PATCH 059/101] refactor: parse native JSON request args in selling/doctype/quotation/mapper.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/selling/doctype/quotation/mapper.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/erpnext/selling/doctype/quotation/mapper.py b/erpnext/selling/doctype/quotation/mapper.py index 166bd5278ab..2182c969d4e 100644 --- a/erpnext/selling/doctype/quotation/mapper.py +++ b/erpnext/selling/doctype/quotation/mapper.py @@ -31,8 +31,7 @@ def make_sales_order( def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, args=None): if args is None: args = {} - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) customer = _make_customer(source_name, ignore_permissions) ordered_items = get_ordered_items(source_name) @@ -151,8 +150,7 @@ def make_sales_invoice( def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, args=None): if args is None: args = {} - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) customer = _make_customer(source_name, ignore_permissions) From 517f97ff7342cc8102b0b0febb59be2733aedf83 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:34 +0530 Subject: [PATCH 060/101] refactor: parse native JSON request args in selling/doctype/sales_order/mapper.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/selling/doctype/sales_order/mapper.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/mapper.py b/erpnext/selling/doctype/sales_order/mapper.py index d2972fdc302..9fddf353d92 100644 --- a/erpnext/selling/doctype/sales_order/mapper.py +++ b/erpnext/selling/doctype/sales_order/mapper.py @@ -430,8 +430,7 @@ def make_sales_invoice( ): if args is None: args = {} - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) # 0 qty is accepted, as the qty is uncertain for some items has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items") @@ -675,8 +674,7 @@ def make_purchase_order( if not selected_items: return - if isinstance(selected_items, str): - selected_items = json.loads(selected_items) + selected_items = frappe.parse_json(selected_items) def set_missing_values(source, target): target.supplier = supplier @@ -843,9 +841,9 @@ def set_delivery_date(items: list, sales_order: str) -> None: @frappe.whitelist() -def make_work_orders(items: str, sales_order: str, company: str, project: str | None = None): +def make_work_orders(items: str | dict, sales_order: str, company: str, project: str | None = None): """Make Work Orders against the given Sales Order for the given `items`""" - items = json.loads(items).get("items") + items = frappe.parse_json(items).get("items") out = [] for i in items: @@ -912,8 +910,7 @@ def make_raw_material_request( if not frappe.has_permission("Sales Order", "write"): frappe.throw(_("Not permitted"), frappe.PermissionError) - if isinstance(items, str): - items = frappe._dict(json.loads(items)) + items = frappe._dict(frappe.parse_json(items)) for item in items.get("items"): item["include_exploded_items"] = items.get("include_exploded_items") @@ -1089,7 +1086,7 @@ def get_mapped_subcontracting_inward_order( target_doc.populate_items_table() if target_doc and isinstance(target_doc, str): - target_doc = json.loads(target_doc) + target_doc = frappe.parse_json(target_doc) for key in ["service_items", "items", "received_items"]: if key in target_doc: del target_doc[key] From f4df5ee0bc496cc3cc682d4cc0469ec3673bbf5e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:34 +0530 Subject: [PATCH 061/101] refactor: parse native JSON request args in selling/doctype/sales_order/sales_order.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/selling/doctype/sales_order/sales_order.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index c468b92f183..784feff8ed7 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -711,11 +711,11 @@ def is_enable_cutoff_date_on_bulk_delivery_note_creation(): @frappe.whitelist() -def close_or_unclose_sales_orders(names: str, status: str): +def close_or_unclose_sales_orders(names: str | list, status: str): if not frappe.has_permission("Sales Order", "write"): frappe.throw(_("Not permitted"), frappe.PermissionError) - names = json.loads(names) + names = frappe.parse_json(names) for name in names: so = frappe.get_lazy_doc("Sales Order", name) if so.docstatus == 1: From 8dd05ca0563080bef26c60927ec8806c3da92399 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:34 +0530 Subject: [PATCH 062/101] refactor: parse native JSON request args in selling/page/point_of_sale/point_of_sale.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/selling/page/point_of_sale/point_of_sale.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py index 83e7bac3fef..a96fc309687 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.py +++ b/erpnext/selling/page/point_of_sale/point_of_sale.py @@ -347,8 +347,8 @@ def check_opening_entry(user: str): @frappe.whitelist() -def create_opening_voucher(pos_profile: str, company: str, balance_details: str): - balance_details = json.loads(balance_details) +def create_opening_voucher(pos_profile: str, company: str, balance_details: str | list): + balance_details = frappe.parse_json(balance_details) new_pos_opening = frappe.get_doc( { From 78f38970c1f8aa8d0dc0f12d6a4a77059df9415b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:34 +0530 Subject: [PATCH 063/101] refactor: parse native JSON request args in setup/doctype/department/department.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/setup/doctype/department/department.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/setup/doctype/department/department.py b/erpnext/setup/doctype/department/department.py index 71cf6e96743..a92c77f249d 100644 --- a/erpnext/setup/doctype/department/department.py +++ b/erpnext/setup/doctype/department/department.py @@ -77,8 +77,7 @@ def get_children( is_root: bool = False, include_disabled: str | dict | None = None, ): - if isinstance(include_disabled, str): - include_disabled = json.loads(include_disabled) + include_disabled = frappe.parse_json(include_disabled) fields = ["name as value", "is_group as expandable"] filters = {} From 76705dd736a04081f793b61200ba59d785a1f9d5 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:34 +0530 Subject: [PATCH 064/101] refactor: parse native JSON request args in setup/doctype/holiday_list/holiday_list.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/setup/doctype/holiday_list/holiday_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/setup/doctype/holiday_list/holiday_list.py b/erpnext/setup/doctype/holiday_list/holiday_list.py index a7c26857ca3..7095da60788 100644 --- a/erpnext/setup/doctype/holiday_list/holiday_list.py +++ b/erpnext/setup/doctype/holiday_list/holiday_list.py @@ -176,7 +176,7 @@ def get_events(start: DateTimeLikeObject, end: DateTimeLikeObject, filters: str :param filters: Filters (JSON). """ if filters: - filters = json.loads(filters) + filters = frappe.parse_json(filters) else: filters = [] From 460b8c5d8d981fa23f9ab3961c0e8f976659421f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:34 +0530 Subject: [PATCH 065/101] refactor: parse native JSON request args in setup/doctype/terms_and_conditions/terms_and_conditions.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../setup/doctype/terms_and_conditions/terms_and_conditions.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py index 32623605f51..f9fd9050d34 100644 --- a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py +++ b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py @@ -37,8 +37,7 @@ class TermsandConditions(Document): @frappe.whitelist() def get_terms_and_conditions(template_name: str, doc: str | dict): - if isinstance(doc, str): - doc = json.loads(doc) + doc = frappe.parse_json(doc) terms_and_conditions = frappe.get_doc("Terms and Conditions", template_name) From 04a93cabf18a5ba06bdf2538e85c846e8df9fa7d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:34 +0530 Subject: [PATCH 066/101] refactor: parse native JSON request args in stock/doctype/batch/batch.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/stock/doctype/batch/batch.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 57b133bb4cc..46c059b25c9 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -404,8 +404,7 @@ def make_batch(kwargs): def get_pos_reserved_batch_qty(filters: dict | str): import json - if isinstance(filters, str): - filters = json.loads(filters) + filters = frappe.parse_json(filters) p = frappe.qb.DocType("POS Invoice").as_("p") item = frappe.qb.DocType("POS Invoice Item").as_("item") From 5a77df6560221a533e26899c5c4ecd59370bf214 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:34 +0530 Subject: [PATCH 067/101] refactor: parse native JSON request args in stock/doctype/delivery_note/mapper.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/stock/doctype/delivery_note/mapper.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/delivery_note/mapper.py b/erpnext/stock/doctype/delivery_note/mapper.py index 605a2d22df6..e4a0eaefe93 100644 --- a/erpnext/stock/doctype/delivery_note/mapper.py +++ b/erpnext/stock/doctype/delivery_note/mapper.py @@ -66,8 +66,7 @@ def make_sales_invoice( if args is None: args = {} - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) doc = frappe.get_doc("Delivery Note", source_name) From ccd115e769c07597f952cb4e10d696492dc7d168 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:34 +0530 Subject: [PATCH 068/101] refactor: parse native JSON request args in stock/doctype/material_request/mapper.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/stock/doctype/material_request/mapper.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py index 5561ad8251f..cb2d19c0adb 100644 --- a/erpnext/stock/doctype/material_request/mapper.py +++ b/erpnext/stock/doctype/material_request/mapper.py @@ -53,8 +53,7 @@ def make_purchase_order( ): if args is None: args = {} - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) is_subcontracted = ( frappe.db.get_value("Material Request", source_name, "material_request_type") == "Subcontracting" From 68e92a893a47f0d775716e7c95634a7392f35a4c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:34 +0530 Subject: [PATCH 069/101] refactor: parse native JSON request args in stock/doctype/packed_item/packed_item.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/stock/doctype/packed_item/packed_item.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 7dbeb109c59..5aff12994c9 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -432,7 +432,7 @@ def on_doctype_update(): @frappe.whitelist() -def get_items_from_product_bundle(row: str): +def get_items_from_product_bundle(row: str | dict): """Item details for each component of a Product Bundle. ``row.product_bundle`` selects a specific version by document name (the buying @@ -441,7 +441,7 @@ def get_items_from_product_bundle(row: str): """ from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle - row, items = ItemDetailsCtx(json.loads(row)), [] + row, items = ItemDetailsCtx(frappe.parse_json(row)), [] if bundle_name := row.get("product_bundle"): frappe.has_permission("Product Bundle", "read", bundle_name, throw=True) From 487aff80e07eeafc33e20a3c35e89fa3656b15fc Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:34 +0530 Subject: [PATCH 070/101] refactor: parse native JSON request args in stock/doctype/pick_list/mapper.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/stock/doctype/pick_list/mapper.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/erpnext/stock/doctype/pick_list/mapper.py b/erpnext/stock/doctype/pick_list/mapper.py index 2d7f0e91ab8..b1168e112a8 100644 --- a/erpnext/stock/doctype/pick_list/mapper.py +++ b/erpnext/stock/doctype/pick_list/mapper.py @@ -113,8 +113,7 @@ def create_dn_for_pick_lists( """Get Items from Multiple Pick Lists and create a Delivery Note for filtered customer""" if kwargs is None: kwargs = {} - if isinstance(kwargs, str): - kwargs = json.loads(kwargs) + kwargs = frappe.parse_json(kwargs) pick_list = frappe.get_doc("Pick List", source_name) validate_item_locations(pick_list) @@ -282,8 +281,8 @@ def add_product_bundles_to_target(pick_list, target_doc, item_mapper, sales_orde @frappe.whitelist() -def create_stock_entry(pick_list: str): - pick_list = frappe.get_doc(json.loads(pick_list)) +def create_stock_entry(pick_list: str | dict): + pick_list = frappe.get_doc(frappe.parse_json(pick_list)) validate_item_locations(pick_list) if stock_entry_exists(pick_list.get("name")): From a11eb741e50866ee64c21b7f4a35c142b6ad37b7 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 071/101] refactor: parse native JSON request args in stock/doctype/purchase_receipt/mapper.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/stock/doctype/purchase_receipt/mapper.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/purchase_receipt/mapper.py b/erpnext/stock/doctype/purchase_receipt/mapper.py index 273afcec7a9..528b6865ed5 100644 --- a/erpnext/stock/doctype/purchase_receipt/mapper.py +++ b/erpnext/stock/doctype/purchase_receipt/mapper.py @@ -60,8 +60,7 @@ def make_purchase_invoice( ): if args is None: args = {} - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) from erpnext.accounts.party import get_payment_terms_template From 8ca2e99cf22c98be290ffcbaf9da5fbe3e3d6811 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 072/101] refactor: parse native JSON request args in stock/doctype/putaway_rule/putaway_rule.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/stock/doctype/putaway_rule/putaway_rule.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/putaway_rule/putaway_rule.py b/erpnext/stock/doctype/putaway_rule/putaway_rule.py index b7dacb9c230..4f5967654ac 100644 --- a/erpnext/stock/doctype/putaway_rule/putaway_rule.py +++ b/erpnext/stock/doctype/putaway_rule/putaway_rule.py @@ -111,8 +111,7 @@ def apply_putaway_rule( purpose: Purpose of Stock Entry sync (optional): Sync with client side only for client side calls """ - if isinstance(items, str): - items = json.loads(items) + items = frappe.parse_json(items) items_not_accomodated, updated_table = [], [] item_wise_rules = defaultdict(list) @@ -198,7 +197,7 @@ def apply_putaway_rule( frappe.msgprint(_("Applied putaway rules."), alert=True) return updated_table - if sync and json.loads(sync): # sync with client side + if sync and frappe.parse_json(sync): # sync with client side return items From 2c7cea28796f2fc10de3d3ee4c736c0726fe61ae Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 073/101] refactor: parse native JSON request args in stock/doctype/repost_item_valuation/repost_item_valuation.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../doctype/repost_item_valuation/repost_item_valuation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index 311396d2bd3..befb52c444a 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -364,8 +364,8 @@ class RepostItemValuation(Document): @frappe.whitelist() -def bulk_restart_reposting(names: str): - names = json.loads(names) +def bulk_restart_reposting(names: str | list): + names = frappe.parse_json(names) for name in names: doc = frappe.get_doc("Repost Item Valuation", name) if doc.status != "Failed": From ddd57ca12e6378abca197f259d3885935ba4eaa1 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 074/101] refactor: parse native JSON request args in stock/doctype/serial_no/serial_no.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/stock/doctype/serial_no/serial_no.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index 6ea89b2db8b..a8d9b9f1e7d 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -222,8 +222,7 @@ def auto_fetch_serial_number( @frappe.whitelist() def get_pos_reserved_serial_nos(filters: str | dict): - if isinstance(filters, str): - filters = json.loads(filters) + filters = frappe.parse_json(filters) POSInvoice = frappe.qb.DocType("POS Invoice") POSInvoiceItem = frappe.qb.DocType("POS Invoice Item") From f5bf9392a082793341046e57fad672b9ac40b886 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 075/101] refactor: parse native JSON request args in stock/doctype/stock_entry/services/manufacturing.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/stock/doctype/stock_entry/services/manufacturing.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/services/manufacturing.py b/erpnext/stock/doctype/stock_entry/services/manufacturing.py index b3455c40b51..216d3c9eea9 100644 --- a/erpnext/stock/doctype/stock_entry/services/manufacturing.py +++ b/erpnext/stock/doctype/stock_entry/services/manufacturing.py @@ -1040,8 +1040,7 @@ def ceil_qty_if_uom_has_whole_number(qty, stock_uom): @frappe.whitelist() def move_sample_to_retention_warehouse(company: str, items: str | list): - if isinstance(items, str): - items = json.loads(items) + items = frappe.parse_json(items) retention_warehouse = frappe.get_single_value("Stock Settings", "sample_retention_warehouse") stock_entry = frappe.new_doc("Stock Entry") From 63a1b7d8e5227c77dc59c8c4dab0d571c59d964f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 076/101] refactor: parse native JSON request args in stock/doctype/stock_entry/services/subcontracting.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/stock/doctype/stock_entry/services/subcontracting.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/services/subcontracting.py b/erpnext/stock/doctype/stock_entry/services/subcontracting.py index d4b4921794d..5c3a1b89da1 100644 --- a/erpnext/stock/doctype/stock_entry/services/subcontracting.py +++ b/erpnext/stock/doctype/stock_entry/services/subcontracting.py @@ -253,8 +253,7 @@ def get_supplied_items( def get_items_from_subcontract_order(source_name: str, target_doc: str | Document | None = None): from erpnext.controllers.subcontracting_controller import make_rm_stock_entry - if isinstance(target_doc, str): - target_doc = frappe.get_doc(json.loads(target_doc)) + target_doc = frappe.get_doc(frappe.parse_json(target_doc)) order_doctype = "Purchase Order" if target_doc.purchase_order else "Subcontracting Order" target_doc = make_rm_stock_entry( From 9506a9d62a919c1ce8f37499843ead3085f15d6e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 077/101] refactor: parse native JSON request args in stock/doctype/stock_entry/stock_entry.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/stock/doctype/stock_entry/stock_entry.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 0ccd3a747b4..ab0496ac908 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1683,8 +1683,7 @@ def get_uom_details(item_code: str, uom: str, qty: float | None): @frappe.whitelist() def get_warehouse_details(args: str | dict): - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) args = frappe._dict(args) From 5aeb711f6926ec39b15d67d75e195c56ab52f6c0 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 078/101] refactor: parse native JSON request args in stock/doctype/stock_reconciliation/stock_reconciliation.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../stock/doctype/stock_reconciliation/stock_reconciliation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index c2cf93ca1d2..341dd22c0b4 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -1239,8 +1239,7 @@ def get_stock_balance_for( item_dict = frappe.get_cached_value("Item", item_code, ["has_serial_no", "has_batch_no"], as_dict=1) - if isinstance(row, str): - row = json.loads(row) + row = frappe.parse_json(row) if isinstance(row, dict): row = frappe._dict(row) From a6ede74b2dd10e12004d5f342c25b9953c8b2515 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 079/101] refactor: parse native JSON request args in stock/doctype/warehouse/warehouse.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/stock/doctype/warehouse/warehouse.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index 4448ab8e52c..1e2d84d1b11 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -176,8 +176,7 @@ def get_children( if is_root: parent = "" - if isinstance(include_disabled, str): - include_disabled = json.loads(include_disabled) + include_disabled = frappe.parse_json(include_disabled) fields = ["name as value", "is_group as expandable"] From cb2679ba2cb4846d1b10ad0722d5149fd592d3c6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 080/101] refactor: parse native JSON request args in stock/get_item_details.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/stock/get_item_details.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index b11868347ea..11990db26b8 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -90,8 +90,7 @@ def get_item_details( item = frappe.get_cached_doc("Item", ctx.item_code) validate_item_details(ctx, item) - if isinstance(doc, str): - doc = json.loads(doc) + doc = frappe.parse_json(doc) if doc: ctx.transaction_date = doc.get("transaction_date") or doc.get("posting_date") From f707da40ec1e37e9a9c78d7e04781cfc4d90dbd6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 081/101] refactor: parse native JSON request args in stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- .../report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py index 83ff83e869d..46c0c5da4ea 100644 --- a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py +++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py @@ -100,12 +100,12 @@ def get_data(filters=None): @frappe.whitelist() -def update_batch_qty(selected_batches: str | None = None): +def update_batch_qty(selected_batches: str | list | None = None): frappe.has_permission("Batch", "write", throw=True, ignore_share_permissions=True) if not selected_batches: return - selected_batches = json.loads(selected_batches) + selected_batches = frappe.parse_json(selected_batches) for row in selected_batches: batch_name = row.get("batch") From b3526db6432f104676cb361393d276b9c9fe18fc Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 082/101] refactor: parse native JSON request args in stock/utils.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/stock/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 1598860b165..8ebef95e47f 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -245,8 +245,7 @@ def get_incoming_rate(args: dict | str, raise_error_if_no_rate: bool = True, fal """Get Incoming Rate based on valuation method""" from erpnext.stock.stock_ledger import get_previous_sle, get_valuation_rate - if isinstance(args, str): - args = json.loads(args) + args = frappe.parse_json(args) if not args.get("posting_datetime") and args.get("posting_date"): args["posting_datetime"] = get_combine_datetime(args.get("posting_date"), args.get("posting_time")) From 7cbebc0545834b7ee9772613e890b29a967edc5e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 083/101] refactor: parse native JSON request args in support/doctype/issue/issue.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/support/doctype/issue/issue.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py index e269246c0e6..adf46d0d7a2 100644 --- a/erpnext/support/doctype/issue/issue.py +++ b/erpnext/support/doctype/issue/issue.py @@ -217,8 +217,8 @@ def get_issue_list(doctype, txt, filters, limit_start, limit_page_length=20, ord @frappe.whitelist() -def set_multiple_status(names: str, status: str): - for name in json.loads(names): +def set_multiple_status(names: str | list, status: str): + for name in frappe.parse_json(names): set_status(name, status) From 7e8965c6be48298d34acbf9a504dfd3123c9d2a4 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 084/101] refactor: parse native JSON request args in utilities/bulk_transaction.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/utilities/bulk_transaction.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/erpnext/utilities/bulk_transaction.py b/erpnext/utilities/bulk_transaction.py index 0b6cbdbb830..33a0fa7f73f 100644 --- a/erpnext/utilities/bulk_transaction.py +++ b/erpnext/utilities/bulk_transaction.py @@ -13,13 +13,9 @@ def transaction_processing( frappe.has_permission(from_doctype, "read", throw=True) frappe.has_permission(to_doctype, "create", throw=True) - if isinstance(data, str): - deserialized_data = json.loads(data) - else: - deserialized_data = data + deserialized_data = frappe.parse_json(data) - if isinstance(args, str): - args = frappe._dict(json.loads(args)) + args = frappe._dict(frappe.parse_json(args)) skipped_records = [d for d in deserialized_data if d.get("status") in ("On Hold", "Closed")] From 785c34e0ad2a98276984bd723ba8c424a0956dac Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 085/101] refactor: parse native JSON request args in utilities/query.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/utilities/query.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/utilities/query.py b/erpnext/utilities/query.py index ef0ef3e716d..21759b36b97 100644 --- a/erpnext/utilities/query.py +++ b/erpnext/utilities/query.py @@ -62,8 +62,7 @@ def get_filter_conditions_qb(doctype, filters, ignore_permissions=None): if isinstance(filters, Criterion): return [filters] - if isinstance(filters, str): - filters = json.loads(filters) + filters = frappe.parse_json(filters) if isinstance(filters, dict): # Mirror get_filters_cond's dict normalization: a string value prefixed with "!" means From 9955adb2fcbaefd17cb9c781ff61071442e43e4e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 086/101] refactor: parse native JSON request args in www/book_appointment/index.py Use frappe.parse_json instead of json.loads so the whitelisted endpoints accept native JSON types (list/dict/bool) in addition to JSON strings. --- erpnext/www/book_appointment/index.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/www/book_appointment/index.py b/erpnext/www/book_appointment/index.py index 14f5fe385b4..ef7985ed514 100644 --- a/erpnext/www/book_appointment/index.py +++ b/erpnext/www/book_appointment/index.py @@ -101,7 +101,7 @@ def get_available_slots_between(query_start_time, query_end_time, settings): @frappe.whitelist(allow_guest=True) -def create_appointment(date: str, time: str, tz: str, contact: str): +def create_appointment(date: str, time: str, tz: str, contact: str | dict): handle_appointment_booking_disabled() format_string = "%Y-%m-%d %H:%M:%S" scheduled_time = datetime.datetime.strptime(date + " " + time, format_string) @@ -112,7 +112,7 @@ def create_appointment(date: str, time: str, tz: str, contact: str): # Create a appointment document from form appointment = frappe.new_doc("Appointment") appointment.scheduled_time = scheduled_time - contact = json.loads(contact) + contact = frappe.parse_json(contact) appointment.customer_name = contact.get("name", None) appointment.customer_phone_number = contact.get("number", None) appointment.customer_skype = contact.get("skype", None) From 8854f0c15315ddd8841f51100507592f50858dd1 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:37:35 +0530 Subject: [PATCH 087/101] feat!: send ERPNext requests as native JSON (use_json_request_body) Opt ERPNext into native application/json request bodies (frappe#40237). Non-GET requests to erpnext.* endpoints now send args as a JSON body instead of form-encoded, per-key JSON-stringified values. Safe after the preceding commits hardened every whitelisted endpoint with frappe.parse_json. --- erpnext/hooks.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 2b966df04d6..1ff17cac37d 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -712,6 +712,10 @@ default_log_clearing_doctypes = { export_python_type_annotations = True +# Send non-GET requests for ERPNext's endpoints as native `application/json` +# bodies instead of form-encoded, per-key JSON-stringified values. +use_json_request_body = True + fields_for_group_similar_items = ["qty", "amount"] # Translation From 3fa7ec656b55d72355b0913460a1cf59d0e191f4 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:55:01 +0530 Subject: [PATCH 088/101] fix: parse native JSON schedules arg in make_payment_request make_payment_request(**args) is whitelisted and the client passes `schedules` as a list, so json.loads(args.get("schedules")) raised TypeError under JSON body mode. Use frappe.parse_json. --- erpnext/accounts/doctype/payment_request/payment_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index c4d6f11f85b..26d5c2ce833 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -740,7 +740,7 @@ def make_payment_request(**args): # Schedule-based PRs are allowed only if no Payment Entry exists for this document. # Any existing Payment Entry forces legacy (amount-based) flow. - selected_payment_schedules = json.loads(args.get("schedules")) if args.get("schedules") else [] + selected_payment_schedules = frappe.parse_json(args.get("schedules")) if args.get("schedules") else [] # Backend guard: # If any Payment Entry exists, schedule-based PRs are not allowed. From 6a4afd1733a939bf6c1aafaf799a3d3de198c025 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:55:01 +0530 Subject: [PATCH 089/101] fix: parse native JSON contacts args in CRM prospect/customer endpoints create_prospect_against_crm_deal and create_customer read `contacts` from form_dict (json.loads(doc.contacts) / customer_data.get("contacts")), which arrives as a native list under JSON body mode. Use frappe.parse_json. --- erpnext/crm/frappe_crm_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/crm/frappe_crm_api.py b/erpnext/crm/frappe_crm_api.py index 27b44222b44..0230dda7925 100644 --- a/erpnext/crm/frappe_crm_api.py +++ b/erpnext/crm/frappe_crm_api.py @@ -33,7 +33,7 @@ def create_prospect_against_crm_deal(): pass if doc.contacts and len(doc.contacts): - create_contacts(json.loads(doc.contacts), prospect.company_name, "Prospect", prospect_name) + create_contacts(frappe.parse_json(doc.contacts), prospect.company_name, "Prospect", prospect_name) create_address("Prospect", prospect_name, doc.address) frappe.response["message"] = prospect_name @@ -152,7 +152,7 @@ def create_customer(customer_data: dict | None = None): customer.insert(ignore_permissions=True) customer_name = customer.name - contacts = json.loads(customer_data.get("contacts")) + contacts = frappe.parse_json(customer_data.get("contacts")) create_contacts(contacts, customer_name, "Customer", customer_name) create_address("Customer", customer_name, customer_data.get("address")) return customer_name From 71b4cc4f12963026acb54a0e50040a6f856cb36b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 20:55:01 +0530 Subject: [PATCH 090/101] refactor: drop dead except TypeError in add_bank_accounts frappe.parse_json never raises TypeError (unlike json.loads on a non-str), so the try/except guarding the parse is now unreachable. --- .../doctype/plaid_settings/plaid_settings.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py index 84ca5cbcff3..ccb9133eb62 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py @@ -80,11 +80,7 @@ def add_institution(token: str, response: str | dict): @frappe.whitelist() def add_bank_accounts(response: str | dict, bank: str | dict, company: str): - try: - response = frappe.parse_json(response) - except TypeError: - pass - + response = frappe.parse_json(response) bank = frappe.parse_json(bank) result = [] From f034bb55d391b7eff835ce287a0451181ab39b63 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 22:01:55 +0530 Subject: [PATCH 091/101] fix: correct process_genericode_import filters hint to str | dict | None import_genericode consumes filters via (filters or {}).items(), so it is a dict, not a list. --- erpnext/edi/doctype/code_list/code_list_import.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/edi/doctype/code_list/code_list_import.py b/erpnext/edi/doctype/code_list/code_list_import.py index beffaff190b..0b8ec588b30 100644 --- a/erpnext/edi/doctype/code_list/code_list_import.py +++ b/erpnext/edi/doctype/code_list/code_list_import.py @@ -156,7 +156,7 @@ def process_genericode_import( code_column: str, title_column: str | None = None, description_column: str | None = None, - filters: str | list | dict | None = None, + filters: str | dict | None = None, ): from erpnext.edi.doctype.common_code.common_code import import_genericode From 3be80d8e877f982f18b1163857d97e583f8dade8 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 24 Jun 2026 22:01:55 +0530 Subject: [PATCH 092/101] fix: include list in get_events filters type hint The calendar view sends filters as a list of [doctype, field, op, value] conditions (filter_area.get()); get_filter_conditions_qb accepts dict or list. --- erpnext/projects/doctype/timesheet/timesheet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index 29684a12508..d227b225f3b 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -497,7 +497,7 @@ def get_activity_cost( @frappe.whitelist() -def get_events(start: str, end: str, filters: str | dict | None = None): +def get_events(start: str, end: str, filters: str | list | dict | None = None): """Returns events for Gantt / Calendar view rendering. :param start: Start date-time. :param end: End date-time. From adb768505a5d621064edf21a1645f8c0ecd1183a Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 11 Jun 2026 19:40:24 +0530 Subject: [PATCH 093/101] refactor: reports on duckdb --- .../report/general_ledger/general_ledger.py | 9 +++++++++ .../report/trial_balance/trial_balance.py | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index dec6e18da20..11111e0bd68 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -817,3 +817,12 @@ def get_columns(filters): columns.extend([{"label": _("Remarks"), "fieldname": "remarks", "width": 400}]) return columns + + +def execute_duckdb(filters, duckdb_conn): + print(filters) + conn = duckdb_conn + columns = get_columns(filters) + res = [] + + return columns, res diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 4aff8b3305c..f9cec7c2dac 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -581,3 +581,23 @@ def hide_group_accounts(data): d.update(indent=0) non_group_accounts_data.append(d) return non_group_accounts_data + + +def execute_duckdb(filters, duckdb_conn): + validate_filters(filters) + conn = duckdb_conn + data = [] + res = conn.sql( + f"select account, sum(debit), sum(credit), account_currency from \"tabGL Entry\" where company = '{filters.company}' and posting_date between '{filters.from_date}' and '{filters.to_date}' and is_opening = 'No' group by account, account_currency;" + ).fetchall() + for x in res: + data.append( + { + "account": x[0], + "debit": x[1], + "credit": x[2], + } + ) + + columns = get_columns() + return columns, data From b1c8e2cb5cf75dc4f3cf6a7e6a71534cb805069e Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 15 Jun 2026 12:51:44 +0530 Subject: [PATCH 094/101] feat(trial-balance): implement execute_duckdb with full parity to normal report Replaces the placeholder stub with 8 focused functions that mirror the normal execute() flow using parameterized DuckDB SQL queries: account fetch, period GL entries, opening balances (with Period Closing Voucher path), and all filters (cost center, project, finance book, accounting dimensions). Reuses existing pure-Python processing functions unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../report/trial_balance/trial_balance.py | 284 +++++++++++++++++- 1 file changed, 270 insertions(+), 14 deletions(-) diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index f9cec7c2dac..502964443f9 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -585,19 +585,275 @@ def hide_group_accounts(data): def execute_duckdb(filters, duckdb_conn): validate_filters(filters) - conn = duckdb_conn - data = [] - res = conn.sql( - f"select account, sum(debit), sum(credit), account_currency from \"tabGL Entry\" where company = '{filters.company}' and posting_date between '{filters.from_date}' and '{filters.to_date}' and is_opening = 'No' group by account, account_currency;" - ).fetchall() - for x in res: - data.append( - { - "account": x[0], - "debit": x[1], - "credit": x[2], - } - ) - columns = get_columns() + data = get_data_duckdb(filters, duckdb_conn) return columns, data + + +def get_data_duckdb(filters, conn): + accounts = get_accounts_duckdb(conn, filters.company) + if not accounts: + return None + + company_currency = filters.presentation_currency or erpnext.get_company_currency(filters.company) + ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") + + accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) + + gl_entries_by_account = get_period_gl_entries_duckdb(conn, filters, ignore_is_opening) + opening_balances = get_opening_balances_duckdb(conn, filters, ignore_is_opening) + + calculate_values( + accounts, + gl_entries_by_account, + opening_balances, + filters.get("show_net_values"), + ignore_is_opening=ignore_is_opening, + ) + accumulate_values_into_parents(accounts, accounts_by_name) + + data = prepare_data(accounts, filters, parent_children_map, company_currency) + data = filter_out_zero_value_rows( + data, parent_children_map, show_zero_values=filters.get("show_zero_values") + ) + + return data + + +def get_accounts_duckdb(conn, company): + rows = conn.execute( + """SELECT name, account_number, parent_account, account_name, root_type, + report_type, is_group, lft, rgt + FROM "tabAccount" WHERE company = ? ORDER BY lft""", + [company], + ).fetchall() + cols = [ + "name", + "account_number", + "parent_account", + "account_name", + "root_type", + "report_type", + "is_group", + "lft", + "rgt", + ] + return [frappe._dict(zip(cols, row, strict=False)) for row in rows] + + +def _build_common_gl_filters(filters): + """Returns (sql_fragments, params) for filters shared across all GL/ACB queries.""" + sql = [] + params = [] + + if filters.get("cost_center"): + cost_centers = get_cost_centers_with_children(filters.get("cost_center")) + placeholders = ", ".join(["?" for _ in cost_centers]) + sql.append(f"AND cost_center IN ({placeholders})") + params.extend(cost_centers) + + if filters.get("project"): + proj_list = filters.project if isinstance(filters.project, list) else [filters.project] + placeholders = ", ".join(["?" for _ in proj_list]) + sql.append(f"AND project IN ({placeholders})") + params.extend(proj_list) + + if frappe.db.count("Finance Book"): + company_fb = frappe.get_cached_value("Company", filters.company, "default_finance_book") + if filters.get("include_default_book_entries"): + if filters.get("finance_book") and company_fb and cstr(filters.finance_book) != cstr(company_fb): + frappe.throw( + _("To use a different finance book, please uncheck 'Include Default FB Entries'") + ) + fb_list = [cstr(filters.get("finance_book")), cstr(company_fb), ""] + else: + fb_list = [cstr(filters.get("finance_book")), ""] + placeholders = ", ".join(["?" for _ in fb_list]) + sql.append(f"AND (finance_book IN ({placeholders}) OR finance_book IS NULL)") + params.extend(fb_list) + + accounting_dimensions = get_accounting_dimensions(as_list=False) + for dimension in accounting_dimensions: + if filters.get(dimension.fieldname): + if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"): + filters[dimension.fieldname] = get_dimension_with_children( + dimension.document_type, filters.get(dimension.fieldname) + ) + dim_vals = filters[dimension.fieldname] + if not isinstance(dim_vals, list): + dim_vals = [dim_vals] + placeholders = ", ".join(["?" for _ in dim_vals]) + sql.append(f"AND {dimension.fieldname} IN ({placeholders})") + params.extend(dim_vals) + + return sql, params + + +def get_period_gl_entries_duckdb(conn, filters, ignore_is_opening): + ignore_closing_entries = not flt(filters.get("with_period_closing_entry_for_current_period")) + common_sql, common_params = _build_common_gl_filters(filters) + + sql_parts = [ + "SELECT account, SUM(debit) AS debit, SUM(credit) AS credit,", + " SUM(debit_in_account_currency) AS debit_in_account_currency,", + " SUM(credit_in_account_currency) AS credit_in_account_currency,", + " account_currency", + 'FROM "tabGL Entry"', + "WHERE company = ?", + " AND is_cancelled = 0", + " AND posting_date >= ?", + " AND posting_date <= ?", + ] + params = [filters.company, filters.from_date, filters.to_date] + + if not ignore_is_opening: + sql_parts.append(" AND is_opening = 'No'") + + if ignore_closing_entries: + sql_parts.append(" AND voucher_type != 'Period Closing Voucher'") + + sql_parts.extend(common_sql) + params.extend(common_params) + sql_parts.append("GROUP BY account, account_currency") + + rows = conn.execute("\n".join(sql_parts), params).fetchall() + cols = [ + "account", + "debit", + "credit", + "debit_in_account_currency", + "credit_in_account_currency", + "account_currency", + ] + entries = [frappe._dict(zip(cols, row, strict=False)) for row in rows] + + if filters.get("presentation_currency"): + convert_to_presentation_currency(entries, get_currency(filters)) + + gl_entries_by_account = {} + for entry in entries: + gl_entries_by_account.setdefault(entry.account, []).append(entry) + + return gl_entries_by_account + + +def get_opening_balances_duckdb(conn, filters, ignore_is_opening): + bs = _get_rootwise_opening_balances_duckdb(conn, filters, "Balance Sheet", ignore_is_opening) + pl = _get_rootwise_opening_balances_duckdb(conn, filters, "Profit and Loss", ignore_is_opening) + bs.update(pl) + return bs + + +def _get_rootwise_opening_balances_duckdb(conn, filters, report_type, ignore_is_opening): + ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance") + last_period_closing_voucher = None + + if not ignore_closing_balances: + pcv = frappe.db.get_all( + "Period Closing Voucher", + filters={"docstatus": 1, "company": filters.company, "period_end_date": ("<", filters.from_date)}, + fields=["period_end_date", "name"], + order_by="period_end_date desc", + limit=1, + ) + if pcv: + last_period_closing_voucher = pcv[0] + + gle = [] + if last_period_closing_voucher: + gle = _query_opening_balance_duckdb( + conn, + "Account Closing Balance", + filters, + report_type, + ignore_is_opening, + period_closing_voucher=last_period_closing_voucher.name, + ) + if getdate(last_period_closing_voucher.period_end_date) < getdate(add_days(filters.from_date, -1)): + start_date = add_days(last_period_closing_voucher.period_end_date, 1) + gle += _query_opening_balance_duckdb( + conn, + "GL Entry", + filters, + report_type, + ignore_is_opening, + start_date=start_date, + ) + else: + gle = _query_opening_balance_duckdb(conn, "GL Entry", filters, report_type, ignore_is_opening) + + opening = frappe._dict() + for d in gle: + opening.setdefault(d.account, {"account": d.account, "opening_debit": 0.0, "opening_credit": 0.0}) + opening[d.account]["opening_debit"] += flt(d.debit) + opening[d.account]["opening_credit"] += flt(d.credit) + + return opening + + +def _query_opening_balance_duckdb( + conn, doctype, filters, report_type, ignore_is_opening, period_closing_voucher=None, start_date=None +): + table = f'"tab{doctype}"' + common_sql, common_params = _build_common_gl_filters(filters) + + sql_parts = [ + "SELECT account, SUM(debit) AS debit, SUM(credit) AS credit,", + " SUM(debit_in_account_currency) AS debit_in_account_currency,", + " SUM(credit_in_account_currency) AS credit_in_account_currency,", + " account_currency", + f"FROM {table}", + "WHERE company = ?", + ' AND account IN (SELECT name FROM "tabAccount" WHERE report_type = ?)', + ] + params = [filters.company, report_type] + + if doctype == "GL Entry": + sql_parts.append(" AND is_cancelled = 0") + + if start_date: + sql_parts.append(" AND posting_date >= ?") + sql_parts.append(" AND posting_date < ?") + params.extend([start_date, filters.from_date]) + if not ignore_is_opening: + sql_parts.append(" AND is_opening = 'No'") + else: + if not ignore_is_opening: + sql_parts.append(" AND (posting_date < ? OR is_opening = 'Yes')") + params.append(filters.from_date) + else: + sql_parts.append(" AND posting_date < ?") + params.append(filters.from_date) + + if not filters.get("show_unclosed_fy_pl_balances") and report_type == "Profit and Loss": + sql_parts.append(" AND posting_date >= ?") + params.append(filters.year_start_date) + + if not flt(filters.get("with_period_closing_entry_for_opening")): + sql_parts.append(" AND voucher_type != 'Period Closing Voucher'") + else: + sql_parts.append(" AND period_closing_voucher = ?") + params.append(period_closing_voucher) + + if not flt(filters.get("with_period_closing_entry_for_opening")): + sql_parts.append(" AND is_period_closing_voucher_entry = 0") + + sql_parts.extend(common_sql) + params.extend(common_params) + sql_parts.append("GROUP BY account, account_currency") + + rows = conn.execute("\n".join(sql_parts), params).fetchall() + cols = [ + "account", + "debit", + "credit", + "debit_in_account_currency", + "credit_in_account_currency", + "account_currency", + ] + gle = [frappe._dict(zip(cols, row, strict=False)) for row in rows] + + if filters.get("presentation_currency"): + convert_to_presentation_currency(gle, get_currency(filters)) + + return gle From 55862f98f4327a0d7994981beccae17170c6acd8 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 15 Jun 2026 13:47:46 +0530 Subject: [PATCH 095/101] refactor(trial-balance): execute_duckdb only reads GL Entry from duckdb Replaces the previous over-engineered stub with 7 short functions. Account data, Account Closing Balance, and all metadata come from frappe.db as normal; only tabGL Entry is read from the duckdb_conn. Reuses get_opening_balance() for Account Closing Balance unchanged, reuses all downstream compute helpers (calculate_values, prepare_data, etc.) unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../report/trial_balance/trial_balance.py | 257 +++++++----------- 1 file changed, 94 insertions(+), 163 deletions(-) diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 502964443f9..ae1e8471689 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -591,13 +591,18 @@ def execute_duckdb(filters, duckdb_conn): def get_data_duckdb(filters, conn): - accounts = get_accounts_duckdb(conn, filters.company) + # accounts and all metadata via frappe.db — only GL Entry comes from DuckDB + accounts = frappe.db.sql( + """select name, account_number, parent_account, account_name, root_type, report_type, is_group, lft, rgt + from `tabAccount` where company=%s order by lft""", + filters.company, + as_dict=True, + ) if not accounts: return None company_currency = filters.presentation_currency or erpnext.get_company_currency(filters.company) ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") - accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) gl_entries_by_account = get_period_gl_entries_duckdb(conn, filters, ignore_is_opening) @@ -613,50 +618,24 @@ def get_data_duckdb(filters, conn): accumulate_values_into_parents(accounts, accounts_by_name) data = prepare_data(accounts, filters, parent_children_map, company_currency) - data = filter_out_zero_value_rows( + return filter_out_zero_value_rows( data, parent_children_map, show_zero_values=filters.get("show_zero_values") ) - return data - -def get_accounts_duckdb(conn, company): - rows = conn.execute( - """SELECT name, account_number, parent_account, account_name, root_type, - report_type, is_group, lft, rgt - FROM "tabAccount" WHERE company = ? ORDER BY lft""", - [company], - ).fetchall() - cols = [ - "name", - "account_number", - "parent_account", - "account_name", - "root_type", - "report_type", - "is_group", - "lft", - "rgt", - ] - return [frappe._dict(zip(cols, row, strict=False)) for row in rows] - - -def _build_common_gl_filters(filters): - """Returns (sql_fragments, params) for filters shared across all GL/ACB queries.""" - sql = [] - params = [] +def _extra_gl_conditions(filters): + """Returns (conditions, params) for optional shared GL Entry filters.""" + conditions, params = [], [] if filters.get("cost_center"): - cost_centers = get_cost_centers_with_children(filters.get("cost_center")) - placeholders = ", ".join(["?" for _ in cost_centers]) - sql.append(f"AND cost_center IN ({placeholders})") - params.extend(cost_centers) + cc = get_cost_centers_with_children(filters.get("cost_center")) + conditions.append(f"cost_center IN ({', '.join(['?'] * len(cc))})") + params.extend(cc) if filters.get("project"): - proj_list = filters.project if isinstance(filters.project, list) else [filters.project] - placeholders = ", ".join(["?" for _ in proj_list]) - sql.append(f"AND project IN ({placeholders})") - params.extend(proj_list) + proj = filters.project if isinstance(filters.project, list) else [filters.project] + conditions.append(f"project IN ({', '.join(['?'] * len(proj))})") + params.extend(proj) if frappe.db.count("Finance Book"): company_fb = frappe.get_cached_value("Company", filters.company, "default_finance_book") @@ -668,55 +647,27 @@ def _build_common_gl_filters(filters): fb_list = [cstr(filters.get("finance_book")), cstr(company_fb), ""] else: fb_list = [cstr(filters.get("finance_book")), ""] - placeholders = ", ".join(["?" for _ in fb_list]) - sql.append(f"AND (finance_book IN ({placeholders}) OR finance_book IS NULL)") + conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_list))}) OR finance_book IS NULL)") params.extend(fb_list) - accounting_dimensions = get_accounting_dimensions(as_list=False) - for dimension in accounting_dimensions: - if filters.get(dimension.fieldname): - if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"): - filters[dimension.fieldname] = get_dimension_with_children( - dimension.document_type, filters.get(dimension.fieldname) + for dim in get_accounting_dimensions(as_list=False): + if filters.get(dim.fieldname): + if frappe.get_cached_value("DocType", dim.document_type, "is_tree"): + filters[dim.fieldname] = get_dimension_with_children( + dim.document_type, filters.get(dim.fieldname) ) - dim_vals = filters[dimension.fieldname] - if not isinstance(dim_vals, list): - dim_vals = [dim_vals] - placeholders = ", ".join(["?" for _ in dim_vals]) - sql.append(f"AND {dimension.fieldname} IN ({placeholders})") - params.extend(dim_vals) + vals = ( + filters[dim.fieldname] + if isinstance(filters[dim.fieldname], list) + else [filters[dim.fieldname]] + ) + conditions.append(f"{dim.fieldname} IN ({', '.join(['?'] * len(vals))})") + params.extend(vals) - return sql, params + return conditions, params -def get_period_gl_entries_duckdb(conn, filters, ignore_is_opening): - ignore_closing_entries = not flt(filters.get("with_period_closing_entry_for_current_period")) - common_sql, common_params = _build_common_gl_filters(filters) - - sql_parts = [ - "SELECT account, SUM(debit) AS debit, SUM(credit) AS credit,", - " SUM(debit_in_account_currency) AS debit_in_account_currency,", - " SUM(credit_in_account_currency) AS credit_in_account_currency,", - " account_currency", - 'FROM "tabGL Entry"', - "WHERE company = ?", - " AND is_cancelled = 0", - " AND posting_date >= ?", - " AND posting_date <= ?", - ] - params = [filters.company, filters.from_date, filters.to_date] - - if not ignore_is_opening: - sql_parts.append(" AND is_opening = 'No'") - - if ignore_closing_entries: - sql_parts.append(" AND voucher_type != 'Period Closing Voucher'") - - sql_parts.extend(common_sql) - params.extend(common_params) - sql_parts.append("GROUP BY account, account_currency") - - rows = conn.execute("\n".join(sql_parts), params).fetchall() +def _fetch_gl_rows_duckdb(conn, conditions, params): cols = [ "account", "debit", @@ -725,135 +676,115 @@ def get_period_gl_entries_duckdb(conn, filters, ignore_is_opening): "credit_in_account_currency", "account_currency", ] - entries = [frappe._dict(zip(cols, row, strict=False)) for row in rows] + sql = f"""SELECT account, SUM(debit), SUM(credit), + SUM(debit_in_account_currency), SUM(credit_in_account_currency), account_currency + FROM "tabGL Entry" WHERE {" AND ".join(conditions)} + GROUP BY account, account_currency""" + return [frappe._dict(zip(cols, row, strict=False)) for row in conn.execute(sql, params).fetchall()] + +def get_period_gl_entries_duckdb(conn, filters, ignore_is_opening): + conditions = ["company = ?", "is_cancelled = 0", "posting_date >= ?", "posting_date <= ?"] + params = [filters.company, filters.from_date, filters.to_date] + + if not ignore_is_opening: + conditions.append("is_opening = 'No'") + if not flt(filters.get("with_period_closing_entry_for_current_period")): + conditions.append("voucher_type != 'Period Closing Voucher'") + + extra_cond, extra_params = _extra_gl_conditions(filters) + conditions.extend(extra_cond) + params.extend(extra_params) + + entries = _fetch_gl_rows_duckdb(conn, conditions, params) if filters.get("presentation_currency"): convert_to_presentation_currency(entries, get_currency(filters)) gl_entries_by_account = {} for entry in entries: gl_entries_by_account.setdefault(entry.account, []).append(entry) - return gl_entries_by_account def get_opening_balances_duckdb(conn, filters, ignore_is_opening): - bs = _get_rootwise_opening_balances_duckdb(conn, filters, "Balance Sheet", ignore_is_opening) - pl = _get_rootwise_opening_balances_duckdb(conn, filters, "Profit and Loss", ignore_is_opening) + bs = _get_rootwise_opening_duckdb(conn, filters, "Balance Sheet", ignore_is_opening) + pl = _get_rootwise_opening_duckdb(conn, filters, "Profit and Loss", ignore_is_opening) bs.update(pl) return bs -def _get_rootwise_opening_balances_duckdb(conn, filters, report_type, ignore_is_opening): +def _get_rootwise_opening_duckdb(conn, filters, report_type, ignore_is_opening): + accounting_dimensions = get_accounting_dimensions(as_list=False) ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance") - last_period_closing_voucher = None + last_pcv = "" if not ignore_closing_balances: - pcv = frappe.db.get_all( + last_pcv = frappe.db.get_all( "Period Closing Voucher", filters={"docstatus": 1, "company": filters.company, "period_end_date": ("<", filters.from_date)}, fields=["period_end_date", "name"], order_by="period_end_date desc", limit=1, ) - if pcv: - last_period_closing_voucher = pcv[0] - gle = [] - if last_period_closing_voucher: - gle = _query_opening_balance_duckdb( - conn, + if last_pcv: + # Account Closing Balance fetched via frappe (not GL Entry) + gle = get_opening_balance( "Account Closing Balance", filters, report_type, - ignore_is_opening, - period_closing_voucher=last_period_closing_voucher.name, + accounting_dimensions, + period_closing_voucher=last_pcv[0].name, + ignore_is_opening=ignore_is_opening, ) - if getdate(last_period_closing_voucher.period_end_date) < getdate(add_days(filters.from_date, -1)): - start_date = add_days(last_period_closing_voucher.period_end_date, 1) - gle += _query_opening_balance_duckdb( - conn, - "GL Entry", - filters, - report_type, - ignore_is_opening, - start_date=start_date, + if getdate(last_pcv[0].period_end_date) < getdate(add_days(filters.from_date, -1)): + start_date = add_days(last_pcv[0].period_end_date, 1) + gle += _get_gl_entry_opening_duckdb( + conn, filters, report_type, ignore_is_opening, start_date=start_date ) else: - gle = _query_opening_balance_duckdb(conn, "GL Entry", filters, report_type, ignore_is_opening) + gle = _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening) opening = frappe._dict() for d in gle: opening.setdefault(d.account, {"account": d.account, "opening_debit": 0.0, "opening_credit": 0.0}) opening[d.account]["opening_debit"] += flt(d.debit) opening[d.account]["opening_credit"] += flt(d.credit) - return opening -def _query_opening_balance_duckdb( - conn, doctype, filters, report_type, ignore_is_opening, period_closing_voucher=None, start_date=None -): - table = f'"tab{doctype}"' - common_sql, common_params = _build_common_gl_filters(filters) +def _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening, start_date=None): + accounts = frappe.db.get_all("Account", filters={"report_type": report_type}, pluck="name") + if not accounts: + return [] - sql_parts = [ - "SELECT account, SUM(debit) AS debit, SUM(credit) AS credit,", - " SUM(debit_in_account_currency) AS debit_in_account_currency,", - " SUM(credit_in_account_currency) AS credit_in_account_currency,", - " account_currency", - f"FROM {table}", - "WHERE company = ?", - ' AND account IN (SELECT name FROM "tabAccount" WHERE report_type = ?)', - ] - params = [filters.company, report_type] + conditions = ["company = ?", f"account IN ({', '.join(['?'] * len(accounts))})", "is_cancelled = 0"] + params = [filters.company, *accounts] - if doctype == "GL Entry": - sql_parts.append(" AND is_cancelled = 0") - - if start_date: - sql_parts.append(" AND posting_date >= ?") - sql_parts.append(" AND posting_date < ?") - params.extend([start_date, filters.from_date]) - if not ignore_is_opening: - sql_parts.append(" AND is_opening = 'No'") - else: - if not ignore_is_opening: - sql_parts.append(" AND (posting_date < ? OR is_opening = 'Yes')") - params.append(filters.from_date) - else: - sql_parts.append(" AND posting_date < ?") - params.append(filters.from_date) - - if not filters.get("show_unclosed_fy_pl_balances") and report_type == "Profit and Loss": - sql_parts.append(" AND posting_date >= ?") - params.append(filters.year_start_date) - - if not flt(filters.get("with_period_closing_entry_for_opening")): - sql_parts.append(" AND voucher_type != 'Period Closing Voucher'") + if start_date: + conditions.append("posting_date >= ? AND posting_date < ?") + params.extend([start_date, filters.from_date]) + if not ignore_is_opening: + conditions.append("is_opening = 'No'") + elif not ignore_is_opening: + conditions.append("(posting_date < ? OR is_opening = 'Yes')") + params.append(filters.from_date) else: - sql_parts.append(" AND period_closing_voucher = ?") - params.append(period_closing_voucher) + conditions.append("posting_date < ?") + params.append(filters.from_date) - if not flt(filters.get("with_period_closing_entry_for_opening")): - sql_parts.append(" AND is_period_closing_voucher_entry = 0") + if not filters.get("show_unclosed_fy_pl_balances") and report_type == "Profit and Loss": + conditions.append("posting_date >= ?") + params.append(filters.year_start_date) - sql_parts.extend(common_sql) - params.extend(common_params) - sql_parts.append("GROUP BY account, account_currency") + if not flt(filters.get("with_period_closing_entry_for_opening")): + conditions.append("voucher_type != 'Period Closing Voucher'") - rows = conn.execute("\n".join(sql_parts), params).fetchall() - cols = [ - "account", - "debit", - "credit", - "debit_in_account_currency", - "credit_in_account_currency", - "account_currency", - ] - gle = [frappe._dict(zip(cols, row, strict=False)) for row in rows] + extra_cond, extra_params = _extra_gl_conditions(filters) + conditions.extend(extra_cond) + params.extend(extra_params) + gle = _fetch_gl_rows_duckdb(conn, conditions, params) if filters.get("presentation_currency"): convert_to_presentation_currency(gle, get_currency(filters)) - return gle From 5c536b8ad1e7a7c7274cd3f82ac9e9ab2f34891f Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 18 Jun 2026 12:39:25 +0530 Subject: [PATCH 096/101] refactor: maintain sync dependency in report master --- .../report/accounts_payable/accounts_payable.json | 10 +++++++++- .../accounts_receivable/accounts_receivable.json | 10 +++++++++- .../report/general_ledger/general_ledger.json | 10 +++++++++- .../accounts/report/trial_balance/trial_balance.json | 12 ++++++++++-- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.json b/erpnext/accounts/report/accounts_payable/accounts_payable.json index 40aa222cbb0..48380605ccf 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.json +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2013-04-22 16:16:03", "default_print_format": "Accounts Payable Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "Payment Ledger Entry" + } + ], "filters": [], + "generate_csv": 0, "idx": 3, "is_standard": "Yes", - "modified": "2026-05-22 14:35:14.716933", + "modified": "2026-06-18 11:54:12.154865", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Payable", @@ -33,5 +40,6 @@ "role": "Auditor" } ], + "synced_report": 1, "timeout": 0 } diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json index b6e7820f91c..3b4d6594bf1 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2013-04-16 11:31:13", "default_print_format": "Accounts Receivable Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "Payment Ledger Entry" + } + ], "filters": [], + "generate_csv": 0, "idx": 5, "is_standard": "Yes", - "modified": "2026-05-22 14:34:57.666402", + "modified": "2026-06-18 11:53:59.190645", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Receivable", @@ -27,5 +34,6 @@ "role": "Accounts User" } ], + "synced_report": 1, "timeout": 0 } diff --git a/erpnext/accounts/report/general_ledger/general_ledger.json b/erpnext/accounts/report/general_ledger/general_ledger.json index 8dac581eae3..7f5d59a9f98 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.json +++ b/erpnext/accounts/report/general_ledger/general_ledger.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2013-12-06 13:22:23", "default_print_format": "General Ledger Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "GL Entry" + } + ], "filters": [], + "generate_csv": 0, "idx": 4, "is_standard": "Yes", - "modified": "2026-05-22 14:34:35.246000", + "modified": "2026-06-18 11:53:29.057634", "modified_by": "Administrator", "module": "Accounts", "name": "General Ledger", @@ -30,5 +37,6 @@ "role": "Auditor" } ], + "synced_report": 1, "timeout": 0 } diff --git a/erpnext/accounts/report/trial_balance/trial_balance.json b/erpnext/accounts/report/trial_balance/trial_balance.json index b6c121bd5fd..321bf46d05b 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.json +++ b/erpnext/accounts/report/trial_balance/trial_balance.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2014-07-22 11:41:23.743564", "default_print_format": "Trial Balance Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "GL Entry" + } + ], "filters": [], - "idx": 2, + "generate_csv": 0, + "idx": 4, "is_standard": "Yes", - "modified": "2026-05-22 14:35:44.889062", + "modified": "2026-06-18 11:41:42.774023", "modified_by": "Administrator", "module": "Accounts", "name": "Trial Balance", @@ -30,5 +37,6 @@ "role": "Auditor" } ], + "synced_report": 1, "timeout": 0 } From f40cd4180146b76e9b62854dc015b8c0ecfb96f4 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 18 Jun 2026 16:39:55 +0530 Subject: [PATCH 097/101] refactor: DB agnostic method names --- .../report/trial_balance/trial_balance.json | 2 +- .../report/trial_balance/trial_balance.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/report/trial_balance/trial_balance.json b/erpnext/accounts/report/trial_balance/trial_balance.json index 321bf46d05b..7aca6d62acc 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.json +++ b/erpnext/accounts/report/trial_balance/trial_balance.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 4, "is_standard": "Yes", - "modified": "2026-06-18 11:41:42.774023", + "modified": "2026-06-18 16:37:42.112788", "modified_by": "Administrator", "module": "Accounts", "name": "Trial Balance", diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index ae1e8471689..85a5142b777 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -583,11 +583,16 @@ def hide_group_accounts(data): return non_group_accounts_data -def execute_duckdb(filters, duckdb_conn): - validate_filters(filters) - columns = get_columns() - data = get_data_duckdb(filters, duckdb_conn) - return columns, data +def execute_synced_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if conn := get_latest_sync("GL Entry"): + validate_filters(filters) + columns = get_columns() + data = get_data_duckdb(filters, conn) + return columns, data + else: + frappe.throw(_("Trial Balance requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))) def get_data_duckdb(filters, conn): From 6b4895bcc92be13d45d82bd31c3229c1914434c1 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 19 Jun 2026 15:47:57 +0530 Subject: [PATCH 098/101] feat(general-ledger): implement execute_synced_report with full parity to normal report Co-Authored-By: Claude Sonnet 4.6 --- .../report/general_ledger/general_ledger.json | 2 +- .../report/general_ledger/general_ledger.py | 286 +++++++++++++++++- 2 files changed, 282 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/report/general_ledger/general_ledger.json b/erpnext/accounts/report/general_ledger/general_ledger.json index 7f5d59a9f98..914fa496c07 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.json +++ b/erpnext/accounts/report/general_ledger/general_ledger.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 4, "is_standard": "Yes", - "modified": "2026-06-18 11:53:29.057634", + "modified": "2026-06-22 11:50:08.020553", "modified_by": "Administrator", "module": "Accounts", "name": "General Ledger", diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 11111e0bd68..cae1f27a556 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -819,10 +819,286 @@ def get_columns(filters): return columns -def execute_duckdb(filters, duckdb_conn): - print(filters) - conn = duckdb_conn - columns = get_columns(filters) - res = [] +def execute_synced_report(filters): + from frappe.database.duckdb.database import get_latest_sync + if conn := get_latest_sync("GL Entry"): + return _execute_with_duckdb_conn(filters, conn) + + frappe.throw(_("General Ledger requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))) + + +def _execute_with_duckdb_conn(filters, conn): + if not filters: + return [], [] + + account_details = {} + + if filters.get("print_in_account_currency") and not filters.get("account"): + frappe.throw(_("Select an account to print in account currency")) + + for acc in frappe.get_all("Account", fields=["name", "is_group"]): + account_details.setdefault(acc.name, acc) + + if filters.get("party"): + filters.party = frappe.parse_json(filters.get("party")) + + validate_filters(filters, account_details) + validate_party(filters) + filters = set_account_currency(filters) + columns = get_columns(filters) + res = get_result_duckdb(filters, account_details, conn) return columns, res + + +def get_result_duckdb(filters, account_details, conn): + accounting_dimensions = [] + if filters.get("include_dimensions"): + accounting_dimensions = get_accounting_dimensions() + + gl_entries = get_gl_entries_duckdb(filters, accounting_dimensions, conn) + data = get_data_with_opening_closing(filters, account_details, accounting_dimensions, gl_entries) + return get_result_as_list(data, filters) + + +def get_gl_entries_duckdb(filters, accounting_dimensions, conn): + currency_map = get_currency(filters) + + col_names = [ + "gl_entry", + "posting_date", + "account", + "party_type", + "party", + "voucher_type", + "voucher_subtype", + "voucher_no", + "cost_center", + "project", + "against_voucher_type", + "against_voucher", + "account_currency", + "against", + "is_opening", + "creation", + "debit", + "credit", + "debit_in_account_currency", + "credit_in_account_currency", + ] + select_exprs = [ + "name", + "posting_date", + "account", + "party_type", + "party", + "voucher_type", + "voucher_subtype", + "voucher_no", + "cost_center", + "project", + "against_voucher_type", + "against_voucher", + "account_currency", + "against", + "is_opening", + "creation", + "debit", + "credit", + "debit_in_account_currency", + "credit_in_account_currency", + ] + + if filters.get("show_remarks"): + remarks_length = frappe.get_single_value("Accounts Settings", "general_ledger_remarks_length") + if remarks_length: + select_exprs.append(f"substr(remarks, 1, {int(remarks_length)})") + else: + select_exprs.append("remarks") + col_names.append("remarks") + + if filters.get("add_values_in_transaction_currency"): + select_exprs += [ + "debit_in_transaction_currency", + "credit_in_transaction_currency", + "transaction_currency", + ] + col_names += [ + "debit_in_transaction_currency", + "credit_in_transaction_currency", + "transaction_currency", + ] + + if accounting_dimensions: + select_exprs += accounting_dimensions + col_names += accounting_dimensions + + order_by = "posting_date, account, creation" + if filters.get("include_dimensions"): + order_by = "posting_date, creation" + if filters.get("categorize_by") == "Categorize by Voucher": + order_by = "posting_date, voucher_type, voucher_no" + if filters.get("categorize_by") == "Categorize by Account": + order_by = "account, posting_date, creation" + + if filters.get("include_default_book_entries"): + filters["company_fb"] = frappe.get_cached_value( + "Company", filters.get("company"), "default_finance_book" + ) + + conditions, params = _build_gl_conditions_duckdb(filters) + select_clause = ", ".join(select_exprs) + sql = f'SELECT {select_clause} FROM "tabGL Entry" WHERE {" AND ".join(conditions)} ORDER BY {order_by}' + + rows = conn.execute(sql, params).fetchall() + gl_entries = [frappe._dict(zip(col_names, row, strict=False)) for row in rows] + + party_name_map = get_party_name_map() + for gl_entry in gl_entries: + if gl_entry.party_type and gl_entry.party: + gl_entry.party_name = party_name_map.get(gl_entry.party_type, {}).get(gl_entry.party) + + if filters.get("presentation_currency"): + return convert_to_presentation_currency(gl_entries, currency_map, filters) + return gl_entries + + +def _build_gl_conditions_duckdb(filters): + ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") + + conditions = ["company = ?"] + params = [filters.company] + + if filters.get("account"): + filters.account = get_accounts_with_children(filters.account) + if filters.account: + conditions.append(f"account IN ({', '.join(['?'] * len(filters.account))})") + params.extend(filters.account) + + if filters.get("cost_center"): + filters.cost_center = get_cost_centers_with_children(filters.cost_center) + conditions.append(f"cost_center IN ({', '.join(['?'] * len(filters.cost_center))})") + params.extend(filters.cost_center) + + if filters.get("voucher_no"): + conditions.append("voucher_no = ?") + params.append(filters.voucher_no) + + if filters.get("against_voucher_no"): + conditions.append("against_voucher = ?") + params.append(filters.against_voucher_no) + + if filters.get("ignore_err"): + err_journals = frappe.db.get_all( + "Journal Entry", + filters={ + "company": filters.get("company"), + "docstatus": 1, + "voucher_type": ("in", ["Exchange Rate Revaluation", "Exchange Gain Or Loss"]), + }, + pluck="name", + ) + if err_journals: + filters.update({"voucher_no_not_in": err_journals}) + + if filters.get("ignore_cr_dr_notes"): + system_generated = frappe.db.get_all( + "Journal Entry", + filters={ + "company": filters.get("company"), + "docstatus": 1, + "voucher_type": ("in", ["Credit Note", "Debit Note"]), + "is_system_generated": 1, + }, + pluck="name", + ) + if system_generated: + vouchers_to_ignore = (filters.get("voucher_no_not_in") or []) + system_generated + filters.update({"voucher_no_not_in": vouchers_to_ignore}) + + if filters.get("voucher_no_not_in"): + vouchers = filters.voucher_no_not_in + conditions.append(f"voucher_no NOT IN ({', '.join(['?'] * len(vouchers))})") + params.extend(vouchers) + + if filters.get("categorize_by") == "Categorize by Party" and not filters.get("party_type"): + conditions.append("party_type IN ('Customer', 'Supplier')") + + if filters.get("party_type"): + conditions.append("party_type = ?") + params.append(filters.party_type) + + if filters.get("party"): + conditions.append(f"party IN ({', '.join(['?'] * len(filters.party))})") + params.extend(filters.party) + + # from_date: skip when filtering by account/party to allow opening balance calc in Python + if filters.get("disable_opening_balance_calculation"): + if not ignore_is_opening: + conditions.append("(posting_date >= ? OR is_opening = 'Yes')") + else: + conditions.append("posting_date >= ?") + params.append(filters.from_date) + elif not ( + filters.get("account") + or filters.get("party") + or filters.get("categorize_by") in ["Categorize by Account", "Categorize by Party"] + ): + if not ignore_is_opening: + conditions.append("(posting_date >= ? OR is_opening = 'Yes')") + else: + conditions.append("posting_date >= ?") + params.append(filters.from_date) + + if not ignore_is_opening: + conditions.append("(posting_date <= ? OR is_opening = 'Yes')") + else: + conditions.append("posting_date <= ?") + params.append(filters.to_date) + + if filters.get("project"): + conditions.append(f"project IN ({', '.join(['?'] * len(filters.project))})") + params.extend(filters.project) + + company_fb = filters.get("company_fb") or frappe.get_cached_value( + "Company", filters.company, "default_finance_book" + ) + if filters.get("include_default_book_entries"): + if filters.get("finance_book"): + if company_fb and cstr(filters.finance_book) != cstr(company_fb): + frappe.throw( + _("To use a different finance book, please uncheck 'Include Default FB Entries'") + ) + fb_vals = [cstr(filters.finance_book), ""] + else: + fb_vals = [cstr(company_fb), ""] + conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_vals))}) OR finance_book IS NULL)") + params.extend(fb_vals) + else: + if filters.get("finance_book"): + conditions.append("(finance_book IN (?, '') OR finance_book IS NULL)") + params.append(cstr(filters.finance_book)) + else: + conditions.append("(finance_book IN ('') OR finance_book IS NULL)") + + if not filters.get("show_cancelled_entries"): + conditions.append("is_cancelled = 0") + + accounting_dimensions_list = get_accounting_dimensions(as_list=False) + if accounting_dimensions_list: + for dimension in accounting_dimensions_list: + if not dimension.disabled and dimension.document_type != "Finance Book": + if filters.get(dimension.fieldname): + if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"): + filters[dimension.fieldname] = get_dimension_with_children( + dimension.document_type, filters.get(dimension.fieldname) + ) + vals = ( + filters[dimension.fieldname] + if isinstance(filters[dimension.fieldname], list) + else [filters[dimension.fieldname]] + ) + conditions.append(f"{dimension.fieldname} IN ({', '.join(['?'] * len(vals))})") + params.extend(vals) + + return conditions, params From bb195408165aa6c69771e75dd36e3b80ca1f2f3a Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 22 Jun 2026 13:29:02 +0530 Subject: [PATCH 099/101] feat(balance-sheet): implement execute_synced_report with full parity to normal report Co-Authored-By: Claude Sonnet 4.6 --- .../report/balance_sheet/balance_sheet.json | 10 +- .../report/balance_sheet/balance_sheet.py | 204 +++++++++++++++++- 2 files changed, 212 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.json b/erpnext/accounts/report/balance_sheet/balance_sheet.json index 4c1d4b64030..a992e189d61 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.json +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2014-07-14 05:24:20.385279", "default_print_format": "Balance Sheet Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "GL Entry" + } + ], "filters": [], + "generate_csv": 0, "idx": 3, "is_standard": "Yes", - "modified": "2026-05-22 14:35:28.187799", + "modified": "2026-06-22 13:06:12.602924", "modified_by": "Administrator", "module": "Accounts", "name": "Balance Sheet", @@ -30,5 +37,6 @@ "role": "Auditor" } ], + "synced_report": 1, "timeout": 0 } diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py index a8531e58acb..756d0c2ebbb 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.py +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py @@ -4,18 +4,27 @@ import frappe from frappe import _ -from frappe.utils import cint, flt +from frappe.utils import add_days, cint, flt from erpnext.accounts.doctype.financial_report_template.financial_report_engine import ( FinancialReportEngine, get_xlsx_styles, #! DO NOT REMOVE - hook for styling ) from erpnext.accounts.report.financial_statements import ( + accumulate_values_into_parents, + add_total_row, + calculate_values, compute_growth_view_data, + filter_accounts, + filter_out_zero_value_rows, + get_accounting_entries, + get_accounts, + get_appropriate_currency, get_columns, get_data, get_filtered_list_for_consolidated_report, get_period_list, + prepare_data, ) @@ -266,3 +275,196 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency): chart["currency"] = currency return chart + + +def execute_synced_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if not (conn := get_latest_sync("GL Entry")): + frappe.throw(_("Balance Sheet requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))) + + period_list = get_period_list( + filters.from_fiscal_year, + filters.to_fiscal_year, + filters.period_start_date, + filters.period_end_date, + filters.filter_based_on, + filters.periodicity, + company=filters.company, + ) + filters.period_start_date = period_list[0]["year_start_date"] + + currency = filters.presentation_currency or frappe.get_cached_value( + "Company", filters.company, "default_currency" + ) + + asset = _get_data_duckdb(conn, filters, "Asset", "Debit", period_list) + liability = _get_data_duckdb(conn, filters, "Liability", "Credit", period_list) + equity = _get_data_duckdb(conn, filters, "Equity", "Credit", period_list) + + provisional_profit_loss, total_credit = get_provisional_profit_loss( + asset, liability, equity, period_list, filters.company, currency + ) + message, opening_balance = check_opening_balance(asset, liability, equity) + + data = [] + data.extend(asset or []) + data.extend(liability or []) + data.extend(equity or []) + if opening_balance and round(opening_balance, 2) != 0: + unclosed = { + "account_name": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'", + "account": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'", + "warn_if_negative": True, + "currency": currency, + } + for period in period_list: + unclosed[period.key] = opening_balance + if provisional_profit_loss: + provisional_profit_loss[period.key] = provisional_profit_loss[period.key] - opening_balance + unclosed["total"] = opening_balance + data.append(unclosed) + + if provisional_profit_loss: + data.append(provisional_profit_loss) + if total_credit: + data.append(total_credit) + + columns = get_columns( + filters.periodicity, period_list, filters.accumulated_values, company=filters.company + ) + chart = get_chart_data(filters, period_list, asset, liability, equity, currency) + report_summary, primitive_summary = get_report_summary( + period_list, asset, liability, equity, provisional_profit_loss, currency, filters + ) + + if filters.get("selected_view") == "Growth": + compute_growth_view_data(data, period_list) + + return columns, data, message, chart, report_summary, primitive_summary + + +def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list): + accounts = get_accounts(filters.company, root_type) + if not accounts: + return None + + accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) + company_currency = get_appropriate_currency(filters.company, filters) + + gl_entries_by_account = {} + _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type) + + calculate_values( + accounts_by_name, + gl_entries_by_account, + period_list, + filters.accumulated_values, + False, + ) + accumulate_values_into_parents(accounts, accounts_by_name, period_list) + + out = prepare_data( + accounts, + balance_must_be, + period_list, + company_currency, + accumulated_values=filters.accumulated_values, + ) + out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values) + + if out: + add_total_row(out, root_type, balance_must_be, period_list, company_currency) + + return out + + +def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type): + from erpnext.accounts.report.trial_balance.trial_balance import ( + _extra_gl_conditions, + _fetch_gl_rows_duckdb, + ) + from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency + + company = filters.company + year_start_date = period_list[0]["year_start_date"] + last_to_date = period_list[-1]["to_date"] + ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting") + + leaf_accounts = [acc.name for acc in accounts if not acc.is_group] + if not leaf_accounts: + return + + opening_from_date = None + ignore_opening_entries = False + + ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance") + if not ignore_closing_balances: + last_pcv_list = frappe.db.get_all( + "Period Closing Voucher", + filters={ + "docstatus": 1, + "company": company, + "period_end_date": ("<", filters.get("period_start_date") or year_start_date), + }, + fields=["period_end_date", "name"], + order_by="period_end_date desc", + limit=1, + ) + if last_pcv_list: + last_pcv = last_pcv_list[0] + pcv_entries = get_accounting_entries( + "Account Closing Balance", + None, + last_to_date, + filters, + root_type=root_type, + ignore_closing_entries=False, + period_closing_voucher=last_pcv.name, + ) + if filters.get("presentation_currency"): + convert_to_presentation_currency(pcv_entries, get_currency(filters)) + for entry in pcv_entries: + gl_entries_by_account.setdefault(entry.account, []).append(entry) + opening_from_date = add_days(last_pcv.period_end_date, 1) + ignore_opening_entries = True + + extra_cond, extra_params = _extra_gl_conditions(filters) + account_placeholders = ", ".join(["?"] * len(leaf_accounts)) + base_conds = [ + "company = ?", + "is_cancelled = 0", + f"account IN ({account_placeholders})", + ] + base_params = [company, *leaf_accounts] + if ignore_opening_entries and not ignore_is_opening: + base_conds.append("is_opening = 'No'") + base_conds.extend(extra_cond) + base_params.extend(extra_params) + + # Opening GL entries from DuckDB (entries before year_start_date) + open_conds = [*base_conds, "posting_date < ?"] + open_params = [*base_params, year_start_date] + if opening_from_date: + open_conds = [*open_conds, "posting_date >= ?"] + open_params = [*open_params, opening_from_date] + + opening_entries = _fetch_gl_rows_duckdb(conn, open_conds, open_params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(opening_entries, get_currency(filters)) + synthetic_open_date = add_days(year_start_date, -1) + for entry in opening_entries: + entry.posting_date = synthetic_open_date + gl_entries_by_account.setdefault(entry.account, []).append(entry) + + # Period GL entries from DuckDB (one aggregated query per period) + for period in period_list: + period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"] + period_params = [*base_params, period.from_date, period.to_date] + + period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(period_entries, get_currency(filters)) + for entry in period_entries: + entry.posting_date = period.to_date + gl_entries_by_account.setdefault(entry.account, []).append(entry) From 6a93baacf05a82a0f643633b38178e13878d2283 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 22 Jun 2026 13:36:02 +0530 Subject: [PATCH 100/101] feat(profit-and-loss): implement execute_synced_report with full parity to normal report Co-Authored-By: Claude Sonnet 4.6 --- .../profit_and_loss_statement.json | 10 +- .../profit_and_loss_statement.py | 130 ++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json index 5abd51e2a30..7565c197119 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json @@ -4,13 +4,20 @@ "columns": [], "creation": "2014-07-18 11:43:33.173207", "default_print_format": "P&L Statement Standard", + "disable_prepared_report_automation": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "doctype_to_sync": [ + { + "doc_type": "GL Entry" + } + ], "filters": [], + "generate_csv": 0, "idx": 2, "is_standard": "Yes", - "modified": "2026-05-22 14:36:04.544347", + "modified": "2026-06-22 13:06:12.602924", "modified_by": "Administrator", "module": "Accounts", "name": "Profit and Loss Statement", @@ -30,5 +37,6 @@ "role": "Auditor" } ], + "synced_report": 1, "timeout": 0 } diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py index 9ce6cd77e5b..297aa961058 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py @@ -11,12 +11,20 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine get_xlsx_styles, #! DO NOT REMOVE - hook for styling ) from erpnext.accounts.report.financial_statements import ( + accumulate_values_into_parents, + add_total_row, + calculate_values, compute_growth_view_data, compute_margin_view_data, + filter_accounts, + filter_out_zero_value_rows, + get_accounts, + get_appropriate_currency, get_columns, get_data, get_filtered_list_for_consolidated_report, get_period_list, + prepare_data, ) @@ -197,3 +205,125 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur chart["currency"] = currency return chart + + +def execute_synced_report(filters): + from frappe.database.duckdb.database import get_latest_sync + + if not (conn := get_latest_sync("GL Entry")): + frappe.throw( + _("Profit and Loss Statement requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")) + ) + + period_list = get_period_list( + filters.from_fiscal_year, + filters.to_fiscal_year, + filters.period_start_date, + filters.period_end_date, + filters.filter_based_on, + filters.periodicity, + company=filters.company, + ) + + income = _get_data_duckdb(conn, filters, "Income", "Credit", period_list) + expense = _get_data_duckdb(conn, filters, "Expense", "Debit", period_list) + + net_profit_loss = get_net_profit_loss( + income, expense, period_list, filters.company, filters.presentation_currency + ) + + data = [] + data.extend(income or []) + data.extend(expense or []) + if net_profit_loss: + data.append(net_profit_loss) + + columns = get_columns(filters.periodicity, period_list, filters.accumulated_values, filters.company) + + currency = filters.presentation_currency or frappe.get_cached_value( + "Company", filters.company, "default_currency" + ) + chart = get_chart_data(filters, period_list, income, expense, net_profit_loss, currency) + + report_summary, primitive_summary = get_report_summary( + period_list, filters.periodicity, income, expense, net_profit_loss, currency, filters + ) + + if filters.get("selected_view") == "Growth": + compute_growth_view_data(data, period_list) + + if filters.get("selected_view") == "Margin": + compute_margin_view_data(data, period_list, filters.accumulated_values) + + return columns, data, None, chart, report_summary, primitive_summary + + +def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list): + accounts = get_accounts(filters.company, root_type) + if not accounts: + return None + + accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) + company_currency = get_appropriate_currency(filters.company, filters) + + gl_entries_by_account = {} + _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account) + + calculate_values( + accounts_by_name, + gl_entries_by_account, + period_list, + filters.accumulated_values, + False, + ) + accumulate_values_into_parents(accounts, accounts_by_name, period_list) + + out = prepare_data( + accounts, + balance_must_be, + period_list, + company_currency, + accumulated_values=filters.accumulated_values, + ) + out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values) + + if out: + add_total_row(out, root_type, balance_must_be, period_list, company_currency) + + return out + + +def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account): + from erpnext.accounts.report.trial_balance.trial_balance import ( + _extra_gl_conditions, + _fetch_gl_rows_duckdb, + ) + from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency + + company = filters.company + leaf_accounts = [acc.name for acc in accounts if not acc.is_group] + if not leaf_accounts: + return + + extra_cond, extra_params = _extra_gl_conditions(filters) + account_placeholders = ", ".join(["?"] * len(leaf_accounts)) + base_conds = [ + "company = ?", + "is_cancelled = 0", + f"account IN ({account_placeholders})", + "voucher_type != 'Period Closing Voucher'", + ] + base_params = [company, *leaf_accounts] + base_conds.extend(extra_cond) + base_params.extend(extra_params) + + for period in period_list: + period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"] + period_params = [*base_params, period.from_date, period.to_date] + + period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params) + if filters.get("presentation_currency"): + convert_to_presentation_currency(period_entries, get_currency(filters)) + for entry in period_entries: + entry.posting_date = period.to_date + gl_entries_by_account.setdefault(entry.account, []).append(entry) From 963bbc8729e279c91582b942f43aaad645c872b1 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 22 Jun 2026 13:39:01 +0530 Subject: [PATCH 101/101] refactor: synced reports should be enabled on sites based on requirements --- .../accounts/report/accounts_payable/accounts_payable.json | 4 ++-- .../report/accounts_receivable/accounts_receivable.json | 4 ++-- erpnext/accounts/report/balance_sheet/balance_sheet.json | 4 ++-- erpnext/accounts/report/general_ledger/general_ledger.json | 4 ++-- .../profit_and_loss_statement/profit_and_loss_statement.json | 4 ++-- erpnext/accounts/report/trial_balance/trial_balance.json | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.json b/erpnext/accounts/report/accounts_payable/accounts_payable.json index 48380605ccf..9c713fccf64 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.json +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 3, "is_standard": "Yes", - "modified": "2026-06-18 11:54:12.154865", + "modified": "2026-06-25 12:03:36.559152", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Payable", @@ -40,6 +40,6 @@ "role": "Auditor" } ], - "synced_report": 1, + "synced_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json index 3b4d6594bf1..dcc3c2c6a49 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 5, "is_standard": "Yes", - "modified": "2026-06-18 11:53:59.190645", + "modified": "2026-06-25 12:03:28.812092", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Receivable", @@ -34,6 +34,6 @@ "role": "Accounts User" } ], - "synced_report": 1, + "synced_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.json b/erpnext/accounts/report/balance_sheet/balance_sheet.json index a992e189d61..75277f72ac7 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.json +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 3, "is_standard": "Yes", - "modified": "2026-06-22 13:06:12.602924", + "modified": "2026-06-22 13:38:25.236839", "modified_by": "Administrator", "module": "Accounts", "name": "Balance Sheet", @@ -37,6 +37,6 @@ "role": "Auditor" } ], - "synced_report": 1, + "synced_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/general_ledger/general_ledger.json b/erpnext/accounts/report/general_ledger/general_ledger.json index 914fa496c07..083f7b62ae8 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.json +++ b/erpnext/accounts/report/general_ledger/general_ledger.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 4, "is_standard": "Yes", - "modified": "2026-06-22 11:50:08.020553", + "modified": "2026-06-22 13:38:35.057216", "modified_by": "Administrator", "module": "Accounts", "name": "General Ledger", @@ -37,6 +37,6 @@ "role": "Auditor" } ], - "synced_report": 1, + "synced_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json index 7565c197119..9aa088aefe0 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 2, "is_standard": "Yes", - "modified": "2026-06-22 13:06:12.602924", + "modified": "2026-06-22 13:38:15.898375", "modified_by": "Administrator", "module": "Accounts", "name": "Profit and Loss Statement", @@ -37,6 +37,6 @@ "role": "Auditor" } ], - "synced_report": 1, + "synced_report": 0, "timeout": 0 } diff --git a/erpnext/accounts/report/trial_balance/trial_balance.json b/erpnext/accounts/report/trial_balance/trial_balance.json index 7aca6d62acc..6793268a1e6 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.json +++ b/erpnext/accounts/report/trial_balance/trial_balance.json @@ -17,7 +17,7 @@ "generate_csv": 0, "idx": 4, "is_standard": "Yes", - "modified": "2026-06-18 16:37:42.112788", + "modified": "2026-06-22 13:38:42.740436", "modified_by": "Administrator", "module": "Accounts", "name": "Trial Balance", @@ -37,6 +37,6 @@ "role": "Auditor" } ], - "synced_report": 1, + "synced_report": 0, "timeout": 0 }