Compare commits

..

2 Commits

Author SHA1 Message Date
ljain112
e775ac3b65 fix: do not use request cache because creating new account
will cause issue
2026-08-31 18:27:49 +05:30
ljain112
02f033bf9c perf(stock): improve warehouse account mapping and rebuild tree only if necessary 2026-08-31 18:04:40 +05:30
542 changed files with 49493 additions and 139640 deletions

View File

@@ -60,13 +60,10 @@ Flag a changed query that uses any of these:
check_field, True)`, `doc.db_set(field, False)`, or `frappe.qb.update(dt).set(check_field, True)`
emit `SET col = true`, which PostgreSQL rejects on a `smallint`/`Check` column
(`column is of type smallint but expression is of type boolean`). Pass `1`/`0`.
- **A direct `.like()`/`.ilike()` on a pypika field (or raw `LIKE`) on a NON-text column** — `idx`,
`docstatus`, a date, etc. frappe maps `.like()` → `ILIKE`, and PostgreSQL has no `bigint ILIKE text`
operator (`operator does not exist: bigint ~~* unknown`). Cast the column to text first —
**`Cast_(col, "varchar")`**, not `Cast(col, "char")` (see below). MariaDB coerces the int
implicitly, so the cast is a no-op there. A `["like", …]` filter passed to `get_all`/`get_list`/
`qb.get_query`/`reportview` needs no cast: the framework casts non-text fields itself
(frappe/frappe#42449).
- **`.like()`/`.ilike()` (or raw `LIKE`) on a NON-text column** — `idx`, `docstatus`, a date, etc.
frappe maps `.like()` → `ILIKE`, and PostgreSQL has no `bigint ILIKE text` operator (`operator
does not exist: bigint ~~* unknown`). Cast the column to text first — **`Cast_(col, "varchar")`**,
not `Cast(col, "char")` (see below). MariaDB coerces the int implicitly, so the cast is a no-op there.
- **`CAST(… AS CHAR)` / `Cast(x, "char")`** — on PostgreSQL bare `CHAR` is `character(1)`, so
`CAST(12 AS CHAR)` → `'1'` (silently truncates multi-digit values); MariaDB gives the full string.
Use `VARCHAR` / `Cast_(x, "varchar")`.
@@ -195,9 +192,8 @@ pick a bound for a stated reason, and cover the varying-group case with a test.
These are auto-handled by the framework and are **not** breaks:
- **`.like()` / `["like", …]`** already renders as `ILIKE` on PostgreSQL — not a
case-sensitivity bug. A `["like", …]` filter on a **non-text** field is also cast to text by
the framework. *(Exception: a direct `.like()` on a **non-text** pypika field — `idx`,
`docstatus` — is a hard break, `bigint ILIKE`; see §1.)*
case-sensitivity bug. *(Exception: `.like()` on a **non-text** column — `idx`, `docstatus` —
is a hard break, `bigint ILIKE`; see §1.)*
- **Raw `ifnull(...)`** inside `frappe.db.sql()` is rewritten to `coalesce(...)` on all engines.
- **Backticks**, **`LOCATE`**, **`REGEXP`** / **`.regexp()`** in raw SQL are auto-translated on
PostgreSQL (`REGEXP` → `~*`). **But `RLIKE` / `.rlike()` is NOT translated** — that one is a

View File

@@ -4,36 +4,6 @@ set -e
cd ~ || exit
# Authenticate git against github.com with the job token: anonymous git-over-HTTPS from the
# runners gets throttled to a 401, which kills whichever clone is in flight — the frappe fetch
# below, or payments under `bench get-app`. See the PR description.
#
# A credential helper rather than a url.insteadOf rewrite, because `git clone` PERSISTS a
# rewritten URL into the new repo's .git/config: an insteadOf would leave the token sitting in
# apps/payments/.git/config on the runner. A helper is consulted only when github.com actually
# challenges, and leaves the stored remote URL untouched. Passing it through GIT_CONFIG_* keeps
# the token out of ~/.gitconfig too, and child processes inherit it (bench shells out to git).
ci_github_token=${CI_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}
if [ -n "$ci_github_token" ]; then
export CI_GITHUB_TOKEN="$ci_github_token"
export GIT_CONFIG_COUNT=3
# Reset first: git runs EVERY configured helper and calls `store` on them after a successful
# auth, so a `credential.helper=store` inherited from the image's gitconfig would write the
# token to ~/.git-credentials. An empty value clears the list before ours is added.
export GIT_CONFIG_KEY_0="credential.helper"
export GIT_CONFIG_VALUE_0=""
export GIT_CONFIG_KEY_1="credential.https://github.com.username"
export GIT_CONFIG_VALUE_1="x-access-token"
export GIT_CONFIG_KEY_2="credential.https://github.com.helper"
# Single-quoted: $CI_GITHUB_TOKEN is expanded by the shell git runs the helper in, so the
# token is read from the environment at call time and never stored anywhere. Answering only
# `get` makes the helper inert for git's `store`/`erase` calls.
export GIT_CONFIG_VALUE_2='!f() { test "$1" = get && echo "password=$CI_GITHUB_TOKEN"; }; f'
fi
# Whatever happens, never sit on a credential prompt: fail fast and legibly instead.
export GIT_TERMINAL_PROMPT=0
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
frappeuser=${FRAPPE_USER:-"frappe"}
frappecommitish=${FRAPPE_BRANCH:-}
@@ -218,7 +188,7 @@ restore_warm_bench() {
# Phase 1 already fetched ~/frappe to the exact live develop SHA. Fetch that commit
# straight from it (bench init names the remote 'upstream', not 'origin', and points
# it at this local clone — so a plain `git fetch origin` does not work).
git fetch --no-tags --update-shallow "$HOME/frappe" HEAD || exit 1
git fetch --no-tags "$HOME/frappe" HEAD || exit 1
git checkout --force FETCH_HEAD || exit 1
); then
echo "Fast-forward to ${frappe_sha} failed; falling back to full init"

View File

@@ -1,51 +0,0 @@
name: Download translations from Crowdin
on:
schedule:
- cron: "0 4 * * 1"
workflow_dispatch:
concurrency:
group: crowdin-download
cancel-in-progress: false
permissions:
contents: read
jobs:
download-translations:
name: Download translations into ${{ matrix.branch }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
branch: ["develop", "version-16-hotfix"]
steps:
- name: Checkout ${{ matrix.branch }}
uses: actions/checkout@v6
with:
ref: ${{ matrix.branch }}
fetch-depth: 0
persist-credentials: false
- name: Download translations and open PR
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
with:
config: crowdin.yml
upload_sources: false
upload_translations: false
download_translations: true
crowdin_branch_name: "[frappe.erpnext] ${{ matrix.branch }}"
skip_ref_checkout: true
localization_branch_name: l10n_crowdin_${{ matrix.branch }}
create_pull_request: true
pull_request_base_branch_name: ${{ matrix.branch }}
commit_message: "fix: sync translations from crowdin"
pull_request_title: "fix: sync translations from crowdin (${{ matrix.branch }})"
pull_request_labels: "translation, skip-release-notes"
pull_request_reviewers: barredterra
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -1,54 +0,0 @@
name: Upload main.pot to Crowdin
on:
push:
branches:
- develop
- version-16-hotfix
paths:
- "erpnext/locale/main.pot"
workflow_dispatch:
concurrency:
group: crowdin-upload-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
jobs:
upload-sources:
name: Upload sources from ${{ github.ref_name }}
runs-on: ubuntu-latest
steps:
- name: Checkout ${{ github.ref_name }}
uses: actions/checkout@v6
- name: Restore Crowdin cache
uses: actions/cache/restore@v6
with:
path: .crowdin
key: crowdin-${{ github.ref_name }}
restore-keys: crowdin-${{ github.ref_name }}-
- name: Upload main.pot to Crowdin
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
with:
config: crowdin.yml
upload_sources: true
upload_translations: false
download_translations: false
create_pull_request: false
crowdin_branch_name: "[frappe.erpnext] ${{ github.ref_name }}"
upload_sources_args: "--cache"
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Save Crowdin cache
uses: actions/cache/save@v6
if: always()
with:
path: .crowdin
key: crowdin-${{ github.ref_name }}-${{ github.run_id }}

View File

@@ -1,45 +0,0 @@
name: Notify Support on PR release
on:
issue_comment:
types: [created]
permissions: {}
jobs:
notify-support:
if: >-
github.event.issue.pull_request
&& github.event.comment.user.id == 28699486
&& contains(github.event.comment.body, 'This PR is included in version')
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Notify support.frappe.io
env:
COMMENT_ID: ${{ github.event.comment.id }}
PR_NUMBER: ${{ github.event.issue.number }}
REPOSITORY: ${{ github.repository }}
SUPPORT_FRAPPE_AUTH: ${{ secrets.SUPPORT_FRAPPE_AUTH }}
run: |
payload="$(
jq -n \
--arg repository "$REPOSITORY" \
--argjson pr_number "$PR_NUMBER" \
--argjson comment_id "$COMMENT_ID" \
'{
repository: $repository,
pr_number: $pr_number,
comment_id: $comment_id
}'
)"
curl --fail-with-body \
--retry 3 \
--retry-all-errors \
--request POST \
--header "Authorization: $SUPPORT_FRAPPE_AUTH" \
--header "Content-Type: application/json" \
--data "$payload" \
"https://support.frappe.io/api/method/notify_pr_release"

View File

@@ -121,8 +121,6 @@ jobs:
env:
DB: mariadb
TYPE: server
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
CI_GITHUB_TOKEN: ${{ github.token }}
- name: Run Patch Tests
run: |

View File

@@ -22,6 +22,6 @@ jobs:
pull-requests: write
steps:
- uses: alyf-de/po-review-action@5928f84d6bc9094f9ad6e2c5780f01c0044b800e # v1.1.1
- uses: alyf-de/po-review-action@v1.1.0
with:
hidden-po-files: eo.po

View File

@@ -129,8 +129,6 @@ jobs:
TYPE: server
FRAPPE_USER: ${{ github.event.inputs.user }}
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
CI_GITHUB_TOKEN: ${{ github.token }}
- name: Run Tests
run: |

View File

@@ -13,7 +13,6 @@ on:
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
- '**.po'
permissions:
contents: read

View File

@@ -13,7 +13,6 @@ on:
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
- '**.po'
schedule:
# Run everday at midnight UTC / 5:30 IST
- cron: "0 0 * * *"
@@ -102,8 +101,6 @@ jobs:
TYPE: server
FRAPPE_USER: ${{ github.event.inputs.user }}
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
CI_GITHUB_TOKEN: ${{ github.token }}
DB_HOST: 127.0.0.1
DB_USER_HOST: '%'
WKHTMLTOX_DEB: /tmp/wkhtmltox.deb

View File

@@ -103,8 +103,6 @@ jobs:
DB: postgres
TYPE: server
FRAPPE_BRANCH: develop
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
CI_GITHUB_TOKEN: ${{ github.token }}
BENCH_CACHE_DIR: /home/runner/bench-cache
- name: Warm up test data

File diff suppressed because one or more lines are too long

View File

@@ -1489,10 +1489,10 @@ balanced-match@^4.0.2:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
baseline-browser-mapping@^2.11.12:
version "2.11.20"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz#26078c7a4b08299656ea7ddceaebec955dc44303"
integrity sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==
baseline-browser-mapping@^2.10.38:
version "2.10.40"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz#f372c8eb36ff4ad0b5e7ae467014abef124554ba"
integrity sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==
brace-expansion@^5.0.5:
version "5.0.7"
@@ -1509,15 +1509,15 @@ brace-expansion@^5.0.8:
balanced-match "^4.0.2"
browserslist@^4.24.0:
version "4.28.8"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.8.tgz#a3c79ceb70028527e5da7dafc887f3200b5168c0"
integrity sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==
version "4.28.4"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.4.tgz#dd8b8167a32845ff5f8cd6ce13f5abba16cd04c9"
integrity sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==
dependencies:
baseline-browser-mapping "^2.11.12"
caniuse-lite "^1.0.30001809"
electron-to-chromium "^1.5.402"
node-releases "^2.0.53"
update-browserslist-db "^1.3.0"
baseline-browser-mapping "^2.10.38"
caniuse-lite "^1.0.30001799"
electron-to-chromium "^1.5.376"
node-releases "^2.0.48"
update-browserslist-db "^1.2.3"
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
version "1.0.2"
@@ -1527,10 +1527,10 @@ call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
es-errors "^1.3.0"
function-bind "^1.1.2"
caniuse-lite@^1.0.30001809:
version "1.0.30001810"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz#4970b477dea3278374de9bc43aa8f5d39fc3cda2"
integrity sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==
caniuse-lite@^1.0.30001799:
version "1.0.30001800"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz#b896c773e1c39400809415162bb5320371291b36"
integrity sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==
ccount@^2.0.0:
version "2.0.1"
@@ -1697,10 +1697,10 @@ dunder-proto@^1.0.1:
es-errors "^1.3.0"
gopd "^1.2.0"
electron-to-chromium@^1.5.402:
version "1.5.420"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz#fc66d26a722d6f227e2092acdf38dd55b198cb44"
integrity sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==
electron-to-chromium@^1.5.376:
version "1.5.383"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.383.tgz#5bd22306497d454103b289b0fef97260c56d0855"
integrity sha512-I2484/KkAvl8lm9VyjH2JnbOIV0d/UCqT7gbzs6l+o6Vmn9wgB66uVcKX+Vk6HrXtY6fbWTOEXuv8waDTuFNCw==
engine.io-client@~6.5.1:
version "6.5.4"
@@ -3012,10 +3012,10 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
node-releases@^2.0.53:
version "2.0.54"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.54.tgz#09af17d5647aa9f221ec5cf2becb95b68a981afe"
integrity sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==
node-releases@^2.0.48:
version "2.0.50"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.50.tgz#597197a852071ce42fc2550e58e223242bcba969"
integrity sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==
object-assign@^4.1.1:
version "4.1.1"
@@ -3589,10 +3589,10 @@ unist-util-visit@^5.0.0:
unist-util-is "^6.0.0"
unist-util-visit-parents "^6.0.0"
update-browserslist-db@^1.3.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz#9d99fbff56c50bb11ba5fd35cece5916da595836"
integrity sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==
update-browserslist-db@^1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d"
integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==
dependencies:
escalade "^3.2.0"
picocolors "^1.1.1"

View File

@@ -1,5 +1,16 @@
preserve_hierarchy: true
files:
- source: /erpnext/locale/main.pot
translation: /erpnext/locale/%two_letters_code%.po
pull_request_title: "fix: sync translations from crowdin"
pull_request_labels:
- translation
- skip-release-notes
pull_request_reviewers:
- barredterra # change to your GitHub username if you copied this file
commit_message: "fix: %language% translations"
append_commit_message: false
languages_mapping:
two_letters_code:
pt-BR: pt_BR
zh-CN: zh
zh-TW: zh_TW

View File

@@ -9,7 +9,7 @@
"idx": 0,
"is_public": 1,
"is_standard": 1,
"modified": "2026-09-04 12:37:31.673782",
"modified": "2025-12-19 12:37:31.673782",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Profit and Loss",
@@ -17,6 +17,7 @@
"owner": "Administrator",
"report_name": "Profit and Loss Statement",
"roles": [],
"show_values_over_chart": 1,
"timeseries": 0,
"type": "Line",
"use_report_chart": 1,

View File

@@ -122,7 +122,6 @@
"description": "Setting Account Type helps in selecting this Account in transactions.",
"fieldname": "account_type",
"fieldtype": "Select",
"in_preview": 1,
"in_standard_filter": 1,
"label": "Account Type",
"oldfieldname": "account_type",
@@ -204,7 +203,7 @@
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2026-09-03 12:59:42.190900",
"modified": "2026-04-14 18:14:42.202065",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Account",
@@ -265,46 +264,6 @@
{
"role": "HR Manager",
"select": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Manufacturing User",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Quality Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -52,42 +52,6 @@ frappe.treeview_settings["Account"] = {
],
root_label: "Accounts",
get_tree_nodes: "erpnext.accounts.utils.get_children",
get_label: function (node) {
// clean display name — the account number renders as a badge (see
// onrender) instead of being glued into the name
return frappe.utils.escape_html(node.data.account_name || node.title || node.label);
},
onrender: function (node) {
if (node.is_root || !node.data) return;
const flags = [];
if (node.data.account_number) {
flags.push(frappe.ui.badge({ label: node.data.account_number }));
}
const company = frappe.treeview_settings["Account"].treeview?.page?.fields_dict?.company?.get_value();
const company_currency = company && erpnext.get_currency(company);
if (
node.data.account_currency &&
company_currency &&
node.data.account_currency !== company_currency
) {
flags.push(frappe.ui.badge({ label: node.data.account_currency, theme: "blue" }));
}
if (node.data.freeze_account === "Yes") {
flags.push(
frappe.ui.badge({
label: __("Frozen"),
icon: "lock",
title: __("Frozen - entries restricted"),
theme: "orange",
})
);
}
erpnext.utils.render_tree_node_flags(node, flags);
},
on_node_render: function (node, deep) {
const render_balances = () => {
for (let account of cur_tree.account_balance_data) {
@@ -268,7 +232,7 @@ frappe.treeview_settings["Account"] = {
frappe.treeview_settings["Account"].treeview["tree"] = treeview.tree;
if (treeview.can_create) {
treeview.page.set_primary_action(
{ label: __("Add Account"), short_label: __("Add") },
__("New"),
function () {
let root_company = treeview.page.fields_dict.root_company.get_value();
if (root_company) {
@@ -279,14 +243,13 @@ frappe.treeview_settings["Account"] = {
treeview.new_node();
}
},
"plus"
"add"
);
}
},
toolbar: [
{
label: __("Add Child"),
icon: "plus",
condition: function (node) {
return (
frappe.boot.user.can_create.indexOf("Account") !== -1 &&
@@ -309,7 +272,6 @@ frappe.treeview_settings["Account"] = {
return !node.root && frappe.boot.user.can_read.indexOf("GL Entry") !== -1;
},
label: __("View Ledger"),
icon: "book-open",
click: function (node, btn) {
frappe.route_options = {
from_date: erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[1],
@@ -324,106 +286,6 @@ frappe.treeview_settings["Account"] = {
},
btnClass: "hidden-xs",
},
{
// same label and mechanism as the Account form's Actions button:
// NOT frappe's generic rename (Allow Rename stays off) — this is
// ERPNext's controlled update that rebuilds the derived
// "number - name - abbr" document name
label: __("Update Account Name / Number"),
icon: "text-cursor-input",
condition: function (node) {
return !node.is_root && frappe.model.can_write("Account");
},
click: function (node) {
const dialog = new frappe.ui.Dialog({
title: __("Update Account Number / Name"),
fields: [
{
fieldtype: "Data",
fieldname: "account_name",
label: __("Account Name"),
reqd: 1,
default: node.data.account_name,
},
{
fieldtype: "Data",
fieldname: "account_number",
label: __("Account Number"),
default: node.data.account_number,
},
],
primary_action_label: __("Update"),
primary_action(values) {
dialog.hide();
frappe.dom.freeze(__("Updating {0}", [node.label]));
frappe.call({
method: "erpnext.accounts.doctype.account.account.update_account_number",
args: {
name: node.label,
account_name: values.account_name,
account_number: values.account_number,
},
callback: function (r) {
if (r.exc) return;
const treeview = frappe.views.trees["Account"];
node.parent_node && treeview.tree.load_children(node.parent_node);
},
always: function () {
frappe.dom.unfreeze();
},
});
},
});
dialog.show();
},
},
{
label: __("Convert to Group"),
icon: "folder-tree",
condition: function (node) {
return !node.is_root && !node.expandable && frappe.model.can_write("Account");
},
click: function (node) {
erpnext.accounts.convert_tree_node("Account", node, "convert_ledger_to_group");
},
},
{
label: __("Convert to Non-Group"),
icon: "file-text",
condition: function (node) {
// only on groups the user has opened and found empty — a
// group with children can't convert, so don't offer it
return (
!node.is_root &&
node.expandable &&
node.loaded &&
!node.$ul.children().length &&
frappe.model.can_write("Account")
);
},
click: function (node) {
erpnext.accounts.convert_tree_node("Account", node, "convert_group_to_ledger");
},
},
],
extend_toolbar: true,
};
frappe.provide("erpnext.accounts");
// shared by the Account and Cost Center tree views (defined in both files,
// whichever loads first wins): run the doctype's whitelisted convert method,
// then re-render the branch so the node's group/leaf state updates
erpnext.accounts.convert_tree_node =
erpnext.accounts.convert_tree_node ||
function (doctype, node, method) {
frappe.call({
method: "run_doc_method",
args: { dt: doctype, dn: node.label, method: method },
callback: function (r) {
if (r.exc) return;
const treeview = frappe.views.trees[doctype];
node.parent_node && treeview.tree.load_children(node.parent_node);
frappe.show_alert({ message: __("{0} converted", [node.label]), indicator: "green" });
},
});
};

View File

@@ -102,8 +102,6 @@ def identify_is_group(child):
def get_chart(chart_template: str | None, existing_company: str | None = None):
chart = {}
if existing_company:
frappe.has_permission("Company", doc=existing_company, throw=True)
return get_account_tree_from_existing_company(existing_company)
elif chart_template == "Standard":

View File

@@ -16,8 +16,6 @@ frappe.ui.form.on("Accounting Dimension", {
return {
filters: {
name: ["not in", invalid_doctypes],
istable: 0,
issingle: 0,
},
};
});

View File

@@ -60,14 +60,6 @@ class AccountingDimension(Document):
msg = _("Not allowed to create accounting dimension for {0}").format(self.document_type)
frappe.throw(msg)
meta = frappe.get_meta(self.document_type)
if meta.istable or meta.issingle:
frappe.throw(
_(
"{0} cannot be used as an accounting dimension as it is not a standalone document type."
).format(frappe.bold(self.document_type))
)
exists = frappe.db.get_value("Accounting Dimension", {"document_type": self.document_type}, ["name"])
if exists and self.is_new():

View File

@@ -51,23 +51,6 @@ class TestAccountingDimension(ERPNextTestSuite):
self.assertEqual(gle.get("department"), "_Test Department - _TC")
self.assertEqual(gle1.get("department"), "_Test Department - _TC")
def test_child_table_not_allowed_as_dimension(self):
dimension = frappe.get_doc({"doctype": "Accounting Dimension", "document_type": "Sales Team"})
self.assertRaises(frappe.ValidationError, dimension.insert)
def test_single_doctype_not_allowed_as_dimension(self):
dimension = frappe.get_doc({"doctype": "Accounting Dimension", "document_type": "Selling Settings"})
self.assertRaises(frappe.ValidationError, dimension.insert)
def test_non_scalar_dimension_value_skipped_in_gl_dict(self):
si = create_sales_invoice(do_not_save=1)
si.department = "_Test Department - _TC"
self.assertEqual(si.get_gl_dict({}).get("department"), "_Test Department - _TC")
si.department = ["_Test Department - _TC"]
self.assertNotIn("department", si.get_gl_dict({}))
def test_mandatory(self):
location = frappe.get_doc("Accounting Dimension", "Location")
location.dimension_defaults[0].mandatory_for_bs = True

View File

@@ -204,8 +204,8 @@ class AccountsSettings(Document):
if self.add_taxes_from_item_tax_template and self.add_taxes_from_taxes_and_charges_template:
frappe.throw(
_("You cannot enable both the settings '{0}' and '{1}'.").format(
frappe.bold(self.meta.get_translated_label("add_taxes_from_item_tax_template")),
frappe.bold(self.meta.get_translated_label("add_taxes_from_taxes_and_charges_template")),
frappe.bold(_(self.meta.get_label("add_taxes_from_item_tax_template"))),
frappe.bold(_(self.meta.get_label("add_taxes_from_taxes_and_charges_template"))),
),
title=_("Auto Tax Settings Error"),
)

View File

@@ -101,7 +101,7 @@
}
],
"links": [],
"modified": "2026-08-21 23:11:39.423431",
"modified": "2024-03-27 13:06:36.896195",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank",
@@ -118,20 +118,11 @@
"role": "System Manager",
"share": 1,
"write": 1
},
{
"role": "Accounts Manager",
"select": 1
},
{
"role": "Accounts User",
"select": 1
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -269,7 +269,7 @@
"link_fieldname": "default_bank_account"
}
],
"modified": "2026-08-21 23:11:39.585456",
"modified": "2026-04-11 19:46:27.609994",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Account",
@@ -299,22 +299,6 @@
"role": "Accounts User",
"share": 1,
"write": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -4,18 +4,29 @@
import frappe
from frappe.utils import add_months, getdate
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import (
set_default_account_for_mode_of_payment,
)
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.tests.utils import ERPNextTestSuite, if_lending_app_installed, if_lending_app_not_installed
class TestBankClearance(ERPNextTestSuite):
def setUp(self):
frappe.clear_cache()
create_warehouse(
warehouse_name="_Test Warehouse",
properties={"parent_warehouse": "All Warehouses - _TC"},
company="_Test Company",
)
create_item("_Test Item")
create_cost_center(cost_center_name="_Test Cost Center", company="_Test Company")
make_bank_account()
add_transactions()
@@ -128,8 +139,11 @@ def add_transactions():
def make_payment_entry():
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
supplier = create_supplier(supplier_name="_Test Supplier")
pi = make_purchase_invoice(
supplier="_Test Supplier",
supplier=supplier.name,
supplier_warehouse="_Test Warehouse - _TC",
expense_account="Cost of Goods Sold - _TC",
uom="Nos",
@@ -144,6 +158,10 @@ def make_payment_entry():
def make_pos_sales_invoice():
from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
make_customer,
)
mode_of_payment = frappe.get_doc({"doctype": "Mode of Payment", "name": "Cash"})
if not frappe.db.get_value("Mode of Payment Account", {"company": "_Test Company", "parent": "Cash"}):
@@ -152,13 +170,13 @@ def make_pos_sales_invoice():
)
mode_of_payment.save()
customer = make_customer(customer="_Test Customer")
mode_of_payment = frappe.get_doc("Mode of Payment", "Wire Transfer")
set_default_account_for_mode_of_payment(mode_of_payment, "_Test Company", "_Test Bank Clearance - _TC")
si = create_sales_invoice(
customer="_Test Customer", item="_Test Item", is_pos=1, qty=1, rate=1000, do_not_save=1
)
si = create_sales_invoice(customer=customer, item="_Test Item", is_pos=1, qty=1, rate=1000, do_not_save=1)
si.set("payments", [])
si.append("payments", {"mode_of_payment": "Wire Transfer", "amount": 1000})
si.insert()

View File

@@ -912,7 +912,7 @@ def search_for_transfer_transaction(transaction_id: str | int):
days = frappe.db.get_single_value("Accounts Settings", "transfer_match_days")
if days is None:
if not days:
days = 3
min_date = frappe.utils.add_days(date, -days)
@@ -1336,11 +1336,9 @@ def get_pe_matching_query(
ref_condition = pe.reference_no == transaction.reference_number
ref_rank = frappe.qb.terms.Case().when(ref_condition, 1).else_(0)
amount_field = pe.received_amount_after_tax if account_from_to == "paid_to" else pe.paid_amount_after_tax
amount_equality = amount_field == transaction.unallocated_amount
amount_equality = pe.paid_amount == transaction.unallocated_amount
amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
amount_condition = amount_equality if exact_match else amount_field > 0.0
amount_condition = amount_equality if exact_match else pe.paid_amount > 0.0
party_condition = (
(pe.party_type == transaction.party_type) & (pe.party == transaction.party) & pe.party.isnotnull()
@@ -1357,7 +1355,7 @@ def get_pe_matching_query(
(ref_rank + amount_rank + party_rank + 1).as_("rank"),
ConstantColumn("Payment Entry").as_("doctype"),
pe.name,
amount_field.as_("paid_amount"),
pe.base_paid_amount_after_tax.as_("paid_amount"),
pe.reference_no,
pe.reference_date,
pe.party,

View File

@@ -10,7 +10,6 @@ from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool
auto_reconcile_vouchers,
get_auto_reconcile_message,
get_bank_transactions,
get_linked_payments,
)
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
@@ -100,14 +99,13 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
self.assertEqual(len(transactions), 0)
def make_bank_transaction(self, date, deposit=100, withdrawal=0):
def make_bank_transaction(self, date, deposit=100):
return (
frappe.get_doc(
{
"doctype": "Bank Transaction",
"date": date,
"deposit": deposit,
"withdrawal": withdrawal,
"bank_account": self.bank_account,
"currency": "INR",
}
@@ -116,73 +114,11 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
.submit()
)
def get_matching_payment_entries(self, bank_transaction, exact_match=False):
document_types = ["payment_entry", "exact_match"] if exact_match else ["payment_entry"]
vouchers = get_linked_payments(
bank_transaction,
document_types,
from_date=add_days(today(), -1),
to_date=today(),
)
return [v for v in vouchers if v.get("doctype") == "Payment Entry"]
def test_get_bank_transactions_excludes_dates_after_to_date(self):
self.make_bank_transaction(date=today())
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
self.assertEqual(names, [])
def test_deposit_matches_amount_received_in_bank_account(self):
# money leaves another bank account and lands here minus a charge, so the two sides differ
payment = frappe.get_doc(
{
"doctype": "Payment Entry",
"payment_type": "Internal Transfer",
"company": self.company,
"posting_date": today(),
"paid_from": "_Test Bank - _TC",
"paid_to": self.bank,
"paid_amount": 3537.64,
"received_amount": 3460.52,
"reference_no": "TRF-001",
"reference_date": today(),
}
)
payment.set_missing_values()
payment.set_exchange_rate()
payment.set_amounts()
payment.deductions[-1].account = "_Test Exchange Gain/Loss - _TC"
payment.deductions[-1].cost_center = "_Test Cost Center - _TC"
payment = payment.save().submit()
transaction = self.make_bank_transaction(date=today(), deposit=3460.52)
# the received side is what reached this bank account, so that is what is shown
matches = self.get_matching_payment_entries(transaction.name)
self.assertEqual([m["name"] for m in matches], [payment.name])
self.assertEqual(matches[0]["paid_amount"], 3460.52)
# and what the exact match compares against
exact_matches = self.get_matching_payment_entries(transaction.name, exact_match=True)
self.assertEqual([m["name"] for m in exact_matches], [payment.name])
def test_withdrawal_matches_amount_paid_from_bank_account(self):
payment = create_payment_entry(
company=self.company,
payment_type="Pay",
party_type="Supplier",
party="_Test Supplier",
paid_from=self.bank,
paid_to="Creditors - _TC",
paid_amount=1250,
)
payment = payment.save().submit()
transaction = self.make_bank_transaction(date=today(), deposit=0, withdrawal=1250)
exact_matches = self.get_matching_payment_entries(transaction.name, exact_match=True)
self.assertEqual([m["name"] for m in exact_matches], [payment.name])
self.assertEqual(exact_matches[0]["paid_amount"], 1250)
def test_auto_reconcile_message_for_no_matches(self):
message, indicator = get_auto_reconcile_message([], [])
self.assertEqual(indicator, "blue")

View File

@@ -23,6 +23,8 @@ from erpnext.tests.utils import ERPNextTestSuite, if_lending_app_installed
class TestBankTransaction(ERPNextTestSuite):
def setUp(self):
make_pos_profile()
# generate and use a uniq hash identifier for 'Bank Account' and it's linked GL 'Account' to avoid validation error
uniq_identifier = frappe.generate_hash(length=10)
gl_account = create_gl_account("_Test Bank " + uniq_identifier)
@@ -30,7 +32,6 @@ class TestBankTransaction(ERPNextTestSuite):
gl_account=gl_account, bank_account_name="Checking Account " + uniq_identifier
)
make_pos_profile()
add_transactions(bank_account=bank_account)
add_vouchers(gl_account=gl_account)
@@ -46,7 +47,7 @@ class TestBankTransaction(ERPNextTestSuite):
from_date=bank_transaction.date,
to_date=utils.today(),
)
self.assertIn("Conrad Electronic", [payment["party"] for payment in linked_payments])
self.assertEqual(linked_payments[0]["party"], "Conrad Electronic")
# This test validates a simple reconciliation leading to the clearance of the bank transaction and the payment
def test_reconcile(self):

View File

@@ -729,7 +729,6 @@ def get_ordered_amount(params):
(child.item_code == item_code)
& (parent.docstatus == 1)
& (child.amount > child.billed_amt)
& (child.closed == 0)
& (parent.status != "Closed")
& Criterion.all(get_other_condition(params, child, parent, "Purchase Order"))
)

View File

@@ -16,8 +16,6 @@ frappe.ui.form.on("Chart of Accounts Importer", {
() => generate_tree_preview(frm),
() => create_import_button(frm),
() => frm.set_df_property("chart_preview", "hidden", 0),
// the preview is the point of this page — open it right away
() => frm.fields_dict.chart_preview.collapse(false),
]);
}
@@ -130,6 +128,7 @@ var create_import_button = function (frm) {
freeze_message: __("Creating Accounts..."),
callback: function (r) {
if (!r.exc) {
clearInterval(frm.page["interval"]);
frm.page.set_indicator(__("Import Successful"), "blue");
create_reset_button(frm);
}
@@ -143,95 +142,42 @@ var create_reset_button = function (frm) {
frm.page
.set_primary_action(__("Reset"), function () {
frm.page.clear_primary_action();
delete frm.page["show_import_button"];
frm.reload_doc();
})
.addClass("btn btn-primary");
};
var generate_tree_preview = function (frm) {
let parent = __("All Accounts");
const wrapper = $(frm.fields_dict["chart_tree"].wrapper).empty(); // empty wrapper to load new data
// search + expand/collapse-all lean on frappe.ui.Tree helpers added with
// row mode; when running against an older frappe that predates them, skip
// this toolbar so the preview still renders (just without the extras)
const has_row_helpers =
typeof frappe.ui.Tree.prototype.get_expansion_state === "function" &&
typeof frappe.ui.Tree.prototype.filter_nodes === "function";
let tree;
let deep_loaded = false;
let search_text = "";
let update_buttons = () => {};
if (has_row_helpers) {
// same toolbar anatomy as the tree view: search on the left,
// expand/collapse-all on the right (three-state: fully collapsed ->
// Expand All, fully expanded -> Collapse All, partially expanded -> both)
const $toolbar = $('<div class="flex items-center gap-2 mb-2"></div>').appendTo(wrapper);
const search_control = frappe.ui.form.make_control({
df: { fieldtype: "Data", fieldname: "preview_search", placeholder: __("Search") },
parent: $toolbar,
only_input: true,
});
search_control.refresh();
$(search_control.wrapper).addClass("m-0").css("width", "220px");
search_control.$input.addClass("input-xs");
search_control.$input.on(
"input",
frappe.utils.debounce(() => {
search_text = search_control.$input.val();
const run = () => {
// a newer keystroke superseded this one while the deep load ran
if (search_text !== search_control.$input.val()) return;
tree.filter_nodes(search_text);
};
if (!search_text || deep_loaded) {
run();
return;
var validate_coa = function (frm) {
if (frm.doc.import_file) {
let parent = __("All Accounts");
return frappe.call({
method: "erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer.get_coa",
args: {
file_name: frm.doc.import_file,
parent: parent,
doctype: "Chart of Accounts Importer",
file_type: frm.doc.file_type,
for_validate: 1,
},
callback: function (r) {
if (r.message["show_import_button"]) {
frm.page["show_import_button"] = Boolean(r.message["show_import_button"]);
}
tree.load_children(tree.root_node, true).then(() => {
deep_loaded = true;
run();
});
}, 300)
);
const $actions = $('<div class="ms-auto flex items-center gap-1"></div>').appendTo($toolbar);
update_buttons = () => {
const state = tree.get_expansion_state();
$expand_all.prop("disabled", !(state === "collapsed" || state === "partial"));
$collapse_all.prop("disabled", !(state === "expanded" || state === "partial"));
};
// tooltip on a wrapper: a disabled es-button has pointer-events:none,
// so hover falls through to the wrapper and the tooltip still shows
const make_action = (icon, label, onclick) => {
const $btn = $(
frappe.ui.button({ icon, disabled: true, onclick, attrs: { "aria-label": label } })
);
const $wrapper = $('<span class="inline-flex"></span>').append($btn).appendTo($actions);
frappe.ui.tooltip($wrapper, { text: label });
return $btn;
};
var $expand_all = make_action("chevrons-up-down", __("Expand All"), () => {
tree.load_children(tree.root_node, true).then(() => {
deep_loaded = true;
});
});
var $collapse_all = make_action("chevrons-down-up", __("Collapse All"), () => {
tree.load_children(tree.root_node, false);
},
});
}
};
var generate_tree_preview = function (frm) {
let parent = __("All Accounts");
$(frm.fields_dict["chart_tree"].wrapper).empty(); // empty wrapper to load new data
// generate tree structure based on the csv data
tree = new frappe.ui.Tree({
parent: wrapper,
return new frappe.ui.Tree({
parent: $(frm.fields_dict["chart_tree"].wrapper),
label: parent,
expandable: true,
// read-only preview: row-mode visuals without actions or hover cards
// (ignored by an older frappe, which renders the legacy tree)
row_style: true,
method: "erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer.get_coa",
args: {
file_name: frm.doc.import_file,
@@ -239,9 +185,8 @@ var generate_tree_preview = function (frm) {
doctype: "Chart of Accounts Importer",
file_type: frm.doc.file_type,
},
on_node_render: () => update_buttons(),
// expanded flips right after this callback — check on the next tick
on_click: () => setTimeout(update_buttons, 0),
onclick: function (node) {
parent = node.value;
},
});
return tree;
};

View File

@@ -126,7 +126,7 @@
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2026-08-21 23:11:40.799391",
"modified": "2026-04-14 18:15:27.367298",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Cost Center",
@@ -181,54 +181,6 @@
{
"role": "HR Manager",
"select": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Manufacturing User",
"select": 1
},
{
"role": "Projects Manager",
"select": 1
},
{
"role": "Projects User",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Quality Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -12,19 +12,6 @@ frappe.treeview_settings["Cost Center"] = {
],
root_label: "Cost Centers",
get_tree_nodes: "erpnext.accounts.utils.get_children",
get_label: function (node) {
// clean display name — the number renders as a badge (see onrender)
return frappe.utils.escape_html(node.data.cost_center_name || node.title || node.label);
},
onrender: function (node) {
if (node.is_root || !node.data) return;
const flags = [];
if (node.data.cost_center_number) {
flags.push(frappe.ui.badge({ label: node.data.cost_center_number }));
}
erpnext.utils.render_tree_node_flags(node, flags);
},
add_tree_node: "erpnext.accounts.utils.add_cc",
menu_items: [
{
@@ -55,37 +42,6 @@ frappe.treeview_settings["Cost Center"] = {
},
],
ignore_fields: ["parent_cost_center"],
toolbar: [
{
label: __("Convert to Group"),
icon: "folder-tree",
condition: function (node) {
return !node.is_root && !node.expandable && frappe.model.can_write("Cost Center");
},
click: function (node) {
erpnext.accounts.convert_tree_node("Cost Center", node, "convert_ledger_to_group");
},
},
{
label: __("Convert to Non-Group"),
icon: "file-text",
condition: function (node) {
// only on groups the user has opened and found empty — a
// group with children can't convert, so don't offer it
return (
!node.is_root &&
node.expandable &&
node.loaded &&
!node.$ul.children().length &&
frappe.model.can_write("Cost Center")
);
},
click: function (node) {
erpnext.accounts.convert_tree_node("Cost Center", node, "convert_group_to_ledger");
},
},
],
extend_toolbar: true,
onload: function (treeview) {
function get_company() {
return treeview.page.fields_dict.company.get_value();
@@ -126,22 +82,3 @@ frappe.treeview_settings["Cost Center"] = {
);
},
};
frappe.provide("erpnext.accounts");
// shared by the Account and Cost Center tree views (defined in both files,
// whichever loads first wins): run the doctype's whitelisted convert method,
// then re-render the branch so the node's group/leaf state updates
erpnext.accounts.convert_tree_node =
erpnext.accounts.convert_tree_node ||
function (doctype, node, method) {
frappe.call({
method: "run_doc_method",
args: { dt: doctype, dn: node.label, method: method },
callback: function (r) {
if (r.exc) return;
const treeview = frappe.views.trees[doctype];
node.parent_node && treeview.tree.load_children(node.parent_node);
frappe.show_alert({ message: __("{0} converted", [node.label]), indicator: "green" });
},
});
};

View File

@@ -125,7 +125,7 @@
}
],
"links": [],
"modified": "2026-08-21 23:11:41.010871",
"modified": "2024-11-19 16:35:11.836441",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Coupon Code",
@@ -179,20 +179,11 @@
"role": "Website Manager",
"share": 1,
"write": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "coupon_name",
"track_changes": 1
}
}

View File

@@ -234,10 +234,8 @@ frappe.ui.form.on("Dunning", {
dn: frm.doc.name,
},
callback: function (r) {
if (!r.exc) {
var doc = frappe.model.sync(r.message);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
}
var doc = frappe.model.sync(r.message);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
},
});
},

View File

@@ -20,7 +20,7 @@
],
"icon": "fa fa-book",
"links": [],
"modified": "2026-08-21 23:11:42.386104",
"modified": "2024-03-27 13:09:44.514241",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Finance Book",
@@ -55,26 +55,13 @@
"report": 1,
"role": "Auditor",
"share": 1
},
{
"role": "HR Manager",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Quality Manager",
"select": 1
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"search_fields": "finance_book_name",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1,
"track_seen": 1
}
}

View File

@@ -31,4 +31,11 @@ class TestFinanceBook(ERPNextTestSuite):
def create_finance_book():
return frappe.get_doc("Finance Book", "Test Finance Book 1")
if not frappe.db.exists("Finance Book", "_Test Finance Book"):
finance_book = frappe.get_doc(
{"doctype": "Finance Book", "finance_book_name": "_Test Finance Book"}
).insert()
else:
finance_book = frappe.get_doc("Finance Book", "_Test Finance Book")
return finance_book

View File

@@ -32,7 +32,6 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_validat
AccountFilterValidator,
CalculationFormulaValidator,
DependencyValidator,
get_valid_api_method,
)
from erpnext.accounts.report.financial_statements import (
get_columns,
@@ -1183,12 +1182,10 @@ class RowProcessor:
def _process_api_row(self, row) -> RowData:
api_path = row.calculation_formula
method = get_valid_api_method(api_path)
# TODO
try:
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-codeinjection-eval
values = frappe.call(method, filters=self.context.filters, periods=self.period_list, row=row)
values = frappe.call(api_path, filters=self.context.filters, periods=self.period_list, row=row)
if row.reverse_sign:
values = [-1 * v for v in values]

View File

@@ -163,7 +163,7 @@ function show_accounts_tree(template_rows, has_selection) {
fieldname: "company",
fieldtype: "Link",
options: "Company",
label: __("Company"),
label: "Company",
reqd: 1,
default: frappe.defaults.get_user_default("Company"),
onchange: () => {
@@ -176,7 +176,7 @@ function show_accounts_tree(template_rows, has_selection) {
fieldname: "view_type",
fieldtype: "Select",
options: ["Missing Accounts", "Filtered Accounts"],
label: __("View"),
label: "View",
default: has_selection ? "Filtered Accounts" : "Missing Accounts",
reqd: 1,
onchange: () => {
@@ -192,10 +192,10 @@ function show_accounts_tree(template_rows, has_selection) {
{
fieldname: "tip",
fieldtype: "HTML",
label: __("Tip"),
label: "Tip",
options: `
<div class="alert alert-success" role="alert">
${__("Tip: Select report lines to view their accounts")}
Tip: Select report lines to view their accounts
</div>
`,
depends_on: has_selection ? "eval: false" : "eval: true",
@@ -203,7 +203,7 @@ function show_accounts_tree(template_rows, has_selection) {
{
fieldname: "tree_area",
fieldtype: "HTML",
label: __("Chart of Accounts"),
label: "Chart of Accounts",
read_only: 1,
depends_on: "eval: doc.company",
},
@@ -236,8 +236,6 @@ async function refresh_tree_view(dialog, account_rows) {
parent: wrapper,
label: company,
root_value: company,
// read-only preview: row-mode visuals without actions
row_style: true,
method: "erpnext.accounts.doctype.financial_report_template.financial_report_engine.get_children_accounts",
args: { doctype: "Account", company: company, filtered_accounts: filtered_accounts, missed: missed },
toolbar: [],
@@ -290,14 +288,14 @@ function update_formula_label(frm, data_source) {
if (!field) return;
const labels = {
"Account Data": __("Account Filter"),
"Custom API": __("API Method Path"),
"Account Data": "Account Filter",
"Custom API": "API Method Path",
};
grid.update_docfield_property(
"calculation_formula",
"label",
labels[data_source] || __("Calculation Formula")
labels[data_source] || "Calculation Formula"
);
}
@@ -372,7 +370,7 @@ function update_formula_description(frm, data_source) {
description_html = `
<div ${container_style}>
<h5 ${title_style}>Custom API Setup</h5>
<p ${text_style}>Path to your custom whitelisted method that returns financial data. It must permit GET requests.</p>
<p ${text_style}>Path to your custom method that returns financial data.</p>
<h6 ${subtitle_style}>Format:</h6>
<ul ${list_style}>
@@ -382,8 +380,7 @@ function update_formula_description(frm, data_source) {
<h6 ${subtitle_style}>Method Signature:</h6>
<div ${code_style}>
<!-- &#10; is used for line breaks since frappe.render replaces newlines with spaces -->
<pre ${pre_style} class="language-python">@frappe.whitelist(methods=["GET"])&#10;def get_custom_data(filters, periods, row):&#10; # filters: dict — report filters (company, period, etc.)&#10; # periods: list[dict] — period definitions&#10; # row: dict — the current report row&#10;&#10; return [1000.0, 1200.0, 1150.0] # one value per period</pre>
<pre ${pre_style}>def get_custom_data(filters, periods, row): <br>&nbsp; # filters: dict — report filters (company, period, etc.) <br>&nbsp; # periods: list[dict] — period definitions <br>&nbsp; # row: dict — the current report row <br><br>&nbsp; return [1000.0, 1200.0, 1150.0] # one value per period</pre>
</div>
<h6 ${subtitle_style}>Return Format:</h6>

View File

@@ -8,40 +8,17 @@ from dataclasses import dataclass, field
from typing import Any
import frappe
from frappe import _, is_whitelisted
from frappe import _
from frappe.database.operator_map import OPERATOR_MAP
def get_valid_api_method(api_path: str):
"""Resolve `api_path`, ensuring it is whitelisted and permits GET (i.e. read-only)."""
method = frappe.get_attr(api_path)
is_whitelisted(method)
if "GET" not in frappe.allowed_http_methods_for_whitelisted_func.get(method, ()):
frappe.throw(
_("Method {0} must permit GET requests").format(frappe.bold(api_path)),
frappe.PermissionError,
title=_("Method Not Allowed"),
)
return method
def get_formula_field_label(data_source: str) -> str:
# Must mirror the `labels` map in financial_report_template.js (update_formula_label),
labels = {
"Account Data": _("Account Filter"),
"Custom API": _("API Method Path"),
}
return labels.get(data_source, _("Calculation Formula"))
@dataclass
class ValidationIssue:
"""Represents a single validation issue"""
message: str
row_idx: int | None = None
field: str | None = None
details: dict[str, Any] = None
def __post_init__(self):
@@ -49,9 +26,10 @@ class ValidationIssue:
self.details = {}
def __str__(self) -> str:
if self.row_idx:
return _("Row {0}: {1}", context="Financial Report Template").format(self.row_idx, self.message)
return self.message
prefix = f"Row {self.row_idx}: " if self.row_idx else ""
field_info = f"[{self.field}] " if self.field else ""
message = f"{prefix}{field_info}{self.message}"
return _(message)
@dataclass
@@ -153,9 +131,7 @@ class TemplateStructureValidator(Validator):
if not re.match(r"^[A-Za-z][A-Za-z0-9_-]*$", ref_code):
result.add_error(
ValidationIssue(
message=_(
"Invalid line reference format: '{0}'. Must start with letter and contain only letters, numbers, underscores, and hyphens"
).format(ref_code),
message=f"Invalid line reference format: '{ref_code}'. Must start with letter and contain only letters, numbers, underscores, and hyphens",
row_idx=row.idx,
)
)
@@ -164,7 +140,7 @@ class TemplateStructureValidator(Validator):
if ref_code in used_codes:
result.add_error(
ValidationIssue(
message=_("Duplicate line reference: '{0}'").format(ref_code),
message=f"Duplicate line reference: '{ref_code}'",
row_idx=row.idx,
)
)
@@ -180,7 +156,7 @@ class TemplateStructureValidator(Validator):
if row.data_source == "Account Data" and not row.balance_type:
result.add_error(
ValidationIssue(
message=_("Balance Type is required for Account Data"),
message="Balance Type is required for Account Data",
row_idx=row.idx,
)
)
@@ -190,11 +166,7 @@ class TemplateStructureValidator(Validator):
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message=_("{0} is required when {1} is {2}").format(
get_formula_field_label(row.data_source),
row.meta.get_translated_label("data_source"),
_(row.data_source),
),
message=f"Formula is required for {row.data_source}",
row_idx=row.idx,
)
)
@@ -221,14 +193,7 @@ class DependencyValidator(Validator):
for row in self.template.rows:
if row.reference_code and row.data_source == "Calculated Amount" and row.calculation_formula:
# skip self-reference, `CalculationFormulaValidator` already reports it
deps = [
code
for code in extract_reference_codes_from_formula(
row.calculation_formula, list(available_codes)
)
if code != row.reference_code
]
deps = extract_reference_codes_from_formula(row.calculation_formula, list(available_codes))
if deps:
graph[row.reference_code] = deps
@@ -258,7 +223,7 @@ class DependencyValidator(Validator):
cycle = [*path[cycle_start:], node]
result.add_error(
ValidationIssue(
message=_("Circular dependency detected: {0}").format("".join(cycle)),
message=f"Circular dependency detected: {''.join(cycle)}",
)
)
return
@@ -290,9 +255,7 @@ class DependencyValidator(Validator):
row_idx = self._get_row_idx(ref_code)
result.add_error(
ValidationIssue(
message=_("Line references undefined in {0}: {1}").format(
get_formula_field_label("Calculated Amount"), ", ".join(undefined)
),
message=f"Line References undefined in Formula: {', '.join(undefined)}",
row_idx=row_idx,
)
)
@@ -319,6 +282,16 @@ class CalculationFormulaValidator(Validator):
if row.data_source != "Calculated Amount":
return result
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message="Formula is required for Calculated Amount",
row_idx=row.idx,
field="Formula",
)
)
return result
formula = self._preprocess_formula(row.calculation_formula)
row.calculation_formula = formula
@@ -326,7 +299,7 @@ class CalculationFormulaValidator(Validator):
if not self._are_parentheses_balanced(formula):
result.add_error(
ValidationIssue(
message=_("Formula has unbalanced parentheses"),
message="Formula has unbalanced parentheses",
row_idx=row.idx,
)
)
@@ -338,7 +311,17 @@ class CalculationFormulaValidator(Validator):
if row.reference_code and row.reference_code in refs:
result.add_error(
ValidationIssue(
message=_("Formula references itself ('{0}')").format(row.reference_code),
message=f"Formula references itself ('{row.reference_code}')",
row_idx=row.idx,
)
)
# Check undefined references
undefined = set(refs) - set(available_codes)
if undefined:
result.add_error(
ValidationIssue(
message=f"Formula references undefined codes: {', '.join(undefined)}",
row_idx=row.idx,
)
)
@@ -348,7 +331,7 @@ class CalculationFormulaValidator(Validator):
if eval_error:
result.add_error(
ValidationIssue(
message=_("Formula evaluation error: {0}").format(eval_error),
message=f"Formula evaluation error: {eval_error}",
row_idx=row.idx,
)
)
@@ -385,7 +368,7 @@ class CalculationFormulaValidator(Validator):
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
if not isinstance(result, (int, float)): # noqa: UP038
return _("Formula must return a numeric value, got {0}").format(type(result).__name__)
return f"Formula must return a numeric value, got {type(result).__name__}"
return None
except Exception as e:
@@ -405,6 +388,16 @@ class AccountFilterValidator(Validator):
if row.data_source != "Account Data":
return result
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message="Account filter is required for Account Data",
row_idx=row.idx,
field="Formula",
)
)
return result
try:
filter_config = json.loads(row.calculation_formula)
error = self._validate_filter_structure(
@@ -416,21 +409,18 @@ class AccountFilterValidator(Validator):
if error:
result.add_error(
ValidationIssue(
message=_("[{0}] {1}", context="Financial Report Template").format(
get_formula_field_label(row.data_source), error
),
message=error,
row_idx=row.idx,
field="Account Filter",
)
)
except json.JSONDecodeError as e:
result.add_error(
ValidationIssue(
message=_("[{0}] {1}", context="Financial Report Template").format(
get_formula_field_label(row.data_source),
_("Invalid JSON format: {0}").format(str(e)),
),
message=f"Invalid JSON format: {e!s}",
row_idx=row.idx,
field="Account Filter",
)
)
@@ -445,38 +435,36 @@ class AccountFilterValidator(Validator):
# simple condition: [field, operator, value]
if isinstance(filter_config, list):
if len(filter_config) != 3:
return _("Filter must be [field, operator, value]")
return "Filter must be [field, operator, value]"
field, operator, value = filter_config
if not isinstance(field, str) or not isinstance(operator, str):
return _("Field and operator must be strings")
return "Field and operator must be strings"
display = (
field if advanced_filtering else self.account_meta.get_translated_label(field)
) or field
display = (field if advanced_filtering else self.account_meta.get_label(field)) or field
if field not in account_fields:
return _("Field '{0}' is not a valid Account field").format(display)
return f"Field '{display}' is not a valid Account field"
if operator.casefold() not in OPERATOR_MAP:
return _("Invalid operator '{0}'").format(operator)
return f"Invalid operator '{operator}'"
if operator in ["in", "not in"] and not isinstance(value, list):
return _("Operator '{0}' requires a list value").format(operator)
return f"Operator '{operator}' requires a list value"
# logical condition: {"and": [condition1, condition2]}
elif isinstance(filter_config, dict):
if len(filter_config) != 1:
return _("Logical condition must have exactly one operator")
return "Logical condition must have exactly one operator"
op = next(iter(filter_config.keys())).lower()
if op not in ["and", "or"]:
return _("Logical operators must be 'and' or 'or'")
return "Logical operators must be 'and' or 'or'"
conditions = filter_config[next(iter(filter_config.keys()))]
if not isinstance(conditions, list) or len(conditions) < 1:
return _("Logical conditions need at least 1 sub-condition")
return "Logical conditions need at least 1 sub-condition"
# recursive
for condition in conditions:
@@ -484,7 +472,7 @@ class AccountFilterValidator(Validator):
if error:
return error
else:
return _("Filter must be a list or dict")
return "Filter must be a list or dict"
return None
@@ -520,32 +508,34 @@ class FormulaValidator(Validator):
if "." not in api_path:
result.add_error(
ValidationIssue(
message=_("{0} should be in format: app.module.method").format(
get_formula_field_label(row.data_source)
),
message="Custom API path should be in format: app.module.method",
row_idx=row.idx,
field="Formula",
)
)
return result
# Method exists?
try:
get_valid_api_method(api_path)
module_path, method_name = api_path.rsplit(".", 1)
module = frappe.get_module(module_path)
if not hasattr(module, method_name):
result.add_error(
ValidationIssue(
message=f"Method '{method_name}' not found in module '{module_path}' (might be environment-specific)",
row_idx=row.idx,
field="Formula",
)
)
except Exception as e:
if isinstance(e, frappe.PermissionError | frappe.ValidationError):
# frappe.throw inside get_valid_api_method logs a message that would pop up in UI
frappe.clear_last_message()
if isinstance(e, frappe.PermissionError):
message = _("[{0}] {1}", context="Financial Report Template").format(
get_formula_field_label(row.data_source),
_("Method '{0}' must be whitelisted and permit GET requests").format(api_path),
result.add_error(
ValidationIssue(
message=f"Could not validate API path: {e!s}",
row_idx=row.idx,
field="Formula",
)
else:
message = _("Could not validate {0}: {1}").format(
get_formula_field_label(row.data_source), str(e)
)
result.add_error(ValidationIssue(message=message, row_idx=row.idx))
)
return result

View File

@@ -2,12 +2,7 @@
# For license information, please see license.txt
import frappe
from frappe.tests.utils import whitelist_for_tests
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
FormulaValidator,
get_valid_api_method,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -77,90 +72,3 @@ class FinancialReportTemplateTestCase(ERPNextTestSuite):
{"doctype": "Financial Report Template", "template_name": template_name, "rows": rows_data}
)
return template
def not_whitelisted_method(**kwargs):
return [42.0]
@whitelist_for_tests(methods=["POST"])
def whitelisted_post_only_method(**kwargs):
return [42.0]
@whitelist_for_tests(methods=["GET"])
def whitelisted_get_method(**kwargs):
return [42.0]
class TestCustomAPIValidation(FinancialReportTemplateTestCase):
"""Custom API rows must point to whitelisted methods that permit GET"""
TEST_MODULE = "erpnext.accounts.doctype.financial_report_template.test_financial_report_template"
NOT_WHITELISTED = f"{TEST_MODULE}.not_whitelisted_method"
WHITELISTED_POST_ONLY = f"{TEST_MODULE}.whitelisted_post_only_method"
WHITELISTED_GET = f"{TEST_MODULE}.whitelisted_get_method"
def create_api_template(self, api_path):
template = self.create_test_template_with_rows(
[
{
"reference_code": "API001",
"display_name": "API Row",
"data_source": "Custom API",
"calculation_formula": api_path,
}
]
)
template.report_type = "Profit and Loss Statement"
return template
def test_get_valid_api_method(self):
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.NOT_WHITELISTED)
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.WHITELISTED_POST_ONLY)
self.assertEqual(get_valid_api_method(self.WHITELISTED_GET), frappe.get_attr(self.WHITELISTED_GET))
def test_save_rejects_invalid_api_methods(self):
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
template = self.create_api_template(api_path)
self.assertRaises(frappe.ValidationError, template.insert)
def test_save_allows_get_whitelisted_method(self):
template = self.create_api_template(self.WHITELISTED_GET)
template.insert()
template.delete()
def test_engine_rejects_invalid_api_methods(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
ReportContext,
RowProcessor,
)
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
template = self.create_api_template(api_path)
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
processor = RowProcessor(context)
self.assertRaises(frappe.PermissionError, processor._process_api_row, template.rows[0])
def test_engine_calls_valid_api_method(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
ReportContext,
RowProcessor,
)
template = self.create_api_template(self.WHITELISTED_GET)
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
processor = RowProcessor(context)
row_data = processor._process_api_row(template.rows[0])
self.assertEqual(row_data.values, [42.0])
def test_validation_keeps_message_log_clean(self):
validator = FormulaValidator(frappe._dict(rows=[]))
message_count = len(frappe.local.message_log)
# last path raises AppNotInstalledError, which also logs a message via frappe.throw
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY, "missing_app.api.method"):
row = frappe._dict(data_source="Custom API", calculation_formula=api_path, idx=1)
result = validator.validate(row)
self.assertFalse(result.is_valid)
self.assertEqual(len(frappe.local.message_log), message_count)

