diff --git a/.github/POSTGRES_COMPATIBILITY.md b/.github/POSTGRES_COMPATIBILITY.md new file mode 100644 index 00000000000..9dee4cb0f0d --- /dev/null +++ b/.github/POSTGRES_COMPATIBILITY.md @@ -0,0 +1,248 @@ +# PostgreSQL compatibility — review guide + +ERPNext targets **both MariaDB and PostgreSQL from a single codebase**. The full server +test suite passes on both, but the PostgreSQL CI job is **label-gated** (it does not run on +every PR), so until it is required this guide is the always-on guard. Greptile loads it as +review context (`.greptile/config.json`). + +When reviewing a PR, flag any **new or changed query** (raw `frappe.db.sql`, `frappe.qb`, +`frappe.get_all/get_list/get_value`, report SQL) that would **error on PostgreSQL** or +**return different results on the two engines**. + +## The one rule that governs everything + +**MariaDB behaviour must not change; PostgreSQL is brought into line with MariaDB — never the +reverse.** A "fix" that changes the value, row count, or ordering MariaDB produced is a +regression, even if the new behaviour looks more correct. The only accepted MariaDB-output +change is replacing a genuinely *undefined/arbitrary* result with a deterministic one (row +count preserved) — and that should be called out explicitly. + +There are two failure modes to watch for: +1. **Hard breaks** — PostgreSQL raises an exception; MariaDB is green. Easy to catch in CI, + but the gated job may not run. +2. **Silent divergences** — both engines succeed but return *different* results. CI on one + engine stays green; the bug only shows on a PostgreSQL site. These are the dangerous ones. + +--- + +## 1. Hard breaks — would error on PostgreSQL + +Flag a changed query that uses any of these: + +- **Loose `GROUP BY`** — selecting/ordering a column that is neither in `GROUP BY` nor wrapped + in an aggregate. MariaDB tolerates it; PostgreSQL errors (`must appear in the GROUP BY + clause or be used in an aggregate function`). This **also covers an aggregate (`Sum`/`Count`/…) + selected alongside bare columns with NO `.groupby()` at all** — MariaDB silently collapses + every row into one arbitrary-valued row (often a *wrong-output* bug there too), PostgreSQL + errors. Fix: add the bare column to `GROUP BY` **if it is functionally dependent on the group + key**, otherwise wrap it in `Max()`/`Min()`. **See §3 — the row-count trap — before suggesting + "add it to GROUP BY".** +- **MySQL-only functions** — `TIMESTAMP(date,time)`, `TIMEDIFF`, `STR_TO_DATE`, `DATE_FORMAT`, + `DATE_ADD/SUB`, `GROUP_CONCAT`, `PERIOD_DIFF`, SQL `IF(cond,a,b)`. Use the portable + `frappe.query_builder.functions` equivalents (`CombineDatetime`, `DateDiff`, `Case`, + `GroupConcat`, …) or a precomputed column (e.g. `posting_datetime`). +- **`UPDATE … JOIN`** — not valid on PostgreSQL. Rewrite as `UPDATE … WHERE name IN (subquery)`. +- **`HAVING` referencing a `SELECT` alias** — PostgreSQL rejects output-column aliases in + `HAVING` (regardless of whether the query has a `GROUP BY`; MariaDB allows them). Repeat the + underlying expression in `HAVING`, or move a non-aggregate predicate into `WHERE`. +- **`SELECT DISTINCT … ORDER BY `** — add the expr to the select + **only if it is single-valued per distinct row**; otherwise it grows the `DISTINCT` key and the + MariaDB row count (see §3) — drop the SQL `ORDER BY` and sort in Python instead. +- **Single-quoted column alias** `AS 'x'` — PostgreSQL reads `'x'` as a string literal. Use an + unquoted (or double-quoted) alias. +- **`varchar | varchar`** (bitwise OR misused as a coalesce) — errors on PostgreSQL. Use + `Coalesce(...)`. +- **Capital-cased identifiers** used as column/field names in `get_value(dt, dn, "Status")`, + `get_all(dt, fields=["Account"])`, and similar — PostgreSQL quotes the identifier and matches + it case-sensitively; a stored column named `status`/`account` won't match `"Status"`/`"Account"` + (`column "Account" does not exist`). Use the exact stored (lower-case) fieldname. +- **Boolean passed where an integer column is expected** — `frappe.db.set_value(dt, dn, + check_field, True)`, `doc.db_set(field, False)`, or `frappe.qb.update(dt).set(check_field, True)` + emit `SET col = true`, which PostgreSQL rejects on a `smallint`/`Check` column + (`column is of type smallint but expression is of type boolean`). Pass `1`/`0`. +- **`.like()`/`.ilike()` (or raw `LIKE`) on a NON-text column** — `idx`, `docstatus`, a date, etc. + frappe maps `.like()` → `ILIKE`, and PostgreSQL has no `bigint ILIKE text` operator (`operator + does not exist: bigint ~~* unknown`). Cast the column to text first — **`Cast_(col, "varchar")`**, + not `Cast(col, "char")` (see below). MariaDB coerces the int implicitly, so the cast is a no-op there. +- **`CAST(… AS CHAR)` / `Cast(x, "char")`** — on PostgreSQL bare `CHAR` is `character(1)`, so + `CAST(12 AS CHAR)` → `'1'` (silently truncates multi-digit values); MariaDB gives the full string. + Use `VARCHAR` / `Cast_(x, "varchar")`. +- **`.rlike()` / raw `RLIKE`** — frappe rewrites `REGEXP` → `~*` on PostgreSQL but does **not** + translate `RLIKE` (no such PostgreSQL operator). Use `.regexp()` (or `.like()` for a simple prefix). +- **`IfNull`/`Coalesce` of a typed column with a different-typed literal** — `IfNull(asset.disposal_date, 0)` + renders `COALESCE("disposal_date", 0)`, coalescing a **DATE** with an **integer**. PostgreSQL requires + `COALESCE` args to share a type (`DatatypeMismatch: COALESCE types date and integer cannot be matched`); + MariaDB's `IFNULL` is permissive. The common shape is `IfNull(date_col, 0) != 0 / == 0` as a presence test — + replace with `date_col.isnotnull()` / `date_col.isnull()` (identical, and valid on both). Otherwise coalesce + to a **same-type** default (`Coalesce(date_col, '1900-01-01')`, `Coalesce(text_col, '')`). +- **Division by a possibly-zero divisor** — `Sum(a) / Sum(b)`, `x / col`, etc. where the + divisor can be `0`/empty. MariaDB returns `NULL` for division by zero; PostgreSQL raises + `division by zero` and aborts the query. Wrap the divisor in `NullIf(divisor, 0)` — that + yields `NULL` on both engines, matching MariaDB's value. (Only the *literal* `/ 0` is a parse + constant; the trap is a divisor that is an aggregate or column the data can drive to zero.) + +--- + +## 2. Silent divergences — succeeds on both, returns different results + +These don't error, so a one-engine CI stays green. Flag them: + +- **Case sensitivity on text equality** — `==`, `.isin()`, `Strpos`/`Locate` on free-text + columns are case-**sensitive** on PostgreSQL but case-**insensitive** under MariaDB's default + collation. `Lower()` both sides. *(Not `.like()`/`["like", …]` — those already render as + `ILIKE` on PostgreSQL; see §4.)* +- **Case sensitivity in a doc-`name` lookup** — lower-casing a value then using it as a + document name in `get_value`/`get_doc`/`exists` misses on PostgreSQL (names are + case-sensitive). Keep original case for the identifier; lower-case only comparison operands. +- **Empty string vs NULL** — PostgreSQL stores a blank link/data field as `NULL` on some paths + while MariaDB keeps `''`; `Concat`/`Concat_ws` then diverge. Prefer the stored full value, or + `Coalesce(col, '')` per argument. +- **NULL ordering** — MariaDB sorts `NULL` first, PostgreSQL sorts it last. For + `ORDER BY … LIMIT 1`/`[0]` on a nullable column, guard with `Coalesce`/`isnotnull()`. +- **`ORDER BY … LIMIT 1` with no unique tiebreaker** — when rows tie on the ordered column the + two engines may pick different rows. Add a `creation`/`name` tiebreaker **only if it does not + change MariaDB's current pick** (see §4). +- **Integer division** — `int / int` truncates on PostgreSQL but is decimal on MariaDB, e.g. + `COUNT(...) / COUNT(...) * 100` → `0`, or `manufacturing_time_in_mins / 1440` flooring a + lead-time to whole days. Force float: multiply by `100.0`, or make a literal a float + (`/ 1440` → `/ 1440.0`), or cast an operand. (Only SQL-level `/` on integer **columns/literals** + — Python `/` is already float.) +- **`DISTINCT` list ordering** — `frappe.get_all(distinct=True, order_by=…)` / + `SELECT DISTINCT … ORDER BY`: frappe's `db_query` **silently drops `ORDER BY` for distinct + queries on PostgreSQL**, so the result is unordered there. Sort in Python instead — and use + `key=str.casefold`, because bare `sorted()` is case-sensitive (ASCII) while MariaDB's + collation is case-insensitive, so a plain sort reorders MariaDB's output. +- **Engine-specific function rewrites** — e.g. a PostgreSQL `regexp_replace` branch + reimplementing MariaDB's `CAST(SUBSTRING_INDEX(name,' ',-1) AS UNSIGNED)` (leading digits of + the last whitespace token). Verify the rewrite matches MariaDB on edge cases (`"X - 3a"→3`, + `"X - 1.5"→1`) by diffing both engines on literal rows. +- **`UnixTimestamp(date)` / date→epoch** is timezone-dependent (midnight in the DB session TZ), + so a strict `epoch <= now` bound is flaky on PostgreSQL. + +--- + +## 3. The row-count trap — `GROUP BY` **and** `DISTINCT` (the single most important rule) + +When making a loose `GROUP BY` PostgreSQL-valid, **do not add a non-functionally-dependent +column to the `GROUP BY` just to satisfy PostgreSQL** — that turns one group row into N and +**changes the MariaDB row count** (a regression). The classic traps are adding the **child/row +primary key** or an **editable per-row field**. Instead **`Max()`/`Min()`-wrap** the offending +column: the row count is preserved and the value goes from arbitrary (MariaDB's old loose pick) +to deterministic. + +**Judge functional dependence by the source table, not the column name:** +- A column from a **master joined on the group key** (`t3.x` where `t1.key = t3.name`) is FD → + safe to keep in `GROUP BY`. +- A descriptive field on the **transaction** table (`t1.supplier_name`, `t1.territory`, + `t1.item_name` — fetched/editable, can differ across historical rows for the same key) is + **not** FD even though it looks master-derived → `Max()`-wrap it. + +Conversely, do **not** suggest changing a `Max()`/`Min()`-wrapped column to `Sum()` (or vice +versa) to make a number "more correct" — that changes the MariaDB value. The wrap reproduces +MariaDB's prior one-value-per-group output; a different aggregate is a product change, out of +scope for a portability fix. + +**The same trap applies to `SELECT DISTINCT`.** To satisfy PostgreSQL's "an `ORDER BY` expr must +appear in the select list under `DISTINCT`" rule, **do not blindly add the ordered column to the +select** — if it is not single-valued per existing distinct row, the `DISTINCT` key grows and +MariaDB returns **more rows** (a regression), exactly as adding a non-FD column to `GROUP BY` does. +Add it only when it is functionally dependent on the existing select columns; otherwise drop the +SQL `ORDER BY` and **sort in Python** (`key=str.casefold`, per §2) so the distinct row set is +unchanged. + +### 3.1 Second-order traps — when the `Max()`/`Min()` wrap itself is the bug + +The wrap is only a no-op when the column is provably single-valued per group (**"`Max()` means +provably constant"**). When the column can genuinely vary, the wrap is a decision, and a full +audit of these fixes found four recurring mistakes: + +- **Incoherent pair** — two semantically-coupled columns (a flag + a link: + `is_phantom_item` + `bom_no`; a discriminator + its value) aggregated with *independent* + `Max()`/`Min()` can pair values from **different rows** — a chimera row that never existed. + MariaDB's loose pick was at least row-coherent. Fix: group by the pair (when consumers + tolerate the extra rows), or select one **representative row** (`Min(child.name)` subquery + + join-back) so every column comes from the same line. +- **NULL-skipping** — `MAX`/`MIN` ignore NULLs, so `Max()` over a mostly-NULL discriminator + (an `original_item`-style column) *deterministically* returns the non-NULL value where + MariaDB could return NULL — deterministically wrong where the old behavior was only + intermittently wrong. Flag it wherever "no value" is a meaningful state (fallback gates, + dict keys). +- **Fabricated arithmetic** — `Sum(x) * Max(y)` where `y` varies within the group invents a + number no row ever had (and `Max` biases it upward) — poisonous when it feeds validation, + budgets, valuation, or GL/stock values. Fix per-row: `Sum(x * y)`. +- **Wrong bound** — where the value has a semantic, pick the bound deliberately: + `Min(schedule_date)` for a "required by", `Min(idx)` for first-line ordering, a qty-weighted + average for a rate. A blind `Max` can understate urgency or overstate a figure. + +Review heuristic: **if choosing between `Max` and `Min` would change the answer, the column is +not functionally dependent** — wrapping either is the wrong fix. Group by it, restructure, or +pick a bound for a stated reason, and cover the varying-group case with a test. + +--- + +## 4. False positives — do NOT flag these + +These are auto-handled by the framework and are **not** breaks: + +- **`.like()` / `["like", …]`** already renders as `ILIKE` on PostgreSQL — not a + case-sensitivity bug. *(Exception: `.like()` on a **non-text** column — `idx`, `docstatus` — + is a hard break, `bigint ILIKE`; see §1.)* +- **Raw `ifnull(...)`** inside `frappe.db.sql()` is rewritten to `coalesce(...)` on all engines. +- **Backticks**, **`LOCATE`**, **`REGEXP`** / **`.regexp()`** in raw SQL are auto-translated on + PostgreSQL (`REGEXP` → `~*`). **But `RLIKE` / `.rlike()` is NOT translated** — that one is a + hard break (see §1). +- **An `ORDER BY … LIMIT 1` tie where the two engines already agree**, or where adding a + tiebreaker would *change* MariaDB's current pick — leave it; "fixing" it would either change + MariaDB or has no observable effect. + +--- + +## 5. Transaction / runtime (not query-shape, still PostgreSQL-only) + +- **Catch-and-continue inserts** — on PostgreSQL a failed `insert()` aborts the **whole + transaction**, so code that swallows a duplicate and keeps going dies on the next statement + with `InFailedSqlTransaction` (frappe dropped its blanket per-statement savepoint in + frappe#40075). Such a handler must wrap the fallible insert in `frappe.db.savepoint(name)` + + `rollback(save_point=name)` — unless it re-`throw`s with no DB call before the throw, or the + insert uses `ignore_if_duplicate=True` / `autoname="hash"` (→ `ON CONFLICT DO NOTHING`). +- **Recover the txn with a *scoped* savepoint, not a full `frappe.db.rollback()`, if any prior work + must survive.** A full rollback un-poisons the txn but also discards every row the handler committed + *before* the failure — which MariaDB kept (it has no statement-abort), so it's a **silent MariaDB + regression**. **"The background job / whitelist entrypoint owns the txn" does NOT make a full rollback + safe** if it did multiple inserts in a loop first — it drops the partial results MariaDB retained. A + full rollback is safe only when it (a) immediately re-`throw`s/`raise`s (MariaDB rolls back anyway), + (b) has nothing successful before it (a single op), or (c) the batch is genuinely meant to be + **atomic** (a partial result is an invalid state → rollback + mark *Failed* is correct). Otherwise use + a **per-iteration / per-record savepoint** — and keep the function's success/`None` return contract: + do **not** return the doc when the savepoint was rolled back. + +--- + +## 6. Refactors and raw-SQL→ORM conversions are not automatically 1:1 + +A commit labeled a **refactor** or a **raw-`frappe.db.sql` → `frappe.qb`/ORM conversion** is meant +to preserve behaviour — but it easily doesn't, and the change passes the static checker and a +one-engine green run. **Diff the `WHERE`/predicate, the `JOIN`/`ON` conditions, and the resulting +row set — not just the `SELECT` shape.** A conversion that silently widens or narrows the filter +changes the rows touched on **both** engines and is a regression hiding under a "refactor" label. + +Real example: an `UPDATE` whose bound was `posting_datetime > X` gained an +`OR (posting_datetime == X AND creation > args.creation)` branch during a "`sql` → `qb` refactor", +widening the rows updated on both engines. Even when such a change is a deliberate bug-fix it must +be called out and tested — it is **not** the no-op the refactor label implies. Confirm the +converted query touches exactly the same rows with the same values MariaDB produced before. + +--- + +## How to review + +For every changed query: does it (a) use a construct from §1 (would error on PostgreSQL), +(b) match a divergence in §2/§3 (different result across engines), or (c) change the row set under +a refactor/conversion label (§6)? If so, comment with the +portable fix and confirm it leaves **MariaDB output unchanged**. Skip the §4 false positives. +Prefer a comment that names the rule (e.g. "loose GROUP BY — Max()-wrap, don't add to GROUP BY: +splits the row count") so the fix is unambiguous. + +The static pre-commit checker (`.github/helper/postgres_compat.py`) catches the *mechanical* +§1 breaks; the **semantic** §2/§3 divergences and the §6 refactor/conversion row-set changes are +exactly what a reviewer (and this guide) must cover, because no static check can see them. diff --git a/.github/helper/hydrate.sh b/.github/helper/hydrate.sh new file mode 100755 index 00000000000..1372bc393a3 --- /dev/null +++ b/.github/helper/hydrate.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# +# Hydrate a test shard from the setup job's artifact. +# +# The bench (apps, venv, node_modules, sites) is already on disk at ~/frappe-bench — the +# workflow untar'd it from the artifact the setup job built. So there is NO bench init, no +# asset build, and no reinstall here: just bring the DB up on the baked datadir and start redis +# so tests can run. The whole point is that the expensive work happened ONCE in the setup job. +# +set -e + +ci_user="${ERPNEXT_CI_USER:-frappe}" +db_host="${DB_HOST:-127.0.0.1}" + +# Re-exec as the ci user (uid 1001) so bench/cache ownership matches the artifact, same as +# install.sh. The workflow untar'd as root with -p, so the files are already owned by ci. +if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then + exec su -m "$ci_user" -s /bin/bash -c \ + "ERPNEXT_CI_USER='$ci_user' DB_HOST='$db_host' DB='${DB:-}' bash '$0'" +fi + +cd ~/frappe-bench + +# Start the DB on the datadir baked into the artifact. It's already populated (the setup job +# reinstalled into this very datadir), so there is NO restore — the server comes up on the +# existing files. This is what replaces the per-shard SQL replay. +bash ~/frappe-bench/start-db.sh + +# Bring up redis (lightmode unit tests need cache + queue). In the self-hosted container we use the +# full `bench start` (web/workers too, like install.sh). On the bare GitHub Postgres shard +# `bench start` (honcho) lagged — it blocks the redis procs behind web/worker procs the lightmode +# suite never uses, so the wait below burned its full timeout (~4m). There, start the two redis +# instances directly: fast and deterministic. +if [ "${DB:-mariadb}" = "postgres" ]; then + # Start redis directly as daemons — reliable and persists across steps. Do NOT route it through + # `bench start`: honcho tears the whole process group down if any one Procfile proc dies on the + # bare shard, which took redis with it (redis @ 13000 refused in Run Tests). Keeping redis + # independent is what makes it survive. The web server (for PDF tests) is NOT started here — a + # backgrounded server doesn't survive into the next step; it's started inside the Run Tests step. + for conf in redis_cache redis_queue; do + [ -f ~/frappe-bench/config/$conf.conf ] && redis-server ~/frappe-bench/config/$conf.conf --daemonize yes + done +else + bench start >> ~/frappe-bench/bench_start.log 2>&1 & +fi + +# Wait for redis, failing fast instead of silently burning minutes if it never comes up. +cfg=~/frappe-bench/sites/common_site_config.json +if [ -f "$cfg" ]; then + ports=$(python - "$cfg" <<'PY' +import json, re, sys +try: + cfg = json.load(open(sys.argv[1])) +except Exception: + sys.exit(0) +for key in ("redis_cache", "redis_queue"): + m = re.search(r":(\d+)", str(cfg.get(key, ""))) + if m: + print(m.group(1)) +PY +) + for port in $ports; do + up=0 + for _ in $(seq 1 60); do + if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then exec 3>&- 3<&-; up=1; break; fi + sleep 1 + done + [ "$up" = "1" ] || { echo "redis did not come up on port $port"; exit 1; } + done +fi + +echo "Hydrated: DB up on baked datadir, redis up — ready for tests." diff --git a/.github/helper/install.sh b/.github/helper/install.sh index e14e62b92b0..27928ee8bbc 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 + local exit_code=0 + timeout --foreground "${CI_INSTALL_STEP_TIMEOUT:-1800}" "$@" || exit_code=$? + date -u + echo "::endgroup::" + return "$exit_code" +} + +if [ -n "${GITHUB_WORKSPACE:-}" ]; then + git config --global --add safe.directory "$GITHUB_WORKSPACE" || true + git config --global --add safe.directory "$GITHUB_WORKSPACE/.git" || true +fi + +rm -rf ~/frappe ~/frappe-bench # --------------------------------------------------------------------------- # Phase 1 — parallelise the three slow, independent setup steps: # a) system packages b) frappe-bench pip install c) frappe git fetch # --------------------------------------------------------------------------- -sudo apt update +if [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ]; then + sudo apt-get update -# apt remove/install must run sequentially but can overlap with pip and git. -sudo apt remove mysql-server mysql-client -sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev & -apt_pid=$! + # apt remove/install must run sequentially but can overlap with pip and git. + sudo apt-get remove -y mysql-server mysql-client + sudo apt-get install -y libcups2-dev redis-server mariadb-client libmariadb-dev & + apt_pid=$! -pip install frappe-bench & -pip_pid=$! + pip install frappe-bench & + pip_pid=$! +else + apt_pid= + pip_pid= +fi mkdir frappe ( @@ -32,76 +117,243 @@ 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; + + # Durability-off for speed (no fsync/synchronous_commit/full_page_writes) is applied by + # start-db.sh's postgres `-o` flags on every start — setup job AND each test shard — so it is + # NOT repeated here. The postgres workflow runs in-runner via start-db.sh, not a service + # container. fi - -install_whktml() { - # Re-use the .deb if the wkhtmltopdf cache step already restored it. - if [ ! -f /tmp/wkhtmltox.deb ]; then - wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb - fi - sudo apt install /tmp/wkhtmltox.deb -} -install_whktml & -wkpid=$! - - cd ~/frappe-bench || exit -sed -i 's/watch:/# watch:/g' Procfile -sed -i 's/schedule:/# schedule:/g' Procfile -sed -i 's/socketio:/# socketio:/g' Procfile -sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile +run_ci_step "Get payments app" bench get-app payments --branch develop -bench get-app payments --branch develop -bench get-app erpnext "${GITHUB_WORKSPACE}" +# Opt-in: skip building erpnext's frontend assets. Server tests don't need them, but PDF +# tests (print formats) do — they pass only if the PDF renderer ignores missing assets. +# Enable with CI_SKIP_ERPNEXT_ASSETS=1 to test; if PDF tests fail, unset it. +erpnext_get_app_args=() +if [ "${CI_SKIP_ERPNEXT_ASSETS:-0}" = "1" ]; then erpnext_get_app_args=(--skip-assets); fi +run_ci_step "Get erpnext app" bench get-app erpnext "${GITHUB_WORKSPACE}" "${erpnext_get_app_args[@]}" -if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi +if [ "$TYPE" == "server" ]; then run_ci_step "Setup dev requirements" bench setup requirements --dev; fi -wait $wkpid +bench start >> ~/frappe-bench/bench_start.log 2>&1 & -bench start &>> ~/frappe-bench/bench_start.log & -CI=Yes bench build --app frappe & -bench --site test_site reinstall --yes +# Under heavy concurrency, gunicorn's startup can delay redis coming up. reinstall and the +# tests need redis, so wait for it (best-effort, bounded) instead of racing — contention +# then slows the job rather than failing it. +wait_for_redis() { + local cfg=~/frappe-bench/sites/common_site_config.json + [ -f "$cfg" ] || return 0 + local ports port + ports=$(python - "$cfg" <<'PY' +import json, re, sys +try: + cfg = json.load(open(sys.argv[1])) +except Exception: + sys.exit(0) +for key in ("redis_cache", "redis_queue"): + match = re.search(r":(\d+)", str(cfg.get(key, ""))) + if match: + print(match.group(1)) +PY +) + for port in $ports; do + local up=0 + for _ in $(seq 1 120); do + if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then + exec 3>&- 3<&-; up=1 + break + fi + sleep 1 + done + # Fail clearly instead of letting reinstall die later on a vague socket-connection error + # when redis never bound. + [ "$up" = "1" ] || { echo "redis did not come up on port $port"; return 1; } + done +} +wait_for_redis + +# Site setup: build the schema (~1000 DocTypes) into the DB. This is the single-threaded-Python +# bottleneck, but the fan-out amortises it — it runs once here in the setup job, and the test +# shards start the DB on the baked datadir instead of repeating the reinstall. +run_ci_step "Reinstall test site" bench --site test_site reinstall --yes diff --git a/.github/helper/postgres_compat.py b/.github/helper/postgres_compat.py new file mode 100755 index 00000000000..f08899df559 --- /dev/null +++ b/.github/helper/postgres_compat.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Static guard against MySQL-only SQL that breaks on PostgreSQL. + +The Postgres test job is label-gated, so it does not run on every PR. This pre-commit +hook is the always-on first line of defence: it flags the *mechanical* Postgres breaks +that static analysis can catch reliably with a low false-positive rate. + +It deliberately does NOT try to catch the *semantic* divergences (loose GROUP BY, +case-sensitive ==/IN, NULL ordering, ORDER BY ... LIMIT 1 tiebreakers, integer-division +intent, division by a possibly-zero divisor, savepoint discipline) — those genuinely need +the test suite or a human/Greptile reviewer. Run the full suite on a Postgres site for those. + +Escape hatch: put `# pg-ok` anywhere on the offending statement's line span (e.g. on a +`SHOW INDEX` query that lives inside an `if frappe.db.db_type == "mariadb":` branch). + +Usage: postgres_compat.py [ ...] (pre-commit passes staged files) +""" + +from __future__ import annotations + +import ast +import re +import sys + +IGNORE = "pg-ok" + +# Strings are only scanned for the patterns below when they have real SQL *structure* +# (not just an English word like "select" or "from"), to keep false positives near zero. +SQL_HINT = re.compile( + r"\bselect\b[\s\S]{0,800}\bfrom\b" # SELECT ... FROM + r"|\bupdate\b[\s\S]{0,400}\bset\b" # UPDATE ... SET + r"|\bdelete\s+from\b" + r"|\binsert\s+into\b" + r"|\bshow\s+(?:index|tables|columns)\b" + r"|\bfrom\s+[\"'`]?tab", # FROM `tabDocType` + re.I, +) + +# MySQL-only constructs with NO frappe auto-translation. (frappe.db.sql already rewrites +# ifnull->coalesce on all engines and backtick/locate/REGEXP on Postgres, and .like() +# renders ILIKE — so those are NOT listed here; flagging them would be false positives.) +SQL_PATTERNS: list[tuple[re.Pattern, str]] = [ + (re.compile(r"\btimestamp\s*\(\s*[^,()]+,", re.I), + "timestamp(date, time) is MySQL-only -> use CombineDatetime() or a precomputed datetime column"), + (re.compile(r"\btimediff\s*\(", re.I), + "timediff() is MySQL-only -> compute the delta in Python"), + (re.compile(r"\bstr_to_date\s*\(", re.I), + "str_to_date() is MySQL-only -> parse in Python and pass a real date"), + (re.compile(r"\bdate_format\s*\(", re.I), + "date_format() is MySQL-only -> filter on a date range instead"), + (re.compile(r"\bdate_(add|sub)\s*\(", re.I), + "date_add()/date_sub() are MySQL-only -> use Python date math or interval arithmetic"), + (re.compile(r"\bgroup_concat\s*\(", re.I), + "group_concat() is MySQL-only -> use GroupConcat (string_agg) or aggregate in Python"), + (re.compile(r"\bperiod_diff\s*\(", re.I), + "period_diff() is MySQL-only -> compute in Python"), + (re.compile(r"\bshow\s+index\b", re.I), + "SHOW INDEX is MySQL-only -> use frappe.db.has_index() / get_column_index()"), + (re.compile(r"\bshow\s+(tables|columns)\b", re.I), + "SHOW TABLES/COLUMNS is MySQL-only -> use frappe.db.get_tables()/table_columns / information-schema helpers"), + (re.compile(r"\bas\s+'[^']+'", re.I), + "single-quoted column alias breaks on Postgres -> use a bare or double-quoted alias"), + (re.compile(r"\bif\s*\(", re.I), + "SQL IF() is MySQL-only -> use CASE WHEN ... THEN ... ELSE ... END (frappe.qb.Case())"), + (re.compile(r"\brlike\b", re.I), + "RLIKE is MySQL-only -> frappe rewrites REGEXP->~* on Postgres but NOT RLIKE; use REGEXP / .regexp() / ~"), + (re.compile(r"\bcast\s*\(.+?\bas\s+char\b", re.I | re.S), # .+? spans nested parens, e.g. CAST(ABS(x) AS CHAR) + "CAST(... AS CHAR) is character(1) on Postgres and truncates -> CAST AS VARCHAR (frappe Cast_(x, 'varchar'))"), +] + +# UPDATE ... JOIN: both keywords in the same SQL string. +UPDATE_JOIN = (re.compile(r"\bupdate\b", re.I), re.compile(r"\bjoin\b", re.I)) + +MYSQL_RESULT_KEYS = {"Column_name", "Key_name", "Seq_in_index", "Non_unique", "Index_type"} + +SET_BOOL_FUNCS = {"set_value", "db_set"} + +# query-builder cast helpers: pypika Cast / frappe Cast_. A "char" target type is character(1) +# on Postgres (truncates); "varchar" is the full-length cast. +CAST_FUNCS = {"Cast", "Cast_"} + +# frappe.get_all / get_list: frappe's db_query SILENTLY drops ORDER BY for `distinct` queries on +# Postgres (the ORDER BY column must appear in the SELECT-DISTINCT list), so `distinct=True` together +# with a literal `order_by` is a no-op on PG and the result comes back unordered. +DISTINCT_ORDER_FUNCS = {"get_all", "get_list"} + + +def _docstring_ids(tree: ast.AST) -> set[int]: + """ids of Constant nodes that are docstrings (so prose describing the rules isn't flagged).""" + ids: set[int] = set() + for node in ast.walk(tree): + if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + body = getattr(node, "body", None) + if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) and isinstance(body[0].value.value, str): + ids.add(id(body[0].value)) + return ids + + +class Visitor(ast.NodeVisitor): + def __init__(self, lines: list[str], docstrings: set[int]): + self.lines = lines + self.docstrings = docstrings + self.violations: list[tuple[int, str]] = [] + + def _ignored(self, node: ast.AST) -> bool: + start = getattr(node, "lineno", 1) + end = getattr(node, "end_lineno", start) or start + # honour `# pg-ok` anywhere on the node's line span, the line just above (the enclosing + # call, e.g. `frappe.db.sql( # pg-ok`), or the line just below (a multi-line call's `) # pg-ok`). + lo = max(0, start - 2) + return any(IGNORE in self.lines[i] for i in range(lo, min(end + 1, len(self.lines)))) + + def _flag(self, node: ast.AST, msg: str) -> None: + if not self._ignored(node): + self.violations.append((getattr(node, "lineno", 1), msg)) + + def _scan_sql(self, text: str, node: ast.AST) -> None: + if not SQL_HINT.search(text): + return + for pattern, msg in SQL_PATTERNS: + if pattern.search(text): + self._flag(node, msg) + if UPDATE_JOIN[0].search(text) and UPDATE_JOIN[1].search(text): + self._flag(node, "UPDATE ... JOIN is MySQL-only -> use a correlated subquery (WHERE ... IN/EXISTS)") + + def visit_Constant(self, node: ast.Constant) -> None: + # plain string literals, incl. `"...".format()` and `"..." % (...)` templates + if isinstance(node.value, str) and id(node) not in self.docstrings: + self._scan_sql(node.value, node) + self.generic_visit(node) + + def visit_JoinedStr(self, node: ast.JoinedStr) -> None: + # f-string: scan its STATIC text (interpolated values become a placeholder) so MySQL-isms + # in dynamic SQL are caught, without flagging safe interpolation of identifiers. + text = "".join( + v.value if isinstance(v, ast.Constant) and isinstance(v.value, str) else " ? " + for v in node.values + ) + self._scan_sql(text, node) + # don't recurse: child literal chunks would otherwise be re-scanned individually + + def visit_Call(self, node: ast.Call) -> None: + fn = node.func + name = fn.attr if isinstance(fn, ast.Attribute) else (fn.id if isinstance(fn, ast.Name) else "") + + # row.get("Column_name") — MySQL SHOW INDEX result key + if name == "get" and node.args and isinstance(node.args[0], ast.Constant) and node.args[0].value in MYSQL_RESULT_KEYS: + self._flag(node, f'"{node.args[0].value}" is a MySQL SHOW INDEX result key -> use frappe.db.has_index()/get_column_index()') + + # set_value(..., True) / db_set("field", True) on a Check (int) column. + # Only the field *value* arg carries bool->smallint risk — NOT trailing flags like + # update_modified. db_set(field, value, update_modified, ...) -> value at args[1] (or a dict + # at args[0]); set_value(dt, dn, field, value, ...) -> value at args[3] (or a dict at args[2]). + if name in SET_BOOL_FUNCS: + value_idx, dict_idx = (1, 0) if name == "db_set" else (3, 2) + dict_arg = ( + node.args[dict_idx] + if len(node.args) > dict_idx and isinstance(node.args[dict_idx], ast.Dict) + else None + ) + if dict_arg is not None: + for v in dict_arg.values: + if isinstance(v, ast.Constant) and isinstance(v.value, bool): + self._flag(node, f"{name}(...) sets an int/Check column with a bool in a dict -> pass 1/0 (Postgres rejects bool->smallint)") + elif len(node.args) > value_idx: + a = node.args[value_idx] + if isinstance(a, ast.Constant) and isinstance(a.value, bool): + self._flag(node, f"{name}(..., {a.value}) sets an int/Check column with a bool -> pass 1/0 (Postgres rejects bool->smallint)") + + # frappe.get_all/get_list(..., distinct=True, order_by="") -> ORDER BY is silently dropped + # for distinct queries on Postgres, so the result is unordered there. Sort in python instead + # (e.g. sorted(frappe.get_all(..., distinct=True), key=str.casefold)). An empty order_by="" (the + # explicit "suppress the injected default" idiom) and a dynamic/variable order_by are not flagged. + if name in DISTINCT_ORDER_FUNCS: + has_distinct = any( + kw.arg == "distinct" and isinstance(kw.value, ast.Constant) and kw.value.value + for kw in node.keywords + ) + order_kw = next((kw for kw in node.keywords if kw.arg == "order_by"), None) + has_literal_order = ( + order_kw is not None + and isinstance(order_kw.value, ast.Constant) + and isinstance(order_kw.value.value, str) + and order_kw.value.value.strip() + ) + if has_distinct and has_literal_order: + self._flag(node, f"{name}(distinct=True, order_by=...) -> frappe drops ORDER BY for distinct queries on Postgres; sort in python instead, e.g. sorted(..., key=str.casefold)") + + # query-builder .rlike(...): pypika emits the MySQL-only RLIKE operator, which frappe does + # NOT translate for Postgres (it rewrites only REGEXP -> ~*). + if name == "rlike": + self._flag(node, ".rlike() emits MySQL-only RLIKE (not translated on Postgres) -> use .regexp() (rewritten to ~*) or .like()") + + # Cast(col, "char") / Cast_(col, "char"): on Postgres a bare CHAR is character(1) and truncates + # (e.g. CAST(12 AS CHAR) -> '1'); use "varchar" for a full-length string cast. + if name in CAST_FUNCS: + for arg in (*node.args, *(kw.value for kw in node.keywords)): + if isinstance(arg, ast.Constant) and isinstance(arg.value, str) and arg.value.strip().lower() == "char": + self._flag(node, f"{name}(..., 'char') is character(1) on Postgres and truncates -> use 'varchar'") + + self.generic_visit(node) + + def visit_Subscript(self, node: ast.Subscript) -> None: + key = node.slice + if isinstance(key, ast.Constant) and key.value in MYSQL_RESULT_KEYS: + self._flag(node, f'"{key.value}" is a MySQL SHOW INDEX result key -> use frappe.db.has_index()/get_column_index()') + self.generic_visit(node) + + +def check_file(path: str) -> list[str]: + try: + # nosemgrep: frappe-semgrep-rules.rules.security.frappe-security-file-traversal -- dev-only lint tool; `path` is a source file supplied by pre-commit, not user input + src = open(path, encoding="utf-8").read() + except (OSError, UnicodeDecodeError): + return [] + try: + tree = ast.parse(src, filename=path) + except SyntaxError: + return [] # check-ast hook reports real syntax errors + v = Visitor(src.splitlines(), _docstring_ids(tree)) + v.visit(tree) + return [f"{path}:{line}: [pg-compat] {msg}" for line, msg in sorted(set(v.violations))] + + +def main(argv: list[str]) -> int: + out: list[str] = [] + for path in argv: + if path.endswith(".py"): + out.extend(check_file(path)) + if out: + print("\n".join(out)) + print( + f"\n{len(out)} PostgreSQL-incompatibility issue(s). Fix them, or add `# pg-ok` to a " + "line that is intentionally MariaDB-only (e.g. inside an `if frappe.db.db_type == 'mariadb':` branch)." + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/.github/helper/site_config_postgres.json b/.github/helper/site_config_postgres.json index c82905fea02..b01d0438940 100644 --- a/.github/helper/site_config_postgres.json +++ b/.github/helper/site_config_postgres.json @@ -13,6 +13,6 @@ "root_login": "postgres", "root_password": "travis", "host_name": "http://test_site:8000", - "install_apps": ["erpnext"], + "install_apps": ["payments", "erpnext"], "throttle_user_limit": 100 } diff --git a/.github/helper/start-db.sh b/.github/helper/start-db.sh new file mode 100755 index 00000000000..507c33bb7e4 --- /dev/null +++ b/.github/helper/start-db.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# +# Run MariaDB INSIDE the runner container, on a datadir we control. Because the datadir can be +# packaged into the bench artifact, test shards start an already-loaded server instead of +# replaying a SQL dump (the ~60s hydrate restore). Each shard gets its own copy → isolation kept. +# +# CI_DB_DATADIR picks the path: +# - setup job: /home/ci/db-data (OUTSIDE the bench, so install.sh's `rm -rf ~/frappe-bench` +# doesn't wipe it; it's moved into the bench just before packaging) +# - test shard: ~/frappe-bench/mariadb-data (where the artifact untar'd it) +# +# Idempotent: inits a fresh datadir if absent (setup), else starts on the existing one (shards). +# +set -e + +ci_user="${ERPNEXT_CI_USER:-frappe}" + +# Re-exec as the ci user so mariadbd and the datadir are owned consistently (root mariadbd is +# refused anyway). Mirrors install.sh's user switch. +if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then + exec su -m "$ci_user" -s /bin/bash -c \ + "ERPNEXT_CI_USER='$ci_user' CI_DB_DATADIR='${CI_DB_DATADIR:-}' DB='${DB:-}' bash '$0'" +fi + +# --- PostgreSQL (GitHub-hosted CI): run in-runner on a PGDATA so it bakes into the artifact, +# same idea as the mariadb datadir. Trust auth (throwaway CI) skips password setup; durability +# off for speed. Postgres is preinstalled on ubuntu-latest under /usr/lib/postgresql//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)" diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 37d8363beaa..0d2cd148251 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -21,7 +21,7 @@ jobs: cache: pip - name: Install and Run Pre-commit - uses: pre-commit/action@v3.0.0 + uses: pre-commit/action@v3.0.1 semgrep: name: semgrep diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml index 96816e59dee..83c2d7ff925 100644 --- a/.github/workflows/patch.yml +++ b/.github/workflows/patch.yml @@ -65,6 +65,22 @@ 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 + # it from the GitHub release 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: | + curl -fSL --retry 5 --retry-all-errors --retry-delay 5 \ + -o ~/erpnext-v14.sql.gz \ + https://github.com/frappe/erpnext/releases/download/v14-baseline/erpnext-v14.sql.gz + - name: Cache pip uses: actions/cache@v4 with: @@ -113,12 +129,20 @@ 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 + # Start every bench process except the background workers. If workers run during a + # migrate, they pick up the orphan-link cleanup jobs it enqueues and race its schema + # changes, which fails with MySQL 1412 "Table definition has changed". Redis and the + # other services stay up; the queued jobs simply wait and are harmless here. + function start_bench_without_workers() { + local procs + procs=$(awk -F: '/^[a-z_]+:/ && $1 !~ /worker/ {print $1}' ~/frappe-bench/Procfile) + honcho start -f ~/frappe-bench/Procfile $procs &>> ~/frappe-bench/bench_start.log & + } function update_to_version() { version=$1 @@ -134,10 +158,11 @@ jobs: # Resetup env and install apps pgrep honcho | xargs kill + sleep 10 rm -rf ~/frappe-bench/env bench -v setup env --python python$2 bench pip install -e ./apps/erpnext - bench start &>> ~/frappe-bench/bench_start.log & + start_bench_without_workers bench --site test_site migrate } @@ -154,7 +179,7 @@ jobs: rm -rf ~/frappe-bench/env bench -v setup env bench pip install -e ./apps/erpnext - bench start &>> ~/frappe-bench/bench_start.log & + start_bench_without_workers bench --site test_site migrate diff --git a/.github/workflows/review-translation-changes.yaml b/.github/workflows/review-translation-changes.yaml index 0284082c310..fafc3245106 100644 --- a/.github/workflows/review-translation-changes.yaml +++ b/.github/workflows/review-translation-changes.yaml @@ -22,4 +22,4 @@ jobs: pull-requests: write steps: - - uses: alyf-de/po-review-action@v1.0.0 + - uses: alyf-de/po-review-action@v1.1.0 diff --git a/.github/workflows/server-tests-mariadb.yml b/.github/workflows/server-tests-mariadb.yml index 0a9b094bc51..3e1d076ce38 100644 --- a/.github/workflows/server-tests-mariadb.yml +++ b/.github/workflows/server-tests-mariadb.yml @@ -31,51 +31,49 @@ on: permissions: contents: read + packages: read concurrency: group: server-mariadb-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }} cancel-in-progress: true +# Shared across both jobs. Both run in the SAME CI image so the bench lives at the identical +# path (/home/ci/frappe-bench) on the setup runner and the test shards — that's what makes the +# packaged Python venv portable between them. +env: + TZ: 'Asia/Kolkata' + DEBIAN_FRONTEND: noninteractive + NODE_ENV: "production" + WITH_COVERAGE: ${{ github.event_name != 'pull_request' }} + ERPNEXT_CI_USER: ci + PIP_CACHE_DIR: /home/ci/.cache/pip + npm_config_cache: /home/ci/.cache/npm + YARN_CACHE_FOLDER: /home/ci/.cache/yarn + UV_CACHE_DIR: /home/ci/.cache/uv + jobs: - test: - runs-on: ubuntu-latest - timeout-minutes: 60 - env: - TZ: 'Asia/Kolkata' - NODE_ENV: "production" - WITH_COVERAGE: ${{ github.event_name != 'pull_request' }} - - strategy: - fail-fast: false - - matrix: - container: [1, 2, 3, 4] - - name: Python Unit Tests - - services: - mysql: - image: mariadb:10.6 - env: - TZ: 'Asia/Kolkata' - MARIADB_ROOT_PASSWORD: 'root' - # Disable durability guarantees that are unnecessary in a throwaway CI container. - # innodb_flush_log_at_trx_commit=0 avoids an fsync on every commit (biggest win). - # sync_binlog=0 skips binary-log syncs; innodb_doublewrite=0 skips the doublewrite buffer. - MARIADB_EXTRA_FLAGS: --innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --innodb-doublewrite=0 - ports: - - 3306:3306 - options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3 - + # Build the bench (clone + pip + yarn + assets) and reinstall test_site ONCE, on a free + # GitHub-hosted runner, then publish the whole bench (with a DB dump baked in) as an artifact. + # The expensive, non-parallelisable work happens here exactly once instead of on every shard. + setup: + name: Build & reinstall (setup) + # Dedicated scale set (fat cpu request) so the build+reinstall runs at full speed, uncontended + # by the many thin test shards. Same CI image + /home/ci path + 127.0.0.1 DB as the shards, + # so the packaged bench (and its venv) transplants cleanly. + runs-on: erpnext-arc-setup + timeout-minutes: 40 + container: + image: ghcr.io/frappe/erpnext-ci-mariadb:py3.14-node24 + credentials: + username: ${{ secrets.GHCR_USERNAME || github.actor }} + password: ${{ secrets.GHCR_TOKEN || github.token }} + defaults: + run: + shell: bash steps: - name: Clone uses: actions/checkout@v6 - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.14' - - name: Check for valid Python & Merge Conflicts run: | python -m compileall -fq "${GITHUB_WORKSPACE}" @@ -84,53 +82,17 @@ jobs: exit 1 fi - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: 24 - check-latest: true - - name: Add to Hosts run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts - - name: Cache pip - uses: actions/cache@v4 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-pip- - ${{ runner.os }}- - - - name: Cache node modules - uses: actions/cache@v4 + # MariaDB runs in-container on a datadir OUTSIDE the bench, because install.sh's next step + # does `rm -rf ~/frappe-bench`. After the reinstall, the datadir is moved into the bench so + # it ships in the artifact — test shards then start an already-loaded server (no restore). + - name: Start DB + run: bash ${GITHUB_WORKSPACE}/.github/helper/start-db.sh env: - cache-name: cache-node-modules - with: - path: ~/.npm - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - - - uses: actions/cache@v4 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Cache wkhtmltopdf - uses: actions/cache@v4 - with: - path: /tmp/wkhtmltox.deb - key: wkhtmltox-0.12.6.1-2-jammy-amd64 + SKIP_SYSTEM_SETUP: "1" + CI_DB_DATADIR: /home/ci/db-data - name: Install run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh @@ -139,9 +101,88 @@ jobs: TYPE: server FRAPPE_USER: ${{ github.event.inputs.user }} FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }} + DB_HOST: 127.0.0.1 + DB_USER_HOST: '%' + WKHTMLTOX_DEB: /tmp/wkhtmltox.deb + SKIP_SYSTEM_SETUP: "1" + SKIP_WKHTMLTOX_SETUP: "1" + + - name: Warm up test data + run: | + su -m "${ERPNEXT_CI_USER:-frappe}" -s /bin/bash <<'EOF' + cd ~/frappe-bench/ + bench --site test_site run-tests --lightmode --module erpnext.tests.bootstrap_test_data + EOF + + # Clean shutdown (consistent InnoDB datadir), then stage it inside the bench for packaging. + - name: Stop DB and stage datadir + run: | + mariadb-admin -h 127.0.0.1 -P 3306 -u root -proot shutdown || true + for _ in $(seq 1 30); do [ -f /home/ci/db-data/mysqld.pid ] || break; sleep 1; done + # Don't bake a dirty datadir — fail if mariadbd didn't finish stopping, rather than ship + # an inconsistent datadir the shards would have to crash-recover. + [ -f /home/ci/db-data/mysqld.pid ] && { echo "mariadbd did not shut down cleanly"; exit 1; } + mv /home/ci/db-data /home/ci/frappe-bench/mariadb-data + + # Package the whole bench (apps, venv, node_modules, sites, the DB dump, and hydrate.sh) + # into one artifact for the test shards to consume. + # Single-node hand-off: stage the bench on a node-local hostPath instead of round-tripping + # through GitHub artifact storage (~60s/shard). Setup and shards share the same disk, so + # the shards just untar it locally. NOTE: this assumes one node — a shard on a different + # node could not read this path (then you'd need GitHub artifacts or an NFS/RWX volume). + - name: Stage bench on node (hostPath) + run: | + cp "${GITHUB_WORKSPACE}/.github/helper/hydrate.sh" /home/ci/frappe-bench/hydrate.sh + cp "${GITHUB_WORKSPACE}/.github/helper/start-db.sh" /home/ci/frappe-bench/start-db.sh + mkdir -p /opt/ci-bench-staging + # self-clean: drop bench tars from runs older than 2h + find /opt/ci-bench-staging -maxdepth 1 -name '*.tar.gz' -mmin +120 -delete 2>/dev/null || true + # Exclude .git/node_modules; the mariadb-data datadir IS included (the pre-loaded DB). + tar czpf "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz" -C /home/ci \ + --exclude='.git' --exclude='node_modules' frappe-bench + ls -lh "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz" + + # Fan-out: each shard downloads the bench, untars it, starts MariaDB on the baked datadir, and + # runs its slice of the suite. No clone, no build, no reinstall, no DB dump restore on the shards. + test: + name: Python Unit Tests + needs: setup + runs-on: erpnext-arc + timeout-minutes: 60 + container: + image: ghcr.io/frappe/erpnext-ci-mariadb:py3.14-node24 + credentials: + username: ${{ secrets.GHCR_USERNAME || github.actor }} + password: ${{ secrets.GHCR_TOKEN || github.token }} + defaults: + run: + shell: bash + + strategy: + fail-fast: false + matrix: + container: [1, 2, 3, 4] + + steps: + - name: Add to Hosts + run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts + + # Read the bench straight from the node-local hostPath the setup job staged it on — no + # GitHub download. -p preserves the ci (uid 1001) ownership so bench runs as ci cleanly. + - name: Untar bench from node (hostPath) + run: | + tar xzpf "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz" -C /home/ci + ls -ld /home/ci/frappe-bench + + - name: Hydrate (start DB on baked datadir + bench start) + run: bash /home/ci/frappe-bench/hydrate.sh + env: + DB_HOST: 127.0.0.1 + SKIP_SYSTEM_SETUP: "1" - name: Run Tests run: | + su -m "${ERPNEXT_CI_USER:-frappe}" -s /bin/bash <<'EOF' cd ~/frappe-bench/ coverage_flag="" if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi @@ -149,10 +190,10 @@ jobs: --total-builds ${{ strategy.job-total }} \ --build-number ${{ matrix.container }} \ $coverage_flag + EOF env: TYPE: server - - name: Show bench output if: ${{ always() }} run: cat ~/frappe-bench/bench_start.log || true @@ -162,11 +203,11 @@ jobs: uses: actions/upload-artifact@v4 with: name: coverage-${{ matrix.container }} - path: /home/runner/frappe-bench/sites/coverage.xml + path: /home/ci/frappe-bench/sites/coverage.xml coverage: name: Coverage Wrap Up - needs: test + needs: [test] if: ${{ github.event_name != 'pull_request' }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/server-tests-postgres.yml b/.github/workflows/server-tests-postgres.yml index da5a4b60c3c..44ab5dfab7a 100644 --- a/.github/workflows/server-tests-postgres.yml +++ b/.github/workflows/server-tests-postgres.yml @@ -1,7 +1,12 @@ name: Server (Postgres) on: + schedule: + # 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' @@ -9,7 +14,7 @@ on: - 'crowdin.yml' - '.coderabbit.yml' - '.mergify.yml' - types: [opened, labelled, synchronize, reopened] + workflow_dispatch: concurrency: group: server-postgres-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }} @@ -18,41 +23,31 @@ concurrency: 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: - if: ${{ contains(github.event.pull_request.labels.*.name, 'postgres') }} + setup: + name: Build & reinstall (setup) runs-on: ubuntu-latest - timeout-minutes: 60 - - strategy: - fail-fast: false - matrix: - container: [1] - - name: Python Unit Tests - - services: - postgres: - image: postgres:13.3 - env: - POSTGRES_PASSWORD: travis - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - + # 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 - 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: | @@ -71,48 +66,133 @@ 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- + DB: postgres + CI_DB_DATADIR: /home/runner/pgdata - 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: Warm up test data + run: | + cd ~/frappe-bench/ + bench --site test_site run-tests --lightmode --module erpnext.tests.bootstrap_test_data + + - name: Stop DB and stage datadir + run: | + PG_BIN=$(ls -d /usr/lib/postgresql/*/bin | sort -V | tail -1) + # Clean shutdown so the baked datadir is consistent. Do NOT swallow a failed stop with + # `|| true`: moving and tarring a still-running cluster ships a torn datadir the shards + # cannot crash-recover (full_page_writes is off). Fail the job instead — mirrors the + # MariaDB sister's "don't bake a dirty datadir" guard. + "$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop + 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 + 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/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator + 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 + bench --site test_site run-parallel-tests --lightmode --app erpnext \ + --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }} env: TYPE: server - CI_BUILD_ID: ${{ github.run_id }} - ORCHESTRATOR_URL: http://test-orchestrator.frappe.io diff --git a/.greptile/config.json b/.greptile/config.json index 8d9c41c662e..e3492ac7c0d 100644 --- a/.greptile/config.json +++ b/.greptile/config.json @@ -6,5 +6,20 @@ "repos": [ "frappe/frappe" ] + }, + "instructions": "ERPNext runs on both MariaDB and PostgreSQL from one codebase, but the PostgreSQL test job is label-gated and may not run on this PR, so review every new or changed database query (raw frappe.db.sql, frappe.qb, frappe.get_all/get_list/get_value, and report SQL) for cross-engine compatibility. PRIME RULE: MariaDB output must never change; PostgreSQL is bent to match MariaDB, never the reverse, so a change to the value, row count, or ordering MariaDB produced is a regression even if it looks more correct (the only accepted change is replacing an arbitrary/undefined result with a deterministic one, row count preserved, and it should be called out). Flag a changed query that (1) would ERROR on PostgreSQL: loose GROUP BY (selecting/ordering a column neither grouped nor aggregated -- including an aggregate like Sum()/Count() selected next to bare columns with NO .groupby() at all), MySQL-only functions (TIMESTAMP(date,time), TIMEDIFF, STR_TO_DATE, DATE_FORMAT, DATE_ADD/DATE_SUB, GROUP_CONCAT, PERIOD_DIFF, SQL IF()), .rlike()/RLIKE (frappe rewrites REGEXP->~* on PostgreSQL but does NOT translate RLIKE; use .regexp()), .like()/LIKE on a NON-text column such as idx/docstatus (bigint ILIKE has no operator; Cast_(col,'varchar') first), CAST AS CHAR / Cast(x,'char') (bare CHAR is character(1) on PostgreSQL and truncates multi-digit values; use 'varchar'), UPDATE..JOIN, HAVING on a SELECT alias, SELECT DISTINCT with an ORDER BY expr not in the select list, single-quoted column aliases, varchar bitwise OR, capital-cased identifiers used as fieldnames in get_value(dt,dn,'Status') or get_all(dt,fields=['Account']) (PostgreSQL matches the quoted identifier case-sensitively; use the stored lower-case name), a Python bool written to a Check/int column via set_value/db_set/qb.update().set() instead of 1/0, or IfNull/Coalesce of a typed column with a different-typed literal such as IfNull(date_col, 0) -> COALESCE(date, integer) (PostgreSQL: 'COALESCE types date and integer cannot be matched'; the common IfNull(date,0) != 0 / == 0 presence test should be date_col.isnotnull() / .isnull(), else coalesce to a same-type default), or division by a possibly-zero divisor (Sum(a)/Sum(b) or x/col where the data can drive the divisor to 0 -- MariaDB returns NULL for division by zero but PostgreSQL raises 'division by zero' and aborts the query, so wrap the divisor in NullIf(divisor, 0)); or (2) would SILENTLY DIVERGE across engines: case-sensitive ==/.isin()/Strpos on USER-ENTERED free-text columns such as Data/Small Text/Long Text but NOT on Link/Select/name columns where exact-case matching is intended (PostgreSQL is case-sensitive, use Lower() both sides), lowercasing a value used as a document-name lookup, empty-string vs NULL in Concat/Concat_ws (MariaDB CONCAT(x,NULL) is NULL but PostgreSQL CONCAT drops the NULL, so a label like Concat('MFG-', nullable_date) leaks a bare 'MFG-' on PostgreSQL -- guard with Case/Coalesce/NullIf), NULL ordering (PostgreSQL sorts NULLs last) in ORDER BY..LIMIT 1, integer division (int/int truncates on PostgreSQL; multiply by 100.0 or make a literal a float, e.g. col/1440 -> col/1440.0), get_all(distinct=True, order_by=...) (frappe DROPS the ORDER BY for distinct queries on PostgreSQL, so sort in Python with key=str.casefold), an engine-specific function rewrite that does not match MariaDB on edge cases, or UnixTimestamp(date)/date-to-epoch math that is timezone-dependent (a strict epoch <= now bound is flaky on PostgreSQL). Also flag CATCH-AND-CONTINUE inserts: on PostgreSQL a failed insert aborts the WHOLE transaction (InFailedSqlTransaction), so code that swallows a duplicate/unique error and keeps going in the same transaction must wrap the fallible insert in frappe.db.savepoint(name) + rollback(save_point=name), unless it re-throws with no DB call before the throw or the insert uses ignore_if_duplicate=True or autoname='hash'. When RECOVERING the poisoned txn, prefer a SCOPED savepoint over a full frappe.db.rollback(): a full rollback discards rows the handler already created before the failure -- which MariaDB keeps -- so it is a silent MariaDB regression. 'The bg job / whitelist entrypoint owns the txn' does NOT make a full rollback safe if it did multiple inserts in a loop first; a full rollback is safe only when it immediately re-throws/raises, has nothing successful before it (single op), or the batch is meant to be atomic (a partial result is invalid -> rollback + mark Failed is correct). Otherwise use a per-iteration/per-record savepoint, and keep the function's success/None return contract (don't return a value for a doc that was just rolled back). GROUP BY ROW-COUNT TRAP (most important): to make a loose GROUP BY PostgreSQL-valid, do NOT add a non-functionally-dependent column (the classic traps are the child/row primary key or an editable per-row field) to GROUP BY because that splits one row into N and changes the MariaDB row count; Max()/Min()-wrap it instead (row count preserved, value arbitrary to deterministic). Judge functional dependence by the SOURCE TABLE: a column from a master joined on the group key is FD and safe in GROUP BY, but a descriptive field on the transaction table (e.g. t1.supplier_name, t1.territory) is NOT FD and must be wrapped. The SAME row-count trap applies to SELECT DISTINCT: to satisfy PostgreSQL's ORDER-BY-expr-must-appear-in-the-select rule, do NOT blindly add the ordered column to the select -- if it is not single-valued per existing distinct row the DISTINCT key grows and MariaDB returns MORE rows (a regression); add it only if functionally dependent on the existing select columns, otherwise drop the SQL ORDER BY and sort in Python (key=str.casefold). Do NOT suggest changing a Max()-wrapped column to Sum() to make a number more correct, that changes MariaDB's value. SECOND-ORDER GROUP BY TRAPS (the Max()/Min() wrap itself can be the bug -- a wrap is only a no-op when the column is provably single-valued per group): (a) INCOHERENT PAIR: two semantically-coupled columns (a flag + a link like is_phantom_item + bom_no, a discriminator + its value) aggregated with INDEPENDENT Max()/Min() can pair values from DIFFERENT rows into a chimera row that never existed (MariaDB's loose pick was at least row-coherent) -- when a consumer uses the two values together (recursion into the link gated by the flag, dict keys, link+flag display) require grouping by the pair or a single representative-row subquery (Min(child.name) + join back). (b) NULL-SKIPPING: Max/Min ignore NULLs, so Max() over a mostly-NULL discriminator deterministically prefers the non-NULL value where MariaDB could return NULL -- flag when 'no value' is a meaningful state (fallback gates like 'if x:', dict keys, status decisions). (c) FABRICATED ARITHMETIC: Sum(x) * Max(y) -- or Python arithmetic combining a Sum'd and a Max'd column from the same grouped query -- where y can vary within the group invents a value no row ever had and Max biases it upward; require per-row Sum(x*y) when it feeds validation, budgets, valuation, or GL/stock values. (d) WRONG BOUND: when the aggregated value has a semantic, the bound must be chosen deliberately (Min(schedule_date) for a 'required by' date, Min(idx) for first-line ordering, a qty-weighted average for a rate); a blind Max can understate urgency or overstate a figure. Heuristic: if switching Max<->Min would change the answer, the column is NOT functionally dependent and wrapping either is the wrong fix -- group by it, restructure, or pick a bound for a stated reason with a test. REFACTOR / CONVERSION FAITHFULNESS: a commit labeled a 'refactor' or a raw-frappe.db.sql->frappe.qb/ORM conversion is meant to preserve behaviour but easily does not, and the change slips past the static checker and a one-engine green run -- diff the WHERE/predicate, the JOIN/ON conditions and the resulting ROW SET, not just the SELECT shape. A conversion that silently widens or narrows the filter (e.g. a 'posting_datetime > X' bound gaining an OR (posting_datetime == X AND creation > args.creation) branch under a sql->qb refactor) changes the rows touched on BOTH engines and is a regression hiding under a refactor label; call it out and require a test even if it is a deliberate bug-fix. DO NOT FLAG these false positives: .like()/['like'] on a TEXT column (already ILIKE on PostgreSQL -- but DO flag it on a non-text/integer column, see above), raw ifnull/backticks/LOCATE/REGEXP/.regexp() inside frappe.db.sql (auto-translated by the framework -- but RLIKE/.rlike() is NOT translated, see above), or an ORDER BY..LIMIT 1 tie where adding a tiebreaker would change MariaDB's current pick. Full catalog with examples and portable fixes is in .github/POSTGRES_COMPATIBILITY.md.", + "customContext": { + "files": [ + { + "scope": [ + "**/*.py", + "**/*.js", + "**/*.sql", + "**/report/**/*.json" + ], + "path": ".github/POSTGRES_COMPATIBILITY.md", + "description": "MariaDB <-> PostgreSQL parity rules for ERPNext: query constructs that error on PostgreSQL or silently diverge across the two engines, the GROUP BY row-count trap, the false positives not to flag, and the rule that MariaDB output must not change. Apply to every changed database query in this PR." + } + ] } } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 958a74595a6..f6993ca1570 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,7 +48,6 @@ repos: cypress/.*| .*node_modules.*| .*boilerplate.*| - erpnext/public/js/controllers/.*| erpnext/templates/pages/order.js| erpnext/templates/includes/.* )$ @@ -66,6 +65,18 @@ repos: - id: ruff-format name: "Run ruff formatter" + - repo: local + hooks: + - id: postgres-compat + name: "PostgreSQL compatibility (static check)" + description: "Flags MySQL-only SQL that breaks on Postgres; the label-gated PG test job is the backstop for semantic divergences." + entry: .github/helper/postgres_compat.py + language: script + files: ^erpnext/.*\.py$ + # patches/ are historical, version-gated migrations (skipped on fresh Postgres installs); + # out of scope for the always-on gate. + exclude: ^erpnext/patches/ + ci: autoupdate_schedule: weekly skip: [] diff --git a/banking/package.json b/banking/package.json index b46a7c4ff98..a957fb01bf6 100644 --- a/banking/package.json +++ b/banking/package.json @@ -14,35 +14,35 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@tailwindcss/vite": "^4.3.0", + "@tailwindcss/vite": "^4.3.2", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.24", - "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-react": "^6.0.3", "chrono-node": "^2.9.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "dayjs": "^1.11.20", - "frappe-react-sdk": "^1.15.0", + "frappe-react-sdk": "^1.17.0", "fuse.js": "^7.3.0", - "jotai": "^2.20.0", - "jotai-family": "^1.0.1", + "jotai": "^2.20.1", + "jotai-family": "^1.0.2", "lodash.isplainobject": "^4.0.6", "lucide-react": "^1.14.0", - "radix-ui": "^1.4.3", - "react": "^19.2.6", + "radix-ui": "^1.6.1", + "react": "^19.2.7", "react-currency-input-field": "^4.0.5", "react-day-picker": "9.14.0", - "react-dom": "^19.2.6", + "react-dom": "^19.2.7", "react-dropzone": "^15.0.0", "react-hook-form": "^7.75.0", "react-hotkeys-hook": "^5.3.2", "react-markdown": "^10.1.0", - "react-router": "^7.15.0", - "react-router-dom": "^7.15.0", + "react-router": "^8.1.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "safe-expr-eval": "^1.0.4", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.3.0", @@ -51,15 +51,15 @@ "vite": "^8.0.16" }, "devDependencies": { - "@eslint/js": "^9.39.1", + "@eslint/js": "^9.39.4", "@types/node": "^25.3.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.4.24", + "eslint-plugin-react-refresh": "^0.5.3", "globals": "^16.5.0", "typescript": "~5.9.3", - "typescript-eslint": "^8.48.0" + "typescript-eslint": "^8.62.1" } } diff --git a/banking/src/App.tsx b/banking/src/App.tsx index b46c5ba4233..2b726dd1dea 100644 --- a/banking/src/App.tsx +++ b/banking/src/App.tsx @@ -1,5 +1,5 @@ import { lazy, useEffect } from 'react' -import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import { BrowserRouter, Navigate, Route, Routes } from 'react-router' import { FrappeProvider } from 'frappe-react-sdk' import { Toaster } from '@/components/ui/sonner' import BankReconciliation from '@/pages/BankReconciliation' diff --git a/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx b/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx index dd248d31092..c26b9e9fb22 100644 --- a/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx +++ b/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx @@ -2,7 +2,6 @@ import { useAtomValue } from "jotai" import { MissingFiltersBanner } from "./MissingFiltersBanner" import { bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms" import { useCurrentCompany } from "@/hooks/useCurrentCompany" -import { Paragraph } from "@/components/ui/typography" import type { ColumnDef } from "@tanstack/react-table" import { useCallback, useMemo, useState } from "react" import { useFrappeGetCall, useFrappePostCall, useSWRConfig } from "frappe-react-sdk" @@ -26,6 +25,7 @@ import { Form } from "@/components/ui/form" import { useForm } from "react-hook-form" import { DateField } from "@/components/ui/form-elements" import { Empty, EmptyMedia, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty" +import MarkdownRenderer from "@/components/ui/markdown" const BankClearanceSummary = () => { const bankAccount = useAtomValue(selectedBankAccountAtom) @@ -203,14 +203,14 @@ const BankClearanceSummaryView = () => { [accountCurrency, bankAccount, companyID, mutate, onCopy], ) + const content = _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`${bankAccount?.account}`, `${formattedFromDate}`, `${formattedToDate}`]) + return
- - ${bankAccount?.account}`, `${formattedFromDate}`, `${formattedToDate}`]) - }} /> - + + +
{error && } diff --git a/banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx b/banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx index 17ef3314a1f..4e5ddb425e2 100644 --- a/banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx +++ b/banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx @@ -18,6 +18,7 @@ import { useMultiFileUploadProgress } from "@/hooks/useMultiFileUploadProgress" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Checkbox } from "@/components/ui/checkbox" import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react" +import { evaluateAmountFormula } from "@/lib/amountFormula" import { flt, formatCurrency } from "@/lib/numbers" import { cn } from "@/lib/utils" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" @@ -215,38 +216,13 @@ const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: Unreconci }) } else { - /** - * The debit and credit amounts can also be expressions - like "transaction_amount * 0.5" - * So we need to compute the value of the expression - * We can use the eval function to do this. But we need to expose certain variables to the expression. - * One of them is transaction_amount which is the unallocated amount of the selected transaction - * @param expression - The expression to compute - * @returns The computed value - */ - const computeExpression = (expression: string) => { - - const script = ` - const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0} - ${expression}; - ` - - let value = 0; - - try { - value = window.eval(script); - } catch (error: unknown) { - console.error(error); - value = 0; - } - - return value; - } + const transactionAmount = selectedTransaction.unallocated_amount ?? 0 if (!acc?.debit && !acc?.credit) { hasTotallyEmptyRowEarlier = true; } - const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0 - const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0 + const computedDebit = acc?.debit ? flt(evaluateAmountFormula(acc.debit, transactionAmount), 2) : 0 + const computedCredit = acc?.credit ? flt(evaluateAmountFormula(acc.credit, transactionAmount), 2) : 0 totalDebits = flt(totalDebits + computedDebit, 2) totalCredits = flt(totalCredits + computedCredit, 2) diff --git a/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx b/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx index 7b505efadc3..0815bc8a65e 100644 --- a/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx +++ b/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx @@ -2,7 +2,6 @@ import { useAtomValue } from "jotai" import { MissingFiltersBanner } from "./MissingFiltersBanner" import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms" import { useCurrentCompany } from "@/hooks/useCurrentCompany" -import { Paragraph } from "@/components/ui/typography" import { useCallback, useMemo } from "react" import type { ColumnDef } from "@tanstack/react-table" import { useFrappeGetCall } from "frappe-react-sdk" @@ -19,6 +18,7 @@ import _ from "@/lib/translate" import { toast } from "sonner" import { useCopyToClipboard } from "usehooks-ts" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" +import MarkdownRenderer from "@/components/ui/markdown" const BankReconciliationStatement = () => { const bankAccount = useAtomValue(selectedBankAccountAtom) @@ -189,14 +189,14 @@ const BankReconciliationStatementView = () => { return data.message.result.filter((row: BankClearanceSummaryEntry) => Boolean(row.payment_entry)) }, [data]) + const content = _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`${bankAccount?.account}`, `${formatDate(dates.toDate)}`]) + return
- - ${bankAccount?.account}`, `${formatDate(dates.toDate)}`]) - }} /> - + + +
{error && } diff --git a/banking/src/components/features/BankReconciliation/BankTransactionList.tsx b/banking/src/components/features/BankReconciliation/BankTransactionList.tsx index 17f231a0833..1513e567a4b 100644 --- a/banking/src/components/features/BankReconciliation/BankTransactionList.tsx +++ b/banking/src/components/features/BankReconciliation/BankTransactionList.tsx @@ -1,7 +1,6 @@ import { useAtomValue, useSetAtom } from "jotai" import { MissingFiltersBanner } from "./MissingFiltersBanner" import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms" -import { Paragraph } from "@/components/ui/typography" import { formatDate } from "@/lib/date" import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view" import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers" @@ -23,6 +22,7 @@ import { useCallback, useMemo, useState } from "react" import { Link } from "react-router" import { Empty, EmptyTitle, EmptyHeader, EmptyMedia, EmptyDescription, EmptyContent } from "@/components/ui/empty" import { InputGroup, InputGroupAddon } from "@/components/ui/input-group" +import MarkdownRenderer from "@/components/ui/markdown" const BankTransactions = () => { const selectedBank = useAtomValue(selectedBankAccountAtom) @@ -243,14 +243,14 @@ const BankTransactionListView = () => { }, [data, search, amountFilter, typeFilter, status]) + const content = _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`${bankAccount?.account_name}`, `${formattedFromDate}`, `${formattedToDate}`]) + return
- - ${bankAccount?.account_name}`, `${formattedFromDate}`, `${formattedToDate}`]) - }} /> - + + +
\n{% endfor %}\n", + "html": "\n\n{%- macro add_header(page_num, max_pages, doc, letter_head, no_letterhead, footer, print_settings=None, print_heading_template=None) -%}\n\t{% if letter_head and not no_letterhead %}\n\t\t
{{ letter_head }}
\n\t{% endif %}\n\t{% if print_heading_template %}\n\t\t{{ frappe.render_template(print_heading_template, {\"doc\":doc}) }}\n\t{% endif %}\n{%- endmacro -%}\n\n{% for page in layout %}\n
\n\t
\n\t\t{{ add_header(loop.index, layout|len, doc, letter_head, no_letterhead, footer, print_settings) }}\n\t
\n\t{%- if doc.meta.is_submittable and doc.docstatus==2-%}\n\t\t
\n\t\t\t

{{ _(\"CANCELLED\") }}

\n\t\t
\n\t{%- endif -%}\n\t{%- if doc.meta.is_submittable and doc.docstatus==0 and (print_settings==None or print_settings.add_draft_heading) -%}\n\t\t
\n\t\t\t

{{ _(\"DRAFT\") }}

\n\t\t
\n\t{%- endif -%}\n\n\t\n\t
\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t \n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t\t\t\t\t{{ _(\"Party Name\") }}: {{doc.party_name }}\n\t\t\t\t\n\t\t\t\t\t{{ _(\"Valid Till\") }}: {{\n\t\t\t\t\tfrappe.utils.format_date(doc.valid_till) }}\n\t\t\t\t
{{ _(\"Quotation\") }}: {{ doc.name }}\n\t\t\t\t\t{{ _(\"Posting Date\") }}: {{\n\t\t\t\t\tfrappe.utils.format_date(doc.transaction_date) }}\n\t\t\t\t
{{ _(\"Bill From\") }}:
\n\t\t\t\t\t{% if doc.company_address %}\n {% set company_address = frappe.db.get_value(\"Address\", doc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %}\n {{ doc.company }}
\n {{ company_address.get(\"address_line1\") or \"\" }}
\n {% if company_address.get(\"address_line2\") %}{{ company_address.get(\"address_line2\") }}
{% endif %}\n {{ company_address.get(\"city\") or \"\" }}, {{ company_address.get(\"state\") or \"\" }} {{ company_address.get(\"pincode\") or \"\" }}, {{ company_address.get(\"country\") or \"\" }}
\n {% endif %}\n\t\t\t\t
{{ _(\"Bill To\") }}:
\n\t\t\t\t {% if doc.customer_address %}\n\t\t\t\t\t\t{% set customer_address = frappe.db.get_value(\"Address\", doc.customer_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %}\n {{ doc.customer_name }}
\n\t\t\t\t\t\t{{ customer_address.address_line1 or \"\" }}
\n\t\t\t\t\t\t{% if customer_address.address_line2 %}{{ customer_address.address_line2 }}
{% endif %}\n\t\t\t\t\t\t{{ customer_address.city or \"\" }} {{ customer_address.state or \"\" }} {{ customer_address.pincode or \"\" }} {{ customer_address.country or \"\" }}
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\n\t\t\n\t\t{% set item_naming_by = frappe.db.get_single_value(\"Stock Settings\", \"item_naming_by\") %}\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{% if item_naming_by != \"Item Code\" %}\n\t\t\t\t\t\t\n\t\t\t\t\t{% endif %}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t{% for item in doc.items %}\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{% if item_naming_by != \"Item Code\" %}\n\t\t\t\t\t\t\n\t\t\t\t\t{% endif %}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t{% endfor %}\n\t\t\t\n\t\t
{{ _(\"No\") }}{{ _(\"Item\") }}{{ _(\"Item Code\") }}{{ _(\"Quantity\") }}{{ _(\"Rate\") }}{{ _(\"Amount\") }}
{{ loop.index }}{{ item.item_name }}{{ item.item_code }}{{ item.get_formatted(\"qty\", 0) }} {{ item.uom }}{{ item.get_formatted(\"net_rate\", doc) }}\n\t\t\t\t\t\t{{ item.get_formatted(\"net_amount\", doc) }}\n\t\t\t\t\t
\n\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t
\n\t\t\t\t

{{ _(\"Total in words\") }}

\n\t\t\t\t
{{ doc.in_words }}
\n\t\t\t
\n\t\t\t\t \n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{%- if doc.apply_discount_on == \"Net Total\" -%}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t{%- endif -%}\n\t\t\t\t\t{%- for tax in doc.taxes -%}\n\t\t\t\t\t\t{%- if (tax.tax_amount or print_settings.print_taxes_with_zero_amount) and (not tax.included_in_print_rate or doc.flags.show_inclusive_tax_in_print) -%}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t{%- endif -%}\n\t\t\t\t\t{%- endfor -%}\n\t\t\t\t\t{%- if doc.apply_discount_on == \"Grand Total\" -%}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t{%- endif -%}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
{{ _(\"Sub Total:\") }}
{{ doc.get_formatted(\"total\", doc) }}
\n\t\t\t\t\t\t\t\t
{{ _(\"Discount\") }} ({{ doc.additional_discount_percentage }}%):\n\t\t\t\t\t\t\t
{{ doc.get_formatted(\"discount_amount\", doc) }}
{{ tax.get_formatted(\"description\") }} ({{ tax.get_formatted(\"rate\") }}%):
{{ tax.get_formatted(\"tax_amount\") }}
\n\t\t\t\t\t\t\t\t
{{ _(\"Discount\") }} ({{ doc.additional_discount_percentage }}%):\n\t\t\t\t\t\t\t
{{ doc.get_formatted(\"discount_amount\", doc) }}
{{ _(\"Grand Total:\") }}{{ doc.get_formatted(\"grand_total\", doc) }}
\n\t\t\t
\n\n\t\t\n\t\t
\n\t\t\t{% if doc.terms %}\n\t\t\t
\n\t\t\t\t
{{ _(\"Terms and Conditions\") }}
\n\t\t\t\t{{ doc.terms}}\n\t\t\t
\n\t\t\t{% endif %}\n\t\t
\n\t
\n\t
\n\t\t{% if not no_letterhead and footer %}\n\t\t
\n\t\t\t{{ footer }}\n\t\t
\n\t\t{% endif %}\n\t\t{% if print_settings.repeat_header_footer %}\n\t\t

\n\t\t\t{{ _(\"Page {0} of {1}\").format('', '') }}\n\t\t

\n\t\t{% endif %}\n\t
\n
\n{% endfor %}", "idx": 0, "line_breaks": 0, "margin_bottom": 15.0, "margin_left": 15.0, "margin_right": 15.0, "margin_top": 15.0, - "modified": "2026-03-23 16:14:39.728914", + "modified": "2026-07-03 02:26:31.243291", "modified_by": "Administrator", "module": "Selling", "name": "Quotation Standard", diff --git a/erpnext/selling/print_format/quotation_with_item_image/quotation_with_item_image.json b/erpnext/selling/print_format/quotation_with_item_image/quotation_with_item_image.json index 2d195632178..ba1474a8173 100644 --- a/erpnext/selling/print_format/quotation_with_item_image/quotation_with_item_image.json +++ b/erpnext/selling/print_format/quotation_with_item_image/quotation_with_item_image.json @@ -9,14 +9,14 @@ "docstatus": 0, "doctype": "Print Format", "font_size": 14, - "html": "{%- macro add_header(page_num, max_pages, doc, letter_head, no_letterhead, footer, print_settings=None, print_heading_template=None) -%}\n\n{% if letter_head and not no_letterhead %}\n
{{ letter_head }}
\n{% endif %}\n{% if print_heading_template %}\n{{ frappe.render_template(print_heading_template, {\"doc\":doc}) }}\n{% endif %}\n{%- endmacro -%}\n\n{% for page in layout %}\n
\n\t
\n\t\t{{ add_header(loop.index, layout|len, doc, letter_head, no_letterhead, footer, print_settings) }}\n\t
\n\t{%- if doc.meta.is_submittable and doc.docstatus==2-%}\n\t\t
\n\t\t\t

{{ _(\"CANCELLED\") }}

\n\t\t
\n\t{%- endif -%}\n\t{%- if doc.meta.is_submittable and doc.docstatus==0 and (print_settings==None or print_settings.add_draft_heading) -%}\n\t\t
\n\t\t\t

{{ _(\"DRAFT\") }}

\n\t\t
\n\t{%- endif -%}\n\n\t\n\n\t
\n\t\t\n\t\t\t\n\t\t\t\t\n\n\t\t\t\t\n\t\t\t\n\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
Customer Name:
\n\t\t\t\t\t\t
Bill to:
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ doc.customer_name }}
\n\t\t\t\t\t\t
\n \t\t\t\t\t{% if doc.customer_address %}\n \t\t\t\t\t\t{% set customer_address = frappe.db.get_value(\"Address\", doc.customer_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %}\n \t\t\t\t\t\t{{ customer_address.address_line1 or \"\" }}
\n \t\t\t\t\t\t{% if customer_address.address_line2 %}{{ customer_address.address_line2 }}
{% endif %}\n \t\t\t\t\t\t{{ customer_address.city or \"\" }} {{ customer_address.state or \"\" }} {{ customer_address.pincode or \"\" }} {{ customer_address.country or \"\" }}
\n \t\t\t\t\t{% endif %}\n\t\t\t\t\t\t
\n\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ _(\"Quotation:\") }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ doc.name }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ _(\"Posting Date:\") }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ frappe.utils.format_date(doc.transaction_date) }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ _(\"Valid Till:\") }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ frappe.utils.format_date(doc.valid_till) }}
\n\t\t\t\t\t
\n\t\t\t\t
\n\n\t\t\n\t\t{% set item_naming_by = frappe.db.get_single_value(\"Stock Settings\", \"item_naming_by\") %}\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{% if item_naming_by != \"Item Code\" %}\n\t\t\t\t\t\t\n\t\t\t\t\t{% endif %}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t{% for item in doc.items %}\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{% if item_naming_by != \"Item Code\" %}\n\t\t\t\t\t\t\n\t\t\t\t\t{% endif %}\n\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t{% endfor %}\n\t\t\t\n\t\t
{{ _(\"No\") }}{{ _(\"Item\") }}{{ _(\"Item Code\") }}{{ _(\"Quantity\") }}{{ _(\"Rate\") }}{{ _(\"Amount\") }}
{{ loop.index }}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{% if item.image %}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{% endif %}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{{ item.item_name }}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t
{{ item.item_code }}{{ item.get_formatted(\"qty\", 0) }} {{ item.uom }}{{ item.get_formatted(\"net_rate\", doc) }}{{ item.get_formatted(\"net_amount\", doc) }}
\n\n\t\t
\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t{%- if doc.apply_discount_on == \"Net Total\" -%}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- for tax in doc.taxes -%}\n\t\t\t\t\t{%- if (tax.tax_amount or print_settings.print_taxes_with_zero_amount) and (not tax.included_in_print_rate or doc.flags.show_inclusive_tax_in_print) -%}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t{%- endif -%}\n\t\t\t\t{%- endfor -%}\n\t\t\t\t{%- if doc.apply_discount_on == \"Grand Total\" -%}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t{%- endif -%}\n\t\t\t
{{ _(\"Sub Total:\") }}{{ doc.get_formatted(\"total\", doc) }}
\n\t\t\t\t\t\t\t{{ _(\"Discount\") }} ({{ doc.additional_discount_percentage }}%):\n\t\t\t\t\t\t{{ doc.get_formatted(\"discount_amount\", doc) }}
{{ tax.get_formatted(\"description\") }} ({{ tax.get_formatted(\"rate\") }}%):{{ tax.get_formatted(\"tax_amount\") }}
\n\t\t\t\t\t\t\t{{ _(\"Discount\") }} ({{ doc.additional_discount_percentage }}%):\n\t\t\t\t\t\t{{ doc.get_formatted(\"discount_amount\", doc) }}
\n\t\t
\n\n\t\t
\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t {{ _(\"In Words: \") }}{{ doc.in_words }}\n\t\t\t\t\t\t
\n\t\t\t\t\t
{{ _(\"Grand Total:\") }}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{{ doc.get_formatted(\"grand_total\", doc) }}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t
\n\n\n\t\t\n\t\t{% if doc.terms %}\n\t\t
\n\t\t\t
{{ _(\"Terms and Conditions\") }}
\n\t\t\t{{ doc.terms}}\n\t\t
\n\t\t{% endif %}\n\t
\n
\n{% endfor %}\n", + "html": "{%- macro add_header(page_num, max_pages, doc, letter_head, no_letterhead, footer, print_settings=None, print_heading_template=None) -%}\n\n{% if letter_head and not no_letterhead %}\n
{{ letter_head }}
\n{% endif %}\n{% if print_heading_template %}\n{{ frappe.render_template(print_heading_template, {\"doc\":doc}) }}\n{% endif %}\n{%- endmacro -%}\n\n{% for page in layout %}\n
\n\t
\n\t\t{{ add_header(loop.index, layout|len, doc, letter_head, no_letterhead, footer, print_settings) }}\n\t
\n\t{%- if doc.meta.is_submittable and doc.docstatus==2-%}\n\t\t
\n\t\t\t

{{ _(\"CANCELLED\") }}

\n\t\t
\n\t{%- endif -%}\n\t{%- if doc.meta.is_submittable and doc.docstatus==0 and (print_settings==None or print_settings.add_draft_heading) -%}\n\t\t
\n\t\t\t

{{ _(\"DRAFT\") }}

\n\t\t
\n\t{%- endif -%}\n\n\t\n\n\t
\n\t\t\n\t\t\t\n\t\t\t\t\n\n\t\t\t\t\n\t\t\t\n\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
Customer Name:
\n\t\t\t\t\t\t
Bill to:
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ doc.customer_name }}
\n\t\t\t\t\t\t
\n \t\t\t\t\t{% if doc.customer_address %}\n \t\t\t\t\t\t{% set customer_address = frappe.db.get_value(\"Address\", doc.customer_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %}\n \t\t\t\t\t\t{{ customer_address.address_line1 or \"\" }}
\n \t\t\t\t\t\t{% if customer_address.address_line2 %}{{ customer_address.address_line2 }}
{% endif %}\n \t\t\t\t\t\t{{ customer_address.city or \"\" }} {{ customer_address.state or \"\" }} {{ customer_address.pincode or \"\" }} {{ customer_address.country or \"\" }}
\n \t\t\t\t\t{% endif %}\n\t\t\t\t\t\t
\n\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ _(\"Quotation:\") }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ doc.name }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ _(\"Posting Date:\") }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ frappe.utils.format_date(doc.transaction_date) }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ _(\"Valid Till:\") }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ frappe.utils.format_date(doc.valid_till) }}
\n\t\t\t\t\t
\n\t\t\t\t
\n\n\t\t\n\t\t{% set item_naming_by = frappe.db.get_single_value(\"Stock Settings\", \"item_naming_by\") %}\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{% if item_naming_by != \"Item Code\" %}\n\t\t\t\t\t\t\n\t\t\t\t\t{% endif %}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t{% for item in doc.items %}\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{% if item_naming_by != \"Item Code\" %}\n\t\t\t\t\t\t\n\t\t\t\t\t{% endif %}\n\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t{% endfor %}\n\t\t\t\n\t\t
{{ _(\"No\") }}{{ _(\"Item\") }}{{ _(\"Item Code\") }}{{ _(\"Quantity\") }}{{ _(\"Rate\") }}{{ _(\"Amount\") }}
{{ loop.index }}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{% if item.image %}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{% endif %}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{{ item.item_name }}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t
{{ item.item_code }}{{ item.get_formatted(\"qty\", 0) }} {{ item.uom }}{{ item.get_formatted(\"net_rate\", doc) }}{{ item.get_formatted(\"net_amount\", doc) }}
\n\n\t\t
\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t{%- if doc.apply_discount_on == \"Net Total\" -%}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- for tax in doc.taxes -%}\n\t\t\t\t\t{%- if (tax.tax_amount or print_settings.print_taxes_with_zero_amount) and (not tax.included_in_print_rate or doc.flags.show_inclusive_tax_in_print) -%}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t{%- endif -%}\n\t\t\t\t{%- endfor -%}\n\t\t\t\t{%- if doc.apply_discount_on == \"Grand Total\" -%}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t{%- endif -%}\n\t\t\t
{{ _(\"Sub Total:\") }}{{ doc.get_formatted(\"total\", doc) }}
\n\t\t\t\t\t\t\t{{ _(\"Discount\") }} ({{ doc.additional_discount_percentage }}%):\n\t\t\t\t\t\t{{ doc.get_formatted(\"discount_amount\", doc) }}
{{ tax.get_formatted(\"description\") }} ({{ tax.get_formatted(\"rate\") }}%):{{ tax.get_formatted(\"tax_amount\") }}
\n\t\t\t\t\t\t\t{{ _(\"Discount\") }} ({{ doc.additional_discount_percentage }}%):\n\t\t\t\t\t\t{{ doc.get_formatted(\"discount_amount\", doc) }}
\n\t\t
\n\n\t\t
\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t {{ _(\"In Words: \") }}{{ doc.in_words }}\n\t\t\t\t\t\t
\n\t\t\t\t\t
{{ _(\"Grand Total:\") }}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{{ doc.get_formatted(\"grand_total\", doc) }}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t
\n\n\n\t\t\n\t\t{% if doc.terms %}\n\t\t
\n\t\t\t
{{ _(\"Terms and Conditions\") }}
\n\t\t\t{{ doc.terms}}\n\t\t
\n\t\t{% endif %}\n\t
\n\t
\n\t\t{% if not no_letterhead and footer %}\n\t\t
\n\t\t\t{{ footer }}\n\t\t
\n\t\t{% endif %}\n\t\t{% if print_settings.repeat_header_footer %}\n\t\t

\n\t\t\t{{ _(\"Page {0} of {1}\").format('', '') }}\n\t\t

\n\t\t{% endif %}\n\t
\n
\n{% endfor %}", "idx": 0, "line_breaks": 0, "margin_bottom": 15.0, "margin_left": 15.0, "margin_right": 15.0, "margin_top": 15.0, - "modified": "2026-03-18 11:57:39.954918", + "modified": "2026-07-03 02:26:31.243291", "modified_by": "Administrator", "module": "Selling", "name": "Quotation with Item Image", diff --git a/erpnext/selling/print_format/sales_order_standard/sales_order_standard.json b/erpnext/selling/print_format/sales_order_standard/sales_order_standard.json index 0df19107c1b..7298cef9505 100644 --- a/erpnext/selling/print_format/sales_order_standard/sales_order_standard.json +++ b/erpnext/selling/print_format/sales_order_standard/sales_order_standard.json @@ -9,14 +9,14 @@ "docstatus": 0, "doctype": "Print Format", "font_size": 14, - "html": "\n\n{%- macro add_header(page_num, max_pages, doc, letter_head, no_letterhead, footer, print_settings=None, print_heading_template=None) -%}\n\t{% if letter_head and not no_letterhead %}\n\t\t
{{ letter_head }}
\n\t{% endif %}\n\t{% if print_heading_template %}\n\t\t{{ frappe.render_template(print_heading_template, {\"doc\":doc}) }}\n\t{% endif %}\n{%- endmacro -%}\n\n{% for page in layout %}\n
\n\t
\n\t\t{{ add_header(loop.index, layout|len, doc, letter_head, no_letterhead, footer, print_settings) }}\n\t
\n\t{%- if doc.meta.is_submittable and doc.docstatus==2-%}\n\t\t
\n\t\t\t

{{ _(\"CANCELLED\") }}

\n\t\t
\n\t{%- endif -%}\n\t{%- if doc.meta.is_submittable and doc.docstatus==0 and (print_settings==None or print_settings.add_draft_heading) -%}\n\t\t
\n\t\t\t

{{ _(\"DRAFT\") }}

\n\t\t
\n\t{%- endif -%}\n\n\t\n\t
\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t\t\t\t\t{{ _(\"Customer Name\") }}: {{doc.customer_name }}\n\t\t\t\t\n\t\t\t\t\t{{ _(\"Delievery Date\") }}: {{\n\t\t\t\t\tfrappe.utils.format_date(doc.delivery_date) }}\n\t\t\t\t
{{ _(\"Sales Order\") }}: {{ doc.name }}\n\t\t\t\t\t{{ _(\"Posting Date\") }}: {{\n\t\t\t\t\tfrappe.utils.format_date(doc.transaction_date) }}\n\t\t\t\t
{{ _(\"Bill From\") }}:
\n\t\t\t\t\t{% if doc.company_address %}\n {% set company_address = frappe.db.get_value(\"Address\", doc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %}\n {{ doc.company }}
\n {{ company_address.get(\"address_line1\") or \"\" }}
\n {% if company_address.get(\"address_line2\") %}{{ company_address.get(\"address_line2\") }}
{% endif %}\n {{ company_address.get(\"city\") or \"\" }}, {{ company_address.get(\"state\") or \"\" }} {{ company_address.get(\"pincode\") or \"\" }}, {{ company_address.get(\"country\") or \"\" }}
\n {% endif %}\n\t\t\t\t
{{ _(\"Bill To\") }}:
\n\t\t\t\t {% if doc.customer_address %}\n\t\t\t\t\t\t{% set customer_address = frappe.db.get_value(\"Address\", doc.customer_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %}\n {{ doc.customer_name }}
\n\t\t\t\t\t\t{{ customer_address.address_line1 or \"\" }}
\n\t\t\t\t\t\t{% if customer_address.address_line2 %}{{ customer_address.address_line2 }}
{% endif %}\n\t\t\t\t\t\t{{ customer_address.city or \"\" }} {{ customer_address.state or \"\" }} {{ customer_address.pincode or \"\" }} {{ customer_address.country or \"\" }}
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\n\t\t\n\t\t{% set item_naming_by = frappe.db.get_single_value(\"Stock Settings\", \"item_naming_by\") %}\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{% if item_naming_by != \"Item Code\" %}\n\t\t\t\t\t\t\n\t\t\t\t\t{% endif %}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t{% for item in doc.items %}\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{% if item_naming_by != \"Item Code\" %}\n\t\t\t\t\t\t\n\t\t\t\t\t{% endif %}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t{% endfor %}\n\t\t\t\n\t\t
{{ _(\"No\") }}{{ _(\"Item\") }}{{ _(\"Item Code\") }}{{ _(\"Quantity\") }}{{ _(\"Rate\") }}{{ _(\"Amount\") }}
{{ loop.index }}{{ item.item_name }}{{ item.item_code }}{{ item.get_formatted(\"qty\", 0) }} {{ item.uom }}{{ item.get_formatted(\"net_rate\", doc) }}\n\t\t\t\t\t\t{{ item.get_formatted(\"net_amount\", doc) }}\n\t\t\t\t\t
\n\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t
\n\t\t\t\t

{{ _(\"Total in words\") }}

\n\t\t\t\t
{{ doc.in_words }}
\n\t\t\t
\n\t\t\t\t \n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{%- if doc.apply_discount_on == \"Net Total\" -%}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t{%- endif -%}\n\t\t\t\t\t{%- for tax in doc.taxes -%}\n\t\t\t\t\t\t{%- if (tax.tax_amount or print_settings.print_taxes_with_zero_amount) and (not tax.included_in_print_rate or doc.flags.show_inclusive_tax_in_print) -%}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t{%- endif -%}\n\t\t\t\t\t{%- endfor -%}\n\t\t\t\t\t{%- if doc.apply_discount_on == \"Grand Total\" -%}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t{%- endif -%}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
{{ _(\"Sub Total:\") }}
{{ doc.get_formatted(\"total\", doc) }}
\n\t\t\t\t\t\t\t\t
{{ _(\"Discount\") }} ({{ doc.additional_discount_percentage }}%):\n\t\t\t\t\t\t\t
{{ doc.get_formatted(\"discount_amount\", doc) }}
{{ tax.get_formatted(\"description\") }} ({{ tax.get_formatted(\"rate\") }}%):
{{ tax.get_formatted(\"tax_amount\") }}
\n\t\t\t\t\t\t\t\t
{{ _(\"Discount\") }} ({{ doc.additional_discount_percentage }}%):\n\t\t\t\t\t\t\t
{{ doc.get_formatted(\"discount_amount\", doc) }}
{{ _(\"Grand Total:\") }}{{ doc.get_formatted(\"grand_total\", doc) }}
\n\t\t\t
\n\n\t\t\n\t\t
\n\t\t\t{% if doc.terms %}\n\t\t\t
\n\t\t\t\t
{{ _(\"Terms and Conditions\") }}
\n\t\t\t\t{{ doc.terms}}\n\t\t\t
\n\t\t\t{% endif %}\n\t
\n
\n{% endfor %}\n", + "html": "\n\n{%- macro add_header(page_num, max_pages, doc, letter_head, no_letterhead, footer, print_settings=None, print_heading_template=None) -%}\n\t{% if letter_head and not no_letterhead %}\n\t\t
{{ letter_head }}
\n\t{% endif %}\n\t{% if print_heading_template %}\n\t\t{{ frappe.render_template(print_heading_template, {\"doc\":doc}) }}\n\t{% endif %}\n{%- endmacro -%}\n\n{% for page in layout %}\n
\n\t
\n\t\t{{ add_header(loop.index, layout|len, doc, letter_head, no_letterhead, footer, print_settings) }}\n\t
\n\t{%- if doc.meta.is_submittable and doc.docstatus==2-%}\n\t\t
\n\t\t\t

{{ _(\"CANCELLED\") }}

\n\t\t
\n\t{%- endif -%}\n\t{%- if doc.meta.is_submittable and doc.docstatus==0 and (print_settings==None or print_settings.add_draft_heading) -%}\n\t\t
\n\t\t\t

{{ _(\"DRAFT\") }}

\n\t\t
\n\t{%- endif -%}\n\n\t\n\t
\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t\t\t\t\t{{ _(\"Customer Name\") }}: {{doc.customer_name }}\n\t\t\t\t\n\t\t\t\t\t{{ _(\"Delievery Date\") }}: {{\n\t\t\t\t\tfrappe.utils.format_date(doc.delivery_date) }}\n\t\t\t\t
{{ _(\"Sales Order\") }}: {{ doc.name }}\n\t\t\t\t\t{{ _(\"Posting Date\") }}: {{\n\t\t\t\t\tfrappe.utils.format_date(doc.transaction_date) }}\n\t\t\t\t
{{ _(\"Bill From\") }}:
\n\t\t\t\t\t{% if doc.company_address %}\n {% set company_address = frappe.db.get_value(\"Address\", doc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %}\n {{ doc.company }}
\n {{ company_address.get(\"address_line1\") or \"\" }}
\n {% if company_address.get(\"address_line2\") %}{{ company_address.get(\"address_line2\") }}
{% endif %}\n {{ company_address.get(\"city\") or \"\" }}, {{ company_address.get(\"state\") or \"\" }} {{ company_address.get(\"pincode\") or \"\" }}, {{ company_address.get(\"country\") or \"\" }}
\n {% endif %}\n\t\t\t\t
{{ _(\"Bill To\") }}:
\n\t\t\t\t {% if doc.customer_address %}\n\t\t\t\t\t\t{% set customer_address = frappe.db.get_value(\"Address\", doc.customer_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %}\n {{ doc.customer_name }}
\n\t\t\t\t\t\t{{ customer_address.address_line1 or \"\" }}
\n\t\t\t\t\t\t{% if customer_address.address_line2 %}{{ customer_address.address_line2 }}
{% endif %}\n\t\t\t\t\t\t{{ customer_address.city or \"\" }} {{ customer_address.state or \"\" }} {{ customer_address.pincode or \"\" }} {{ customer_address.country or \"\" }}
\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\n\t\t\n\t\t{% set item_naming_by = frappe.db.get_single_value(\"Stock Settings\", \"item_naming_by\") %}\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{% if item_naming_by != \"Item Code\" %}\n\t\t\t\t\t\t\n\t\t\t\t\t{% endif %}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t{% for item in doc.items %}\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{% if item_naming_by != \"Item Code\" %}\n\t\t\t\t\t\t\n\t\t\t\t\t{% endif %}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t{% endfor %}\n\t\t\t\n\t\t
{{ _(\"No\") }}{{ _(\"Item\") }}{{ _(\"Item Code\") }}{{ _(\"Quantity\") }}{{ _(\"Rate\") }}{{ _(\"Amount\") }}
{{ loop.index }}{{ item.item_name }}{{ item.item_code }}{{ item.get_formatted(\"qty\", 0) }} {{ item.uom }}{{ item.get_formatted(\"net_rate\", doc) }}\n\t\t\t\t\t\t{{ item.get_formatted(\"net_amount\", doc) }}\n\t\t\t\t\t
\n\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t
\n\t\t\t\t

{{ _(\"Total in words\") }}

\n\t\t\t\t
{{ doc.in_words }}
\n\t\t\t
\n\t\t\t\t \n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{%- if doc.apply_discount_on == \"Net Total\" -%}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t{%- endif -%}\n\t\t\t\t\t{%- for tax in doc.taxes -%}\n\t\t\t\t\t\t{%- if (tax.tax_amount or print_settings.print_taxes_with_zero_amount) and (not tax.included_in_print_rate or doc.flags.show_inclusive_tax_in_print) -%}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t{%- endif -%}\n\t\t\t\t\t{%- endfor -%}\n\t\t\t\t\t{%- if doc.apply_discount_on == \"Grand Total\" -%}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t{%- endif -%}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
{{ _(\"Sub Total:\") }}
{{ doc.get_formatted(\"total\", doc) }}
\n\t\t\t\t\t\t\t\t
{{ _(\"Discount\") }} ({{ doc.additional_discount_percentage }}%):\n\t\t\t\t\t\t\t
{{ doc.get_formatted(\"discount_amount\", doc) }}
{{ tax.get_formatted(\"description\") }} ({{ tax.get_formatted(\"rate\") }}%):
{{ tax.get_formatted(\"tax_amount\") }}
\n\t\t\t\t\t\t\t\t
{{ _(\"Discount\") }} ({{ doc.additional_discount_percentage }}%):\n\t\t\t\t\t\t\t
{{ doc.get_formatted(\"discount_amount\", doc) }}
{{ _(\"Grand Total:\") }}{{ doc.get_formatted(\"grand_total\", doc) }}
\n\t\t\t
\n\n\t\t\n\t\t
\n\t\t\t{% if doc.terms %}\n\t\t\t
\n\t\t\t\t
{{ _(\"Terms and Conditions\") }}
\n\t\t\t\t{{ doc.terms}}\n\t\t\t
\n\t\t\t{% endif %}\n\t
\n\t
\n\t\t{% if not no_letterhead and footer %}\n\t\t
\n\t\t\t{{ footer }}\n\t\t
\n\t\t{% endif %}\n\t\t{% if print_settings.repeat_header_footer %}\n\t\t

\n\t\t\t{{ _(\"Page {0} of {1}\").format('', '') }}\n\t\t

\n\t\t{% endif %}\n\t
\n
\n{% endfor %}", "idx": 0, "line_breaks": 0, "margin_bottom": 15.0, "margin_left": 15.0, "margin_right": 15.0, "margin_top": 15.0, - "modified": "2026-02-23 13:04:24.036955", + "modified": "2026-07-03 02:26:31.243291", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Standard", diff --git a/erpnext/selling/print_format/sales_order_with_item_image/sales_order_with_item_image.json b/erpnext/selling/print_format/sales_order_with_item_image/sales_order_with_item_image.json index 25cd22bcf66..47c89caccc0 100644 --- a/erpnext/selling/print_format/sales_order_with_item_image/sales_order_with_item_image.json +++ b/erpnext/selling/print_format/sales_order_with_item_image/sales_order_with_item_image.json @@ -9,14 +9,14 @@ "docstatus": 0, "doctype": "Print Format", "font_size": 14, - "html": "{%- macro add_header(page_num, max_pages, doc, letter_head, no_letterhead, footer, print_settings=None, print_heading_template=None) -%}\n\n{% if letter_head and not no_letterhead %}\n
{{ letter_head }}
\n{% endif %}\n{% if print_heading_template %}\n{{ frappe.render_template(print_heading_template, {\"doc\":doc}) }}\n{% endif %}\n{%- endmacro -%}\n\n{% for page in layout %}\n
\n\t
\n\t\t{{ add_header(loop.index, layout|len, doc, letter_head, no_letterhead, footer, print_settings) }}\n\t
\n\t{%- if doc.meta.is_submittable and doc.docstatus==2-%}\n\t\t
\n\t\t\t

{{ _(\"CANCELLED\") }}

\n\t\t
\n\t{%- endif -%}\n\t{%- if doc.meta.is_submittable and doc.docstatus==0 and (print_settings==None or print_settings.add_draft_heading) -%}\n\t\t
\n\t\t\t

{{ _(\"DRAFT\") }}

\n\t\t
\n\t{%- endif -%}\n\n\t\n\n\t
\n\t\t\n\t\t\t\n\t\t\t\t\n\n\t\t\t\t\n\t\t\t\n\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
Customer Name:
\n\t\t\t\t\t\t
Bill to:
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ doc.customer_name }}
\n\t\t\t\t\t\t
\n \t\t\t\t\t{% if doc.customer_address %}\n \t\t\t\t\t\t{% set customer_address = frappe.db.get_value(\"Address\", doc.customer_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %}\n \t\t\t\t\t\t{{ customer_address.address_line1 or \"\" }}
\n \t\t\t\t\t\t{% if customer_address.address_line2 %}{{ customer_address.address_line2 }}
{% endif %}\n \t\t\t\t\t\t{{ customer_address.city or \"\" }} {{ customer_address.state or \"\" }} {{ customer_address.pincode or \"\" }} {{ customer_address.country or \"\" }}
\n \t\t\t\t\t{% endif %}\n\t\t\t\t\t\t
\n\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ _(\"Sales Order:\") }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ doc.name }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ _(\"Order Date:\") }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ frappe.utils.format_date(doc.transaction_date) }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ _(\"Delivery Date:\") }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ frappe.utils.format_date(doc.delivery_date) }}
\n\t\t\t\t\t
\n\t\t\t\t
\n\n\t\t\n\t\t{% set item_naming_by = frappe.db.get_single_value(\"Stock Settings\", \"item_naming_by\") %}\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{% if item_naming_by != \"Item Code\" %}\n\t\t\t\t\t\t\n\t\t\t\t\t{% endif %}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t{% for item in doc.items %}\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{% if item_naming_by != \"Item Code\" %}\n\t\t\t\t\t\t\n\t\t\t\t\t{% endif %}\n\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t{% endfor %}\n\t\t\t\n\t\t
{{ _(\"No\") }}{{ _(\"Item\") }}{{ _(\"Item Code\") }}{{ _(\"Quantity\") }}{{ _(\"Rate\") }}{{ _(\"Amount\") }}
{{ loop.index }}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{% if item.image %}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{% endif %}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{{ item.item_name }}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t
{{ item.item_code }}{{ item.get_formatted(\"qty\", 0) }} {{ item.uom }}{{ item.get_formatted(\"net_rate\", doc) }}{{ item.get_formatted(\"net_amount\", doc) }}
\n\n\t\t
\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t{%- if doc.apply_discount_on == \"Net Total\" -%}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- for tax in doc.taxes -%}\n\t\t\t\t\t{%- if (tax.tax_amount or print_settings.print_taxes_with_zero_amount) and (not tax.included_in_print_rate or doc.flags.show_inclusive_tax_in_print) -%}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t{%- endif -%}\n\t\t\t\t{%- endfor -%}\n\t\t\t\t{%- if doc.apply_discount_on == \"Grand Total\" -%}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t{%- endif -%}\n\t\t\t
{{ _(\"Sub Total:\") }}{{ doc.get_formatted(\"total\", doc) }}
\n\t\t\t\t\t\t\t{{ _(\"Discount\") }} ({{ doc.additional_discount_percentage }}%):\n\t\t\t\t\t\t{{ doc.get_formatted(\"discount_amount\", doc) }}
{{ tax.get_formatted(\"description\") }} ({{ tax.get_formatted(\"rate\") }}%):{{ tax.get_formatted(\"tax_amount\") }}
\n\t\t\t\t\t\t\t{{ _(\"Discount\") }} ({{ doc.additional_discount_percentage }}%):\n\t\t\t\t\t\t{{ doc.get_formatted(\"discount_amount\", doc) }}
\n\t\t
\n\n\t\t
\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t {{ _(\"In Words: \") }}{{ doc.in_words }}\n\t\t\t\t\t\t
\n\t\t\t\t\t
{{ _(\"Grand Total:\") }}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{{ doc.get_formatted(\"grand_total\", doc) }}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t
\n\n\n\t\t\n\t\t{% if doc.terms %}\n\t\t
\n\t\t\t
{{ _(\"Terms and Conditions\") }}
\n\t\t\t{{ doc.terms}}\n\t\t
\n\t\t{% endif %}\n\t
\n
\n{% endfor %}\n", + "html": "{%- macro add_header(page_num, max_pages, doc, letter_head, no_letterhead, footer, print_settings=None, print_heading_template=None) -%}\n\n{% if letter_head and not no_letterhead %}\n
{{ letter_head }}
\n{% endif %}\n{% if print_heading_template %}\n{{ frappe.render_template(print_heading_template, {\"doc\":doc}) }}\n{% endif %}\n{%- endmacro -%}\n\n{% for page in layout %}\n
\n\t
\n\t\t{{ add_header(loop.index, layout|len, doc, letter_head, no_letterhead, footer, print_settings) }}\n\t
\n\t{%- if doc.meta.is_submittable and doc.docstatus==2-%}\n\t\t
\n\t\t\t

{{ _(\"CANCELLED\") }}

\n\t\t
\n\t{%- endif -%}\n\t{%- if doc.meta.is_submittable and doc.docstatus==0 and (print_settings==None or print_settings.add_draft_heading) -%}\n\t\t
\n\t\t\t

{{ _(\"DRAFT\") }}

\n\t\t
\n\t{%- endif -%}\n\n\t\n\n\t
\n\t\t\n\t\t\t\n\t\t\t\t\n\n\t\t\t\t\n\t\t\t\n\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
Customer Name:
\n\t\t\t\t\t\t
Bill to:
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ doc.customer_name }}
\n\t\t\t\t\t\t
\n \t\t\t\t\t{% if doc.customer_address %}\n \t\t\t\t\t\t{% set customer_address = frappe.db.get_value(\"Address\", doc.customer_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %}\n \t\t\t\t\t\t{{ customer_address.address_line1 or \"\" }}
\n \t\t\t\t\t\t{% if customer_address.address_line2 %}{{ customer_address.address_line2 }}
{% endif %}\n \t\t\t\t\t\t{{ customer_address.city or \"\" }} {{ customer_address.state or \"\" }} {{ customer_address.pincode or \"\" }} {{ customer_address.country or \"\" }}
\n \t\t\t\t\t{% endif %}\n\t\t\t\t\t\t
\n\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ _(\"Sales Order:\") }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ doc.name }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ _(\"Order Date:\") }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ frappe.utils.format_date(doc.transaction_date) }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ _(\"Delivery Date:\") }}
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t
{{ frappe.utils.format_date(doc.delivery_date) }}
\n\t\t\t\t\t
\n\t\t\t\t
\n\n\t\t\n\t\t{% set item_naming_by = frappe.db.get_single_value(\"Stock Settings\", \"item_naming_by\") %}\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{% if item_naming_by != \"Item Code\" %}\n\t\t\t\t\t\t\n\t\t\t\t\t{% endif %}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t{% for item in doc.items %}\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{% if item_naming_by != \"Item Code\" %}\n\t\t\t\t\t\t\n\t\t\t\t\t{% endif %}\n\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t{% endfor %}\n\t\t\t\n\t\t
{{ _(\"No\") }}{{ _(\"Item\") }}{{ _(\"Item Code\") }}{{ _(\"Quantity\") }}{{ _(\"Rate\") }}{{ _(\"Amount\") }}
{{ loop.index }}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{% if item.image %}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{% endif %}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{{ item.item_name }}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t
{{ item.item_code }}{{ item.get_formatted(\"qty\", 0) }} {{ item.uom }}{{ item.get_formatted(\"net_rate\", doc) }}{{ item.get_formatted(\"net_amount\", doc) }}
\n\n\t\t
\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t{%- if doc.apply_discount_on == \"Net Total\" -%}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- for tax in doc.taxes -%}\n\t\t\t\t\t{%- if (tax.tax_amount or print_settings.print_taxes_with_zero_amount) and (not tax.included_in_print_rate or doc.flags.show_inclusive_tax_in_print) -%}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t{%- endif -%}\n\t\t\t\t{%- endfor -%}\n\t\t\t\t{%- if doc.apply_discount_on == \"Grand Total\" -%}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t{%- endif -%}\n\t\t\t
{{ _(\"Sub Total:\") }}{{ doc.get_formatted(\"total\", doc) }}
\n\t\t\t\t\t\t\t{{ _(\"Discount\") }} ({{ doc.additional_discount_percentage }}%):\n\t\t\t\t\t\t{{ doc.get_formatted(\"discount_amount\", doc) }}
{{ tax.get_formatted(\"description\") }} ({{ tax.get_formatted(\"rate\") }}%):{{ tax.get_formatted(\"tax_amount\") }}
\n\t\t\t\t\t\t\t{{ _(\"Discount\") }} ({{ doc.additional_discount_percentage }}%):\n\t\t\t\t\t\t{{ doc.get_formatted(\"discount_amount\", doc) }}
\n\t\t
\n\n\t\t
\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t {{ _(\"In Words: \") }}{{ doc.in_words }}\n\t\t\t\t\t\t
\n\t\t\t\t\t
{{ _(\"Grand Total:\") }}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{{ doc.get_formatted(\"grand_total\", doc) }}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t
\n\n\n\t\t\n\t\t{% if doc.terms %}\n\t\t
\n\t\t\t
{{ _(\"Terms and Conditions\") }}
\n\t\t\t{{ doc.terms}}\n\t\t
\n\t\t{% endif %}\n\t
\n\t
\n\t\t{% if not no_letterhead and footer %}\n\t\t
\n\t\t\t{{ footer }}\n\t\t
\n\t\t{% endif %}\n\t\t{% if print_settings.repeat_header_footer %}\n\t\t

\n\t\t\t{{ _(\"Page {0} of {1}\").format('', '') }}\n\t\t

\n\t\t{% endif %}\n\t
\n
\n{% endfor %}", "idx": 0, "line_breaks": 0, "margin_bottom": 15.0, "margin_left": 15.0, "margin_right": 15.0, "margin_top": 15.0, - "modified": "2026-02-23 13:00:02.496058", + "modified": "2026-07-03 02:26:31.243291", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order with Item Image", diff --git a/erpnext/selling/report/customer_wise_item_price/customer_wise_item_price.py b/erpnext/selling/report/customer_wise_item_price/customer_wise_item_price.py index f6783abfbe5..2cd286edb7b 100644 --- a/erpnext/selling/report/customer_wise_item_price/customer_wise_item_price.py +++ b/erpnext/selling/report/customer_wise_item_price/customer_wise_item_price.py @@ -62,7 +62,7 @@ def fetch_item_prices( or_conditions = [] if items: and_conditions.append(ip.item_code.isin([x.item_code for x in items])) - and_conditions.append(ip.selling.eq(True)) + and_conditions.append(ip.selling.eq(1)) or_conditions.append(ip.customer.isnull()) or_conditions.append(ip.price_list.isnull()) diff --git a/erpnext/selling/report/customer_wise_item_price/test_customer_wise_item_price.py b/erpnext/selling/report/customer_wise_item_price/test_customer_wise_item_price.py new file mode 100644 index 00000000000..70181b19e79 --- /dev/null +++ b/erpnext/selling/report/customer_wise_item_price/test_customer_wise_item_price.py @@ -0,0 +1,69 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe + +from erpnext.selling.report.customer_wise_item_price.customer_wise_item_price import execute +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.tests.utils import ERPNextTestSuite + +PRICE_LIST = "Standard Selling" + + +class TestCustomerWiseItemPrice(ERPNextTestSuite): + """The report lists sales items with the selling rate from the customer's price + list and the available stock (summed across warehouses).""" + + def setUp(self): + self.item = make_item(properties={"is_stock_item": 1, "is_sales_item": 1}).name + self.customer = self.create_customer() + frappe.get_doc( + { + "doctype": "Item Price", + "item_code": self.item, + "price_list": PRICE_LIST, + "selling": 1, + "price_list_rate": 250, + } + ).insert() + make_stock_entry(item_code=self.item, to_warehouse="Stores - _TC", qty=10, rate=100) + + def create_customer(self): + name = "_Test CWIP Customer" + if not frappe.db.exists("Customer", name): + frappe.get_doc( + { + "doctype": "Customer", + "customer_name": name, + "customer_group": "_Test Customer Group", + "territory": "_Test Territory", + "default_price_list": PRICE_LIST, + } + ).insert() + return name + + def run_report(self, **extra): + filters = frappe._dict({"customer": self.customer}) + filters.update(extra) + return execute(filters)[1] + + def test_customer_filter_is_mandatory(self): + self.assertRaises(frappe.ValidationError, execute, frappe._dict({})) + + def test_selling_rate_and_available_stock_for_item(self): + rows = self.run_report(item=self.item) + + row = next((r for r in rows if r["item_code"] == self.item), None) + self.assertIsNotNone(row, "Sales item missing from report") + self.assertEqual(row["item_name"], frappe.db.get_value("Item", self.item, "item_name")) + self.assertEqual(row["selling_rate"], 250) # from the customer's price list + self.assertEqual(row["available_stock"], 10) # stocked into Stores - _TC + self.assertEqual(row["price_list"], PRICE_LIST) + + def test_item_filter_scopes_to_single_item(self): + other = make_item(properties={"is_stock_item": 1, "is_sales_item": 1}).name + + item_codes = {r["item_code"] for r in self.run_report(item=self.item)} + self.assertIn(self.item, item_codes) + self.assertNotIn(other, item_codes) diff --git a/erpnext/selling/report/inactive_customers/inactive_customers.py b/erpnext/selling/report/inactive_customers/inactive_customers.py index 2dedb346601..1710566b92a 100644 --- a/erpnext/selling/report/inactive_customers/inactive_customers.py +++ b/erpnext/selling/report/inactive_customers/inactive_customers.py @@ -86,6 +86,7 @@ def get_last_sales_amt(customer, doctype): .select(sales_doctype.base_net_total) .where((sales_doctype.customer == customer) & (sales_doctype.docstatus == 1)) .orderby(date_col, order=frappe.qb.desc) + .orderby(sales_doctype.name, order=frappe.qb.desc) .limit(1) ).run() diff --git a/erpnext/selling/report/item_wise_sales_history/test_item_wise_sales_history.py b/erpnext/selling/report/item_wise_sales_history/test_item_wise_sales_history.py new file mode 100644 index 00000000000..4d599832573 --- /dev/null +++ b/erpnext/selling/report/item_wise_sales_history/test_item_wise_sales_history.py @@ -0,0 +1,123 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe + +from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice +from erpnext.selling.doctype.sales_order.test_sales_order import ( + create_dn_against_so, + make_sales_order, +) +from erpnext.selling.report.item_wise_sales_history.item_wise_sales_history import execute +from erpnext.tests.utils import ERPNextTestSuite + + +class TestItemWiseSalesHistory(ERPNextTestSuite): + def run_report(self, **extra): + filters = frappe._dict( + { + "company": "_Test Company", + "from_date": "2026-01-01", + "to_date": "2026-12-31", + **extra, + } + ) + return execute(filters) + + def so_row(self, so_name, **extra): + data = self.run_report(**extra)[1] + return next(row for row in data if row["sales_order"] == so_name) + + def test_sales_order_line_shown_with_values(self): + so = make_sales_order(qty=10, rate=100, transaction_date="2026-06-01") + + row = self.so_row(so.name) + self.assertEqual(row["item_code"], "_Test Item") + self.assertEqual(row["quantity"], 10) + self.assertEqual(row["rate"], 100) + self.assertEqual(row["amount"], 1000) + self.assertEqual(row["customer"], "_Test Customer") + + def test_draft_sales_order_excluded(self): + so = make_sales_order(transaction_date="2026-06-01", do_not_submit=True) + + names = {row["sales_order"] for row in self.run_report()[1]} + self.assertNotIn(so.name, names) + + def test_date_range_filters_on_transaction_date(self): + so = make_sales_order(transaction_date="2026-06-01") + + in_range = { + row["sales_order"] for row in self.run_report(from_date="2026-05-01", to_date="2026-07-01")[1] + } + self.assertIn(so.name, in_range) + + out_of_range = { + row["sales_order"] for row in self.run_report(from_date="2026-01-01", to_date="2026-03-01")[1] + } + self.assertNotIn(so.name, out_of_range) + + def test_item_code_filter(self): + so = make_sales_order( + transaction_date="2026-06-01", + item_list=[ + {"item_code": "_Test Item", "qty": 5, "rate": 100, "warehouse": "_Test Warehouse - _TC"}, + {"item_code": "_Test Item 2", "qty": 3, "rate": 200, "warehouse": "_Test Warehouse - _TC"}, + ], + ) + + item_codes = {row["item_code"] for row in self.run_report(item_code="_Test Item 2")[1]} + self.assertEqual(item_codes, {"_Test Item 2"}) + # the filtered-out line of the same order must not leak in + self.assertTrue( + all(row["sales_order"] == so.name for row in self.run_report(item_code="_Test Item 2")[1]) + ) + + def test_customer_filter(self): + make_sales_order(customer="_Test Customer 1", transaction_date="2026-06-01") + make_sales_order(customer="_Test Customer 2", transaction_date="2026-06-01") + + customers = {row["customer"] for row in self.run_report(customer="_Test Customer 1")[1]} + self.assertEqual(customers, {"_Test Customer 1"}) + + def test_delivered_quantity_reflects_delivery(self): + so = make_sales_order(qty=10, rate=100, transaction_date="2026-06-01") + create_dn_against_so(so.name, delivered_qty=4) + + self.assertEqual(self.so_row(so.name)["delivered_quantity"], 4) + + def test_billed_amount_reflects_invoice(self): + so = make_sales_order(qty=10, rate=100, transaction_date="2026-06-01") + si = make_sales_invoice(so.name) + si.insert() + si.submit() + + self.assertEqual(self.so_row(so.name)["billed_amount"], 1000) + + def test_amounts_reported_in_company_currency(self): + # a USD order must report rate/amount converted to the company's currency (base_* fields) + so = make_sales_order( + do_not_save=True, + currency="USD", + qty=10, + rate=100, + transaction_date="2026-06-01", + ) + so.conversion_rate = 80 + so.insert() + so.submit() + + row = self.so_row(so.name) + self.assertEqual(row["rate"], 8000) # 100 USD * 80 + self.assertEqual(row["amount"], 80000) # 10 * 100 USD * 80 + + def test_chart_aggregates_amount_per_item(self): + make_sales_order(item_code="_Test Item", qty=2, rate=100, transaction_date="2026-06-01") + make_sales_order(item_code="_Test Item", qty=3, rate=100, transaction_date="2026-06-01") + + chart = self.run_report(item_code="_Test Item")[3] + labels = chart["data"]["labels"] + values = chart["data"]["datasets"][0]["values"] + self.assertIn("_Test Item", labels) + # 2*100 + 3*100 aggregated for the item + self.assertEqual(values[labels.index("_Test Item")], 500) diff --git a/erpnext/selling/report/lost_quotations/lost_quotations.py b/erpnext/selling/report/lost_quotations/lost_quotations.py index c3bcd54cfd3..799dbec5878 100644 --- a/erpnext/selling/report/lost_quotations/lost_quotations.py +++ b/erpnext/selling/report/lost_quotations/lost_quotations.py @@ -6,7 +6,7 @@ from typing import Literal import frappe from frappe import _ from frappe.model.docstatus import DocStatus -from frappe.query_builder.functions import Coalesce, Count, Round, Sum +from frappe.query_builder.functions import Coalesce, Count, NullIf, Round, Sum from frappe.utils.data import get_timespan_date_range @@ -86,7 +86,7 @@ def get_data(company: str, from_date: str, to_date: str, group_by: Literal["Lost # `* 100.0` before dividing: count/count is integer division on Postgres (truncates to 0) Round((Count(q.name).distinct() * 100.0 / total_quotations), 2), Sum(q.base_net_total), - Round((Sum(q.base_net_total) / total_value * 100), 2), + Round((Sum(q.base_net_total) / NullIf(total_value, 0) * 100), 2), ) .left_join(dimension) .on(dimension.parent == q.name) diff --git a/erpnext/selling/report/quotation_trends/test_quotation_trends.py b/erpnext/selling/report/quotation_trends/test_quotation_trends.py new file mode 100644 index 00000000000..4ff03a5b53c --- /dev/null +++ b/erpnext/selling/report/quotation_trends/test_quotation_trends.py @@ -0,0 +1,88 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe + +from erpnext.selling.doctype.quotation.test_quotation import make_quotation +from erpnext.selling.report.quotation_trends.quotation_trends import execute +from erpnext.tests.utils import ERPNextTestSuite + +FISCAL_YEAR = "_Test Fiscal Year 2026" +TXN_DATE = "2026-06-01" + + +class TestQuotationTrends(ERPNextTestSuite): + """The trends report buckets submitted Quotation quantities/amounts by period + (Yearly/Monthly) for the chosen `based_on` dimension (Item, Customer, ...).""" + + def run_report(self, **extra): + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": FISCAL_YEAR, + "based_on": "Item", + "period": "Yearly", + } + ) + filters.update(extra) + result = execute(filters) + columns, data = result[0], result[1] + labels = [c.split(":")[0] if isinstance(c, str) else c.get("label") for c in columns] + return labels, data + + def _cell(self, data, key_label, key_value, col_label, labels): + """Value at column `col_label` for the row whose `key_label` column equals + `key_value`, or 0 when that row doesn't exist yet.""" + key_idx = labels.index(key_label) + col_idx = labels.index(col_label) + for row in data: + if row[key_idx] == key_value: + return row[col_idx] or 0 + return 0 + + def test_yearly_item_amount_and_total(self): + # Yearly period => a single " (Qty)"/"(Amt)" bucket plus Total(Qty)/Total(Amt). + labels, before = self.run_report() + qty_col = f"{FISCAL_YEAR} (Qty)" + amt_col = f"{FISCAL_YEAR} (Amt)" + before_qty = self._cell(before, "Item", "_Test Item", qty_col, labels) + before_amt = self._cell(before, "Item", "_Test Item", amt_col, labels) + before_tot_qty = self._cell(before, "Item", "_Test Item", "Total(Qty)", labels) + before_tot_amt = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels) + + make_quotation(item="_Test Item", qty=4, rate=200, transaction_date=TXN_DATE) + + labels, after = self.run_report() + self.assertEqual(self._cell(after, "Item", "_Test Item", qty_col, labels) - before_qty, 4) + self.assertEqual(self._cell(after, "Item", "_Test Item", amt_col, labels) - before_amt, 800) + self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Qty)", labels) - before_tot_qty, 4) + self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_tot_amt, 800) + + def test_monthly_lands_in_june_bucket(self): + # Monthly period => one bucket per month; a 2026-06-01 quotation hits "Jun (Qty)"/"(Amt)". + labels, before = self.run_report(period="Monthly") + before_jun_qty = self._cell(before, "Item", "_Test Item", "Jun (Qty)", labels) + before_jun_amt = self._cell(before, "Item", "_Test Item", "Jun (Amt)", labels) + before_may_qty = self._cell(before, "Item", "_Test Item", "May (Qty)", labels) + + make_quotation(item="_Test Item", qty=3, rate=100, transaction_date=TXN_DATE) + + labels, after = self.run_report(period="Monthly") + self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Qty)", labels) - before_jun_qty, 3) + # the amount path is a separate SUM(base_net_amount) case, so assert it too + self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Amt)", labels) - before_jun_amt, 300) + # nothing was quoted in May, so that bucket is unchanged + self.assertEqual(self._cell(after, "Item", "_Test Item", "May (Qty)", labels) - before_may_qty, 0) + + def test_based_on_customer_groups_amount_by_party(self): + # based_on Customer keys rows on the "Party" column (the customer id) + labels, before = self.run_report(based_on="Customer") + amt_col = f"{FISCAL_YEAR} (Amt)" + before_amt = self._cell(before, "Party", "_Test Customer", amt_col, labels) + + make_quotation( + party_name="_Test Customer", item="_Test Item", qty=2, rate=150, transaction_date=TXN_DATE + ) + + labels, after = self.run_report(based_on="Customer") + self.assertEqual(self._cell(after, "Party", "_Test Customer", amt_col, labels) - before_amt, 300) diff --git a/erpnext/selling/report/sales_analytics/sales_analytics.py b/erpnext/selling/report/sales_analytics/sales_analytics.py index 93f1abe8222..9eb879681ee 100644 --- a/erpnext/selling/report/sales_analytics/sales_analytics.py +++ b/erpnext/selling/report/sales_analytics/sales_analytics.py @@ -528,12 +528,18 @@ class Analytics: if not frappe.db.exists("DocType", self.filters.doc_type): frappe.throw(_("Invalid Document Type {0}").format(self.filters.doc_type)) - order_types = frappe.get_all( - self.filters.doc_type, - filters={"order_type": ["is", "set"]}, - pluck="order_type", - distinct=True, - order_by="order_type", + # frappe drops ORDER BY for distinct queries on postgres (db_query), so a SQL order_by="order_type" + # would be a no-op there and the leaf rows would come back unordered. Sort in python with casefold + # to keep the report's order-type row order deterministic, case-insensitive (matching MariaDB's + # collation), and identical on both engines. + order_types = sorted( + frappe.get_all( + self.filters.doc_type, + filters={"order_type": ["is", "set"]}, + pluck="order_type", + distinct=True, + ), + key=str.casefold, ) self.group_entries = [frappe._dict(name="Order Types", lft=0, rgt=2, parent="")] diff --git a/erpnext/selling/report/sales_analytics/test_sales_analytics.py b/erpnext/selling/report/sales_analytics/test_sales_analytics.py index b9827327b63..9d7ad3f8dad 100644 --- a/erpnext/selling/report/sales_analytics/test_sales_analytics.py +++ b/erpnext/selling/report/sales_analytics/test_sales_analytics.py @@ -117,6 +117,30 @@ class TestSalesAnalytics(ERPNextTestSuite): self.assertAlmostEqual(rows["Sales"]["total"], expected, places=2) self.assertAlmostEqual(rows["Order Types"]["total"], expected, places=2) + def test_order_type_leaf_rows_in_sorted_order(self): + """get_teams fetches distinct order_types; frappe drops the SQL ORDER BY for distinct queries on + postgres, so the report sorts the order-type rows in python (key=str.casefold) to keep them in a + deterministic, case-insensitive order identical on both engines.""" + for order_type in ("Shopping Cart", "Maintenance", "Sales"): # created out of sorted order + so = make_sales_order( + company=COMPANY, + customer=CUSTOMER, + qty=1, + rate=100, + transaction_date="2019-04-12", + do_not_submit=True, + ) + so.order_type = order_type + so.submit() + + columns, data, *_ = execute(self._base_filters(tree_type="Order Type")) + + mine = {"Sales", "Maintenance", "Shopping Cart"} + leaves = [row["entity"] for row in data if row.get("entity") in mine] + # the order-type rows must appear in casefold-sorted order on both engines + self.assertEqual(leaves, sorted(leaves, key=str.casefold)) + self.assertEqual(set(leaves), mine) + def test_customer_group_by_quantity(self): """value_quantity='Quantity' switches the selected value column (total_qty).""" _columns, data, *_ = execute( diff --git a/erpnext/selling/report/sales_order_trends/test_sales_order_trends.py b/erpnext/selling/report/sales_order_trends/test_sales_order_trends.py index 9fbae8f2e31..46f856a6f03 100644 --- a/erpnext/selling/report/sales_order_trends/test_sales_order_trends.py +++ b/erpnext/selling/report/sales_order_trends/test_sales_order_trends.py @@ -1,14 +1,17 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +import frappe + from erpnext.tests.utils import ERPNextTestSuite class TestSalesOrderTrends(ERPNextTestSuite): def test_report_executes_with_group_by(self): # trends.get_data builds per-period SUM(CASE ...) aggregates (converted from MySQL SUM(IF)), - # with a GROUP BY widened to every selected non-aggregated column and a based_on_key for the - # group-by detail subqueries. Setting group_by exercises that full path on both engines. + # groups by the based-on KEY only (non-key descriptive columns like item_name/territory are + # MAX()-aggregated so the report stays one row per key on both engines), and uses a based_on_key + # for the group-by detail subqueries. Setting group_by exercises that full path on both engines. from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.selling.report.sales_order_trends.sales_order_trends import execute @@ -24,3 +27,27 @@ class TestSalesOrderTrends(ERPNextTestSuite): self.assertTrue(columns) self.assertTrue(any("_Test Item" in [str(cell) for cell in row] for row in data)) + + def test_customer_with_divergent_stored_territory_stays_one_row(self): + # territory (and customer_name) are stored per-transaction fields; historical sales docs can hold a + # different value for the same customer. trends groups by t1.customer only and aggregates these with + # Max(), so the report stays one row per customer on both MariaDB and Postgres. Grouping by territory + # (the pre-fix behaviour) would split the customer into two rows. + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + from erpnext.selling.report.sales_order_trends.sales_order_trends import execute + + make_sales_order(customer="_Test Customer", item_code="_Test Item", qty=3, rate=100) + so2 = make_sales_order(customer="_Test Customer", item_code="_Test Item", qty=2, rate=100) + # simulate a historical doc that stored a different territory for the same customer + frappe.db.set_value("Sales Order", so2.name, "territory", "_Test Territory Rest Of The World") + + filters = { + "company": "_Test Company", + "period": "Monthly", + "based_on": "Customer", + } + columns, data, _chart_none, _chart = execute(filters) + + self.assertTrue(columns) + customer_rows = [row for row in data if row[0] == "_Test Customer"] + self.assertEqual(len(customer_rows), 1) diff --git a/erpnext/selling/report/sales_partner_commission_summary/sales_partner_commission_summary.py b/erpnext/selling/report/sales_partner_commission_summary/sales_partner_commission_summary.py index 5b98c4bf386..859156a6d23 100644 --- a/erpnext/selling/report/sales_partner_commission_summary/sales_partner_commission_summary.py +++ b/erpnext/selling/report/sales_partner_commission_summary/sales_partner_commission_summary.py @@ -47,7 +47,7 @@ class SalesPartnerSummaryReport: frappe.throw(_("Please select the document type first.")) if self.filters.get("doctype") not in SALES_TRANSACTION_DOCTYPES: - frappe.throw(_("DocType can be one of them {0}").format(comma_or(SALES_TRANSACTION_DOCTYPES))) + frappe.throw(_("DocType can be one of {0}").format(comma_or(SALES_TRANSACTION_DOCTYPES))) if not self.filters.get("company"): frappe.throw(_("Please select a company.")) diff --git a/erpnext/selling/report/sales_partner_commission_summary/test_sales_partner_commission_summary.py b/erpnext/selling/report/sales_partner_commission_summary/test_sales_partner_commission_summary.py index 9a46bcb85db..32f71f12aaf 100644 --- a/erpnext/selling/report/sales_partner_commission_summary/test_sales_partner_commission_summary.py +++ b/erpnext/selling/report/sales_partner_commission_summary/test_sales_partner_commission_summary.py @@ -19,7 +19,7 @@ class SalesPartnerSummaryReportTestMixin(ERPNextTestSuite): with self.assertRaisesRegex( frappe.ValidationError, - _("DocType can be one of them {0}").format(comma_or(SALES_TRANSACTION_DOCTYPES)), + _("DocType can be one of {0}").format(comma_or(SALES_TRANSACTION_DOCTYPES)), ): run(self.report_name, self.filters) diff --git a/erpnext/selling/report/sales_person_commission_summary/test_sales_person_commission_summary.py b/erpnext/selling/report/sales_person_commission_summary/test_sales_person_commission_summary.py new file mode 100644 index 00000000000..b1385ca4f09 --- /dev/null +++ b/erpnext/selling/report/sales_person_commission_summary/test_sales_person_commission_summary.py @@ -0,0 +1,85 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe + +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.selling.report.sales_person_commission_summary.sales_person_commission_summary import ( + execute, +) +from erpnext.tests.utils import ERPNextTestSuite + + +class TestSalesPersonCommissionSummary(ERPNextTestSuite): + """The report joins a sales document (Sales Invoice/Order/Delivery Note) with its + Sales Team rows, listing each sales person's contribution and commission.""" + + def setUp(self): + # reuse the bootstrap sales persons (under the "Sales Team" group) + self.sales_person = "_Test Sales Person" + + def make_invoice_with_commission(self, percentage=100, commission_rate=5, incentives=50): + si = create_sales_invoice(rate=1000, qty=1, do_not_save=True, posting_date="2026-06-01") + si.append( + "sales_team", + { + "sales_person": self.sales_person, + "allocated_percentage": percentage, + "commission_rate": commission_rate, + "incentives": incentives, + }, + ) + si.insert() + si.submit() + si.reload() # reflect any values recomputed on submit + return si + + def run_report(self, **extra): + filters = frappe._dict( + { + "company": "_Test Company", + "doc_type": "Sales Invoice", + "sales_person": self.sales_person, + # scope to this test's posting date so the query isn't unbounded over + # every invoice for the shared sales person + "from_date": "2026-06-01", + "to_date": "2026-06-01", + } + ) + filters.update(extra) + return execute(filters)[1] + + def test_doc_type_is_mandatory(self): + self.assertRaises(frappe.ValidationError, execute, frappe._dict({"company": "_Test Company"})) + + def test_commission_row_matches_sales_team_entry(self): + si = self.make_invoice_with_commission(percentage=100, commission_rate=5, incentives=50) + team = si.sales_team[0] + + rows = self.run_report() + row = next((r for r in rows if r[0] == si.name), None) + self.assertIsNotNone(row, "Invoice with commission missing from report") + + # row: name, customer, territory, posting_date, base_net_amount, sales_person, + # allocated_percentage, commission_rate, allocated_amount, incentives + self.assertEqual(row[1], si.customer) + self.assertEqual(row[4], si.base_net_total) + self.assertEqual(row[5], self.sales_person) + self.assertEqual(row[6], team.allocated_percentage) + self.assertEqual(row[7], team.commission_rate) + self.assertEqual(row[8], team.allocated_amount) + self.assertEqual(row[9], team.incentives) + + def test_appends_total_row(self): + self.make_invoice_with_commission() + rows = self.run_report() + # the report appends a blank total row after one or more real data rows + self.assertGreaterEqual(len(rows), 2) + self.assertTrue(any(r[0] for r in rows[:-1]), "expected real data rows before the total row") + self.assertEqual(rows[-1], [""] * len(rows[0])) + + def test_sales_person_filter_scopes_rows(self): + si = self.make_invoice_with_commission() + + filtered = self.run_report(sales_person="_Test Sales Person 1") + self.assertNotIn(si.name, {r[0] for r in filtered if r[0]}) diff --git a/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py b/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py index f834f27df50..180740dcb6e 100644 --- a/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py +++ b/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py @@ -183,8 +183,22 @@ def get_entries(filters): .as_("contribution_amt") ) + # Only pass valid document-field filters to get_query; report-specific keys such as + # doc_type / sales_person / item_group are handled separately below. + doc_filters = {"docstatus": 1} + for field in ["company", "customer", "territory"]: + if filters.get(field): + doc_filters[field] = filters.get(field) + + if filters.get("from_date") and filters.get("to_date"): + doc_filters[date_field] = ["between", [filters.get("from_date"), filters.get("to_date")]] + elif filters.get("from_date"): + doc_filters[date_field] = [">=", filters.get("from_date")] + elif filters.get("to_date"): + doc_filters[date_field] = ["<=", filters.get("to_date")] + query = ( - frappe.get_query(dt, filters=filters, ignore_permissions=False) + frappe.get_query(dt, filters=doc_filters, ignore_permissions=False) .join(dt_item) .on(dt.name == dt_item.parent) .join(st) @@ -203,48 +217,29 @@ def get_entries(filters): contribution_amt_case, ) .where(st.parenttype == doc_type) - .where(dt.docstatus == 1) ) + if filters.get("sales_person"): + lft, rgt = frappe.db.get_value("Sales Person", filters.get("sales_person"), ["lft", "rgt"]) + sp = frappe.qb.DocType("Sales Person") + query = query.where( + st.sales_person.isin(frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt))) + ) + + # only resolve items when an item_group/brand filter is set; otherwise get_items + # would return every item in the system and add a huge IN() clause on each run + if filters.get("item_group") or filters.get("brand"): + items = get_items(filters) + if not items: + # the item_group/brand filter matched nothing -> no rows + return [] + query = query.where(dt_item.item_code.isin([d[0] for d in items])) + query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc) return query.run(as_dict=True) -def get_conditions(filters, date_field): - conditions = [""] - values = [] - - for field in ["company", "customer", "territory"]: - if filters.get(field): - conditions.append(f"dt.{field}=%s") - values.append(filters[field]) - - if filters.get("sales_person"): - lft, rgt = frappe.get_value("Sales Person", filters.get("sales_person"), ["lft", "rgt"]) - conditions.append( - f"exists(select name from `tabSales Person` where lft >= {lft} and rgt <= {rgt} and name=st.sales_person)" - ) - - if filters.get("from_date"): - conditions.append(f"dt.{date_field}>=%s") - values.append(filters["from_date"]) - - if filters.get("to_date"): - conditions.append(f"dt.{date_field}<=%s") - values.append(filters["to_date"]) - - items = get_items(filters) - if items: - conditions.append("dt_item.item_code in (%s)" % ", ".join(["%s"] * len(items))) - values += items - else: - # return empty result, if no items are fetched after filtering on 'item group' and 'brand' - conditions.append("dt_item.item_code = Null") - - return " and ".join(conditions), values - - def get_items(filters): item = qb.DocType("Item") diff --git a/erpnext/selling/report/sales_person_wise_transaction_summary/test_sales_person_wise_transaction_summary.py b/erpnext/selling/report/sales_person_wise_transaction_summary/test_sales_person_wise_transaction_summary.py new file mode 100644 index 00000000000..2dbe8fee822 --- /dev/null +++ b/erpnext/selling/report/sales_person_wise_transaction_summary/test_sales_person_wise_transaction_summary.py @@ -0,0 +1,69 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe + +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.selling.report.sales_person_wise_transaction_summary.sales_person_wise_transaction_summary import ( + execute, +) +from erpnext.tests.utils import ERPNextTestSuite + + +class TestSalesPersonWiseTransactionSummary(ERPNextTestSuite): + """Item-level summary joining a sales document with its Sales Team rows, showing + each sales person's contributed qty and amount per item line.""" + + def setUp(self): + self.sales_person = "_Test Sales Person" + + def make_invoice_with_commission(self, qty=5, rate=200, percentage=100): + si = create_sales_invoice( + item="_Test Item", qty=qty, rate=rate, do_not_save=True, posting_date="2026-06-01" + ) + si.append("sales_team", {"sales_person": self.sales_person, "allocated_percentage": percentage}) + si.insert() + si.submit() + return si + + def run_report(self, **extra): + filters = frappe._dict( + {"company": "_Test Company", "doc_type": "Sales Invoice", "sales_person": self.sales_person} + ) + filters.update(extra) + return execute(filters)[1] + + def test_doc_type_is_mandatory(self): + self.assertRaises(frappe.ValidationError, execute, frappe._dict({"company": "_Test Company"})) + + def test_invalid_doc_type_throws(self): + self.assertRaises( + frappe.ValidationError, + execute, + frappe._dict({"company": "_Test Company", "doc_type": "Purchase Invoice"}), + ) + + def test_item_line_contribution(self): + si = self.make_invoice_with_commission(qty=5, rate=200, percentage=100) + item = si.items[0] + + rows = self.run_report() + row = next((r for r in rows if r[0] == si.name and r[5] == "_Test Item"), None) + self.assertIsNotNone(row, "Invoice item line missing from report") + + # row: name, customer, territory, warehouse, posting_date, item_code, item_group, + # brand, stock_qty, base_net_amount, sales_person, allocated_percentage, + # contributed_qty, contribution_amt, currency + self.assertEqual(row[1], si.customer) + self.assertEqual(row[8], item.stock_qty) + self.assertEqual(row[9], item.base_net_amount) + self.assertEqual(row[10], self.sales_person) + self.assertEqual(row[11], 100) + self.assertEqual(row[12], item.stock_qty * 100 / 100) # contributed qty + self.assertEqual(row[13], item.base_net_amount * 100 / 100) # contribution amount + + def test_appends_total_row(self): + self.make_invoice_with_commission() + rows = self.run_report() + self.assertTrue(rows) + self.assertEqual(rows[-1], [""] * len(rows[0])) diff --git a/erpnext/selling/report/territory_target_variance_based_on_item_group/test_territory_target_variance_based_on_item_group.py b/erpnext/selling/report/territory_target_variance_based_on_item_group/test_territory_target_variance_based_on_item_group.py new file mode 100644 index 00000000000..8c98a98bd7c --- /dev/null +++ b/erpnext/selling/report/territory_target_variance_based_on_item_group/test_territory_target_variance_based_on_item_group.py @@ -0,0 +1,68 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.utils import flt, nowdate + +from erpnext.accounts.utils import get_fiscal_year +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.selling.report.sales_person_target_variance_based_on_item_group.test_sales_person_target_variance_based_on_item_group import ( + create_target_distribution, +) +from erpnext.selling.report.territory_target_variance_based_on_item_group.territory_target_variance_based_on_item_group import ( + execute, +) +from erpnext.tests.utils import ERPNextTestSuite + + +class TestTerritoryTargetVarianceBasedOnItemGroup(ERPNextTestSuite): + def setUp(self): + self.fiscal_year = get_fiscal_year(nowdate())[0] + + def test_achieved_target_and_variance(self): + distribution = create_target_distribution(self.fiscal_year) + territory = create_territory_with_target( + "_Test Target Territory", self.fiscal_year, distribution.name, target_qty=50 + ) + + # a Sales Order in that territory contributes to the achieved quantity + so = make_sales_order(rate=1000, qty=20, do_not_submit=True) + so.territory = territory.name + so.submit() + + result = execute( + frappe._dict( + { + "fiscal_year": self.fiscal_year, + "doctype": "Sales Order", + "period": "Yearly", + "target_on": "Quantity", + } + ) + )[1] + + # no item_group is set on the target, so the report emits exactly one row per + # territory -- assert all three figures against that single row + rows = [frappe._dict(r) for r in result if r.get("territory") == territory.name] + self.assertEqual(len(rows), 1, "expected exactly one row for the target territory") + row = rows[0] + self.assertEqual(flt(row.total_target, 2), 50) + self.assertEqual(flt(row.total_achieved, 2), 20) + self.assertEqual(flt(row.total_variance, 2), -30) + + +def create_territory_with_target(name, fiscal_year, distribution_id, target_qty=50): + doc = frappe.new_doc("Territory") + doc.territory_name = name + doc.parent_territory = "All Territories" + doc.is_group = 0 + doc.append( + "targets", + { + "fiscal_year": fiscal_year, + "target_qty": target_qty, + "target_amount": 30000, + "distribution_id": distribution_id, + }, + ) + return doc.insert() diff --git a/erpnext/selling/report/territory_wise_sales/test_territory_wise_sales.py b/erpnext/selling/report/territory_wise_sales/test_territory_wise_sales.py new file mode 100644 index 00000000000..8a069810b8d --- /dev/null +++ b/erpnext/selling/report/territory_wise_sales/test_territory_wise_sales.py @@ -0,0 +1,62 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe + +from erpnext.selling.doctype.quotation.test_quotation import make_quotation +from erpnext.selling.report.territory_wise_sales.territory_wise_sales import execute +from erpnext.tests.utils import ERPNextTestSuite + +TERRITORY = "_Test Territory" + + +class TestTerritoryWiseSales(ERPNextTestSuite): + """The report walks the Opportunity -> Quotation -> Sales Order -> Sales Invoice + funnel and totals each stage's amount per territory. + + These tests cover the Opportunity and Quotation stages; the Sales Order and + Sales Invoice (order_amount / billing_amount) stages are not yet exercised.""" + + def make_opportunity(self, amount=5000): + return frappe.get_doc( + { + "doctype": "Opportunity", + "opportunity_from": "Customer", + "party_name": "_Test Customer", + "territory": TERRITORY, + "company": "_Test Company", + "currency": "INR", + "opportunity_amount": amount, + "transaction_date": "2026-06-01", + } + ).insert() + + def make_quotation_for(self, opportunity, qty, rate): + qo = make_quotation(item="_Test Item", qty=qty, rate=rate, do_not_save=True) + qo.opportunity = opportunity.name + qo.insert() + qo.submit() + return qo + + def amount_for(self, territory, field): + for row in execute(frappe._dict({"company": "_Test Company"}))[1]: + if row["territory"] == territory: + return row[field] + return 0 + + def test_opportunity_amount_grouped_by_territory(self): + before = self.amount_for(TERRITORY, "opportunity_amount") + opp = self.make_opportunity(5000) + self.assertEqual(opp.territory, TERRITORY) + + after = self.amount_for(TERRITORY, "opportunity_amount") + self.assertEqual(after - before, 5000) + + def test_quotation_amount_flows_from_opportunity(self): + before = self.amount_for(TERRITORY, "quotation_amount") + + opp = self.make_opportunity() + quotation = self.make_quotation_for(opp, qty=2, rate=500) + + after = self.amount_for(TERRITORY, "quotation_amount") + self.assertEqual(after - before, quotation.base_grand_total) diff --git a/erpnext/selling/workspace/selling/selling.json b/erpnext/selling/workspace/selling/selling.json index 141b3634708..7bcc6264948 100644 --- a/erpnext/selling/workspace/selling/selling.json +++ b/erpnext/selling/workspace/selling/selling.json @@ -13,7 +13,7 @@ "doctype": "Workspace", "for_user": "", "hide_custom": 0, - "icon": "sell", + "icon": "store", "idx": 0, "is_hidden": 0, "label": "Selling", @@ -622,9 +622,10 @@ "type": "Link" } ], - "modified": "2026-02-19 13:01:26.893303", + "modified": "2026-07-03 13:44:07.820564", "modified_by": "Administrator", "module": "Selling", + "module_onboarding": "Selling Onboarding", "name": "Selling", "number_cards": [ { @@ -648,6 +649,762 @@ "roles": [], "sequence_id": 6.0, "shortcuts": [], + "sidebar_items": [ + { + "child": 0, + "collapsible": 1, + "icon": "house", + "indent": 0, + "keep_closed": 0, + "label": "Home", + "link_to": "Selling", + "link_type": "Workspace", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "chart-column", + "indent": 0, + "keep_closed": 0, + "label": "Dashboard", + "link_to": "Selling", + "link_type": "Dashboard", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "receipt-text", + "indent": 0, + "keep_closed": 0, + "label": "Quotation", + "link_to": "Quotation", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "store", + "indent": 0, + "keep_closed": 0, + "label": "Sales Order", + "link_to": "Sales Order", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "receipt", + "indent": 0, + "keep_closed": 0, + "label": "Sales Invoice", + "link_to": "Sales Invoice", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "computer", + "indent": 1, + "keep_closed": 1, + "label": "POS", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "POS", + "link_to": "point-of-sale", + "link_type": "Page", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "POS Profile", + "link_to": "POS Profile", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "POS Invoice", + "link_to": "POS Invoice", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "POS Opening Entry", + "link_to": "POS Opening Entry", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "POS Closing Entry", + "link_to": "POS Closing Entry", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "POS Invoice Merge Log", + "link_to": "POS Invoice Merge Log", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "POS Settings", + "link_to": "POS Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Loyalty Program", + "link_to": "Loyalty Program", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Loyalty Point Entry", + "link_to": "Loyalty Point Entry", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "package", + "indent": 1, + "keep_closed": 1, + "label": "Items & Pricing", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "Item", + "link_to": "Item", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Item Group", + "link_to": "Item Group", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Price List", + "link_to": "Price List", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Item Price", + "link_to": "Item Price", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Pricing Rule", + "link_to": "Pricing Rule", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Promotional Scheme", + "link_to": "Promotional Scheme", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Coupon Code", + "link_to": "Coupon Code", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Blanket Order", + "link_to": "Blanket Order", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "database", + "indent": 1, + "keep_closed": 1, + "label": "Setup", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "icon": "", + "indent": 0, + "keep_closed": 0, + "label": "Customer", + "link_to": "Customer", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Customer Group", + "link_to": "Customer Group", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Address", + "link_to": "Address", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Contact", + "link_to": "Contact", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Territory", + "link_to": "Territory", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Campaign", + "link_to": "Campaign", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Sales Person", + "link_to": "Sales Person", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Sales Partner", + "link_to": "Sales Partner", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Monthly Distribution", + "link_to": "Monthly Distribution", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Terms Template", + "link_to": "Terms and Conditions", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Tax Template", + "link_to": "Sales Taxes and Charges Template", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Product Bundle", + "link_to": "Product Bundle", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "UTM Source", + "link_to": "UTM Source", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Shipping Rule", + "link_to": "Shipping Rule", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "sheet", + "indent": 1, + "keep_closed": 1, + "label": "Reports", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Sales Register", + "link_to": "Sales Register", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Item-wise Sales Register", + "link_to": "Item-wise Sales Register", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Sales Analytics", + "link_to": "Sales Analytics", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Customer Addresses And Contacts", + "link_to": "Address And Contacts", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Inactive Customers", + "link_to": "Inactive Customers", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Sales Invoice Trends", + "link_to": "Sales Invoice Trends", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Customer Credit Balance", + "link_to": "Customer Credit Balance", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Customers Without Any Sales Transactions", + "link_to": "Customers Without Any Sales Transactions", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Sales Partners Commission", + "link_to": "Sales Partners Commission", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Available Stock for Packing Items", + "link_to": "Available Stock for Packing Items", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Territory Target Variance Based On Item Group", + "link_to": "Territory Target Variance Based On Item Group", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Sales Person Target Variance Based On Item Group", + "link_to": "Sales Person Target Variance Based On Item Group", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Sales Partner Target Variance Based On Item Group", + "link_to": "Sales Partner Target Variance based on Item Group", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Pending SO Items For Purchase Request", + "link_to": "Pending SO Items For Purchase Request", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Sales Funnel", + "link_to": "sales-funnel", + "link_type": "Page", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Sales Order Analysis", + "link_to": "Sales Order Analysis", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Customer Acquisition and Loyalty", + "link_to": "Customer Acquisition and Loyalty", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Quotation Trends", + "link_to": "Quotation Trends", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Sales Order Trends", + "link_to": "Sales Order Trends", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Item-wise Sales History", + "link_to": "Item-wise Sales History", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Sales Person-wise Transaction Summary", + "link_to": "Sales Person-wise Transaction Summary", + "link_type": "Report", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "settings", + "indent": 0, + "keep_closed": 0, + "label": "Settings", + "link_to": "Selling Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + } + ], + "standard": 1, "title": "Selling", "type": "Workspace" } diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index 66971a354b4..84b79c95074 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -236,7 +236,7 @@ frappe.ui.form.on("Company", { }, function (data) { if (data.company_name !== frm.doc.name) { - frappe.msgprint(__("Company name not same")); + frappe.msgprint(__("Company name does not match")); return; } frappe.call({ @@ -339,7 +339,7 @@ erpnext.company.setup_queries = function (frm) { ], [ "stock_delivered_but_not_billed", - { root_type: "Liability", account_type: "Stock Delivered But Not Billed" }, + { root_type: "Asset", account_type: "Stock Delivered But Not Billed" }, ], [ "service_received_but_not_billed", diff --git a/erpnext/setup/doctype/company/company.json b/erpnext/setup/doctype/company/company.json index 49f61238839..0036ea249ba 100644 --- a/erpnext/setup/doctype/company/company.json +++ b/erpnext/setup/doctype/company/company.json @@ -129,7 +129,10 @@ "valuation_method", "column_break_32", "stock_adjustment_account", + "default_purchase_price_variance_account", + "default_manufacturing_variance_account", "stock_received_but_not_billed", + "enable_stock_delivered_but_not_billed", "stock_delivered_but_not_billed", "disable_sdbnb_in_sr", "default_provisional_account", @@ -351,33 +354,48 @@ "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "round_off_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Round Off Account", + "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "round_off_cost_center", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Round Off Cost Center", + "no_copy": 1, "options": "Cost Center" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "write_off_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Write Off Account", + "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "exchange_gain_loss_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Exchange Gain / Loss Account", + "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "unrealized_exchange_gain_loss_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Unrealized Exchange Gain/Loss Account", + "no_copy": 1, "options": "Account" }, { @@ -491,6 +509,26 @@ "no_copy": 1, "options": "Account" }, + { + "description": "Used for items valued at Standard Cost: the difference between the purchase price and the standard rate is booked here.", + "fieldname": "default_purchase_price_variance_account", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Default Purchase Price Variance Account", + "no_copy": 1, + "options": "Account", + "show_description_on_click": 1 + }, + { + "description": "For Standard Cost items: the Manufacture/Repack consumed cost vs standard rate difference is booked here.", + "fieldname": "default_manufacturing_variance_account", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Default Manufacturing Variance Account", + "no_copy": 1, + "options": "Account", + "show_description_on_click": 1 + }, { "fieldname": "column_break_32", "fieldtype": "Column Break" @@ -504,15 +542,19 @@ "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "accumulated_depreciation_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Accumulated Depreciation Account", "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "depreciation_expense_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Depreciation Expense Account", "no_copy": 1, "options": "Account" @@ -527,29 +569,39 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "disposal_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Gain/Loss Account on Asset Disposal", "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "depreciation_cost_center", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Asset Depreciation Cost Center", "no_copy": 1, "options": "Cost Center" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "capital_work_in_progress_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Capital Work In Progress Account", + "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "asset_received_but_not_billed", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Asset Received But Not Billed", + "no_copy": 1, "options": "Account" }, { @@ -681,15 +733,21 @@ "options": "Warehouse" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "unrealized_profit_loss_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Unrealized Profit / Loss Account", + "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "default_discount_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Default Payment Discount Account", + "no_copy": 1, "options": "Account" }, { @@ -731,8 +789,10 @@ "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account", "fieldname": "default_advance_received_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Default Advance Received Account", "mandatory_depends_on": "book_advance_payments_as_liability", + "no_copy": 1, "options": "Account" }, { @@ -741,8 +801,10 @@ "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account", "fieldname": "default_advance_paid_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Default Advance Paid Account", "mandatory_depends_on": "book_advance_payments_as_liability", + "no_copy": 1, "options": "Account" }, { @@ -822,9 +884,12 @@ "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "round_off_for_opening", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Round Off for Opening", + "no_copy": 1, "options": "Account" }, { @@ -984,18 +1049,28 @@ }, { "default": "0", + "depends_on": "enable_stock_delivered_but_not_billed", "fieldname": "disable_sdbnb_in_sr", "fieldtype": "Check", "label": "Disable Stock Delivered But Not Billed in Sales Return", "no_copy": 1 }, { + "depends_on": "enable_stock_delivered_but_not_billed", "fieldname": "stock_delivered_but_not_billed", "fieldtype": "Link", "ignore_user_permissions": 1, "label": "Stock Delivered But Not Billed", + "mandatory_depends_on": "enable_stock_delivered_but_not_billed", "no_copy": 1, "options": "Account" + }, + { + "default": "0", + "description": "If enabled, the value of goods delivered before invoicing will be recorded in the Stock Delivered But Not Billed account.", + "fieldname": "enable_stock_delivered_but_not_billed", + "fieldtype": "Check", + "label": "Enable Stock Delivered But Not Billed" } ], "grid_page_length": 50, @@ -1004,7 +1079,7 @@ "image_field": "company_logo", "is_tree": 1, "links": [], - "modified": "2026-05-14 16:50:34.132345", + "modified": "2026-07-02 07:21:21.794533", "modified_by": "Administrator", "module": "Setup", "name": "Company", diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 59064847173..420804a552f 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -80,9 +80,11 @@ class Company(NestedSet): default_inventory_account: DF.Link | None default_letter_head: DF.Link | None default_letter_head_report: DF.Link | None + default_manufacturing_variance_account: DF.Link | None default_operating_cost_account: DF.Link | None default_payable_account: DF.Link | None default_provisional_account: DF.Link | None + default_purchase_price_variance_account: DF.Link | None default_receivable_account: DF.Link | None default_sales_contact: DF.Link | None default_scrap_warehouse: DF.Link | None @@ -98,6 +100,7 @@ class Company(NestedSet): enable_item_wise_inventory_account: DF.Check enable_perpetual_inventory: DF.Check enable_provisional_accounting_for_non_stock_items: DF.Check + enable_stock_delivered_but_not_billed: DF.Check exception_budget_approver_role: DF.Link | None exchange_gain_loss_account: DF.Link | None existing_company: DF.Link | None @@ -184,6 +187,64 @@ class Company(NestedSet): self.validate_inventory_account_settings() self.cant_change_valuation_method() self.validate_pending_reposts(old_doc) + self.validate_sdbnb_configuration() + + def validate_outstanding_sdbnb_transactions(self, account): + GLEntry = frappe.qb.DocType("GL Entry") + DeliveryNote = frappe.qb.DocType("Delivery Note") + + delivery_notes = ( + frappe.qb.from_(GLEntry) + .join(DeliveryNote) + .on((GLEntry.voucher_type == "Delivery Note") & (GLEntry.voucher_no == DeliveryNote.name)) + .select(DeliveryNote.name) + .where( + (GLEntry.is_cancelled == 0) + & (GLEntry.company == self.name) + & (GLEntry.account == account) + & (DeliveryNote.per_billed < 100) + & (DeliveryNote.docstatus == 1) + & (DeliveryNote.status.isin(["To Bill", "Partially Billed"])) + ) + .distinct() + .run(pluck=True) + ) + + if delivery_notes: + dn_links = ", ".join(get_link_to_form("Delivery Note", dn) for dn in delivery_notes[:10]) + + frappe.throw( + _( + "Stock Delivered But Not Billed Account cannot be changed or disabled since account {0} contains outstanding Delivery Notes: {1}" + ).format( + bold(account), + dn_links, + ) + ) + + def validate_sdbnb_configuration(self): + if self.get("__islocal"): + return + + if self.enable_stock_delivered_but_not_billed and not self.stock_delivered_but_not_billed: + frappe.throw(_("Please select Stock Delivered But Not Billed Account")) + + doc_before_save = self.get_doc_before_save() + + if not (doc_before_save and doc_before_save.stock_delivered_but_not_billed): + return + + account_changed = ( + self.stock_delivered_but_not_billed != doc_before_save.stock_delivered_but_not_billed + ) + + feature_disabled = ( + doc_before_save.enable_stock_delivered_but_not_billed + and not self.enable_stock_delivered_but_not_billed + ) + + if account_changed or feature_disabled: + self.validate_outstanding_sdbnb_transactions(doc_before_save.stock_delivered_but_not_billed) def cant_change_valuation_method(self): doc_before_save = self.get_doc_before_save() @@ -856,6 +917,7 @@ def install_country_fixtures(company, country): except ImportError: pass except Exception: + frappe.db.rollback() frappe.log_error("Unable to set country fixtures") frappe.throw( _("Failed to setup defaults for country {0}. Please contact support.").format( @@ -945,7 +1007,7 @@ def get_children(doctype: str, parent: str | None = None, company: str | None = ) -@frappe.whitelist() +@frappe.whitelist(methods=["POST"]) def add_node(): from frappe.desk.treeview import make_tree_args @@ -1056,7 +1118,7 @@ def get_billing_shipping_address( return {"primary_address": primary_address, "shipping_address": shipping_address} -@frappe.whitelist() +@frappe.whitelist(methods=["POST"]) def create_transaction_deletion_request(company: str): frappe.only_for("System Manager") diff --git a/erpnext/setup/doctype/company/company_list.js b/erpnext/setup/doctype/company/company_list.js index 558e7500e3a..e69de29bb2d 100644 --- a/erpnext/setup/doctype/company/company_list.js +++ b/erpnext/setup/doctype/company/company_list.js @@ -1,5 +0,0 @@ -frappe.listview_settings["Company"] = { - onload() { - frappe.breadcrumbs.add("Accounts"); - }, -}; diff --git a/erpnext/setup/doctype/company/test_company.py b/erpnext/setup/doctype/company/test_company.py index bdb87e4bfdc..7ec5bee5d0e 100644 --- a/erpnext/setup/doctype/company/test_company.py +++ b/erpnext/setup/doctype/company/test_company.py @@ -10,7 +10,11 @@ from frappe.utils import random_string from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import ( get_charts_for_country, ) +from erpnext.accounts.doctype.account.test_account import create_account from erpnext.setup.doctype.company.company import get_default_company_address +from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.tests.utils import ERPNextTestSuite @@ -64,7 +68,12 @@ class TestCompany(ERPNextTestSuite): try: company = frappe.new_doc("Company") company.company_name = template - company.abbr = random_string(3) + # a short random abbr collides with existing test companies often enough + # to flake, so pick one that is verified unique + abbr = random_string(3) + while frappe.db.exists("Company", {"abbr": abbr}): + abbr = random_string(3) + company.abbr = abbr company.default_currency = "USD" company.create_chart_of_accounts_based_on = "Standard Template" company.chart_of_accounts = template @@ -234,6 +243,44 @@ class TestCompany(ERPNextTestSuite): after = get_all_transactions_annual_history(company).get(key, 0) self.assertEqual(after - before, 2) + def test_sdbnb_validation_requires_account_when_enabled(self): + company = get_test_company() + + company.enable_stock_delivered_but_not_billed = 1 + company.stock_delivered_but_not_billed = None + + with self.assertRaises(frappe.ValidationError): + company.save() + + def test_disable_sdbnb_with_outstanding_delivery_note_fails(self): + company = get_test_company() + + item_code = create_stock_item_with_inventory() + create_outstanding_delivery_note(item_code) + + company.enable_stock_delivered_but_not_billed = 0 + + with self.assertRaises(frappe.ValidationError): + company.save() + + def test_cannot_change_sdbnb_account_with_outstanding_delivery_note(self): + company = get_test_company() + + item_code = create_stock_item_with_inventory() + create_outstanding_delivery_note(item_code) + + new_account = create_account( + account_name="Stock Delivered But Not Billed - New", + account_type="Stock Delivered But Not Billed", + parent_account="Stock Assets - _TSDBNB", + company=company.name, + ) + + company.stock_delivered_but_not_billed = new_account + + with self.assertRaises(frappe.ValidationError): + company.save() + def test_demo_data(self): from erpnext.setup.demo import clear_demo_data, setup_demo_data @@ -297,3 +344,49 @@ def create_test_lead_in_company(company): lead.company = company lead.save() return lead.name + + +def get_test_company(): + if frappe.db.exists("Company", "_Test SDBNB Company"): + return frappe.get_doc("Company", "_Test SDBNB Company") + + return frappe.get_doc( + { + "doctype": "Company", + "company_name": "_Test SDBNB Company", + "abbr": "_TSDBNB", + "country": "India", + "default_currency": "INR", + "enable_perpetual_inventory": 1, + "enable_stock_delivered_but_not_billed": 1, + } + ).insert() + + +def create_stock_item_with_inventory(): + item_code = make_item( + "SDBNB Test Item", + properties={"is_stock_item": 1}, + ).name + + make_stock_entry( + item_code=item_code, + target="Stores - _TSDBNB", + qty=10, + basic_rate=100, + company="_Test SDBNB Company", + ) + + return item_code + + +def create_outstanding_delivery_note(item_code): + return create_delivery_note( + item_code=item_code, + qty=5, + rate=150, + company="_Test SDBNB Company", + warehouse="Stores - _TSDBNB", + cost_center="Main - _TSDBNB", + expense_account="Stock Delivered But Not Billed - _TSDBNB", + ) diff --git a/erpnext/setup/doctype/company/test_records.json b/erpnext/setup/doctype/company/test_records.json index d3faeec4672..794175c81ce 100644 --- a/erpnext/setup/doctype/company/test_records.json +++ b/erpnext/setup/doctype/company/test_records.json @@ -223,5 +223,17 @@ "doctype": "Company", "chart_of_accounts": "Standard", "create_chart_of_accounts_based_on": "Standard Template" + }, + { + "abbr": "_TSDBNB", + "company_name": "_Test SDBNB Company", + "country": "India", + "default_currency": "INR", + "doctype": "Company", + "domain": "Manufacturing", + "chart_of_accounts": "Standard", + "default_holiday_list": "_Test Holiday List", + "enable_perpetual_inventory": 1, + "enable_stock_delivered_but_not_billed": 1 } -] +] \ No newline at end of file diff --git a/erpnext/setup/doctype/department/department.js b/erpnext/setup/doctype/department/department.js index d703a52fc50..73c9265e94d 100644 --- a/erpnext/setup/doctype/department/department.js +++ b/erpnext/setup/doctype/department/department.js @@ -16,7 +16,7 @@ frappe.ui.form.on("Department", { }, validate: function (frm) { if (frm.doc.name == "All Departments") { - frappe.throw(__("You cannot edit root node.")); + frappe.throw(__("You cannot edit the root node.")); } }, }); diff --git a/erpnext/setup/doctype/department/department.py b/erpnext/setup/doctype/department/department.py index 71cf6e96743..6eda9e3510d 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 = {} @@ -96,7 +95,7 @@ def get_children( return frappe.get_all("Department", fields=fields, filters=filters, order_by="name") -@frappe.whitelist() +@frappe.whitelist(methods=["POST"]) def add_node(): from frappe.desk.treeview import make_tree_args diff --git a/erpnext/setup/doctype/email_digest/test_email_digest.py b/erpnext/setup/doctype/email_digest/test_email_digest.py index 09f100b92ab..5ca1caf1d7b 100644 --- a/erpnext/setup/doctype/email_digest/test_email_digest.py +++ b/erpnext/setup/doctype/email_digest/test_email_digest.py @@ -2,7 +2,7 @@ # See license.txt import frappe -from frappe.utils import add_days, today +from frappe.utils import add_days, getdate, now_datetime, today from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order from erpnext.tests.utils import ERPNextTestSuite @@ -116,3 +116,38 @@ def create_email_digest(**args): doc.insert() return doc + + +class TestEmailDigestDates(ERPNextTestSuite): + """The digest's reporting windows are pure date math driven by the frequency.""" + + def make_digest(self, frequency, from_date="2026-06-15"): + doc = frappe.new_doc("Email Digest") + doc.frequency = frequency + doc.from_date = getdate(from_date) + doc.to_date = getdate(from_date) + return doc + + def test_set_dates_daily_looks_back_one_day(self): + doc = self.make_digest("Daily") + doc.set_dates() + self.assertEqual(doc.past_from_date, getdate("2026-06-14")) + self.assertEqual(doc.past_to_date, getdate("2026-06-14")) + + def test_set_dates_weekly_looks_back_one_week(self): + doc = self.make_digest("Weekly") + doc.set_dates() + self.assertEqual(doc.past_from_date, getdate("2026-06-08")) + self.assertEqual(doc.past_to_date, getdate("2026-06-14")) + + def test_set_dates_monthly_looks_back_one_month(self): + doc = self.make_digest("Monthly") + doc.set_dates() + self.assertEqual(doc.past_from_date, getdate("2026-05-15")) + self.assertEqual(doc.past_to_date, getdate("2026-06-14")) + + def test_weekly_window_is_the_previous_monday_to_sunday(self): + from_date, to_date = self.make_digest("Weekly").get_from_to_date() + self.assertEqual(from_date.weekday(), 0) # Monday + self.assertEqual((to_date - from_date).days, 6) # through Sunday + self.assertLess(to_date, now_datetime().date()) # entirely in the past diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py index 77f7e9bd43a..8a9ad025314 100755 --- a/erpnext/setup/doctype/employee/employee.py +++ b/erpnext/setup/doctype/employee/employee.py @@ -427,12 +427,12 @@ def is_holiday(employee, date=None, raise_exception=True, only_non_weekly=False, def deactivate_sales_person(status: str, employee: str): frappe.has_permission("Employee", doc=employee, ptype="write", throw=True) if status == "Left": - sales_person = frappe.db.get_value("Sales Person", {"Employee": employee}) + sales_person = frappe.db.get_value("Sales Person", {"employee": employee}) if sales_person: frappe.db.set_value("Sales Person", sales_person, "enabled", 0) -@frappe.whitelist() +@frappe.whitelist(methods=["POST"]) def create_user(employee: str, email: str | None = None, create_user_permission: int = 0) -> str: emp = frappe.get_doc("Employee", employee) emp.check_permission("write") diff --git a/erpnext/setup/doctype/global_defaults/global_defaults.js b/erpnext/setup/doctype/global_defaults/global_defaults.js index 20ac6dafde5..7a20b01daf2 100644 --- a/erpnext/setup/doctype/global_defaults/global_defaults.js +++ b/erpnext/setup/doctype/global_defaults/global_defaults.js @@ -17,7 +17,7 @@ frappe.ui.form.on("Global Defaults", { method: "frappe.client.get_list", args: { doctype: "UOM Conversion Factor", - filters: { category: __("Length") }, + filters: { category: "Length" }, fields: ["to_uom"], limit_page_length: 500, }, 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 = [] diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py index e0dcdf996a7..b4733fb36cf 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -46,7 +46,9 @@ class ItemGroup(NestedSet): frappe.throw( _("{0} entered twice {1} in Item Taxes").format( frappe.bold(d.item_tax_template), - f"for tax category {frappe.bold(d.tax_category)}" if d.tax_category else "", + _("for tax category {0}").format(frappe.bold(d.tax_category)) + if d.tax_category + else "", ) ) else: diff --git a/erpnext/setup/doctype/item_group/test_item_group.py b/erpnext/setup/doctype/item_group/test_item_group.py index a6e3e946806..a37ab55d508 100644 --- a/erpnext/setup/doctype/item_group/test_item_group.py +++ b/erpnext/setup/doctype/item_group/test_item_group.py @@ -2,6 +2,7 @@ # License: GNU General Public License v3. See license.txt import frappe +from frappe.query_builder.functions import Max from frappe.utils.nestedset import ( NestedSetChildExistsError, NestedSetInvalidMergeError, @@ -20,7 +21,8 @@ class TestItemGroup(ERPNextTestSuite): def test_basic_tree(self, records=None): min_lft = 1 - max_rgt = frappe.db.sql("select max(rgt) from `tabItem Group`")[0][0] + ig = frappe.qb.DocType("Item Group") + max_rgt = frappe.qb.from_(ig).select(Max(ig.rgt)).run()[0][0] if not records: records = self.globalTestRecords["Item Group"][2:] @@ -131,12 +133,7 @@ class TestItemGroup(ERPNextTestSuite): frappe.db.get_value("Item Group", parent_item_group, "rgt") ancestors = get_ancestors_of("Item Group", "_Test Item Group B - 3") - ancestors = frappe.db.sql( - """select name, rgt from `tabItem Group` - where name in ({})""".format(", ".join(["%s"] * len(ancestors))), - tuple(ancestors), - as_dict=True, - ) + ancestors = frappe.get_all("Item Group", filters={"name": ["in", ancestors]}, fields=["name", "rgt"]) frappe.delete_doc("Item Group", "_Test Item Group B - 3") records_to_test = self.globalTestRecords["Item Group"][2:] @@ -168,9 +165,8 @@ class TestItemGroup(ERPNextTestSuite): self.test_basic_tree() # move its children back - for name in frappe.db.sql_list( - """select name from `tabItem Group` - where parent_item_group='_Test Item Group C'""" + for name in frappe.get_all( + "Item Group", filters={"parent_item_group": "_Test Item Group C"}, pluck="name" ): doc = frappe.get_doc("Item Group", name) doc.parent_item_group = "_Test Item Group B" @@ -218,11 +214,7 @@ class TestItemGroup(ERPNextTestSuite): def get_no_of_children(item_groups, no_of_children): children = [] for ig in item_groups: - children += frappe.db.sql_list( - """select name from `tabItem Group` - where ifnull(parent_item_group, '')=%s""", - ig or "", - ) + children += frappe.get_all("Item Group", filters={"parent_item_group": ig}, pluck="name") if len(children): return get_no_of_children(children, no_of_children + len(children)) diff --git a/erpnext/setup/doctype/sales_person/sales_person.json b/erpnext/setup/doctype/sales_person/sales_person.json index 57572c6d7d5..1486904e841 100644 --- a/erpnext/setup/doctype/sales_person/sales_person.json +++ b/erpnext/setup/doctype/sales_person/sales_person.json @@ -54,7 +54,7 @@ }, { "fieldname": "commission_rate", - "fieldtype": "Data", + "fieldtype": "Percent", "label": "Commission Rate", "print_hide": 1 }, @@ -145,7 +145,7 @@ "idx": 1, "is_tree": 1, "links": [], - "modified": "2024-03-27 13:10:37.891377", + "modified": "2026-06-21 12:52:33.742603", "modified_by": "Administrator", "module": "Setup", "name": "Sales Person", @@ -184,4 +184,4 @@ "sort_field": "creation", "sort_order": "ASC", "states": [] -} \ No newline at end of file +} 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) 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..82ef3bb766b 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 @@ -635,7 +646,7 @@ class TransactionDeletionRecord(Document): def validate_doc_status(self): if self.status != "Running": frappe.throw( - _("{0} is not running. Cannot trigger events for this Document").format( + _("{0} is not running. Cannot trigger events for this document").format( get_link_to_form("Transaction Deletion Record", self.name) ) ) @@ -691,8 +702,6 @@ class TransactionDeletionRecord(Document): "Dynamic Link", filters={"link_name": ("in", leads)}, pluck="parent" ) if addresses: - addresses = ["%s" % frappe.db.escape(addr) for addr in addresses] - address = qb.DocType("Address") dl1 = qb.DocType("Dynamic Link") dl2 = qb.DocType("Dynamic Link") diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index a9604a53656..48dea538517 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -21,6 +21,8 @@ def after_install(): if not frappe.db.exists("Role", "Analytics"): frappe.get_doc({"doctype": "Role", "role_name": "Analytics"}).insert() + create_shop_floor_roles() + set_single_defaults() setup_repost_defaults() create_print_setting_custom_fields() @@ -50,6 +52,15 @@ def make_default_operations(): doc.insert(ignore_permissions=True) +def create_shop_floor_roles(): + """Roles that drive the Shop Floor page's two experiences (manager board vs operator view).""" + for role_name in ("Shop Floor Manager", "Shop Floor User"): + if not frappe.db.exists("Role", role_name): + frappe.get_doc({"doctype": "Role", "role_name": role_name, "desk_access": 1}).insert( + ignore_permissions=True + ) + + def set_single_defaults(): for dt in ( "Accounts Settings", diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index 806db071963..1d1edeaf949 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -567,6 +567,7 @@ def create_bank_account(args, demo=False): } ) try: + frappe.db.savepoint("create_bank_account") doc = bank_account.insert() if args.get("set_default"): @@ -583,6 +584,7 @@ def create_bank_account(args, demo=False): except RootNotEditable: frappe.throw(frappe._("Bank account cannot be named as {0}").format(args.get("bank_account"))) except frappe.DuplicateEntryError: + frappe.db.rollback(save_point="create_bank_account") # preserve transaction in postgres # bank account same as a CoA entry pass diff --git a/erpnext/setup/setup_wizard/operations/taxes_setup.py b/erpnext/setup/setup_wizard/operations/taxes_setup.py index 3cc405aa658..d3b7e2a03fd 100644 --- a/erpnext/setup/setup_wizard/operations/taxes_setup.py +++ b/erpnext/setup/setup_wizard/operations/taxes_setup.py @@ -11,7 +11,7 @@ from frappe import _ def setup_taxes_and_charges(company_name: str, country: str): if not frappe.db.exists("Company", company_name): - frappe.throw(_("Company {} does not exist yet. Taxes setup aborted.").format(company_name)) + frappe.throw(_("Company {0} does not exist yet. Taxes setup aborted.").format(company_name)) file_path = os.path.join(os.path.dirname(__file__), "..", "data", "country_wise_tax.json") with open(file_path) as json_file: @@ -120,6 +120,7 @@ def from_detailed_data(company_name, data): def update_regional_tax_settings(country, company): path = frappe.get_app_path("erpnext", "regional", frappe.scrub(country)) if os.path.exists(path.encode("utf-8")): + frappe.db.savepoint("regional_tax_settings") try: module_name = f"erpnext.regional.{frappe.scrub(country)}.setup.update_regional_tax_settings" frappe.get_attr(module_name)(country, company) @@ -127,6 +128,7 @@ def update_regional_tax_settings(country, company): pass except Exception: # Log error and ignore if failed to setup regional tax settings + frappe.db.rollback(save_point="regional_tax_settings") frappe.log_error("Unable to setup regional tax settings") diff --git a/erpnext/setup/setup_wizard/setup_wizard.py b/erpnext/setup/setup_wizard/setup_wizard.py index 20330d89631..0f43fe9a860 100644 --- a/erpnext/setup/setup_wizard/setup_wizard.py +++ b/erpnext/setup/setup_wizard/setup_wizard.py @@ -4,12 +4,13 @@ import frappe from frappe import _ +from frappe.utils.telemetry import capture from erpnext.setup.demo import setup_demo_data from erpnext.setup.setup_wizard.operations import install_fixtures as fixtures -def get_setup_stages(args=None): +def get_setup_stages(args=None): # nosemgrep stages = [ { "status": _("Installing presets"), @@ -28,6 +29,13 @@ def get_setup_stages(args=None): {"fn": setup_defaults, "args": args, "fail_msg": _("Failed to setup defaults")}, ], }, + { + "status": _("Personalizing your setup"), + "fail_msg": _("Failed to personalize your setup"), + "tasks": [ + {"fn": capture_user_persona, "args": args, "fail_msg": _("Failed to personalize your setup")} + ], + }, ] if args.get("setup_demo"): @@ -42,15 +50,38 @@ def get_setup_stages(args=None): return stages -def stage_fixtures(args): +def capture_user_persona(args): # nosemgrep + """Send the persona answers captured on the setup slide to telemetry.""" + if not args: + return + + capture( + "user_persona_submitted", + "erpnext", + properties={ + "implementing_for": args.get("persona_implementing_for"), + "company_size": args.get("persona_company_size"), + "industry": args.get("persona_industry"), + "current_system": args.get("persona_current_system"), + "module_accounting": bool(args.get("module_accounting")), + "module_stock": bool(args.get("module_stock")), + "module_manufacturing": bool(args.get("module_manufacturing")), + "module_projects": bool(args.get("module_projects")), + "country": args.get("country"), + "language": args.get("language"), + }, + ) + + +def stage_fixtures(args): # nosemgrep fixtures.install(args.get("country")) -def setup_company(args): +def setup_company(args): # nosemgrep fixtures.install_company(args) -def setup_defaults(args): +def setup_defaults(args): # nosemgrep fixtures.install_defaults(frappe._dict(args)) @@ -59,7 +90,7 @@ def setup_demo(args): # nosemgrep # Only for programmatical use -def setup_complete(args=None): +def setup_complete(args=None): # nosemgrep stage_fixtures(args) setup_company(args) setup_defaults(args) diff --git a/erpnext/setup/utils.py b/erpnext/setup/utils.py index 2fbddcec948..3e0decd8f16 100644 --- a/erpnext/setup/utils.py +++ b/erpnext/setup/utils.py @@ -95,7 +95,11 @@ def get_exchange_rate( # cksgb 19/09/2016: get last entry in Currency Exchange with from_currency and to_currency. entries = frappe.get_all( - "Currency Exchange", fields=["exchange_rate"], filters=filters, order_by="date desc", limit=1 + "Currency Exchange", + fields=["exchange_rate"], + filters=filters, + order_by="date desc, name desc", + limit=1, ) if entries: return flt(entries[0].exchange_rate) diff --git a/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json b/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json index 745d0ad4d24..57e558c0e7d 100644 --- a/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json +++ b/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json @@ -8,7 +8,7 @@ "doctype": "Workspace", "for_user": "", "hide_custom": 0, - "icon": "setting", + "icon": "sliders-horizontal", "idx": 0, "is_hidden": 0, "label": "ERPNext Settings", @@ -69,7 +69,7 @@ "type": "Link" } ], - "modified": "2026-01-09 13:05:08.007297", + "modified": "2026-07-03 13:43:50.429297", "modified_by": "Administrator", "module": "Setup", "name": "ERPNext Settings", @@ -97,7 +97,7 @@ "type": "DocType" }, { - "icon": "accounting", + "icon": "wallet", "label": "Accounts Settings", "link_to": "Accounts Settings", "type": "DocType" @@ -110,24 +110,254 @@ "type": "DocType" }, { - "icon": "stock", + "icon": "package", "label": "Stock Settings", "link_to": "Stock Settings", "type": "DocType" }, { - "icon": "sell", + "icon": "store", "label": "Selling Settings", "link_to": "Selling Settings", "type": "DocType" }, { - "icon": "buying", + "icon": "shopping-cart", "label": "Buying Settings", "link_to": "Buying Settings", "type": "DocType" } ], + "sidebar_items": [ + { + "child": 0, + "collapsible": 1, + "icon": "earth", + "indent": 0, + "keep_closed": 0, + "label": "Global Defaults", + "link_to": "Global Defaults", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "washing-machine", + "indent": 0, + "keep_closed": 0, + "label": "System Settings", + "link_to": "System Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "wallet", + "indent": 0, + "keep_closed": 0, + "label": "Accounts Settings", + "link_to": "Accounts Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "computer", + "indent": 0, + "keep_closed": 0, + "label": "POS Settings", + "link_to": "POS Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "store", + "indent": 0, + "keep_closed": 0, + "label": "Selling Settings", + "link_to": "Selling Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "shopping-cart", + "indent": 0, + "keep_closed": 0, + "label": "Buying Settings", + "link_to": "Buying Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "package", + "indent": 0, + "keep_closed": 0, + "label": "Stock Settings", + "link_to": "Stock Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "building-2", + "indent": 0, + "keep_closed": 0, + "label": "Manufacturing Settings", + "link_to": "Manufacturing Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "folder-kanban", + "indent": 0, + "keep_closed": 0, + "label": "Projects Settings", + "link_to": "Projects Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "handshake", + "indent": 0, + "keep_closed": 0, + "label": "CRM Settings", + "link_to": "CRM Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "headset", + "indent": 0, + "keep_closed": 0, + "label": "Support Settings", + "link_to": "Support Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "icon": "rocket", + "indent": 1, + "keep_closed": 1, + "label": "Other Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Section Break" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Subscription Settings", + "link_to": "Subscription Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Item Variant Settings", + "link_to": "Item Variant Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Delivery Settings", + "link_to": "Delivery Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Currency Exchange Settings", + "link_to": "Currency Exchange Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Appointment Booking Settings", + "link_to": "Appointment Booking Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 1, + "collapsible": 1, + "indent": 0, + "keep_closed": 0, + "label": "Stock Reposting Settings", + "link_to": "Stock Reposting Settings", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + } + ], + "standard": 1, "title": "ERPNext Settings", "type": "Workspace" } diff --git a/erpnext/setup/workspace/home/home.json b/erpnext/setup/workspace/home/home.json index 8c011f32d29..076c0383ffb 100644 --- a/erpnext/setup/workspace/home/home.json +++ b/erpnext/setup/workspace/home/home.json @@ -1,18 +1,26 @@ { "app": "erpnext", "charts": [], - "content": "[{\"id\":\"aCk49ShVRs\",\"type\":\"onboarding\",\"data\":{\"onboarding_name\":\"Home\",\"col\":12}},{\"id\":\"kb3XPLg8lb\",\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"id\":\"nWd2KJPW8l\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Item\",\"col\":3}},{\"id\":\"snrzfbFr5Y\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customer\",\"col\":3}},{\"id\":\"SHJKakmLLf\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Supplier\",\"col\":3}},{\"id\":\"CPxEyhaf3G\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Sales Invoice\",\"col\":3}},{\"id\":\"WU4F-HUcIQ\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Leaderboard\",\"col\":3}},{\"id\":\"d_KVM1gsf9\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"JVu8-FJZCu\",\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"id\":\"JiuSi0ubOg\",\"type\":\"card\",\"data\":{\"card_name\":\"Accounting\",\"col\":4}},{\"id\":\"ji2Jlm3Q8i\",\"type\":\"card\",\"data\":{\"card_name\":\"Stock\",\"col\":4}},{\"id\":\"N61oiXpuwK\",\"type\":\"card\",\"data\":{\"card_name\":\"CRM\",\"col\":4}},{\"id\":\"6J0CVl1mPo\",\"type\":\"card\",\"data\":{\"card_name\":\"Data Import and Settings\",\"col\":4}}]", + "content": "[{\"id\":\"kb3XPLg8lb\",\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"id\":\"nWd2KJPW8l\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Item\",\"col\":3}},{\"id\":\"snrzfbFr5Y\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customer\",\"col\":3}},{\"id\":\"SHJKakmLLf\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Supplier\",\"col\":3}},{\"id\":\"CPxEyhaf3G\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Sales Invoice\",\"col\":3}},{\"id\":\"d_KVM1gsf9\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"JVu8-FJZCu\",\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"id\":\"JiuSi0ubOg\",\"type\":\"card\",\"data\":{\"card_name\":\"Accounting\",\"col\":4}},{\"id\":\"ji2Jlm3Q8i\",\"type\":\"card\",\"data\":{\"card_name\":\"Stock\",\"col\":4}},{\"id\":\"N61oiXpuwK\",\"type\":\"card\",\"data\":{\"card_name\":\"CRM\",\"col\":4}},{\"id\":\"6J0CVl1mPo\",\"type\":\"card\",\"data\":{\"card_name\":\"Data Import and Settings\",\"col\":4}}]", "creation": "2020-01-23 13:46:38.833076", "custom_blocks": [], "docstatus": 0, "doctype": "Workspace", "for_user": "", "hide_custom": 0, - "icon": "home", + "icon": "house", "idx": 0, "is_hidden": 0, "label": "Home", "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Accounting", + "link_count": 0, + "onboard": 0, + "type": "Card Break" + }, { "hidden": 0, "is_query_report": 0, @@ -32,6 +40,28 @@ "onboard": 1, "type": "Link" }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Chart of Accounts", + "link_count": 0, + "link_to": "Account", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Company", + "link_count": 0, + "link_to": "Company", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, { "dependencies": "", "hidden": 0, @@ -54,6 +84,28 @@ "onboard": 1, "type": "Link" }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Customer", + "link_count": 0, + "link_to": "Customer", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Supplier", + "link_count": 0, + "link_to": "Supplier", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, { "dependencies": "", "hidden": 0, @@ -73,6 +125,14 @@ "onboard": 0, "type": "Card Break" }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Stock", + "link_count": 0, + "onboard": 0, + "type": "Card Break" + }, { "dependencies": "", "hidden": 0, @@ -84,6 +144,28 @@ "onboard": 1, "type": "Link" }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Item", + "link_count": 0, + "link_to": "Item", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Warehouse", + "link_count": 0, + "link_to": "Warehouse", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, { "dependencies": "", "hidden": 0, @@ -106,6 +188,17 @@ "onboard": 1, "type": "Link" }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Brand", + "link_count": 0, + "link_to": "Brand", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, { "dependencies": "", "hidden": 0, @@ -117,6 +210,28 @@ "onboard": 1, "type": "Link" }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Unit of Measure (UOM)", + "link_count": 0, + "link_to": "UOM", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Stock Reconciliation", + "link_count": 0, + "link_to": "Stock Reconciliation", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, { "dependencies": "", "hidden": 0, @@ -136,6 +251,25 @@ "onboard": 0, "type": "Card Break" }, + { + "hidden": 0, + "is_query_report": 0, + "label": "CRM", + "link_count": 0, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Lead", + "link_count": 0, + "link_to": "Lead", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, { "dependencies": "", "hidden": 0, @@ -158,6 +292,28 @@ "onboard": 1, "type": "Link" }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Customer Group", + "link_count": 0, + "link_to": "Customer Group", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Territory", + "link_count": 0, + "link_to": "Territory", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, { "dependencies": "", "hidden": 0, @@ -177,6 +333,14 @@ "onboard": 0, "type": "Card Break" }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Data Import and Settings", + "link_count": 0, + "onboard": 0, + "type": "Card Break" + }, { "dependencies": "", "hidden": 0, @@ -188,6 +352,28 @@ "onboard": 1, "type": "Link" }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Import Data", + "link_count": 0, + "link_to": "Data Import", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Opening Invoice Creation Tool", + "link_count": 0, + "link_to": "Opening Invoice Creation Tool", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, { "dependencies": "", "hidden": 0, @@ -210,6 +396,28 @@ "onboard": 1, "type": "Link" }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Chart of Accounts Importer", + "link_count": 0, + "link_to": "Chart of Accounts Importer", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Letter Head", + "link_count": 0, + "link_to": "Letter Head", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, { "dependencies": "", "hidden": 0, @@ -221,6 +429,17 @@ "onboard": 1, "type": "Link" }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Email Account", + "link_count": 0, + "link_to": "Email Account", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, { "dependencies": "", "hidden": 0, @@ -233,7 +452,7 @@ "type": "Link" } ], - "modified": "2025-07-02 14:12:28.407612", + "modified": "2026-07-03 14:22:16.927245", "modified_by": "Administrator", "module": "Setup", "name": "Home", @@ -267,6 +486,74 @@ "type": "DocType" } ], + "sidebar_items": [ + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Item", + "link_to": "Item", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Home", + "link_to": "Home", + "link_type": "Workspace", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Customer", + "link_to": "Customer", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Supplier", + "link_to": "Supplier", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "indent": 0, + "keep_closed": 0, + "label": "Sales Invoice", + "link_to": "Sales Invoice", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + } + ], + "standard": 1, "title": "Home", "type": "Workspace" } diff --git a/erpnext/setup/workspace/organization/organization.json b/erpnext/setup/workspace/organization/organization.json new file mode 100644 index 00000000000..45ca544db31 --- /dev/null +++ b/erpnext/setup/workspace/organization/organization.json @@ -0,0 +1,204 @@ +{ + "allowed_users": [ + { + "user": "Administrator" + }, + { + "user": "Guest" + }, + { + "user": "accounts@test.com" + }, + { + "user": "ankush@erpnext.com" + }, + { + "user": "faris@erpnext.com" + }, + { + "user": "mention_test_user@example.com" + }, + { + "user": "project@frappe.io" + }, + { + "user": "rushabh@erpnext.com" + }, + { + "user": "saqib@erpnext.com" + }, + { + "user": "soham@frappe.io" + }, + { + "user": "sohamengineer123@gmail.com" + }, + { + "user": "sohamkulkarns9@gmail.com" + }, + { + "user": "sydel@frappe.io" + }, + { + "user": "test'5@example.com" + }, + { + "user": "test1@example.com" + }, + { + "user": "test2@example.com" + }, + { + "user": "test3@example.com" + }, + { + "user": "test4@example.com" + }, + { + "user": "test@example.com" + }, + { + "user": "test@portal.com" + }, + { + "user": "testpassword@example.com" + }, + { + "user": "testperm@example.com" + }, + { + "user": "web@web.com" + } + ], + "app": "erpnext", + "charts": [], + "content": "[]", + "creation": "2026-06-11 11:51:21.789012", + "custom_blocks": [], + "docstatus": 0, + "doctype": "Workspace", + "for_user": "", + "hide_custom": 0, + "icon": "building-2", + "idx": 0, + "indicator_color": "green", + "is_hidden": 0, + "label": "Organization", + "link_type": "DocType", + "links": [], + "modified": "2026-07-03 00:45:57.595188", + "modified_by": "Administrator", + "module": "Setup", + "module_onboarding": "Organization Onboarding", + "name": "Organization", + "number_cards": [], + "owner": "Administrator", + "public": 1, + "quick_lists": [], + "roles": [], + "sequence_id": 46.0, + "shortcuts": [], + "sidebar_items": [ + { + "child": 0, + "collapsible": 1, + "default_workspace": 1, + "icon": "building-2", + "indent": 0, + "keep_closed": 0, + "label": "Company", + "link_to": "Company", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "icon": "book-text", + "indent": 0, + "keep_closed": 0, + "label": "Letter Head", + "link_to": "Letter Head", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "icon": "file-user", + "indent": 0, + "keep_closed": 0, + "label": "Department", + "link_to": "Department", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "icon": "book-user", + "indent": 0, + "keep_closed": 0, + "label": "Branch", + "link_to": "Branch", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "icon": "users", + "indent": 0, + "keep_closed": 0, + "label": "User", + "link_to": "User", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "icon": "user-round-check", + "indent": 0, + "keep_closed": 0, + "label": "Role Permissions", + "link_to": "permission-manager", + "link_type": "Page", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "child": 0, + "collapsible": 1, + "default_workspace": 0, + "icon": "mail", + "indent": 0, + "keep_closed": 0, + "label": "Email Account", + "link_to": "Email Account", + "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + } + ], + "standard": 1, + "title": "Organization", + "type": "Workspace" +} diff --git a/erpnext/stock/dashboard/item_dashboard.py b/erpnext/stock/dashboard/item_dashboard.py index 2acf8e3bbf3..9f628f8152f 100644 --- a/erpnext/stock/dashboard/item_dashboard.py +++ b/erpnext/stock/dashboard/item_dashboard.py @@ -17,6 +17,9 @@ def get_data( sort_order: str = "desc", ): """Return data to render the item dashboard""" + if not frappe.has_permission("Bin", "read"): + return [] + filters = [] if item_code: filters.append(["item_code", "=", item_code]) @@ -44,7 +47,10 @@ def get_data( if build_match_conditions("Warehouse", user=frappe.session.user): filters.append(["warehouse", "in", [w.name for w in frappe.get_list("Warehouse")]]) except frappe.PermissionError: - # user does not have access on warehouse + # user does not have access on warehouse; build_match_conditions already queued a + # "Not permitted" message via frappe.throw before this was caught, drop it so the + # client doesn't show a spurious error for a request that's failing gracefully here + frappe.clear_last_message() return [] items = frappe.db.get_all( diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index b2010411644..9e097099f01 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -144,12 +144,8 @@ class DeprecatedBatchNoValuation: if self.sle.name: conditions &= sle.name != self.sle.name - # Lock the scanned SLE rows so a concurrent stock posting can't change them mid-valuation. - # MariaDB carries the lock on the grouped query; postgres rejects FOR UPDATE with GROUP BY, so - # lock the same rows in a separate plain SELECT first (held for the transaction). - if frappe.db.db_type == "postgres": - frappe.qb.from_(sle).select(sle.name).where(conditions).for_update().run() - + # MariaDB carries a row lock on the grouped query below; on postgres the caller + # (calculate_avg_rate) serializes via a txn-scoped advisory lock on (item, warehouse). query = ( frappe.qb.from_(sle) .select( @@ -269,13 +265,8 @@ class DeprecatedBatchNoValuation: if self.sle.name: conditions &= sle.name != self.sle.name - # Lock the scanned SLE rows so a concurrent stock posting can't change them mid-valuation. - # MariaDB carries the lock on the grouped query; postgres rejects FOR UPDATE with GROUP BY, so - # lock the same SLE rows in a separate plain SELECT first. The batch.use_batchwise_valuation - # refinement below only narrows the set, so locking without the join is a safe superset. - if frappe.db.db_type == "postgres": - frappe.qb.from_(sle).select(sle.name).where(conditions).for_update().run() - + # MariaDB carries a row lock on the grouped query below; on postgres the caller + # (calculate_avg_rate) serializes via a txn-scoped advisory lock on (item, warehouse). query = ( frappe.qb.from_(sle) .inner_join(batch) @@ -402,21 +393,8 @@ class DeprecatedBatchNoValuation: conditions &= bundle.name != self.sle.serial_and_batch_bundle conditions &= bundle.voucher_type != "Pick List" - # Lock the scanned bundle rows so a concurrent stock posting can't change them mid-valuation. - # MariaDB carries the lock on the grouped query; postgres rejects FOR UPDATE with GROUP BY, so - # lock the same rows in a separate plain SELECT first (the batch.use_batchwise_valuation - # refinement below only narrows the set, so omitting that join is a safe superset). - if frappe.db.db_type == "postgres": - ( - frappe.qb.from_(bundle) - .inner_join(bundle_child) - .on(bundle.name == bundle_child.parent) - .select(bundle_child.name) - .where(conditions) - .for_update() - .run() - ) - + # MariaDB carries a row lock on the grouped query below; on postgres the caller + # (calculate_avg_rate) serializes via a txn-scoped advisory lock on (item, warehouse). query = ( frappe.qb.from_(bundle) .inner_join(bundle_child) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 57b133bb4cc..4d20b25b0f7 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -301,7 +301,7 @@ def get_batches_by_oldest(item_code: str, warehouse: str): return batches_dates -@frappe.whitelist() +@frappe.whitelist(methods=["POST"]) def split_batch(batch_no: str, item_code: str, warehouse: str, qty: float, new_batch_id: str | None = None): """Split the batch into a new batch""" batch = frappe.get_doc(doctype="Batch", item=item_code, batch_id=new_batch_id).insert() @@ -390,7 +390,7 @@ def validate_serial_no_with_batch(serial_nos, item_code): serial_no_link = ",".join(get_link_to_form("Serial No", sn) for sn in serial_nos) - message = "Serial Nos" if len(serial_nos) > 1 else "Serial No" + message = _("Serial Nos") if len(serial_nos) > 1 else _("Serial No") frappe.throw(_("There is no batch found against the {0}: {1}").format(message, serial_no_link)) @@ -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") diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index e2062c4e6a9..cc7b55031c1 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -19,7 +19,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle get_batch_from_bundle, ) from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry -from erpnext.stock.get_item_details import ItemDetailsCtx, get_item_details +from erpnext.stock.get_item_details import get_item_details from erpnext.stock.serial_batch_bundle import SerialBatchCreation from erpnext.tests.utils import ERPNextTestSuite @@ -386,6 +386,52 @@ class TestBatch(ERPNextTestSuite): self.assertEqual(get_batch_qty("batch a", "_Test Warehouse - _TC"), 90) + def test_get_batch_no_search_returns_batches(self): + """The batch-number picker must run on every engine. + + Both query builders group by Stock Ledger Entry / Serial-and-Batch-Entry + columns while selecting un-aggregated Batch-master columns; PostgreSQL only + accepts that when the Batch primary key is in the GROUP BY, so the picker + errors there without it. + """ + from erpnext.controllers.queries import ( + get_batch_no, + get_batches_from_serial_and_batch_bundle, + get_batches_from_stock_ledger_entries, + ) + + self.make_batch_item("ITEM-BATCH-PICKER") + self.make_new_batch_and_entry("ITEM-BATCH-PICKER", "batch picker a", "_Test Warehouse - _TC") + self.make_new_batch_and_entry("ITEM-BATCH-PICKER", "batch picker b", "_Test Warehouse - _TC") + + searchfields = frappe.get_meta("Batch").get_search_fields() + filters = {"item_code": "ITEM-BATCH-PICKER", "warehouse": "_Test Warehouse - _TC"} + + # Exercise both query builders directly so each GROUP BY is covered regardless + # of which path holds the data: PostgreSQL validates the GROUP BY even when no + # rows match, so a missing Batch primary key raises GroupingError here. + get_batches_from_stock_ledger_entries(searchfields, "", filters) + get_batches_from_serial_and_batch_bundle(searchfields, "", filters) + + result = get_batch_no( + doctype="Batch", + txt="", + searchfield="name", + start=0, + page_len=20, + filters=filters, + ) + returned = {row[0] for row in result} + self.assertIn("batch picker a", returned) + self.assertIn("batch picker b", returned) + + # These batches have no manufacturing/expiry date. MariaDB CONCAT('MFG-', NULL) + # is NULL, but Postgres CONCAT drops the NULL and would surface a bare "MFG-"/ + # "EXP-"; the null-guarded select must keep both engines free of that artifact. + flat = [value for row in result for value in row] + self.assertNotIn("MFG-", flat) + self.assertNotIn("EXP-", flat) + def test_ignore_reserved_qty(self): from erpnext.selling.doctype.sales_order.mapper import create_pick_list from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order @@ -549,7 +595,7 @@ class TestBatch(ERPNextTestSuite): company = "_Test Company with perpetual inventory" currency = frappe.get_cached_value("Company", company, "default_currency") - ctx = ItemDetailsCtx( + ctx = frappe._dict( { "item_code": "_Test Batch Price Item", "company": company, diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index b7393b49cba..2b3c40b22ca 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -277,19 +277,27 @@ def update_qty(bin_name, args): - flt(bin_details.reserved_qty_for_production_plan) ) - frappe.db.set_value( - "Bin", - bin_name, - { - "actual_qty": actual_qty, - "ordered_qty": ordered_qty, - "reserved_qty": reserved_qty, - "indented_qty": indented_qty, - "planned_qty": planned_qty, - "projected_qty": projected_qty, - }, - update_modified=True, - ) + bin_values = { + "actual_qty": actual_qty, + "ordered_qty": ordered_qty, + "reserved_qty": reserved_qty, + "indented_qty": indented_qty, + "planned_qty": planned_qty, + "projected_qty": projected_qty, + } + + # Standard Cost items are not reposted on backdated entries, so the Bin's stock value is not + # refreshed by a repost. Keep it in step with the balance at the standard rate. + from erpnext.stock.utils import get_valuation_method + + if get_valuation_method(args.get("item_code")) == "Standard Cost": + from erpnext.stock.doctype.item_standard_cost.item_standard_cost import get_item_standard_rate + + bin_values["stock_value"] = flt(actual_qty) * flt( + get_item_standard_rate(args.get("item_code"), args.get("company")) + ) + + frappe.db.set_value("Bin", bin_name, bin_values, update_modified=True) def get_actual_qty(item_code, warehouse): diff --git a/erpnext/stock/doctype/bin/test_bin.py b/erpnext/stock/doctype/bin/test_bin.py index e66e453aca1..81b60d6ce19 100644 --- a/erpnext/stock/doctype/bin/test_bin.py +++ b/erpnext/stock/doctype/bin/test_bin.py @@ -19,8 +19,10 @@ class TestBin(ERPNextTestSuite): bin1.insert() bin2 = frappe.get_doc(doctype="Bin", item_code=item_code, warehouse=warehouse) + frappe.db.savepoint("dup_bin") with self.assertRaises(frappe.UniqueValidationError): bin2.insert() + frappe.db.rollback(save_point="dup_bin") # preserve transaction in postgres # util method should handle it bin = _create_bin(item_code, warehouse) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index 6cb8e707449..5e0fc9bfaf2 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -1154,11 +1154,13 @@ "width": "50%" }, { + "depends_on": "eval:doc.sales_partner", "fetch_from": "sales_partner.commission_rate", "fetch_if_empty": 1, "fieldname": "commission_rate", "fieldtype": "Float", "label": "Commission Rate (%)", + "no_copy": 1, "oldfieldname": "commission_rate", "oldfieldtype": "Currency", "print_hide": 1, @@ -1166,9 +1168,11 @@ "width": "100px" }, { + "depends_on": "eval:doc.sales_partner", "fieldname": "total_commission", "fieldtype": "Currency", "label": "Total Commission", + "no_copy": 1, "oldfieldname": "total_commission", "oldfieldtype": "Currency", "options": "Company:company:default_currency", @@ -1262,9 +1266,11 @@ "read_only": 1 }, { + "depends_on": "eval:doc.sales_partner", "fieldname": "amount_eligible_for_commission", "fieldtype": "Currency", "label": "Amount Eligible for Commission", + "no_copy": 1, "options": "Company:company:default_currency", "read_only": 1 }, @@ -1466,7 +1472,7 @@ "idx": 146, "is_submittable": 1, "links": [], - "modified": "2026-05-28 11:44:37.286743", + "modified": "2026-06-21 12:46:13.250145", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 7f524c82912..a3a1884cae2 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -426,6 +426,7 @@ class DeliveryNote(SellingController): "stock_delivered_but_not_billed", "disable_sdbnb_in_sr", "default_expense_account", + "enable_stock_delivered_but_not_billed", ], as_dict=True, ) @@ -433,7 +434,7 @@ class DeliveryNote(SellingController): sdbnb_account = company_values.stock_delivered_but_not_billed disable_sdbnb_in_sr = company_values.disable_sdbnb_in_sr default_expense_account = company_values.default_expense_account - + is_enabled_sdbnb = company_values.enable_stock_delivered_but_not_billed for item in self.items: if item.get("against_sales_invoice"): if sdbnb_account and item.expense_account == sdbnb_account: @@ -447,14 +448,16 @@ class DeliveryNote(SellingController): # Only stock items if is_stock_item and not item.get("is_fixed_asset") and not item.get("is_subcontracted"): # Sales Return handling - if self.is_return and disable_sdbnb_in_sr: + if self.is_return and disable_sdbnb_in_sr and sdbnb_account and is_enabled_sdbnb: if default_expense_account and ( not item.expense_account or item.expense_account == sdbnb_account ): item.expense_account = default_expense_account - elif sdbnb_account: + elif sdbnb_account and is_enabled_sdbnb: item.expense_account = sdbnb_account + elif sdbnb_account and item.expense_account == sdbnb_account: + item.expense_account = default_expense_account if not item.expense_account and default_expense_account: item.expense_account = default_expense_account diff --git a/erpnext/stock/doctype/delivery_note/mapper.py b/erpnext/stock/doctype/delivery_note/mapper.py index a77a94dcf88..8f8589601df 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) @@ -80,7 +79,7 @@ def make_sales_invoice( target.run_method("set_po_nos") if len(target.get("items")) == 0: - frappe.throw(_("All these items have already been Invoiced/Returned")) + frappe.throw(_("All these items have already been invoiced/returned")) if args and args.get("merge_taxes"): merge_taxes(source, target) @@ -132,7 +131,8 @@ def make_sales_invoice( { "Delivery Note": { "doctype": "Sales Invoice", - "field_map": {"is_return": "is_return"}, + # commission_rate is no_copy (so it isn't carried on Duplicate), map it explicitly here + "field_map": {"is_return": "is_return", "commission_rate": "commission_rate"}, "validation": {"docstatus": ["=", 1]}, }, "Delivery Note Item": { @@ -171,7 +171,12 @@ def make_sales_invoice( frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") ) - if not doc.is_return: + if doc.is_return: + # A credit note made from a return Delivery Note should roll back the billed + # amount on the linked Sales Order too, so that per_billed stays consistent with + # per_delivered (which the return already reset). + doc.update_billed_amount_in_sales_order = True + else: from erpnext.accounts.services.payment_schedule import PaymentScheduleService ps = PaymentScheduleService(doc) diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 0d30a693edb..c5db9cdcecb 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -50,7 +50,7 @@ class TestDeliveryNote(ERPNextTestSuite): self.load_test_records("Stock Entry") def get_perpetual_defaults(self): - company = frappe.get_doc("Company", "_Test Company with perpetual inventory") + company = frappe.get_doc("Company", "_Test SDBNB Company") self.perpetual_company = company.name self.perpetual_account = company.stock_delivered_but_not_billed self.perpetual_cost_center = company.cost_center @@ -2640,6 +2640,92 @@ class TestDeliveryNote(ERPNextTestSuite): self.assertEqual(dn.per_returned, 100) self.assertEqual(returned.status, "Return") + def _assert_credit_note_from_return_dn_resets_per_billed(self, so, dn): + """Given a fully billed Sales Order and a submitted Delivery Note that delivers it, + a credit note made from the return of that Delivery Note must reset per_billed to 0 + while leaving the delivery quantities exactly as the return already set them.""" + from erpnext.stock.doctype.delivery_note.mapper import make_sales_return + + so.load_from_db() + self.assertEqual(so.per_delivered, 100) + self.assertEqual(so.per_billed, 100) + + return_dn = make_sales_return(dn.name) + return_dn.insert() + return_dn.submit() + + # the return reverses the delivery quantities + so.load_from_db() + self.assertEqual(so.per_delivered, 0) + self.assertEqual(so.items[0].delivered_qty, 0) + + credit_note = make_sales_invoice(return_dn.name) + self.assertTrue(credit_note.is_return) + self.assertTrue(credit_note.update_billed_amount_in_sales_order) + # A Delivery Note-linked invoice can't update stock (validate_delivery_note), so the + # credit note only rolls back billing and never re-reverses the delivery quantities. + self.assertFalse(credit_note.update_stock) + credit_note.insert() + credit_note.submit() + + # per_billed is reset, and the delivery state stays exactly as the return left it + so.load_from_db() + self.assertEqual(so.per_billed, 0) + self.assertEqual(so.per_delivered, 0) + self.assertEqual(so.items[0].delivered_qty, 0) + self.assertEqual(so.items[0].returned_qty, 0) + + # Cancelling the credit note should restore the billed amount on the Sales Order. + credit_note.cancel() + so.load_from_db() + self.assertEqual(so.per_billed, 100) + + def test_sales_order_per_billed_after_credit_note_from_return_dn(self): + # Reported flow: SO -> SI (from SO) -> DN (from SI) -> return DN -> credit note. + # The DN carries si_detail in this path. + from erpnext.accounts.doctype.sales_invoice.mapper import make_delivery_note + from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice as make_si_from_so + + make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100) + + so = make_sales_order(qty=2) + + si = make_si_from_so(so.name) + si.insert() + si.submit() + + dn = make_delivery_note(si.name) + dn.insert() + dn.submit() + + self._assert_credit_note_from_return_dn_resets_per_billed(so, dn) + + def test_sales_order_per_billed_after_credit_note_from_so_derived_dn(self): + # SO billed and delivered separately (SO -> SI, SO -> DN), then return DN -> credit note. + # SO per_billed rolls back via the status_updater in update_prevdoc_status. + from erpnext.selling.doctype.sales_order.mapper import ( + make_delivery_note as make_dn_from_so, + ) + from erpnext.selling.doctype.sales_order.mapper import ( + make_sales_invoice as make_si_from_so, + ) + + make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100) + + so = make_sales_order(qty=2) + + si = make_si_from_so(so.name) + si.insert() + si.submit() + + dn = make_dn_from_so(so.name) + dn.insert() + dn.submit() + + self.assertIsNone(dn.items[0].si_detail) + + self._assert_credit_note_from_return_dn_resets_per_billed(so, dn) + def test_packed_item_serial_no_status(self): from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle from erpnext.stock.doctype.item.test_item import make_item diff --git a/erpnext/stock/doctype/delivery_trip/delivery_trip.js b/erpnext/stock/doctype/delivery_trip/delivery_trip.js index 9eb5b1f83c3..79b706656db 100755 --- a/erpnext/stock/doctype/delivery_trip/delivery_trip.js +++ b/erpnext/stock/doctype/delivery_trip/delivery_trip.js @@ -89,10 +89,10 @@ frappe.ui.form.on("Delivery Trip", { calculate_arrival_time: function (frm) { if (!frm.doc.driver_address) { - frappe.throw(__("Cannot Calculate Arrival Time as Driver Address is Missing.")); + frappe.throw(__("Cannot calculate arrival time as the driver address is missing.")); } frappe.show_alert({ - message: "Calculating Arrival Times", + message: __("Calculating arrival times"), indicator: "orange", }); frm.call( @@ -122,10 +122,10 @@ frappe.ui.form.on("Delivery Trip", { optimize_route: function (frm) { if (!frm.doc.driver_address) { - frappe.throw(__("Cannot Optimize Route as Driver Address is Missing.")); + frappe.throw(__("Cannot optimize route as the driver address is missing.")); } frappe.show_alert({ - message: "Optimizing Route", + message: __("Optimizing route"), indicator: "orange", }); frm.call( @@ -143,7 +143,7 @@ frappe.ui.form.on("Delivery Trip", { $.each(frm.doc.delivery_stops || [], function (i, delivery_stop) { if (!delivery_stop.delivery_note) { frappe.msgprint({ - message: __("No Delivery Note selected for Customer {}", [delivery_stop.customer]), + message: __("No Delivery Note selected for Customer {0}", [delivery_stop.customer]), title: __("Warning"), indicator: "orange", alert: 1, diff --git a/erpnext/stock/doctype/delivery_trip/delivery_trip.py b/erpnext/stock/doctype/delivery_trip/delivery_trip.py index 857ba0618e4..907cdac76d0 100644 --- a/erpnext/stock/doctype/delivery_trip/delivery_trip.py +++ b/erpnext/stock/doctype/delivery_trip/delivery_trip.py @@ -216,7 +216,7 @@ class DeliveryTrip(Document): (list of list of str): List of address routes split at locks, if optimize is `True` """ if not self.driver_address: - frappe.throw(_("Cannot Calculate Arrival Time as Driver Address is Missing.")) + frappe.throw(_("Cannot calculate arrival time as the driver address is missing.")) home_address = get_address_display(frappe.get_doc("Address", self.driver_address).as_dict()) diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py index 4a3c89d8eae..17ae0cc6c61 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py @@ -409,7 +409,7 @@ def get_inventory_dimensions(): "validate_negative_stock", "name as dimension_name", ], - order_by="creation", + order_by="creation", # pg-ok: dropped under distinct on PG — config-list iteration order only, not data distinct=True, ) diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index ed6d4efe43d..3a475032bd5 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -475,7 +475,7 @@ function render_serial_batch_banner(wrapper) { let banner_html = `