Compare commits

..

1 Commits

Author SHA1 Message Date
Nabin Hait
40e4f07f99 test: add end-to-end procure-to-pay flow coverage
Cross-document integration tests for the purchase cycle:
- full chain: Purchase Order -> Receipt -> Invoice -> Payment, asserting
  received/billed status propagation and that outstanding clears on payment
- partial receipt and billing propagates the right percentages and status
- a submitted Purchase Order marks its source Material Request as Ordered
- a purchase return reduces the received qty on the originating order
2026-06-22 16:02:11 +05:30
389 changed files with 64590 additions and 202170 deletions

View File

@@ -1,152 +0,0 @@
# 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`). Fix: add it 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 <expr not in the select list>`** — add the expr to the select.
- **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")` and
similar — PostgreSQL folds unquoted identifiers to lower case; a stored column named
`status` won't match `"Status"`. Use the exact stored case.
- **Boolean passed where an integer column is expected** — `frappe.db.set_value(dt, dn,
check_field, True)` emits `SET col = true`, which PostgreSQL rejects on a `smallint`
(`DatatypeMismatch`). Pass `1`/`0`.
---
## 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** — `COUNT(...) / COUNT(...) * 100` truncates to `0` on PostgreSQL
(integer/integer) but is decimal on MariaDB. Multiply by `100.0` first.
- **`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 `GROUP BY` row-count trap (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.
---
## 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.
- **Raw `ifnull(...)`** inside `frappe.db.sql()` is rewritten to `coalesce(...)` on all engines.
- **Backticks**, **`LOCATE`**, **`REGEXP`** in raw SQL are auto-translated on PostgreSQL.
- **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`).
---
## How to review
For every changed query: does it (a) use a construct from §1 (would error on PostgreSQL), or
(b) match a divergence in §2/§3 (different result across engines)? 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 are exactly what a reviewer (and this guide) must
cover, because no static check can see them.

View File

@@ -74,14 +74,6 @@ fi
if [ "$DB" == "postgres" ];then
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
# CI databases are disposable, so trade durability for speed: postgres fsyncs on every commit
# by default, which dominates a commit-heavy test suite. These are all reload-time settings
# (no restart needed). MariaDB CI is unaffected (DB != postgres).
echo "travis" | psql -h 127.0.0.1 -p 5432 -U postgres \
-c "ALTER SYSTEM SET synchronous_commit = 'off'" \
-c "ALTER SYSTEM SET fsync = 'off'" \
-c "ALTER SYSTEM SET full_page_writes = 'off'" \
-c "SELECT pg_reload_conf()";
fi

View File

@@ -1,221 +0,0 @@
#!/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, savepoint discipline) — those genuinely need the test suite. 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 <file.py> [<file.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())"),
]
# 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"}
# 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="<col>") -> 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)")
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:]))

View File

@@ -13,6 +13,6 @@
"root_login": "postgres",
"root_password": "travis",
"host_name": "http://test_site:8000",
"install_apps": ["payments", "erpnext"],
"install_apps": ["erpnext"],
"throttle_user_limit": 100
}

View File

@@ -119,15 +119,6 @@ jobs:
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
@@ -143,11 +134,10 @@ 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
start_bench_without_workers
bench start &>> ~/frappe-bench/bench_start.log &
bench --site test_site migrate
}
@@ -164,7 +154,7 @@ jobs:
rm -rf ~/frappe-bench/env
bench -v setup env
bench pip install -e ./apps/erpnext
start_bench_without_workers
bench start &>> ~/frappe-bench/bench_start.log &
bench --site test_site migrate

View File

@@ -1,80 +1,51 @@
name: Server (Postgres)
on:
repository_dispatch:
types: [frappe-framework-change]
pull_request:
# 'labeled' is required so adding the 'postgres' label to an open PR triggers this run
# (the job itself is gated on that label below)
types: [opened, reopened, synchronize, labeled]
paths-ignore:
- '**.js'
- '**.css'
- '**.svg'
- '**.md'
- '**.html'
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
schedule:
# Run everday at midnight UTC / 5:30 IST
- cron: "0 0 * * *"
workflow_dispatch:
inputs:
user:
description: 'Frappe Framework repository user (add your username for forks)'
required: true
default: 'frappe'
type: string
branch:
description: 'Frappe Framework branch'
default: 'develop'
required: false
type: string
permissions:
contents: read
types: [opened, labelled, synchronize, reopened]
concurrency:
group: server-postgres-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
# Opt-in on PRs: only runs when the PR carries the 'postgres' label. Scheduled / manual /
# framework-dispatch runs always execute (no PR labels to gate on).
if: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'postgres') }}
if: ${{ contains(github.event.pull_request.labels.*.name, 'postgres') }}
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]
container: [1]
# Distinct from the MariaDB job's "Python Unit Tests" so its check contexts do NOT collide with
# the required "Python Unit Tests (1..4)" status checks -- this keeps Postgres non-required for now.
name: Postgres Unit Tests
name: Python Unit Tests
services:
postgres:
image: postgres:13.3
env:
POSTGRES_PASSWORD: travis
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Clone
uses: actions/checkout@v6
@@ -133,65 +104,15 @@ jobs:
restore-keys: |
${{ runner.os }}-yarn-
- name: Cache wkhtmltopdf
uses: actions/cache@v4
with:
path: /tmp/wkhtmltox.deb
key: wkhtmltox-0.12.6.1-2-jammy-amd64
- name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env:
DB: postgres
TYPE: server
FRAPPE_USER: ${{ github.event.inputs.user }}
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
- name: Run Tests
run: |
cd ~/frappe-bench/
coverage_flag=""
if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
bench --site test_site run-parallel-tests --lightmode --app erpnext \
--total-builds ${{ strategy.job-total }} \
--build-number ${{ matrix.container }} \
$coverage_flag
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator
env:
TYPE: server
- name: Show bench output
if: ${{ always() }}
run: cat ~/frappe-bench/bench_start.log || true
- name: Upload coverage data
if: ${{ env.WITH_COVERAGE == 'true' }}
uses: actions/upload-artifact@v4
with:
name: coverage-postgres-${{ matrix.container }}
path: /home/runner/frappe-bench/sites/coverage.xml
coverage:
name: Coverage Wrap Up
needs: test
if: ${{ github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Clone
uses: actions/checkout@v6
- name: Download artifacts
uses: actions/download-artifact@v4
with:
pattern: coverage-postgres-*
- name: Upload coverage data
uses: codecov/codecov-action@v4
with:
name: Postgres
flags: postgres
# explicit glob: download-artifact extracts each shard into its own coverage-postgres-N/ dir
files: coverage-postgres-*/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
verbose: true
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io

View File

@@ -6,20 +6,5 @@
"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), MySQL-only functions (TIMESTAMP(date,time), TIMEDIFF, STR_TO_DATE, DATE_FORMAT, DATE_ADD/DATE_SUB, GROUP_CONCAT, PERIOD_DIFF, SQL IF()), 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 in get_value(dt,dn,'Status'), or set_value/db_set of a Check field with a Python bool instead of 1/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, NULL ordering (PostgreSQL sorts NULLs last) in ORDER BY..LIMIT 1, integer division (multiply by 100.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'. 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. Do NOT suggest changing a Max()-wrapped column to Sum() to make a number more correct, that changes MariaDB's value. DO NOT FLAG these false positives: .like()/['like'] (already ILIKE on PostgreSQL), raw ifnull/backticks/LOCATE/REGEXP inside frappe.db.sql (auto-translated by the framework), 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."
}
]
}
}

View File

@@ -66,18 +66,6 @@ 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: []

View File