View File

@@ -82,7 +82,7 @@
"icon": "fa fa-calendar",
"idx": 1,
"links": [],
"modified": "2026-08-21 23:11:42.509102",
"modified": "2024-05-27 17:29:55.560840",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Fiscal Year",
@@ -131,15 +131,10 @@
{
"read": 1,
"role": "Auditor"
},
{
"role": "Sales Master Manager",
"select": 1
}
],
"row_format": "Dynamic",
"show_name_in_global_search": 1,
"sort_field": "name",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -134,7 +134,7 @@ class GLEntry(Document):
mandatory = ["account", "voucher_type", "voucher_no", "company"]
for k in mandatory:
if not self.get(k):
frappe.throw(_("{0} is required").format(self.meta.get_translated_label(k)))
frappe.throw(_("{0} is required").format(_(self.meta.get_label(k))))
if not self.is_cancelled and not (self.party_type and self.party):
account_type = frappe.get_cached_value("Account", self.account, "account_type")

View File

@@ -57,7 +57,7 @@
}
],
"links": [],
"modified": "2026-08-21 23:11:43.571355",
"modified": "2024-03-27 13:09:55.573483",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Item Tax Template",
@@ -95,61 +95,12 @@
"report": 1,
"role": "Accounts User",
"share": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Item Manager",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase User",
"select": 1
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
},
{
"role": "Stock User",
"select": 1
}
],
"row_format": "Dynamic",
"show_name_in_global_search": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1
}
}

