Compare commits

..

2 Commits

Author SHA1 Message Date
Nabin Hait
824415d50e test: cover AR/AP show-remarks, delivery notes and group-by-party filters
Add coverage for previously untested checkbox filters: Show Remarks (the
invoice remark appears in the row) and Show Linked Delivery Notes on the
receivable report, and Show Remarks and Group By Supplier on the payable
report.
2026-06-23 11:32:31 +05:30
Nabin Hait
8c124ed4a9 test: cover AR/AP journal-entry payments and credit notes
The receivable/payable reports lacked coverage for settling invoices via a
Journal Entry (rather than a Payment Entry) and for credit notes raised via
JE. Add: an invoice partially and fully paid via JE on the receivable side,
a standalone JE credit note showing as negative outstanding, and a supplier
invoice partially paid via JE on the payable side.
2026-06-23 11:04:17 +05:30
389 changed files with 64689 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

@@ -54,6 +54,84 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
pi = pi.submit()
return pi
def test_invoice_partially_paid_via_journal_entry(self):
pi = self.create_purchase_invoice() # outstanding 300
je = frappe.new_doc("Journal Entry")
je.company = self.company
je.posting_date = today()
je.append(
"accounts",
{
"account": "Creditors - _TC",
"party_type": "Supplier",
"party": self.supplier,
"debit": 120,
"debit_in_account_currency": 120,
"reference_type": "Purchase Invoice",
"reference_name": pi.name,
"cost_center": "Main - _TC",
},
)
je.append(
"accounts",
{
"account": "Cash - _TC",
"credit": 120,
"credit_in_account_currency": 120,
"cost_center": "Main - _TC",
},
)
je.save().submit()
filters = {
"company": self.company,
"party_type": "Supplier",
"party": [self.supplier],
"report_date": today(),
"range": "30, 60, 90, 120",
}
row = next(row for row in execute(filters)[1] if row.voucher_no == pi.name)
self.assertEqual(row.paid, 120)
self.assertEqual(row.outstanding, 180)
def test_show_remarks_includes_invoice_remark(self):
pi = self.create_purchase_invoice(do_not_submit=True)
pi.remarks = "AP test remark"
pi.save().submit()
filters = {
"company": self.company,
"party_type": "Supplier",
"party": [self.supplier],
"report_date": today(),
"range": "30, 60, 90, 120",
"show_remarks": 1,
}
row = next(row for row in execute(filters)[1] if row.voucher_no == pi.name)
self.assertIn("AP test remark", row.remarks or "")
def test_group_by_supplier_totals(self):
self.create_purchase_invoice() # outstanding 300
filters = {
"company": self.company,
"party_type": "Supplier",
"party": [self.supplier],
"report_date": today(),
"range": "30, 60, 90, 120",
"group_by_party": True,
}
report = execute(filters)[1]
# a per-supplier subtotal row plus a grand total row
party_subtotal = next(
row for row in report if row.get("party") == self.supplier and not row.get("voucher_no")
)
grand_total = next(row for row in report if row.get("party") == "Total")
self.assertEqual(party_subtotal.get("invoiced"), 300)
self.assertEqual(grand_total.get("outstanding"), 300)
def test_payment_terms_template_filters(self):
from erpnext.controllers.accounts_controller import get_payment_terms

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

@@ -568,6 +568,119 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
report = execute(filters)
self.assertEqual(report[1], [])
def pay_invoice_via_journal_entry(self, si, amount):
je = frappe.new_doc("Journal Entry")
je.company = self.company
je.posting_date = today()
je.append(
"accounts",
{
"account": self.cash,
"debit": amount,
"debit_in_account_currency": amount,
"cost_center": self.cost_center,
},
)
je.append(
"accounts",
{
"account": self.debit_to,
"party_type": "Customer",
"party": self.customer,
"credit": amount,
"credit_in_account_currency": amount,
"reference_type": "Sales Invoice",
"reference_name": si.name,
"cost_center": self.cost_center,
},
)
return je.save().submit()
def ar_rows(self):
filters = {"company": self.company, "report_date": today(), "range": "30, 60, 90, 120"}
return execute(filters)[1]
def test_invoice_partially_paid_via_journal_entry(self):
si = self.create_sales_invoice(no_payment_schedule=True) # outstanding 100
self.pay_invoice_via_journal_entry(si, 40)
row = next(row for row in self.ar_rows() if row.voucher_no == si.name)
self.assertEqual(row.paid, 40)
self.assertEqual(row.outstanding, 60)
def test_invoice_fully_paid_via_journal_entry(self):
si = self.create_sales_invoice(no_payment_schedule=True) # outstanding 100
self.pay_invoice_via_journal_entry(si, 100)
# a fully settled invoice drops out of the receivable report
self.assertEqual([row for row in self.ar_rows() if row.voucher_no == si.name], [])
def test_credit_note_via_journal_entry_shows_negative_outstanding(self):
je = frappe.new_doc("Journal Entry")
je.company = self.company
je.voucher_type = "Credit Note"
je.posting_date = today()
je.append(
"accounts",
{
"account": self.income_account,
"debit": 100,
"debit_in_account_currency": 100,
"cost_center": self.cost_center,
},
)
je.append(
"accounts",
{
"account": self.debit_to,
"party_type": "Customer",
"party": self.customer,
"credit": 100,
"credit_in_account_currency": 100,
"cost_center": self.cost_center,
},
)
je = je.save().submit()
row = next(row for row in self.ar_rows() if row.voucher_no == je.name)
self.assertEqual(row.outstanding, -100)
def test_show_remarks_includes_invoice_remark(self):
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
si.remarks = "AR test remark"
si.save().submit()
filters = {
"company": self.company,
"report_date": today(),
"range": "30, 60, 90, 120",
"show_remarks": 1,
}
row = next(row for row in execute(filters)[1] if row.voucher_no == si.name)
self.assertIn("AR test remark", row.remarks or "")
def test_show_delivery_notes_links_delivery_note(self):
from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
make_stock_entry(item_code=self.item, qty=5, to_warehouse=self.warehouse, basic_rate=100)
dn = create_delivery_note(
customer=self.customer, item=self.item, warehouse=self.warehouse, cost_center=self.cost_center
)
si = make_sales_invoice(dn.name)
si.insert()
si.submit()
filters = {
"company": self.company,
"report_date": today(),
"range": "30, 60, 90, 120",
"show_delivery_notes": 1,
}
row = next(row for row in execute(filters)[1] if row.voucher_no == si.name)
self.assertIn(dn.name, row.delivery_notes or "")
def test_group_by_party(self):
si1 = self.create_sales_invoice(do_not_submit=True)
si1.posting_date = add_days(today(), -1)
@@ -699,61 +812,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

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