@@ -1,7 +1,6 @@
import frappe
from frappe import _
from frappe.email import sendmail_to_system_managers
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import (
add_days,
add_months,
@@ -54,24 +53,20 @@ def validate_service_stop_date(doc):
def build_conditions(process_type, account, company):
if process_type == "Income":
item = frappe.qb.DocType("Sales Invoice Item")
parent = frappe.qb.DocType("Sales Invoice")
deferred_account = item.deferred_revenue_account
else:
item = frappe.qb.DocType("Purchase Invoice Item")
parent = frappe.qb.DocType("Purchase Invoice")
deferred_account = item.deferred_expense_account
conditions = ""
deferred_account = (
"item.deferred_revenue_account" if process_type == "Income" else "item.deferred_expense_account"
)
if account:
return deferred_account == account
conditions += f"AND {deferred_account}={frappe.db.escape(account)}"
elif company:
return parent.company == company
conditions += f"AND p.company = {frappe.db.escape(company)}"
return None
return conditions
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=None):
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=""):
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
if not start_date:
@@ -80,25 +75,17 @@ def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_d
end_date = add_days(today(), -1)
# check for the purchase invoice for which GL entries has to be done
item = frappe.qb.DocType("Purchase Invoice Item")
parent = frappe.qb.DocType("Purchase Invoice")
query = (
frappe.qb.from_(item)
.inner_join(parent)
.on(item.parent == parent.name)
.select(item.parent)
.distinct()
.where(
(item.service_start_date <= end_date)
& (item.service_end_date >= start_date)
& (item.enable_deferred_expense == 1)
& (item.docstatus == 1)
& (IfNull(item.amount, 0) > 0)
)
)
if conditions is not None:
query = query.where(conditions)
invoices = query.run(pluck=True)
invoices = frappe.db.sql_list(
f"""
select distinct item.parent
from `tabPurchase Invoice Item` item, `tabPurchase Invoice` p
where item.service_start_date<=%s and item.service_end_date>=%s
and item.enable_deferred_expense = 1 and item.parent=p.name
and item.docstatus = 1 and ifnull(item.amount, 0) > 0
{conditions}
""",
(end_date, start_date),
) # nosec
# For each invoice, book deferred expense
for invoice in invoices:
@@ -109,7 +96,7 @@ def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_d
send_mail(deferred_process)
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=None):
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=""):
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
if not start_date:
@@ -118,25 +105,17 @@ def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_da
end_date = add_days(today(), -1)
# check for the sales invoice for which GL entries has to be done
item = frappe.qb.DocType("Sales Invoice Item")
parent = frappe.qb.DocType("Sales Invoice")
query = (
frappe.qb.from_(item)
.inner_join(parent)
.on(item.parent == parent.name)
.select(item.parent)
.distinct()
.where(
(item.service_start_date <= end_date)
& (item.service_end_date >= start_date)
& (item.enable_deferred_revenue == 1)
& (item.docstatus == 1)
& (IfNull(item.amount, 0) > 0)
)
)
if conditions is not None:
query = query.where(conditions)
invoices = query.run(pluck=True)
invoices = frappe.db.sql_list(
f"""
select distinct item.parent
from `tabSales Invoice Item` item, `tabSales Invoice` p
where item.service_start_date<=%s and item.service_end_date>=%s
and item.enable_deferred_revenue = 1 and item.parent=p.name
and item.docstatus = 1 and ifnull(item.amount, 0) > 0
{conditions}
""",
(end_date, start_date),
) # nosec
for invoice in invoices:
doc = frappe.get_doc("Sales Invoice", invoice)
@@ -157,39 +136,26 @@ def get_booking_dates(doc, item, posting_date=None, prev_posting_date=None):
)
if not prev_posting_date:
prev_gl_entry = frappe.get_all(
"GL Entry",
filters={
"company": doc.company,
"account": item.get(deferred_account),
"voucher_type": doc.doctype,
"voucher_no": doc.name,
"voucher_detail_no": item.name,
"is_cancelled": 0,
},
fields=["name", "posting_date"],
order_by="posting_date desc",
limit=1,
prev_gl_entry = frappe.db.sql(
"""
select name, posting_date from `tabGL Entry` where company=%s and account=%s and
voucher_type=%s and voucher_no=%s and voucher_detail_no=%s
and is_cancelled = 0
order by posting_date desc limit 1
""",
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
as_dict=True,
)
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
prev_gl_via_je = (
frappe.qb.from_(je)
.inner_join(jea)
.on(je.name == jea.parent)
.select(je.name, je.posting_date)
.where(
(je.company == doc.company)
& (jea.account == item.get(deferred_account))
& (jea.reference_type == doc.doctype)
& (jea.reference_name == doc.name)
& (jea.reference_detail_no == item.name)
& (jea.docstatus < 2)
)
.orderby(je.posting_date, order=frappe.qb.desc)
.limit(1)
.run(as_dict=True)
prev_gl_via_je = frappe.db.sql(
"""
SELECT p.name, p.posting_date FROM `tabJournal Entry` p, `tabJournal Entry Account` c
WHERE p.name = c.parent and p.company=%s and c.account=%s
and c.reference_type=%s and c.reference_name=%s
and c.reference_detail_no=%s and c.docstatus < 2 order by posting_date desc limit 1
""",
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
as_dict=True,
)
if prev_gl_via_je:
@@ -311,47 +277,26 @@ def get_already_booked_amount(doc, item):
total_credit_debit, total_credit_debit_currency = "credit", "credit_in_account_currency"
deferred_account = "deferred_expense_account"
gle = frappe.qb.DocType("GL Entry")
gl_entries_details = (
frappe.qb.from_(gle)
.select(
Sum(gle[total_credit_debit]).as_("total_credit"),
Sum(gle[total_credit_debit_currency]).as_("total_credit_in_account_currency"),
gle.voucher_detail_no,
)
.where(
(gle.company == doc.company)
& (gle.account == item.get(deferred_account))
& (gle.voucher_type == doc.doctype)
& (gle.voucher_no == doc.name)
& (gle.voucher_detail_no == item.name)
& (gle.is_cancelled == 0)
)
.groupby(gle.voucher_detail_no)
.run(as_dict=True)
gl_entries_details = frappe.db.sql(
"""
select sum({}) as total_credit, sum({}) as total_credit_in_account_currency, voucher_detail_no
from `tabGL Entry` where company=%s and account=%s and voucher_type=%s and voucher_no=%s and voucher_detail_no=%s
and is_cancelled = 0
group by voucher_detail_no
""".format(total_credit_debit, total_credit_debit_currency),
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
as_dict=True,
)
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
journal_entry_details = (
frappe.qb.from_(je)
.inner_join(jea)
.on(je.name == jea.parent)
.select(
Sum(jea[total_credit_debit]).as_("total_credit"),
Sum(jea[total_credit_debit_currency]).as_("total_credit_in_account_currency"),
jea.reference_detail_no,
)
.where(
(je.company == doc.company)
& (jea.account == item.get(deferred_account))
& (jea.reference_type == doc.doctype)
& (jea.reference_name == doc.name)
& (jea.reference_detail_no == item.name)
& (je.docstatus < 2)
)
.groupby(jea.reference_detail_no)
.run(as_dict=True)
journal_entry_details = frappe.db.sql(
"""
SELECT sum(c.{}) as total_credit, sum(c.{}) as total_credit_in_account_currency, reference_detail_no
FROM `tabJournal Entry` p , `tabJournal Entry Account` c WHERE p.name = c.parent and
p.company = %s and c.account=%s and c.reference_type=%s and c.reference_name=%s and c.reference_detail_no=%s
and p.docstatus < 2 group by reference_detail_no
""".format(total_credit_debit, total_credit_debit_currency),
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
as_dict=True,
)
already_booked_amount = gl_entries_details[0].total_credit if gl_entries_details else 0

View File

@@ -10,7 +10,7 @@ frappe.ui.form.on("Accounts Settings", {
},
};
});
if (!frm.naming_controller) frm.naming_controller = new frappe.ui.NamingSeriesController(frm);
if (!frm.naming_controller) frm.naming_controller = new erpnext.NamingSeriesController(frm);
frm.naming_controller.render_table("transaction_naming_html", get_transactions(frm));
},

View File

@@ -7,7 +7,7 @@ from frappe import _, msgprint
from frappe.model.document import Document
from frappe.query_builder import Case
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Coalesce, Max, Sum
from frappe.query_builder.functions import Coalesce, Sum
from frappe.utils import cint, flt, fmt_money, getdate
from pypika import Order
@@ -195,17 +195,14 @@ def get_payment_entries_for_bank_clearance(
.select(
ConstantColumn("Journal Entry").as_("payment_document"),
journal_entry.name.as_("payment_entry"),
# non-grouped columns are constant per grouped JE name / account (against_account is
# arbitrary per group on MySQL) -> Max() keeps the GROUP BY valid on postgres with the
# same value MySQL picked.
Max(journal_entry.cheque_no).as_("cheque_number"),
Max(journal_entry.cheque_date).as_("cheque_date"),
journal_entry.cheque_no.as_("cheque_number"),
journal_entry.cheque_date,
Sum(journal_entry_account.debit_in_account_currency).as_("debit"),
Sum(journal_entry_account.credit_in_account_currency).as_("credit"),
Max(journal_entry.posting_date).as_("posting_date"),
Max(journal_entry_account.against_account).as_("against_account"),
Max(journal_entry.clearance_date).as_("clearance_date"),
Max(journal_entry_account.account_currency).as_("account_currency"),
journal_entry.posting_date,
journal_entry_account.against_account,
journal_entry.clearance_date,
journal_entry_account.account_currency,
)
.where(
(journal_entry_account.account == account)
@@ -218,13 +215,12 @@ def get_payment_entries_for_bank_clearance(
if not include_reconciled_entries:
journal_entry_query = journal_entry_query.where(
(journal_entry.clearance_date.isnull())
| (journal_entry.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
(journal_entry.clearance_date.isnull()) | (journal_entry.clearance_date == "0000-00-00")
)
journal_entries = (
journal_entry_query.groupby(journal_entry_account.account, journal_entry.name)
.orderby(Max(journal_entry.posting_date))
.orderby(journal_entry.posting_date)
.orderby(journal_entry.name, order=Order.desc)
).run(as_dict=True)
@@ -294,8 +290,7 @@ def get_payment_entries_for_bank_clearance(
if not include_reconciled_entries:
payment_entry_query = payment_entry_query.where(
(pe.clearance_date.isnull())
| (pe.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
(pe.clearance_date.isnull()) | (pe.clearance_date == "0000-00-00")
)
payment_entries = (payment_entry_query.orderby(pe.posting_date).orderby(pe.name, order=Order.desc)).run(
@@ -332,8 +327,7 @@ def get_payment_entries_for_bank_clearance(
if not include_reconciled_entries:
paid_purchase_invoices_query = paid_purchase_invoices_query.where(
(pi.clearance_date.isnull())
| (pi.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
(pi.clearance_date.isnull()) | (pi.clearance_date == "0000-00-00")
)
paid_purchase_invoices = (
@@ -373,8 +367,7 @@ def get_payment_entries_for_bank_clearance(
if not include_reconciled_entries:
pos_sales_invoices_query = pos_sales_invoices_query.where(
(si_payment.clearance_date.isnull())
| (si_payment.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
(si_payment.clearance_date.isnull()) | (si_payment.clearance_date == "0000-00-00")
)
pos_sales_invoices = (

View File

@@ -9,13 +9,6 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
frappe.ui.form.on("Bank Guarantee", {
setup: function (frm) {
frm.set_query("reference_doctype", function () {
return {
filters: {
name: ["in", ["Sales Order", "Purchase Order"]],
},
};
});
frm.set_query("bank_account", function () {
return {
filters: {

View File

@@ -1,6 +1,5 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "ACC-BG-.YYYY.-.#####",
"creation": "2016-12-17 10:43:35.731631",
"doctype": "DocType",
@@ -51,7 +50,8 @@
"fieldname": "reference_doctype",
"fieldtype": "Link",
"label": "Reference Document Type",
"options": "DocType"
"options": "DocType",
"read_only": 1
},
{
"fieldname": "reference_docname",
@@ -60,14 +60,14 @@
"options": "reference_doctype"
},
{
"depends_on": "eval: doc.reference_doctype == \"Sales Order\"",
"depends_on": "eval: doc.bg_type == \"Receiving\"",
"fieldname": "customer",
"fieldtype": "Link",
"label": "Customer",
"options": "Customer"
},
{
"depends_on": "eval: doc.reference_doctype == \"Purchase Order\"",
"depends_on": "eval: doc.bg_type == \"Providing\"",
"fieldname": "supplier",
"fieldtype": "Link",
"label": "Supplier",
@@ -218,11 +218,10 @@
"grid_page_length": 50,
"is_submittable": 1,
"links": [],
"modified": "2026-05-25 18:12:10.768835",
"modified": "2025-08-29 11:52:33.550847",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Guarantee",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{

View File

@@ -221,12 +221,12 @@
"default": "0",
"fieldname": "import_mt940_fromat",
"fieldtype": "Check",
"label": "Import MT940 Format"
"label": "Import MT940 Fromat"
}
],
"hide_toolbar": 1,
"links": [],
"modified": "2026-06-19 14:18:00.000000",
"modified": "2026-05-31 00:41:11.251215",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Statement Import",

View File

@@ -136,9 +136,6 @@ function set_total_budget_amount(frm) {
function toggle_distribution_fields(frm) {
const grid = frm.fields_dict.budget_distribution.grid;
frm.set_df_property("budget_distribution", "cannot_add_rows", true);
frm.set_df_property("budget_distribution", "cannot_delete_rows", true);
["amount", "percent"].forEach((field) => {
grid.update_docfield_property(field, "read_only", frm.doc.distribute_equally);
});

View File

@@ -5,11 +5,9 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Coalesce, Sum
from frappe.query_builder.functions import Sum
from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate
from frappe.utils.data import get_first_day
from pypika.terms import ExistsCriterion
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
@@ -117,26 +115,23 @@ class Budget(Document):
if not account:
return
budget = frappe.qb.DocType("Budget")
fy_from = frappe.qb.DocType("Fiscal Year").as_("fy_from")
fy_to = frappe.qb.DocType("Fiscal Year").as_("fy_to")
existing_budget = (
frappe.qb.from_(budget)
.inner_join(fy_from)
.on(fy_from.name == budget.from_fiscal_year)
.inner_join(fy_to)
.on(fy_to.name == budget.to_fiscal_year)
.select(budget.name, budget.account)
.where(
(budget.docstatus < 2)
& (budget.company == self.company)
& (budget[budget_against_field] == budget_against)
& (budget.account == account)
& (budget.name != self.name)
& (fy_from.year_start_date <= self.budget_end_date)
& (fy_to.year_end_date >= self.budget_start_date)
)
.run(as_dict=True)
existing_budget = frappe.db.sql(
f"""
SELECT name, account
FROM `tabBudget`
WHERE
docstatus < 2
AND company = %s
AND {budget_against_field} = %s
AND account = %s
AND name != %s
AND (
(SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
AND (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
)
""",
(self.company, budget_against, account, self.name, self.budget_end_date, self.budget_start_date),
as_dict=True,
)
if existing_budget:
@@ -358,8 +353,8 @@ class Budget(Document):
if self.should_regenerate_budget_distribution():
return
total_amount = sum(flt(d.amount) for d in self.budget_distribution)
total_percent = sum(flt(d.percent) for d in self.budget_distribution)
total_amount = sum(d.amount for d in self.budget_distribution)
total_percent = sum(d.percent for d in self.budget_distribution)
if flt(abs(total_amount - self.budget_amount), 2) > 0.10:
frappe.throw(
@@ -386,24 +381,17 @@ def validate_expense_against_budget(params, expense_amount=0):
posting_fiscal_year = get_fiscal_year(posting_date, company=params.get("company"))[0]
year_start_date, year_end_date = get_fiscal_year_date_range(posting_fiscal_year, posting_fiscal_year)
budget = frappe.qb.DocType("Budget")
fy_from = frappe.qb.DocType("Fiscal Year").as_("fy_from")
fy_to = frappe.qb.DocType("Fiscal Year").as_("fy_to")
budget_exists = (
frappe.qb.from_(budget)
.inner_join(fy_from)
.on(fy_from.name == budget.from_fiscal_year)
.inner_join(fy_to)
.on(fy_to.name == budget.to_fiscal_year)
.select(budget.name)
.where(
(budget.company == params.company)
& (budget.docstatus == 1)
& (fy_from.year_start_date <= year_end_date)
& (fy_to.year_end_date >= year_start_date)
)
.limit(1)
.run()
budget_exists = frappe.db.sql(
"""
select name
from `tabBudget`
where company = %s
and docstatus = 1
and (SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
and (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
limit 1
""",
(params.company, year_end_date, year_start_date),
)
if not budget_exists:
@@ -446,52 +434,50 @@ def validate_expense_against_budget(params, expense_amount=0):
and (frappe.get_cached_value("Account", params.account, "root_type") == "Expense")
):
doctype = dimension.get("document_type")
params.is_tree = bool(frappe.get_cached_value("DocType", doctype, "is_tree"))
if frappe.get_cached_value("DocType", doctype, "is_tree"):
lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"])
condition = f"""and exists(select name from `tab{doctype}`
where lft<={lft} and rgt>={rgt} and name=b.{budget_against})""" # nosec
params.is_tree = True
else:
condition = f"and b.{budget_against}={frappe.db.escape(params.get(budget_against))}"
params.is_tree = False
params.budget_against_field = budget_against
params.budget_against_doctype = doctype
b = frappe.qb.DocType("Budget")
query = (
frappe.qb.from_(b)
.select(
budget_records = frappe.db.sql(
f"""
SELECT
b.name,
getattr(b, budget_against).as_("budget_against"),
b.{budget_against} AS budget_against,
b.budget_amount,
b.from_fiscal_year,
b.to_fiscal_year,
b.budget_start_date,
b.budget_end_date,
Coalesce(b.applicable_on_material_request, 0).as_("for_material_request"),
Coalesce(b.applicable_on_purchase_order, 0).as_("for_purchase_order"),
Coalesce(b.applicable_on_booking_actual_expenses, 0).as_("for_actual_expenses"),
IFNULL(b.applicable_on_material_request, 0) AS for_material_request,
IFNULL(b.applicable_on_purchase_order, 0) AS for_purchase_order,
IFNULL(b.applicable_on_booking_actual_expenses, 0) AS for_actual_expenses,
b.action_if_annual_budget_exceeded,
b.action_if_accumulated_monthly_budget_exceeded,
b.action_if_annual_budget_exceeded_on_mr,
b.action_if_accumulated_monthly_budget_exceeded_on_mr,
b.action_if_annual_budget_exceeded_on_po,
b.action_if_accumulated_monthly_budget_exceeded_on_po,
)
.where(b.company == params.company)
.where(b.docstatus == 1)
.where(b.budget_start_date <= params.posting_date)
.where(b.budget_end_date >= params.posting_date)
.where(b.account == params.account)
)
if params.is_tree:
lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"])
dim = frappe.qb.DocType(doctype)
query = query.where(
ExistsCriterion(
frappe.qb.from_(dim)
.select(dim.name)
.where((dim.lft <= lft) & (dim.rgt >= rgt) & (dim.name == getattr(b, budget_against)))
)
)
else:
query = query.where(getattr(b, budget_against) == params.get(budget_against))
budget_records = query.run(as_dict=True)
b.action_if_accumulated_monthly_budget_exceeded_on_po
FROM
`tabBudget` b
WHERE
b.company = %s
AND b.docstatus = 1
AND %s BETWEEN b.budget_start_date AND b.budget_end_date
AND b.account = %s
{condition}
""",
(params.company, params.posting_date, params.account),
as_dict=True,
) # nosec
if budget_records:
validate_budget_records(params, budget_records, expense_amount)
@@ -688,27 +674,15 @@ def get_actions(params, budget):
def get_requested_amount(params):
item_code = params.get("item_code")
condition = get_other_condition(params, "Material Request")
child = frappe.qb.DocType("Material Request Item")
parent = frappe.qb.DocType("Material Request")
data = (
frappe.qb.from_(child)
.join(parent)
.on(parent.name == child.parent)
.select(
# rate inside the aggregate: Sum(qty * rate) is the correct requested amount and is PG-valid
Coalesce(Sum((child.stock_qty - child.ordered_qty) * child.rate), 0).as_("amount")
)
.where(
(child.item_code == item_code)
& (parent.docstatus == 1)
& (child.stock_qty > child.ordered_qty)
& Criterion.all(get_other_condition(params, child, parent, "Material Request"))
& (parent.material_request_type == "Purchase")
& (parent.status != "Stopped")
)
.run(as_list=1)
data = frappe.db.sql(
""" select ifnull((sum(child.stock_qty - child.ordered_qty) * rate), 0) as amount
from `tabMaterial Request Item` child, `tabMaterial Request` parent where parent.name = child.parent and
child.item_code = %s and parent.docstatus = 1 and child.stock_qty > child.ordered_qty and {} and
parent.material_request_type = 'Purchase' and parent.status != 'Stopped'""".format(condition),
item_code,
as_list=1,
)
return data[0][0] if data else 0
@@ -716,43 +690,37 @@ def get_requested_amount(params):
def get_ordered_amount(params):
item_code = params.get("item_code")
condition = get_other_condition(params, "Purchase Order")
child = frappe.qb.DocType("Purchase Order Item")
parent = frappe.qb.DocType("Purchase Order")
data = (
frappe.qb.from_(child)
.join(parent)
.on(parent.name == child.parent)
.select(Coalesce(Sum(child.amount - child.billed_amt), 0).as_("amount"))
.where(
(child.item_code == item_code)
& (parent.docstatus == 1)
& (child.amount > child.billed_amt)
& (parent.status != "Closed")
& Criterion.all(get_other_condition(params, child, parent, "Purchase Order"))
)
.run(as_list=1)
data = frappe.db.sql(
f""" select ifnull(sum(child.amount - child.billed_amt), 0) as amount
from `tabPurchase Order Item` child, `tabPurchase Order` parent where
parent.name = child.parent and child.item_code = %s and parent.docstatus = 1 and child.amount > child.billed_amt
and parent.status != 'Closed' and {condition}""",
item_code,
as_list=1,
)
return data[0][0] if data else 0
def get_other_condition(params, child, parent, for_doc):
conditions = [child.expense_account == params.expense_account]
def get_other_condition(params, for_doc):
condition = f"expense_account = {frappe.db.escape(params.expense_account)}"
budget_against_field = params.get("budget_against_field")
if budget_against_field and params.get(budget_against_field):
conditions.append(child[budget_against_field] == params.get(budget_against_field))
condition += (
f" and child.{budget_against_field} = {frappe.db.escape(params.get(budget_against_field))}"
)
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
start_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date")
end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date")
conditions.append(parent[date_field][str(start_date) : str(end_date)])
condition += f" and parent.{date_field} between {frappe.db.escape(str(start_date))} and {frappe.db.escape(str(end_date))}"
return conditions
return condition
def get_actual_expense(params):
@@ -760,19 +728,11 @@ def get_actual_expense(params):
params.budget_against_doctype = frappe.unscrub(params.budget_against_field)
budget_against_field = params.get("budget_against_field")
condition1 = " and gle.posting_date <= %(month_end_date)s" if params.get("month_end_date") else ""
gle = frappe.qb.DocType("GL Entry")
conditions = [
gle.is_cancelled == 0,
gle.account == params.get("account"),
gle.posting_date[str(params.budget_start_date) : str(params.budget_end_date)],
gle.company == params.get("company"),
gle.docstatus == 1,
]
if params.get("month_end_date"):
conditions.append(gle.posting_date <= params.get("month_end_date"))
date_condition = (
f"and gle.posting_date between '{params.budget_start_date}' and '{params.budget_end_date}'"
)
if params.is_tree:
lft_rgt = frappe.db.get_value(
@@ -780,27 +740,35 @@ def get_actual_expense(params):
)
params.update(lft_rgt)
tree = frappe.qb.DocType(params.budget_against_doctype)
conditions.append(
ExistsCriterion(
frappe.qb.from_(tree)
.select(tree.name)
.where(
(tree.lft >= params.get("lft"))
& (tree.rgt <= params.get("rgt"))
& (tree.name == gle[budget_against_field])
)
condition2 = f"""
and exists(
select name from `tab{params.budget_against_doctype}`
where lft >= %(lft)s and rgt <= %(rgt)s
and name = gle.{budget_against_field}
)
)
"""
else:
conditions.append(gle[budget_against_field] == params.get(budget_against_field))
condition2 = f"""
and gle.{budget_against_field} = %({budget_against_field})s
"""
amount = flt(
frappe.qb.from_(gle)
.select(Sum(gle.debit) - Sum(gle.credit))
.where(Criterion.all(conditions))
.run()[0][0]
)
frappe.db.sql(
f"""
select sum(gle.debit) - sum(gle.credit)
from `tabGL Entry` gle
where
is_cancelled = 0
and gle.account = %(account)s
{condition1}
{date_condition}
and gle.company = %(company)s
and gle.docstatus = 1
{condition2}
""",
params,
)[0][0]
) # nosec
return amount

View File

@@ -18,7 +18,6 @@
"in_list_view": 1,
"label": "Start Date",
"read_only": 1,
"reqd": 1,
"search_index": 1
},
{
@@ -26,29 +25,26 @@
"fieldtype": "Date",
"in_list_view": 1,
"label": "End Date",
"read_only": 1,
"reqd": 1
"read_only": 1
},
{
"fieldname": "amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Amount",
"reqd": 1
"label": "Amount"
},
{
"fieldname": "percent",
"fieldtype": "Percent",
"in_list_view": 1,
"label": "Percent",
"reqd": 1
"label": "Percent"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-06-18 11:23:17.669733",
"modified": "2025-11-03 13:18:28.398198",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Budget Distribution",

View File

@@ -15,12 +15,12 @@ class BudgetDistribution(Document):
from frappe.types import DF
amount: DF.Currency
end_date: DF.Date
end_date: DF.Date | None
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
percent: DF.Percent
start_date: DF.Date
start_date: DF.Date | None
# end: auto-generated types
pass

View File

@@ -75,10 +75,7 @@ def validate_company(company: str):
@frappe.whitelist()
def import_coa(file_name: str, company: str):
frappe.only_for("Accounts Manager")
# delete existing data for accounts
frappe.has_permission("Company", "write", company, throw=True)
unset_existing_data(company)
# create accounts
@@ -456,7 +453,6 @@ def unset_existing_data(company):
fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", [])
linked = [{"fieldname": name} for name in fieldnames]
update_values = {d.get("fieldname"): "" for d in linked}
frappe.db.set_value("Company", company, update_values, update_values)
# remove accounts data from various doctypes
@@ -468,7 +464,8 @@ def unset_existing_data(company):
"Sales Taxes and Charges Template",
"Purchase Taxes and Charges Template",
]:
frappe.get_query(doctype, delete=True, filters={"company": company}, ignore_permissions=False).run()
dt = frappe.qb.DocType(doctype)
frappe.qb.from_(dt).where(dt.company == company).delete().run()
def set_default_accounts(company):

View File

@@ -84,10 +84,10 @@ class CostCenter(NestedSet):
return frappe.db.get_value("GL Entry", {"cost_center": self.name})
def check_if_child_exists(self):
return frappe.get_all(
"Cost Center",
filters={"parent_cost_center": self.name, "docstatus": ["!=", 2]},
pluck="name",
return frappe.db.sql(
"select name from `tabCost Center` where \
parent_cost_center = %s and docstatus != 2",
self.name,
)
def if_allocation_exists_against_cost_center(self):

View File

@@ -173,66 +173,3 @@ class TestCouponCode(ERPNextTestSuite):
# Clean up
coupon.delete()
def test_validate_coupon_code_rejections(self):
from frappe.utils import add_days, nowdate
from erpnext.accounts.doctype.pricing_rule.utils import validate_coupon_code
pricing_rule = frappe.db.get_value("Pricing Rule", {"title": "_Test Pricing Rule for _Test Item"})
def make_coupon(name, **kwargs):
frappe.delete_doc_if_exists("Coupon Code", name)
return frappe.get_doc(
{
"doctype": "Coupon Code",
"coupon_name": name,
"coupon_code": name,
"coupon_type": "Promotional",
"pricing_rule": pricing_rule,
**kwargs,
}
).insert(ignore_permissions=True)
with self.subTest("validity not yet started"):
make_coupon("_Test Coupon Future", valid_from=add_days(nowdate(), 5))
self.assertRaises(frappe.ValidationError, validate_coupon_code, "_Test Coupon Future")
with self.subTest("validity expired"):
make_coupon("_Test Coupon Expired", valid_upto=add_days(nowdate(), -5))
self.assertRaises(frappe.ValidationError, validate_coupon_code, "_Test Coupon Expired")
with self.subTest("maximum use exhausted"):
make_coupon("_Test Coupon Exhausted", maximum_use=2, used=2)
self.assertRaises(frappe.ValidationError, validate_coupon_code, "_Test Coupon Exhausted")
with self.subTest("valid coupon passes"):
make_coupon("_Test Coupon Valid", maximum_use=5, used=1, valid_upto=add_days(nowdate(), 5))
validate_coupon_code("_Test Coupon Valid") # no raise
def test_update_coupon_code_count_cancel_and_exhaust(self):
from erpnext.accounts.doctype.pricing_rule.utils import update_coupon_code_count
pricing_rule = frappe.db.get_value("Pricing Rule", {"title": "_Test Pricing Rule for _Test Item"})
frappe.delete_doc_if_exists("Coupon Code", "_Test Coupon Count")
frappe.get_doc(
{
"doctype": "Coupon Code",
"coupon_name": "_Test Coupon Count",
"coupon_code": "_Test Coupon Count",
"coupon_type": "Promotional",
"pricing_rule": pricing_rule,
"maximum_use": 2,
"used": 1,
}
).insert(ignore_permissions=True)
# cancelling a transaction releases one use
update_coupon_code_count("_Test Coupon Count", "cancelled")
self.assertEqual(frappe.db.get_value("Coupon Code", "_Test Coupon Count", "used"), 0)
# using up to the maximum is allowed, beyond it is rejected
update_coupon_code_count("_Test Coupon Count", "used")
update_coupon_code_count("_Test Coupon Count", "used")
self.assertEqual(frappe.db.get_value("Coupon Code", "_Test Coupon Count", "used"), 2)
self.assertRaises(frappe.ValidationError, update_coupon_code_count, "_Test Coupon Count", "used")

View File

@@ -626,8 +626,6 @@ def get_account_details(
party: str | None = None,
rounding_loss_allowance: float = 0.0,
):
frappe.has_permission("Account", doc=account, throw=True)
if not (company and posting_date):
frappe.throw(_("Company and Posting Date is mandatory"))

View File

@@ -72,8 +72,10 @@ class FiscalYear(Document):
if existing_fiscal_years:
for existing in existing_fiscal_years:
company_for_existing = frappe.get_all(
"Fiscal Year Company", filters={"parent": existing.name}, pluck="company"
company_for_existing = frappe.db.sql_list(
"""select company from `tabFiscal Year Company`
where parent=%s""",
existing.name,
)
overlap = False

View File

@@ -7,7 +7,6 @@ from frappe import _
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.model.naming import set_name_from_naming_options
from frappe.query_builder.functions import Sum
from frappe.utils import create_batch, flt, fmt_money, now
import erpnext
@@ -332,12 +331,10 @@ def validate_balance_type(account, adv_adj=False):
if not adv_adj and account:
balance_must_be = frappe.get_cached_value("Account", account, "balance_must_be")
if balance_must_be:
gle = frappe.qb.DocType("GL Entry")
balance = (
frappe.qb.from_(gle)
.select(Sum(gle.debit) - Sum(gle.credit))
.where((gle.is_cancelled == 0) & (gle.account == account))
.run()
balance = frappe.db.sql(
"""select sum(debit) - sum(credit)
from `tabGL Entry` where is_cancelled = 0 and account = %s""",
account,
)[0][0]
if (balance_must_be == "Debit" and flt(balance) < 0) or (
@@ -351,48 +348,44 @@ def validate_balance_type(account, adv_adj=False):
def update_outstanding_amt(
account, party_type, party, against_voucher_type, against_voucher, on_cancel=False
):
gle = frappe.qb.DocType("GL Entry")
conditions = (
(gle.against_voucher_type == against_voucher_type)
& (gle.against_voucher == against_voucher)
& (gle.voucher_type != "Invoice Discounting")
)
if party_type and party:
conditions &= (gle.party_type == party_type) & (gle.party == party)
party_condition = " and party_type={} and party={}".format(
frappe.db.escape(party_type), frappe.db.escape(party)
)
else:
party_condition = ""
if against_voucher_type == "Sales Invoice":
party_account = frappe.get_cached_value(against_voucher_type, against_voucher, "debit_to")
conditions &= gle.account.isin([account, party_account])
account_condition = f"and account in ({frappe.db.escape(account)}, {frappe.db.escape(party_account)})"
else:
conditions &= gle.account == account
account_condition = f" and account = {frappe.db.escape(account)}"
# get final outstanding amt
bal = flt(
frappe.qb.from_(gle)
.select(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
.where(conditions)
.run()[0][0]
frappe.db.sql(
f"""
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabGL Entry`
where against_voucher_type=%s and against_voucher=%s
and voucher_type != 'Invoice Discounting'
{party_condition} {account_condition}""",
(against_voucher_type, against_voucher),
)[0][0]
or 0.0
)
if against_voucher_type == "Purchase Invoice":
bal = -bal
elif against_voucher_type == "Journal Entry":
je_conditions = (
(gle.voucher_type == "Journal Entry")
& (gle.voucher_no == against_voucher)
& (gle.account == account)
& (gle.against_voucher.isnull() | (gle.against_voucher == ""))
)
if party_type and party:
je_conditions &= (gle.party_type == party_type) & (gle.party == party)
against_voucher_amount = flt(
frappe.qb.from_(gle)
.select(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
.where(je_conditions)
.run()[0][0]
frappe.db.sql(
f"""
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabGL Entry` where voucher_type = 'Journal Entry' and voucher_no = %s
and account = %s and (against_voucher is null or against_voucher='') {party_condition}""",
(against_voucher, account),
)[0][0]
)
if not against_voucher_amount:
@@ -487,14 +480,10 @@ def rename_temporarily_named_docs(doctype):
oldname = doc.name
set_name_from_naming_options(autoname, doc)
newname = doc.name
dt = frappe.qb.DocType(doctype)
(
frappe.qb.update(dt)
.set(dt.name, newname)
.set(dt.to_rename, 0)
.set(dt.modified, now())
.where(dt.name == oldname)
).run()
frappe.db.sql(
f"UPDATE `tab{doctype}` SET name = %s, to_rename = 0, modified = %s where name = %s",
(newname, now(), oldname),
)
for hook_type in ("on_gle_rename", "on_sle_rename"):
for hook in frappe.get_hooks(hook_type):

View File

@@ -26,17 +26,12 @@ class TestGLEntry(ERPNextTestSuite):
jv.flags.ignore_validate = True
jv.submit()
round_off_entry = frappe.get_all(
"GL Entry",
filters={
"voucher_type": "Journal Entry",
"voucher_no": jv.name,
"account": "_Test Write Off - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit": 0,
"credit": 0.01,
},
pluck="name",
round_off_entry = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Journal Entry' and voucher_no = %s
and account='_Test Write Off - _TC' and cost_center='_Test Cost Center - _TC'
and debit = 0 and credit = '.01'""",
jv.name,
)
self.assertTrue(round_off_entry)
@@ -60,9 +55,8 @@ class TestGLEntry(ERPNextTestSuite):
)
self.assertTrue(all(entry.to_rename == 1 for entry in gl_entries))
series = frappe.qb.DocType("Series")
old_naming_series_current_value = (
frappe.qb.from_(series).select(series["current"]).where(series.name == naming_series).run()
old_naming_series_current_value = frappe.db.sql(
"SELECT current from tabSeries where name = %s", naming_series
)[0][0]
rename_gle_sle_docs()
@@ -79,8 +73,8 @@ class TestGLEntry(ERPNextTestSuite):
all(new.name != old.name for new, old in zip(gl_entries, new_gl_entries, strict=False))
)
new_naming_series_current_value = (
frappe.qb.from_(series).select(series["current"]).where(series.name == naming_series).run()
new_naming_series_current_value = frappe.db.sql(
"SELECT current from tabSeries where name = %s", naming_series
)[0][0]
self.assertEqual(old_naming_series_current_value + 2, new_naming_series_current_value)

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,6 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
)
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import JournalTaxWithholding
from erpnext.accounts.party import get_party_account
from erpnext.accounts.services.gl_validator import validate_opening_entry_against_pcv
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
get_account_currency,
@@ -150,9 +149,6 @@ class JournalEntry(AccountsController):
if not self.is_opening:
self.is_opening = "No"
if self.is_opening == "Yes":
validate_opening_entry_against_pcv(self.company)
self.clearance_date = None
self.validate_party()
@@ -893,7 +889,7 @@ class JournalEntry(AccountsController):
msgprint(_("'Entries' cannot be empty"), raise_exception=True)
return
self.set_total_debit_credit()
self.total_debit, self.total_credit = 0, 0
diff = flt(self.difference, self.precision("difference"))
if diff:
self._apply_difference_to_blank_row(diff, difference_account)

View File

@@ -43,18 +43,18 @@ class TestJournalEntry(ERPNextTestSuite):
if test_voucher.doctype == "Journal Entry":
self.assertTrue(
frappe.get_all(
"Journal Entry Account",
filters={"account": "Debtors - _TC", "docstatus": 1, "parent": test_voucher.name},
pluck="name",
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where account = %s and docstatus = 1 and parent = %s""",
("Debtors - _TC", test_voucher.name),
)
)
self.assertFalse(
frappe.get_all(
"Journal Entry Account",
filters={"reference_type": test_voucher.doctype, "reference_name": test_voucher.name},
pluck="name",
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_type = %s and reference_name = %s""",
(test_voucher.doctype, test_voucher.name),
)
)
@@ -69,14 +69,10 @@ class TestJournalEntry(ERPNextTestSuite):
submitted_voucher = frappe.get_doc(test_voucher.doctype, test_voucher.name)
self.assertTrue(
frappe.get_all(
"Journal Entry Account",
filters={
"reference_type": submitted_voucher.doctype,
"reference_name": submitted_voucher.name,
dr_or_cr: 400,
},
pluck="name",
frappe.db.sql(
f"""select name from `tabJournal Entry Account`
where reference_type = %s and reference_name = %s and {dr_or_cr}=400""",
(submitted_voucher.doctype, submitted_voucher.name),
)
)
@@ -86,20 +82,24 @@ class TestJournalEntry(ERPNextTestSuite):
def advance_paid_testcase(self, base_jv, test_voucher, dr_or_cr):
# Test advance paid field
advance_paid = frappe.db.get_value(test_voucher.doctype, test_voucher.name, "advance_paid")
advance_paid = frappe.db.sql(
"""select advance_paid from `tab{}`
where name={}""".format(test_voucher.doctype, "%s"),
(test_voucher.name),
)
payment_against_order = base_jv.get("accounts")[0].get(dr_or_cr)
self.assertEqual(flt(advance_paid), flt(payment_against_order))
self.assertEqual(flt(advance_paid[0][0]), flt(payment_against_order))
def cancel_against_voucher_testcase(self, test_voucher):
if test_voucher.doctype == "Journal Entry":
# if test_voucher is a Journal Entry, test cancellation of test_voucher
test_voucher.cancel()
self.assertFalse(
frappe.get_all(
"Journal Entry Account",
filters={"reference_type": "Journal Entry", "reference_name": test_voucher.name},
pluck="name",
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_type='Journal Entry' and reference_name=%s""",
test_voucher.name,
)
)
@@ -202,10 +202,10 @@ class TestJournalEntry(ERPNextTestSuite):
# cancel
jv.cancel()
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": jv.name},
pluck="name",
gle = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
jv.name,
)
self.assertFalse(gle)
@@ -526,16 +526,9 @@ class TestJournalEntry(ERPNextTestSuite):
gl_entries = query.run(as_dict=True)
# MariaDB and Postgres collate `account` differently, so the DB ordering isn't portable;
# sort both sides identically before the positional comparison.
def _key(row):
return tuple(str(row[f]) for f in self.fields)
gl_entries = sorted(gl_entries, key=_key)
expected_gle = sorted(self.expected_gle, key=_key)
for i in range(len(expected_gle)):
for i in range(len(self.expected_gle)):
for field in self.fields:
self.assertEqual(expected_gle[i][field], gl_entries[i][field])
self.assertEqual(self.expected_gle[i][field], gl_entries[i][field])
def test_negative_debit_and_credit_with_same_account_head(self):
from erpnext.accounts.general_ledger import process_gl_map
@@ -771,29 +764,6 @@ class TestJournalEntry(ERPNextTestSuite):
self.assertEqual(blank_row.credit_in_account_currency, 100)
self.assertEqual(jv.total_debit, jv.total_credit)
def test_get_balance_recomputes_difference_ignoring_client_value(self):
"""get_balance computes its own difference instead of trusting a stale client-sent value."""
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.append(
"accounts",
{
"account": "_Test Cash - _TC",
"debit_in_account_currency": 100,
"debit": 100,
"exchange_rate": 1,
},
)
jv.append("accounts", {"account": "_Test Bank - _TC", "exchange_rate": 1})
# a stale/incorrect value as the client might send; get_balance must not rely on it
jv.difference = 0
jv.get_balance()
self.assertEqual(jv.accounts[1].credit_in_account_currency, 100)
self.assertEqual(jv.total_debit, jv.total_credit)
self.assertEqual(jv.difference, 0)
def test_get_outstanding_invoices_builds_write_off_rows(self):
"""Characterize: get_outstanding_invoices adds a party row for each outstanding invoice."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice

View File

@@ -44,7 +44,6 @@
{
"bold": 1,
"columns": 4,
"fetch_from": "bank_account.account",
"fieldname": "account",
"fieldtype": "Link",
"in_global_search": 1,

View File

@@ -39,32 +39,28 @@ def get_loyalty_point_entries(customer, loyalty_program, company, expiry_date=No
if not expiry_date:
expiry_date = today()
return frappe.get_all(
"Loyalty Point Entry",
filters={
"customer": customer,
"loyalty_program": loyalty_program,
"expiry_date": [">=", expiry_date],
"loyalty_points": [">", 0],
"company": company,
},
fields=["name", "loyalty_points", "expiry_date", "loyalty_program_tier", "invoice_type", "invoice"],
order_by="expiry_date",
return frappe.db.sql(
"""
select name, loyalty_points, expiry_date, loyalty_program_tier, invoice_type, invoice
from `tabLoyalty Point Entry`
where customer=%s and loyalty_program=%s
and expiry_date>=%s and loyalty_points>0 and company=%s
order by expiry_date
""",
(customer, loyalty_program, expiry_date, company),
as_dict=1,
)
def get_redemption_details(customer, loyalty_program, company):
return frappe._dict(
frappe.get_all(
"Loyalty Point Entry",
filters={
"customer": customer,
"loyalty_program": loyalty_program,
"loyalty_points": ["<", 0],
"company": company,
},
fields=["redeem_against", {"SUM": "loyalty_points", "as": "loyalty_points"}],
group_by="redeem_against",
as_list=True,
frappe.db.sql(
"""
select redeem_against, sum(loyalty_points)
from `tabLoyalty Point Entry`
where customer=%s and loyalty_program=%s and loyalty_points<0 and company=%s
group by redeem_against
""",
(customer, loyalty_program, company),
)
)

View File

@@ -1191,9 +1191,9 @@ class PaymentEntry(AccountsController):
continue
if tax.add_deduct_tax == "Add":
included_taxes += flt(tax.base_tax_amount)
included_taxes += tax.base_tax_amount
else:
included_taxes -= flt(tax.base_tax_amount)
included_taxes -= tax.base_tax_amount
return included_taxes

View File

@@ -818,11 +818,12 @@ class TestPaymentEntry(ERPNextTestSuite):
self.assertEqual(expected_gle[gle.account][3], gle.against_voucher)
def get_gle(self, voucher_no):
return frappe.get_all(
"GL Entry",
filters={"voucher_type": "Payment Entry", "voucher_no": voucher_no},
fields=["account", "debit", "credit", "against_voucher"],
order_by="account asc",
return frappe.db.sql(
"""select account, debit, credit, against_voucher
from `tabGL Entry` where voucher_type='Payment Entry' and voucher_no=%s
order by account asc""",
voucher_no,
as_dict=1,
)
def test_payment_entry_write_off_difference(self):
@@ -917,19 +918,13 @@ class TestPaymentEntry(ERPNextTestSuite):
"Debtors - _TC": {"cost_center": cost_center},
}
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Payment Entry", "voucher_no": pe.name},
fields=[
"account",
"cost_center",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, cost_center, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Payment Entry' and voucher_no=%s
order by account asc""",
pe.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -960,19 +955,13 @@ class TestPaymentEntry(ERPNextTestSuite):
"Creditors - _TC": {"cost_center": cost_center},
}
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Payment Entry", "voucher_no": pe.name},
fields=[
"account",
"cost_center",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, cost_center, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Payment Entry' and voucher_no=%s
order by account asc""",
pe.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -1124,27 +1113,6 @@ class TestPaymentEntry(ERPNextTestSuite):
self.assertEqual(gl_entries, expected_gl_entries)
def test_payment_entry_with_inclusive_tax(self):
# inclusive tax built server-side: base_tax_amount is None until apply_taxes()
payment_entry = create_payment_entry(paid_amount=1180)
payment_entry.append(
"taxes",
{
"account_head": "_Test Account Service Tax - _TC",
"charge_type": "On Paid Amount",
"rate": 18,
"included_in_paid_amount": 1,
"add_deduct_tax": "Add",
"description": "Service Tax",
},
)
payment_entry.save()
payment_entry.submit()
# 1180 incl 18% => 1000 base + 180 tax
self.assertEqual(flt(payment_entry.total_taxes_and_charges, 2), 180.0)
self.assertEqual(flt(payment_entry.unallocated_amount, 2), 1000.0)
def test_payment_entry_against_onhold_purchase_invoice(self):
pi = make_purchase_invoice()
@@ -1781,18 +1749,9 @@ class TestPaymentEntry(ERPNextTestSuite):
.where((gle.voucher_no == self.voucher_no) & (gle.is_cancelled == 0))
.orderby(gle.account, gle.debit, gle.credit, order=frappe.qb.desc)
).run(as_dict=True)
# MariaDB and Postgres collate `account` differently, so the DB ordering isn't portable;
# sort both sides identically before the positional comparison.
fields = ["account", "debit", "credit"]
def _key(row):
return tuple(str(row[f]) for f in fields)
gl_entries = sorted(gl_entries, key=_key)
expected_gle = sorted(self.expected_gle, key=_key)
for row in range(len(expected_gle)):
for field in fields:
self.assertEqual(expected_gle[row][field], gl_entries[row][field])
for row in range(len(self.expected_gle)):
for field in ["account", "debit", "credit"]:
self.assertEqual(self.expected_gle[row][field], gl_entries[row][field])
def test_reverse_payment_reconciliation(self):
customer = create_customer(frappe.generate_hash(length=10), "INR")

View File

@@ -347,11 +347,12 @@ class TestPaymentRequest(ERPNextTestSuite):
]
)
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Payment Entry", "voucher_no": pe.name},
fields=["account", "debit", "credit", "against_voucher"],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, debit, credit, against_voucher
from `tabGL Entry` where voucher_type='Payment Entry' and voucher_no=%s
order by account asc""",
pe.name,
as_dict=1,
)
self.assertTrue(gl_entries)

View File

@@ -73,10 +73,7 @@ class PeriodClosingVoucher(AccountsController):
if not previous_fiscal_year:
return
# get_fiscal_year() returns a single (name, start_date, end_date) tuple, so the start date
# is [1]; the old [0][1] read the 2nd char of the name ('T'), which MariaDB silently
# coerced to NULL but postgres rejects as an invalid date.
previous_fiscal_year_start_date = previous_fiscal_year[1]
previous_fiscal_year_start_date = previous_fiscal_year[0][1]
previous_fiscal_year_closed = frappe.db.exists(
"Period Closing Voucher",
{
@@ -290,43 +287,40 @@ class PeriodClosingVoucher(AccountsController):
self.accounting_dimension_fields = default_dimensions + get_accounting_dimensions()
def get_gl_entries_for_current_period(self, report_type, only_opening_entries=False, as_iterator=False):
gle = frappe.qb.DocType("GL Entry")
account = frappe.qb.DocType("Account")
fields = [
gle.name,
gle.posting_date,
gle.account,
gle.account_currency,
gle.debit_in_account_currency,
gle.credit_in_account_currency,
gle.debit,
gle.credit,
]
fields += [gle[dimension] for dimension in self.accounting_dimension_fields]
query = (
frappe.qb.from_(gle)
.select(*fields)
.where(
(gle.company == self.company)
& (gle.voucher_type != "Period Closing Voucher")
& (gle.is_cancelled == 0)
& gle.account.isin(
frappe.qb.from_(account).select(account.name).where(account.report_type == report_type)
)
)
)
date_condition = ""
if only_opening_entries:
query = query.where(gle.is_opening == "Yes")
date_condition = "is_opening = 'Yes'"
else:
query = query.where(
gle.posting_date.between(self.period_start_date, self.period_end_date)
& (gle.is_opening == "No")
)
date_condition = f"posting_date BETWEEN '{self.period_start_date}' AND '{self.period_end_date}' and is_opening = 'No'"
return query.run(as_dict=1, as_iterator=as_iterator)
# nosemgrep
return frappe.db.sql(
"""
SELECT
name,
posting_date,
account,
account_currency,
debit_in_account_currency,
credit_in_account_currency,
debit,
credit,
{}
FROM `tabGL Entry`
WHERE
{}
AND company = %s
AND voucher_type != 'Period Closing Voucher'
AND EXISTS(SELECT name FROM `tabAccount` WHERE name = account AND report_type = %s)
AND is_cancelled = 0
""".format(
", ".join(self.accounting_dimension_fields),
date_condition,
),
(self.company, report_type),
as_dict=1,
as_iterator=as_iterator,
)
def set_account_balance_dict(self, gle, acc_bal_dict):
key = self.get_key(gle)

View File

@@ -55,19 +55,15 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
("Sales - TPC", 400.0, 0.0),
)
pcv_gle = [
tuple(row)
for row in frappe.get_all(
"GL Entry",
filters={"voucher_no": pcv.name},
fields=["account", "debit", "credit"],
order_by="account",
as_list=True,
)
]
pcv_gle = frappe.db.sql(
"""
select account, debit, credit from `tabGL Entry` where voucher_no=%s order by account
""",
(pcv.name),
)
pcv.reload()
self.assertEqual(pcv.gle_processing_status, "Completed")
self.assertEqual(tuple(pcv_gle), expected_gle)
self.assertEqual(pcv_gle, expected_gle)
def test_cost_center_wise_posting(self):
surplus_account = create_account()
@@ -110,16 +106,14 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
("Sales - TPC", 200.0, 0.0, cost_center2),
)
pcv_gle = [
tuple(row)
for row in frappe.get_all(
"GL Entry",
filters={"voucher_no": pcv.name},
fields=["account", "debit", "credit", "cost_center"],
order_by="account, cost_center",
as_list=True,
)
]
pcv_gle = frappe.db.sql(
"""
select account, debit, credit, cost_center
from `tabGL Entry` where voucher_no=%s
order by account, cost_center
""",
(pcv.name),
)
self.assertSequenceEqual(pcv_gle, expected_gle)
@@ -172,19 +166,16 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
("Sales - TPC", 400.0, 0.0, jv.finance_book),
)
pcv_gle = [
tuple(row)
for row in frappe.get_all(
"GL Entry",
filters={"voucher_no": pcv.name},
fields=["account", "debit", "credit", "finance_book"],
order_by="account, finance_book",
as_list=True,
)
]
pcv_gle = frappe.db.sql(
"""
select account, debit, credit, finance_book
from `tabGL Entry` where voucher_no=%s
order by account, finance_book
""",
(pcv.name),
)
# compare order-independently: postgres and MariaDB order NULL finance_book differently
self.assertSequenceEqual(sorted(pcv_gle, key=str), sorted(expected_gle, key=str))
self.assertSequenceEqual(pcv_gle, expected_gle)
def test_gl_entries_restrictions(self):
cost_center = create_cost_center("Test Cost Center 1")
@@ -367,10 +358,14 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
posting_date="2022-01-01",
)
totals_after_cancel = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Journal Entry", "voucher_no": jv.name, "is_cancelled": 0},
fields=[{"SUM": "debit", "as": "total_debit"}, {"SUM": "credit", "as": "total_credit"}],
totals_after_cancel = frappe.db.sql(
"""
select sum(debit) as total_debit, sum(credit) as total_credit
from `tabGL Entry`
where voucher_type=%s and voucher_no=%s and is_cancelled=0
""",
("Journal Entry", jv.name),
as_dict=True,
)[0]
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)

View File

@@ -295,7 +295,7 @@ def get_payments(invoices):
.groupby(SalesInvoicePayment.mode_of_payment)
.select(
SalesInvoicePayment.mode_of_payment,
fn.Max(SalesInvoicePayment.account).as_("account"),
SalesInvoicePayment.account,
fn.Sum(SalesInvoicePayment.amount).as_("amount"),
)
)
@@ -419,7 +419,7 @@ def build_invoice_query(invoice_doctype, user, pos_profile, start, end):
InvoiceDocType.account_for_change_amount,
InvoiceDocType.is_return,
InvoiceDocType.return_against,
fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time).as_("timestamp"),
fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time).as_("timestamp"),
ConstantColumn(invoice_doctype).as_("doctype"),
)
.where(
@@ -428,8 +428,8 @@ def build_invoice_query(invoice_doctype, user, pos_profile, start, end):
& (InvoiceDocType.is_pos == 1)
& (InvoiceDocType.pos_profile == pos_profile)
& (
(fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time) >= start)
& (fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time) <= end)
(fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time) >= start)
& (fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time) <= end)
)
)
)

View File

@@ -6,7 +6,7 @@ import frappe
from frappe import _, bold
from frappe.model.document import Document
from frappe.model.mapper import map_child_doc, map_doc
from frappe.query_builder.functions import IfNull, Lower, Sum
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate
from frappe.utils.nestedset import get_descendants_of
@@ -505,20 +505,19 @@ class POSInvoice(SalesInvoice):
if d.get("serial_no"):
serial_nos = get_serial_nos(d.serial_no)
for sr in serial_nos:
POI = frappe.qb.DocType("POS Invoice Item")
s = sr.lower()
serial_no_exists = (
frappe.qb.from_(POI)
.select(POI.name)
.where(POI.parent == self.return_against)
.where(
(Lower(POI.serial_no) == s)
| Lower(POI.serial_no).like(f"{s}\n%")
| Lower(POI.serial_no).like(f"%\n{s}")
| Lower(POI.serial_no).like(f"%\n{s}\n%")
)
.limit(1)
.run()
serial_no_exists = frappe.db.sql(
"""
SELECT name
FROM `tabPOS Invoice Item`
WHERE
parent = %s
and (serial_no = %s
or serial_no like %s
or serial_no like %s
or serial_no like %s
)
""",
(self.return_against, sr, sr + "\n%", "%\n" + sr, "%\n" + sr + "\n%"),
)
if not serial_no_exists:
@@ -964,9 +963,15 @@ def get_bundle_availability(bundle_item_code, warehouse):
def get_bin_qty(item_code, warehouse):
actual_qty = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty")
bin_qty = frappe.db.sql(
"""select actual_qty from `tabBin`
where item_code = %s and warehouse = %s
limit 1""",
(item_code, warehouse),
as_dict=1,
)
return actual_qty or 0
return bin_qty[0].actual_qty or 0 if bin_qty else 0
def get_pos_reserved_qty(item_code, warehouse):

View File

@@ -118,21 +118,14 @@ class POSProfile(Document):
def validate_default_profile(self):
for row in self.applicable_for_users:
pfu = frappe.qb.DocType("POS Profile User")
pf = frappe.qb.DocType("POS Profile")
res = (
frappe.qb.from_(pfu)
.inner_join(pf)
.on(pf.name == pfu.parent)
.select(pf.name)
.where(
(pfu.user == row.user)
& (pf.name != self.name)
& (pf.company == self.company)
& (pfu.default == 1)
& (pf.disabled == 0)
)
.run()
res = frappe.db.sql(
"""select pf.name
from
`tabPOS Profile User` pfu, `tabPOS Profile` pf
where
pf.name = pfu.parent and pfu.user = %s and pf.name != %s and pf.company = %s
and pfu.default=1 and pf.disabled = 0""",
(row.user, self.name, self.company),
)
if row.default and res:
@@ -242,18 +235,15 @@ def get_item_groups(pos_profile):
for data in pos_profile.get("item_groups"):
item_groups.extend(
[
d.name
"%s" % frappe.db.escape(d.name)
for d in get_child_nodes("Item Group", data.item_group)
if not permitted_item_groups or d.name in permitted_item_groups
]
)
if not item_groups and permitted_item_groups:
item_groups = list(permitted_item_groups)
item_groups = ["%s" % frappe.db.escape(d) for d in permitted_item_groups]
# Return raw Item Group names; the callers parameterize them via the query builder
# (item_group.isin(...)) / frappe.get_all, which escapes them once. Pre-escaping here would
# double-escape (item_group IN ('''X''')) and match nothing.
return list(set(item_groups))
@@ -275,11 +265,10 @@ def get_permitted_nodes(group_type):
def get_child_nodes(group_type, root):
lft, rgt = frappe.db.get_value(group_type, root, ["lft", "rgt"])
return frappe.get_all(
group_type,
filters={"lft": [">=", lft], "rgt": ["<=", rgt]},
fields=["name", "lft", "rgt"],
order_by="lft",
return frappe.db.sql(
f""" Select name, lft, rgt from `tab{group_type}` where
lft >= {lft} and rgt <= {rgt} order by lft""",
as_dict=1,
)
@@ -289,33 +278,40 @@ def pos_profile_query(doctype: str, txt: str, searchfield: str, start: int, page
user = frappe.session["user"]
company = filters.get("company") or frappe.defaults.get_user_default("company")
pf = frappe.qb.DocType("POS Profile")
pfu = frappe.qb.DocType("POS Profile User")
args = {
"user": user,
"start": start,
"company": company,
"page_len": page_len,
"txt": "%%%s%%" % txt,
}
pos_profile = (
frappe.qb.from_(pf)
.inner_join(pfu)
.on(pfu.parent == pf.name)
.select(pf.name)
.where((pfu.user == user) & (pf.company == company) & pf.name.like(f"%{txt}%") & (pf.disabled == 0))
.limit(page_len)
.offset(start)
.run()
pos_profile = frappe.db.sql(
"""select pf.name
from
`tabPOS Profile` pf, `tabPOS Profile User` pfu
where
pfu.parent = pf.name and pfu.user = %(user)s and pf.company = %(company)s
and (pf.name like %(txt)s)
and pf.disabled = 0 limit %(page_len)s offset %(start)s""",
args,
)
if not pos_profile:
pos_profile = (
frappe.qb.from_(pf)
.left_join(pfu)
.on(pf.name == pfu.parent)
.select(pf.name)
.where(
(pfu.user.isnull() | (pfu.user == ""))
& (pf.company == company)
& pf.name.like(f"%{txt}%")
& (pf.disabled == 0)
)
.run()
del args["user"]
pos_profile = frappe.db.sql(
"""select pf.name
from
`tabPOS Profile` pf left join `tabPOS Profile User` pfu
on
pf.name = pfu.parent
where
ifnull(pfu.user, '') = ''
and pf.company = %(company)s
and pf.name like %(txt)s
and pf.disabled = 0""",
args,
)
return pos_profile

View File

@@ -114,7 +114,7 @@ def _get_pricing_rules(apply_on, args, values):
if apply_on_field == "item_code":
if args.get("uom", None):
item_conditions += (
" and ({child_doc}.uom={item_uom} or COALESCE({child_doc}.uom, '')='')".format(
" and ({child_doc}.uom={item_uom} or IFNULL({child_doc}.uom, '')='')".format(
child_doc=child_doc, item_uom=frappe.db.escape(args.get("uom"))
)
)
@@ -127,7 +127,7 @@ def _get_pricing_rules(apply_on, args, values):
elif apply_on_field == "item_group":
item_conditions = _get_tree_conditions(args, "Item Group", child_doc, False)
if args.get("uom", None):
item_conditions += " and ({child_doc}.uom={item_uom} or COALESCE({child_doc}.uom, '')='')".format(
item_conditions += " and ({child_doc}.uom={item_uom} or IFNULL({child_doc}.uom, '')='')".format(
child_doc=child_doc, item_uom=frappe.db.escape(args.get("uom"))
)
@@ -139,7 +139,7 @@ def _get_pricing_rules(apply_on, args, values):
if not args.price_list:
args.price_list = None
conditions += " and coalesce(`tabPricing Rule`.for_price_list, '') in (%(price_list)s, '')"
conditions += " and ifnull(`tabPricing Rule`.for_price_list, '') in (%(price_list)s, '')"
values["price_list"] = args.get("price_list")
pricing_rules = (
@@ -195,8 +195,10 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
except TypeError:
frappe.throw(_("Invalid {0}").format(args.get(field)))
parent_groups = frappe.get_all(
parenttype, filters={"lft": ["<=", lft], "rgt": [">=", rgt]}, pluck="name"
parent_groups = frappe.db.sql_list(
"""select name from `tab{}`
where lft<={} and rgt>={}""".format(parenttype, "%s", "%s"),
(lft, rgt),
)
if parenttype in ["Customer Group", "Item Group", "Territory"]:
@@ -215,14 +217,14 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
if parent_groups:
if allow_blank:
parent_groups.append("")
condition = "coalesce({table}.{field}, '') in ({parent_groups})".format(
condition = "ifnull({table}.{field}, '') in ({parent_groups})".format(
table=table, field=field, parent_groups=", ".join(frappe.db.escape(d) for d in parent_groups)
)
frappe.flags.tree_conditions[key] = condition
elif allow_blank:
condition = f"coalesce({table}.{field}, '') = ''"
condition = f"ifnull({table}.{field}, '') = ''"
return condition
@@ -230,10 +232,10 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True):
def get_other_conditions(conditions, values, args):
for field in ["company", "customer", "supplier", "campaign", "sales_partner"]:
if args.get(field):
conditions += f" and coalesce(`tabPricing Rule`.{field}, '') in (%({field})s, '')"
conditions += f" and ifnull(`tabPricing Rule`.{field}, '') in (%({field})s, '')"
values[field] = args.get(field)
else:
conditions += f" and coalesce(`tabPricing Rule`.{field}, '') = ''"
conditions += f" and ifnull(`tabPricing Rule`.{field}, '') = ''"
for parenttype in ["Customer Group", "Territory", "Supplier Group"]:
group_condition = _get_tree_conditions(args, parenttype, "`tabPricing Rule`")
@@ -246,8 +248,8 @@ def get_other_conditions(conditions, values, args):
or frappe.get_value(args.get("doctype"), args.get("name"), "posting_date", ignore=True)
)
if date:
conditions += """ and %(transaction_date)s between coalesce(`tabPricing Rule`.valid_from, '2000-01-01')
and coalesce(`tabPricing Rule`.valid_upto, '2500-12-31')"""
conditions += """ and %(transaction_date)s between ifnull(`tabPricing Rule`.valid_from, '2000-01-01')
and ifnull(`tabPricing Rule`.valid_upto, '2500-12-31')"""
values["transaction_date"] = date
if args.get("doctype") in [
@@ -262,9 +264,9 @@ def get_other_conditions(conditions, values, args):
"POS Invoice",
"POS Invoice Item",
]:
conditions += """ and coalesce(`tabPricing Rule`.selling, 0) = 1"""
conditions += """ and ifnull(`tabPricing Rule`.selling, 0) = 1"""
else:
conditions += """ and coalesce(`tabPricing Rule`.buying, 0) = 1"""
conditions += """ and ifnull(`tabPricing Rule`.buying, 0) = 1"""
return conditions
@@ -756,16 +758,21 @@ def validate_coupon_code(coupon_name):
def update_coupon_code_count(coupon_name, transaction_type):
coupon = frappe.get_doc("Coupon Code", coupon_name)
if transaction_type == "used":
if coupon.maximum_use and coupon.used >= coupon.maximum_use:
frappe.throw(
_("{0} Coupon used are {1}. Allowed quantity is exhausted").format(
coupon.coupon_code, coupon.used
if coupon:
if transaction_type == "used":
if not coupon.maximum_use:
coupon.used = coupon.used + 1
coupon.save(ignore_permissions=True)
elif coupon.used < coupon.maximum_use:
coupon.used = coupon.used + 1
coupon.save(ignore_permissions=True)
else:
frappe.throw(
_("{0} Coupon used are {1}. Allowed quantity is exhausted").format(
coupon.coupon_code, coupon.used
)
)
)
coupon.used = coupon.used + 1
coupon.save(ignore_permissions=True)
elif transaction_type == "cancelled":
if coupon.used > 0:
coupon.used = coupon.used - 1
coupon.save(ignore_permissions=True)
elif transaction_type == "cancelled":
if coupon.used > 0:
coupon.used = coupon.used - 1
coupon.save(ignore_permissions=True)

View File

@@ -431,9 +431,7 @@ def reconcile(doc: None | str = None) -> None:
# Update reconciled flag
allocation_names = [x.name for x in allocations]
ppa = qb.DocType("Process Payment Reconciliation Log Allocations")
qb.update(ppa).set(ppa.reconciled, 1).where(
ppa.name.isin(allocation_names)
).run() # smallint, not bool
qb.update(ppa).set(ppa.reconciled, True).where(ppa.name.isin(allocation_names)).run()
# Update reconciled count
reconciled_count = frappe.db.count(
@@ -479,7 +477,7 @@ def reconcile(doc: None | str = None) -> None:
finally:
if reconciled_entries == total_allocations:
frappe.db.set_value("Process Payment Reconciliation Log", log, "status", "Reconciled")
frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", 1)
frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", True)
frappe.db.set_value("Process Payment Reconciliation", doc, "status", "Completed")
else:
if frappe.db.get_value("Process Payment Reconciliation", doc, "status") != "Paused":
@@ -503,7 +501,7 @@ def reconcile(doc: None | str = None) -> None:
)
else:
frappe.db.set_value("Process Payment Reconciliation Log", log, "status", "Reconciled")
frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", 1)
frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", True)
frappe.db.set_value("Process Payment Reconciliation", doc, "status", "Completed")

View File

@@ -16,7 +16,6 @@
"categorize_by",
"cost_center",
"territory",
"show_opening_entries",
"ignore_exchange_rate_revaluation_journals",
"ignore_cr_dr_notes",
"column_break_14",
@@ -415,17 +414,10 @@
"fieldtype": "Link",
"label": "Print Format",
"options": "Print Format"
},
{
"default": "0",
"depends_on": "eval:(doc.report == 'General Ledger');",
"fieldname": "show_opening_entries",
"fieldtype": "Check",
"label": "Show Opening Entries"
}
],
"links": [],
"modified": "2026-06-01 15:37:07.660442",
"modified": "2025-10-07 12:19:20.719898",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Statement Of Accounts",

View File

@@ -6,6 +6,7 @@ import copy
import frappe
from frappe import _
from frappe.desk.reportview import get_match_cond
from frappe.model.document import Document
from frappe.utils import add_days, add_months, format_date, getdate, today
from frappe.utils.jinja import validate_template
@@ -19,7 +20,6 @@ from erpnext.accounts.report.accounts_receivable_summary.accounts_receivable_sum
execute as get_ageing,
)
from erpnext.accounts.report.general_ledger.general_ledger import execute as get_soa
from erpnext.utilities.query import get_match_conditions_qb
class ProcessStatementOfAccounts(Document):
@@ -75,7 +75,6 @@ class ProcessStatementOfAccounts(Document):
sender: DF.Link | None
show_future_payments: DF.Check
show_net_values_in_party_account: DF.Check
show_opening_entries: DF.Check
show_remarks: DF.Check
start_date: DF.Date | None
subject: DF.Data | None
@@ -271,7 +270,7 @@ def get_gl_filters(doc, entry, tax_id, presentation_currency):
"categorize_by": doc.categorize_by,
"currency": doc.currency,
"project": [p.project_name for p in doc.project],
"show_opening_entries": doc.show_opening_entries,
"show_opening_entries": 0,
"include_default_book_entries": 0,
"tax_id": tax_id if tax_id else None,
"show_net_values_in_party_account": doc.show_net_values_in_party_account,
@@ -366,19 +365,15 @@ def get_customers_based_on_territory_or_customer_group(customer_collection, coll
def get_customers_based_on_sales_person(sales_person):
lft, rgt = frappe.db.get_value("Sales Person", sales_person, ["lft", "rgt"])
steam = frappe.qb.DocType("Sales Team")
sp = frappe.qb.DocType("Sales Person")
records = (
frappe.qb.from_(steam)
.select(steam.parent, steam.parenttype)
.distinct()
.where(
(steam.parenttype == "Customer")
& steam.sales_person.isin(
frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt))
)
)
.run(as_dict=1)
records = frappe.db.sql(
"""
select distinct parent, parenttype
from `tabSales Team` steam
where parenttype = 'Customer'
and exists(select name from `tabSales Person` where lft >= %s and rgt <= %s and name = steam.sales_person)
""",
(lft, rgt),
as_dict=1,
)
sales_person_records = frappe._dict()
for d in records:
@@ -473,30 +468,31 @@ def get_customer_emails(customer_name: str, primary_mandatory: str | int, billin
frappe.has_permission("Customer", "read", customer_name, throw=True)
email = frappe.qb.DocType("Contact Email")
link = frappe.qb.DocType("Dynamic Link")
contact = frappe.qb.DocType("Contact")
query = (
frappe.qb.from_(email)
.join(link)
.on(email.parent == link.parent)
.join(contact)
.on(contact.name == link.parent)
.select(email.email_id)
.where(
(link.link_doctype == "Customer")
& (link.link_name == customer_name)
& (contact.is_billing_contact == 1)
)
.orderby(contact.creation, order=frappe.qb.desc)
billing_email = frappe.db.sql(
"""
SELECT
email.email_id
FROM
`tabContact Email` AS email
JOIN
`tabDynamic Link` AS link
ON
email.parent=link.parent
JOIN
`tabContact` AS contact
ON
contact.name=link.parent
WHERE
link.link_doctype='Customer'
and link.link_name=%s
and contact.is_billing_contact=1
{mcond}
ORDER BY
contact.creation desc
""".format(mcond=get_match_cond("Contact")),
customer_name,
)
for condition in get_match_conditions_qb("Contact", table=contact):
query = query.where(condition)
billing_email = query.run()
if len(billing_email) == 0 or (billing_email[0][0] is None):
if billing_and_primary:
frappe.throw(_("No billing email found for customer: {0}").format(customer_name))

View File

@@ -524,11 +524,16 @@ class PurchaseInvoice(BuyingController):
def check_prev_docstatus(self):
for d in self.get("items"):
if d.purchase_order:
submitted = frappe.db.exists("Purchase Order", {"docstatus": 1, "name": d.purchase_order})
submitted = frappe.db.sql(
"select name from `tabPurchase Order` where docstatus = 1 and name = %s", d.purchase_order
)
if not submitted:
frappe.throw(_("Purchase Order {0} is not submitted").format(d.purchase_order))
if d.purchase_receipt:
submitted = frappe.db.exists("Purchase Receipt", {"docstatus": 1, "name": d.purchase_receipt})
submitted = frappe.db.sql(
"select name from `tabPurchase Receipt` where docstatus = 1 and name = %s",
d.purchase_receipt,
)
if not submitted:
frappe.throw(_("Purchase Receipt {0} is not submitted").format(d.purchase_receipt))
@@ -796,20 +801,25 @@ class PurchaseInvoice(BuyingController):
if cint(frappe.get_single_value("Accounts Settings", "check_supplier_invoice_uniqueness")):
fiscal_year = get_fiscal_year(self.posting_date, company=self.company, as_dict=True)
pi = frappe.get_all(
"Purchase Invoice",
filters={
pi = frappe.db.sql(
"""select name from `tabPurchase Invoice`
where
bill_no = %(bill_no)s
and supplier = %(supplier)s
and name != %(name)s
and docstatus < 2
and posting_date between %(year_start_date)s and %(year_end_date)s""",
{
"bill_no": self.bill_no,
"supplier": self.supplier,
"name": ["!=", self.name],
"docstatus": ["<", 2],
"posting_date": ["between", [fiscal_year.year_start_date, fiscal_year.year_end_date]],
"name": self.name,
"year_start_date": fiscal_year.year_start_date,
"year_end_date": fiscal_year.year_end_date,
},
pluck="name",
)
if pi:
pi = pi[0]
pi = pi[0][0]
frappe.throw(
_("Supplier Invoice No exists in Purchase Invoice {0}").format(

View File

@@ -55,13 +55,10 @@ class ExpenseAccountService:
else:
# check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not
if item.purchase_receipt:
negative_expense_booked_in_pr = frappe.db.exists(
"GL Entry",
{
"voucher_type": "Purchase Receipt",
"voucher_no": item.purchase_receipt,
"account": stock_not_billed_account,
},
negative_expense_booked_in_pr = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Purchase Receipt' and voucher_no=%s and account = %s""",
(item.purchase_receipt, stock_not_billed_account),
)
if negative_expense_booked_in_pr:

View File

@@ -395,14 +395,10 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
):
# Post reverse entry for Stock-Received-But-Not-Billed if booked in Purchase Receipt
if item.purchase_receipt and valuation_tax_accounts:
negative_expense_booked_in_pr = frappe.get_all(
"GL Entry",
filters={
"voucher_type": "Purchase Receipt",
"voucher_no": item.purchase_receipt,
"account": ["in", valuation_tax_accounts],
},
pluck="name",
negative_expense_booked_in_pr = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Purchase Receipt' and voucher_no=%s and account in %s""",
(item.purchase_receipt, valuation_tax_accounts),
)
(
@@ -590,10 +586,6 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
tax_service = TaxService(doc)
valuation_tax = {}
# Amount of each valuation charge actually capitalized into stock/asset valuation, keyed by
# tax row name - a non-stock item's share of a spread-across-all-items charge is excluded.
capitalized_valuation_tax = doc.get_capitalized_valuation_tax()
for tax in doc.get("taxes"):
amount, base_amount = tax_service.get_tax_amounts(tax, None)
if tax.category in ("Total", "Valuation and Total") and flt(base_amount):
@@ -628,7 +620,8 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
tax.idx, _(tax.category)
)
)
valuation_tax[tax.name] = capitalized_valuation_tax.get(tax.name, 0.0)
valuation_tax.setdefault(tax.name, 0)
valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(base_amount)
if doc.is_opening == "No" and doc.negative_expense_to_be_booked and valuation_tax:
total_valuation_amount = sum(valuation_tax.values())

View File

@@ -3,7 +3,6 @@
import frappe
from frappe.query_builder.functions import Sum
from frappe.utils import add_days, cint, flt, getdate, nowdate, today
import erpnext
@@ -124,10 +123,11 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
"_Test Account Discount - _TC": [0, 168.03],
"Round Off - _TC": [0, 0.3],
}
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=["account", "debit", "credit"],
gl_entries = frappe.db.sql(
"""select account, debit, credit from `tabGL Entry`
where voucher_type = 'Purchase Invoice' and voucher_no = %s""",
pi.name,
as_dict=1,
)
for d in gl_entries:
self.assertEqual([d.debit, d.credit], expected_gl_entries.get(d.account))
@@ -317,11 +317,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
self.check_gle_for_pi(pi.name)
def check_gle_for_pi(self, pi):
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi},
fields=["account", {"SUM": "debit", "as": "debit"}, {"SUM": "credit", "as": "credit"}],
group_by="account",
gl_entries = frappe.db.sql(
"""select account, sum(debit) as debit, sum(credit) as credit
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
group by account""",
pi,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -341,83 +342,6 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
self.assertEqual(expected_values[gle.account][1], gle.debit)
self.assertEqual(expected_values[gle.account][2], gle.credit)
def test_full_actual_charge_capitalized_on_stock_items_only(self):
"""On a stock-updating Purchase Invoice, an actual valuation charge (e.g. Freight) with
"Allocate Full Amount to Stock Items" checked is fully capitalized onto stock/asset items
only. For 2 stock items + 1 service item (each net 100) and a 30 freight charge, the charge
is distributed over the 200 stock net only (15 per stock item) and the entire 30 is
capitalized; nothing is lost to the non-stock item."""
from erpnext.stock import get_warehouse_account_map
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
company = "_Test Company with perpetual inventory"
warehouse = "Stores - TCP1"
stock_item1 = make_item(properties={"is_stock_item": 1}).name
stock_item2 = make_item(properties={"is_stock_item": 1}).name
service_item = make_item(properties={"is_stock_item": 0}).name
pi = frappe.new_doc("Purchase Invoice")
pi.company = company
pi.supplier = "_Test Supplier"
pi.currency = "INR"
pi.update_stock = 1
pi.credit_to = "Creditors - TCP1"
# Order matters: stock, service, stock (service item in the middle)
for code in (stock_item1, service_item, stock_item2):
pi.append(
"items",
{
"item_code": code,
"qty": 1,
"rate": 100,
"warehouse": warehouse,
"cost_center": "Main - TCP1",
"expense_account": "Cost of Goods Sold - TCP1",
},
)
pi.append(
"taxes",
{
"charge_type": "Actual",
"account_head": "_Test Account Shipping Charges - TCP1",
"category": "Valuation and Total",
"cost_center": "Main - TCP1",
"description": "Freight",
"tax_amount": 30,
# Default behavior: allocate the full amount to stock/asset items only
"allocate_full_amount_to_stock_items": 1,
},
)
pi.insert()
# 30 freight / 200 stock net = 15 per stock item. The service item carries nothing.
self.assertAlmostEqual(pi.items[0].item_tax_amount, 15.0, places=2)
self.assertAlmostEqual(pi.items[1].item_tax_amount, 0.0, places=2)
self.assertAlmostEqual(pi.items[2].item_tax_amount, 15.0, places=2)
pi.submit()
gl_entries = get_gl_entries("Purchase Invoice", pi.name, skip_cancelled=True, as_dict=True)
# Sum per account - the same account can appear in multiple GL rows (e.g. the stock account
# is debited once per item), so aggregate rather than keeping only the last row.
gl_map = {}
for row in gl_entries:
acc = gl_map.setdefault(row.account, {"debit": 0.0, "credit": 0.0})
acc["debit"] += row.debit
acc["credit"] += row.credit
warehouse_account = get_warehouse_account_map(company)
stock_account = warehouse_account[warehouse]["account"]
# Stock asset = 200 (goods) + 30 (the entire freight charge)
self.assertAlmostEqual(gl_map[stock_account]["debit"], 230.0, places=2)
# The whole freight charge (30) is capitalized
self.assertAlmostEqual(gl_map["_Test Account Shipping Charges - TCP1"]["credit"], 30.0, places=2)
@ERPNextTestSuite.change_settings(
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
)
@@ -537,11 +461,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
self.assertTrue(pi.status, "Unpaid")
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=["account", "debit", "credit"],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
order by account asc""",
pi.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -550,11 +475,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
["Creditors - TCP1", 0, 250],
]
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
self.assertEqual(
sorted((gle.account, gle.debit, gle.credit) for gle in gl_entries),
sorted((e[0], e[1], e[2]) for e in expected_values),
)
for i, gle in enumerate(gl_entries):
self.assertEqual(expected_values[i][0], gle.account)
self.assertEqual(expected_values[i][1], gle.debit)
self.assertEqual(expected_values[i][2], gle.credit)
def test_purchase_invoice_calculation(self):
pi = frappe.copy_doc(self.globalTestRecords["Purchase Invoice"][0])
@@ -622,24 +546,21 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pi.load_from_db()
self.assertTrue(
frappe.get_all(
"Journal Entry Account",
filters={
"reference_type": "Purchase Invoice",
"reference_name": pi.name,
"debit_in_account_currency": 300,
},
pluck="name",
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_type='Purchase Invoice'
and reference_name=%s and debit_in_account_currency=300""",
pi.name,
)
)
pi.cancel()
self.assertFalse(
frappe.get_all(
"Journal Entry Account",
filters={"reference_type": "Purchase Invoice", "reference_name": pi.name},
pluck="name",
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_type='Purchase Invoice' and reference_name=%s""",
pi.name,
)
)
@@ -683,14 +604,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pi.load_from_db()
self.assertTrue(
frappe.get_all(
"Journal Entry Account",
filters={
"reference_type": "Purchase Invoice",
"reference_name": pi.name,
"debit_in_account_currency": 300,
},
pluck="name",
frappe.db.sql(
"select name from `tabJournal Entry Account` where reference_type='Purchase Invoice' and "
"reference_name=%s and debit_in_account_currency=300",
pi.name,
)
)
@@ -699,10 +616,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pi.cancel()
self.assertFalse(
frappe.get_all(
"Journal Entry Account",
filters={"reference_type": "Purchase Invoice", "reference_name": pi.name},
pluck="name",
frappe.db.sql(
"select name from `tabJournal Entry Account` where reference_type='Purchase Invoice' and "
"reference_name=%s",
pi.name,
)
)
@@ -712,12 +629,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
else:
project = frappe.get_doc("Project", {"project_name": "_Test Project for Purchase"})
existing_purchase_cost = frappe.get_all(
"Purchase Invoice Item",
filters={"project": project.name, "docstatus": 1},
fields=[{"SUM": "base_net_amount", "as": "base_net_amount"}],
existing_purchase_cost = frappe.db.sql(
f"""select sum(base_net_amount)
from `tabPurchase Invoice Item`
where project = '{project.name}'
and docstatus=1"""
)
existing_purchase_cost = existing_purchase_cost and existing_purchase_cost[0].base_net_amount or 0
existing_purchase_cost = existing_purchase_cost and existing_purchase_cost[0][0] or 0
pi = make_purchase_invoice(currency="USD", conversion_rate=60, project=project.name)
self.assertEqual(
@@ -761,11 +679,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
)
# check gl entries for return
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": return_pi.name},
fields=["account", "debit", "credit"],
order_by="account desc",
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type=%s and voucher_no=%s
order by account desc""",
("Purchase Invoice", return_pi.name),
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -854,18 +773,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
conversion_rate=50,
)
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=[
"account",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
order by account asc""",
pi.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -907,10 +821,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
# cancel
pi.cancel()
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": pi.name},
pluck="name",
gle = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
pi.name,
)
self.assertFalse(gle)
@@ -928,18 +842,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
expense_account="_Test Account Cost for Goods Sold - TCP1",
)
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=[
"account",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
order by account asc""",
pi.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -968,16 +877,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
expense_account="_Test Account Cost for Goods Sold - TCP1",
)
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=[
"account",
{"SUM": "debit", "as": "debit"},
{"SUM": "credit", "as": "credit"},
],
group_by="account, voucher_no",
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, account_currency, sum(debit) as debit,
sum(credit) as credit, debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
group by account, voucher_no order by account asc;""",
pi.name,
as_dict=1,
)
stock_in_hand_account = get_inventory_account(pi.company, pi.get("items")[0].warehouse)
@@ -1239,19 +1145,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
"_Test Account Cost for Goods Sold - _TC": {"cost_center": cost_center},
}
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=[
"account",
"cost_center",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, cost_center, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
order by account asc""",
pi.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -1268,19 +1168,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
"_Test Account Cost for Goods Sold - _TC": {"cost_center": cost_center},
}
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=[
"account",
"cost_center",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, cost_center, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
order by account asc""",
pi.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -1315,20 +1209,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
"_Test Account Cost for Goods Sold - _TC": {"project": item_project.name},
}
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=[
"account",
"cost_center",
"project",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, cost_center, project, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
order by account asc""",
pi.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -1382,15 +1269,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
[deferred_account, 23.07, 0.0, "2019-03-15"],
]
gl_entries = frappe.get_all(
"GL Entry",
filters={
"voucher_type": "Journal Entry",
"voucher_detail_no": pi.items[0].name,
"posting_date": ["<=", pi.posting_date],
},
fields=["account", "debit", "credit", "posting_date"],
order_by="posting_date asc, account asc",
gl_entries = gl_entries = frappe.db.sql(
"""select account, debit, credit, posting_date
from `tabGL Entry`
where voucher_type='Journal Entry' and voucher_detail_no=%s and posting_date <= %s
order by posting_date asc, account asc""",
(pi.items[0].name, pi.posting_date),
as_dict=1,
)
for i, gle in enumerate(gl_entries):
@@ -1465,14 +1350,14 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
["_Test Payable USD - _TC", -37500.0],
]
gle = frappe.qb.DocType("GL Entry")
gl_entries = (
frappe.qb.from_(gle)
.select(gle.account, Sum(gle.debit - gle.credit).as_("balance"))
.where(gle.voucher_no == pi.name)
.groupby(gle.account)
.orderby(gle.account)
.run(as_dict=1)
gl_entries = frappe.db.sql(
"""
select account, sum(debit - credit) as balance from `tabGL Entry`
where voucher_no=%s
group by account
order by account asc""",
(pi.name),
as_dict=1,
)
for i, gle in enumerate(gl_entries):
@@ -1536,14 +1421,13 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
["_Test Payable USD - _TC", -36500.0],
]
gle = frappe.qb.DocType("GL Entry")
gl_entries = (
frappe.qb.from_(gle)
.select(gle.account, Sum(gle.debit - gle.credit).as_("balance"))
.where(gle.voucher_no == pi_2.name)
.groupby(gle.account)
.orderby(gle.account)
.run(as_dict=1)
gl_entries = frappe.db.sql(
"""
select account, sum(debit - credit) as balance from `tabGL Entry`
where voucher_no=%s
group by account order by account asc""",
(pi_2.name),
as_dict=1,
)
for i, gle in enumerate(gl_entries):
@@ -1552,21 +1436,18 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
expected_gle = [["_Test Payable USD - _TC", 70000.0], ["Cash - _TC", -70000.0]]
gle = frappe.qb.DocType("GL Entry")
gl_entries = (
frappe.qb.from_(gle)
.select(gle.account, Sum(gle.debit - gle.credit).as_("balance"))
.where((gle.voucher_no == pay.name) & (gle.is_cancelled == 0))
.groupby(gle.account)
.orderby(gle.account)
.run(as_dict=1)
gl_entries = frappe.db.sql(
"""
select account, sum(debit - credit) as balance from `tabGL Entry`
where voucher_no=%s and is_cancelled=0
group by account order by account asc""",
(pay.name),
as_dict=1,
)
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
self.assertEqual(
sorted((gle.account, gle.balance) for gle in gl_entries),
sorted((e[0], e[1]) for e in expected_gle),
)
for i, gle in enumerate(gl_entries):
self.assertEqual(expected_gle[i][0], gle.account)
self.assertEqual(expected_gle[i][1], gle.balance)
total_debit_amount = frappe.db.get_all(
"Journal Entry Account",
@@ -1665,18 +1546,19 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
[tds_account, 0, 3000],
]
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Payment Entry", "voucher_no": payment_entry.name},
fields=["account", "debit", "credit"],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry`
where voucher_type='Payment Entry' and voucher_no=%s
order by account asc""",
(payment_entry.name),
as_dict=1,
)
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
self.assertEqual(
sorted((gle.account, gle.debit, gle.credit) for gle in gl_entries),
sorted((e[0], e[1], e[2]) for e in expected_gle),
)
for i, gle in enumerate(gl_entries):
self.assertEqual(expected_gle[i][0], gle.account)
self.assertEqual(expected_gle[i][1], gle.debit)
self.assertEqual(expected_gle[i][2], gle.credit)
# Create Purchase Invoice against Purchase Order
purchase_invoice = get_mapped_purchase_invoice(po.name)
@@ -1690,21 +1572,19 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
# Zero net effect on final TDS payable on invoice
expected_gle = [["_Test Account Cost for Goods Sold - _TC", 30000], ["Creditors - _TC", -30000]]
gle = frappe.qb.DocType("GL Entry")
gl_entries = (
frappe.qb.from_(gle)
.select(gle.account, Sum(gle.debit - gle.credit).as_("amount"))
.where((gle.voucher_type == "Purchase Invoice") & (gle.voucher_no == purchase_invoice.name))
.groupby(gle.account)
.orderby(gle.account)
.run(as_dict=1)
gl_entries = frappe.db.sql(
"""select account, sum(debit - credit) as amount
from `tabGL Entry`
where voucher_type='Purchase Invoice' and voucher_no=%s
group by account
order by account asc""",
(purchase_invoice.name),
as_dict=1,
)
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
self.assertEqual(
sorted((gle.account, gle.amount) for gle in gl_entries),
sorted((e[0], e[1]) for e in expected_gle),
)
for i, gle in enumerate(gl_entries):
self.assertEqual(expected_gle[i][0], gle.account)
self.assertEqual(expected_gle[i][1], gle.amount)
payment_entry.load_from_db()
tax_allocated = sum(
@@ -2596,11 +2476,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pi.insert()
pi.submit()
pr_gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name},
fields=["account", "debit", "credit"],
order_by="account asc",
pr_gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Purchase Receipt' and voucher_no=%s
order by account asc""",
pr.name,
as_dict=1,
)
pr_expected_values = [
@@ -2613,11 +2494,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
self.assertEqual(pr_expected_values[i][1], gle.debit)
self.assertEqual(pr_expected_values[i][2], gle.credit)
pi_gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Purchase Invoice", "voucher_no": pi.name},
fields=["account", "debit", "credit"],
order_by="account asc",
pi_gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s
order by account asc""",
pi.name,
as_dict=1,
)
pi_expected_values = [
["Asset Received But Not Billed - _TC", 5000, 0],
@@ -3165,25 +3047,17 @@ def check_gl_entries(
gl_entries = query.run(as_dict=True)
# MariaDB and Postgres collate `account` differently, so the DB row order isn't portable.
# Match each actual GL row against the expected set instead of comparing positionally; like the
# original loop (which iterated the actual rows), extra expected rows are tolerated.
cols = additional_columns or []
for i, gle in enumerate(gl_entries):
doc.assertEqual(expected_gle[i][0], gle.account)
doc.assertEqual(expected_gle[i][1], gle.debit)
doc.assertEqual(expected_gle[i][2], gle.credit)
doc.assertEqual(getdate(expected_gle[i][3]), gle.posting_date)
def _key(account, debit, credit, posting_date, extras):
return (account, flt(debit), flt(credit), getdate(posting_date), *(str(v) for v in extras))
remaining = {}
for e in expected_gle:
k = _key(e[0], e[1], e[2], e[3], e[4 : 4 + len(cols)])
remaining[k] = remaining.get(k, 0) + 1
for gle in gl_entries:
k = _key(gle.account, gle.debit, gle.credit, gle.posting_date, [gle[c] for c in cols])
doc.assertGreater(
remaining.get(k, 0), 0, msg=f"Unexpected GL entry {k}; expected one of {list(remaining)}"
)
remaining[k] -= 1
if additional_columns:
j = 4
for col in additional_columns:
doc.assertEqual(expected_gle[i][j], gle[col])
j += 1
def create_tax_witholding_category(category_name, company, account):

View File

@@ -11,16 +11,13 @@
"add_deduct_tax",
"charge_type",
"row_id",
"included_in_print_rate",
"included_in_paid_amount",
"col_break1",
"account_head",
"description",
"section_break_uhfl",
"set_by_item_tax_template",
"is_tax_withholding_account",
"allocate_full_amount_to_stock_items",
"column_break_zqtz",
"included_in_print_rate",
"included_in_paid_amount",
"set_by_item_tax_template",
"section_break_10",
"rate",
"accounting_dimensions_section",
@@ -81,15 +78,6 @@
"oldfieldname": "row_id",
"oldfieldtype": "Data"
},
{
"default": "1",
"depends_on": "eval:doc.charge_type=='Actual' && ['Valuation', 'Valuation and Total'].includes(doc.category)",
"description": "If checked, the entire amount (e.g. Freight) is allocated to the valuation of stock & asset items only. If unchecked, the amount is distributed across all items and the portion belonging to non-stock items is not added to valuation.",
"fieldname": "allocate_full_amount_to_stock_items",
"fieldtype": "Check",
"label": "Allocate Full Amount to Stock Items",
"show_description_on_click": 1
},
{
"default": "0",
"description": "If checked, the tax amount will be considered as already included in the Print Rate / Print Amount",
@@ -284,21 +272,13 @@
"label": "Don't Recompute Tax",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "section_break_uhfl",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_zqtz",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-06-21 17:13:05.586544",
"modified": "2025-11-24 18:22:56.886010",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Taxes and Charges",

View File

@@ -17,7 +17,6 @@ class PurchaseTaxesandCharges(Document):
account_currency: DF.Link | None
account_head: DF.Link
add_deduct_tax: DF.Literal["Add", "Deduct"]
allocate_full_amount_to_stock_items: DF.Check
base_net_amount: DF.Currency
base_tax_amount: DF.Currency
base_tax_amount_after_discount_amount: DF.Currency

View File

@@ -69,7 +69,6 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
qb.from_(gl)
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
.groupby(gl.voucher_no)
.run()
)
@@ -83,7 +82,6 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
qb.from_(gl)
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
.groupby(gl.voucher_no)
.run()
)

View File

@@ -200,11 +200,106 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
set_purchase_references(target)
def update_details(source_doc, target_doc, source_parent):
def _validate_address_link(address, link_doctype, link_name):
return frappe.db.get_value(
"Dynamic Link",
{
"parent": address,
"parenttype": "Address",
"link_doctype": link_doctype,
"link_name": link_name,
},
"parent",
)
target_doc.inter_company_invoice_reference = source_doc.name
if target_doc.doctype in ["Purchase Invoice", "Purchase Order"]:
_apply_purchase_party_details(target_doc, source_doc, details)
currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency")
target_doc.company = details.get("company")
target_doc.supplier = details.get("party")
target_doc.is_internal_supplier = 1
target_doc.ignore_pricing_rule = 1
target_doc.buying_price_list = source_doc.selling_price_list
# Invert Addresses
if source_doc.company_address and _validate_address_link(
source_doc.company_address, "Supplier", details.get("party")
):
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
if source_doc.dispatch_address_name and _validate_address_link(
source_doc.dispatch_address_name, "Company", details.get("company")
):
update_address(
target_doc,
"dispatch_address",
"dispatch_address_display",
source_doc.dispatch_address_name,
)
if source_doc.shipping_address_name and _validate_address_link(
source_doc.shipping_address_name, "Company", details.get("company")
):
update_address(
target_doc,
"shipping_address",
"shipping_address_display",
source_doc.shipping_address_name,
)
if source_doc.customer_address and _validate_address_link(
source_doc.customer_address, "Company", details.get("company")
):
update_address(
target_doc, "billing_address", "billing_address_display", source_doc.customer_address
)
if currency:
target_doc.currency = currency
update_taxes(
target_doc,
party=target_doc.supplier,
party_type="Supplier",
company=target_doc.company,
doctype=target_doc.doctype,
party_address=target_doc.supplier_address,
company_address=target_doc.shipping_address,
)
else:
_apply_sales_party_details(target_doc, source_doc, details)
currency = frappe.db.get_value("Customer", details.get("party"), "default_currency")
target_doc.company = details.get("company")
target_doc.customer = details.get("party")
target_doc.selling_price_list = source_doc.buying_price_list
if source_doc.supplier_address and _validate_address_link(
source_doc.supplier_address, "Company", details.get("company")
):
update_address(
target_doc, "company_address", "company_address_display", source_doc.supplier_address
)
if source_doc.shipping_address and _validate_address_link(
source_doc.shipping_address, "Customer", details.get("party")
):
update_address(
target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address
)
if source_doc.shipping_address and _validate_address_link(
source_doc.shipping_address, "Customer", details.get("party")
):
update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
if currency:
target_doc.currency = currency
update_taxes(
target_doc,
party=target_doc.customer,
party_type="Customer",
company=target_doc.company,
doctype=target_doc.doctype,
party_address=target_doc.customer_address,
company_address=target_doc.company_address,
shipping_address_name=target_doc.shipping_address_name,
)
def update_item(source, target, source_parent):
target.qty = flt(source.qty) - received_items.get(source.name, 0.0)
@@ -283,97 +378,6 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
return doclist
def _get_linked_address(address, link_doctype, link_name):
return frappe.db.get_value(
"Dynamic Link",
{
"parent": address,
"parenttype": "Address",
"link_doctype": link_doctype,
"link_name": link_name,
},
"parent",
)
def _apply_purchase_party_details(target_doc, source_doc, details):
currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency")
target_doc.company = details.get("company")
target_doc.supplier = details.get("party")
target_doc.is_internal_supplier = 1
target_doc.ignore_pricing_rule = 1
target_doc.buying_price_list = source_doc.selling_price_list
# Invert Addresses
if source_doc.company_address and _get_linked_address(
source_doc.company_address, "Supplier", details.get("party")
):
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
if source_doc.dispatch_address_name and _get_linked_address(
source_doc.dispatch_address_name, "Company", details.get("company")
):
update_address(
target_doc, "dispatch_address", "dispatch_address_display", source_doc.dispatch_address_name
)
if source_doc.shipping_address_name and _get_linked_address(
source_doc.shipping_address_name, "Company", details.get("company")
):
update_address(
target_doc, "shipping_address", "shipping_address_display", source_doc.shipping_address_name
)
if source_doc.customer_address and _get_linked_address(
source_doc.customer_address, "Company", details.get("company")
):
update_address(target_doc, "billing_address", "billing_address_display", source_doc.customer_address)
if currency:
target_doc.currency = currency
update_taxes(
target_doc,
party=target_doc.supplier,
party_type="Supplier",
company=target_doc.company,
doctype=target_doc.doctype,
party_address=target_doc.supplier_address,
company_address=target_doc.shipping_address,
)
def _apply_sales_party_details(target_doc, source_doc, details):
currency = frappe.db.get_value("Customer", details.get("party"), "default_currency")
target_doc.company = details.get("company")
target_doc.customer = details.get("party")
target_doc.selling_price_list = source_doc.buying_price_list
if source_doc.supplier_address and _get_linked_address(
source_doc.supplier_address, "Company", details.get("company")
):
update_address(target_doc, "company_address", "company_address_display", source_doc.supplier_address)
if source_doc.shipping_address and _get_linked_address(
source_doc.shipping_address, "Customer", details.get("party")
):
update_address(target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address)
if source_doc.shipping_address and _get_linked_address(
source_doc.shipping_address, "Customer", details.get("party")
):
update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
if currency:
target_doc.currency = currency
update_taxes(
target_doc,
party=target_doc.customer,
party_type="Customer",
company=target_doc.company,
doctype=target_doc.doctype,
party_address=target_doc.customer_address,
company_address=target_doc.company_address,
shipping_address_name=target_doc.shipping_address_name,
)
@frappe.whitelist()
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
reference_field = "inter_company_invoice_reference"

View File

@@ -25,35 +25,30 @@ class FixedAssetService:
if doc.doctype != "Sales Invoice":
return
for item in doc.get("items"):
if item.is_fixed_asset:
self._validate_fixed_asset_item(item)
for d in doc.get("items"):
if not d.is_fixed_asset:
continue
def _validate_fixed_asset_item(self, item) -> None:
doc = self.doc
if not item.asset:
frappe.throw(
_("Row #{0}: You must select an Asset for Item {1}.").format(item.idx, item.item_code),
title=_("Missing Asset"),
)
if doc.is_return:
if not doc.return_against:
frappe.throw(_("Row #{0}: Return Against is required for returning asset").format(item.idx))
return
if doc.update_stock:
frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale"))
asset_status = frappe.db.get_value("Asset", item.asset, "status")
if asset_status in ("Scrapped", "Cancelled", "Capitalized"):
frappe.throw(
_("Row #{0}: Asset {1} cannot be sold, it is already {2}").format(
item.idx, item.asset, asset_status
if d.asset:
if not doc.is_return:
asset_status = frappe.db.get_value("Asset", d.asset, "status")
if doc.update_stock:
frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale"))
elif asset_status in ("Scrapped", "Cancelled", "Capitalized"):
frappe.throw(
_("Row #{0}: Asset {1} cannot be sold, it is already {2}").format(
d.idx, d.asset, asset_status
)
)
elif asset_status == "Sold" and not doc.is_return:
frappe.throw(_("Row #{0}: Asset {1} is already sold").format(d.idx, d.asset))
elif not doc.return_against:
frappe.throw(_("Row #{0}: Return Against is required for returning asset").format(d.idx))
else:
frappe.throw(
_("Row #{0}: You must select an Asset for Item {1}.").format(d.idx, d.item_code),
title=_("Missing Asset"),
)
)
if asset_status == "Sold":
frappe.throw(_("Row #{0}: Asset {1} is already sold").format(item.idx, item.asset))
def set_income_account_for_fixed_assets(self) -> None:
for item in self.doc.items:

View File

@@ -93,7 +93,54 @@ class SalesInvoiceGLComposer(BaseGLComposer):
if enable_discount_accounting:
for item in doc.get("items"):
if item.get("discount_amount") and item.get("discount_account"):
self._append_item_discount_gl_entries(item, gl_entries)
discount_amount = item.discount_amount * item.qty
income_account = (
item.income_account
if (not item.enable_deferred_revenue or doc.is_return)
else item.deferred_revenue_account
)
account_currency = get_account_currency(item.discount_account)
gl_entries.append(
doc.get_gl_dict(
{
"account": item.discount_account,
"against": doc.customer,
"debit": flt(
discount_amount * doc.get("conversion_rate"),
item.precision("discount_amount"),
),
"debit_in_transaction_currency": flt(
discount_amount, item.precision("discount_amount")
),
"cost_center": item.cost_center,
"project": item.project,
},
account_currency,
item=item,
)
)
account_currency = get_account_currency(income_account)
gl_entries.append(
doc.get_gl_dict(
{
"account": income_account,
"against": doc.customer,
"credit": flt(
discount_amount * doc.get("conversion_rate"),
item.precision("discount_amount"),
),
"credit_in_transaction_currency": flt(
discount_amount, item.precision("discount_amount")
),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
if (
(enable_discount_accounting or doc.get("is_cash_or_non_trade_discount"))
@@ -112,143 +159,81 @@ class SalesInvoiceGLComposer(BaseGLComposer):
)
)
def _append_item_discount_gl_entries(self, item, gl_entries) -> None:
doc = self.doc
discount_amount = item.discount_amount * item.qty
income_account = (
item.income_account
if (not item.enable_deferred_revenue or doc.is_return)
else item.deferred_revenue_account
)
account_currency = get_account_currency(item.discount_account)
gl_entries.append(
doc.get_gl_dict(
{
"account": item.discount_account,
"against": doc.customer,
"debit": flt(
discount_amount * doc.get("conversion_rate"),
item.precision("discount_amount"),
),
"debit_in_transaction_currency": flt(discount_amount, item.precision("discount_amount")),
"cost_center": item.cost_center,
"project": item.project,
},
account_currency,
item=item,
)
)
account_currency = get_account_currency(income_account)
gl_entries.append(
doc.get_gl_dict(
{
"account": income_account,
"against": doc.customer,
"credit": flt(
discount_amount * doc.get("conversion_rate"),
item.precision("discount_amount"),
),
"credit_in_transaction_currency": flt(discount_amount, item.precision("discount_amount")),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
def stock_delivered_but_not_billed_gl_entries(self, gl_entries):
doc = self.doc
if doc.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(doc.company)):
return
for item in doc.get("items"):
booking = self._get_sdbnb_booking_for_item(item)
if booking:
self._append_sdbnb_gl_entries(item, booking, gl_entries)
if not item.delivery_note and not item.dn_detail:
continue
def _get_sdbnb_booking_for_item(self, item) -> dict | None:
"""SDBNB account and valuation to reverse for a billed-from-delivery-note item, if any."""
if not item.delivery_note and not item.dn_detail:
return None
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
continue
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
return None
dn_expense_account = frappe.get_cached_value("Delivery Note Item", item.dn_detail, "expense_account")
if not self._is_sdbnb_reversal(dn_expense_account, item):
return None
delivery_note = item.delivery_note or frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "parent"
)
if not delivery_note:
return None
item_g = frappe.get_cached_value(
"Stock Ledger Entry",
{
"voucher_no": delivery_note,
"voucher_detail_no": item.dn_detail,
"item_code": item.item_code,
"is_cancelled": 0,
},
["stock_value_difference", "actual_qty"],
as_dict=True,
)
if not item_g or not flt(item_g.actual_qty):
return None
valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty)
return {
"dn_expense_account": dn_expense_account,
"valuation_amount": valuation_rate * item.stock_qty,
}
def _is_sdbnb_reversal(self, dn_expense_account, item) -> bool:
"""True when the DN booked to an SDBNB account distinct from the item's expense account."""
return bool(
dn_expense_account
and frappe.get_cached_value("Account", dn_expense_account, "account_type")
== "Stock Delivered But Not Billed"
and item.expense_account
and dn_expense_account != item.expense_account
)
def _append_sdbnb_gl_entries(self, item, booking, gl_entries) -> None:
dn_expense_account = booking["dn_expense_account"]
valuation_amount = booking["valuation_amount"]
dn_account_currency = get_account_currency(dn_expense_account)
item_account_currency = get_account_currency(item.expense_account)
gl_entries.append(
self.get_gl_dict(
{
"account": dn_expense_account,
"against": item.expense_account,
"credit": flt(valuation_amount),
"credit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
dn_account_currency,
item=item,
dn_expense_account = frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "expense_account"
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": item.expense_account,
"against": dn_expense_account,
"debit": flt(valuation_amount),
"debit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
item_account_currency,
item=item,
if (
not dn_expense_account
or frappe.get_cached_value("Account", dn_expense_account, "account_type")
!= "Stock Delivered But Not Billed"
or not item.expense_account
or dn_expense_account == item.expense_account
):
continue
delivery_note = item.delivery_note or frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "parent"
)
if not delivery_note:
continue
item_g = frappe.get_cached_value(
"Stock Ledger Entry",
{
"voucher_no": delivery_note,
"voucher_detail_no": item.dn_detail,
"item_code": item.item_code,
"is_cancelled": 0,
},
["stock_value_difference", "actual_qty"],
as_dict=True,
)
if not item_g or not flt(item_g.actual_qty):
continue
valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty)
valuation_amount = valuation_rate * item.stock_qty
dn_account_currency = get_account_currency(dn_expense_account)
item_account_currency = get_account_currency(item.expense_account)
gl_entries.append(
self.get_gl_dict(
{
"account": dn_expense_account,
"against": item.expense_account,
"credit": flt(valuation_amount),
"credit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
dn_account_currency,
item=item,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": item.expense_account,
"against": dn_expense_account,
"debit": flt(valuation_amount),
"debit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
item_account_currency,
item=item,
)
)
)
def make_customer_gl_entry(self, gl_entries):
doc = self.doc
@@ -265,6 +250,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
)
if grand_total and not doc.is_internal_transfer():
against_voucher = doc.name
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
against_voucher = doc.return_against
# Did not use base_grand_total to book rounding loss gle
gl_entries.append(
self.get_gl_dict(
@@ -275,11 +264,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"due_date": doc.due_date,
"against": doc.against_income_account,
"debit": base_grand_total,
"debit_in_account_currency": self._get_amount_in_account_currency(
doc.party_account_currency, base_grand_total, grand_total
),
"debit_in_account_currency": base_grand_total
if doc.party_account_currency == doc.company_currency
else grand_total,
"debit_in_transaction_currency": grand_total,
"against_voucher": self._resolve_against_voucher(),
"against_voucher": against_voucher,
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
"project": doc.project,
@@ -307,10 +296,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"account": tax.account_head,
"against": doc.customer,
"credit": flt(base_amount, tax.precision("tax_amount_after_discount_amount")),
"credit_in_account_currency": self._get_amount_in_account_currency(
account_currency,
flt(base_amount, tax.precision("base_tax_amount_after_discount_amount")),
flt(amount, tax.precision("tax_amount_after_discount_amount")),
"credit_in_account_currency": (
flt(base_amount, tax.precision("base_tax_amount_after_discount_amount"))
if account_currency == doc.company_currency
else flt(amount, tax.precision("tax_amount_after_discount_amount"))
),
"credit_in_transaction_currency": flt(
amount, tax.precision("tax_amount_after_discount_amount")
@@ -352,57 +341,53 @@ class SalesInvoiceGLComposer(BaseGLComposer):
)
for item in doc.get("items"):
if not (
if (
flt(item.base_net_amount, item.precision("base_net_amount"))
or item.is_fixed_asset
or enable_discount_accounting
):
continue
# Do not book income for transfer within same company
if doc.is_internal_transfer():
continue
# Do not book income for transfer within same company
if doc.is_internal_transfer():
continue
if item.is_fixed_asset and item.asset:
self.get_gl_entries_for_fixed_asset(item, gl_entries)
else:
income_account = (
item.income_account
if (not item.enable_deferred_revenue or doc.is_return)
else item.deferred_revenue_account
)
if item.is_fixed_asset and item.asset:
self.get_gl_entries_for_fixed_asset(item, gl_entries)
else:
self._append_item_income_gl_entry(item, gl_entries, tax_service, enable_discount_accounting)
amount, base_amount = tax_service.get_amount_and_base_amount(
item, enable_discount_accounting
)
account_currency = get_account_currency(income_account)
gl_entries.append(
self.get_gl_dict(
{
"account": income_account,
"against": doc.customer,
"credit": flt(base_amount, item.precision("base_net_amount")),
"credit_in_account_currency": (
flt(base_amount, item.precision("base_net_amount"))
if account_currency == doc.company_currency
else flt(amount, item.precision("net_amount"))
),
"credit_in_transaction_currency": flt(amount, item.precision("net_amount")),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
# expense account gl entries
if cint(doc.update_stock) and erpnext.is_perpetual_inventory_enabled(doc.company):
gl_entries += super(SalesInvoice, doc).get_gl_entries()
def _append_item_income_gl_entry(self, item, gl_entries, tax_service, enable_discount_accounting) -> None:
doc = self.doc
income_account = (
item.income_account
if (not item.enable_deferred_revenue or doc.is_return)
else item.deferred_revenue_account
)
amount, base_amount = tax_service.get_amount_and_base_amount(item, enable_discount_accounting)
account_currency = get_account_currency(income_account)
gl_entries.append(
self.get_gl_dict(
{
"account": income_account,
"against": doc.customer,
"credit": flt(base_amount, item.precision("base_net_amount")),
"credit_in_account_currency": self._get_amount_in_account_currency(
account_currency,
flt(base_amount, item.precision("base_net_amount")),
flt(amount, item.precision("net_amount")),
),
"credit_in_transaction_currency": flt(amount, item.precision("net_amount")),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
def get_gl_entries_for_fixed_asset(self, item, gl_entries):
doc = self.doc
asset = frappe.get_cached_doc("Asset", item.asset)
@@ -476,6 +461,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
if skip_change_gl_entries and payment_mode.account == doc.account_for_change_amount:
payment_mode.base_amount -= flt(doc.change_amount)
against_voucher = doc.name
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
against_voucher = doc.return_against
if payment_mode.base_amount:
# POS, make payment entries
gl_entries.append(
@@ -486,11 +475,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"party": doc.customer,
"against": payment_mode.account,
"credit": payment_mode.base_amount,
"credit_in_account_currency": self._get_amount_in_account_currency(
doc.party_account_currency, payment_mode.base_amount, payment_mode.amount
),
"credit_in_account_currency": payment_mode.base_amount
if doc.party_account_currency == doc.company_currency
else payment_mode.amount,
"credit_in_transaction_currency": payment_mode.amount,
"against_voucher": self._resolve_against_voucher(),
"against_voucher": against_voucher,
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
},
@@ -506,11 +495,9 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"account": payment_mode.account,
"against": doc.customer,
"debit": payment_mode.base_amount,
"debit_in_account_currency": self._get_amount_in_account_currency(
payment_mode_account_currency,
payment_mode.base_amount,
payment_mode.amount,
),
"debit_in_account_currency": payment_mode.base_amount
if payment_mode_account_currency == doc.company_currency
else payment_mode.amount,
"debit_in_transaction_currency": payment_mode.amount,
"cost_center": doc.cost_center,
},
@@ -538,9 +525,9 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"party": doc.customer,
"against": doc.account_for_change_amount,
"debit": flt(doc.base_change_amount),
"debit_in_account_currency": self._get_amount_in_account_currency(
doc.party_account_currency, flt(doc.base_change_amount), flt(doc.change_amount)
),
"debit_in_account_currency": flt(doc.base_change_amount)
if doc.party_account_currency == doc.company_currency
else flt(doc.change_amount),
"debit_in_transaction_currency": flt(doc.change_amount),
"against_voucher": doc.return_against
if cint(doc.is_return) and doc.return_against
@@ -583,10 +570,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"party": doc.customer,
"against": doc.write_off_account,
"credit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
"credit_in_account_currency": self._get_amount_in_account_currency(
doc.party_account_currency,
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
flt(doc.write_off_amount, doc.precision("write_off_amount")),
"credit_in_account_currency": (
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount"))
if doc.party_account_currency == doc.company_currency
else flt(doc.write_off_amount, doc.precision("write_off_amount"))
),
"credit_in_transaction_currency": flt(
doc.write_off_amount, doc.precision("write_off_amount")
@@ -606,10 +593,10 @@ class SalesInvoiceGLComposer(BaseGLComposer):
"account": doc.write_off_account,
"against": doc.customer,
"debit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
"debit_in_account_currency": self._get_amount_in_account_currency(
write_off_account_currency,
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
flt(doc.write_off_amount, doc.precision("write_off_amount")),
"debit_in_account_currency": (
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount"))
if write_off_account_currency == doc.company_currency
else flt(doc.write_off_amount, doc.precision("write_off_amount"))
),
"debit_in_transaction_currency": flt(
doc.write_off_amount, doc.precision("write_off_amount")
@@ -672,14 +659,3 @@ class SalesInvoiceGLComposer(BaseGLComposer):
item=doc,
)
)
def _get_amount_in_account_currency(self, account_currency, base_amount, transaction_amount):
"""Base amount when the account is in company currency, else the transaction amount."""
return base_amount if account_currency == self.doc.company_currency else transaction_amount
def _resolve_against_voucher(self) -> str:
"""Settle against the original invoice for returns not kept on their own outstanding."""
doc = self.doc
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
return doc.return_against
return doc.name

View File

@@ -13,54 +13,36 @@ def validate_inter_company_party(
if not party:
return
config = _get_inter_company_party_config(doctype)
if doctype in ["Sales Invoice", "Sales Order"]:
partytype, ref_partytype, internal = "Customer", "Supplier", "is_internal_customer"
ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order"
else:
partytype, ref_partytype, internal = "Supplier", "Customer", "is_internal_supplier"
ref_doc = "Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order"
if inter_company_reference:
_validate_against_reference(config, party, company, inter_company_reference)
elif frappe.db.get_value(config.partytype, {"name": party, config.internal: 1}, "name") == party:
_validate_internal_party_company(config.partytype, party, company)
doc = frappe.get_doc(ref_doc, inter_company_reference)
ref_party = doc.supplier if doctype in ["Sales Invoice", "Sales Order"] else doc.customer
if frappe.db.get_value(partytype, {"represents_company": doc.company}, "name") != party:
frappe.throw(_("Invalid {0} for Inter Company Transaction.").format(_(partytype)))
if frappe.get_cached_value(ref_partytype, ref_party, "represents_company") != company:
frappe.throw(_("Invalid Company for Inter Company Transaction."))
def _get_inter_company_party_config(doctype: str) -> "frappe._dict":
if doctype in ["Sales Invoice", "Sales Order"]:
return frappe._dict(
partytype="Customer",
ref_partytype="Supplier",
internal="is_internal_customer",
ref_doc="Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order",
)
return frappe._dict(
partytype="Supplier",
ref_partytype="Customer",
internal="is_internal_supplier",
ref_doc="Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order",
)
def _validate_against_reference(config, party: str, company: str, inter_company_reference: str) -> None:
doc = frappe.get_doc(config.ref_doc, inter_company_reference)
ref_party = doc.supplier if config.partytype == "Customer" else doc.customer
if frappe.db.get_value(config.partytype, {"represents_company": doc.company}, "name") != party:
frappe.throw(_("Invalid {0} for Inter Company Transaction.").format(_(config.partytype)))
if frappe.get_cached_value(config.ref_partytype, ref_party, "represents_company") != company:
frappe.throw(_("Invalid Company for Inter Company Transaction."))
def _validate_internal_party_company(partytype: str, party: str, company: str) -> None:
companies = [
d.company
for d in frappe.get_all(
"Allowed To Transact With",
fields=["company"],
filters={"parenttype": partytype, "parent": party},
)
]
if company not in companies:
frappe.throw(
_(
"{0} not allowed to transact with {1}. Please change the Company or add the Company in the 'Allowed To Transact With'-Section in the Customer record."
).format(_(partytype), company)
)
elif frappe.db.get_value(partytype, {"name": party, internal: 1}, "name") == party:
companies = [
d.company
for d in frappe.get_all(
"Allowed To Transact With",
fields=["company"],
filters={"parenttype": partytype, "parent": party},
)
]
if company not in companies:
frappe.throw(
_(
"{0} not allowed to transact with {1}. Please change the Company or add the Company in the 'Allowed To Transact With'-Section in the Customer record."
).format(_(partytype), company)
)
def update_linked_doc(doctype: str, name: str, inter_company_reference: str | None) -> None:

View File

@@ -4,7 +4,7 @@
"""POS helpers for Sales Invoice."""
import frappe
from frappe import _
from frappe import _, msgprint
from frappe.utils import cint, flt, get_link_to_form
@@ -13,140 +13,106 @@ class PartialPaymentValidationError(frappe.ValidationError):
class POSService:
def __init__(self, doc) -> None:
def __init__(self, doc):
self.doc = doc
def set_pos_fields(self, for_validate: bool = False) -> frappe.Document | dict | None:
"""Populate POS-profile fields on the invoice; return the profile, {} or None."""
def set_pos_fields(self, for_validate: bool = False) -> frappe.Document | None:
"""Populate POS-profile fields on the invoice; return the profile or None."""
doc = self.doc
if cint(doc.is_pos) != 1:
return None
self._set_default_change_amount_account()
if not self._ensure_pos_profile():
return None
pos = frappe.get_doc("POS Profile", doc.pos_profile) if doc.pos_profile else {}
if pos:
self._apply_pos_profile(pos, for_validate)
return pos
def _set_default_change_amount_account(self) -> None:
doc = self.doc
if not doc.account_for_change_amount:
doc.account_for_change_amount = frappe.get_cached_value(
"Company", doc.company, "default_cash_account"
)
def _ensure_pos_profile(self) -> bool:
"""Auto-pick a POS Profile for the company; return False if none could be found."""
doc = self.doc
if doc.pos_profile or doc.flags.ignore_pos_profile:
return True
from erpnext.stock.get_item_details import (
ItemDetailsCtx,
get_pos_profile,
get_pos_profile_item_details_,
)
from erpnext.stock.get_item_details import get_pos_profile
if not doc.pos_profile and not doc.flags.ignore_pos_profile:
pos_profile = get_pos_profile(doc.company) or {}
if not pos_profile:
return None
doc.pos_profile = pos_profile.get("name")
pos_profile = get_pos_profile(doc.company) or {}
if not pos_profile:
return False
pos = {}
if doc.pos_profile:
pos = frappe.get_doc("POS Profile", doc.pos_profile)
doc.pos_profile = pos_profile.get("name")
return True
if pos:
if not for_validate:
update_multi_mode_option(doc, pos)
doc.tax_category = pos.get("tax_category")
def _apply_pos_profile(self, pos, for_validate: bool) -> None:
doc = self.doc
if not for_validate:
self._apply_editable_pos_defaults(pos)
if not for_validate and not doc.customer:
doc.customer = pos.customer
if pos.get("account_for_change_amount"):
doc.account_for_change_amount = pos.get("account_for_change_amount")
if not for_validate:
doc.ignore_pricing_rule = pos.ignore_pricing_rule
self._copy_pos_profile_fields(pos, for_validate)
if pos.get("account_for_change_amount"):
doc.account_for_change_amount = pos.get("account_for_change_amount")
if pos.get("company_address"):
doc.company_address = pos.get("company_address")
for fieldname in (
"currency",
"letter_head",
"tc_name",
"company",
"select_print_heading",
"write_off_account",
"taxes_and_charges",
"write_off_cost_center",
"apply_discount_on",
"cost_center",
):
if (not for_validate) or (for_validate and not doc.get(fieldname)):
doc.set(fieldname, pos.get(fieldname))
self._set_selling_price_list(pos)
if pos.get("company_address"):
doc.company_address = pos.get("company_address")
if not for_validate:
self._set_update_stock_from_profile(pos)
if doc.customer:
customer_price_list, customer_group = frappe.get_value(
"Customer", doc.customer, ["default_price_list", "customer_group"]
)
customer_group_price_list = frappe.get_value(
"Customer Group", customer_group, "default_price_list"
)
selling_price_list = (
customer_price_list or customer_group_price_list or pos.get("selling_price_list")
)
else:
selling_price_list = pos.get("selling_price_list")
self._apply_pos_item_defaults(pos, for_validate)
self._set_terms_and_taxes(pos)
if selling_price_list:
doc.set("selling_price_list", selling_price_list)
def _apply_editable_pos_defaults(self, pos) -> None:
"""Profile defaults the user may override; only applied outside validation."""
doc = self.doc
update_multi_mode_option(doc, pos)
doc.tax_category = pos.get("tax_category")
if not doc.customer:
doc.customer = pos.customer
doc.ignore_pricing_rule = pos.ignore_pricing_rule
if not for_validate:
dn_flag = any(d.get("dn_detail") for d in doc.get("items"))
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
def _copy_pos_profile_fields(self, pos, for_validate: bool) -> None:
doc = self.doc
for fieldname in (
"currency",
"letter_head",
"tc_name",
"company",
"select_print_heading",
"write_off_account",
"taxes_and_charges",
"write_off_cost_center",
"apply_discount_on",
"cost_center",
):
if (not for_validate) or (for_validate and not doc.get(fieldname)):
doc.set(fieldname, pos.get(fieldname))
for item in doc.get("items"):
if item.get("item_code"):
profile_details = get_pos_profile_item_details_(
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
)
for fname, val in profile_details.items():
if (not for_validate) or (for_validate and not item.get(fname)):
item.set(fname, val)
def _set_selling_price_list(self, pos) -> None:
doc = self.doc
if doc.customer:
customer_price_list, customer_group = frappe.get_value(
"Customer", doc.customer, ["default_price_list", "customer_group"]
)
customer_group_price_list = frappe.get_value(
"Customer Group", customer_group, "default_price_list"
)
selling_price_list = (
customer_price_list or customer_group_price_list or pos.get("selling_price_list")
)
else:
selling_price_list = pos.get("selling_price_list")
if doc.tc_name and not doc.terms:
doc.terms = frappe.db.get_value("Terms and Conditions", doc.tc_name, "terms")
if selling_price_list:
doc.set("selling_price_list", selling_price_list)
if doc.taxes_and_charges and not len(doc.get("taxes")):
from erpnext.accounts.services.taxes import TaxService
def _set_update_stock_from_profile(self, pos) -> None:
doc = self.doc
dn_flag = any(d.get("dn_detail") for d in doc.get("items"))
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
TaxService(doc).set_taxes()
def _apply_pos_item_defaults(self, pos, for_validate: bool) -> None:
from erpnext.stock.get_item_details import ItemDetailsCtx, get_pos_profile_item_details_
for item in self.doc.get("items"):
if not item.get("item_code"):
continue
profile_details = get_pos_profile_item_details_(
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
)
for fname, val in profile_details.items():
if (not for_validate) or (for_validate and not item.get(fname)):
item.set(fname, val)
def _set_terms_and_taxes(self, pos) -> None:
doc = self.doc
if doc.tc_name and not doc.terms:
doc.terms = frappe.db.get_value("Terms and Conditions", doc.tc_name, "terms")
if doc.taxes_and_charges and not len(doc.get("taxes")):
from erpnext.accounts.services.taxes import TaxService
TaxService(doc).set_taxes()
return pos
def update_paid_amount(self) -> None:
doc = self.doc
@@ -178,7 +144,6 @@ class POSService:
doc.paid_amount = 0
def validate_pos_return(self) -> None:
"""Ensure POS return payments are not less than the (negative) invoice total."""
doc = self.doc
if doc.is_consolidated:
return
@@ -195,7 +160,6 @@ class POSService:
frappe.throw(_("At least one mode of payment is required for POS invoice."))
def validate_pos(self) -> None:
"""On a POS return, paid amount plus write-off cannot exceed the grand total."""
doc = self.doc
if doc.is_return:
invoice_total = doc.rounded_total or doc.grand_total
@@ -216,7 +180,6 @@ class POSService:
self.validate_pos_opening_entry()
def validate_full_payment(self) -> None:
"""Block partial payment on a submitted POS invoice unless the profile allows it."""
doc = self.doc
allow_partial_payment = frappe.db.get_value("POS Profile", doc.pos_profile, "allow_partial_payment")
invoice_total = flt(doc.rounded_total) or flt(doc.grand_total)
@@ -233,7 +196,6 @@ class POSService:
)
def validate_pos_opening_entry(self) -> None:
"""Require exactly one current, open POS Opening Entry for the profile."""
doc = self.doc
opening_entries = frappe.get_all(
"POS Opening Entry",
@@ -319,6 +281,38 @@ class POSService:
if entry.amount > 0:
frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx))
def get_warehouse(self) -> str | None:
doc = self.doc
POSProfile = frappe.qb.DocType("POS Profile")
user_query = (
frappe.qb.from_(POSProfile)
.select(POSProfile.name, POSProfile.warehouse)
.where(POSProfile.company == doc.company)
.where(
(POSProfile.user == frappe.session["user"])
| ((POSProfile.user.isnull() | (POSProfile.user == "")) & (frappe.session["user"] == ""))
)
)
user_pos_profile = user_query.run()
warehouse = user_pos_profile[0][1] if user_pos_profile else None
if not warehouse:
global_query = (
frappe.qb.from_(POSProfile)
.select(POSProfile.name, POSProfile.warehouse)
.where(POSProfile.company == doc.company)
.where(POSProfile.user.isnull() | (POSProfile.user == ""))
)
global_pos_profile = global_query.run()
if global_pos_profile:
warehouse = global_pos_profile[0][1]
elif not user_pos_profile:
msgprint(_("POS Profile required to make POS Entry"), raise_exception=True)
return warehouse
def get_bank_cash_account(mode_of_payment: str, company: str) -> dict:
account = frappe.db.get_value(
@@ -375,43 +369,61 @@ def update_multi_mode_option(doc, pos_profile) -> None:
def get_all_mode_of_payments(doc) -> list:
"""All enabled modes of payment with their default accounts for the doc's company."""
query, mopa, mop = _enabled_mode_of_payment_query(doc.company)
return query.select(mopa.default_account, mopa.parent, mop.type.as_("type")).run(as_dict=1)
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
query = (
frappe.qb.from_(ModeOfPaymentAccount)
.join(ModeOfPayment)
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
.select(
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
)
.where(ModeOfPaymentAccount.company == doc.company)
.where(ModeOfPayment.enabled == 1)
)
return query.run(as_dict=1)
def get_mode_of_payments_info(mode_of_payments: list, company: str) -> dict:
"""Map each of the named modes of payment to its account info for the company."""
query, mopa, mop = _enabled_mode_of_payment_query(company)
data = (
query.select(mopa.default_account, mopa.parent.as_("mop"), mop.type.as_("type"))
.where(mop.name.isin(mode_of_payments))
# group by all selected columns so postgres accepts it (one row per mode of payment)
.groupby(mopa.default_account, mopa.parent, mop.type)
.run(as_dict=1)
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
query = (
frappe.qb.from_(ModeOfPaymentAccount)
.join(ModeOfPayment)
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
.select(
ModeOfPaymentAccount.default_account,
ModeOfPaymentAccount.parent.as_("mop"),
ModeOfPayment.type.as_("type"),
)
.where(ModeOfPaymentAccount.company == company)
.where(ModeOfPayment.enabled == 1)
.where(ModeOfPayment.name.isin(mode_of_payments))
.groupby(ModeOfPayment.name)
)
data = query.run(as_dict=1)
return {row.get("mop"): row for row in data}
def get_mode_of_payment_info(mode_of_payment: str, company: str) -> list:
"""Account info for a single mode of payment in the company."""
query, mopa, mop = _enabled_mode_of_payment_query(company)
return (
query.select(mopa.default_account, mopa.parent, mop.type.as_("type"))
.where(mop.name == mode_of_payment)
.run(as_dict=1)
)
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
def _enabled_mode_of_payment_query(company: str):
"""Base query joining enabled modes of payment to their accounts for a company."""
mopa = frappe.qb.DocType("Mode of Payment Account")
mop = frappe.qb.DocType("Mode of Payment")
query = (
frappe.qb.from_(mopa)
.join(mop)
.on(mopa.parent == mop.name)
.where(mopa.company == company)
.where(mop.enabled == 1)
frappe.qb.from_(ModeOfPayment)
.join(ModeOfPaymentAccount)
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
.select(
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
)
.where(ModeOfPaymentAccount.company == company)
.where(ModeOfPayment.enabled == 1)
.where(ModeOfPayment.name == mode_of_payment)
)
return query, mopa, mop
return query.run(as_dict=1)

View File

@@ -21,52 +21,45 @@ class StatusService:
doc.status = "Draft"
return
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
total = get_total_in_party_account_currency(doc)
if not status:
if doc.docstatus == 2:
status = "Cancelled"
elif doc.docstatus == 1:
doc.status = self._get_submitted_status()
if doc.is_internal_transfer():
doc.status = "Internal Transfer"
elif is_overdue(doc, total):
doc.status = "Overdue"
elif 0 < outstanding_amount < total:
doc.status = "Partly Paid"
elif outstanding_amount > 0 and getdate(doc.due_date) >= getdate():
doc.status = "Unpaid"
elif doc.is_return == 0 and frappe.db.get_value(
"Sales Invoice", {"is_return": 1, "return_against": doc.name, "docstatus": 1}
):
doc.status = "Credit Note Issued"
elif doc.is_return == 1:
doc.status = "Return"
elif outstanding_amount <= 0:
doc.status = "Paid"
else:
doc.status = "Submitted"
if (
doc.status in ("Unpaid", "Partly Paid", "Overdue")
and doc.is_discounted
and get_discounting_status(doc.name) == "Disbursed"
):
doc.status += " and Discounted"
else:
doc.status = "Draft"
if update:
doc.db_set("status", doc.status, update_modified=update_modified)
def _get_submitted_status(self) -> str:
"""Status of a submitted invoice, with the invoice-discounting suffix applied."""
doc = self.doc
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
total = get_total_in_party_account_currency(doc)
status = self._get_payment_status(outstanding_amount, total)
if (
status in ("Unpaid", "Partly Paid", "Overdue")
and doc.is_discounted
and get_discounting_status(doc.name) == "Disbursed"
):
status += " and Discounted"
return status
def _get_payment_status(self, outstanding_amount: float, total: float) -> str:
doc = self.doc
if doc.is_internal_transfer():
return "Internal Transfer"
if is_overdue(doc, total):
return "Overdue"
if 0 < outstanding_amount < total:
return "Partly Paid"
if outstanding_amount > 0 and getdate(doc.due_date) >= getdate():
return "Unpaid"
if doc.is_return == 0 and frappe.db.get_value(
"Sales Invoice", {"is_return": 1, "return_against": doc.name, "docstatus": 1}
):
return "Credit Note Issued"
if doc.is_return == 1:
return "Return"
if outstanding_amount <= 0:
return "Paid"
return "Submitted"
def set_indicator(self) -> None:
doc = self.doc
if doc.outstanding_amount < 0:

View File

@@ -99,24 +99,23 @@ class TimesheetBillingService:
doc.total_billing_hours = sum(flt(ts.billing_hours) for ts in doc.timesheets)
def _update_time_sheet_detail(self, timesheet, args, sales_invoice: str | None) -> None:
for data in timesheet.time_logs:
if args.timesheet_detail == data.name and self._should_set_sales_invoice(data, sales_invoice):
data.sales_invoice = sales_invoice
def _should_set_sales_invoice(self, time_log, sales_invoice: str | None) -> bool:
"""Whether this time log's sales-invoice link should be (re)set to sales_invoice."""
doc = self.doc
if doc.project:
return True
if not time_log.sales_invoice:
return True
if not sales_invoice and time_log.sales_invoice == doc.name:
# clearing the link on cancellation of this invoice
return True
# clearing the link on a return raised against the original invoice
return bool(
doc.is_return
and doc.return_against
and not sales_invoice
and time_log.sales_invoice == doc.return_against
)
for data in timesheet.time_logs:
if (
(doc.project and args.timesheet_detail == data.name)
or (not doc.project and not data.sales_invoice and args.timesheet_detail == data.name)
or (
not sales_invoice
and data.sales_invoice == doc.name
and args.timesheet_detail == data.name
)
or (
doc.is_return
and doc.return_against
and data.sales_invoice
and data.sales_invoice == doc.return_against
and not sales_invoice
and args.timesheet_detail == data.name
)
):
data.sales_invoice = sales_invoice

View File

@@ -20,12 +20,6 @@ from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
unlink_payment_on_cancel_of_invoice,
)
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction
from erpnext.accounts.doctype.sales_invoice.services.pos import (
POSService,
get_all_mode_of_payments,
get_mode_of_payment_info,
get_mode_of_payments_info,
)
from erpnext.accounts.utils import PaymentEntryUnlinkError
from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
from erpnext.assets.doctype.asset.test_asset import create_asset
@@ -745,11 +739,12 @@ class TestSalesInvoice(ERPNextTestSuite):
si.insert()
si.submit()
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
si.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -780,10 +775,10 @@ class TestSalesInvoice(ERPNextTestSuite):
# cancel
si.cancel()
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["*"],
gle = frappe.db.sql(
"""select * from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
si.name,
)
self.assertTrue(gle)
@@ -1200,11 +1195,12 @@ class TestSalesInvoice(ERPNextTestSuite):
si.insert()
si.submit()
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
si.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -1227,10 +1223,10 @@ class TestSalesInvoice(ERPNextTestSuite):
# cancel
si.cancel()
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["*"],
gle = frappe.db.sql(
"""select * from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
si.name,
)
self.assertTrue(gle)
@@ -1350,101 +1346,6 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(pos.grand_total, 100.0)
self.assertEqual(pos.write_off_amount, 0)
def test_set_pos_fields_populates_invoice_from_profile(self):
terms = frappe.db.exists("Terms and Conditions", "_Test POS Terms")
if not terms:
terms = (
frappe.get_doc(
{
"doctype": "Terms and Conditions",
"title": "_Test POS Terms",
"terms": "POS terms and conditions",
"selling": 1,
}
)
.insert()
.name
)
profile = make_pos_profile()
profile.customer = "_Test Customer"
profile.tax_category = "_Test Tax Category 1"
profile.account_for_change_amount = "Cash - _TC"
profile.ignore_pricing_rule = 1
profile.update_stock = 1
profile.apply_discount_on = "Grand Total"
profile.tc_name = terms
profile.taxes_and_charges = "_Test Sales Taxes and Charges Template - _TC"
profile.save()
si = create_sales_invoice(do_not_save=True)
si.is_pos = 1
si.pos_profile = profile.name
si.customer = None
si.taxes = []
POSService(si).set_pos_fields(for_validate=False)
self.assertEqual(si.customer, "_Test Customer")
self.assertEqual(si.tax_category, "_Test Tax Category 1")
self.assertEqual(si.ignore_pricing_rule, 1)
self.assertEqual(si.account_for_change_amount, "Cash - _TC")
self.assertEqual(si.taxes_and_charges, "_Test Sales Taxes and Charges Template - _TC")
self.assertEqual(si.apply_discount_on, "Grand Total")
self.assertEqual(si.update_stock, 1)
self.assertEqual(si.terms, "POS terms and conditions")
self.assertTrue(si.get("payments"))
self.assertTrue(si.get("taxes"))
def test_set_pos_fields_for_validate_preserves_existing_values(self):
profile = make_pos_profile()
profile.tax_category = "_Test Tax Category 1"
profile.save()
si = create_sales_invoice(do_not_save=True)
si.is_pos = 1
si.pos_profile = profile.name
si.apply_discount_on = "Net Total"
existing_customer = si.customer
POSService(si).set_pos_fields(for_validate=True)
# for_validate must not overwrite a field the user already set
self.assertEqual(si.apply_discount_on, "Net Total")
# for_validate skips mode-of-payment fetch and profile-driven customer/tax_category
self.assertFalse(si.get("payments"))
self.assertEqual(si.customer, existing_customer)
self.assertFalse(si.tax_category)
def test_set_pos_fields_uses_profile_price_list_without_customer(self):
profile = make_pos_profile(selling_price_list="_Test Price List")
profile.customer = None
profile.save()
si = create_sales_invoice(do_not_save=True)
si.is_pos = 1
si.pos_profile = profile.name
si.customer = None
POSService(si).set_pos_fields(for_validate=False)
self.assertEqual(si.selling_price_list, "_Test Price List")
def test_pos_service_mode_of_payment_queries(self):
make_pos_profile() # ensures a Cash mode-of-payment account for _Test Company
si = create_sales_invoice(do_not_save=True)
single = get_mode_of_payment_info("Cash", "_Test Company")
self.assertTrue(single)
self.assertEqual(single[0].parent, "Cash")
all_modes = get_all_mode_of_payments(si)
self.assertTrue(any(row.parent == "Cash" for row in all_modes))
grouped = get_mode_of_payments_info(["Cash"], "_Test Company")
self.assertIn("Cash", grouped)
self.assertEqual(grouped["Cash"].mop, "Cash")
def test_auto_write_off_amount(self):
make_pos_profile(
company="_Test Company with perpetual inventory",
@@ -1575,84 +1476,16 @@ class TestSalesInvoice(ERPNextTestSuite):
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
def test_stock_delivered_but_not_billed_gl_on_invoice(self):
company = "_Test Company with perpetual inventory"
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
make_purchase_receipt(
company=company,
item_code="_Test FG Item",
warehouse="Stores - TCP1",
cost_center="Main - TCP1",
qty=5,
rate=100,
)
dn = create_delivery_note(
company=company,
item_code="_Test FG Item",
warehouse="Stores - TCP1",
cost_center="Main - TCP1",
qty=2,
rate=300,
)
# A perpetual-inventory Delivery Note books the cost to the SDBNB account
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - TCP1")
si = make_sales_invoice(dn.name)
si.insert()
si.submit()
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_no": si.name, "is_cancelled": 0},
fields=["account", "debit", "credit"],
)
sdbnb_credit = sum(
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - TCP1"
)
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - TCP1")
# Billing reverses SDBNB and recognises the cost in COGS for an equal amount
self.assertTrue(sdbnb_credit > 0)
self.assertEqual(sdbnb_credit, cogs_debit)
def test_get_gle_for_change_amount(self):
from erpnext.accounts.doctype.sales_invoice.services.gl_composer import SalesInvoiceGLComposer
si = create_sales_invoice(do_not_save=True)
si.is_pos = 1
si.party_account_currency = "INR"
# no change amount -> no entries
si.change_amount = 0
self.assertEqual(SalesInvoiceGLComposer(si).get_gle_for_change_amount(), [])
# change amount without an account -> mandatory error
si.change_amount = 10
si.base_change_amount = 10
si.account_for_change_amount = None
self.assertRaises(frappe.ValidationError, SalesInvoiceGLComposer(si).get_gle_for_change_amount)
# change amount with an account -> debit-to debited, change account credited
si.account_for_change_amount = "Cash - _TC"
entries = SalesInvoiceGLComposer(si).get_gle_for_change_amount()
self.assertEqual(len(entries), 2)
debit_entry = next(entry for entry in entries if entry["account"] == si.debit_to)
credit_entry = next(entry for entry in entries if entry["account"] == "Cash - _TC")
self.assertEqual(debit_entry["party"], si.customer)
self.assertEqual(flt(debit_entry["debit"]), 10.0)
self.assertEqual(flt(credit_entry["credit"]), 10.0)
def validate_pos_gl_entry(self, si, pos, cash_amount, validate_without_change_gle=False):
if validate_without_change_gle:
cash_amount -= pos.change_amount
# check stock ledger entries
sle = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["*"],
sle = frappe.db.sql(
"""select * from `tabStock Ledger Entry`
where voucher_type = 'Sales Invoice' and voucher_no = %s""",
si.name,
as_dict=1,
)[0]
self.assertTrue(sle)
self.assertEqual(
@@ -1660,11 +1493,12 @@ class TestSalesInvoice(ERPNextTestSuite):
)
# check gl entries
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc, debit asc, credit asc",
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc, debit asc, credit asc""",
si.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -1691,15 +1525,15 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(expected_gl_entries[i][2], gle.credit)
si.cancel()
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["*"],
gle = frappe.db.sql(
"""select * from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
si.name,
)
self.assertTrue(gle)
frappe.db.delete("POS Profile")
frappe.db.sql("delete from `tabPOS Profile`")
def test_bin_details_of_packed_item(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
@@ -1766,11 +1600,12 @@ class TestSalesInvoice(ERPNextTestSuite):
si.insert()
si.submit()
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
si.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -1785,11 +1620,12 @@ class TestSalesInvoice(ERPNextTestSuite):
def test_sales_invoice_gl_entry_with_perpetual_inventory_non_stock_item(self):
si = create_sales_invoice(item="_Test Non Stock Item")
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
si.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -1843,18 +1679,18 @@ class TestSalesInvoice(ERPNextTestSuite):
si.load_from_db()
self.assertTrue(
frappe.get_all(
"Journal Entry Account",
filters={"reference_name": si.name},
pluck="name",
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_name=%s""",
si.name,
)
)
self.assertTrue(
frappe.get_all(
"Journal Entry Account",
filters={"reference_name": si.name, "credit_in_account_currency": 300},
pluck="name",
frappe.db.sql(
"""select name from `tabJournal Entry Account`
where reference_name=%s and credit_in_account_currency=300""",
si.name,
)
)
@@ -2166,18 +2002,13 @@ class TestSalesInvoice(ERPNextTestSuite):
conversion_rate=50,
)
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=[
"account",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
si.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -2212,10 +2043,10 @@ class TestSalesInvoice(ERPNextTestSuite):
# cancel
si.cancel()
gle = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
pluck="name",
gle = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s""",
si.name,
)
self.assertTrue(gle)
@@ -2242,16 +2073,14 @@ class TestSalesInvoice(ERPNextTestSuite):
)
si.submit()
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name, "account": "Sales - _TC"},
fields=[
"transaction_currency",
"transaction_exchange_rate",
"debit_in_transaction_currency",
"credit_in_transaction_currency",
],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select transaction_currency, transaction_exchange_rate,
debit_in_transaction_currency, credit_in_transaction_currency
from `tabGL Entry`
where voucher_type='Sales Invoice' and voucher_no=%s and account = 'Sales - _TC'
order by account asc""",
si.name,
as_dict=1,
)
expected_gle = {
@@ -2596,11 +2425,12 @@ class TestSalesInvoice(ERPNextTestSuite):
]
)
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", "debit", "credit"],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
si.name,
as_dict=1,
)
for gle in gl_entries:
@@ -2652,12 +2482,13 @@ class TestSalesInvoice(ERPNextTestSuite):
"Sales - _TC": [0.0, 1272.20],
}
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", {"SUM": "debit", "as": "debit"}, {"SUM": "credit", "as": "credit"}],
group_by="account",
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, sum(debit) as debit, sum(credit) as credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
group by account
order by account asc""",
si.name,
as_dict=1,
)
for gle in gl_entries:
@@ -2718,12 +2549,13 @@ class TestSalesInvoice(ERPNextTestSuite):
]
)
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=["account", {"SUM": "debit", "as": "debit"}, {"SUM": "credit", "as": "credit"}],
group_by="account",
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, sum(debit) as debit, sum(credit) as credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
group by account
order by account asc""",
si.name,
as_dict=1,
)
debit_credit_diff = 0
@@ -2733,9 +2565,7 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(expected_values[gle.account][2], gle.credit)
debit_credit_diff += gle.debit - gle.credit
# Postgres returns DECIMAL columns as float (DEC2FLOAT), so a debit-credit sum carries a
# tiny FP residue where MariaDB's DECIMAL arithmetic is exact; assert it's ~0.
self.assertAlmostEqual(debit_credit_diff, 0)
self.assertEqual(debit_credit_diff, 0)
round_off_gle = frappe.db.get_value(
"GL Entry",
@@ -2819,19 +2649,13 @@ class TestSalesInvoice(ERPNextTestSuite):
"Sales - _TC": {"cost_center": cost_center},
}
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=[
"account",
"cost_center",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, cost_center, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
si.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -2868,20 +2692,13 @@ class TestSalesInvoice(ERPNextTestSuite):
"Sales - _TC": {"project": item_project.name},
}
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": sales_invoice.name},
fields=[
"account",
"cost_center",
"project",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, cost_center, project, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
sales_invoice.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -2898,19 +2715,13 @@ class TestSalesInvoice(ERPNextTestSuite):
"Sales - _TC": {"cost_center": cost_center},
}
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Sales Invoice", "voucher_no": si.name},
fields=[
"account",
"cost_center",
"account_currency",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
],
order_by="account asc",
gl_entries = frappe.db.sql(
"""select account, cost_center, account_currency, debit, credit,
debit_in_account_currency, credit_in_account_currency
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
order by account asc""",
si.name,
as_dict=1,
)
self.assertTrue(gl_entries)
@@ -3686,49 +3497,6 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(expected_values[i][2], schedule.accumulated_depreciation_amount)
self.assertTrue(schedule.journal_entry)
def test_fixed_asset_sale_validations(self):
from erpnext.accounts.doctype.sales_invoice.services.fixed_assets import FixedAssetService
asset = create_asset(item_code="Macbook Pro", calculate_depreciation=0, submit=1)
def asset_invoice(asset_name, **kwargs):
si = create_sales_invoice(
item_code="Macbook Pro", asset=asset_name, qty=1, rate=90000, do_not_save=True, **kwargs
)
si.items[0].is_fixed_asset = 1
return si
with self.subTest("item without an asset is rejected"):
si = asset_invoice(None)
self.assertRaises(frappe.ValidationError, FixedAssetService(si).validate_fixed_asset)
with self.subTest("update stock on an asset sale is rejected"):
si = asset_invoice(asset.name, update_stock=1)
self.assertRaises(frappe.ValidationError, FixedAssetService(si).validate_fixed_asset)
with self.subTest("return without return-against is rejected"):
si = asset_invoice(asset.name, is_return=1)
self.assertRaises(frappe.ValidationError, FixedAssetService(si).validate_fixed_asset)
for bad_status in ("Sold", "Scrapped", "Cancelled", "Capitalized"):
with self.subTest(f"selling a {bad_status} asset is rejected"):
frappe.db.set_value("Asset", asset.name, "status", bad_status)
si = asset_invoice(asset.name)
self.assertRaises(frappe.ValidationError, FixedAssetService(si).validate_fixed_asset)
frappe.db.set_value("Asset", asset.name, "status", "Submitted")
def test_fixed_asset_restore_note_text(self):
from erpnext.accounts.doctype.sales_invoice.services.fixed_assets import FixedAssetService
asset = frappe._dict(doctype="Asset", name="_Test Asset For Note")
si = create_sales_invoice(do_not_save=True)
si.is_return = 1
self.assertIn("returned", FixedAssetService(si)._get_note_for_asset_return(asset))
si.is_return = 0
self.assertIn("restored", FixedAssetService(si)._get_note_for_asset_return(asset))
def test_sales_invoice_against_supplier(self):
from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
make_customer,
@@ -3942,27 +3710,6 @@ class TestSalesInvoice(ERPNextTestSuite):
party_link.delete()
def test_status_indicator(self):
from erpnext.accounts.doctype.sales_invoice.services.status import StatusService
si = create_sales_invoice(do_not_save=True)
cases = [
# outstanding, due_date, is_return -> indicator color, title
(-50, nowdate(), 0, "gray", "Credit Note Issued"),
(100, add_days(nowdate(), 5), 0, "orange", "Unpaid"),
(100, add_days(nowdate(), -5), 0, "red", "Overdue"),
(0, nowdate(), 1, "gray", "Return"),
(0, nowdate(), 0, "green", "Paid"),
]
for outstanding, due_date, is_return, color, title in cases:
with self.subTest(title=title):
si.outstanding_amount = outstanding
si.due_date = due_date
si.is_return = is_return
StatusService(si).set_indicator()
self.assertEqual(si.indicator_color, color)
self.assertEqual(si.indicator_title, title)
def test_payment_statuses(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
@@ -4188,15 +3935,13 @@ class TestSalesInvoice(ERPNextTestSuite):
[deferred_account, 2022.47, 0.0, "2019-03-15"],
]
gl_entries = frappe.get_all(
"GL Entry",
filters={
"voucher_type": "Journal Entry",
"voucher_detail_no": si.items[0].name,
"posting_date": ["<=", si.posting_date],
},
fields=["account", "debit", "credit", "posting_date"],
order_by="posting_date asc, account asc",
gl_entries = frappe.db.sql(
"""select account, debit, credit, posting_date
from `tabGL Entry`
where voucher_type='Journal Entry' and voucher_detail_no=%s and posting_date <= %s
order by posting_date asc, account asc""",
(si.items[0].name, si.posting_date),
as_dict=1,
)
for i, gle in enumerate(gl_entries):
@@ -4838,8 +4583,7 @@ class TestSalesInvoice(ERPNextTestSuite):
{"account": "Temporary Opening - _TC", "debit": 0.0, "credit": 138.09, "is_opening": "Yes"},
]
self.assertEqual(len(actual), 4)
# DB account collation isn't portable across MariaDB/Postgres; compare order-independently.
self.assertCountEqual(actual, expected)
self.assertEqual(expected, actual)
@ERPNextTestSuite.change_settings("Accounts Settings", {"enable_common_party_accounting": True})
def test_common_party_with_foreign_currency_jv(self):
@@ -5144,7 +4888,7 @@ class TestSalesInvoice(ERPNextTestSuite):
def test_pos_sales_invoice_creation_during_pos_invoice_mode(self):
# Deleting all opening entry
frappe.db.delete("POS Opening Entry")
frappe.db.sql("delete from `tabPOS Opening Entry`")
with self.change_settings("POS Settings", {"invoice_type": "POS Invoice"}):
pos_profile = make_pos_profile()
@@ -5514,11 +5258,6 @@ def check_gl_entries(doc, voucher_no, expected_gle, posting_date, voucher_type="
doc.assertGreater(len(gl_entries), 0)
# MariaDB and Postgres collate `account` differently, so the DB ordering isn't portable;
# sort both sides identically (by the compared values) before the positional check.
gl_entries = sorted(gl_entries, key=lambda g: (g.account, g.debit, g.credit))
expected_gle = sorted(expected_gle, key=lambda e: (e[0], e[1], e[2]))
for i, gle in enumerate(gl_entries):
doc.assertEqual(expected_gle[i][0], gle.account)
doc.assertEqual(expected_gle[i][1], gle.debit)
@@ -5666,21 +5405,17 @@ def create_sales_invoice_against_cost_center(**args):
def get_outstanding_amount(against_voucher_type, against_voucher, account, party, party_type):
balance = frappe.get_all(
"GL Entry",
filters={
"against_voucher_type": against_voucher_type,
"against_voucher": against_voucher,
"account": account,
"party": party,
"party_type": party_type,
},
fields=[
{"SUM": "debit_in_account_currency", "as": "debit"},
{"SUM": "credit_in_account_currency", "as": "credit"},
],
bal = flt(
frappe.db.sql(
"""
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabGL Entry`
where against_voucher_type=%s and against_voucher=%s
and account = %s and party = %s and party_type = %s""",
(against_voucher_type, against_voucher, account, party, party_type),
)[0][0]
or 0.0
)
bal = flt(balance[0].debit) - flt(balance[0].credit)
if against_voucher_type == "Purchase Invoice":
bal = bal * -1

View File

@@ -56,14 +56,11 @@ def valdiate_taxes_and_charges_template(doc):
# doc.is_default = 1
if doc.is_default == 1:
template = frappe.qb.DocType(doc.doctype)
(
frappe.qb.update(template)
.set(template.is_default, 0)
.where(
(template.is_default == 1) & (template.name != doc.name) & (template.company == doc.company)
)
).run()
frappe.db.sql(
f"""update `tab{doc.doctype}` set is_default = 0
where is_default = 1 and name != %s and company = %s""",
(doc.name, doc.company),
)
validate_disabled(doc)

View File

@@ -424,7 +424,7 @@ class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
self.disable_advance_as_liability()
def test_07_adv_from_so_to_invoice(self):
frappe.db.set_value("Company", self.company, "book_advance_payments_in_separate_party_account", 1)
frappe.db.set_value("Company", self.company, "book_advance_payments_in_separate_party_account", True)
frappe.db.set_value(
"Company", self.company, "default_advance_received_account", "Advance Received - _TC"
)

View File

@@ -672,7 +672,7 @@ def make_reverse_gl_entries(
)
if not immutable_ledger_enabled:
query = query.set(gle.is_cancelled, 1) # smallint column; postgres rejects boolean true
query = query.set(gle.is_cancelled, True)
query.run()
else:
@@ -683,14 +683,12 @@ def make_reverse_gl_entries(
if not all(gle_names):
set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"])
else:
gle = frappe.qb.DocType("GL Entry")
(
frappe.qb.update(gle)
.set(gle.is_cancelled, 1)
.set(gle.modified, now())
.set(gle.modified_by, frappe.session.user)
.where(gle.name.isin(gle_names) & (gle.is_cancelled == 0))
).run()
frappe.db.sql(
"""UPDATE `tabGL Entry` SET is_cancelled = 1,
modified=%s, modified_by=%s
where name in %s and is_cancelled = 0""",
(now(), frappe.session.user, tuple(gle_names)),
)
for entry in gl_entries:
new_gle = copy.deepcopy(entry)
@@ -727,11 +725,9 @@ def set_as_cancel(voucher_type, voucher_no):
"""
Set is_cancelled=1 in all original gl entries for the voucher
"""
gle = frappe.qb.DocType("GL Entry")
(
frappe.qb.update(gle)
.set(gle.is_cancelled, 1)
.set(gle.modified, now())
.set(gle.modified_by, frappe.session.user)
.where((gle.voucher_type == voucher_type) & (gle.voucher_no == voucher_no) & (gle.is_cancelled == 0))
).run()
frappe.db.sql(
"""UPDATE `tabGL Entry` SET is_cancelled = 1,
modified=%s, modified_by=%s
where voucher_type=%s and voucher_no=%s and is_cancelled = 0""",
(now(), frappe.session.user, voucher_type, voucher_no),
)

View File

@@ -900,13 +900,16 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
d.company, {"grand_total": d.grand_total, "base_grand_total": d.base_grand_total}
)
gle = frappe.qb.DocType("GL Entry")
company_wise_total_unpaid = frappe._dict(
frappe.qb.from_(gle)
.select(gle.company, Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
.where((gle.party_type == party_type) & (gle.party == party) & (gle.is_cancelled == 0))
.groupby(gle.company)
.run()
frappe.db.sql(
"""
select company, sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabGL Entry`
where party_type = %s and party=%s
and is_cancelled = 0
group by company""",
(party_type, party),
)
)
for d in companies:

View File

@@ -7,7 +7,7 @@ from collections import OrderedDict
import frappe
from frappe import _, qb, query_builder, scrub
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Date, Substring, Sum
from frappe.query_builder.functions import Date, Max, Substring, Sum
from frappe.utils import cint, cstr, flt, getdate, nowdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
@@ -691,11 +691,13 @@ class ReceivablePayableReport:
.inner_join(jea)
.on(jea.parent == je.name)
.select(
jea.reference_name.as_("invoice_no"),
jea.party,
jea.party_type,
je.posting_date.as_("future_date"),
je.cheque_no.as_("future_ref"),
# Sum() below makes this an implicit aggregate (no GROUP BY); the non-aggregated columns
# are arbitrary per the single group on MySQL -> Max() keeps it valid on postgres.
Max(jea.reference_name).as_("invoice_no"),
Max(jea.party).as_("party"),
Max(jea.party_type).as_("party_type"),
Max(je.posting_date).as_("future_date"),
Max(je.cheque_no).as_("future_ref"),
)
.where(
(je.docstatus < 2)
@@ -725,14 +727,6 @@ class ReceivablePayableReport:
future_amount.as_("future_amount"),
future_amount_in_base_currency.as_("future_amount_in_base_currency"),
)
# One row per (future-payment JE, invoice, party): group by the JE name (primary key, so the
# JE-level posting_date/cheque_no are deterministic) plus the per-reference dimensions, summing
# amounts across JE Account rows that hit the same invoice. Without this GROUP BY the implicit
# single-group aggregate collapsed every future JE payment into one row keyed by an arbitrary
# invoice, mis-allocating the whole sum.
query = query.groupby(
je.name, jea.reference_name, jea.party, jea.party_type, je.posting_date, je.cheque_no
)
# use the aggregate expression in HAVING; postgres can't reference a SELECT alias there
query = query.having(future_amount > 0)
return query.run(as_dict=True)

View File

@@ -699,61 +699,6 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
[row.invoiced, row.paid, row.outstanding, row.remaining_balance, row.future_amount],
)
def test_future_payments_from_journal_entry(self):
# A single future-dated Journal Entry paying two different invoices must surface as one
# future-payment row PER invoice, not collapse the whole sum onto one arbitrary invoice
# (regression: the implicit single-group aggregate filed all future JE payments under one key).
si_a = self.create_sales_invoice(no_payment_schedule=True)
si_b = self.create_sales_invoice(no_payment_schedule=True)
je = frappe.get_doc(
{
"doctype": "Journal Entry",
"voucher_type": "Journal Entry",
"company": self.company,
"posting_date": add_days(today(), 1),
"accounts": [
{
"account": self.debit_to,
"party_type": "Customer",
"party": self.customer,
"reference_type": "Sales Invoice",
"reference_name": si_a.name,
"credit_in_account_currency": 50,
"credit": 50,
},
{
"account": self.debit_to,
"party_type": "Customer",
"party": self.customer,
"reference_type": "Sales Invoice",
"reference_name": si_b.name,
"credit_in_account_currency": 50,
"credit": 50,
},
{"account": self.cash, "debit_in_account_currency": 100, "debit": 100},
],
}
)
je.insert().submit()
filters = {
"company": self.company,
"report_date": today(),
"range": "30, 60, 90, 120",
"show_future_payments": True,
}
report = execute(filters)[1]
rows_a = [row for row in report if row.voucher_no == si_a.name]
rows_b = [row for row in report if row.voucher_no == si_b.name]
# exactly one report row per invoice, each keeping its own future payment; the bug collapsed
# both into a single row and allocated the whole 100 to one arbitrary invoice
self.assertEqual(len(rows_a), 1)
self.assertEqual(len(rows_b), 1)
self.assertEqual(rows_a[0].future_amount, 50.0)
self.assertEqual(rows_b[0].future_amount, 50.0)
def test_sales_person(self):
sales_person = frappe.get_doc(
{"doctype": "Sales Person", "sales_person_name": "John Clark", "enabled": True}

View File

@@ -84,13 +84,7 @@ def build_budget_map(budget_records, filters):
budget_distributions = get_budget_distributions(budget)
for row in budget_distributions:
if not row.start_date or not row.end_date:
continue
months = get_months_in_range(row.start_date, row.end_date)
if not months:
continue
monthly_budget = flt(row.amount) / len(months)
for month_date in months:

View File

@@ -164,8 +164,7 @@ def get_gl_entries(filters, accounting_dimensions):
if filters.get("show_remarks"):
if remarks_length := frappe.get_single_value("Accounts Settings", "general_ledger_remarks_length"):
# bare alias, not 'remarks' — Postgres treats a single-quoted alias as a string literal
select_fields += f",substr(remarks, 1, {remarks_length}) as remarks"
select_fields += f",substr(remarks, 1, {remarks_length}) as 'remarks'"
else:
select_fields += """,remarks"""

View File

@@ -15,42 +15,6 @@ class TestGeneralLedger(ERPNextTestSuite):
def setUp(self):
self.company = "_Test Company"
def test_gl_report_runs_with_remarks_length(self):
# general_ledger_remarks_length adds `substr(remarks, 1, n) as remarks` to the raw SQL; the
# alias must be unquoted to be valid on Postgres (a single-quoted alias is a string literal there).
from frappe.utils import today
frappe.db.set_single_value("Accounts Settings", "general_ledger_remarks_length", 50)
self.addCleanup(frappe.db.set_single_value, "Accounts Settings", "general_ledger_remarks_length", 0)
si = create_sales_invoice(company=self.company)
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
columns, data = execute(
frappe._dict(
{
"company": self.company,
"from_date": today(),
"to_date": today(),
"group_by": "Group by Voucher (Consolidated)",
# required to reach the `substr(remarks, 1, n) as remarks` branch under test
"show_remarks": True,
}
)
)
self.assertTrue(columns)
self.assertTrue(data)
self.assertTrue(any("remarks" in row for row in data))
@staticmethod
def _cancel_and_delete(doctype, name):
if not frappe.db.exists(doctype, name):
return
doc = frappe.get_doc(doctype, name)
if doc.docstatus == 1:
doc.cancel()
frappe.delete_doc(doctype, name, force=1)
def clear_old_entries(self):
doctype_list = [
"GL Entry",
@@ -170,17 +134,17 @@ class TestGeneralLedger(ERPNextTestSuite):
revaluation_jv.submit()
# check the balance of the account
balance = frappe.get_all(
"GL Entry",
filters={"account": account.name},
fields=[
{"SUM": "debit_in_account_currency", "as": "debit"},
{"SUM": "credit_in_account_currency", "as": "credit"},
],
group_by="account",
balance = frappe.db.sql(
"""
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabGL Entry`
where account = %s
group by account
""",
account.name,
)
self.assertEqual(flt(balance[0].debit) - flt(balance[0].credit), 100)
self.assertEqual(balance[0][0], 100)
# check if general ledger shows correct balance
columns, data = execute(

View File

@@ -309,22 +309,17 @@ def get_account_columns(invoice_list, include_payments):
unrealized_profit_loss_account_columns = []
if invoice_list:
# frappe drops ORDER BY for distinct queries on postgres (db_query), so sort in python with
# casefold to keep the generated account-column order deterministic and identical on both
# backends, matching MariaDB's case-insensitive collation (the original ORDER BY).
expense_accounts = sorted(
frappe.get_all(
"Purchase Invoice Item",
filters={
"docstatus": 1,
"expense_account": ["is", "set"],
"parenttype": "Purchase Invoice",
"parent": ["in", [inv.name for inv in invoice_list]],
},
pluck="expense_account",
distinct=True,
),
key=str.casefold,
expense_accounts = frappe.get_all(
"Purchase Invoice Item",
filters={
"docstatus": 1,
"expense_account": ["is", "set"],
"parenttype": "Purchase Invoice",
"parent": ["in", [inv.name for inv in invoice_list]],
},
pluck="expense_account",
distinct=True,
order_by="expense_account",
)
purchase_taxes_query = get_taxes_query(invoice_list, "Purchase Taxes and Charges", "Purchase Invoice")
@@ -336,18 +331,16 @@ def get_account_columns(invoice_list, include_payments):
advance_tax_accounts = advance_taxes_query.run(as_dict=True, pluck="account_head")
tax_accounts = set(tax_accounts + advance_tax_accounts)
unrealized_profit_loss_accounts = sorted(
frappe.get_all(
"Purchase Invoice",
filters={
"docstatus": 1,
"name": ["in", [inv.name for inv in invoice_list]],
"unrealized_profit_loss_account": ["is", "set"],
},
pluck="unrealized_profit_loss_account",
distinct=True,
),
key=str.casefold,
unrealized_profit_loss_accounts = frappe.get_all(
"Purchase Invoice",
filters={
"docstatus": 1,
"name": ["in", [inv.name for inv in invoice_list]],
"unrealized_profit_loss_account": ["is", "set"],
},
pluck="unrealized_profit_loss_account",
distinct=True,
order_by="unrealized_profit_loss_account",
)
for account in expense_accounts:

View File

@@ -24,29 +24,6 @@ class TestPurchaseRegister(ERPNextTestSuite):
self.assertEqual(first_row.total_tax, 100)
self.assertEqual(first_row.grand_total, 1100)
def test_expense_account_columns_sorted_case_insensitively(self):
# The dynamic expense-account columns must follow MariaDB's case-insensitive collation order and
# be identical on both engines. frappe drops ORDER BY for distinct queries on postgres, so the
# report sorts in python with casefold; plain sorted() would be case-sensitive ("ZZZ" < "aaa").
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
company = "_Test Company"
lower = create_account(
account_name="aaa Test Expense", parent_account="Expenses - _TC", company=company
)
upper = create_account(
account_name="ZZZ Test Expense", parent_account="Expenses - _TC", company=company
)
for account in (upper, lower): # submit in non-casefold order
make_purchase_invoice(company=company, expense_account=account)
filters = frappe._dict(company=company, from_date=add_months(today(), -1), to_date=today())
columns = execute(filters)[0]
labels = [col["label"] for col in columns if col.get("label") in (lower, upper)]
self.assertEqual(labels, sorted([lower, upper], key=str.casefold))
def test_purchase_register_ignores_tax_rows_from_other_doctype(self):
filters = frappe._dict(company="_Test Company 6", from_date=add_months(today(), -1), to_date=today())

View File

@@ -3,7 +3,6 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Coalesce, Max, Sum
from frappe.utils import cstr
@@ -101,275 +100,178 @@ def get_sales_payment_data(filters, columns):
return data
def apply_conditions(query, a, filters):
"""Apply the same filters get_conditions() used to build, as parameterized qb .where() clauses.
`a` is the field source for the Sales Invoice columns -- either the `tabSales Invoice`
DocType or a subquery aliased `a` that selects those columns. This mirrors the previous
raw SQL where every predicate was keyed on the `a` alias.
"""
def get_conditions(filters):
conditions = "1=1"
if filters.get("from_date"):
query = query.where(a.posting_date >= filters.get("from_date"))
conditions += " and a.posting_date >= %(from_date)s"
if filters.get("to_date"):
query = query.where(a.posting_date <= filters.get("to_date"))
conditions += " and a.posting_date <= %(to_date)s"
if filters.get("company"):
query = query.where(a.company == filters.get("company"))
conditions += " and a.company=%(company)s"
if filters.get("customer"):
query = query.where(a.customer == filters.get("customer"))
conditions += " and a.customer = %(customer)s"
if filters.get("owner"):
query = query.where(a.owner == filters.get("owner"))
conditions += " and a.owner = %(owner)s"
if filters.get("is_pos"):
query = query.where(a.is_pos == filters.get("is_pos"))
return query
conditions += " and a.is_pos = %(is_pos)s"
return conditions
def get_pos_invoice_data(filters):
sii = frappe.qb.DocType("Sales Invoice Item")
sip = frappe.qb.DocType("Sales Invoice Payment")
si = frappe.qb.DocType("Sales Invoice")
# t1: one row per invoice with the summed item base_total. warehouse/cost_center are line-level and
# not grouped, so they are arbitrary per invoice -- Max() makes that pick deterministic and valid on
# Postgres (item_code was selected but never consumed downstream, so it is dropped).
t1 = (
frappe.qb.from_(sii)
.select(
sii.parent,
Sum(sii.amount).as_("base_total"),
Max(sii.warehouse).as_("warehouse"),
Max(sii.cost_center).as_("cost_center"),
)
.groupby(sii.parent)
conditions = get_conditions(filters)
result = frappe.db.sql(
""
"SELECT "
'posting_date, owner, sum(net_total) as "net_total", sum(total_taxes) as "total_taxes", '
'sum(paid_amount) as "paid_amount", sum(outstanding_amount) as "outstanding_amount", '
"mode_of_payment, warehouse, cost_center "
"FROM ("
"SELECT "
'parent, item_code, sum(amount) as "base_total", warehouse, cost_center '
"from `tabSales Invoice Item` group by parent"
") t1 "
"left join "
"(select parent, mode_of_payment from `tabSales Invoice Payment` group by parent) t3 "
"on (t3.parent = t1.parent) "
"JOIN ("
"SELECT "
'docstatus, company, is_pos, name, posting_date, owner, sum(base_total) as "base_total", '
'sum(net_total) as "net_total", sum(total_taxes_and_charges) as "total_taxes", '
'sum(base_paid_amount) as "paid_amount", sum(outstanding_amount) as "outstanding_amount" '
"FROM `tabSales Invoice` "
"GROUP BY name"
") a "
"ON ("
"t1.parent = a.name and t1.base_total = a.base_total) "
"WHERE a.docstatus = 1"
f" AND {conditions} "
"GROUP BY "
"owner, posting_date, warehouse",
filters,
as_dict=1,
)
# t3: mode_of_payment per invoice (arbitrary across an invoice's payment lines -> Max() to be valid)
t3 = (
frappe.qb.from_(sip)
.select(sip.parent, Max(sip.mode_of_payment).as_("mode_of_payment"))
.groupby(sip.parent)
)
# a: invoice-level aggregates. Grouped by the primary key (si.name), so the other plain si columns
# (incl. customer, needed by the customer filter) are functionally dependent and valid on Postgres.
a = (
frappe.qb.from_(si)
.select(
si.docstatus,
si.company,
si.customer,
si.is_pos,
si.name,
si.posting_date,
si.owner,
Sum(si.base_total).as_("base_total"),
Sum(si.net_total).as_("net_total"),
Sum(si.total_taxes_and_charges).as_("total_taxes"),
Sum(si.base_paid_amount).as_("paid_amount"),
Sum(si.outstanding_amount).as_("outstanding_amount"),
)
.groupby(si.name)
)
query = (
frappe.qb.from_(t1)
.left_join(t3)
.on(t3.parent == t1.parent)
.join(a)
.on((t1.parent == a.name) & (t1.base_total == a.base_total))
.select(
a.posting_date,
a.owner,
Sum(a.net_total).as_("net_total"),
Sum(a.total_taxes).as_("total_taxes"),
Sum(a.paid_amount).as_("paid_amount"),
Sum(a.outstanding_amount).as_("outstanding_amount"),
# mode_of_payment/cost_center are not in the outer GROUP BY -> Max() (deterministic, both engines)
Max(t3.mode_of_payment).as_("mode_of_payment"),
t1.warehouse,
Max(t1.cost_center).as_("cost_center"),
)
.where(a.docstatus == 1)
.groupby(a.owner, a.posting_date, t1.warehouse)
)
query = apply_conditions(query, a, filters)
return query.run(as_dict=True)
return result
def get_sales_invoice_data(filters):
a = frappe.qb.DocType("Sales Invoice")
query = (
frappe.qb.from_(a)
.select(
a.posting_date,
a.owner,
Sum(a.net_total).as_("net_total"),
Sum(a.total_taxes_and_charges).as_("total_taxes"),
Sum(a.base_paid_amount).as_("paid_amount"),
Sum(a.outstanding_amount).as_("outstanding_amount"),
)
.where(a.docstatus == 1)
.groupby(a.owner, a.posting_date)
conditions = get_conditions(filters)
return frappe.db.sql(
f"""
select
a.posting_date, a.owner,
sum(a.net_total) as "net_total",
sum(a.total_taxes_and_charges) as "total_taxes",
sum(a.base_paid_amount) as "paid_amount",
sum(a.outstanding_amount) as "outstanding_amount"
from `tabSales Invoice` a
where a.docstatus = 1
and {conditions}
group by
a.owner, a.posting_date
""",
filters,
as_dict=1,
)
query = apply_conditions(query, a, filters)
return query.run(as_dict=True)
def get_mode_of_payments(filters):
mode_of_payments = {}
invoice_list = get_invoices(filters)
invoice_names = [invoice["name"] for invoice in invoice_list]
invoice_list_names = ",".join("'" + invoice["name"] + "'" for invoice in invoice_list)
if invoice_list:
# Branch 1: payments recorded directly on the Sales Invoice
si1 = frappe.qb.DocType("Sales Invoice")
sip = frappe.qb.DocType("Sales Invoice Payment")
branch1 = (
frappe.qb.from_(si1)
.join(sip)
.on(si1.name == sip.parent)
.select(si1.owner, si1.posting_date, Coalesce(sip.mode_of_payment, "").as_("mode_of_payment"))
.where(si1.docstatus == 1)
.where(si1.name.isin(invoice_names))
inv_mop = frappe.db.sql(
f"""select a.owner,a.posting_date, ifnull(b.mode_of_payment, '') as mode_of_payment
from `tabSales Invoice` a, `tabSales Invoice Payment` b
where a.name = b.parent
and a.docstatus = 1
and a.name in ({invoice_list_names})
union
select a.owner,a.posting_date, ifnull(b.mode_of_payment, '') as mode_of_payment
from `tabSales Invoice` a, `tabPayment Entry` b,`tabPayment Entry Reference` c
where a.name = c.reference_name
and b.name = c.parent
and b.docstatus = 1
and a.name in ({invoice_list_names})
union
select a.owner, a.posting_date,
ifnull(a.voucher_type,'') as mode_of_payment
from `tabJournal Entry` a, `tabJournal Entry Account` b
where a.name = b.parent
and a.docstatus = 1
and b.reference_type = 'Sales Invoice'
and b.reference_name in ({invoice_list_names})
""",
as_dict=1,
)
# Branch 2: payments via Payment Entry referencing the invoice
si2 = frappe.qb.DocType("Sales Invoice")
pe = frappe.qb.DocType("Payment Entry")
per = frappe.qb.DocType("Payment Entry Reference")
branch2 = (
frappe.qb.from_(si2)
.join(per)
.on(si2.name == per.reference_name)
.join(pe)
.on(pe.name == per.parent)
.select(si2.owner, si2.posting_date, Coalesce(pe.mode_of_payment, "").as_("mode_of_payment"))
.where(pe.docstatus == 1)
.where(si2.name.isin(invoice_names))
)
# Branch 3: payments via Journal Entry referencing the invoice
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
branch3 = (
frappe.qb.from_(je)
.join(jea)
.on(je.name == jea.parent)
.select(je.owner, je.posting_date, Coalesce(je.voucher_type, "").as_("mode_of_payment"))
.where(je.docstatus == 1)
.where(jea.reference_type == "Sales Invoice")
.where(jea.reference_name.isin(invoice_names))
)
# bare UNION => de-duplicated rows across the three branches
inv_mop = (branch1.union(branch2).union(branch3)).run(as_dict=True)
for d in inv_mop:
mode_of_payments.setdefault(d["owner"] + cstr(d["posting_date"]), []).append(d.mode_of_payment)
return mode_of_payments
def get_invoices(filters):
a = frappe.qb.DocType("Sales Invoice")
query = frappe.qb.from_(a).select(a.name).where(a.docstatus == 1)
query = apply_conditions(query, a, filters)
return query.run(as_dict=True)
conditions = get_conditions(filters)
return frappe.db.sql(
f"""select a.name
from `tabSales Invoice` a
where a.docstatus = 1 and {conditions}""",
filters,
as_dict=1,
)
def get_mode_of_payment_details(filters):
mode_of_payment_details = {}
invoice_list = get_invoices(filters)
invoice_names = [invoice["name"] for invoice in invoice_list]
invoice_list_names = ",".join("'" + invoice["name"] + "'" for invoice in invoice_list)
if invoice_list:
# Branch 1: amounts paid directly on the Sales Invoice
si1 = frappe.qb.DocType("Sales Invoice")
sip = frappe.qb.DocType("Sales Invoice Payment")
mop1 = Coalesce(sip.mode_of_payment, "")
branch1 = (
frappe.qb.from_(si1)
.join(sip)
.on(si1.name == sip.parent)
.select(
si1.owner,
si1.posting_date,
mop1.as_("mode_of_payment"),
Sum(sip.base_amount).as_("paid_amount"),
)
.where(si1.docstatus == 1)
.where(si1.name.isin(invoice_names))
.groupby(si1.owner, si1.posting_date, mop1)
inv_mop_detail = frappe.db.sql(
f"""
select t.owner,
t.posting_date,
t.mode_of_payment,
sum(t.paid_amount) as paid_amount
from (
select a.owner, a.posting_date,
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(b.base_amount) as paid_amount
from `tabSales Invoice` a, `tabSales Invoice Payment` b
where a.name = b.parent
and a.docstatus = 1
and a.name in ({invoice_list_names})
group by a.owner, a.posting_date, mode_of_payment
union
select a.owner,a.posting_date,
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(c.allocated_amount) as paid_amount
from `tabSales Invoice` a, `tabPayment Entry` b,`tabPayment Entry Reference` c
where a.name = c.reference_name
and b.name = c.parent
and b.docstatus = 1
and a.name in ({invoice_list_names})
group by a.owner, a.posting_date, mode_of_payment
union
select a.owner, a.posting_date,
ifnull(a.voucher_type,'') as mode_of_payment, sum(b.credit)
from `tabJournal Entry` a, `tabJournal Entry Account` b
where a.name = b.parent
and a.docstatus = 1
and b.reference_type = 'Sales Invoice'
and b.reference_name in ({invoice_list_names})
group by a.owner, a.posting_date, mode_of_payment
) t
group by t.owner, t.posting_date, t.mode_of_payment
""",
as_dict=1,
)
# Branch 2: amounts allocated via Payment Entry
si2 = frappe.qb.DocType("Sales Invoice")
pe = frappe.qb.DocType("Payment Entry")
per = frappe.qb.DocType("Payment Entry Reference")
mop2 = Coalesce(pe.mode_of_payment, "")
branch2 = (
frappe.qb.from_(si2)
.join(per)
.on(si2.name == per.reference_name)
.join(pe)
.on(pe.name == per.parent)
.select(
si2.owner,
si2.posting_date,
mop2.as_("mode_of_payment"),
Sum(per.allocated_amount).as_("paid_amount"),
)
.where(pe.docstatus == 1)
.where(si2.name.isin(invoice_names))
.groupby(si2.owner, si2.posting_date, mop2)
)
# Branch 3: amounts credited via Journal Entry
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
mop3 = Coalesce(je.voucher_type, "")
branch3 = (
frappe.qb.from_(je)
.join(jea)
.on(je.name == jea.parent)
.select(
je.owner, je.posting_date, mop3.as_("mode_of_payment"), Sum(jea.credit).as_("paid_amount")
)
.where(je.docstatus == 1)
.where(jea.reference_type == "Sales Invoice")
.where(jea.reference_name.isin(invoice_names))
.groupby(je.owner, je.posting_date, mop3)
)
# bare UNION => de-duplicated rows; wrapped as subquery `t` for the outer re-aggregation
t = branch1.union(branch2).union(branch3)
inv_mop_detail = (
frappe.qb.from_(t)
.select(
t.owner,
t.posting_date,
t.mode_of_payment,
Sum(t.paid_amount).as_("paid_amount"),
)
.groupby(t.owner, t.posting_date, t.mode_of_payment)
.run(as_dict=True)
)
# change amount paid back in cash, subtracted from the matching mode-of-payment detail below
sic = frappe.qb.DocType("Sales Invoice")
sipc = frappe.qb.DocType("Sales Invoice Payment")
mopc = Coalesce(sipc.mode_of_payment, "")
inv_change_amount = (
frappe.qb.from_(sic)
.join(sipc)
.on(sic.name == sipc.parent)
.select(
sic.owner,
sic.posting_date,
mopc.as_("mode_of_payment"),
Sum(sic.base_change_amount).as_("change_amount"),
)
.where(sic.name.isin(invoice_names))
.where(sipc.type == "Cash")
.where(sic.base_change_amount > 0)
.groupby(sic.owner, sic.posting_date, mopc)
.run(as_dict=True)
inv_change_amount = frappe.db.sql(
f"""select a.owner, a.posting_date,
ifnull(b.mode_of_payment, '') as mode_of_payment, sum(a.base_change_amount) as change_amount
from `tabSales Invoice` a, `tabSales Invoice Payment` b
where a.name = b.parent
and a.name in ({invoice_list_names})
and b.type = 'Cash'
and a.base_change_amount > 0
group by a.owner, a.posting_date, mode_of_payment""",
as_dict=1,
)
for d in inv_change_amount:

View File

@@ -2,13 +2,12 @@
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import flt, today
from frappe.utils import today
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.report.sales_payment_summary.sales_payment_summary import (
get_mode_of_payment_details,
get_mode_of_payments,
get_pos_invoice_data,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -103,33 +102,6 @@ class TestSalesPaymentSummary(ERPNextTestSuite):
self.assertGreater(cc_init_amount, cc_final_amount)
def test_get_pos_invoice_data(self):
"""The POS path (is_pos filter -> get_pos_invoice_data) used nested loose-GROUP-BY subqueries
that raised on Postgres; it now aggregates deterministically and runs identically on both
engines."""
si = create_sales_invoice_record()
si.is_pos = 1
si.append(
"payments",
{"mode_of_payment": "Cash", "account": "_Test Cash - _TC", "amount": 10000},
)
si.insert()
si.submit()
filters = frappe._dict(
{"is_pos": 1, "company": "_Test Company", "from_date": today(), "to_date": today()}
)
data = get_pos_invoice_data(filters)
# the POS invoice's paid amount is aggregated; previously this query raised GroupingError on PG
self.assertTrue(data)
self.assertTrue(any(flt(row.get("paid_amount")) >= 10000 for row in data))
# customer filter must work: a.customer was not selected by the invoice subquery before the fix,
# so the filter errored on both engines. With the invoice's customer it still returns its payment.
filters["customer"] = si.customer
self.assertTrue(any(flt(row.get("paid_amount")) >= 10000 for row in get_pos_invoice_data(filters)))
def get_filters():
return {"from_date": "1900-01-01", "to_date": today(), "company": "_Test Company"}

View File

@@ -347,17 +347,14 @@ def get_account_columns(invoice_list, include_payments):
if invoice_list:
# frappe drops ORDER BY for distinct queries on postgres (db_query), so sort in python to keep
# the generated account-column order deterministic and identical on both backends. casefold
# reproduces MariaDB's case-insensitive collation order (the original raw SQL ORDER BY); plain
# sorted() would be case-sensitive and reorder columns vs the pre-effort MariaDB output.
# the generated account-column order deterministic and identical on both backends.
income_accounts = sorted(
frappe.get_all(
"Sales Invoice Item",
filters={"docstatus": 1, "parent": ["in", [inv.name for inv in invoice_list]]},
pluck="income_account",
distinct=True,
),
key=str.casefold,
)
)
sales_taxes_query = get_taxes_query(invoice_list, "Sales Taxes and Charges", "Sales Invoice")
@@ -380,8 +377,7 @@ def get_account_columns(invoice_list, include_payments):
},
pluck="unrealized_profit_loss_account",
distinct=True,
),
key=str.casefold,
)
)
for account in income_accounts:

View File

@@ -55,39 +55,6 @@ class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
si = si.submit()
return si
def _ensure_income_account(self, account_name):
name = f"{account_name} - _TC"
if not frappe.db.exists("Account", name):
frappe.get_doc(
{
"doctype": "Account",
"account_name": account_name,
"parent_account": "Income - _TC",
"company": self.company,
"root_type": "Income",
"report_type": "Profit and Loss",
"account_type": "Income Account",
}
).insert()
return name
def test_income_account_columns_sorted_case_insensitively(self):
# The dynamic income-account columns must follow MariaDB's case-insensitive collation order and
# be identical on both engines. Plain python sorted() is case-sensitive (ASCII), so "ZZZ" would
# sort before "aaa"; casefold restores the pre-effort MariaDB order on both backends.
lower = self._ensure_income_account("aaa Test Income")
upper = self._ensure_income_account("ZZZ Test Income")
for account in (upper, lower): # submit in non-casefold order
si = self.create_sales_invoice(do_not_submit=True)
si.items[0].income_account = account
si.submit()
filters = frappe._dict({"from_date": today(), "to_date": today(), "company": self.company})
columns = execute(filters)[0]
labels = [col["label"] for col in columns if col.get("label") in (lower, upper)]
self.assertEqual(labels, sorted([lower, upper], key=str.casefold))
def test_basic_report_output(self):
si = self.create_sales_invoice(rate=98)

View File

@@ -146,6 +146,7 @@ def get_appropriate_company(filters):
return company
@frappe.whitelist()
def get_invoiced_item_gross_margin(
sales_invoice: str | None = None,
item_code: str | None = None,

View File

@@ -37,22 +37,25 @@ def validate_disabled_accounts(gl_map):
def validate_accounting_period(gl_map):
ap = frappe.qb.DocType("Accounting Period")
cd = frappe.qb.DocType("Closed Document")
accounting_periods = (
frappe.qb.from_(ap)
.inner_join(cd)
.on(ap.name == cd.parent)
.select(ap.name.as_("name"), ap.exempted_role.as_("exempted_role"))
.where(
(ap.company == gl_map[0].company)
& (ap.disabled == 0)
& (cd.closed == 1)
& (cd.document_type == gl_map[0].voucher_type)
& (ap.start_date <= gl_map[0].posting_date)
& (ap.end_date >= gl_map[0].posting_date)
)
.run(as_dict=1)
accounting_periods = frappe.db.sql(
""" SELECT
ap.name as name, ap.exempted_role as exempted_role
FROM
`tabAccounting Period` ap, `tabClosed Document` cd
WHERE
ap.name = cd.parent
AND ap.company = %(company)s
AND ap.disabled = 0
AND cd.closed = 1
AND cd.document_type = %(voucher_type)s
AND %(date)s between ap.start_date and ap.end_date
""",
{
"date": gl_map[0].posting_date,
"company": gl_map[0].company,
"voucher_type": gl_map[0].voucher_type,
},
as_dict=1,
)
if accounting_periods:
@@ -78,11 +81,13 @@ def validate_cwip_accounts(gl_map):
for ac in frappe.db.get_all("Asset Category", "enable_cwip_accounting")
)
if cwip_enabled:
cwip_accounts = frappe.get_all(
"Account",
filters={"account_type": "Capital Work in Progress", "is_group": 0},
pluck="name",
)
cwip_accounts = [
d[0]
for d in frappe.db.sql(
"""select name from tabAccount
where account_type = 'Capital Work in Progress' and is_group=0"""
)
]
for entry in gl_map:
if entry.account in cwip_accounts:
@@ -117,24 +122,13 @@ def check_freezing_date(posting_date, company, adv_adj=False):
)
def validate_opening_entry_against_pcv(company):
if frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
def validate_against_pcv(is_opening, posting_date, company):
if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
frappe.throw(
_(
"A Period Closing Voucher is already submitted and an Opening Entry can no longer be created. {0} to learn more."
).format(
'<a href="https://docs.frappe.io/erpnext/period-closing-voucher#14-pcv-and-opening-entries" target="_blank" rel="noopener">'
+ _("Read the docs")
+ "</a>"
),
_("Opening Entry can not be created after Period Closing Voucher is created."),
title=_("Invalid Opening Entry"),
)
def validate_against_pcv(is_opening, posting_date, company):
if is_opening:
validate_opening_entry_against_pcv(company)
last_pcv_date = frappe.db.get_value(
"Period Closing Voucher", {"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}]
)

View File

@@ -13,7 +13,7 @@ from frappe.desk.reportview import build_match_conditions
from frappe.model.meta import get_field_precision
from frappe.model.naming import determine_consecutive_week_number
from frappe.query_builder import AliasedQuery, Case, Criterion, Field, Table
from frappe.query_builder.functions import Count, IfNull, Max, Min, Round, Sum
from frappe.query_builder.functions import Count, IfNull, Max, Round, Sum
from frappe.query_builder.utils import DocType
from frappe.utils import (
add_days,
@@ -411,9 +411,10 @@ def get_count_on(account, fieldname, date):
else:
dr_or_cr = "debit" if fieldname == "invoiced_amount" else "credit"
cr_or_dr = "credit" if fieldname == "invoiced_amount" else "debit"
gl = frappe.qb.DocType("GL Entry")
amount_expr = (
Sum(gl.credit - gl.debit) if fieldname == "invoiced_amount" else Sum(gl.debit - gl.credit)
select_fields = (
"ifnull(sum(credit-debit),0)"
if fieldname == "invoiced_amount"
else "ifnull(sum(debit-credit),0)"
)
if (
@@ -421,21 +422,14 @@ def get_count_on(account, fieldname, date):
or (gle.against_voucher_type in ["Sales Order", "Purchase Order"])
or (gle.against_voucher == gle.voucher_no and gle.get(dr_or_cr) > 0)
):
payment_amount = (
(
frappe.qb.from_(gl)
.select(amount_expr)
.where(
(gl.docstatus < 2)
& (gl.posting_date <= date)
& (gl.against_voucher == gle.voucher_no)
& (gl.party == gle.party)
& (gl.name != gle.name)
)
.run()[0][0]
)
or 0
)
payment_amount = frappe.db.sql(
f"""
SELECT {select_fields}
FROM `tabGL Entry` gle
WHERE docstatus < 2 and posting_date <= %(date)s and against_voucher = %(voucher_no)s
and party = %(party)s and name != %(name)s""",
{"date": date, "voucher_no": gle.voucher_no, "party": gle.party, "name": gle.name},
)[0][0]
outstanding_amount = flt(gle.get(dr_or_cr)) - flt(gle.get(cr_or_dr)) - payment_amount
currency_precision = get_currency_precision() or 2
@@ -1175,27 +1169,26 @@ def get_company_default(company: str, fieldname: str, ignore_validation: bool =
def fix_total_debit_credit():
gle = frappe.qb.DocType("GL Entry")
vouchers = (
frappe.qb.from_(gle)
.select(gle.voucher_type, gle.voucher_no, (Sum(gle.debit) - Sum(gle.credit)).as_("diff"))
.groupby(gle.voucher_type, gle.voucher_no)
.having(Sum(gle.debit) != Sum(gle.credit))
.run(as_dict=1)
vouchers = frappe.db.sql(
"""select voucher_type, voucher_no,
sum(debit) - sum(credit) as diff
from `tabGL Entry`
group by voucher_type, voucher_no
having sum(debit) != sum(credit)""",
as_dict=1,
)
for d in vouchers:
if abs(d.diff) > 0:
dr_or_cr = d.voucher_type == "Sales Invoice" and "credit" or "debit"
gle = frappe.qb.DocType("GL Entry")
name = frappe.db.get_value(
"GL Entry",
{"voucher_type": d.voucher_type, "voucher_no": d.voucher_no, dr_or_cr: [">", 0]},
"name",
frappe.db.sql(
"""update `tabGL Entry` set {} = {} + {}
where voucher_type = {} and voucher_no = {} and {} > 0 limit 1""".format(
dr_or_cr, dr_or_cr, "%s", "%s", "%s", dr_or_cr
),
(d.diff, d.voucher_type, d.voucher_no),
)
if name:
frappe.qb.update(gle).set(gle[dr_or_cr], gle[dr_or_cr] + d.diff).where(gle.name == name).run()
def get_currency_precision():
@@ -1237,12 +1230,11 @@ def get_held_invoices(party_type, party):
held_invoices = None
if party_type == "Supplier":
held_invoices = frappe.get_all(
"Purchase Invoice",
filters={"on_hold": 1, "release_date": [">", nowdate()]},
pluck="name",
held_invoices = frappe.db.sql(
"select name from `tabPurchase Invoice` where on_hold = 1 and release_date IS NOT NULL and release_date > CURDATE()",
as_dict=1,
)
held_invoices = set(held_invoices)
held_invoices = set(d["name"] for d in held_invoices)
return held_invoices
@@ -1750,15 +1742,13 @@ def sort_stock_vouchers_by_posting_date(
sle = frappe.qb.DocType("Stock Ledger Entry")
voucher_nos = [v[1] for v in stock_vouchers]
# only voucher_type/voucher_no are used downstream; order by Min() of the (per-voucher constant)
# posting_datetime so postgres accepts the GROUP BY without selecting non-aggregated columns
sles = (
frappe.qb.from_(sle)
.select(sle.voucher_type, sle.voucher_no)
.select(sle.voucher_type, sle.voucher_no, sle.posting_date, sle.posting_time, sle.creation)
.where((sle.is_cancelled == 0) & (sle.voucher_no.isin(voucher_nos)))
.groupby(sle.voucher_type, sle.voucher_no)
.orderby(Min(sle.posting_datetime))
.orderby(Min(sle.creation))
.orderby(sle.posting_datetime)
.orderby(sle.creation)
)
if company:
@@ -1779,37 +1769,25 @@ def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, f
SLE = DocType("Stock Ledger Entry")
conditions = (SLE.posting_datetime >= posting_datetime) & (SLE.is_cancelled == 0)
if for_items:
conditions &= SLE.item_code.isin(for_items)
if for_warehouses:
conditions &= SLE.warehouse.isin(for_warehouses)
if company:
conditions &= SLE.company == company
# These SLE rows must stay locked for the duration of the repost so a concurrent stock
# transaction can't modify them mid-flight (the original DISTINCT ... FOR UPDATE did this).
# MariaDB carries the lock on the grouped query below; postgres rejects FOR UPDATE alongside
# GROUP BY, so lock the matching rows in a separate pass first -- the row locks are held until
# the surrounding transaction ends, giving the same protection.
if frappe.db.db_type == "postgres":
frappe.qb.from_(SLE).select(SLE.name).where(conditions).for_update().run()
# distinct vouchers in chronological order; expressed as GROUP BY + Min() so it's valid on
# postgres (SELECT DISTINCT can't ORDER BY non-selected cols, and FOR UPDATE is invalid with both).
# posting_datetime is constant per voucher, so the ordering is unchanged vs the DISTINCT form.
query = (
frappe.qb.from_(SLE)
.select(SLE.voucher_type, SLE.voucher_no)
.where(conditions)
.groupby(SLE.voucher_type, SLE.voucher_no)
.orderby(Min(SLE.posting_datetime))
.orderby(Min(SLE.creation))
.distinct()
.where(SLE.posting_datetime >= posting_datetime)
.where(SLE.is_cancelled == 0)
.orderby(SLE.posting_datetime)
.orderby(SLE.creation)
.for_update()
)
# lock scanned rows on MariaDB; on postgres they were already locked above
if frappe.db.db_type != "postgres":
query = query.for_update()
if for_items:
query = query.where(SLE.item_code.isin(for_items))
if for_warehouses:
query = query.where(SLE.warehouse.isin(for_warehouses))
if company:
query = query.where(SLE.company == company)
future_stock_vouchers = query.run(as_dict=True)
@@ -1831,11 +1809,14 @@ def get_voucherwise_gl_entries(future_stock_vouchers, posting_date):
voucher_nos = [d[1] for d in future_stock_vouchers]
gles = frappe.get_all(
"GL Entry",
filters={"posting_date": [">=", posting_date], "voucher_no": ["in", voucher_nos]},
fields=["name", "account", "credit", "debit", "cost_center", "project", "voucher_type", "voucher_no"],
limit=0,
gles = frappe.db.sql(
"""
select name, account, credit, debit, cost_center, project, voucher_type, voucher_no
from `tabGL Entry`
where
posting_date >= {} and voucher_no in ({})""".format("%s", ", ".join(["%s"] * len(voucher_nos))),
tuple([posting_date, *voucher_nos]),
as_dict=1,
)
for d in gles:
@@ -2254,7 +2235,7 @@ def delink_original_entry(pl_entry, partial_cancel=False):
qb.update(ple)
.set(ple.modified, now())
.set(ple.modified_by, frappe.session.user)
.set(ple.delinked, 1) # smallint column; postgres rejects boolean true
.set(ple.delinked, True)
.where(
(ple.company == pl_entry.company)
& (ple.account_type == pl_entry.account_type)
@@ -2369,10 +2350,8 @@ class QueryPaymentLedger:
.where(Criterion.all(self.dimensions_filter))
.where(Criterion.all(self.voucher_posting_date))
.groupby(ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
# order by the select aliases (postgres can't ORDER BY a non-existent ple column)
.orderby(qb.Field("invoice_date"), qb.Field("voucher_no"))
# postgres HAVING can't reference a select alias; use the aggregate expression
.having(Sum(ple.amount_in_account_currency) > 0)
.orderby(ple.invoice_date, ple.voucher_no)
.having(qb.Field("amount_in_account_currency") > 0)
.limit(self.limit)
.run()
)
@@ -2386,21 +2365,18 @@ class QueryPaymentLedger:
query_voucher_amount = (
qb.from_(ple)
.select(
# columns that are constant per (voucher_type, voucher_no, party_type, party) are
# wrapped in Max() so the query is valid on postgres (which, unlike MariaDB, requires
# every non-aggregated column to be grouped or aggregated)
Max(ple.account).as_("account"),
ple.account,
ple.voucher_type,
ple.voucher_no,
ple.party_type,
ple.party,
Max(ple.posting_date).as_("posting_date"),
Max(ple.due_date).as_("due_date"),
Max(ple.account_currency).as_("currency"),
Max(ple.cost_center).as_("cost_center"),
ple.posting_date,
ple.due_date,
ple.account_currency.as_("currency"),
ple.cost_center.as_("cost_center"),
Sum(ple.amount).as_("amount"),
Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
Max(ple.remarks).as_("remarks"),
ple.remarks,
)
.where(ple.delinked == 0)
.where(Criterion.all(filter_on_voucher_no))
@@ -2414,15 +2390,14 @@ class QueryPaymentLedger:
query_voucher_outstanding = (
qb.from_(ple)
.select(
# Max() on columns constant per group keeps this valid on postgres (see above)
Max(ple.account).as_("account"),
ple.account,
ple.against_voucher_type.as_("voucher_type"),
ple.against_voucher_no.as_("voucher_no"),
ple.party_type,
ple.party,
Max(ple.posting_date).as_("posting_date"),
Max(ple.due_date).as_("due_date"),
Max(ple.account_currency).as_("currency"),
ple.posting_date,
ple.due_date,
ple.account_currency.as_("currency"),
Sum(ple.amount).as_("amount"),
Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
)
@@ -2471,19 +2446,17 @@ class QueryPaymentLedger:
# build CTE filter
# only fetch invoices
# The combined CTE query has no GROUP BY, so these are row filters. MariaDB tolerates HAVING
# on a select alias here, but postgres does not; express them as WHERE on the source column.
if self.get_invoices:
self.cte_query_voucher_amount_and_outstanding = (
self.cte_query_voucher_amount_and_outstanding.where(
Table("outstanding").amount_in_account_currency > 0
self.cte_query_voucher_amount_and_outstanding.having(
qb.Field("outstanding_in_account_currency") > 0
)
)
# only fetch payments
elif self.get_payments:
self.cte_query_voucher_amount_and_outstanding = (
self.cte_query_voucher_amount_and_outstanding.where(
Table("outstanding").amount_in_account_currency < 0
self.cte_query_voucher_amount_and_outstanding.having(
qb.Field("outstanding_in_account_currency") < 0
)
)

View File

@@ -735,16 +735,12 @@ class Asset(AccountsController):
frappe.throw(_("Asset cannot be cancelled, as it is already {0}").format(self.status))
def cancel_movement_entries(self):
# filter the parent Asset Movement's docstatus (as the original SQL did), not the child row's
asm = frappe.qb.DocType("Asset Movement")
asm_item = frappe.qb.DocType("Asset Movement Item")
movements = (
frappe.qb.from_(asm_item)
.inner_join(asm)
.on(asm_item.parent == asm.name)
.select(asm.name)
.where((asm_item.asset == self.name) & (asm.docstatus == 1))
.run(as_dict=True)
movements = frappe.db.sql(
"""SELECT asm.name, asm.docstatus
FROM `tabAsset Movement` asm, `tabAsset Movement Item` asm_item
WHERE asm_item.parent=asm.name and asm_item.asset=%s and asm.docstatus=1""",
self.name,
as_dict=1,
)
for movement in movements:
@@ -864,18 +860,15 @@ class Asset(AccountsController):
cwip_enabled = is_cwip_accounting_enabled(self.asset_category)
cwip_account = self.get_cwip_account(cwip_enabled=cwip_enabled)
query = """SELECT name FROM `tabGL Entry` WHERE voucher_no = %s and account = %s"""
if asset_bought_with_invoice:
# with invoice purchase either expense or cwip has been booked
expense_booked = frappe.db.exists(
"GL Entry", {"voucher_no": purchase_document, "account": fixed_asset_account}
)
expense_booked = frappe.db.sql(query, (purchase_document, fixed_asset_account), as_dict=1)
if expense_booked:
# if expense is already booked from invoice then do not make gl entries regardless of cwip enabled/disabled
return False
cwip_booked = frappe.db.exists(
"GL Entry", {"voucher_no": purchase_document, "account": cwip_account}
)
cwip_booked = frappe.db.sql(query, (purchase_document, cwip_account), as_dict=1)
if cwip_booked:
# if cwip is booked from invoice then make gl entries regardless of cwip enabled/disabled
return True
@@ -885,11 +878,10 @@ class Asset(AccountsController):
# if cwip account isn't available do not make gl entries
return False
cwip_booked = frappe.db.sql(query, (purchase_document, cwip_account), as_dict=1)
# if cwip is not booked from receipt then do not make gl entries
# if cwip is booked from receipt then make gl entries
return bool(
frappe.db.exists("GL Entry", {"voucher_no": purchase_document, "account": cwip_account})
)
return cwip_booked
def get_purchase_document(self):
asset_bought_with_invoice = self.purchase_invoice and frappe.db.get_value(
@@ -1082,15 +1074,11 @@ def make_post_gl_entry():
for asset_category in asset_categories:
if cint(asset_category.enable_cwip_accounting):
assets = frappe.get_all(
"Asset",
filters={
"asset_category": asset_category.name,
"booked_fixed_asset": 0,
"available_for_use_date": nowdate(),
"docstatus": 1,
},
pluck="name",
assets = frappe.db.sql_list(
""" select name from `tabAsset`
where asset_category = %s and ifnull(booked_fixed_asset, 0) = 0
and available_for_use_date = %s and docstatus = 1""",
(asset_category.name, nowdate()),
)
for asset in assets:

View File

@@ -85,8 +85,8 @@ class TestAsset(AssetSetup):
self.assertRaises(frappe.ValidationError, asset.save)
def test_validate_item(self):
asset = create_asset(item_code="Macbook Pro", do_not_save=1)
item = frappe.get_doc("Item", "Macbook Pro")
asset = create_asset(item_code="MacBook Pro", do_not_save=1)
item = frappe.get_doc("Item", "MacBook Pro")
item.disabled = 1
item.save()
@@ -140,7 +140,7 @@ class TestAsset(AssetSetup):
)
gle = get_gl_entries("Purchase Invoice", pi.name)
self.assertCountEqual(gle, expected_gle)
self.assertSequenceEqual(gle, expected_gle)
pi.cancel()
asset.cancel()
@@ -283,7 +283,7 @@ class TestAsset(AssetSetup):
)
gle = get_gl_entries("Journal Entry", asset.journal_entry_for_scrap)
self.assertCountEqual(gle, expected_gle)
self.assertSequenceEqual(gle, expected_gle)
restore_asset(asset.name)
second_asset_depr_schedule.load_from_db()
@@ -362,7 +362,7 @@ class TestAsset(AssetSetup):
("Debtors - _TC", 25000.0, 0.0),
)
gle = get_gl_entries("Sales Invoice", si.name)
self.assertCountEqual(gle, expected_gle)
self.assertSequenceEqual(gle, expected_gle)
si.cancel()
self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Partially Depreciated")
@@ -436,7 +436,7 @@ class TestAsset(AssetSetup):
)
gle = get_gl_entries("Sales Invoice", si.name)
self.assertCountEqual(gle, expected_gle)
self.assertSequenceEqual(gle, expected_gle)
def test_asset_with_maintenance_required_status_after_sale(self):
asset = create_asset(
@@ -577,7 +577,7 @@ class TestAsset(AssetSetup):
)
pr_gle = get_gl_entries("Purchase Receipt", pr.name)
self.assertCountEqual(pr_gle, expected_gle)
self.assertSequenceEqual(pr_gle, expected_gle)
pi = make_invoice(pr.name)
pi.submit()
@@ -590,7 +590,7 @@ class TestAsset(AssetSetup):
)
pi_gle = get_gl_entries("Purchase Invoice", pi.name)
self.assertCountEqual(pi_gle, expected_gle)
self.assertSequenceEqual(pi_gle, expected_gle)
asset = frappe.db.get_value("Asset", {"purchase_receipt": pr.name, "docstatus": 0}, "name")
@@ -617,7 +617,7 @@ class TestAsset(AssetSetup):
expected_gle = (("_Test Fixed Asset - _TC", 5250.0, 0.0), ("CWIP Account - _TC", 0.0, 5250.0))
gle = get_gl_entries("Asset", asset_doc.name)
self.assertCountEqual(gle, expected_gle)
self.assertSequenceEqual(gle, expected_gle)
def test_asset_cwip_toggling_cases(self):
cwip = frappe.db.get_value("Asset Category", "Computers", "enable_cwip_accounting")
@@ -1732,18 +1732,14 @@ class TestDepreciationBasics(AssetSetup):
("_Test Depreciations - _TC", 30000.0, 0.0),
)
gle = [
tuple(row)
for row in frappe.get_all(
"GL Entry",
filters={"against_voucher_type": "Asset", "against_voucher": asset.name},
fields=["account", "debit", "credit"],
order_by="account",
as_list=True,
)
]
gle = frappe.db.sql(
"""select account, debit, credit from `tabGL Entry`
where against_voucher_type='Asset' and against_voucher = %s
order by account""",
asset.name,
)
self.assertCountEqual(gle, expected_gle)
self.assertSequenceEqual(gle, expected_gle)
self.assertEqual(asset.get("value_after_depreciation"), 70000)
def test_expected_value_change(self):

View File

@@ -2,7 +2,6 @@
# See license.txt
import frappe
from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt, now_datetime
from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
@@ -550,33 +549,34 @@ def create_depreciation_asset(**args):
def get_actual_gle_dict(name):
gle = frappe.qb.DocType("GL Entry")
diff = Sum(gle.debit - gle.credit)
return dict(
frappe.qb.from_(gle)
.select(gle.account, diff.as_("diff"))
.where((gle.voucher_type == "Asset Capitalization") & (gle.voucher_no == name))
.groupby(gle.account)
.having(diff != 0)
.run()
frappe.db.sql(
"""
select account, sum(debit-credit) as diff
from `tabGL Entry`
where voucher_type = 'Asset Capitalization' and voucher_no = %s
group by account
having diff != 0
""",
name,
)
)
def get_actual_sle_dict(name):
sle = frappe.qb.DocType("Stock Ledger Entry")
actual_qty = Sum(sle.actual_qty)
sles = (
frappe.qb.from_(sle)
.select(
sle.item_code,
sle.warehouse,
actual_qty.as_("actual_qty"),
Sum(sle.stock_value_difference).as_("stock_value_difference"),
)
.where((sle.voucher_type == "Asset Capitalization") & (sle.voucher_no == name))
.groupby(sle.item_code, sle.warehouse)
.having(actual_qty != 0)
.run(as_dict=1)
sles = frappe.db.sql(
"""
select
item_code, warehouse,
sum(actual_qty) as actual_qty,
sum(stock_value_difference) as stock_value_difference
from `tabStock Ledger Entry`
where voucher_type = 'Asset Capitalization' and voucher_no = %s
group by item_code, warehouse
having actual_qty != 0
""",
name,
as_dict=1,
)
sle_dict = {}

View File

@@ -79,14 +79,11 @@ def assign_tasks(asset_maintenance_name, assign_to_member, maintenance_task, nex
"description": maintenance_task,
"date": next_due_date,
}
if not frappe.db.exists(
"ToDo",
{
"reference_type": args["doctype"],
"reference_name": args["name"],
"status": "Open",
"owner": args["assign_to"],
},
if not frappe.db.sql(
"""select owner from `tabToDo`
where reference_type=%(doctype)s and reference_name=%(name)s and status='Open'
and owner=%(assign_to)s""",
args,
):
# assign_to function expects a list
args["assign_to"] = [args["assign_to"]]
@@ -190,9 +187,13 @@ def get_team_members(
@frappe.whitelist()
def get_maintenance_log(asset_name: str):
return frappe.get_all(
"Asset Maintenance Log",
filters={"asset_name": asset_name},
fields=["maintenance_status", {"COUNT": "asset_name", "as": "count"}, "asset_name"],
group_by="maintenance_status, asset_name",
return frappe.db.sql(
"""
select maintenance_status, count(asset_name) as count, asset_name
from `tabAsset Maintenance Log`
where asset_name=%s
group by maintenance_status
""",
(asset_name,),
as_dict=1,
)

View File

@@ -18,36 +18,6 @@ class TestAssetMaintenance(ERPNextTestSuite):
self.asset_name = frappe.db.get_value("Asset", {"purchase_receipt": self.pr.name}, "name")
self.asset_doc = frappe.get_doc("Asset", self.asset_name)
def test_get_maintenance_log_counts_by_status(self):
"""get_maintenance_log uses a v16 dict aggregate field spec
({"COUNT": "asset_name", "as": "count"}); confirm it runs and returns correct per-status counts
on both engines (the whitelisted endpoint was previously untested)."""
from erpnext.assets.doctype.asset_maintenance.asset_maintenance import get_maintenance_log
self.asset_doc.available_for_use_date = nowdate()
self.asset_doc.purchase_date = nowdate()
self.asset_doc.save()
frappe.get_doc(
{
"doctype": "Asset Maintenance",
"asset_name": self.asset_name,
"maintenance_team": "Team Awesome",
"company": "_Test Company",
"asset_maintenance_tasks": get_maintenance_tasks(),
}
).insert()
rows = get_maintenance_log(self.asset_name)
# the dict aggregate spec did not crash and returned grouped rows...
self.assertTrue(rows)
self.assertTrue(all("maintenance_status" in r for r in rows))
# ...and the per-status counts sum to the total number of logs for this asset
self.assertEqual(
sum(r["count"] for r in rows),
frappe.db.count("Asset Maintenance Log", {"asset_name": self.asset_name}),
)
def test_create_asset_maintenance_with_log(self):
month_end_date = get_last_day(nowdate())

View File

@@ -127,20 +127,24 @@ class AssetMovement(Document):
def get_latest_location_and_custodian(self, asset):
current_location, current_employee = "", ""
cond = "1=1"
# latest entry corresponds to current document's location, employee when transaction date > previous dates
# In case of cancellation it corresponds to previous latest document's location, employee
asm = frappe.qb.DocType("Asset Movement")
asm_item = frappe.qb.DocType("Asset Movement Item")
latest_movement_entry = (
frappe.qb.from_(asm_item)
.inner_join(asm)
.on(asm_item.parent == asm.name)
.select(asm_item.target_location, asm_item.to_employee)
.where((asm_item.asset == asset) & (asm.company == self.company) & (asm.docstatus == 1))
.orderby(asm.transaction_date, order=frappe.qb.desc)
.limit(1)
.run()
args = {"asset": asset, "company": self.company}
latest_movement_entry = frappe.db.sql(
f"""
SELECT asm_item.target_location, asm_item.to_employee
FROM `tabAsset Movement Item` asm_item
JOIN `tabAsset Movement` asm ON asm_item.parent = asm.name
WHERE
asm_item.asset = %(asset)s AND
asm.company = %(company)s AND
asm.docstatus = 1 AND {cond}
ORDER BY asm.transaction_date DESC
LIMIT 1
""",
args,
)
if latest_movement_entry:

View File

@@ -215,12 +215,17 @@ def get_children(doctype: str, parent: str | None = None, location: str | None =
if parent is None or parent == "All Locations":
parent = ""
filters = {"parent_location": parent} if parent else {"parent_location": ["is", "not set"]}
return frappe.get_all(
"Location",
filters=filters,
fields=["name as value", "is_group as expandable"],
return frappe.db.sql(
f"""
select
name as value,
is_group as expandable
from
`tabLocation` comp
where
ifnull(parent_location, "")={frappe.db.escape(parent)}
""",
as_dict=1,
)

View File

@@ -395,30 +395,32 @@ def get_group_by_data(
def get_purchase_receipt_supplier_map():
pr = frappe.qb.DocType("Purchase Receipt")
pri = frappe.qb.DocType("Purchase Receipt Item")
return frappe._dict(
frappe.qb.from_(pr)
.inner_join(pri)
.on(pri.parent == pr.name)
.select(pr.name, pr.supplier)
.distinct()
.where((pri.is_fixed_asset == 1) & (pr.docstatus == 1) & (pr.is_return == 0))
.run()
frappe.db.sql(
""" Select
pr.name, pr.supplier
FROM `tabPurchase Receipt` pr, `tabPurchase Receipt Item` pri
WHERE
pri.parent = pr.name
AND pri.is_fixed_asset=1
AND pr.docstatus=1
AND pr.is_return=0"""
)
)
def get_purchase_invoice_supplier_map():
pi = frappe.qb.DocType("Purchase Invoice")
pii = frappe.qb.DocType("Purchase Invoice Item")
return frappe._dict(
frappe.qb.from_(pi)
.inner_join(pii)
.on(pii.parent == pi.name)
.select(pi.name, pi.supplier)
.distinct()
.where((pii.is_fixed_asset == 1) & (pi.docstatus == 1) & (pi.is_return == 0))
.run()
frappe.db.sql(
""" Select
pi.name, pi.supplier
FROM `tabPurchase Invoice` pi, `tabPurchase Invoice Item` pii
WHERE
pii.parent = pi.name
AND pii.is_fixed_asset=1
AND pi.docstatus=1
AND pi.is_return=0"""
)
)

View File

@@ -1,33 +0,0 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.assets.doctype.asset.test_asset import AssetSetup, create_asset
from erpnext.assets.report.fixed_asset_register.fixed_asset_register import execute
class TestFixedAssetRegister(AssetSetup):
def test_report_lists_submitted_asset(self):
"""Exercises the report's converted queries -- including the depreciation aggregate that groups
by asset.name (must be valid on Postgres) -- by asserting a submitted asset is listed."""
asset = create_asset(
item_code="Macbook Pro",
purchase_date="2020-01-01",
available_for_use_date="2020-06-06",
location="Test Location",
submit=1,
)
filters = frappe._dict(
{
"company": "_Test Company",
"status": "In Location",
"filter_based_on": "Date Range",
"from_date": "2020-01-01",
"to_date": "2030-12-31",
"date_based_on": "Purchase Date",
}
)
data = execute(filters)[1]
asset_ids = {row.get("asset_id") for row in data}
self.assertIn(asset.name, asset_ids)

View File

@@ -30,7 +30,10 @@ class BulkTransactionLog(Document):
def load_from_db(self):
log_detail = qb.DocType("Bulk Transaction Log Detail")
has_records = frappe.db.exists("Bulk Transaction Log Detail", {"date": self.name})
has_records = frappe.db.sql(
"select exists (select * from `tabBulk Transaction Log Detail` where date = %s);",
(self.name,),
)[0][0]
if not has_records:
raise frappe.DoesNotExistError

View File

@@ -1,76 +1,11 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import frappe
from frappe.utils import nowtime, random_string
from erpnext.tests.utils import ERPNextTestSuite
class TestBulkTransactionLog(ERPNextTestSuite):
def _make_log_doc(self, date):
# "Bulk Transaction Log" is a virtual doctype named by date; build the doc
# in-memory and drive load_from_db() directly to exercise the converted query.
doc = frappe.new_doc("Bulk Transaction Log")
doc.name = date
return doc
def _insert_detail(self, date, status="Success"):
detail = frappe.get_doc(
{
"doctype": "Bulk Transaction Log Detail",
"from_doctype": "Sales Order",
"to_doctype": "Sales Invoice",
"transaction_name": "_Test BTLD " + random_string(8),
"date": date,
"time": nowtime(),
"transaction_status": status,
}
)
# transaction_name is a Dynamic Link (options=from_doctype); the converted
# query never reads it, so skip link validation rather than create real txns.
detail.insert(ignore_permissions=True, ignore_links=True)
return detail
def test_load_raises_when_no_detail_rows(self):
# A date with zero Bulk Transaction Log Detail rows must not resolve to a log.
date = "2024-01-01"
self.assertFalse(
frappe.db.exists("Bulk Transaction Log Detail", {"date": date}),
"precondition: no detail rows for this date",
)
doc = self._make_log_doc(date)
self.assertRaises(frappe.DoesNotExistError, doc.load_from_db)
def test_load_succeeds_and_aggregates_after_detail_inserted(self):
date = "2024-02-02"
# Initially absent -> load_from_db must raise.
self.assertRaises(frappe.DoesNotExistError, self._make_log_doc(date).load_from_db)
# Insert detail rows for this date: 2 succeeded, 1 failed.
self._insert_detail(date, "Success")
self._insert_detail(date, "Success")
self._insert_detail(date, "Failed")
# Now the exists() check passes and load_from_db() populates aggregates.
doc = self._make_log_doc(date)
doc.load_from_db()
self.assertEqual(doc.date, date)
self.assertEqual(doc.succeeded, 2)
self.assertEqual(doc.failed, 1)
self.assertEqual(doc.log_entries, 3)
def test_load_isolated_per_date(self):
# Detail rows on a different date must not satisfy the lookup for our date.
other_date = "2024-03-03"
self._insert_detail(other_date, "Success")
target_date = "2024-04-04"
self.assertFalse(
frappe.db.exists("Bulk Transaction Log Detail", {"date": target_date}),
"target date has no rows; rows on another date must not leak in",
)
self.assertRaises(frappe.DoesNotExistError, self._make_log_doc(target_date).load_from_db)
pass

View File

@@ -3,7 +3,7 @@
frappe.ui.form.on("Buying Settings", {
refresh(frm) {
if (!frm.naming_controller) frm.naming_controller = new frappe.ui.NamingSeriesController(frm);
if (!frm.naming_controller) frm.naming_controller = new erpnext.NamingSeriesController(frm);
const display = frm.doc.supp_master_name === "Naming Series";
frm.set_df_property("naming_series_details", "hidden", !display);

View File

@@ -477,8 +477,10 @@ class TestPurchaseOrder(ERPNextTestSuite):
item_doc.save()
else:
# update valid from
frappe.db.set_value(
"Item Tax", {"parent": item, "item_tax_template": tax_template}, "valid_from", nowdate()
frappe.db.sql(
"""UPDATE `tabItem Tax` set valid_from = CURRENT_DATE
where parent = %(item)s and item_tax_template = %(tax)s""",
{"item": item, "tax": tax_template},
)
po = create_purchase_order(item_code=item, qty=1, do_not_save=1)
@@ -525,8 +527,10 @@ class TestPurchaseOrder(ERPNextTestSuite):
self.assertEqual(po.taxes[1].total, 840)
# teardown
frappe.db.set_value(
"Item Tax", {"parent": item, "item_tax_template": tax_template}, "valid_from", None
frappe.db.sql(
"""UPDATE `tabItem Tax` set valid_from = NULL
where parent = %(item)s and item_tax_template = %(tax)s""",
{"item": item, "tax": tax_template},
)
po.cancel()
po.delete()
@@ -648,7 +652,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
def test_purchase_order_on_hold(self):
po = create_purchase_order(item_code="_Test Product Bundle Item")
po.db_set("status", "On Hold")
po.db_set("Status", "On Hold")
pi = make_pi_from_po(po.name)
pr = make_purchase_receipt(po.name)
self.assertRaises(frappe.ValidationError, pr.submit)

View File

@@ -43,7 +43,6 @@ def make_supplier_quotation_from_rfq(
"name": "request_for_quotation_item",
"parent": "request_for_quotation",
"project_name": "project",
"cost_center": "cost_center",
},
},
},
@@ -111,7 +110,6 @@ def create_rfq_items(sq_doc, supplier, data):
"material_request_item",
"stock_qty",
"uom",
"cost_center",
]:
args[field] = data.get(field)
@@ -178,7 +176,6 @@ def get_item_from_material_requests_based_on_supplier(
["name", "material_request_item"],
["parent", "material_request"],
["uom", "uom"],
["cost_center", "cost_center"],
],
},
},

View File

@@ -19,7 +19,6 @@ from erpnext.controllers.accounts_controller import InvalidQtyError
from erpnext.crm.doctype.opportunity.mapper import make_request_for_quotation as make_rfq
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
from erpnext.templates.pages.rfq import check_supplier_has_docname_access
from erpnext.tests.utils import ERPNextTestSuite
@@ -60,42 +59,6 @@ class TestRequestforQuotation(ERPNextTestSuite):
self.assertEqual(rfq.get("suppliers")[0].quote_status, "Received")
self.assertEqual(rfq.get("suppliers")[1].quote_status, "Pending")
def test_duplicate_supplier_rejected(self):
rfq = frappe.new_doc("Request for Quotation")
rfq.transaction_date = nowdate()
rfq.company = "_Test Company"
rfq.message_for_supplier = "Please quote"
rfq.append("suppliers", {"supplier": "_Test Supplier"})
rfq.append("suppliers", {"supplier": "_Test Supplier"})
rfq.append(
"items",
{
"item_code": "_Test Item",
"qty": 5,
"uom": "_Test UOM",
"stock_uom": "_Test UOM",
"conversion_factor": 1.0,
"warehouse": "_Test Warehouse - _TC",
"schedule_date": nowdate(),
},
)
self.assertRaises(frappe.ValidationError, rfq.insert)
def test_rfq_blocked_for_supplier_with_prevent_rfqs(self):
frappe.db.set_value("Supplier", "_Test Supplier", "prevent_rfqs", 1)
rfq = make_request_for_quotation(
supplier_data=[{"supplier": "_Test Supplier", "supplier_name": "_Test Supplier"}],
do_not_save=True,
)
self.assertRaises(frappe.ValidationError, rfq.save)
def test_rfq_status_lifecycle(self):
rfq = make_request_for_quotation()
self.assertEqual(rfq.status, "Submitted")
rfq.cancel()
self.assertEqual(rfq.status, "Cancelled")
def test_make_supplier_quotation(self):
rfq = make_request_for_quotation()
@@ -287,41 +250,6 @@ class TestRequestforQuotation(ERPNextTestSuite):
self.assertEqual(sq.items[0].qty, 0)
self.assertEqual(sq.items[0].item_code, rfq.items[0].item_code)
def test_cost_center_flows_from_mr_to_rfq(self):
from erpnext.stock.doctype.material_request.mapper import (
make_request_for_quotation as mr_make_rfq,
)
mr = make_material_request(cost_center="_Test Cost Center - _TC")
rfq = mr_make_rfq(mr.name)
self.assertEqual(rfq.items[0].cost_center, "_Test Cost Center - _TC")
def test_cost_center_flows_from_rfq_to_supplier_quotation(self):
rfq = make_request_for_quotation(do_not_submit=True)
rfq.items[0].cost_center = "_Test Cost Center - _TC"
rfq.save()
rfq.submit()
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.get("suppliers")[0].supplier)
self.assertEqual(sq.items[0].cost_center, "_Test Cost Center - _TC")
def test_cost_center_flows_end_to_end_mr_rfq_sq(self):
from erpnext.stock.doctype.material_request.mapper import (
make_request_for_quotation as mr_make_rfq,
)
mr = make_material_request(cost_center="_Test Cost Center - _TC")
rfq = mr_make_rfq(mr.name)
rfq.append("suppliers", {"supplier": "_Test Supplier", "supplier_name": "_Test Supplier"})
rfq.insert()
rfq.submit()
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier="_Test Supplier")
self.assertEqual(sq.items[0].cost_center, "_Test Cost Center - _TC")
def make_request_for_quotation(**args):
"""

View File

@@ -30,9 +30,7 @@
"col_break4",
"material_request",
"material_request_item",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"section_break_24",
"project_name",
"section_break_23",
"page_break"
@@ -255,26 +253,15 @@
},
{
"collapsible": 1,
"fieldname": "accounting_dimensions_section",
"fieldname": "section_break_24",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center",
"print_hide": 1
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-06-15 00:00:00.000000",
"modified": "2026-01-31 19:46:27.884592",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation Item",

View File

@@ -12,7 +12,6 @@
"field_order": [
"naming_series",
"supplier_name",
"alias",
"supplier_type",
"gender",
"column_break0",
@@ -541,13 +540,6 @@
{
"fieldname": "section_break_pgad",
"fieldtype": "Section Break"
},
{
"fieldname": "alias",
"fieldtype": "Data",
"in_global_search": 1,
"label": "Alias",
"unique": 1
}
],
"grid_page_length": 50,
@@ -561,7 +553,7 @@
"link_fieldname": "party"
}
],
"modified": "2026-06-22 12:23:09.241125",
"modified": "2026-05-29 16:52:59.441272",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier",
@@ -621,7 +613,7 @@
],
"quick_entry": 1,
"row_format": "Dynamic",
"search_fields": "supplier_group, alias",
"search_fields": "supplier_group",
"show_name_in_global_search": 1,
"sort_field": "creation",
"sort_order": "ASC",

View File

@@ -39,7 +39,6 @@ class Supplier(TransactionBase):
from erpnext.utilities.doctype.portal_user.portal_user import PortalUser
accounts: DF.Table[PartyAccount]
alias: DF.Data | None
allow_purchase_invoice_creation_without_purchase_order: DF.Check
allow_purchase_invoice_creation_without_purchase_receipt: DF.Check
companies: DF.Table[AllowedToTransactWith]

View File

@@ -243,16 +243,11 @@ def get_list_context(context=None):
def set_expired_status():
# Only submitted quotations past their validity should be expired
frappe.db.set_value(
"Supplier Quotation",
{
"docstatus": 1,
"status": ["not in", ["Cancelled", "Stopped"]],
"valid_till": ["<", nowdate()],
},
"status",
"Expired",
filters={"status": ["not in", ["Cancelled", "Stopped"]], "valid_till": ["<", nowdate()]},
fieldname="status",
value="Expired",
update_modified=True,
)

View File

@@ -8,12 +8,7 @@ import frappe
from frappe.tests import change_settings
from frappe.utils import add_days, today
from erpnext.buying.doctype.request_for_quotation.mapper import make_supplier_quotation_from_rfq
from erpnext.buying.doctype.request_for_quotation.test_request_for_quotation import (
make_request_for_quotation,
)
from erpnext.buying.doctype.supplier_quotation.mapper import make_purchase_order
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import set_expired_status
from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate
from erpnext.tests.utils import ERPNextTestSuite
@@ -22,56 +17,6 @@ class TestPurchaseOrder(ERPNextTestSuite):
def setUp(self):
self.load_test_records("Supplier Quotation")
def test_valid_till_before_transaction_date_rejected(self):
rfq = make_request_for_quotation()
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[0].supplier)
sq.transaction_date = today()
sq.valid_till = add_days(today(), -1)
self.assertRaises(frappe.ValidationError, sq.insert)
def test_set_expired_status_expires_only_submitted_past_quotations(self):
rfq = make_request_for_quotation()
expired = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[0].supplier)
expired.transaction_date = add_days(today(), -10)
expired.valid_till = add_days(today(), -2)
expired.insert()
expired.submit()
valid = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[1].supplier)
valid.valid_till = add_days(today(), 10)
valid.insert()
valid.submit()
# A past-validity draft must not be expired - "Expired" applies to submitted quotations only
draft = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[0].supplier)
draft.transaction_date = add_days(today(), -10)
draft.valid_till = add_days(today(), -2)
draft.insert()
set_expired_status()
self.assertEqual(frappe.db.get_value("Supplier Quotation", expired.name, "status"), "Expired")
self.assertEqual(frappe.db.get_value("Supplier Quotation", valid.name, "status"), "Submitted")
self.assertEqual(frappe.db.get_value("Supplier Quotation", draft.name, "status"), "Draft")
def test_submit_and_cancel_updates_rfq_quote_status(self):
rfq = make_request_for_quotation()
supplier_row = rfq.suppliers[0]
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=supplier_row.supplier)
sq.submit()
self.assertEqual(
frappe.db.get_value("Request for Quotation Supplier", supplier_row.name, "quote_status"),
"Received",
)
sq.cancel()
self.assertEqual(
frappe.db.get_value("Request for Quotation Supplier", supplier_row.name, "quote_status"),
"Pending",
)
def test_update_child_supplier_quotation_add_item(self):
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
sq.submit()

View File

@@ -60,20 +60,25 @@ class SupplierScorecard(Document):
self.save()
def validate_standings(self):
# Standings must form a continuous chain of bands covering 0 to 100 with no gaps or overlaps
expected_min = 0
for standing in sorted(self.standings, key=lambda s: s.min_grade or 0):
if standing.min_grade >= standing.max_grade:
throw(
_("Standing {0} must have a minimum grade lower than its maximum grade").format(
standing.standing_name
)
)
if standing.min_grade != expected_min:
throw(_("Standing scores must be continuous and cover 0 to 100 without gaps or overlaps"))
expected_min = standing.max_grade
if expected_min < 100:
throw(_("Standing scores must cover the full range from 0 to 100"))
# Check that there are no overlapping scores and check that there are no missing scores
score = 0
for c1 in self.standings:
for c2 in self.standings:
if c1 != c2:
if c1.max_grade > c2.min_grade and c1.min_grade < c2.max_grade:
throw(
_("Overlap in scoring between {0} and {1}").format(
c1.standing_name, c2.standing_name
)
)
if c2.min_grade == score:
score = c2.max_grade
if score < 100:
throw(
_(
"Unable to find score starting at {0}. You need to have standing scores covering 0 to 100"
).format(score)
)
def validate_criteria_weights(self):
weight = 0
@@ -114,29 +119,22 @@ class SupplierScorecard(Document):
self.supplier_score = 100
def update_standing(self):
highest_grade = max((s.max_grade for s in self.standings if s.max_grade), default=0)
# Get the setup document
for standing in self.standings:
if self.score_within_standing(standing, highest_grade):
self.apply_standing(standing)
if (not standing.min_grade or (standing.min_grade <= self.supplier_score)) and (
not standing.max_grade or (standing.max_grade > self.supplier_score)
):
self.status = standing.standing_name
self.indicator_color = standing.standing_color
self.notify_supplier = standing.notify_supplier
self.notify_employee = standing.notify_employee
self.employee_link = standing.employee_link
def score_within_standing(self, standing, highest_grade):
score = self.supplier_score
above_min = not standing.min_grade or standing.min_grade <= score
if standing.max_grade and standing.max_grade == highest_grade:
# Top band is inclusive of its upper bound so a perfect score still maps to a standing
return above_min and score <= standing.max_grade
return above_min and (not standing.max_grade or standing.max_grade > score)
def apply_standing(self, standing):
self.status = standing.standing_name
self.indicator_color = standing.standing_color
self.notify_supplier = standing.notify_supplier
self.notify_employee = standing.notify_employee
self.employee_link = standing.employee_link
for fieldname in ("prevent_pos", "prevent_rfqs", "warn_rfqs", "warn_pos"):
self.set(fieldname, standing.get(fieldname))
frappe.db.set_value("Supplier", self.supplier, fieldname, self.get(fieldname))
# Update supplier standing info
for fieldname in ("prevent_pos", "prevent_rfqs", "warn_rfqs", "warn_pos"):
self.set(fieldname, standing.get(fieldname))
frappe.db.set_value("Supplier", self.supplier, fieldname, self.get(fieldname))
@frappe.whitelist()

View File

@@ -3,12 +3,7 @@
import frappe
from frappe.utils import add_days, getdate, nowdate
from erpnext.buying.doctype.supplier_scorecard.supplier_scorecard import (
get_scorecard_date,
make_all_scorecards,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -23,72 +18,6 @@ class TestSupplierScorecard(ERPNextTestSuite):
d.weight = 0
self.assertRaises(frappe.ValidationError, my_doc.insert)
def test_overlapping_standings_are_rejected(self):
doc = make_supplier_scorecard()
# "Poor" (30-50) stretched to 60 now overlaps "Average" (50-80)
doc.standings[1].max_grade = 60
self.assertRaises(frappe.ValidationError, doc.validate_standings)
def test_standings_must_cover_full_range(self):
doc = make_supplier_scorecard()
# "Excellent" capped at 90 leaves the 90-100 band uncovered
doc.standings[3].max_grade = 90
self.assertRaises(frappe.ValidationError, doc.validate_standings)
def test_inverted_standing_band_rejected(self):
doc = make_supplier_scorecard()
doc.standings = []
doc.append("standings", {"standing_name": "Inverted", "min_grade": 60, "max_grade": 40})
self.assertRaises(frappe.ValidationError, doc.validate_standings)
def test_perfect_score_maps_to_top_standing(self):
# A perfect score (the upper bound of the top band) must still resolve to a standing
supplier = create_test_supplier("_Test Supplier SC Perfect")
doc = make_supplier_scorecard()
doc.supplier = supplier
doc.supplier_score = 100
doc.update_standing()
self.assertEqual(doc.status, "Excellent")
def test_total_score_defaults_to_100_without_periods(self):
doc = make_supplier_scorecard()
doc.name = "_Test Scorecard Without Periods"
doc.calculate_total_score()
self.assertEqual(doc.supplier_score, 100)
def test_update_standing_propagates_blocking_flags_to_supplier(self):
supplier = create_test_supplier("_Test Supplier SC Standing")
doc = make_supplier_scorecard()
doc.supplier = supplier
doc.supplier_score = 20 # falls in the "Very Poor" (0-30) band
doc.update_standing()
self.assertEqual(doc.status, "Very Poor")
self.assertEqual(doc.prevent_pos, 1)
self.assertEqual(doc.prevent_rfqs, 1)
self.assertEqual(frappe.db.get_value("Supplier", supplier, "prevent_pos"), 1)
self.assertEqual(frappe.db.get_value("Supplier", supplier, "prevent_rfqs"), 1)
def test_scorecard_period_end_dates(self):
start = getdate("2024-01-01")
self.assertEqual(get_scorecard_date("Per Week", start), getdate("2024-01-08"))
self.assertEqual(get_scorecard_date("Per Month", start), getdate("2024-01-31"))
self.assertEqual(get_scorecard_date("Per Year", start), getdate("2024-12-31"))
def test_make_all_scorecards_is_idempotent(self):
supplier = create_test_supplier("_Test Supplier SC Idempotent")
frappe.db.set_value("Supplier", supplier, "creation", add_days(nowdate(), -75))
frappe.delete_doc_if_exists("Supplier Scorecard", supplier)
doc = make_supplier_scorecard()
doc.supplier = supplier
doc.name = supplier
doc.insert() # on_update generates the period scorecards
created = frappe.db.count("Supplier Scorecard Period", {"scorecard": doc.name, "docstatus": 1})
self.assertGreater(created, 0)
self.assertEqual(make_all_scorecards(doc.name), 0)
def make_supplier_scorecard():
my_doc = frappe.get_doc(valid_scorecard[0])
@@ -103,18 +32,6 @@ def make_supplier_scorecard():
return my_doc
def create_test_supplier(supplier_name):
if not frappe.db.exists("Supplier", supplier_name):
frappe.get_doc(
{
"doctype": "Supplier",
"supplier_name": supplier_name,
"supplier_group": "_Test Supplier Group",
}
).insert()
return supplier_name
valid_scorecard = [
{
"standings": [

View File

@@ -82,6 +82,7 @@ class SupplierScorecardPeriod(Document):
).format(crit.criteria_name),
frappe.ValidationError,
)
crit.score = 0
def calculate_score(self):
myscore = 0

View File

@@ -1,65 +1,8 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.tests.utils import ERPNextTestSuite
class TestSupplierScorecardPeriod(ERPNextTestSuite):
def test_criteria_score_is_clamped_to_bounds(self):
period = make_period(
criteria=[
{"criteria_name": "Over", "formula": "200", "max_score": 100, "weight": 50},
{"criteria_name": "Negative", "formula": "-50", "max_score": 100, "weight": 50},
]
)
period.calculate_criteria()
self.assertEqual(period.criteria[0].score, 100) # capped at max_score
self.assertEqual(period.criteria[1].score, 0) # floored at zero
def test_invalid_criteria_formula_raises(self):
period = make_period(
criteria=[{"criteria_name": "Bad", "formula": "{missing} +", "max_score": 100, "weight": 100}]
)
self.assertRaises(frappe.ValidationError, period.calculate_criteria)
def test_eval_statement_substitutes_variable_values(self):
period = make_period(
variables=[
{"variable_label": "A", "param_name": "a", "path": "get_total_workdays", "value": 5},
{"variable_label": "B", "param_name": "b", "path": "get_total_workdays", "value": 0},
]
)
# get_eval_statement checks `if var.value:` (truthiness), so a falsy value -
# whether 0 or None - is substituted as "0.0", while a real value is formatted
self.assertEqual(period.get_eval_statement("{a} + {b}"), "5.00 + 0.0")
def test_period_score_is_weighted_sum_of_criteria(self):
period = make_period(
criteria=[
{"criteria_name": "C1", "formula": "80", "max_score": 100, "weight": 25},
{"criteria_name": "C2", "formula": "40", "max_score": 100, "weight": 75},
]
)
period.calculate_criteria()
period.calculate_score()
# 80 * 0.25 + 40 * 0.75 = 50
self.assertEqual(period.total_score, 50)
def test_criteria_weights_must_total_100(self):
period = make_period(
criteria=[{"criteria_name": "C1", "formula": "100", "max_score": 100, "weight": 60}]
)
self.assertRaises(frappe.ValidationError, period.validate_criteria_weights)
def make_period(variables=None, criteria=None):
period = frappe.new_doc("Supplier Scorecard Period")
for variable in variables or []:
period.append("variables", variable)
for criterion in criteria or []:
period.append("criteria", criterion)
return period
pass

View File

@@ -8,7 +8,7 @@ import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.functions import DateDiff, Sum
from frappe.utils import flt, getdate
from frappe.utils import getdate
class VariablePathNotFound(frappe.ValidationError):
@@ -184,18 +184,16 @@ def get_total_days_late(scorecard):
def get_on_time_shipments(scorecard):
"""Counts PO lines (scheduled in the period) fully received on or before their schedule date.
"""Gets the number of on time shipments (counting each item) in the period (based on Purchase Receipts vs POs)"""
Counting in PO-line units keeps this consistent with get_total_shipments so that
get_late_shipments (total - on time) stays non-negative even for split deliveries.
"""
from frappe.query_builder.functions import Count
PO = frappe.qb.DocType("Purchase Order")
PO_Item = frappe.qb.DocType("Purchase Order Item")
PR = frappe.qb.DocType("Purchase Receipt")
PR_Item = frappe.qb.DocType("Purchase Receipt Item")
rows = (
query = (
frappe.qb.from_(PR_Item)
.join(PR)
.on(PR_Item.parent == PR.name)
@@ -203,15 +201,17 @@ def get_on_time_shipments(scorecard):
.on(PR_Item.purchase_order_item == PO_Item.name)
.join(PO)
.on(PO_Item.parent == PO.name)
.select(PO_Item.name, PO_Item.qty, Sum(PR_Item.qty).as_("received_on_time"))
.select(Count(PR_Item.qty))
.where(PO.supplier == scorecard.supplier)
.where(PO_Item.schedule_date[scorecard.start_date : scorecard.end_date])
.where(PO_Item.schedule_date >= PR.posting_date)
.where(PO_Item.qty == PR_Item.qty)
.where(PR_Item.docstatus == 1)
.groupby(PO_Item.name, PO_Item.qty)
).run(as_dict=True)
)
return sum(1 for row in rows if flt(row.received_on_time) >= flt(row.qty))
result = query.run(as_list=True)
total_items_delivered_on_time = result[0][0] if result and result[0][0] is not None else 0
return total_items_delivered_on_time
def get_late_shipments(scorecard):

Some files were not shown because too many files have changed in this diff Show More