View File

@@ -677,6 +677,6 @@ Object.assign(erpnext.journal_entry, {
} else {
erpnext.journal_entry.set_debit_credit_in_company_currency(frm, cdt, cdn);
}
frm.get_field("accounts").grid.refresh_row(cdn);
frm.refresh_field("accounts");
},
});

View File

@@ -27,7 +27,6 @@ def get_payment_entry_against_order(
) -> dict | Document:
"""Build an advance-payment Journal Entry against an unbilled Sales/Purchase Order."""
ref_doc = frappe.get_doc(dt, dn)
ref_doc.check_permission()
if flt(ref_doc.per_billed, 2) > 0:
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
@@ -79,8 +78,6 @@ def get_payment_entry_against_invoice(
) -> dict | Document:
"""Build a payment Journal Entry against a Sales/Purchase Invoice's outstanding amount."""
ref_doc = frappe.get_doc(dt, dn)
ref_doc.check_permission()
if dt == "Sales Invoice":
party_type = "Customer"
party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to
@@ -121,8 +118,6 @@ def get_payment_entry(ref_doc, args: dict) -> dict | Document:
Returns the Journal Entry document when `args["journal_entry"]` is truthy, otherwise its
dict (for client calls).
"""
frappe.has_permission("Journal Entry", ptype="create", throw=True)
je = frappe.new_doc("Journal Entry")
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})

View File

@@ -318,8 +318,9 @@ class TestJournalEntry(ERPNextTestSuite):
)
# the guard must not disclose the reversal to a user who cannot read the entry
with self.set_user("Guest"):
self.assertRaises(frappe.PermissionError, make_reverse_journal_entry, rjv.name)
frappe.set_user("Guest")
self.addCleanup(frappe.set_user, "Administrator")
self.assertRaises(frappe.PermissionError, make_reverse_journal_entry, rjv.name)
def test_disallow_change_in_account_currency_for_a_party(self):
# create jv in USD

View File

@@ -56,9 +56,7 @@ class LedgerMerge(Document):
@frappe.whitelist()
def form_start_merge(docname: str):
lm_doc = frappe.get_doc("Ledger Merge", docname)
lm_doc.check_permission("write")
return lm_doc.start_merge()
return frappe.get_doc("Ledger Merge", docname).start_merge()
def start_merge(docname):

View File

@@ -147,14 +147,14 @@
"fieldtype": "Column Break"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
}
],
"links": [],
"modified": "2026-08-21 23:11:44.144864",
"modified": "2024-03-27 13:10:03.361383",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Loyalty Program",
@@ -171,20 +171,11 @@
"role": "System Manager",
"share": 1,
"write": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -97,8 +97,6 @@ def get_loyalty_program_details_with_points(
include_expired_entry: bool = False,
current_transaction_amount: int | float = 0,
):
frappe.has_permission("Customer", doc=customer, throw=True)
lp_details = get_loyalty_program_details(customer, loyalty_program, company=company, silent=silent)
loyalty_program = frappe.get_doc("Loyalty Program", loyalty_program)
loyalty_details = get_loyalty_details(

View File

@@ -1,6 +1,6 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from unittest.mock import patch
import unittest
import frappe
from frappe.query_builder.functions import Sum
@@ -196,7 +196,7 @@ class TestLoyaltyProgram(ERPNextTestSuite):
for d in company_wise_info:
self.assertTrue(d.get("loyalty_points"))
@patch("erpnext.accounts.doctype.loyalty_program.loyalty_program.get_loyalty_details")
@unittest.mock.patch("erpnext.accounts.doctype.loyalty_program.loyalty_program.get_loyalty_details")
def test_tier_selection(self, mock_get_loyalty_details):
# Create a new loyalty program with multiple tiers
loyalty_program = frappe.get_doc(

View File

@@ -48,7 +48,7 @@
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-08-21 23:11:44.763131",
"modified": "2026-04-14 18:16:47.795986",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Mode of Payment",
@@ -76,30 +76,6 @@
{
"role": "HR Manager",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase User",
"select": 1
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -46,7 +46,7 @@
"icon": "fa fa-bar-chart",
"idx": 1,
"links": [],
"modified": "2026-08-21 23:11:44.908490",
"modified": "2024-03-27 13:10:05.873547",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Monthly Distribution",
@@ -69,14 +69,9 @@
"read": 1,
"report": 1,
"role": "Accounts Manager"
},
{
"role": "Sales Master Manager",
"select": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -297,9 +297,6 @@ def start_import(invoices):
invoice_number = d.invoice_number
doc = frappe.get_doc(d)
doc.flags.ignore_mandatory = True
# the outstanding amount is entered inclusive of tax, so taxes must not
# be added on top of it
doc.flags.dont_auto_add_taxes = True
doc.insert(set_name=invoice_number)
doc.submit()
if not frappe.in_test:

View File

@@ -4,11 +4,9 @@
import frappe
from frappe.utils import add_days, today
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
get_temporary_opening_account,
)
from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule
from erpnext.projects.doctype.project.test_project import make_project
from erpnext.tests.utils import ERPNextTestSuite
@@ -128,55 +126,6 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
for invoice in invoices:
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{"add_taxes_from_taxes_and_charges_template": 1, "add_taxes_from_item_tax_template": 0},
)
def test_opening_invoice_creation_without_taxes(self):
company = "_Test Opening Invoice Company"
template = frappe.get_doc(
{
"doctype": "Sales Taxes and Charges Template",
"company": company,
"title": "_Test Opening Invoice Tax",
"taxes": [
{
"charge_type": "On Net Total",
"account_head": create_account(
account_name="_Test Opening Tax Account",
parent_account="Duties and Taxes - _TOIC",
account_type="Tax",
company=company,
),
"description": "Test taxes",
"rate": 9,
}
],
}
).insert()
# makes the template the default for the party, as it would be on a live site
make_tax_rule(tax_type="Sales", company=company, sales_tax_template=template.name, save=1)
tool = self.make_invoices(company=company, return_doc=True)
invoices = tool.make_invoices()
self.assertEqual(len(invoices), 2)
# outstanding amount is entered inclusive of tax, so taxes must not be added on top of it
for invoice in invoices:
si = frappe.get_doc("Sales Invoice", invoice)
self.assertFalse(si.taxes)
self.assertEqual(si.grand_total, 200)
self.assertEqual(si.outstanding_amount, 200)
# the same invoice created outside the tool keeps the default taxes,
# since adding them there is the user's decision
si = frappe.get_doc(tool.get_invoices()[0])
si.flags.ignore_mandatory = True
si.insert()
self.assertTrue(si.taxes)
self.assertEqual(si.grand_total, 218)
def test_opening_entry_project_linking(self):
doc = self.make_invoices(
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True

View File

@@ -1279,14 +1279,8 @@ frappe.ui.form.on("Payment Entry", {
await frappe.after_ajax();
const base_paid_amount = frm.doc.base_paid_amount || 0;
const base_received_amount = frm.doc.base_received_amount || 0;
let other_deductions = 0;
if (frm.doc.payment_type === "Internal Transfer") {
other_deductions = (frm.doc.deductions || [])
.filter((row) => !row.is_exchange_gain_loss)
.reduce((sum, row) => sum + flt(row.amount), 0);
}
const exchange_gain_loss = flt(
base_paid_amount - base_received_amount - other_deductions,
base_paid_amount - base_received_amount,
get_deduction_amount_precision()
);
@@ -1863,19 +1857,11 @@ frappe.ui.form.on("Payment Entry Deduction", {
},
amount: function (frm) {
if (frm.doc.payment_type === "Internal Transfer") {
frm.events.set_exchange_gain_loss_deduction(frm);
} else {
frm.events.set_unallocated_amount(frm);
}
frm.events.set_unallocated_amount(frm);
},
deductions_remove: function (frm) {
if (frm.doc.payment_type === "Internal Transfer") {
frm.events.set_exchange_gain_loss_deduction(frm);
} else {
frm.events.set_unallocated_amount(frm);
}
frm.events.set_unallocated_amount(frm);
},
});

View File

@@ -278,8 +278,7 @@ class PaymentEntry(AccountsController):
if not liability_account:
throw(
_("Please set default {0} in Company {1}").format(
frappe.bold(frappe.get_meta("Company").get_translated_label(fieldname)),
frappe.bold(self.company),
frappe.bold(frappe.get_meta("Company").get_label(fieldname)), frappe.bold(self.company)
)
)
@@ -662,7 +661,7 @@ class PaymentEntry(AccountsController):
def validate_mandatory(self):
for field in ("paid_amount", "received_amount", "source_exchange_rate", "target_exchange_rate"):
if not self.get(field):
frappe.throw(_("{0} is mandatory").format(self.meta.get_translated_label(field)))
frappe.throw(_("{0} is mandatory").format(_(self.meta.get_label(field))))
def validate_reference_documents(self):
valid_reference_doctypes = self.get_valid_reference_doctypes()
@@ -1118,14 +1117,8 @@ class PaymentEntry(AccountsController):
)
def set_exchange_gain_loss(self):
other_deductions = 0
if self.payment_type == "Internal Transfer":
other_deductions = sum(
flt(row.amount) for row in self.get("deductions") if not row.is_exchange_gain_loss
)
exchange_gain_loss = flt(
self.base_paid_amount - self.base_received_amount - other_deductions,
self.base_paid_amount - self.base_received_amount,
self.precision("amount", "deductions"),
)
@@ -1158,7 +1151,7 @@ class PaymentEntry(AccountsController):
if value:
continue
label = frappe.get_meta("Company").get_translated_label(fieldname)
label = _(frappe.get_meta("Company").get_label(fieldname))
return frappe.msgprint(
_("Please set {0} in Company {1} to account for Exchange Gain / Loss").format(
label, get_link_to_form("Company", self.company)
@@ -2626,11 +2619,7 @@ def get_payment_entry(
reference_date: str | date | None = None,
created_from_payment_request: bool | None = None,
):
frappe.has_permission("Payment Entry", ptype="create", throw=True)
doc = frappe.get_doc(dt, dn)
doc.check_permission()
over_billing_allowance = frappe.get_single_value("Accounts Settings", "over_billing_allowance")
if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= (100.0 + over_billing_allowance):
frappe.throw(_("Can only make payment against unbilled {0}").format(_(dt)))

View File

@@ -789,6 +789,7 @@ class TestPaymentEntry(ERPNextTestSuite):
company="_Test Company",
)
frappe.db.set_value("Company", "_Test Company", "bank_charges_account", bank_charges_account)
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "bank_charges_account", "")
pe = frappe.new_doc("Payment Entry")
pe.payment_type = "Internal Transfer"
@@ -833,6 +834,7 @@ class TestPaymentEntry(ERPNextTestSuite):
company="_Test Company",
)
frappe.db.set_value("Company", "_Test Company", "bank_charges_account", bank_charges_account)
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "bank_charges_account", "")
pe = frappe.new_doc("Payment Entry")
pe.payment_type = "Internal Transfer"
@@ -868,64 +870,6 @@ class TestPaymentEntry(ERPNextTestSuite):
self.validate_gl_entries(pe.name, expected_gle)
def test_cross_currency_transfer_splits_bank_charge_and_exchange_gain_loss(self):
exchange_gain_loss_account = frappe.db.get_value(
"Company", "_Test Company", "exchange_gain_loss_account"
)
bank_charges_account = create_account(
parent_account="Indirect Expenses - _TC",
account_name="_Test Bank Charges",
company="_Test Company",
)
pe = frappe.new_doc("Payment Entry")
pe.payment_type = "Internal Transfer"
pe.company = "_Test Company"
pe.paid_from = "_Test Bank USD - _TC"
pe.paid_to = "_Test Bank - _TC"
pe.paid_amount = 100
pe.source_exchange_rate = 50
pe.received_amount = 4500
pe.reference_no = "6"
pe.reference_date = nowdate()
pe.append(
"deductions",
{
"account": bank_charges_account,
"cost_center": "_Test Cost Center - _TC",
"amount": 100,
},
)
pe.setup_party_account_field()
pe.set_missing_values()
pe.set_exchange_rate()
pe.set_amounts()
deductions = {d.account: d for d in pe.deductions}
self.assertEqual(deductions[bank_charges_account].amount, 100)
self.assertEqual(deductions[exchange_gain_loss_account].amount, 400)
self.assertTrue(deductions[exchange_gain_loss_account].is_exchange_gain_loss)
self.assertEqual(pe.difference_amount, 0)
for d in pe.deductions:
d.cost_center = "_Test Cost Center - _TC"
pe.insert()
pe.submit()
expected_gle = dict(
(d[0], d)
for d in [
["_Test Bank USD - _TC", 0, 5000, None],
["_Test Bank - _TC", 4500, 0, None],
[exchange_gain_loss_account, 400.0, 0, None],
[bank_charges_account, 100.0, 0, None],
]
)
self.validate_gl_entries(pe.name, expected_gle)
def test_payment_against_negative_sales_invoice(self):
si1 = create_sales_invoice()
@@ -1107,6 +1051,8 @@ class TestPaymentEntry(ERPNextTestSuite):
)
frappe.db.set_value("Company", "_Test Company", "exchange_gain_account", gain_account)
frappe.db.set_value("Company", "_Test Company", "exchange_loss_account", loss_account)
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "exchange_gain_account", "")
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "exchange_loss_account", "")
si_gain = create_sales_invoice(
customer="_Test Customer USD",

View File

@@ -92,7 +92,6 @@ def get_supplier_query(doctype: str, txt: str, searchfield: str, start: int, pag
@frappe.whitelist()
def make_payment_records(name: str, supplier: str, mode_of_payment: str | None = None):
doc = frappe.get_doc("Payment Order", name)
doc.check_permission()
make_journal_entry(doc, supplier, mode_of_payment)

View File

@@ -647,7 +647,7 @@ class PaymentReconciliation(Document):
def check_mandatory_to_fetch(self):
for fieldname in ["company", "party_type", "party", "receivable_payable_account"]:
if not self.get(fieldname):
frappe.throw(_("Please select {0} first").format(self.meta.get_translated_label(fieldname)))
frappe.throw(_("Please select {0} first").format(_(self.meta.get_label(fieldname))))
def validate_entries(self):
if not self.get("invoices"):

View File

@@ -201,6 +201,8 @@ class TestPaymentReconciliation(ERPNextTestSuite):
)
frappe.db.set_value("Company", self.company, "exchange_gain_account", gain_account)
frappe.db.set_value("Company", self.company, "exchange_loss_account", loss_account)
self.addCleanup(frappe.db.set_value, "Company", self.company, "exchange_gain_account", "")
self.addCleanup(frappe.db.set_value, "Company", self.company, "exchange_loss_account", "")
return gain_account, loss_account
def create_foreign_currency_sales_invoice(self, conversion_rate):
@@ -1329,6 +1331,15 @@ class TestPaymentReconciliation(ERPNextTestSuite):
test_user = "test@example.com"
permitted_ccs = ["_Test Cost Center - _TC", "_Test Cost Center 2 - _TC"]
restricted_cc = "_Test Write Off Cost Center - _TC"
existing_apply_strict_user_permissions = cint(
frappe.db.get_single_value("System Settings", "apply_strict_user_permissions")
)
self.addCleanup(
frappe.db.set_single_value,
"System Settings",
"apply_strict_user_permissions",
existing_apply_strict_user_permissions,
)
transaction_date = nowdate()
rate = 100

View File

@@ -92,7 +92,6 @@ frappe.ui.form.on("Payment Request", "is_a_subscription", function (frm) {
freeze: true,
callback: function (data) {
if (!data.exc) {
frm.clear_table("subscription_plans");
$.each(data.message || [], function (i, v) {
var d = frappe.model.add_child(
frm.doc,

View File

@@ -875,7 +875,6 @@ def make_payment_request(**args):
party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
party_account_currency = get_account_currency(party_account)
subscription_plans = get_subscription_details(ref_doc.doctype, ref_doc.name)
pr.update(
{
"payment_gateway_account": gateway_account.get("name"),
@@ -907,24 +906,12 @@ def make_payment_request(**args):
or gateway_account.get("payment_channel", "Email") != "Email"
),
"phone_number": args.get("phone_number") if args.get("phone_number") else None,
"is_a_subscription": 1 if subscription_plans else 0,
}
)
if selected_payment_schedules:
apply_payment_references(pr, payment_reference)
if subscription_plans:
pr.set(
"subscription_plans",
[
{
"plan": row.plan,
"qty": row.qty,
}
for row in subscription_plans
],
)
# Dimensions
pr.update(
{
@@ -1238,25 +1225,20 @@ def get_dummy_message(doc):
@frappe.whitelist()
def get_subscription_details(reference_doctype: str, reference_name: str) -> list[dict]:
frappe.has_permission(reference_doctype, "read", reference_name, throw=True)
if not frappe.get_meta(reference_doctype).has_field("subscription"):
return []
subscription = frappe.db.get_value(reference_doctype, reference_name, "subscription")
if not subscription:
return []
return frappe.get_all(
"Subscription Plan Detail",
filters={"parent": subscription, "parenttype": "Subscription", "parentfield": "plans"},
fields=[
"plan",
"qty",
],
)
def get_subscription_details(reference_doctype: str, reference_name: str):
if reference_doctype == "Sales Invoice":
subscriptions = frappe.get_all(
"Subscription Invoice",
filters={"invoice": reference_name},
fields=["parent as sub_name"],
order_by="", # match the original query (no ORDER BY); avoid get_all's default sort
)
subscription_plans = []
for subscription in subscriptions:
plans = frappe.get_doc("Subscription", subscription.sub_name).plans
for plan in plans:
subscription_plans.append(plan)
return subscription_plans
@frappe.whitelist()
@@ -1359,7 +1341,6 @@ def get_irequests_of_payment_request(doc: str | None = None) -> list:
@frappe.whitelist()
def get_available_payment_schedules(reference_doctype: str, reference_name: str):
ref_doc = frappe.get_doc(reference_doctype, reference_name)
ref_doc.check_permission()
if not hasattr(ref_doc, "payment_schedule") or not ref_doc.payment_schedule:
return []

View File

@@ -11,27 +11,15 @@ from frappe.utils import add_days, nowdate
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_terms_template
from erpnext.accounts.doctype.payment_request.payment_request import (
get_subscription_details,
make_payment_request,
)
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.doctype.subscription.test_subscription import (
create_plan,
create_subscription,
make_plans,
)
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.setup.utils import get_exchange_rate
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
PAYMENT_URL = "https://example.com/payment"
SEND_EMAIL_MOCK = MagicMock(return_value=None)
GET_PAYMENT_URL_MOCK = MagicMock(return_value=PAYMENT_URL)
GET_PAYMENT_GATEWAY_CONTROLLER_MOCK = MagicMock()
payment_gateways = [
{"doctype": "Payment Gateway", "gateway": "_Test Gateway"},
@@ -74,18 +62,6 @@ payment_method = [
]
@patch(
"erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.send_email",
new=SEND_EMAIL_MOCK,
)
@patch(
"erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.get_payment_url",
new=GET_PAYMENT_URL_MOCK,
)
@patch(
"erpnext.accounts.doctype.payment_request.payment_request._get_payment_gateway_controller",
new=GET_PAYMENT_GATEWAY_CONTROLLER_MOCK,
)
class TestPaymentRequest(ERPNextTestSuite):
def setUp(self):
for payment_gateway in payment_gateways:
@@ -104,11 +80,24 @@ class TestPaymentRequest(ERPNextTestSuite):
):
frappe.get_doc(method).insert(ignore_permissions=True)
for mock in (SEND_EMAIL_MOCK, GET_PAYMENT_URL_MOCK, GET_PAYMENT_GATEWAY_CONTROLLER_MOCK):
mock.reset_mock()
self.send_email = SEND_EMAIL_MOCK
self.get_payment_url = GET_PAYMENT_URL_MOCK
self._get_payment_gateway_controller = GET_PAYMENT_GATEWAY_CONTROLLER_MOCK
send_email = patch(
"erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.send_email",
return_value=None,
)
self.send_email = send_email.start()
self.addCleanup(send_email.stop)
get_payment_url = patch(
# this also shadows one (1) call to _get_payment_gateway_controller
"erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.get_payment_url",
return_value=PAYMENT_URL,
)
self.get_payment_url = get_payment_url.start()
self.addCleanup(get_payment_url.stop)
_get_payment_gateway_controller = patch(
"erpnext.accounts.doctype.payment_request.payment_request._get_payment_gateway_controller",
)
self._get_payment_gateway_controller = _get_payment_gateway_controller.start()
self.addCleanup(_get_payment_gateway_controller.stop)
def test_payment_request_linkings(self):
so_inr = make_sales_order(currency="INR", do_not_save=True)
@@ -2020,140 +2009,3 @@ class TestPaymentRequestV2Gateway(ERPNextTestSuite):
call_kwargs = mock_log_error.call_args
self.assertIn("Payment Initialization Failed", str(call_kwargs))
self.assertIn("_Test Gateway", str(call_kwargs))
def test_payment_request_with_subscription(self):
make_plans()
subscription_plan = frappe.get_doc("Subscription Plan", "_Test Plan Name")
subscription_plan.payment_gateway = "_Test Gateway - INR - _TC"
subscription_plan.save()
subscription = create_subscription(
plans=[{"plan": "_Test Plan Name", "qty": 1}],
start_date=nowdate(),
generate_invoice_at="Prepaid (bill at period start)",
submit_invoice=1,
)
invoice_name = frappe.get_value(
"Sales Invoice",
{
"subscription": subscription.name,
"docstatus": 1,
"is_return": 0,
},
"name",
order_by="from_date asc",
)
payment_request = make_payment_request(
dt="Sales Invoice",
dn=invoice_name,
recipient_id="test@example.com",
)
self.assertEqual(payment_request.is_a_subscription, 1)
self.assertEqual(len(payment_request.subscription_plans), 1)
subscription_plan = payment_request.subscription_plans[0]
self.assertEqual(subscription_plan.plan, "_Test Plan Name")
self.assertEqual(subscription_plan.qty, 1)
self.assertEqual(payment_request.reference_doctype, "Sales Invoice")
self.assertEqual(payment_request.reference_name, invoice_name)
def test_payment_request_without_subscription(self):
si = create_sales_invoice()
payment_request = make_payment_request(
dt="Sales Invoice",
dn=si.name,
recipient_id="test@example.com",
)
self.assertEqual(payment_request.is_a_subscription, 0)
self.assertEqual(len(payment_request.subscription_plans), 0)
self.assertEqual(payment_request.reference_doctype, "Sales Invoice")
self.assertEqual(payment_request.reference_name, si.name)
def test_payment_request_with_subscription_for_purchase_invoice(self):
make_plans()
subscription_plan = frappe.get_doc("Subscription Plan", "_Test Plan Name")
subscription_plan.payment_gateway = "_Test Gateway - INR - _TC"
subscription_plan.save()
subscription = create_subscription(
party_type="Supplier",
party="_Test Supplier",
plans=[{"plan": "_Test Plan Name", "qty": 1}],
start_date=nowdate(),
generate_invoice_at="Prepaid (bill at period start)",
submit_invoice=1,
)
invoice_name = frappe.get_value(
"Purchase Invoice",
{
"subscription": subscription.name,
"docstatus": 1,
"is_return": 0,
},
"name",
order_by="from_date asc",
)
payment_request = make_payment_request(
dt="Purchase Invoice",
dn=invoice_name,
party_type="Supplier",
party="_Test Supplier",
recipient_id="test@example.com",
)
self.assertEqual(payment_request.is_a_subscription, 1)
self.assertEqual(len(payment_request.subscription_plans), 1)
subscription_plan = payment_request.subscription_plans[0]
self.assertEqual(subscription_plan.plan, "_Test Plan Name")
self.assertEqual(subscription_plan.qty, 1)
self.assertEqual(payment_request.reference_doctype, "Purchase Invoice")
self.assertEqual(payment_request.reference_name, invoice_name)
def test_payment_request_without_subscription_for_purchase_invoice(self):
pi = make_purchase_invoice()
payment_request = make_payment_request(
dt="Purchase Invoice",
dn=pi.name,
party_type="Supplier",
party=pi.supplier,
recipient_id="test@example.com",
)
self.assertEqual(payment_request.is_a_subscription, 0)
self.assertEqual(len(payment_request.subscription_plans), 0)
self.assertEqual(payment_request.reference_doctype, "Purchase Invoice")
self.assertEqual(payment_request.reference_name, pi.name)
def test_get_subscription_details_returns_empty_for_doctype_without_subscription_field(self):
so = make_sales_order()
self.assertEqual(get_subscription_details("Sales Order", so.name), [])
def test_get_subscription_details_requires_read_permission_on_reference(self):
si = create_sales_invoice()
restricted_user = "no-roles@example.com"
if not frappe.db.exists("User", restricted_user):
user = frappe.new_doc("User")
user.email = restricted_user
user.first_name = "No Roles"
user.send_welcome_email = 0
user.insert()
accounts_user = "accounts-user@example.com"
if not frappe.db.exists("User", accounts_user):
user = frappe.new_doc("User")
user.email = accounts_user
user.first_name = "Accounts"
user.send_welcome_email = 0
user.add_roles("Accounts User")
with self.set_user(restricted_user):
self.assertRaises(frappe.PermissionError, get_subscription_details, "Sales Invoice", si.name)
with self.set_user(accounts_user):
self.assertEqual(get_subscription_details("Sales Invoice", si.name), [])

View File

@@ -116,7 +116,7 @@
}
],
"links": [],
"modified": "2026-08-21 23:11:45.693762",
"modified": "2024-03-27 13:10:11.511137",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Term",
@@ -157,36 +157,11 @@
"role": "Accounts User",
"share": 1,
"write": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase User",
"select": 1
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -263,15 +263,12 @@ def get_cashiers(doctype: str, txt: str, searchfield: str, start: int, page_len:
@frappe.whitelist()
def get_invoices(start: str | datetime, end: str | datetime, pos_profile: str, user: str):
invoice_doctype = frappe.db.get_single_value("POS Settings", "invoice_type")
frappe.has_permission("POS Profile", doc=pos_profile, throw=True)
frappe.has_permission("Sales Invoice", throw=True)
sales_inv_query = build_invoice_query("Sales Invoice", user, pos_profile, start, end)
query = sales_inv_query
if invoice_doctype == "POS Invoice":
frappe.has_permission("POS Invoice", throw=True)
pos_inv_query = build_invoice_query("POS Invoice", user, pos_profile, start, end)
query = query + pos_inv_query

View File

@@ -21,12 +21,13 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestPOSClosingEntry(ERPNextTestSuite):
def setUp(self):
self.test_user, self.pos_profile = init_user_and_profile()
init_user_and_profile()
make_stock_entry(target="_Test Warehouse - _TC", qty=2, basic_rate=100)
frappe.db.set_single_value("POS Settings", "invoice_type", "POS Invoice")
def test_pos_closing_entry(self):
opening_entry = create_opening_entry(self.pos_profile, self.test_user.name)
test_user, pos_profile = init_user_and_profile()
opening_entry = create_opening_entry(pos_profile, test_user.name)
pos_inv1 = create_pos_invoice(rate=3500, do_not_submit=1)
pos_inv1.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3500})
@@ -58,7 +59,8 @@ class TestPOSClosingEntry(ERPNextTestSuite):
"""
Test if POS Closing Entry is created without item code
"""
opening_entry = create_opening_entry(self.pos_profile, self.test_user.name)
test_user, pos_profile = init_user_and_profile()
opening_entry = create_opening_entry(pos_profile, test_user.name)
pos_inv = create_pos_invoice(rate=3500, do_not_submit=1, item_name="Test Item", without_item_code=1)
pos_inv.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3500})
@@ -77,9 +79,10 @@ class TestPOSClosingEntry(ERPNextTestSuite):
"""
from erpnext.accounts.doctype.pos_invoice.pos_invoice import make_sales_return
opening_entry = create_opening_entry(self.pos_profile, self.test_user.name)
test_user, pos_profile = init_user_and_profile()
opening_entry = create_opening_entry(pos_profile, test_user.name)
test_item_qty = get_test_item_qty(self.pos_profile)
test_item_qty = get_test_item_qty(pos_profile)
pos_inv1 = create_pos_invoice(rate=3500, do_not_submit=1)
pos_inv1.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3500})
@@ -101,11 +104,13 @@ class TestPOSClosingEntry(ERPNextTestSuite):
pcv_doc.flags.in_test = True
pcv_doc.submit()
test_item_qty_after_sales = get_test_item_qty(self.pos_profile)
opening_entry = create_opening_entry(pos_profile, test_user.name)
test_item_qty_after_sales = get_test_item_qty(pos_profile)
self.assertEqual(test_item_qty_after_sales, test_item_qty - 1)
def test_cancelling_of_pos_closing_entry(self):
opening_entry = create_opening_entry(self.pos_profile, self.test_user.name)
test_user, pos_profile = init_user_and_profile()
opening_entry = create_opening_entry(pos_profile, test_user.name)
pos_inv1 = create_pos_invoice(rate=3500, do_not_submit=1)
pos_inv1.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3500})
@@ -164,7 +169,9 @@ class TestPOSClosingEntry(ERPNextTestSuite):
pos_profile.insert()
self.assertTrue(frappe.db.exists("POS Profile", pos_profile.name))
opening_entry = create_opening_entry(pos_profile, self.test_user.name)
test_user = init_user_and_profile(do_not_create_pos_profile=1)
opening_entry = create_opening_entry(pos_profile, test_user.name)
pos_inv1 = create_pos_invoice(rate=350, do_not_submit=1, pos_profile=pos_profile.name)
pos_inv1.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3500})
pos_inv1.save()
@@ -188,6 +195,9 @@ class TestPOSClosingEntry(ERPNextTestSuite):
def test_merging_into_sales_invoice_for_batched_item(self):
frappe.flags.print_message = False
from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import (
init_user_and_profile,
)
from erpnext.stock.doctype.batch.batch import get_batch_qty
item_doc = make_item(
@@ -210,7 +220,8 @@ class TestPOSClosingEntry(ERPNextTestSuite):
)
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
opening_entry = create_opening_entry(self.pos_profile, self.test_user.name)
test_user, pos_profile = init_user_and_profile()
opening_entry = create_opening_entry(pos_profile, test_user.name)
pos_inv = create_pos_invoice(
item_code=item_code,
@@ -280,17 +291,18 @@ class TestPOSClosingEntry(ERPNextTestSuite):
@ERPNextTestSuite.change_settings("POS Settings", {"invoice_type": "Sales Invoice"})
def test_closing_entries_with_sales_invoice(self):
opening_entry = create_opening_entry(self.pos_profile, self.test_user.name)
test_user, pos_profile = init_user_and_profile()
opening_entry = create_opening_entry(pos_profile, test_user.name)
pos_si = create_sales_invoice(
qty=10, is_created_using_pos=1, pos_profile=self.pos_profile.name, do_not_save=1
qty=10, is_created_using_pos=1, pos_profile=pos_profile.name, do_not_save=1
)
pos_si.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000})
pos_si.save()
pos_si.submit()
pos_si2 = create_sales_invoice(
qty=5, is_created_using_pos=1, pos_profile=self.pos_profile.name, do_not_save=11
qty=5, is_created_using_pos=1, pos_profile=pos_profile.name, do_not_save=11
)
pos_si2.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000})
pos_si2.save()
@@ -320,10 +332,12 @@ class TestPOSClosingEntry(ERPNextTestSuite):
"""
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
with self.change_settings("POS Settings", {"invoice_type": "Sales Invoice"}):
opening_entry1 = create_opening_entry(self.pos_profile, self.test_user.name)
test_user, pos_profile = init_user_and_profile()
pos_si1, pos_si2 = create_multiple_sales_invoices(self.pos_profile)
with self.change_settings("POS Settings", {"invoice_type": "Sales Invoice"}):
opening_entry1 = create_opening_entry(pos_profile, test_user.name)
pos_si1, pos_si2 = create_multiple_sales_invoices(pos_profile)
pos_inv = create_pos_invoice(rate=100, do_not_save=1)
pos_inv.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 100})
@@ -343,13 +357,13 @@ class TestPOSClosingEntry(ERPNextTestSuite):
self.assertEqual(pos_si2.pos_closing_entry, pcv_doc1.name)
with self.change_settings("POS Settings", {"invoice_type": "POS Invoice"}):
opening_entry2 = create_opening_entry(self.pos_profile, self.test_user.name)
opening_entry2 = create_opening_entry(pos_profile, test_user.name)
pos_inv1, pos_inv2 = create_multiple_pos_invoices(self.pos_profile)
pos_inv1, pos_inv2 = create_multiple_pos_invoices(pos_profile)
# Trying to create Sales Invoice when invoice_type is set to POS Invoice.
pos_si3 = create_sales_invoice(
qty=1, is_created_using_pos=1, pos_profile=self.pos_profile.name, do_not_save=1
qty=1, is_created_using_pos=1, pos_profile=pos_profile.name, do_not_save=1
)
pos_si3.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 100})
self.assertRaises(frappe.ValidationError, pos_si3.save)
@@ -380,14 +394,16 @@ class TestPOSClosingEntry(ERPNextTestSuite):
"""
from erpnext.accounts.doctype.pos_invoice.pos_invoice import make_sales_return
with self.change_settings("POS Settings", {"invoice_type": "POS Invoice"}):
opening_entry1 = create_opening_entry(self.pos_profile, self.test_user.name)
test_user, pos_profile = init_user_and_profile()
pos_inv1, pos_inv2 = create_multiple_pos_invoices(self.pos_profile)
with self.change_settings("POS Settings", {"invoice_type": "POS Invoice"}):
opening_entry1 = create_opening_entry(pos_profile, test_user.name)
pos_inv1, pos_inv2 = create_multiple_pos_invoices(pos_profile)
# Trying to create Sales Invoice when invoice_type is set to POS Invoice.
pos_sinv = create_sales_invoice(
qty=1, is_created_using_pos=1, pos_profile=self.pos_profile.name, do_not_save=1
qty=1, is_created_using_pos=1, pos_profile=pos_profile.name, do_not_save=1
)
pos_sinv.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 100})
self.assertRaises(frappe.ValidationError, pos_sinv.save)
@@ -405,9 +421,9 @@ class TestPOSClosingEntry(ERPNextTestSuite):
self.assertEqual(pcv_doc1.grand_total, 300)
with self.change_settings("POS Settings", {"invoice_type": "Sales Invoice"}):
opening_entry2 = create_opening_entry(self.pos_profile, self.test_user.name)
opening_entry2 = create_opening_entry(pos_profile, test_user.name)
pos_si1, pos_si2 = create_multiple_sales_invoices(self.pos_profile)
pos_si1, pos_si2 = create_multiple_sales_invoices(pos_profile)
pos_inv3 = create_pos_invoice(rate=100, do_not_save=1)
pos_inv3.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 100})

