mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-04 00:50:18 +00:00
Compare commits
155 Commits
perf-wareh
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8951034be | ||
|
|
56caf3c770 | ||
|
|
3b9535ed91 | ||
|
|
f8c53be376 | ||
|
|
a1b402020e | ||
|
|
e3a976f115 | ||
|
|
68666c2ed0 | ||
|
|
717af27559 | ||
|
|
4944df8733 | ||
|
|
19aa3b20e0 | ||
|
|
09f5e76b77 | ||
|
|
646c7d042d | ||
|
|
3e08a085b9 | ||
|
|
2918e98a2b | ||
|
|
02fcdc0337 | ||
|
|
a2071a6fdd | ||
|
|
dbe153a15e | ||
|
|
f16f249a38 | ||
|
|
9cb736a271 | ||
|
|
c93815b4ae | ||
|
|
2b54a581f8 | ||
|
|
199cae9496 | ||
|
|
9261c9b47f | ||
|
|
0230879501 | ||
|
|
56a391c522 | ||
|
|
e74ab38eeb | ||
|
|
a9b857bdfe | ||
|
|
a20fc4f931 | ||
|
|
22e3b87621 | ||
|
|
e2f24eea15 | ||
|
|
db52e465d0 | ||
|
|
5691a7b3c7 | ||
|
|
0d78261bdf | ||
|
|
120e62f208 | ||
|
|
21a3b1c3dc | ||
|
|
d8432d92c8 | ||
|
|
31761699ae | ||
|
|
7ecfa6b356 | ||
|
|
24209ae699 | ||
|
|
26d000e15f | ||
|
|
dbae23765e | ||
|
|
c755e24731 | ||
|
|
d5789c2e8b | ||
|
|
0f14f8050f | ||
|
|
b1c7657dfa | ||
|
|
b36895a4c3 | ||
|
|
509501c299 | ||
|
|
20ea09b4a0 | ||
|
|
8ca2905a33 | ||
|
|
9087f2cdba | ||
|
|
6cca7d670b | ||
|
|
4355f8e60e | ||
|
|
0e4b384af1 | ||
|
|
83dea1a24e | ||
|
|
6d445d94ee | ||
|
|
05e5e50a6f | ||
|
|
3501beb2bd | ||
|
|
7af1af35cd | ||
|
|
86852d954e | ||
|
|
d5df40986d | ||
|
|
1cedd1421d | ||
|
|
db56080285 | ||
|
|
8b43394533 | ||
|
|
caf8a36bdb | ||
|
|
a59792a605 | ||
|
|
6d13e4a633 | ||
|
|
90ac7db704 | ||
|
|
eb49f51d29 | ||
|
|
0223223385 | ||
|
|
ca49de633f | ||
|
|
64186f229f | ||
|
|
074c84e880 | ||
|
|
1f7f8cd9d3 | ||
|
|
9160182727 | ||
|
|
0985451276 | ||
|
|
987408f203 | ||
|
|
2a14b5d953 | ||
|
|
9018573179 | ||
|
|
e95ca2444c | ||
|
|
971b6fd49d | ||
|
|
457283f2b0 | ||
|
|
0849f187e7 | ||
|
|
a8ba713f80 | ||
|
|
5f99a3418d | ||
|
|
b90e3d4656 | ||
|
|
2866be2816 | ||
|
|
6842ebb186 | ||
|
|
e08a166281 | ||
|
|
4d4cf034b5 | ||
|
|
62e6e23581 | ||
|
|
0d90608bc1 | ||
|
|
dd033cbc10 | ||
|
|
d6956790d8 | ||
|
|
4def9ed20a | ||
|
|
b2489eaf1c | ||
|
|
88948bab42 | ||
|
|
98be36ef12 | ||
|
|
687bb9c839 | ||
|
|
731f03e2f2 | ||
|
|
fd2057befa | ||
|
|
4cfa42921f | ||
|
|
8d2aa69e61 | ||
|
|
3f29cdf8d2 | ||
|
|
ccc19f2554 | ||
|
|
2ca02fb305 | ||
|
|
1b81db4754 | ||
|
|
918e5a28db | ||
|
|
c940bd1e66 | ||
|
|
6bdc18b753 | ||
|
|
35b3358a5d | ||
|
|
55dd11f977 | ||
|
|
6ddff9c386 | ||
|
|
84a32c40f4 | ||
|
|
7b32d07d1c | ||
|
|
6a6f2ac127 | ||
|
|
a01cc92184 | ||
|
|
ae119b1c29 | ||
|
|
ce23fcc055 | ||
|
|
8b8422662c | ||
|
|
9cf76c6a68 | ||
|
|
6fbcfade6c | ||
|
|
5fa68dd068 | ||
|
|
ac2bacd3d0 | ||
|
|
7ec34bab8d | ||
|
|
75d6183bb6 | ||
|
|
560ed25ff7 | ||
|
|
ef7461cb5b | ||
|
|
abf94bc72d | ||
|
|
59d80b29c4 | ||
|
|
7149df398a | ||
|
|
4a43e5203f | ||
|
|
8c8b282a2e | ||
|
|
1b51f8cbd6 | ||
|
|
aaa3100efc | ||
|
|
64486f34e8 | ||
|
|
0ea36160fe | ||
|
|
6ba623e95f | ||
|
|
e71e08959f | ||
|
|
eae36c2d67 | ||
|
|
275844d496 | ||
|
|
624d402143 | ||
|
|
2328e6da94 | ||
|
|
941baffdda | ||
|
|
7faadb8282 | ||
|
|
e8b214e7d3 | ||
|
|
8320c77694 | ||
|
|
696f0df811 | ||
|
|
deeb787f57 | ||
|
|
478a2f4f4b | ||
|
|
cfca71782e | ||
|
|
f4ef7fdbcc | ||
|
|
e5cd30c5ab | ||
|
|
8eeb0196e4 | ||
|
|
d07f4bb857 | ||
|
|
513f19924d |
32
.github/helper/install.sh
vendored
32
.github/helper/install.sh
vendored
@@ -4,6 +4,36 @@ 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:-}
|
||||
@@ -188,7 +218,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 "$HOME/frappe" HEAD || exit 1
|
||||
git fetch --no-tags --update-shallow "$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"
|
||||
|
||||
51
.github/workflows/crowdin-actions-download-translations.yml
vendored
Normal file
51
.github/workflows/crowdin-actions-download-translations.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
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 }}
|
||||
54
.github/workflows/crowdin-actions-update-main-pot.yml
vendored
Normal file
54
.github/workflows/crowdin-actions-update-main-pot.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
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 }}
|
||||
45
.github/workflows/notify-support-on-release.yml
vendored
Normal file
45
.github/workflows/notify-support-on-release.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
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"
|
||||
2
.github/workflows/patch.yml
vendored
2
.github/workflows/patch.yml
vendored
@@ -121,6 +121,8 @@ 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: |
|
||||
|
||||
@@ -22,6 +22,6 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: alyf-de/po-review-action@v1.1.0
|
||||
- uses: alyf-de/po-review-action@5928f84d6bc9094f9ad6e2c5780f01c0044b800e # v1.1.1
|
||||
with:
|
||||
hidden-po-files: eo.po
|
||||
|
||||
2
.github/workflows/run-individual-tests.yml
vendored
2
.github/workflows/run-individual-tests.yml
vendored
@@ -129,6 +129,8 @@ 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: |
|
||||
|
||||
@@ -13,6 +13,7 @@ on:
|
||||
- 'crowdin.yml'
|
||||
- '.coderabbit.yml'
|
||||
- '.mergify.yml'
|
||||
- '**.po'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
3
.github/workflows/server-tests-mariadb.yml
vendored
3
.github/workflows/server-tests-mariadb.yml
vendored
@@ -13,6 +13,7 @@ on:
|
||||
- 'crowdin.yml'
|
||||
- '.coderabbit.yml'
|
||||
- '.mergify.yml'
|
||||
- '**.po'
|
||||
schedule:
|
||||
# Run everday at midnight UTC / 5:30 IST
|
||||
- cron: "0 0 * * *"
|
||||
@@ -101,6 +102,8 @@ 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
|
||||
|
||||
2
.github/workflows/server-tests-postgres.yml
vendored
2
.github/workflows/server-tests-postgres.yml
vendored
@@ -103,6 +103,8 @@ 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
|
||||
|
||||
@@ -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.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==
|
||||
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==
|
||||
|
||||
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.4"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.4.tgz#dd8b8167a32845ff5f8cd6ce13f5abba16cd04c9"
|
||||
integrity sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==
|
||||
version "4.28.8"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.8.tgz#a3c79ceb70028527e5da7dafc887f3200b5168c0"
|
||||
integrity sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==
|
||||
dependencies:
|
||||
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"
|
||||
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"
|
||||
|
||||
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.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==
|
||||
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==
|
||||
|
||||
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.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==
|
||||
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==
|
||||
|
||||
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.48:
|
||||
version "2.0.50"
|
||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.50.tgz#597197a852071ce42fc2550e58e223242bcba969"
|
||||
integrity sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==
|
||||
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==
|
||||
|
||||
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.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==
|
||||
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==
|
||||
dependencies:
|
||||
escalade "^3.2.0"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
15
crowdin.yml
15
crowdin.yml
@@ -1,16 +1,5 @@
|
||||
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
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
"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",
|
||||
@@ -203,7 +204,7 @@
|
||||
"idx": 1,
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-14 18:14:42.202065",
|
||||
"modified": "2026-09-03 12:59:42.190900",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Account",
|
||||
@@ -264,6 +265,46 @@
|
||||
{
|
||||
"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",
|
||||
|
||||
@@ -52,6 +52,42 @@ 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) {
|
||||
@@ -232,7 +268,7 @@ frappe.treeview_settings["Account"] = {
|
||||
frappe.treeview_settings["Account"].treeview["tree"] = treeview.tree;
|
||||
if (treeview.can_create) {
|
||||
treeview.page.set_primary_action(
|
||||
__("New"),
|
||||
{ label: __("Add Account"), short_label: __("Add") },
|
||||
function () {
|
||||
let root_company = treeview.page.fields_dict.root_company.get_value();
|
||||
if (root_company) {
|
||||
@@ -243,13 +279,14 @@ frappe.treeview_settings["Account"] = {
|
||||
treeview.new_node();
|
||||
}
|
||||
},
|
||||
"add"
|
||||
"plus"
|
||||
);
|
||||
}
|
||||
},
|
||||
toolbar: [
|
||||
{
|
||||
label: __("Add Child"),
|
||||
icon: "plus",
|
||||
condition: function (node) {
|
||||
return (
|
||||
frappe.boot.user.can_create.indexOf("Account") !== -1 &&
|
||||
@@ -272,6 +309,7 @@ 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],
|
||||
@@ -286,6 +324,106 @@ 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" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -16,6 +16,8 @@ frappe.ui.form.on("Accounting Dimension", {
|
||||
return {
|
||||
filters: {
|
||||
name: ["not in", invalid_doctypes],
|
||||
istable: 0,
|
||||
issingle: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -60,6 +60,14 @@ 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():
|
||||
|
||||
@@ -51,6 +51,23 @@ 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
|
||||
|
||||
@@ -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_label("add_taxes_from_item_tax_template"))),
|
||||
frappe.bold(_(self.meta.get_label("add_taxes_from_taxes_and_charges_template"))),
|
||||
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")),
|
||||
),
|
||||
title=_("Auto Tax Settings Error"),
|
||||
)
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:36.896195",
|
||||
"modified": "2026-08-21 23:11:39.423431",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank",
|
||||
@@ -118,11 +118,20 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
"link_fieldname": "default_bank_account"
|
||||
}
|
||||
],
|
||||
"modified": "2026-04-11 19:46:27.609994",
|
||||
"modified": "2026-08-21 23:11:39.585456",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Account",
|
||||
@@ -299,6 +299,22 @@
|
||||
"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",
|
||||
|
||||
@@ -4,29 +4,18 @@
|
||||
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()
|
||||
|
||||
@@ -139,11 +128,8 @@ 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=supplier.name,
|
||||
supplier="_Test Supplier",
|
||||
supplier_warehouse="_Test Warehouse - _TC",
|
||||
expense_account="Cost of Goods Sold - _TC",
|
||||
uom="Nos",
|
||||
@@ -158,10 +144,6 @@ 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"}):
|
||||
@@ -170,13 +152,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=customer, item="_Test Item", is_pos=1, qty=1, rate=1000, do_not_save=1)
|
||||
si = create_sales_invoice(
|
||||
customer="_Test 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()
|
||||
|
||||
@@ -23,8 +23,6 @@ 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)
|
||||
@@ -32,6 +30,7 @@ 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)
|
||||
|
||||
@@ -47,7 +46,7 @@ class TestBankTransaction(ERPNextTestSuite):
|
||||
from_date=bank_transaction.date,
|
||||
to_date=utils.today(),
|
||||
)
|
||||
self.assertEqual(linked_payments[0]["party"], "Conrad Electronic")
|
||||
self.assertIn("Conrad Electronic", [payment["party"] for payment in linked_payments])
|
||||
|
||||
# This test validates a simple reconciliation leading to the clearance of the bank transaction and the payment
|
||||
def test_reconcile(self):
|
||||
|
||||
@@ -729,6 +729,7 @@ 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"))
|
||||
)
|
||||
|
||||
@@ -16,6 +16,8 @@ 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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -128,7 +130,6 @@ 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);
|
||||
}
|
||||
@@ -142,42 +143,95 @@ 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 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"]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var generate_tree_preview = function (frm) {
|
||||
let parent = __("All Accounts");
|
||||
$(frm.fields_dict["chart_tree"].wrapper).empty(); // empty wrapper to load new data
|
||||
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;
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// generate tree structure based on the csv data
|
||||
return new frappe.ui.Tree({
|
||||
parent: $(frm.fields_dict["chart_tree"].wrapper),
|
||||
tree = new frappe.ui.Tree({
|
||||
parent: 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,
|
||||
@@ -185,8 +239,9 @@ var generate_tree_preview = function (frm) {
|
||||
doctype: "Chart of Accounts Importer",
|
||||
file_type: frm.doc.file_type,
|
||||
},
|
||||
onclick: function (node) {
|
||||
parent = node.value;
|
||||
},
|
||||
on_node_render: () => update_buttons(),
|
||||
// expanded flips right after this callback — check on the next tick
|
||||
on_click: () => setTimeout(update_buttons, 0),
|
||||
});
|
||||
return tree;
|
||||
};
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
"idx": 1,
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-14 18:15:27.367298",
|
||||
"modified": "2026-08-21 23:11:40.799391",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Cost Center",
|
||||
@@ -181,6 +181,54 @@
|
||||
{
|
||||
"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",
|
||||
|
||||
@@ -12,6 +12,19 @@ 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: [
|
||||
{
|
||||
@@ -42,6 +55,37 @@ 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();
|
||||
@@ -82,3 +126,22 @@ 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" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2024-11-19 16:35:11.836441",
|
||||
"modified": "2026-08-21 23:11:41.010871",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Coupon Code",
|
||||
@@ -179,11 +179,20 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,8 +234,10 @@ frappe.ui.form.on("Dunning", {
|
||||
dn: frm.doc.name,
|
||||
},
|
||||
callback: function (r) {
|
||||
var doc = frappe.model.sync(r.message);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
if (!r.exc) {
|
||||
var doc = frappe.model.sync(r.message);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
],
|
||||
"icon": "fa fa-book",
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:09:44.514241",
|
||||
"modified": "2026-08-21 23:11:42.386104",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Finance Book",
|
||||
@@ -55,13 +55,26 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,4 @@ class TestFinanceBook(ERPNextTestSuite):
|
||||
|
||||
|
||||
def create_finance_book():
|
||||
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
|
||||
return frappe.get_doc("Finance Book", "Test Finance Book 1")
|
||||
|
||||
@@ -32,6 +32,7 @@ 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,
|
||||
@@ -1182,10 +1183,12 @@ class RowProcessor:
|
||||
|
||||
def _process_api_row(self, row) -> RowData:
|
||||
api_path = row.calculation_formula
|
||||
# TODO
|
||||
|
||||
method = get_valid_api_method(api_path)
|
||||
|
||||
try:
|
||||
values = frappe.call(api_path, filters=self.context.filters, periods=self.period_list, row=row)
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-codeinjection-eval
|
||||
values = frappe.call(method, filters=self.context.filters, periods=self.period_list, row=row)
|
||||
|
||||
if row.reverse_sign:
|
||||
values = [-1 * v for v in values]
|
||||
|
||||
@@ -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,6 +236,8 @@ 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: [],
|
||||
@@ -288,14 +290,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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -370,7 +372,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 method that returns financial data.</p>
|
||||
<p ${text_style}>Path to your custom whitelisted method that returns financial data. It must permit GET requests.</p>
|
||||
|
||||
<h6 ${subtitle_style}>Format:</h6>
|
||||
<ul ${list_style}>
|
||||
@@ -380,7 +382,8 @@ function update_formula_description(frm, data_source) {
|
||||
|
||||
<h6 ${subtitle_style}>Method Signature:</h6>
|
||||
<div ${code_style}>
|
||||
<pre ${pre_style}>def get_custom_data(filters, periods, row): <br> # filters: dict — report filters (company, period, etc.) <br> # periods: list[dict] — period definitions <br> # row: dict — the current report row <br><br> return [1000.0, 1200.0, 1150.0] # one value per period</pre>
|
||||
<!-- is used for line breaks since frappe.render replaces newlines with spaces -->
|
||||
<pre ${pre_style} class="language-python">@frappe.whitelist(methods=["GET"]) def get_custom_data(filters, periods, row): # filters: dict — report filters (company, period, etc.) # periods: list[dict] — period definitions # row: dict — the current report row return [1000.0, 1200.0, 1150.0] # one value per period</pre>
|
||||
</div>
|
||||
|
||||
<h6 ${subtitle_style}>Return Format:</h6>
|
||||
|
||||
@@ -8,17 +8,40 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe import _, is_whitelisted
|
||||
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):
|
||||
@@ -26,10 +49,9 @@ class ValidationIssue:
|
||||
self.details = {}
|
||||
|
||||
def __str__(self) -> str:
|
||||
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)
|
||||
if self.row_idx:
|
||||
return _("Row {0}: {1}", context="Financial Report Template").format(self.row_idx, self.message)
|
||||
return self.message
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -131,7 +153,9 @@ class TemplateStructureValidator(Validator):
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_-]*$", ref_code):
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Invalid line reference format: '{ref_code}'. Must start with letter and contain only letters, numbers, underscores, and hyphens",
|
||||
message=_(
|
||||
"Invalid line reference format: '{0}'. Must start with letter and contain only letters, numbers, underscores, and hyphens"
|
||||
).format(ref_code),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -140,7 +164,7 @@ class TemplateStructureValidator(Validator):
|
||||
if ref_code in used_codes:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Duplicate line reference: '{ref_code}'",
|
||||
message=_("Duplicate line reference: '{0}'").format(ref_code),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -156,7 +180,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,
|
||||
)
|
||||
)
|
||||
@@ -166,7 +190,9 @@ class TemplateStructureValidator(Validator):
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Formula is required for {row.data_source}",
|
||||
message=_("{0} is required for {1}").format(
|
||||
get_formula_field_label(row.data_source), row.data_source
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -223,7 +249,7 @@ class DependencyValidator(Validator):
|
||||
cycle = [*path[cycle_start:], node]
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Circular dependency detected: {' → '.join(cycle)}",
|
||||
message=_("Circular dependency detected: {0}").format(" → ".join(cycle)),
|
||||
)
|
||||
)
|
||||
return
|
||||
@@ -255,7 +281,7 @@ class DependencyValidator(Validator):
|
||||
row_idx = self._get_row_idx(ref_code)
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Line References undefined in Formula: {', '.join(undefined)}",
|
||||
message=_("Line References undefined in Formula: {0}").format(", ".join(undefined)),
|
||||
row_idx=row_idx,
|
||||
)
|
||||
)
|
||||
@@ -285,9 +311,10 @@ class CalculationFormulaValidator(Validator):
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message="Formula is required for Calculated Amount",
|
||||
message=_("{0} is required for Calculated Amount").format(
|
||||
get_formula_field_label(row.data_source)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
field="Formula",
|
||||
)
|
||||
)
|
||||
return result
|
||||
@@ -299,7 +326,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,
|
||||
)
|
||||
)
|
||||
@@ -311,7 +338,7 @@ class CalculationFormulaValidator(Validator):
|
||||
if row.reference_code and row.reference_code in refs:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Formula references itself ('{row.reference_code}')",
|
||||
message=_("Formula references itself ('{0}')").format(row.reference_code),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -321,7 +348,7 @@ class CalculationFormulaValidator(Validator):
|
||||
if undefined:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Formula references undefined codes: {', '.join(undefined)}",
|
||||
message=_("Formula references undefined codes: {0}").format(", ".join(undefined)),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -331,7 +358,7 @@ class CalculationFormulaValidator(Validator):
|
||||
if eval_error:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Formula evaluation error: {eval_error}",
|
||||
message=_("Formula evaluation error: {0}").format(eval_error),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -368,7 +395,7 @@ class CalculationFormulaValidator(Validator):
|
||||
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
|
||||
|
||||
if not isinstance(result, (int, float)): # noqa: UP038
|
||||
return f"Formula must return a numeric value, got {type(result).__name__}"
|
||||
return _("Formula must return a numeric value, got {0}").format(type(result).__name__)
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
@@ -391,9 +418,10 @@ class AccountFilterValidator(Validator):
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message="Account filter is required for Account Data",
|
||||
message=_("{0} is required for Account Data").format(
|
||||
get_formula_field_label(row.data_source)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
field="Formula",
|
||||
)
|
||||
)
|
||||
return result
|
||||
@@ -409,18 +437,18 @@ class AccountFilterValidator(Validator):
|
||||
if error:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=error,
|
||||
message=_("{0}: {1}").format(get_formula_field_label(row.data_source), error),
|
||||
row_idx=row.idx,
|
||||
field="Account Filter",
|
||||
)
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Invalid JSON format: {e!s}",
|
||||
message=_("{0}: Invalid JSON format: {1}").format(
|
||||
get_formula_field_label(row.data_source), str(e)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
field="Account Filter",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -435,36 +463,38 @@ 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_label(field)) or field
|
||||
display = (
|
||||
field if advanced_filtering else self.account_meta.get_translated_label(field)
|
||||
) or field
|
||||
|
||||
if field not in account_fields:
|
||||
return f"Field '{display}' is not a valid Account field"
|
||||
return _("Field '{0}' is not a valid Account field").format(display)
|
||||
|
||||
if operator.casefold() not in OPERATOR_MAP:
|
||||
return f"Invalid operator '{operator}'"
|
||||
return _("Invalid operator '{0}'").format(operator)
|
||||
|
||||
if operator in ["in", "not in"] and not isinstance(value, list):
|
||||
return f"Operator '{operator}' requires a list value"
|
||||
return _("Operator '{0}' requires a list value").format(operator)
|
||||
|
||||
# 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:
|
||||
@@ -472,7 +502,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
|
||||
|
||||
@@ -508,34 +538,31 @@ class FormulaValidator(Validator):
|
||||
if "." not in api_path:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message="Custom API path should be in format: app.module.method",
|
||||
message=_("{0} should be in format: app.module.method").format(
|
||||
get_formula_field_label(row.data_source)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
field="Formula",
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
# Method exists?
|
||||
try:
|
||||
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",
|
||||
)
|
||||
)
|
||||
get_valid_api_method(api_path)
|
||||
except Exception as e:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Could not validate API path: {e!s}",
|
||||
row_idx=row.idx,
|
||||
field="Formula",
|
||||
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}: Method '{1}' must be whitelisted and permit GET requests").format(
|
||||
get_formula_field_label(row.data_source), api_path
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -72,3 +77,90 @@ 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)
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
"icon": "fa fa-calendar",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2024-05-27 17:29:55.560840",
|
||||
"modified": "2026-08-21 23:11:42.509102",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Fiscal Year",
|
||||
@@ -131,10 +131,15 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_label(k))))
|
||||
frappe.throw(_("{0} is required").format(self.meta.get_translated_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")
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:09:55.573483",
|
||||
"modified": "2026-08-21 23:11:43.571355",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Item Tax Template",
|
||||
@@ -95,12 +95,61 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,6 +677,6 @@ Object.assign(erpnext.journal_entry, {
|
||||
} else {
|
||||
erpnext.journal_entry.set_debit_credit_in_company_currency(frm, cdt, cdn);
|
||||
}
|
||||
frm.refresh_field("accounts");
|
||||
frm.get_field("accounts").grid.refresh_row(cdn);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ 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))
|
||||
@@ -78,6 +79,8 @@ 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
|
||||
@@ -118,6 +121,8 @@ 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")})
|
||||
|
||||
|
||||
@@ -318,9 +318,8 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
# the guard must not disclose the reversal to a user who cannot read the entry
|
||||
frappe.set_user("Guest")
|
||||
self.addCleanup(frappe.set_user, "Administrator")
|
||||
self.assertRaises(frappe.PermissionError, make_reverse_journal_entry, rjv.name)
|
||||
with self.set_user("Guest"):
|
||||
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
|
||||
|
||||
@@ -56,7 +56,9 @@ class LedgerMerge(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def form_start_merge(docname: str):
|
||||
return frappe.get_doc("Ledger Merge", docname).start_merge()
|
||||
lm_doc = frappe.get_doc("Ledger Merge", docname)
|
||||
lm_doc.check_permission("write")
|
||||
return lm_doc.start_merge()
|
||||
|
||||
|
||||
def start_merge(docname):
|
||||
|
||||
@@ -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": "2024-03-27 13:10:03.361383",
|
||||
"modified": "2026-08-21 23:11:44.144864",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Loyalty Program",
|
||||
@@ -171,11 +171,20 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
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"))
|
||||
|
||||
@unittest.mock.patch("erpnext.accounts.doctype.loyalty_program.loyalty_program.get_loyalty_details")
|
||||
@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(
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-14 18:16:47.795986",
|
||||
"modified": "2026-08-21 23:11:44.763131",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Mode of Payment",
|
||||
@@ -76,6 +76,30 @@
|
||||
{
|
||||
"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,
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"icon": "fa fa-bar-chart",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:05.873547",
|
||||
"modified": "2026-08-21 23:11:44.908490",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Monthly Distribution",
|
||||
@@ -69,9 +69,14 @@
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts Manager"
|
||||
},
|
||||
{
|
||||
"role": "Sales Master Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,9 @@ 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:
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
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
|
||||
|
||||
@@ -126,6 +128,55 @@ 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
|
||||
|
||||
@@ -1279,8 +1279,14 @@ 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,
|
||||
base_paid_amount - base_received_amount - other_deductions,
|
||||
get_deduction_amount_precision()
|
||||
);
|
||||
|
||||
@@ -1857,11 +1863,19 @@ frappe.ui.form.on("Payment Entry Deduction", {
|
||||
},
|
||||
|
||||
amount: function (frm) {
|
||||
frm.events.set_unallocated_amount(frm);
|
||||
if (frm.doc.payment_type === "Internal Transfer") {
|
||||
frm.events.set_exchange_gain_loss_deduction(frm);
|
||||
} else {
|
||||
frm.events.set_unallocated_amount(frm);
|
||||
}
|
||||
},
|
||||
|
||||
deductions_remove: function (frm) {
|
||||
frm.events.set_unallocated_amount(frm);
|
||||
if (frm.doc.payment_type === "Internal Transfer") {
|
||||
frm.events.set_exchange_gain_loss_deduction(frm);
|
||||
} else {
|
||||
frm.events.set_unallocated_amount(frm);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -278,7 +278,8 @@ class PaymentEntry(AccountsController):
|
||||
if not liability_account:
|
||||
throw(
|
||||
_("Please set default {0} in Company {1}").format(
|
||||
frappe.bold(frappe.get_meta("Company").get_label(fieldname)), frappe.bold(self.company)
|
||||
frappe.bold(frappe.get_meta("Company").get_translated_label(fieldname)),
|
||||
frappe.bold(self.company),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -661,7 +662,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_label(field))))
|
||||
frappe.throw(_("{0} is mandatory").format(self.meta.get_translated_label(field)))
|
||||
|
||||
def validate_reference_documents(self):
|
||||
valid_reference_doctypes = self.get_valid_reference_doctypes()
|
||||
@@ -1117,8 +1118,14 @@ 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,
|
||||
self.base_paid_amount - self.base_received_amount - other_deductions,
|
||||
self.precision("amount", "deductions"),
|
||||
)
|
||||
|
||||
@@ -1151,7 +1158,7 @@ class PaymentEntry(AccountsController):
|
||||
if value:
|
||||
continue
|
||||
|
||||
label = _(frappe.get_meta("Company").get_label(fieldname))
|
||||
label = frappe.get_meta("Company").get_translated_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)
|
||||
@@ -2619,7 +2626,11 @@ 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)))
|
||||
|
||||
@@ -789,7 +789,6 @@ 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"
|
||||
@@ -834,7 +833,6 @@ 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"
|
||||
@@ -870,6 +868,64 @@ 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()
|
||||
|
||||
@@ -1051,8 +1107,6 @@ 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",
|
||||
|
||||
@@ -92,6 +92,7 @@ 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)
|
||||
|
||||
|
||||
|
||||
@@ -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_label(fieldname))))
|
||||
frappe.throw(_("Please select {0} first").format(self.meta.get_translated_label(fieldname)))
|
||||
|
||||
def validate_entries(self):
|
||||
if not self.get("invoices"):
|
||||
|
||||
@@ -201,8 +201,6 @@ 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):
|
||||
@@ -1331,15 +1329,6 @@ 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
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ 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,
|
||||
|
||||
@@ -875,6 +875,7 @@ 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"),
|
||||
@@ -906,12 +907,24 @@ 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(
|
||||
{
|
||||
@@ -1225,20 +1238,25 @@ def get_dummy_message(doc):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
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
|
||||
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",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -1341,6 +1359,7 @@ 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 []
|
||||
|
||||
@@ -11,15 +11,27 @@ 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 make_payment_request
|
||||
from erpnext.accounts.doctype.payment_request.payment_request import (
|
||||
get_subscription_details,
|
||||
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"},
|
||||
@@ -62,6 +74,18 @@ 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:
|
||||
@@ -80,24 +104,11 @@ class TestPaymentRequest(ERPNextTestSuite):
|
||||
):
|
||||
frappe.get_doc(method).insert(ignore_permissions=True)
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
def test_payment_request_linkings(self):
|
||||
so_inr = make_sales_order(currency="INR", do_not_save=True)
|
||||
@@ -2009,3 +2020,140 @@ 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), [])
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:11.511137",
|
||||
"modified": "2026-08-21 23:11:45.693762",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Term",
|
||||
@@ -157,11 +157,36 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,12 +263,15 @@ 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
|
||||
|
||||
|
||||
@@ -21,13 +21,12 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestPOSClosingEntry(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
init_user_and_profile()
|
||||
self.test_user, self.pos_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):
|
||||
test_user, pos_profile = init_user_and_profile()
|
||||
opening_entry = create_opening_entry(pos_profile, test_user.name)
|
||||
opening_entry = create_opening_entry(self.pos_profile, self.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})
|
||||
@@ -59,8 +58,7 @@ class TestPOSClosingEntry(ERPNextTestSuite):
|
||||
"""
|
||||
Test if POS Closing Entry is created without item code
|
||||
"""
|
||||
test_user, pos_profile = init_user_and_profile()
|
||||
opening_entry = create_opening_entry(pos_profile, test_user.name)
|
||||
opening_entry = create_opening_entry(self.pos_profile, self.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})
|
||||
@@ -79,10 +77,9 @@ class TestPOSClosingEntry(ERPNextTestSuite):
|
||||
"""
|
||||
from erpnext.accounts.doctype.pos_invoice.pos_invoice import make_sales_return
|
||||
|
||||
test_user, pos_profile = init_user_and_profile()
|
||||
opening_entry = create_opening_entry(pos_profile, test_user.name)
|
||||
opening_entry = create_opening_entry(self.pos_profile, self.test_user.name)
|
||||
|
||||
test_item_qty = get_test_item_qty(pos_profile)
|
||||
test_item_qty = get_test_item_qty(self.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})
|
||||
@@ -104,13 +101,11 @@ class TestPOSClosingEntry(ERPNextTestSuite):
|
||||
pcv_doc.flags.in_test = True
|
||||
pcv_doc.submit()
|
||||
|
||||
opening_entry = create_opening_entry(pos_profile, test_user.name)
|
||||
test_item_qty_after_sales = get_test_item_qty(pos_profile)
|
||||
test_item_qty_after_sales = get_test_item_qty(self.pos_profile)
|
||||
self.assertEqual(test_item_qty_after_sales, test_item_qty - 1)
|
||||
|
||||
def test_cancelling_of_pos_closing_entry(self):
|
||||
test_user, pos_profile = init_user_and_profile()
|
||||
opening_entry = create_opening_entry(pos_profile, test_user.name)
|
||||
opening_entry = create_opening_entry(self.pos_profile, self.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})
|
||||
@@ -169,9 +164,7 @@ class TestPOSClosingEntry(ERPNextTestSuite):
|
||||
pos_profile.insert()
|
||||
self.assertTrue(frappe.db.exists("POS Profile", pos_profile.name))
|
||||
|
||||
test_user = init_user_and_profile(do_not_create_pos_profile=1)
|
||||
|
||||
opening_entry = create_opening_entry(pos_profile, test_user.name)
|
||||
opening_entry = create_opening_entry(pos_profile, self.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()
|
||||
@@ -195,9 +188,6 @@ 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(
|
||||
@@ -220,8 +210,7 @@ class TestPOSClosingEntry(ERPNextTestSuite):
|
||||
)
|
||||
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
|
||||
|
||||
test_user, pos_profile = init_user_and_profile()
|
||||
opening_entry = create_opening_entry(pos_profile, test_user.name)
|
||||
opening_entry = create_opening_entry(self.pos_profile, self.test_user.name)
|
||||
|
||||
pos_inv = create_pos_invoice(
|
||||
item_code=item_code,
|
||||
@@ -291,18 +280,17 @@ class TestPOSClosingEntry(ERPNextTestSuite):
|
||||
|
||||
@ERPNextTestSuite.change_settings("POS Settings", {"invoice_type": "Sales Invoice"})
|
||||
def test_closing_entries_with_sales_invoice(self):
|
||||
test_user, pos_profile = init_user_and_profile()
|
||||
opening_entry = create_opening_entry(pos_profile, test_user.name)
|
||||
opening_entry = create_opening_entry(self.pos_profile, self.test_user.name)
|
||||
|
||||
pos_si = create_sales_invoice(
|
||||
qty=10, is_created_using_pos=1, pos_profile=pos_profile.name, do_not_save=1
|
||||
qty=10, is_created_using_pos=1, pos_profile=self.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=pos_profile.name, do_not_save=11
|
||||
qty=5, is_created_using_pos=1, pos_profile=self.pos_profile.name, do_not_save=11
|
||||
)
|
||||
pos_si2.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000})
|
||||
pos_si2.save()
|
||||
@@ -332,12 +320,10 @@ class TestPOSClosingEntry(ERPNextTestSuite):
|
||||
"""
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
|
||||
|
||||
test_user, pos_profile = init_user_and_profile()
|
||||
|
||||
with self.change_settings("POS Settings", {"invoice_type": "Sales Invoice"}):
|
||||
opening_entry1 = create_opening_entry(pos_profile, test_user.name)
|
||||
opening_entry1 = create_opening_entry(self.pos_profile, self.test_user.name)
|
||||
|
||||
pos_si1, pos_si2 = create_multiple_sales_invoices(pos_profile)
|
||||
pos_si1, pos_si2 = create_multiple_sales_invoices(self.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})
|
||||
@@ -357,13 +343,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(pos_profile, test_user.name)
|
||||
opening_entry2 = create_opening_entry(self.pos_profile, self.test_user.name)
|
||||
|
||||
pos_inv1, pos_inv2 = create_multiple_pos_invoices(pos_profile)
|
||||
pos_inv1, pos_inv2 = create_multiple_pos_invoices(self.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=pos_profile.name, do_not_save=1
|
||||
qty=1, is_created_using_pos=1, pos_profile=self.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)
|
||||
@@ -394,16 +380,14 @@ class TestPOSClosingEntry(ERPNextTestSuite):
|
||||
"""
|
||||
from erpnext.accounts.doctype.pos_invoice.pos_invoice import make_sales_return
|
||||
|
||||
test_user, pos_profile = init_user_and_profile()
|
||||
|
||||
with self.change_settings("POS Settings", {"invoice_type": "POS Invoice"}):
|
||||
opening_entry1 = create_opening_entry(pos_profile, test_user.name)
|
||||
opening_entry1 = create_opening_entry(self.pos_profile, self.test_user.name)
|
||||
|
||||
pos_inv1, pos_inv2 = create_multiple_pos_invoices(pos_profile)
|
||||
pos_inv1, pos_inv2 = create_multiple_pos_invoices(self.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=pos_profile.name, do_not_save=1
|
||||
qty=1, is_created_using_pos=1, pos_profile=self.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)
|
||||
@@ -421,9 +405,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(pos_profile, test_user.name)
|
||||
opening_entry2 = create_opening_entry(self.pos_profile, self.test_user.name)
|
||||
|
||||
pos_si1, pos_si2 = create_multiple_sales_invoices(pos_profile)
|
||||
pos_si1, pos_si2 = create_multiple_sales_invoices(self.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})
|
||||
|
||||
@@ -1643,7 +1643,7 @@
|
||||
"icon": "fa fa-file-text",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified": "2026-08-21 23:11:45.029925",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice",
|
||||
@@ -1686,6 +1686,14 @@
|
||||
"permlevel": 1,
|
||||
"read": 1,
|
||||
"role": "All"
|
||||
},
|
||||
{
|
||||
"role": "Sales Manager",
|
||||
"select": 1
|
||||
},
|
||||
{
|
||||
"role": "Sales User",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -4,6 +4,7 @@ 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,
|
||||
@@ -53,14 +54,14 @@ class TestPOSInvoice(POSInvoiceTestMixin):
|
||||
|
||||
w2 = frappe.get_doc(w.doctype, w.name)
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(1)
|
||||
w.save()
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(1)
|
||||
frappe.db.set_value(
|
||||
w.doctype,
|
||||
w.name,
|
||||
"modified",
|
||||
add_to_date(w.modified, seconds=1),
|
||||
update_modified=False,
|
||||
)
|
||||
self.assertRaises(frappe.TimestampMismatchError, w2.save)
|
||||
|
||||
def test_change_naming_series(self):
|
||||
@@ -902,9 +903,6 @@ 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
|
||||
|
||||
@@ -916,8 +914,6 @@ 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],
|
||||
@@ -931,13 +927,9 @@ 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
|
||||
|
||||
init_user_and_profile()
|
||||
from erpnext.stock.utils import get_stock_balance
|
||||
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
@@ -959,9 +951,18 @@ class TestPOSInvoice(POSInvoiceTestMixin):
|
||||
is_stock_item=1,
|
||||
)
|
||||
|
||||
# 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)
|
||||
# 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,
|
||||
)
|
||||
|
||||
# Create Product Bundle: Test Bundle (SubA x2 + SubB x1)
|
||||
bundle_item = "_Test Bundle"
|
||||
@@ -1010,16 +1011,19 @@ class TestPOSInvoice(POSInvoiceTestMixin):
|
||||
|
||||
def create_pos_invoice(**args):
|
||||
args = frappe._dict(args)
|
||||
pos_profile = None
|
||||
if not args.pos_profile:
|
||||
pos_profile = make_pos_profile()
|
||||
pos_profile.save()
|
||||
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_inv = frappe.new_doc("POS Invoice")
|
||||
pos_inv.update(args)
|
||||
pos_inv.update_stock = 1
|
||||
pos_inv.is_pos = 1
|
||||
pos_inv.pos_profile = args.pos_profile or pos_profile.name
|
||||
pos_inv.pos_profile = pos_profile_name
|
||||
|
||||
if args.posting_date:
|
||||
pos_inv.set_posting_time = 1
|
||||
|
||||
@@ -26,14 +26,10 @@ 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()
|
||||
@@ -55,14 +51,10 @@ 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(
|
||||
@@ -107,9 +99,6 @@ 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,
|
||||
)
|
||||
@@ -121,7 +110,6 @@ 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(
|
||||
|
||||
@@ -582,7 +582,7 @@
|
||||
"link_fieldname": "pos_profile"
|
||||
}
|
||||
],
|
||||
"modified": "2026-02-10 14:24:48.597412",
|
||||
"modified": "2026-08-21 23:11:45.419667",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Profile",
|
||||
@@ -606,6 +606,10 @@
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts User"
|
||||
},
|
||||
{
|
||||
"role": "Sales Manager",
|
||||
"select": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -289,6 +289,11 @@ 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")
|
||||
|
||||
@@ -298,6 +303,7 @@ 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()
|
||||
@@ -314,6 +320,7 @@ 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()
|
||||
)
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
{
|
||||
"fieldname": "fieldname",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Fieldname"
|
||||
"in_list_view": 1,
|
||||
"label": "Fieldname",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "field",
|
||||
@@ -26,7 +27,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-29 18:08:40.323579",
|
||||
"modified": "2026-08-31 20:41:12.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Search Fields",
|
||||
|
||||
@@ -1,40 +1,9 @@
|
||||
// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
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",
|
||||
];
|
||||
function is_valid_invoice_field(df) {
|
||||
return frappe.model.no_value_type.indexOf(df.fieldtype) === -1 || df.fieldtype === "Button";
|
||||
}
|
||||
|
||||
frappe.ui.form.on("POS Settings", {
|
||||
onload: function (frm) {
|
||||
@@ -42,74 +11,97 @@ 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) {
|
||||
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;
|
||||
}
|
||||
});
|
||||
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);
|
||||
|
||||
frm.fields_dict.invoice_fields.grid.update_docfield_property(
|
||||
"fieldname",
|
||||
"options",
|
||||
[""].concat(fields)
|
||||
[""].concat(
|
||||
fields.map((df) => {
|
||||
return { label: `${df.label} (${df.fieldtype})`, value: df.fieldname };
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
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.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;
|
||||
}
|
||||
});
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.pos_settings.pos_settings.get_pos_search_field_options",
|
||||
callback: ({ message }) => {
|
||||
const fields = message || [];
|
||||
|
||||
fields.unshift("");
|
||||
frm.fields_dict.pos_search_fields.grid.update_docfield_property("field", "options", fields);
|
||||
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))
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("POS Search Fields", {
|
||||
field: function (frm, 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];
|
||||
const doc = frappe.get_doc(doctype, name);
|
||||
|
||||
doc.fieldname = df.fieldname;
|
||||
frm.refresh_field("fields");
|
||||
doc.fieldname = frm.searchable_item_fields?.[doc.field] || "";
|
||||
frm.refresh_field("pos_search_fields");
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("POS Field", {
|
||||
fieldname: function (frm, doctype, name) {
|
||||
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];
|
||||
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;
|
||||
|
||||
doc.label = df.label;
|
||||
doc.reqd = df.reqd;
|
||||
doc.options = df.options;
|
||||
doc.fieldtype = df.fieldtype;
|
||||
doc.default_value = df.default;
|
||||
frm.refresh_field("fields");
|
||||
frm.refresh_field("invoice_fields");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,8 +5,46 @@ 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
|
||||
@@ -32,17 +70,10 @@ class POSSettings(Document):
|
||||
if old_doc.invoice_type != self.invoice_type:
|
||||
self.validate_invoice_type()
|
||||
|
||||
self.validate_duplicate_invoice_fields()
|
||||
self.validate_invoice_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)
|
||||
)
|
||||
self.validate_duplicate_pos_search_fields()
|
||||
self.validate_pos_search_fields()
|
||||
|
||||
def validate_invoice_type(self):
|
||||
pos_opening_entries_count = frappe.db.count(
|
||||
@@ -55,3 +86,94 @@ 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()
|
||||
]
|
||||
|
||||
@@ -1,8 +1,135 @@
|
||||
# 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):
|
||||
pass
|
||||
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")
|
||||
|
||||
@@ -187,7 +187,10 @@ 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_label(tocheck))), frappe.MandatoryError)
|
||||
throw(
|
||||
_("{0} is required").format(self.meta.get_translated_label(tocheck)),
|
||||
frappe.MandatoryError,
|
||||
)
|
||||
|
||||
if self.apply_rule_on_other:
|
||||
o_field = "other_" + frappe.scrub(self.apply_rule_on_other)
|
||||
|
||||
@@ -142,6 +142,8 @@ 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()
|
||||
|
||||
@@ -157,6 +159,8 @@ 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()
|
||||
|
||||
@@ -171,6 +175,8 @@ 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()
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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()
|
||||
@@ -52,6 +53,11 @@ 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)
|
||||
@@ -75,15 +81,13 @@ def make_purchase_receipt(
|
||||
or {}
|
||||
)
|
||||
|
||||
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)
|
||||
)
|
||||
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)
|
||||
|
||||
def select_item(d):
|
||||
filtered_items = args.get("filtered_children", [])
|
||||
@@ -113,7 +117,8 @@ def make_purchase_receipt(
|
||||
"wip_composite_asset": "wip_composite_asset",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
|
||||
"condition": lambda doc: abs(received_and_mapped_qty(doc)) < abs(doc.qty)
|
||||
and select_item(doc),
|
||||
},
|
||||
"Purchase Taxes and Charges": {
|
||||
"doctype": "Purchase Taxes and Charges",
|
||||
|
||||
@@ -1696,7 +1696,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified": "2026-08-21 23:11:46.733125",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
@@ -1749,6 +1749,18 @@
|
||||
"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",
|
||||
|
||||
@@ -235,6 +235,9 @@ 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()
|
||||
|
||||
@@ -130,6 +130,9 @@ 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)
|
||||
@@ -270,25 +273,34 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
|
||||
# Amount added through landed-cost-voucher
|
||||
if landed_cost_entries:
|
||||
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,
|
||||
)
|
||||
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")
|
||||
)
|
||||
)
|
||||
|
||||
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):
|
||||
|
||||
@@ -578,17 +578,7 @@ 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",
|
||||
@@ -616,16 +606,7 @@ 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"
|
||||
@@ -3545,7 +3526,6 @@ 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",
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
"icon": "fa fa-money",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:26.945131",
|
||||
"modified": "2026-08-21 23:11:47.506282",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Taxes and Charges Template",
|
||||
@@ -104,12 +104,29 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ 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:
|
||||
|
||||
@@ -278,6 +278,9 @@ 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"""
|
||||
@@ -614,6 +617,7 @@ 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",
|
||||
|
||||
@@ -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.change_amount)
|
||||
payment_mode.base_amount -= flt(doc.base_change_amount)
|
||||
|
||||
if payment_mode.base_amount:
|
||||
# POS, make payment entries
|
||||
|
||||
@@ -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, cint, flt, format_date, getdate, nowdate, today
|
||||
from frappe.utils import add_days, add_to_date, 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()
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(1)
|
||||
frappe.db.set_value(
|
||||
w.doctype,
|
||||
w.name,
|
||||
"modified",
|
||||
add_to_date(w.modified, seconds=1),
|
||||
update_modified=False,
|
||||
)
|
||||
self.assertRaises(frappe.TimestampMismatchError, w2.save)
|
||||
|
||||
def test_sales_invoice_change_naming_series(self):
|
||||
@@ -1583,6 +1583,35 @@ 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
|
||||
@@ -3817,25 +3846,12 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
# enable common party accounting
|
||||
frappe.db.set_single_value("Accounts Settings", "enable_common_party_accounting", 1)
|
||||
|
||||
# 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()
|
||||
# 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 sales invoice
|
||||
si = create_sales_invoice(
|
||||
@@ -5789,12 +5805,6 @@ 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",
|
||||
@@ -5815,12 +5825,6 @@ 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",
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
"icon": "fa fa-money",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:38.343481",
|
||||
"modified": "2026-08-21 23:11:48.797423",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Taxes and Charges Template",
|
||||
@@ -113,12 +113,33 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
"icon": "fa fa-truck",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-22 14:53:27.315435",
|
||||
"modified": "2026-08-21 23:11:49.532098",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Shipping Rule",
|
||||
@@ -195,6 +195,38 @@
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.utils.data import (
|
||||
@@ -658,23 +659,15 @@ 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")
|
||||
|
||||
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:
|
||||
# Stub transaction recovery so the test can observe the complete iteration in isolation.
|
||||
with patch.object(Subscription, "process", patched), patch.object(frappe.db, "rollback"):
|
||||
process_all([sub1.name, sub2.name])
|
||||
finally:
|
||||
Subscription.process = original_process
|
||||
frappe.db.rollback = original_rollback
|
||||
|
||||
self.assertEqual(processed, [sub1.name, sub2.name])
|
||||
|
||||
@@ -1073,12 +1066,6 @@ 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"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:51.976600",
|
||||
"modified": "2026-08-21 23:11:50.740962",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Tax Category",
|
||||
@@ -68,11 +68,68 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,25 +63,6 @@ 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",
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-30 07:13:51.785735",
|
||||
"modified": "2026-08-21 23:11:50.979676",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Tax Withholding Category",
|
||||
@@ -142,6 +142,26 @@
|
||||
"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",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-29 05:25:50.243710",
|
||||
"modified": "2026-08-21 23:11:51.176158",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Tax Withholding Group",
|
||||
@@ -39,6 +39,30 @@
|
||||
"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",
|
||||
|
||||
@@ -94,10 +94,15 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
options: get_party_type_options(),
|
||||
on_change: function () {
|
||||
frappe.query_report.set_filter_value("party", "");
|
||||
frappe.query_report.toggle_filter_display(
|
||||
"supplier_group",
|
||||
frappe.query_report.get_filter_value("party_type") !== "Supplier"
|
||||
);
|
||||
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", []);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -869,9 +869,7 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertEqual(rows_b[0].future_amount, 50.0)
|
||||
|
||||
def test_sales_person(self):
|
||||
sales_person = frappe.get_doc(
|
||||
{"doctype": "Sales Person", "sales_person_name": "John Clark", "enabled": True}
|
||||
).insert()
|
||||
sales_person = frappe.get_doc("Sales Person", "_Test Sales Person")
|
||||
si = self.create_sales_invoice(do_not_submit=True)
|
||||
si.append("sales_team", {"sales_person": sales_person.name, "allocated_percentage": 100})
|
||||
si.save().submit()
|
||||
@@ -1494,17 +1492,8 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
|
||||
def test_receivable_filtered_by_sales_partner(self):
|
||||
frappe.set_user("Administrator")
|
||||
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()
|
||||
partner_a = "_Test Sales Partner India - 1"
|
||||
partner_b = "_Test Sales Partner India - 2"
|
||||
|
||||
def _si(sales_partner):
|
||||
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True, qty=2)
|
||||
|
||||
@@ -193,16 +193,7 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertEqual(len(rpt_output), 0)
|
||||
|
||||
def test_03_summary_sales_partner_column(self):
|
||||
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()
|
||||
partner = "_Test Sales Partner India - 1"
|
||||
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
|
||||
@@ -21,10 +21,8 @@ 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)
|
||||
|
||||
si = create_sales_invoice(company=self.company)
|
||||
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
|
||||
create_sales_invoice(company=self.company)
|
||||
|
||||
columns, data = execute(
|
||||
frappe._dict(
|
||||
@@ -42,15 +40,6 @@ 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",
|
||||
|
||||
@@ -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 = make_sales_person("_Test Sales Person")
|
||||
sales_person = frappe.get_doc("Sales Person", "_Test Sales Person")
|
||||
|
||||
posting_date = get_first_day(nowdate())
|
||||
qty = 10
|
||||
@@ -1194,19 +1194,3 @@ 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
|
||||
|
||||
@@ -12,7 +12,7 @@ COMPANY = "_Test Company"
|
||||
class TestShareBalanceReport(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.share_type = create_share_type("_Test Share Balance Equity")
|
||||
self.shareholder = create_shareholder("_Test Share Balance Holder", COMPANY)
|
||||
self.shareholder = get_shareholder("Iron Man", COMPANY)
|
||||
|
||||
def test_date_filter_is_mandatory(self):
|
||||
self.assertRaises(frappe.ValidationError, execute, frappe._dict({"shareholder": self.shareholder}))
|
||||
@@ -96,7 +96,7 @@ class TestShareBalanceReport(ERPNextTestSuite):
|
||||
self.assertEqual(row[4], 3000)
|
||||
|
||||
def test_balance_reduces_after_transfer_out(self):
|
||||
other_holder = create_shareholder("_Test Share Balance Holder 2", COMPANY)
|
||||
other_holder = get_shareholder("Thor", COMPANY)
|
||||
create_share_transfer(
|
||||
transfer_type="Issue",
|
||||
to_shareholder=self.shareholder,
|
||||
@@ -187,9 +187,8 @@ def create_share_type(title):
|
||||
return title
|
||||
|
||||
|
||||
def create_shareholder(title, company):
|
||||
shareholder = frappe.get_doc({"doctype": "Shareholder", "title": title, "company": company}).insert()
|
||||
return shareholder.name
|
||||
def get_shareholder(title, company):
|
||||
return frappe.db.get_value("Shareholder", {"title": title, "company": company}, "name")
|
||||
|
||||
|
||||
def create_share_transfer(**kwargs):
|
||||
|
||||
@@ -23,7 +23,7 @@ COL_SHARE_TRANSFER = 8
|
||||
|
||||
class TestShareLedger(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.shareholder = self.create_shareholder("_Test Share Ledger Holder")
|
||||
self.shareholder = self.get_shareholder("Iron Man")
|
||||
# Issue 100 shares on 2026-06-01, then another 50 on 2026-06-10.
|
||||
self.first = self.issue_shares(date="2026-06-01", from_no=1, to_no=100, rate=10)
|
||||
self.second = self.issue_shares(date="2026-06-10", from_no=101, to_no=150, rate=12)
|
||||
@@ -72,7 +72,7 @@ class TestShareLedger(ERPNextTestSuite):
|
||||
self.assertEqual(data[0][COL_NO_OF_SHARES], 100)
|
||||
|
||||
def test_transfer_type_label_when_shareholder_is_seller(self):
|
||||
buyer = self.create_shareholder("_Test Share Ledger Buyer")
|
||||
buyer = self.get_shareholder("Thor")
|
||||
transfer = self.make_transfer(
|
||||
from_shareholder=self.shareholder,
|
||||
to_shareholder=buyer,
|
||||
@@ -87,7 +87,7 @@ class TestShareLedger(ERPNextTestSuite):
|
||||
self.assertEqual(row[COL_TRANSFER_TYPE], f"Transfer to {buyer}")
|
||||
|
||||
def test_transfer_type_label_when_shareholder_is_buyer(self):
|
||||
seller = self.create_shareholder("_Test Share Ledger Seller")
|
||||
seller = self.get_shareholder("Hulk")
|
||||
# the seller must own shares before it can transfer them
|
||||
self.issue_shares(date="2026-06-12", from_no=201, to_no=300, rate=10, shareholder=seller)
|
||||
transfer = self.make_transfer(
|
||||
@@ -119,15 +119,8 @@ class TestShareLedger(ERPNextTestSuite):
|
||||
self.assertIsNotNone(row, f"Share Transfer {transfer_name} missing from ledger")
|
||||
return row
|
||||
|
||||
def create_shareholder(self, title):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Shareholder",
|
||||
"title": title,
|
||||
"company": COMPANY,
|
||||
}
|
||||
).insert()
|
||||
return doc.name
|
||||
def get_shareholder(self, title):
|
||||
return frappe.db.get_value("Shareholder", {"title": title, "company": COMPANY}, "name")
|
||||
|
||||
def issue_shares(self, date, from_no, to_no, rate, shareholder=None):
|
||||
doc = frappe.get_doc(
|
||||
|
||||
@@ -9,6 +9,7 @@ from frappe.utils import cint, flt
|
||||
|
||||
from erpnext.accounts.report.general_ledger.general_ledger import get_accounts_with_children
|
||||
from erpnext.accounts.report.trial_balance.trial_balance import validate_filters
|
||||
from erpnext.accounts.utils import get_currency_precision
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -43,6 +44,7 @@ def get_data(filters, show_party_name):
|
||||
account_filter = get_accounts_with_children(filters.get("account"))
|
||||
|
||||
company_currency = frappe.get_cached_value("Company", filters.company, "default_currency")
|
||||
precision = get_currency_precision()
|
||||
opening_balances = get_opening_balances(filters, account_filter)
|
||||
balances_within_period = get_balances_within_period(filters, account_filter)
|
||||
|
||||
@@ -65,14 +67,17 @@ def get_data(filters, show_party_name):
|
||||
|
||||
# opening
|
||||
opening_debit, opening_credit = opening_balances.get(party.name, [0, 0])
|
||||
opening_debit, opening_credit = flt(opening_debit, precision), flt(opening_credit, precision)
|
||||
row.update({"opening_debit": opening_debit, "opening_credit": opening_credit})
|
||||
|
||||
# within period
|
||||
debit, credit = balances_within_period.get(party.name, [0, 0])
|
||||
debit, credit = flt(debit, precision), flt(credit, precision)
|
||||
row.update({"debit": debit, "credit": credit})
|
||||
|
||||
# closing
|
||||
closing_debit, closing_credit = toggle_debit_credit(opening_debit + debit, opening_credit + credit)
|
||||
closing_debit, closing_credit = flt(closing_debit, precision), flt(closing_credit, precision)
|
||||
row.update({"closing_debit": closing_debit, "closing_credit": closing_credit})
|
||||
|
||||
row.update({"currency": company_currency})
|
||||
|
||||
@@ -309,6 +309,9 @@ def get_payment_entries(filters, args):
|
||||
pe.mode_of_payment,
|
||||
pe.project,
|
||||
pe.cost_center,
|
||||
pe.payment_type,
|
||||
pe.source_exchange_rate,
|
||||
pe.target_exchange_rate,
|
||||
)
|
||||
.where(
|
||||
(pe.docstatus == 1)
|
||||
@@ -319,6 +322,22 @@ def get_payment_entries(filters, args):
|
||||
)
|
||||
query = apply_common_conditions(filters, query, doctype="Payment Entry", payments=True)
|
||||
payment_entries = query.run(as_dict=True)
|
||||
|
||||
if payment_entries:
|
||||
ded = frappe.qb.DocType("Payment Entry Deduction")
|
||||
deduction_totals = frappe._dict(
|
||||
frappe.qb.from_(ded)
|
||||
.select(ded.parent, Sum(ded.amount))
|
||||
.where(ded.parent.isin([d.name for d in payment_entries]) & (ded.is_exchange_gain_loss == 0))
|
||||
.groupby(ded.parent)
|
||||
.run()
|
||||
)
|
||||
for d in payment_entries:
|
||||
exchange_rate = (
|
||||
d.source_exchange_rate if d.payment_type == "Receive" else d.target_exchange_rate
|
||||
) or 1
|
||||
d.base_grand_total = flt(d.base_grand_total) + flt(deduction_totals.get(d.name)) / exchange_rate
|
||||
|
||||
return payment_entries
|
||||
|
||||
|
||||
|
||||
@@ -67,9 +67,12 @@ def get_gl_dict(doc, args: dict, account_currency: str | None = None, item=None)
|
||||
accounting_dimensions = get_accounting_dimensions()
|
||||
dimension_dict = frappe._dict()
|
||||
for dimension in accounting_dimensions:
|
||||
dimension_dict[dimension] = doc.get(dimension)
|
||||
value = doc.get(dimension)
|
||||
if item and item.get(dimension):
|
||||
dimension_dict[dimension] = item.get(dimension)
|
||||
value = item.get(dimension)
|
||||
if isinstance(value, list | dict):
|
||||
continue
|
||||
dimension_dict[dimension] = value
|
||||
|
||||
gl_dict.update(dimension_dict)
|
||||
gl_dict.update(args)
|
||||
@@ -139,8 +142,13 @@ def add_gl_entry(
|
||||
voucher_detail_no: str | None = None,
|
||||
item=None,
|
||||
posting_date=None,
|
||||
dimensions: dict | None = None,
|
||||
) -> None:
|
||||
"""Build a GL entry via get_gl_dict and append it to gl_entries."""
|
||||
"""Build a GL entry via get_gl_dict and append it to gl_entries.
|
||||
|
||||
`dimensions` sets accounting dimensions explicitly, overriding the values `get_gl_dict`
|
||||
would otherwise derive from `item` and the parent document.
|
||||
"""
|
||||
gl_entry = {
|
||||
"account": account,
|
||||
"cost_center": cost_center,
|
||||
@@ -165,6 +173,9 @@ def add_gl_entry(
|
||||
if posting_date:
|
||||
gl_entry["posting_date"] = posting_date
|
||||
|
||||
if dimensions:
|
||||
gl_entry.update(dimensions)
|
||||
|
||||
gl_entries.append(get_gl_dict(doc, gl_entry, account_currency, item=item))
|
||||
|
||||
|
||||
@@ -252,6 +263,7 @@ class BaseGLComposer:
|
||||
voucher_detail_no: str | None = None,
|
||||
item=None,
|
||||
posting_date=None,
|
||||
dimensions: dict | None = None,
|
||||
) -> None:
|
||||
add_gl_entry(
|
||||
self.doc,
|
||||
@@ -269,4 +281,5 @@ class BaseGLComposer:
|
||||
voucher_detail_no,
|
||||
item,
|
||||
posting_date,
|
||||
dimensions,
|
||||
)
|
||||
|
||||
@@ -32,12 +32,13 @@ class ChildItemUpdater:
|
||||
self.child_docname = child_docname
|
||||
self.parent = frappe.get_doc(parent_doctype, parent_doctype_name)
|
||||
self.allow_zero_qty = get_allow_zero_qty(parent_doctype)
|
||||
self._ordered_items: dict | None = None
|
||||
self._purchased_items: dict | None = None
|
||||
self._transacted_stock_qty: dict | None = None
|
||||
|
||||
def update(self, trans_items: str | list) -> None:
|
||||
"""Process item additions, edits, and deletions from trans_items JSON."""
|
||||
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items
|
||||
from erpnext.buying.doctype.supplier_quotation.mapper import (
|
||||
get_ordered_items as get_ordered_supplier_quotation_items,
|
||||
)
|
||||
from erpnext.selling.doctype.quotation.mapper import get_ordered_items
|
||||
|
||||
data = frappe.parse_json(trans_items)
|
||||
@@ -48,11 +49,15 @@ class ChildItemUpdater:
|
||||
self._check_permissions("write")
|
||||
|
||||
if self.parent_doctype == "Quotation":
|
||||
self._ordered_items = get_ordered_items(self.parent.name)
|
||||
items_added_or_removed |= validate_and_delete_children(self.parent, data, self._ordered_items)
|
||||
self._transacted_stock_qty = get_ordered_items(self.parent.name)
|
||||
items_added_or_removed |= validate_and_delete_children(
|
||||
self.parent, data, self._transacted_stock_qty
|
||||
)
|
||||
elif self.parent_doctype == "Supplier Quotation":
|
||||
self._purchased_items = get_purchased_items(self.parent.name)
|
||||
items_added_or_removed |= validate_and_delete_children(self.parent, data, self._purchased_items)
|
||||
self._transacted_stock_qty = get_ordered_supplier_quotation_items(self.parent.name)
|
||||
items_added_or_removed |= validate_and_delete_children(
|
||||
self.parent, data, self._transacted_stock_qty
|
||||
)
|
||||
else:
|
||||
items_added_or_removed |= validate_and_delete_children(self.parent, data)
|
||||
|
||||
@@ -71,6 +76,7 @@ class ChildItemUpdater:
|
||||
else:
|
||||
self._check_permissions("write")
|
||||
child_item = frappe.get_doc(self.parent_doctype + " Item", d.get("docname"))
|
||||
d["conversion_factor"] = self._get_new_conversion_factor(child_item, d)
|
||||
|
||||
change_state = get_child_item_change_state(self.parent_doctype, child_item, d)
|
||||
rate_unchanged = change_state.rate_unchanged
|
||||
@@ -78,6 +84,13 @@ class ChildItemUpdater:
|
||||
if is_child_item_unchanged(change_state):
|
||||
continue
|
||||
|
||||
if child_item.get("closed"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Cannot change item {1} because it is closed. Reopen the row first."
|
||||
).format(child_item.idx, child_item.item_code)
|
||||
)
|
||||
|
||||
self._validate_quantity_and_rate(child_item, d, rate_unchanged)
|
||||
|
||||
if flt(child_item.get("qty")) != flt(d.get("qty")):
|
||||
@@ -144,6 +157,7 @@ class ChildItemUpdater:
|
||||
if parent.is_against_so():
|
||||
parent.update_status_updater()
|
||||
elif self.parent_doctype == "Sales Order":
|
||||
parent.set_skip_delivery()
|
||||
parent.check_credit_limit()
|
||||
|
||||
for idx, row in enumerate(parent.get(self.child_docname), start=1):
|
||||
@@ -250,6 +264,22 @@ class ChildItemUpdater:
|
||||
item_row,
|
||||
)
|
||||
|
||||
def _get_new_conversion_factor(self, child_item, new_data: dict) -> float:
|
||||
current_factor = flt(child_item.get("conversion_factor")) or 1
|
||||
uom = new_data.get("uom") or child_item.get("uom")
|
||||
|
||||
if uom == child_item.get("stock_uom"):
|
||||
return 1
|
||||
|
||||
requested_factor = flt(new_data.get("conversion_factor"))
|
||||
if requested_factor:
|
||||
return requested_factor
|
||||
|
||||
if uom == child_item.get("uom"):
|
||||
return current_factor
|
||||
|
||||
return flt(get_conversion_factor(child_item.item_code, uom).get("conversion_factor")) or 1
|
||||
|
||||
def _validate_quantity_and_rate(self, child_item, new_data: dict, rate_unchanged: bool | None) -> None:
|
||||
if not flt(new_data.get("qty")) and not self.allow_zero_qty:
|
||||
frappe.throw(
|
||||
@@ -263,24 +293,24 @@ class ChildItemUpdater:
|
||||
"Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity.")),
|
||||
"Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity.")),
|
||||
}
|
||||
old_conversion_factor = flt(child_item.get("conversion_factor")) or 1
|
||||
new_conversion_factor = flt(new_data.get("conversion_factor")) or old_conversion_factor
|
||||
new_stock_qty = flt(new_data.get("qty")) * new_conversion_factor
|
||||
|
||||
if self.parent_doctype in qty_limits:
|
||||
qty_field, error_message = qty_limits[self.parent_doctype]
|
||||
if flt(new_data.get("qty")) < flt(child_item.get(qty_field)):
|
||||
old_stock_qty = flt(child_item.get(qty_field)) * old_conversion_factor
|
||||
if new_stock_qty < old_stock_qty:
|
||||
frappe.throw(
|
||||
_("Row #{0}:").format(new_data.get("idx")) + error_message,
|
||||
title=_("Invalid Qty"),
|
||||
)
|
||||
|
||||
if self.parent_doctype not in ("Quotation", "Supplier Quotation"):
|
||||
if not self._transacted_stock_qty:
|
||||
return
|
||||
|
||||
items_map = self._ordered_items if self.parent_doctype == "Quotation" else self._purchased_items
|
||||
if not items_map:
|
||||
return
|
||||
|
||||
qty_to_check = items_map.get(child_item.name)
|
||||
if not qty_to_check:
|
||||
old_stock_qty = self._transacted_stock_qty.get(child_item.name)
|
||||
if not old_stock_qty:
|
||||
return
|
||||
|
||||
if not rate_unchanged:
|
||||
@@ -290,7 +320,7 @@ class ChildItemUpdater:
|
||||
).format(frappe.bold(new_data.get("item_code")))
|
||||
)
|
||||
|
||||
if flt(new_data.get("qty")) < qty_to_check:
|
||||
if new_stock_qty < old_stock_qty:
|
||||
frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity"))
|
||||
|
||||
def _validate_fg_item_for_subcontracting(self, new_data: dict, is_new: bool) -> None:
|
||||
@@ -457,7 +487,11 @@ def update_bin_on_delete(row, doctype: str) -> None:
|
||||
def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
|
||||
"""Delete child rows not present in data; return True if any were removed."""
|
||||
updated_item_names = [d.get("docname") for d in data]
|
||||
deleted_children = [item for item in parent.items if item.name not in updated_item_names]
|
||||
# A closed row is left out of the payload rather than deleted, so its absence
|
||||
# must not be read as a removal.
|
||||
deleted_children = [
|
||||
item for item in parent.items if item.name not in updated_item_names and not item.get("closed")
|
||||
]
|
||||
|
||||
for d in deleted_children:
|
||||
validate_child_on_delete(d, parent, ordered_item)
|
||||
@@ -580,22 +614,18 @@ def update_child_item_rate_and_discount(
|
||||
|
||||
|
||||
def update_child_item_uom_and_weight(child_item, new_data) -> None:
|
||||
conv_fac_precision = child_item.precision("conversion_factor") or 2
|
||||
|
||||
if new_data.get("conversion_factor"):
|
||||
if child_item.stock_uom == child_item.uom:
|
||||
child_item.conversion_factor = 1
|
||||
else:
|
||||
child_item.conversion_factor = flt(new_data.get("conversion_factor"), conv_fac_precision)
|
||||
child_item.conversion_factor = flt(new_data.get("conversion_factor"))
|
||||
|
||||
if new_data.get("uom"):
|
||||
child_item.uom = new_data.get("uom")
|
||||
conversion_factor = flt(
|
||||
get_conversion_factor(child_item.item_code, child_item.uom).get("conversion_factor")
|
||||
)
|
||||
child_item.conversion_factor = (
|
||||
flt(new_data.get("conversion_factor"), conv_fac_precision) or conversion_factor
|
||||
)
|
||||
child_item.conversion_factor = flt(new_data.get("conversion_factor")) or conversion_factor
|
||||
|
||||
if child_item.get("weight_per_unit"):
|
||||
child_item.total_weight = flt(
|
||||
|
||||
@@ -183,7 +183,7 @@ class PartyValidator:
|
||||
):
|
||||
frappe.throw(
|
||||
_("{0} does not belong to the Company {1}.").format(
|
||||
_(doc.meta.get_label(field)), frappe.bold(doc.company)
|
||||
doc.meta.get_translated_label(field), frappe.bold(doc.company)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -53,6 +53,11 @@ class TaxService:
|
||||
if doc.get("taxes") or doc.get("is_pos"):
|
||||
return
|
||||
|
||||
# set by the Opening Invoice Creation Tool, where the outstanding amount
|
||||
# entered against a party is already inclusive of tax
|
||||
if doc.flags.dont_auto_add_taxes:
|
||||
return
|
||||
|
||||
if frappe.get_single_value(
|
||||
"Accounts Settings", "add_taxes_from_taxes_and_charges_template"
|
||||
) and hasattr(doc, "taxes_and_charges"):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user