Compare commits

..

1 Commits

30 changed files with 490 additions and 833 deletions

View File

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

View File

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

View File

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

View File

@@ -65,19 +65,6 @@ jobs:
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
# The v14 baseline backup is a fixed published file — cache it instead of re-downloading
# ~100MB from frappe.io every run.
- name: Cache erpnext v14 backup
id: cache-v14
uses: actions/cache@v4
with:
path: ~/erpnext-v14.sql.gz
key: erpnext-v14-sql-gz
- name: Download erpnext v14 backup
if: steps.cache-v14.outputs.cache-hit != 'true'
run: wget -O ~/erpnext-v14.sql.gz https://frappe.io/files/erpnext-v14.sql.gz
- name: Cache pip
uses: actions/cache@v4
with:
@@ -126,7 +113,8 @@ jobs:
jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json
mv tmp.json ~/frappe-bench/sites/test_site/site_config.json
bench --site test_site --force restore ~/erpnext-v14.sql.gz
wget https://frappe.io/files/erpnext-v14.sql.gz
bench --site test_site --force restore ~/frappe-bench/erpnext-v14.sql.gz
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git

View File

@@ -1,45 +1,79 @@
name: Server (Postgres)
on:
schedule:
# 03:00 AM IST daily (21:30 UTC the previous day)
- cron: "30 21 * * *"
repository_dispatch:
types: [frappe-framework-change]
pull_request:
# 'labeled' so adding the 'postgres' label to an already-open PR re-triggers the run.
# '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:
setup:
name: Build & reinstall (setup)
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') }}
runs-on: ubuntu-latest
# Runs on the daily schedule (and workflow_dispatch). On PRs it runs ONLY when the PR carries
# the 'postgres' label — the test job needs setup, so it's skipped too when this is.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'postgres')
timeout-minutes: 40
timeout-minutes: 60
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
steps:
- name: Clone
uses: actions/checkout@v6
@@ -47,7 +81,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
python-version: '3.14'
- name: Check for valid Python & Merge Conflicts
run: |
@@ -66,124 +100,98 @@ jobs:
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
- name: Cache deps (uv/pip/npm/yarn)
- name: Cache pip
uses: actions/cache@v4
with:
path: |
~/.cache/uv
~/.cache/pip
~/.npm
~/.cache/yarn
key: ${{ runner.os }}-deps-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }}
restore-keys: ${{ runner.os }}-deps-
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
# Warm-bench cache (the big one): install.sh saves the built base bench — frappe + env +
# node_modules + assets — here as frappe-bench-base-*.tar.zst. Later runs restore it and only
# fast-forward to the live develop SHA + rebuild the delta, so the bench BUILD is near-free and
# only the test_site reinstall (per-run DB, uncacheable) stays slow — matching the self-hosted
# box. The first run after a deps change populates it; every run after that is fast.
- name: Cache warm bench (base build)
- name: Cache node modules
uses: actions/cache@v4
with:
path: ~/bench-cache
key: ${{ runner.os }}-warmbench-v2-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }}
restore-keys: ${{ runner.os }}-warmbench-v2-
# Postgres runs in-runner on a PGDATA OUTSIDE the bench (install.sh wipes ~/frappe-bench);
# after the reinstall it's moved into the bench so it ships in the artifact.
- name: Start DB
run: bash ${GITHUB_WORKSPACE}/.github/helper/start-db.sh
env:
DB: postgres
CI_DB_DATADIR: /home/runner/pgdata
cache-name: cache-node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Cache wkhtmltopdf
uses: actions/cache@v4
with:
path: /tmp/wkhtmltox.deb
key: wkhtmltox-0.12.6.1-2-jammy-amd64
- name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env:
DB: postgres
TYPE: server
FRAPPE_BRANCH: develop
BENCH_CACHE_DIR: /home/runner/bench-cache
- name: Stop DB and stage datadir
run: |
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin | sort -V | tail -1)
"$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop || true
mv /home/runner/pgdata /home/runner/frappe-bench/pgdata
- name: Package bench for test shards
run: |
cp "${GITHUB_WORKSPACE}/.github/helper/hydrate.sh" /home/runner/frappe-bench/hydrate.sh
cp "${GITHUB_WORKSPACE}/.github/helper/start-db.sh" /home/runner/frappe-bench/start-db.sh
tar czpf "${GITHUB_WORKSPACE}/bench.tar.gz" -C /home/runner \
--exclude='.git' --exclude='node_modules' frappe-bench
ls -lh "${GITHUB_WORKSPACE}/bench.tar.gz"
- name: Upload bench artifact
uses: actions/upload-artifact@v4
with:
name: bench-pg
path: bench.tar.gz
retention-days: 1
compression-level: 0
test:
name: Python Unit Tests (PG)
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
container: [1, 2, 3, 4]
steps:
- name: Download bench artifact
uses: actions/download-artifact@v4
with:
name: bench-pg
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
# The bench CLI (frappe-bench) and redis are global/system tools — not in the bench tarball.
# The setup runner got them via install.sh; the MariaDB shards get them from the arc5 image.
# GitHub-hosted PG shards install them here (cheap vs the build+reinstall that setup did once).
- name: Install shard runtime (bench CLI + redis + wkhtmltopdf)
run: |
pip install frappe-bench
command -v redis-server >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq redis-server; }
# wkhtmltopdf (patched-qt build) for print-format / PDF tests — same .deb install.sh uses.
if ! command -v wkhtmltopdf >/dev/null; then
wget -qO /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
sudo apt-get install -y -qq /tmp/wkhtmltox.deb
fi
- name: Untar bench
run: |
tar xzpf "${GITHUB_WORKSPACE}/bench.tar.gz" -C /home/runner
ls -ld /home/runner/frappe-bench
- name: Hydrate (start Postgres on the baked datadir)
run: bash /home/runner/frappe-bench/hydrate.sh
env:
DB: postgres
DB_HOST: 127.0.0.1
FRAPPE_USER: ${{ github.event.inputs.user }}
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
- name: Run Tests
run: |
cd ~/frappe-bench/
# print-format / PDF tests are engine-independent (they exercise wkhtmltopdf rendering,
# not postgres SQL — the MariaDB CI already covers them). They only fetch the static asset
# bundles from http://test_site:8000/assets/..., so a plain static file server over sites/
# satisfies wkhtmltopdf without the frappe web server (which never bound on a bare runner).
( cd ~/frappe-bench/sites && nohup python3 -m http.server 8000 --bind 127.0.0.1 > ~/frappe-bench/web.log 2>&1 & )
for _ in $(seq 1 15); do (exec 3<>/dev/tcp/127.0.0.1/8000) 2>/dev/null && { exec 3>&- 3<&-; break; }; sleep 1; done
coverage_flag=""
if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
bench --site test_site run-parallel-tests --lightmode --app erpnext \
--total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }}
--total-builds ${{ strategy.job-total }} \
--build-number ${{ matrix.container }} \
$coverage_flag
env:
TYPE: server
- name: Show bench output
if: ${{ always() }}
run: cat ~/frappe-bench/bench_start.log || true
- name: Upload coverage data
if: ${{ env.WITH_COVERAGE == 'true' }}
uses: actions/upload-artifact@v4
with:
name: coverage-postgres-${{ matrix.container }}
path: /home/runner/frappe-bench/sites/coverage.xml
coverage:
name: Coverage Wrap Up
needs: test
if: ${{ github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Clone
uses: actions/checkout@v6
- name: Download artifacts
uses: actions/download-artifact@v4
with:
pattern: coverage-postgres-*
- name: Upload coverage data
uses: codecov/codecov-action@v4
with:
name: Postgres
flags: postgres
# explicit glob: download-artifact extracts each shard into its own coverage-postgres-N/ dir
files: coverage-postgres-*/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
verbose: true

View File

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

View File

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

View File

@@ -162,9 +162,9 @@ class Budget(Document):
frappe.throw(_("Account {0} does not belong to company {1}").format(self.account, self.company))
elif account_details.report_type != "Profit and Loss":
frappe.throw(
_(
"Budget cannot be assigned against {0}, as its Root Type is not of Income or Expense"
).format(self.account)
_("Budget cannot be assigned against {0}, as it's not an Income or Expense account").format(
self.account
)
)
def set_null_value(self):

View File

@@ -12,9 +12,8 @@ from typing import Any, Union
import frappe
from frappe import _
from frappe.database.operator_map import OPERATOR_MAP
from frappe.model import numeric_fieldtypes
from frappe.query_builder import Case
from frappe.query_builder.functions import Cast_, Sum
from frappe.query_builder.functions import Sum
from frappe.utils import cstr, date_diff, flt, getdate
from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder
from pypika.terms import Bracket, LiteralValue
@@ -865,15 +864,8 @@ class FilterExpressionParser:
field = getattr(table, field_name, None)
operator_fn = OPERATOR_MAP.get(operator.casefold())
if "like" in operator.casefold():
if "%" not in value:
value = f"%{value}%"
# Postgres has no LIKE/ILIKE operator for non-text columns; MariaDB implicitly casts
# the numeric column to text. Cast a numeric/Check Account field to varchar so the
# match runs on both engines and reproduces MariaDB's result.
meta_field = frappe.get_meta("Account").get_field(field_name)
if meta_field and meta_field.fieldtype in numeric_fieldtypes:
field = Cast_(field, "varchar")
if "like" in operator.casefold() and "%" not in value:
value = f"%{value}%"
return operator_fn(field, value)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -72,14 +72,17 @@ def employee_query(
.where(Criterion.any(search_conditions))
.orderby(
Case()
.when(Locate(txt_no_percent, Employee.name) > 0, Locate(txt_no_percent, Employee.name))
.when(
Locate(Lower(txt_no_percent), Lower(Employee.name)) > 0,
Locate(Lower(txt_no_percent), Lower(Employee.name)),
)
.else_(99999)
)
.orderby(
Case()
.when(
Locate(txt_no_percent, Employee.employee_name) > 0,
Locate(txt_no_percent, Employee.employee_name),
Locate(Lower(txt_no_percent), Lower(Employee.employee_name)) > 0,
Locate(Lower(txt_no_percent), Lower(Employee.employee_name)),
)
.else_(99999)
)
@@ -135,17 +138,28 @@ def lead_query(
query.where(Lead.docstatus < 2)
.where(Lead.status.isnull() | (Lead.status != "Converted"))
.where(Criterion.any(search_conditions))
.orderby(
Case().when(Locate(txt_no_percent, Lead.name) > 0, Locate(txt_no_percent, Lead.name)).else_(99999)
)
.orderby(
Case()
.when(Locate(txt_no_percent, Lead.lead_name) > 0, Locate(txt_no_percent, Lead.lead_name))
.when(
Locate(Lower(txt_no_percent), Lower(Lead.name)) > 0,
Locate(Lower(txt_no_percent), Lower(Lead.name)),
)
.else_(99999)
)
.orderby(
Case()
.when(Locate(txt_no_percent, Lead.company_name) > 0, Locate(txt_no_percent, Lead.company_name))
.when(
Locate(Lower(txt_no_percent), Lower(Lead.lead_name)) > 0,
Locate(Lower(txt_no_percent), Lower(Lead.lead_name)),
)
.else_(99999)
)
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(Lead.company_name)) > 0,
Locate(Lower(txt_no_percent), Lower(Lead.company_name)),
)
.else_(99999)
)
.orderby(Lead.idx, order=Order.desc)
@@ -383,7 +397,12 @@ def bom(
.where(BOM.is_active == 1)
.where(BOM[searchfield].like(f"%{txt}%"))
.orderby(
Case().when(Locate(txt_no_percent, BOM.name) > 0, Locate(txt_no_percent, BOM.name)).else_(99999)
Case()
.when(
Locate(Lower(txt_no_percent), Lower(BOM.name)) > 0,
Locate(Lower(txt_no_percent), Lower(BOM.name)),
)
.else_(99999)
)
.orderby(BOM.idx, order=Order.desc)
.orderby(BOM.name)

View File

@@ -169,7 +169,7 @@ class calculate_taxes_and_totals:
return
if not self.discount_amount_applied:
do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"]
do_not_round_fields = ["valuation_rate", "incoming_rate"]
for item in self.doc.items:
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)

View File

@@ -694,11 +694,10 @@ frappe.ui.form.on("Job Card", {
// ── Wire up button click handlers ─────────────────────────────────
if (show_start) {
wrapper.find(".jcd-btn-start").on("click", () => {
const from_time = frappe.datetime.now_datetime();
const has_no_employee = !frm.doc.employee || !frm.doc.employee.length;
if (has_no_employee) {
// Capture the start time only when the employee dialog is submitted, not on click,
// so the time spent selecting the operator is not counted as worked time.
frappe.prompt(
{
fieldtype: "Table MultiSelect",
@@ -708,11 +707,11 @@ frappe.ui.form.on("Job Card", {
reqd: 1,
filters: { status: "Active" },
},
(d) => frm.events.start_timer(frm, frappe.datetime.now_datetime(), d.employees),
(d) => frm.events.start_timer(frm, from_time, d.employees),
__("Assign Job to Employee")
);
} else {
frm.events.start_timer(frm, frappe.datetime.now_datetime(), frm.doc.employee);
frm.events.start_timer(frm, from_time, frm.doc.employee);
}
});
}

View File

@@ -3,6 +3,8 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Lower
from frappe.utils import cstr
def execute(filters=None):
@@ -63,6 +65,9 @@ def append_filters(query, report_filters, operations, job_card):
if report_filters.get(field):
if field == "serial_no":
query = query.where(job_card[field].like(f"%{report_filters.get(field)}%"))
elif field == "batch_no":
# Lower() both sides: exact match stays case-insensitive on Postgres as it is on MariaDB
query = query.where(Lower(job_card[field]) == cstr(report_filters.get(field)).lower())
elif field == "operation":
query = query.where(job_card[field].isin(operations))
else:

View File

@@ -0,0 +1,65 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_to_date, now
from erpnext.manufacturing.doctype.job_card.mapper import make_corrective_job_card
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.report.cost_of_poor_quality_report.cost_of_poor_quality_report import execute
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestCostOfPoorQualityReport(ERPNextTestSuite):
def setUp(self):
self.load_test_records("BOM")
# BOM with operations for _Test FG Item 2, so submitting the work order creates Job Cards
bom = frappe.copy_doc(self.globalTestRecords["BOM"][2])
bom.set_rate_of_sub_assembly_item_based_on_bom = 0
bom.rm_cost_as_per = "Valuation Rate"
bom.items[0].uom = "_Test UOM 1"
bom.items[0].conversion_factor = 5
bom.insert(ignore_if_duplicate=True)
def test_batch_no_filter_is_case_insensitive(self):
# The report's batch_no filter used an exact `==`, which is case-sensitive on Postgres -- a
# differently-cased batch_no would miss job cards that MariaDB (case-insensitive collation)
# matches. Lower() both sides keeps MariaDB unchanged and makes Postgres match too.
wo = make_wo_order_test_record(item="_Test FG Item 2", qty=2, transfer_material_against="Work Order")
for item in wo.required_items:
make_stock_entry(
item_code=item.item_code,
target=item.source_warehouse,
qty=item.required_qty * 2,
basic_rate=100,
)
job_card = frappe.get_last_doc("Job Card", {"work_order": wo.name})
job_card.append(
"time_logs", {"from_time": now(), "to_time": add_to_date(now(), hours=1), "completed_qty": 2}
)
job_card.submit()
corrective_op = frappe.get_doc(
doctype="Operation", is_corrective_operation=1, name=frappe.generate_hash()
).insert()
corrective_jc = make_corrective_job_card(
job_card.name, operation=corrective_op.name, for_operation=job_card.operation
)
corrective_jc.hour_rate = 100
corrective_jc.insert()
corrective_jc.append(
"time_logs",
{
"from_time": add_to_date(now(), hours=2),
"to_time": add_to_date(now(), hours=2, minutes=30),
"completed_qty": 2,
},
)
corrective_jc.submit()
# store an uppercase batch_no; the report is then filtered with a lowercase value
corrective_jc.db_set("batch_no", "TESTCOPQBATCH")
_columns, data = execute(frappe._dict({"batch_no": "testcopqbatch"}))
self.assertTrue(any(row.get("name") == corrective_jc.name for row in data))

View File

@@ -491,4 +491,3 @@ 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)

View File

@@ -40,6 +40,15 @@ erpnext.PointOfSale.Controller = class {
in_list_view: 1,
label: __("Opening Amount"),
options: "company:company_currency",
onchange: function () {
dialog.fields_dict.balance_details.df.data.some((d) => {
if (d.idx == this.doc.idx) {
d.opening_amount = this.value;
dialog.fields_dict.balance_details.grid.refresh();
return true;
}
});
},
},
];
const fetch_pos_payment_methods = () => {

View File

@@ -318,23 +318,12 @@ class TransactionDeletionRecord(Document):
Returns:
list: List of child table DocType names (Table field options)
"""
child_tables = frappe.get_all(
return frappe.get_all(
"DocField",
filters={"parent": doctype_name, "fieldtype": ["in", ["Table", "Table MultiSelect"]]},
pluck="options",
)
if not child_tables:
return []
child_tables = frappe.get_all(
"DocType",
filters={"name": ["in", child_tables], "is_virtual": 0},
pluck="name",
)
return child_tables
def _get_to_delete_row_infos(self, doctype_name, company_field=None, company=None):
"""Get child tables and document count for a To Delete list row

View File

@@ -345,9 +345,6 @@ class RepostItemValuation(Document):
def _recalculate_valuation_rate(self):
doc = frappe.get_doc(self.voucher_type, self.voucher_no)
if doc.get("is_internal_supplier"):
doc.set_sales_incoming_rate_for_internal_transfer()
doc.update_valuation_rate()
for item in doc.items:
item.db_set("valuation_rate", item.valuation_rate)

View File

@@ -11,7 +11,7 @@ import frappe.query_builder
from frappe import _, _dict, bold
from frappe.model.document import Document
from frappe.model.naming import make_autoname
from frappe.query_builder.functions import Concat_ws, Max, Sum
from frappe.query_builder.functions import Concat_ws, Lower, Max, Sum
from frappe.utils import (
cint,
cstr,
@@ -3389,7 +3389,8 @@ def get_stock_ledgers_for_serial_nos(kwargs):
query.left_join(serial_batch_entry)
.on(stock_ledger_entry.serial_and_batch_bundle == serial_batch_entry.parent)
.where(
serial_batch_entry.serial_no.isin(serial_nos)
# Lower() both sides so serial-no matching is case-insensitive on Postgres as on MariaDB
Lower(serial_batch_entry.serial_no).isin([sn.lower() for sn in serial_nos])
| Concat_ws("", "\n", stock_ledger_entry.serial_no, "\n").regexp(regex_pattern)
)
.distinct()

View File

@@ -80,6 +80,31 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
self.assertFalse(bundle_doc.name.startswith("SABB-"))
def test_get_stock_ledgers_for_serial_nos_is_case_insensitive(self):
# get_stock_ledgers_for_serial_nos matches Serial and Batch Entry.serial_no with isin(), which is
# case-sensitive on Postgres -- a differently-cased serial no would miss entries that MariaDB
# (case-insensitive collation) matches. Lower() both sides keeps MariaDB unchanged and makes
# Postgres match too.
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_stock_ledgers_for_serial_nos,
)
item_code = "Test SBB Case-insensitive Serial Item"
make_item(
item_code,
{"has_serial_no": 1, "serial_no_series": "TESTCISER-.#####", "is_stock_item": 1},
)
pr = make_purchase_receipt(item_code=item_code, warehouse="_Test Warehouse - _TC", qty=1, rate=100)
bundle = pr.items[0].serial_and_batch_bundle
serial_no = get_serial_nos_from_bundle(bundle)[0]
# query with a lowercased serial no; the stored Serial and Batch Entry value is uppercase
rows = get_stock_ledgers_for_serial_nos(
frappe._dict({"item_code": item_code, "serial_nos": [serial_no.lower()]})
)
self.assertTrue(any(row.serial_and_batch_bundle == bundle for row in rows))
def test_inward_outward_serial_valuation(self):
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt

View File

@@ -1234,7 +1234,10 @@ def get_item_price(
& (ip.price_list == pctx.price_list)
& (IfNull(ip.uom, "").isin(["", pctx.uom]))
)
.orderby(ip.valid_from, order=frappe.qb.desc)
# IfNull so a NULL valid_from sorts last under DESC on both engines: MariaDB sorts NULL last
# for DESC, but Postgres defaults to NULLS FIRST, which would otherwise make a NULL-valid_from
# price win the LIMIT 1 over the most-recent dated price.
.orderby(IfNull(ip.valid_from, "1900-01-01"), order=frappe.qb.desc)
.orderby(IfNull(ip.batch_no, ""), order=frappe.qb.desc)
.orderby(ip.uom, order=frappe.qb.desc)
.limit(1)

View File

@@ -2,6 +2,7 @@
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.query_builder.functions import Lower
from frappe.utils import cint, cstr, flt, fmt_money
from erpnext.accounts.doctype.pricing_rule.pricing_rule import get_pricing_rule_for_item
@@ -133,9 +134,12 @@ def get_item_codes_by_attributes(attribute_filters, template_item_code=None):
frappe.qb.from_(iva)
.select(iva.parent)
# attribute_value is a varchar column; cast values to str so postgres doesn't choke on
# `varchar = numeric` for numeric attributes (stored values are strings on both backends)
# `varchar = numeric` for numeric attributes (stored values are strings on both backends).
# Lower() both sides so matching is case-insensitive on Postgres too, matching MariaDB's
# default collation (MariaDB result is unchanged -- it already matches case-insensitively).
.where(
(iva.attribute == attribute) & (iva.attribute_value.isin([cstr(v) for v in attribute_values]))
(Lower(iva.attribute) == cstr(attribute).lower())
& (Lower(iva.attribute_value).isin([cstr(v).lower() for v in attribute_values]))
)
.where(iva.parent.isin(item_subquery))
.groupby(iva.parent)

View File

@@ -0,0 +1,25 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.controllers.item_variant import create_variant
from erpnext.tests.utils import ERPNextTestSuite
from erpnext.utilities.product import get_item_codes_by_attributes
class TestProduct(ERPNextTestSuite):
def test_get_item_codes_by_attributes_is_case_insensitive(self):
# get_item_codes_by_attributes matches Item Variant Attribute values. A raw equality is
# case-sensitive on Postgres, so a differently-cased filter value would miss variants that
# MariaDB (case-insensitive collation) matches. Lower() both sides keeps MariaDB unchanged and
# makes Postgres match too.
template = "_Test Variant Item"
variant = create_variant(template, {"Test Size": "Small"})
if not frappe.db.exists("Item", variant.name):
variant.insert()
self.addCleanup(frappe.delete_doc, "Item", variant.name, force=True)
# stored attribute value is "Small"; query with a different case
matches = get_item_codes_by_attributes({"Test Size": ["small"]}, template)
self.assertIn(variant.name, matches)