View File

@@ -1643,7 +1643,7 @@
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2026-08-21 23:11:45.029925",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",
@@ -1686,14 +1686,6 @@
"permlevel": 1,
"read": 1,
"role": "All"
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -4,7 +4,6 @@ import copy
import frappe
from frappe import _
from frappe.utils import add_to_date
from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import (
set_default_account_for_mode_of_payment,
@@ -54,14 +53,14 @@ class TestPOSInvoice(POSInvoiceTestMixin):
w2 = frappe.get_doc(w.doctype, w.name)
import time
time.sleep(1)
w.save()
frappe.db.set_value(
w.doctype,
w.name,
"modified",
add_to_date(w.modified, seconds=1),
update_modified=False,
)
import time
time.sleep(1)
self.assertRaises(frappe.TimestampMismatchError, w2.save)
def test_change_naming_series(self):
@@ -903,6 +902,9 @@ class TestPOSInvoice(POSInvoiceTestMixin):
self.assertEqual(pos_inv.items[0].rate, 300)
def test_delivered_serial_no_case(self):
from erpnext.accounts.doctype.pos_invoice_merge_log.test_pos_invoice_merge_log import (
init_user_and_profile,
)
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
@@ -914,6 +916,8 @@ class TestPOSInvoice(POSInvoiceTestMixin):
self.assertEqual(serial_no, delivered_serial_no)
init_user_and_profile()
pos_inv = create_pos_invoice(
item_code="_Test Serialized Item With Series",
serial_no=[serial_no],
@@ -927,9 +931,13 @@ class TestPOSInvoice(POSInvoiceTestMixin):
def test_bundle_stock_availability_validation(self):
from erpnext.accounts.doctype.pos_invoice.pos_invoice import ProductBundleStockValidationError
from erpnext.accounts.doctype.pos_invoice_merge_log.test_pos_invoice_merge_log import (
init_user_and_profile,
)
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.utils import get_stock_balance
init_user_and_profile()
frappe.set_user("Administrator")
@@ -951,18 +959,9 @@ class TestPOSInvoice(POSInvoiceTestMixin):
is_stock_item=1,
)
# Set initial stock to SubA=5 and SubB=2, even when this test is rerun on the same site.
for item_code, target_qty in ((sub_item_a, 5), (sub_item_b, 2)):
balance = get_stock_balance(item_code, warehouse)
difference = target_qty - balance
if difference:
make_stock_entry(
item_code=item_code,
to_warehouse=warehouse if difference > 0 else None,
from_warehouse=warehouse if difference < 0 else None,
qty=abs(difference),
company=company,
)
# Add initial stock: SubA=5, SubB=2
make_stock_entry(item_code=sub_item_a, target=warehouse, qty=5, company=company)
make_stock_entry(item_code=sub_item_b, target=warehouse, qty=2, company=company)
# Create Product Bundle: Test Bundle (SubA x2 + SubB x1)
bundle_item = "_Test Bundle"
@@ -1011,19 +1010,16 @@ class TestPOSInvoice(POSInvoiceTestMixin):
def create_pos_invoice(**args):
args = frappe._dict(args)
pos_profile_name = args.pos_profile
if not pos_profile_name:
pos_profile_name = frappe.db.exists("POS Profile", "_Test POS Profile")
if not pos_profile_name:
pos_profile = make_pos_profile()
pos_profile.save()
pos_profile_name = pos_profile.name
pos_profile = None
if not args.pos_profile:
pos_profile = make_pos_profile()
pos_profile.save()
pos_inv = frappe.new_doc("POS Invoice")
pos_inv.update(args)
pos_inv.update_stock = 1
pos_inv.is_pos = 1
pos_inv.pos_profile = pos_profile_name
pos_inv.pos_profile = args.pos_profile or pos_profile.name
if args.posting_date:
pos_inv.set_posting_time = 1

View File

@@ -26,10 +26,14 @@ class TestPOSInvoiceMerging(POSInvoiceTestMixin):
from erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry import (
make_closing_entry_from_opening,
)
from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import (
init_user_and_profile,
)
from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import (
consolidate_pos_invoices,
)
test_user, pos_profile = init_user_and_profile()
pos_inv = create_pos_invoice(rate=300, additional_discount_percentage=10, do_not_submit=1)
pos_inv.append("payments", {"mode_of_payment": "Cash", "amount": 270})
pos_inv.save()
@@ -51,10 +55,14 @@ class TestPOSInvoiceMerging(POSInvoiceTestMixin):
from erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry import (
make_closing_entry_from_opening,
)
from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import (
init_user_and_profile,
)
from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import (
consolidate_pos_invoices,
)
test_user, pos_profile = init_user_and_profile()
pos_inv = create_pos_invoice(rate=300, do_not_submit=1)
pos_inv.append("payments", {"mode_of_payment": "Cash", "amount": 300})
pos_inv.append(
@@ -99,6 +107,9 @@ class TestPOSInvoiceMerging(POSInvoiceTestMixin):
from erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry import (
make_closing_entry_from_opening,
)
from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import (
init_user_and_profile,
)
from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import (
consolidate_pos_invoices,
)
@@ -110,6 +121,7 @@ class TestPOSInvoiceMerging(POSInvoiceTestMixin):
make_item(item, {"is_stock_item": 1})
make_purchase_receipt(item_code=item, warehouse="_Test Warehouse - _TC", qty=1, rate=300)
test_user, pos_profile = init_user_and_profile()
pos_inv = create_pos_invoice(item=item, rate=300, do_not_submit=1)
pos_inv.append("payments", {"mode_of_payment": "Cash", "amount": 300})
pos_inv.append(

View File

@@ -582,7 +582,7 @@
"link_fieldname": "pos_profile"
}
],
"modified": "2026-08-21 23:11:45.419667",
"modified": "2026-02-10 14:24:48.597412",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Profile",
@@ -606,10 +606,6 @@
"read": 1,
"report": 1,
"role": "Accounts User"
},
{
"role": "Sales Manager",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -289,11 +289,6 @@ def pos_profile_query(doctype: str, txt: str, searchfield: str, start: int, page
user = frappe.session["user"]
company = filters.get("company") or frappe.defaults.get_user_default("company")
allowed_pos_profiles = frappe.get_list("POS Profile", pluck="name")
if not allowed_pos_profiles:
return {}
pf = frappe.qb.DocType("POS Profile")
pfu = frappe.qb.DocType("POS Profile User")
@@ -303,7 +298,6 @@ def pos_profile_query(doctype: str, txt: str, searchfield: str, start: int, page
.on(pfu.parent == pf.name)
.select(pf.name)
.where((pfu.user == user) & (pf.company == company) & pf.name.like(f"%{txt}%") & (pf.disabled == 0))
.where(pf.name.isin(allowed_pos_profiles))
.limit(page_len)
.offset(start)
.run()
@@ -320,7 +314,6 @@ def pos_profile_query(doctype: str, txt: str, searchfield: str, start: int, page
& (pf.company == company)
& pf.name.like(f"%{txt}%")
& (pf.disabled == 0)
& (pf.name.isin(allowed_pos_profiles))
)
.run()
)

View File

@@ -12,9 +12,8 @@
{
"fieldname": "fieldname",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Fieldname",
"read_only": 1
"hidden": 1,
"label": "Fieldname"
},
{
"fieldname": "field",
@@ -27,7 +26,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-08-31 20:41:12.000000",
"modified": "2025-07-29 18:08:40.323579",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Search Fields",

View File

@@ -1,9 +1,40 @@
// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
function is_valid_invoice_field(df) {
return frappe.model.no_value_type.indexOf(df.fieldtype) === -1 || df.fieldtype === "Button";
}
let search_fields_datatypes = [
"Data",
"Link",
"Dynamic Link",
"Long Text",
"Select",
"Small Text",
"Text",
"Text Editor",
];
let do_not_include_fields = [
"naming_series",
"item_code",
"item_name",
"stock_uom",
"asset_naming_series",
"default_material_request_type",
"valuation_method",
"warranty_period",
"weight_uom",
"batch_number_series",
"serial_no_series",
"purchase_uom",
"customs_tariff_number",
"sales_uom",
"deferred_revenue_account",
"deferred_expense_account",
"quality_inspection_template",
"route",
"slideshow",
"website_image_alt",
"thumbnail",
"web_long_description",
];
frappe.ui.form.on("POS Settings", {
onload: function (frm) {
@@ -11,97 +42,74 @@ frappe.ui.form.on("POS Settings", {
frm.trigger("add_search_options");
},
invoice_type: function (frm) {
frm.trigger("get_invoice_fields");
},
get_invoice_fields: function (frm) {
const invoice_type = frm.doc.invoice_type;
if (!invoice_type) return;
frappe.model.with_doctype(invoice_type, () => {
// the invoice type can change again while the meta loads
if (frm.doc.invoice_type !== invoice_type) return;
const fields = frappe.get_doc("DocType", invoice_type).fields.filter(is_valid_invoice_field);
frappe.model.with_doctype("POS Invoice", () => {
var fields = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
if (
frappe.model.no_value_type.indexOf(d.fieldtype) === -1 ||
["Button"].includes(d.fieldtype)
) {
return { label: d.label + " (" + d.fieldtype + ")", value: d.fieldname };
} else {
return null;
}
});
frm.fields_dict.invoice_fields.grid.update_docfield_property(
"fieldname",
"options",
[""].concat(
fields.map((df) => {
return { label: `${df.label} (${df.fieldtype})`, value: df.fieldname };
})
)
[""].concat(fields)
);
frm.trigger("validate_invoice_fields");
});
},
validate_invoice_fields: function (frm) {
const valid_fieldnames = frappe
.get_doc("DocType", frm.doc.invoice_type)
.fields.filter(is_valid_invoice_field)
.map((df) => df.fieldname);
const invalid_fields = (frm.doc.invoice_fields || [])
.filter((row) => row.fieldname && !valid_fieldnames.includes(row.fieldname))
.map((row) => `#${row.idx} ${row.fieldname}`);
if (!invalid_fields.length) return;
frappe.msgprint({
title: __("Invalid POS Fields"),
indicator: "orange",
message: __("The following rows are not valid fields of {0} and have to be removed: {1}", [
frm.doc.invoice_type.bold(),
invalid_fields.join(", "),
]),
});
},
add_search_options: function (frm) {
frappe.call({
method: "erpnext.accounts.doctype.pos_settings.pos_settings.get_pos_search_field_options",
callback: ({ message }) => {
const fields = message || [];
frappe.model.with_doctype("Item", () => {
var fields = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
if (
search_fields_datatypes.includes(d.fieldtype) &&
!do_not_include_fields.includes(d.fieldname)
) {
return [d.label];
} else {
return null;
}
});
frm.searchable_item_fields = Object.fromEntries(
fields.map((df) => [df.option, df.fieldname])
);
frm.fields_dict.pos_search_fields.grid.update_docfield_property(
"field",
"options",
[""].concat(fields.map((df) => df.option))
);
},
fields.unshift("");
frm.fields_dict.pos_search_fields.grid.update_docfield_property("field", "options", fields);
});
},
});
frappe.ui.form.on("POS Search Fields", {
field: function (frm, doctype, name) {
const doc = frappe.get_doc(doctype, name);
var doc = frappe.get_doc(doctype, name);
var df = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
if (doc.field == d.label && search_fields_datatypes.includes(d.fieldtype)) {
return d;
} else {
return null;
}
})[0];
doc.fieldname = frm.searchable_item_fields?.[doc.field] || "";
frm.refresh_field("pos_search_fields");
doc.fieldname = df.fieldname;
frm.refresh_field("fields");
},
});
frappe.ui.form.on("POS Field", {
fieldname: function (frm, doctype, name) {
const doc = frappe.get_doc(doctype, name);
const invoice_meta = frappe.get_doc("DocType", frm.doc.invoice_type);
const df = invoice_meta?.fields.find((d) => d.fieldname === doc.fieldname);
if (!df) return;
var doc = frappe.get_doc(doctype, name);
var df = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
return doc.fieldname == d.fieldname ? d : null;
})[0];
doc.label = df.label;
doc.reqd = df.reqd;
doc.options = df.options;
doc.fieldtype = df.fieldtype;
doc.default_value = df.default;
frm.refresh_field("invoice_fields");
frm.refresh_field("fields");
},
});

View File

@@ -5,46 +5,8 @@ from collections import Counter
import frappe
from frappe import _
from frappe.model import no_value_fields
from frappe.model.document import Document
SEARCH_FIELD_TYPES = (
"Data",
"Link",
"Dynamic Link",
"Long Text",
"Select",
"Small Text",
"Text",
"Text Editor",
)
# Item fields that are of a searchable fieldtype, but are not meaningful to search a POS item by
DO_NOT_INCLUDE_FIELDS = (
"naming_series",
"item_code",
"item_name",
"stock_uom",
"asset_naming_series",
"default_material_request_type",
"valuation_method",
"warranty_period",
"weight_uom",
"batch_number_series",
"serial_no_series",
"purchase_uom",
"customs_tariff_number",
"sales_uom",
"deferred_revenue_account",
"deferred_expense_account",
"quality_inspection_template",
"route",
"slideshow",
"website_image_alt",
"thumbnail",
"web_long_description",
)
class POSSettings(Document):
# begin: auto-generated types
@@ -70,10 +32,17 @@ class POSSettings(Document):
if old_doc.invoice_type != self.invoice_type:
self.validate_invoice_type()
self.validate_duplicate_invoice_fields()
self.validate_invoice_fields()
self.validate_duplicate_pos_search_fields()
self.validate_pos_search_fields()
def validate_invoice_fields(self):
invoice_fields = [field.fieldname for field in self.invoice_fields]
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
if len(duplicate_invoice_fields):
for field in duplicate_invoice_fields:
frappe.throw(
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
)
def validate_invoice_type(self):
pos_opening_entries_count = frappe.db.count(
@@ -86,94 +55,3 @@ class POSSettings(Document):
),
title=_("Invoice Document Type Selection Error"),
)
def validate_duplicate_invoice_fields(self):
invoice_fields = [field.fieldname for field in self.invoice_fields]
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
if len(duplicate_invoice_fields):
for field in duplicate_invoice_fields:
frappe.throw(
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
)
def validate_invoice_fields(self):
if not self.invoice_type:
return
meta = frappe.get_meta(self.invoice_type)
for field in self.invoice_fields:
df = meta.get_field(field.fieldname)
if not df or not is_valid_invoice_field(df):
frappe.throw(
title=_("Invalid POS Field"),
msg=_("Row #{0}: '{1}' is not a valid field of {2}.").format(
field.idx, frappe.bold(field.fieldname or ""), frappe.bold(_(self.invoice_type))
),
)
# read only in the form, so keep them in sync with the invoice
field.label = df.label
field.fieldtype = df.fieldtype
field.options = df.options
def validate_duplicate_pos_search_fields(self):
fieldnames = [field.fieldname for field in self.pos_search_fields]
duplicate_fieldnames = {key for key, value in Counter(fieldnames).items() if value > 1}
for fieldname in duplicate_fieldnames:
frappe.throw(
title=_("Duplicate POS Search Fields"),
msg=_("'{0}' has been already added.").format(fieldname),
)
def validate_pos_search_fields(self):
searchable_fields = {df.fieldname: df for df in get_searchable_item_fields()}
for field in self.pos_search_fields:
df = searchable_fields.get(field.fieldname)
if not df:
frappe.throw(
title=_("Invalid POS Search Field"),
msg=_("Row #{0}: '{1}' cannot be used to search items.").format(
field.idx, frappe.bold(field.fieldname or "")
),
)
if field.field != get_search_field_option(df):
frappe.throw(
title=_("Invalid POS Search Field"),
msg=_("Row #{0}: '{1}' does not match {2}.").format(
field.idx, frappe.bold(field.field or ""), frappe.bold(df.fieldname)
),
)
def is_valid_invoice_field(df):
return df.fieldtype not in no_value_fields or df.fieldtype == "Button"
def get_searchable_item_fields():
return [
df
for df in frappe.get_meta("Item").fields
if df.fieldtype in SEARCH_FIELD_TYPES and df.fieldname not in DO_NOT_INCLUDE_FIELDS
]
def get_search_field_option(df):
# the fieldname keeps the option unique, two Item fields can share a label
return f"{df.label} ({df.fieldname})"
@frappe.whitelist()
def get_pos_search_field_options():
frappe.has_permission("POS Settings", throw=True)
return [
{"option": get_search_field_option(df), "fieldname": df.fieldname}
for df in get_searchable_item_fields()
]

