mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-10 08:23:01 +00:00
Compare commits
263 Commits
v16.19.1
...
version-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c8fff648f | ||
|
|
1bfb0c8e5a | ||
|
|
9dd0a9fd90 | ||
|
|
018f06d8d1 | ||
|
|
4fe7e958bf | ||
|
|
e15879acd1 | ||
|
|
01e90830f1 | ||
|
|
1c0dace3d6 | ||
|
|
e48ffe6ef0 | ||
|
|
8ff6fc5fea | ||
|
|
e90a6ecf1c | ||
|
|
9a6fae9fdd | ||
|
|
7904385b90 | ||
|
|
f0e51b35a6 | ||
|
|
60a270acd4 | ||
|
|
b4c850da1c | ||
|
|
9b1cdd0e8f | ||
|
|
26a19bf372 | ||
|
|
10cfac865e | ||
|
|
02a29a85a7 | ||
|
|
6e7a96eae9 | ||
|
|
dd837052ef | ||
|
|
95818ec71d | ||
|
|
3d1327cfe5 | ||
|
|
b617f1302d | ||
|
|
c9a5b0026e | ||
|
|
c5434e39d8 | ||
|
|
4453c1072a | ||
|
|
8e930d0ef1 | ||
|
|
e6f6f2c173 | ||
|
|
2ae6451f10 | ||
|
|
497c3a5e83 | ||
|
|
5efeb2af18 | ||
|
|
a03e3bfe9f | ||
|
|
d941ccfe3c | ||
|
|
ecd3a19912 | ||
|
|
8f85cce4cf | ||
|
|
fa08501045 | ||
|
|
aaf2531a4e | ||
|
|
7a23a9347f | ||
|
|
f43af66246 | ||
|
|
38dd2982f3 | ||
|
|
4471666c8c | ||
|
|
c1b47bd561 | ||
|
|
c6560be58d | ||
|
|
76b9b6a34e | ||
|
|
c0cf9aa1a7 | ||
|
|
0f069e13da | ||
|
|
a761a98e3a | ||
|
|
a9926871d6 | ||
|
|
0785fa0257 | ||
|
|
86ee17614c | ||
|
|
f657503ea3 | ||
|
|
e8a6933ff3 | ||
|
|
d8afc00ab5 | ||
|
|
18afcf0c01 | ||
|
|
90ac065930 | ||
|
|
b3d3f13fc5 | ||
|
|
04fe76bf83 | ||
|
|
6d038c5e71 | ||
|
|
254290a88e | ||
|
|
a403e85918 | ||
|
|
7c46298fb6 | ||
|
|
6eecf0701e | ||
|
|
cfe5d07b4c | ||
|
|
e7a4d6451e | ||
|
|
12c1940e0b | ||
|
|
32011c3364 | ||
|
|
3f983c9e4d | ||
|
|
bda915a201 | ||
|
|
743afc972d | ||
|
|
84d205f553 | ||
|
|
1823fbea37 | ||
|
|
f7e6542bcd | ||
|
|
3917415368 | ||
|
|
dee7bd8d64 | ||
|
|
e112a1933a | ||
|
|
80f7aff3f9 | ||
|
|
808e51db19 | ||
|
|
0274afe560 | ||
|
|
6a503f834c | ||
|
|
7de77a8916 | ||
|
|
142ab3ce2a | ||
|
|
9a2549dc32 | ||
|
|
40cf77e7f0 | ||
|
|
6ff9685881 | ||
|
|
f6f542fadc | ||
|
|
19c47abb79 | ||
|
|
7dfae51044 | ||
|
|
a563d01425 | ||
|
|
5d7e69d8cf | ||
|
|
6c05625e65 | ||
|
|
eaadb15bd5 | ||
|
|
8f9db3c72d | ||
|
|
37b61f06ae | ||
|
|
201e62195f | ||
|
|
20af7093ac | ||
|
|
44adfbea33 | ||
|
|
9eb0e3c82e | ||
|
|
0984c86583 | ||
|
|
90667b2de2 | ||
|
|
f6791d5bcf | ||
|
|
93de14c421 | ||
|
|
107a446d98 | ||
|
|
af97849c7b | ||
|
|
a7d42a4edd | ||
|
|
4f2611cbe8 | ||
|
|
91a64692f9 | ||
|
|
3fb6437d26 | ||
|
|
bfc6f44fb0 | ||
|
|
fffba84868 | ||
|
|
6a1c384f9b | ||
|
|
d57e86362a | ||
|
|
1521410125 | ||
|
|
81d10d32f2 | ||
|
|
2213c1ffad | ||
|
|
37814bf6cd | ||
|
|
ceb10422ae | ||
|
|
072a853fad | ||
|
|
0bbc493213 | ||
|
|
7a7cc31523 | ||
|
|
61d6d2f344 | ||
|
|
b89a34970b | ||
|
|
0fe44e1a67 | ||
|
|
4c05ebc21e | ||
|
|
224426e06b | ||
|
|
980cefa169 | ||
|
|
8d12a89558 | ||
|
|
d1b2425b2b | ||
|
|
1679680d8e | ||
|
|
51a140a2bd | ||
|
|
13e1159c41 | ||
|
|
056f622634 | ||
|
|
fe585dc225 | ||
|
|
5393c93675 | ||
|
|
fdf5439ece | ||
|
|
de1256467f | ||
|
|
1029ef46a7 | ||
|
|
c59dc684fe | ||
|
|
d2bbf6d32b | ||
|
|
0c946f2420 | ||
|
|
fc842fb45f | ||
|
|
ef2700bec6 | ||
|
|
7c5d617049 | ||
|
|
2f51c48fd8 | ||
|
|
aa5dfde23b | ||
|
|
ad267ec295 | ||
|
|
5c51145984 | ||
|
|
277a0723ef | ||
|
|
36a54ca40b | ||
|
|
7426aaf1e2 | ||
|
|
2feb8eb370 | ||
|
|
22c26f42e9 | ||
|
|
75465035ae | ||
|
|
e1f29de078 | ||
|
|
26d94c5594 | ||
|
|
542aff2677 | ||
|
|
db4e459a5a | ||
|
|
e42c3961a9 | ||
|
|
63aff0098c | ||
|
|
6d747420a3 | ||
|
|
d82e03edb6 | ||
|
|
2d554c05d6 | ||
|
|
876995a35f | ||
|
|
abe19e1212 | ||
|
|
bfc8683e52 | ||
|
|
84ce7c720e | ||
|
|
29441b7249 | ||
|
|
41b2de35a9 | ||
|
|
faa15731cb | ||
|
|
0ce697b170 | ||
|
|
933ac0108c | ||
|
|
b74e365421 | ||
|
|
20592fc25d | ||
|
|
4ac003a804 | ||
|
|
08eaaa5b83 | ||
|
|
7cbef15596 | ||
|
|
3a7af0e59d | ||
|
|
65b6da552e | ||
|
|
91dcb96307 | ||
|
|
aa6c45bae1 | ||
|
|
8c4d5d343b | ||
|
|
8b1d9817a6 | ||
|
|
82e12d2d52 | ||
|
|
3359e20d06 | ||
|
|
198970cdee | ||
|
|
9919b3d75c | ||
|
|
ae92a82930 | ||
|
|
8b4ad229e7 | ||
|
|
64ee4b8d99 | ||
|
|
356bb7878f | ||
|
|
8925a6527b | ||
|
|
cd7e1bbff1 | ||
|
|
4dff5a7820 | ||
|
|
1781893c3c | ||
|
|
304de47b48 | ||
|
|
67c922cdf3 | ||
|
|
bd4c24493c | ||
|
|
6925b6b645 | ||
|
|
3ce9cf2bd8 | ||
|
|
0ede7759df | ||
|
|
1f14ef2344 | ||
|
|
27db98d222 | ||
|
|
82b0372d5b | ||
|
|
69c6ed3cd9 | ||
|
|
ca6bcb57d3 | ||
|
|
03d96c7b85 | ||
|
|
bfd37dcc21 | ||
|
|
9600ecd61c | ||
|
|
5dc37b1130 | ||
|
|
a19611a2e9 | ||
|
|
6755101654 | ||
|
|
705814f066 | ||
|
|
1a75c14308 | ||
|
|
e3f03a21c3 | ||
|
|
56d99152f9 | ||
|
|
a618f4cca4 | ||
|
|
7b6adce89a | ||
|
|
7752f703d2 | ||
|
|
7159b13ee2 | ||
|
|
ea60efd91a | ||
|
|
a1457c759d | ||
|
|
ab9e30b487 | ||
|
|
9596e6e1e9 | ||
|
|
1e61ca162f | ||
|
|
edf6bea2ee | ||
|
|
ea863477a4 | ||
|
|
171cd41928 | ||
|
|
d2a793b03b | ||
|
|
3081368aad | ||
|
|
b372e6f118 | ||
|
|
e2558b6e51 | ||
|
|
66c9170465 | ||
|
|
123b4ad563 | ||
|
|
92689e05da | ||
|
|
bde7f1660e | ||
|
|
8a4cb28d90 | ||
|
|
498bd2fb4b | ||
|
|
bfb7a0e941 | ||
|
|
f3f8f327df | ||
|
|
0d816010dd | ||
|
|
a39733ddd0 | ||
|
|
5c9149c5a5 | ||
|
|
99642b9636 | ||
|
|
a63b344a0a | ||
|
|
54be4ee275 | ||
|
|
c45d2a3487 | ||
|
|
82793cbd4d | ||
|
|
620161c526 | ||
|
|
dfc91441b4 | ||
|
|
e630ab64eb | ||
|
|
a7eb3acd1a | ||
|
|
02f4d9a4d6 | ||
|
|
6d8bbc5b6f | ||
|
|
cc438a4600 | ||
|
|
b11365b8c2 | ||
|
|
bb87ffc90a | ||
|
|
17bc2b691f | ||
|
|
36aca51fbb | ||
|
|
56a9b37fac | ||
|
|
05f641d3bc | ||
|
|
830d035459 | ||
|
|
e56ee383bc |
52
.github/helper/merge_po_files.py
vendored
Normal file
52
.github/helper/merge_po_files.py
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Overlay develop's .po translations onto hotfix's .po files.
|
||||
|
||||
Called by sync_hotfix_translations.sh before `bench update-po-files`.
|
||||
Merge rules:
|
||||
a. msgid absent from develop → keep hotfix's existing msgstr
|
||||
b. language not yet in hotfix → copy file as-is (bench will filter to main.pot)
|
||||
c. msgid present in both → use develop's msgstr
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from babel.messages.pofile import read_po, write_po
|
||||
|
||||
DEVELOP = Path("/tmp/develop-po/erpnext/locale/")
|
||||
LOCALE = Path("./apps/erpnext/erpnext/locale/")
|
||||
|
||||
added = updated = 0
|
||||
|
||||
for src in sorted(DEVELOP.glob("*.po")):
|
||||
dst = LOCALE / src.name
|
||||
|
||||
with src.open("rb") as f:
|
||||
dev = read_po(f)
|
||||
|
||||
if not dst.exists():
|
||||
dev.revision_date = datetime.now(timezone.utc)
|
||||
with dst.open("wb") as f:
|
||||
write_po(f, dev)
|
||||
added += 1
|
||||
print(f" [new] {src.name}")
|
||||
continue
|
||||
|
||||
with dst.open("rb") as f:
|
||||
hf = read_po(f)
|
||||
|
||||
changes = 0
|
||||
for msg in hf:
|
||||
if msg.id and msg.id in dev and dev[msg.id].string and dev[msg.id].string != msg.string:
|
||||
msg.string = dev[msg.id].string
|
||||
changes += 1
|
||||
|
||||
if changes:
|
||||
hf.revision_date = datetime.now(timezone.utc)
|
||||
with dst.open("wb") as f:
|
||||
write_po(f, hf)
|
||||
updated += 1
|
||||
print(f" [updated] {src.name} ({changes} msgstr(s) from develop)")
|
||||
else:
|
||||
print(f" [no-op] {src.name}")
|
||||
|
||||
print(f"\n{added} new language(s), {updated} updated.")
|
||||
121
.github/helper/sync_hotfix_translations.sh
vendored
Normal file
121
.github/helper/sync_hotfix_translations.sh
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/bin/bash
|
||||
# Syncs Crowdin translations from develop to a hotfix branch.
|
||||
# Merge logic: see merge_po_files.py.
|
||||
# Env: GH_TOKEN, PR_REVIEWER, GITHUB_WORKSPACE, APP_NAME, GITHUB_REPOSITORY
|
||||
# (all set by Actions).
|
||||
|
||||
set -e
|
||||
|
||||
HOTFIX_BRANCH="${HOTFIX_BRANCH:?HOTFIX_BRANCH env var is required}"
|
||||
APP_NAME="${APP_NAME:?APP_NAME env var is required}"
|
||||
|
||||
cd ~ || exit
|
||||
|
||||
echo "=== Setting up bench ==="
|
||||
pip install frappe-bench
|
||||
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)"
|
||||
cd ./frappe-bench || exit
|
||||
bench get-app --skip-assets "${APP_NAME}" "${GITHUB_WORKSPACE}"
|
||||
|
||||
echo "=== Setting up sync_translations_${HOTFIX_BRANCH} branch ==="
|
||||
cd "./apps/${APP_NAME}" || exit
|
||||
git config user.email "developers@erpnext.com"
|
||||
git config user.name "frappe-pr-bot"
|
||||
git remote set-url upstream "https://github.com/${GITHUB_REPOSITORY}.git"
|
||||
git config remote.upstream.fetch "+refs/heads/*:refs/remotes/upstream/*"
|
||||
gh auth setup-git
|
||||
git fetch upstream "${HOTFIX_BRANCH}"
|
||||
|
||||
if git ls-remote --exit-code --heads upstream "sync_translations_${HOTFIX_BRANCH}" >/dev/null 2>&1; then
|
||||
git fetch upstream "sync_translations_${HOTFIX_BRANCH}"
|
||||
git checkout -b "sync_translations_${HOTFIX_BRANCH}" "upstream/sync_translations_${HOTFIX_BRANCH}"
|
||||
git merge -X theirs "upstream/${HOTFIX_BRANCH}" --no-edit
|
||||
else
|
||||
git checkout -b "sync_translations_${HOTFIX_BRANCH}" "upstream/${HOTFIX_BRANCH}"
|
||||
fi
|
||||
cd ../.. || exit
|
||||
|
||||
echo "=== Fetching develop's .po files ==="
|
||||
mkdir -p /tmp/develop-po
|
||||
git -C "${GITHUB_WORKSPACE}" fetch origin develop
|
||||
git -C "${GITHUB_WORKSPACE}" archive origin/develop "${APP_NAME}/locale/" \
|
||||
| tar -xf - -C /tmp/develop-po/
|
||||
|
||||
po_count=$(find "/tmp/develop-po/${APP_NAME}/locale" -name "*.po" | wc -l)
|
||||
if [ "${po_count}" -eq 0 ]; then
|
||||
echo "ERROR: No .po files found in develop's archive. Aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Extracted ${po_count} .po file(s) from develop."
|
||||
|
||||
echo "=== Merging and reconciling ==="
|
||||
env/bin/python "${GITHUB_WORKSPACE}/.github/helper/merge_po_files.py"
|
||||
bench update-po-files --app "${APP_NAME}"
|
||||
|
||||
cd "./apps/${APP_NAME}" || exit
|
||||
|
||||
if git diff --quiet "${APP_NAME}/locale/" && [ -z "$(git ls-files --others --exclude-standard "${APP_NAME}/locale/")" ]; then
|
||||
echo "Translations are already up to date. No PR needed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
git diff --name-only "${APP_NAME}/locale/"
|
||||
git ls-files --others --exclude-standard "${APP_NAME}/locale/"
|
||||
|
||||
echo "=== Committing ==="
|
||||
while IFS= read -r file; do
|
||||
git add "${file}"
|
||||
lang=$(basename "${file}" .po)
|
||||
git commit -m "chore: add ${lang} translation to ${HOTFIX_BRANCH}"
|
||||
done < <(git ls-files --others --exclude-standard "${APP_NAME}/locale/" | grep '\.po$' | sort)
|
||||
|
||||
while IFS= read -r file; do
|
||||
git add "${file}"
|
||||
if ! git diff --staged --quiet -- "${file}"; then
|
||||
lang=$(basename "${file}" .po)
|
||||
git commit -m "chore: sync ${lang} translation to ${HOTFIX_BRANCH}"
|
||||
else
|
||||
git restore --staged -- "${file}"
|
||||
fi
|
||||
done < <(git diff --name-only "${APP_NAME}/locale/" | grep '\.po$' | sort)
|
||||
|
||||
if git ls-remote --exit-code --heads upstream "sync_translations_${HOTFIX_BRANCH}" >/dev/null 2>&1; then
|
||||
git fetch upstream "sync_translations_${HOTFIX_BRANCH}"
|
||||
git merge -X ours "upstream/sync_translations_${HOTFIX_BRANCH}" --no-edit
|
||||
fi
|
||||
git push -u upstream sync_translations_${HOTFIX_BRANCH}
|
||||
|
||||
echo "=== Opening PR (if not already open) ==="
|
||||
existing_pr=$(gh pr list \
|
||||
--base "${HOTFIX_BRANCH}" \
|
||||
--head "sync_translations_${HOTFIX_BRANCH}" \
|
||||
--state open \
|
||||
--json number \
|
||||
--jq 'length' \
|
||||
-R "${GITHUB_REPOSITORY}")
|
||||
|
||||
if [ "${existing_pr}" -gt 0 ]; then
|
||||
echo "PR already open — branch updated in place. No new PR needed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh pr create \
|
||||
--base "${HOTFIX_BRANCH}" \
|
||||
--head "sync_translations_${HOTFIX_BRANCH}" \
|
||||
--title "chore: sync translations to ${HOTFIX_BRANCH}" \
|
||||
--body "Automated sync of Crowdin translations from \`develop\` to \`${HOTFIX_BRANCH}\`.
|
||||
|
||||
A 3-way merge is performed per language, then \`bench update-po-files\` reconciles each \`.po\` against hotfix's \`main.pot\`:
|
||||
|
||||
| Case | Condition | Result |
|
||||
|------|-----------|--------|
|
||||
| **a** | \`msgid\` in hotfix's \`main.pot\`, **not** in develop's \`.po\` | Hotfix's existing \`msgstr\` is **preserved** (string removed from develop but still needed in hotfix) |
|
||||
| **b** | \`msgid\` **not** in hotfix's \`main.pot\` | **Dropped** from hotfix's \`.po\` |
|
||||
| **c** | \`msgid\` in both hotfix's \`main.pot\` and develop's \`.po\` | Develop's \`msgstr\` is used (Crowdin translation wins) |
|
||||
|
||||
Generated by the \`sync-hotfix-translations\` workflow." \
|
||||
--label "translation" \
|
||||
--label "skip-release-notes" \
|
||||
--reviewer "${PR_REVIEWER}" \
|
||||
-R "${GITHUB_REPOSITORY}"
|
||||
70
.github/workflows/build-and-commit-assets.yml
vendored
Normal file
70
.github/workflows/build-and-commit-assets.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Build and Upload Assets
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- 'version-*'
|
||||
|
||||
concurrency:
|
||||
group: build-assets-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-assets:
|
||||
name: Build JS/CSS and upload to release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: frappe/frappe
|
||||
path: apps/frappe
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: apps/erpnext
|
||||
|
||||
- name: Create bench structure
|
||||
run: |
|
||||
mkdir -p sites
|
||||
printf "frappe\nerpnext\n" > sites/apps.txt
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: yarn
|
||||
cache-dependency-path: apps/frappe/yarn.lock
|
||||
|
||||
- name: Install frappe JS dependencies
|
||||
working-directory: apps/frappe
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Install erpnext JS dependencies
|
||||
working-directory: apps/erpnext
|
||||
run: yarn install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Link node_modules into public/
|
||||
working-directory: apps/frappe
|
||||
run: ln -s "$PWD/node_modules" frappe/public/node_modules
|
||||
|
||||
- name: Build assets (production)
|
||||
working-directory: apps/frappe
|
||||
run: yarn run production
|
||||
|
||||
- name: Package assets
|
||||
working-directory: apps/erpnext
|
||||
run: tar czf erpnext-assets.tar.gz -C ../../sites/assets/erpnext dist
|
||||
|
||||
- name: Upload to rolling release
|
||||
working-directory: apps/erpnext
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="assets-${GITHUB_REF_NAME//\//-}"
|
||||
gh release create "$TAG" --prerelease --title "Assets: $GITHUB_REF_NAME" --notes "" 2>/dev/null || true
|
||||
gh release upload "$TAG" erpnext-assets.tar.gz --clobber
|
||||
25
.github/workflows/review-translation-changes.yaml
vendored
Normal file
25
.github/workflows/review-translation-changes.yaml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Review translation PRs
|
||||
description: "Posts review comments with relevant translation changes that are hard to inspect in the diff view."
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
paths:
|
||||
- "**/*.po"
|
||||
- "**/*.pot"
|
||||
|
||||
concurrency:
|
||||
group: po-review-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
review-po-pr:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: alyf-de/po-review-action@v1.0.0
|
||||
52
.github/workflows/run-hotfix-translation-sync.yml
vendored
Normal file
52
.github/workflows/run-hotfix-translation-sync.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# Runner — maintain this file on each hotfix branch, not on develop.
|
||||
#
|
||||
# Fires when main.pot changes on this branch (i.e. after a POT update PR
|
||||
# merges), or when dispatched by the orchestrator on develop (weekly schedule).
|
||||
#
|
||||
# Uses github.ref_name so the file is identical across all hotfix branches
|
||||
# with no branch-specific edits required.
|
||||
|
||||
name: Run hotfix translation sync
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
# One run at a time per branch. cancel-in-progress: false to avoid leaving
|
||||
# an orphaned remote branch from a mid-flight git push + gh pr create.
|
||||
concurrency:
|
||||
group: sync-hotfix-translations-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync-translations:
|
||||
name: Sync translations from develop into ${{ github.ref_name }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
HOTFIX_BRANCH: ${{ github.ref_name }}
|
||||
APP_NAME: ${{ github.event.repository.name }}
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ env.HOTFIX_BRANCH }}
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ env.HOTFIX_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Run sync script
|
||||
run: |
|
||||
bash "${GITHUB_WORKSPACE}/.github/helper/sync_hotfix_translations.sh"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
PR_REVIEWER: diptanilsaha
|
||||
39
.github/workflows/sync-hotfix-translations.yml
vendored
Normal file
39
.github/workflows/sync-hotfix-translations.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# Orchestrator — lives on develop only.
|
||||
#
|
||||
# Triggers on the weekly schedule and dispatches the runner workflow on each
|
||||
# hotfix branch listed in the matrix. To add or remove a branch, edit the
|
||||
# matrix below.
|
||||
#
|
||||
# POT-change triggers are handled by the runner on each hotfix branch
|
||||
# (run-hotfix-translation-sync.yml), since GitHub only evaluates a workflow
|
||||
# from the branch that receives the push.
|
||||
|
||||
name: Sync translations to hotfix branches
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 10:00 UTC Monday
|
||||
- cron: "0 10 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
trigger-runners:
|
||||
name: Trigger sync → ${{ matrix.hotfix_branch }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
hotfix_branch:
|
||||
- version-16-hotfix
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- name: Dispatch runner on ${{ matrix.hotfix_branch }}
|
||||
run: |
|
||||
gh workflow run run-hotfix-translation-sync.yml \
|
||||
--repo "${{ github.repository }}" \
|
||||
--ref "${{ matrix.hotfix_branch }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "16.19.1"
|
||||
__version__ = "16.15.1"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
{
|
||||
"custom_fields": [
|
||||
{
|
||||
"_assign": null,
|
||||
"_comments": null,
|
||||
"_liked_by": null,
|
||||
"_user_tags": null,
|
||||
"allow_in_quick_entry": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"collapsible_depends_on": null,
|
||||
"columns": 0,
|
||||
"creation": "2018-12-28 22:29:21.828090",
|
||||
"default": null,
|
||||
"depends_on": null,
|
||||
"description": null,
|
||||
"docstatus": 0,
|
||||
"dt": "Address",
|
||||
"fetch_from": null,
|
||||
"fetch_if_empty": 0,
|
||||
"fieldname": "tax_category",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 0,
|
||||
"hide_border": 0,
|
||||
"hide_days": 0,
|
||||
"hide_seconds": 0,
|
||||
"idx": 15,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_preview": 0,
|
||||
"in_standard_filter": 0,
|
||||
"insert_after": "fax",
|
||||
"label": "Tax Category",
|
||||
"length": 0,
|
||||
"mandatory_depends_on": null,
|
||||
"modified": "2018-12-28 22:29:21.828090",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Address-tax_category",
|
||||
"no_copy": 0,
|
||||
"options": "Tax Category",
|
||||
"owner": "Administrator",
|
||||
"parent": null,
|
||||
"parentfield": null,
|
||||
"parenttype": null,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"print_width": null,
|
||||
"read_only": 0,
|
||||
"read_only_depends_on": null,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0,
|
||||
"width": null
|
||||
},
|
||||
{
|
||||
"_assign": null,
|
||||
"_comments": null,
|
||||
"_liked_by": null,
|
||||
"_user_tags": null,
|
||||
"allow_in_quick_entry": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"collapsible_depends_on": null,
|
||||
"columns": 0,
|
||||
"creation": "2020-10-14 17:41:40.878179",
|
||||
"default": "0",
|
||||
"depends_on": null,
|
||||
"description": null,
|
||||
"docstatus": 0,
|
||||
"dt": "Address",
|
||||
"fetch_from": null,
|
||||
"fetch_if_empty": 0,
|
||||
"fieldname": "is_your_company_address",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 0,
|
||||
"hide_border": 0,
|
||||
"hide_days": 0,
|
||||
"hide_seconds": 0,
|
||||
"idx": 20,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_preview": 0,
|
||||
"in_standard_filter": 0,
|
||||
"insert_after": "linked_with",
|
||||
"label": "Is Your Company Address",
|
||||
"length": 0,
|
||||
"mandatory_depends_on": null,
|
||||
"modified": "2020-10-14 17:41:40.878179",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Address-is_your_company_address",
|
||||
"no_copy": 0,
|
||||
"options": null,
|
||||
"owner": "Administrator",
|
||||
"parent": null,
|
||||
"parentfield": null,
|
||||
"parenttype": null,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"print_width": null,
|
||||
"read_only": 0,
|
||||
"read_only_depends_on": null,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0,
|
||||
"width": null
|
||||
}
|
||||
],
|
||||
"custom_perms": [],
|
||||
"doctype": "Address",
|
||||
"property_setters": [],
|
||||
"sync_on_migrate": 1
|
||||
}
|
||||
@@ -518,6 +518,7 @@ def get_account_autoname(account_number, account_name, company):
|
||||
def update_account_number(name, account_name, account_number=None, from_descendant=False):
|
||||
_ensure_idle_system()
|
||||
account = frappe.get_cached_doc("Account", name)
|
||||
account.check_permission("write")
|
||||
if not account:
|
||||
return
|
||||
|
||||
@@ -579,10 +580,12 @@ def update_account_number(name, account_name, account_number=None, from_descenda
|
||||
@frappe.whitelist()
|
||||
def merge_account(old, new):
|
||||
_ensure_idle_system()
|
||||
# Validate properties before merging
|
||||
new_account = frappe.get_cached_doc("Account", new)
|
||||
old_account = frappe.get_cached_doc("Account", old)
|
||||
|
||||
new_account.check_permission("write")
|
||||
old_account.check_permission("write")
|
||||
|
||||
if not new_account:
|
||||
throw(_("Account {0} does not exist").format(new))
|
||||
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
{
|
||||
"country_code": "nz",
|
||||
"name": "New Zealand - Chart of Accounts with Account Numbers",
|
||||
"disabled": "No",
|
||||
"tree": {
|
||||
"Application of Funds (Assets)": {
|
||||
"Current Assets": {
|
||||
"Bank Accounts": {
|
||||
"Business Transaction Account": {
|
||||
"account_number": "11011",
|
||||
"account_type": "Bank"
|
||||
},
|
||||
"Business Savings Account": {
|
||||
"account_number": "11012",
|
||||
"account_type": "Bank"
|
||||
},
|
||||
"account_number": "11010",
|
||||
"is_group": 1
|
||||
},
|
||||
"Cash on Hand": {
|
||||
"account_number": "11020",
|
||||
"account_type": "Cash"
|
||||
},
|
||||
"Accounts Receivable": {
|
||||
"Debtors": {
|
||||
"account_number": "11210",
|
||||
"account_type": "Receivable"
|
||||
},
|
||||
"Provision for Doubtful Debts": {
|
||||
"account_number": "11220"
|
||||
},
|
||||
"account_number": "11200",
|
||||
"is_group": 1
|
||||
},
|
||||
"Inventory": {
|
||||
"Stock on Hand": {
|
||||
"account_number": "11311",
|
||||
"account_type": "Stock"
|
||||
},
|
||||
"Work In Progress": {
|
||||
"account_number": "11312",
|
||||
"account_type": "Stock"
|
||||
},
|
||||
"account_number": "11310",
|
||||
"account_type": "Stock",
|
||||
"is_group": 1
|
||||
},
|
||||
"Prepayments": {
|
||||
"Prepayments": {
|
||||
"account_number": "11411"
|
||||
},
|
||||
"Supplier Advances": {
|
||||
"account_number": "11412"
|
||||
},
|
||||
"Deferred Expense": {
|
||||
"account_number": "11413"
|
||||
},
|
||||
"account_number": "11410",
|
||||
"is_group": 1
|
||||
},
|
||||
"GST Receivable": {
|
||||
"account_number": "11510",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"Income Tax Receivable": {
|
||||
"account_number": "11520",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"account_number": "11000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Fixed Assets": {
|
||||
"Plant & Equipment": {
|
||||
"Plant & Equipment": {
|
||||
"account_number": "16011",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Plant & Equipment": {
|
||||
"account_number": "16012",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16010",
|
||||
"is_group": 1
|
||||
},
|
||||
"Motor Vehicles": {
|
||||
"Motor Vehicles": {
|
||||
"account_number": "16021",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Motor Vehicles": {
|
||||
"account_number": "16022",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16020",
|
||||
"is_group": 1
|
||||
},
|
||||
"Office Equipment": {
|
||||
"Office Equipment": {
|
||||
"account_number": "16031",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Office Equipment": {
|
||||
"account_number": "16032",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16030",
|
||||
"is_group": 1
|
||||
},
|
||||
"Buildings": {
|
||||
"Buildings": {
|
||||
"account_number": "16041",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Buildings": {
|
||||
"account_number": "16042",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16040",
|
||||
"is_group": 1
|
||||
},
|
||||
"Computer Equipment": {
|
||||
"Computer Equipment": {
|
||||
"account_number": "16051",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Computer Equipment": {
|
||||
"account_number": "16052",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16050",
|
||||
"is_group": 1
|
||||
},
|
||||
"Capital Work in Progress": {
|
||||
"account_number": "16090",
|
||||
"account_type": "Capital Work in Progress"
|
||||
},
|
||||
"account_number": "16000",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "10000",
|
||||
"root_type": "Asset"
|
||||
},
|
||||
"Source of Funds (Liabilities)": {
|
||||
"Current Liabilities": {
|
||||
"Accounts Payable": {
|
||||
"Creditors": {
|
||||
"account_number": "21010",
|
||||
"account_type": "Payable"
|
||||
},
|
||||
"account_number": "21000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Goods Received Not Invoiced": {
|
||||
"account_number": "21100",
|
||||
"account_type": "Stock Received But Not Billed"
|
||||
},
|
||||
"Asset Received Not Invoiced": {
|
||||
"account_number": "21110",
|
||||
"account_type": "Asset Received But Not Billed"
|
||||
},
|
||||
"Service Received Not Invoiced": {
|
||||
"account_number": "21120",
|
||||
"account_type": "Service Received But Not Billed"
|
||||
},
|
||||
"Accrued Expenses": {
|
||||
"account_number": "21200"
|
||||
},
|
||||
"Wages Payable": {
|
||||
"account_number": "21300"
|
||||
},
|
||||
"PAYE Payable": {
|
||||
"account_number": "22010"
|
||||
},
|
||||
"KiwiSaver Payable": {
|
||||
"account_number": "22020"
|
||||
},
|
||||
"ACC Payable": {
|
||||
"account_number": "22030"
|
||||
},
|
||||
"Credit Cards": {
|
||||
"Business Credit Card": {
|
||||
"account_number": "22110"
|
||||
},
|
||||
"account_number": "22100",
|
||||
"is_group": 1
|
||||
},
|
||||
"Customer Advances": {
|
||||
"account_number": "22200"
|
||||
},
|
||||
"Deferred Revenue": {
|
||||
"account_number": "22210"
|
||||
},
|
||||
"Provisional Account": {
|
||||
"account_number": "22220"
|
||||
},
|
||||
"Tax Liabilities": {
|
||||
"GST Payable": {
|
||||
"account_number": "22310",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"GST Suspense": {
|
||||
"account_number": "22320",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"FBT Payable": {
|
||||
"account_number": "22330",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"Income Tax Payable": {
|
||||
"account_number": "22340",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"account_number": "22300",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "21500",
|
||||
"is_group": 1
|
||||
},
|
||||
"Non-Current Liabilities": {
|
||||
"Bank Loans": {
|
||||
"Bank Loan": {
|
||||
"account_number": "25011"
|
||||
},
|
||||
"account_number": "25010",
|
||||
"is_group": 1
|
||||
},
|
||||
"Lease Liabilities": {
|
||||
"Lease Liability": {
|
||||
"account_number": "25021"
|
||||
},
|
||||
"account_number": "25020",
|
||||
"is_group": 1
|
||||
},
|
||||
"Shareholder Loans": {
|
||||
"Shareholder Loan": {
|
||||
"account_number": "25031"
|
||||
},
|
||||
"account_number": "25030",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "25000",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "20000",
|
||||
"root_type": "Liability"
|
||||
},
|
||||
"Equity": {
|
||||
"Share Capital": {
|
||||
"account_number": "31010",
|
||||
"account_type": "Equity"
|
||||
},
|
||||
"Drawings": {
|
||||
"account_number": "31020",
|
||||
"account_type": "Equity"
|
||||
},
|
||||
"Current Year Earnings": {
|
||||
"account_number": "35010",
|
||||
"account_type": "Equity"
|
||||
},
|
||||
"Retained Earnings": {
|
||||
"account_number": "35020",
|
||||
"account_type": "Equity"
|
||||
},
|
||||
"account_number": "30000",
|
||||
"root_type": "Equity"
|
||||
},
|
||||
"Income": {
|
||||
"Sales": {
|
||||
"account_number": "41010",
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"Other Income": {
|
||||
"Interest Income": {
|
||||
"account_number": "47010",
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"Rounding Gain/Loss": {
|
||||
"account_number": "47020",
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"Foreign Exchange Gain": {
|
||||
"account_number": "47030",
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"account_number": "47000",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "40000",
|
||||
"root_type": "Income"
|
||||
},
|
||||
"Expenses": {
|
||||
"Cost of Goods Sold": {
|
||||
"Purchases": {
|
||||
"account_number": "51010",
|
||||
"account_type": "Cost of Goods Sold"
|
||||
},
|
||||
"Freight Inwards": {
|
||||
"account_number": "51020",
|
||||
"account_type": "Expenses Included In Valuation"
|
||||
},
|
||||
"Duty and Landing Costs": {
|
||||
"account_number": "51030",
|
||||
"account_type": "Expenses Included In Valuation"
|
||||
},
|
||||
"Stock Adjustment": {
|
||||
"account_number": "51040",
|
||||
"account_type": "Stock Adjustment"
|
||||
},
|
||||
"Stock Write Off": {
|
||||
"account_number": "51050",
|
||||
"account_type": "Stock Adjustment"
|
||||
},
|
||||
"account_number": "51000",
|
||||
"account_type": "Cost of Goods Sold",
|
||||
"is_group": 1
|
||||
},
|
||||
"Operating Expenses": {
|
||||
"Wages & Salaries": {
|
||||
"account_number": "61010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"KiwiSaver Employer Contribution": {
|
||||
"account_number": "61020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"ACC Levies": {
|
||||
"account_number": "61030",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Rent": {
|
||||
"account_number": "65010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Power": {
|
||||
"account_number": "65020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Telephone": {
|
||||
"account_number": "66010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Insurance": {
|
||||
"account_number": "64010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Accounting Fees": {
|
||||
"account_number": "64020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Legal Fees": {
|
||||
"account_number": "64030",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Advertising and Marketing": {
|
||||
"account_number": "65030",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Repairs and Maintenance": {
|
||||
"account_number": "65040",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Freight and Courier": {
|
||||
"account_number": "65050",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Operating Costs": {
|
||||
"account_number": "65060",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"account_number": "60000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Depreciation and Amortisation": {
|
||||
"Depreciation - Plant & Equipment": {
|
||||
"account_number": "62010",
|
||||
"account_type": "Depreciation"
|
||||
},
|
||||
"Depreciation - Motor Vehicles": {
|
||||
"account_number": "62020",
|
||||
"account_type": "Depreciation"
|
||||
},
|
||||
"Depreciation - Office Equipment": {
|
||||
"account_number": "62030",
|
||||
"account_type": "Depreciation"
|
||||
},
|
||||
"Depreciation - Computer Equipment": {
|
||||
"account_number": "62040",
|
||||
"account_type": "Depreciation"
|
||||
},
|
||||
"account_number": "62000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Finance Costs": {
|
||||
"Bank Charges": {
|
||||
"account_number": "67010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Interest Expense": {
|
||||
"account_number": "67020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Rounding Off": {
|
||||
"account_number": "67030",
|
||||
"account_type": "Round Off"
|
||||
},
|
||||
"Payment Discounts": {
|
||||
"account_number": "67040",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"account_number": "67000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Income Tax Expense": {
|
||||
"account_number": "81010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Foreign Exchange": {
|
||||
"Exchange Gain/Loss": {
|
||||
"account_number": "82010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Unrealized Exchange Gain/Loss": {
|
||||
"account_number": "82020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"account_number": "82000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Bad Debts": {
|
||||
"account_number": "83010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Write Off": {
|
||||
"account_number": "83020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Gain/Loss on Asset Disposal": {
|
||||
"account_number": "83030",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Expenses Included In Asset Valuation": {
|
||||
"account_number": "84010",
|
||||
"account_type": "Expenses Included In Asset Valuation"
|
||||
},
|
||||
"account_number": "50000",
|
||||
"root_type": "Expense"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -570,6 +570,17 @@
|
||||
"account_number": "5000",
|
||||
"is_group": 1,
|
||||
"root_type": "Expense",
|
||||
"Cost of Goods Sold": {
|
||||
"account_number": "5001",
|
||||
"is_group": 1,
|
||||
"root_type": "Expense",
|
||||
"Cost of Goods Sold": {
|
||||
"account_number": "5010",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Cost of Goods Sold"
|
||||
}
|
||||
},
|
||||
"Operating Expenses": {
|
||||
"account_number": "5100",
|
||||
"is_group": 1,
|
||||
|
||||
@@ -10,6 +10,9 @@ frappe.ui.form.on("Accounts Settings", {
|
||||
},
|
||||
};
|
||||
});
|
||||
if (!frm.naming_controller) frm.naming_controller = new erpnext.NamingSeriesController(frm);
|
||||
|
||||
frm.naming_controller.render_table("transaction_naming_html", get_transactions(frm));
|
||||
},
|
||||
enable_immutable_ledger: function (frm) {
|
||||
if (!frm.doc.enable_immutable_ledger) {
|
||||
@@ -49,3 +52,16 @@ function toggle_tax_settings(frm, field_name) {
|
||||
frm.set_value(other_field, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function get_transactions(frm) {
|
||||
const transactions = [
|
||||
{ label: __("Journal Entry"), doctype: "Journal Entry" },
|
||||
{ label: __("Payment Entry"), doctype: "Payment Entry" },
|
||||
{ label: __("Purchase Invoice"), doctype: "Purchase Invoice" },
|
||||
{ label: __("Purchase Order"), doctype: "Purchase Order" },
|
||||
{ label: __("Purchase Receipt"), doctype: "Purchase Receipt" },
|
||||
{ label: __("Sales Invoice"), doctype: "Sales Invoice" },
|
||||
];
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
"allow_multi_currency_invoices_against_single_party_account",
|
||||
"confirm_before_resetting_posting_date",
|
||||
"analytics_section",
|
||||
"enable_discounts_and_margin",
|
||||
"enable_accounting_dimensions",
|
||||
"column_break_vtnr",
|
||||
"enable_discounts_and_margin",
|
||||
"journals_section",
|
||||
"merge_similar_account_heads",
|
||||
"deferred_accounting_settings_section",
|
||||
@@ -43,7 +43,6 @@
|
||||
"print_settings",
|
||||
"show_inclusive_tax_in_print",
|
||||
"show_taxes_as_table_in_print",
|
||||
"column_break_12",
|
||||
"show_payment_schedule_in_print",
|
||||
"item_price_settings_section",
|
||||
"maintain_same_internal_transaction_rate",
|
||||
@@ -59,29 +58,30 @@
|
||||
"payments_tab",
|
||||
"section_break_jpd0",
|
||||
"auto_reconcile_payments",
|
||||
"exchange_gain_loss_posting_date",
|
||||
"auto_reconciliation_job_trigger",
|
||||
"reconciliation_queue_size",
|
||||
"column_break_resa",
|
||||
"exchange_gain_loss_posting_date",
|
||||
"repost_section",
|
||||
"column_break_mfor",
|
||||
"repost_allowed_types",
|
||||
"payment_options_section",
|
||||
"fetch_payment_schedule_in_payment_request",
|
||||
"enable_loyalty_point_program",
|
||||
"column_break_ctam",
|
||||
"fetch_payment_schedule_in_payment_request",
|
||||
"invoicing_settings_tab",
|
||||
"accounts_transactions_settings_section",
|
||||
"over_billing_allowance",
|
||||
"column_break_11",
|
||||
"role_allowed_to_over_bill",
|
||||
"credit_controller",
|
||||
"make_payment_via_journal_entry",
|
||||
"over_billing_allowance",
|
||||
"credit_controller",
|
||||
"role_allowed_to_over_bill",
|
||||
"column_break_11",
|
||||
"assets_tab",
|
||||
"asset_settings_section",
|
||||
"calculate_depr_using_total_days",
|
||||
"column_break_gjcc",
|
||||
"book_asset_depreciation_entry_automatically",
|
||||
"calculate_depr_using_total_days",
|
||||
"role_to_notify_on_depreciation_failure",
|
||||
"column_break_gjcc",
|
||||
"closing_settings_tab",
|
||||
"period_closing_settings_section",
|
||||
"ignore_account_closing_balance",
|
||||
@@ -90,8 +90,8 @@
|
||||
"reports_tab",
|
||||
"remarks_section",
|
||||
"general_ledger_remarks_length",
|
||||
"column_break_lvjk",
|
||||
"receivable_payable_remarks_length",
|
||||
"column_break_lvjk",
|
||||
"accounts_receivable_payable_tuning_section",
|
||||
"receivable_payable_fetch_method",
|
||||
"default_ageing_range",
|
||||
@@ -103,11 +103,14 @@
|
||||
"show_balance_in_coa",
|
||||
"banking_section",
|
||||
"enable_party_matching",
|
||||
"automatically_run_rules_on_unreconciled_transactions",
|
||||
"enable_fuzzy_matching",
|
||||
"payment_request_section",
|
||||
"create_pr_in_draft_status",
|
||||
"budget_section",
|
||||
"use_legacy_budget_controller"
|
||||
"use_legacy_budget_controller",
|
||||
"document_naming_tab",
|
||||
"transaction_naming_html"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -115,14 +118,14 @@
|
||||
"description": "Address used to determine Tax Category in transactions",
|
||||
"fieldname": "determine_address_tax_category_from",
|
||||
"fieldtype": "Select",
|
||||
"label": "Determine Address Tax Category From",
|
||||
"label": "Determine Address Tax Category from",
|
||||
"options": "Billing Address\nShipping Address"
|
||||
},
|
||||
{
|
||||
"fieldname": "credit_controller",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Role allowed to bypass Credit Limit",
|
||||
"label": "Role allowed to bypass credit limit",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
@@ -130,7 +133,7 @@
|
||||
"description": "Enabling this ensures each Purchase Invoice has a unique value in Supplier Invoice No. field within a particular fiscal year",
|
||||
"fieldname": "check_supplier_invoice_uniqueness",
|
||||
"fieldtype": "Check",
|
||||
"label": "Check Supplier Invoice Number Uniqueness"
|
||||
"label": "Check Supplier invoice number uniqueness"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -141,27 +144,29 @@
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"documentation_url": "https://docs.frappe.io/erpnext/accounts-settings#4-unlink-payment-on-cancellation-of-invoice",
|
||||
"fieldname": "unlink_payment_on_cancellation_of_invoice",
|
||||
"fieldtype": "Check",
|
||||
"label": "Unlink Payment on Cancellation of Invoice"
|
||||
"label": "Unlink Payment on cancellation of invoice"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"documentation_url": "https://docs.frappe.io/erpnext/accounts-settings#8-unlink-advance-payment-on-cancellation-of-order",
|
||||
"fieldname": "unlink_advance_payment_on_cancelation_of_order",
|
||||
"fieldtype": "Check",
|
||||
"label": "Unlink Advance Payment on Cancellation of Order"
|
||||
"label": "Unlink Advance Payment on cancellation of order"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "book_asset_depreciation_entry_automatically",
|
||||
"fieldtype": "Check",
|
||||
"label": "Book Asset Depreciation Entry Automatically"
|
||||
"label": "Book Asset Depreciation entry automatically"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "add_taxes_from_item_tax_template",
|
||||
"fieldtype": "Check",
|
||||
"label": "Automatically Add Taxes and Charges from Item Tax Template"
|
||||
"label": "Automatically add Taxes and Charges from Item Tax Template"
|
||||
},
|
||||
{
|
||||
"fieldname": "print_settings",
|
||||
@@ -172,17 +177,13 @@
|
||||
"default": "0",
|
||||
"fieldname": "show_inclusive_tax_in_print",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Inclusive Tax in Print"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_12",
|
||||
"fieldtype": "Column Break"
|
||||
"label": "Show inclusive tax in print"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "show_payment_schedule_in_print",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Payment Schedule in Print"
|
||||
"label": "Show Payment Schedule in print"
|
||||
},
|
||||
{
|
||||
"fieldname": "currency_exchange_section",
|
||||
@@ -208,7 +209,7 @@
|
||||
"description": "Payment Terms from orders will be fetched into the invoices as is",
|
||||
"fieldname": "automatically_fetch_payment_terms",
|
||||
"fieldtype": "Check",
|
||||
"label": "Automatically Fetch Payment Terms from Order/Quotation"
|
||||
"label": "Automatically fetch Payment Terms from Order/Quotation"
|
||||
},
|
||||
{
|
||||
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
|
||||
@@ -220,7 +221,7 @@
|
||||
"default": "1",
|
||||
"fieldname": "automatically_process_deferred_accounting_entry",
|
||||
"fieldtype": "Check",
|
||||
"label": "Automatically Process Deferred Accounting Entry"
|
||||
"label": "Automatically process deferred Accounting entry"
|
||||
},
|
||||
{
|
||||
"fieldname": "deferred_accounting_settings_section",
|
||||
@@ -236,7 +237,7 @@
|
||||
"description": "If this is unchecked, direct GL entries will be created to book deferred revenue or expense",
|
||||
"fieldname": "book_deferred_entries_via_journal_entry",
|
||||
"fieldtype": "Check",
|
||||
"label": "Book Deferred Entries Via Journal Entry"
|
||||
"label": "Book deferred entries via Journal Entry"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -244,38 +245,37 @@
|
||||
"description": "If this is unchecked Journal Entries will be saved in a Draft state and will have to be submitted manually",
|
||||
"fieldname": "submit_journal_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Submit Journal Entries"
|
||||
"label": "Submit Journal entries"
|
||||
},
|
||||
{
|
||||
"default": "Days",
|
||||
"description": "If \"Months\" is selected, a fixed amount will be booked as deferred revenue or expense for each month irrespective of the number of days in a month. It will be prorated if deferred revenue or expense is not booked for an entire month",
|
||||
"fieldname": "book_deferred_entries_based_on",
|
||||
"fieldtype": "Select",
|
||||
"label": "Book Deferred Entries Based On",
|
||||
"label": "Book Deferred entries based on",
|
||||
"options": "Days\nMonths"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "delete_linked_ledger_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Delete Accounting and Stock Ledger Entries on deletion of Transaction"
|
||||
"label": "Delete Accounting and Stock Ledger entries on deletion of transaction"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.over_billing_allowance > 0",
|
||||
"description": "Users with this role are allowed to over bill above the allowance percentage",
|
||||
"fieldname": "role_allowed_to_over_bill",
|
||||
"fieldtype": "Link",
|
||||
"label": "Role Allowed to Over Bill ",
|
||||
"label": "Role Allowed to over bill ",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
"fieldname": "period_closing_settings_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Period Closing Settings"
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "accounts_transactions_settings_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Credit Limit Settings"
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_11",
|
||||
@@ -360,14 +360,14 @@
|
||||
"default": "1",
|
||||
"fieldname": "show_balance_in_coa",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Balances in Chart Of Accounts"
|
||||
"label": "Show balances in Chart of Accounts"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Split Early Payment Discount Loss into Income and Tax Loss",
|
||||
"fieldname": "book_tax_discount_loss",
|
||||
"fieldtype": "Check",
|
||||
"label": "Book Tax Loss on Early Payment Discount"
|
||||
"label": "Book tax loss on early payment discount"
|
||||
},
|
||||
{
|
||||
"fieldname": "journals_section",
|
||||
@@ -379,7 +379,7 @@
|
||||
"description": "Rows with Same Account heads will be merged on Ledger",
|
||||
"fieldname": "merge_similar_account_heads",
|
||||
"fieldtype": "Check",
|
||||
"label": "Merge Similar Account Heads"
|
||||
"label": "Merge similar Account Heads"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_jpd0",
|
||||
@@ -390,13 +390,13 @@
|
||||
"default": "0",
|
||||
"fieldname": "auto_reconcile_payments",
|
||||
"fieldtype": "Check",
|
||||
"label": "Auto Reconcile Payments"
|
||||
"label": "Auto reconcile Payments"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "show_taxes_as_table_in_print",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Taxes as Table in Print"
|
||||
"label": "Show taxes as table in print"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -418,14 +418,14 @@
|
||||
"description": "Financial reports will be generated using GL Entry doctypes (should be enabled if Period Closing Voucher is not posted for all years sequentially or missing) ",
|
||||
"fieldname": "ignore_account_closing_balance",
|
||||
"fieldtype": "Check",
|
||||
"label": "Ignore Account Closing Balance"
|
||||
"label": "Ignore Account closing balance"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Tax Amount will be rounded on a row(items) level",
|
||||
"fieldname": "round_row_wise_tax",
|
||||
"fieldtype": "Check",
|
||||
"label": "Round Tax Amount Row-wise"
|
||||
"label": "Round tax amount row-wise"
|
||||
},
|
||||
{
|
||||
"fieldname": "reports_tab",
|
||||
@@ -437,14 +437,14 @@
|
||||
"description": "Truncates 'Remarks' column to set character length",
|
||||
"fieldname": "general_ledger_remarks_length",
|
||||
"fieldtype": "Int",
|
||||
"label": "General Ledger"
|
||||
"label": "General Ledger remarks length"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Truncates 'Remarks' column to set character length",
|
||||
"fieldname": "receivable_payable_remarks_length",
|
||||
"fieldtype": "Int",
|
||||
"label": "Accounts Receivable/Payable"
|
||||
"label": "Accounts Receivable / Payable remarks length"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_lvjk",
|
||||
@@ -478,7 +478,7 @@
|
||||
"description": "Payment Requests made from Sales / Purchase Invoice will be put in Draft explicitly",
|
||||
"fieldname": "create_pr_in_draft_status",
|
||||
"fieldtype": "Check",
|
||||
"label": "Create in Draft Status"
|
||||
"label": "Create payment requests in Draft status"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_yuug",
|
||||
@@ -493,14 +493,14 @@
|
||||
"description": "Interval should be between 1 to 59 MInutes",
|
||||
"fieldname": "auto_reconciliation_job_trigger",
|
||||
"fieldtype": "Int",
|
||||
"label": "Auto Reconciliation Job Trigger"
|
||||
"label": "Auto Reconciliation job trigger"
|
||||
},
|
||||
{
|
||||
"default": "5",
|
||||
"description": "Documents Processed on each trigger. Queue Size should be between 5 and 100",
|
||||
"fieldname": "reconciliation_queue_size",
|
||||
"fieldtype": "Int",
|
||||
"label": "Reconciliation Queue Size"
|
||||
"label": "Reconciliation queue size"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -514,14 +514,14 @@
|
||||
"description": "Only applies for Normal Payments",
|
||||
"fieldname": "exchange_gain_loss_posting_date",
|
||||
"fieldtype": "Select",
|
||||
"label": "Posting Date Inheritance for Exchange Gain / Loss",
|
||||
"label": "Posting Date inheritance for exchange gain / loss",
|
||||
"options": "Invoice\nPayment\nReconciliation Date"
|
||||
},
|
||||
{
|
||||
"default": "Buffered Cursor",
|
||||
"fieldname": "receivable_payable_fetch_method",
|
||||
"fieldtype": "Select",
|
||||
"label": "Data Fetch Method",
|
||||
"label": "Data fetch method",
|
||||
"options": "Buffered Cursor\nUnBuffered Cursor"
|
||||
},
|
||||
{
|
||||
@@ -538,14 +538,14 @@
|
||||
"default": "0",
|
||||
"fieldname": "maintain_same_internal_transaction_rate",
|
||||
"fieldtype": "Check",
|
||||
"label": "Maintain Same Rate Throughout Internal Transaction"
|
||||
"label": "Maintain same rate throughout internal Transaction"
|
||||
},
|
||||
{
|
||||
"default": "Stop",
|
||||
"depends_on": "maintain_same_internal_transaction_rate",
|
||||
"fieldname": "maintain_same_rate_action",
|
||||
"fieldtype": "Select",
|
||||
"label": "Action if Same Rate is Not Maintained Throughout Internal Transaction",
|
||||
"label": "Action if same rate is not maintained throughout internal transaction",
|
||||
"mandatory_depends_on": "maintain_same_internal_transaction_rate",
|
||||
"options": "Stop\nWarn"
|
||||
},
|
||||
@@ -553,7 +553,7 @@
|
||||
"depends_on": "eval: doc.maintain_same_internal_transaction_rate && doc.maintain_same_rate_action == 'Stop'",
|
||||
"fieldname": "role_to_override_stop_action",
|
||||
"fieldtype": "Link",
|
||||
"label": "Role Allowed to Override Stop Action",
|
||||
"label": "Role allowed to override stop action",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
@@ -585,7 +585,7 @@
|
||||
"description": "If no taxes are set, and Taxes and Charges Template is selected, the system will automatically apply the taxes from the chosen template.",
|
||||
"fieldname": "add_taxes_from_taxes_and_charges_template",
|
||||
"fieldtype": "Check",
|
||||
"label": "Automatically Add Taxes from Taxes and Charges Template"
|
||||
"label": "Automatically add taxes from Taxes and Charges Template"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ntmi",
|
||||
@@ -595,19 +595,20 @@
|
||||
"default": "0",
|
||||
"fieldname": "fetch_valuation_rate_for_internal_transaction",
|
||||
"fieldtype": "Check",
|
||||
"label": "Fetch Valuation Rate for Internal Transaction"
|
||||
"label": "Fetch valuation rate for internal Transaction"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Enable this if you are experiencing issues with the new budget controller. Uses the older budget validation logic",
|
||||
"fieldname": "use_legacy_budget_controller",
|
||||
"fieldtype": "Check",
|
||||
"label": "Use Legacy Budget Controller"
|
||||
"label": "Use legacy Budget Controller"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "use_legacy_controller_for_pcv",
|
||||
"fieldtype": "Check",
|
||||
"label": "Use Legacy Controller For Period Closing Voucher"
|
||||
"label": "Use legacy controller for Period Closing Voucher"
|
||||
},
|
||||
{
|
||||
"description": "Users with this role will be notified if the asset depreciation gets failed",
|
||||
@@ -625,7 +626,7 @@
|
||||
{
|
||||
"fieldname": "chart_of_accounts_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Chart Of Accounts"
|
||||
"label": "Chart of Accounts"
|
||||
},
|
||||
{
|
||||
"fieldname": "banking_section",
|
||||
@@ -670,6 +671,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"documentation_url": "https://docs.frappe.io/erpnext/loyalty-program",
|
||||
"fieldname": "enable_loyalty_point_program",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Loyalty Point Program"
|
||||
@@ -696,7 +698,7 @@
|
||||
"default": "1",
|
||||
"fieldname": "fetch_payment_schedule_in_payment_request",
|
||||
"fieldtype": "Check",
|
||||
"label": "Fetch Payment Schedule In Payment Request"
|
||||
"label": "Fetch Payment Schedule in Payment Request"
|
||||
},
|
||||
{
|
||||
"fieldname": "repost_section",
|
||||
@@ -706,8 +708,22 @@
|
||||
{
|
||||
"fieldname": "repost_allowed_types",
|
||||
"fieldtype": "Table",
|
||||
"label": "Allowed Doctypes",
|
||||
"label": "Allowed DocTypes",
|
||||
"options": "Repost Allowed Types"
|
||||
},
|
||||
{
|
||||
"fieldname": "document_naming_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Document Naming"
|
||||
},
|
||||
{
|
||||
"fieldname": "transaction_naming_html",
|
||||
"fieldtype": "HTML"
|
||||
},
|
||||
{
|
||||
"description": "Changing the account in any transaction of the DocTypes listed below will trigger a repost. To prevent reposting, remove the relevant DocType from the list.",
|
||||
"fieldname": "column_break_mfor",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -717,7 +733,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-18 12:16:33.679345",
|
||||
"modified": "2026-06-03 13:11:54.721495",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"autoname": "format:Bank Statement Import on {creation}",
|
||||
"beta": 1,
|
||||
"creation": "2019-08-04 14:16:08.318714",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -226,7 +225,7 @@
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-11 02:23:22.159961",
|
||||
"modified": "2026-05-30 20:51:10.353723",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Statement Import",
|
||||
@@ -251,4 +250,4 @@
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ class BisectAccountingStatements(Document):
|
||||
|
||||
cur_node.save()
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def build_tree(self):
|
||||
frappe.db.delete("Bisect Nodes")
|
||||
|
||||
|
||||
@@ -707,18 +707,20 @@ def get_ordered_amount(params):
|
||||
|
||||
|
||||
def get_other_condition(params, for_doc):
|
||||
condition = f"expense_account = '{params.expense_account}'"
|
||||
condition = f"expense_account = {frappe.db.escape(params.expense_account)}"
|
||||
budget_against_field = params.get("budget_against_field")
|
||||
|
||||
if budget_against_field and params.get(budget_against_field):
|
||||
condition += f" and child.{budget_against_field} = '{params.get(budget_against_field)}'"
|
||||
condition += (
|
||||
f" and child.{budget_against_field} = {frappe.db.escape(params.get(budget_against_field))}"
|
||||
)
|
||||
|
||||
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
|
||||
|
||||
start_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date")
|
||||
end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date")
|
||||
|
||||
condition += f" and parent.{date_field} between '{start_date}' and '{end_date}'"
|
||||
condition += f" and parent.{date_field} between {frappe.db.escape(str(start_date))} and {frappe.db.escape(str(end_date))}"
|
||||
|
||||
return condition
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ frappe.provide("erpnext.cheque_print");
|
||||
frappe.ui.form.on("Cheque Print Template", {
|
||||
refresh: function (frm) {
|
||||
if (!frm.doc.__islocal) {
|
||||
frm.add_custom_button(
|
||||
frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"),
|
||||
function () {
|
||||
erpnext.cheque_print.view_cheque_print(frm);
|
||||
}
|
||||
).addClass("btn-primary");
|
||||
if (frappe.user.has_role("System Manager")) {
|
||||
frm.add_custom_button(
|
||||
frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"),
|
||||
function () {
|
||||
erpnext.cheque_print.view_cheque_print(frm);
|
||||
}
|
||||
).addClass("btn-primary");
|
||||
}
|
||||
|
||||
$(frm.fields_dict.cheque_print_preview.wrapper).empty();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "field:bank_name",
|
||||
"creation": "2016-05-04 14:35:00.402544",
|
||||
"doctype": "DocType",
|
||||
@@ -294,7 +295,7 @@
|
||||
],
|
||||
"links": [],
|
||||
"max_attachments": 1,
|
||||
"modified": "2024-03-27 13:06:44.654989",
|
||||
"modified": "2026-06-08 12:10:35.829531",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Cheque Print Template",
|
||||
@@ -325,19 +326,17 @@
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ class ChequePrintTemplate(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_or_update_cheque_print_format(template_name):
|
||||
frappe.only_for("System Manager")
|
||||
|
||||
if not frappe.db.exists("Print Format", template_name):
|
||||
cheque_print = frappe.new_doc("Print Format")
|
||||
cheque_print.update(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"actions": [],
|
||||
"allow_events_in_timeline": 1,
|
||||
"autoname": "naming_series:",
|
||||
"beta": 1,
|
||||
"creation": "2019-07-05 16:34:31.013238",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
@@ -400,7 +399,7 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-11-26 13:46:07.760867",
|
||||
"modified": "2026-05-30 23:18:04.712528",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Dunning",
|
||||
@@ -449,9 +448,10 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": [],
|
||||
"title_field": "customer_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"beta": 1,
|
||||
"creation": "2019-12-04 04:59:08.003664",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -107,7 +106,7 @@
|
||||
"link_fieldname": "dunning_type"
|
||||
}
|
||||
],
|
||||
"modified": "2024-03-27 13:08:19.584112",
|
||||
"modified": "2026-05-30 23:18:20.740726",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Dunning Type",
|
||||
@@ -151,8 +150,9 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,18 +565,19 @@ class FinancialQueryBuilder:
|
||||
frappe.qb.from_(acb_table)
|
||||
.select(
|
||||
acb_table.account,
|
||||
(acb_table.debit - acb_table.credit).as_("balance"),
|
||||
Sum(acb_table.debit - acb_table.credit).as_("balance"),
|
||||
)
|
||||
.where(acb_table.company == self.company)
|
||||
.where(acb_table.account.isin(account_names))
|
||||
.where(acb_table.period_closing_voucher == closing_voucher)
|
||||
.groupby(acb_table.account)
|
||||
)
|
||||
|
||||
query = self._apply_standard_filters(query, acb_table, "Account Closing Balance")
|
||||
results = self._execute_with_permissions(query, "Account Closing Balance")
|
||||
|
||||
for row in results:
|
||||
closing_balances[row["account"]] = row["balance"]
|
||||
closing_balances[row["account"]] = row["balance"] or 0.0
|
||||
|
||||
return closing_balances
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from erpnext.accounts.doctype.financial_report_template.test_financial_report_te
|
||||
)
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.utils import get_currency_precision, get_fiscal_year
|
||||
from erpnext.tests.utils import change_settings
|
||||
|
||||
|
||||
class TestDependencyResolver(FinancialReportTemplateTestCase):
|
||||
@@ -1953,6 +1954,104 @@ class TestFinancialQueryBuilder(FinancialReportTemplateTestCase):
|
||||
|
||||
jv_2023.cancel()
|
||||
|
||||
@change_settings("Accounts Settings", {"use_legacy_controller_for_pcv": 1})
|
||||
def test_opening_balance_sums_acb_rows_across_dimensions(self):
|
||||
"""
|
||||
Account Closing Balance stores one row per (account, cost_center,
|
||||
project, finance_book). The closing-balance fetch must sum all rows.
|
||||
"""
|
||||
company = "_Test Company"
|
||||
cash_account = "_Test Cash - _TC"
|
||||
sales_account = "Sales - _TC"
|
||||
cc_1 = "_Test Cost Center - _TC"
|
||||
cc_2 = "_Test Cost Center 2 - _TC"
|
||||
docs = []
|
||||
|
||||
try:
|
||||
jv_2023_cc1 = make_journal_entry(
|
||||
account1=cash_account,
|
||||
account2=sales_account,
|
||||
amount=3000,
|
||||
posting_date="2023-06-15",
|
||||
cost_center=cc_1,
|
||||
company=company,
|
||||
submit=True,
|
||||
)
|
||||
docs.append(jv_2023_cc1)
|
||||
jv_2023_cc2 = make_journal_entry(
|
||||
account1=cash_account,
|
||||
account2=sales_account,
|
||||
amount=2000,
|
||||
posting_date="2023-06-15",
|
||||
cost_center=cc_2,
|
||||
company=company,
|
||||
submit=True,
|
||||
)
|
||||
docs.append(jv_2023_cc2)
|
||||
|
||||
fy_2023 = get_fiscal_year("2023-06-15", company=company)
|
||||
|
||||
pcv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Period Closing Voucher",
|
||||
"transaction_date": "2023-12-31",
|
||||
"period_start_date": fy_2023[1],
|
||||
"period_end_date": fy_2023[2],
|
||||
"company": company,
|
||||
"fiscal_year": fy_2023[0],
|
||||
"cost_center": cc_1,
|
||||
"closing_account_head": "Deferred Revenue - _TC",
|
||||
"remarks": "Test multi-dim PCV",
|
||||
}
|
||||
)
|
||||
pcv.insert()
|
||||
pcv.submit()
|
||||
docs.append(pcv)
|
||||
|
||||
jv_2024 = make_journal_entry(
|
||||
account1=cash_account,
|
||||
account2=sales_account,
|
||||
amount=100,
|
||||
posting_date="2024-01-15",
|
||||
cost_center=cc_1,
|
||||
company=company,
|
||||
submit=True,
|
||||
)
|
||||
docs.append(jv_2024)
|
||||
|
||||
filters = {
|
||||
"company": company,
|
||||
"from_fiscal_year": "2024",
|
||||
"to_fiscal_year": "2024",
|
||||
"period_start_date": "2024-01-01",
|
||||
"period_end_date": "2024-03-31",
|
||||
"filter_based_on": "Date Range",
|
||||
"periodicity": "Monthly",
|
||||
"ignore_closing_entries": True,
|
||||
}
|
||||
periods = [
|
||||
{"key": "2024_jan", "from_date": "2024-01-01", "to_date": "2024-01-31"},
|
||||
{"key": "2024_feb", "from_date": "2024-02-01", "to_date": "2024-02-29"},
|
||||
{"key": "2024_mar", "from_date": "2024-03-01", "to_date": "2024-03-31"},
|
||||
]
|
||||
|
||||
query_builder = FinancialQueryBuilder(filters, periods)
|
||||
accounts = [
|
||||
frappe._dict({"name": cash_account, "account_name": "Cash", "account_number": "1001"}),
|
||||
]
|
||||
|
||||
balances_data = query_builder.fetch_account_balances(accounts)
|
||||
cash_data = balances_data.get(cash_account)
|
||||
self.assertIsNotNone(cash_data, "Cash account must appear in results")
|
||||
|
||||
jan_cash = cash_data.get_period("2024_jan")
|
||||
self.assertEqual(jan_cash.opening, 5000.0)
|
||||
self.assertEqual(jan_cash.movement, 100.0)
|
||||
self.assertEqual(jan_cash.closing, 5100.0)
|
||||
|
||||
finally:
|
||||
self.cancel_docs(docs)
|
||||
|
||||
def test_opening_entries_roll_into_opening_after_period_closing(self):
|
||||
"""
|
||||
Sequence:
|
||||
|
||||
@@ -9,6 +9,14 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
class FinancialReportTemplateTestCase(ERPNextTestSuite):
|
||||
"""Utility class with common setup and helper methods for all test classes"""
|
||||
|
||||
def cancel_docs(self, docs):
|
||||
"""Cancel submitted docs in reverse creation order to avoid dependency issues."""
|
||||
for doc in reversed(docs):
|
||||
if doc:
|
||||
doc.reload()
|
||||
if doc.docstatus == 1:
|
||||
doc.cancel()
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
self.create_test_template()
|
||||
|
||||
@@ -433,15 +433,17 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
|
||||
|
||||
accounts_add(doc, cdt, cdn) {
|
||||
var row = frappe.get_doc(cdt, cdn);
|
||||
row.exchange_rate = 1;
|
||||
$.each(doc.accounts, function (i, d) {
|
||||
if (d.account && d.party && d.party_type) {
|
||||
row.account = d.account;
|
||||
row.party = d.party;
|
||||
row.party_type = d.party_type;
|
||||
row.exchange_rate = d.exchange_rate;
|
||||
}
|
||||
});
|
||||
if (!row.exchange_rate) row.exchange_rate = 1;
|
||||
if (!row.account) {
|
||||
$.each(doc.accounts, function (i, d) {
|
||||
if (d.account && d.party && d.party_type) {
|
||||
row.account = d.account;
|
||||
row.party = d.party;
|
||||
row.party_type = d.party_type;
|
||||
row.exchange_rate = d.exchange_rate;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// set difference
|
||||
if (doc.difference) {
|
||||
|
||||
@@ -1291,7 +1291,11 @@ class JournalEntry(AccountsController):
|
||||
self.validate_total_debit_and_credit()
|
||||
|
||||
def get_values(self):
|
||||
cond = f" and outstanding_amount <= {self.write_off_amount}" if flt(self.write_off_amount) > 0 else ""
|
||||
cond = (
|
||||
f" and outstanding_amount <= {flt(self.write_off_amount)}"
|
||||
if flt(self.write_off_amount) > 0
|
||||
else ""
|
||||
)
|
||||
|
||||
if self.write_off_based_on == "Accounts Receivable":
|
||||
return frappe.db.sql(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_copy": 1,
|
||||
"beta": 1,
|
||||
"creation": "2017-08-29 02:22:54.947711",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -90,7 +89,7 @@
|
||||
"hide_toolbar": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-31 01:47:20.360352",
|
||||
"modified": "2026-05-30 23:18:48.691227",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Opening Invoice Creation Tool",
|
||||
|
||||
@@ -807,11 +807,14 @@ frappe.ui.form.on("Payment Entry", {
|
||||
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
|
||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||
if (!frm.doc.received_amount) {
|
||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("received_amount", frm.doc.paid_amount);
|
||||
} else if (company_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
if (company_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("received_amount", frm.doc.base_paid_amount);
|
||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
} else if (frm.doc.target_exchange_rate) {
|
||||
frm.set_value(
|
||||
"received_amount",
|
||||
flt(frm.doc.base_paid_amount) / flt(frm.doc.target_exchange_rate)
|
||||
);
|
||||
}
|
||||
}
|
||||
frm.trigger("reset_received_amount");
|
||||
@@ -828,15 +831,14 @@ frappe.ui.form.on("Payment Entry", {
|
||||
);
|
||||
|
||||
if (!frm.doc.paid_amount) {
|
||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("paid_amount", frm.doc.received_amount);
|
||||
if (frm.doc.target_exchange_rate) {
|
||||
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
|
||||
}
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
} else if (company_currency == frm.doc.paid_from_account_currency) {
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
if (company_currency == frm.doc.paid_from_account_currency) {
|
||||
frm.set_value("paid_amount", frm.doc.base_received_amount);
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
} else if (frm.doc.source_exchange_rate) {
|
||||
frm.set_value(
|
||||
"paid_amount",
|
||||
flt(frm.doc.base_received_amount) / flt(frm.doc.source_exchange_rate)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1724,6 +1726,35 @@ frappe.ui.form.on("Payment Entry", {
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
before_cancel: function (frm) {
|
||||
return new Promise((resolve, reject) => {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_linked_bank_transactions",
|
||||
args: { payment_entry: frm.doc.name },
|
||||
callback: function (r) {
|
||||
const linked = r.message || [];
|
||||
if (!linked.length) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const bt_links = linked
|
||||
.map((name) => frappe.utils.get_form_link("Bank Transaction", name, true))
|
||||
.join(", ");
|
||||
frappe.confirm(
|
||||
__(
|
||||
"This Payment Entry is reconciled with {0}. Cancelling will automatically unreconcile it. Do you want to proceed?",
|
||||
[bt_links]
|
||||
),
|
||||
() => resolve(),
|
||||
() => reject(),
|
||||
__("Yes"),
|
||||
__("No")
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Payment Entry Reference", {
|
||||
|
||||
@@ -208,6 +208,7 @@ class PaymentEntry(AccountsController):
|
||||
self.make_gl_entries()
|
||||
self.update_outstanding_amounts()
|
||||
self.set_status()
|
||||
self.trigger_invoice_update_for_subscriptions()
|
||||
|
||||
def validate_for_repost(self):
|
||||
validate_docs_for_voucher_types(["Payment Entry"])
|
||||
@@ -314,6 +315,7 @@ class PaymentEntry(AccountsController):
|
||||
self.update_outstanding_amounts()
|
||||
self.delink_advance_entry_references()
|
||||
self.set_status()
|
||||
self.trigger_invoice_update_for_subscriptions()
|
||||
|
||||
def update_payment_requests(self, cancel=False):
|
||||
from erpnext.accounts.doctype.payment_request.payment_request import (
|
||||
@@ -505,6 +507,19 @@ class PaymentEntry(AccountsController):
|
||||
doc = frappe.get_lazy_doc(reference.reference_doctype, reference.reference_name)
|
||||
doc.delink_advance_entries(self.name)
|
||||
|
||||
def trigger_invoice_update_for_subscriptions(self):
|
||||
invoice_names = set()
|
||||
for ref in self.references:
|
||||
if ref.reference_doctype in ("Sales Invoice", "Purchase Invoice"):
|
||||
invoice_names.add((ref.reference_doctype, ref.reference_name))
|
||||
|
||||
for doctype, name in invoice_names:
|
||||
try:
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
doc.refresh_subscription_status()
|
||||
except Exception:
|
||||
frappe.log_error(_("Failed to update subscription status for {0} {1}").format(doctype, name))
|
||||
|
||||
def set_missing_values(self):
|
||||
if self.payment_type == "Internal Transfer":
|
||||
for field in (
|
||||
@@ -1238,9 +1253,9 @@ class PaymentEntry(AccountsController):
|
||||
else:
|
||||
remarks = [
|
||||
_("Amount {0} {1} {2} {3}").format(
|
||||
_(self.paid_to_account_currency)
|
||||
_(self.paid_from_account_currency)
|
||||
if self.payment_type == "Receive"
|
||||
else _(self.paid_from_account_currency),
|
||||
else _(self.paid_to_account_currency),
|
||||
self.paid_amount if self.payment_type == "Receive" else self.received_amount,
|
||||
_("received from") if self.payment_type == "Receive" else _("paid to"),
|
||||
self.party,
|
||||
@@ -1256,7 +1271,7 @@ class PaymentEntry(AccountsController):
|
||||
for d in self.get("references"):
|
||||
if d.allocated_amount:
|
||||
remarks.append(
|
||||
_("Amount {0} {1} against {2} {3}").format(
|
||||
_("Amount {0} {1} adjusted against {2} {3}").format(
|
||||
_(self.party_account_currency),
|
||||
d.allocated_amount,
|
||||
d.reference_doctype,
|
||||
@@ -1267,7 +1282,7 @@ class PaymentEntry(AccountsController):
|
||||
for d in self.get("deductions"):
|
||||
if d.amount:
|
||||
remarks.append(
|
||||
_("Amount {0} {1} deducted against {2}").format(
|
||||
_("Amount {0} {1} as adjustment to {2}").format(
|
||||
_(self.company_currency), d.amount, d.account
|
||||
)
|
||||
)
|
||||
@@ -3568,3 +3583,16 @@ def make_payment_order(source_name, target_doc=None):
|
||||
@erpnext.allow_regional
|
||||
def add_regional_gl_entries(gl_entries, doc):
|
||||
return
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_linked_bank_transactions(payment_entry: str) -> list:
|
||||
frappe.has_permission("Payment Entry", ptype="read", doc=payment_entry, throw=True)
|
||||
return frappe.get_all(
|
||||
"Bank Transaction Payments",
|
||||
filters={
|
||||
"payment_document": "Payment Entry",
|
||||
"payment_entry": payment_entry,
|
||||
},
|
||||
pluck="parent",
|
||||
)
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.utils import nowdate
|
||||
from frappe.query_builder.functions import Count, Sum
|
||||
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_entry
|
||||
@@ -90,6 +91,7 @@ class TestPaymentLedgerEntry(ERPNextTestSuite):
|
||||
posting_date = nowdate()
|
||||
|
||||
sinv = create_sales_invoice(
|
||||
posting_date=posting_date,
|
||||
qty=qty,
|
||||
rate=rate,
|
||||
company=self.company,
|
||||
@@ -531,3 +533,82 @@ class TestPaymentLedgerEntry(ERPNextTestSuite):
|
||||
# with references removed, deletion should be possible
|
||||
so.delete()
|
||||
self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, so.doctype, so.name)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings",
|
||||
{"enable_immutable_ledger": 1},
|
||||
)
|
||||
def test_reverse_entries_on_cancel_for_immutable_ledger(self):
|
||||
invoice_posting_date = add_days(nowdate(), -5)
|
||||
gle = qb.DocType("GL Entry")
|
||||
ple = qb.DocType("Payment Ledger Entry")
|
||||
|
||||
si = self.create_sales_invoice(qty=1, rate=100, posting_date=invoice_posting_date)
|
||||
|
||||
gles_before = (
|
||||
qb.from_(gle)
|
||||
.select(
|
||||
Count(gle.name),
|
||||
)
|
||||
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
|
||||
.run()[0][0]
|
||||
)
|
||||
ples_before = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
Count(ple.name),
|
||||
)
|
||||
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked.eq(0)))
|
||||
.run()[0][0]
|
||||
)
|
||||
|
||||
si.cancel()
|
||||
|
||||
gles_after = (
|
||||
qb.from_(gle)
|
||||
.select(Count(gle.account))
|
||||
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
|
||||
.run()[0][0]
|
||||
)
|
||||
self.assertEqual(gles_after, gles_before * 2)
|
||||
|
||||
ples_after = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
Count(ple.name),
|
||||
)
|
||||
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked.eq(0)))
|
||||
.run()[0][0]
|
||||
)
|
||||
self.assertEqual(ples_after, ples_before * 2)
|
||||
|
||||
# assert debit/credit are reversed
|
||||
gl_entries = (
|
||||
qb.from_(gle)
|
||||
.select(gle.account, Sum(gle.debit).as_("total_debit"), Sum(gle.credit).as_("total_credit"))
|
||||
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
|
||||
.groupby(gle.account)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
for gl in gl_entries:
|
||||
with self.subTest(gl=gl):
|
||||
self.assertEqual(gl.total_debit, gl.total_credit)
|
||||
|
||||
# assert amounts are reversed
|
||||
pl_entries = (
|
||||
qb.from_(ple)
|
||||
.select(ple.account, Sum(ple.amount).as_("total_amount"))
|
||||
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked == 0))
|
||||
.groupby(ple.account)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
for pl in pl_entries:
|
||||
with self.subTest(pl=pl):
|
||||
self.assertEqual(pl.total_amount, 0)
|
||||
|
||||
self.assertFalse(
|
||||
frappe.db.exists(
|
||||
"Payment Ledger Entry",
|
||||
{"voucher_type": si.doctype, "voucher_no": si.name, "delinked": 1},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, getdate
|
||||
|
||||
from erpnext.controllers.accounts_controller import get_payment_term_details
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -56,6 +58,52 @@ class TestPaymentTermsTemplate(ERPNextTestSuite):
|
||||
|
||||
self.assertRaises(frappe.ValidationError, template.insert)
|
||||
|
||||
def test_no_discount_date_without_discount(self):
|
||||
posting_date = "2026-05-29"
|
||||
term = frappe._dict(
|
||||
{
|
||||
"payment_term": "_Test No Discount Term",
|
||||
"invoice_portion": 100.0,
|
||||
"due_date_based_on": "Day(s) after invoice date",
|
||||
"credit_days": 0,
|
||||
"credit_months": 0,
|
||||
"discount_type": "Percentage",
|
||||
"discount": 0,
|
||||
"discount_validity_based_on": "Day(s) after invoice date",
|
||||
"discount_validity": 0,
|
||||
}
|
||||
)
|
||||
|
||||
details = get_payment_term_details(
|
||||
term, posting_date=posting_date, grand_total=100, base_grand_total=100
|
||||
)
|
||||
|
||||
self.assertEqual(getdate(details.due_date), getdate(posting_date))
|
||||
self.assertIsNone(details.discount_date)
|
||||
|
||||
def test_discount_date_generated_with_discount(self):
|
||||
posting_date = "2026-05-29"
|
||||
term = frappe._dict(
|
||||
{
|
||||
"payment_term": "_Test Discount Term",
|
||||
"invoice_portion": 100.0,
|
||||
"due_date_based_on": "Day(s) after invoice date",
|
||||
"credit_days": 30,
|
||||
"credit_months": 0,
|
||||
"discount_type": "Percentage",
|
||||
"discount": 5,
|
||||
"discount_validity_based_on": "Day(s) after invoice date",
|
||||
"discount_validity": 10,
|
||||
}
|
||||
)
|
||||
|
||||
details = get_payment_term_details(
|
||||
term, posting_date=posting_date, grand_total=100, base_grand_total=100
|
||||
)
|
||||
|
||||
self.assertEqual(getdate(details.due_date), getdate(add_days(posting_date, 30)))
|
||||
self.assertEqual(getdate(details.discount_date), getdate(add_days(posting_date, 10)))
|
||||
|
||||
def test_duplicate_terms(self):
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ from frappe.utils import today
|
||||
from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.general_ledger import make_reverse_gl_entries
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -334,6 +335,48 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
|
||||
return pcv
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings",
|
||||
{"enable_immutable_ledger": 1},
|
||||
)
|
||||
def test_immutable_ledger_reverse_entry_uses_passed_posting_date_after_pcv(self):
|
||||
company = create_company()
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
|
||||
jv = make_journal_entry(
|
||||
posting_date="2021-03-15",
|
||||
amount=400,
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center,
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
jv.company = company
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
self.make_period_closing_voucher(posting_date="2021-03-31")
|
||||
|
||||
# Passed posting_date is after PCV end date, so cancellation should not fail.
|
||||
make_reverse_gl_entries(
|
||||
voucher_type="Journal Entry",
|
||||
voucher_no=jv.name,
|
||||
posting_date="2022-01-01",
|
||||
)
|
||||
|
||||
totals_after_cancel = frappe.db.sql(
|
||||
"""
|
||||
select sum(debit) as total_debit, sum(credit) as total_credit
|
||||
from `tabGL Entry`
|
||||
where voucher_type=%s and voucher_no=%s and is_cancelled=0
|
||||
""",
|
||||
("Journal Entry", jv.name),
|
||||
as_dict=True,
|
||||
)[0]
|
||||
|
||||
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)
|
||||
|
||||
|
||||
def create_company():
|
||||
company = frappe.get_doc(
|
||||
|
||||
@@ -26,8 +26,6 @@
|
||||
"due_date",
|
||||
"amended_from",
|
||||
"return_against",
|
||||
"section_break_abck",
|
||||
"title",
|
||||
"accounting_dimensions_section",
|
||||
"project",
|
||||
"dimension_col_break",
|
||||
@@ -172,6 +170,7 @@
|
||||
"is_discounted",
|
||||
"col_break23",
|
||||
"status",
|
||||
"title",
|
||||
"more_info",
|
||||
"debit_to",
|
||||
"party_account_currency",
|
||||
@@ -1625,10 +1624,6 @@
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Auto Repeat"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_abck",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "title",
|
||||
@@ -1641,7 +1636,7 @@
|
||||
"icon": "fa fa-file-text",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-01 02:37:30.580568",
|
||||
"modified": "2026-05-28 12:22:50.253090",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice",
|
||||
|
||||
@@ -13,52 +13,69 @@
|
||||
"column_break_9",
|
||||
"warehouse",
|
||||
"company_address",
|
||||
"section_break_15",
|
||||
"applicable_for_users",
|
||||
"accounting_tab",
|
||||
"section_break_11",
|
||||
"payments",
|
||||
"set_grand_total_to_default_mop",
|
||||
"price_list_and_currency_section",
|
||||
"currency",
|
||||
"column_break_bptt",
|
||||
"selling_price_list",
|
||||
"write_off_section",
|
||||
"write_off_account",
|
||||
"column_break_ukpz",
|
||||
"write_off_cost_center",
|
||||
"column_break_pkca",
|
||||
"write_off_limit",
|
||||
"income_and_expense_account",
|
||||
"income_account",
|
||||
"column_break_byzk",
|
||||
"expense_account",
|
||||
"taxes_section",
|
||||
"taxes_and_charges",
|
||||
"column_break_cjpp",
|
||||
"tax_category",
|
||||
"section_break_19",
|
||||
"account_for_change_amount",
|
||||
"disable_rounded_total",
|
||||
"column_break_23",
|
||||
"apply_discount_on",
|
||||
"allow_partial_payment",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
"dimension_col_break",
|
||||
"project",
|
||||
"pos_configurations_tab",
|
||||
"section_break_14",
|
||||
"hide_images",
|
||||
"hide_unavailable_items",
|
||||
"auto_add_item_to_cart",
|
||||
"validate_stock_on_save",
|
||||
"print_receipt_on_order_complete",
|
||||
"action_on_new_invoice",
|
||||
"validate_stock_on_save",
|
||||
"column_break_16",
|
||||
"update_stock",
|
||||
"ignore_pricing_rule",
|
||||
"print_receipt_on_order_complete",
|
||||
"pos_item_selector_section",
|
||||
"hide_images",
|
||||
"column_break_rpny",
|
||||
"hide_unavailable_items",
|
||||
"column_break_stcl",
|
||||
"auto_add_item_to_cart",
|
||||
"pos_item_details_section",
|
||||
"allow_rate_change",
|
||||
"column_break_hwfg",
|
||||
"allow_discount_change",
|
||||
"set_grand_total_to_default_mop",
|
||||
"allow_partial_payment",
|
||||
"section_break_15",
|
||||
"applicable_for_users",
|
||||
"section_break_23",
|
||||
"item_groups",
|
||||
"column_break_25",
|
||||
"customer_groups",
|
||||
"more_info_tab",
|
||||
"section_break_16",
|
||||
"print_format",
|
||||
"letter_head",
|
||||
"column_break0",
|
||||
"tc_name",
|
||||
"select_print_heading",
|
||||
"section_break_19",
|
||||
"selling_price_list",
|
||||
"currency",
|
||||
"write_off_account",
|
||||
"write_off_cost_center",
|
||||
"write_off_limit",
|
||||
"account_for_change_amount",
|
||||
"disable_rounded_total",
|
||||
"column_break_23",
|
||||
"income_account",
|
||||
"expense_account",
|
||||
"taxes_and_charges",
|
||||
"tax_category",
|
||||
"apply_discount_on",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
"dimension_col_break",
|
||||
"project",
|
||||
"utm_analytics_section",
|
||||
"utm_source",
|
||||
"column_break_tvls",
|
||||
@@ -133,8 +150,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_14",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Configuration"
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"description": "Only show Items from these Item Groups",
|
||||
@@ -155,6 +171,7 @@
|
||||
"options": "POS Customer Group"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "section_break_16",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Print Settings"
|
||||
@@ -194,7 +211,7 @@
|
||||
{
|
||||
"fieldname": "section_break_19",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Accounting"
|
||||
"label": "Miscellaneous"
|
||||
},
|
||||
{
|
||||
"fieldname": "selling_price_list",
|
||||
@@ -430,6 +447,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Applicable on POS Invoice",
|
||||
"fieldname": "allow_partial_payment",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Partial Payment"
|
||||
@@ -447,6 +465,83 @@
|
||||
"fieldname": "utm_analytics_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Campaign"
|
||||
},
|
||||
{
|
||||
"fieldname": "accounting_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Accounting"
|
||||
},
|
||||
{
|
||||
"fieldname": "more_info_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "More Info"
|
||||
},
|
||||
{
|
||||
"fieldname": "pos_configurations_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "POS Configurations"
|
||||
},
|
||||
{
|
||||
"fieldname": "price_list_and_currency_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Price List & Currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_bptt",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "write_off_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Write Off"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ukpz",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_pkca",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "income_and_expense_account",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Income and Expense"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_byzk",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "taxes_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Taxes"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_cjpp",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "pos_item_selector_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "POS Item Selector"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_rpny",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_stcl",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "pos_item_details_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "POS Item Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_hwfg",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -475,7 +570,7 @@
|
||||
"link_fieldname": "pos_profile"
|
||||
}
|
||||
],
|
||||
"modified": "2026-02-10 14:24:48.597412",
|
||||
"modified": "2026-05-26 12:07:48.597412",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Profile",
|
||||
|
||||
@@ -208,15 +208,14 @@ class POSProfile(Document):
|
||||
def set_defaults(self, include_current_pos=True):
|
||||
frappe.defaults.clear_default("is_pos")
|
||||
|
||||
if not include_current_pos:
|
||||
condition = " where pfu.name != '%s' and pfu.default = 1 " % self.name.replace("'", "'")
|
||||
else:
|
||||
condition = " where pfu.default = 1 "
|
||||
pfu = frappe.qb.DocType("POS Profile User")
|
||||
|
||||
pos_view_users = frappe.db.sql_list(
|
||||
f"""select pfu.user
|
||||
from `tabPOS Profile User` as pfu {condition}"""
|
||||
)
|
||||
query = frappe.qb.from_(pfu).select(pfu.user).where(pfu.default == 1)
|
||||
|
||||
if not include_current_pos:
|
||||
query = query.where(pfu.name != self.name)
|
||||
|
||||
pos_view_users = query.run(as_list=1, pluck=True)
|
||||
|
||||
for user in pos_view_users:
|
||||
if user:
|
||||
@@ -315,32 +314,3 @@ def pos_profile_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
)
|
||||
|
||||
return pos_profile
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_default_profile(pos_profile, company):
|
||||
modified = now()
|
||||
user = frappe.session.user
|
||||
|
||||
if pos_profile and company:
|
||||
frappe.db.sql(
|
||||
""" update `tabPOS Profile User` pfu, `tabPOS Profile` pf
|
||||
set
|
||||
pfu.default = 0, pf.modified = %s, pf.modified_by = %s
|
||||
where
|
||||
pfu.user = %s and pf.name = pfu.parent and pf.company = %s
|
||||
and pfu.default = 1""",
|
||||
(modified, user, user, company),
|
||||
auto_commit=1,
|
||||
)
|
||||
|
||||
frappe.db.sql(
|
||||
""" update `tabPOS Profile User` pfu, `tabPOS Profile` pf
|
||||
set
|
||||
pfu.default = 1, pf.modified = %s, pf.modified_by = %s
|
||||
where
|
||||
pfu.user = %s and pf.name = pfu.parent and pf.company = %s and pf.name = %s
|
||||
""",
|
||||
(modified, user, user, company, pos_profile),
|
||||
auto_commit=1,
|
||||
)
|
||||
|
||||
@@ -151,13 +151,13 @@
|
||||
"label": "Default Advance Account",
|
||||
"mandatory_depends_on": "doc.party_type",
|
||||
"options": "Account",
|
||||
"reqd": 1
|
||||
"reqd": 0
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-08 08:22:14.798085",
|
||||
"modified": "2026-05-16 11:43:12.758685",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Payment Reconciliation",
|
||||
|
||||
@@ -23,7 +23,7 @@ class ProcessPaymentReconciliation(Document):
|
||||
bank_cash_account: DF.Link | None
|
||||
company: DF.Link
|
||||
cost_center: DF.Link | None
|
||||
default_advance_account: DF.Link
|
||||
default_advance_account: DF.Link | None
|
||||
error_log: DF.LongText | None
|
||||
from_invoice_date: DF.Date | None
|
||||
from_payment_date: DF.Date | None
|
||||
@@ -131,6 +131,7 @@ def is_job_running(job_name: str) -> bool:
|
||||
@frappe.whitelist()
|
||||
def pause_job_for_doc(docname: str | None = None):
|
||||
if docname:
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
frappe.db.set_value("Process Payment Reconciliation", docname, "status", "Paused")
|
||||
log = frappe.db.get_value("Process Payment Reconciliation Log", filters={"process_pr": docname})
|
||||
if log:
|
||||
@@ -145,6 +146,8 @@ def trigger_job_for_doc(docname: str | None = None):
|
||||
if not docname:
|
||||
return
|
||||
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
|
||||
if not frappe.get_single_value("Accounts Settings", "auto_reconcile_payments"):
|
||||
frappe.throw(
|
||||
_("Auto Reconciliation of Payments has been disabled. Enable it through {0}").format(
|
||||
@@ -218,10 +221,7 @@ def trigger_reconciliation_for_queued_docs():
|
||||
fields = ["company", "party_type", "party", "receivable_payable_account", "default_advance_account"]
|
||||
|
||||
def get_filters_as_tuple(fields, doc):
|
||||
filters = ()
|
||||
for x in fields:
|
||||
filters += tuple(doc.get(x))
|
||||
return filters
|
||||
return tuple(doc.get(x) or "" for x in fields)
|
||||
|
||||
for x in all_queued:
|
||||
doc = frappe.get_doc("Process Payment Reconciliation", x)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "format:Process-PCV-{###}",
|
||||
"creation": "2025-09-25 15:44:03.534699",
|
||||
"doctype": "DocType",
|
||||
@@ -7,11 +8,13 @@
|
||||
"field_order": [
|
||||
"parent_pcv",
|
||||
"status",
|
||||
"amended_from",
|
||||
"section_normal_balances",
|
||||
"p_l_closing_balance",
|
||||
"normal_balances",
|
||||
"bs_closing_balance",
|
||||
"z_opening_balances",
|
||||
"amended_from"
|
||||
"normal_balances",
|
||||
"section_opening_balances",
|
||||
"z_opening_balances"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -64,17 +67,27 @@
|
||||
"fieldname": "bs_closing_balance",
|
||||
"fieldtype": "JSON",
|
||||
"label": "Balance Sheet Closing Balance"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_normal_balances",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Normal Balances"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_opening_balances",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Opening Balances"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-05 11:40:24.996403",
|
||||
"modified": "2026-06-01 12:16:37.374412",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Period Closing Voucher",
|
||||
"naming_rule": "Expression",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -36,8 +36,8 @@ class ProcessPeriodClosingVoucher(Document):
|
||||
parent_pcv: DF.Link
|
||||
status: DF.Literal["Queued", "Running", "Paused", "Completed", "Cancelled"]
|
||||
z_opening_balances: DF.Table[ProcessPeriodClosingVoucherDetail]
|
||||
|
||||
# end: auto-generated types
|
||||
|
||||
def on_discard(self):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
@@ -92,6 +92,7 @@ class ProcessPeriodClosingVoucher(Document):
|
||||
@frappe.whitelist()
|
||||
def start_pcv_processing(docname: str):
|
||||
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
@@ -137,9 +138,10 @@ def pause_pcv_processing(docname: str):
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
# If a date is stuck in 'Running' state, this will allow it to procced.
|
||||
if queued_dates := frappe.db.get_all(
|
||||
"Process Period Closing Voucher Detail",
|
||||
filters={"parent": docname, "status": "Queued"},
|
||||
filters={"parent": docname, "status": ["in", ["Queued", "Running"]]},
|
||||
pluck="name",
|
||||
):
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
@@ -173,6 +175,9 @@ def resume_pcv_processing(docname: str):
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
qb.update(ppcvd).set(ppcvd.status, "Queued").where(ppcvd.name.isin(paused_dates)).run()
|
||||
start_pcv_processing(docname)
|
||||
else:
|
||||
# If a parent doc is stuck in 'Running' state, will allow it to proceed.
|
||||
schedule_next_date(docname)
|
||||
|
||||
|
||||
def update_default_dimensions(dimension_fields, gl_entry, dimension_values):
|
||||
@@ -288,7 +293,21 @@ def schedule_next_date(docname: str):
|
||||
)
|
||||
# Ensure both normal and opening balances are processed for all dates
|
||||
if total_no_of_dates == completed:
|
||||
summarize_and_post_ledger_entries(docname)
|
||||
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
|
||||
is_job_running,
|
||||
)
|
||||
|
||||
job_name = f"summarize_{docname}"
|
||||
if not is_job_running(job_name):
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries",
|
||||
queue="long",
|
||||
timeout="3600",
|
||||
is_async=True,
|
||||
job_name=job_name,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
)
|
||||
|
||||
|
||||
def make_dict_json_compliant(dimension_wise_balance) -> dict:
|
||||
@@ -544,6 +563,9 @@ def process_individual_date(docname: str, date, report_type, parentfield):
|
||||
|
||||
if parentfield == "z_opening_balances":
|
||||
query = query.where(gle.is_opening.eq("Yes"))
|
||||
else:
|
||||
# Keep balances aligned with legacy PCV logic (non-opening transactions only)
|
||||
query = query.where(gle.is_opening.eq("No"))
|
||||
|
||||
query = query.groupby(gle.account)
|
||||
for dim in dimensions:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
// render
|
||||
frappe.listview_settings["Process Period Closing Voucher"] = {
|
||||
add_fields: ["status"],
|
||||
get_indicator: function (doc) {
|
||||
const status_colors = {
|
||||
Queued: "blue",
|
||||
Running: "orange",
|
||||
Paused: "gray",
|
||||
Completed: "green",
|
||||
Cancelled: "red",
|
||||
};
|
||||
return [__(doc.status), status_colors[doc.status], "status,=," + doc.status];
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,173 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher import (
|
||||
process_individual_date,
|
||||
)
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 0)
|
||||
self.company = "_Test Company"
|
||||
|
||||
def make_period_closing_voucher(self, posting_date, submit=True):
|
||||
fy = get_fiscal_year(posting_date, company="_Test Company")
|
||||
pcv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Period Closing Voucher",
|
||||
"transaction_date": posting_date or today(),
|
||||
"period_start_date": fy[1],
|
||||
"period_end_date": fy[2],
|
||||
"company": self.company,
|
||||
"fiscal_year": fy[0],
|
||||
"closing_account_head": "Retained Earnings - _TC",
|
||||
"remarks": "closing",
|
||||
}
|
||||
)
|
||||
pcv.insert()
|
||||
if submit:
|
||||
pcv.submit()
|
||||
|
||||
return pcv
|
||||
|
||||
def make_process_pcv(self):
|
||||
self.pcv = self.make_period_closing_voucher(posting_date=today(), submit=False)
|
||||
ppcv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Process Period Closing Voucher",
|
||||
"parent_pcv": self.pcv.name,
|
||||
}
|
||||
)
|
||||
ppcv.save()
|
||||
return ppcv
|
||||
|
||||
def set_processing_date_status(self, date, ppcv, rpt_type, parentfield, status):
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
|
||||
"status",
|
||||
status,
|
||||
)
|
||||
|
||||
def get_processing_date_closing_balance(self, date, ppcv, rpt_type, parentfield):
|
||||
return frappe.db.get_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
|
||||
"closing_balance",
|
||||
)
|
||||
|
||||
def test_opening_balance_double_counting(self):
|
||||
ppcv = self.make_process_pcv()
|
||||
self.assertEqual(self.pcv.is_first_period_closing_voucher(), True)
|
||||
opening_jv = make_journal_entry(
|
||||
posting_date=today(),
|
||||
amount=10,
|
||||
account1="Cash - _TC",
|
||||
account2="Debtors - _TC",
|
||||
company=self.company,
|
||||
save=False,
|
||||
)
|
||||
opening_jv.accounts[1].party_type = "Customer"
|
||||
opening_jv.accounts[1].party = "_Test Customer"
|
||||
opening_jv.is_opening = "Yes"
|
||||
opening_jv.save()
|
||||
opening_jv.submit()
|
||||
|
||||
jv = make_journal_entry(
|
||||
posting_date=today(),
|
||||
amount=120,
|
||||
account1="Debtors - _TC",
|
||||
account2="Sales - _TC",
|
||||
company=self.company,
|
||||
save=False,
|
||||
)
|
||||
jv.accounts[0].party_type = "Customer"
|
||||
jv.accounts[0].party = "_Test Customer"
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
# P&L balance
|
||||
parentfield = "normal_balances"
|
||||
rpt_type = "Profit and Loss"
|
||||
# status has to be set to 'Running' for logic to run
|
||||
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
|
||||
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(
|
||||
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
|
||||
)
|
||||
self.assertEqual(len(bal), 1)
|
||||
expected_pl = {
|
||||
"account": "Sales - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"debit": 0.0,
|
||||
"credit": 120.0,
|
||||
"debit_in_account_currency": 0.0,
|
||||
"credit_in_account_currency": 120.0,
|
||||
}
|
||||
for k in expected_pl.keys():
|
||||
with self.subTest(k):
|
||||
self.assertEqual(expected_pl[k], bal[0][k])
|
||||
|
||||
# Balance sheet balance
|
||||
rpt_type = "Balance Sheet"
|
||||
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
|
||||
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(
|
||||
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
|
||||
)
|
||||
self.assertEqual(len(bal), 1)
|
||||
expected_bs = {
|
||||
"account": "Debtors - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"debit": 120.0,
|
||||
"credit": 0.0,
|
||||
"debit_in_account_currency": 120.0,
|
||||
"credit_in_account_currency": 0.0,
|
||||
}
|
||||
for k in expected_bs.keys():
|
||||
with self.subTest(k):
|
||||
self.assertEqual(expected_bs[k], bal[0][k])
|
||||
|
||||
# Opening balance
|
||||
parentfield = "z_opening_balances"
|
||||
rpt_type = "Balance Sheet"
|
||||
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
|
||||
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(
|
||||
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
|
||||
)
|
||||
self.assertEqual(len(bal), 2)
|
||||
opening_cash = next(x for x in bal if x["account"] == "Cash - _TC")
|
||||
expected_opening_cash = {
|
||||
"account": "Cash - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"debit": 10.0,
|
||||
"credit": 0.0,
|
||||
"debit_in_account_currency": 10.0,
|
||||
"credit_in_account_currency": 0.0,
|
||||
"account_currency": "INR",
|
||||
}
|
||||
for k in expected_opening_cash.keys():
|
||||
with self.subTest(k):
|
||||
self.assertEqual(expected_opening_cash[k], opening_cash[k])
|
||||
|
||||
opening_debtors = next(x for x in bal if x["account"] == "Debtors - _TC")
|
||||
expected_opening_debtors = {
|
||||
"account": "Debtors - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"debit": 0.0,
|
||||
"credit": 10.0,
|
||||
"debit_in_account_currency": 0.0,
|
||||
"credit_in_account_currency": 10.0,
|
||||
"account_currency": "INR",
|
||||
}
|
||||
for k in expected_opening_debtors.keys():
|
||||
with self.subTest(k):
|
||||
self.assertEqual(expected_opening_debtors[k], opening_debtors[k])
|
||||
|
||||
@@ -101,6 +101,7 @@ class ProcessStatementOfAccounts(Document):
|
||||
|
||||
validate_template(self.subject)
|
||||
validate_template(self.body)
|
||||
validate_template(self.pdf_name)
|
||||
|
||||
if not self.customers:
|
||||
frappe.throw(_("Customers not selected."))
|
||||
@@ -518,6 +519,7 @@ def download_statements(document_name):
|
||||
@frappe.whitelist()
|
||||
def send_emails(document_name, from_scheduler=False, posting_date=None):
|
||||
doc = frappe.get_doc("Process Statement Of Accounts", document_name)
|
||||
doc.check_permission()
|
||||
report = get_report_pdf(doc, consolidated=False)
|
||||
|
||||
if report:
|
||||
@@ -574,6 +576,7 @@ def send_emails(document_name, from_scheduler=False, posting_date=None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def send_auto_email():
|
||||
frappe.has_permission("Process Statement Of Accounts", throw=True)
|
||||
selected = frappe.get_list(
|
||||
"Process Statement Of Accounts",
|
||||
filters={"enable_auto_email": 1},
|
||||
|
||||
@@ -591,6 +591,25 @@ frappe.ui.form.on("Purchase Invoice", {
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("write_off_account", function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
report_type: "Profit and Loss",
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("write_off_cost_center", function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.fields_dict["items"].grid.get_field("deferred_expense_account").get_query = function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
|
||||
@@ -27,8 +27,6 @@
|
||||
"update_billed_amount_in_purchase_receipt",
|
||||
"apply_tds",
|
||||
"amended_from",
|
||||
"section_break_hzux",
|
||||
"title",
|
||||
"supplier_invoice_details",
|
||||
"bill_no",
|
||||
"column_break_15",
|
||||
@@ -201,6 +199,7 @@
|
||||
"hold_comment",
|
||||
"additional_info_section",
|
||||
"is_internal_supplier",
|
||||
"title",
|
||||
"represents_company",
|
||||
"supplier_group",
|
||||
"sender",
|
||||
@@ -1685,10 +1684,6 @@
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Automation"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_hzux",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "title",
|
||||
@@ -1703,7 +1698,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-28 07:15:31.062404",
|
||||
"modified": "2026-05-28 12:36:55.215363",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -32,10 +32,14 @@ from erpnext.accounts.general_ledger import (
|
||||
merge_similar_entries,
|
||||
)
|
||||
from erpnext.accounts.party import get_due_date, get_party_account
|
||||
from erpnext.accounts.utils import get_account_currency, get_fiscal_year, update_voucher_outstanding
|
||||
from erpnext.accounts.utils import (
|
||||
get_account_currency,
|
||||
get_fiscal_year,
|
||||
refresh_subscription_status,
|
||||
update_voucher_outstanding,
|
||||
)
|
||||
from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
|
||||
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
|
||||
from erpnext.buying.utils import check_on_hold_or_closed_status
|
||||
from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
@@ -282,7 +286,9 @@ class PurchaseInvoice(BuyingController):
|
||||
self.check_conversion_rate()
|
||||
self.validate_credit_to_acc()
|
||||
self.clear_unallocated_advances("Purchase Invoice Advance", "advances")
|
||||
self.check_on_hold_or_closed_status()
|
||||
self.check_for_on_hold_or_closed_status(
|
||||
"Purchase Order", "purchase_order", exclude_if_field="purchase_receipt"
|
||||
)
|
||||
self.validate_with_previous_doc()
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.validate_uom_is_integer("stock_uom", "stock_qty")
|
||||
@@ -290,6 +296,7 @@ class PurchaseInvoice(BuyingController):
|
||||
self.validate_expense_account()
|
||||
self.set_against_expense_account()
|
||||
self.validate_write_off_account()
|
||||
self.validate_write_off_cost_center()
|
||||
self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
|
||||
self.set_status()
|
||||
self.validate_purchase_receipt_if_update_stock()
|
||||
@@ -387,14 +394,6 @@ class PurchaseInvoice(BuyingController):
|
||||
|
||||
self.party_account_currency = account.account_currency
|
||||
|
||||
def check_on_hold_or_closed_status(self):
|
||||
check_list = []
|
||||
|
||||
for d in self.get("items"):
|
||||
if d.purchase_order and d.purchase_order not in check_list and not d.purchase_receipt:
|
||||
check_list.append(d.purchase_order)
|
||||
check_on_hold_or_closed_status("Purchase Order", d.purchase_order)
|
||||
|
||||
def validate_with_previous_doc(self):
|
||||
super().validate_with_previous_doc(
|
||||
{
|
||||
@@ -635,15 +634,16 @@ class PurchaseInvoice(BuyingController):
|
||||
throw(msg, title=_("Mandatory Purchase Order"))
|
||||
|
||||
def pr_required(self):
|
||||
stock_items = self.get_stock_items()
|
||||
if frappe.db.get_single_value("Buying Settings", "pr_required") == "Yes":
|
||||
stock_and_asset_items = self.get_stock_items()
|
||||
stock_and_asset_items.extend(self.get_asset_items())
|
||||
if frappe.get_value(
|
||||
"Supplier", self.supplier, "allow_purchase_invoice_creation_without_purchase_receipt"
|
||||
):
|
||||
return
|
||||
|
||||
for d in self.get("items"):
|
||||
if not d.purchase_receipt and d.item_code in stock_items:
|
||||
if not d.purchase_receipt and d.item_code in stock_and_asset_items:
|
||||
msg = _("Purchase Receipt Required for item {}").format(frappe.bold(d.item_code))
|
||||
msg += "<br><br>"
|
||||
msg += _(
|
||||
@@ -659,6 +659,27 @@ class PurchaseInvoice(BuyingController):
|
||||
if self.write_off_amount and not self.write_off_account:
|
||||
throw(_("Please enter Write Off Account"))
|
||||
|
||||
if not self.write_off_account:
|
||||
return
|
||||
|
||||
doc = frappe.db.get_value(
|
||||
"Account", self.write_off_account, ["report_type", "is_group", "company"], as_dict=True
|
||||
)
|
||||
|
||||
if not doc or doc.report_type != "Profit and Loss" or doc.is_group or doc.company != self.company:
|
||||
throw(_("Please enter a valid Write Off Account"))
|
||||
|
||||
def validate_write_off_cost_center(self):
|
||||
if not self.write_off_cost_center:
|
||||
return
|
||||
|
||||
doc = frappe.db.get_value(
|
||||
"Cost Center", self.write_off_cost_center, ["is_group", "company"], as_dict=True
|
||||
)
|
||||
|
||||
if not doc or doc.is_group or doc.company != self.company:
|
||||
throw(_("Please enter a valid Write Off Cost Center"))
|
||||
|
||||
def check_prev_docstatus(self):
|
||||
for d in self.get("items"):
|
||||
if d.purchase_order:
|
||||
@@ -739,6 +760,7 @@ class PurchaseInvoice(BuyingController):
|
||||
|
||||
def validate_for_repost(self):
|
||||
self.validate_write_off_account()
|
||||
self.validate_write_off_cost_center()
|
||||
self.validate_expense_account()
|
||||
validate_docs_for_voucher_types(["Purchase Invoice"])
|
||||
validate_docs_for_deferred_accounting([], [self.name])
|
||||
@@ -806,6 +828,10 @@ class PurchaseInvoice(BuyingController):
|
||||
self.validate_for_repost()
|
||||
self.repost_accounting_entries()
|
||||
|
||||
def refresh_subscription_status(self):
|
||||
if self.get("subscription"):
|
||||
refresh_subscription_status(self.subscription)
|
||||
|
||||
def make_gl_entries(self, gl_entries=None, from_repost=False):
|
||||
update_outstanding = "No" if (cint(self.is_paid) or self.write_off_account) else "Yes"
|
||||
if self.docstatus == 1:
|
||||
@@ -852,7 +878,9 @@ class PurchaseInvoice(BuyingController):
|
||||
if update_outstanding == "No":
|
||||
update_voucher_outstanding(
|
||||
voucher_type=self.doctype,
|
||||
voucher_no=self.return_against if cint(self.is_return) and self.return_against else self.name,
|
||||
voucher_no=self.return_against
|
||||
if (cint(self.is_return) and self.return_against)
|
||||
else self.name,
|
||||
account=self.credit_to,
|
||||
party_type="Supplier",
|
||||
party=self.supplier,
|
||||
@@ -1546,6 +1574,9 @@ class PurchaseInvoice(BuyingController):
|
||||
def make_payment_gl_entries(self, gl_entries):
|
||||
# Make Cash GL Entries
|
||||
if cint(self.is_paid) and self.cash_bank_account and self.paid_amount:
|
||||
against_voucher = self.name
|
||||
if self.is_return and self.return_against and not self.update_outstanding_for_self:
|
||||
against_voucher = self.return_against
|
||||
bank_account_currency = get_account_currency(self.cash_bank_account)
|
||||
# CASH, make payment entries
|
||||
gl_entries.append(
|
||||
@@ -1560,9 +1591,7 @@ class PurchaseInvoice(BuyingController):
|
||||
if self.party_account_currency == self.company_currency
|
||||
else self.paid_amount,
|
||||
"debit_in_transaction_currency": self.paid_amount,
|
||||
"against_voucher": self.return_against
|
||||
if cint(self.is_return) and self.return_against
|
||||
else self.name,
|
||||
"against_voucher": against_voucher,
|
||||
"against_voucher_type": self.doctype,
|
||||
"cost_center": self.cost_center,
|
||||
"project": self.project,
|
||||
@@ -1684,7 +1713,9 @@ class PurchaseInvoice(BuyingController):
|
||||
super().on_cancel()
|
||||
PurchaseTaxWithholding(self).on_cancel()
|
||||
|
||||
self.check_on_hold_or_closed_status()
|
||||
self.check_for_on_hold_or_closed_status(
|
||||
"Purchase Order", "purchase_order", exclude_if_field="purchase_receipt"
|
||||
)
|
||||
|
||||
if self.is_return and not self.update_billed_amount_in_purchase_order:
|
||||
# NOTE status updating bypassed for is_return
|
||||
@@ -1977,6 +2008,7 @@ def make_stock_entry(source_name, target_doc=None):
|
||||
def change_release_date(name, release_date=None):
|
||||
if frappe.db.exists("Purchase Invoice", name):
|
||||
pi = frappe.get_lazy_doc("Purchase Invoice", name)
|
||||
pi.check_permission()
|
||||
pi.db_set("release_date", release_date)
|
||||
|
||||
|
||||
|
||||
@@ -2962,6 +2962,52 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
pr = make_purchase_receipt_from_pi(pi.name)
|
||||
self.assertFalse(pr.items)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"enable_common_party_accounting": True})
|
||||
def test_purchase_invoice_return_common_party_je_has_no_negative_amounts(self):
|
||||
from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
|
||||
make_customer,
|
||||
)
|
||||
from erpnext.accounts.doctype.party_link.party_link import create_party_link
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
customer = make_customer(customer="_Test Common Party Return PI")
|
||||
supplier = create_supplier(supplier_name="_Test Common Party Return PI").name
|
||||
# Supplier must be secondary so get_common_party_link finds it via the PI's party_type
|
||||
party_link = create_party_link("Customer", customer, supplier)
|
||||
|
||||
pi = make_purchase_invoice(supplier=supplier, parent_cost_center="_Test Cost Center - _TC")
|
||||
|
||||
return_pi = make_return_doc(pi.doctype, pi.name)
|
||||
return_pi.submit()
|
||||
|
||||
# JE for the return should credit the supplier (secondary/reconciliation) account
|
||||
# and debit the customer (primary) account — all positive amounts
|
||||
jv_accounts = frappe.get_all(
|
||||
"Journal Entry Account",
|
||||
filters={"reference_type": return_pi.doctype, "reference_name": return_pi.name, "docstatus": 1},
|
||||
fields=["debit_in_account_currency", "credit_in_account_currency", "account"],
|
||||
)
|
||||
|
||||
self.assertTrue(jv_accounts, "Expected a Journal Entry for the return invoice")
|
||||
for row in jv_accounts:
|
||||
self.assertGreaterEqual(
|
||||
row.debit_in_account_currency,
|
||||
0,
|
||||
f"Negative debit on account {row.account}",
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
row.credit_in_account_currency,
|
||||
0,
|
||||
f"Negative credit on account {row.account}",
|
||||
)
|
||||
|
||||
# Supplier (secondary) account must be credited, not debited
|
||||
supplier_row = next(r for r in jv_accounts if r.account == pi.credit_to)
|
||||
self.assertGreater(supplier_row.credit_in_account_currency, 0)
|
||||
self.assertEqual(supplier_row.debit_in_account_currency, 0)
|
||||
|
||||
party_link.delete()
|
||||
|
||||
|
||||
def set_advance_flag(company, flag, default_account):
|
||||
frappe.db.set_value(
|
||||
|
||||
@@ -179,12 +179,31 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
: "Inter Company Purchase Invoice";
|
||||
|
||||
me.frm.add_custom_button(
|
||||
button_label,
|
||||
__(button_label),
|
||||
function () {
|
||||
me.make_inter_company_invoice();
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.get_received_items",
|
||||
args: {
|
||||
reference_name: me.frm.doc.name,
|
||||
doctype: "Purchase Invoice",
|
||||
reference_fieldname: "sales_invoice_item",
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.exc) return;
|
||||
const received_items = r.message || {};
|
||||
const has_pending_qty = me.frm.doc.items.some(
|
||||
(item) => flt(item.qty) - flt(received_items[item.name] || 0) > 0
|
||||
);
|
||||
if (!has_pending_qty) {
|
||||
me.frm.remove_custom_button(__(button_label), __("Create"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,6 @@
|
||||
"is_created_using_pos",
|
||||
"pos_closing_entry",
|
||||
"has_subcontracted",
|
||||
"section_break_qllv",
|
||||
"title",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
"dimension_col_break",
|
||||
@@ -234,6 +232,7 @@
|
||||
"status",
|
||||
"remarks",
|
||||
"customer_group",
|
||||
"title",
|
||||
"column_break_imbx",
|
||||
"is_internal_customer",
|
||||
"represents_company",
|
||||
@@ -1921,7 +1920,7 @@
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: !doc.is_return",
|
||||
"description": "Issue a debit note with 0 qty against an existing Sales Invoice",
|
||||
"description": "Issue a debit note against an existing Sales Invoice to adjust the rate. The quantity will be retained from the original invoice.",
|
||||
"fieldname": "is_debit_note",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Rate Adjustment Entry (Debit Note)"
|
||||
@@ -2343,10 +2342,6 @@
|
||||
"fieldname": "column_break_iaso",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_qllv",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "title",
|
||||
@@ -2367,7 +2362,7 @@
|
||||
"link_fieldname": "consolidated_invoice"
|
||||
}
|
||||
],
|
||||
"modified": "2026-05-01 02:37:29.742764",
|
||||
"modified": "2026-05-28 12:15:12.486443",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice",
|
||||
|
||||
@@ -28,9 +28,15 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
|
||||
)
|
||||
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import SalesTaxWithholding
|
||||
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
|
||||
from erpnext.accounts.party import get_due_date, get_party_account, get_party_details
|
||||
from erpnext.accounts.party import (
|
||||
CROSS_PARTY_FIELD_NO_MAP,
|
||||
get_due_date,
|
||||
get_party_account,
|
||||
get_party_details,
|
||||
)
|
||||
from erpnext.accounts.utils import (
|
||||
get_account_currency,
|
||||
refresh_subscription_status,
|
||||
update_voucher_outstanding,
|
||||
)
|
||||
from erpnext.assets.doctype.asset.asset import split_asset
|
||||
@@ -370,6 +376,8 @@ class SalesInvoice(SellingController):
|
||||
if row.billing_amount:
|
||||
row.billing_amount = -abs(row.billing_amount)
|
||||
|
||||
self.validate_update_stock_for_pick_list_reference()
|
||||
self.set_serial_and_batch_bundle_from_pick_list()
|
||||
self.update_packing_list()
|
||||
self.set_billing_hours_and_amount()
|
||||
self.update_timesheet_billing_for_project()
|
||||
@@ -389,6 +397,18 @@ class SalesInvoice(SellingController):
|
||||
self.validate_subcontracted_sales_order()
|
||||
self.validate_scio_self_rm_qty()
|
||||
|
||||
def validate_update_stock_for_pick_list_reference(self):
|
||||
if self.update_stock or self.is_return:
|
||||
return
|
||||
|
||||
for row in self.items:
|
||||
if row.get("against_pick_list"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row {0}: Update Stock must be checked for item {1} because it is against Pick List {2}."
|
||||
).format(row.idx, frappe.bold(row.item_code), frappe.bold(row.against_pick_list))
|
||||
)
|
||||
|
||||
def validate_accounts(self):
|
||||
self.validate_write_off_account()
|
||||
self.validate_account_for_change_amount()
|
||||
@@ -491,6 +511,7 @@ class SalesInvoice(SellingController):
|
||||
|
||||
if self.update_stock == 1:
|
||||
self.repost_future_sle_and_gle()
|
||||
self.update_pick_list_status()
|
||||
|
||||
if not self.is_return:
|
||||
self.update_billing_status_for_zero_amount_refdoc("Delivery Note")
|
||||
@@ -614,6 +635,7 @@ class SalesInvoice(SellingController):
|
||||
if self.update_stock == 1:
|
||||
self.update_stock_reservation_entries()
|
||||
self.repost_future_sle_and_gle()
|
||||
self.update_pick_list_status()
|
||||
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
@@ -665,26 +687,41 @@ class SalesInvoice(SellingController):
|
||||
if not cint(self.update_stock):
|
||||
return
|
||||
|
||||
self.status_updater.append(
|
||||
{
|
||||
"source_dt": "Sales Invoice Item",
|
||||
"target_dt": "Sales Order Item",
|
||||
"target_parent_dt": "Sales Order",
|
||||
"target_parent_field": "per_delivered",
|
||||
"target_field": "delivered_qty",
|
||||
"target_ref_field": "qty",
|
||||
"source_field": "qty",
|
||||
"join_field": "so_detail",
|
||||
"percent_join_field": "sales_order",
|
||||
"status_field": "delivery_status",
|
||||
"keyword": "Delivered",
|
||||
"second_source_dt": "Delivery Note Item",
|
||||
"second_source_field": "qty",
|
||||
"second_join_field": "so_detail",
|
||||
"overflow_type": "delivery",
|
||||
"extra_cond": """ and exists(select name from `tabSales Invoice`
|
||||
where name=`tabSales Invoice Item`.parent and update_stock = 1)""",
|
||||
}
|
||||
self.status_updater.extend(
|
||||
[
|
||||
{
|
||||
"source_dt": "Sales Invoice Item",
|
||||
"target_dt": "Sales Order Item",
|
||||
"target_parent_dt": "Sales Order",
|
||||
"target_parent_field": "per_delivered",
|
||||
"target_field": "delivered_qty",
|
||||
"target_ref_field": "qty",
|
||||
"source_field": "qty",
|
||||
"join_field": "so_detail",
|
||||
"percent_join_field": "sales_order",
|
||||
"status_field": "delivery_status",
|
||||
"keyword": "Delivered",
|
||||
"second_source_dt": "Delivery Note Item",
|
||||
"second_source_field": "qty",
|
||||
"second_join_field": "so_detail",
|
||||
"overflow_type": "delivery",
|
||||
"extra_cond": """ and exists(select name from `tabSales Invoice`
|
||||
where name=`tabSales Invoice Item`.parent and update_stock = 1)""",
|
||||
},
|
||||
{
|
||||
"source_dt": "Sales Invoice Item",
|
||||
"target_dt": "Pick List Item",
|
||||
"join_field": "pick_list_item",
|
||||
"target_field": "delivered_qty",
|
||||
"target_parent_dt": "Pick List",
|
||||
"target_parent_field": "per_delivered",
|
||||
"target_ref_field": "picked_qty",
|
||||
"source_field": "stock_qty",
|
||||
"percent_join_field": "against_pick_list",
|
||||
"status_field": "delivery_status",
|
||||
"keyword": "Delivered",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
if not cint(self.is_return):
|
||||
@@ -777,6 +814,10 @@ class SalesInvoice(SellingController):
|
||||
"set_default_payment": pos.get("set_grand_total_to_default_mop", 1),
|
||||
}
|
||||
|
||||
def refresh_subscription_status(self):
|
||||
if self.get("subscription"):
|
||||
refresh_subscription_status(self.subscription)
|
||||
|
||||
@frappe.whitelist()
|
||||
def reset_mode_of_payments(self):
|
||||
if self.pos_profile:
|
||||
@@ -968,9 +1009,6 @@ class SalesInvoice(SellingController):
|
||||
if selling_price_list:
|
||||
self.set("selling_price_list", selling_price_list)
|
||||
|
||||
if not for_validate:
|
||||
self.update_stock = cint(pos.get("update_stock"))
|
||||
|
||||
# set pos values in items
|
||||
for item in self.get("items"):
|
||||
if item.get("item_code"):
|
||||
@@ -981,6 +1019,10 @@ class SalesInvoice(SellingController):
|
||||
if (not for_validate) or (for_validate and not item.get(fname)):
|
||||
item.set(fname, val)
|
||||
|
||||
if not for_validate:
|
||||
dn_flag = any(d.get("dn_detail") for d in self.get("items"))
|
||||
self.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
|
||||
|
||||
# fetch terms
|
||||
if self.tc_name and not self.terms:
|
||||
self.terms = frappe.db.get_value("Terms and Conditions", self.tc_name, "terms")
|
||||
@@ -2023,15 +2065,24 @@ class SalesInvoice(SellingController):
|
||||
def update_billing_status_in_dn(self, update_modified=True):
|
||||
if self.is_return and not self.update_billed_amount_in_delivery_note:
|
||||
return
|
||||
|
||||
updated_delivery_notes = []
|
||||
|
||||
SalesInvoiceItem = frappe.qb.DocType("Sales Invoice Item")
|
||||
from frappe.query_builder.functions import Coalesce, Sum
|
||||
|
||||
for d in self.get("items"):
|
||||
if d.dn_detail:
|
||||
billed_amt = frappe.db.sql(
|
||||
"""select sum(amount) from `tabSales Invoice Item`
|
||||
where dn_detail=%s and docstatus=1""",
|
||||
d.dn_detail,
|
||||
query = (
|
||||
frappe.qb.from_(SalesInvoiceItem)
|
||||
.select(Coalesce(Sum(SalesInvoiceItem.amount), 0))
|
||||
.where(SalesInvoiceItem.dn_detail == d.dn_detail)
|
||||
.where(SalesInvoiceItem.docstatus == 1)
|
||||
)
|
||||
billed_amt = billed_amt and billed_amt[0][0] or 0
|
||||
|
||||
res = query.run()
|
||||
billed_amt = res[0][0] if res else 0
|
||||
|
||||
frappe.db.set_value(
|
||||
"Delivery Note Item",
|
||||
d.dn_detail,
|
||||
@@ -2742,7 +2793,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
"rate": "rate",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: doc.qty > 0,
|
||||
"condition": lambda doc: doc.qty - received_items.get(doc.name, 0.0) > 0,
|
||||
}
|
||||
|
||||
if doctype in ["Sales Invoice", "Sales Order"]:
|
||||
@@ -2775,7 +2826,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
"doctype": target_doctype,
|
||||
"postprocess": update_details,
|
||||
"set_target_warehouse": "set_from_warehouse",
|
||||
"field_no_map": ["taxes_and_charges", "set_warehouse", "shipping_address", "cost_center"],
|
||||
"field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse", "cost_center"],
|
||||
},
|
||||
doctype + " Item": item_field_map,
|
||||
},
|
||||
@@ -2783,10 +2834,19 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
set_missing_values,
|
||||
)
|
||||
|
||||
if not doclist.get("items"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot create Intercompany {0}. All items in the source {1} have already been fully invoiced. "
|
||||
"Please check the existing linked {2}s."
|
||||
).format(target_doctype, doctype, target_doctype)
|
||||
)
|
||||
|
||||
return doclist
|
||||
|
||||
|
||||
def get_received_items(reference_name, doctype, reference_fieldname):
|
||||
@frappe.whitelist()
|
||||
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
|
||||
reference_field = "inter_company_invoice_reference"
|
||||
if doctype == "Purchase Order":
|
||||
reference_field = "inter_company_order_reference"
|
||||
@@ -2799,20 +2859,19 @@ def get_received_items(reference_name, doctype, reference_fieldname):
|
||||
target_doctypes = frappe.get_all(
|
||||
doctype,
|
||||
filters=filters,
|
||||
as_list=True,
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
received_items_map = {}
|
||||
if target_doctypes:
|
||||
target_doctypes = list(target_doctypes[0])
|
||||
|
||||
received_items_map = frappe._dict(
|
||||
frappe.get_all(
|
||||
received_items_data = frappe.get_all(
|
||||
doctype + " Item",
|
||||
filters={"parent": ("in", target_doctypes)},
|
||||
fields=[reference_fieldname, "qty"],
|
||||
as_list=1,
|
||||
)
|
||||
)
|
||||
for item in received_items_data:
|
||||
key = item.get(reference_fieldname)
|
||||
if key:
|
||||
received_items_map[key] = received_items_map.get(key, 0.0) + flt(item.qty)
|
||||
|
||||
return received_items_map
|
||||
|
||||
@@ -3035,15 +3094,22 @@ def update_multi_mode_option(doc, pos_profile):
|
||||
|
||||
|
||||
def get_all_mode_of_payments(doc):
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select mpa.default_account, mpa.parent, mp.type as type
|
||||
from `tabMode of Payment Account` mpa,`tabMode of Payment` mp
|
||||
where mpa.parent = mp.name and mpa.company = %(company)s and mp.enabled = 1""",
|
||||
{"company": doc.company},
|
||||
as_dict=1,
|
||||
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
|
||||
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(ModeOfPaymentAccount)
|
||||
.join(ModeOfPayment)
|
||||
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
|
||||
.select(
|
||||
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
|
||||
)
|
||||
.where(ModeOfPaymentAccount.company == doc.company)
|
||||
.where(ModeOfPayment.enabled == 1)
|
||||
)
|
||||
|
||||
return query.run(as_dict=1)
|
||||
|
||||
|
||||
def get_mode_of_payments_info(mode_of_payments, company):
|
||||
data = frappe.db.sql(
|
||||
|
||||
@@ -383,6 +383,262 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(si.net_total, 3859.65)
|
||||
self.assertEqual(si.grand_total, 4900.00)
|
||||
|
||||
@ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###", "currency_precision": 0})
|
||||
def test_inclusive_tax_zero_decimal_currency(self):
|
||||
"""Tax-included prices in zero-decimal currencies (e.g. JPY) must not produce
|
||||
net + tax != gross due to double rounding of the net amount."""
|
||||
si = create_sales_invoice(qty=1, rate=50000, do_not_save=True)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Tax 10%",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
|
||||
# With currency_precision=0 (like JPY, KRW):
|
||||
# 50,000 / 1.10 = 45,454.545... → net rounds to 45,455
|
||||
# Tax from unrounded net: 0.10 * 45,454.545 = 4,545.4545 → rounds to 4,545
|
||||
# The fix ensures net + tax = gross without double rounding error
|
||||
self.assertEqual(si.items[0].net_amount, 45455)
|
||||
self.assertEqual(si.taxes[0].tax_amount, 4545)
|
||||
self.assertEqual(si.grand_total, 50000)
|
||||
|
||||
def test_inclusive_tax_decimal_value_currency(self):
|
||||
"""Tax-included prices with decimal currency values must preserve gross total."""
|
||||
si = create_sales_invoice(qty=1, rate=10000.04, do_not_save=True)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Tax 10%",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
|
||||
# 10,000.04 / 1.10 = 9,090.94545... → net rounds to 9,090.95
|
||||
# Tax from unrounded net: 0.10 * 9,090.94545... = 909.0945... → rounds to 909.09
|
||||
# If tax were calculated from rounded net instead, it would become 909.10 and grand total 10,000.05.
|
||||
self.assertEqual(si.items[0].net_amount, 9090.95)
|
||||
self.assertEqual(si.taxes[0].tax_amount, 909.09)
|
||||
self.assertEqual(si.grand_total, 10000.04)
|
||||
|
||||
@ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###", "currency_precision": 0})
|
||||
def test_inclusive_tax_zero_decimal_currency_multiple_items(self):
|
||||
"""Multiple items with tax-included prices in zero-decimal currency."""
|
||||
si = create_sales_invoice(qty=1, rate=50000, do_not_save=True)
|
||||
create_item("_Test Inclusive Tax Item 2")
|
||||
si.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Inclusive Tax Item 2",
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"qty": 1,
|
||||
"rate": 30000,
|
||||
"income_account": "Sales - _TC",
|
||||
"expense_account": "Cost of Goods Sold - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
},
|
||||
)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Tax 10%",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
|
||||
# With currency_precision=0:
|
||||
# Item 1: 50,000 / 1.10 = 45,454.545 → net 45,455, tax 4,545
|
||||
# Item 2: 30,000 / 1.10 = 27,272.727 → net 27,273, tax 2,727
|
||||
# Per-item: net + tax = gross holds (45455+4545=50000, 27273+2727=30000)
|
||||
# Accumulated tax rounds separately: flt(7272.72, 0) = 7273
|
||||
# adjust_grand_total_for_inclusive_tax patches grand_total back to 80000
|
||||
self.assertEqual(si.items[0].net_amount, 45455)
|
||||
self.assertEqual(si.items[1].net_amount, 27273)
|
||||
self.assertEqual(si.net_total, 72728)
|
||||
self.assertEqual(si.taxes[0].tax_amount, 7273)
|
||||
self.assertEqual(si.grand_total, 80000)
|
||||
|
||||
@ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###", "currency_precision": 0})
|
||||
def test_inclusive_tax_zero_decimal_currency_many_items(self):
|
||||
"""Test with 10 items (mixed 10% and 5% tax) to verify tolerance of 1 is sufficient."""
|
||||
si = create_sales_invoice(qty=1, rate=50000, do_not_save=True)
|
||||
|
||||
# Add 9 more items - mix of amounts and tax rates
|
||||
# Using similar amounts to maximize same-direction rounding
|
||||
item_configs = [
|
||||
("_Test Inclusive Tax Item 2", 50100, None), # 10% (default)
|
||||
("_Test Inclusive Tax Item 3", 50200, '{"_Test Account Service Tax - _TC": 5}'), # 5%
|
||||
("_Test Inclusive Tax Item 4", 50300, None), # 10%
|
||||
("_Test Inclusive Tax Item 5", 50400, '{"_Test Account Service Tax - _TC": 5}'), # 5%
|
||||
("_Test Inclusive Tax Item 6", 50500, None), # 10%
|
||||
("_Test Inclusive Tax Item 7", 50600, '{"_Test Account Service Tax - _TC": 5}'), # 5%
|
||||
("_Test Inclusive Tax Item 8", 50700, None), # 10%
|
||||
("_Test Inclusive Tax Item 9", 50800, None), # 10%
|
||||
("_Test Inclusive Tax Item 10", 50900, '{"_Test Account Service Tax - _TC": 5}'), # 5%
|
||||
]
|
||||
|
||||
for item_code, rate, item_tax_rate in item_configs:
|
||||
create_item(item_code)
|
||||
item_dict = {
|
||||
"item_code": item_code,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"qty": 1,
|
||||
"rate": rate,
|
||||
"income_account": "Sales - _TC",
|
||||
"expense_account": "Cost of Goods Sold - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
}
|
||||
if item_tax_rate:
|
||||
item_dict["item_tax_rate"] = item_tax_rate
|
||||
si.append("items", item_dict)
|
||||
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Tax 10%",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
|
||||
# Verify each item: net + tax = gross (within rounding tolerance)
|
||||
total_gross = 0
|
||||
for item in si.items:
|
||||
total_gross += item.amount
|
||||
|
||||
# Grand total should match sum of gross amounts
|
||||
# This tests that the tolerance of 1 handles mixed tax rates and similar amounts
|
||||
self.assertEqual(si.grand_total, total_gross)
|
||||
|
||||
def test_inclusive_tax_with_decimal_value_on_previous_row_amount(self):
|
||||
"""Inclusive tax with decimal value and On Previous Row Amount must not double-round net amount."""
|
||||
si = create_sales_invoice(qty=1, rate=50000.55, do_not_save=True)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Tax 10%",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Previous Row Amount",
|
||||
"account_head": "_Test Account Education Cess - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Cess 5% on Tax 10%",
|
||||
"rate": 5,
|
||||
"row_id": 1,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
|
||||
# Tax fractions: 10% + (5% of 10%) = 10.5%
|
||||
# 50,000.55 / 1.105 = 45,249.3665... → net rounds to 45,249.37
|
||||
# Taxes are calculated from the unrounded net to keep the inclusive gross stable.
|
||||
self.assertEqual(si.items[0].net_amount, 45249.37)
|
||||
self.assertEqual(si.taxes[0].tax_amount, 4524.94)
|
||||
self.assertEqual(si.taxes[1].tax_amount, 226.25)
|
||||
self.assertEqual(si.grand_total, 50000.55)
|
||||
|
||||
def test_inclusive_tax_with_decimal_value_on_previous_row_amount_non_inclusive(self):
|
||||
"""Non-inclusive previous-row tax should be added after inclusive tax extraction."""
|
||||
si = create_sales_invoice(qty=1, rate=10000.04, do_not_save=True)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Tax 10%",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Previous Row Amount",
|
||||
"account_head": "_Test Account Education Cess - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Cess 5% on Tax 10%",
|
||||
"rate": 5,
|
||||
"row_id": 1,
|
||||
"included_in_print_rate": 0,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
|
||||
# Only the first tax is inclusive:
|
||||
# 10,000.04 / 1.10 = 9,090.94545... → net rounds to 9,090.95
|
||||
# Inclusive tax = 909.09, restoring the original gross of 10,000.04
|
||||
# The non-inclusive previous-row tax is added afterward: 5% of 909.09 = 45.45
|
||||
self.assertEqual(si.items[0].net_amount, 9090.95)
|
||||
self.assertEqual(si.taxes[0].tax_amount, 909.09)
|
||||
self.assertEqual(si.taxes[1].tax_amount, 45.45)
|
||||
self.assertEqual(si.grand_total, 10045.49)
|
||||
|
||||
def test_inclusive_tax_with_decimal_value_on_previous_row_total(self):
|
||||
"""Inclusive tax with decimal value and On Previous Row Total must not double-round net amount."""
|
||||
si = create_sales_invoice(qty=1, rate=50000.55, do_not_save=True)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Tax 10%",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Previous Row Total",
|
||||
"account_head": "_Test Account Education Cess - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Cess 5% on Previous Total",
|
||||
"rate": 5,
|
||||
"row_id": 1,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
|
||||
# Tax fractions: 10% + (5% of 110%) = 15.5%
|
||||
# 50,000.55 / 1.155 = 43,290.5195... → net rounds to 43,290.52
|
||||
# Taxes are calculated from the unrounded net/previous total to keep the inclusive gross stable.
|
||||
self.assertEqual(si.items[0].net_amount, 43290.52)
|
||||
self.assertEqual(si.taxes[0].tax_amount, 4329.05)
|
||||
self.assertEqual(si.taxes[1].tax_amount, 2380.98)
|
||||
self.assertEqual(si.grand_total, 50000.55)
|
||||
|
||||
def test_sales_invoice_discount_amount(self):
|
||||
si = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][3])
|
||||
si.discount_amount = 104.94
|
||||
@@ -2662,6 +2918,34 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(target_doc.company, "_Test Company 1")
|
||||
self.assertEqual(target_doc.supplier, "_Test Internal Supplier")
|
||||
|
||||
def test_inter_company_transaction_does_not_inherit_party_fields(self):
|
||||
"""
|
||||
Party-derived fields on SI (from Customer) must not leak into the mapped PI.
|
||||
"""
|
||||
si = create_sales_invoice(
|
||||
company="Wind Power LLC",
|
||||
customer="_Test Internal Customer",
|
||||
debit_to="Debtors - WP",
|
||||
warehouse="Stores - WP",
|
||||
income_account="Sales - WP",
|
||||
expense_account="Cost of Goods Sold - WP",
|
||||
cost_center="Main - WP",
|
||||
currency="USD",
|
||||
do_not_save=1,
|
||||
)
|
||||
si.selling_price_list = "_Test Price List Rest of the World"
|
||||
si.tax_category = "_Test Tax Category 1"
|
||||
si.language = "ar"
|
||||
si.payment_terms_template = "_Test Payment Term Template"
|
||||
si.submit()
|
||||
|
||||
pi = make_inter_company_transaction("Sales Invoice", si.name)
|
||||
|
||||
supplier = frappe.get_doc("Supplier", "_Test Internal Supplier")
|
||||
self.assertEqual(pi.tax_category or None, supplier.tax_category or None)
|
||||
self.assertEqual(pi.language or None, supplier.language or None)
|
||||
self.assertEqual(pi.payment_terms_template or None, supplier.payment_terms or None)
|
||||
|
||||
def test_inter_company_transaction_without_default_warehouse(self):
|
||||
"Check mapping (expense account) of inter company SI to PI in absence of default warehouse."
|
||||
# setup
|
||||
@@ -2716,6 +3000,67 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
frappe.local.enable_perpetual_inventory["_Test Company 1"] = old_perpetual_inventory
|
||||
frappe.db.set_single_value("Stock Settings", "allow_negative_stock", old_negative_stock)
|
||||
|
||||
def test_restrict_inter_company_pi_when_sales_invoice_qty_fully_consumed(self):
|
||||
item_code_1 = "_Test IC Item 1"
|
||||
item_code_2 = "_Test IC Item 2"
|
||||
|
||||
create_item(item_code_1, is_stock_item=1)
|
||||
create_item(item_code_2, is_stock_item=1)
|
||||
|
||||
si = create_sales_invoice(
|
||||
company="Wind Power LLC",
|
||||
customer="_Test Internal Customer",
|
||||
item_code=item_code_1,
|
||||
debit_to="Debtors - WP",
|
||||
warehouse="Stores - WP",
|
||||
income_account="Sales - WP",
|
||||
expense_account="Cost of Goods Sold - WP",
|
||||
cost_center="Main - WP",
|
||||
currency="USD",
|
||||
qty=3,
|
||||
do_not_save=1,
|
||||
)
|
||||
si.selling_price_list = "_Test Price List Rest of the World"
|
||||
si.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item_code_2,
|
||||
"item_name": item_code_2,
|
||||
"description": item_code_2,
|
||||
"warehouse": "Stores - WP",
|
||||
"qty": 2,
|
||||
"uom": "Nos",
|
||||
"stock_uom": "Nos",
|
||||
"rate": 100,
|
||||
"price_list_rate": 100,
|
||||
"income_account": "Sales - WP",
|
||||
"expense_account": "Cost of Goods Sold - WP",
|
||||
"cost_center": "Main - WP",
|
||||
"conversion_factor": 1,
|
||||
},
|
||||
)
|
||||
|
||||
si.submit()
|
||||
|
||||
target_doc = make_inter_company_transaction("Sales Invoice", si.name)
|
||||
|
||||
for item in target_doc.items:
|
||||
item.update(
|
||||
{
|
||||
"expense_account": "Cost of Goods Sold - _TC1",
|
||||
"cost_center": "Main - _TC1",
|
||||
}
|
||||
)
|
||||
|
||||
target_doc.submit()
|
||||
self.assertEqual(len(target_doc.items), 2)
|
||||
self.assertEqual([item.qty for item in target_doc.items], [3, 2])
|
||||
with self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"already been fully invoiced",
|
||||
):
|
||||
make_inter_company_transaction("Sales Invoice", si.name)
|
||||
|
||||
def test_sle_for_target_warehouse(self):
|
||||
se = make_stock_entry(
|
||||
item_code="138-CMS Shoe",
|
||||
@@ -3319,6 +3664,52 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
party_link.delete()
|
||||
frappe.db.set_single_value("Accounts Settings", "enable_common_party_accounting", 0)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"enable_common_party_accounting": True})
|
||||
def test_sales_invoice_return_common_party_je_has_no_negative_amounts(self):
|
||||
from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
|
||||
make_customer,
|
||||
)
|
||||
from erpnext.accounts.doctype.party_link.party_link import create_party_link
|
||||
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
customer = make_customer(customer="_Test Common Party Return SI")
|
||||
supplier = create_supplier(supplier_name="_Test Common Party Return SI").name
|
||||
party_link = create_party_link("Supplier", supplier, customer)
|
||||
|
||||
si = create_sales_invoice(customer=customer, parent_cost_center="_Test Cost Center - _TC")
|
||||
|
||||
return_si = make_return_doc(si.doctype, si.name)
|
||||
return_si.submit()
|
||||
|
||||
# JE for the return should credit the supplier (primary/advance) account
|
||||
# and debit the customer (secondary/reconciliation) account — all positive amounts
|
||||
jv_accounts = frappe.get_all(
|
||||
"Journal Entry Account",
|
||||
filters={"reference_type": return_si.doctype, "reference_name": return_si.name, "docstatus": 1},
|
||||
fields=["debit_in_account_currency", "credit_in_account_currency", "account"],
|
||||
)
|
||||
|
||||
self.assertTrue(jv_accounts, "Expected a Journal Entry for the return invoice")
|
||||
for row in jv_accounts:
|
||||
self.assertGreaterEqual(
|
||||
row.debit_in_account_currency,
|
||||
0,
|
||||
f"Negative debit on account {row.account}",
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
row.credit_in_account_currency,
|
||||
0,
|
||||
f"Negative credit on account {row.account}",
|
||||
)
|
||||
|
||||
# Customer (secondary) account must be debited, not credited
|
||||
customer_row = next(r for r in jv_accounts if r.account == return_si.debit_to)
|
||||
self.assertGreater(customer_row.debit_in_account_currency, 0)
|
||||
self.assertEqual(customer_row.credit_in_account_currency, 0)
|
||||
|
||||
party_link.delete()
|
||||
|
||||
def test_payment_statuses(self):
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
"sales_order",
|
||||
"so_detail",
|
||||
"sales_invoice_item",
|
||||
"pick_list_item",
|
||||
"column_break_74",
|
||||
"delivery_note",
|
||||
"dn_detail",
|
||||
@@ -112,6 +113,7 @@
|
||||
"pos_invoice",
|
||||
"pos_invoice_item",
|
||||
"scio_detail",
|
||||
"against_pick_list",
|
||||
"internal_transfer_section",
|
||||
"purchase_order",
|
||||
"column_break_92",
|
||||
@@ -855,8 +857,8 @@
|
||||
"fieldtype": "Currency",
|
||||
"label": "Rate of Stock UOM",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"options": "currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -947,7 +949,8 @@
|
||||
"fieldtype": "Currency",
|
||||
"label": "Distributed Discount Amount",
|
||||
"options": "currency",
|
||||
"print_hide": 1
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "available_quantity_section",
|
||||
@@ -1010,13 +1013,30 @@
|
||||
"label": "Consider for Tax Withholding",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "against_pick_list",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Against Pick List",
|
||||
"no_copy": 1,
|
||||
"options": "Pick List",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "pick_list_item",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Pick List Item",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-24 14:37:16.853941",
|
||||
"modified": "2026-06-03 13:17:36.145788",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Item",
|
||||
|
||||
@@ -22,6 +22,7 @@ class SalesInvoiceItem(Document):
|
||||
|
||||
actual_batch_qty: DF.Float
|
||||
actual_qty: DF.Float
|
||||
against_pick_list: DF.Link | None
|
||||
allow_zero_valuation_rate: DF.Check
|
||||
amount: DF.Currency
|
||||
apply_tds: DF.Check
|
||||
@@ -72,6 +73,7 @@ class SalesInvoiceItem(Document):
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
pick_list_item: DF.Data | None
|
||||
pos_invoice: DF.Link | None
|
||||
pos_invoice_item: DF.Data | None
|
||||
price_list_rate: DF.Currency
|
||||
|
||||
@@ -86,6 +86,39 @@ class Subscription(Document):
|
||||
# update start just before the subscription doc is created
|
||||
self.update_subscription_period(self.start_date)
|
||||
|
||||
def after_insert(self) -> None:
|
||||
if frappe.flags.in_import or frappe.flags.in_migrate:
|
||||
return
|
||||
|
||||
if getdate(self.start_date) > getdate(nowdate()):
|
||||
return
|
||||
|
||||
self.generate_invoices_till_date()
|
||||
|
||||
def generate_invoices_till_date(self) -> None:
|
||||
"""
|
||||
Catch up a freshly created subscription by billing every elapsed period
|
||||
from the start date up to today, then advancing the status (e.g. cancelling
|
||||
if the end date has been crossed). Stops early when no further invoice is due
|
||||
or an outstanding invoice blocks billing (per `generate_new_invoices_past_due_date`).
|
||||
"""
|
||||
while getdate(self._next_invoice_trigger_date()) <= getdate(nowdate()):
|
||||
period_start = self.current_invoice_start
|
||||
self.process(posting_date=self._next_invoice_trigger_date())
|
||||
|
||||
if self.status == "Cancelled" or getdate(self.current_invoice_start) == getdate(period_start):
|
||||
break
|
||||
|
||||
if not self.generate_new_invoices_past_due_date:
|
||||
break
|
||||
|
||||
def _next_invoice_trigger_date(self) -> DateTimeLikeObject:
|
||||
if self.generate_invoice_at == "Beginning of the current subscription period":
|
||||
return self.current_invoice_start
|
||||
if self.generate_invoice_at == "Days before the current subscription period":
|
||||
return add_days(self.current_invoice_start, -self.number_of_days)
|
||||
return self.current_invoice_end
|
||||
|
||||
def update_subscription_period(self, date: DateTimeLikeObject | None = None):
|
||||
"""
|
||||
Subscription period is the period to be billed. This method updates the
|
||||
@@ -269,7 +302,7 @@ class Subscription(Document):
|
||||
Returns `True` if the grace period for the `Subscription` has passed
|
||||
"""
|
||||
if not self.current_invoice_is_past_due():
|
||||
return
|
||||
return False
|
||||
|
||||
grace_period = cint(frappe.get_value("Subscription Settings", None, "grace_period"))
|
||||
return getdate(posting_date) >= getdate(add_days(self.current_invoice.due_date, grace_period))
|
||||
@@ -281,6 +314,9 @@ class Subscription(Document):
|
||||
if not self.current_invoice or self.is_paid(self.current_invoice):
|
||||
return False
|
||||
|
||||
if not self.current_invoice.due_date:
|
||||
return False
|
||||
|
||||
return getdate(posting_date) >= getdate(self.current_invoice.due_date)
|
||||
|
||||
@property
|
||||
@@ -345,7 +381,13 @@ class Subscription(Document):
|
||||
frappe.throw(_("Trial Period Start date cannot be after Subscription Start Date"))
|
||||
|
||||
def validate_end_date(self) -> None:
|
||||
if not self.plans:
|
||||
return
|
||||
|
||||
billing_cycle_info = self.get_billing_cycle_data()
|
||||
if not billing_cycle_info:
|
||||
return
|
||||
|
||||
end_date = add_to_date(self.start_date, **billing_cycle_info)
|
||||
|
||||
if self.end_date and getdate(self.end_date) <= getdate(end_date):
|
||||
@@ -514,7 +556,7 @@ class Subscription(Document):
|
||||
|
||||
item_code = plan_doc.item
|
||||
|
||||
if self.party == "Customer":
|
||||
if self.party_type == "Customer":
|
||||
deferred_field = "enable_deferred_revenue"
|
||||
else:
|
||||
deferred_field = "enable_deferred_expense"
|
||||
@@ -598,19 +640,22 @@ class Subscription(Document):
|
||||
if self.has_outstanding_invoice() and not self.generate_new_invoices_past_due_date:
|
||||
return False
|
||||
|
||||
if self.generate_invoice_at == "Beginning of the current subscription period" and (
|
||||
getdate(posting_date) == getdate(self.current_invoice_start)
|
||||
):
|
||||
return True
|
||||
elif self.generate_invoice_at == "Days before the current subscription period" and (
|
||||
getdate(posting_date) == getdate(add_days(self.current_invoice_start, -1 * self.number_of_days))
|
||||
):
|
||||
return True
|
||||
elif getdate(posting_date) == getdate(self.current_invoice_end):
|
||||
return True
|
||||
else:
|
||||
posting = getdate(posting_date)
|
||||
trigger = getdate(self._next_invoice_trigger_date())
|
||||
|
||||
if posting < trigger:
|
||||
return False
|
||||
|
||||
# Cap the late-fire window at one billing cycle past the period end so a
|
||||
# multi-year gap doesn't retroactively bill cycle after cycle in one call.
|
||||
billing_cycle_info = self.get_billing_cycle_data()
|
||||
if billing_cycle_info:
|
||||
upper = getdate(add_to_date(self.current_invoice_end, **billing_cycle_info))
|
||||
else:
|
||||
upper = getdate(self.current_invoice_end)
|
||||
|
||||
return posting <= upper
|
||||
|
||||
def is_current_invoice_generated(
|
||||
self,
|
||||
_current_start_date: DateTimeLikeObject | None = None,
|
||||
@@ -650,13 +695,6 @@ class Subscription(Document):
|
||||
if invoice:
|
||||
return frappe.get_doc(self.invoice_document_type, invoice[0])
|
||||
|
||||
def cancel_subscription_at_period_end(self) -> None:
|
||||
"""
|
||||
Called when `Subscription.cancel_at_period_end` is truthy
|
||||
"""
|
||||
self.status = "Cancelled"
|
||||
self.cancelation_date = nowdate()
|
||||
|
||||
@property
|
||||
def invoices(self) -> list[dict]:
|
||||
return frappe.get_all(
|
||||
@@ -703,7 +741,7 @@ class Subscription(Document):
|
||||
self.status = "Cancelled"
|
||||
self.cancelation_date = nowdate()
|
||||
|
||||
if to_generate_invoice and self.cancelation_date >= self.current_invoice_start:
|
||||
if to_generate_invoice and getdate(self.cancelation_date) >= getdate(self.current_invoice_start):
|
||||
self.generate_invoice(self.current_invoice_start, self.cancelation_date)
|
||||
|
||||
self.save()
|
||||
@@ -731,7 +769,7 @@ class Subscription(Document):
|
||||
"""
|
||||
|
||||
# Don't process future subscriptions
|
||||
if nowdate() < self.current_invoice_start:
|
||||
if getdate(nowdate()) < getdate(self.current_invoice_start):
|
||||
frappe.msgprint(_("Subscription for Future dates cannot be processed."))
|
||||
return
|
||||
|
||||
@@ -770,10 +808,10 @@ def process_all(subscription: list, posting_date: DateTimeLikeObject | None = No
|
||||
|
||||
for subscription_name in subscription:
|
||||
try:
|
||||
subscription = frappe.get_doc("Subscription", subscription_name)
|
||||
subscription.process(posting_date)
|
||||
sub = frappe.get_doc("Subscription", subscription_name)
|
||||
sub.process(posting_date)
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit()
|
||||
except frappe.ValidationError:
|
||||
frappe.db.rollback()
|
||||
subscription.log_error("Subscription failed")
|
||||
sub.log_error("Subscription failed")
|
||||
|
||||
@@ -17,7 +17,8 @@ from frappe.utils.data import (
|
||||
)
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.subscription.subscription import get_prorata_factor
|
||||
from erpnext.accounts.doctype.subscription.subscription import Subscription, get_prorata_factor, process_all
|
||||
from erpnext.accounts.utils import update_subscription_on_invoice_update
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -61,16 +62,13 @@ class TestSubscription(ERPNextTestSuite):
|
||||
self.assertRaises(frappe.ValidationError, subscription.save)
|
||||
|
||||
def test_invoice_is_generated_at_end_of_billing_period(self):
|
||||
# Back-dated postpaid period has already ended, so catch-up bills it on creation
|
||||
# and advances to the next period.
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, "2018-01-01")
|
||||
self.assertEqual(subscription.current_invoice_end, "2018-01-31")
|
||||
|
||||
subscription.process(posting_date="2018-01-31")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.current_invoice_start, "2018-02-01")
|
||||
self.assertEqual(subscription.current_invoice_end, "2018-02-28")
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
self.assertEqual(getdate(subscription.current_invoice_start), getdate("2018-02-01"))
|
||||
self.assertEqual(getdate(subscription.current_invoice_end), getdate("2018-02-28"))
|
||||
|
||||
def test_status_goes_back_to_active_after_invoice_is_paid(self):
|
||||
subscription = create_subscription(
|
||||
@@ -100,12 +98,10 @@ class TestSubscription(ERPNextTestSuite):
|
||||
settings.cancel_after_grace = 1
|
||||
settings.save()
|
||||
|
||||
# Back-dated unpaid invoice is already past its (zero) grace period, so catch-up
|
||||
# cancels the subscription on creation.
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
subscription.process(posting_date="2018-01-31") # generate first invoice
|
||||
# This should change status to Cancelled since grace period is 0
|
||||
# And is backdated subscription so subscription will be cancelled after processing
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
|
||||
def test_subscription_unpaid_after_grace_period(self):
|
||||
@@ -257,18 +253,12 @@ class TestSubscription(ERPNextTestSuite):
|
||||
settings.cancel_after_grace = 1
|
||||
settings.save()
|
||||
|
||||
# Back-dated unpaid invoice past grace -> cancelled with one invoice on creation.
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
subscription.process() # generate first invoice
|
||||
|
||||
# Generate an invoice for the cancelled period
|
||||
subscription.cancel_subscription()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
# Re-processing a cancelled subscription is a no-op.
|
||||
subscription.process()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
@@ -407,13 +397,21 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
|
||||
subscription.save()
|
||||
|
||||
# even though subscription starts at "2018-01-15" and Billing interval is Month and count 3
|
||||
# First invoice will end at "2018-03-31" instead of "2018-04-14"
|
||||
self.assertEqual(get_date_str(subscription.current_invoice_end), "2018-03-31")
|
||||
# The first (prepaid) period is billed on creation. Even though the subscription
|
||||
# starts at "2018-01-15" with a 3-month interval, follow_calendar_months ends the
|
||||
# first invoice at "2018-03-31" instead of "2018-04-14".
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(
|
||||
getdate(frappe.db.get_value("Purchase Invoice", subscription.invoices[0].name, "to_date")),
|
||||
getdate("2018-03-31"),
|
||||
)
|
||||
|
||||
def test_subscription_generate_invoice_past_due(self):
|
||||
# With `generate_new_invoices_past_due_date` enabled, catch-up bills every elapsed
|
||||
# 3-month period up to the end date on creation, even while previous ones are unpaid.
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
end_date="2018-12-31",
|
||||
party_type="Supplier",
|
||||
party="_Test Supplier",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
@@ -421,18 +419,9 @@ class TestSubscription(ERPNextTestSuite):
|
||||
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
|
||||
)
|
||||
|
||||
# Process subscription and create first invoice
|
||||
# Subscription status will be unpaid since due date has already passed
|
||||
subscription.process(posting_date="2018-01-01")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(len(subscription.invoices), 4)
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
# Now the Subscription is unpaid
|
||||
# Even then new invoice should be created as we have enabled `generate_new_invoices_past_due_date` in
|
||||
# subscription and the interval between the subscriptions is 3 months
|
||||
subscription.process(posting_date="2018-04-01")
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
|
||||
def test_subscription_without_generate_invoice_past_due(self):
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
@@ -493,16 +482,13 @@ class TestSubscription(ERPNextTestSuite):
|
||||
"""Test if Subscription recovers when start/end date run out of sync with created invoices."""
|
||||
subscription = create_subscription(
|
||||
start_date="2021-01-01",
|
||||
end_date="2021-02-28",
|
||||
submit_invoice=0,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
party="_Test Subscription Customer John Doe",
|
||||
)
|
||||
|
||||
# create invoices for the first two moths
|
||||
subscription.process(posting_date="2021-01-31")
|
||||
|
||||
subscription.process(posting_date="2021-02-28")
|
||||
|
||||
# Catch-up bills both elapsed months on creation.
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
self.assertEqual(
|
||||
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")),
|
||||
@@ -513,7 +499,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
getdate("2021-02-01"),
|
||||
)
|
||||
|
||||
# recreate most recent invoice
|
||||
# Re-processing much later must not duplicate the already-billed periods.
|
||||
subscription.process(posting_date="2022-01-31")
|
||||
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
@@ -527,17 +513,16 @@ class TestSubscription(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
def test_subscription_invoice_generation_before_days(self):
|
||||
# "Days before" trigger fires 10 days ahead of each period; catch-up bills both
|
||||
# elapsed periods (within the end date) on creation.
|
||||
subscription = create_subscription(
|
||||
start_date="2023-01-01",
|
||||
end_date="2023-02-28",
|
||||
generate_invoice_at="Days before the current subscription period",
|
||||
number_of_days=10,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
)
|
||||
|
||||
subscription.process(posting_date="2022-12-22")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process(posting_date="2023-01-22")
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
|
||||
def test_future_subscription(self):
|
||||
@@ -596,13 +581,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
plans=[{"plan": "_Test plan name 10", "qty": 1}],
|
||||
)
|
||||
subscription.process(posting_date=add_days(start_date, 2))
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process(posting_date=add_days(start_date, 5))
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
|
||||
subscription.process(posting_date=add_days(start_date, 8))
|
||||
# Catch-up billing on creation generates every elapsed period and cancels at end
|
||||
self.assertEqual(len(subscription.invoices), 3)
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
|
||||
@@ -624,20 +603,71 @@ class TestSubscription(ERPNextTestSuite):
|
||||
plans=[{"plan": "_Test plan name 10", "qty": 1}],
|
||||
)
|
||||
|
||||
subscription.process(posting_date=add_days(start_date, 2))
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process(posting_date=add_days(start_date, 5))
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
|
||||
# partial last cycle invoice
|
||||
subscription.process(posting_date=add_days(start_date, 6))
|
||||
# Catch-up billing on creation incl. the partial last cycle, then cancels at end
|
||||
self.assertEqual(len(subscription.invoices), 3)
|
||||
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
|
||||
self.assertRaises(frappe.ValidationError, subscription.process, posting_date=add_days(start_date, 7))
|
||||
|
||||
def test_invoice_generated_when_scheduler_runs_one_day_late(self):
|
||||
# The trigger date (period end) is long past, yet catch-up still bills the period
|
||||
# on creation (Bug 1: the check is `>= trigger`, not `== trigger`).
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_deferred_revenue_applied_for_customer_subscription(self):
|
||||
item_code = "_Test Non Stock Item"
|
||||
frappe.db.set_value("Item", item_code, "enable_deferred_revenue", 1)
|
||||
try:
|
||||
# Build the period without saving, so on-create billing doesn't try to post an
|
||||
# invoice (the deferred item has no account configured). This only exercises the
|
||||
# item-mapping helper.
|
||||
subscription = create_subscription(start_date="2018-01-01", do_not_save=True)
|
||||
subscription.update_subscription_period("2018-01-01")
|
||||
items = subscription.get_items_from_plans(subscription.plans)
|
||||
self.assertEqual(items[0].get("enable_deferred_revenue"), 1)
|
||||
self.assertEqual(getdate(items[0]["service_start_date"]), getdate("2018-01-01"))
|
||||
self.assertEqual(getdate(items[0]["service_end_date"]), getdate("2018-01-31"))
|
||||
finally:
|
||||
frappe.db.set_value("Item", item_code, "enable_deferred_revenue", 0)
|
||||
|
||||
def test_validate_end_date_with_no_plans_does_not_crash(self):
|
||||
sub = frappe.new_doc("Subscription")
|
||||
sub.party_type = "Customer"
|
||||
sub.party = "_Test Customer"
|
||||
sub.company = "_Test Company"
|
||||
sub.start_date = "2018-01-01"
|
||||
sub.end_date = "2018-03-01"
|
||||
try:
|
||||
sub.validate_end_date()
|
||||
except TypeError as e:
|
||||
self.fail(f"validate_end_date crashed with no plans: {e}")
|
||||
|
||||
def test_process_all_logs_error_when_first_subscription_fails(self):
|
||||
sub1 = create_subscription(start_date="2018-01-01")
|
||||
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:
|
||||
process_all([sub1.name, sub2.name])
|
||||
finally:
|
||||
Subscription.process = original_process
|
||||
frappe.db.rollback = original_rollback
|
||||
|
||||
self.assertEqual(processed, [sub1.name, sub2.name])
|
||||
|
||||
def test_subscription_auto_completion(self):
|
||||
create_plan(
|
||||
plan_name="_Test Plan 3 Day",
|
||||
@@ -674,10 +704,106 @@ class TestSubscription(ERPNextTestSuite):
|
||||
for invoice in invoices:
|
||||
pi = get_payment_entry("Sales Invoice", invoice.name)
|
||||
pi.submit()
|
||||
# Paying the invoices refreshes the subscription via the Payment Entry hook, so
|
||||
# reload before processing the stale in-memory copy.
|
||||
subscription.reload()
|
||||
# After processing through all days, subscription should be completed
|
||||
subscription.process(posting_date=add_days(end_date, 1))
|
||||
self.assertEqual(subscription.status, "Completed")
|
||||
|
||||
def test_status_updates_immediately_when_invoice_paid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
payment = get_payment_entry("Sales Invoice", invoice.name)
|
||||
payment.submit()
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_invoice_update_hook_refreshes_subscription_status(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
invoice.db_set("outstanding_amount", 0)
|
||||
invoice.db_set("status", "Paid")
|
||||
|
||||
update_subscription_on_invoice_update(invoice)
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_payment_entry_triggers_subscription_status_update(self):
|
||||
# Test that payment entry → invoice → subscription status update chain works
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
self.assertIsNotNone(invoice)
|
||||
self.assertGreater(invoice.outstanding_amount, 0)
|
||||
|
||||
# Create and submit payment entry
|
||||
payment_entry = get_payment_entry(invoice.doctype, invoice.name, bank_account="_Test Bank - _TC")
|
||||
payment_entry.reference_no = "12345"
|
||||
payment_entry.reference_date = nowdate()
|
||||
payment_entry.submit()
|
||||
|
||||
# Subscription status should now be Active (via on_update_after_submit hook)
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_first_invoice_generated_on_create_for_prepaid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_first_invoice_not_generated_on_create_during_trial(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
trial_period_start=nowdate(),
|
||||
trial_period_end=add_days(nowdate(), 30),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
self.assertEqual(subscription.status, "Trialing")
|
||||
|
||||
def test_first_invoice_not_generated_during_bulk_import(self):
|
||||
frappe.flags.in_import = True
|
||||
try:
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
finally:
|
||||
frappe.flags.in_import = False
|
||||
|
||||
def test_first_invoice_not_generated_for_future_dated_subscription(self):
|
||||
subscription = create_subscription(
|
||||
start_date=add_days(nowdate(), 10),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
|
||||
def make_plans():
|
||||
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")
|
||||
|
||||
@@ -14,6 +14,7 @@ from frappe.utils import cstr
|
||||
from frappe.utils.nestedset import get_root_of
|
||||
|
||||
from erpnext.setup.doctype.customer_group.customer_group import get_parent_customer_groups
|
||||
from erpnext.setup.doctype.supplier_group.supplier_group import get_parent_supplier_groups
|
||||
|
||||
|
||||
class IncorrectCustomerGroup(frappe.ValidationError):
|
||||
@@ -176,38 +177,44 @@ def get_party_details(party, party_type, args=None):
|
||||
def get_tax_template(posting_date, args):
|
||||
"""Get matching tax rule"""
|
||||
args = frappe._dict(args)
|
||||
conditions = []
|
||||
|
||||
TaxRule = DocType("Tax Rule")
|
||||
query = frappe.qb.from_(TaxRule).select("*")
|
||||
|
||||
if posting_date:
|
||||
conditions.append(
|
||||
f"""(from_date is null or from_date <= '{posting_date}')
|
||||
and (to_date is null or to_date >= '{posting_date}')"""
|
||||
query = query.where(
|
||||
(TaxRule.from_date.isnull() | (TaxRule.from_date <= posting_date))
|
||||
& (TaxRule.to_date.isnull() | (TaxRule.to_date >= posting_date))
|
||||
)
|
||||
else:
|
||||
conditions.append("(from_date is null) and (to_date is null)")
|
||||
query = query.where(TaxRule.from_date.isnull() & TaxRule.to_date.isnull())
|
||||
|
||||
conditions.append(
|
||||
"ifnull(tax_category, '') = {}".format(frappe.db.escape(cstr(args.get("tax_category")), False))
|
||||
)
|
||||
if "tax_category" in args.keys():
|
||||
del args["tax_category"]
|
||||
def get_group_ancestors(doctype, get_parents, value):
|
||||
if not value:
|
||||
value = get_root_of(doctype)
|
||||
return [""] + [d.name for d in get_parents(value)]
|
||||
|
||||
group_fields = {
|
||||
"customer_group": ("Customer Group", get_parent_customer_groups),
|
||||
"supplier_group": ("Supplier Group", get_parent_supplier_groups),
|
||||
}
|
||||
|
||||
args.setdefault("tax_category", "")
|
||||
|
||||
for key, value in args.items():
|
||||
if key == "use_for_shopping_cart":
|
||||
conditions.append(f"use_for_shopping_cart = {1 if value else 0}")
|
||||
elif key == "customer_group":
|
||||
if not value:
|
||||
value = get_root_of("Customer Group")
|
||||
customer_group_condition = get_customer_group_condition(value)
|
||||
conditions.append(f"ifnull({key}, '') in ('', {customer_group_condition})")
|
||||
query = query.where(TaxRule.use_for_shopping_cart == value)
|
||||
elif key == "tax_category":
|
||||
query = query.where(IfNull(TaxRule.tax_category, "") == (value or ""))
|
||||
elif key in group_fields:
|
||||
doctype, get_parents = group_fields[key]
|
||||
query = query.where(
|
||||
IfNull(TaxRule[key], "").isin(get_group_ancestors(doctype, get_parents, value))
|
||||
)
|
||||
else:
|
||||
conditions.append(f"ifnull({key}, '') in ('', {frappe.db.escape(cstr(value))})")
|
||||
query = query.where(IfNull(TaxRule[key], "").isin(["", value or ""]))
|
||||
|
||||
tax_rule = frappe.db.sql(
|
||||
"""select * from `tabTax Rule`
|
||||
where {}""".format(" and ".join(conditions)),
|
||||
as_dict=True,
|
||||
)
|
||||
tax_rule = query.run(as_dict=True)
|
||||
|
||||
if not tax_rule:
|
||||
return None
|
||||
@@ -236,11 +243,3 @@ def get_tax_template(posting_date, args):
|
||||
return None
|
||||
|
||||
return tax_template
|
||||
|
||||
|
||||
def get_customer_group_condition(customer_group):
|
||||
condition = ""
|
||||
customer_groups = ["%s" % (frappe.db.escape(d.name)) for d in get_parent_customer_groups(customer_group)]
|
||||
if customer_groups:
|
||||
condition = ",".join(["%s"] * len(customer_groups)) % (tuple(customer_groups))
|
||||
return condition
|
||||
|
||||
@@ -62,6 +62,117 @@ class TestTaxRule(ERPNextTestSuite):
|
||||
"_Test Sales Taxes and Charges Template - _TC",
|
||||
)
|
||||
|
||||
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",
|
||||
purchase_tax_template=purchase_template,
|
||||
priority=1,
|
||||
use_for_shopping_cart=0,
|
||||
from_date="2015-01-01",
|
||||
save=1,
|
||||
)
|
||||
|
||||
# "_Test Supplier Group" has "All Supplier Groups" as its parent — should match hierarchically
|
||||
self.assertEqual(
|
||||
get_tax_template(
|
||||
"2015-01-01",
|
||||
{
|
||||
"supplier_group": "_Test Supplier Group",
|
||||
"tax_type": "Purchase",
|
||||
"use_for_shopping_cart": 0,
|
||||
},
|
||||
),
|
||||
purchase_template,
|
||||
)
|
||||
|
||||
def test_use_for_shopping_cart_filter(self):
|
||||
city = "Test Cart City"
|
||||
# higher priority ensures this rule wins when use_for_shopping_cart is not filtered
|
||||
make_tax_rule(
|
||||
customer="_Test Customer",
|
||||
billing_city=city,
|
||||
sales_tax_template="_Test Sales Taxes and Charges Template - _TC",
|
||||
use_for_shopping_cart=0,
|
||||
priority=2,
|
||||
save=1,
|
||||
)
|
||||
make_tax_rule(
|
||||
customer="_Test Customer",
|
||||
billing_city=city,
|
||||
sales_tax_template="_Test Sales Taxes and Charges Template 1 - _TC",
|
||||
use_for_shopping_cart=1,
|
||||
priority=1,
|
||||
save=1,
|
||||
)
|
||||
|
||||
# Cart request (use_for_shopping_cart=1) filters to cart rules only
|
||||
self.assertEqual(
|
||||
get_tax_template(
|
||||
"2015-01-01",
|
||||
{"customer": "_Test Customer", "billing_city": city, "use_for_shopping_cart": 1},
|
||||
),
|
||||
"_Test Sales Taxes and Charges Template 1 - _TC",
|
||||
)
|
||||
|
||||
# Non-cart request omits use_for_shopping_cart — no filter is applied, both rules
|
||||
# are candidates; non-cart rule wins by higher priority
|
||||
self.assertEqual(
|
||||
get_tax_template(
|
||||
"2015-01-01",
|
||||
{"customer": "_Test Customer", "billing_city": city},
|
||||
),
|
||||
"_Test Sales Taxes and Charges Template - _TC",
|
||||
)
|
||||
|
||||
def test_use_for_shopping_cart_default(self):
|
||||
city = "Test Default Cart City"
|
||||
# use_for_shopping_cart not set — Check field defaults to 0
|
||||
make_tax_rule(
|
||||
customer="_Test Customer",
|
||||
billing_city=city,
|
||||
sales_tax_template="_Test Sales Taxes and Charges Template - _TC",
|
||||
use_for_shopping_cart=0, # Default is set to 1.
|
||||
save=1,
|
||||
)
|
||||
|
||||
# Non-cart request (no use_for_shopping_cart in args) matches the rule
|
||||
self.assertEqual(
|
||||
get_tax_template(
|
||||
"2015-01-01",
|
||||
{"customer": "_Test Customer", "billing_city": city},
|
||||
),
|
||||
"_Test Sales Taxes and Charges Template - _TC",
|
||||
)
|
||||
|
||||
# Cart request (use_for_shopping_cart=1) does not match — rule has default 0
|
||||
self.assertIsNone(
|
||||
get_tax_template(
|
||||
"2015-01-01",
|
||||
{"customer": "_Test Customer", "billing_city": city, "use_for_shopping_cart": 1},
|
||||
)
|
||||
)
|
||||
|
||||
def test_conflict_with_overlapping_dates(self):
|
||||
tax_rule1 = make_tax_rule(
|
||||
customer="_Test Customer",
|
||||
|
||||
@@ -7,7 +7,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import getdate
|
||||
from frappe.utils import cstr, getdate
|
||||
|
||||
from erpnext import allow_regional
|
||||
from erpnext.controllers.accounts_controller import validate_account_head
|
||||
@@ -48,7 +48,7 @@ class TaxWithholdingCategory(Document):
|
||||
for d in self.get("rates"):
|
||||
if getdate(d.from_date) >= getdate(d.to_date):
|
||||
frappe.throw(_("Row #{0}: From Date cannot be before To Date").format(d.idx))
|
||||
group_rates[d.tax_withholding_group].append(d)
|
||||
group_rates[cstr(d.tax_withholding_group)].append(d)
|
||||
|
||||
# Validate overlapping dates within each group
|
||||
for group, rates in group_rates.items():
|
||||
@@ -92,10 +92,9 @@ class TaxWithholdingCategory(Document):
|
||||
|
||||
def get_applicable_tax_row(self, posting_date, tax_withholding_group):
|
||||
for row in self.rates:
|
||||
if (
|
||||
getdate(row.from_date) <= getdate(posting_date) <= getdate(row.to_date)
|
||||
and row.tax_withholding_group == tax_withholding_group
|
||||
):
|
||||
if getdate(row.from_date) <= getdate(posting_date) <= getdate(row.to_date) and cstr(
|
||||
row.tax_withholding_group
|
||||
) == cstr(tax_withholding_group):
|
||||
return row
|
||||
|
||||
frappe.throw(_("No Tax Withholding data found for the current posting date."))
|
||||
@@ -116,7 +115,7 @@ class TaxWithholdingDetails:
|
||||
def __init__(
|
||||
self,
|
||||
tax_withholding_categories: list[str],
|
||||
tax_withholding_group: str,
|
||||
tax_withholding_group: str | None,
|
||||
posting_date: str,
|
||||
party_type: str,
|
||||
party: str,
|
||||
|
||||
@@ -999,6 +999,47 @@ class TestTaxWithholdingCategory(ERPNextTestSuite):
|
||||
|
||||
self.cleanup_invoices(invoices)
|
||||
|
||||
def test_null_and_empty_tax_withholding_group_are_equivalent(self):
|
||||
"""
|
||||
NULL and empty-string `tax_withholding_group` must be treated as the
|
||||
same value.
|
||||
"""
|
||||
category = frappe.get_doc("Tax Withholding Category", "Cumulative Threshold TDS")
|
||||
original_row = category.rates[0]
|
||||
original_row.tax_withholding_group = None
|
||||
|
||||
# Part 1: validate_dates must detect overlap between NULL-group and
|
||||
# empty-string-group rows covering the same date range.
|
||||
category.append(
|
||||
"rates",
|
||||
{
|
||||
"from_date": original_row.from_date,
|
||||
"to_date": original_row.to_date,
|
||||
"tax_withholding_group": "",
|
||||
"tax_withholding_rate": original_row.tax_withholding_rate,
|
||||
},
|
||||
)
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
category.validate_dates()
|
||||
category.rates.pop()
|
||||
|
||||
# Part 2: get_applicable_tax_row must match NULL <-> "" in either direction.
|
||||
posting_date = original_row.from_date
|
||||
|
||||
row = category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group="")
|
||||
self.assertEqual(row.name, original_row.name)
|
||||
|
||||
row = category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group=None)
|
||||
self.assertEqual(row.name, original_row.name)
|
||||
|
||||
original_row.tax_withholding_group = ""
|
||||
row = category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group=None)
|
||||
self.assertEqual(row.name, original_row.name)
|
||||
|
||||
original_row.tax_withholding_group = None
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group="194R")
|
||||
|
||||
def test_tds_calculation_on_net_total(self):
|
||||
self.setup_party_with_category("Supplier", "Test TDS Supplier4", "Cumulative Threshold TDS")
|
||||
invoices = []
|
||||
|
||||
@@ -431,6 +431,8 @@ def make_entry(args, adv_adj, update_outstanding, from_repost=False):
|
||||
gle.flags.adv_adj = adv_adj
|
||||
gle.flags.update_outstanding = update_outstanding or "Yes"
|
||||
gle.flags.notify_update = False
|
||||
if gle.is_cancelled or is_immutable_ledger_enabled():
|
||||
gle.flags.ignore_links = True
|
||||
gle.submit()
|
||||
|
||||
if (
|
||||
@@ -717,7 +719,12 @@ def make_reverse_gl_entries(
|
||||
check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
|
||||
|
||||
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
|
||||
validate_against_pcv(is_opening, gl_entries[0]["posting_date"], gl_entries[0]["company"])
|
||||
|
||||
# For reverse entries, use the posting_date parameter if provided and valid
|
||||
# Otherwise fall back to original posting_date
|
||||
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
|
||||
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
|
||||
|
||||
if partial_cancel:
|
||||
# Partial cancel is only used by `Advance` in separate account feature.
|
||||
# Only cancel GL entries for unlinked reference using `voucher_detail_no`
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Number Card",
|
||||
"document_type": "Purchase Invoice",
|
||||
"dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\" frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
|
||||
"filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"],[\"Purchase Invoice\",\"posting_date\",\"Timespan\",\"this year\"]]",
|
||||
"dynamic_filters_json": "[[\"Purchase Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Purchase Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
|
||||
"filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"]]",
|
||||
"function": "Sum",
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"label": "Total Incoming Bills",
|
||||
"modified": "2024-12-05 12:00:00.000000",
|
||||
"modified": "2026-06-01 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Total Incoming Bills",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Number Card",
|
||||
"document_type": "Payment Entry",
|
||||
"dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
|
||||
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]",
|
||||
"dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
|
||||
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]",
|
||||
"function": "Sum",
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"label": "Total Incoming Payment",
|
||||
"modified": "2024-12-05 12:00:00.000000",
|
||||
"modified": "2026-06-01 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Total Incoming Payment",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Number Card",
|
||||
"document_type": "Sales Invoice",
|
||||
"dynamic_filters_json": "[[\"Sales Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
|
||||
"filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"],[\"Sales Invoice\",\"posting_date\",\"Timespan\",\"this year\"]]",
|
||||
"dynamic_filters_json": "[[\"Sales Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Sales Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
|
||||
"filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"]]",
|
||||
"function": "Sum",
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"label": "Total Outgoing Bills",
|
||||
"modified": "2024-12-05 12:00:00.000000",
|
||||
"modified": "2026-06-01 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Total Outgoing Bills",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Number Card",
|
||||
"document_type": "Payment Entry",
|
||||
"dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
|
||||
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]",
|
||||
"dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
|
||||
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]",
|
||||
"function": "Sum",
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"label": "Total Outgoing Payment",
|
||||
"modified": "2024-12-05 12:00:00.000000",
|
||||
"modified": "2026-06-01 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Total Outgoing Payment",
|
||||
|
||||
@@ -48,6 +48,25 @@ SALES_TRANSACTION_TYPES = {
|
||||
}
|
||||
TRANSACTION_TYPES = PURCHASE_TRANSACTION_TYPES | SALES_TRANSACTION_TYPES
|
||||
|
||||
# Party-derived fields that must NOT be auto-copied by `get_mapped_doc` when the
|
||||
# source and target documents belong to different parties (e.g. Sales Order →
|
||||
# Purchase Order or inter-company Sales Invoice → Purchase Invoice).
|
||||
CROSS_PARTY_FIELD_NO_MAP = [
|
||||
"tax_category",
|
||||
"tax_id",
|
||||
"tax_withholding_category",
|
||||
"taxes_and_charges",
|
||||
"address_display",
|
||||
"contact_display",
|
||||
"contact_mobile",
|
||||
"contact_email",
|
||||
"contact_person",
|
||||
"shipping_address",
|
||||
"dispatch_address",
|
||||
"payment_terms_template",
|
||||
"language",
|
||||
]
|
||||
|
||||
|
||||
class DuplicatePartyAccountError(frappe.ValidationError):
|
||||
pass
|
||||
@@ -750,7 +769,7 @@ def set_taxes(
|
||||
args.update({"tax_type": "Purchase"})
|
||||
|
||||
if use_for_shopping_cart:
|
||||
args.update({"use_for_shopping_cart": use_for_shopping_cart})
|
||||
args.update({"use_for_shopping_cart": cint(use_for_shopping_cart)})
|
||||
|
||||
return get_tax_template(posting_date, args)
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,32 +1,37 @@
|
||||
{
|
||||
"add_total_row": 1,
|
||||
"apply_user_permissions": 1,
|
||||
"creation": "2013-04-22 16:16:03",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2017-02-24 20:09:46.150861",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Payable",
|
||||
"owner": "Administrator",
|
||||
"ref_doctype": "Purchase Invoice",
|
||||
"report_name": "Accounts Payable",
|
||||
"report_type": "Script Report",
|
||||
"add_total_row": 1,
|
||||
"add_translate_data": 0,
|
||||
"columns": [],
|
||||
"creation": "2013-04-22 16:16:03",
|
||||
"default_print_format": "Accounts Payable Standard",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:35:14.716933",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Payable",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "Purchase Invoice",
|
||||
"report_name": "Accounts Payable",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "Accounts User"
|
||||
},
|
||||
},
|
||||
{
|
||||
"role": "Purchase User"
|
||||
},
|
||||
},
|
||||
{
|
||||
"role": "Accounts Manager"
|
||||
},
|
||||
},
|
||||
{
|
||||
"role": "Auditor"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -1,32 +1,37 @@
|
||||
{
|
||||
"add_total_row": 1,
|
||||
"apply_user_permissions": 1,
|
||||
"creation": "2014-11-04 12:09:59.672379",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2017-02-24 20:11:35.655834",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Payable Summary",
|
||||
"owner": "Administrator",
|
||||
"ref_doctype": "Purchase Invoice",
|
||||
"report_name": "Accounts Payable Summary",
|
||||
"report_type": "Script Report",
|
||||
"add_total_row": 1,
|
||||
"add_translate_data": 0,
|
||||
"columns": [],
|
||||
"creation": "2014-11-04 12:09:59.672379",
|
||||
"default_print_format": "Accounts Payable Summary Standard",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:35:19.179799",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Payable Summary",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "Purchase Invoice",
|
||||
"report_name": "Accounts Payable Summary",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "Accounts User"
|
||||
},
|
||||
},
|
||||
{
|
||||
"role": "Purchase User"
|
||||
},
|
||||
},
|
||||
{
|
||||
"role": "Accounts Manager"
|
||||
},
|
||||
},
|
||||
{
|
||||
"role": "Auditor"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
{
|
||||
"add_total_row": 1,
|
||||
"apply_user_permissions": 1,
|
||||
"creation": "2013-04-16 11:31:13",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2017-03-06 05:52:06.235584",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Receivable",
|
||||
"owner": "Administrator",
|
||||
"ref_doctype": "Sales Invoice",
|
||||
"report_name": "Accounts Receivable",
|
||||
"report_type": "Script Report",
|
||||
"add_total_row": 1,
|
||||
"add_translate_data": 0,
|
||||
"columns": [],
|
||||
"creation": "2013-04-16 11:31:13",
|
||||
"default_print_format": "Accounts Receivable Standard",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 5,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:34:57.666402",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Receivable",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "Sales Invoice",
|
||||
"report_name": "Accounts Receivable",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "Accounts Manager"
|
||||
},
|
||||
},
|
||||
{
|
||||
"role": "Accounts User"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
{
|
||||
"add_total_row": 1,
|
||||
"apply_user_permissions": 1,
|
||||
"creation": "2014-10-17 15:45:00.694265",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2017-03-06 05:52:23.751082",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Receivable Summary",
|
||||
"owner": "Administrator",
|
||||
"ref_doctype": "Sales Invoice",
|
||||
"report_name": "Accounts Receivable Summary",
|
||||
"report_type": "Script Report",
|
||||
"add_total_row": 1,
|
||||
"add_translate_data": 0,
|
||||
"columns": [],
|
||||
"creation": "2014-10-17 15:45:00.694265",
|
||||
"default_print_format": "Accounts Receivable Summary Standard",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:35:10.656797",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Receivable Summary",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "Sales Invoice",
|
||||
"report_name": "Accounts Receivable Summary",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "Accounts Manager"
|
||||
},
|
||||
},
|
||||
{
|
||||
"role": "Accounts User"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
{
|
||||
"add_total_row": 0,
|
||||
"creation": "2014-07-14 05:24:20.385279",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2018-09-07 12:18:21.850851",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Balance Sheet",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "GL Entry",
|
||||
"report_name": "Balance Sheet",
|
||||
"report_type": "Script Report",
|
||||
"add_total_row": 0,
|
||||
"add_translate_data": 0,
|
||||
"columns": [],
|
||||
"creation": "2014-07-14 05:24:20.385279",
|
||||
"default_print_format": "Balance Sheet Standard",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:35:28.187799",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Balance Sheet",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "GL Entry",
|
||||
"report_name": "Balance Sheet",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "Accounts User"
|
||||
},
|
||||
},
|
||||
{
|
||||
"role": "Accounts Manager"
|
||||
},
|
||||
},
|
||||
{
|
||||
"role": "Auditor"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -38,6 +38,14 @@ function get_filters() {
|
||||
let budget_against_options = get_dimensions();
|
||||
|
||||
let filters = [
|
||||
{
|
||||
fieldname: "company",
|
||||
label: __("Company"),
|
||||
fieldtype: "Link",
|
||||
options: "Company",
|
||||
default: frappe.defaults.get_user_default("Company"),
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "from_fiscal_year",
|
||||
label: __("From Fiscal Year"),
|
||||
@@ -67,14 +75,6 @@ function get_filters() {
|
||||
default: "Yearly",
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "company",
|
||||
label: __("Company"),
|
||||
fieldtype: "Link",
|
||||
options: "Company",
|
||||
default: frappe.defaults.get_user_default("Company"),
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "budget_against",
|
||||
label: __("Budget Against"),
|
||||
@@ -96,9 +96,12 @@ function get_filters() {
|
||||
if (!frappe.query_report.filters) return;
|
||||
|
||||
let budget_against = frappe.query_report.get_filter_value("budget_against");
|
||||
let company = frappe.query_report.get_filter_value("company");
|
||||
if (!budget_against) return;
|
||||
|
||||
return frappe.db.get_link_options(budget_against, txt);
|
||||
const filters = budget_against !== "Branch" && company ? { company: company } : {};
|
||||
|
||||
return frappe.db.get_link_options(budget_against, txt, filters);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import CustomFunction
|
||||
from frappe.utils import add_months, flt, formatdate
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
|
||||
@@ -19,6 +20,8 @@ def execute(filters=None):
|
||||
columns = get_columns(filters)
|
||||
if filters.get("budget_against_filter"):
|
||||
dimensions = filters.get("budget_against_filter")
|
||||
if filters.get("budget_against") == "Cost Center":
|
||||
dimensions = get_cost_center_with_children(dimensions)
|
||||
else:
|
||||
dimensions = get_budget_dimensions(filters)
|
||||
if not dimensions:
|
||||
@@ -40,39 +43,29 @@ def validate_filters(filters):
|
||||
|
||||
def get_budget_records(filters, dimensions):
|
||||
budget_against_field = frappe.scrub(filters["budget_against"])
|
||||
budget = frappe.qb.DocType("Budget")
|
||||
|
||||
return frappe.db.sql(
|
||||
f"""
|
||||
SELECT
|
||||
b.name,
|
||||
b.account,
|
||||
b.{budget_against_field} AS dimension,
|
||||
b.budget_amount,
|
||||
b.from_fiscal_year,
|
||||
b.to_fiscal_year,
|
||||
b.budget_start_date,
|
||||
b.budget_end_date
|
||||
FROM
|
||||
`tabBudget` b
|
||||
WHERE
|
||||
b.company = %s
|
||||
AND b.docstatus = 1
|
||||
AND b.budget_against = %s
|
||||
AND b.{budget_against_field} IN ({", ".join(["%s"] * len(dimensions))})
|
||||
AND (
|
||||
b.from_fiscal_year <= %s
|
||||
AND b.to_fiscal_year >= %s
|
||||
)
|
||||
""",
|
||||
(
|
||||
filters.company,
|
||||
filters.budget_against,
|
||||
*dimensions,
|
||||
filters.to_fiscal_year,
|
||||
filters.from_fiscal_year,
|
||||
),
|
||||
as_dict=True,
|
||||
)
|
||||
return (
|
||||
frappe.qb.from_(budget)
|
||||
.select(
|
||||
budget.name,
|
||||
budget.account,
|
||||
budget[budget_against_field].as_("dimension"),
|
||||
budget.budget_amount,
|
||||
budget.from_fiscal_year,
|
||||
budget.to_fiscal_year,
|
||||
budget.budget_start_date,
|
||||
budget.budget_end_date,
|
||||
)
|
||||
.where(
|
||||
(budget.company == filters.company)
|
||||
& (budget.docstatus == 1)
|
||||
& (budget.budget_against == filters.budget_against)
|
||||
& (budget[budget_against_field].isin(dimensions))
|
||||
& (budget.from_fiscal_year <= filters.to_fiscal_year)
|
||||
& (budget.to_fiscal_year >= filters.from_fiscal_year)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
|
||||
def build_budget_map(budget_records, filters):
|
||||
@@ -120,50 +113,41 @@ def build_budget_map(budget_records, filters):
|
||||
|
||||
def get_actual_transactions(dimension_name, filters):
|
||||
budget_against = frappe.scrub(filters.get("budget_against"))
|
||||
cost_center_filter = ""
|
||||
monthname = CustomFunction("MONTHNAME", ["date"])
|
||||
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
budget = frappe.qb.DocType("Budget")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(gle)
|
||||
.from_(budget)
|
||||
.select(
|
||||
gle.account,
|
||||
gle.debit,
|
||||
gle.credit,
|
||||
gle.fiscal_year,
|
||||
monthname(gle.posting_date).as_("month_name"),
|
||||
budget[budget_against].as_("budget_against"),
|
||||
)
|
||||
.where(
|
||||
(budget.docstatus == 1)
|
||||
& (budget.account == gle.account)
|
||||
& (gle.fiscal_year >= filters.from_fiscal_year)
|
||||
& (gle.fiscal_year <= filters.to_fiscal_year)
|
||||
& (gle.is_cancelled == 0)
|
||||
& (budget[budget_against] == dimension_name)
|
||||
)
|
||||
.groupby(gle.name)
|
||||
.orderby(gle.fiscal_year)
|
||||
)
|
||||
|
||||
if filters.get("budget_against") == "Cost Center" and dimension_name:
|
||||
cc_lft, cc_rgt = frappe.db.get_value("Cost Center", dimension_name, ["lft", "rgt"])
|
||||
cost_center_filter = f"""
|
||||
and lft >= "{cc_lft}"
|
||||
and rgt <= "{cc_rgt}"
|
||||
"""
|
||||
cost_centers = get_cost_center_with_children([dimension_name])
|
||||
query = query.where(gle.cost_center.isin(cost_centers))
|
||||
else:
|
||||
query = query.where(budget[budget_against] == gle[budget_against])
|
||||
|
||||
actual_transactions = frappe.db.sql(
|
||||
f"""
|
||||
select
|
||||
gl.account,
|
||||
gl.debit,
|
||||
gl.credit,
|
||||
gl.fiscal_year,
|
||||
MONTHNAME(gl.posting_date) as month_name,
|
||||
b.{budget_against} as budget_against
|
||||
from
|
||||
`tabGL Entry` gl,
|
||||
`tabBudget` b
|
||||
where
|
||||
b.docstatus = 1
|
||||
and b.account=gl.account
|
||||
and b.{budget_against} = gl.{budget_against}
|
||||
and gl.fiscal_year between %s and %s
|
||||
and gl.is_cancelled = 0
|
||||
and b.{budget_against} = %s
|
||||
and exists(
|
||||
select
|
||||
name
|
||||
from
|
||||
`tab{filters.budget_against}`
|
||||
where
|
||||
name = gl.{budget_against}
|
||||
{cost_center_filter}
|
||||
)
|
||||
group by
|
||||
gl.name
|
||||
order by gl.fiscal_year
|
||||
""",
|
||||
(filters.from_fiscal_year, filters.to_fiscal_year, dimension_name),
|
||||
as_dict=1,
|
||||
)
|
||||
actual_transactions = query.run(as_dict=True)
|
||||
|
||||
actual_transactions_map = {}
|
||||
for transaction in actual_transactions:
|
||||
@@ -382,33 +366,37 @@ def get_fiscal_years(filters):
|
||||
return fiscal_year
|
||||
|
||||
|
||||
def get_budget_dimensions(filters):
|
||||
order_by = ""
|
||||
if filters.get("budget_against") == "Cost Center":
|
||||
order_by = "order by lft"
|
||||
|
||||
if filters.get("budget_against") in ["Cost Center", "Project"]:
|
||||
return frappe.db.sql_list(
|
||||
"""
|
||||
select
|
||||
name
|
||||
from
|
||||
`tab{tab}`
|
||||
where
|
||||
company = %s
|
||||
{order_by}
|
||||
""".format(tab=filters.get("budget_against"), order_by=order_by),
|
||||
filters.get("company"),
|
||||
def get_cost_center_with_children(cost_centers):
|
||||
"""Expand each cost center to include itself and all its descendants."""
|
||||
cc = frappe.qb.DocType("Cost Center")
|
||||
all_cost_centers = set()
|
||||
for cost_center in cost_centers:
|
||||
result = frappe.db.get_value("Cost Center", cost_center, ["lft", "rgt"])
|
||||
if not result:
|
||||
continue
|
||||
lft, rgt = result
|
||||
children = (
|
||||
frappe.qb.from_(cc).select(cc.name).where((cc.lft >= lft) & (cc.rgt <= rgt)).run(pluck="name")
|
||||
)
|
||||
all_cost_centers.update(children)
|
||||
return list(all_cost_centers)
|
||||
|
||||
|
||||
def get_budget_dimensions(filters):
|
||||
budget_against = filters.get("budget_against")
|
||||
dimension = frappe.qb.DocType(budget_against)
|
||||
|
||||
if budget_against in ["Cost Center", "Project"]:
|
||||
query = (
|
||||
frappe.qb.from_(dimension)
|
||||
.select(dimension.name)
|
||||
.where(dimension.company == filters.get("company"))
|
||||
)
|
||||
if budget_against == "Cost Center":
|
||||
query = query.orderby(dimension.lft)
|
||||
return query.run(pluck="name")
|
||||
else:
|
||||
return frappe.db.sql_list(
|
||||
"""
|
||||
select
|
||||
name
|
||||
from
|
||||
`tab{tab}`
|
||||
""".format(tab=filters.get("budget_against"))
|
||||
) # nosec
|
||||
return frappe.qb.from_(dimension).select(dimension.name).run(pluck="name")
|
||||
|
||||
|
||||
def validate_budget_dimensions(filters):
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
{
|
||||
"add_total_row": 0,
|
||||
"apply_user_permissions": 1,
|
||||
"creation": "2015-12-12 10:22:45.383203",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2017-02-24 20:09:19.748690",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Cash Flow",
|
||||
"owner": "Administrator",
|
||||
"ref_doctype": "GL Entry",
|
||||
"report_name": "Cash Flow",
|
||||
"report_type": "Script Report",
|
||||
"add_total_row": 0,
|
||||
"add_translate_data": 0,
|
||||
"columns": [],
|
||||
"creation": "2015-12-12 10:22:45.383203",
|
||||
"default_print_format": "Cash Flow Statement Standard",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:35:34.353508",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Cash Flow",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "GL Entry",
|
||||
"report_name": "Cash Flow",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "Accounts User"
|
||||
},
|
||||
},
|
||||
{
|
||||
"role": "Accounts Manager"
|
||||
},
|
||||
},
|
||||
{
|
||||
"role": "Auditor"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -132,7 +132,14 @@ def execute(filters=None):
|
||||
)
|
||||
|
||||
net_change_in_cash = add_total_row_account(
|
||||
data, data, _("Net Change in Cash"), period_list, company_currency, summary_data, filters
|
||||
data,
|
||||
data,
|
||||
_("Net Change in Cash"),
|
||||
period_list,
|
||||
company_currency,
|
||||
summary_data,
|
||||
filters,
|
||||
add_blank_row=False,
|
||||
)
|
||||
|
||||
if filters.show_opening_and_closing_balance:
|
||||
@@ -250,7 +257,17 @@ def get_start_date(period, accumulated_values, company):
|
||||
return start_date
|
||||
|
||||
|
||||
def add_total_row_account(out, data, label, period_list, currency, summary_data, filters, consolidated=False):
|
||||
def add_total_row_account(
|
||||
out,
|
||||
data,
|
||||
label,
|
||||
period_list,
|
||||
currency,
|
||||
summary_data,
|
||||
filters,
|
||||
consolidated=False,
|
||||
add_blank_row=True,
|
||||
):
|
||||
total_row = {
|
||||
"section_name": "'" + _("{0}").format(label) + "'",
|
||||
"section": "'" + _("{0}").format(label) + "'",
|
||||
@@ -275,7 +292,9 @@ def add_total_row_account(out, data, label, period_list, currency, summary_data,
|
||||
total_row["total"] += row["total"]
|
||||
|
||||
out.append(total_row)
|
||||
out.append({})
|
||||
|
||||
if add_blank_row:
|
||||
out.append({})
|
||||
|
||||
return total_row
|
||||
|
||||
|
||||
@@ -737,6 +737,9 @@ def compute_growth_view_data(data, columns):
|
||||
data_copy = copy.deepcopy(data)
|
||||
|
||||
for row_idx in range(len(data_copy)):
|
||||
if not data_copy[row_idx]:
|
||||
continue
|
||||
|
||||
for column_idx in range(1, len(columns)):
|
||||
previous_period_key = columns[column_idx - 1].get("key")
|
||||
current_period_key = columns[column_idx].get("key")
|
||||
@@ -785,13 +788,21 @@ def compute_margin_view_data(data, columns, accumulated_values):
|
||||
|
||||
for column in columns:
|
||||
curr_period = column.get("key")
|
||||
base_value = base_row[curr_period]
|
||||
curr_value = row[curr_period]
|
||||
|
||||
if curr_value is None or base_value <= 0:
|
||||
base_value = base_row.get(curr_period)
|
||||
curr_value = row.get(curr_period)
|
||||
|
||||
if base_value is None or curr_value is None:
|
||||
data[row_idx][curr_period] = None
|
||||
continue
|
||||
|
||||
if base_value == 0:
|
||||
if curr_value == 0:
|
||||
data[row_idx][curr_period] = 0
|
||||
else:
|
||||
data[row_idx][curr_period] = None
|
||||
continue
|
||||
|
||||
margin_percent = round((curr_value / base_value) * 100, 2)
|
||||
|
||||
data[row_idx][curr_period] = margin_percent
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"add_translate_data": 0,
|
||||
"columns": [],
|
||||
"creation": "2013-12-06 13:22:23",
|
||||
"default_print_format": "General Ledger Standard",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 3,
|
||||
"idx": 4,
|
||||
"is_standard": "Yes",
|
||||
"letterhead": null,
|
||||
"modified": "2025-11-05 15:47:59.597853",
|
||||
"modified": "2026-05-22 14:34:35.246000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "General Ledger",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.query_reports["Gross and Net Profit Report"] = $.extend({}, erpnext.financial_statements);
|
||||
const GNP_REPORT = "Gross and Net Profit Report";
|
||||
|
||||
frappe.query_reports["Gross and Net Profit Report"]["filters"].push({
|
||||
frappe.query_reports[GNP_REPORT] = $.extend({}, erpnext.financial_statements);
|
||||
|
||||
erpnext.utils.add_dimensions(GNP_REPORT, 10);
|
||||
|
||||
frappe.query_reports[GNP_REPORT]["filters"].push({
|
||||
fieldname: "accumulated_values",
|
||||
label: __("Accumulated Values"),
|
||||
fieldtype: "Check",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import CustomFunction
|
||||
from frappe.utils import cint
|
||||
|
||||
|
||||
@@ -94,19 +95,35 @@ def get_data(filters):
|
||||
def get_sales_details(filters):
|
||||
item_details_map = {}
|
||||
|
||||
date_field = "s.transaction_date" if filters["based_on"] == "Sales Order" else "s.posting_date"
|
||||
if filters["based_on"] not in ("Sales Order", "Sales Invoice"):
|
||||
frappe.throw(_("Invalid value {0} for 'Based On'").format(filters["based_on"]))
|
||||
|
||||
sales_data = frappe.db.sql(
|
||||
"""
|
||||
select s.territory, s.customer, si.item_group, si.item_code, si.qty, {date_field} as last_order_date,
|
||||
DATEDIFF(CURRENT_DATE, {date_field}) as days_since_last_order
|
||||
from `tab{doctype}` s, `tab{doctype} Item` si
|
||||
where s.name = si.parent and s.docstatus = 1
|
||||
order by days_since_last_order """.format( # nosec
|
||||
date_field=date_field, doctype=filters["based_on"]
|
||||
),
|
||||
as_dict=1,
|
||||
)
|
||||
parent = frappe.qb.DocType(filters["based_on"])
|
||||
child_doctype = "Sales Order Item" if filters["based_on"] == "Sales Order" else "Sales Invoice Item"
|
||||
child = frappe.qb.DocType(child_doctype)
|
||||
|
||||
date_diff = CustomFunction("DATEDIFF", ["d1", "d2"])
|
||||
current_date = CustomFunction("CURRENT_DATE", [])
|
||||
|
||||
date_col = parent.transaction_date if filters["based_on"] == "Sales Order" else parent.posting_date
|
||||
days_since_last_order = date_diff(current_date(), date_col)
|
||||
|
||||
sales_data = (
|
||||
frappe.qb.from_(parent)
|
||||
.inner_join(child)
|
||||
.on(parent.name == child.parent)
|
||||
.select(
|
||||
parent.territory,
|
||||
parent.customer,
|
||||
child.item_group,
|
||||
child.item_code,
|
||||
child.qty,
|
||||
date_col.as_("last_order_date"),
|
||||
days_since_last_order.as_("days_since_last_order"),
|
||||
)
|
||||
.where(parent.docstatus == 1)
|
||||
.orderby(days_since_last_order)
|
||||
).run(as_dict=True)
|
||||
|
||||
for d in sales_data:
|
||||
item_details_map.setdefault((d.territory, d.item_code), d)
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
{
|
||||
"add_total_row": 0,
|
||||
"apply_user_permissions": 1,
|
||||
"creation": "2014-07-18 11:43:33.173207",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2017-02-24 20:12:40.282376",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Profit and Loss Statement",
|
||||
"owner": "Administrator",
|
||||
"ref_doctype": "GL Entry",
|
||||
"report_name": "Profit and Loss Statement",
|
||||
"report_type": "Script Report",
|
||||
"add_total_row": 0,
|
||||
"add_translate_data": 0,
|
||||
"columns": [],
|
||||
"creation": "2014-07-18 11:43:33.173207",
|
||||
"default_print_format": "P&L Statement Standard",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:36:04.544347",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Profit and Loss Statement",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "GL Entry",
|
||||
"report_name": "Profit and Loss Statement",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "Accounts User"
|
||||
},
|
||||
},
|
||||
{
|
||||
"role": "Accounts Manager"
|
||||
},
|
||||
},
|
||||
{
|
||||
"role": "Auditor"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
{
|
||||
"add_total_row": 0,
|
||||
"apply_user_permissions": 1,
|
||||
"creation": "2014-07-22 11:41:23.743564",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2017-02-24 20:12:33.520866",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Trial Balance",
|
||||
"owner": "Administrator",
|
||||
"ref_doctype": "GL Entry",
|
||||
"report_name": "Trial Balance",
|
||||
"add_total_row": 0,
|
||||
"add_translate_data": 0,
|
||||
"columns": [],
|
||||
"creation": "2014-07-22 11:41:23.743564",
|
||||
"default_print_format": "Trial Balance Standard",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-05-22 14:35:44.889062",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Trial Balance",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "GL Entry",
|
||||
"report_name": "Trial Balance",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
@@ -25,5 +29,6 @@
|
||||
{
|
||||
"role": "Auditor"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -40,9 +40,11 @@ import erpnext
|
||||
from erpnext.accounts.doctype.account.account import get_account_currency
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
|
||||
from erpnext.stock import get_warehouse_account_map
|
||||
from erpnext.stock.utils import get_stock_value_on
|
||||
from erpnext.stock.utils import get_combine_datetime, get_stock_value_on
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.model.document import Document
|
||||
|
||||
from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import RepostItemValuation
|
||||
|
||||
|
||||
@@ -443,15 +445,13 @@ def add_ac(args=None):
|
||||
if not args:
|
||||
args = frappe.local.form_dict
|
||||
|
||||
args.pop("ignore_permissions", None)
|
||||
frappe.has_permission("Account", "create", throw=True)
|
||||
|
||||
args.doctype = "Account"
|
||||
args = make_tree_args(**args)
|
||||
|
||||
ac = frappe.new_doc("Account")
|
||||
|
||||
if args.get("ignore_permissions"):
|
||||
ac.flags.ignore_permissions = True
|
||||
args.pop("ignore_permissions")
|
||||
|
||||
ac.update(args)
|
||||
|
||||
if not ac.parent_account:
|
||||
@@ -1540,6 +1540,7 @@ def update_cost_center(docname, cost_center_name, cost_center_number, company, m
|
||||
Renames the document by adding the number as a prefix to the current name and updates
|
||||
all transaction where it was present.
|
||||
"""
|
||||
frappe.has_permission("Cost Center", "write", doc=docname, throw=True)
|
||||
validate_field_number("Cost Center", docname, cost_center_number, company, "cost_center_number")
|
||||
|
||||
if cost_center_number:
|
||||
@@ -1752,31 +1753,31 @@ def sort_stock_vouchers_by_posting_date(
|
||||
|
||||
|
||||
def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, for_items=None, company=None):
|
||||
values = []
|
||||
condition = ""
|
||||
posting_datetime = get_combine_datetime(posting_date, posting_time)
|
||||
|
||||
SLE = DocType("Stock Ledger Entry")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(SLE)
|
||||
.select(SLE.voucher_type, SLE.voucher_no)
|
||||
.distinct()
|
||||
.where(SLE.posting_datetime >= posting_datetime)
|
||||
.where(SLE.is_cancelled == 0)
|
||||
.orderby(SLE.posting_datetime)
|
||||
.orderby(SLE.creation)
|
||||
.for_update()
|
||||
)
|
||||
|
||||
if for_items:
|
||||
condition += " and item_code in ({})".format(", ".join(["%s"] * len(for_items)))
|
||||
values += for_items
|
||||
query = query.where(SLE.item_code.isin(for_items))
|
||||
|
||||
if for_warehouses:
|
||||
condition += " and warehouse in ({})".format(", ".join(["%s"] * len(for_warehouses)))
|
||||
values += for_warehouses
|
||||
query = query.where(SLE.warehouse.isin(for_warehouses))
|
||||
|
||||
if company:
|
||||
condition += " and company = %s"
|
||||
values.append(company)
|
||||
query = query.where(SLE.company == company)
|
||||
|
||||
future_stock_vouchers = frappe.db.sql(
|
||||
f"""select distinct sle.voucher_type, sle.voucher_no
|
||||
from `tabStock Ledger Entry` sle
|
||||
where
|
||||
timestamp(sle.posting_date, sle.posting_time) >= timestamp(%s, %s)
|
||||
and is_cancelled = 0
|
||||
{condition}
|
||||
order by timestamp(sle.posting_date, sle.posting_time) asc, creation asc for update""",
|
||||
tuple([posting_date, posting_time, *values]),
|
||||
as_dict=True,
|
||||
)
|
||||
future_stock_vouchers = query.run(as_dict=True)
|
||||
|
||||
return [(d.voucher_type, d.voucher_no) for d in future_stock_vouchers]
|
||||
|
||||
@@ -2130,8 +2131,9 @@ def create_payment_ledger_entry(
|
||||
ple = frappe.get_doc(entry)
|
||||
|
||||
if cancel:
|
||||
delink_original_entry(ple, partial_cancel=partial_cancel)
|
||||
if is_immutable_ledger_enabled():
|
||||
if not is_immutable_ledger_enabled():
|
||||
delink_original_entry(ple, partial_cancel=partial_cancel)
|
||||
else:
|
||||
ple.delinked = 0
|
||||
ple.posting_date = frappe.form_dict.get("posting_date") or getdate()
|
||||
|
||||
@@ -2220,6 +2222,7 @@ def delink_original_entry(pl_entry, partial_cancel=False):
|
||||
qb.update(ple)
|
||||
.set(ple.modified, now())
|
||||
.set(ple.modified_by, frappe.session.user)
|
||||
.set(ple.delinked, True)
|
||||
.where(
|
||||
(ple.company == pl_entry.company)
|
||||
& (ple.account_type == pl_entry.account_type)
|
||||
@@ -2236,9 +2239,6 @@ def delink_original_entry(pl_entry, partial_cancel=False):
|
||||
if partial_cancel:
|
||||
query = query.where(ple.voucher_detail_no == pl_entry.voucher_detail_no)
|
||||
|
||||
if not is_immutable_ledger_enabled():
|
||||
query = query.set(ple.delinked, True)
|
||||
|
||||
query.run()
|
||||
|
||||
|
||||
@@ -2711,3 +2711,14 @@ def build_qb_match_conditions(doctype, user=None) -> list:
|
||||
|
||||
def is_immutable_ledger_enabled():
|
||||
return frappe.get_single_value("Accounts Settings", "enable_immutable_ledger")
|
||||
|
||||
|
||||
def update_subscription_on_invoice_update(doc: "Document", method: str | None = None) -> None:
|
||||
if doc.get("subscription"):
|
||||
refresh_subscription_status(doc.subscription)
|
||||
|
||||
|
||||
def refresh_subscription_status(name: str) -> None:
|
||||
subscription = frappe.get_doc("Subscription", name)
|
||||
subscription.set_subscription_status()
|
||||
subscription.save(ignore_permissions=True)
|
||||
|
||||
@@ -551,7 +551,9 @@ frappe.ui.form.on("Asset", {
|
||||
asset_type: function (frm) {
|
||||
if (frm.doc.docstatus == 0) {
|
||||
if (frm.doc.asset_type == "Composite Asset") {
|
||||
frm.set_value("net_purchase_amount", 0);
|
||||
if (!frm.doc.net_purchase_amount) {
|
||||
frm.set_value("net_purchase_amount", 0);
|
||||
}
|
||||
} else {
|
||||
frm.set_df_property("net_purchase_amount", "read_only", 0);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ from frappe import _
|
||||
from frappe.query_builder import Order
|
||||
from frappe.query_builder.functions import Max, Min
|
||||
from frappe.utils import (
|
||||
DateTimeLikeObject,
|
||||
add_months,
|
||||
cint,
|
||||
flt,
|
||||
@@ -359,7 +360,8 @@ def get_message_for_depr_entry_posting_error(asset_links, error_log_links):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def scrap_asset(asset_name, scrap_date=None):
|
||||
def scrap_asset(asset_name: str, scrap_date: DateTimeLikeObject | None = None):
|
||||
frappe.has_permission("Asset", "write", asset_name, throw=True)
|
||||
asset = frappe.get_doc("Asset", asset_name)
|
||||
scrap_date = getdate(scrap_date) or getdate(today())
|
||||
asset.db_set("disposal_date", scrap_date)
|
||||
@@ -448,7 +450,8 @@ def create_journal_entry_for_scrap(asset, scrap_date):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def restore_asset(asset_name):
|
||||
def restore_asset(asset_name: str):
|
||||
frappe.has_permission("Asset", "write", asset_name, throw=True)
|
||||
asset = frappe.get_doc("Asset", asset_name)
|
||||
reverse_depreciation_entry_made_on_disposal(asset)
|
||||
reset_depreciation_schedule(asset, get_note_for_restore(asset))
|
||||
|
||||
@@ -31,7 +31,8 @@ class BulkTransactionLog(Document):
|
||||
log_detail = qb.DocType("Bulk Transaction Log Detail")
|
||||
|
||||
has_records = frappe.db.sql(
|
||||
f"select exists (select * from `tabBulk Transaction Log Detail` where date = '{self.name}');"
|
||||
"select exists (select * from `tabBulk Transaction Log Detail` where date = %s);",
|
||||
(self.name,),
|
||||
)[0][0]
|
||||
if not has_records:
|
||||
raise frappe.DoesNotExistError
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"section_break_vwgg",
|
||||
"maintain_same_rate",
|
||||
"column_break_lwxs",
|
||||
"set_landed_cost_based_on_purchase_invoice_rate",
|
||||
"maintain_same_rate_action",
|
||||
"role_to_override_stop_action",
|
||||
"transaction_settings_section",
|
||||
@@ -24,7 +25,8 @@
|
||||
"po_required",
|
||||
"pr_required",
|
||||
"project_update_frequency",
|
||||
"column_break_12",
|
||||
"over_order_allowance",
|
||||
"column_break_kdcm",
|
||||
"allow_multiple_items",
|
||||
"allow_negative_rates_for_items",
|
||||
"set_valuation_rate_for_rejected_materials",
|
||||
@@ -33,7 +35,6 @@
|
||||
"purchase_invoice_settings_section",
|
||||
"bill_for_rejected_quantity_in_purchase_invoice",
|
||||
"use_transaction_date_exchange_rate",
|
||||
"set_landed_cost_based_on_purchase_invoice_rate",
|
||||
"zero_quantity_line_items_section",
|
||||
"allow_zero_qty_in_supplier_quotation",
|
||||
"allow_zero_qty_in_request_for_quotation",
|
||||
@@ -156,10 +157,6 @@
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Transaction Settings"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_12",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Prevents the system from automatically using the rate from the last purchase transaction when creating new purchase orders or transactions.",
|
||||
@@ -335,6 +332,16 @@
|
||||
"hidden": 1,
|
||||
"is_virtual": 1,
|
||||
"label": "Naming Series options"
|
||||
},
|
||||
{
|
||||
"description": "The percentage by which you are allowed to order more on a Purchase Order than the quantity requested on the originating Material Request. For example, if the Material Request has 100 units and the allowance is 10%, you can order up to 110 units",
|
||||
"fieldname": "over_order_allowance",
|
||||
"fieldtype": "Float",
|
||||
"label": "Over Order Allowance (%)"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_kdcm",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -343,7 +350,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-05 16:30:37.184607",
|
||||
"modified": "2026-05-27 23:04:00.842393",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Buying Settings",
|
||||
|
||||
@@ -34,6 +34,7 @@ class BuyingSettings(Document):
|
||||
fixed_email: DF.Link | None
|
||||
maintain_same_rate: DF.Check
|
||||
maintain_same_rate_action: DF.Literal["Stop", "Warn"]
|
||||
over_order_allowance: DF.Float
|
||||
over_transfer_allowance: DF.Float
|
||||
po_required: DF.Literal["No", "Yes"]
|
||||
pr_required: DF.Literal["No", "Yes"]
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
"is_subcontracted",
|
||||
"has_unit_price_items",
|
||||
"supplier_warehouse",
|
||||
"section_break_ahub",
|
||||
"title",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
"dimension_col_break",
|
||||
@@ -56,9 +54,7 @@
|
||||
"net_total",
|
||||
"section_break_48",
|
||||
"pricing_rules",
|
||||
"raw_material_details",
|
||||
"set_reserve_warehouse",
|
||||
"supplied_items",
|
||||
"taxes_section",
|
||||
"tax_category",
|
||||
"taxes_and_charges",
|
||||
@@ -156,6 +152,7 @@
|
||||
"auto_repeat",
|
||||
"update_auto_repeat_reference",
|
||||
"additional_info_section",
|
||||
"title",
|
||||
"party_account_currency",
|
||||
"represents_company",
|
||||
"ref_sq",
|
||||
@@ -1312,10 +1309,6 @@
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Auto Repeat"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ahub",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "title",
|
||||
@@ -1330,7 +1323,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-28 06:11:46.904768",
|
||||
"modified": "2026-05-28 12:34:19.659621",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order",
|
||||
|
||||
@@ -16,7 +16,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
||||
validate_inter_company_party,
|
||||
)
|
||||
from erpnext.accounts.party import get_party_account, get_party_account_currency
|
||||
from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items
|
||||
from erpnext.buying.utils import validate_for_items
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
|
||||
validate_against_blanket_order,
|
||||
@@ -182,6 +182,9 @@ class PurchaseOrder(BuyingController):
|
||||
"target_ref_field": "stock_qty",
|
||||
"source_field": "stock_qty",
|
||||
"percent_join_field": "material_request",
|
||||
"global_allowance_field": "over_order_allowance",
|
||||
"global_allowance_doctype": "Buying Settings",
|
||||
"item_allowance_field": "over_order_allowance",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -203,7 +206,7 @@ class PurchaseOrder(BuyingController):
|
||||
self.validate_supplier()
|
||||
self.validate_schedule_date()
|
||||
validate_for_items(self)
|
||||
self.check_on_hold_or_closed_status()
|
||||
self.check_for_on_hold_or_closed_status("Material Request", "material_request")
|
||||
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.validate_uom_is_integer("stock_uom", "stock_qty")
|
||||
@@ -398,18 +401,6 @@ class PurchaseOrder(BuyingController):
|
||||
d.base_rate
|
||||
) = d.price_list_rate = d.rate = d.last_purchase_rate = item_last_purchase_rate
|
||||
|
||||
# Check for Closed status
|
||||
def check_on_hold_or_closed_status(self):
|
||||
check_list = []
|
||||
for d in self.get("items"):
|
||||
if (
|
||||
d.meta.get_field("material_request")
|
||||
and d.material_request
|
||||
and d.material_request not in check_list
|
||||
):
|
||||
check_list.append(d.material_request)
|
||||
check_on_hold_or_closed_status("Material Request", d.material_request)
|
||||
|
||||
def update_ordered_qty(self, po_item_rows=None):
|
||||
"""update requested qty (before ordered_qty is updated)"""
|
||||
item_wh_list = []
|
||||
@@ -495,7 +486,7 @@ class PurchaseOrder(BuyingController):
|
||||
self.update_receiving_percentage()
|
||||
|
||||
self.update_reserved_qty_for_subcontract()
|
||||
self.check_on_hold_or_closed_status()
|
||||
self.check_for_on_hold_or_closed_status("Material Request", "material_request")
|
||||
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
|
||||
@@ -128,6 +128,44 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
frappe.db.set_value("Item", "_Test Item", "over_billing_allowance", 0)
|
||||
frappe.db.set_single_value("Accounts Settings", "over_billing_allowance", 0)
|
||||
|
||||
def test_over_order_allowance_against_material_request(self) -> None:
|
||||
"""Over Order Allowance in Buying Settings must govern PO qty vs MR qty independently
|
||||
from Over Delivery/Receipt Allowance which governs receipt/delivery against a PO."""
|
||||
mr = make_material_request(qty=100)
|
||||
po = make_purchase_order(mr.name)
|
||||
po.supplier = "_Test Supplier"
|
||||
po.items[0].qty = 110 # 10% over the MR qty
|
||||
|
||||
# Without any allowance, submitting should raise an OverAllowanceError
|
||||
from erpnext.controllers.status_updater import OverAllowanceError
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0)
|
||||
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0)
|
||||
self.assertRaises(OverAllowanceError, po.submit)
|
||||
|
||||
# Granting 10% in Over Order Allowance (Buying Settings) must allow the submit
|
||||
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 10)
|
||||
po.reload()
|
||||
po.items[0].qty = 110
|
||||
po.submit()
|
||||
self.assertEqual(po.docstatus, 1)
|
||||
po.cancel()
|
||||
|
||||
# Over Delivery/Receipt Allowance must remain independent — changing it must not
|
||||
# affect the MR → PO validation when Over Order Allowance is 0.
|
||||
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0)
|
||||
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 50)
|
||||
|
||||
mr2 = make_material_request(qty=100)
|
||||
po2 = make_purchase_order(mr2.name)
|
||||
po2.supplier = "_Test Supplier"
|
||||
po2.items[0].qty = 110
|
||||
self.assertRaises(OverAllowanceError, po2.submit)
|
||||
|
||||
# cleanup
|
||||
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0)
|
||||
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0)
|
||||
|
||||
def test_update_remove_child_linked_to_mr(self):
|
||||
"""Test impact on linked PO and MR on deleting/updating row."""
|
||||
mr = make_material_request(qty=10)
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
"status",
|
||||
"has_unit_price_items",
|
||||
"amended_from",
|
||||
"section_break_mhyw",
|
||||
"title",
|
||||
"suppliers_section",
|
||||
"suppliers",
|
||||
"items_section",
|
||||
@@ -44,6 +42,7 @@
|
||||
"letter_head",
|
||||
"more_info",
|
||||
"opportunity",
|
||||
"title",
|
||||
"address_and_contact_tab",
|
||||
"billing_address",
|
||||
"billing_address_display",
|
||||
@@ -374,10 +373,6 @@
|
||||
"label": "Shipping Address Details",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_mhyw",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "title",
|
||||
@@ -392,7 +387,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-28 06:18:05.661710",
|
||||
"modified": "2026-05-28 12:28:46.606963",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Request for Quotation",
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.contacts.doctype.contact.contact import get_full_name
|
||||
from frappe.core.doctype.communication.email import make
|
||||
from frappe.desk.form.load import get_attachments
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
@@ -275,12 +276,20 @@ class RequestforQuotation(BuyingController):
|
||||
supplier_doc.save()
|
||||
|
||||
def create_user(self, rfq_supplier, link):
|
||||
contact_name = None
|
||||
if rfq_supplier.contact:
|
||||
name_fields = frappe.get_value(
|
||||
"Contact", rfq_supplier.contact, ["first_name", "middle_name", "last_name"]
|
||||
)
|
||||
if name_fields:
|
||||
contact_name = get_full_name(*name_fields)
|
||||
|
||||
user = frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"send_welcome_email": 0,
|
||||
"email": rfq_supplier.email_id,
|
||||
"first_name": rfq_supplier.supplier_name or rfq_supplier.supplier,
|
||||
"first_name": contact_name or rfq_supplier.supplier_name or rfq_supplier.supplier,
|
||||
"user_type": "Website User",
|
||||
"redirect_url": link,
|
||||
}
|
||||
|
||||
@@ -368,19 +368,22 @@
|
||||
"fieldname": "supplier_primary_contact",
|
||||
"fieldtype": "Link",
|
||||
"label": "Supplier Primary Contact",
|
||||
"no_copy": 1,
|
||||
"options": "Contact"
|
||||
},
|
||||
{
|
||||
"fetch_from": "supplier_primary_contact.mobile_no",
|
||||
"fieldname": "mobile_no",
|
||||
"fieldtype": "Read Only",
|
||||
"label": "Mobile No"
|
||||
"label": "Mobile No",
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "supplier_primary_contact.email_id",
|
||||
"fieldname": "email_id",
|
||||
"fieldtype": "Read Only",
|
||||
"label": "Email Id"
|
||||
"label": "Email Id",
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_44",
|
||||
@@ -390,6 +393,7 @@
|
||||
"fieldname": "primary_address",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Primary Address",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -397,6 +401,7 @@
|
||||
"fieldname": "supplier_primary_address",
|
||||
"fieldtype": "Link",
|
||||
"label": "Supplier Primary Address",
|
||||
"no_copy": 1,
|
||||
"options": "Address"
|
||||
},
|
||||
{
|
||||
@@ -517,7 +522,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2026-03-09 17:15:25.465759",
|
||||
"modified": "2026-05-29 16:52:59.441272",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user