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": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t
\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t\"Company\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t
{{ doc.company }}
\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}
\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}
\n\t\t\t\t{% endif %}\n\t\t\t
\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\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
\n\t\t\t\t{% if website %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Website:\") }}\n\t\t\t\t\t{{ website }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Email:\") }}\n\t\t\t\t\t{{ email }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Contact:\") }}\n\t\t\t\t\t{{ phone_no }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t
", + "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\n\t\t\t\t
\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t\"Company\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t{% if doc.company %}
{{ doc.company }}
{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}
\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}
\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}
\n\t\t\t\t{% endif %}\n\t\t\t
\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\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
\n\t\t\t\t{% if website %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Website:\") }}\n\t\t\t\t\t{{ website }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Email:\") }}\n\t\t\t\t\t{{ email }}\n\t\t\t\t
\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Contact:\") }}\n\t\t\t\t\t{{ phone_no }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t
", "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\n\t\t\t\n\t\t\n\t\n
\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\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", + "content": "\n\t\n\t\t\n\t\t\t\n\n\t\t\t\n\t\t\n\t\n
\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\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", "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": "\n\t\n\t\t\n\n\t\t\t\n\n\t\t\t\n\n\t\t\t\n\n\t\t\n\t\n
\n\t\t\t\t{% set company = frappe.get_doc(\"Company\", doc.company) %}\n\n\t\t\t\t
\n\t\t\t\t\t{% if company.company_logo %}\n\t\t\t\t\t\"Company\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t
{{ company.name }}
\n\n\t\t\t\t{% set company_address_name = frappe.db.get_value(\n\t\t\t\t\t\"Dynamic Link\",\n\t\t\t\t\t{\n\t\t\t\t\t\t\"link_doctype\": \"Company\",\n\t\t\t\t\t\t\"link_name\": company.name,\n\t\t\t\t\t\t\"parenttype\": \"Address\"\n\t\t\t\t\t},\n\t\t\t\t\t\"parent\"\n\t\t\t\t) %}\n\n\t\t\t\t{% if company_address_name %}\n\t\t\t\t\t{% set company_address = frappe.db.get_value(\n\t\t\t\t\t\t\"Address\",\n\t\t\t\t\t\tcompany_address_name,\n\t\t\t\t\t\t[\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"],\n\t\t\t\t\t\tas_dict=True\n\t\t\t\t\t) %}\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if company_address %}\n\t\t\t\t
\n\t\t\t\t\t{{ company_address.address_line1 or \"\" }}\n\n\t\t\t\t\t{% if company_address.address_line2 %}\n\t\t\t\t\t\t
{{ company_address.address_line2 }}\n\t\t\t\t\t{% endif %}\n\n\t\t\t\t\t
\n\n\t\t\t\t\t{{ company_address.city or \"\" }}\n\t\t\t\t\t{% if company_address.state %}, {{ company_address.state }}{% endif %}\n\t\t\t\t\t{{ company_address.pincode or \"\" }}\n\n\t\t\t\t\t{% if company_address.country %}\n\t\t\t\t\t\t, {{ company_address.country }}\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t
\n\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %}\n\t\t\t\t{% set email = frappe.db.get_value(\"Company\", doc.company, \"email\") %}\n\t\t\t\t{% set phone_no = frappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t{% if website %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Website:\") }}\n\t\t\t\t\t{{ website }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if email %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Email:\") }}\n\t\t\t\t\t{{ email }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if phone_no %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Contact:\") }}\n\t\t\t\t\t{{ phone_no }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t
", + "content": "\n\t\n\t\t\n\n\t\t\t\n\n\t\t\t\n\n\t\t\t\n\n\t\t\n\t\n
\n\t\t\t\t{% if doc.company %}{% set company = frappe.get_doc(\"Company\", doc.company) %}{% else %}{% set company = frappe._dict() %}{% endif %}\n\n\t\t\t\t
\n\t\t\t\t\t{% if company.company_logo %}\n\t\t\t\t\t\"Company\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\t{% if company.name %}
{{ company.name }}
{% endif %}\n\n\t\t\t\t{% set company_address_name = frappe.db.get_value(\n\t\t\t\t\t\"Dynamic Link\",\n\t\t\t\t\t{\n\t\t\t\t\t\t\"link_doctype\": \"Company\",\n\t\t\t\t\t\t\"link_name\": company.name,\n\t\t\t\t\t\t\"parenttype\": \"Address\"\n\t\t\t\t\t},\n\t\t\t\t\t\"parent\"\n\t\t\t\t) %}\n\n\t\t\t\t{% if company_address_name %}\n\t\t\t\t\t{% set company_address = frappe.db.get_value(\n\t\t\t\t\t\t\"Address\",\n\t\t\t\t\t\tcompany_address_name,\n\t\t\t\t\t\t[\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"],\n\t\t\t\t\t\tas_dict=True\n\t\t\t\t\t) %}\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if company_address %}\n\t\t\t\t
\n\t\t\t\t\t{{ company_address.address_line1 or \"\" }}\n\n\t\t\t\t\t{% if company_address.address_line2 %}\n\t\t\t\t\t\t
{{ company_address.address_line2 }}\n\t\t\t\t\t{% endif %}\n\n\t\t\t\t\t
\n\n\t\t\t\t\t{{ company_address.city or \"\" }}\n\t\t\t\t\t{% if company_address.state %}, {{ company_address.state }}{% endif %}\n\t\t\t\t\t{{ company_address.pincode or \"\" }}\n\n\t\t\t\t\t{% if company_address.country %}\n\t\t\t\t\t\t, {{ company_address.country }}\n\t\t\t\t\t{% endif %}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t
\n\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %}\n\t\t\t\t{% set email = frappe.db.get_value(\"Company\", doc.company, \"email\") %}\n\t\t\t\t{% set phone_no = frappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t{% if website %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Website:\") }}\n\t\t\t\t\t{{ website }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if email %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Email:\") }}\n\t\t\t\t\t{{ email }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if phone_no %}\n\t\t\t\t
\n\t\t\t\t\t{{ _(\"Contact:\") }}\n\t\t\t\t\t{{ phone_no }}\n\t\t\t\t
\n\t\t\t\t{% endif %}\n\t\t\t
", "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)