View File

@@ -1,135 +1,8 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.patches.v16_0.append_fieldname_to_pos_search_fields import execute as append_fieldname
from erpnext.tests.utils import ERPNextTestSuite
class TestPOSSettings(ERPNextTestSuite):
def setUp(self):
self.settings = frappe.get_single("POS Settings")
self.settings.invoice_fields = []
self.settings.pos_search_fields = []
def assertInvalid(self, message):
with self.assertRaises(frappe.ValidationError) as context:
self.settings.save()
self.assertIn(message, str(context.exception))
def test_invoice_field_is_validated_against_invoice_type(self):
# consolidated_invoice exists on POS Invoice only
self.settings.invoice_type = "POS Invoice"
self.settings.append("invoice_fields", {"fieldname": "consolidated_invoice"})
self.settings.save()
self.settings.invoice_type = "Sales Invoice"
self.assertInvalid("is not a valid field of")
def test_field_common_to_both_invoice_types_is_allowed(self):
for invoice_type in ("POS Invoice", "Sales Invoice"):
self.settings.invoice_type = invoice_type
self.settings.invoice_fields = []
self.settings.append("invoice_fields", {"fieldname": "po_no"})
self.settings.save()
def test_unknown_invoice_field_is_not_allowed(self):
self.settings.append("invoice_fields", {"fieldname": "not_a_field"})
self.assertInvalid("is not a valid field of")
def test_layout_invoice_field_is_not_allowed(self):
self.settings.append("invoice_fields", {"fieldname": "accounting_dimensions_section"})
self.assertInvalid("is not a valid field of")
def test_invoice_field_properties_are_set_from_the_invoice(self):
self.settings.append(
"invoice_fields", {"fieldname": "customer", "label": "Tampered", "fieldtype": "Data"}
)
self.settings.save()
field = self.settings.invoice_fields[0]
self.assertEqual(field.label, "Customer")
self.assertEqual(field.fieldtype, "Link")
self.assertEqual(field.options, "Customer")
def test_searchable_item_field_is_allowed(self):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.settings.save()
self.assertEqual(self.settings.pos_search_fields[0].fieldname, "description")
def test_excluded_search_field_is_not_allowed(self):
self.settings.append(
"pos_search_fields", {"field": "Item Name (item_name)", "fieldname": "item_name"}
)
self.assertInvalid("cannot be used to search items")
def test_search_field_of_unsearchable_type_is_not_allowed(self):
# maintain stock is a Check field
self.settings.append(
"pos_search_fields", {"field": "Maintain Stock (is_stock_item)", "fieldname": "is_stock_item"}
)
self.assertInvalid("cannot be used to search items")
def test_unknown_search_field_is_not_allowed(self):
self.settings.append(
"pos_search_fields", {"field": "Nope (not_an_item_field)", "fieldname": "not_an_item_field"}
)
self.assertInvalid("cannot be used to search items")
def test_search_field_without_a_fieldname_is_not_allowed(self):
# the form fills the fieldname in, it cannot be picked on its own
self.settings.append("pos_search_fields", {"field": "Description (description)"})
self.assertInvalid("cannot be used to search items")
def test_search_field_option_must_match_its_fieldname(self):
self.settings.append("pos_search_fields", {"field": "Brand (brand)", "fieldname": "description"})
self.assertInvalid("does not match")
def test_bare_label_is_not_accepted_as_a_search_field(self):
# the stored option carries the fieldname, the patch backfills older rows
self.settings.append("pos_search_fields", {"field": "Description", "fieldname": "description"})
self.assertInvalid("does not match")
def test_duplicate_search_fields_are_not_allowed(self):
for _ in range(2):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.assertInvalid("has been already added")
def test_patch_appends_the_fieldname_to_a_legacy_search_field(self):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.settings.save()
row = self.settings.pos_search_fields[0].name
frappe.db.set_value("POS Search Fields", row, "field", "Description", update_modified=False)
append_fieldname()
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
def test_patch_leaves_an_already_migrated_search_field_alone(self):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.settings.save()
append_fieldname()
row = self.settings.pos_search_fields[0].name
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
def test_invoice_fields_are_skipped_when_no_invoice_type_is_selected(self):
self.settings.invoice_type = None
self.settings.append("invoice_fields", {"fieldname": "customer"})
self.settings.save()
self.assertEqual(self.settings.invoice_fields[0].fieldname, "customer")
pass

