diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml
index 80325d2e087..40fc667e3d9 100644
--- a/.github/workflows/patch.yml
+++ b/.github/workflows/patch.yml
@@ -65,6 +65,19 @@ jobs:
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
+ # The v14 baseline backup is a fixed published file — cache it instead of re-downloading
+ # ~100MB from frappe.io every run.
+ - name: Cache erpnext v14 backup
+ id: cache-v14
+ uses: actions/cache@v4
+ with:
+ path: ~/erpnext-v14.sql.gz
+ key: erpnext-v14-sql-gz
+
+ - name: Download erpnext v14 backup
+ if: steps.cache-v14.outputs.cache-hit != 'true'
+ run: wget -O ~/erpnext-v14.sql.gz https://frappe.io/files/erpnext-v14.sql.gz
+
- name: Cache pip
uses: actions/cache@v4
with:
@@ -113,8 +126,7 @@ jobs:
jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json
mv tmp.json ~/frappe-bench/sites/test_site/site_config.json
- wget https://frappe.io/files/erpnext-v14.sql.gz
- bench --site test_site --force restore ~/frappe-bench/erpnext-v14.sql.gz
+ bench --site test_site --force restore ~/erpnext-v14.sql.gz
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git
diff --git a/.github/workflows/server-tests-postgres.yml b/.github/workflows/server-tests-postgres.yml
index 3a668133a5f..8cd50f235f0 100644
--- a/.github/workflows/server-tests-postgres.yml
+++ b/.github/workflows/server-tests-postgres.yml
@@ -1,79 +1,45 @@
name: Server (Postgres)
on:
- repository_dispatch:
- types: [frappe-framework-change]
+ schedule:
+ # 03:00 AM IST daily (21:30 UTC the previous day)
+ - cron: "30 21 * * *"
pull_request:
- # 'labeled' is required so adding the 'postgres' label to an open PR triggers this run
- # (the job itself is gated on that label below)
+ # 'labeled' so adding the 'postgres' label to an already-open PR re-triggers the run.
types: [opened, reopened, synchronize, labeled]
paths-ignore:
- '**.js'
- - '**.css'
- - '**.svg'
- '**.md'
- '**.html'
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
- schedule:
- # Run everday at midnight UTC / 5:30 IST
- - cron: "0 0 * * *"
workflow_dispatch:
- inputs:
- user:
- description: 'Frappe Framework repository user (add your username for forks)'
- required: true
- default: 'frappe'
- type: string
- branch:
- description: 'Frappe Framework branch'
- default: 'develop'
- required: false
- type: string
-
-permissions:
- contents: read
concurrency:
group: server-postgres-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
cancel-in-progress: true
+permissions:
+ contents: read
+
+# Postgres CI stays on GitHub-hosted (free, full-speed VM per shard) but follows the same fan-out
+# we built for MariaDB: build the bench + reinstall ONCE in the setup job, bake the PostgreSQL
+# PGDATA into the artifact, and have 4 test shards start Postgres on that datadir — no per-shard
+# clone/build/reinstall/restore. Python is pinned so the venv transplants between VMs.
+env:
+ TZ: 'Asia/Kolkata'
+ NODE_ENV: "production"
+ PYTHON_VERSION: '3.14'
+
jobs:
- test:
- # Opt-in on PRs: only runs when the PR carries the 'postgres' label. Scheduled / manual /
- # framework-dispatch runs always execute (no PR labels to gate on).
- if: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'postgres') }}
+ setup:
+ name: Build & reinstall (setup)
runs-on: ubuntu-latest
- timeout-minutes: 60
- env:
- TZ: 'Asia/Kolkata'
- NODE_ENV: "production"
- WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
-
- strategy:
- fail-fast: false
-
- matrix:
- container: [1, 2, 3, 4]
-
- # Distinct from the MariaDB job's "Python Unit Tests" so its check contexts do NOT collide with
- # the required "Python Unit Tests (1..4)" status checks -- this keeps Postgres non-required for now.
- name: Postgres Unit Tests
-
- services:
- postgres:
- image: postgres:13.3
- env:
- POSTGRES_PASSWORD: travis
- ports:
- - 5432:5432
- options: >-
- --health-cmd pg_isready
- --health-interval 10s
- --health-timeout 5s
- --health-retries 5
-
+ # Runs on the daily schedule (and workflow_dispatch). On PRs it runs ONLY when the PR carries
+ # the 'postgres' label — the test job needs setup, so it's skipped too when this is.
+ if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'postgres')
+ timeout-minutes: 40
steps:
- name: Clone
uses: actions/checkout@v6
@@ -81,7 +47,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v6
with:
- python-version: '3.14'
+ python-version: ${{ env.PYTHON_VERSION }}
- name: Check for valid Python & Merge Conflicts
run: |
@@ -100,98 +66,124 @@ jobs:
- name: Add to Hosts
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
- - name: Cache pip
+ - name: Cache deps (uv/pip/npm/yarn)
uses: actions/cache@v4
with:
- path: ~/.cache/pip
- key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
- restore-keys: |
- ${{ runner.os }}-pip-
- ${{ runner.os }}-
+ path: |
+ ~/.cache/uv
+ ~/.cache/pip
+ ~/.npm
+ ~/.cache/yarn
+ key: ${{ runner.os }}-deps-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }}
+ restore-keys: ${{ runner.os }}-deps-
- - name: Cache node modules
+ # Warm-bench cache (the big one): install.sh saves the built base bench — frappe + env +
+ # node_modules + assets — here as frappe-bench-base-*.tar.zst. Later runs restore it and only
+ # fast-forward to the live develop SHA + rebuild the delta, so the bench BUILD is near-free and
+ # only the test_site reinstall (per-run DB, uncacheable) stays slow — matching the self-hosted
+ # box. The first run after a deps change populates it; every run after that is fast.
+ - name: Cache warm bench (base build)
uses: actions/cache@v4
+ with:
+ path: ~/bench-cache
+ key: ${{ runner.os }}-warmbench-v2-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }}
+ restore-keys: ${{ runner.os }}-warmbench-v2-
+
+ # Postgres runs in-runner on a PGDATA OUTSIDE the bench (install.sh wipes ~/frappe-bench);
+ # after the reinstall it's moved into the bench so it ships in the artifact.
+ - name: Start DB
+ run: bash ${GITHUB_WORKSPACE}/.github/helper/start-db.sh
env:
- cache-name: cache-node-modules
- with:
- path: ~/.npm
- key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
- restore-keys: |
- ${{ runner.os }}-build-${{ env.cache-name }}-
- ${{ runner.os }}-build-
- ${{ runner.os }}-
-
- - name: Get yarn cache directory path
- id: yarn-cache-dir-path
- run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
-
- - uses: actions/cache@v4
- id: yarn-cache
- with:
- path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
- key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
- restore-keys: |
- ${{ runner.os }}-yarn-
-
- - name: Cache wkhtmltopdf
- uses: actions/cache@v4
- with:
- path: /tmp/wkhtmltox.deb
- key: wkhtmltox-0.12.6.1-2-jammy-amd64
+ DB: postgres
+ CI_DB_DATADIR: /home/runner/pgdata
- name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env:
DB: postgres
TYPE: server
- FRAPPE_USER: ${{ github.event.inputs.user }}
- FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
+ FRAPPE_BRANCH: develop
+ BENCH_CACHE_DIR: /home/runner/bench-cache
+
+ - name: Stop DB and stage datadir
+ run: |
+ PG_BIN=$(ls -d /usr/lib/postgresql/*/bin | sort -V | tail -1)
+ "$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop || true
+ mv /home/runner/pgdata /home/runner/frappe-bench/pgdata
+
+ - name: Package bench for test shards
+ run: |
+ cp "${GITHUB_WORKSPACE}/.github/helper/hydrate.sh" /home/runner/frappe-bench/hydrate.sh
+ cp "${GITHUB_WORKSPACE}/.github/helper/start-db.sh" /home/runner/frappe-bench/start-db.sh
+ tar czpf "${GITHUB_WORKSPACE}/bench.tar.gz" -C /home/runner \
+ --exclude='.git' --exclude='node_modules' frappe-bench
+ ls -lh "${GITHUB_WORKSPACE}/bench.tar.gz"
+
+ - name: Upload bench artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: bench-pg
+ path: bench.tar.gz
+ retention-days: 1
+ compression-level: 0
+
+ test:
+ name: Python Unit Tests (PG)
+ needs: setup
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ container: [1, 2, 3, 4]
+ steps:
+ - name: Download bench artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: bench-pg
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+
+ - name: Add to Hosts
+ run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
+
+ # The bench CLI (frappe-bench) and redis are global/system tools — not in the bench tarball.
+ # The setup runner got them via install.sh; the MariaDB shards get them from the arc5 image.
+ # GitHub-hosted PG shards install them here (cheap vs the build+reinstall that setup did once).
+ - name: Install shard runtime (bench CLI + redis + wkhtmltopdf)
+ run: |
+ pip install frappe-bench
+ command -v redis-server >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq redis-server; }
+ # wkhtmltopdf (patched-qt build) for print-format / PDF tests — same .deb install.sh uses.
+ if ! command -v wkhtmltopdf >/dev/null; then
+ wget -qO /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
+ sudo apt-get install -y -qq /tmp/wkhtmltox.deb
+ fi
+
+ - name: Untar bench
+ run: |
+ tar xzpf "${GITHUB_WORKSPACE}/bench.tar.gz" -C /home/runner
+ ls -ld /home/runner/frappe-bench
+
+ - name: Hydrate (start Postgres on the baked datadir)
+ run: bash /home/runner/frappe-bench/hydrate.sh
+ env:
+ DB: postgres
+ DB_HOST: 127.0.0.1
- name: Run Tests
run: |
cd ~/frappe-bench/
- coverage_flag=""
- if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
+ # print-format / PDF tests are engine-independent (they exercise wkhtmltopdf rendering,
+ # not postgres SQL — the MariaDB CI already covers them). They only fetch the static asset
+ # bundles from http://test_site:8000/assets/..., so a plain static file server over sites/
+ # satisfies wkhtmltopdf without the frappe web server (which never bound on a bare runner).
+ ( cd ~/frappe-bench/sites && nohup python3 -m http.server 8000 --bind 127.0.0.1 > ~/frappe-bench/web.log 2>&1 & )
+ for _ in $(seq 1 15); do (exec 3<>/dev/tcp/127.0.0.1/8000) 2>/dev/null && { exec 3>&- 3<&-; break; }; sleep 1; done
bench --site test_site run-parallel-tests --lightmode --app erpnext \
- --total-builds ${{ strategy.job-total }} \
- --build-number ${{ matrix.container }} \
- $coverage_flag
+ --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }}
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
diff --git a/erpnext/__init__.py b/erpnext/__init__.py
index 7e72b2b585d..1fb45004e6f 100644
--- a/erpnext/__init__.py
+++ b/erpnext/__init__.py
@@ -3,8 +3,6 @@ import inspect
from typing import TypeVar
import frappe
-from frappe.model.document import Document
-from frappe.utils.user import is_website_user
__version__ = "17.0.0-dev"
@@ -155,6 +153,8 @@ def allow_regional(fn):
def check_app_permission():
+ from frappe.utils.user import is_website_user
+
if frappe.session.user == "Administrator":
return True
@@ -175,6 +175,8 @@ def normalize_ctx_input(T: type) -> callable:
- Casting the result to the specified type T
"""
+ from frappe.model.document import Document
+
def decorator(func: callable):
# conserve annotations for frappe.utils.typing_validations
@functools.wraps(func, assigned=(a for a in functools.WRAPPER_ASSIGNMENTS if a != "__annotations__"))
diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/chart_of_accounts.py b/erpnext/accounts/doctype/account/chart_of_accounts/chart_of_accounts.py
index 1ee409a290c..89530b56e81 100644
--- a/erpnext/accounts/doctype/account/chart_of_accounts/chart_of_accounts.py
+++ b/erpnext/accounts/doctype/account/chart_of_accounts/chart_of_accounts.py
@@ -137,7 +137,7 @@ def get_charts_for_country(country: str, with_standard: bool = False):
def _get_chart_name(content):
if content:
- content = json.loads(content)
+ content = frappe.parse_json(content)
if (
content and content.get("disabled", "No") == "No"
) or frappe.local.flags.allow_unverified_charts:
diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py
index 5b0e3bf939b..bff12b0dda2 100644
--- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py
+++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py
@@ -224,7 +224,7 @@ def disable_dimension(doc: str):
def toggle_disabling(doc):
- doc = json.loads(doc)
+ doc = frappe.parse_json(doc)
if doc.get("disabled"):
df = {"read_only": 1}
diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
index 4ec9132cb70..da92cdd5b0a 100644
--- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
+++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
@@ -87,6 +87,7 @@
"period_closing_settings_section",
"ignore_account_closing_balance",
"use_legacy_controller_for_pcv",
+ "pcv_job_timeout",
"column_break_25",
"reports_tab",
"remarks_section",
@@ -612,6 +613,14 @@
"fieldtype": "Check",
"label": "Use legacy controller for Period Closing Voucher"
},
+ {
+ "default": "3600",
+ "depends_on": "eval: !doc.use_legacy_controller_for_pcv",
+ "description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher",
+ "fieldname": "pcv_job_timeout",
+ "fieldtype": "Int",
+ "label": "PCV Job Timeout (seconds)"
+ },
{
"description": "Users with this role will be notified if the asset depreciation gets failed",
"fieldname": "role_to_notify_on_depreciation_failure",
@@ -756,7 +765,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2026-06-03 13:11:54.721495",
+ "modified": "2026-06-24 12:59:41.868865",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",
diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py
index d408d1987e7..c56d39ad8d9 100644
--- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py
+++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py
@@ -90,6 +90,7 @@ class AccountsSettings(Document):
make_payment_via_journal_entry: DF.Check
merge_similar_account_heads: DF.Check
over_billing_allowance: DF.Currency
+ pcv_job_timeout: DF.Int
preview_mode: DF.Check
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
receivable_payable_remarks_length: DF.Int
diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
index d9aab98a98f..e84136a04c8 100644
--- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
+++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
@@ -1058,9 +1058,9 @@ def get_auto_reconcile_message(partially_reconciled, reconciled):
@frappe.whitelist()
-def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str, is_new_voucher: bool = False):
+def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str | list, is_new_voucher: bool = False):
# updated clear date of all the vouchers based on the bank transaction
- vouchers = json.loads(vouchers)
+ vouchers = frappe.parse_json(vouchers)
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
transaction.add_payment_entries(vouchers, is_new_voucher)
transaction.validate_duplicate_references()
diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py
index facaf80c008..4554ab6a3a2 100644
--- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py
+++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py
@@ -290,7 +290,7 @@ def update_mapping_db(bank, template_options):
for d in bank.bank_transaction_mapping:
d.delete()
- for d in json.loads(template_options)["column_to_field_map"].items():
+ for d in frappe.parse_json(template_options)["column_to_field_map"].items():
bank.append("bank_transaction_mapping", {"bank_transaction_field": d[1], "file_field": d[0]})
bank.save()
diff --git a/erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py b/erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py
index f50549befa1..783ebf23870 100644
--- a/erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py
+++ b/erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py
@@ -1183,8 +1183,7 @@ def update_pdf_tables(statement_import_id: str, tables: list | str):
if doc.status == "Completed":
frappe.throw(_("This statement has already been imported."), title=_("Already Imported"))
- if isinstance(tables, str):
- tables = json.loads(tables)
+ tables = frappe.parse_json(tables)
doc.apply_pdf_tables(tables)
@@ -1204,8 +1203,7 @@ def reextract_pdf_table(statement_import_id: str, page: int, table_index: int, b
if doc.status == "Completed":
frappe.throw(_("This statement has already been imported."), title=_("Already Imported"))
- if isinstance(bbox, str):
- bbox = json.loads(bbox)
+ bbox = frappe.parse_json(bbox)
page = int(page)
table_index = int(table_index)
@@ -1290,8 +1288,7 @@ def update_column_mapping(statement_import_id: str, column_mapping: list | str):
if doc.status == "Completed":
frappe.throw(_("This statement has already been imported."), title=_("Already Imported"))
- if isinstance(column_mapping, str):
- column_mapping = json.loads(column_mapping)
+ column_mapping = frappe.parse_json(column_mapping)
doc.apply_column_mapping(column_mapping)
doc.save()
diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py
index d0d0188cbd3..c2bac737a78 100644
--- a/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py
+++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py
@@ -35,12 +35,12 @@ def upload_bank_statement():
@frappe.whitelist()
-def create_bank_entries(columns: str, data: str, bank_account: str):
+def create_bank_entries(columns: str, data: str | list, bank_account: str):
header_map = get_header_mapping(columns, bank_account)
success = 0
errors = 0
- for d in json.loads(data):
+ for d in frappe.parse_json(data):
if all(item is None for item in d) is True:
continue
fields = {}
@@ -66,7 +66,7 @@ def get_header_mapping(columns, bank_account):
mapping = get_bank_mapping(bank_account)
header_map = {}
- for column in json.loads(columns):
+ for column in frappe.parse_json(columns):
if column["content"] in mapping:
header_map.update({mapping[column["content"]]: column["colIndex"]})
diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py
index f64e957400b..0c5cf545c9c 100644
--- a/erpnext/accounts/doctype/dunning/dunning.py
+++ b/erpnext/accounts/doctype/dunning/dunning.py
@@ -248,8 +248,7 @@ def get_dunning_letter_text(dunning_type: str, doc: str | dict, language: str |
DOCTYPE = "Dunning Letter Text"
FIELDS = ["body_text", "closing_text", "language"]
- if isinstance(doc, str):
- doc = json.loads(doc)
+ doc = frappe.parse_json(doc)
if not language:
language = doc.get("language")
diff --git a/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py b/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py
index 6d44796fb1c..d113b3b4d0e 100644
--- a/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py
+++ b/erpnext/accounts/doctype/financial_report_template/financial_report_engine.py
@@ -1032,8 +1032,7 @@ class FormulaFieldUpdater:
def get_filtered_accounts(company: str, account_rows: str | list):
frappe.has_permission("Financial Report Template", ptype="read", throw=True)
- if isinstance(account_rows, str):
- account_rows = json.loads(account_rows, object_hook=frappe._dict)
+ account_rows = [frappe._dict(row) for row in frappe.parse_json(account_rows)]
return DataCollector.get_filtered_accounts(company, account_rows)
diff --git a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py
index 3e2f18e1f41..bf4f9e072d0 100644
--- a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py
+++ b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py
@@ -317,8 +317,8 @@ class InvoiceDiscounting(AccountsController):
@frappe.whitelist()
-def get_invoices(filters: str):
- filters = frappe._dict(json.loads(filters))
+def get_invoices(filters: str | dict):
+ filters = frappe._dict(frappe.parse_json(filters))
si = frappe.qb.DocType("Sales Invoice")
di = frappe.qb.DocType("Discounted Invoice")
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index d6b597067f6..680755343e6 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -1805,8 +1805,7 @@ class PaymentEntry(AccountsController):
if not self.references or not matched_payment_requests:
return
- if isinstance(matched_payment_requests, str):
- matched_payment_requests = json.loads(matched_payment_requests)
+ matched_payment_requests = frappe.parse_json(matched_payment_requests)
# modify matched_payment_requests
# like (reference_doctype, reference_name, allocated_amount): payment_request
@@ -2011,8 +2010,7 @@ def validate_inclusive_tax(tax, doc):
@frappe.whitelist()
def get_outstanding_reference_documents(args: str | dict, validate: bool = False):
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
if args.get("party_type") == "Member":
return
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py
index 93faa06a1a2..26d5c2ce833 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/payment_request.py
@@ -740,7 +740,7 @@ def make_payment_request(**args):
# Schedule-based PRs are allowed only if no Payment Entry exists for this document.
# Any existing Payment Entry forces legacy (amount-based) flow.
- selected_payment_schedules = json.loads(args.get("schedules")) if args.get("schedules") else []
+ selected_payment_schedules = frappe.parse_json(args.get("schedules")) if args.get("schedules") else []
# Backend guard:
# If any Payment Entry exists, schedule-based PRs are not allowed.
@@ -931,7 +931,7 @@ def apply_payment_references(pr, payment_reference):
def set_payment_references(payment_schedules):
- payment_schedules = json.loads(payment_schedules) if payment_schedules else []
+ payment_schedules = frappe.parse_json(payment_schedules) if payment_schedules else []
payment_reference = []
for row in payment_schedules:
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
index 23ef9099b6d..71d04db1ea0 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
@@ -1036,8 +1036,7 @@ def make_sales_return(source_name: str, target_doc: Document | str | None = None
def make_merge_log(invoices: str | list):
import json
- if isinstance(invoices, str):
- invoices = json.loads(invoices)
+ invoices = frappe.parse_json(invoices)
if len(invoices) == 0:
frappe.throw(_("At least one invoice has to be selected."))
diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
index d691040104b..c372381850b 100644
--- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
+++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
@@ -341,8 +341,7 @@ def apply_pricing_rule(args: str | dict, doc: str | dict | Document | None = Non
}
"""
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
args = frappe._dict(args)
@@ -397,8 +396,7 @@ def get_pricing_rule_for_item(args, doc=None, for_validate=False):
get_product_discount_rule,
)
- if isinstance(doc, str):
- doc = json.loads(doc)
+ doc = frappe.parse_json(doc)
if doc:
doc = frappe.get_doc(doc)
@@ -628,9 +626,7 @@ def remove_pricing_rule_for_item(
get_pricing_rule_items,
)
- if isinstance(item_details, str):
- item_details = json.loads(item_details)
- item_details = frappe._dict(item_details)
+ item_details = frappe._dict(frappe.parse_json(item_details))
for d in get_applied_pricing_rules(pricing_rules):
if not d or not frappe.db.exists("Pricing Rule", d):
@@ -671,8 +667,7 @@ def remove_pricing_rule_for_item(
@frappe.whitelist()
def remove_pricing_rules(item_list: str | list):
- if isinstance(item_list, str):
- item_list = json.loads(item_list)
+ item_list = frappe.parse_json(item_list)
out = []
for item in item_list:
diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py
index ca1e6f2f129..9fabb2bdc89 100644
--- a/erpnext/accounts/doctype/pricing_rule/utils.py
+++ b/erpnext/accounts/doctype/pricing_rule/utils.py
@@ -636,7 +636,7 @@ def remove_free_item(doc):
def get_applied_pricing_rules(pricing_rules):
if pricing_rules:
if pricing_rules.startswith("["):
- return json.loads(pricing_rules)
+ return frappe.parse_json(pricing_rules)
else:
return pricing_rules.split(",")
diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py
index f4440345e96..21ac42a5d3a 100644
--- a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py
+++ b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py
@@ -542,8 +542,7 @@ def check_multi_currency(pr_doc):
def is_any_doc_running(for_filter: str | dict | None = None) -> str | None:
running_doc = None
if for_filter:
- if isinstance(for_filter, str):
- for_filter = json.loads(for_filter)
+ for_filter = frappe.parse_json(for_filter)
running_doc = frappe.db.get_value(
"Process Payment Reconciliation",
diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py
index 24c8c92c7e8..17e63c68b41 100644
--- a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py
+++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py
@@ -95,6 +95,8 @@ def start_pcv_processing(docname: str):
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
+ timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
+
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
if normal_balances := (
qb.from_(ppcvd)
@@ -121,7 +123,7 @@ def start_pcv_processing(docname: str):
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
- timeout="3600",
+ timeout=timeout,
is_async=True,
enqueue_after_commit=True,
docname=docname,
@@ -247,6 +249,8 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
@frappe.whitelist()
def schedule_next_date(docname: str):
+ timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
+
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
if to_process := (
qb.from_(ppcvd)
@@ -272,7 +276,7 @@ def schedule_next_date(docname: str):
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
- timeout="3600",
+ timeout=timeout,
is_async=True,
enqueue_after_commit=True,
docname=docname,
@@ -302,7 +306,7 @@ def schedule_next_date(docname: str):
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries",
queue="long",
- timeout="3600",
+ timeout=timeout,
is_async=True,
job_name=job_name,
enqueue_after_commit=True,
diff --git a/erpnext/accounts/doctype/purchase_invoice/mapper.py b/erpnext/accounts/doctype/purchase_invoice/mapper.py
index 7c50121f1e5..0d0a771ea37 100644
--- a/erpnext/accounts/doctype/purchase_invoice/mapper.py
+++ b/erpnext/accounts/doctype/purchase_invoice/mapper.py
@@ -50,8 +50,7 @@ def make_purchase_receipt(
):
if args is None:
args = {}
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
def post_parent_process(source_parent, target_parent):
remove_items_with_zero_qty(target_parent)
diff --git a/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py b/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py
index be4b1674241..97a17c21600 100644
--- a/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py
+++ b/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py
@@ -201,9 +201,9 @@ def get_linked_advances(company, docname):
@frappe.whitelist()
-def create_unreconcile_doc_for_selection(selections: str | None = None):
+def create_unreconcile_doc_for_selection(selections: str | list | None = None):
if selections:
- selections = json.loads(selections)
+ selections = frappe.parse_json(selections)
# assuming each row is a unique voucher
for row in selections:
unrecon = frappe.new_doc("Unreconcile Payment")
diff --git a/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json b/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json
index e0b41c42e51..28b60e313c4 100644
--- a/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json
+++ b/erpnext/accounts/letter_head/company_letterhead/company_letterhead.json
@@ -1,6 +1,6 @@
{
"align": "Left",
- "content": "
",
+ "content": "",
"creation": "2026-05-15 15:21:48.255627",
"custom_css": "\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tpadding-right: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\n\t.letter-head td {\n\t\tpadding: 0px !important;\n\t}\n\t.invoice-header {\n\t\twidth: 100%;\n\t}\n\t.logo-cell {\n\t\twidth: 100px;\n\t\ttext-align: center;\n\t\tposition: relative;\n\t}\n\t.logo-container {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t}\n\t.logo-container img {\n\t\tmax-width: 90px;\n\t\tmax-height: 90px;\n\t\tdisplay: inline-block;\n\t\tborder-radius: 15px;\n\t}\n\t.company-details {\n\t\twidth: 40%;\n\t\talign-content: center;\n\t}\n\t.company-name {\n\t\tfont-size: 14px;\n\t\tfont-weight: bold;\n\t\tcolor: #171717;\n\t\tmargin-bottom: 4px;\n\t}\n\t.invoice-info-cell {\n\t\tfloat: right;\n\t\tvertical-align: top;\n\t}\n\t.invoice-info {\n\t\tmargin-bottom: 2px;\n\t}\n\t.invoice-label {\n\t\tcolor: #7c7c7c;\n\t\tdisplay: inline-block;\n\t\tmargin-right: 5px;\n\t}",
"disabled": 0,
@@ -16,7 +16,7 @@
"is_default": 0,
"letter_head_for": "DocType",
"letter_head_name": "Company Letterhead",
- "modified": "2026-05-16 15:15:23.014622",
+ "modified": "2026-06-24 17:49:52.350750",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Company Letterhead",
diff --git a/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json b/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json
index 0f0f903dad9..67c03298195 100644
--- a/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json
+++ b/erpnext/accounts/letter_head/company_letterhead___grey/company_letterhead___grey.json
@@ -1,6 +1,6 @@
{
"align": "Left",
- "content": "\n\t\n\t\t\n\t\t\t\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t\n\t\t\t\t\t  \n\t\t\t\t \n\t\t\t\t{% endif %}\n\t\t\t\t{{ doc.company }} \n\t\t\t\t\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }} \n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }} \n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}} \n\t\t\t\t\t{% endif %}\n\t\t\t\t \n\t\t\t | \n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t {{ doc.doctype }} \n\t\t\t\t\t {{ doc.name }} \n\t\t\t\t\t \n\t\t\t\t \n\t\t\t\t\n\t\t\t\t\t{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %} {% set website = company_details.website %} {% set email =\n\t\t\t\t\tcompany_details.email %} {% set phone_no = company_details.phone_no %} {% if website %}\n\t\t\t\t\t \n\t\t\t\t\t\t{{ _(\"Website:\") }}{{ website }}\n\t\t\t\t\t \n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t \n\t\t\t\t\t\t{{ _(\"Email:\") }}{{ email }}\n\t\t\t\t\t \n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t \n\t\t\t\t\t\t{{ _(\"Contact:\") }}{{ phone_no }}\n\t\t\t\t\t \n\t\t\t\t\t{% endif %}\n\t\t\t\t \n\t\t\t | \n\t\t
\n\t\n
\n",
+ "content": "\n\t\n\t\t\n\t\t\t\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t\n\t\t\t\t\t  \n\t\t\t\t \n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}{{ doc.company }} {% endif %}\n\t\t\t\t\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }} \n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }} \n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}} \n\t\t\t\t\t{% endif %}\n\t\t\t\t \n\t\t\t | \n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t {{ doc.doctype }} \n\t\t\t\t\t {{ doc.name }} \n\t\t\t\t\t \n\t\t\t\t \n\t\t\t\t\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t \n\t\t\t\t\t\t{{ _(\"Website:\") }}{{ website }}\n\t\t\t\t\t \n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t \n\t\t\t\t\t\t{{ _(\"Email:\") }}{{ email }}\n\t\t\t\t\t \n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t \n\t\t\t\t\t\t{{ _(\"Contact:\") }}{{ phone_no }}\n\t\t\t\t\t \n\t\t\t\t\t{% endif %}\n\t\t\t\t \n\t\t\t | \n\t\t
\n\t\n
\n",
"creation": "2026-05-15 15:21:48.373815",
"custom_css": "\t.print-format-preview {\n\t\tmargin-top: 12px;\n\t}\n\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tbackground: #f8f8f8;\n\t\tpadding: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\t.letterhead-container {\n\t\twidth: 100%;\n\t}\n\t.letterhead-container .other-details {\n\t\tposition: absolute;\n\t\tright: 0;\n\t\tbottom: 0;\n\t}\n\t.logo-address {\n\t\twidth: 65%;\n\t\tvertical-align: top;\n\t}\n\n\t.letter-head .logo {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t\tmargin-bottom: 10px;\n\t}\n\n\t.letter-head .logo img {\n\t\tborder-radius: 15px;\n\t}\n\n\t.company-name {\n\t\tcolor: #171717;\n\t\tfont-weight: bold;\n\t\tline-height: 23px;\n\t\tmargin-bottom: 5px;\n\t}\n\n\t.company-address {\n\t\tcolor: #171717;\n\t\twidth: 300px;\n\t}\n\n\t.invoice-title {\n\t\tfont-weight: bold;\n\t}\n\n\t.invoice-number {\n\t\tcolor: #7c7c7c;\n\t}\n\n\t.contact-title {\n\t\tcolor: #7c7c7c;\n\t\twidth: 60px;\n\t\tdisplay: inline-block;\n\t\tvertical-align: top;\n\t\tmargin-right: 10px;\n\t}\n\n\t.contact-value {\n\t\tcolor: #171717;\n\t\tdisplay: inline-block;\n\t}\n\t.letterhead-container td {\n\t\tpadding: 0px !important;\n\t\tposition: relative;\n\t}",
"disabled": 0,
@@ -16,7 +16,7 @@
"is_default": 0,
"letter_head_for": "DocType",
"letter_head_name": "Company Letterhead - Grey",
- "modified": "2026-05-16 15:15:19.942207",
+ "modified": "2026-06-24 18:23:05.120521",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Company Letterhead - Grey",
diff --git a/erpnext/accounts/letter_head/company_letterhead_report/company_letterhead_report.json b/erpnext/accounts/letter_head/company_letterhead_report/company_letterhead_report.json
index cc158d71aed..0417c6c0433 100644
--- a/erpnext/accounts/letter_head/company_letterhead_report/company_letterhead_report.json
+++ b/erpnext/accounts/letter_head/company_letterhead_report/company_letterhead_report.json
@@ -1,6 +1,6 @@
{
"align": "Left",
- "content": "",
+ "content": "",
"creation": "2026-05-15 19:49:47.582252",
"custom_css": ".letter-head {\n\tborder-radius: 18px;\n\tpadding: 8px 10px;\n\tmargin: 10px 0 14px;\n\tfont-family: Inter, sans-serif;\n\tfont-size: 14px;\n\tcolor: #171717;\n}\n\n.letter-head td {\n\tpadding: 0 !important;\n\tvertical-align: middle;\n}\n\n.invoice-header {\n\twidth: 100%;\n\tborder-collapse: collapse;\n\ttable-layout: fixed;\n\tborder-bottom: 1px solid #ededed;\n\tpadding-bottom: 10px;\n}\n\n.logo-cell {\n\twidth: 100px;\n\ttext-align: center;\n\twhite-space: nowrap;\n}\n\n.logo-container {\n\tdisplay: inline-block;\n\tmargin: auto;\n}\n\n.logo-container img {\n\tmax-width: 95px;\n\tmax-height: 95px;\n\tdisplay: block;\n\tborder-radius: 12px;\n}\n\n.company-details {\n\twidth: 55%;\n\tpadding-left: 10px !important;\n\tline-height: 1.5;\n}\n\n.company-name {\n\tfont-size: 14px;\n\tfont-weight: 600;\n\tcolor: #171717;\n\tmargin-bottom: 4px;\n}\n\n.company-address {\n\tfont-size: 14px;\n\tline-height: 1.5;\n\tcolor: #171717;\n}\n\n.invoice-info-cell {\n\twidth: 240px;\n\ttext-align: right;\n\tvertical-align: top !important;\n\tline-height: 1.5;\n}\n\n.document-name {\n\tfont-size: 14px;\n\tfont-weight: 600;\n\tcolor: #171717;\n\tmargin-bottom: 6px;\n}\n\n.invoice-info {\n\tfont-size: 14px;\n\tcolor: #171717;\n\tmargin-bottom: 2px;\n\tfont-variant-numeric: tabular-nums;\n}\n\n.invoice-label {\n\tcolor: #7c7c7c;\n\tfont-weight: 500;\n\tmargin-right: 4px;\n\tdisplay: inline-block;\n}",
"disabled": 0,
@@ -16,7 +16,7 @@
"is_default": 0,
"letter_head_for": "Report",
"letter_head_name": "Company Letterhead Report",
- "modified": "2026-05-16 15:15:26.155770",
+ "modified": "2026-06-24 18:06:39.820968",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Company Letterhead Report",
diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.json b/erpnext/accounts/report/accounts_payable/accounts_payable.json
index 40aa222cbb0..9c713fccf64 100644
--- a/erpnext/accounts/report/accounts_payable/accounts_payable.json
+++ b/erpnext/accounts/report/accounts_payable/accounts_payable.json
@@ -4,13 +4,20 @@
"columns": [],
"creation": "2013-04-22 16:16:03",
"default_print_format": "Accounts Payable Standard",
+ "disable_prepared_report_automation": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
+ "doctype_to_sync": [
+ {
+ "doc_type": "Payment Ledger Entry"
+ }
+ ],
"filters": [],
+ "generate_csv": 0,
"idx": 3,
"is_standard": "Yes",
- "modified": "2026-05-22 14:35:14.716933",
+ "modified": "2026-06-25 12:03:36.559152",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Payable",
@@ -33,5 +40,6 @@
"role": "Auditor"
}
],
+ "synced_report": 0,
"timeout": 0
}
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json
index b6e7820f91c..dcc3c2c6a49 100644
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.json
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.json
@@ -4,13 +4,20 @@
"columns": [],
"creation": "2013-04-16 11:31:13",
"default_print_format": "Accounts Receivable Standard",
+ "disable_prepared_report_automation": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
+ "doctype_to_sync": [
+ {
+ "doc_type": "Payment Ledger Entry"
+ }
+ ],
"filters": [],
+ "generate_csv": 0,
"idx": 5,
"is_standard": "Yes",
- "modified": "2026-05-22 14:34:57.666402",
+ "modified": "2026-06-25 12:03:28.812092",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Receivable",
@@ -27,5 +34,6 @@
"role": "Accounts User"
}
],
+ "synced_report": 0,
"timeout": 0
}
diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.json b/erpnext/accounts/report/balance_sheet/balance_sheet.json
index 4c1d4b64030..75277f72ac7 100644
--- a/erpnext/accounts/report/balance_sheet/balance_sheet.json
+++ b/erpnext/accounts/report/balance_sheet/balance_sheet.json
@@ -4,13 +4,20 @@
"columns": [],
"creation": "2014-07-14 05:24:20.385279",
"default_print_format": "Balance Sheet Standard",
+ "disable_prepared_report_automation": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
+ "doctype_to_sync": [
+ {
+ "doc_type": "GL Entry"
+ }
+ ],
"filters": [],
+ "generate_csv": 0,
"idx": 3,
"is_standard": "Yes",
- "modified": "2026-05-22 14:35:28.187799",
+ "modified": "2026-06-22 13:38:25.236839",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Balance Sheet",
@@ -30,5 +37,6 @@
"role": "Auditor"
}
],
+ "synced_report": 0,
"timeout": 0
}
diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py
index a8531e58acb..756d0c2ebbb 100644
--- a/erpnext/accounts/report/balance_sheet/balance_sheet.py
+++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py
@@ -4,18 +4,27 @@
import frappe
from frappe import _
-from frappe.utils import cint, flt
+from frappe.utils import add_days, cint, flt
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
FinancialReportEngine,
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
)
from erpnext.accounts.report.financial_statements import (
+ accumulate_values_into_parents,
+ add_total_row,
+ calculate_values,
compute_growth_view_data,
+ filter_accounts,
+ filter_out_zero_value_rows,
+ get_accounting_entries,
+ get_accounts,
+ get_appropriate_currency,
get_columns,
get_data,
get_filtered_list_for_consolidated_report,
get_period_list,
+ prepare_data,
)
@@ -266,3 +275,196 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency):
chart["currency"] = currency
return chart
+
+
+def execute_synced_report(filters):
+ from frappe.database.duckdb.database import get_latest_sync
+
+ if not (conn := get_latest_sync("GL Entry")):
+ frappe.throw(_("Balance Sheet requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
+
+ period_list = get_period_list(
+ filters.from_fiscal_year,
+ filters.to_fiscal_year,
+ filters.period_start_date,
+ filters.period_end_date,
+ filters.filter_based_on,
+ filters.periodicity,
+ company=filters.company,
+ )
+ filters.period_start_date = period_list[0]["year_start_date"]
+
+ currency = filters.presentation_currency or frappe.get_cached_value(
+ "Company", filters.company, "default_currency"
+ )
+
+ asset = _get_data_duckdb(conn, filters, "Asset", "Debit", period_list)
+ liability = _get_data_duckdb(conn, filters, "Liability", "Credit", period_list)
+ equity = _get_data_duckdb(conn, filters, "Equity", "Credit", period_list)
+
+ provisional_profit_loss, total_credit = get_provisional_profit_loss(
+ asset, liability, equity, period_list, filters.company, currency
+ )
+ message, opening_balance = check_opening_balance(asset, liability, equity)
+
+ data = []
+ data.extend(asset or [])
+ data.extend(liability or [])
+ data.extend(equity or [])
+ if opening_balance and round(opening_balance, 2) != 0:
+ unclosed = {
+ "account_name": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'",
+ "account": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'",
+ "warn_if_negative": True,
+ "currency": currency,
+ }
+ for period in period_list:
+ unclosed[period.key] = opening_balance
+ if provisional_profit_loss:
+ provisional_profit_loss[period.key] = provisional_profit_loss[period.key] - opening_balance
+ unclosed["total"] = opening_balance
+ data.append(unclosed)
+
+ if provisional_profit_loss:
+ data.append(provisional_profit_loss)
+ if total_credit:
+ data.append(total_credit)
+
+ columns = get_columns(
+ filters.periodicity, period_list, filters.accumulated_values, company=filters.company
+ )
+ chart = get_chart_data(filters, period_list, asset, liability, equity, currency)
+ report_summary, primitive_summary = get_report_summary(
+ period_list, asset, liability, equity, provisional_profit_loss, currency, filters
+ )
+
+ if filters.get("selected_view") == "Growth":
+ compute_growth_view_data(data, period_list)
+
+ return columns, data, message, chart, report_summary, primitive_summary
+
+
+def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list):
+ accounts = get_accounts(filters.company, root_type)
+ if not accounts:
+ return None
+
+ accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
+ company_currency = get_appropriate_currency(filters.company, filters)
+
+ gl_entries_by_account = {}
+ _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type)
+
+ calculate_values(
+ accounts_by_name,
+ gl_entries_by_account,
+ period_list,
+ filters.accumulated_values,
+ False,
+ )
+ accumulate_values_into_parents(accounts, accounts_by_name, period_list)
+
+ out = prepare_data(
+ accounts,
+ balance_must_be,
+ period_list,
+ company_currency,
+ accumulated_values=filters.accumulated_values,
+ )
+ out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values)
+
+ if out:
+ add_total_row(out, root_type, balance_must_be, period_list, company_currency)
+
+ return out
+
+
+def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type):
+ from erpnext.accounts.report.trial_balance.trial_balance import (
+ _extra_gl_conditions,
+ _fetch_gl_rows_duckdb,
+ )
+ from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
+
+ company = filters.company
+ year_start_date = period_list[0]["year_start_date"]
+ last_to_date = period_list[-1]["to_date"]
+ ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
+
+ leaf_accounts = [acc.name for acc in accounts if not acc.is_group]
+ if not leaf_accounts:
+ return
+
+ opening_from_date = None
+ ignore_opening_entries = False
+
+ ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance")
+ if not ignore_closing_balances:
+ last_pcv_list = frappe.db.get_all(
+ "Period Closing Voucher",
+ filters={
+ "docstatus": 1,
+ "company": company,
+ "period_end_date": ("<", filters.get("period_start_date") or year_start_date),
+ },
+ fields=["period_end_date", "name"],
+ order_by="period_end_date desc",
+ limit=1,
+ )
+ if last_pcv_list:
+ last_pcv = last_pcv_list[0]
+ pcv_entries = get_accounting_entries(
+ "Account Closing Balance",
+ None,
+ last_to_date,
+ filters,
+ root_type=root_type,
+ ignore_closing_entries=False,
+ period_closing_voucher=last_pcv.name,
+ )
+ if filters.get("presentation_currency"):
+ convert_to_presentation_currency(pcv_entries, get_currency(filters))
+ for entry in pcv_entries:
+ gl_entries_by_account.setdefault(entry.account, []).append(entry)
+ opening_from_date = add_days(last_pcv.period_end_date, 1)
+ ignore_opening_entries = True
+
+ extra_cond, extra_params = _extra_gl_conditions(filters)
+ account_placeholders = ", ".join(["?"] * len(leaf_accounts))
+ base_conds = [
+ "company = ?",
+ "is_cancelled = 0",
+ f"account IN ({account_placeholders})",
+ ]
+ base_params = [company, *leaf_accounts]
+ if ignore_opening_entries and not ignore_is_opening:
+ base_conds.append("is_opening = 'No'")
+ base_conds.extend(extra_cond)
+ base_params.extend(extra_params)
+
+ # Opening GL entries from DuckDB (entries before year_start_date)
+ open_conds = [*base_conds, "posting_date < ?"]
+ open_params = [*base_params, year_start_date]
+ if opening_from_date:
+ open_conds = [*open_conds, "posting_date >= ?"]
+ open_params = [*open_params, opening_from_date]
+
+ opening_entries = _fetch_gl_rows_duckdb(conn, open_conds, open_params)
+ if filters.get("presentation_currency"):
+ convert_to_presentation_currency(opening_entries, get_currency(filters))
+ synthetic_open_date = add_days(year_start_date, -1)
+ for entry in opening_entries:
+ entry.posting_date = synthetic_open_date
+ gl_entries_by_account.setdefault(entry.account, []).append(entry)
+
+ # Period GL entries from DuckDB (one aggregated query per period)
+ for period in period_list:
+ period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"]
+ period_params = [*base_params, period.from_date, period.to_date]
+
+ period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params)
+ if filters.get("presentation_currency"):
+ convert_to_presentation_currency(period_entries, get_currency(filters))
+ for entry in period_entries:
+ entry.posting_date = period.to_date
+ gl_entries_by_account.setdefault(entry.account, []).append(entry)
diff --git a/erpnext/accounts/report/general_ledger/general_ledger.json b/erpnext/accounts/report/general_ledger/general_ledger.json
index 8dac581eae3..083f7b62ae8 100644
--- a/erpnext/accounts/report/general_ledger/general_ledger.json
+++ b/erpnext/accounts/report/general_ledger/general_ledger.json
@@ -4,13 +4,20 @@
"columns": [],
"creation": "2013-12-06 13:22:23",
"default_print_format": "General Ledger Standard",
+ "disable_prepared_report_automation": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
+ "doctype_to_sync": [
+ {
+ "doc_type": "GL Entry"
+ }
+ ],
"filters": [],
+ "generate_csv": 0,
"idx": 4,
"is_standard": "Yes",
- "modified": "2026-05-22 14:34:35.246000",
+ "modified": "2026-06-22 13:38:35.057216",
"modified_by": "Administrator",
"module": "Accounts",
"name": "General Ledger",
@@ -30,5 +37,6 @@
"role": "Auditor"
}
],
+ "synced_report": 0,
"timeout": 0
}
diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py
index dec6e18da20..cae1f27a556 100644
--- a/erpnext/accounts/report/general_ledger/general_ledger.py
+++ b/erpnext/accounts/report/general_ledger/general_ledger.py
@@ -817,3 +817,288 @@ def get_columns(filters):
columns.extend([{"label": _("Remarks"), "fieldname": "remarks", "width": 400}])
return columns
+
+
+def execute_synced_report(filters):
+ from frappe.database.duckdb.database import get_latest_sync
+
+ if conn := get_latest_sync("GL Entry"):
+ return _execute_with_duckdb_conn(filters, conn)
+
+ frappe.throw(_("General Ledger requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
+
+
+def _execute_with_duckdb_conn(filters, conn):
+ if not filters:
+ return [], []
+
+ account_details = {}
+
+ if filters.get("print_in_account_currency") and not filters.get("account"):
+ frappe.throw(_("Select an account to print in account currency"))
+
+ for acc in frappe.get_all("Account", fields=["name", "is_group"]):
+ account_details.setdefault(acc.name, acc)
+
+ if filters.get("party"):
+ filters.party = frappe.parse_json(filters.get("party"))
+
+ validate_filters(filters, account_details)
+ validate_party(filters)
+ filters = set_account_currency(filters)
+ columns = get_columns(filters)
+ res = get_result_duckdb(filters, account_details, conn)
+ return columns, res
+
+
+def get_result_duckdb(filters, account_details, conn):
+ accounting_dimensions = []
+ if filters.get("include_dimensions"):
+ accounting_dimensions = get_accounting_dimensions()
+
+ gl_entries = get_gl_entries_duckdb(filters, accounting_dimensions, conn)
+ data = get_data_with_opening_closing(filters, account_details, accounting_dimensions, gl_entries)
+ return get_result_as_list(data, filters)
+
+
+def get_gl_entries_duckdb(filters, accounting_dimensions, conn):
+ currency_map = get_currency(filters)
+
+ col_names = [
+ "gl_entry",
+ "posting_date",
+ "account",
+ "party_type",
+ "party",
+ "voucher_type",
+ "voucher_subtype",
+ "voucher_no",
+ "cost_center",
+ "project",
+ "against_voucher_type",
+ "against_voucher",
+ "account_currency",
+ "against",
+ "is_opening",
+ "creation",
+ "debit",
+ "credit",
+ "debit_in_account_currency",
+ "credit_in_account_currency",
+ ]
+ select_exprs = [
+ "name",
+ "posting_date",
+ "account",
+ "party_type",
+ "party",
+ "voucher_type",
+ "voucher_subtype",
+ "voucher_no",
+ "cost_center",
+ "project",
+ "against_voucher_type",
+ "against_voucher",
+ "account_currency",
+ "against",
+ "is_opening",
+ "creation",
+ "debit",
+ "credit",
+ "debit_in_account_currency",
+ "credit_in_account_currency",
+ ]
+
+ if filters.get("show_remarks"):
+ remarks_length = frappe.get_single_value("Accounts Settings", "general_ledger_remarks_length")
+ if remarks_length:
+ select_exprs.append(f"substr(remarks, 1, {int(remarks_length)})")
+ else:
+ select_exprs.append("remarks")
+ col_names.append("remarks")
+
+ if filters.get("add_values_in_transaction_currency"):
+ select_exprs += [
+ "debit_in_transaction_currency",
+ "credit_in_transaction_currency",
+ "transaction_currency",
+ ]
+ col_names += [
+ "debit_in_transaction_currency",
+ "credit_in_transaction_currency",
+ "transaction_currency",
+ ]
+
+ if accounting_dimensions:
+ select_exprs += accounting_dimensions
+ col_names += accounting_dimensions
+
+ order_by = "posting_date, account, creation"
+ if filters.get("include_dimensions"):
+ order_by = "posting_date, creation"
+ if filters.get("categorize_by") == "Categorize by Voucher":
+ order_by = "posting_date, voucher_type, voucher_no"
+ if filters.get("categorize_by") == "Categorize by Account":
+ order_by = "account, posting_date, creation"
+
+ if filters.get("include_default_book_entries"):
+ filters["company_fb"] = frappe.get_cached_value(
+ "Company", filters.get("company"), "default_finance_book"
+ )
+
+ conditions, params = _build_gl_conditions_duckdb(filters)
+ select_clause = ", ".join(select_exprs)
+ sql = f'SELECT {select_clause} FROM "tabGL Entry" WHERE {" AND ".join(conditions)} ORDER BY {order_by}'
+
+ rows = conn.execute(sql, params).fetchall()
+ gl_entries = [frappe._dict(zip(col_names, row, strict=False)) for row in rows]
+
+ party_name_map = get_party_name_map()
+ for gl_entry in gl_entries:
+ if gl_entry.party_type and gl_entry.party:
+ gl_entry.party_name = party_name_map.get(gl_entry.party_type, {}).get(gl_entry.party)
+
+ if filters.get("presentation_currency"):
+ return convert_to_presentation_currency(gl_entries, currency_map, filters)
+ return gl_entries
+
+
+def _build_gl_conditions_duckdb(filters):
+ ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
+
+ conditions = ["company = ?"]
+ params = [filters.company]
+
+ if filters.get("account"):
+ filters.account = get_accounts_with_children(filters.account)
+ if filters.account:
+ conditions.append(f"account IN ({', '.join(['?'] * len(filters.account))})")
+ params.extend(filters.account)
+
+ if filters.get("cost_center"):
+ filters.cost_center = get_cost_centers_with_children(filters.cost_center)
+ conditions.append(f"cost_center IN ({', '.join(['?'] * len(filters.cost_center))})")
+ params.extend(filters.cost_center)
+
+ if filters.get("voucher_no"):
+ conditions.append("voucher_no = ?")
+ params.append(filters.voucher_no)
+
+ if filters.get("against_voucher_no"):
+ conditions.append("against_voucher = ?")
+ params.append(filters.against_voucher_no)
+
+ if filters.get("ignore_err"):
+ err_journals = frappe.db.get_all(
+ "Journal Entry",
+ filters={
+ "company": filters.get("company"),
+ "docstatus": 1,
+ "voucher_type": ("in", ["Exchange Rate Revaluation", "Exchange Gain Or Loss"]),
+ },
+ pluck="name",
+ )
+ if err_journals:
+ filters.update({"voucher_no_not_in": err_journals})
+
+ if filters.get("ignore_cr_dr_notes"):
+ system_generated = frappe.db.get_all(
+ "Journal Entry",
+ filters={
+ "company": filters.get("company"),
+ "docstatus": 1,
+ "voucher_type": ("in", ["Credit Note", "Debit Note"]),
+ "is_system_generated": 1,
+ },
+ pluck="name",
+ )
+ if system_generated:
+ vouchers_to_ignore = (filters.get("voucher_no_not_in") or []) + system_generated
+ filters.update({"voucher_no_not_in": vouchers_to_ignore})
+
+ if filters.get("voucher_no_not_in"):
+ vouchers = filters.voucher_no_not_in
+ conditions.append(f"voucher_no NOT IN ({', '.join(['?'] * len(vouchers))})")
+ params.extend(vouchers)
+
+ if filters.get("categorize_by") == "Categorize by Party" and not filters.get("party_type"):
+ conditions.append("party_type IN ('Customer', 'Supplier')")
+
+ if filters.get("party_type"):
+ conditions.append("party_type = ?")
+ params.append(filters.party_type)
+
+ if filters.get("party"):
+ conditions.append(f"party IN ({', '.join(['?'] * len(filters.party))})")
+ params.extend(filters.party)
+
+ # from_date: skip when filtering by account/party to allow opening balance calc in Python
+ if filters.get("disable_opening_balance_calculation"):
+ if not ignore_is_opening:
+ conditions.append("(posting_date >= ? OR is_opening = 'Yes')")
+ else:
+ conditions.append("posting_date >= ?")
+ params.append(filters.from_date)
+ elif not (
+ filters.get("account")
+ or filters.get("party")
+ or filters.get("categorize_by") in ["Categorize by Account", "Categorize by Party"]
+ ):
+ if not ignore_is_opening:
+ conditions.append("(posting_date >= ? OR is_opening = 'Yes')")
+ else:
+ conditions.append("posting_date >= ?")
+ params.append(filters.from_date)
+
+ if not ignore_is_opening:
+ conditions.append("(posting_date <= ? OR is_opening = 'Yes')")
+ else:
+ conditions.append("posting_date <= ?")
+ params.append(filters.to_date)
+
+ if filters.get("project"):
+ conditions.append(f"project IN ({', '.join(['?'] * len(filters.project))})")
+ params.extend(filters.project)
+
+ company_fb = filters.get("company_fb") or frappe.get_cached_value(
+ "Company", filters.company, "default_finance_book"
+ )
+ if filters.get("include_default_book_entries"):
+ if filters.get("finance_book"):
+ if company_fb and cstr(filters.finance_book) != cstr(company_fb):
+ frappe.throw(
+ _("To use a different finance book, please uncheck 'Include Default FB Entries'")
+ )
+ fb_vals = [cstr(filters.finance_book), ""]
+ else:
+ fb_vals = [cstr(company_fb), ""]
+ conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_vals))}) OR finance_book IS NULL)")
+ params.extend(fb_vals)
+ else:
+ if filters.get("finance_book"):
+ conditions.append("(finance_book IN (?, '') OR finance_book IS NULL)")
+ params.append(cstr(filters.finance_book))
+ else:
+ conditions.append("(finance_book IN ('') OR finance_book IS NULL)")
+
+ if not filters.get("show_cancelled_entries"):
+ conditions.append("is_cancelled = 0")
+
+ accounting_dimensions_list = get_accounting_dimensions(as_list=False)
+ if accounting_dimensions_list:
+ for dimension in accounting_dimensions_list:
+ if not dimension.disabled and dimension.document_type != "Finance Book":
+ if filters.get(dimension.fieldname):
+ if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"):
+ filters[dimension.fieldname] = get_dimension_with_children(
+ dimension.document_type, filters.get(dimension.fieldname)
+ )
+ vals = (
+ filters[dimension.fieldname]
+ if isinstance(filters[dimension.fieldname], list)
+ else [filters[dimension.fieldname]]
+ )
+ conditions.append(f"{dimension.fieldname} IN ({', '.join(['?'] * len(vals))})")
+ params.extend(vals)
+
+ return conditions, params
diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json
index 5abd51e2a30..9aa088aefe0 100644
--- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json
+++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.json
@@ -4,13 +4,20 @@
"columns": [],
"creation": "2014-07-18 11:43:33.173207",
"default_print_format": "P&L Statement Standard",
+ "disable_prepared_report_automation": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
+ "doctype_to_sync": [
+ {
+ "doc_type": "GL Entry"
+ }
+ ],
"filters": [],
+ "generate_csv": 0,
"idx": 2,
"is_standard": "Yes",
- "modified": "2026-05-22 14:36:04.544347",
+ "modified": "2026-06-22 13:38:15.898375",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Profit and Loss Statement",
@@ -30,5 +37,6 @@
"role": "Auditor"
}
],
+ "synced_report": 0,
"timeout": 0
}
diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py
index 9ce6cd77e5b..297aa961058 100644
--- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py
+++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py
@@ -11,12 +11,20 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
)
from erpnext.accounts.report.financial_statements import (
+ accumulate_values_into_parents,
+ add_total_row,
+ calculate_values,
compute_growth_view_data,
compute_margin_view_data,
+ filter_accounts,
+ filter_out_zero_value_rows,
+ get_accounts,
+ get_appropriate_currency,
get_columns,
get_data,
get_filtered_list_for_consolidated_report,
get_period_list,
+ prepare_data,
)
@@ -197,3 +205,125 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur
chart["currency"] = currency
return chart
+
+
+def execute_synced_report(filters):
+ from frappe.database.duckdb.database import get_latest_sync
+
+ if not (conn := get_latest_sync("GL Entry")):
+ frappe.throw(
+ _("Profit and Loss Statement requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))
+ )
+
+ period_list = get_period_list(
+ filters.from_fiscal_year,
+ filters.to_fiscal_year,
+ filters.period_start_date,
+ filters.period_end_date,
+ filters.filter_based_on,
+ filters.periodicity,
+ company=filters.company,
+ )
+
+ income = _get_data_duckdb(conn, filters, "Income", "Credit", period_list)
+ expense = _get_data_duckdb(conn, filters, "Expense", "Debit", period_list)
+
+ net_profit_loss = get_net_profit_loss(
+ income, expense, period_list, filters.company, filters.presentation_currency
+ )
+
+ data = []
+ data.extend(income or [])
+ data.extend(expense or [])
+ if net_profit_loss:
+ data.append(net_profit_loss)
+
+ columns = get_columns(filters.periodicity, period_list, filters.accumulated_values, filters.company)
+
+ currency = filters.presentation_currency or frappe.get_cached_value(
+ "Company", filters.company, "default_currency"
+ )
+ chart = get_chart_data(filters, period_list, income, expense, net_profit_loss, currency)
+
+ report_summary, primitive_summary = get_report_summary(
+ period_list, filters.periodicity, income, expense, net_profit_loss, currency, filters
+ )
+
+ if filters.get("selected_view") == "Growth":
+ compute_growth_view_data(data, period_list)
+
+ if filters.get("selected_view") == "Margin":
+ compute_margin_view_data(data, period_list, filters.accumulated_values)
+
+ return columns, data, None, chart, report_summary, primitive_summary
+
+
+def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list):
+ accounts = get_accounts(filters.company, root_type)
+ if not accounts:
+ return None
+
+ accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
+ company_currency = get_appropriate_currency(filters.company, filters)
+
+ gl_entries_by_account = {}
+ _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account)
+
+ calculate_values(
+ accounts_by_name,
+ gl_entries_by_account,
+ period_list,
+ filters.accumulated_values,
+ False,
+ )
+ accumulate_values_into_parents(accounts, accounts_by_name, period_list)
+
+ out = prepare_data(
+ accounts,
+ balance_must_be,
+ period_list,
+ company_currency,
+ accumulated_values=filters.accumulated_values,
+ )
+ out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values)
+
+ if out:
+ add_total_row(out, root_type, balance_must_be, period_list, company_currency)
+
+ return out
+
+
+def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account):
+ from erpnext.accounts.report.trial_balance.trial_balance import (
+ _extra_gl_conditions,
+ _fetch_gl_rows_duckdb,
+ )
+ from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
+
+ company = filters.company
+ leaf_accounts = [acc.name for acc in accounts if not acc.is_group]
+ if not leaf_accounts:
+ return
+
+ extra_cond, extra_params = _extra_gl_conditions(filters)
+ account_placeholders = ", ".join(["?"] * len(leaf_accounts))
+ base_conds = [
+ "company = ?",
+ "is_cancelled = 0",
+ f"account IN ({account_placeholders})",
+ "voucher_type != 'Period Closing Voucher'",
+ ]
+ base_params = [company, *leaf_accounts]
+ base_conds.extend(extra_cond)
+ base_params.extend(extra_params)
+
+ for period in period_list:
+ period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"]
+ period_params = [*base_params, period.from_date, period.to_date]
+
+ period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params)
+ if filters.get("presentation_currency"):
+ convert_to_presentation_currency(period_entries, get_currency(filters))
+ for entry in period_entries:
+ entry.posting_date = period.to_date
+ gl_entries_by_account.setdefault(entry.account, []).append(entry)
diff --git a/erpnext/accounts/report/trial_balance/trial_balance.json b/erpnext/accounts/report/trial_balance/trial_balance.json
index b6c121bd5fd..6793268a1e6 100644
--- a/erpnext/accounts/report/trial_balance/trial_balance.json
+++ b/erpnext/accounts/report/trial_balance/trial_balance.json
@@ -4,13 +4,20 @@
"columns": [],
"creation": "2014-07-22 11:41:23.743564",
"default_print_format": "Trial Balance Standard",
+ "disable_prepared_report_automation": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
+ "doctype_to_sync": [
+ {
+ "doc_type": "GL Entry"
+ }
+ ],
"filters": [],
- "idx": 2,
+ "generate_csv": 0,
+ "idx": 4,
"is_standard": "Yes",
- "modified": "2026-05-22 14:35:44.889062",
+ "modified": "2026-06-22 13:38:42.740436",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Trial Balance",
@@ -30,5 +37,6 @@
"role": "Auditor"
}
],
+ "synced_report": 0,
"timeout": 0
}
diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py
index 4aff8b3305c..85a5142b777 100644
--- a/erpnext/accounts/report/trial_balance/trial_balance.py
+++ b/erpnext/accounts/report/trial_balance/trial_balance.py
@@ -581,3 +581,215 @@ def hide_group_accounts(data):
d.update(indent=0)
non_group_accounts_data.append(d)
return non_group_accounts_data
+
+
+def execute_synced_report(filters):
+ from frappe.database.duckdb.database import get_latest_sync
+
+ if conn := get_latest_sync("GL Entry"):
+ validate_filters(filters)
+ columns = get_columns()
+ data = get_data_duckdb(filters, conn)
+ return columns, data
+ else:
+ frappe.throw(_("Trial Balance requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
+
+
+def get_data_duckdb(filters, conn):
+ # accounts and all metadata via frappe.db — only GL Entry comes from DuckDB
+ accounts = frappe.db.sql(
+ """select name, account_number, parent_account, account_name, root_type, report_type, is_group, lft, rgt
+ from `tabAccount` where company=%s order by lft""",
+ filters.company,
+ as_dict=True,
+ )
+ if not accounts:
+ return None
+
+ company_currency = filters.presentation_currency or erpnext.get_company_currency(filters.company)
+ ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
+ accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
+
+ gl_entries_by_account = get_period_gl_entries_duckdb(conn, filters, ignore_is_opening)
+ opening_balances = get_opening_balances_duckdb(conn, filters, ignore_is_opening)
+
+ calculate_values(
+ accounts,
+ gl_entries_by_account,
+ opening_balances,
+ filters.get("show_net_values"),
+ ignore_is_opening=ignore_is_opening,
+ )
+ accumulate_values_into_parents(accounts, accounts_by_name)
+
+ data = prepare_data(accounts, filters, parent_children_map, company_currency)
+ return filter_out_zero_value_rows(
+ data, parent_children_map, show_zero_values=filters.get("show_zero_values")
+ )
+
+
+def _extra_gl_conditions(filters):
+ """Returns (conditions, params) for optional shared GL Entry filters."""
+ conditions, params = [], []
+
+ if filters.get("cost_center"):
+ cc = get_cost_centers_with_children(filters.get("cost_center"))
+ conditions.append(f"cost_center IN ({', '.join(['?'] * len(cc))})")
+ params.extend(cc)
+
+ if filters.get("project"):
+ proj = filters.project if isinstance(filters.project, list) else [filters.project]
+ conditions.append(f"project IN ({', '.join(['?'] * len(proj))})")
+ params.extend(proj)
+
+ if frappe.db.count("Finance Book"):
+ company_fb = frappe.get_cached_value("Company", filters.company, "default_finance_book")
+ if filters.get("include_default_book_entries"):
+ if filters.get("finance_book") and company_fb and cstr(filters.finance_book) != cstr(company_fb):
+ frappe.throw(
+ _("To use a different finance book, please uncheck 'Include Default FB Entries'")
+ )
+ fb_list = [cstr(filters.get("finance_book")), cstr(company_fb), ""]
+ else:
+ fb_list = [cstr(filters.get("finance_book")), ""]
+ conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_list))}) OR finance_book IS NULL)")
+ params.extend(fb_list)
+
+ for dim in get_accounting_dimensions(as_list=False):
+ if filters.get(dim.fieldname):
+ if frappe.get_cached_value("DocType", dim.document_type, "is_tree"):
+ filters[dim.fieldname] = get_dimension_with_children(
+ dim.document_type, filters.get(dim.fieldname)
+ )
+ vals = (
+ filters[dim.fieldname]
+ if isinstance(filters[dim.fieldname], list)
+ else [filters[dim.fieldname]]
+ )
+ conditions.append(f"{dim.fieldname} IN ({', '.join(['?'] * len(vals))})")
+ params.extend(vals)
+
+ return conditions, params
+
+
+def _fetch_gl_rows_duckdb(conn, conditions, params):
+ cols = [
+ "account",
+ "debit",
+ "credit",
+ "debit_in_account_currency",
+ "credit_in_account_currency",
+ "account_currency",
+ ]
+ sql = f"""SELECT account, SUM(debit), SUM(credit),
+ SUM(debit_in_account_currency), SUM(credit_in_account_currency), account_currency
+ FROM "tabGL Entry" WHERE {" AND ".join(conditions)}
+ GROUP BY account, account_currency"""
+ return [frappe._dict(zip(cols, row, strict=False)) for row in conn.execute(sql, params).fetchall()]
+
+
+def get_period_gl_entries_duckdb(conn, filters, ignore_is_opening):
+ conditions = ["company = ?", "is_cancelled = 0", "posting_date >= ?", "posting_date <= ?"]
+ params = [filters.company, filters.from_date, filters.to_date]
+
+ if not ignore_is_opening:
+ conditions.append("is_opening = 'No'")
+ if not flt(filters.get("with_period_closing_entry_for_current_period")):
+ conditions.append("voucher_type != 'Period Closing Voucher'")
+
+ extra_cond, extra_params = _extra_gl_conditions(filters)
+ conditions.extend(extra_cond)
+ params.extend(extra_params)
+
+ entries = _fetch_gl_rows_duckdb(conn, conditions, params)
+ if filters.get("presentation_currency"):
+ convert_to_presentation_currency(entries, get_currency(filters))
+
+ gl_entries_by_account = {}
+ for entry in entries:
+ gl_entries_by_account.setdefault(entry.account, []).append(entry)
+ return gl_entries_by_account
+
+
+def get_opening_balances_duckdb(conn, filters, ignore_is_opening):
+ bs = _get_rootwise_opening_duckdb(conn, filters, "Balance Sheet", ignore_is_opening)
+ pl = _get_rootwise_opening_duckdb(conn, filters, "Profit and Loss", ignore_is_opening)
+ bs.update(pl)
+ return bs
+
+
+def _get_rootwise_opening_duckdb(conn, filters, report_type, ignore_is_opening):
+ accounting_dimensions = get_accounting_dimensions(as_list=False)
+ ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance")
+ last_pcv = ""
+
+ if not ignore_closing_balances:
+ last_pcv = frappe.db.get_all(
+ "Period Closing Voucher",
+ filters={"docstatus": 1, "company": filters.company, "period_end_date": ("<", filters.from_date)},
+ fields=["period_end_date", "name"],
+ order_by="period_end_date desc",
+ limit=1,
+ )
+
+ if last_pcv:
+ # Account Closing Balance fetched via frappe (not GL Entry)
+ gle = get_opening_balance(
+ "Account Closing Balance",
+ filters,
+ report_type,
+ accounting_dimensions,
+ period_closing_voucher=last_pcv[0].name,
+ ignore_is_opening=ignore_is_opening,
+ )
+ if getdate(last_pcv[0].period_end_date) < getdate(add_days(filters.from_date, -1)):
+ start_date = add_days(last_pcv[0].period_end_date, 1)
+ gle += _get_gl_entry_opening_duckdb(
+ conn, filters, report_type, ignore_is_opening, start_date=start_date
+ )
+ else:
+ gle = _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening)
+
+ opening = frappe._dict()
+ for d in gle:
+ opening.setdefault(d.account, {"account": d.account, "opening_debit": 0.0, "opening_credit": 0.0})
+ opening[d.account]["opening_debit"] += flt(d.debit)
+ opening[d.account]["opening_credit"] += flt(d.credit)
+ return opening
+
+
+def _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening, start_date=None):
+ accounts = frappe.db.get_all("Account", filters={"report_type": report_type}, pluck="name")
+ if not accounts:
+ return []
+
+ conditions = ["company = ?", f"account IN ({', '.join(['?'] * len(accounts))})", "is_cancelled = 0"]
+ params = [filters.company, *accounts]
+
+ if start_date:
+ conditions.append("posting_date >= ? AND posting_date < ?")
+ params.extend([start_date, filters.from_date])
+ if not ignore_is_opening:
+ conditions.append("is_opening = 'No'")
+ elif not ignore_is_opening:
+ conditions.append("(posting_date < ? OR is_opening = 'Yes')")
+ params.append(filters.from_date)
+ else:
+ conditions.append("posting_date < ?")
+ params.append(filters.from_date)
+
+ if not filters.get("show_unclosed_fy_pl_balances") and report_type == "Profit and Loss":
+ conditions.append("posting_date >= ?")
+ params.append(filters.year_start_date)
+
+ if not flt(filters.get("with_period_closing_entry_for_opening")):
+ conditions.append("voucher_type != 'Period Closing Voucher'")
+
+ extra_cond, extra_params = _extra_gl_conditions(filters)
+ conditions.extend(extra_cond)
+ params.extend(extra_params)
+
+ gle = _fetch_gl_rows_duckdb(conn, conditions, params)
+ if filters.get("presentation_currency"):
+ convert_to_presentation_currency(gle, get_currency(filters))
+ return gle
diff --git a/erpnext/accounts/services/child_item_update.py b/erpnext/accounts/services/child_item_update.py
index 0871c732b56..7a8ff400a65 100644
--- a/erpnext/accounts/services/child_item_update.py
+++ b/erpnext/accounts/services/child_item_update.py
@@ -30,7 +30,7 @@ class ChildItemUpdater:
self._ordered_items: dict | None = None
self._purchased_items: dict | None = None
- def update(self, trans_items: str) -> None:
+ def update(self, trans_items: str | list) -> None:
"""Process item additions, edits, and deletions from trans_items JSON."""
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items
from erpnext.selling.doctype.quotation.mapper import get_ordered_items
diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py
index 1f0bee0f021..f2a71bb5f64 100644
--- a/erpnext/assets/doctype/asset/asset.py
+++ b/erpnext/assets/doctype/asset/asset.py
@@ -995,8 +995,7 @@ class Asset(AccountsController):
@frappe.whitelist()
def get_depreciation_rate(self, args: str | dict | Document, on_validate: bool = False):
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
rate_field_precision = frappe.get_single_value("System Settings", "float_precision") or 2
diff --git a/erpnext/assets/doctype/asset/mapper.py b/erpnext/assets/doctype/asset/mapper.py
index 282d58a987c..aabe28179e7 100644
--- a/erpnext/assets/doctype/asset/mapper.py
+++ b/erpnext/assets/doctype/asset/mapper.py
@@ -162,8 +162,7 @@ def make_asset_movement(
assets: list[dict] | str,
purpose: str = "Transfer",
):
- if isinstance(assets, str):
- assets = json.loads(assets)
+ assets = frappe.parse_json(assets)
if len(assets) == 0:
frappe.throw(_("At least one asset has to be selected."))
diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
index ada205080cb..188254929d9 100644
--- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
+++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
@@ -669,8 +669,7 @@ def get_service_item_details(ctx: ItemDetailsCtx) -> frappe._dict:
@frappe.whitelist()
def get_items_tagged_to_wip_composite_asset(params: dict | str):
- if isinstance(params, str):
- params = json.loads(params)
+ params = frappe.parse_json(params)
fields = [
"item_code",
diff --git a/erpnext/buying/doctype/purchase_order/mapper.py b/erpnext/buying/doctype/purchase_order/mapper.py
index e379824f33f..468ab3e2e5d 100644
--- a/erpnext/buying/doctype/purchase_order/mapper.py
+++ b/erpnext/buying/doctype/purchase_order/mapper.py
@@ -27,8 +27,7 @@ def make_purchase_receipt(
):
if args is None:
args = {}
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
has_unit_price_items = frappe.db.get_value("Purchase Order", source_name, "has_unit_price_items")
@@ -123,8 +122,7 @@ def make_purchase_invoice_from_portal(purchase_order_name: str):
def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions=False, args=None):
if args is None:
args = {}
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
def postprocess(source, target):
target.flags.ignore_permissions = ignore_permissions
@@ -294,7 +292,7 @@ def get_mapped_subcontracting_order(source_name: str, target_doc: str | Document
) or frappe.get_value("Production Plan", target_doc.production_plan, "reserve_stock")
if target_doc and isinstance(target_doc, str):
- target_doc = json.loads(target_doc)
+ target_doc = frappe.parse_json(target_doc)
for key in ["service_items", "items", "supplied_items"]:
if key in target_doc:
del target_doc[key]
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py
index 976ee5bbf9e..be27000db2b 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.py
@@ -549,11 +549,11 @@ def item_last_purchase_rate(name, conversion_rate, item_code, conversion_factor=
@frappe.whitelist()
-def close_or_unclose_purchase_orders(names: str, status: str):
+def close_or_unclose_purchase_orders(names: str | list, status: str):
if not frappe.has_permission("Purchase Order", "write"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
- names = json.loads(names)
+ names = frappe.parse_json(names)
for name in names:
po = frappe.get_lazy_doc("Purchase Order", name)
if po.docstatus == 1:
diff --git a/erpnext/buying/doctype/request_for_quotation/mapper.py b/erpnext/buying/doctype/request_for_quotation/mapper.py
index b435bb607f5..77e9f02db85 100644
--- a/erpnext/buying/doctype/request_for_quotation/mapper.py
+++ b/erpnext/buying/doctype/request_for_quotation/mapper.py
@@ -57,8 +57,7 @@ def make_supplier_quotation_from_rfq(
# This method is used to make supplier quotation from supplier's portal.
@frappe.whitelist()
def create_supplier_quotation(doc: str | Document | dict):
- if isinstance(doc, str):
- doc = json.loads(doc)
+ doc = frappe.parse_json(doc)
if frappe.session.user not in frappe.get_all(
"Portal User", {"parent": doc.get("supplier")}, pluck="user"
diff --git a/erpnext/buying/doctype/supplier_quotation/mapper.py b/erpnext/buying/doctype/supplier_quotation/mapper.py
index aebe5d94a4c..67bd32223e6 100644
--- a/erpnext/buying/doctype/supplier_quotation/mapper.py
+++ b/erpnext/buying/doctype/supplier_quotation/mapper.py
@@ -15,8 +15,7 @@ def make_purchase_order(
):
if args is None:
args = {}
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
def set_missing_values(source, target):
target.run_method("set_missing_values")
diff --git a/erpnext/buying/utils.py b/erpnext/buying/utils.py
index f661ecb5d3d..293c084520e 100644
--- a/erpnext/buying/utils.py
+++ b/erpnext/buying/utils.py
@@ -124,12 +124,12 @@ def check_on_hold_or_closed_status(doctype, docname) -> None:
@frappe.whitelist()
-def get_linked_material_requests(items: str):
+def get_linked_material_requests(items: str | list):
"""
Retrieve Material Requests linked to a list of items.
"""
- items = json.loads(items)
+ items = frappe.parse_json(items)
mr_list = []
mr = frappe.qb.DocType("Material Request")
diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py
index a1a6c477f13..4dadc91da3b 100644
--- a/erpnext/controllers/item_variant.py
+++ b/erpnext/controllers/item_variant.py
@@ -45,8 +45,7 @@ def get_variant(
if item_template.variant_based_on == "Manufacturer" and manufacturer:
return make_variant_based_on_manufacturer(item_template, manufacturer, manufacturer_part_no)
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
attribute_args = {k: v for k, v in args.items() if k != "use_template_image"}
if not attribute_args:
@@ -258,8 +257,7 @@ def find_variant(template, args, variant_item_code=None):
@frappe.whitelist()
def create_variant(item: str, args: dict | str, use_template_image: bool = False):
use_template_image = frappe.parse_json(use_template_image)
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
template = frappe.get_doc("Item", item)
variant = frappe.new_doc("Item")
@@ -286,10 +284,7 @@ def create_variant(item: str, args: dict | str, use_template_image: bool = False
def enqueue_multiple_variant_creation(item: str, args: dict | str, use_template_image: bool = False):
use_template_image = frappe.parse_json(use_template_image)
# There can be innumerable attribute combinations, enqueue
- if isinstance(args, str):
- variants = json.loads(args)
- else:
- variants = args
+ variants = frappe.parse_json(args)
variants = {key: values for key, values in variants.items() if values}
if not variants:
frappe.throw(_("Please select at least one attribute value"))
@@ -315,8 +310,7 @@ def enqueue_multiple_variant_creation(item: str, args: dict | str, use_template_
def create_multiple_variants(item, args, use_template_image=False):
count = 0
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
args = {key: values for key, values in args.items() if values}
template_item = frappe.get_doc("Item", item)
@@ -483,7 +477,7 @@ def make_variant_item_code(template_item_code, template_item_name, variant):
@frappe.whitelist()
def create_variant_doc_for_quick_entry(template: str, args: dict | str):
variant_based_on = frappe.db.get_value("Item", template, "variant_based_on")
- args = json.loads(args)
+ args = frappe.parse_json(args)
if variant_based_on == "Manufacturer":
variant = get_variant(template, **args)
else:
diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py
index f6e322eca6a..74b8b8af761 100644
--- a/erpnext/controllers/queries.py
+++ b/erpnext/controllers/queries.py
@@ -213,8 +213,7 @@ def item_query(
"""
doctype = "Item"
- if isinstance(filters, str):
- filters = json.loads(filters)
+ filters = frappe.parse_json(filters)
if filters and isinstance(filters, dict):
if filters.get("customer") or filters.get("supplier"):
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index e350f2d950c..5354c8c6f4e 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -625,8 +625,7 @@ def repost_required_for_queue(doc: StockController) -> bool:
def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str | list[dict]):
from erpnext.stock.services.quality_inspection_service import INSPECTION_FIELDNAME_MAP
- if isinstance(items, str):
- items = json.loads(items)
+ items = frappe.parse_json(items)
inspection_fieldname = INSPECTION_FIELDNAME_MAP.get(doctype)
if inspection_fieldname is None:
@@ -658,8 +657,7 @@ def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str
def make_quality_inspections(
company: str, doctype: str, docname: str, items: str | list, inspection_type: str
):
- if isinstance(items, str):
- items = json.loads(items)
+ items = frappe.parse_json(items)
inspections = []
for item in items:
diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py
index a5ca0ae7999..b598db625a4 100644
--- a/erpnext/controllers/taxes_and_totals.py
+++ b/erpnext/controllers/taxes_and_totals.py
@@ -169,7 +169,7 @@ class calculate_taxes_and_totals:
return
if not self.discount_amount_applied:
- do_not_round_fields = ["valuation_rate", "incoming_rate"]
+ do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"]
for item in self.doc.items:
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
@@ -342,7 +342,7 @@ class calculate_taxes_and_totals:
self._set_in_company_currency(item, ["net_rate", "net_amount"])
def _load_item_tax_rate(self, item_tax_rate):
- return json.loads(item_tax_rate) if item_tax_rate else {}
+ return frappe.parse_json(item_tax_rate) if item_tax_rate else {}
def get_current_tax_fraction(self, tax, item_tax_map):
"""
diff --git a/erpnext/crm/doctype/contract_template/contract_template.py b/erpnext/crm/doctype/contract_template/contract_template.py
index d2a77e426f4..b9dc9c8b7f3 100644
--- a/erpnext/crm/doctype/contract_template/contract_template.py
+++ b/erpnext/crm/doctype/contract_template/contract_template.py
@@ -35,8 +35,7 @@ class ContractTemplate(Document):
@frappe.whitelist()
def get_contract_template(template_name: str, doc: str | dict | Document):
- if isinstance(doc, str):
- doc = json.loads(doc)
+ doc = frappe.parse_json(doc)
contract_template = frappe.get_doc("Contract Template", template_name)
contract_terms = None
diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py
index 84ac1f4d300..5932a35cde4 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.py
+++ b/erpnext/crm/doctype/opportunity/opportunity.py
@@ -391,7 +391,7 @@ def get_item_details(item_code: str):
@frappe.whitelist()
def set_multiple_status(names: str | list[str], status: str):
- names = json.loads(names)
+ names = frappe.parse_json(names)
for name in names:
opp = frappe.get_doc("Opportunity", name)
opp.status = status
diff --git a/erpnext/crm/frappe_crm_api.py b/erpnext/crm/frappe_crm_api.py
index c86876cf084..0230dda7925 100644
--- a/erpnext/crm/frappe_crm_api.py
+++ b/erpnext/crm/frappe_crm_api.py
@@ -33,7 +33,7 @@ def create_prospect_against_crm_deal():
pass
if doc.contacts and len(doc.contacts):
- create_contacts(json.loads(doc.contacts), prospect.company_name, "Prospect", prospect_name)
+ create_contacts(frappe.parse_json(doc.contacts), prospect.company_name, "Prospect", prospect_name)
create_address("Prospect", prospect_name, doc.address)
frappe.response["message"] = prospect_name
@@ -69,8 +69,7 @@ def create_contacts(contacts, organization=None, link_doctype=None, link_docname
def create_address(doctype, docname, address):
if not address:
return
- if isinstance(address, str):
- address = json.loads(address)
+ address = frappe.parse_json(address)
try:
_address = frappe.db.exists("Address", address.get("name"))
if not _address:
@@ -153,7 +152,7 @@ def create_customer(customer_data: dict | None = None):
customer.insert(ignore_permissions=True)
customer_name = customer.name
- contacts = json.loads(customer_data.get("contacts"))
+ contacts = frappe.parse_json(customer_data.get("contacts"))
create_contacts(contacts, customer_name, "Customer", customer_name)
create_address("Customer", customer_name, customer_data.get("address"))
return customer_name
diff --git a/erpnext/edi/doctype/code_list/code_list_import.py b/erpnext/edi/doctype/code_list/code_list_import.py
index 0f6a51fc993..0b8ec588b30 100644
--- a/erpnext/edi/doctype/code_list/code_list_import.py
+++ b/erpnext/edi/doctype/code_list/code_list_import.py
@@ -156,13 +156,15 @@ def process_genericode_import(
code_column: str,
title_column: str | None = None,
description_column: str | None = None,
- filters: str | None = None,
+ filters: str | dict | None = None,
):
from erpnext.edi.doctype.common_code.common_code import import_genericode
column_map = {"code": code_column, "title": title_column, "description": description_column}
- return import_genericode(code_list_name, file_name, column_map, json.loads(filters) if filters else None)
+ return import_genericode(
+ code_list_name, file_name, column_map, frappe.parse_json(filters) if filters else None
+ )
def get_genericode_columns_and_examples(root):
diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py
index 36c2f8c7fc8..ccb9133eb62 100644
--- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py
+++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py
@@ -51,8 +51,8 @@ def get_plaid_configuration():
@frappe.whitelist()
-def add_institution(token: str, response: str):
- response = json.loads(response)
+def add_institution(token: str, response: str | dict):
+ response = frappe.parse_json(response)
plaid = PlaidConnector()
access_token = plaid.get_access_token(token)
@@ -80,13 +80,8 @@ def add_institution(token: str, response: str):
@frappe.whitelist()
def add_bank_accounts(response: str | dict, bank: str | dict, company: str):
- try:
- response = json.loads(response)
- except TypeError:
- pass
-
- if isinstance(bank, str):
- bank = json.loads(bank)
+ response = frappe.parse_json(response)
+ bank = frappe.parse_json(bank)
result = []
parent_gl_account = frappe.db.get_all(
@@ -358,8 +353,8 @@ def get_company(bank_account_name):
@frappe.whitelist()
-def update_bank_account_ids(response: str):
- data = json.loads(response)
+def update_bank_account_ids(response: str | dict):
+ data = frappe.parse_json(response)
institution_name = data["institution"]["name"]
bank = frappe.get_doc("Bank", institution_name).as_dict()
bank_account_name = f"{data['account']['name']} - {institution_name}"
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index 2b966df04d6..1ff17cac37d 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -712,6 +712,10 @@ default_log_clearing_doctypes = {
export_python_type_annotations = True
+# Send non-GET requests for ERPNext's endpoints as native `application/json`
+# bodies instead of form-encoded, per-key JSON-stringified values.
+use_json_request_body = True
+
fields_for_group_similar_items = ["qty", "amount"]
# Translation
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index 061fbbf43e7..0f375271607 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -585,7 +585,7 @@ class BOM(WebsiteGenerator):
if isinstance(kwargs, str):
import json
- kwargs = json.loads(kwargs)
+ kwargs = frappe.parse_json(kwargs)
return kwargs
diff --git a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py
index 983dd2d4cd8..70052591b6a 100644
--- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py
+++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py
@@ -32,8 +32,7 @@ class BOMUpdateTool(Document):
def enqueue_replace_bom(boms: dict | str | None = None, args: dict | str | None = None) -> "BOMUpdateLog":
"""Returns a BOM Update Log (that queues a job) for BOM Replacement."""
boms = boms or args
- if isinstance(boms, str):
- boms = json.loads(boms)
+ boms = frappe.parse_json(boms)
update_log = create_bom_update_log(boms=boms)
return update_log
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py
index f36373323d4..9f4db6c117c 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/job_card.py
@@ -1685,8 +1685,7 @@ class JobCard(Document):
@frappe.whitelist()
def make_time_log(kwargs: str | dict):
- if isinstance(kwargs, str):
- kwargs = json.loads(kwargs)
+ kwargs = frappe.parse_json(kwargs)
kwargs = frappe._dict(kwargs)
doc = frappe.get_doc("Job Card", kwargs.job_card_id)
@@ -1761,8 +1760,7 @@ def get_job_card_filter_conditions(jc, filters):
Replaces the previous raw SQL ``get_filters_cond`` based filtering so that all
user supplied values are passed as bound parameters via the query builder.
"""
- if isinstance(filters, str):
- filters = json.loads(filters)
+ filters = frappe.parse_json(filters)
if not filters:
return []
diff --git a/erpnext/manufacturing/doctype/production_plan/services/material_request.py b/erpnext/manufacturing/doctype/production_plan/services/material_request.py
index a17d434136a..c4f12d8f1b7 100644
--- a/erpnext/manufacturing/doctype/production_plan/services/material_request.py
+++ b/erpnext/manufacturing/doctype/production_plan/services/material_request.py
@@ -159,8 +159,7 @@ def get_items_for_material_requests(
def _normalize_mr_doc(doc):
- if isinstance(doc, str):
- doc = frappe._dict(json.loads(doc))
+ doc = frappe._dict(frappe.parse_json(doc))
return doc
diff --git a/erpnext/manufacturing/doctype/production_plan/services/planning_queries.py b/erpnext/manufacturing/doctype/production_plan/services/planning_queries.py
index 87ccdd2b5c7..615d840f08a 100644
--- a/erpnext/manufacturing/doctype/production_plan/services/planning_queries.py
+++ b/erpnext/manufacturing/doctype/production_plan/services/planning_queries.py
@@ -24,8 +24,7 @@ def get_bin_details(
):
frappe.has_permission("Production Plan", "read", throw=True)
- if isinstance(row, str):
- row = frappe._dict(json.loads(row))
+ row = frappe._dict(frappe.parse_json(row))
bin = frappe.qb.DocType("Bin")
subquery = _bin_warehouse_subquery(bin, company, row, for_warehouse, all_warehouse)
@@ -65,8 +64,7 @@ def _bin_qty_columns(bin):
def get_warehouse_list(warehouses):
warehouse_list = []
- if isinstance(warehouses, str):
- warehouses = json.loads(warehouses)
+ warehouses = frappe.parse_json(warehouses)
for row in warehouses:
child_warehouses = frappe.db.get_descendants("Warehouse", row.get("warehouse"))
diff --git a/erpnext/manufacturing/doctype/work_order/mapper.py b/erpnext/manufacturing/doctype/work_order/mapper.py
index 05ae74df42f..74377094470 100644
--- a/erpnext/manufacturing/doctype/work_order/mapper.py
+++ b/erpnext/manufacturing/doctype/work_order/mapper.py
@@ -148,8 +148,7 @@ def _new_work_order(item, bom_no, company, item_details, use_multi_level_bom):
def add_variant_item(variant_items, wo_doc, bom_no, table_name="items"):
- if isinstance(variant_items, str):
- variant_items = json.loads(variant_items)
+ variant_items = frappe.parse_json(variant_items)
for item in variant_items:
_add_variant_row(item, wo_doc, bom_no, table_name)
@@ -289,8 +288,7 @@ def _set_stock_entry_warehouses(stock_entry, work_order, purpose, target_warehou
def make_job_card(work_order: str, operations: str | list, parent_bom: str | None = None):
frappe.has_permission("Job Card", "create", throw=True)
- if isinstance(operations, str):
- operations = json.loads(operations)
+ operations = frappe.parse_json(operations)
work_order = frappe.get_doc("Work Order", work_order)
for row in operations:
@@ -469,10 +467,10 @@ def get_work_order_operation_data(work_order, operation, workstation):
@frappe.whitelist()
-def create_pick_list(source_name: str, target_doc: str | None = None, for_qty: float | None = None):
+def create_pick_list(source_name: str, target_doc: str | dict | None = None, for_qty: float | None = None):
frappe.has_permission("Pick List", "create", throw=True)
- for_qty = for_qty or json.loads(target_doc).get("for_qty")
+ for_qty = for_qty or frappe.parse_json(target_doc).get("for_qty")
max_finished_goods_qty = frappe.db.get_value("Work Order", source_name, "qty")
postprocess = partial(
_set_pick_list_item_qty, for_qty=for_qty, max_finished_goods_qty=max_finished_goods_qty
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index c5abaf1cb19..0d1c3f01025 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -491,3 +491,4 @@ erpnext.patches.v16_0.migrate_subscription_generate_invoice_at
erpnext.patches.v16_0.rename_subscription_billing_period_fields
erpnext.patches.v16_0.drop_redundant_serial_no_index_from_sabb
erpnext.patches.v16_0.set_default_close_opportunity_after_days
+execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600)
diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py
index 037e46de78f..2b567ce44e1 100644
--- a/erpnext/projects/doctype/project/project.py
+++ b/erpnext/projects/doctype/project/project.py
@@ -629,11 +629,11 @@ def allow_to_make_project_update(project, time, frequency):
@frappe.whitelist()
-def create_duplicate_project(prev_doc: str, project_name: str):
+def create_duplicate_project(prev_doc: str | dict, project_name: str):
"""Create duplicate project based on the old project"""
import json
- prev_doc = json.loads(prev_doc)
+ prev_doc = frappe.parse_json(prev_doc)
if project_name == prev_doc.get("name"):
frappe.throw(_("Use a name that is different from previous project name"))
diff --git a/erpnext/projects/doctype/task/task.py b/erpnext/projects/doctype/task/task.py
index d7781df5f86..6cdfd50933a 100755
--- a/erpnext/projects/doctype/task/task.py
+++ b/erpnext/projects/doctype/task/task.py
@@ -363,8 +363,8 @@ def get_project(doctype: str, txt: str, searchfield: str, start: int, page_len:
@frappe.whitelist()
-def set_multiple_status(names: str, status: str):
- names = json.loads(names)
+def set_multiple_status(names: str | list, status: str):
+ names = frappe.parse_json(names)
for name in names:
task = frappe.get_doc("Task", name)
task.status = status
@@ -459,8 +459,8 @@ def add_node():
@frappe.whitelist()
-def add_multiple_tasks(data: str, parent: str):
- data = json.loads(data)
+def add_multiple_tasks(data: str | list, parent: str):
+ data = frappe.parse_json(data)
new_doc = {"doctype": "Task", "parent_task": parent if parent != "All Tasks" else ""}
new_doc["project"] = frappe.db.get_value("Task", {"name": parent}, "project") or ""
diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py
index 04819e68a0f..d227b225f3b 100644
--- a/erpnext/projects/doctype/timesheet/timesheet.py
+++ b/erpnext/projects/doctype/timesheet/timesheet.py
@@ -497,7 +497,7 @@ def get_activity_cost(
@frappe.whitelist()
-def get_events(start: str, end: str, filters: str | None = None):
+def get_events(start: str, end: str, filters: str | list | dict | None = None):
"""Returns events for Gantt / Calendar view rendering.
:param start: Start date-time.
:param end: End date-time.
@@ -505,7 +505,7 @@ def get_events(start: str, end: str, filters: str | None = None):
"""
from erpnext.utilities.query import get_event_conditions_qb
- filters = json.loads(filters) if filters else {}
+ filters = frappe.parse_json(filters) if filters else {}
tsd = frappe.qb.DocType("Timesheet Detail")
ts = frappe.qb.DocType("Timesheet")
diff --git a/erpnext/regional/italy/utils.py b/erpnext/regional/italy/utils.py
index 7c8727aa7fa..b012ae9be69 100644
--- a/erpnext/regional/italy/utils.py
+++ b/erpnext/regional/italy/utils.py
@@ -104,7 +104,7 @@ def prepare_invoice(invoice, progressive_number):
def get_conditions(filters):
- filters = json.loads(filters)
+ filters = frappe.parse_json(filters)
conditions = {"docstatus": 1, "company_tax_id": ("!=", "")}
diff --git a/erpnext/regional/report/irs_1099/irs_1099.py b/erpnext/regional/report/irs_1099/irs_1099.py
index cebc60d1aec..7d9b02ecf54 100644
--- a/erpnext/regional/report/irs_1099/irs_1099.py
+++ b/erpnext/regional/report/irs_1099/irs_1099.py
@@ -84,7 +84,7 @@ def get_columns():
@frappe.whitelist()
-def irs_1099_print(filters: str):
+def irs_1099_print(filters: str | dict):
if not filters:
frappe._dict(
{
@@ -93,7 +93,7 @@ def irs_1099_print(filters: str):
}
)
else:
- filters = frappe._dict(json.loads(filters))
+ filters = frappe._dict(frappe.parse_json(filters))
fiscal_year_doc = get_fiscal_year(fiscal_year=filters.fiscal_year, as_dict=True)
fiscal_year = cstr(fiscal_year_doc.year_start_date.year)
diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py
index e8ba3eab04d..cb5e770b141 100644
--- a/erpnext/selling/doctype/customer/customer.py
+++ b/erpnext/selling/doctype/customer/customer.py
@@ -559,8 +559,7 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False,
def send_emails(
customer: str, customer_outstanding: float, credit_limit: float, credit_controller_users_list: str | list
):
- if isinstance(credit_controller_users_list, str):
- credit_controller_users_list = json.loads(credit_controller_users_list)
+ credit_controller_users_list = frappe.parse_json(credit_controller_users_list)
subject = _("Credit limit reached for customer {0}").format(customer)
message = _("Credit limit has been crossed for customer {0} ({1}/{2})").format(
customer, customer_outstanding, credit_limit
diff --git a/erpnext/selling/doctype/quotation/mapper.py b/erpnext/selling/doctype/quotation/mapper.py
index 166bd5278ab..2182c969d4e 100644
--- a/erpnext/selling/doctype/quotation/mapper.py
+++ b/erpnext/selling/doctype/quotation/mapper.py
@@ -31,8 +31,7 @@ def make_sales_order(
def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, args=None):
if args is None:
args = {}
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
customer = _make_customer(source_name, ignore_permissions)
ordered_items = get_ordered_items(source_name)
@@ -151,8 +150,7 @@ def make_sales_invoice(
def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, args=None):
if args is None:
args = {}
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
customer = _make_customer(source_name, ignore_permissions)
diff --git a/erpnext/selling/doctype/sales_order/mapper.py b/erpnext/selling/doctype/sales_order/mapper.py
index d2972fdc302..9fddf353d92 100644
--- a/erpnext/selling/doctype/sales_order/mapper.py
+++ b/erpnext/selling/doctype/sales_order/mapper.py
@@ -430,8 +430,7 @@ def make_sales_invoice(
):
if args is None:
args = {}
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
# 0 qty is accepted, as the qty is uncertain for some items
has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
@@ -675,8 +674,7 @@ def make_purchase_order(
if not selected_items:
return
- if isinstance(selected_items, str):
- selected_items = json.loads(selected_items)
+ selected_items = frappe.parse_json(selected_items)
def set_missing_values(source, target):
target.supplier = supplier
@@ -843,9 +841,9 @@ def set_delivery_date(items: list, sales_order: str) -> None:
@frappe.whitelist()
-def make_work_orders(items: str, sales_order: str, company: str, project: str | None = None):
+def make_work_orders(items: str | dict, sales_order: str, company: str, project: str | None = None):
"""Make Work Orders against the given Sales Order for the given `items`"""
- items = json.loads(items).get("items")
+ items = frappe.parse_json(items).get("items")
out = []
for i in items:
@@ -912,8 +910,7 @@ def make_raw_material_request(
if not frappe.has_permission("Sales Order", "write"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
- if isinstance(items, str):
- items = frappe._dict(json.loads(items))
+ items = frappe._dict(frappe.parse_json(items))
for item in items.get("items"):
item["include_exploded_items"] = items.get("include_exploded_items")
@@ -1089,7 +1086,7 @@ def get_mapped_subcontracting_inward_order(
target_doc.populate_items_table()
if target_doc and isinstance(target_doc, str):
- target_doc = json.loads(target_doc)
+ target_doc = frappe.parse_json(target_doc)
for key in ["service_items", "items", "received_items"]:
if key in target_doc:
del target_doc[key]
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index c468b92f183..784feff8ed7 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -711,11 +711,11 @@ def is_enable_cutoff_date_on_bulk_delivery_note_creation():
@frappe.whitelist()
-def close_or_unclose_sales_orders(names: str, status: str):
+def close_or_unclose_sales_orders(names: str | list, status: str):
if not frappe.has_permission("Sales Order", "write"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
- names = json.loads(names)
+ names = frappe.parse_json(names)
for name in names:
so = frappe.get_lazy_doc("Sales Order", name)
if so.docstatus == 1:
diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py
index 83e7bac3fef..a96fc309687 100644
--- a/erpnext/selling/page/point_of_sale/point_of_sale.py
+++ b/erpnext/selling/page/point_of_sale/point_of_sale.py
@@ -347,8 +347,8 @@ def check_opening_entry(user: str):
@frappe.whitelist()
-def create_opening_voucher(pos_profile: str, company: str, balance_details: str):
- balance_details = json.loads(balance_details)
+def create_opening_voucher(pos_profile: str, company: str, balance_details: str | list):
+ balance_details = frappe.parse_json(balance_details)
new_pos_opening = frappe.get_doc(
{
diff --git a/erpnext/setup/doctype/department/department.py b/erpnext/setup/doctype/department/department.py
index 71cf6e96743..a92c77f249d 100644
--- a/erpnext/setup/doctype/department/department.py
+++ b/erpnext/setup/doctype/department/department.py
@@ -77,8 +77,7 @@ def get_children(
is_root: bool = False,
include_disabled: str | dict | None = None,
):
- if isinstance(include_disabled, str):
- include_disabled = json.loads(include_disabled)
+ include_disabled = frappe.parse_json(include_disabled)
fields = ["name as value", "is_group as expandable"]
filters = {}
diff --git a/erpnext/setup/doctype/holiday_list/holiday_list.py b/erpnext/setup/doctype/holiday_list/holiday_list.py
index a7c26857ca3..7095da60788 100644
--- a/erpnext/setup/doctype/holiday_list/holiday_list.py
+++ b/erpnext/setup/doctype/holiday_list/holiday_list.py
@@ -176,7 +176,7 @@ def get_events(start: DateTimeLikeObject, end: DateTimeLikeObject, filters: str
:param filters: Filters (JSON).
"""
if filters:
- filters = json.loads(filters)
+ filters = frappe.parse_json(filters)
else:
filters = []
diff --git a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py
index 32623605f51..f9fd9050d34 100644
--- a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py
+++ b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py
@@ -37,8 +37,7 @@ class TermsandConditions(Document):
@frappe.whitelist()
def get_terms_and_conditions(template_name: str, doc: str | dict):
- if isinstance(doc, str):
- doc = json.loads(doc)
+ doc = frappe.parse_json(doc)
terms_and_conditions = frappe.get_doc("Terms and Conditions", template_name)
diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
index 5a3dcc5b840..043edd44ed1 100644
--- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
+++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
@@ -318,12 +318,23 @@ class TransactionDeletionRecord(Document):
Returns:
list: List of child table DocType names (Table field options)
"""
- return frappe.get_all(
+ child_tables = frappe.get_all(
"DocField",
filters={"parent": doctype_name, "fieldtype": ["in", ["Table", "Table MultiSelect"]]},
pluck="options",
)
+ if not child_tables:
+ return []
+
+ child_tables = frappe.get_all(
+ "DocType",
+ filters={"name": ["in", child_tables], "is_virtual": 0},
+ pluck="name",
+ )
+
+ return child_tables
+
def _get_to_delete_row_infos(self, doctype_name, company_field=None, company=None):
"""Get child tables and document count for a To Delete list row
diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py
index 57b133bb4cc..46c059b25c9 100644
--- a/erpnext/stock/doctype/batch/batch.py
+++ b/erpnext/stock/doctype/batch/batch.py
@@ -404,8 +404,7 @@ def make_batch(kwargs):
def get_pos_reserved_batch_qty(filters: dict | str):
import json
- if isinstance(filters, str):
- filters = json.loads(filters)
+ filters = frappe.parse_json(filters)
p = frappe.qb.DocType("POS Invoice").as_("p")
item = frappe.qb.DocType("POS Invoice Item").as_("item")
diff --git a/erpnext/stock/doctype/delivery_note/mapper.py b/erpnext/stock/doctype/delivery_note/mapper.py
index 605a2d22df6..e4a0eaefe93 100644
--- a/erpnext/stock/doctype/delivery_note/mapper.py
+++ b/erpnext/stock/doctype/delivery_note/mapper.py
@@ -66,8 +66,7 @@ def make_sales_invoice(
if args is None:
args = {}
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
doc = frappe.get_doc("Delivery Note", source_name)
diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py
index 5561ad8251f..cb2d19c0adb 100644
--- a/erpnext/stock/doctype/material_request/mapper.py
+++ b/erpnext/stock/doctype/material_request/mapper.py
@@ -53,8 +53,7 @@ def make_purchase_order(
):
if args is None:
args = {}
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
is_subcontracted = (
frappe.db.get_value("Material Request", source_name, "material_request_type") == "Subcontracting"
diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py
index 7dbeb109c59..5aff12994c9 100644
--- a/erpnext/stock/doctype/packed_item/packed_item.py
+++ b/erpnext/stock/doctype/packed_item/packed_item.py
@@ -432,7 +432,7 @@ def on_doctype_update():
@frappe.whitelist()
-def get_items_from_product_bundle(row: str):
+def get_items_from_product_bundle(row: str | dict):
"""Item details for each component of a Product Bundle.
``row.product_bundle`` selects a specific version by document name (the buying
@@ -441,7 +441,7 @@ def get_items_from_product_bundle(row: str):
"""
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
- row, items = ItemDetailsCtx(json.loads(row)), []
+ row, items = ItemDetailsCtx(frappe.parse_json(row)), []
if bundle_name := row.get("product_bundle"):
frappe.has_permission("Product Bundle", "read", bundle_name, throw=True)
diff --git a/erpnext/stock/doctype/pick_list/mapper.py b/erpnext/stock/doctype/pick_list/mapper.py
index 2d7f0e91ab8..b1168e112a8 100644
--- a/erpnext/stock/doctype/pick_list/mapper.py
+++ b/erpnext/stock/doctype/pick_list/mapper.py
@@ -113,8 +113,7 @@ def create_dn_for_pick_lists(
"""Get Items from Multiple Pick Lists and create a Delivery Note for filtered customer"""
if kwargs is None:
kwargs = {}
- if isinstance(kwargs, str):
- kwargs = json.loads(kwargs)
+ kwargs = frappe.parse_json(kwargs)
pick_list = frappe.get_doc("Pick List", source_name)
validate_item_locations(pick_list)
@@ -282,8 +281,8 @@ def add_product_bundles_to_target(pick_list, target_doc, item_mapper, sales_orde
@frappe.whitelist()
-def create_stock_entry(pick_list: str):
- pick_list = frappe.get_doc(json.loads(pick_list))
+def create_stock_entry(pick_list: str | dict):
+ pick_list = frappe.get_doc(frappe.parse_json(pick_list))
validate_item_locations(pick_list)
if stock_entry_exists(pick_list.get("name")):
diff --git a/erpnext/stock/doctype/purchase_receipt/mapper.py b/erpnext/stock/doctype/purchase_receipt/mapper.py
index 273afcec7a9..528b6865ed5 100644
--- a/erpnext/stock/doctype/purchase_receipt/mapper.py
+++ b/erpnext/stock/doctype/purchase_receipt/mapper.py
@@ -60,8 +60,7 @@ def make_purchase_invoice(
):
if args is None:
args = {}
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
from erpnext.accounts.party import get_payment_terms_template
diff --git a/erpnext/stock/doctype/putaway_rule/putaway_rule.py b/erpnext/stock/doctype/putaway_rule/putaway_rule.py
index b7dacb9c230..4f5967654ac 100644
--- a/erpnext/stock/doctype/putaway_rule/putaway_rule.py
+++ b/erpnext/stock/doctype/putaway_rule/putaway_rule.py
@@ -111,8 +111,7 @@ def apply_putaway_rule(
purpose: Purpose of Stock Entry
sync (optional): Sync with client side only for client side calls
"""
- if isinstance(items, str):
- items = json.loads(items)
+ items = frappe.parse_json(items)
items_not_accomodated, updated_table = [], []
item_wise_rules = defaultdict(list)
@@ -198,7 +197,7 @@ def apply_putaway_rule(
frappe.msgprint(_("Applied putaway rules."), alert=True)
return updated_table
- if sync and json.loads(sync): # sync with client side
+ if sync and frappe.parse_json(sync): # sync with client side
return items
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
index dec591d7961..befb52c444a 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
@@ -345,6 +345,9 @@ class RepostItemValuation(Document):
def _recalculate_valuation_rate(self):
doc = frappe.get_doc(self.voucher_type, self.voucher_no)
+ if doc.get("is_internal_supplier"):
+ doc.set_sales_incoming_rate_for_internal_transfer()
+
doc.update_valuation_rate()
for item in doc.items:
item.db_set("valuation_rate", item.valuation_rate)
@@ -361,8 +364,8 @@ class RepostItemValuation(Document):
@frappe.whitelist()
-def bulk_restart_reposting(names: str):
- names = json.loads(names)
+def bulk_restart_reposting(names: str | list):
+ names = frappe.parse_json(names)
for name in names:
doc = frappe.get_doc("Repost Item Valuation", name)
if doc.status != "Failed":
diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py
index 6ea89b2db8b..a8d9b9f1e7d 100644
--- a/erpnext/stock/doctype/serial_no/serial_no.py
+++ b/erpnext/stock/doctype/serial_no/serial_no.py
@@ -222,8 +222,7 @@ def auto_fetch_serial_number(
@frappe.whitelist()
def get_pos_reserved_serial_nos(filters: str | dict):
- if isinstance(filters, str):
- filters = json.loads(filters)
+ filters = frappe.parse_json(filters)
POSInvoice = frappe.qb.DocType("POS Invoice")
POSInvoiceItem = frappe.qb.DocType("POS Invoice Item")
diff --git a/erpnext/stock/doctype/stock_entry/services/manufacturing.py b/erpnext/stock/doctype/stock_entry/services/manufacturing.py
index b3455c40b51..216d3c9eea9 100644
--- a/erpnext/stock/doctype/stock_entry/services/manufacturing.py
+++ b/erpnext/stock/doctype/stock_entry/services/manufacturing.py
@@ -1040,8 +1040,7 @@ def ceil_qty_if_uom_has_whole_number(qty, stock_uom):
@frappe.whitelist()
def move_sample_to_retention_warehouse(company: str, items: str | list):
- if isinstance(items, str):
- items = json.loads(items)
+ items = frappe.parse_json(items)
retention_warehouse = frappe.get_single_value("Stock Settings", "sample_retention_warehouse")
stock_entry = frappe.new_doc("Stock Entry")
diff --git a/erpnext/stock/doctype/stock_entry/services/subcontracting.py b/erpnext/stock/doctype/stock_entry/services/subcontracting.py
index d4b4921794d..5c3a1b89da1 100644
--- a/erpnext/stock/doctype/stock_entry/services/subcontracting.py
+++ b/erpnext/stock/doctype/stock_entry/services/subcontracting.py
@@ -253,8 +253,7 @@ def get_supplied_items(
def get_items_from_subcontract_order(source_name: str, target_doc: str | Document | None = None):
from erpnext.controllers.subcontracting_controller import make_rm_stock_entry
- if isinstance(target_doc, str):
- target_doc = frappe.get_doc(json.loads(target_doc))
+ target_doc = frappe.get_doc(frappe.parse_json(target_doc))
order_doctype = "Purchase Order" if target_doc.purchase_order else "Subcontracting Order"
target_doc = make_rm_stock_entry(
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index 0ccd3a747b4..ab0496ac908 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -1683,8 +1683,7 @@ def get_uom_details(item_code: str, uom: str, qty: float | None):
@frappe.whitelist()
def get_warehouse_details(args: str | dict):
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
args = frappe._dict(args)
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
index c2cf93ca1d2..341dd22c0b4 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
@@ -1239,8 +1239,7 @@ def get_stock_balance_for(
item_dict = frappe.get_cached_value("Item", item_code, ["has_serial_no", "has_batch_no"], as_dict=1)
- if isinstance(row, str):
- row = json.loads(row)
+ row = frappe.parse_json(row)
if isinstance(row, dict):
row = frappe._dict(row)
diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py
index 4448ab8e52c..1e2d84d1b11 100644
--- a/erpnext/stock/doctype/warehouse/warehouse.py
+++ b/erpnext/stock/doctype/warehouse/warehouse.py
@@ -176,8 +176,7 @@ def get_children(
if is_root:
parent = ""
- if isinstance(include_disabled, str):
- include_disabled = json.loads(include_disabled)
+ include_disabled = frappe.parse_json(include_disabled)
fields = ["name as value", "is_group as expandable"]
diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py
index b11868347ea..11990db26b8 100644
--- a/erpnext/stock/get_item_details.py
+++ b/erpnext/stock/get_item_details.py
@@ -90,8 +90,7 @@ def get_item_details(
item = frappe.get_cached_doc("Item", ctx.item_code)
validate_item_details(ctx, item)
- if isinstance(doc, str):
- doc = json.loads(doc)
+ doc = frappe.parse_json(doc)
if doc:
ctx.transaction_date = doc.get("transaction_date") or doc.get("posting_date")
diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py
index 83ff83e869d..46c0c5da4ea 100644
--- a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py
+++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py
@@ -100,12 +100,12 @@ def get_data(filters=None):
@frappe.whitelist()
-def update_batch_qty(selected_batches: str | None = None):
+def update_batch_qty(selected_batches: str | list | None = None):
frappe.has_permission("Batch", "write", throw=True, ignore_share_permissions=True)
if not selected_batches:
return
- selected_batches = json.loads(selected_batches)
+ selected_batches = frappe.parse_json(selected_batches)
for row in selected_batches:
batch_name = row.get("batch")
diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py
index 1598860b165..8ebef95e47f 100644
--- a/erpnext/stock/utils.py
+++ b/erpnext/stock/utils.py
@@ -245,8 +245,7 @@ def get_incoming_rate(args: dict | str, raise_error_if_no_rate: bool = True, fal
"""Get Incoming Rate based on valuation method"""
from erpnext.stock.stock_ledger import get_previous_sle, get_valuation_rate
- if isinstance(args, str):
- args = json.loads(args)
+ args = frappe.parse_json(args)
if not args.get("posting_datetime") and args.get("posting_date"):
args["posting_datetime"] = get_combine_datetime(args.get("posting_date"), args.get("posting_time"))
diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py
index e269246c0e6..adf46d0d7a2 100644
--- a/erpnext/support/doctype/issue/issue.py
+++ b/erpnext/support/doctype/issue/issue.py
@@ -217,8 +217,8 @@ def get_issue_list(doctype, txt, filters, limit_start, limit_page_length=20, ord
@frappe.whitelist()
-def set_multiple_status(names: str, status: str):
- for name in json.loads(names):
+def set_multiple_status(names: str | list, status: str):
+ for name in frappe.parse_json(names):
set_status(name, status)
diff --git a/erpnext/utilities/bulk_transaction.py b/erpnext/utilities/bulk_transaction.py
index 0b6cbdbb830..33a0fa7f73f 100644
--- a/erpnext/utilities/bulk_transaction.py
+++ b/erpnext/utilities/bulk_transaction.py
@@ -13,13 +13,9 @@ def transaction_processing(
frappe.has_permission(from_doctype, "read", throw=True)
frappe.has_permission(to_doctype, "create", throw=True)
- if isinstance(data, str):
- deserialized_data = json.loads(data)
- else:
- deserialized_data = data
+ deserialized_data = frappe.parse_json(data)
- if isinstance(args, str):
- args = frappe._dict(json.loads(args))
+ args = frappe._dict(frappe.parse_json(args))
skipped_records = [d for d in deserialized_data if d.get("status") in ("On Hold", "Closed")]
diff --git a/erpnext/utilities/query.py b/erpnext/utilities/query.py
index ef0ef3e716d..21759b36b97 100644
--- a/erpnext/utilities/query.py
+++ b/erpnext/utilities/query.py
@@ -62,8 +62,7 @@ def get_filter_conditions_qb(doctype, filters, ignore_permissions=None):
if isinstance(filters, Criterion):
return [filters]
- if isinstance(filters, str):
- filters = json.loads(filters)
+ filters = frappe.parse_json(filters)
if isinstance(filters, dict):
# Mirror get_filters_cond's dict normalization: a string value prefixed with "!" means
diff --git a/erpnext/www/book_appointment/index.py b/erpnext/www/book_appointment/index.py
index 14f5fe385b4..ef7985ed514 100644
--- a/erpnext/www/book_appointment/index.py
+++ b/erpnext/www/book_appointment/index.py
@@ -101,7 +101,7 @@ def get_available_slots_between(query_start_time, query_end_time, settings):
@frappe.whitelist(allow_guest=True)
-def create_appointment(date: str, time: str, tz: str, contact: str):
+def create_appointment(date: str, time: str, tz: str, contact: str | dict):
handle_appointment_booking_disabled()
format_string = "%Y-%m-%d %H:%M:%S"
scheduled_time = datetime.datetime.strptime(date + " " + time, format_string)
@@ -112,7 +112,7 @@ def create_appointment(date: str, time: str, tz: str, contact: str):
# Create a appointment document from form
appointment = frappe.new_doc("Appointment")
appointment.scheduled_time = scheduled_time
- contact = json.loads(contact)
+ contact = frappe.parse_json(contact)
appointment.customer_name = contact.get("name", None)
appointment.customer_phone_number = contact.get("number", None)
appointment.customer_skype = contact.get("skype", None)