mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-19 12:44:03 +00:00
Compare commits
1 Commits
refactor/j
...
auth_did
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
265bc4eb6f |
51
.github/helper/install.sh
vendored
51
.github/helper/install.sh
vendored
@@ -4,46 +4,24 @@ set -e
|
||||
|
||||
cd ~ || exit
|
||||
|
||||
sudo apt update
|
||||
sudo apt remove mysql-server mysql-client
|
||||
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev
|
||||
|
||||
pip install frappe-bench
|
||||
|
||||
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
|
||||
frappeuser=${FRAPPE_USER:-"frappe"}
|
||||
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — parallelise the three slow, independent setup steps:
|
||||
# a) system packages b) frappe-bench pip install c) frappe git fetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
sudo apt update
|
||||
|
||||
# apt remove/install must run sequentially but can overlap with pip and git.
|
||||
sudo apt remove mysql-server mysql-client
|
||||
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev &
|
||||
apt_pid=$!
|
||||
|
||||
pip install frappe-bench &
|
||||
pip_pid=$!
|
||||
|
||||
mkdir frappe
|
||||
(
|
||||
cd frappe
|
||||
git init
|
||||
git remote add origin "https://github.com/${frappeuser}/frappe"
|
||||
git fetch origin "${frappecommitish}" --depth 1
|
||||
) &
|
||||
clone_pid=$!
|
||||
|
||||
wait $apt_pid
|
||||
wait $pip_pid
|
||||
wait $clone_pid
|
||||
|
||||
pushd frappe
|
||||
git init
|
||||
git remote add origin "https://github.com/${frappeuser}/frappe"
|
||||
git fetch origin "${frappecommitish}" --depth 1
|
||||
git checkout FETCH_HEAD
|
||||
popd
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — bench init and site setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
|
||||
|
||||
mkdir ~/frappe-bench/sites/test_site
|
||||
@@ -59,11 +37,6 @@ if [ "$DB" == "mariadb" ];then
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
|
||||
# Belt-and-suspenders: also set performance variables at runtime in case
|
||||
# MARIADB_EXTRA_FLAGS was not honoured by the container image.
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot \
|
||||
-e "SET GLOBAL innodb_flush_log_at_trx_commit=0; SET GLOBAL sync_binlog=0;"
|
||||
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
|
||||
@@ -78,11 +51,9 @@ fi
|
||||
|
||||
|
||||
install_whktml() {
|
||||
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
|
||||
if [ ! -f /tmp/wkhtmltox.deb ]; then
|
||||
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
fi
|
||||
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
sudo apt install /tmp/wkhtmltox.deb
|
||||
|
||||
}
|
||||
install_whktml &
|
||||
wkpid=$!
|
||||
|
||||
21
.github/workflows/server-tests-mariadb.yml
vendored
21
.github/workflows/server-tests-mariadb.yml
vendored
@@ -59,10 +59,6 @@ jobs:
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
MARIADB_ROOT_PASSWORD: 'root'
|
||||
# Disable durability guarantees that are unnecessary in a throwaway CI container.
|
||||
# innodb_flush_log_at_trx_commit=0 avoids an fsync on every commit (biggest win).
|
||||
# sync_binlog=0 skips binary-log syncs; innodb_doublewrite=0 skips the doublewrite buffer.
|
||||
MARIADB_EXTRA_FLAGS: --innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --innodb-doublewrite=0
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
@@ -126,12 +122,6 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Cache wkhtmltopdf
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/wkhtmltox.deb
|
||||
key: wkhtmltox-0.12.6.1-2-jammy-amd64
|
||||
|
||||
- name: Install
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
||||
env:
|
||||
@@ -141,14 +131,7 @@ jobs:
|
||||
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
cd ~/frappe-bench/
|
||||
coverage_flag=""
|
||||
if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
|
||||
bench --site test_site run-parallel-tests --lightmode --app erpnext \
|
||||
--total-builds ${{ strategy.job-total }} \
|
||||
--build-number ${{ matrix.container }} \
|
||||
$coverage_flag
|
||||
run: 'cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --lightmode --app erpnext --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }} --with-coverage'
|
||||
env:
|
||||
TYPE: server
|
||||
|
||||
@@ -158,7 +141,6 @@ jobs:
|
||||
run: cat ~/frappe-bench/bench_start.log || true
|
||||
|
||||
- name: Upload coverage data
|
||||
if: ${{ env.WITH_COVERAGE == 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-${{ matrix.container }}
|
||||
@@ -167,7 +149,6 @@ jobs:
|
||||
coverage:
|
||||
name: Coverage Wrap Up
|
||||
needs: test
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"disabledLabels": [
|
||||
"conflicts"
|
||||
],
|
||||
"context": {
|
||||
"repos": [
|
||||
"frappe/frappe"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
@@ -250,7 +250,7 @@ const ClosingBalanceForm = ({ defaultBalance, date, bankAccount, onClose }: { de
|
||||
{_("Enter the closing balance you see in your bank statement for {0} as of the {1}", [bankAccount?.account_name ?? bankAccount?.name ?? '', formatDate(date, 'Do MMM YYYY')])}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && <div className="py-2"><ErrorBanner error={error} /></div>}
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div className="py-4">
|
||||
<CurrencyFormField
|
||||
name="balance"
|
||||
|
||||
@@ -33,16 +33,6 @@ export const getErrorMessages = (error?: FrappeError | null): ParsedErrorMessage
|
||||
}
|
||||
})
|
||||
|
||||
// @ts-expect-error - some errors have _error_message
|
||||
if (error?._error_message) {
|
||||
eMessages.push({
|
||||
// @ts-expect-error - some errors have _error_message
|
||||
message: error?._error_message,
|
||||
title: "Error",
|
||||
indicator: "red"
|
||||
})
|
||||
}
|
||||
|
||||
if (eMessages.length === 0) {
|
||||
// Get the message from the exception by removing the exc_type
|
||||
const indexOfFirstColon = error?.exception?.indexOf(':')
|
||||
|
||||
@@ -358,10 +358,10 @@
|
||||
dependencies:
|
||||
"@tybys/wasm-util" "^0.10.1"
|
||||
|
||||
"@oxc-project/types@=0.133.0":
|
||||
version "0.133.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.133.0.tgz#2e282ef9e1d26e06b68ccd14b73f310a3b2cf7f8"
|
||||
integrity sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==
|
||||
"@oxc-project/types@=0.128.0":
|
||||
version "0.128.0"
|
||||
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.128.0.tgz#efc7524f948ff9e8ab1404ecad1823849c6fe149"
|
||||
integrity sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==
|
||||
|
||||
"@radix-ui/number@1.1.1":
|
||||
version "1.1.1"
|
||||
@@ -1042,95 +1042,95 @@
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb"
|
||||
integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==
|
||||
|
||||
"@rolldown/binding-android-arm64@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz#54ce8f8382213f4a314a0c2f7ba83f81ffeae592"
|
||||
integrity sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==
|
||||
"@rolldown/binding-android-arm64@1.0.0-rc.18":
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz#3af8b2242086125934a85c1915b76e0a6a2054c1"
|
||||
integrity sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==
|
||||
|
||||
"@rolldown/binding-darwin-arm64@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz#388fca1566c14c00c4b446fc3928630e7f0d95fc"
|
||||
integrity sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==
|
||||
"@rolldown/binding-darwin-arm64@1.0.0-rc.18":
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz#ae0b4467d24ecd6c6589f03d4d4699616ee9649c"
|
||||
integrity sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==
|
||||
|
||||
"@rolldown/binding-darwin-x64@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz#53f57de1f599ecf1db13823cfc88c18fb80954ad"
|
||||
integrity sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==
|
||||
"@rolldown/binding-darwin-x64@1.0.0-rc.18":
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz#23cf24b0a7b96c8990bbdd8a91e7fd3ba82b00e7"
|
||||
integrity sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==
|
||||
|
||||
"@rolldown/binding-freebsd-x64@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz#6f3fdda1b7aeaac9d268a526804b4fb96e4e35f1"
|
||||
integrity sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==
|
||||
"@rolldown/binding-freebsd-x64@1.0.0-rc.18":
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz#a047a770f94dc451c062b729e5d1cf82e5c6f9c4"
|
||||
integrity sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz#d87a454bf585cc9676849377e91d6e375297326f"
|
||||
integrity sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==
|
||||
"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18":
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz#c0b7f346cbf50301cea669a4632bc63aabe6a72c"
|
||||
integrity sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz#419fd6bf612cf348f10528cbcd94ebab9607d8d1"
|
||||
integrity sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==
|
||||
"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18":
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz#af56373c7996ebe6379207cd699c9f7f705e235d"
|
||||
integrity sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz#fcc6918696bb76844877e1e4930a18fd0d374069"
|
||||
integrity sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==
|
||||
"@rolldown/binding-linux-arm64-musl@1.0.0-rc.18":
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz#a8f5acd21fcffc8991aa84710e3ae603c4240ea4"
|
||||
integrity sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz#32aecb7c8dae5d4f2a8cde57a058ec86991542f8"
|
||||
integrity sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==
|
||||
"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18":
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz#1d4a89e040ff82141fc46e717cfab80b05f7c13f"
|
||||
integrity sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz#bed9346ea81e6bb8b93cf11f5d88b77db890b763"
|
||||
integrity sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==
|
||||
"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18":
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz#97c21feeb2ed87d07820f0b2dcc5dd663e7a7f3b"
|
||||
integrity sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz#64c2d26f75dffd9b5a1f97557a00ae77250c8cb7"
|
||||
integrity sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==
|
||||
"@rolldown/binding-linux-x64-gnu@1.0.0-rc.18":
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz#06310d40fe139ccc3c433b361120d337c66ebec2"
|
||||
integrity sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==
|
||||
|
||||
"@rolldown/binding-linux-x64-musl@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz#5a45132e8a47659eeaaf3b540c2954a97c860ff3"
|
||||
integrity sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==
|
||||
"@rolldown/binding-linux-x64-musl@1.0.0-rc.18":
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz#6a711258841f42609b238050cfcd5db13ac136d0"
|
||||
integrity sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==
|
||||
|
||||
"@rolldown/binding-openharmony-arm64@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz#290513068c55e849dc8457a32afee1d7b0acb309"
|
||||
integrity sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==
|
||||
"@rolldown/binding-openharmony-arm64@1.0.0-rc.18":
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz#15cb644beeafdbec930d79ed45c2a7c2573eac70"
|
||||
integrity sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==
|
||||
|
||||
"@rolldown/binding-wasm32-wasi@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz#3d9972dbf1a953d3c7afaa4a0f20ef2b2e39f31b"
|
||||
integrity sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==
|
||||
"@rolldown/binding-wasm32-wasi@1.0.0-rc.18":
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz#ca3a56d11dfd533d743711141b3bb4c1ec10110e"
|
||||
integrity sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==
|
||||
dependencies:
|
||||
"@emnapi/core" "1.10.0"
|
||||
"@emnapi/runtime" "1.10.0"
|
||||
"@napi-rs/wasm-runtime" "^1.1.4"
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz#a004ab607a16d6f03bcb555728ff888af75773ad"
|
||||
integrity sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==
|
||||
"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18":
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz#8c2117d68331d7de59d24631146d538fc203d27c"
|
||||
integrity sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz#e2a25b34691a1cc8a1209d7de709063026dd0cdb"
|
||||
integrity sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==
|
||||
"@rolldown/binding-win32-x64-msvc@1.0.0-rc.18":
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz#bb5c28df3095046778cc1b020ef52fc5ee7b7e70"
|
||||
integrity sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==
|
||||
|
||||
"@rolldown/pluginutils@1.0.0-rc.18":
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz#51cf2589596a179ebe8cbf313f1358c7b51a2fdc"
|
||||
integrity sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==
|
||||
|
||||
"@rolldown/pluginutils@1.0.0-rc.7":
|
||||
version "1.0.0-rc.7"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz#0414869467f0e471a6515d4f506c85fde867e022"
|
||||
integrity sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==
|
||||
|
||||
"@rolldown/pluginutils@^1.0.0":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz#e3fcee093fbb5ce765e1ad088ff4de2889f6f9be"
|
||||
integrity sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==
|
||||
|
||||
"@socket.io/component-emitter@~3.1.0":
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
|
||||
@@ -3031,10 +3031,10 @@ ms@^2.1.3:
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
||||
|
||||
nanoid@^3.3.12:
|
||||
version "3.3.12"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.12.tgz#ab3d912e217a6d0a514f00a72a16543a28982c05"
|
||||
integrity sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==
|
||||
nanoid@^3.3.11:
|
||||
version "3.3.11"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
|
||||
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
|
||||
|
||||
natural-compare@^1.4.0:
|
||||
version "1.4.0"
|
||||
@@ -3119,17 +3119,22 @@ picocolors@^1.1.1:
|
||||
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
|
||||
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
|
||||
|
||||
picomatch@^4.0.3:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
|
||||
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
|
||||
|
||||
picomatch@^4.0.4:
|
||||
version "4.0.4"
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
|
||||
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
|
||||
|
||||
postcss@^8.5.15:
|
||||
version "8.5.15"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.15.tgz#d1eaf677a324e9ec02196da2d3fecf4a0b9a735c"
|
||||
integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==
|
||||
postcss@^8.5.14:
|
||||
version "8.5.14"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c"
|
||||
integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==
|
||||
dependencies:
|
||||
nanoid "^3.3.12"
|
||||
nanoid "^3.3.11"
|
||||
picocolors "^1.1.1"
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
@@ -3389,29 +3394,29 @@ resolve-from@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
|
||||
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
|
||||
|
||||
rolldown@1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.3.tgz#db88a3008fb0e28230a00423727ce75ba32121ac"
|
||||
integrity sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==
|
||||
rolldown@1.0.0-rc.18:
|
||||
version "1.0.0-rc.18"
|
||||
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.18.tgz#c597f89a4ce12e6fc918fa91e4f892b340aa92f0"
|
||||
integrity sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==
|
||||
dependencies:
|
||||
"@oxc-project/types" "=0.133.0"
|
||||
"@rolldown/pluginutils" "^1.0.0"
|
||||
"@oxc-project/types" "=0.128.0"
|
||||
"@rolldown/pluginutils" "1.0.0-rc.18"
|
||||
optionalDependencies:
|
||||
"@rolldown/binding-android-arm64" "1.0.3"
|
||||
"@rolldown/binding-darwin-arm64" "1.0.3"
|
||||
"@rolldown/binding-darwin-x64" "1.0.3"
|
||||
"@rolldown/binding-freebsd-x64" "1.0.3"
|
||||
"@rolldown/binding-linux-arm-gnueabihf" "1.0.3"
|
||||
"@rolldown/binding-linux-arm64-gnu" "1.0.3"
|
||||
"@rolldown/binding-linux-arm64-musl" "1.0.3"
|
||||
"@rolldown/binding-linux-ppc64-gnu" "1.0.3"
|
||||
"@rolldown/binding-linux-s390x-gnu" "1.0.3"
|
||||
"@rolldown/binding-linux-x64-gnu" "1.0.3"
|
||||
"@rolldown/binding-linux-x64-musl" "1.0.3"
|
||||
"@rolldown/binding-openharmony-arm64" "1.0.3"
|
||||
"@rolldown/binding-wasm32-wasi" "1.0.3"
|
||||
"@rolldown/binding-win32-arm64-msvc" "1.0.3"
|
||||
"@rolldown/binding-win32-x64-msvc" "1.0.3"
|
||||
"@rolldown/binding-android-arm64" "1.0.0-rc.18"
|
||||
"@rolldown/binding-darwin-arm64" "1.0.0-rc.18"
|
||||
"@rolldown/binding-darwin-x64" "1.0.0-rc.18"
|
||||
"@rolldown/binding-freebsd-x64" "1.0.0-rc.18"
|
||||
"@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.18"
|
||||
"@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.18"
|
||||
"@rolldown/binding-linux-arm64-musl" "1.0.0-rc.18"
|
||||
"@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.18"
|
||||
"@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.18"
|
||||
"@rolldown/binding-linux-x64-gnu" "1.0.0-rc.18"
|
||||
"@rolldown/binding-linux-x64-musl" "1.0.0-rc.18"
|
||||
"@rolldown/binding-openharmony-arm64" "1.0.0-rc.18"
|
||||
"@rolldown/binding-wasm32-wasi" "1.0.0-rc.18"
|
||||
"@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.18"
|
||||
"@rolldown/binding-win32-x64-msvc" "1.0.0-rc.18"
|
||||
|
||||
scheduler@^0.27.0:
|
||||
version "0.27.0"
|
||||
@@ -3535,10 +3540,18 @@ tapable@^2.3.3:
|
||||
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160"
|
||||
integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
|
||||
|
||||
tinyglobby@^0.2.15, tinyglobby@^0.2.17:
|
||||
version "0.2.17"
|
||||
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631"
|
||||
integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==
|
||||
tinyglobby@^0.2.15:
|
||||
version "0.2.15"
|
||||
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2"
|
||||
integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
|
||||
dependencies:
|
||||
fdir "^6.5.0"
|
||||
picomatch "^4.0.3"
|
||||
|
||||
tinyglobby@^0.2.16:
|
||||
version "0.2.16"
|
||||
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6"
|
||||
integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==
|
||||
dependencies:
|
||||
fdir "^6.5.0"
|
||||
picomatch "^4.0.4"
|
||||
@@ -3712,16 +3725,16 @@ vfile@^6.0.0:
|
||||
"@types/unist" "^3.0.0"
|
||||
vfile-message "^4.0.0"
|
||||
|
||||
vite@^8.0.16:
|
||||
version "8.0.16"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.16.tgz#ae073866c06563d6634a90169a496e11bd84f1a6"
|
||||
integrity sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==
|
||||
vite@^8.0.11:
|
||||
version "8.0.11"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.11.tgz#d128fe82a0dd24da5127d20560735f1cd7ade0a6"
|
||||
integrity sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==
|
||||
dependencies:
|
||||
lightningcss "^1.32.0"
|
||||
picomatch "^4.0.4"
|
||||
postcss "^8.5.15"
|
||||
rolldown "1.0.3"
|
||||
tinyglobby "^0.2.17"
|
||||
postcss "^8.5.14"
|
||||
rolldown "1.0.0-rc.18"
|
||||
tinyglobby "^0.2.16"
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.3"
|
||||
|
||||
|
||||
@@ -22,13 +22,11 @@ class TestAdvancePaymentLedgerEntry(ERPNextTestSuite, AccountsTestMixin):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.supplier = "_Test Supplier"
|
||||
self.item = "_Test Item"
|
||||
self.cash = "Cash - _TC"
|
||||
self.debtors_usd = "_Test Receivable USD - _TC"
|
||||
self.creditors_usd = "_Test Payable USD - _TC"
|
||||
self.create_company()
|
||||
self.create_usd_receivable_account()
|
||||
self.create_usd_payable_account()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
|
||||
def create_sales_order(self, qty=1, rate=100, currency="INR", do_not_submit=False):
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_rename": 1,
|
||||
"creation": "2026-04-11 19:48:13.622253",
|
||||
"doctype": "DocType",
|
||||
@@ -8,8 +7,7 @@
|
||||
"field_order": [
|
||||
"bank_account",
|
||||
"date",
|
||||
"balance",
|
||||
"company"
|
||||
"balance"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -33,20 +31,12 @@
|
||||
"in_list_view": 1,
|
||||
"label": "Balance",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "bank_account.company",
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"label": "Company",
|
||||
"options": "Company",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-16 22:17:48.007982",
|
||||
"modified": "2026-04-11 19:49:45.374695",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Account Balance",
|
||||
|
||||
@@ -16,7 +16,6 @@ class BankAccountBalance(Document):
|
||||
|
||||
balance: DF.Currency
|
||||
bank_account: DF.Link
|
||||
company: DF.Link | None
|
||||
date: DF.Date
|
||||
# end: auto-generated types
|
||||
|
||||
|
||||
@@ -94,7 +94,6 @@ class BankClearance(Document):
|
||||
invalid_document = []
|
||||
invalid_cheque_date = []
|
||||
entries_to_update = []
|
||||
self.check_permission("write")
|
||||
|
||||
def validate_entry(d):
|
||||
is_valid = True
|
||||
|
||||
@@ -8,7 +8,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cint, create_batch, flt
|
||||
|
||||
from erpnext import get_default_cost_center
|
||||
@@ -518,7 +518,6 @@ def create_internal_transfer(
|
||||
"""
|
||||
|
||||
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
|
||||
bank_transaction.check_permission("write")
|
||||
|
||||
bank_account = frappe.get_cached_value("Bank Account", bank_transaction.bank_account, "account")
|
||||
company = frappe.get_cached_value("Account", bank_account, "company")
|
||||
@@ -779,6 +778,7 @@ def create_bulk_payment_entry_and_reconcile(
|
||||
"""
|
||||
Create a payment entry and reconcile it with the bank transaction
|
||||
"""
|
||||
|
||||
output = []
|
||||
|
||||
for bank_transaction_name in bank_transaction_names:
|
||||
@@ -1410,14 +1410,12 @@ def get_je_matching_query(
|
||||
Sum(getattr(jea, amount_field)).as_("paid_amount"),
|
||||
ConstantColumn("Journal Entry").as_("doctype"),
|
||||
je.name,
|
||||
# non-grouped columns are constant per grouped JE name (party_type/currency come from the
|
||||
# single bank-account line) -> Max() keeps the GROUP BY valid on postgres with the same value
|
||||
Max(je.cheque_no).as_("reference_no"),
|
||||
Max(je.cheque_date).as_("reference_date"),
|
||||
Max(je.pay_to_recd_from).as_("party"),
|
||||
Max(jea.party_type).as_("party_type"),
|
||||
Max(je.posting_date).as_("posting_date"),
|
||||
Max(jea.account_currency).as_("currency"),
|
||||
je.cheque_no.as_("reference_no"),
|
||||
je.cheque_date.as_("reference_date"),
|
||||
je.pay_to_recd_from.as_("party"),
|
||||
jea.party_type,
|
||||
je.posting_date,
|
||||
jea.account_currency.as_("currency"),
|
||||
)
|
||||
.where(je.docstatus == 1)
|
||||
.where(je.voucher_type != "Opening Entry")
|
||||
@@ -1425,7 +1423,7 @@ def get_je_matching_query(
|
||||
.where(jea.account == common_filters.bank_account)
|
||||
.where(filter_by_date)
|
||||
.groupby(je.name)
|
||||
.orderby(Max(je.cheque_date) if cint(filter_by_reference_date) else Max(je.posting_date))
|
||||
.orderby(je.cheque_date if cint(filter_by_reference_date) else je.posting_date)
|
||||
)
|
||||
|
||||
if frappe.flags.auto_reconcile_vouchers is True:
|
||||
|
||||
@@ -17,10 +17,9 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.bank = "HDFC - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
bank_dt = qb.DocType("Bank")
|
||||
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
||||
self.create_bank_account()
|
||||
|
||||
@@ -26,9 +26,9 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.bank = "HDFC - _TC"
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
bank_dt = qb.DocType("Bank")
|
||||
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
||||
self.create_bank_account()
|
||||
|
||||
@@ -5,8 +5,6 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.model.docstatus import DocStatus
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import Tuple
|
||||
from frappe.query_builder.functions import Abs, Max, Sum
|
||||
from frappe.utils import flt, getdate
|
||||
|
||||
|
||||
@@ -376,7 +374,6 @@ def unreconcile_transaction(transaction_name: str | int):
|
||||
Else, cancel the individual entries
|
||||
"""
|
||||
transaction = frappe.get_doc("Bank Transaction", transaction_name)
|
||||
transaction.check_permission("write")
|
||||
|
||||
vouchers_to_cancel = []
|
||||
|
||||
@@ -404,7 +401,6 @@ def unreconcile_transaction_entry(bank_transaction_id: str | int, voucher_type:
|
||||
"""
|
||||
|
||||
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_id)
|
||||
bank_transaction.check_permission("write")
|
||||
|
||||
# Find the voucher in the bank transaction and depending on the action, either remove it or cancel the voucher
|
||||
for entry in bank_transaction.payment_entries:
|
||||
@@ -480,28 +476,30 @@ def get_clearance_details(transaction, payment_entry, bt_allocations, gl_entries
|
||||
|
||||
|
||||
def get_related_bank_gl_entries(docs):
|
||||
# nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
|
||||
if not docs:
|
||||
return {}
|
||||
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
ac = frappe.qb.DocType("Account")
|
||||
result = (
|
||||
frappe.qb.from_(gle)
|
||||
.left_join(ac)
|
||||
.on(ac.name == gle.account)
|
||||
.select(
|
||||
gle.voucher_type.as_("doctype"),
|
||||
gle.voucher_no.as_("docname"),
|
||||
gle.account.as_("gl_account"),
|
||||
Sum(Abs(gle.credit_in_account_currency - gle.debit_in_account_currency)).as_("amount"),
|
||||
)
|
||||
.where(
|
||||
(ac.account_type == "Bank")
|
||||
& Tuple(gle.voucher_type, gle.voucher_no).isin([Tuple(vt, vn) for vt, vn in docs])
|
||||
& (gle.is_cancelled == 0)
|
||||
)
|
||||
.groupby(gle.voucher_type, gle.voucher_no, gle.account)
|
||||
.run(as_dict=True)
|
||||
result = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
gle.voucher_type AS doctype,
|
||||
gle.voucher_no AS docname,
|
||||
gle.account AS gl_account,
|
||||
SUM(ABS(gle.credit_in_account_currency - gle.debit_in_account_currency)) AS amount
|
||||
FROM
|
||||
`tabGL Entry` gle
|
||||
LEFT JOIN
|
||||
`tabAccount` ac ON ac.name = gle.account
|
||||
WHERE
|
||||
ac.account_type = 'Bank'
|
||||
AND (gle.voucher_type, gle.voucher_no) IN %(docs)s
|
||||
AND gle.is_cancelled = 0
|
||||
GROUP BY
|
||||
gle.voucher_type, gle.voucher_no, gle.account
|
||||
""",
|
||||
{"docs": docs},
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
entries = {}
|
||||
@@ -523,32 +521,31 @@ def get_total_allocated_amount(docs):
|
||||
if not docs:
|
||||
return {}
|
||||
|
||||
# The original window query (ROW_NUMBER/FIRST_VALUE + rownum = 1) just collapses to one
|
||||
# row per (account, payment_document, payment_entry) with the partition's allocation total
|
||||
# and most recent transaction date — i.e. a plain GROUP BY with SUM and MAX.
|
||||
btp = frappe.qb.DocType("Bank Transaction Payments")
|
||||
bt = frappe.qb.DocType("Bank Transaction")
|
||||
ba = frappe.qb.DocType("Bank Account")
|
||||
|
||||
result = (
|
||||
frappe.qb.from_(btp)
|
||||
.left_join(bt)
|
||||
.on(bt.name == btp.parent)
|
||||
.left_join(ba)
|
||||
.on(ba.name == bt.bank_account)
|
||||
.select(
|
||||
Sum(btp.allocated_amount).as_("total"),
|
||||
Max(bt.date).as_("latest_date"),
|
||||
ba.account.as_("gl_account"),
|
||||
btp.payment_document,
|
||||
btp.payment_entry,
|
||||
)
|
||||
.where(
|
||||
Tuple(btp.payment_document, btp.payment_entry).isin([Tuple(pd, pe) for pd, pe in docs])
|
||||
& (bt.docstatus == 1)
|
||||
)
|
||||
.groupby(ba.account, btp.payment_document, btp.payment_entry)
|
||||
.run(as_dict=True)
|
||||
# nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
|
||||
result = frappe.db.sql(
|
||||
"""
|
||||
SELECT total, latest_date, gl_account, payment_document, payment_entry FROM (
|
||||
SELECT
|
||||
ROW_NUMBER() OVER w AS rownum,
|
||||
SUM(btp.allocated_amount) OVER(PARTITION BY ba.account, btp.payment_document, btp.payment_entry) AS total,
|
||||
FIRST_VALUE(bt.date) OVER w AS latest_date,
|
||||
ba.account AS gl_account,
|
||||
btp.payment_document,
|
||||
btp.payment_entry
|
||||
FROM
|
||||
`tabBank Transaction Payments` btp
|
||||
LEFT JOIN `tabBank Transaction` bt ON bt.name=btp.parent
|
||||
LEFT JOIN `tabBank Account` ba ON ba.name=bt.bank_account
|
||||
WHERE
|
||||
(btp.payment_document, btp.payment_entry) IN %(docs)s
|
||||
AND bt.docstatus = 1
|
||||
WINDOW w AS (PARTITION BY ba.account, btp.payment_document, btp.payment_entry ORDER BY bt.date DESC)
|
||||
) temp
|
||||
WHERE
|
||||
rownum = 1
|
||||
""",
|
||||
dict(docs=docs),
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
payment_allocation_details = {}
|
||||
|
||||
@@ -104,36 +104,6 @@ class TestBankTransaction(ERPNextTestSuite):
|
||||
self.assertEqual(bank_transaction.unallocated_amount, 1700)
|
||||
self.assertEqual(bank_transaction.payment_entries, [])
|
||||
|
||||
# Amending a reconciled payment entry must not carry over its clearance date
|
||||
def test_clearance_date_cleared_on_amend(self):
|
||||
bank_transaction = frappe.get_doc(
|
||||
"Bank Transaction",
|
||||
dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G"),
|
||||
)
|
||||
payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700))
|
||||
vouchers = json.dumps(
|
||||
[
|
||||
{
|
||||
"payment_doctype": "Payment Entry",
|
||||
"payment_name": payment.name,
|
||||
"amount": bank_transaction.unallocated_amount,
|
||||
}
|
||||
]
|
||||
)
|
||||
reconcile_vouchers(bank_transaction.name, vouchers)
|
||||
|
||||
self.assertTrue(frappe.db.get_value("Payment Entry", payment.name, "clearance_date"))
|
||||
|
||||
payment.reload()
|
||||
payment.cancel()
|
||||
|
||||
amended = frappe.copy_doc(payment)
|
||||
amended.amended_from = payment.name
|
||||
amended.docstatus = 0
|
||||
amended.insert()
|
||||
|
||||
self.assertFalse(amended.clearance_date)
|
||||
|
||||
# Check if ERPNext can correctly filter a linked payments based on the debit/credit amount
|
||||
def test_debit_credit_output(self):
|
||||
bank_transaction = frappe.get_doc(
|
||||
|
||||
@@ -11,11 +11,9 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestBankTransactionRule(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.bank = "HDFC - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cash = "Cash - _TC"
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
bank_dt = qb.DocType("Bank")
|
||||
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
||||
self.create_bank_account()
|
||||
|
||||
@@ -17,7 +17,6 @@ frappe.ui.form.on("Budget", {
|
||||
filters: {
|
||||
is_group: 0,
|
||||
company: frm.doc.company,
|
||||
root_type: ["in", ["Income", "Expense"]],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt
|
||||
|
||||
|
||||
@@ -44,17 +43,13 @@ class CashierClosing(Document):
|
||||
self.make_calculations()
|
||||
|
||||
def get_outstanding(self):
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
values = (
|
||||
frappe.qb.from_(si)
|
||||
.select(Sum(si.outstanding_amount))
|
||||
.where(
|
||||
(si.posting_date == self.date)
|
||||
& (si.posting_time >= self.from_time)
|
||||
& (si.posting_time <= self.time)
|
||||
& (si.owner == self.user)
|
||||
)
|
||||
.run()
|
||||
values = frappe.db.sql(
|
||||
"""
|
||||
select sum(outstanding_amount)
|
||||
from `tabSales Invoice`
|
||||
where posting_date=%s and posting_time>=%s and posting_time<=%s and owner=%s
|
||||
""",
|
||||
(self.date, self.from_time, self.time, self.user),
|
||||
)
|
||||
self.outstanding_amount = flt(values[0][0] if values else 0)
|
||||
|
||||
|
||||
@@ -6,14 +6,12 @@ frappe.provide("erpnext.cheque_print");
|
||||
frappe.ui.form.on("Cheque Print Template", {
|
||||
refresh: function (frm) {
|
||||
if (!frm.doc.__islocal) {
|
||||
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.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,6 +1,5 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "field:bank_name",
|
||||
"creation": "2016-05-04 14:35:00.402544",
|
||||
"doctype": "DocType",
|
||||
@@ -295,7 +294,7 @@
|
||||
],
|
||||
"links": [],
|
||||
"max_attachments": 1,
|
||||
"modified": "2026-06-08 12:10:35.829531",
|
||||
"modified": "2024-03-27 13:06:44.654989",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Cheque Print Template",
|
||||
@@ -326,17 +325,19 @@
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts User",
|
||||
"share": 1
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,6 @@ class ChequePrintTemplate(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_or_update_cheque_print_format(template_name: str):
|
||||
frappe.only_for("System Manager")
|
||||
|
||||
if not frappe.db.exists("Print Format", template_name):
|
||||
cheque_print = frappe.new_doc("Print Format")
|
||||
cheque_print.update(
|
||||
|
||||
@@ -11,28 +11,22 @@ frappe.ui.form.on("Currency Exchange Settings", {
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r && r.message) {
|
||||
let result = [],
|
||||
params = {};
|
||||
if (frm.doc.service_provider == "exchangerate.host") {
|
||||
result = ["result"];
|
||||
params = {
|
||||
let result = ["result"];
|
||||
let params = {
|
||||
date: "{transaction_date}",
|
||||
from: "{from_currency}",
|
||||
to: "{to_currency}",
|
||||
};
|
||||
add_param(frm, r.message, params, result);
|
||||
} else if (["frankfurter.app", "frankfurter.dev"].includes(frm.doc.service_provider)) {
|
||||
result = ["rates", "{to_currency}"];
|
||||
params = {
|
||||
let result = ["rates", "{to_currency}"];
|
||||
let params = {
|
||||
base: "{from_currency}",
|
||||
symbols: "{to_currency}",
|
||||
};
|
||||
} else if (frm.doc.service_provider == "frankfurter.dev - v2") {
|
||||
result = ["rate"];
|
||||
params = {
|
||||
date: "{transaction_date}",
|
||||
};
|
||||
add_param(frm, r.message, params, result);
|
||||
}
|
||||
add_param(frm, r.message, params, result);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
"fieldname": "service_provider",
|
||||
"fieldtype": "Select",
|
||||
"label": "Service Provider",
|
||||
"options": "frankfurter.dev\nexchangerate.host\nfrankfurter.dev - v2\nCustom",
|
||||
"options": "frankfurter.dev\nexchangerate.host\nCustom",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@@ -101,10 +101,11 @@
|
||||
"label": "Use HTTP Protocol"
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 0,
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-15 11:25:55.873110",
|
||||
"modified": "2026-03-16 13:28:21.075743",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Currency Exchange Settings",
|
||||
@@ -121,11 +122,24 @@
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "Accounts Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "Accounts User",
|
||||
"share": 1
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -29,7 +29,7 @@ class CurrencyExchangeSettings(Document):
|
||||
disabled: DF.Check
|
||||
req_params: DF.Table[CurrencyExchangeSettingsDetails]
|
||||
result_key: DF.Table[CurrencyExchangeSettingsResult]
|
||||
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "frankfurter.dev - v2", "Custom"]
|
||||
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "Custom"]
|
||||
url: DF.Data | None
|
||||
use_http: DF.Check
|
||||
# end: auto-generated types
|
||||
@@ -70,14 +70,6 @@ class CurrencyExchangeSettings(Document):
|
||||
self.append("req_params", {"key": "base", "value": "{from_currency}"})
|
||||
self.append("req_params", {"key": "symbols", "value": "{to_currency}"})
|
||||
|
||||
elif self.service_provider == "frankfurter.dev - v2":
|
||||
self.set("result_key", [])
|
||||
self.set("req_params", [])
|
||||
|
||||
self.api_endpoint = get_api_endpoint(self.service_provider, self.use_http)
|
||||
self.append("result_key", {"key": "rate"})
|
||||
self.append("req_params", {"key": "date", "value": "{transaction_date}"})
|
||||
|
||||
def validate_parameters(self):
|
||||
params = {}
|
||||
for row in self.req_params:
|
||||
@@ -113,20 +105,13 @@ class CurrencyExchangeSettings(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_api_endpoint(service_provider: str | None = None, use_http: bool = False):
|
||||
if service_provider and service_provider in [
|
||||
"exchangerate.host",
|
||||
"frankfurter.dev",
|
||||
"frankfurter.app",
|
||||
"frankfurter.dev - v2",
|
||||
]:
|
||||
if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev", "frankfurter.app"]:
|
||||
if service_provider == "exchangerate.host":
|
||||
api = "api.exchangerate.host/convert"
|
||||
elif service_provider == "frankfurter.app":
|
||||
api = "api.frankfurter.app/{transaction_date}"
|
||||
elif service_provider == "frankfurter.dev":
|
||||
api = "api.frankfurter.dev/v1/{transaction_date}"
|
||||
elif service_provider == "frankfurter.dev - v2":
|
||||
api = "api.frankfurter.dev/v2/rate/{from_currency}/{to_currency}"
|
||||
|
||||
protocol = "https://"
|
||||
if use_http:
|
||||
|
||||
@@ -8,7 +8,7 @@ from frappe import _, qb
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.query_builder import Criterion, Order
|
||||
from frappe.query_builder.functions import Max, NullIf, Sum
|
||||
from frappe.query_builder.functions import NullIf, Sum
|
||||
from frappe.utils import flt, get_link_to_form
|
||||
|
||||
import erpnext
|
||||
@@ -188,17 +188,11 @@ class ExchangeRateRevaluation(Document):
|
||||
accounts = [x[0] for x in res]
|
||||
|
||||
if accounts:
|
||||
gle = qb.DocType("GL Entry")
|
||||
having_clause = (qb.Field("balance") != qb.Field("balance_in_account_currency")) & (
|
||||
(qb.Field("balance_in_account_currency") != 0) | (qb.Field("balance") != 0)
|
||||
)
|
||||
|
||||
# balance expressions reused in both SELECT and HAVING; postgres can't reference a
|
||||
# SELECT alias inside HAVING, so the aggregate expression must be repeated there.
|
||||
balance = Sum(gle.debit) - Sum(gle.credit)
|
||||
balance_in_account_currency = Sum(gle.debit_in_account_currency) - Sum(
|
||||
gle.credit_in_account_currency
|
||||
)
|
||||
having_clause = (balance != balance_in_account_currency) & (
|
||||
(balance_in_account_currency != 0) | (balance != 0)
|
||||
)
|
||||
gle = qb.DocType("GL Entry")
|
||||
|
||||
# conditions
|
||||
conditions = []
|
||||
@@ -215,15 +209,17 @@ class ExchangeRateRevaluation(Document):
|
||||
qb.from_(gle)
|
||||
.select(
|
||||
gle.account,
|
||||
# grouped by NullIf(party_type/party, ""); the bare columns + account_currency are
|
||||
# constant per group -> Max() keeps the GROUP BY valid on postgres with the same value.
|
||||
Max(gle.party_type).as_("party_type"),
|
||||
Max(gle.party).as_("party"),
|
||||
Max(gle.account_currency).as_("account_currency"),
|
||||
balance_in_account_currency.as_("balance_in_account_currency"),
|
||||
balance.as_("balance"),
|
||||
# zero_balance is recomputed in Python below (after rounding), so the SQL value is
|
||||
# unused -- dropped (it used MySQL's XOR operator, which postgres lacks).
|
||||
gle.party_type,
|
||||
gle.party,
|
||||
gle.account_currency,
|
||||
(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency)).as_(
|
||||
"balance_in_account_currency"
|
||||
),
|
||||
(Sum(gle.debit) - Sum(gle.credit)).as_("balance"),
|
||||
(Sum(gle.debit) - Sum(gle.credit) == 0)
|
||||
^ (Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency) == 0).as_(
|
||||
"zero_balance"
|
||||
),
|
||||
)
|
||||
.where(Criterion.all(conditions))
|
||||
.groupby(gle.account, NullIf(gle.party_type, ""), NullIf(gle.party, ""))
|
||||
|
||||
@@ -15,11 +15,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.item = "_Test Item"
|
||||
self.customer = "_Test Customer"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.debtors_usd = "_Test Receivable USD - _TC"
|
||||
self.create_company()
|
||||
self.create_usd_receivable_account()
|
||||
self.create_item()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
self.set_system_and_company_settings()
|
||||
|
||||
def set_system_and_company_settings(self):
|
||||
|
||||
@@ -319,48 +319,56 @@ class InvoiceDiscounting(AccountsController):
|
||||
@frappe.whitelist()
|
||||
def get_invoices(filters: str):
|
||||
filters = frappe._dict(json.loads(filters))
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
di = frappe.qb.DocType("Discounted Invoice")
|
||||
|
||||
discounted = frappe.qb.from_(di).select(di.sales_invoice).where(di.docstatus == 1)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(si)
|
||||
.select(
|
||||
si.name.as_("sales_invoice"),
|
||||
si.customer,
|
||||
si.posting_date,
|
||||
si.outstanding_amount,
|
||||
si.debit_to,
|
||||
)
|
||||
.where((si.docstatus == 1) & (si.outstanding_amount > 0) & si.name.notin(discounted))
|
||||
)
|
||||
|
||||
cond = []
|
||||
if filters.customer:
|
||||
query = query.where(si.customer == filters.customer)
|
||||
cond.append("customer=%(customer)s")
|
||||
if filters.from_date:
|
||||
query = query.where(si.posting_date >= filters.from_date)
|
||||
cond.append("posting_date >= %(from_date)s")
|
||||
if filters.to_date:
|
||||
query = query.where(si.posting_date <= filters.to_date)
|
||||
cond.append("posting_date <= %(to_date)s")
|
||||
if filters.min_amount:
|
||||
query = query.where(si.base_grand_total >= filters.min_amount)
|
||||
cond.append("base_grand_total >= %(min_amount)s")
|
||||
if filters.max_amount:
|
||||
query = query.where(si.base_grand_total <= filters.max_amount)
|
||||
cond.append("base_grand_total <= %(max_amount)s")
|
||||
|
||||
return query.run(as_dict=1)
|
||||
where_condition = ""
|
||||
if cond:
|
||||
where_condition += " and " + " and ".join(cond)
|
||||
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
name as sales_invoice,
|
||||
customer,
|
||||
posting_date,
|
||||
outstanding_amount,
|
||||
debit_to
|
||||
from `tabSales Invoice` si
|
||||
where
|
||||
docstatus = 1
|
||||
and outstanding_amount > 0
|
||||
%s
|
||||
and not exists(select di.name from `tabDiscounted Invoice` di
|
||||
where di.docstatus=1 and di.sales_invoice=si.name)
|
||||
"""
|
||||
% where_condition,
|
||||
filters,
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
|
||||
def get_party_account_based_on_invoice_discounting(sales_invoice):
|
||||
party_account = None
|
||||
par = frappe.qb.DocType("Invoice Discounting")
|
||||
ch = frappe.qb.DocType("Discounted Invoice")
|
||||
invoice_discounting = (
|
||||
frappe.qb.from_(par)
|
||||
.inner_join(ch)
|
||||
.on(par.name == ch.parent)
|
||||
.select(par.accounts_receivable_discounted, par.accounts_receivable_unpaid, par.status)
|
||||
.where((par.docstatus == 1) & (ch.sales_invoice == sales_invoice))
|
||||
.run(as_dict=1)
|
||||
invoice_discounting = frappe.db.sql(
|
||||
"""
|
||||
select par.accounts_receivable_discounted, par.accounts_receivable_unpaid, par.status
|
||||
from `tabInvoice Discounting` par, `tabDiscounted Invoice` ch
|
||||
where par.name=ch.parent
|
||||
and par.docstatus=1
|
||||
and ch.sales_invoice = %s
|
||||
""",
|
||||
(sales_invoice),
|
||||
as_dict=1,
|
||||
)
|
||||
if invoice_discounting:
|
||||
if invoice_discounting[0].status == "Disbursed":
|
||||
|
||||
@@ -5,7 +5,7 @@ import frappe
|
||||
from frappe.utils import add_days, flt, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import get_payment_entry_against_invoice
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_payment_entry_against_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,261 +0,0 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Document builders that map a source document to a Journal Entry or to a
|
||||
Payment Entry raised against it."""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import flt, get_link_to_form, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
|
||||
get_party_account_based_on_invoice_discounting,
|
||||
)
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.utils import get_account_currency
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_entry_against_order(
|
||||
dt: str,
|
||||
dn: str,
|
||||
amount: float | None = None,
|
||||
debit_in_account_currency: str | float | None = None,
|
||||
journal_entry: bool = False,
|
||||
bank_account: str | None = None,
|
||||
) -> dict | Document:
|
||||
"""Build an advance-payment Journal Entry against an unbilled Sales/Purchase Order."""
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
|
||||
if flt(ref_doc.per_billed, 2) > 0:
|
||||
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
|
||||
|
||||
if dt == "Sales Order":
|
||||
party_type = "Customer"
|
||||
amount_field_party = "credit_in_account_currency"
|
||||
amount_field_bank = "debit_in_account_currency"
|
||||
else:
|
||||
party_type = "Supplier"
|
||||
amount_field_party = "debit_in_account_currency"
|
||||
amount_field_bank = "credit_in_account_currency"
|
||||
|
||||
party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
|
||||
party_account_currency = get_account_currency(party_account)
|
||||
|
||||
if not amount:
|
||||
if party_account_currency == ref_doc.company_currency:
|
||||
amount = flt(ref_doc.base_grand_total) - flt(ref_doc.advance_paid)
|
||||
else:
|
||||
amount = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid)
|
||||
|
||||
return get_payment_entry(
|
||||
ref_doc,
|
||||
{
|
||||
"party_type": party_type,
|
||||
"party_account": party_account,
|
||||
"party_account_currency": party_account_currency,
|
||||
"amount_field_party": amount_field_party,
|
||||
"amount_field_bank": amount_field_bank,
|
||||
"amount": amount,
|
||||
"debit_in_account_currency": debit_in_account_currency,
|
||||
"remarks": f"Advance Payment received against {dt} {dn}",
|
||||
"is_advance": "Yes",
|
||||
"bank_account": bank_account,
|
||||
"journal_entry": journal_entry,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_entry_against_invoice(
|
||||
dt: str,
|
||||
dn: str,
|
||||
amount: float | None = None,
|
||||
debit_in_account_currency: str | None = None,
|
||||
journal_entry: bool = False,
|
||||
bank_account: str | None = None,
|
||||
) -> dict | Document:
|
||||
"""Build a payment Journal Entry against a Sales/Purchase Invoice's outstanding amount."""
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
if dt == "Sales Invoice":
|
||||
party_type = "Customer"
|
||||
party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to
|
||||
else:
|
||||
party_type = "Supplier"
|
||||
party_account = ref_doc.credit_to
|
||||
|
||||
if (dt == "Sales Invoice" and ref_doc.outstanding_amount > 0) or (
|
||||
dt == "Purchase Invoice" and ref_doc.outstanding_amount < 0
|
||||
):
|
||||
amount_field_party = "credit_in_account_currency"
|
||||
amount_field_bank = "debit_in_account_currency"
|
||||
else:
|
||||
amount_field_party = "debit_in_account_currency"
|
||||
amount_field_bank = "credit_in_account_currency"
|
||||
|
||||
return get_payment_entry(
|
||||
ref_doc,
|
||||
{
|
||||
"party_type": party_type,
|
||||
"party_account": party_account,
|
||||
"party_account_currency": ref_doc.party_account_currency,
|
||||
"amount_field_party": amount_field_party,
|
||||
"amount_field_bank": amount_field_bank,
|
||||
"amount": amount if amount else abs(ref_doc.outstanding_amount),
|
||||
"debit_in_account_currency": debit_in_account_currency,
|
||||
"remarks": f"Payment received against {dt} {dn}. {ref_doc.remarks}",
|
||||
"is_advance": "No",
|
||||
"bank_account": bank_account,
|
||||
"journal_entry": journal_entry,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_payment_entry(ref_doc, args: dict) -> dict | Document:
|
||||
"""Build a Bank Entry Journal Entry paying `ref_doc`, with a party row and a bank row.
|
||||
|
||||
Returns the Journal Entry document when `args["journal_entry"]` is truthy, otherwise its
|
||||
dict (for client calls).
|
||||
"""
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
|
||||
|
||||
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
|
||||
"Company", ref_doc.company, "cost_center"
|
||||
)
|
||||
exchange_rate = _reference_exchange_rate(ref_doc, args)
|
||||
|
||||
party_row = _append_party_row(je, ref_doc, args, cost_center, exchange_rate)
|
||||
bank_row = _append_bank_row(je, ref_doc, args, cost_center, exchange_rate)
|
||||
|
||||
if party_row.account_currency != ref_doc.company_currency or (
|
||||
bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency
|
||||
):
|
||||
je.multi_currency = 1
|
||||
|
||||
je.set_amounts_in_company_currency()
|
||||
je.set_total_debit_credit()
|
||||
|
||||
return je if args.get("journal_entry") else je.as_dict()
|
||||
|
||||
|
||||
def _reference_exchange_rate(ref_doc, args: dict) -> float:
|
||||
"""Exchange rate of the party account on the reference document's posting date."""
|
||||
if not args.get("party_account"):
|
||||
return 1
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate
|
||||
|
||||
return get_exchange_rate(
|
||||
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||
args.get("party_account"),
|
||||
args.get("party_account_currency"),
|
||||
ref_doc.company,
|
||||
ref_doc.doctype,
|
||||
ref_doc.name,
|
||||
)
|
||||
|
||||
|
||||
def _append_party_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
|
||||
"""Append the party (debtor/creditor) row that records the advance/payment."""
|
||||
return je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": args.get("party_account"),
|
||||
"party_type": args.get("party_type"),
|
||||
"party": ref_doc.get(args.get("party_type").lower()),
|
||||
"cost_center": cost_center,
|
||||
"account_type": frappe.get_cached_value("Account", args.get("party_account"), "account_type"),
|
||||
"account_currency": args.get("party_account_currency")
|
||||
or get_account_currency(args.get("party_account")),
|
||||
"exchange_rate": exchange_rate,
|
||||
args.get("amount_field_party"): args.get("amount"),
|
||||
"is_advance": args.get("is_advance"),
|
||||
"reference_type": ref_doc.doctype,
|
||||
"reference_name": ref_doc.name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _append_bank_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
|
||||
"""Append the bank/cash row, defaulting the account and converting the amount to it."""
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import (
|
||||
get_default_bank_cash_account,
|
||||
get_exchange_rate,
|
||||
)
|
||||
|
||||
bank_row = je.append("accounts")
|
||||
bank_account = get_default_bank_cash_account(ref_doc.company, "Bank", account=args.get("bank_account"))
|
||||
if bank_account:
|
||||
bank_row.update(bank_account)
|
||||
# posting date assumed to be the reference document's posting/transaction date
|
||||
bank_row.exchange_rate = get_exchange_rate(
|
||||
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||
bank_account["account"],
|
||||
bank_account["account_currency"],
|
||||
ref_doc.company,
|
||||
)
|
||||
|
||||
bank_row.cost_center = cost_center
|
||||
|
||||
amount = args.get("debit_in_account_currency") or args.get("amount")
|
||||
if bank_row.account_currency == args.get("party_account_currency"):
|
||||
bank_row.set(args.get("amount_field_bank"), amount)
|
||||
else:
|
||||
bank_row.set(args.get("amount_field_bank"), amount * exchange_rate)
|
||||
|
||||
return bank_row
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str) -> dict:
|
||||
"""Build the counterpart Journal Entry in another company, linked back to `name`."""
|
||||
journal_entry = frappe.new_doc("Journal Entry")
|
||||
journal_entry.voucher_type = voucher_type
|
||||
journal_entry.company = company
|
||||
journal_entry.posting_date = nowdate()
|
||||
journal_entry.inter_company_journal_entry_reference = name
|
||||
return journal_entry.as_dict()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None) -> Document:
|
||||
"""Map a submitted Journal Entry to a reversing one (debits and credits swapped)."""
|
||||
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
|
||||
if existing_reverse:
|
||||
frappe.throw(
|
||||
_("A Reverse Journal Entry {0} already exists for this Journal Entry.").format(
|
||||
get_link_to_form("Journal Entry", existing_reverse)
|
||||
)
|
||||
)
|
||||
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
|
||||
def post_process(source, target) -> None:
|
||||
target.reversal_of = source.name
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Journal Entry",
|
||||
source_name,
|
||||
{
|
||||
"Journal Entry": {"doctype": "Journal Entry", "validation": {"docstatus": ["=", 1]}},
|
||||
"Journal Entry Account": {
|
||||
"doctype": "Journal Entry Account",
|
||||
"field_map": {
|
||||
"account_currency": "account_currency",
|
||||
"exchange_rate": "exchange_rate",
|
||||
"debit_in_account_currency": "credit_in_account_currency",
|
||||
"debit": "credit",
|
||||
"credit_in_account_currency": "debit_in_account_currency",
|
||||
"credit": "debit",
|
||||
"reference_type": "reference_type",
|
||||
"reference_name": "reference_name",
|
||||
},
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
post_process,
|
||||
)
|
||||
|
||||
return doclist
|
||||
@@ -1,200 +0,0 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
|
||||
get_depr_schedule,
|
||||
)
|
||||
|
||||
|
||||
class AssetService:
|
||||
"""Keeps Assets in sync with the Journal Entries that depreciate, dispose or
|
||||
adjust them.
|
||||
|
||||
On submit of a Depreciation Entry it reduces the asset value and links the
|
||||
depreciation schedule; on submit of an Asset Disposal it marks the asset
|
||||
disposed. On cancel it reverses those links. It also guards cancellation of
|
||||
Journal Entries tied to asset scrapping or value adjustments.
|
||||
"""
|
||||
|
||||
def __init__(self, doc) -> None:
|
||||
self.doc = doc
|
||||
|
||||
def validate_depr_account_and_depr_entry_voucher_type(self) -> None:
|
||||
"""A depreciation account requires voucher type Depreciation Entry and an Expense account."""
|
||||
for d in self.doc.get("accounts"):
|
||||
if d.account_type == "Depreciation":
|
||||
if self.doc.voucher_type != "Depreciation Entry":
|
||||
frappe.throw(
|
||||
_("Journal Entry type should be set as Depreciation Entry for asset depreciation")
|
||||
)
|
||||
|
||||
if frappe.get_cached_value("Account", d.account, "root_type") != "Expense":
|
||||
frappe.throw(_("Account {0} should be of type Expense").format(d.account))
|
||||
|
||||
def has_asset_adjustment_entry(self) -> None:
|
||||
"""Block cancellation while a submitted Asset Value Adjustment links to this entry."""
|
||||
if self.doc.flags.get("via_asset_value_adjustment"):
|
||||
return
|
||||
|
||||
asset_value_adjustment = frappe.db.get_value(
|
||||
"Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.doc.name}, "name"
|
||||
)
|
||||
if asset_value_adjustment:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot cancel this document as it is linked with the submitted Asset Value Adjustment <b>{0}</b>. Please cancel the Asset Value Adjustment to continue."
|
||||
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
|
||||
)
|
||||
|
||||
def update_asset_value(self) -> None:
|
||||
"""Apply the entry's effect to its linked assets on submit (depreciation or disposal)."""
|
||||
self.update_asset_on_depreciation()
|
||||
self.update_asset_on_disposal()
|
||||
|
||||
def update_asset_on_depreciation(self) -> None:
|
||||
"""Reduce each depreciated asset's value and link the depreciation schedule row."""
|
||||
if self.doc.voucher_type != "Depreciation Entry":
|
||||
return
|
||||
|
||||
for d in self.doc.get("accounts"):
|
||||
if (
|
||||
d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
|
||||
and d.debit
|
||||
):
|
||||
asset = frappe.get_cached_doc("Asset", d.reference_name)
|
||||
|
||||
if asset.calculate_depreciation:
|
||||
self.update_journal_entry_link_on_depr_schedule(asset, d)
|
||||
self.update_value_after_depreciation(asset, d.debit)
|
||||
|
||||
asset.db_set("value_after_depreciation", asset.value_after_depreciation - d.debit)
|
||||
asset.set_status()
|
||||
asset.set_total_booked_depreciations()
|
||||
|
||||
def update_value_after_depreciation(self, asset, depr_amount: float) -> None:
|
||||
"""Subtract the depreciation amount from the asset's relevant finance book."""
|
||||
fb_idx = 1
|
||||
if self.doc.finance_book:
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if fb_row.finance_book == self.doc.finance_book:
|
||||
fb_idx = fb_row.idx
|
||||
break
|
||||
fb_row = asset.get("finance_books")[fb_idx - 1]
|
||||
fb_row.value_after_depreciation -= depr_amount
|
||||
frappe.db.set_value(
|
||||
"Asset Finance Book", fb_row.name, "value_after_depreciation", fb_row.value_after_depreciation
|
||||
)
|
||||
|
||||
def update_journal_entry_link_on_depr_schedule(self, asset, je_row) -> None:
|
||||
"""Stamp this entry onto the matching (date + amount) depreciation schedule row."""
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book)
|
||||
for d in depr_schedule or []:
|
||||
if (
|
||||
d.schedule_date == self.doc.posting_date
|
||||
and not d.journal_entry
|
||||
and d.depreciation_amount == flt(je_row.debit)
|
||||
):
|
||||
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name)
|
||||
|
||||
def update_asset_on_disposal(self) -> None:
|
||||
"""Mark each referenced asset disposed (date + scrap entry) on an Asset Disposal."""
|
||||
if self.doc.voucher_type == "Asset Disposal":
|
||||
disposed_assets = []
|
||||
for d in self.doc.get("accounts"):
|
||||
if (
|
||||
d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and d.reference_name not in disposed_assets
|
||||
):
|
||||
frappe.db.set_value(
|
||||
"Asset",
|
||||
d.reference_name,
|
||||
{
|
||||
"disposal_date": self.doc.posting_date,
|
||||
"journal_entry_for_scrap": self.doc.name,
|
||||
},
|
||||
)
|
||||
asset_doc = frappe.get_doc("Asset", d.reference_name)
|
||||
asset_doc.set_status()
|
||||
disposed_assets.append(d.reference_name)
|
||||
|
||||
def unlink_asset_reference(self) -> None:
|
||||
"""On cancel, reverse depreciation links and block cancelling an asset-scrap entry."""
|
||||
for d in self.doc.get("accounts"):
|
||||
if self._is_depreciation_asset_row(d):
|
||||
self._reverse_asset_depreciation(d)
|
||||
elif (
|
||||
self.doc.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name
|
||||
):
|
||||
self._block_scrap_journal_cancel(d)
|
||||
|
||||
def _is_depreciation_asset_row(self, d) -> bool:
|
||||
return bool(
|
||||
self.doc.voucher_type == "Depreciation Entry"
|
||||
and d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
|
||||
and d.debit
|
||||
)
|
||||
|
||||
def _reverse_asset_depreciation(self, d) -> None:
|
||||
"""Add the depreciation amount back to the asset and unlink its schedule row."""
|
||||
asset = frappe.get_doc("Asset", d.reference_name)
|
||||
|
||||
if asset.calculate_depreciation and not self._restore_scheduled_depreciation(asset, d.debit):
|
||||
self._restore_finance_book_value(asset, d.debit)
|
||||
|
||||
asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit)
|
||||
asset.set_status()
|
||||
asset.set_total_booked_depreciations()
|
||||
|
||||
def _restore_scheduled_depreciation(self, asset, debit: float) -> bool:
|
||||
"""Unlink this entry from the depreciation schedule and credit back its finance book.
|
||||
|
||||
Returns True if a matching scheduled depreciation was found.
|
||||
"""
|
||||
for fb_row in asset.get("finance_books"):
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book)
|
||||
for s in depr_schedule or []:
|
||||
if s.journal_entry == self.doc.name:
|
||||
s.db_set("journal_entry", None)
|
||||
fb_row.value_after_depreciation += debit
|
||||
fb_row.db_update()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _restore_finance_book_value(self, asset, debit: float) -> None:
|
||||
"""Credit the depreciation amount back to the relevant finance book when no schedule matched."""
|
||||
fb_idx = 1
|
||||
if self.doc.finance_book:
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if fb_row.finance_book == self.doc.finance_book:
|
||||
fb_idx = fb_row.idx
|
||||
break
|
||||
|
||||
fb_row = asset.get("finance_books")[fb_idx - 1]
|
||||
fb_row.value_after_depreciation += debit
|
||||
fb_row.db_update()
|
||||
|
||||
def _block_scrap_journal_cancel(self, d) -> None:
|
||||
"""Prevent cancelling a plain Journal Entry that is an asset's scrap voucher."""
|
||||
journal_entry_for_scrap = frappe.db.get_value("Asset", d.reference_name, "journal_entry_for_scrap")
|
||||
if journal_entry_for_scrap == self.doc.name:
|
||||
frappe.throw(
|
||||
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
|
||||
)
|
||||
|
||||
def unlink_asset_adjustment_entry(self) -> None:
|
||||
"""Detach this entry from any Asset Value Adjustment that referenced it."""
|
||||
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
|
||||
(
|
||||
frappe.qb.update(AssetValueAdjustment)
|
||||
.set(AssetValueAdjustment.journal_entry, None)
|
||||
.where(AssetValueAdjustment.journal_entry == self.doc.name)
|
||||
).run()
|
||||
@@ -18,88 +18,86 @@ class JournalEntryGLComposer(BaseGLComposer):
|
||||
from the first foreign-currency row (mirroring the former build_gl_map).
|
||||
"""
|
||||
|
||||
def compose(self) -> list:
|
||||
"""Project the Journal Entry's non-zero account rows into GL dicts."""
|
||||
self._set_transaction_currency()
|
||||
def compose(self):
|
||||
doc = self.doc
|
||||
gl_map = []
|
||||
|
||||
company_currency = erpnext.get_company_currency(doc.company)
|
||||
doc.transaction_currency = company_currency
|
||||
doc.transaction_exchange_rate = 1
|
||||
if doc.multi_currency:
|
||||
for row in doc.get("accounts"):
|
||||
if row.account_currency != company_currency:
|
||||
# Journal assumes the first foreign currency as transaction currency
|
||||
doc.transaction_currency = row.account_currency
|
||||
doc.transaction_exchange_rate = row.exchange_rate
|
||||
break
|
||||
|
||||
advance_doctypes = get_advance_payment_doctypes()
|
||||
|
||||
gl_map = []
|
||||
for d in self.doc.get("accounts"):
|
||||
if d.debit or d.credit or self.doc.voucher_type == "Exchange Gain Or Loss":
|
||||
gl_map.append(self.get_gl_dict(self._gl_row(d, advance_doctypes), item=d))
|
||||
return gl_map
|
||||
for d in doc.get("accounts"):
|
||||
if d.debit or d.credit or (doc.voucher_type == "Exchange Gain Or Loss"):
|
||||
r = [d.user_remark, doc.remark]
|
||||
r = [x for x in r if x]
|
||||
remarks = "\n".join(r)
|
||||
|
||||
def _set_transaction_currency(self) -> None:
|
||||
"""Company currency, or the first foreign-currency row, becomes the transaction currency."""
|
||||
doc = self.doc
|
||||
doc.transaction_currency = erpnext.get_company_currency(doc.company)
|
||||
doc.transaction_exchange_rate = 1
|
||||
if not doc.multi_currency:
|
||||
return
|
||||
|
||||
for row in doc.get("accounts"):
|
||||
if row.account_currency != doc.transaction_currency:
|
||||
# Journal assumes the first foreign currency as transaction currency
|
||||
doc.transaction_currency = row.account_currency
|
||||
doc.transaction_exchange_rate = row.exchange_rate
|
||||
break
|
||||
|
||||
def _gl_row(self, d, advance_doctypes: list) -> dict:
|
||||
"""Build the GL dict for a single account row."""
|
||||
doc = self.doc
|
||||
remarks = "\n".join(x for x in [d.user_remark, doc.remark] if x)
|
||||
|
||||
row = {
|
||||
"account": d.account,
|
||||
"party_type": d.party_type,
|
||||
"due_date": doc.due_date,
|
||||
"party": d.party,
|
||||
"against": d.against_account,
|
||||
"debit": flt(d.debit, d.precision("debit")),
|
||||
"credit": flt(d.credit, d.precision("credit")),
|
||||
"account_currency": d.account_currency,
|
||||
"debit_in_account_currency": flt(
|
||||
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||
),
|
||||
"credit_in_account_currency": flt(
|
||||
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||
),
|
||||
"transaction_currency": doc.transaction_currency,
|
||||
"transaction_exchange_rate": doc.transaction_exchange_rate,
|
||||
"debit_in_transaction_currency": flt(
|
||||
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||
)
|
||||
if doc.transaction_currency == d.account_currency
|
||||
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
|
||||
"credit_in_transaction_currency": flt(
|
||||
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||
)
|
||||
if doc.transaction_currency == d.account_currency
|
||||
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
|
||||
"against_voucher_type": d.reference_type,
|
||||
"against_voucher": d.reference_name,
|
||||
"remarks": remarks,
|
||||
"voucher_detail_no": d.reference_detail_no,
|
||||
"cost_center": d.cost_center,
|
||||
"project": d.project,
|
||||
"finance_book": doc.finance_book,
|
||||
"advance_voucher_type": d.advance_voucher_type,
|
||||
"advance_voucher_no": d.advance_voucher_no,
|
||||
}
|
||||
|
||||
if d.reference_type in advance_doctypes:
|
||||
row.update(
|
||||
{
|
||||
"against_voucher_type": doc.doctype,
|
||||
"against_voucher": doc.name,
|
||||
"advance_voucher_type": d.reference_type,
|
||||
"advance_voucher_no": d.reference_name,
|
||||
row = {
|
||||
"account": d.account,
|
||||
"party_type": d.party_type,
|
||||
"due_date": doc.due_date,
|
||||
"party": d.party,
|
||||
"against": d.against_account,
|
||||
"debit": flt(d.debit, d.precision("debit")),
|
||||
"credit": flt(d.credit, d.precision("credit")),
|
||||
"account_currency": d.account_currency,
|
||||
"debit_in_account_currency": flt(
|
||||
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||
),
|
||||
"credit_in_account_currency": flt(
|
||||
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||
),
|
||||
"transaction_currency": doc.transaction_currency,
|
||||
"transaction_exchange_rate": doc.transaction_exchange_rate,
|
||||
"debit_in_transaction_currency": flt(
|
||||
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||
)
|
||||
if doc.transaction_currency == d.account_currency
|
||||
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
|
||||
"credit_in_transaction_currency": flt(
|
||||
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||
)
|
||||
if doc.transaction_currency == d.account_currency
|
||||
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
|
||||
"against_voucher_type": d.reference_type,
|
||||
"against_voucher": d.reference_name,
|
||||
"remarks": remarks,
|
||||
"voucher_detail_no": d.reference_detail_no,
|
||||
"cost_center": d.cost_center,
|
||||
"project": d.project,
|
||||
"finance_book": doc.finance_book,
|
||||
"advance_voucher_type": d.advance_voucher_type,
|
||||
"advance_voucher_no": d.advance_voucher_no,
|
||||
}
|
||||
)
|
||||
|
||||
# set flag to skip party validation
|
||||
account_type = frappe.get_cached_value("Account", d.account, "account_type")
|
||||
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
|
||||
frappe.flags.party_not_required = True
|
||||
if d.reference_type in advance_doctypes:
|
||||
row.update(
|
||||
{
|
||||
"against_voucher_type": doc.doctype,
|
||||
"against_voucher": doc.name,
|
||||
"advance_voucher_type": d.reference_type,
|
||||
"advance_voucher_no": d.reference_name,
|
||||
}
|
||||
)
|
||||
|
||||
return row
|
||||
# set flag to skip party validation
|
||||
account_type = frappe.get_cached_value("Account", d.account, "account_type")
|
||||
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
|
||||
frappe.flags.party_not_required = True
|
||||
|
||||
gl_map.append(
|
||||
self.get_gl_dict(
|
||||
row,
|
||||
item=d,
|
||||
)
|
||||
)
|
||||
return gl_map
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _, scrub
|
||||
from frappe.utils import cstr, flt, fmt_money
|
||||
|
||||
from erpnext.accounts.deferred_revenue import get_deferred_booking_accounts
|
||||
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
|
||||
get_party_account_based_on_invoice_discounting,
|
||||
)
|
||||
from erpnext.accounts.utils import get_account_currency
|
||||
|
||||
REFERENCE_PARTY_ACCOUNT_FIELDS = {
|
||||
"Sales Invoice": ["Customer", "Debit To"],
|
||||
"Purchase Invoice": ["Supplier", "Credit To"],
|
||||
"Sales Order": ["Customer"],
|
||||
"Purchase Order": ["Supplier"],
|
||||
}
|
||||
|
||||
|
||||
class JournalEntryReferenceValidator:
|
||||
"""Validates Journal Entry account rows against their referenced documents.
|
||||
|
||||
For each row that links a Sales/Purchase Invoice or Order, this checks the
|
||||
debit/credit direction, party and account match, and aggregates per-reference
|
||||
totals (held on the document as ``reference_totals``/``reference_types``/
|
||||
``reference_accounts``) which are then validated against the referenced
|
||||
orders and invoices.
|
||||
"""
|
||||
|
||||
def __init__(self, doc) -> None:
|
||||
self.doc = doc
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate every reference-bearing row, then the referenced orders and invoices."""
|
||||
self.doc.reference_totals = {}
|
||||
self.doc.reference_types = {}
|
||||
self.doc.reference_accounts = {}
|
||||
for row in self.doc.get("accounts"):
|
||||
self._normalize_reference_fields(row)
|
||||
if not self._has_party_reference(row):
|
||||
continue
|
||||
self._validate_order_direction(row)
|
||||
self._register_reference(row)
|
||||
self._validate_reference_party_and_account(row)
|
||||
|
||||
self._validate_orders()
|
||||
self._validate_invoices()
|
||||
|
||||
def _normalize_reference_fields(self, row) -> None:
|
||||
if not row.reference_type:
|
||||
row.reference_name = None
|
||||
if not row.reference_name:
|
||||
row.reference_type = None
|
||||
|
||||
def _has_party_reference(self, row) -> bool:
|
||||
return bool(
|
||||
row.reference_type and row.reference_name and row.reference_type in REFERENCE_PARTY_ACCOUNT_FIELDS
|
||||
)
|
||||
|
||||
def _reference_amount_field(self, row) -> str:
|
||||
if row.reference_type in ("Sales Order", "Sales Invoice"):
|
||||
return "credit_in_account_currency"
|
||||
return "debit_in_account_currency"
|
||||
|
||||
def _validate_order_direction(self, row) -> None:
|
||||
"""An order can only be linked on the side that records an advance."""
|
||||
if row.reference_type == "Sales Order" and flt(row.debit) > 0:
|
||||
frappe.throw(
|
||||
_("Row {0}: Debit entry can not be linked with a {1}").format(row.idx, row.reference_type)
|
||||
)
|
||||
if row.reference_type == "Purchase Order" and flt(row.credit) > 0:
|
||||
frappe.throw(
|
||||
_("Row {0}: Credit entry can not be linked with a {1}").format(row.idx, row.reference_type)
|
||||
)
|
||||
|
||||
def _register_reference(self, row) -> None:
|
||||
"""Aggregate the row's amount, type and account onto the per-reference lookups."""
|
||||
if row.reference_name not in self.doc.reference_totals:
|
||||
self.doc.reference_totals[row.reference_name] = 0.0
|
||||
if self.doc.voucher_type not in ("Deferred Revenue", "Deferred Expense"):
|
||||
self.doc.reference_totals[row.reference_name] += flt(row.get(self._reference_amount_field(row)))
|
||||
self.doc.reference_types[row.reference_name] = row.reference_type
|
||||
self.doc.reference_accounts[row.reference_name] = row.account
|
||||
|
||||
def _validate_reference_party_and_account(self, row) -> None:
|
||||
"""Reject a missing reference, then check party/account against the linked document."""
|
||||
party_fields = REFERENCE_PARTY_ACCOUNT_FIELDS[row.reference_type]
|
||||
against_voucher = frappe.db.get_value(
|
||||
row.reference_type, row.reference_name, [scrub(f) for f in party_fields]
|
||||
)
|
||||
if not against_voucher:
|
||||
frappe.throw(_("Row {0}: Invalid reference {1}").format(row.idx, row.reference_name))
|
||||
|
||||
if row.reference_type in ("Sales Invoice", "Purchase Invoice"):
|
||||
self._validate_invoice_party_and_account(row, against_voucher, party_fields)
|
||||
elif row.reference_type in ("Sales Order", "Purchase Order"):
|
||||
self._validate_order_party(row, against_voucher)
|
||||
|
||||
def _validate_invoice_party_and_account(self, row, against_voucher, party_fields) -> None:
|
||||
party_account, against_party = self._resolve_invoice_party_account(row, against_voucher)
|
||||
if self.doc.voucher_type == "Exchange Gain Or Loss":
|
||||
return
|
||||
if against_party != cstr(row.party) or party_account != row.account:
|
||||
frappe.throw(
|
||||
_("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}").format(
|
||||
row.idx, party_fields[0], party_fields[1], row.reference_type, row.reference_name
|
||||
)
|
||||
)
|
||||
|
||||
def _resolve_invoice_party_account(self, row, against_voucher) -> tuple:
|
||||
"""Expected (party_account, party) for an invoice row, honouring deferred booking
|
||||
and invoice-discounting accounts."""
|
||||
if self.doc.voucher_type in ("Deferred Revenue", "Deferred Expense") and row.reference_detail_no:
|
||||
debit_or_credit = "Debit" if row.debit else "Credit"
|
||||
party_account = get_deferred_booking_accounts(
|
||||
row.reference_type, row.reference_detail_no, debit_or_credit
|
||||
)
|
||||
return party_account, ""
|
||||
if row.reference_type == "Sales Invoice":
|
||||
party_account = (
|
||||
get_party_account_based_on_invoice_discounting(row.reference_name) or against_voucher[1]
|
||||
)
|
||||
else:
|
||||
party_account = against_voucher[1]
|
||||
return party_account, against_voucher[0]
|
||||
|
||||
def _validate_order_party(self, row, against_voucher) -> None:
|
||||
if against_voucher != row.party:
|
||||
frappe.throw(
|
||||
_("Row {0}: {1} {2} does not match with {3}").format(
|
||||
row.idx, row.party_type, row.party, row.reference_type
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_orders(self) -> None:
|
||||
"""Validate totals, closed and docstatus for referenced orders."""
|
||||
for reference_name, total in self.doc.reference_totals.items():
|
||||
reference_type = self.doc.reference_types[reference_name]
|
||||
account = self.doc.reference_accounts[reference_name]
|
||||
if reference_type not in ("Sales Order", "Purchase Order"):
|
||||
continue
|
||||
|
||||
order = frappe.get_doc(reference_type, reference_name)
|
||||
self._validate_order_status(order, reference_type, reference_name)
|
||||
self._validate_order_advance_total(order, account, total, reference_type, reference_name)
|
||||
|
||||
def _validate_order_status(self, order, reference_type, reference_name) -> None:
|
||||
if order.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||
if flt(order.per_billed) >= 100:
|
||||
frappe.throw(_("{0} {1} is fully billed").format(reference_type, reference_name))
|
||||
if cstr(order.status) == "Closed":
|
||||
frappe.throw(_("{0} {1} is closed").format(reference_type, reference_name))
|
||||
|
||||
def _validate_order_advance_total(self, order, account, total, reference_type, reference_name) -> None:
|
||||
"""The advance paid against an order cannot exceed its grand total."""
|
||||
account_currency = get_account_currency(account)
|
||||
if account_currency == self.doc.company_currency:
|
||||
voucher_total = order.base_grand_total
|
||||
field = "base_grand_total"
|
||||
else:
|
||||
voucher_total = order.grand_total
|
||||
field = "grand_total"
|
||||
|
||||
if flt(voucher_total) < (flt(order.advance_paid) + total):
|
||||
formatted_voucher_total = fmt_money(
|
||||
voucher_total, order.precision(field), currency=account_currency
|
||||
)
|
||||
frappe.throw(
|
||||
_("Advance paid against {0} {1} cannot be greater than Grand Total {2}").format(
|
||||
reference_type, reference_name, formatted_voucher_total
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_invoices(self) -> None:
|
||||
"""Validate totals and docstatus for referenced invoices."""
|
||||
if self.doc.voucher_type in ("Debit Note", "Credit Note"):
|
||||
return
|
||||
for reference_name, total in self.doc.reference_totals.items():
|
||||
reference_type = self.doc.reference_types[reference_name]
|
||||
if reference_type not in ("Sales Invoice", "Purchase Invoice"):
|
||||
continue
|
||||
invoice = frappe.get_doc(reference_type, reference_name)
|
||||
self._validate_invoice_outstanding(invoice, total, reference_type, reference_name)
|
||||
|
||||
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name) -> None:
|
||||
"""Payment booked against an invoice cannot exceed its outstanding amount."""
|
||||
if invoice.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||
|
||||
precision = invoice.precision("outstanding_amount")
|
||||
if total and flt(invoice.outstanding_amount, precision) < flt(total, precision):
|
||||
frappe.throw(
|
||||
_("Payment against {0} {1} cannot be greater than Outstanding Amount {2}").format(
|
||||
reference_type, reference_name, invoice.outstanding_amount
|
||||
)
|
||||
)
|
||||
@@ -169,11 +169,8 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
"debit_in_account_currency",
|
||||
"credit",
|
||||
"credit_in_account_currency",
|
||||
"debit_in_transaction_currency",
|
||||
"credit_in_transaction_currency",
|
||||
]
|
||||
|
||||
# Transaction currency is USD (first foreign row); the INR row is converted at 1/50.
|
||||
self.expected_gle = [
|
||||
{
|
||||
"account": "_Test Bank - _TC",
|
||||
@@ -182,8 +179,6 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
"debit_in_account_currency": 0,
|
||||
"credit": 5000,
|
||||
"credit_in_account_currency": 5000,
|
||||
"debit_in_transaction_currency": 0,
|
||||
"credit_in_transaction_currency": 100,
|
||||
},
|
||||
{
|
||||
"account": "_Test Bank USD - _TC",
|
||||
@@ -192,8 +187,6 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
"debit_in_account_currency": 100,
|
||||
"credit": 0,
|
||||
"credit_in_account_currency": 0,
|
||||
"debit_in_transaction_currency": 100,
|
||||
"credit_in_transaction_currency": 0,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -210,54 +203,8 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
|
||||
self.assertFalse(gle)
|
||||
|
||||
def test_multi_currency_transaction_currency_on_foreign_debit(self):
|
||||
"""Pin debit_in_transaction_currency for a foreign-currency debit row.
|
||||
|
||||
Transaction currency is USD (the first foreign row); the INR debit row must be
|
||||
converted at 1/exchange_rate, so 5000 INR -> 100 USD. Guards the / vs * direction.
|
||||
"""
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
jv.company = "_Test Company"
|
||||
jv.posting_date = nowdate()
|
||||
jv.multi_currency = 1
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Bank USD - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"credit_in_account_currency": 100,
|
||||
"exchange_rate": 50,
|
||||
},
|
||||
)
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Bank - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"debit_in_account_currency": 5000,
|
||||
"exchange_rate": 1,
|
||||
},
|
||||
)
|
||||
jv.submit()
|
||||
|
||||
self.voucher_no = jv.name
|
||||
self.fields = ["account", "debit_in_transaction_currency", "credit_in_transaction_currency"]
|
||||
self.expected_gle = [
|
||||
{
|
||||
"account": "_Test Bank - _TC",
|
||||
"debit_in_transaction_currency": 100,
|
||||
"credit_in_transaction_currency": 0,
|
||||
},
|
||||
{
|
||||
"account": "_Test Bank USD - _TC",
|
||||
"debit_in_transaction_currency": 0,
|
||||
"credit_in_transaction_currency": 100,
|
||||
},
|
||||
]
|
||||
self.check_gl_entries()
|
||||
|
||||
def test_reverse_journal_entry(self):
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
|
||||
|
||||
jv = make_journal_entry("_Test Bank USD - _TC", "Sales - _TC", 100, exchange_rate=50, save=False)
|
||||
|
||||
@@ -662,197 +609,6 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
jv.save()
|
||||
self.assertRaises(frappe.ValidationError, jv.submit)
|
||||
|
||||
def test_validate_reference_doc_debit_against_sales_order_throws(self):
|
||||
"""Characterize: a debit entry linked to a Sales Order is rejected."""
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
sales_order = make_sales_order()
|
||||
jv = make_journal_entry("Debtors - _TC", "_Test Cash - _TC", 100, save=False)
|
||||
jv.accounts[0].party_type = "Customer"
|
||||
jv.accounts[0].party = "_Test Customer"
|
||||
jv.accounts[0].reference_type = "Sales Order"
|
||||
jv.accounts[0].reference_name = sales_order.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Debit entry can not be linked", jv.insert)
|
||||
|
||||
def test_validate_reference_doc_credit_against_purchase_order_throws(self):
|
||||
"""Characterize: a credit entry linked to a Purchase Order is rejected."""
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
|
||||
purchase_order = create_purchase_order()
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Creditors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Supplier"
|
||||
jv.accounts[1].party = "_Test Supplier"
|
||||
jv.accounts[1].reference_type = "Purchase Order"
|
||||
jv.accounts[1].reference_name = purchase_order.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Credit entry can not be linked", jv.insert)
|
||||
|
||||
def test_validate_reference_doc_nonexistent_reference_rejected(self):
|
||||
"""Characterize: a JE referencing a non-existent invoice is rejected by link validation.
|
||||
|
||||
Note: the controller's own "Invalid reference" branch is unreachable in normal flow
|
||||
because Frappe link validation rejects the missing reference before validate_reference_doc.
|
||||
"""
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = "_Test Customer"
|
||||
jv.accounts[1].reference_type = "Sales Invoice"
|
||||
jv.accounts[1].reference_name = "NON-EXISTENT-SI"
|
||||
self.assertRaises(frappe.LinkValidationError, jv.insert)
|
||||
|
||||
def test_validate_reference_doc_invoice_party_mismatch_throws(self):
|
||||
"""Characterize: an invoice reference whose party differs from the row party is rejected."""
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
invoice = create_sales_invoice(rate=500)
|
||||
other_customer = make_customer("_Test JE Mismatch Customer")
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = other_customer
|
||||
jv.accounts[1].reference_type = "Sales Invoice"
|
||||
jv.accounts[1].reference_name = invoice.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Party / Account does not match", jv.insert)
|
||||
|
||||
def test_validate_reference_doc_order_party_mismatch_throws(self):
|
||||
"""Characterize: a Sales Order reference whose party differs from the row party is rejected."""
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
sales_order = make_sales_order()
|
||||
other_customer = make_customer("_Test JE Mismatch Customer")
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = other_customer
|
||||
jv.accounts[1].is_advance = "Yes"
|
||||
jv.accounts[1].reference_type = "Sales Order"
|
||||
jv.accounts[1].reference_name = sales_order.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "does not match", jv.insert)
|
||||
|
||||
def test_validate_reference_doc_populates_reference_side_effects(self):
|
||||
"""Characterize: a valid invoice reference populates reference_totals/types/accounts."""
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
invoice = create_sales_invoice(rate=500)
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = "_Test Customer"
|
||||
jv.accounts[1].reference_type = "Sales Invoice"
|
||||
jv.accounts[1].reference_name = invoice.name
|
||||
jv.insert()
|
||||
self.assertEqual(jv.reference_totals[invoice.name], 100.0)
|
||||
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
|
||||
self.assertEqual(jv.reference_accounts[invoice.name], "Debtors - _TC")
|
||||
|
||||
def test_get_balance_places_difference_on_blank_row(self):
|
||||
"""Characterize: get_balance puts the unbalanced difference on an amountless row."""
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
jv.company = "_Test Company"
|
||||
jv.posting_date = nowdate()
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Cash - _TC",
|
||||
"debit_in_account_currency": 100,
|
||||
"debit": 100,
|
||||
"exchange_rate": 1,
|
||||
},
|
||||
)
|
||||
jv.append("accounts", {"account": "_Test Bank - _TC", "exchange_rate": 1}) # amountless row
|
||||
jv.set_total_debit_credit()
|
||||
self.assertEqual(jv.difference, 100)
|
||||
|
||||
jv.get_balance()
|
||||
blank_row = jv.accounts[1]
|
||||
self.assertEqual(blank_row.credit_in_account_currency, 100)
|
||||
self.assertEqual(jv.total_debit, jv.total_credit)
|
||||
|
||||
def test_get_balance_recomputes_difference_ignoring_client_value(self):
|
||||
"""get_balance computes its own difference instead of trusting a stale client-sent value."""
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
jv.company = "_Test Company"
|
||||
jv.posting_date = nowdate()
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Cash - _TC",
|
||||
"debit_in_account_currency": 100,
|
||||
"debit": 100,
|
||||
"exchange_rate": 1,
|
||||
},
|
||||
)
|
||||
jv.append("accounts", {"account": "_Test Bank - _TC", "exchange_rate": 1})
|
||||
# a stale/incorrect value as the client might send; get_balance must not rely on it
|
||||
jv.difference = 0
|
||||
|
||||
jv.get_balance()
|
||||
self.assertEqual(jv.accounts[1].credit_in_account_currency, 100)
|
||||
self.assertEqual(jv.total_debit, jv.total_credit)
|
||||
self.assertEqual(jv.difference, 0)
|
||||
|
||||
def test_get_outstanding_invoices_builds_write_off_rows(self):
|
||||
"""Characterize: get_outstanding_invoices adds a party row for each outstanding invoice."""
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
invoice = create_sales_invoice(rate=700)
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
jv.company = "_Test Company"
|
||||
jv.posting_date = nowdate()
|
||||
jv.voucher_type = "Write Off Entry"
|
||||
jv.write_off_based_on = "Accounts Receivable"
|
||||
jv.write_off_amount = 1000
|
||||
jv.get_outstanding_invoices()
|
||||
|
||||
invoice_rows = [row for row in jv.accounts if row.reference_name == invoice.name]
|
||||
self.assertTrue(invoice_rows)
|
||||
self.assertEqual(invoice_rows[0].party_type, "Customer")
|
||||
self.assertEqual(invoice_rows[0].reference_type, "Sales Invoice")
|
||||
self.assertEqual(flt(invoice_rows[0].credit_in_account_currency), 700)
|
||||
|
||||
def test_unlink_advance_entry_reference_on_cancel(self):
|
||||
"""Characterize: cancelling an advance JE against an invoice clears the row's reference."""
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
invoice = create_sales_invoice(rate=700)
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
advance_row = jv.accounts[1]
|
||||
advance_row.party_type = "Customer"
|
||||
advance_row.party = "_Test Customer"
|
||||
advance_row.is_advance = "Yes"
|
||||
advance_row.reference_type = "Sales Invoice"
|
||||
advance_row.reference_name = invoice.name
|
||||
jv.submit()
|
||||
|
||||
jv.cancel()
|
||||
jv.reload()
|
||||
self.assertFalse(jv.accounts[1].reference_type)
|
||||
self.assertFalse(jv.accounts[1].reference_name)
|
||||
|
||||
def test_get_payment_entry_against_order_builds_advance_je(self):
|
||||
"""Characterize the mapper: an advance Bank Entry JE is built against an unbilled order."""
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import get_payment_entry_against_order
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
sales_order = make_sales_order()
|
||||
je = get_payment_entry_against_order("Sales Order", sales_order.name, journal_entry=True)
|
||||
|
||||
self.assertEqual(je.voucher_type, "Bank Entry")
|
||||
party_rows = [row for row in je.accounts if row.party_type == "Customer"]
|
||||
self.assertTrue(party_rows)
|
||||
self.assertEqual(party_rows[0].reference_type, "Sales Order")
|
||||
self.assertEqual(party_rows[0].reference_name, sales_order.name)
|
||||
self.assertEqual(party_rows[0].is_advance, "Yes")
|
||||
|
||||
def test_make_inter_company_journal_entry_builds_linked_draft(self):
|
||||
"""Characterize the mapper: the counterpart JE carries the company and back-reference."""
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import make_inter_company_journal_entry
|
||||
|
||||
source = make_journal_entry("_Test Cash - _TC", "_Test Bank - _TC", 100, submit=True)
|
||||
result = make_inter_company_journal_entry(
|
||||
source.name, "Inter Company Journal Entry", "_Test Company 1"
|
||||
)
|
||||
|
||||
self.assertEqual(result.get("voucher_type"), "Inter Company Journal Entry")
|
||||
self.assertEqual(result.get("company"), "_Test Company 1")
|
||||
self.assertEqual(result.get("inter_company_journal_entry_reference"), source.name)
|
||||
|
||||
|
||||
def make_journal_entry(
|
||||
account1,
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
{
|
||||
"bold": 1,
|
||||
"columns": 4,
|
||||
"fetch_from": "bank_account.account",
|
||||
"fieldname": "account",
|
||||
"fieldtype": "Link",
|
||||
"in_global_search": 1,
|
||||
|
||||
@@ -12,11 +12,10 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestLedgerHealth(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.configure_monitoring_tool()
|
||||
self.clear_old_entries()
|
||||
|
||||
def configure_monitoring_tool(self):
|
||||
monitor_settings = frappe.get_doc("Ledger Health Monitor")
|
||||
|
||||
@@ -52,11 +52,12 @@ class ModeofPayment(Document):
|
||||
|
||||
def validate_pos_mode_of_payment(self):
|
||||
if not self.enabled:
|
||||
pos_profiles = frappe.get_all(
|
||||
"Sales Invoice Payment",
|
||||
filters={"parenttype": "POS Profile", "mode_of_payment": self.name},
|
||||
pluck="parent",
|
||||
pos_profiles = frappe.db.sql(
|
||||
"""SELECT sip.parent FROM `tabSales Invoice Payment` sip
|
||||
WHERE sip.parenttype = 'POS Profile' and sip.mode_of_payment = %s""",
|
||||
(self.name),
|
||||
)
|
||||
pos_profiles = list(map(lambda x: x[0], pos_profiles))
|
||||
|
||||
if pos_profiles:
|
||||
message = _(
|
||||
|
||||
@@ -270,13 +270,6 @@ def start_import(invoices):
|
||||
errors = 0
|
||||
names = []
|
||||
for idx, d in enumerate(invoices):
|
||||
# Scope each invoice to a savepoint so a failure only undoes that invoice.
|
||||
# A plain rollback() would discard the whole transaction — including invoices
|
||||
# imported earlier in this batch and the error logs of earlier failures (the
|
||||
# latter only survive on mariadb because the Error Log table is MyISAM; on
|
||||
# postgres they would be lost). Rolling back to a savepoint keeps both.
|
||||
savepoint = f"opening_invoice_{frappe.generate_hash(length=8)}"
|
||||
frappe.db.savepoint(savepoint)
|
||||
try:
|
||||
invoice_number = None
|
||||
if d.invoice_number:
|
||||
@@ -291,7 +284,7 @@ def start_import(invoices):
|
||||
names.append(doc.name)
|
||||
except Exception:
|
||||
errors += 1
|
||||
frappe.db.rollback(save_point=savepoint)
|
||||
frappe.db.rollback()
|
||||
doc.log_error("Opening invoice creation failed")
|
||||
if errors:
|
||||
frappe.msgprint(
|
||||
|
||||
@@ -9,8 +9,8 @@ import frappe
|
||||
from frappe import ValidationError, _, qb, scrub, throw
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.query_builder import Case, Tuple
|
||||
from frappe.query_builder.functions import Abs, Count, Max
|
||||
from frappe.query_builder import Tuple
|
||||
from frappe.query_builder.functions import Count
|
||||
from frappe.utils import cint, comma_or, flt, getdate, nowdate
|
||||
from frappe.utils.data import comma_and, fmt_money, get_link_to_form
|
||||
from pypika.functions import Coalesce, Sum
|
||||
@@ -766,19 +766,13 @@ class PaymentEntry(AccountsController):
|
||||
def validate_journal_entry(self):
|
||||
for d in self.get("references"):
|
||||
if d.allocated_amount and d.reference_doctype == "Journal Entry":
|
||||
je_accounts = frappe.get_all(
|
||||
"Journal Entry Account",
|
||||
filters={
|
||||
"account": self.party_account,
|
||||
"party": self.party,
|
||||
"docstatus": 1,
|
||||
"parent": d.reference_name,
|
||||
},
|
||||
or_filters=[
|
||||
["reference_type", "is", "not set"],
|
||||
["reference_type", "in", ["Sales Order", "Purchase Order"]],
|
||||
],
|
||||
fields=["debit", "credit"],
|
||||
je_accounts = frappe.db.sql(
|
||||
"""select debit, credit from `tabJournal Entry Account`
|
||||
where account = %s and party=%s and docstatus = 1 and parent = %s
|
||||
and (reference_type is null or reference_type in ("", "Sales Order", "Purchase Order"))
|
||||
""",
|
||||
(self.party_account, self.party, d.reference_name),
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
if not je_accounts:
|
||||
@@ -863,17 +857,27 @@ class PaymentEntry(AccountsController):
|
||||
)
|
||||
base_outstanding = flt(allocated_amount * conversion_rate, base_outstanding_precision)
|
||||
|
||||
ps = frappe.qb.DocType("Payment Schedule")
|
||||
if cancel:
|
||||
(
|
||||
frappe.qb.update(ps)
|
||||
.set(ps.paid_amount, ps.paid_amount - (allocated_amount - discounted_amt))
|
||||
.set(ps.base_paid_amount, ps.base_paid_amount - base_paid_amount)
|
||||
.set(ps.discounted_amount, ps.discounted_amount - discounted_amt)
|
||||
.set(ps.outstanding, ps.outstanding + allocated_amount)
|
||||
.set(ps.base_outstanding, ps.base_outstanding - base_outstanding)
|
||||
.where((ps.parent == key[1]) & (ps.payment_term == key[0]))
|
||||
).run()
|
||||
frappe.db.sql(
|
||||
"""
|
||||
UPDATE `tabPayment Schedule`
|
||||
SET
|
||||
paid_amount = `paid_amount` - %s,
|
||||
base_paid_amount = `base_paid_amount` - %s,
|
||||
discounted_amount = `discounted_amount` - %s,
|
||||
outstanding = `outstanding` + %s,
|
||||
base_outstanding = `base_outstanding` - %s
|
||||
WHERE parent = %s and payment_term = %s""",
|
||||
(
|
||||
allocated_amount - discounted_amt,
|
||||
base_paid_amount,
|
||||
discounted_amt,
|
||||
allocated_amount,
|
||||
base_outstanding,
|
||||
key[1],
|
||||
key[0],
|
||||
),
|
||||
)
|
||||
else:
|
||||
if allocated_amount > outstanding:
|
||||
frappe.throw(
|
||||
@@ -883,15 +887,26 @@ class PaymentEntry(AccountsController):
|
||||
)
|
||||
|
||||
if allocated_amount and outstanding:
|
||||
(
|
||||
frappe.qb.update(ps)
|
||||
.set(ps.paid_amount, ps.paid_amount + (allocated_amount - discounted_amt))
|
||||
.set(ps.base_paid_amount, ps.base_paid_amount + base_paid_amount)
|
||||
.set(ps.discounted_amount, ps.discounted_amount + discounted_amt)
|
||||
.set(ps.outstanding, ps.outstanding - allocated_amount)
|
||||
.set(ps.base_outstanding, ps.base_outstanding - base_outstanding)
|
||||
.where((ps.parent == key[1]) & (ps.payment_term == key[0]))
|
||||
).run()
|
||||
frappe.db.sql(
|
||||
"""
|
||||
UPDATE `tabPayment Schedule`
|
||||
SET
|
||||
paid_amount = `paid_amount` + %s,
|
||||
base_paid_amount = `base_paid_amount` + %s,
|
||||
discounted_amount = `discounted_amount` + %s,
|
||||
outstanding = `outstanding` - %s,
|
||||
base_outstanding = `base_outstanding` - %s
|
||||
WHERE parent = %s and payment_term = %s""",
|
||||
(
|
||||
allocated_amount - discounted_amt,
|
||||
base_paid_amount,
|
||||
discounted_amt,
|
||||
allocated_amount,
|
||||
base_outstanding,
|
||||
key[1],
|
||||
key[0],
|
||||
),
|
||||
)
|
||||
|
||||
def get_allocated_amount_in_transaction_currency(
|
||||
self, allocated_amount, reference_doctype, reference_docname
|
||||
@@ -1201,7 +1216,11 @@ class PaymentEntry(AccountsController):
|
||||
# Clear the reference document which doesn't have allocated amount on validate so that form can be loaded fast
|
||||
def clear_unallocated_reference_document_rows(self):
|
||||
self.set("references", self.get("references", {"allocated_amount": ["not in", [0, None, ""]]}))
|
||||
frappe.db.delete("Payment Entry Reference", {"parent": self.name, "allocated_amount": 0})
|
||||
frappe.db.sql(
|
||||
"""delete from `tabPayment Entry Reference`
|
||||
where parent = %s and allocated_amount = 0""",
|
||||
self.name,
|
||||
)
|
||||
|
||||
def set_title(self):
|
||||
if frappe.flags.in_import and self.title:
|
||||
@@ -1857,7 +1876,7 @@ def get_matched_payment_request_of_references(references=None):
|
||||
PR.reference_doctype,
|
||||
PR.reference_name,
|
||||
PR.outstanding_amount.as_("allocated_amount"),
|
||||
Max(PR.name).as_("payment_request"), # count == 1 below ⇒ one row per group; postgres-safe
|
||||
PR.name.as_("payment_request"),
|
||||
Count("*").as_("count"),
|
||||
)
|
||||
.where(Tuple(PR.reference_doctype, PR.reference_name, PR.outstanding_amount).isin(refs))
|
||||
@@ -2017,9 +2036,6 @@ def get_outstanding_reference_documents(args: str | dict, validate: bool = False
|
||||
if args.get("party_type") == "Member":
|
||||
return
|
||||
|
||||
if args.get("party_type") and args.get("party"):
|
||||
frappe.has_permission(args["party_type"], "read", args["party"], throw=True)
|
||||
|
||||
if not args.get("get_outstanding_invoices") and not args.get("get_orders_to_be_billed"):
|
||||
args["get_outstanding_invoices"] = True
|
||||
|
||||
@@ -2296,7 +2312,12 @@ def get_orders_to_be_billed(
|
||||
if not voucher_type:
|
||||
return []
|
||||
|
||||
# dynamic dimension filters
|
||||
condition = ""
|
||||
active_dimensions = get_dimensions(True)[0]
|
||||
for dim in active_dimensions:
|
||||
if filters.get(dim.fieldname):
|
||||
condition += f" and {dim.fieldname}={frappe.db.escape(filters.get(dim.fieldname))}"
|
||||
|
||||
if party_account_currency == company_currency:
|
||||
grand_total_field = "base_grand_total"
|
||||
@@ -2305,38 +2326,38 @@ def get_orders_to_be_billed(
|
||||
grand_total_field = "grand_total"
|
||||
rounded_total_field = "rounded_total"
|
||||
|
||||
voucher = frappe.qb.DocType(voucher_type)
|
||||
invoice_amount = (
|
||||
Case()
|
||||
.when(voucher[rounded_total_field] != 0, voucher[rounded_total_field])
|
||||
.else_(voucher[grand_total_field])
|
||||
orders = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
name as voucher_no,
|
||||
if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) as invoice_amount,
|
||||
(if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) - advance_paid) as outstanding_amount,
|
||||
transaction_date as posting_date
|
||||
from
|
||||
`tab{voucher_type}`
|
||||
where
|
||||
{party_type} = %s
|
||||
and docstatus = 1
|
||||
and company = %s
|
||||
and status != "Closed"
|
||||
and if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) > advance_paid
|
||||
and abs(100 - per_billed) > 0.01
|
||||
{condition}
|
||||
order by
|
||||
transaction_date, name
|
||||
""".format(
|
||||
**{
|
||||
"rounded_total_field": rounded_total_field,
|
||||
"grand_total_field": grand_total_field,
|
||||
"voucher_type": voucher_type,
|
||||
"party_type": scrub(party_type),
|
||||
"condition": condition,
|
||||
}
|
||||
),
|
||||
(party, company),
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(voucher)
|
||||
.select(
|
||||
voucher.name.as_("voucher_no"),
|
||||
invoice_amount.as_("invoice_amount"),
|
||||
(invoice_amount - voucher.advance_paid).as_("outstanding_amount"),
|
||||
voucher.transaction_date.as_("posting_date"),
|
||||
)
|
||||
.where(
|
||||
(voucher[scrub(party_type)] == party)
|
||||
& (voucher.docstatus == 1)
|
||||
& (voucher.company == company)
|
||||
& (voucher.status != "Closed")
|
||||
& (invoice_amount > voucher.advance_paid)
|
||||
& (Abs(100 - voucher.per_billed) > 0.01)
|
||||
)
|
||||
)
|
||||
|
||||
# dynamic dimension filters
|
||||
for dim in active_dimensions:
|
||||
if filters.get(dim.fieldname):
|
||||
query = query.where(voucher[dim.fieldname] == filters.get(dim.fieldname))
|
||||
|
||||
orders = query.orderby(voucher.transaction_date).orderby(voucher.name).run(as_dict=True)
|
||||
|
||||
order_list = []
|
||||
for d in orders:
|
||||
if (
|
||||
@@ -2385,8 +2406,8 @@ def get_negative_outstanding_invoices(
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
'{voucher_type}' as voucher_type, name as voucher_no, {account} as account,
|
||||
coalesce(nullif({rounded_total_field}, 0), {grand_total_field}) as invoice_amount,
|
||||
"{voucher_type}" as voucher_type, name as voucher_no, {account} as account,
|
||||
if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) as invoice_amount,
|
||||
outstanding_amount, posting_date,
|
||||
due_date, conversion_rate as exchange_rate
|
||||
from
|
||||
@@ -2510,7 +2531,6 @@ def get_reference_details(
|
||||
):
|
||||
total_amount = outstanding_amount = exchange_rate = account = None
|
||||
|
||||
frappe.has_permission(reference_doctype, "read", reference_name, throw=True)
|
||||
ref_doc = frappe.get_lazy_doc(reference_doctype, reference_name)
|
||||
company_currency = ref_doc.get("company_currency") or erpnext.get_company_currency(ref_doc.company)
|
||||
|
||||
@@ -2756,7 +2776,7 @@ def get_payment_entry(
|
||||
pe, doc, discount_amount, base_total_discount_loss, party_account_currency
|
||||
)
|
||||
|
||||
pe.set_exchange_rate()
|
||||
pe.set_exchange_rate(ref_doc=doc)
|
||||
pe.set_amounts()
|
||||
|
||||
# If PE is created from PR directly, then no need to find open PRs for the references
|
||||
@@ -3248,28 +3268,27 @@ def get_reference_as_per_payment_terms(
|
||||
|
||||
|
||||
def get_paid_amount(dt, dn, party_type, party, account, due_date):
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
if party_type == "Customer":
|
||||
dr_or_cr = gle.credit_in_account_currency - gle.debit_in_account_currency
|
||||
dr_or_cr = "credit_in_account_currency - debit_in_account_currency"
|
||||
else:
|
||||
dr_or_cr = gle.debit_in_account_currency - gle.credit_in_account_currency
|
||||
dr_or_cr = "debit_in_account_currency - credit_in_account_currency"
|
||||
|
||||
paid_amount = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(Sum(dr_or_cr))
|
||||
.where(
|
||||
(gle.against_voucher_type == dt)
|
||||
& (gle.against_voucher == dn)
|
||||
& (gle.party_type == party_type)
|
||||
& (gle.party == party)
|
||||
& (gle.account == account)
|
||||
& (gle.due_date == due_date)
|
||||
& (dr_or_cr > 0)
|
||||
)
|
||||
.run()
|
||||
paid_amount = frappe.db.sql(
|
||||
f"""
|
||||
select ifnull(sum({dr_or_cr}), 0) as paid_amount
|
||||
from `tabGL Entry`
|
||||
where against_voucher_type = %s
|
||||
and against_voucher = %s
|
||||
and party_type = %s
|
||||
and party = %s
|
||||
and account = %s
|
||||
and due_date = %s
|
||||
and {dr_or_cr} > 0
|
||||
""",
|
||||
(dt, dn, party_type, party, account, due_date),
|
||||
)
|
||||
|
||||
return (paid_amount[0][0] or 0) if paid_amount else 0
|
||||
return paid_amount[0][0] if paid_amount else 0
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -34,14 +34,8 @@ class PaymentEntryGLComposer(BaseGLComposer):
|
||||
self.add_deductions_gl_entries(gl_entries)
|
||||
self.add_tax_gl_entries(gl_entries)
|
||||
add_regional_gl_entries(gl_entries, doc)
|
||||
self.set_transaction_currency_and_rate_in_gl_map(gl_entries, doc)
|
||||
return gl_entries
|
||||
|
||||
def set_transaction_currency_and_rate_in_gl_map(self, gl_entries, doc):
|
||||
for gle in gl_entries:
|
||||
gle.setdefault("transaction_currency", doc.transaction_currency)
|
||||
gle.setdefault("transaction_exchange_rate", doc.transaction_exchange_rate)
|
||||
|
||||
def add_party_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
if not doc.party_account:
|
||||
|
||||
@@ -532,8 +532,6 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
si.submit()
|
||||
|
||||
pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC", bank_amount=4700)
|
||||
pe.source_exchange_rate = 50
|
||||
pe.set_amounts()
|
||||
pe.reference_no = si.name
|
||||
pe.reference_date = nowdate()
|
||||
|
||||
@@ -609,8 +607,6 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
pe = get_payment_entry(
|
||||
"Sales Invoice", si.name, party_amount=20, bank_account="_Test Bank - _TC", bank_amount=900
|
||||
)
|
||||
pe.source_exchange_rate = 50
|
||||
pe.set_amounts()
|
||||
pe.reference_no = "1"
|
||||
pe.reference_date = "2016-01-01"
|
||||
|
||||
@@ -1037,17 +1033,14 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
gle.credit_in_account_currency,
|
||||
gle.debit_in_transaction_currency,
|
||||
gle.credit_in_transaction_currency,
|
||||
gle.transaction_currency,
|
||||
gle.transaction_exchange_rate,
|
||||
)
|
||||
.orderby(gle.account)
|
||||
.where(gle.voucher_no == payment_entry.name)
|
||||
.run()
|
||||
)
|
||||
# transaction currency/rate come from the paid-from USD account (company currency is INR)
|
||||
expected_gl_entries = (
|
||||
(paid_from, 0.0, 8440.0, 0.0, 100.0, 0.0, 100.0, "USD", 84.4),
|
||||
("_Test Payable USD - _TC", 8440.0, 0.0, 100.0, 0.0, 100.0, 0.0, "USD", 84.4),
|
||||
(paid_from, 0.0, 8440.0, 0.0, 100.0, 0.0, 100.0),
|
||||
("_Test Payable USD - _TC", 8440.0, 0.0, 100.0, 0.0, 100.0, 0.0),
|
||||
)
|
||||
self.assertEqual(gl_entries, expected_gl_entries)
|
||||
|
||||
|
||||
@@ -10,22 +10,76 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestPaymentLedgerEntry(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.ple = qb.DocType("Payment Ledger Entry")
|
||||
self.company = "_Test Company"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.warehouse = "Stores - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.expense_account = "Cost of Goods Sold - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.creditors = "Creditors - _TC"
|
||||
self.bank = "Cash - _TC"
|
||||
self.item = "_Test Item"
|
||||
self.customer = "_Test Customer"
|
||||
self.create_company()
|
||||
self.create_item()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
|
||||
def create_company(self):
|
||||
company_name = "_Test Payment Ledger"
|
||||
company = None
|
||||
if frappe.db.exists("Company", company_name):
|
||||
company = frappe.get_doc("Company", company_name)
|
||||
else:
|
||||
company = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Company",
|
||||
"company_name": company_name,
|
||||
"country": "India",
|
||||
"default_currency": "INR",
|
||||
"create_chart_of_accounts_based_on": "Standard Template",
|
||||
"chart_of_accounts": "Standard",
|
||||
}
|
||||
)
|
||||
company = company.save()
|
||||
|
||||
self.company = company.name
|
||||
self.cost_center = company.cost_center
|
||||
self.warehouse = "All Warehouses - _PL"
|
||||
self.income_account = "Sales - _PL"
|
||||
self.expense_account = "Cost of Goods Sold - _PL"
|
||||
self.debit_to = "Debtors - _PL"
|
||||
self.creditors = "Creditors - _PL"
|
||||
|
||||
# create bank account
|
||||
if frappe.db.exists("Account", "HDFC - _PL"):
|
||||
self.bank = "HDFC - _PL"
|
||||
else:
|
||||
bank_acc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Account",
|
||||
"account_name": "HDFC",
|
||||
"parent_account": "Bank Accounts - _PL",
|
||||
"company": self.company,
|
||||
}
|
||||
)
|
||||
bank_acc.save()
|
||||
self.bank = bank_acc.name
|
||||
|
||||
def create_item(self):
|
||||
item_name = "_Test PL Item"
|
||||
item = create_item(
|
||||
item_code=item_name, is_stock_item=0, company=self.company, warehouse=self.warehouse
|
||||
)
|
||||
self.item = item if isinstance(item, str) else item.item_code
|
||||
|
||||
def create_customer(self):
|
||||
name = "_Test PL Customer"
|
||||
if frappe.db.exists("Customer", name):
|
||||
self.customer = name
|
||||
else:
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = name
|
||||
customer.type = "Individual"
|
||||
customer.save()
|
||||
self.customer = customer.name
|
||||
|
||||
def create_sales_invoice(
|
||||
self, qty=1, rate=100, posting_date=None, do_not_save=False, do_not_submit=False
|
||||
@@ -98,6 +152,18 @@ class TestPaymentLedgerEntry(ERPNextTestSuite):
|
||||
)
|
||||
return so
|
||||
|
||||
def clear_old_entries(self):
|
||||
doctype_list = [
|
||||
"GL Entry",
|
||||
"Payment Ledger Entry",
|
||||
"Sales Invoice",
|
||||
"Purchase Invoice",
|
||||
"Payment Entry",
|
||||
"Journal Entry",
|
||||
]
|
||||
for doctype in doctype_list:
|
||||
qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
|
||||
|
||||
def create_journal_entry(self, acc1=None, acc2=None, amount=0, posting_date=None, cost_center=None):
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.posting_date = posting_date or nowdate()
|
||||
|
||||
@@ -60,32 +60,23 @@ class PaymentOrder(Document):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_mop_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
return frappe.get_all(
|
||||
"Payment Order Reference",
|
||||
filters={"parent": filters.get("parent"), "mode_of_payment": ["like", f"%{txt}%"]},
|
||||
fields=["mode_of_payment"],
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
order_by="", # match the original query (no ORDER BY); avoid get_all's default sort
|
||||
as_list=True,
|
||||
return frappe.db.sql(
|
||||
""" select mode_of_payment from `tabPayment Order Reference`
|
||||
where parent = %(parent)s and mode_of_payment like %(txt)s
|
||||
limit %(page_len)s offset %(start)s""",
|
||||
{"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt},
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_supplier_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
return frappe.get_all(
|
||||
"Payment Order Reference",
|
||||
filters={
|
||||
"parent": filters.get("parent"),
|
||||
"supplier": ["like", f"%{txt}%"],
|
||||
"payment_reference": ["is", "not set"],
|
||||
},
|
||||
fields=["supplier"],
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
order_by="", # match the original query (no ORDER BY); avoid get_all's default sort
|
||||
as_list=True,
|
||||
return frappe.db.sql(
|
||||
""" select supplier from `tabPayment Order Reference`
|
||||
where parent = %(parent)s and supplier like %(txt)s and
|
||||
(payment_reference is null or payment_reference='')
|
||||
limit %(page_len)s offset %(start)s""",
|
||||
{"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -11,12 +11,11 @@ from erpnext import get_company_currency
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
)
|
||||
from erpnext.accounts.doctype.bank_account.bank_account import get_party_bank_account
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import (
|
||||
get_payment_entry,
|
||||
)
|
||||
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.party import get_party_account, get_party_bank_account
|
||||
from erpnext.accounts.utils import get_account_currency, get_advance_payment_doctypes, get_currency_precision
|
||||
from erpnext.utilities import payment_app_import_guard
|
||||
|
||||
@@ -629,9 +628,11 @@ class PaymentRequest(Document):
|
||||
|
||||
def check_if_payment_entry_exists(self):
|
||||
if self.status == "Paid":
|
||||
if frappe.db.exists(
|
||||
if frappe.get_all(
|
||||
"Payment Entry Reference",
|
||||
{"reference_name": self.reference_name, "docstatus": ["<", 2]},
|
||||
filters={"reference_name": self.reference_name, "docstatus": ["<", 2]},
|
||||
fields=["parent"],
|
||||
limit=1,
|
||||
):
|
||||
frappe.throw(_("Payment Entry already exists"), title=_("Error"))
|
||||
|
||||
@@ -1210,11 +1211,10 @@ def get_dummy_message(doc):
|
||||
@frappe.whitelist()
|
||||
def get_subscription_details(reference_doctype: str, reference_name: str):
|
||||
if reference_doctype == "Sales Invoice":
|
||||
subscriptions = frappe.get_all(
|
||||
"Subscription Invoice",
|
||||
filters={"invoice": reference_name},
|
||||
fields=["parent as sub_name"],
|
||||
order_by="", # match the original query (no ORDER BY); avoid get_all's default sort
|
||||
subscriptions = frappe.db.sql(
|
||||
"""SELECT parent as sub_name FROM `tabSubscription Invoice` WHERE invoice=%s""",
|
||||
reference_name,
|
||||
as_dict=1,
|
||||
)
|
||||
subscription_plans = []
|
||||
for subscription in subscriptions:
|
||||
|
||||
@@ -332,12 +332,7 @@ class TestPaymentRequest(ERPNextTestSuite):
|
||||
return_doc=1,
|
||||
)
|
||||
|
||||
pe = pr.create_payment_entry(submit=False)
|
||||
pe.source_exchange_rate = 50
|
||||
pe.target_exchange_rate = 50
|
||||
pe.set_amounts()
|
||||
pe.insert(ignore_permissions=True)
|
||||
pe.submit()
|
||||
pe = pr.set_as_paid()
|
||||
|
||||
expected_gle = dict(
|
||||
(d[0], d)
|
||||
@@ -423,12 +418,7 @@ class TestPaymentRequest(ERPNextTestSuite):
|
||||
pr = make_payment_request(dt=po_doc.doctype, dn=po_doc.name, recipient_id="nabin@erpnext.com")
|
||||
pr = frappe.get_doc(pr).save().submit()
|
||||
|
||||
pe = pr.create_payment_entry(submit=False)
|
||||
pe.target_exchange_rate = 80
|
||||
pe.paid_amount = 800
|
||||
pe.set_amounts()
|
||||
pe.insert(ignore_permissions=True)
|
||||
pe.submit()
|
||||
pe = pr.create_payment_entry()
|
||||
self.assertEqual(pe.base_paid_amount, 800)
|
||||
self.assertEqual(pe.paid_amount, 800)
|
||||
self.assertEqual(pe.base_received_amount, 800)
|
||||
|
||||
@@ -18,6 +18,7 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 1)
|
||||
|
||||
def test_closing_entry(self):
|
||||
company = create_company()
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
|
||||
jv1 = make_journal_entry(
|
||||
@@ -26,10 +27,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center,
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
jv1.company = "Test PCV Company"
|
||||
jv1.company = company
|
||||
jv1.save()
|
||||
jv1.submit()
|
||||
|
||||
@@ -39,10 +40,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cost of Goods Sold - TPC",
|
||||
account2="Cash - TPC",
|
||||
cost_center=cost_center,
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
jv2.company = "Test PCV Company"
|
||||
jv2.company = company
|
||||
jv2.save()
|
||||
jv2.submit()
|
||||
|
||||
@@ -66,13 +67,14 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
self.assertEqual(pcv_gle, expected_gle)
|
||||
|
||||
def test_cost_center_wise_posting(self):
|
||||
company = create_company()
|
||||
surplus_account = create_account()
|
||||
|
||||
cost_center1 = create_cost_center("Main")
|
||||
cost_center2 = create_cost_center("Western Branch")
|
||||
|
||||
create_sales_invoice(
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
cost_center=cost_center1,
|
||||
income_account="Sales - TPC",
|
||||
expense_account="Cost of Goods Sold - TPC",
|
||||
@@ -83,7 +85,7 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
create_sales_invoice(
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
cost_center=cost_center2,
|
||||
income_account="Sales - TPC",
|
||||
expense_account="Cost of Goods Sold - TPC",
|
||||
@@ -128,11 +130,12 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
def test_period_closing_with_finance_book_entries(self):
|
||||
company = create_company()
|
||||
surplus_account = create_account()
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
|
||||
create_sales_invoice(
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
income_account="Sales - TPC",
|
||||
expense_account="Cost of Goods Sold - TPC",
|
||||
cost_center=cost_center,
|
||||
@@ -149,9 +152,9 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
amount=400,
|
||||
cost_center=cost_center,
|
||||
posting_date="2021-03-15",
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
)
|
||||
jv.company = "Test PCV Company"
|
||||
jv.company = company
|
||||
jv.finance_book = create_finance_book().name
|
||||
jv.save()
|
||||
jv.submit()
|
||||
@@ -178,6 +181,7 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
self.assertSequenceEqual(pcv_gle, expected_gle)
|
||||
|
||||
def test_gl_entries_restrictions(self):
|
||||
company = create_company()
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
|
||||
self.make_period_closing_voucher(posting_date="2021-03-31")
|
||||
@@ -188,15 +192,16 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center,
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
jv1.company = "Test PCV Company"
|
||||
jv1.company = company
|
||||
jv1.save()
|
||||
|
||||
self.assertRaises(frappe.ValidationError, jv1.submit)
|
||||
|
||||
def test_closing_balance_with_dimensions_and_test_reposting_entry(self):
|
||||
company = create_company()
|
||||
cost_center1 = create_cost_center("Test Cost Center 1")
|
||||
cost_center2 = create_cost_center("Test Cost Center 2")
|
||||
|
||||
@@ -206,10 +211,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center1,
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
jv1.company = "Test PCV Company"
|
||||
jv1.company = company
|
||||
jv1.save()
|
||||
jv1.submit()
|
||||
|
||||
@@ -219,10 +224,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center2,
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
jv2.company = "Test PCV Company"
|
||||
jv2.company = company
|
||||
jv2.save()
|
||||
jv2.submit()
|
||||
|
||||
@@ -249,11 +254,11 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center2,
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
|
||||
jv3.company = "Test PCV Company"
|
||||
jv3.company = company
|
||||
jv3.save()
|
||||
jv3.submit()
|
||||
|
||||
@@ -288,12 +293,12 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
self.assertEqual(cc2_closing_balance.credit, 500)
|
||||
self.assertEqual(cc2_closing_balance.credit_in_account_currency, 500)
|
||||
|
||||
warehouse = frappe.db.get_value("Warehouse", {"company": "Test PCV Company"}, "name")
|
||||
warehouse = frappe.db.get_value("Warehouse", {"company": company}, "name")
|
||||
|
||||
repost_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Repost Item Valuation",
|
||||
"company": "Test PCV Company",
|
||||
"company": company,
|
||||
"posting_date": "2020-03-15",
|
||||
"based_on": "Item and Warehouse",
|
||||
"item_code": "Test Item 1",
|
||||
@@ -334,6 +339,7 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
{"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(
|
||||
@@ -342,10 +348,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center,
|
||||
company="Test PCV Company",
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
jv.company = "Test PCV Company"
|
||||
jv.company = company
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
@@ -371,6 +377,19 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)
|
||||
|
||||
|
||||
def create_company():
|
||||
company = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Company",
|
||||
"company_name": "Test PCV Company",
|
||||
"country": "United States",
|
||||
"default_currency": "USD",
|
||||
}
|
||||
)
|
||||
company.insert(ignore_if_duplicate=True)
|
||||
return company.name
|
||||
|
||||
|
||||
def create_account():
|
||||
account = frappe.get_doc(
|
||||
{
|
||||
|
||||
@@ -21,7 +21,6 @@ from erpnext.accounts.doctype.sales_invoice.services.loyalty import LoyaltyServi
|
||||
from erpnext.accounts.party import get_due_date, get_party_account
|
||||
from erpnext.controllers.queries import item_query as _item_query
|
||||
from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.stock_ledger import is_negative_stock_allowed
|
||||
|
||||
@@ -404,7 +403,7 @@ class POSInvoice(SalesInvoice):
|
||||
|
||||
for d in self.get("items"):
|
||||
if not d.serial_and_batch_bundle:
|
||||
if get_active_product_bundle(d.item_code):
|
||||
if frappe.db.exists("Product Bundle", d.item_code):
|
||||
(
|
||||
availability,
|
||||
is_stock_item,
|
||||
@@ -917,7 +916,7 @@ def get_stock_availability(item_code: str | None, warehouse: str):
|
||||
return bin_qty - pos_sales_qty, is_stock_item, is_negative_stock_allowed(item_code=item_code)
|
||||
else:
|
||||
is_stock_item = True
|
||||
if get_active_product_bundle(item_code):
|
||||
if frappe.db.exists("Product Bundle", {"name": item_code, "disabled": 0}):
|
||||
return get_bundle_availability(item_code, warehouse), is_stock_item, False
|
||||
else:
|
||||
is_stock_item = False
|
||||
@@ -927,7 +926,7 @@ def get_stock_availability(item_code: str | None, warehouse: str):
|
||||
|
||||
def get_product_bundle_stock_availability(item_code, warehouse, item_qty):
|
||||
is_stock_item = True
|
||||
bundle = frappe.get_doc("Product Bundle", get_active_product_bundle(item_code))
|
||||
bundle = frappe.get_doc("Product Bundle", item_code)
|
||||
availabilities = []
|
||||
for bundle_item in bundle.items:
|
||||
if frappe.get_value("Item", bundle_item.item_code, "is_stock_item"):
|
||||
@@ -946,7 +945,7 @@ def get_product_bundle_stock_availability(item_code, warehouse, item_qty):
|
||||
|
||||
|
||||
def get_bundle_availability(bundle_item_code, warehouse):
|
||||
product_bundle = frappe.get_doc("Product Bundle", get_active_product_bundle(bundle_item_code))
|
||||
product_bundle = frappe.get_doc("Product Bundle", bundle_item_code)
|
||||
|
||||
bundle_bin_qty = 1000000
|
||||
for item in product_bundle.items:
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
"barcode",
|
||||
"has_item_scanned",
|
||||
"item_code",
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"col_break1",
|
||||
"item_name",
|
||||
"customer_item_code",
|
||||
@@ -127,23 +125,6 @@
|
||||
"options": "Item",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_product_bundle",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_product_bundle",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only_depends_on": "eval:doc.so_detail"
|
||||
},
|
||||
{
|
||||
"fieldname": "col_break1",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -877,7 +858,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-08 20:00:00.000000",
|
||||
"modified": "2026-04-20 16:16:12.322024",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice Item",
|
||||
|
||||
@@ -553,8 +553,7 @@ def process_individual_date(docname: str, date, report_type, parentfield):
|
||||
Sum(gle.credit).as_("credit"),
|
||||
Sum(gle.debit_in_account_currency).as_("debit_in_account_currency"),
|
||||
Sum(gle.credit_in_account_currency).as_("credit_in_account_currency"),
|
||||
# account_currency is constant per grouped account -> Max() keeps the GROUP BY postgres-valid
|
||||
Max(gle.account_currency).as_("account_currency"),
|
||||
gle.account_currency,
|
||||
).where(
|
||||
(gle.company.eq(company))
|
||||
& (gle.is_cancelled.eq(0))
|
||||
|
||||
@@ -101,7 +101,6 @@ 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."))
|
||||
@@ -579,7 +578,6 @@ def send_emails(document_name: str, from_scheduler: bool = False, posting_date:
|
||||
|
||||
@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},
|
||||
|
||||
@@ -25,8 +25,10 @@ class TestProcessStatementOfAccounts(ERPNextTestSuite, AccountsTestMixin):
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
self.company = "_Test Company"
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_customer(customer_name="Other Customer")
|
||||
self.clear_old_entries()
|
||||
self.si = create_sales_invoice()
|
||||
create_sales_invoice(customer="Other Customer")
|
||||
|
||||
|
||||
@@ -614,12 +614,10 @@
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:doc.items.every((item) => !item.pr_detail)",
|
||||
"description": "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Purchase Receipt is created separately.",
|
||||
"fieldname": "update_stock",
|
||||
"fieldtype": "Check",
|
||||
"label": "Update Stock",
|
||||
"print_hide": 1,
|
||||
"show_description_on_click": 1
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "scan_barcode",
|
||||
@@ -1692,7 +1690,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-13 18:36:46.704623",
|
||||
"modified": "2026-05-28 12:36:55.215363",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -653,9 +653,6 @@ class PurchaseInvoice(BuyingController):
|
||||
|
||||
self.process_common_party_accounting()
|
||||
|
||||
if self.is_return:
|
||||
self.refresh_subscription_status()
|
||||
|
||||
def on_update_after_submit(self):
|
||||
fields_to_check = [
|
||||
"cash_bank_account",
|
||||
@@ -775,8 +772,6 @@ class PurchaseInvoice(BuyingController):
|
||||
"Tax Withholding Entry",
|
||||
)
|
||||
|
||||
self.refresh_subscription_status()
|
||||
|
||||
def update_project(self):
|
||||
projects = frappe._dict()
|
||||
for d in self.items:
|
||||
@@ -939,9 +934,9 @@ def make_regional_gl_entries(gl_entries, doc):
|
||||
|
||||
@frappe.whitelist()
|
||||
def change_release_date(name: str, release_date: str | None = None):
|
||||
pi = frappe.get_lazy_doc("Purchase Invoice", name)
|
||||
pi.check_permission()
|
||||
pi.db_set("release_date", release_date)
|
||||
if frappe.db.exists("Purchase Invoice", name):
|
||||
pi = frappe.get_lazy_doc("Purchase Invoice", name)
|
||||
pi.db_set("release_date", release_date)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -51,6 +51,16 @@ class ExpenseAccountService:
|
||||
if doc.update_stock and item.warehouse and (not item.from_warehouse):
|
||||
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
|
||||
|
||||
if for_validate and item.expense_account and item.expense_account != _inv_dict["account"]:
|
||||
msg = _(
|
||||
"Row {0}: Expense Head changed to {1} because account {2} is not linked to warehouse {3} or it is not the default inventory account"
|
||||
).format(
|
||||
item.idx,
|
||||
frappe.bold(_inv_dict["account"]),
|
||||
frappe.bold(item.expense_account),
|
||||
frappe.bold(item.warehouse),
|
||||
)
|
||||
frappe.msgprint(msg, title=_("Expense Head Changed"))
|
||||
item.expense_account = _inv_dict["account"]
|
||||
else:
|
||||
# check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not
|
||||
|
||||
@@ -886,10 +886,8 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Product Bundle version this row was packed from",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only": 1
|
||||
@@ -1010,7 +1008,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-08 21:00:00.000000",
|
||||
"modified": "2026-05-06 08:08:40.782395",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice Item",
|
||||
|
||||
@@ -158,7 +158,6 @@ def start_repost(account_repost_doc: str | None = None) -> None:
|
||||
frappe.flags.through_repost_accounting_ledger = True
|
||||
if account_repost_doc:
|
||||
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
|
||||
repost_doc.check_permission("write")
|
||||
|
||||
if repost_doc.docstatus == 1:
|
||||
# Prevent repost on invoices with deferred accounting
|
||||
|
||||
@@ -327,7 +327,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
"rate": "rate",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: doc.qty - received_items.get(doc.name, 0.0) > 0,
|
||||
"condition": lambda doc: doc.qty > 0,
|
||||
}
|
||||
|
||||
if doctype in ["Sales Invoice", "Sales Order"]:
|
||||
@@ -367,19 +367,11 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
target_doc,
|
||||
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
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
|
||||
def get_received_items(reference_name, doctype, reference_fieldname):
|
||||
reference_field = "inter_company_invoice_reference"
|
||||
if doctype == "Purchase Order":
|
||||
reference_field = "inter_company_order_reference"
|
||||
@@ -392,19 +384,20 @@ def get_received_items(reference_name: str, doctype: str, reference_fieldname: s
|
||||
target_doctypes = frappe.get_all(
|
||||
doctype,
|
||||
filters=filters,
|
||||
pluck="name",
|
||||
as_list=True,
|
||||
)
|
||||
received_items_map = {}
|
||||
|
||||
if target_doctypes:
|
||||
received_items_data = frappe.get_all(
|
||||
target_doctypes = list(target_doctypes[0])
|
||||
|
||||
received_items_map = frappe._dict(
|
||||
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
|
||||
|
||||
|
||||
@@ -179,31 +179,12 @@ 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.mapper.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"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,8 +567,6 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
set_dynamic_labels() {
|
||||
super.set_dynamic_labels();
|
||||
this.frm.events.hide_fields(this.frm);
|
||||
const hide_update_stock = cint(this.frm.doc.is_debit_note) || cint(this.frm.doc.has_subcontracted);
|
||||
this.frm.set_df_property("update_stock", "hidden", hide_update_stock);
|
||||
}
|
||||
|
||||
items_on_form_rendered() {
|
||||
@@ -1176,20 +1155,13 @@ frappe.ui.form.on("Sales Invoice", {
|
||||
);
|
||||
},
|
||||
|
||||
is_debit_note: function (frm) {
|
||||
if (frm.doc.is_debit_note) {
|
||||
frm.set_value("update_stock", 0);
|
||||
}
|
||||
// visibility handled by set_dynamic_labels()
|
||||
frm.cscript.set_dynamic_labels();
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.is_debit_note) {
|
||||
frm.set_df_property("return_against", "label", __("Adjustment Against"));
|
||||
}
|
||||
|
||||
frm.set_df_property("update_stock", "read_only", frm.doc.has_subcontracted);
|
||||
frm.toggle_display("update_stock", !frm.doc.has_subcontracted);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -715,7 +715,6 @@
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:doc.items.every((item) => !item.dn_detail)",
|
||||
"description": "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Delivery Note is created separately.",
|
||||
"fieldname": "update_stock",
|
||||
"fieldtype": "Check",
|
||||
"hide_days": 1,
|
||||
@@ -723,8 +722,7 @@
|
||||
"label": "Update Stock",
|
||||
"oldfieldname": "update_stock",
|
||||
"oldfieldtype": "Check",
|
||||
"print_hide": 1,
|
||||
"show_description_on_click": 1
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "scan_barcode",
|
||||
|
||||
@@ -304,7 +304,6 @@ class SalesInvoice(SellingController):
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.check_sales_order_on_hold_or_close("sales_order")
|
||||
self.validate_debit_to_acc()
|
||||
self.validate_debit_note_with_update_stock()
|
||||
self.clear_unallocated_advances("Sales Invoice Advance", "advances")
|
||||
FixedAssetService(self).validate_fixed_asset()
|
||||
FixedAssetService(self).set_income_account_for_fixed_assets()
|
||||
@@ -412,8 +411,8 @@ class SalesInvoice(SellingController):
|
||||
validate_account_head(item.idx, item.income_account, self.company, _("Income"))
|
||||
|
||||
def before_save(self):
|
||||
POSService(self).update_paid_amount()
|
||||
POSService(self).set_account_for_mode_of_payment()
|
||||
POSService(self).set_paid_amount()
|
||||
|
||||
def before_submit(self):
|
||||
self.add_remarks()
|
||||
@@ -498,9 +497,6 @@ class SalesInvoice(SellingController):
|
||||
self.process_common_party_accounting()
|
||||
self.update_billed_qty_in_scio()
|
||||
|
||||
if self.is_return:
|
||||
self.refresh_subscription_status()
|
||||
|
||||
def before_cancel(self):
|
||||
POSService(self).check_if_created_using_pos_and_pos_closing_entry_generated()
|
||||
POSService(self).check_if_consolidated_invoice()
|
||||
@@ -588,7 +584,6 @@ class SalesInvoice(SellingController):
|
||||
POSService(self).cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode()
|
||||
|
||||
self.update_billed_qty_in_scio()
|
||||
self.refresh_subscription_status()
|
||||
|
||||
def update_status_updater_args(self):
|
||||
if not cint(self.update_stock):
|
||||
@@ -961,17 +956,6 @@ class SalesInvoice(SellingController):
|
||||
if flt(self.change_amount) and not self.account_for_change_amount:
|
||||
msgprint(_("Please enter Account for Change Amount"), raise_exception=1)
|
||||
|
||||
def validate_debit_note_with_update_stock(self):
|
||||
"""Prevent stock update when Sales Invoice is marked as Debit Note."""
|
||||
if self.is_debit_note and cint(self.update_stock):
|
||||
frappe.throw(
|
||||
_(
|
||||
"You cannot update stock for a Debit Note. A Debit Note is a financial "
|
||||
"document that should not affect inventory. Please disable 'Update Stock'."
|
||||
),
|
||||
title=_("Invalid Configuration"),
|
||||
)
|
||||
|
||||
def validate_dropship_item(self):
|
||||
"""If items are drop shipped, stock cannot be updated."""
|
||||
if not cint(self.update_stock):
|
||||
|
||||
@@ -114,17 +114,10 @@ class POSService:
|
||||
|
||||
return pos
|
||||
|
||||
def update_paid_amount(self) -> None:
|
||||
def set_paid_amount(self) -> None:
|
||||
doc = self.doc
|
||||
paid_amount = 0.0
|
||||
base_paid_amount = 0.0
|
||||
|
||||
if not cint(doc.is_pos) and doc.is_return:
|
||||
doc.set("payments", [])
|
||||
doc.paid_amount = paid_amount
|
||||
doc.base_paid_amount = base_paid_amount
|
||||
return
|
||||
|
||||
for data in doc.payments:
|
||||
data.base_amount = flt(data.amount * doc.conversion_rate, doc.precision("base_paid_amount"))
|
||||
paid_amount += data.amount
|
||||
|
||||
@@ -2918,67 +2918,6 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(target_doc.company, "_Test Company 1")
|
||||
self.assertEqual(target_doc.supplier, "_Test Internal Supplier")
|
||||
|
||||
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_inter_company_transaction_does_not_inherit_party_fields(self):
|
||||
"""
|
||||
Party-derived fields on SI (from Customer) must not leak into the mapped PI.
|
||||
@@ -5213,13 +5152,6 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
|
||||
frappe.db.set_value("Company", "_Test Company 1", "cost_center", cost_center)
|
||||
|
||||
def test_debit_note_with_update_stock_validation(self):
|
||||
"""Test that saving a Debit Note with Update Stock enabled raises ValidationError."""
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.is_debit_note = 1
|
||||
si.update_stock = 1
|
||||
self.assertRaises(frappe.ValidationError, si.save)
|
||||
|
||||
|
||||
def make_item_for_si(item_code, properties=None):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
"barcode",
|
||||
"has_item_scanned",
|
||||
"item_code",
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"col_break1",
|
||||
"item_name",
|
||||
"customer_item_code",
|
||||
@@ -146,23 +144,6 @@
|
||||
"options": "Item",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_product_bundle",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_product_bundle",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only_depends_on": "eval:doc.so_detail || doc.dn_detail"
|
||||
},
|
||||
{
|
||||
"fieldname": "col_break1",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -1055,7 +1036,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-08 20:00:00.000000",
|
||||
"modified": "2026-06-03 13:17:36.145788",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Item",
|
||||
|
||||
@@ -29,13 +29,7 @@ frappe.ui.form.on("Subscription", {
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.is_new()) {
|
||||
// The field wrapper is reused across docs; clear any stale heatmap.
|
||||
frm.get_field("billing_heatmap").$wrapper.empty();
|
||||
return;
|
||||
}
|
||||
|
||||
frm.trigger("render_billing_heatmap");
|
||||
if (frm.is_new()) return;
|
||||
|
||||
if (frm.doc.status !== "Cancelled") {
|
||||
frm.add_custom_button(
|
||||
@@ -101,88 +95,4 @@ frappe.ui.form.on("Subscription", {
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
render_billing_heatmap: function (frm) {
|
||||
frm.call("get_billing_heatmap").then((r) => {
|
||||
if (!r.message || !r.message.length) return;
|
||||
render_heatmap(frm.get_field("billing_heatmap").$wrapper, r.message, frm.doc);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Status -> colour and label for the calendar heatmap. Keys are Title-case to
|
||||
// match the value frappe-charts shows in its hover tooltip.
|
||||
const HEATMAP_COLORS = {
|
||||
Paid: "#39d353",
|
||||
Unpaid: "#388bfd",
|
||||
Overdue: "#f0883e",
|
||||
Cancelled: "#f85149",
|
||||
Refunded: "#a371f7",
|
||||
Planned: "#87ceeb",
|
||||
};
|
||||
|
||||
// Days inside the window but outside the subscription's active span stay faded.
|
||||
const EMPTY_COLOR = "#ebedf0";
|
||||
|
||||
function title_case(status) {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
function render_heatmap($wrapper, days, doc) {
|
||||
const data_points = {};
|
||||
days.forEach((day) => {
|
||||
data_points[day.date] = title_case(day.status);
|
||||
});
|
||||
|
||||
$wrapper.empty();
|
||||
const chart_el = $('<div class="subscription-billing-heatmap"></div>').appendTo($wrapper)[0];
|
||||
|
||||
new frappe.Chart(chart_el, {
|
||||
type: "heatmap",
|
||||
data: {
|
||||
dataPoints: data_points,
|
||||
start: new Date(days[0].date),
|
||||
end: new Date(days[days.length - 1].date),
|
||||
},
|
||||
discreteDomains: 1,
|
||||
showLegend: 0,
|
||||
// frappe-charts only does an intensity scale; we recolour each square by
|
||||
// its own status below, so the scale colours are placeholders.
|
||||
colors: ["#ebedf0", "#ebedf0", "#ebedf0", "#ebedf0", "#ebedf0"],
|
||||
});
|
||||
|
||||
// Paint every day square with its status colour (data-value holds the status).
|
||||
// The chart re-renders once for its entry animation, so repaint on each redraw.
|
||||
const within_subscription = (date) =>
|
||||
(!doc.start_date || date >= doc.start_date) && (!doc.end_date || date <= doc.end_date);
|
||||
|
||||
const paint = () =>
|
||||
chart_el.querySelectorAll("[data-date]").forEach((square) => {
|
||||
const status = square.getAttribute("data-value");
|
||||
if (status === "Planned" && !within_subscription(square.getAttribute("data-date"))) {
|
||||
// Outside the subscription's span: render blank and drop the status so the
|
||||
// hover tooltip shows only the date, not "Planned".
|
||||
square.setAttribute("fill", EMPTY_COLOR);
|
||||
square.setAttribute("data-value", "");
|
||||
return;
|
||||
}
|
||||
square.setAttribute("fill", HEATMAP_COLORS[status] || EMPTY_COLOR);
|
||||
});
|
||||
|
||||
paint();
|
||||
new MutationObserver(paint).observe(chart_el, { childList: true, subtree: true });
|
||||
|
||||
const legend = Object.keys(HEATMAP_COLORS)
|
||||
.map(
|
||||
(status) =>
|
||||
`<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px;">
|
||||
<span style="width:11px;height:11px;border-radius:2px;background:${HEATMAP_COLORS[status]};"></span>
|
||||
${__(status)}
|
||||
</span>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
$(`<div style="margin-top:8px;font-size:11px;color:var(--text-muted);">${legend}</div>`).appendTo(
|
||||
$wrapper
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"billing_history_section",
|
||||
"billing_heatmap",
|
||||
"section_break_jznv",
|
||||
"party_type",
|
||||
"party",
|
||||
"cb_1",
|
||||
@@ -24,16 +21,12 @@
|
||||
"generate_new_invoices_past_due_date",
|
||||
"submit_invoice",
|
||||
"column_break_11",
|
||||
"current_invoice_start",
|
||||
"current_invoice_end",
|
||||
"days_until_due",
|
||||
"generate_invoice_at",
|
||||
"number_of_days",
|
||||
"cancel_at_period_end",
|
||||
"billing_period_section",
|
||||
"current_invoice_start",
|
||||
"current_invoice_end",
|
||||
"billing_period_cb",
|
||||
"next_billing_period_start",
|
||||
"next_billing_period_end",
|
||||
"sb_4",
|
||||
"plans",
|
||||
"sb_1",
|
||||
@@ -58,7 +51,7 @@
|
||||
"fieldtype": "Select",
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "\nTrialing\nActive\nGrace Period\nCancelled\nUnpaid\nCompleted\nRefunded",
|
||||
"options": "\nTrialing\nActive\nGrace Period\nCancelled\nUnpaid\nCompleted",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -90,40 +83,17 @@
|
||||
"fieldname": "column_break_11",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "billing_period_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Billing Period"
|
||||
},
|
||||
{
|
||||
"fieldname": "current_invoice_start",
|
||||
"fieldtype": "Date",
|
||||
"label": "Current Invoice Start",
|
||||
"label": "Current Invoice Start Date",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "current_invoice_end",
|
||||
"fieldtype": "Date",
|
||||
"label": "Current Invoice End",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "billing_period_cb",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "next_billing_period_start",
|
||||
"fieldtype": "Date",
|
||||
"label": "Next Billing Period Start",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "next_billing_period_end",
|
||||
"fieldtype": "Date",
|
||||
"label": "Next Billing Period End",
|
||||
"label": "Current Invoice End Date",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -138,18 +108,7 @@
|
||||
"default": "0",
|
||||
"fieldname": "cancel_at_period_end",
|
||||
"fieldtype": "Check",
|
||||
"label": "Cancel When Period Ends"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "billing_history_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Billing History"
|
||||
},
|
||||
{
|
||||
"fieldname": "billing_heatmap",
|
||||
"fieldtype": "HTML",
|
||||
"label": "Billing Heatmap"
|
||||
"label": "Cancel At End Of Period"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
@@ -247,7 +206,7 @@
|
||||
"description": "New invoices will be generated as per schedule even if current invoices are unpaid or past due date",
|
||||
"fieldname": "generate_new_invoices_past_due_date",
|
||||
"fieldtype": "Check",
|
||||
"label": "Bill Even If Previous Invoice Unpaid"
|
||||
"label": "Generate New Invoices Past Due Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "end_date",
|
||||
@@ -280,23 +239,19 @@
|
||||
"label": "Submit Generated Invoices"
|
||||
},
|
||||
{
|
||||
"default": "Postpaid (bill at period end)",
|
||||
"default": "End of the current subscription period",
|
||||
"fieldname": "generate_invoice_at",
|
||||
"fieldtype": "Select",
|
||||
"label": "Generate Invoice At",
|
||||
"options": "Postpaid (bill at period end)\nPrepaid (bill at period start)\nBill N days before period start",
|
||||
"options": "End of the current subscription period\nBeginning of the current subscription period\nDays before the current subscription period",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.generate_invoice_at === \"Bill N days before period start\"",
|
||||
"depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\"",
|
||||
"fieldname": "number_of_days",
|
||||
"fieldtype": "Int",
|
||||
"label": "Number of Days",
|
||||
"mandatory_depends_on": "eval:doc.generate_invoice_at === \"Bill N days before period start\""
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_jznv",
|
||||
"fieldtype": "Section Break"
|
||||
"mandatory_depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\""
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
@@ -312,11 +267,11 @@
|
||||
"link_fieldname": "subscription"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-04 07:21:15.938170",
|
||||
"modified": "2025-12-23 19:42:52.036034",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Subscription",
|
||||
"naming_rule": "Expression",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -14,7 +14,6 @@ from frappe.utils.data import (
|
||||
cint,
|
||||
date_diff,
|
||||
flt,
|
||||
get_first_day,
|
||||
get_last_day,
|
||||
get_link_to_form,
|
||||
getdate,
|
||||
@@ -36,24 +35,6 @@ class InvoiceNotCancelled(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
GENERATE_AT_END = "Postpaid (bill at period end)"
|
||||
GENERATE_AT_BEGINNING = "Prepaid (bill at period start)"
|
||||
GENERATE_AT_DAYS_BEFORE = "Bill N days before period start"
|
||||
|
||||
STATUS_TRIALING = "Trialing"
|
||||
STATUS_ACTIVE = "Active"
|
||||
STATUS_GRACE_PERIOD = "Grace Period"
|
||||
STATUS_CANCELLED = "Cancelled"
|
||||
STATUS_UNPAID = "Unpaid"
|
||||
STATUS_COMPLETED = "Completed"
|
||||
STATUS_REFUNDED = "Refunded"
|
||||
|
||||
PARTY_CUSTOMER = "Customer"
|
||||
PARTY_SUPPLIER = "Supplier"
|
||||
|
||||
INVOICE_PAID = "Paid"
|
||||
|
||||
|
||||
DateTimeLikeObject = str | date
|
||||
|
||||
|
||||
@@ -83,13 +64,11 @@ class Subscription(Document):
|
||||
end_date: DF.Date | None
|
||||
follow_calendar_months: DF.Check
|
||||
generate_invoice_at: DF.Literal[
|
||||
"Postpaid (bill at period end)",
|
||||
"Prepaid (bill at period start)",
|
||||
"Bill N days before period start",
|
||||
"End of the current subscription period",
|
||||
"Beginning of the current subscription period",
|
||||
"Days before the current subscription period",
|
||||
]
|
||||
generate_new_invoices_past_due_date: DF.Check
|
||||
next_billing_period_end: DF.Date | None
|
||||
next_billing_period_start: DF.Date | None
|
||||
number_of_days: DF.Int
|
||||
party: DF.DynamicLink
|
||||
party_type: DF.Link
|
||||
@@ -97,9 +76,7 @@ class Subscription(Document):
|
||||
purchase_tax_template: DF.Link | None
|
||||
sales_tax_template: DF.Link | None
|
||||
start_date: DF.Date | None
|
||||
status: DF.Literal[
|
||||
"", "Trialing", "Active", "Grace Period", "Cancelled", "Unpaid", "Completed", "Refunded"
|
||||
]
|
||||
status: DF.Literal["", "Trialing", "Active", "Grace Period", "Cancelled", "Unpaid", "Completed"]
|
||||
submit_invoice: DF.Check
|
||||
trial_period_end: DF.Date | None
|
||||
trial_period_start: DF.Date | None
|
||||
@@ -126,39 +103,38 @@ class Subscription(Document):
|
||||
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.next_billing_period_start
|
||||
period_start = self.current_invoice_start
|
||||
self.process(posting_date=self._next_invoice_trigger_date())
|
||||
|
||||
if self.status == STATUS_CANCELLED or getdate(self.next_billing_period_start) == getdate(
|
||||
period_start
|
||||
):
|
||||
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:
|
||||
return self._invoice_date_for_period(self.next_billing_period_start, self.next_billing_period_end)
|
||||
|
||||
def _invoice_date_for_period(
|
||||
self, period_start: DateTimeLikeObject, period_end: DateTimeLikeObject
|
||||
) -> DateTimeLikeObject:
|
||||
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
|
||||
return period_start
|
||||
if self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
|
||||
return add_days(period_start, -self.number_of_days)
|
||||
return period_end
|
||||
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
|
||||
beginning of the billing period and end of the billing period.
|
||||
The beginning of the billing period is represented in the doctype as
|
||||
`next_billing_period_start` and the end of the billing period is represented
|
||||
as `next_billing_period_end`.
|
||||
`current_invoice_start` and the end of the billing period is represented
|
||||
as `current_invoice_end`.
|
||||
"""
|
||||
self.next_billing_period_start = self.get_current_invoice_start(date)
|
||||
self.next_billing_period_end = self.get_current_invoice_end(self.next_billing_period_start)
|
||||
self.current_invoice_start = self.get_current_invoice_start(date)
|
||||
self.current_invoice_end = self.get_current_invoice_end(self.current_invoice_start)
|
||||
|
||||
def _get_subscription_period(self, date: DateTimeLikeObject | None = None):
|
||||
_current_invoice_start = self.get_current_invoice_start(date)
|
||||
_current_invoice_end = self.get_current_invoice_end(_current_invoice_start)
|
||||
|
||||
return _current_invoice_start, _current_invoice_end
|
||||
|
||||
def get_current_invoice_start(self, date: DateTimeLikeObject | None = None) -> DateTimeLikeObject:
|
||||
"""
|
||||
@@ -199,7 +175,7 @@ class Subscription(Document):
|
||||
_current_invoice_end = add_to_date(self.start_date, **billing_cycle_info)
|
||||
|
||||
# For cases where trial period is for an entire billing interval
|
||||
if getdate(self.next_billing_period_end) < getdate(date):
|
||||
if getdate(self.current_invoice_end) < getdate(date):
|
||||
_current_invoice_end = add_to_date(date, **billing_cycle_info)
|
||||
else:
|
||||
_current_invoice_end = add_to_date(date, **billing_cycle_info)
|
||||
@@ -277,35 +253,21 @@ class Subscription(Document):
|
||||
"""
|
||||
Sets the status of the `Subscription`
|
||||
"""
|
||||
self._set_current_invoice_dates()
|
||||
if self.is_trialling():
|
||||
self.status = STATUS_TRIALING
|
||||
elif self.is_fully_refunded() and self.has_outstanding_invoice():
|
||||
self.status = STATUS_REFUNDED
|
||||
self.status = "Trialing"
|
||||
elif (
|
||||
not self.has_outstanding_invoice()
|
||||
and self.end_date
|
||||
and getdate(posting_date) > getdate(self.end_date)
|
||||
):
|
||||
self.status = STATUS_COMPLETED
|
||||
self.status = "Completed"
|
||||
elif self.is_past_grace_period():
|
||||
self.status = self.get_status_for_past_grace_period()
|
||||
self.cancelation_date = getdate(posting_date) if self.status == STATUS_CANCELLED else None
|
||||
self.cancelation_date = getdate(posting_date) if self.status == "Cancelled" else None
|
||||
elif self.current_invoice_is_past_due() and not self.is_past_grace_period():
|
||||
self.status = STATUS_GRACE_PERIOD
|
||||
self.status = "Grace Period"
|
||||
elif not self.has_outstanding_invoice():
|
||||
self.status = STATUS_ACTIVE
|
||||
|
||||
def _set_current_invoice_dates(self) -> None:
|
||||
invoice = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={"subscription": self.name, "docstatus": ("<", 2), "is_return": 0},
|
||||
fields=["from_date", "to_date"],
|
||||
order_by="to_date desc",
|
||||
limit=1,
|
||||
)
|
||||
self.current_invoice_start = invoice[0].from_date if invoice else None
|
||||
self.current_invoice_end = invoice[0].to_date if invoice else None
|
||||
self.status = "Active"
|
||||
|
||||
def is_trialling(self) -> bool:
|
||||
"""
|
||||
@@ -320,6 +282,7 @@ class Subscription(Document):
|
||||
"""
|
||||
Returns true if the given `end_date` has passed
|
||||
"""
|
||||
# todo: test for illegal time
|
||||
if not end_date:
|
||||
return True
|
||||
|
||||
@@ -327,10 +290,10 @@ class Subscription(Document):
|
||||
|
||||
def get_status_for_past_grace_period(self) -> str:
|
||||
cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace"))
|
||||
status = STATUS_UNPAID
|
||||
status = "Unpaid"
|
||||
|
||||
if cancel_after_grace:
|
||||
status = STATUS_CANCELLED
|
||||
status = "Cancelled"
|
||||
|
||||
return status
|
||||
|
||||
@@ -358,7 +321,7 @@ class Subscription(Document):
|
||||
|
||||
@property
|
||||
def invoice_document_type(self) -> str:
|
||||
return "Sales Invoice" if self.party_type == PARTY_CUSTOMER else "Purchase Invoice"
|
||||
return "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
|
||||
|
||||
def validate(self) -> None:
|
||||
self.validate_trial_period()
|
||||
@@ -450,7 +413,11 @@ class Subscription(Document):
|
||||
to_date: DateTimeLikeObject | None = None,
|
||||
posting_date: DateTimeLikeObject | None = None,
|
||||
) -> Document:
|
||||
"""Public alias for `create_invoice`; kept for external integrations."""
|
||||
"""
|
||||
Creates a `Invoice` for the `Subscription`, updates `self.invoices` and
|
||||
saves the `Subscription`.
|
||||
Backwards compatibility
|
||||
"""
|
||||
return self.create_invoice(from_date=from_date, to_date=to_date, posting_date=posting_date)
|
||||
|
||||
def create_invoice(
|
||||
@@ -462,19 +429,8 @@ class Subscription(Document):
|
||||
"""
|
||||
Creates a `Invoice`, submits it and returns it
|
||||
"""
|
||||
company = self._resolve_company()
|
||||
invoice = self._init_invoice_doc(company, posting_date)
|
||||
self._set_invoice_party(invoice)
|
||||
self._set_invoice_currency(invoice)
|
||||
self._apply_accounting_dimensions(invoice)
|
||||
self._append_invoice_items(invoice)
|
||||
self._apply_taxes(invoice)
|
||||
self._apply_payment_schedule(invoice)
|
||||
self._apply_discounts(invoice)
|
||||
return self._finalize_invoice(invoice, from_date, to_date)
|
||||
|
||||
def _resolve_company(self) -> str:
|
||||
# Earlier subscriptions didn't have a company field
|
||||
# For backward compatibility
|
||||
# Earlier subscription didn't had any company field
|
||||
company = self.get("company") or get_default_company()
|
||||
if not company:
|
||||
frappe.throw(
|
||||
@@ -482,49 +438,48 @@ class Subscription(Document):
|
||||
"Company is mandatory for generating an invoice. Please set a default company in Global Defaults."
|
||||
)
|
||||
)
|
||||
return company
|
||||
|
||||
def _init_invoice_doc(self, company: str, posting_date: DateTimeLikeObject | None = None) -> Document:
|
||||
invoice = frappe.new_doc(self.invoice_document_type)
|
||||
invoice.company = company
|
||||
invoice.set_posting_time = 1
|
||||
invoice.posting_date = self._invoice_posting_date(posting_date)
|
||||
|
||||
if self.generate_invoice_at == "Beginning of the current subscription period":
|
||||
invoice.posting_date = self.current_invoice_start
|
||||
elif self.generate_invoice_at == "Days before the current subscription period":
|
||||
invoice.posting_date = posting_date or self.current_invoice_start
|
||||
else:
|
||||
invoice.posting_date = self.current_invoice_end
|
||||
|
||||
invoice.cost_center = self.cost_center
|
||||
return invoice
|
||||
|
||||
def _invoice_posting_date(self, posting_date: DateTimeLikeObject | None = None) -> DateTimeLikeObject:
|
||||
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
|
||||
return self.next_billing_period_start
|
||||
if self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
|
||||
return posting_date or self.next_billing_period_start
|
||||
return self.next_billing_period_end
|
||||
|
||||
def _set_invoice_party(self, invoice: Document) -> None:
|
||||
if self.invoice_document_type == "Sales Invoice":
|
||||
invoice.customer = self.party
|
||||
return
|
||||
else:
|
||||
invoice.supplier = self.party
|
||||
tax_withholding_category, tax_withholding_group = frappe.get_cached_value(
|
||||
"Supplier", self.party, ["tax_withholding_category", "tax_withholding_group"]
|
||||
)
|
||||
if tax_withholding_category or tax_withholding_group:
|
||||
invoice.apply_tds = 1
|
||||
|
||||
invoice.supplier = self.party
|
||||
tax_withholding_category, tax_withholding_group = frappe.get_cached_value(
|
||||
"Supplier", self.party, ["tax_withholding_category", "tax_withholding_group"]
|
||||
)
|
||||
if tax_withholding_category or tax_withholding_group:
|
||||
invoice.apply_tds = 1
|
||||
|
||||
def _set_invoice_currency(self, invoice: Document) -> None:
|
||||
# Add currency to invoice
|
||||
invoice.currency = frappe.db.get_value("Subscription Plan", {"name": self.plans[0].plan}, "currency")
|
||||
|
||||
def _apply_accounting_dimensions(self, invoice: Document) -> None:
|
||||
for dimension in get_accounting_dimensions():
|
||||
# Add dimensions in invoice for subscription:
|
||||
accounting_dimensions = get_accounting_dimensions()
|
||||
|
||||
for dimension in accounting_dimensions:
|
||||
if self.get(dimension):
|
||||
invoice.update({dimension: self.get(dimension)})
|
||||
|
||||
def _append_invoice_items(self, invoice: Document) -> None:
|
||||
# Subscription is better suited for service items, so `update_stock` is left untouched
|
||||
for item in self.get_items_from_plans(self.plans, is_prorate()):
|
||||
# Subscription is better suited for service items. I won't update `update_stock`
|
||||
# for that reason
|
||||
items_list = self.get_items_from_plans(self.plans, is_prorate())
|
||||
|
||||
for item in items_list:
|
||||
invoice.append("items", item)
|
||||
|
||||
def _apply_taxes(self, invoice: Document) -> None:
|
||||
# Taxes
|
||||
tax_template = ""
|
||||
|
||||
if self.invoice_document_type == "Sales Invoice" and self.sales_tax_template:
|
||||
@@ -538,43 +493,37 @@ class Subscription(Document):
|
||||
invoice.taxes_and_charges = tax_template
|
||||
TaxService(invoice).set_taxes()
|
||||
|
||||
def _apply_payment_schedule(self, invoice: Document) -> None:
|
||||
if not self.days_until_due:
|
||||
return
|
||||
# Due date
|
||||
if self.days_until_due:
|
||||
invoice.append(
|
||||
"payment_schedule",
|
||||
{
|
||||
"due_date": add_days(invoice.posting_date, cint(self.days_until_due)),
|
||||
"invoice_portion": 100,
|
||||
},
|
||||
)
|
||||
|
||||
invoice.append(
|
||||
"payment_schedule",
|
||||
{
|
||||
"due_date": add_days(invoice.posting_date, cint(self.days_until_due)),
|
||||
"invoice_portion": 100,
|
||||
},
|
||||
)
|
||||
|
||||
def _apply_discounts(self, invoice: Document) -> None:
|
||||
# Discounts
|
||||
if self.is_trialling():
|
||||
invoice.additional_discount_percentage = 100
|
||||
return
|
||||
else:
|
||||
if self.additional_discount_percentage:
|
||||
invoice.additional_discount_percentage = self.additional_discount_percentage
|
||||
|
||||
if self.additional_discount_percentage:
|
||||
invoice.additional_discount_percentage = self.additional_discount_percentage
|
||||
if self.additional_discount_amount:
|
||||
invoice.discount_amount = self.additional_discount_amount
|
||||
|
||||
if self.additional_discount_amount:
|
||||
invoice.discount_amount = self.additional_discount_amount
|
||||
if self.additional_discount_percentage or self.additional_discount_amount:
|
||||
discount_on = self.apply_additional_discount
|
||||
invoice.apply_discount_on = discount_on if discount_on else "Grand Total"
|
||||
|
||||
if self.additional_discount_percentage or self.additional_discount_amount:
|
||||
invoice.apply_discount_on = self.apply_additional_discount or "Grand Total"
|
||||
|
||||
def _finalize_invoice(
|
||||
self,
|
||||
invoice: Document,
|
||||
from_date: DateTimeLikeObject | None = None,
|
||||
to_date: DateTimeLikeObject | None = None,
|
||||
) -> Document:
|
||||
# Subscription period
|
||||
invoice.subscription = self.name
|
||||
invoice.from_date = from_date or self.next_billing_period_start
|
||||
invoice.to_date = to_date or self.next_billing_period_end
|
||||
invoice.from_date = from_date or self.current_invoice_start
|
||||
invoice.to_date = to_date or self.current_invoice_end
|
||||
|
||||
invoice.flags.ignore_mandatory = True
|
||||
|
||||
invoice.set_missing_values()
|
||||
invoice.save()
|
||||
|
||||
@@ -591,9 +540,15 @@ class Subscription(Document):
|
||||
prorate_factor = 1
|
||||
if prorate:
|
||||
prorate_factor = get_prorata_factor(
|
||||
self.next_billing_period_end,
|
||||
self.next_billing_period_start,
|
||||
cint(self.generate_invoice_at in [GENERATE_AT_BEGINNING, GENERATE_AT_DAYS_BEFORE]),
|
||||
self.current_invoice_end,
|
||||
self.current_invoice_start,
|
||||
cint(
|
||||
self.generate_invoice_at
|
||||
in [
|
||||
"Beginning of the current subscription period",
|
||||
"Days before the current subscription period",
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
items = []
|
||||
@@ -603,7 +558,7 @@ class Subscription(Document):
|
||||
|
||||
item_code = plan_doc.item
|
||||
|
||||
if self.party_type == PARTY_CUSTOMER:
|
||||
if self.party_type == "Customer":
|
||||
deferred_field = "enable_deferred_revenue"
|
||||
else:
|
||||
deferred_field = "enable_deferred_expense"
|
||||
@@ -617,8 +572,8 @@ class Subscription(Document):
|
||||
plan.plan,
|
||||
plan.qty,
|
||||
party,
|
||||
self.next_billing_period_start,
|
||||
self.next_billing_period_end,
|
||||
self.current_invoice_start,
|
||||
self.current_invoice_end,
|
||||
prorate_factor,
|
||||
),
|
||||
"cost_center": plan_doc.cost_center,
|
||||
@@ -628,8 +583,8 @@ class Subscription(Document):
|
||||
item.update(
|
||||
{
|
||||
deferred_field: deferred,
|
||||
"service_start_date": self.next_billing_period_start,
|
||||
"service_end_date": self.next_billing_period_end,
|
||||
"service_start_date": self.current_invoice_start,
|
||||
"service_end_date": self.current_invoice_end,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -652,11 +607,11 @@ class Subscription(Document):
|
||||
2. `process_for_past_due`
|
||||
"""
|
||||
if not self.is_current_invoice_generated(
|
||||
self.next_billing_period_start, self.next_billing_period_end
|
||||
self.current_invoice_start, self.current_invoice_end
|
||||
) and self.can_generate_new_invoice(posting_date):
|
||||
self.generate_invoice(posting_date=posting_date)
|
||||
if self.end_date:
|
||||
next_start = add_days(self.next_billing_period_end, 1)
|
||||
next_start = add_days(self.current_invoice_end, 1)
|
||||
|
||||
if getdate(next_start) > getdate(self.end_date):
|
||||
if self.cancel_at_period_end:
|
||||
@@ -666,12 +621,12 @@ class Subscription(Document):
|
||||
|
||||
self.save()
|
||||
return
|
||||
self.update_subscription_period(add_days(self.next_billing_period_end, 1))
|
||||
elif posting_date and getdate(posting_date) > getdate(self.next_billing_period_end):
|
||||
self.update_subscription_period(add_days(self.current_invoice_end, 1))
|
||||
elif posting_date and getdate(posting_date) > getdate(self.current_invoice_end):
|
||||
self.update_subscription_period()
|
||||
|
||||
if self.cancel_at_period_end and (
|
||||
getdate(posting_date) >= getdate(self.next_billing_period_end)
|
||||
getdate(posting_date) >= getdate(self.current_invoice_end)
|
||||
or getdate(posting_date) >= getdate(self.end_date)
|
||||
):
|
||||
self.cancel_subscription()
|
||||
@@ -697,9 +652,9 @@ class Subscription(Document):
|
||||
# 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.next_billing_period_end, **billing_cycle_info))
|
||||
upper = getdate(add_to_date(self.current_invoice_end, **billing_cycle_info))
|
||||
else:
|
||||
upper = getdate(self.next_billing_period_end)
|
||||
upper = getdate(self.current_invoice_end)
|
||||
|
||||
return posting <= upper
|
||||
|
||||
@@ -709,8 +664,9 @@ class Subscription(Document):
|
||||
_current_end_date: DateTimeLikeObject | None = None,
|
||||
) -> bool:
|
||||
if not (_current_start_date and _current_end_date):
|
||||
_current_start_date = self.get_current_invoice_start(add_days(self.next_billing_period_end, 1))
|
||||
_current_end_date = self.get_current_invoice_end(_current_start_date)
|
||||
_current_start_date, _current_end_date = self._get_subscription_period(
|
||||
date=add_days(self.current_invoice_end, 1)
|
||||
)
|
||||
|
||||
if self.current_invoice and getdate(_current_start_date) <= getdate(
|
||||
self.current_invoice.posting_date
|
||||
@@ -732,7 +688,7 @@ class Subscription(Document):
|
||||
"""
|
||||
invoice = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
{"subscription": self.name, "docstatus": ("<", 2), "is_return": 0},
|
||||
{"subscription": self.name, "docstatus": ("<", 2)},
|
||||
limit=1,
|
||||
order_by="to_date desc",
|
||||
pluck="name",
|
||||
@@ -754,70 +710,41 @@ class Subscription(Document):
|
||||
"""
|
||||
Return `True` if the given invoice is paid
|
||||
"""
|
||||
return invoice.status == INVOICE_PAID
|
||||
return invoice.status == "Paid"
|
||||
|
||||
def has_outstanding_invoice(self) -> int:
|
||||
"""
|
||||
Returns the count of submitted, non-return invoices that are not yet paid.
|
||||
Returns `True` if the most recent invoice for the `Subscription` is not paid
|
||||
"""
|
||||
return frappe.db.count(
|
||||
self.invoice_document_type,
|
||||
{
|
||||
"subscription": self.name,
|
||||
"docstatus": 1,
|
||||
"is_return": 0,
|
||||
"status": ["!=", INVOICE_PAID],
|
||||
"status": ["!=", "Paid"],
|
||||
},
|
||||
)
|
||||
|
||||
def is_fully_refunded(self) -> bool:
|
||||
"""
|
||||
`True` only when every submitted, not-`Paid` invoice on the subscription has
|
||||
credit notes whose absolute total covers its outstanding amount.
|
||||
"""
|
||||
unpaid_invoices = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={
|
||||
"subscription": self.name,
|
||||
"docstatus": 1,
|
||||
"is_return": 0,
|
||||
"status": ["!=", INVOICE_PAID],
|
||||
},
|
||||
fields=["name", "outstanding_amount"],
|
||||
)
|
||||
if not unpaid_invoices:
|
||||
return False
|
||||
|
||||
return all(self._is_invoice_fully_credited(invoice) for invoice in unpaid_invoices)
|
||||
|
||||
def _is_invoice_fully_credited(self, invoice: dict) -> bool:
|
||||
credit_notes = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={"return_against": invoice.name, "docstatus": 1},
|
||||
pluck="grand_total",
|
||||
)
|
||||
credited = sum(flt(amount) for amount in credit_notes)
|
||||
return abs(credited) >= flt(invoice.outstanding_amount)
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_subscription(self) -> None:
|
||||
"""
|
||||
This sets the subscription as cancelled. It will stop invoices from being generated
|
||||
but it will not affect already created invoices.
|
||||
"""
|
||||
if self.status == STATUS_CANCELLED:
|
||||
if self.status == "Cancelled":
|
||||
frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
|
||||
|
||||
to_generate_invoice = (
|
||||
True
|
||||
if self.status == STATUS_ACTIVE and self.generate_invoice_at != GENERATE_AT_BEGINNING
|
||||
if self.status == "Active"
|
||||
and self.generate_invoice_at != "Beginning of the current subscription period"
|
||||
else False
|
||||
)
|
||||
self.status = STATUS_CANCELLED
|
||||
self.status = "Cancelled"
|
||||
self.cancelation_date = nowdate()
|
||||
|
||||
if to_generate_invoice and getdate(self.cancelation_date) >= getdate(self.next_billing_period_start):
|
||||
self.generate_invoice(self.next_billing_period_start, self.cancelation_date)
|
||||
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()
|
||||
|
||||
@@ -828,10 +755,10 @@ class Subscription(Document):
|
||||
subscription and the `Subscription` will lose all the history of generated invoices
|
||||
it has.
|
||||
"""
|
||||
if self.status != STATUS_CANCELLED:
|
||||
if self.status != "Cancelled":
|
||||
frappe.throw(_("You cannot restart a Subscription that is not cancelled."), InvoiceNotCancelled)
|
||||
|
||||
self.status = STATUS_ACTIVE
|
||||
self.status = "Active"
|
||||
self.cancelation_date = None
|
||||
self.update_subscription_period(posting_date or nowdate())
|
||||
self.save()
|
||||
@@ -839,130 +766,25 @@ class Subscription(Document):
|
||||
@frappe.whitelist()
|
||||
def force_fetch_subscription_updates(self):
|
||||
"""
|
||||
Process Subscription and create Invoices even if current date doesn't lie between next_billing_period_start and next_billing_period_end
|
||||
Process Subscription and create Invoices even if current date doesn't lie between current_invoice_start and currenct_invoice_end
|
||||
It makes use of 'Proces Subscription' to force processing in a specific 'posting_date'
|
||||
"""
|
||||
|
||||
# Don't process future subscriptions
|
||||
if getdate(nowdate()) < getdate(self.next_billing_period_start):
|
||||
if getdate(nowdate()) < getdate(self.current_invoice_start):
|
||||
frappe.msgprint(_("Subscription for Future dates cannot be processed."))
|
||||
return
|
||||
|
||||
processing_date = None
|
||||
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
|
||||
processing_date = self.next_billing_period_start
|
||||
elif self.generate_invoice_at == GENERATE_AT_END:
|
||||
processing_date = self.next_billing_period_end
|
||||
elif self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
|
||||
processing_date = add_days(self.next_billing_period_start, -self.number_of_days)
|
||||
if self.generate_invoice_at == "Beginning of the current subscription period":
|
||||
processing_date = self.current_invoice_start
|
||||
elif self.generate_invoice_at == "End of the current subscription period":
|
||||
processing_date = self.current_invoice_end
|
||||
elif self.generate_invoice_at == "Days before the current subscription period":
|
||||
processing_date = add_days(self.current_invoice_start, -self.number_of_days)
|
||||
|
||||
self.process(posting_date=processing_date)
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_billing_heatmap(self) -> list[dict]:
|
||||
"""
|
||||
One cell per calendar day for a fixed 12-month window starting at the first day of
|
||||
the subscription's first month. Each day is coloured by the status of the billing
|
||||
period it falls into; days with no invoice yet are `planned`.
|
||||
"""
|
||||
periods = self._billing_periods()
|
||||
window_start = get_first_day(self.start_date) if self.start_date else get_first_day(nowdate())
|
||||
window_end = get_last_day(add_months(window_start, 11))
|
||||
|
||||
cells = []
|
||||
day = window_start
|
||||
while day <= window_end:
|
||||
cells.append(self._heatmap_cell(day, periods))
|
||||
day = add_days(day, 1)
|
||||
|
||||
return cells
|
||||
|
||||
def _billing_periods(self) -> list[dict]:
|
||||
invoices = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={"subscription": self.name},
|
||||
fields=[
|
||||
"name",
|
||||
"from_date",
|
||||
"to_date",
|
||||
"status",
|
||||
"due_date",
|
||||
"grand_total",
|
||||
"docstatus",
|
||||
"is_return",
|
||||
"return_against",
|
||||
],
|
||||
order_by="from_date asc",
|
||||
)
|
||||
|
||||
credited = {
|
||||
invoice.return_against
|
||||
for invoice in invoices
|
||||
if invoice.is_return and invoice.docstatus == 1 and invoice.return_against
|
||||
}
|
||||
|
||||
periods = [
|
||||
{
|
||||
"period_start": str(invoice.from_date),
|
||||
"period_end": str(invoice.to_date),
|
||||
"invoice": invoice.name,
|
||||
"amount": flt(invoice.grand_total),
|
||||
"status": self._heatmap_status(invoice, invoice.name in credited),
|
||||
}
|
||||
for invoice in invoices
|
||||
if not invoice.is_return and invoice.from_date and invoice.to_date
|
||||
]
|
||||
|
||||
return [*periods, *self._planned_periods(periods)]
|
||||
|
||||
def _heatmap_status(self, invoice: dict, is_credited: bool) -> str:
|
||||
if invoice.docstatus == 2:
|
||||
return "cancelled"
|
||||
if is_credited:
|
||||
return "refunded"
|
||||
if invoice.status == INVOICE_PAID:
|
||||
return "paid"
|
||||
if invoice.due_date and getdate(invoice.due_date) < getdate(nowdate()):
|
||||
return "overdue"
|
||||
return "unpaid"
|
||||
|
||||
def _planned_periods(self, invoiced_periods: list[dict]) -> list[dict]:
|
||||
invoiced = {(period["period_start"], period["period_end"]) for period in invoiced_periods}
|
||||
planned = []
|
||||
for start, end in self._upcoming_periods():
|
||||
if start and end and (str(start), str(end)) not in invoiced:
|
||||
planned.append(
|
||||
{
|
||||
"period_start": str(start),
|
||||
"period_end": str(end),
|
||||
"invoice": None,
|
||||
"amount": 0.0,
|
||||
"status": "planned",
|
||||
}
|
||||
)
|
||||
return planned
|
||||
|
||||
def _upcoming_periods(self) -> list[tuple]:
|
||||
"""The open billing period and the one immediately after it."""
|
||||
open_period = (self.next_billing_period_start, self.next_billing_period_end)
|
||||
after_start = add_days(self.next_billing_period_end, 1) if self.next_billing_period_end else None
|
||||
after_end = self.get_current_invoice_end(after_start) if after_start else None
|
||||
return [open_period, (after_start, after_end)]
|
||||
|
||||
def _heatmap_cell(self, day: date, periods: list[dict]) -> dict:
|
||||
for period in periods:
|
||||
if getdate(period["period_start"]) <= day <= getdate(period["period_end"]):
|
||||
return {"date": str(day), **period}
|
||||
|
||||
return {
|
||||
"date": str(day),
|
||||
"status": "planned",
|
||||
"invoice": None,
|
||||
"amount": 0.0,
|
||||
"period_start": None,
|
||||
"period_end": None,
|
||||
}
|
||||
|
||||
|
||||
def is_prorate() -> int:
|
||||
return cint(frappe.db.get_single_value("Subscription Settings", "prorate"))
|
||||
|
||||
@@ -11,8 +11,6 @@ from frappe.utils.data import (
|
||||
date_diff,
|
||||
flt,
|
||||
get_date_str,
|
||||
get_first_day,
|
||||
get_last_day,
|
||||
getdate,
|
||||
nowdate,
|
||||
)
|
||||
@@ -37,11 +35,11 @@ class TestSubscription(ERPNextTestSuite):
|
||||
self.assertEqual(subscription.trial_period_start, nowdate())
|
||||
self.assertEqual(subscription.trial_period_end, add_months(nowdate(), 1))
|
||||
self.assertEqual(
|
||||
add_days(subscription.trial_period_end, 1), get_date_str(subscription.next_billing_period_start)
|
||||
add_days(subscription.trial_period_end, 1), get_date_str(subscription.current_invoice_start)
|
||||
)
|
||||
self.assertEqual(
|
||||
add_to_date(subscription.next_billing_period_start, months=1, days=-1),
|
||||
get_date_str(subscription.next_billing_period_end),
|
||||
add_to_date(subscription.current_invoice_start, months=1, days=-1),
|
||||
get_date_str(subscription.current_invoice_end),
|
||||
)
|
||||
self.assertEqual(subscription.invoices, [])
|
||||
self.assertEqual(subscription.status, "Trialing")
|
||||
@@ -50,8 +48,8 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription = create_subscription()
|
||||
self.assertEqual(subscription.trial_period_start, None)
|
||||
self.assertEqual(subscription.trial_period_end, None)
|
||||
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
# No invoice is created
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
@@ -68,12 +66,12 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
self.assertEqual(getdate(subscription.next_billing_period_start), getdate("2018-02-01"))
|
||||
self.assertEqual(getdate(subscription.next_billing_period_end), getdate("2018-02-28"))
|
||||
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(
|
||||
start_date="2018-01-01", generate_invoice_at="Prepaid (bill at period start)"
|
||||
start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
|
||||
)
|
||||
subscription.process(posting_date="2018-01-01") # generate first invoice
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
@@ -91,7 +89,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.process()
|
||||
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.next_billing_period_start, add_months(subscription.start_date, 1))
|
||||
self.assertEqual(subscription.current_invoice_start, add_months(subscription.start_date, 1))
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_subscription_cancel_after_grace_period(self):
|
||||
@@ -124,7 +122,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
_date = add_months(nowdate(), -1)
|
||||
subscription = create_subscription(start_date=_date, days_until_due=10)
|
||||
|
||||
subscription.process(posting_date=subscription.next_billing_period_end) # generate first invoice
|
||||
subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
@@ -136,7 +134,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
|
||||
subscription = create_subscription(start_date=add_days(nowdate(), -1000))
|
||||
|
||||
subscription.process(posting_date=subscription.next_billing_period_end) # generate first invoice
|
||||
subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
|
||||
self.assertEqual(subscription.status, "Grace Period")
|
||||
|
||||
subscription.process()
|
||||
@@ -156,20 +154,20 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription = create_subscription() # no changes expected
|
||||
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
subscription.process() # no changes expected still
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
subscription.process() # no changes expected yet still
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
def test_subscription_cancellation(self):
|
||||
@@ -193,18 +191,16 @@ class TestSubscription(ERPNextTestSuite):
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
diff = flt(date_diff(nowdate(), subscription.next_billing_period_start) + 1)
|
||||
plan_days = flt(
|
||||
date_diff(subscription.next_billing_period_end, subscription.next_billing_period_start) + 1
|
||||
)
|
||||
diff = flt(date_diff(nowdate(), subscription.current_invoice_start) + 1)
|
||||
plan_days = flt(date_diff(subscription.current_invoice_end, subscription.current_invoice_start) + 1)
|
||||
prorate_factor = flt(diff / plan_days)
|
||||
|
||||
self.assertEqual(
|
||||
flt(
|
||||
get_prorata_factor(
|
||||
subscription.next_billing_period_end,
|
||||
subscription.next_billing_period_start,
|
||||
cint(subscription.generate_invoice_at == "Prepaid (bill at period start)"),
|
||||
subscription.current_invoice_end,
|
||||
subscription.current_invoice_start,
|
||||
cint(subscription.generate_invoice_at == "Beginning of the current subscription period"),
|
||||
),
|
||||
2,
|
||||
),
|
||||
@@ -241,10 +237,8 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.cancel_subscription()
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
diff = flt(date_diff(nowdate(), subscription.next_billing_period_start) + 1)
|
||||
plan_days = flt(
|
||||
date_diff(subscription.next_billing_period_end, subscription.next_billing_period_start) + 1
|
||||
)
|
||||
diff = flt(date_diff(nowdate(), subscription.current_invoice_start) + 1)
|
||||
plan_days = flt(date_diff(subscription.current_invoice_end, subscription.current_invoice_start) + 1)
|
||||
prorate_factor = flt(diff / plan_days)
|
||||
|
||||
self.assertEqual(flt(invoice.grand_total, 2), flt(prorate_factor * 900, 2))
|
||||
@@ -309,9 +303,9 @@ class TestSubscription(ERPNextTestSuite):
|
||||
settings.save()
|
||||
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01", generate_invoice_at="Prepaid (bill at period start)"
|
||||
start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
|
||||
)
|
||||
subscription.process(subscription.next_billing_period_start) # generate first invoice
|
||||
subscription.process(subscription.current_invoice_start) # generate first invoice
|
||||
# This should change status to Unpaid since grace period is 0
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
@@ -323,7 +317,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
# A new invoice is generated
|
||||
subscription.process(posting_date=subscription.next_billing_period_start)
|
||||
subscription.process(posting_date=subscription.current_invoice_start)
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
settings.cancel_after_grace = default_grace_period_action
|
||||
@@ -360,7 +354,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
|
||||
# Change the subscription type to prebilled and process it.
|
||||
# Prepaid invoice should be generated
|
||||
subscription.generate_invoice_at = "Prepaid (bill at period start)"
|
||||
subscription.generate_invoice_at = "Beginning of the current subscription period"
|
||||
subscription.save()
|
||||
subscription.process()
|
||||
|
||||
@@ -372,7 +366,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
settings.prorate = 1
|
||||
settings.save()
|
||||
|
||||
subscription = create_subscription(generate_invoice_at="Prepaid (bill at period start)")
|
||||
subscription = create_subscription(generate_invoice_at="Beginning of the current subscription period")
|
||||
subscription.process()
|
||||
subscription.cancel_subscription()
|
||||
|
||||
@@ -393,7 +387,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.company = "_Test Company"
|
||||
subscription.party_type = "Supplier"
|
||||
subscription.party = "_Test Supplier"
|
||||
subscription.generate_invoice_at = "Prepaid (bill at period start)"
|
||||
subscription.generate_invoice_at = "Beginning of the current subscription period"
|
||||
subscription.follow_calendar_months = 1
|
||||
|
||||
# select subscription start date as "2018-01-15"
|
||||
@@ -419,7 +413,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
end_date="2018-12-31",
|
||||
party_type="Supplier",
|
||||
party="_Test Supplier",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_new_invoices_past_due_date=1,
|
||||
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
|
||||
)
|
||||
@@ -430,7 +424,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
def test_subscription_without_generate_invoice_past_due(self):
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
|
||||
)
|
||||
|
||||
@@ -448,7 +442,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
frappe.db.set_value("Customer", party, "default_currency", "USD")
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
plans=[{"plan": "_Test Plan Multicurrency", "qty": 1, "currency": "USD"}],
|
||||
party=party,
|
||||
)
|
||||
@@ -470,7 +464,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
frappe.db.set_value("Customer", party, "default_currency", "USD")
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
plans=[{"plan": "_Test Plan Multicurrency", "qty": 1, "currency": "USD"}],
|
||||
party=party,
|
||||
)
|
||||
@@ -523,7 +517,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription = create_subscription(
|
||||
start_date="2023-01-01",
|
||||
end_date="2023-02-28",
|
||||
generate_invoice_at="Bill N days before period start",
|
||||
generate_invoice_at="Days before the current subscription period",
|
||||
number_of_days=10,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
)
|
||||
@@ -561,7 +555,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
start_date=start_date,
|
||||
party_type="Supplier",
|
||||
party="_Test Supplier",
|
||||
generate_invoice_at="Bill N days before period start",
|
||||
generate_invoice_at="Days before the current subscription period",
|
||||
generate_new_invoices_past_due_date=1,
|
||||
number_of_days=2,
|
||||
plans=[{"plan": "_Test Plan Name 5", "qty": 1}],
|
||||
@@ -583,7 +577,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
end_date=add_days(start_date, 8),
|
||||
cancel_at_period_end=1,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
plans=[{"plan": "_Test plan name 10", "qty": 1}],
|
||||
)
|
||||
# Catch-up billing on creation generates every elapsed period and cancels at end
|
||||
@@ -604,7 +598,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
end_date=add_days(start_date, 6),
|
||||
cancel_at_period_end=1,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
plans=[{"plan": "_Test plan name 10", "qty": 1}],
|
||||
)
|
||||
|
||||
@@ -690,7 +684,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
end_date=end_date,
|
||||
party_type="Customer",
|
||||
party="_Test Customer",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_new_invoices_past_due_date=1,
|
||||
plans=[{"plan": "_Test Plan 3 Day", "qty": 1}],
|
||||
)
|
||||
@@ -719,7 +713,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
def test_status_updates_immediately_when_invoice_paid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
@@ -735,7 +729,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
def test_invoice_update_hook_refreshes_subscription_status(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
@@ -754,7 +748,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
# Test that payment entry → invoice → subscription status update chain works
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
@@ -777,33 +771,16 @@ class TestSubscription(ERPNextTestSuite):
|
||||
def test_first_invoice_generated_on_create_for_prepaid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_current_invoice_dates_reflect_latest_invoice(self):
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date="2018-01-01")
|
||||
invoice = subscription.get_current_invoice()
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(getdate(subscription.current_invoice_start), getdate(invoice.from_date))
|
||||
self.assertEqual(getdate(subscription.current_invoice_end), getdate(invoice.to_date))
|
||||
# `next_billing_period_start` tracks the next (unbilled) period.
|
||||
self.assertEqual(
|
||||
getdate(subscription.next_billing_period_start), getdate(add_days(invoice.to_date, 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="Prepaid (bill at period start)",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
self.assertEqual(subscription.status, "Trialing")
|
||||
@@ -813,7 +790,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
try:
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
finally:
|
||||
@@ -822,144 +799,10 @@ class TestSubscription(ERPNextTestSuite):
|
||||
def test_first_invoice_not_generated_for_future_dated_subscription(self):
|
||||
subscription = create_subscription(
|
||||
start_date=add_days(nowdate(), 10),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
def test_generate_invoice_at_migration_patch(self):
|
||||
from erpnext.patches.v16_0.migrate_subscription_generate_invoice_at import VALUE_MAP, execute
|
||||
|
||||
subscription = create_subscription(start_date=add_days(nowdate(), 10))
|
||||
for old_value, new_value in VALUE_MAP.items():
|
||||
frappe.db.set_value("Subscription", subscription.name, "generate_invoice_at", old_value)
|
||||
execute()
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Subscription", subscription.name, "generate_invoice_at"), new_value
|
||||
)
|
||||
|
||||
def test_next_billing_period_populated_for_prepaid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=add_days(nowdate(), 10),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
self.assertEqual(getdate(subscription.next_billing_period_start), getdate(add_days(nowdate(), 10)))
|
||||
self.assertGreater(
|
||||
getdate(subscription.next_billing_period_end), getdate(subscription.next_billing_period_start)
|
||||
)
|
||||
|
||||
def test_status_becomes_refunded_when_only_invoice_credited(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
make_full_credit_note(subscription.get_current_invoice().name)
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Refunded")
|
||||
|
||||
def test_status_stays_unpaid_when_one_of_two_invoices_credited(self):
|
||||
subscription = create_subscription(
|
||||
start_date=add_months(nowdate(), -2),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
)
|
||||
invoices = frappe.get_all(
|
||||
"Sales Invoice",
|
||||
filters={"subscription": subscription.name, "docstatus": 1, "is_return": 0},
|
||||
pluck="name",
|
||||
order_by="from_date asc",
|
||||
)
|
||||
self.assertGreaterEqual(len(invoices), 2)
|
||||
|
||||
make_full_credit_note(invoices[0])
|
||||
|
||||
subscription.reload()
|
||||
self.assertNotEqual(subscription.status, "Refunded")
|
||||
|
||||
def test_refunded_reverts_to_active_after_full_settlement(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
make_full_credit_note(invoice.name)
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Refunded")
|
||||
|
||||
invoice.db_set("status", "Paid")
|
||||
invoice.db_set("outstanding_amount", 0)
|
||||
subscription.process()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_heatmap_spans_twelve_months_from_start_month(self):
|
||||
start_date = getdate("2024-03-14")
|
||||
subscription = create_subscription(start_date=start_date)
|
||||
heatmap = subscription.get_billing_heatmap()
|
||||
self.assertEqual(getdate(heatmap[0]["date"]), get_first_day(start_date))
|
||||
self.assertEqual(
|
||||
getdate(heatmap[-1]["date"]), get_last_day(add_months(get_first_day(start_date), 11))
|
||||
)
|
||||
self.assertIn("status", heatmap[0])
|
||||
|
||||
def test_heatmap_marks_paid_days_green(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
invoice.db_set("status", "Paid")
|
||||
invoice.db_set("outstanding_amount", 0)
|
||||
|
||||
subscription.reload()
|
||||
cells = {cell["date"]: cell for cell in subscription.get_billing_heatmap()}
|
||||
self.assertEqual(cells[str(getdate(invoice.from_date))]["status"], "paid")
|
||||
|
||||
def test_heatmap_marks_future_planned_days(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
today = getdate(nowdate())
|
||||
planned = [
|
||||
cell
|
||||
for cell in subscription.get_billing_heatmap()
|
||||
if cell["status"] == "planned" and getdate(cell["date"]) > today
|
||||
]
|
||||
self.assertTrue(planned)
|
||||
|
||||
def test_heatmap_marks_refunded_days_for_credited_periods(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
make_full_credit_note(invoice.name)
|
||||
|
||||
subscription.reload()
|
||||
cells = {cell["date"]: cell for cell in subscription.get_billing_heatmap()}
|
||||
self.assertEqual(cells[str(getdate(invoice.from_date))]["status"], "refunded")
|
||||
|
||||
|
||||
def make_full_credit_note(invoice_name):
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
|
||||
|
||||
credit_note = make_sales_return(invoice_name)
|
||||
credit_note.insert()
|
||||
credit_note.submit()
|
||||
return credit_note
|
||||
|
||||
|
||||
def make_plans():
|
||||
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")
|
||||
|
||||
@@ -16,14 +16,12 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.supplier = "_Test Supplier"
|
||||
self.item = "_Test Item"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.cash = "Cash - _TC"
|
||||
self.debtors_usd = "_Test Receivable USD - _TC"
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_supplier()
|
||||
self.create_usd_receivable_account()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
|
||||
def create_sales_invoice(self, do_not_submit=False):
|
||||
si = create_sales_invoice(
|
||||
@@ -374,6 +372,7 @@ class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertEqual(so.advance_paid, 0)
|
||||
|
||||
def test_06_unreconcile_advance_from_payment_entry(self):
|
||||
self.enable_advance_as_liability()
|
||||
so1 = self.create_sales_order()
|
||||
so2 = self.create_sales_order()
|
||||
|
||||
@@ -424,11 +423,7 @@ class TestUnreconcilePayment(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.disable_advance_as_liability()
|
||||
|
||||
def test_07_adv_from_so_to_invoice(self):
|
||||
frappe.db.set_value("Company", self.company, "book_advance_payments_in_separate_party_account", True)
|
||||
frappe.db.set_value(
|
||||
"Company", self.company, "default_advance_received_account", "Advance Received - _TC"
|
||||
)
|
||||
|
||||
self.enable_advance_as_liability()
|
||||
so = self.create_sales_order()
|
||||
pe = self.create_payment_entry()
|
||||
pe.paid_amount = 1000
|
||||
|
||||
@@ -7,7 +7,7 @@ import frappe
|
||||
from frappe import _, qb
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder.functions import Abs, Max, Sum
|
||||
from frappe.query_builder.functions import Abs, Sum
|
||||
from frappe.utils.data import comma_and
|
||||
|
||||
from erpnext.accounts.utils import (
|
||||
@@ -72,7 +72,7 @@ class UnreconcilePayment(Document):
|
||||
alloc.party,
|
||||
)
|
||||
|
||||
frappe.db.set_value("Unreconcile Payment Entries", alloc.name, "unlinked", 1)
|
||||
frappe.db.set_value("Unreconcile Payment Entries", alloc.name, "unlinked", True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -120,20 +120,18 @@ def get_linked_payments_for_doc(
|
||||
res = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
Max(ple.account).as_("account"),
|
||||
Max(ple.party_type).as_("party_type"),
|
||||
Max(ple.party).as_("party"),
|
||||
Max(ple.company).as_("company"),
|
||||
Max(ple.voucher_type).as_("reference_doctype"),
|
||||
ple.account,
|
||||
ple.party_type,
|
||||
ple.party,
|
||||
ple.company,
|
||||
ple.voucher_type.as_("reference_doctype"),
|
||||
ple.voucher_no.as_("reference_name"),
|
||||
Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"),
|
||||
Max(ple.account_currency).as_("account_currency"),
|
||||
ple.account_currency,
|
||||
)
|
||||
.where(Criterion.all(criteria))
|
||||
.groupby(ple.voucher_no, ple.against_voucher_no)
|
||||
.having(Abs(Sum(ple.amount_in_account_currency)) > 0)
|
||||
# deterministic order across backends (postgres GROUP BY does not imply ordering)
|
||||
.orderby(ple.voucher_no)
|
||||
.having(qb.Field("allocated_amount") > 0)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
return res
|
||||
@@ -148,19 +146,17 @@ def get_linked_payments_for_doc(
|
||||
query = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
Max(ple.company).as_("company"),
|
||||
Max(ple.account).as_("account"),
|
||||
Max(ple.party_type).as_("party_type"),
|
||||
Max(ple.party).as_("party"),
|
||||
Max(ple.against_voucher_type).as_("reference_doctype"),
|
||||
ple.company,
|
||||
ple.account,
|
||||
ple.party_type,
|
||||
ple.party,
|
||||
ple.against_voucher_type.as_("reference_doctype"),
|
||||
ple.against_voucher_no.as_("reference_name"),
|
||||
Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"),
|
||||
Max(ple.account_currency).as_("account_currency"),
|
||||
ple.account_currency,
|
||||
)
|
||||
.where(Criterion.all(criteria))
|
||||
.groupby(ple.against_voucher_no)
|
||||
# deterministic order across backends (postgres GROUP BY does not imply ordering)
|
||||
.orderby(ple.against_voucher_no)
|
||||
)
|
||||
|
||||
res = query.run(as_dict=True)
|
||||
@@ -184,18 +180,15 @@ def get_linked_advances(company, docname):
|
||||
return (
|
||||
qb.from_(adv)
|
||||
.select(
|
||||
# non-grouped columns are constant per against_voucher_no -> Max() is unchanged and postgres-valid
|
||||
Max(adv.company).as_("company"),
|
||||
Max(adv.against_voucher_type).as_("reference_doctype"),
|
||||
adv.company,
|
||||
adv.against_voucher_type.as_("reference_doctype"),
|
||||
adv.against_voucher_no.as_("reference_name"),
|
||||
Abs(Sum(adv.amount)).as_("allocated_amount"),
|
||||
Max(adv.currency).as_("currency"),
|
||||
adv.currency,
|
||||
)
|
||||
.where(Criterion.all(criteria))
|
||||
.having(Abs(Sum(adv.amount)) > 0)
|
||||
.having(qb.Field("allocated_amount") > 0)
|
||||
.groupby(adv.against_voucher_no)
|
||||
# deterministic order across backends (postgres GROUP BY does not imply ordering)
|
||||
.orderby(adv.against_voucher_no)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
|
||||
@@ -509,6 +509,11 @@ def get_party_advance_account(party_type, party, company):
|
||||
return account
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_party_bank_account(party_type: str, party: str):
|
||||
return frappe.db.get_value("Bank Account", {"party_type": party_type, "party": party, "is_default": 1})
|
||||
|
||||
|
||||
def get_party_account_currency(party_type, party, company):
|
||||
def generator():
|
||||
party_account = get_party_account(party_type, party, company)
|
||||
@@ -543,19 +548,11 @@ def get_party_gle_currency(party_type, party, company):
|
||||
|
||||
def get_party_gle_account(party_type, party, company):
|
||||
def generator():
|
||||
gl = qb.DocType("GL Entry")
|
||||
existing_gle_account = (
|
||||
qb.from_(gl)
|
||||
.select(gl.account)
|
||||
.where(
|
||||
(gl.docstatus == 1)
|
||||
& (gl.company == company)
|
||||
& (gl.party_type == party_type)
|
||||
& (gl.party == party)
|
||||
& (gl.is_cancelled == 0)
|
||||
)
|
||||
.limit(1)
|
||||
.run()
|
||||
existing_gle_account = frappe.db.sql(
|
||||
"""select account from `tabGL Entry`
|
||||
where docstatus=1 and company=%(company)s and party_type=%(party_type)s and party=%(party)s
|
||||
limit 1""",
|
||||
{"company": company, "party_type": party_type, "party": party},
|
||||
)
|
||||
|
||||
return existing_gle_account[0][0] if existing_gle_account else None
|
||||
|
||||
@@ -9,10 +9,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.item = "_Test Item"
|
||||
self.supplier = "_Test Supplier 2"
|
||||
self.creditors_usd = "_Test Payable USD - _TC"
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.create_supplier(currency="USD", supplier_name="Test Supplier2")
|
||||
self.create_usd_payable_account()
|
||||
|
||||
def test_accounts_payable_for_foreign_currency_supplier(self):
|
||||
pi = self.create_purchase_invoice(do_not_submit=True)
|
||||
|
||||
@@ -7,7 +7,7 @@ from collections import OrderedDict
|
||||
import frappe
|
||||
from frappe import _, qb, query_builder, scrub
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder.functions import Date, Max, Substring, Sum
|
||||
from frappe.query_builder.functions import Date, Substring, Sum
|
||||
from frappe.utils import cint, cstr, flt, getdate, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
@@ -427,21 +427,32 @@ class ReceivablePayableReport:
|
||||
self.delivery_notes = frappe._dict()
|
||||
|
||||
# delivery note link inside sales invoice
|
||||
si_against_dn = frappe.get_all(
|
||||
"Sales Invoice Item",
|
||||
filters={"docstatus": 1, "parent": ["in", list(self.invoices)]},
|
||||
fields=["parent", "delivery_note"],
|
||||
# nosemgrep
|
||||
si_against_dn = frappe.db.sql(
|
||||
"""
|
||||
select parent, delivery_note
|
||||
from `tabSales Invoice Item`
|
||||
where docstatus=1 and parent in (%s)
|
||||
"""
|
||||
% (",".join(["%s"] * len(self.invoices))),
|
||||
tuple(self.invoices),
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
for d in si_against_dn:
|
||||
if d.delivery_note:
|
||||
self.delivery_notes.setdefault(d.parent, set()).add(d.delivery_note)
|
||||
|
||||
dn_against_si = frappe.get_all(
|
||||
"Delivery Note Item",
|
||||
filters={"against_sales_invoice": ["in", list(self.invoices)]},
|
||||
fields=["parent", "against_sales_invoice"],
|
||||
distinct=True,
|
||||
# nosemgrep
|
||||
dn_against_si = frappe.db.sql(
|
||||
"""
|
||||
select distinct parent, against_sales_invoice
|
||||
from `tabDelivery Note Item`
|
||||
where against_sales_invoice in (%s)
|
||||
"""
|
||||
% (",".join(["%s"] * len(self.invoices))),
|
||||
tuple(self.invoices),
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
for d in dn_against_si:
|
||||
@@ -465,10 +476,14 @@ class ReceivablePayableReport:
|
||||
|
||||
# Get Sales Team
|
||||
if self.filters.show_sales_person:
|
||||
sales_team = frappe.get_all(
|
||||
"Sales Team",
|
||||
filters={"parenttype": "Sales Invoice"},
|
||||
fields=["parent", "sales_person"],
|
||||
# nosemgrep
|
||||
sales_team = frappe.db.sql(
|
||||
"""
|
||||
select parent, sales_person
|
||||
from `tabSales Team`
|
||||
where parenttype = 'Sales Invoice'
|
||||
""",
|
||||
as_dict=1,
|
||||
)
|
||||
for d in sales_team:
|
||||
self.invoice_details.setdefault(d.parent, {}).setdefault("sales_team", []).append(
|
||||
@@ -533,31 +548,22 @@ class ReceivablePayableReport:
|
||||
|
||||
def get_payment_terms(self, row):
|
||||
# build payment_terms for row
|
||||
si = frappe.qb.DocType(row.voucher_type)
|
||||
ps = frappe.qb.DocType("Payment Schedule")
|
||||
payment_terms_details = (
|
||||
frappe.qb.from_(si)
|
||||
.inner_join(ps)
|
||||
.on(si.name == ps.parent)
|
||||
.select(
|
||||
si.name,
|
||||
si.party_account_currency,
|
||||
si.currency,
|
||||
si.conversion_rate,
|
||||
si.total_advance,
|
||||
ps.due_date,
|
||||
ps.payment_term,
|
||||
ps.payment_amount,
|
||||
ps.base_payment_amount,
|
||||
ps.description,
|
||||
ps.paid_amount,
|
||||
ps.base_paid_amount,
|
||||
ps.discounted_amount,
|
||||
)
|
||||
.where((ps.parenttype == row.voucher_type) & (si.name == row.voucher_no) & (si.is_return == 0))
|
||||
.orderby(ps.paid_amount, order=frappe.qb.desc)
|
||||
.orderby(ps.due_date)
|
||||
.run(as_dict=1)
|
||||
# nosemgrep
|
||||
payment_terms_details = frappe.db.sql(
|
||||
f"""
|
||||
select
|
||||
si.name, si.party_account_currency, si.currency, si.conversion_rate,
|
||||
si.total_advance, ps.due_date, ps.payment_term, ps.payment_amount, ps.base_payment_amount,
|
||||
ps.description, ps.paid_amount, ps.base_paid_amount, ps.discounted_amount
|
||||
from `tab{row.voucher_type}` si, `tabPayment Schedule` ps
|
||||
where
|
||||
si.name = ps.parent and ps.parenttype = '{row.voucher_type}' and
|
||||
si.name = %s and
|
||||
si.is_return = 0
|
||||
order by ps.paid_amount desc, due_date
|
||||
""",
|
||||
row.voucher_no,
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
original_row = frappe._dict(row)
|
||||
@@ -655,6 +661,7 @@ class ReceivablePayableReport:
|
||||
def get_future_payments_from_payment_entry(self):
|
||||
pe = frappe.qb.DocType("Payment Entry")
|
||||
pe_ref = frappe.qb.DocType("Payment Entry Reference")
|
||||
ifelse = query_builder.CustomFunction("IF", ["condition", "then", "else"])
|
||||
|
||||
return (
|
||||
frappe.qb.from_(pe)
|
||||
@@ -667,14 +674,11 @@ class ReceivablePayableReport:
|
||||
(pe.posting_date).as_("future_date"),
|
||||
(pe_ref.allocated_amount).as_("future_amount"),
|
||||
(pe.reference_no).as_("future_ref"),
|
||||
# CASE is portable; MySQL's IF() does not exist on postgres
|
||||
query_builder.Case()
|
||||
.when(
|
||||
ifelse(
|
||||
pe.payment_type == "Receive",
|
||||
pe.source_exchange_rate * pe_ref.allocated_amount,
|
||||
)
|
||||
.else_(pe.target_exchange_rate * pe_ref.allocated_amount)
|
||||
.as_("future_amount_in_base_currency"),
|
||||
pe.target_exchange_rate * pe_ref.allocated_amount,
|
||||
).as_("future_amount_in_base_currency"),
|
||||
)
|
||||
.where(
|
||||
(pe.docstatus < 2)
|
||||
@@ -691,13 +695,11 @@ class ReceivablePayableReport:
|
||||
.inner_join(jea)
|
||||
.on(jea.parent == je.name)
|
||||
.select(
|
||||
# Sum() below makes this an implicit aggregate (no GROUP BY); the non-aggregated columns
|
||||
# are arbitrary per the single group on MySQL -> Max() keeps it valid on postgres.
|
||||
Max(jea.reference_name).as_("invoice_no"),
|
||||
Max(jea.party).as_("party"),
|
||||
Max(jea.party_type).as_("party_type"),
|
||||
Max(je.posting_date).as_("future_date"),
|
||||
Max(je.cheque_no).as_("future_ref"),
|
||||
jea.reference_name.as_("invoice_no"),
|
||||
jea.party,
|
||||
jea.party_type,
|
||||
je.posting_date.as_("future_date"),
|
||||
je.cheque_no.as_("future_ref"),
|
||||
)
|
||||
.where(
|
||||
(je.docstatus < 2)
|
||||
@@ -710,25 +712,30 @@ class ReceivablePayableReport:
|
||||
|
||||
if self.filters.get("party"):
|
||||
if self.account_type == "Payable":
|
||||
future_amount = Sum(jea.debit_in_account_currency - jea.credit_in_account_currency)
|
||||
future_amount_in_base_currency = Sum(jea.debit - jea.credit)
|
||||
query = query.select(
|
||||
Sum(jea.debit_in_account_currency - jea.credit_in_account_currency).as_("future_amount")
|
||||
)
|
||||
query = query.select(Sum(jea.debit - jea.credit).as_("future_amount_in_base_currency"))
|
||||
else:
|
||||
future_amount = Sum(jea.credit_in_account_currency - jea.debit_in_account_currency)
|
||||
future_amount_in_base_currency = Sum(jea.credit - jea.debit)
|
||||
query = query.select(
|
||||
Sum(jea.credit_in_account_currency - jea.debit_in_account_currency).as_("future_amount")
|
||||
)
|
||||
query = query.select(Sum(jea.credit - jea.debit).as_("future_amount_in_base_currency"))
|
||||
else:
|
||||
future_amount_in_base_currency = Sum(jea.debit if self.account_type == "Payable" else jea.credit)
|
||||
future_amount = Sum(
|
||||
jea.debit_in_account_currency
|
||||
if self.account_type == "Payable"
|
||||
else jea.credit_in_account_currency
|
||||
query = query.select(
|
||||
Sum(jea.debit if self.account_type == "Payable" else jea.credit).as_(
|
||||
"future_amount_in_base_currency"
|
||||
)
|
||||
)
|
||||
query = query.select(
|
||||
Sum(
|
||||
jea.debit_in_account_currency
|
||||
if self.account_type == "Payable"
|
||||
else jea.credit_in_account_currency
|
||||
).as_("future_amount")
|
||||
)
|
||||
|
||||
query = query.select(
|
||||
future_amount.as_("future_amount"),
|
||||
future_amount_in_base_currency.as_("future_amount_in_base_currency"),
|
||||
)
|
||||
# use the aggregate expression in HAVING; postgres can't reference a SELECT alias there
|
||||
query = query.having(future_amount > 0)
|
||||
query = query.having(qb.Field("future_amount") > 0)
|
||||
return query.run(as_dict=True)
|
||||
|
||||
def allocate_future_payments(self, row):
|
||||
@@ -884,19 +891,16 @@ class ReceivablePayableReport:
|
||||
if self.filters.get("sales_person"):
|
||||
lft, rgt = frappe.db.get_value("Sales Person", self.filters.get("sales_person"), ["lft", "rgt"])
|
||||
|
||||
steam = frappe.qb.DocType("Sales Team")
|
||||
sp = frappe.qb.DocType("Sales Person")
|
||||
records = (
|
||||
frappe.qb.from_(steam)
|
||||
.select(steam.parent, steam.parenttype)
|
||||
.distinct()
|
||||
.where(
|
||||
steam.parenttype.isin(["Customer", "Sales Invoice"])
|
||||
& steam.sales_person.isin(
|
||||
frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt))
|
||||
)
|
||||
)
|
||||
.run(as_dict=1)
|
||||
# nosemgrep
|
||||
records = frappe.db.sql(
|
||||
"""
|
||||
select distinct parent, parenttype
|
||||
from `tabSales Team` steam
|
||||
where parenttype in ('Customer', 'Sales Invoice')
|
||||
and exists(select name from `tabSales Person` where lft >= %s and rgt <= %s and name = steam.sales_person)
|
||||
""",
|
||||
(lft, rgt),
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
self.sales_person_records = frappe._dict()
|
||||
@@ -923,28 +927,8 @@ class ReceivablePayableReport:
|
||||
if self.filters.project:
|
||||
self.qb_selection_filter.append(self.ple.project.isin(self.filters.project))
|
||||
|
||||
self.add_user_permission_filters()
|
||||
|
||||
self.add_accounting_dimensions_filters()
|
||||
|
||||
def add_user_permission_filters(self):
|
||||
# Party is a dynamic link, so match conditions cannot auto-apply Customer/Supplier user permissions
|
||||
from frappe.core.doctype.user_permission.user_permission import get_user_permissions
|
||||
from frappe.permissions import get_allowed_docs_for_doctype
|
||||
|
||||
user_permissions = get_user_permissions()
|
||||
if not user_permissions:
|
||||
return
|
||||
|
||||
for party_type in self.party_type:
|
||||
if party_type not in user_permissions:
|
||||
continue
|
||||
|
||||
allowed_parties = get_allowed_docs_for_doctype(user_permissions[party_type], party_type)
|
||||
self.qb_selection_filter.append(
|
||||
(self.ple.party_type != party_type) | self.ple.party.isin(allowed_parties or [""])
|
||||
)
|
||||
|
||||
def get_cost_center_conditions(self):
|
||||
cost_center_list = get_cost_centers_with_children(self.filters.cost_center)
|
||||
self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list))
|
||||
|
||||
@@ -12,17 +12,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.company_abbr = "_TC"
|
||||
self.customer = "_Test Customer"
|
||||
self.item = "_Test Item"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.warehouse = "Stores - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.expense_account = "Cost of Goods Sold - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cash = "Cash - _TC"
|
||||
self.debtors_usd = "_Test Receivable USD - _TC"
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.create_usd_receivable_account()
|
||||
self.clear_old_entries()
|
||||
|
||||
def create_sales_invoice(self, no_payment_schedule=False, do_not_submit=False, **args):
|
||||
frappe.set_user("Administrator")
|
||||
@@ -1249,44 +1243,3 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertEqual(len(report[1]), 1)
|
||||
row = report[1][0]
|
||||
self.assertEqual([si.name, project.name, 60], [row.voucher_no, row.project, row.outstanding])
|
||||
|
||||
def test_accounts_receivable_respects_user_permissions(self):
|
||||
# Party is a dynamic link on Payment Ledger Entry, so user permissions on Customer
|
||||
# must be applied explicitly. The report should only show permitted customers.
|
||||
original_customer = self.customer
|
||||
second_customer = "_Test AR Perm Customer"
|
||||
|
||||
# create_customer overrides self.customer, so build the restricted invoice first
|
||||
self.create_customer(customer_name=second_customer)
|
||||
self.create_sales_invoice(no_payment_schedule=True)
|
||||
|
||||
self.customer = original_customer
|
||||
allowed_invoice = self.create_sales_invoice(no_payment_schedule=True)
|
||||
|
||||
test_user = "test_ar_user_permission@example.com"
|
||||
if not frappe.db.exists("User", test_user):
|
||||
user = frappe.new_doc("User")
|
||||
user.email = test_user
|
||||
user.first_name = "AR Perm"
|
||||
user.append("roles", {"role": "Accounts User"})
|
||||
user.save()
|
||||
|
||||
frappe.permissions.add_user_permission("Customer", original_customer, test_user)
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Customer",
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
|
||||
frappe.set_user(test_user)
|
||||
try:
|
||||
report = execute(filters)
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
parties = {row.party for row in report[1]}
|
||||
self.assertIn(original_customer, parties)
|
||||
self.assertNotIn(second_customer, parties)
|
||||
self.assertEqual(allowed_invoice.customer, original_customer)
|
||||
|
||||
@@ -11,11 +11,10 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.maxDiff = None
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.item = "_Test Item"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
|
||||
def test_01_receivable_summary_output(self):
|
||||
"""
|
||||
|
||||
@@ -15,7 +15,10 @@ def execute(filters=None):
|
||||
|
||||
def get_data(filters):
|
||||
data = []
|
||||
depreciation_accounts = frappe.get_all("Account", filters={"account_type": "Depreciation"}, pluck="name")
|
||||
depreciation_accounts = frappe.db.sql_list(
|
||||
""" select name from tabAccount
|
||||
where ifnull(account_type, '') = 'Depreciation' """
|
||||
)
|
||||
|
||||
filters_data = [
|
||||
["company", "=", filters.get("company")],
|
||||
@@ -30,8 +33,10 @@ def get_data(filters):
|
||||
filters_data.append(["against_voucher", "=", filters.get("asset")])
|
||||
|
||||
if filters.get("asset_category"):
|
||||
assets = frappe.get_all(
|
||||
"Asset", filters={"asset_category": filters.get("asset_category"), "docstatus": 1}, pluck="name"
|
||||
assets = frappe.db.sql_list(
|
||||
"""select name from tabAsset
|
||||
where asset_category = %s and docstatus=1""",
|
||||
filters.get("asset_category"),
|
||||
)
|
||||
|
||||
filters_data.append(["against_voucher", "in", assets])
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.report.asset_depreciation_ledger.asset_depreciation_ledger import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestAssetDepreciationLedger(ERPNextTestSuite):
|
||||
def test_report_executes(self):
|
||||
# Smoke-guards the raw-SQL -> query-builder port: the report query must compile and run on
|
||||
# both MariaDB and postgres.
|
||||
company = frappe.db.get_value("Company", {}, "name")
|
||||
columns, *_rest = execute(
|
||||
frappe._dict({"company": company, "from_date": "2020-01-01", "to_date": "2030-12-31"})
|
||||
)
|
||||
self.assertTrue(columns)
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.custom import MonthName
|
||||
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
|
||||
@@ -113,6 +113,7 @@ def build_budget_map(budget_records, filters):
|
||||
|
||||
def get_actual_transactions(dimension_name, filters):
|
||||
budget_against = frappe.scrub(filters.get("budget_against"))
|
||||
monthname = CustomFunction("MONTHNAME", ["date"])
|
||||
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
budget = frappe.qb.DocType("Budget")
|
||||
@@ -125,7 +126,7 @@ def get_actual_transactions(dimension_name, filters):
|
||||
gle.debit,
|
||||
gle.credit,
|
||||
gle.fiscal_year,
|
||||
MonthName(gle.posting_date).as_("month_name"),
|
||||
monthname(gle.posting_date).as_("month_name"),
|
||||
budget[budget_against].as_("budget_against"),
|
||||
)
|
||||
.where(
|
||||
@@ -136,10 +137,7 @@ def get_actual_transactions(dimension_name, filters):
|
||||
& (gle.is_cancelled == 0)
|
||||
& (budget[budget_against] == dimension_name)
|
||||
)
|
||||
# budget[budget_against] is selected from the Budget table, which is not functionally
|
||||
# dependent on the grouped GL Entry PK, so postgres requires it in the GROUP BY. The WHERE
|
||||
# pins it to dimension_name (a constant), so grouping by it does not change the result.
|
||||
.groupby(gle.name, budget[budget_against])
|
||||
.groupby(gle.name)
|
||||
.orderby(gle.fiscal_year)
|
||||
)
|
||||
|
||||
@@ -159,11 +157,15 @@ def get_actual_transactions(dimension_name, filters):
|
||||
|
||||
|
||||
def get_budget_distributions(budget):
|
||||
return frappe.get_all(
|
||||
"Budget Distribution",
|
||||
filters={"parent": budget.name},
|
||||
fields=["start_date", "end_date", "amount", "percent"],
|
||||
order_by="start_date asc",
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
SELECT start_date, end_date, amount, percent
|
||||
FROM `tabBudget Distribution`
|
||||
WHERE parent = %s
|
||||
ORDER BY start_date ASC
|
||||
""",
|
||||
(budget.name,),
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -349,16 +351,20 @@ def get_columns(filters):
|
||||
|
||||
|
||||
def get_fiscal_years(filters):
|
||||
return frappe.get_all(
|
||||
"Fiscal Year",
|
||||
filters={"name": ["between", [filters["from_fiscal_year"], filters["to_fiscal_year"]]]},
|
||||
fields=["name"],
|
||||
# the raw query had no ORDER BY (de-facto oldest-first); get_all would otherwise apply the
|
||||
# Fiscal Year doctype default (name DESC) and reverse column order / cumulative-mode values.
|
||||
order_by="name asc",
|
||||
as_list=True,
|
||||
fiscal_year = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
name
|
||||
from
|
||||
`tabFiscal Year`
|
||||
where
|
||||
name between %(from_fiscal_year)s and %(to_fiscal_year)s
|
||||
""",
|
||||
{"from_fiscal_year": filters["from_fiscal_year"], "to_fiscal_year": filters["to_fiscal_year"]},
|
||||
)
|
||||
|
||||
return fiscal_year
|
||||
|
||||
|
||||
def get_cost_center_with_children(cost_centers):
|
||||
"""Expand each cost center to include itself and all its descendants."""
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.report.budget_variance_report.budget_variance_report import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestBudgetVarianceReport(ERPNextTestSuite):
|
||||
def test_report_executes(self):
|
||||
# Smoke-guards the raw-SQL -> query-builder port: the report query must compile and run on
|
||||
# both MariaDB and postgres.
|
||||
company = frappe.db.get_value("Company", {}, "name")
|
||||
fy = frappe.db.get_value("Fiscal Year", {}, "name", order_by="year_start_date desc")
|
||||
columns, *_rest = execute(
|
||||
frappe._dict(
|
||||
{
|
||||
"company": company,
|
||||
"from_fiscal_year": fy,
|
||||
"to_fiscal_year": fy,
|
||||
"period": "Yearly",
|
||||
"budget_against": "Cost Center",
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertTrue(columns)
|
||||
@@ -7,7 +7,6 @@ from datetime import timedelta
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cstr, flt
|
||||
from pypika import Order
|
||||
|
||||
@@ -214,43 +213,37 @@ def get_account_type_based_data(company, account_type, period_list, accumulated_
|
||||
|
||||
|
||||
def get_account_type_based_gl_data(company, filters=None):
|
||||
cond = ""
|
||||
filters = frappe._dict(filters or {})
|
||||
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
account = frappe.qb.DocType("Account")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(Sum(gle.credit) - Sum(gle.debit))
|
||||
.where(
|
||||
(gle.company == company)
|
||||
& (gle.posting_date >= filters.start_date)
|
||||
& (gle.posting_date <= filters.end_date)
|
||||
& (gle.voucher_type != "Period Closing Voucher")
|
||||
& gle.account.isin(
|
||||
frappe.qb.from_(account)
|
||||
.select(account.name)
|
||||
.where(account.account_type == filters.account_type)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if filters.include_default_book_entries:
|
||||
company_fb = frappe.get_cached_value("Company", company, "default_finance_book")
|
||||
query = query.where(
|
||||
gle.finance_book.isin([filters.finance_book, company_fb, ""]) | gle.finance_book.isnull()
|
||||
cond = """ AND (finance_book in ({}, {}, '') OR finance_book IS NULL)
|
||||
""".format(
|
||||
frappe.db.escape(filters.finance_book),
|
||||
frappe.db.escape(company_fb),
|
||||
)
|
||||
else:
|
||||
query = query.where(
|
||||
gle.finance_book.isin([cstr(filters.finance_book), ""]) | gle.finance_book.isnull()
|
||||
cond = " AND (finance_book in (%s, '') OR finance_book IS NULL)" % (
|
||||
frappe.db.escape(cstr(filters.finance_book))
|
||||
)
|
||||
|
||||
if filters.get("cost_center"):
|
||||
cost_centers = get_cost_centers_with_children(filters.cost_center)
|
||||
query = query.where(gle.cost_center.isin(cost_centers))
|
||||
filters.cost_center = get_cost_centers_with_children(filters.cost_center)
|
||||
cond += " and cost_center in %(cost_center)s"
|
||||
|
||||
gl_sum = query.run()
|
||||
return gl_sum[0][0] if gl_sum and gl_sum[0][0] else 0
|
||||
gl_sum = frappe.db.sql_list(
|
||||
f"""
|
||||
select sum(credit) - sum(debit)
|
||||
from `tabGL Entry`
|
||||
where company=%(company)s and posting_date >= %(start_date)s and posting_date <= %(end_date)s
|
||||
and voucher_type != 'Period Closing Voucher'
|
||||
and account in ( SELECT name FROM tabAccount WHERE account_type = %(account_type)s) {cond}
|
||||
""",
|
||||
filters,
|
||||
)
|
||||
|
||||
return gl_sum[0] if gl_sum and gl_sum[0] else 0
|
||||
|
||||
|
||||
def get_start_date(period, accumulated_values, company):
|
||||
@@ -374,10 +367,11 @@ def get_net_income(company, period_list, filters):
|
||||
from_date, to_date = get_opening_range_using_fiscal_year(company, period_list)
|
||||
|
||||
for root_type in ["Income", "Expense"]:
|
||||
for root in frappe.get_all(
|
||||
"Account",
|
||||
filters={"root_type": root_type, "parent_account": ["is", "not set"]},
|
||||
fields=["lft", "rgt"],
|
||||
for root in frappe.db.sql(
|
||||
"""select lft, rgt from tabAccount
|
||||
where root_type=%s and ifnull(parent_account, '') = ''""",
|
||||
root_type,
|
||||
as_dict=1,
|
||||
):
|
||||
set_gl_entries_by_account(
|
||||
company,
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.report.cash_flow.cash_flow import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestCashFlow(ERPNextTestSuite):
|
||||
def test_report_executes(self):
|
||||
# Smoke-guards the raw-SQL -> query-builder port: the report query must compile and run on
|
||||
# both MariaDB and postgres.
|
||||
company = frappe.db.get_value("Company", {}, "name")
|
||||
fy = frappe.db.get_value("Fiscal Year", {}, "name", order_by="year_start_date desc")
|
||||
columns, *_rest = execute(
|
||||
frappe._dict(
|
||||
{
|
||||
"company": company,
|
||||
"from_fiscal_year": fy,
|
||||
"to_fiscal_year": fy,
|
||||
"filter_based_on": "Fiscal Year",
|
||||
"periodicity": "Yearly",
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertTrue(columns)
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _, qb
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder import CustomFunction
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ def get_amounts_not_reflected_in_system_for_bank_reconciliation_statement(filter
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
ifelse = CustomFunction("IF", ["condition", "then", "else"])
|
||||
pe = qb.DocType("Payment Entry")
|
||||
doctype_name = ConstantColumn("Payment Entry")
|
||||
payments = (
|
||||
@@ -100,10 +101,7 @@ def get_amounts_not_reflected_in_system_for_bank_reconciliation_statement(filter
|
||||
.select(
|
||||
doctype_name.as_("doctype"),
|
||||
pe.name,
|
||||
Case()
|
||||
.when(pe.paid_from.eq(filters.account), pe.paid_amount)
|
||||
.else_(pe.received_amount)
|
||||
.as_("amount"),
|
||||
ifelse(pe.paid_from.eq(filters.account), pe.paid_amount, pe.received_amount).as_("amount"),
|
||||
pe.payment_type,
|
||||
pe.party_type,
|
||||
pe.posting_date,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import nowdate
|
||||
|
||||
from erpnext.accounts.report.cheques_and_deposits_incorrectly_cleared.cheques_and_deposits_incorrectly_cleared import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestChequesAndDepositsIncorrectlyCleared(ERPNextTestSuite):
|
||||
def test_report_executes_with_case_amount(self):
|
||||
# Exercises the Payment Entry branch whose amount column uses a db-aware CASE expression
|
||||
# (previously a MySQL-only IF()). IF() does not compile on postgres, so running the report
|
||||
# query guards the portability fix on both databases.
|
||||
company = frappe.db.get_value("Company", {}, "name")
|
||||
account = frappe.db.get_value(
|
||||
"Account", {"account_type": "Bank", "company": company, "is_group": 0}, "name"
|
||||
)
|
||||
columns, data = execute(frappe._dict({"account": account, "report_date": nowdate()}))
|
||||
self.assertTrue(columns)
|
||||
self.assertIsInstance(data, list)
|
||||
@@ -347,10 +347,11 @@ def get_data(companies, root_type, balance_must_be, fiscal_year, filters=None, i
|
||||
filters.end_date = end_date
|
||||
|
||||
gl_entries_by_account = {}
|
||||
for root in frappe.get_all(
|
||||
"Account",
|
||||
filters={"root_type": root_type, "parent_account": ["is", "not set"]},
|
||||
fields=["lft", "rgt"],
|
||||
for root in frappe.db.sql(
|
||||
"""select lft, rgt from tabAccount
|
||||
where root_type=%s and ifnull(parent_account, '') = ''""",
|
||||
root_type,
|
||||
as_dict=1,
|
||||
):
|
||||
set_gl_entries_by_account(
|
||||
start_date,
|
||||
@@ -511,11 +512,9 @@ def get_companies(filters):
|
||||
def get_subsidiary_companies(company):
|
||||
lft, rgt = frappe.get_cached_value("Company", company, ["lft", "rgt"])
|
||||
|
||||
return frappe.get_all(
|
||||
"Company",
|
||||
filters={"lft": [">=", lft], "rgt": ["<=", rgt]},
|
||||
pluck="name",
|
||||
order_by="lft, rgt",
|
||||
return frappe.db.sql_list(
|
||||
f"""select name from `tabCompany`
|
||||
where lft >= {lft} and rgt <= {rgt} order by lft, rgt"""
|
||||
)
|
||||
|
||||
|
||||
@@ -605,10 +604,14 @@ def set_gl_entries_by_account(
|
||||
|
||||
company_lft, company_rgt = frappe.get_cached_value("Company", filters.get("company"), ["lft", "rgt"])
|
||||
|
||||
companies = frappe.get_all(
|
||||
"Company",
|
||||
filters={"lft": [">=", company_lft], "rgt": ["<=", company_rgt]},
|
||||
fields=["name", "default_currency"],
|
||||
companies = frappe.db.sql(
|
||||
""" select name, default_currency from `tabCompany`
|
||||
where lft >= %(company_lft)s and rgt <= %(company_rgt)s""",
|
||||
{
|
||||
"company_lft": company_lft,
|
||||
"company_rgt": company_rgt,
|
||||
},
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
currency_info = frappe._dict(
|
||||
|
||||
@@ -126,22 +126,12 @@ def get_data(filters) -> list[list]:
|
||||
|
||||
|
||||
def get_company_wise_tb_data(filters, reporting_currency, ignore_reporting_currency):
|
||||
accounts = frappe.get_all(
|
||||
"Account",
|
||||
filters={"company": filters.company},
|
||||
fields=[
|
||||
"name",
|
||||
"account_number",
|
||||
"parent_account",
|
||||
"account_name",
|
||||
"root_type",
|
||||
"report_type",
|
||||
"account_type",
|
||||
"is_group",
|
||||
"lft",
|
||||
"rgt",
|
||||
],
|
||||
order_by="lft",
|
||||
accounts = frappe.db.sql(
|
||||
"""select name, account_number, parent_account, account_name, root_type, report_type, account_type, is_group, lft, rgt
|
||||
|
||||
from `tabAccount` where company=%s order by lft""",
|
||||
filters.company,
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
|
||||
|
||||
@@ -11,12 +11,10 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestCustomerLedgerSummary(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.customer = "_Test Customer"
|
||||
self.item = "_Test Item"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.cash = "Cash - _TC"
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
|
||||
def create_sales_invoice(self, do_not_submit=False, **args):
|
||||
si = create_sales_invoice(
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _, qb
|
||||
from frappe.query_builder import functions
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder import Column, functions
|
||||
from frappe.utils import add_days, date_diff, flt, get_first_day, get_last_day, getdate, rounded
|
||||
|
||||
from erpnext.accounts.report.financial_statements import get_period_list
|
||||
@@ -301,10 +300,8 @@ class Deferred_Revenue_and_Expense_Report:
|
||||
Get all sales and purchase invoices which has deferred revenue/expense items
|
||||
"""
|
||||
gle = qb.DocType("GL Entry")
|
||||
# a literal marker: real GL rows are "posted" (dummy/simulated future entries use "not").
|
||||
# ConstantColumn renders a single-quoted string literal, valid on both backends -- a plain
|
||||
# Column rendered as "posted", which MySQL reads as the string but postgres as an identifier.
|
||||
posted = ConstantColumn("posted").as_("posted")
|
||||
# column doesn't have an alias option
|
||||
posted = Column("posted")
|
||||
|
||||
if self.filters.type == "Revenue":
|
||||
inv = qb.DocType("Sales Invoice")
|
||||
@@ -330,15 +327,13 @@ class Deferred_Revenue_and_Expense_Report:
|
||||
)
|
||||
.select(
|
||||
inv.name.as_("doc"),
|
||||
# non-grouped columns are constant per grouped invoice / invoice item -> Max() keeps the
|
||||
# GROUP BY valid on postgres while returning the same value MySQL picked.
|
||||
functions.Max(inv.posting_date).as_("posting_date"),
|
||||
inv.posting_date,
|
||||
inv_item.name.as_("item"),
|
||||
functions.Max(inv_item.item_name).as_("item_name"),
|
||||
functions.Max(inv_item.service_start_date).as_("service_start_date"),
|
||||
functions.Max(inv_item.service_end_date).as_("service_end_date"),
|
||||
functions.Max(inv_item.base_net_amount).as_("base_net_amount"),
|
||||
functions.Max(deferred_account_field).as_(deferred_account_field.name),
|
||||
inv_item.item_name,
|
||||
inv_item.service_start_date,
|
||||
inv_item.service_end_date,
|
||||
inv_item.base_net_amount,
|
||||
deferred_account_field,
|
||||
gle.posting_date.as_("gle_posting_date"),
|
||||
functions.Sum(gle.debit).as_("debit"),
|
||||
functions.Sum(gle.credit).as_("credit"),
|
||||
|
||||
@@ -61,16 +61,11 @@ class TestDeferredRevenueAndExpense(ERPNextTestSuite, AccountsTestMixin):
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.company_abbr = "_TC"
|
||||
self.customer = "_Test Customer"
|
||||
self.supplier = "_Test Supplier"
|
||||
self.warehouse = "Stores - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.expense_account = "Cost of Goods Sold - _TC"
|
||||
self.create_company()
|
||||
self.create_customer("_Test Customer")
|
||||
self.create_supplier("_Test Furniture Supplier")
|
||||
self.setup_deferred_accounts_and_items()
|
||||
self.clear_old_entries()
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"book_deferred_entries_based_on": "Months"})
|
||||
def test_deferred_revenue(self):
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt
|
||||
from frappe.utils import cstr, flt
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.report.financial_statements import (
|
||||
@@ -31,23 +31,18 @@ def execute(filters=None):
|
||||
def get_data(filters, dimension_list):
|
||||
company_currency = erpnext.get_company_currency(filters.company)
|
||||
|
||||
acc = frappe.get_all(
|
||||
"Account",
|
||||
filters={"company": filters.company},
|
||||
fields=[
|
||||
"name",
|
||||
"account_number",
|
||||
"parent_account",
|
||||
"lft",
|
||||
"rgt",
|
||||
"root_type",
|
||||
"report_type",
|
||||
"account_name",
|
||||
"include_in_gross",
|
||||
"account_type",
|
||||
"is_group",
|
||||
],
|
||||
order_by="lft",
|
||||
acc = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
name, account_number, parent_account, lft, rgt, root_type,
|
||||
report_type, account_name, include_in_gross, account_type, is_group
|
||||
from
|
||||
`tabAccount`
|
||||
where
|
||||
company=%s
|
||||
order by lft""",
|
||||
(filters.company),
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
if not acc:
|
||||
@@ -55,17 +50,16 @@ def get_data(filters, dimension_list):
|
||||
|
||||
accounts, accounts_by_name, parent_children_map = filter_accounts(acc)
|
||||
|
||||
lft_rgt = frappe.get_all(
|
||||
"Account",
|
||||
filters={"company": filters.company},
|
||||
fields=[{"MIN": "lft", "as": "min_lft"}, {"MAX": "rgt", "as": "max_rgt"}],
|
||||
min_lft, max_rgt = frappe.db.sql(
|
||||
"""select min(lft), max(rgt) from `tabAccount`
|
||||
where company=%s""",
|
||||
(filters.company),
|
||||
)[0]
|
||||
min_lft, max_rgt = lft_rgt.min_lft, lft_rgt.max_rgt
|
||||
|
||||
account = frappe.get_all(
|
||||
"Account",
|
||||
filters={"lft": [">=", min_lft], "rgt": ["<=", max_rgt], "company": filters.company},
|
||||
pluck="name",
|
||||
account = frappe.db.sql_list(
|
||||
"""select name from `tabAccount`
|
||||
where lft >= %s and rgt <= %s and company = %s""",
|
||||
(min_lft, max_rgt, filters.company),
|
||||
)
|
||||
|
||||
gl_entries_by_account = {}
|
||||
@@ -81,34 +75,42 @@ def get_data(filters, dimension_list):
|
||||
|
||||
|
||||
def set_gl_entries_by_account(dimension_list, filters, account, gl_entries_by_account):
|
||||
dimension_field = frappe.scrub(filters.get("dimension"))
|
||||
condition = get_condition(filters.get("dimension"))
|
||||
|
||||
if account:
|
||||
condition += " and account in ({})".format(", ".join([frappe.db.escape(d) for d in account]))
|
||||
|
||||
gl_filters = {
|
||||
"company": filters.get("company"),
|
||||
dimension_field: ["in", list(set(dimension_list))],
|
||||
"posting_date": ["between", [filters.get("from_date"), filters.get("to_date")]],
|
||||
"is_cancelled": 0,
|
||||
"from_date": filters.get("from_date"),
|
||||
"to_date": filters.get("to_date"),
|
||||
"finance_book": cstr(filters.get("finance_book")),
|
||||
}
|
||||
if account:
|
||||
gl_filters["account"] = ["in", account]
|
||||
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters=gl_filters,
|
||||
fields=[
|
||||
"posting_date",
|
||||
"account",
|
||||
dimension_field,
|
||||
"debit",
|
||||
"credit",
|
||||
"is_opening",
|
||||
"fiscal_year",
|
||||
"debit_in_account_currency",
|
||||
"credit_in_account_currency",
|
||||
"account_currency",
|
||||
],
|
||||
order_by="account, posting_date",
|
||||
)
|
||||
gl_filters["dimensions"] = tuple(set(dimension_list))
|
||||
|
||||
if filters.get("include_default_book_entries"):
|
||||
gl_filters["company_fb"] = frappe.get_cached_value("Company", filters.company, "default_finance_book")
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
posting_date, account, {dimension}, debit, credit, is_opening, fiscal_year,
|
||||
debit_in_account_currency, credit_in_account_currency, account_currency
|
||||
from
|
||||
`tabGL Entry`
|
||||
where
|
||||
company=%(company)s
|
||||
{condition}
|
||||
and posting_date >= %(from_date)s
|
||||
and posting_date <= %(to_date)s
|
||||
and is_cancelled = 0
|
||||
order by account, posting_date""".format(
|
||||
dimension=frappe.scrub(filters.get("dimension")), condition=condition
|
||||
),
|
||||
gl_filters,
|
||||
as_dict=True,
|
||||
) # nosec
|
||||
|
||||
for entry in gl_entries:
|
||||
gl_entries_by_account.setdefault(entry.account, []).append(entry)
|
||||
@@ -176,6 +178,14 @@ def accumulate_values_into_parents(accounts, accounts_by_name, dimension_list):
|
||||
].get(frappe.scrub(dimension), 0.0) + d.get(frappe.scrub(dimension), 0.0)
|
||||
|
||||
|
||||
def get_condition(dimension):
|
||||
conditions = []
|
||||
|
||||
conditions.append(f"{frappe.scrub(dimension)} in %(dimensions)s")
|
||||
|
||||
return " and {}".format(" and ".join(conditions)) if conditions else ""
|
||||
|
||||
|
||||
def get_dimensions(filters):
|
||||
meta = frappe.get_meta(filters.get("dimension"), cached=False)
|
||||
query_filters = {}
|
||||
|
||||
@@ -71,7 +71,6 @@ def get_ratios_data(filters, period_list, years):
|
||||
assets, liabilities, income, expense = get_gl_data(filters, period_list, years)
|
||||
|
||||
current_asset, total_asset = {}, {}
|
||||
fixed_asset = {}
|
||||
current_liability, total_liability = {}, {}
|
||||
net_sales, total_income = {}, {}
|
||||
cogs, total_expense = {}, {}
|
||||
@@ -94,7 +93,6 @@ def get_ratios_data(filters, period_list, years):
|
||||
quick_asset,
|
||||
total_quick_asset,
|
||||
],
|
||||
[fixed_asset, total_asset, "Fixed Asset", year, assets, "Asset", {}, 0],
|
||||
[
|
||||
current_liability,
|
||||
total_liability,
|
||||
@@ -114,7 +112,7 @@ def get_ratios_data(filters, period_list, years):
|
||||
add_solvency_ratios(
|
||||
data, years, total_asset, total_liability, net_sales, cogs, total_income, total_expense
|
||||
)
|
||||
add_turnover_ratios(data, years, period_list, filters, fixed_asset, net_sales, cogs, direct_expense)
|
||||
add_turnover_ratios(data, years, period_list, filters, total_asset, net_sales, cogs, direct_expense)
|
||||
|
||||
return data
|
||||
|
||||
@@ -195,7 +193,7 @@ def add_solvency_ratios(
|
||||
data.append(return_on_equity_ratio)
|
||||
|
||||
|
||||
def add_turnover_ratios(data, years, period_list, filters, fixed_asset, net_sales, cogs, direct_expense):
|
||||
def add_turnover_ratios(data, years, period_list, filters, total_asset, net_sales, cogs, direct_expense):
|
||||
precision = frappe.db.get_single_value("System Settings", "float_precision")
|
||||
data.append({"ratio": _("Turnover Ratios")})
|
||||
|
||||
@@ -210,7 +208,7 @@ def add_turnover_ratios(data, years, period_list, filters, fixed_asset, net_sale
|
||||
)
|
||||
|
||||
ratio_data = [
|
||||
[_("Fixed Asset Turnover Ratio"), net_sales, fixed_asset],
|
||||
[_("Fixed Asset Turnover Ratio"), net_sales, total_asset],
|
||||
[_("Debtor Turnover Ratio"), net_sales, avg_debtors],
|
||||
[_("Creditor Turnover Ratio"), direct_expense, avg_creditors],
|
||||
[_("Inventory Turnover Ratio"), cogs, avg_stock],
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.report.financial_ratios.financial_ratios import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestFinancialRatios(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.abbr = "_TC"
|
||||
# The report matches the group accounts by their account_type, which the
|
||||
# standard chart of accounts does not set on group accounts by default.
|
||||
self.set_account_type("Fixed Assets", "Fixed Asset")
|
||||
self.set_account_type("Direct Income", "Direct Income")
|
||||
|
||||
def set_account_type(self, account_name, account_type):
|
||||
frappe.db.set_value("Account", f"{account_name} - {self.abbr}", "account_type", account_type)
|
||||
|
||||
def test_fixed_asset_turnover_uses_net_fixed_assets(self):
|
||||
# Acquire a fixed asset worth 10,000 funded by equity.
|
||||
self.make_journal_entry("Buildings", "Capital Stock", 10000)
|
||||
# Book sales of 20,000 collected in cash. Total assets now = 30,000
|
||||
# (Buildings 10,000 + Cash 20,000), while net fixed assets stay at 10,000.
|
||||
self.make_journal_entry("Cash", "Sales", 20000)
|
||||
|
||||
columns, data = execute(self.get_report_filters())
|
||||
year_key = columns[1]["fieldname"]
|
||||
ratio_row = next((row for row in data if row.get("ratio") == "Fixed Asset Turnover Ratio"), None)
|
||||
self.assertIsNotNone(ratio_row, "Fixed Asset Turnover Ratio row not found in report output")
|
||||
|
||||
# Net Sales / Net Fixed Assets = 20,000 / 10,000 = 2.0
|
||||
# (the old behaviour divided by total assets, giving 20,000 / 30,000 = 0.667)
|
||||
self.assertEqual(ratio_row[year_key], 2.0)
|
||||
|
||||
def get_report_filters(self):
|
||||
active_fy = frappe.db.get_value(
|
||||
"Fiscal Year",
|
||||
{"disabled": 0, "year_start_date": ("<=", today()), "year_end_date": (">=", today())},
|
||||
["name", "year_start_date", "year_end_date"],
|
||||
as_dict=True,
|
||||
)
|
||||
return frappe._dict(
|
||||
company=self.company,
|
||||
from_fiscal_year=active_fy.name,
|
||||
to_fiscal_year=active_fy.name,
|
||||
period_start_date=active_fy.year_start_date,
|
||||
period_end_date=active_fy.year_end_date,
|
||||
filter_based_on="Fiscal Year",
|
||||
periodicity="Yearly",
|
||||
)
|
||||
|
||||
def make_journal_entry(self, debit_account, credit_account, amount):
|
||||
journal_entry = frappe.new_doc("Journal Entry")
|
||||
journal_entry.posting_date = today()
|
||||
journal_entry.company = self.company
|
||||
for account, debit, credit in (
|
||||
(debit_account, amount, 0),
|
||||
(credit_account, 0, amount),
|
||||
):
|
||||
journal_entry.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": f"{account} - {self.abbr}",
|
||||
"debit_in_account_currency": debit,
|
||||
"credit_in_account_currency": credit,
|
||||
},
|
||||
)
|
||||
journal_entry.insert()
|
||||
journal_entry.submit()
|
||||
@@ -179,10 +179,11 @@ def get_data(
|
||||
company_currency = get_appropriate_currency(company, filters)
|
||||
|
||||
gl_entries_by_account = {}
|
||||
for root in frappe.get_all(
|
||||
"Account",
|
||||
filters={"root_type": root_type, "parent_account": ["is", "not set"]},
|
||||
fields=["lft", "rgt"],
|
||||
for root in frappe.db.sql(
|
||||
"""select lft, rgt from tabAccount
|
||||
where root_type=%s and ifnull(parent_account, '') = ''""",
|
||||
root_type,
|
||||
as_dict=1,
|
||||
):
|
||||
set_gl_entries_by_account(
|
||||
company,
|
||||
@@ -372,23 +373,13 @@ def add_total_row(out, root_type, balance_must_be, period_list, company_currency
|
||||
|
||||
|
||||
def get_accounts(company, root_type):
|
||||
return frappe.get_all(
|
||||
"Account",
|
||||
filters={"company": company, "root_type": root_type},
|
||||
fields=[
|
||||
"name",
|
||||
"account_number",
|
||||
"parent_account",
|
||||
"lft",
|
||||
"rgt",
|
||||
"root_type",
|
||||
"report_type",
|
||||
"account_name",
|
||||
"include_in_gross",
|
||||
"account_type",
|
||||
"is_group",
|
||||
],
|
||||
order_by="lft",
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select name, account_number, parent_account, lft, rgt, root_type, report_type, account_name, include_in_gross, account_type, is_group, lft, rgt
|
||||
from `tabAccount`
|
||||
where company=%s and root_type=%s order by lft""",
|
||||
(company, root_type),
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -538,11 +529,7 @@ def get_accounting_entries(
|
||||
gl_entry.credit_in_account_currency
|
||||
if not group_by_account
|
||||
else Sum(gl_entry.credit_in_account_currency).as_("credit_in_account_currency"),
|
||||
# when grouping by account the non-aggregated columns must be aggregated for postgres;
|
||||
# account_currency is constant per account so Max() returns the same value.
|
||||
gl_entry.account_currency
|
||||
if not group_by_account
|
||||
else Max(gl_entry.account_currency).as_("account_currency"),
|
||||
gl_entry.account_currency,
|
||||
)
|
||||
.where(gl_entry.company == filters.company)
|
||||
)
|
||||
@@ -560,29 +547,15 @@ def get_accounting_entries(
|
||||
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
|
||||
|
||||
if doctype == "GL Entry":
|
||||
# aggregate the non-grouped columns when grouping by account (postgres requirement)
|
||||
if group_by_account:
|
||||
query = query.select(
|
||||
Max(gl_entry.posting_date).as_("posting_date"),
|
||||
Max(gl_entry.is_opening).as_("is_opening"),
|
||||
Max(gl_entry.fiscal_year).as_("fiscal_year"),
|
||||
)
|
||||
else:
|
||||
query = query.select(gl_entry.posting_date, gl_entry.is_opening, gl_entry.fiscal_year)
|
||||
query = query.select(gl_entry.posting_date, gl_entry.is_opening, gl_entry.fiscal_year)
|
||||
query = query.where(gl_entry.is_cancelled == 0)
|
||||
query = query.where(gl_entry.posting_date <= to_date)
|
||||
# FORCE INDEX is MySQL-only; postgres has no index hints (its planner uses the index anyway)
|
||||
if frappe.db.db_type != "postgres":
|
||||
query = query.force_index("posting_date_company_index")
|
||||
query = query.force_index("posting_date_company_index")
|
||||
|
||||
if ignore_opening_entries and not ignore_is_opening:
|
||||
query = query.where(gl_entry.is_opening == "No")
|
||||
else:
|
||||
query = query.select(
|
||||
Max(gl_entry.closing_date).as_("posting_date")
|
||||
if group_by_account
|
||||
else gl_entry.closing_date.as_("posting_date")
|
||||
)
|
||||
query = query.select(gl_entry.closing_date.as_("posting_date"))
|
||||
query = query.where(gl_entry.period_closing_voucher == period_closing_voucher)
|
||||
|
||||
query = apply_additional_conditions(doctype, query, from_date, ignore_closing_entries, filters)
|
||||
|
||||
@@ -12,13 +12,7 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestGeneralAndPaymentLedger(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.expense_account = "Cost of Goods Sold - _TC"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.warehouse = "Stores - _TC"
|
||||
self.creditors = "Creditors - _TC"
|
||||
self.create_company()
|
||||
self.cleanup()
|
||||
|
||||
def cleanup(self):
|
||||
|
||||
@@ -35,7 +35,7 @@ def execute(filters=None):
|
||||
if filters and filters.get("print_in_account_currency") and not filters.get("account"):
|
||||
frappe.throw(_("Select an account to print in account currency"))
|
||||
|
||||
for acc in frappe.get_all("Account", fields=["name", "is_group"]):
|
||||
for acc in frappe.db.sql("""select name, is_group from tabAccount""", as_dict=1):
|
||||
account_details.setdefault(acc.name, acc)
|
||||
|
||||
if filters.get("party"):
|
||||
@@ -650,8 +650,10 @@ def get_result_as_list(data, filters):
|
||||
|
||||
def get_supplier_invoice_details():
|
||||
inv_details = {}
|
||||
for d in frappe.get_all(
|
||||
"Purchase Invoice", filters={"docstatus": 1, "bill_no": ["is", "set"]}, fields=["name", "bill_no"]
|
||||
for d in frappe.db.sql(
|
||||
""" select name, bill_no from `tabPurchase Invoice`
|
||||
where docstatus = 1 and bill_no is not null and bill_no != '' """,
|
||||
as_dict=1,
|
||||
):
|
||||
inv_details[d.name] = d.bill_no
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
class TestGeneralLedger(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.clear_old_entries()
|
||||
|
||||
def clear_old_entries(self):
|
||||
doctype_list = [
|
||||
|
||||
@@ -713,25 +713,20 @@ class GrossProfitGenerator:
|
||||
)
|
||||
|
||||
def get_returned_invoice_items(self):
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
si_item = frappe.qb.DocType("Sales Invoice Item")
|
||||
returned_invoices = (
|
||||
frappe.qb.from_(si)
|
||||
.inner_join(si_item)
|
||||
.on(si.name == si_item.parent)
|
||||
.select(
|
||||
si.name,
|
||||
si_item.item_code,
|
||||
si_item.stock_qty.as_("qty"),
|
||||
si_item.base_net_amount.as_("base_amount"),
|
||||
si.return_against,
|
||||
)
|
||||
.where(
|
||||
(si.docstatus == 1)
|
||||
& (si.is_return == 1)
|
||||
& si.posting_date.between(self.filters.from_date, self.filters.to_date)
|
||||
)
|
||||
.run(as_dict=1)
|
||||
returned_invoices = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
si.name, si_item.item_code, si_item.stock_qty as qty, si_item.base_net_amount as base_amount, si.return_against
|
||||
from
|
||||
`tabSales Invoice` si, `tabSales Invoice Item` si_item
|
||||
where
|
||||
si.name = si_item.parent
|
||||
and si.docstatus = 1
|
||||
and si.is_return = 1
|
||||
and si.posting_date between %(from_date)s and %(to_date)s
|
||||
""",
|
||||
{"from_date": self.filters.from_date, "to_date": self.filters.to_date},
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
self.returned_invoices = frappe._dict()
|
||||
@@ -1246,4 +1241,7 @@ class GrossProfitGenerator:
|
||||
).setdefault(d.parent_item, []).append(d)
|
||||
|
||||
def load_non_stock_items(self):
|
||||
self.non_stock_items = frappe.get_all("Item", filters={"is_stock_item": 0}, pluck="name")
|
||||
self.non_stock_items = frappe.db.sql_list(
|
||||
"""select name from tabItem
|
||||
where is_stock_item=0"""
|
||||
)
|
||||
|
||||
@@ -14,17 +14,73 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestGrossProfit(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.cost_center = "Main - _TC"
|
||||
self.warehouse = "Stores - _TC"
|
||||
self.finished_warehouse = "Finished Goods - _TC"
|
||||
self.income_account = "Sales - _TC"
|
||||
self.expense_account = "Cost of Goods Sold - _TC"
|
||||
self.debit_to = "Debtors - _TC"
|
||||
self.item = "_Test Item"
|
||||
self.item2 = "_Test Item Home Desktop 100"
|
||||
self.bundle = "_Test Product Bundle Item"
|
||||
self.customer = "_Test Customer"
|
||||
self.create_company()
|
||||
self.create_item()
|
||||
self.create_bundle()
|
||||
self.create_customer()
|
||||
self.create_sales_invoice()
|
||||
self.clear_old_entries()
|
||||
|
||||
def create_company(self):
|
||||
company_name = "_Test Gross Profit"
|
||||
abbr = "_GP"
|
||||
if frappe.db.exists("Company", company_name):
|
||||
company = frappe.get_doc("Company", company_name)
|
||||
else:
|
||||
company = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Company",
|
||||
"company_name": company_name,
|
||||
"country": "India",
|
||||
"default_currency": "INR",
|
||||
"create_chart_of_accounts_based_on": "Standard Template",
|
||||
"chart_of_accounts": "Standard",
|
||||
}
|
||||
)
|
||||
company = company.save()
|
||||
|
||||
self.company = company.name
|
||||
self.cost_center = company.cost_center
|
||||
self.warehouse = "Stores - " + abbr
|
||||
self.finished_warehouse = "Finished Goods - " + abbr
|
||||
self.income_account = "Sales - " + abbr
|
||||
self.expense_account = "Cost of Goods Sold - " + abbr
|
||||
self.debit_to = "Debtors - " + abbr
|
||||
self.creditors = "Creditors - " + abbr
|
||||
|
||||
def create_item(self):
|
||||
item = create_item(
|
||||
item_code="_Test GP Item", is_stock_item=1, company=self.company, warehouse=self.warehouse
|
||||
)
|
||||
self.item = item if isinstance(item, str) else item.item_code
|
||||
|
||||
def create_bundle(self):
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
|
||||
item2 = create_item(
|
||||
item_code="_Test GP Item 2", is_stock_item=1, company=self.company, warehouse=self.warehouse
|
||||
)
|
||||
self.item2 = item2 if isinstance(item2, str) else item2.item_code
|
||||
|
||||
# This will be parent item
|
||||
bundle = create_item(
|
||||
item_code="_Test GP bundle", is_stock_item=0, company=self.company, warehouse=self.warehouse
|
||||
)
|
||||
self.bundle = bundle if isinstance(bundle, str) else bundle.item_code
|
||||
|
||||
# Create Product Bundle
|
||||
self.product_bundle = make_product_bundle(parent=self.bundle, items=[self.item, self.item2])
|
||||
|
||||
def create_customer(self):
|
||||
name = "_Test GP Customer"
|
||||
if frappe.db.exists("Customer", name):
|
||||
self.customer = name
|
||||
else:
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = name
|
||||
customer.type = "Individual"
|
||||
customer.save()
|
||||
self.customer = customer.name
|
||||
|
||||
def create_sales_invoice(
|
||||
self, qty=1, rate=100, posting_date=None, do_not_save=False, do_not_submit=False
|
||||
@@ -158,7 +214,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _TC",
|
||||
"warehouse": "Stores - _GP",
|
||||
"qty": 1.0,
|
||||
"avg._selling_rate": 100.0,
|
||||
"valuation_rate": 150.0,
|
||||
@@ -187,7 +243,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _TC",
|
||||
"warehouse": "Stores - _GP",
|
||||
"qty": 1.0,
|
||||
"avg._selling_rate": 100.0,
|
||||
"valuation_rate": 100.0,
|
||||
@@ -219,7 +275,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"item_code": self.item2,
|
||||
"s_warehouse": "",
|
||||
"t_warehouse": self.finished_warehouse,
|
||||
"qty": 2,
|
||||
"qty": 1,
|
||||
"basic_rate": 100,
|
||||
"conversion_factor": item.conversion_factor or 1.0,
|
||||
"transfer_qty": flt(item.qty) * (flt(item.conversion_factor) or 1.0),
|
||||
@@ -319,7 +375,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _TC",
|
||||
"warehouse": "Stores - _GP",
|
||||
"qty": 4.0,
|
||||
"avg._selling_rate": 100.0,
|
||||
"valuation_rate": 125.0,
|
||||
@@ -360,10 +416,10 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _TC",
|
||||
"warehouse": "Stores - _GP",
|
||||
"qty": 0.0,
|
||||
"avg._selling_rate": 100.0,
|
||||
"valuation_rate": 100.0,
|
||||
"avg._selling_rate": 100,
|
||||
"valuation_rate": 0.0,
|
||||
"selling_amount": 0.0,
|
||||
"buying_amount": 0.0,
|
||||
"gross_profit": 0.0,
|
||||
@@ -383,7 +439,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"""
|
||||
# Make Cr Note
|
||||
sinv = self.create_sales_invoice(
|
||||
qty=-1, rate=200, posting_date=nowdate(), do_not_save=True, do_not_submit=True
|
||||
qty=-1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True
|
||||
)
|
||||
sinv.is_return = 1
|
||||
sinv.items[0].allow_zero_valuation_rate = 1
|
||||
@@ -406,14 +462,14 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _TC",
|
||||
"warehouse": "Stores - _GP",
|
||||
"qty": -1.0,
|
||||
"avg._selling_rate": 200.0,
|
||||
"valuation_rate": 100.0,
|
||||
"selling_amount": -200.0,
|
||||
"buying_amount": -100.0,
|
||||
"avg._selling_rate": 100.0,
|
||||
"valuation_rate": 0.0,
|
||||
"selling_amount": -100.0,
|
||||
"buying_amount": 0.0,
|
||||
"gross_profit": -100.0,
|
||||
"gross_profit_%": -50.0,
|
||||
"gross_profit_%": -100.0,
|
||||
}
|
||||
gp_entry = [x for x in data if x.parent_invoice == sinv.name]
|
||||
report_output = {k: v for k, v in gp_entry[0].items() if k in expected_entry}
|
||||
@@ -499,7 +555,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
"posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()),
|
||||
"item_code": self.item,
|
||||
"item_name": self.item,
|
||||
"warehouse": "Stores - _TC",
|
||||
"warehouse": "Stores - _GP",
|
||||
"qty": 4.0,
|
||||
"avg._selling_rate": 800.0,
|
||||
"valuation_rate": 700.0,
|
||||
@@ -562,7 +618,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
def test_gross_profit_groupby_invoices(self):
|
||||
create_sales_invoice(
|
||||
qty=1,
|
||||
rate=200,
|
||||
rate=100,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
item_code=self.item,
|
||||
@@ -584,10 +640,10 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
_, data = execute(filters=filters)
|
||||
total = data[-1]
|
||||
|
||||
self.assertEqual(total.selling_amount, 200.0)
|
||||
self.assertEqual(total.buying_amount, 100.0)
|
||||
self.assertEqual(total.selling_amount, 100.0)
|
||||
self.assertEqual(total.buying_amount, 0.0)
|
||||
self.assertEqual(total.gross_profit, 100.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), 50.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), 100.0)
|
||||
|
||||
def test_profit_for_later_period_return(self):
|
||||
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
|
||||
@@ -596,7 +652,7 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
return_inv_date = add_days(month_end_date, 1)
|
||||
|
||||
# create sales invoice on month start date
|
||||
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_save=True, do_not_submit=True)
|
||||
sinv = self.create_sales_invoice(qty=1, rate=100, do_not_save=True, do_not_submit=True)
|
||||
sinv.set_posting_time = 1
|
||||
sinv.posting_date = sales_inv_date
|
||||
sinv.save().submit()
|
||||
@@ -615,10 +671,10 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
_, data = execute(filters=filters)
|
||||
total = data[-1]
|
||||
|
||||
self.assertEqual(total.selling_amount, 200.0)
|
||||
self.assertEqual(total.buying_amount, 100.0)
|
||||
self.assertEqual(total.selling_amount, 100.0)
|
||||
self.assertEqual(total.buying_amount, 0.0)
|
||||
self.assertEqual(total.gross_profit, 100.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), 50.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), 100.0)
|
||||
|
||||
# extend filters upto returned period
|
||||
filters.update({"to_date": return_inv_date})
|
||||
@@ -636,10 +692,10 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
_, data = execute(filters=filters)
|
||||
total = data[-1]
|
||||
|
||||
self.assertEqual(total.selling_amount, -200.0)
|
||||
self.assertEqual(total.buying_amount, -100.0)
|
||||
self.assertEqual(total.selling_amount, -100.0)
|
||||
self.assertEqual(total.buying_amount, 0.0)
|
||||
self.assertEqual(total.gross_profit, -100.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), -50.0)
|
||||
self.assertEqual(total.get("gross_profit_%"), -100.0)
|
||||
|
||||
def test_sales_person_wise_gross_profit(self):
|
||||
sales_person = make_sales_person("_Test Sales Person")
|
||||
@@ -670,10 +726,10 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
_, data = execute(filters=filters)
|
||||
total = data[-1]
|
||||
|
||||
self.assertEqual(total[5], 1000.0) # selling amount
|
||||
self.assertEqual(total[6], 1000.0) # buying amount
|
||||
self.assertEqual(total[7], 0.0) # gross profit
|
||||
self.assertEqual(total[8], 0.0) # gross profit %
|
||||
self.assertEqual(total[5], 1000.0)
|
||||
self.assertEqual(total[6], 0.0)
|
||||
self.assertEqual(total[7], 1000.0)
|
||||
self.assertEqual(total[8], 100.0)
|
||||
|
||||
def test_drop_ship(self):
|
||||
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import CurDate, DateDiff
|
||||
from frappe.utils import cint
|
||||
|
||||
|
||||
@@ -98,32 +97,19 @@ def get_sales_details(filters):
|
||||
if filters["based_on"] not in ("Sales Order", "Sales Invoice"):
|
||||
frappe.throw(_("Invalid value {0} for 'Based On'").format(filters["based_on"]))
|
||||
|
||||
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_field = "s.transaction_date" if filters["based_on"] == "Sales Order" else "s.posting_date"
|
||||
|
||||
date_col = parent.transaction_date if filters["based_on"] == "Sales Order" else parent.posting_date
|
||||
|
||||
# DateDiff is cross-database (DATEDIFF on MariaDB, date subtraction on postgres); CurDate()
|
||||
# renders the bare CURRENT_DATE keyword. Yields the integer number of days.
|
||||
days_since_last_order = DateDiff(CurDate(), 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)
|
||||
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,
|
||||
)
|
||||
|
||||
for d in sales_data:
|
||||
item_details_map.setdefault((d.territory, d.item_code), d)
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.report.inactive_sales_items.inactive_sales_items import execute
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestInactiveSalesItems(ERPNextTestSuite):
|
||||
def test_days_since_last_order_is_computed(self):
|
||||
# Exercises the date-arithmetic path (DATEDIFF/CURRENT_DATE on mariadb, date subtraction on
|
||||
# postgres) which must produce the same integer day count on both databases.
|
||||
item = make_item("_Test Inactive Sales Item").name
|
||||
old_date = add_days(today(), -120)
|
||||
so = make_sales_order(item=item, qty=3, rate=150, transaction_date=old_date)
|
||||
so.items[0].delivery_date = add_days(old_date, 7)
|
||||
so.save()
|
||||
so.submit()
|
||||
|
||||
columns, data = execute(frappe._dict({"based_on": "Sales Order", "days": 30}))
|
||||
self.assertTrue(columns)
|
||||
row = next((r for r in data if r.get("item") == item and r.get("days_since_last_order")), None)
|
||||
self.assertIsNotNone(row, "Inactive item should appear in the report")
|
||||
self.assertGreaterEqual(row["days_since_last_order"], 30)
|
||||
|
||||
def test_report_runs_for_sales_invoice(self):
|
||||
columns, _data = execute(frappe._dict({"based_on": "Sales Invoice", "days": 30}))
|
||||
self.assertTrue(columns)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user