View File

@@ -187,10 +187,7 @@ class PricingRule(Document):
tocheck = frappe.scrub(self.get("applicable_for", ""))
if tocheck and not self.get(tocheck):
throw(
_("{0} is required").format(self.meta.get_translated_label(tocheck)),
frappe.MandatoryError,
)
throw(_("{0} is required").format(_(self.meta.get_label(tocheck))), frappe.MandatoryError)
if self.apply_rule_on_other:
o_field = "other_" + frappe.scrub(self.apply_rule_on_other)

View File

@@ -142,8 +142,6 @@ def start_pcv_processing(docname: str):
@frappe.whitelist()
def pause_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
@@ -159,8 +157,6 @@ def pause_pcv_processing(docname: str):
@frappe.whitelist()
def cancel_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="cancel", doc=docname, throw=True)
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Cancelled").where(ppcv.name.eq(docname)).run()
@@ -175,8 +171,6 @@ def cancel_pcv_processing(docname: str):
@frappe.whitelist()
def resume_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Running").where(ppcv.name.eq(docname)).run()

View File

@@ -10,7 +10,6 @@ from frappe.model.mapper import get_mapped_doc
from frappe.utils import flt
from erpnext.controllers.accounts_controller import merge_taxes
from erpnext.controllers.mapper import get_qty_already_mapped
@frappe.whitelist()
@@ -53,11 +52,6 @@ def make_purchase_receipt(
args = {}
args = frappe.parse_json(args)
mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_invoice_item")
def received_and_mapped_qty(obj):
return flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0))
def post_parent_process(source_parent, target_parent):
remove_items_with_zero_qty(target_parent)
set_missing_values(source_parent, target_parent)
@@ -81,13 +75,15 @@ def make_purchase_receipt(
or {}
)
pending_qty = flt(obj.qty) - received_and_mapped_qty(obj)
target.qty = pending_qty - flt(returned_qty_map.get("qty"))
target.received_qty = pending_qty
target.stock_qty = (pending_qty - flt(returned_qty_map.get("qty"))) * flt(obj.conversion_factor)
target.amount = pending_qty * flt(obj.rate)
target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate)
target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))) * flt(
obj.conversion_factor
)
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
target.base_amount = (
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
)
def select_item(d):
filtered_items = args.get("filtered_children", [])
@@ -117,8 +113,7 @@ def make_purchase_receipt(
"wip_composite_asset": "wip_composite_asset",
},
"postprocess": update_item,
"condition": lambda doc: abs(received_and_mapped_qty(doc)) < abs(doc.qty)
and select_item(doc),
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
},
"Purchase Taxes and Charges": {
"doctype": "Purchase Taxes and Charges",

View File

@@ -1696,7 +1696,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-08-21 23:11:46.733125",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",
@@ -1749,18 +1749,6 @@
"read": 1,
"role": "Accounts Manager",
"write": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Quality Manager",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -235,9 +235,6 @@ class PurchaseInvoice(BuyingController):
"overflow_type": "billing",
}
]
self.closed_source_links = [
("Purchase Invoice Item", "pr_detail", "Purchase Receipt Item", "Purchase Receipt")
]
def onload(self):
super().onload()

View File

@@ -130,9 +130,6 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
get_purchase_document_details,
)
from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import (
get_custom_dimension_overrides,
)
doc = self.doc
tax_service = TaxService(doc)
@@ -273,34 +270,25 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
# Amount added through landed-cost-voucher
if landed_cost_entries:
for entry in landed_cost_entries.get((item.item_code, item.name), []):
if not (entry.amount or entry.base_amount):
continue
lcv_account_currency = get_account_currency(entry.expense_account)
credit_in_transaction_currency = (
flt(entry.amount)
if lcv_account_currency == doc.currency
else flt(
entry.base_amount / doc.conversion_rate, item.precision("net_amount")
if (item.item_code, item.name) in landed_cost_entries:
for account, base_amount in landed_cost_entries[
(item.item_code, item.name)
].items():
gl_entries.append(
self.get_gl_dict(
{
"account": account,
"against": item.expense_account,
"cost_center": item.cost_center,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"credit": flt(base_amount["base_amount"]),
"credit_in_account_currency": flt(base_amount["amount"]),
"credit_in_transaction_currency": item.net_amount,
"project": item.project or doc.project,
},
item=item,
)
)
)
gl_dict = self.get_gl_dict(
{
"account": entry.expense_account,
"against": item.expense_account,
"cost_center": entry.dimensions.cost_center or item.cost_center,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"credit": flt(entry.base_amount),
"credit_in_account_currency": flt(entry.amount),
"credit_in_transaction_currency": credit_in_transaction_currency,
"project": entry.dimensions.project or item.project or doc.project,
},
item=item,
)
gl_dict.update(get_custom_dimension_overrides(entry))
gl_entries.append(gl_dict)
# sub-contracting warehouse
if flt(item.rm_supp_cost):

View File

@@ -578,7 +578,17 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
make_purchase_invoice as create_purchase_invoice,
)
original_value = frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
self.addCleanup(
frappe.db.set_single_value,
"Buying Settings",
"set_landed_cost_based_on_purchase_invoice_rate",
original_value,
)
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
@@ -606,7 +616,16 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
make_purchase_invoice as create_purchase_invoice,
)
original_value = frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
self.addCleanup(
frappe.db.set_single_value,
"Buying Settings",
"set_landed_cost_based_on_purchase_invoice_rate",
original_value,
)
pr = frappe.new_doc("Purchase Receipt")
pr.currency = "USD"
@@ -3526,6 +3545,7 @@ def make_purchase_invoice_against_cost_center(**args):
def setup_provisional_accounting(**args):
args = frappe._dict(args)
create_item("_Test Non Stock Item", is_stock_item=0)
company = args.company or "_Test Company"
provisional_account = create_account(
account_name=args.account_name or "Provision Account",

View File

@@ -77,7 +77,7 @@
"icon": "fa fa-money",
"idx": 1,
"links": [],
"modified": "2026-08-21 23:11:47.506282",
"modified": "2024-03-27 13:10:26.945131",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Taxes and Charges Template",
@@ -104,29 +104,12 @@
{
"read": 1,
"role": "Purchase User"
},
{
"role": "Accounts Manager",
"select": 1
},
{
"role": "Accounts User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1
}
}

View File

@@ -27,7 +27,6 @@ def start_payment_ledger_repost(docname: str | None = None):
"""
if docname:
repost_doc = frappe.get_doc("Repost Payment Ledger", docname)
repost_doc.check_permission("submit")
if repost_doc.docstatus.is_submitted() and repost_doc.repost_status in ["Queued", "Failed"]:
try:
for entry in repost_doc.repost_vouchers:

View File

@@ -278,9 +278,6 @@ class SalesInvoice(SellingController):
"overflow_type": "billing",
}
]
self.closed_source_links = [
("Sales Invoice Item", "dn_detail", "Delivery Note Item", "Delivery Note")
]
def set_indicator(self):
"""Set indicator for portal"""
@@ -617,7 +614,6 @@ class SalesInvoice(SellingController):
"percent_join_field": "sales_order",
"status_field": "delivery_status",
"keyword": "Delivered",
"exclude_field": "skip_delivery",
"second_source_dt": "Delivery Note Item",
"second_source_field": "qty",
"second_join_field": "so_detail",

View File

@@ -476,7 +476,7 @@ class SalesInvoiceGLComposer(BaseGLComposer):
for payment_mode in doc.payments:
if skip_change_gl_entries and payment_mode.account == doc.account_for_change_amount:
payment_mode.base_amount -= flt(doc.base_change_amount)
payment_mode.base_amount -= flt(doc.change_amount)
if payment_mode.base_amount:
# POS, make payment entries

View File

@@ -7,7 +7,7 @@ import json
import frappe
from frappe import qb
from frappe.model.dynamic_links import get_dynamic_link_map
from frappe.utils import add_days, add_to_date, cint, flt, format_date, getdate, nowdate, today
from frappe.utils import add_days, cint, flt, format_date, getdate, nowdate, today
import erpnext
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account
@@ -129,14 +129,14 @@ class TestSalesInvoice(ERPNextTestSuite):
w2 = frappe.get_doc(w.doctype, w.name)
import time
time.sleep(1)
w.save()
frappe.db.set_value(
w.doctype,
w.name,
"modified",
add_to_date(w.modified, seconds=1),
update_modified=False,
)
import time
time.sleep(1)
self.assertRaises(frappe.TimestampMismatchError, w2.save)
def test_sales_invoice_change_naming_series(self):
@@ -1583,35 +1583,6 @@ class TestSalesInvoice(ERPNextTestSuite):
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
def test_pos_change_amount_multi_currency_gl_entry(self):
from erpnext.accounts.doctype.sales_invoice.services.gl_composer import SalesInvoiceGLComposer
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 0)
si = create_sales_invoice(do_not_save=True)
si.is_pos = 1
si.currency = "USD"
si.conversion_rate = 50
si.party_account_currency = "USD"
si.account_for_change_amount = "Cash - _TC"
si.change_amount = 50
si.base_change_amount = 2500
si.append(
"payments",
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 150, "base_amount": 7500},
)
gl_entries = []
SalesInvoiceGLComposer(si).make_pos_gl_entries(gl_entries)
debtors_entry = next(entry for entry in gl_entries if entry["account"] == si.debit_to)
cash_entry = next(entry for entry in gl_entries if entry["account"] == "Cash - _TC")
self.assertEqual(flt(debtors_entry["credit"]), 5000.0)
self.assertEqual(flt(cash_entry["debit"]), 5000.0)
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
def test_stock_delivered_but_not_billed_gl_on_invoice(self):
company = "_Test SDBNB Company"
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
@@ -3846,12 +3817,25 @@ class TestSalesInvoice(ERPNextTestSuite):
# enable common party accounting
frappe.db.set_single_value("Accounts Settings", "enable_common_party_accounting", 1)
# make the shared department dimension mandatory
dim = frappe.get_doc("Accounting Dimension", {"document_type": "Department"})
dim.disabled = False
dim.dimension_defaults = []
dim.append("dimension_defaults", {"company": "_Test Company", "mandatory_for_bs": True})
dim.save()
# create a dimension and make it mandatory
if not frappe.get_all("Accounting Dimension", filters={"document_type": "Department"}):
dim = frappe.get_doc(
{
"doctype": "Accounting Dimension",
"document_type": "Department",
"dimension_defaults": [{"company": "_Test Company", "mandatory_for_bs": True}],
}
)
dim.save()
else:
dim = frappe.get_doc(
"Accounting Dimension",
frappe.get_all("Accounting Dimension", filters={"document_type": "Department"})[0],
)
dim.disabled = False
dim.dimension_defaults = []
dim.append("dimension_defaults", {"company": "_Test Company", "mandatory_for_bs": True})
dim.save()
# create a sales invoice
si = create_sales_invoice(
@@ -5805,6 +5789,12 @@ def create_internal_parties():
allowed_to_interact_with="Wind Power LLC",
)
create_internal_customer(
customer_name="_Test Internal Customer 2",
represents_company="_Test Company with perpetual inventory",
allowed_to_interact_with="_Test Company with perpetual inventory",
)
create_internal_customer(
customer_name="_Test Internal Customer 3",
represents_company="_Test Company",
@@ -5825,6 +5815,12 @@ def create_internal_parties():
allowed_to_interact_with="_Test Company 1",
)
create_internal_supplier(
supplier_name="_Test Internal Supplier 2",
represents_company="_Test Company with perpetual inventory",
allowed_to_interact_with="_Test Company with perpetual inventory",
)
create_internal_supplier(
supplier_name="_Test Internal Customer 3",
represents_company="_Test Company",

View File

@@ -79,7 +79,7 @@
"icon": "fa fa-money",
"idx": 1,
"links": [],
"modified": "2026-08-21 23:11:48.797423",
"modified": "2024-03-27 13:10:38.343481",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Taxes and Charges Template",
@@ -113,33 +113,12 @@
"role": "Sales Master Manager",
"share": 1,
"write": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "creation",
"sort_order": "ASC",
"states": [],
"title_field": "title",
"track_changes": 1
}
}

View File

@@ -149,7 +149,7 @@
"icon": "fa fa-truck",
"idx": 1,
"links": [],
"modified": "2026-08-21 23:11:49.532098",
"modified": "2026-07-22 14:53:27.315435",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Shipping Rule",
@@ -195,38 +195,6 @@
"role": "Sales Master Manager",
"share": 1,
"write": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase User",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -1,7 +1,6 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from unittest.mock import patch
import frappe
from frappe.utils.data import (
@@ -659,15 +658,23 @@ class TestSubscription(ERPNextTestSuite):
sub2 = create_subscription(start_date="2018-01-02")
processed = []
original_process = Subscription.process
original_rollback = frappe.db.rollback
def patched(self, posting_date=None):
processed.append(self.name)
if self.name == sub1.name:
raise frappe.ValidationError("forced failure")
# Stub transaction recovery so the test can observe the complete iteration in isolation.
with patch.object(Subscription, "process", patched), patch.object(frappe.db, "rollback"):
Subscription.process = patched
# process_all calls frappe.db.rollback() on error which would otherwise wipe
# the test transaction; stub it so we can observe the iteration in isolation.
frappe.db.rollback = lambda *a, **kw: None
try:
process_all([sub1.name, sub2.name])
finally:
Subscription.process = original_process
frappe.db.rollback = original_rollback
self.assertEqual(processed, [sub1.name, sub2.name])
@@ -1066,6 +1073,12 @@ def create_plan(**kwargs):
def create_parties():
if not frappe.db.exists("Supplier", "_Test Supplier"):
supplier = frappe.new_doc("Supplier")
supplier.supplier_name = "_Test Supplier"
supplier.supplier_group = "All Supplier Groups"
supplier.insert()
if not frappe.db.exists("Customer", "_Test Subscription Customer"):
customer = frappe.new_doc("Customer")
customer.customer_name = "_Test Subscription Customer"

View File

@@ -29,7 +29,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-08-21 23:11:50.740962",
"modified": "2024-03-27 13:10:51.976600",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Tax Category",
@@ -68,68 +68,11 @@
"report": 1,
"role": "Accounts User",
"share": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Item Manager",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Purchase User",
"select": 1
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
},
{
"role": "Stock User",
"select": 1
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -63,6 +63,25 @@ class TestTaxRule(ERPNextTestSuite):
def test_for_parent_supplier_group(self):
purchase_template = "_Test Purchase Taxes and Charges Template - _TC"
if not frappe.db.exists("Purchase Taxes and Charges Template", purchase_template):
frappe.get_doc(
{
"doctype": "Purchase Taxes and Charges Template",
"title": "_Test Purchase Taxes and Charges Template",
"company": "_Test Company",
"taxes": [
{
"account_head": "_Test Account VAT - _TC",
"charge_type": "On Net Total",
"description": "VAT",
"doctype": "Purchase Taxes and Charges",
"cost_center": "Main - _TC",
"rate": 6,
}
],
}
).insert()
make_tax_rule(
supplier_group="All Supplier Groups",
tax_type="Purchase",

View File

@@ -100,7 +100,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-08-21 23:11:50.979676",
"modified": "2025-07-30 07:13:51.785735",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Tax Withholding Category",
@@ -142,26 +142,6 @@
"role": "Accounts User",
"share": 1,
"write": 1
},
{
"role": "Item Manager",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -21,7 +21,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-08-21 23:11:51.176158",
"modified": "2025-06-29 05:25:50.243710",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Tax Withholding Group",
@@ -39,30 +39,6 @@
"role": "System Manager",
"share": 1,
"write": 1
},
{
"role": "Accounts Manager",
"select": 1
},
{
"role": "Accounts User",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -106,8 +106,6 @@ def get_linked_payments_for_doc(
company: str | None = None, doctype: str | None = None, docname: str | None = None
) -> list:
if company and doctype and docname:
frappe.has_permission(doctype, doc=docname, throw=True)
_dt = doctype
_dn = docname
ple = qb.DocType("Payment Ledger Entry")

View File

@@ -94,15 +94,10 @@ frappe.query_reports["Accounts Payable"] = {
options: get_party_type_options(),
on_change: function () {
frappe.query_report.set_filter_value("party", "");
let is_supplier = frappe.query_report.get_filter_value("party_type") === "Supplier";
let supplier_group_filter = frappe.query_report.get_filter("supplier_group");
if (supplier_group_filter) {
supplier_group_filter.df.hidden = !is_supplier;
}
frappe.query_report.toggle_filter_display("supplier_group", !is_supplier);
if (!is_supplier) {
frappe.query_report.set_filter_value("supplier_group", []);
}
frappe.query_report.toggle_filter_display(
"supplier_group",
frappe.query_report.get_filter_value("party_type") !== "Supplier"
);
},
},
{

View File

@@ -869,7 +869,9 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
self.assertEqual(rows_b[0].future_amount, 50.0)
def test_sales_person(self):
sales_person = frappe.get_doc("Sales Person", "_Test Sales Person")
sales_person = frappe.get_doc(
{"doctype": "Sales Person", "sales_person_name": "John Clark", "enabled": True}
).insert()
si = self.create_sales_invoice(do_not_submit=True)
si.append("sales_team", {"sales_person": sales_person.name, "allocated_percentage": 100})
si.save().submit()
@@ -1492,8 +1494,17 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
def test_receivable_filtered_by_sales_partner(self):
frappe.set_user("Administrator")
partner_a = "_Test Sales Partner India - 1"
partner_b = "_Test Sales Partner India - 2"
partner_a, partner_b = "_Test AR Sales Partner A", "_Test AR Sales Partner B"
for partner in (partner_a, partner_b):
if not frappe.db.exists("Sales Partner", partner):
frappe.get_doc(
{
"doctype": "Sales Partner",
"partner_name": partner,
"commission_rate": 0,
"territory": "All Territories",
}
).insert()
def _si(sales_partner):
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True, qty=2)

View File

@@ -193,7 +193,16 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
self.assertEqual(len(rpt_output), 0)
def test_03_summary_sales_partner_column(self):
partner = "_Test Sales Partner India - 1"
partner = "_Test AR Summary Sales Partner"
if not frappe.db.exists("Sales Partner", partner):
frappe.get_doc(
{
"doctype": "Sales Partner",
"partner_name": partner,
"commission_rate": 0,
"territory": "All Territories",
}
).insert()
si = create_sales_invoice(
item=self.item,

View File

@@ -21,8 +21,10 @@ class TestGeneralLedger(ERPNextTestSuite):
from frappe.utils import today
frappe.db.set_single_value("Accounts Settings", "general_ledger_remarks_length", 50)
self.addCleanup(frappe.db.set_single_value, "Accounts Settings", "general_ledger_remarks_length", 0)
create_sales_invoice(company=self.company)
si = create_sales_invoice(company=self.company)
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
columns, data = execute(
frappe._dict(
@@ -40,6 +42,15 @@ class TestGeneralLedger(ERPNextTestSuite):
self.assertTrue(data)
self.assertTrue(any("remarks" in row for row in data))
@staticmethod
def _cancel_and_delete(doctype, name):
if not frappe.db.exists(doctype, name):
return
doc = frappe.get_doc(doctype, name)
if doc.docstatus == 1:
doc.cancel()
frappe.delete_doc(doctype, name, force=1)
def clear_old_entries(self):
doctype_list = [
"GL Entry",

View File

@@ -642,7 +642,7 @@ class TestGrossProfit(ERPNextTestSuite):
self.assertEqual(total.get("gross_profit_%"), -50.0)
def test_sales_person_wise_gross_profit(self):
sales_person = frappe.get_doc("Sales Person", "_Test Sales Person")
sales_person = make_sales_person("_Test Sales Person")
posting_date = get_first_day(nowdate())
qty = 10
@@ -1194,3 +1194,19 @@ class TestGrossProfit(ERPNextTestSuite):
self.assertEqual(base_rate, 220.0) # avg selling rate = 220/1
self.assertEqual(gross_profit, 120.0) # 220 - 100
self.assertAlmostEqual(gp_percent, 54.545, places=2) # 120/220 * 100
def make_sales_person(sales_person_name="_Test Sales Person"):
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):
sales_person_doc = frappe.get_doc(
{
"doctype": "Sales Person",
"is_group": 0,
"parent_sales_person": "Sales Team",
"sales_person_name": sales_person_name,
}
).insert(ignore_permissions=True)
else:
sales_person_doc = frappe.get_doc("Sales Person", {"sales_person_name": sales_person_name})
return sales_person_doc

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