mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-06 10:00:22 +00:00
Compare commits
76 Commits
l10n_crowd
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1e0865771 | ||
|
|
71f1edeb1c | ||
|
|
135921ca79 | ||
|
|
9e9c4b8102 | ||
|
|
5beed5f4f0 | ||
|
|
04b84ef069 | ||
|
|
154c6fb943 | ||
|
|
c3319d74cf | ||
|
|
b2233a44d1 | ||
|
|
5895ed0ee9 | ||
|
|
1b7da82669 | ||
|
|
0b1f1d6851 | ||
|
|
00f04fc084 | ||
|
|
f368a5b64e | ||
|
|
f8951034be | ||
|
|
56caf3c770 | ||
|
|
3b9535ed91 | ||
|
|
f8c53be376 | ||
|
|
a1b402020e | ||
|
|
e3a976f115 | ||
|
|
68666c2ed0 | ||
|
|
717af27559 | ||
|
|
4944df8733 | ||
|
|
19aa3b20e0 | ||
|
|
09f5e76b77 | ||
|
|
646c7d042d | ||
|
|
3e08a085b9 | ||
|
|
2918e98a2b | ||
|
|
02fcdc0337 | ||
|
|
a2071a6fdd | ||
|
|
dbe153a15e | ||
|
|
f16f249a38 | ||
|
|
9cb736a271 | ||
|
|
c93815b4ae | ||
|
|
2b54a581f8 | ||
|
|
199cae9496 | ||
|
|
9261c9b47f | ||
|
|
0230879501 | ||
|
|
56a391c522 | ||
|
|
e74ab38eeb | ||
|
|
a9b857bdfe | ||
|
|
a20fc4f931 | ||
|
|
22e3b87621 | ||
|
|
e2f24eea15 | ||
|
|
db52e465d0 | ||
|
|
5691a7b3c7 | ||
|
|
0d78261bdf | ||
|
|
120e62f208 | ||
|
|
21a3b1c3dc | ||
|
|
d8432d92c8 | ||
|
|
31761699ae | ||
|
|
7ecfa6b356 | ||
|
|
24209ae699 | ||
|
|
26d000e15f | ||
|
|
dbae23765e | ||
|
|
c755e24731 | ||
|
|
d5789c2e8b | ||
|
|
0f14f8050f | ||
|
|
b1c7657dfa | ||
|
|
b36895a4c3 | ||
|
|
509501c299 | ||
|
|
20ea09b4a0 | ||
|
|
8ca2905a33 | ||
|
|
9087f2cdba | ||
|
|
6cca7d670b | ||
|
|
4355f8e60e | ||
|
|
0e4b384af1 | ||
|
|
83dea1a24e | ||
|
|
6d445d94ee | ||
|
|
05e5e50a6f | ||
|
|
3501beb2bd | ||
|
|
7af1af35cd | ||
|
|
86852d954e | ||
|
|
e08a166281 | ||
|
|
4a43e5203f | ||
|
|
8c8b282a2e |
16
.github/POSTGRES_COMPATIBILITY.md
vendored
16
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -60,10 +60,13 @@ Flag a changed query that uses any of these:
|
||||
check_field, True)`, `doc.db_set(field, False)`, or `frappe.qb.update(dt).set(check_field, True)`
|
||||
emit `SET col = true`, which PostgreSQL rejects on a `smallint`/`Check` column
|
||||
(`column is of type smallint but expression is of type boolean`). Pass `1`/`0`.
|
||||
- **`.like()`/`.ilike()` (or raw `LIKE`) on a NON-text column** — `idx`, `docstatus`, a date, etc.
|
||||
frappe maps `.like()` → `ILIKE`, and PostgreSQL has no `bigint ILIKE text` operator (`operator
|
||||
does not exist: bigint ~~* unknown`). Cast the column to text first — **`Cast_(col, "varchar")`**,
|
||||
not `Cast(col, "char")` (see below). MariaDB coerces the int implicitly, so the cast is a no-op there.
|
||||
- **A direct `.like()`/`.ilike()` on a pypika field (or raw `LIKE`) on a NON-text column** — `idx`,
|
||||
`docstatus`, a date, etc. frappe maps `.like()` → `ILIKE`, and PostgreSQL has no `bigint ILIKE text`
|
||||
operator (`operator does not exist: bigint ~~* unknown`). Cast the column to text first —
|
||||
**`Cast_(col, "varchar")`**, not `Cast(col, "char")` (see below). MariaDB coerces the int
|
||||
implicitly, so the cast is a no-op there. A `["like", …]` filter passed to `get_all`/`get_list`/
|
||||
`qb.get_query`/`reportview` needs no cast: the framework casts non-text fields itself
|
||||
(frappe/frappe#42449).
|
||||
- **`CAST(… AS CHAR)` / `Cast(x, "char")`** — on PostgreSQL bare `CHAR` is `character(1)`, so
|
||||
`CAST(12 AS CHAR)` → `'1'` (silently truncates multi-digit values); MariaDB gives the full string.
|
||||
Use `VARCHAR` / `Cast_(x, "varchar")`.
|
||||
@@ -192,8 +195,9 @@ pick a bound for a stated reason, and cover the varying-group case with a test.
|
||||
These are auto-handled by the framework and are **not** breaks:
|
||||
|
||||
- **`.like()` / `["like", …]`** already renders as `ILIKE` on PostgreSQL — not a
|
||||
case-sensitivity bug. *(Exception: `.like()` on a **non-text** column — `idx`, `docstatus` —
|
||||
is a hard break, `bigint ILIKE`; see §1.)*
|
||||
case-sensitivity bug. A `["like", …]` filter on a **non-text** field is also cast to text by
|
||||
the framework. *(Exception: a direct `.like()` on a **non-text** pypika field — `idx`,
|
||||
`docstatus` — is a hard break, `bigint ILIKE`; see §1.)*
|
||||
- **Raw `ifnull(...)`** inside `frappe.db.sql()` is rewritten to `coalesce(...)` on all engines.
|
||||
- **Backticks**, **`LOCATE`**, **`REGEXP`** / **`.regexp()`** in raw SQL are auto-translated on
|
||||
PostgreSQL (`REGEXP` → `~*`). **But `RLIKE` / `.rlike()` is NOT translated** — that one is a
|
||||
|
||||
32
.github/helper/install.sh
vendored
32
.github/helper/install.sh
vendored
@@ -4,6 +4,36 @@ set -e
|
||||
|
||||
cd ~ || exit
|
||||
|
||||
# Authenticate git against github.com with the job token: anonymous git-over-HTTPS from the
|
||||
# runners gets throttled to a 401, which kills whichever clone is in flight — the frappe fetch
|
||||
# below, or payments under `bench get-app`. See the PR description.
|
||||
#
|
||||
# A credential helper rather than a url.insteadOf rewrite, because `git clone` PERSISTS a
|
||||
# rewritten URL into the new repo's .git/config: an insteadOf would leave the token sitting in
|
||||
# apps/payments/.git/config on the runner. A helper is consulted only when github.com actually
|
||||
# challenges, and leaves the stored remote URL untouched. Passing it through GIT_CONFIG_* keeps
|
||||
# the token out of ~/.gitconfig too, and child processes inherit it (bench shells out to git).
|
||||
ci_github_token=${CI_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}
|
||||
if [ -n "$ci_github_token" ]; then
|
||||
export CI_GITHUB_TOKEN="$ci_github_token"
|
||||
export GIT_CONFIG_COUNT=3
|
||||
# Reset first: git runs EVERY configured helper and calls `store` on them after a successful
|
||||
# auth, so a `credential.helper=store` inherited from the image's gitconfig would write the
|
||||
# token to ~/.git-credentials. An empty value clears the list before ours is added.
|
||||
export GIT_CONFIG_KEY_0="credential.helper"
|
||||
export GIT_CONFIG_VALUE_0=""
|
||||
export GIT_CONFIG_KEY_1="credential.https://github.com.username"
|
||||
export GIT_CONFIG_VALUE_1="x-access-token"
|
||||
export GIT_CONFIG_KEY_2="credential.https://github.com.helper"
|
||||
# Single-quoted: $CI_GITHUB_TOKEN is expanded by the shell git runs the helper in, so the
|
||||
# token is read from the environment at call time and never stored anywhere. Answering only
|
||||
# `get` makes the helper inert for git's `store`/`erase` calls.
|
||||
export GIT_CONFIG_VALUE_2='!f() { test "$1" = get && echo "password=$CI_GITHUB_TOKEN"; }; f'
|
||||
fi
|
||||
|
||||
# Whatever happens, never sit on a credential prompt: fail fast and legibly instead.
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
|
||||
frappeuser=${FRAPPE_USER:-"frappe"}
|
||||
frappecommitish=${FRAPPE_BRANCH:-}
|
||||
@@ -188,7 +218,7 @@ restore_warm_bench() {
|
||||
# Phase 1 already fetched ~/frappe to the exact live develop SHA. Fetch that commit
|
||||
# straight from it (bench init names the remote 'upstream', not 'origin', and points
|
||||
# it at this local clone — so a plain `git fetch origin` does not work).
|
||||
git fetch --no-tags "$HOME/frappe" HEAD || exit 1
|
||||
git fetch --no-tags --update-shallow "$HOME/frappe" HEAD || exit 1
|
||||
git checkout --force FETCH_HEAD || exit 1
|
||||
); then
|
||||
echo "Fast-forward to ${frappe_sha} failed; falling back to full init"
|
||||
|
||||
2
.github/workflows/patch.yml
vendored
2
.github/workflows/patch.yml
vendored
@@ -121,6 +121,8 @@ jobs:
|
||||
env:
|
||||
DB: mariadb
|
||||
TYPE: server
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Run Patch Tests
|
||||
run: |
|
||||
|
||||
2
.github/workflows/run-individual-tests.yml
vendored
2
.github/workflows/run-individual-tests.yml
vendored
@@ -129,6 +129,8 @@ jobs:
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
|
||||
2
.github/workflows/server-tests-mariadb.yml
vendored
2
.github/workflows/server-tests-mariadb.yml
vendored
@@ -102,6 +102,8 @@ jobs:
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_USER_HOST: '%'
|
||||
WKHTMLTOX_DEB: /tmp/wkhtmltox.deb
|
||||
|
||||
2
.github/workflows/server-tests-postgres.yml
vendored
2
.github/workflows/server-tests-postgres.yml
vendored
@@ -103,6 +103,8 @@ jobs:
|
||||
DB: postgres
|
||||
TYPE: server
|
||||
FRAPPE_BRANCH: develop
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
BENCH_CACHE_DIR: /home/runner/bench-cache
|
||||
|
||||
- name: Warm up test data
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1489,10 +1489,10 @@ balanced-match@^4.0.2:
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
|
||||
integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
|
||||
|
||||
baseline-browser-mapping@^2.10.38:
|
||||
version "2.10.40"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz#f372c8eb36ff4ad0b5e7ae467014abef124554ba"
|
||||
integrity sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==
|
||||
baseline-browser-mapping@^2.11.12:
|
||||
version "2.11.20"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz#26078c7a4b08299656ea7ddceaebec955dc44303"
|
||||
integrity sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==
|
||||
|
||||
brace-expansion@^5.0.5:
|
||||
version "5.0.7"
|
||||
@@ -1509,15 +1509,15 @@ brace-expansion@^5.0.8:
|
||||
balanced-match "^4.0.2"
|
||||
|
||||
browserslist@^4.24.0:
|
||||
version "4.28.4"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.4.tgz#dd8b8167a32845ff5f8cd6ce13f5abba16cd04c9"
|
||||
integrity sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==
|
||||
version "4.28.8"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.8.tgz#a3c79ceb70028527e5da7dafc887f3200b5168c0"
|
||||
integrity sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==
|
||||
dependencies:
|
||||
baseline-browser-mapping "^2.10.38"
|
||||
caniuse-lite "^1.0.30001799"
|
||||
electron-to-chromium "^1.5.376"
|
||||
node-releases "^2.0.48"
|
||||
update-browserslist-db "^1.2.3"
|
||||
baseline-browser-mapping "^2.11.12"
|
||||
caniuse-lite "^1.0.30001809"
|
||||
electron-to-chromium "^1.5.402"
|
||||
node-releases "^2.0.53"
|
||||
update-browserslist-db "^1.3.0"
|
||||
|
||||
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
|
||||
version "1.0.2"
|
||||
@@ -1527,10 +1527,10 @@ call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
|
||||
es-errors "^1.3.0"
|
||||
function-bind "^1.1.2"
|
||||
|
||||
caniuse-lite@^1.0.30001799:
|
||||
version "1.0.30001800"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz#b896c773e1c39400809415162bb5320371291b36"
|
||||
integrity sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==
|
||||
caniuse-lite@^1.0.30001809:
|
||||
version "1.0.30001810"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz#4970b477dea3278374de9bc43aa8f5d39fc3cda2"
|
||||
integrity sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -1697,10 +1697,10 @@ dunder-proto@^1.0.1:
|
||||
es-errors "^1.3.0"
|
||||
gopd "^1.2.0"
|
||||
|
||||
electron-to-chromium@^1.5.376:
|
||||
version "1.5.383"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.383.tgz#5bd22306497d454103b289b0fef97260c56d0855"
|
||||
integrity sha512-I2484/KkAvl8lm9VyjH2JnbOIV0d/UCqT7gbzs6l+o6Vmn9wgB66uVcKX+Vk6HrXtY6fbWTOEXuv8waDTuFNCw==
|
||||
electron-to-chromium@^1.5.402:
|
||||
version "1.5.420"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz#fc66d26a722d6f227e2092acdf38dd55b198cb44"
|
||||
integrity sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==
|
||||
|
||||
engine.io-client@~6.5.1:
|
||||
version "6.5.4"
|
||||
@@ -3012,10 +3012,10 @@ natural-compare@^1.4.0:
|
||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
||||
|
||||
node-releases@^2.0.48:
|
||||
version "2.0.50"
|
||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.50.tgz#597197a852071ce42fc2550e58e223242bcba969"
|
||||
integrity sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==
|
||||
node-releases@^2.0.53:
|
||||
version "2.0.54"
|
||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.54.tgz#09af17d5647aa9f221ec5cf2becb95b68a981afe"
|
||||
integrity sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==
|
||||
|
||||
object-assign@^4.1.1:
|
||||
version "4.1.1"
|
||||
@@ -3589,10 +3589,10 @@ unist-util-visit@^5.0.0:
|
||||
unist-util-is "^6.0.0"
|
||||
unist-util-visit-parents "^6.0.0"
|
||||
|
||||
update-browserslist-db@^1.2.3:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d"
|
||||
integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==
|
||||
update-browserslist-db@^1.3.0:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz#9d99fbff56c50bb11ba5fd35cece5916da595836"
|
||||
integrity sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==
|
||||
dependencies:
|
||||
escalade "^3.2.0"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"modified": "2025-12-19 12:37:31.673782",
|
||||
"modified": "2026-09-04 12:37:31.673782",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Profit and Loss",
|
||||
@@ -17,7 +17,6 @@
|
||||
"owner": "Administrator",
|
||||
"report_name": "Profit and Loss Statement",
|
||||
"roles": [],
|
||||
"show_values_over_chart": 1,
|
||||
"timeseries": 0,
|
||||
"type": "Line",
|
||||
"use_report_chart": 1,
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
"description": "Setting Account Type helps in selecting this Account in transactions.",
|
||||
"fieldname": "account_type",
|
||||
"fieldtype": "Select",
|
||||
"in_preview": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Account Type",
|
||||
"oldfieldname": "account_type",
|
||||
@@ -203,7 +204,7 @@
|
||||
"idx": 1,
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:37.851001",
|
||||
"modified": "2026-09-03 12:59:42.190900",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Account",
|
||||
|
||||
@@ -52,6 +52,42 @@ frappe.treeview_settings["Account"] = {
|
||||
],
|
||||
root_label: "Accounts",
|
||||
get_tree_nodes: "erpnext.accounts.utils.get_children",
|
||||
get_label: function (node) {
|
||||
// clean display name — the account number renders as a badge (see
|
||||
// onrender) instead of being glued into the name
|
||||
return frappe.utils.escape_html(node.data.account_name || node.title || node.label);
|
||||
},
|
||||
onrender: function (node) {
|
||||
if (node.is_root || !node.data) return;
|
||||
|
||||
const flags = [];
|
||||
if (node.data.account_number) {
|
||||
flags.push(frappe.ui.badge({ label: node.data.account_number }));
|
||||
}
|
||||
|
||||
const company = frappe.treeview_settings["Account"].treeview?.page?.fields_dict?.company?.get_value();
|
||||
const company_currency = company && erpnext.get_currency(company);
|
||||
if (
|
||||
node.data.account_currency &&
|
||||
company_currency &&
|
||||
node.data.account_currency !== company_currency
|
||||
) {
|
||||
flags.push(frappe.ui.badge({ label: node.data.account_currency, theme: "blue" }));
|
||||
}
|
||||
|
||||
if (node.data.freeze_account === "Yes") {
|
||||
flags.push(
|
||||
frappe.ui.badge({
|
||||
label: __("Frozen"),
|
||||
icon: "lock",
|
||||
title: __("Frozen - entries restricted"),
|
||||
theme: "orange",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
erpnext.utils.render_tree_node_flags(node, flags);
|
||||
},
|
||||
on_node_render: function (node, deep) {
|
||||
const render_balances = () => {
|
||||
for (let account of cur_tree.account_balance_data) {
|
||||
@@ -232,7 +268,7 @@ frappe.treeview_settings["Account"] = {
|
||||
frappe.treeview_settings["Account"].treeview["tree"] = treeview.tree;
|
||||
if (treeview.can_create) {
|
||||
treeview.page.set_primary_action(
|
||||
__("New"),
|
||||
{ label: __("Add Account"), short_label: __("Add") },
|
||||
function () {
|
||||
let root_company = treeview.page.fields_dict.root_company.get_value();
|
||||
if (root_company) {
|
||||
@@ -243,13 +279,14 @@ frappe.treeview_settings["Account"] = {
|
||||
treeview.new_node();
|
||||
}
|
||||
},
|
||||
"add"
|
||||
"plus"
|
||||
);
|
||||
}
|
||||
},
|
||||
toolbar: [
|
||||
{
|
||||
label: __("Add Child"),
|
||||
icon: "plus",
|
||||
condition: function (node) {
|
||||
return (
|
||||
frappe.boot.user.can_create.indexOf("Account") !== -1 &&
|
||||
@@ -272,6 +309,7 @@ frappe.treeview_settings["Account"] = {
|
||||
return !node.root && frappe.boot.user.can_read.indexOf("GL Entry") !== -1;
|
||||
},
|
||||
label: __("View Ledger"),
|
||||
icon: "book-open",
|
||||
click: function (node, btn) {
|
||||
frappe.route_options = {
|
||||
from_date: erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[1],
|
||||
@@ -286,6 +324,106 @@ frappe.treeview_settings["Account"] = {
|
||||
},
|
||||
btnClass: "hidden-xs",
|
||||
},
|
||||
{
|
||||
// same label and mechanism as the Account form's Actions button:
|
||||
// NOT frappe's generic rename (Allow Rename stays off) — this is
|
||||
// ERPNext's controlled update that rebuilds the derived
|
||||
// "number - name - abbr" document name
|
||||
label: __("Update Account Name / Number"),
|
||||
icon: "text-cursor-input",
|
||||
condition: function (node) {
|
||||
return !node.is_root && frappe.model.can_write("Account");
|
||||
},
|
||||
click: function (node) {
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Update Account Number / Name"),
|
||||
fields: [
|
||||
{
|
||||
fieldtype: "Data",
|
||||
fieldname: "account_name",
|
||||
label: __("Account Name"),
|
||||
reqd: 1,
|
||||
default: node.data.account_name,
|
||||
},
|
||||
{
|
||||
fieldtype: "Data",
|
||||
fieldname: "account_number",
|
||||
label: __("Account Number"),
|
||||
default: node.data.account_number,
|
||||
},
|
||||
],
|
||||
primary_action_label: __("Update"),
|
||||
primary_action(values) {
|
||||
dialog.hide();
|
||||
frappe.dom.freeze(__("Updating {0}", [node.label]));
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.account.account.update_account_number",
|
||||
args: {
|
||||
name: node.label,
|
||||
account_name: values.account_name,
|
||||
account_number: values.account_number,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.exc) return;
|
||||
const treeview = frappe.views.trees["Account"];
|
||||
node.parent_node && treeview.tree.load_children(node.parent_node);
|
||||
},
|
||||
always: function () {
|
||||
frappe.dom.unfreeze();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
dialog.show();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __("Convert to Group"),
|
||||
icon: "folder-tree",
|
||||
condition: function (node) {
|
||||
return !node.is_root && !node.expandable && frappe.model.can_write("Account");
|
||||
},
|
||||
click: function (node) {
|
||||
erpnext.accounts.convert_tree_node("Account", node, "convert_ledger_to_group");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __("Convert to Non-Group"),
|
||||
icon: "file-text",
|
||||
condition: function (node) {
|
||||
// only on groups the user has opened and found empty — a
|
||||
// group with children can't convert, so don't offer it
|
||||
return (
|
||||
!node.is_root &&
|
||||
node.expandable &&
|
||||
node.loaded &&
|
||||
!node.$ul.children().length &&
|
||||
frappe.model.can_write("Account")
|
||||
);
|
||||
},
|
||||
click: function (node) {
|
||||
erpnext.accounts.convert_tree_node("Account", node, "convert_group_to_ledger");
|
||||
},
|
||||
},
|
||||
],
|
||||
extend_toolbar: true,
|
||||
};
|
||||
|
||||
frappe.provide("erpnext.accounts");
|
||||
// shared by the Account and Cost Center tree views (defined in both files,
|
||||
// whichever loads first wins): run the doctype's whitelisted convert method,
|
||||
// then re-render the branch so the node's group/leaf state updates
|
||||
erpnext.accounts.convert_tree_node =
|
||||
erpnext.accounts.convert_tree_node ||
|
||||
function (doctype, node, method) {
|
||||
frappe.call({
|
||||
method: "run_doc_method",
|
||||
args: { dt: doctype, dn: node.label, method: method },
|
||||
callback: function (r) {
|
||||
if (r.exc) return;
|
||||
const treeview = frappe.views.trees[doctype];
|
||||
node.parent_node && treeview.tree.load_children(node.parent_node);
|
||||
frappe.show_alert({ message: __("{0} converted", [node.label]), indicator: "green" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -912,7 +912,7 @@ def search_for_transfer_transaction(transaction_id: str | int):
|
||||
|
||||
days = frappe.db.get_single_value("Accounts Settings", "transfer_match_days")
|
||||
|
||||
if not days:
|
||||
if days is None:
|
||||
days = 3
|
||||
|
||||
min_date = frappe.utils.add_days(date, -days)
|
||||
@@ -1336,9 +1336,11 @@ def get_pe_matching_query(
|
||||
ref_condition = pe.reference_no == transaction.reference_number
|
||||
ref_rank = frappe.qb.terms.Case().when(ref_condition, 1).else_(0)
|
||||
|
||||
amount_equality = pe.paid_amount == transaction.unallocated_amount
|
||||
amount_field = pe.received_amount_after_tax if account_from_to == "paid_to" else pe.paid_amount_after_tax
|
||||
|
||||
amount_equality = amount_field == transaction.unallocated_amount
|
||||
amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
|
||||
amount_condition = amount_equality if exact_match else pe.paid_amount > 0.0
|
||||
amount_condition = amount_equality if exact_match else amount_field > 0.0
|
||||
|
||||
party_condition = (
|
||||
(pe.party_type == transaction.party_type) & (pe.party == transaction.party) & pe.party.isnotnull()
|
||||
@@ -1355,7 +1357,7 @@ def get_pe_matching_query(
|
||||
(ref_rank + amount_rank + party_rank + 1).as_("rank"),
|
||||
ConstantColumn("Payment Entry").as_("doctype"),
|
||||
pe.name,
|
||||
pe.base_paid_amount_after_tax.as_("paid_amount"),
|
||||
amount_field.as_("paid_amount"),
|
||||
pe.reference_no,
|
||||
pe.reference_date,
|
||||
pe.party,
|
||||
|
||||
@@ -10,6 +10,7 @@ from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool
|
||||
auto_reconcile_vouchers,
|
||||
get_auto_reconcile_message,
|
||||
get_bank_transactions,
|
||||
get_linked_payments,
|
||||
)
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
@@ -99,13 +100,14 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
|
||||
self.assertEqual(len(transactions), 0)
|
||||
|
||||
def make_bank_transaction(self, date, deposit=100):
|
||||
def make_bank_transaction(self, date, deposit=100, withdrawal=0):
|
||||
return (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Bank Transaction",
|
||||
"date": date,
|
||||
"deposit": deposit,
|
||||
"withdrawal": withdrawal,
|
||||
"bank_account": self.bank_account,
|
||||
"currency": "INR",
|
||||
}
|
||||
@@ -114,11 +116,73 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
.submit()
|
||||
)
|
||||
|
||||
def get_matching_payment_entries(self, bank_transaction, exact_match=False):
|
||||
document_types = ["payment_entry", "exact_match"] if exact_match else ["payment_entry"]
|
||||
vouchers = get_linked_payments(
|
||||
bank_transaction,
|
||||
document_types,
|
||||
from_date=add_days(today(), -1),
|
||||
to_date=today(),
|
||||
)
|
||||
return [v for v in vouchers if v.get("doctype") == "Payment Entry"]
|
||||
|
||||
def test_get_bank_transactions_excludes_dates_after_to_date(self):
|
||||
self.make_bank_transaction(date=today())
|
||||
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
|
||||
self.assertEqual(names, [])
|
||||
|
||||
def test_deposit_matches_amount_received_in_bank_account(self):
|
||||
# money leaves another bank account and lands here minus a charge, so the two sides differ
|
||||
payment = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Payment Entry",
|
||||
"payment_type": "Internal Transfer",
|
||||
"company": self.company,
|
||||
"posting_date": today(),
|
||||
"paid_from": "_Test Bank - _TC",
|
||||
"paid_to": self.bank,
|
||||
"paid_amount": 3537.64,
|
||||
"received_amount": 3460.52,
|
||||
"reference_no": "TRF-001",
|
||||
"reference_date": today(),
|
||||
}
|
||||
)
|
||||
payment.set_missing_values()
|
||||
payment.set_exchange_rate()
|
||||
payment.set_amounts()
|
||||
payment.deductions[-1].account = "_Test Exchange Gain/Loss - _TC"
|
||||
payment.deductions[-1].cost_center = "_Test Cost Center - _TC"
|
||||
payment = payment.save().submit()
|
||||
|
||||
transaction = self.make_bank_transaction(date=today(), deposit=3460.52)
|
||||
|
||||
# the received side is what reached this bank account, so that is what is shown
|
||||
matches = self.get_matching_payment_entries(transaction.name)
|
||||
self.assertEqual([m["name"] for m in matches], [payment.name])
|
||||
self.assertEqual(matches[0]["paid_amount"], 3460.52)
|
||||
|
||||
# and what the exact match compares against
|
||||
exact_matches = self.get_matching_payment_entries(transaction.name, exact_match=True)
|
||||
self.assertEqual([m["name"] for m in exact_matches], [payment.name])
|
||||
|
||||
def test_withdrawal_matches_amount_paid_from_bank_account(self):
|
||||
payment = create_payment_entry(
|
||||
company=self.company,
|
||||
payment_type="Pay",
|
||||
party_type="Supplier",
|
||||
party="_Test Supplier",
|
||||
paid_from=self.bank,
|
||||
paid_to="Creditors - _TC",
|
||||
paid_amount=1250,
|
||||
)
|
||||
payment = payment.save().submit()
|
||||
|
||||
transaction = self.make_bank_transaction(date=today(), deposit=0, withdrawal=1250)
|
||||
|
||||
exact_matches = self.get_matching_payment_entries(transaction.name, exact_match=True)
|
||||
self.assertEqual([m["name"] for m in exact_matches], [payment.name])
|
||||
self.assertEqual(exact_matches[0]["paid_amount"], 1250)
|
||||
|
||||
def test_auto_reconcile_message_for_no_matches(self):
|
||||
message, indicator = get_auto_reconcile_message([], [])
|
||||
self.assertEqual(indicator, "blue")
|
||||
|
||||
@@ -729,6 +729,7 @@ def get_ordered_amount(params):
|
||||
(child.item_code == item_code)
|
||||
& (parent.docstatus == 1)
|
||||
& (child.amount > child.billed_amt)
|
||||
& (child.closed == 0)
|
||||
& (parent.status != "Closed")
|
||||
& Criterion.all(get_other_condition(params, child, parent, "Purchase Order"))
|
||||
)
|
||||
|
||||
@@ -16,6 +16,8 @@ frappe.ui.form.on("Chart of Accounts Importer", {
|
||||
() => generate_tree_preview(frm),
|
||||
() => create_import_button(frm),
|
||||
() => frm.set_df_property("chart_preview", "hidden", 0),
|
||||
// the preview is the point of this page — open it right away
|
||||
() => frm.fields_dict.chart_preview.collapse(false),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -128,7 +130,6 @@ var create_import_button = function (frm) {
|
||||
freeze_message: __("Creating Accounts..."),
|
||||
callback: function (r) {
|
||||
if (!r.exc) {
|
||||
clearInterval(frm.page["interval"]);
|
||||
frm.page.set_indicator(__("Import Successful"), "blue");
|
||||
create_reset_button(frm);
|
||||
}
|
||||
@@ -142,42 +143,95 @@ var create_reset_button = function (frm) {
|
||||
frm.page
|
||||
.set_primary_action(__("Reset"), function () {
|
||||
frm.page.clear_primary_action();
|
||||
delete frm.page["show_import_button"];
|
||||
frm.reload_doc();
|
||||
})
|
||||
.addClass("btn btn-primary");
|
||||
};
|
||||
|
||||
var validate_coa = function (frm) {
|
||||
if (frm.doc.import_file) {
|
||||
let parent = __("All Accounts");
|
||||
return frappe.call({
|
||||
method: "erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer.get_coa",
|
||||
args: {
|
||||
file_name: frm.doc.import_file,
|
||||
parent: parent,
|
||||
doctype: "Chart of Accounts Importer",
|
||||
file_type: frm.doc.file_type,
|
||||
for_validate: 1,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message["show_import_button"]) {
|
||||
frm.page["show_import_button"] = Boolean(r.message["show_import_button"]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var generate_tree_preview = function (frm) {
|
||||
let parent = __("All Accounts");
|
||||
$(frm.fields_dict["chart_tree"].wrapper).empty(); // empty wrapper to load new data
|
||||
const wrapper = $(frm.fields_dict["chart_tree"].wrapper).empty(); // empty wrapper to load new data
|
||||
|
||||
// search + expand/collapse-all lean on frappe.ui.Tree helpers added with
|
||||
// row mode; when running against an older frappe that predates them, skip
|
||||
// this toolbar so the preview still renders (just without the extras)
|
||||
const has_row_helpers =
|
||||
typeof frappe.ui.Tree.prototype.get_expansion_state === "function" &&
|
||||
typeof frappe.ui.Tree.prototype.filter_nodes === "function";
|
||||
|
||||
let tree;
|
||||
let deep_loaded = false;
|
||||
let search_text = "";
|
||||
let update_buttons = () => {};
|
||||
|
||||
if (has_row_helpers) {
|
||||
// same toolbar anatomy as the tree view: search on the left,
|
||||
// expand/collapse-all on the right (three-state: fully collapsed ->
|
||||
// Expand All, fully expanded -> Collapse All, partially expanded -> both)
|
||||
const $toolbar = $('<div class="flex items-center gap-2 mb-2"></div>').appendTo(wrapper);
|
||||
|
||||
const search_control = frappe.ui.form.make_control({
|
||||
df: { fieldtype: "Data", fieldname: "preview_search", placeholder: __("Search") },
|
||||
parent: $toolbar,
|
||||
only_input: true,
|
||||
});
|
||||
search_control.refresh();
|
||||
$(search_control.wrapper).addClass("m-0").css("width", "220px");
|
||||
search_control.$input.addClass("input-xs");
|
||||
search_control.$input.on(
|
||||
"input",
|
||||
frappe.utils.debounce(() => {
|
||||
search_text = search_control.$input.val();
|
||||
const run = () => {
|
||||
// a newer keystroke superseded this one while the deep load ran
|
||||
if (search_text !== search_control.$input.val()) return;
|
||||
tree.filter_nodes(search_text);
|
||||
};
|
||||
if (!search_text || deep_loaded) {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
tree.load_children(tree.root_node, true).then(() => {
|
||||
deep_loaded = true;
|
||||
run();
|
||||
});
|
||||
}, 300)
|
||||
);
|
||||
|
||||
const $actions = $('<div class="ms-auto flex items-center gap-1"></div>').appendTo($toolbar);
|
||||
update_buttons = () => {
|
||||
const state = tree.get_expansion_state();
|
||||
$expand_all.prop("disabled", !(state === "collapsed" || state === "partial"));
|
||||
$collapse_all.prop("disabled", !(state === "expanded" || state === "partial"));
|
||||
};
|
||||
// tooltip on a wrapper: a disabled es-button has pointer-events:none,
|
||||
// so hover falls through to the wrapper and the tooltip still shows
|
||||
const make_action = (icon, label, onclick) => {
|
||||
const $btn = $(
|
||||
frappe.ui.button({ icon, disabled: true, onclick, attrs: { "aria-label": label } })
|
||||
);
|
||||
const $wrapper = $('<span class="inline-flex"></span>').append($btn).appendTo($actions);
|
||||
frappe.ui.tooltip($wrapper, { text: label });
|
||||
return $btn;
|
||||
};
|
||||
var $expand_all = make_action("chevrons-up-down", __("Expand All"), () => {
|
||||
tree.load_children(tree.root_node, true).then(() => {
|
||||
deep_loaded = true;
|
||||
});
|
||||
});
|
||||
var $collapse_all = make_action("chevrons-down-up", __("Collapse All"), () => {
|
||||
tree.load_children(tree.root_node, false);
|
||||
});
|
||||
}
|
||||
|
||||
// generate tree structure based on the csv data
|
||||
return new frappe.ui.Tree({
|
||||
parent: $(frm.fields_dict["chart_tree"].wrapper),
|
||||
tree = new frappe.ui.Tree({
|
||||
parent: wrapper,
|
||||
label: parent,
|
||||
expandable: true,
|
||||
// read-only preview: row-mode visuals without actions or hover cards
|
||||
// (ignored by an older frappe, which renders the legacy tree)
|
||||
row_style: true,
|
||||
method: "erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer.get_coa",
|
||||
args: {
|
||||
file_name: frm.doc.import_file,
|
||||
@@ -185,8 +239,9 @@ var generate_tree_preview = function (frm) {
|
||||
doctype: "Chart of Accounts Importer",
|
||||
file_type: frm.doc.file_type,
|
||||
},
|
||||
onclick: function (node) {
|
||||
parent = node.value;
|
||||
},
|
||||
on_node_render: () => update_buttons(),
|
||||
// expanded flips right after this callback — check on the next tick
|
||||
on_click: () => setTimeout(update_buttons, 0),
|
||||
});
|
||||
return tree;
|
||||
};
|
||||
|
||||
@@ -12,6 +12,19 @@ frappe.treeview_settings["Cost Center"] = {
|
||||
],
|
||||
root_label: "Cost Centers",
|
||||
get_tree_nodes: "erpnext.accounts.utils.get_children",
|
||||
get_label: function (node) {
|
||||
// clean display name — the number renders as a badge (see onrender)
|
||||
return frappe.utils.escape_html(node.data.cost_center_name || node.title || node.label);
|
||||
},
|
||||
onrender: function (node) {
|
||||
if (node.is_root || !node.data) return;
|
||||
|
||||
const flags = [];
|
||||
if (node.data.cost_center_number) {
|
||||
flags.push(frappe.ui.badge({ label: node.data.cost_center_number }));
|
||||
}
|
||||
erpnext.utils.render_tree_node_flags(node, flags);
|
||||
},
|
||||
add_tree_node: "erpnext.accounts.utils.add_cc",
|
||||
menu_items: [
|
||||
{
|
||||
@@ -42,6 +55,37 @@ frappe.treeview_settings["Cost Center"] = {
|
||||
},
|
||||
],
|
||||
ignore_fields: ["parent_cost_center"],
|
||||
toolbar: [
|
||||
{
|
||||
label: __("Convert to Group"),
|
||||
icon: "folder-tree",
|
||||
condition: function (node) {
|
||||
return !node.is_root && !node.expandable && frappe.model.can_write("Cost Center");
|
||||
},
|
||||
click: function (node) {
|
||||
erpnext.accounts.convert_tree_node("Cost Center", node, "convert_ledger_to_group");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __("Convert to Non-Group"),
|
||||
icon: "file-text",
|
||||
condition: function (node) {
|
||||
// only on groups the user has opened and found empty — a
|
||||
// group with children can't convert, so don't offer it
|
||||
return (
|
||||
!node.is_root &&
|
||||
node.expandable &&
|
||||
node.loaded &&
|
||||
!node.$ul.children().length &&
|
||||
frappe.model.can_write("Cost Center")
|
||||
);
|
||||
},
|
||||
click: function (node) {
|
||||
erpnext.accounts.convert_tree_node("Cost Center", node, "convert_group_to_ledger");
|
||||
},
|
||||
},
|
||||
],
|
||||
extend_toolbar: true,
|
||||
onload: function (treeview) {
|
||||
function get_company() {
|
||||
return treeview.page.fields_dict.company.get_value();
|
||||
@@ -82,3 +126,22 @@ frappe.treeview_settings["Cost Center"] = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
frappe.provide("erpnext.accounts");
|
||||
// shared by the Account and Cost Center tree views (defined in both files,
|
||||
// whichever loads first wins): run the doctype's whitelisted convert method,
|
||||
// then re-render the branch so the node's group/leaf state updates
|
||||
erpnext.accounts.convert_tree_node =
|
||||
erpnext.accounts.convert_tree_node ||
|
||||
function (doctype, node, method) {
|
||||
frappe.call({
|
||||
method: "run_doc_method",
|
||||
args: { dt: doctype, dn: node.label, method: method },
|
||||
callback: function (r) {
|
||||
if (r.exc) return;
|
||||
const treeview = frappe.views.trees[doctype];
|
||||
node.parent_node && treeview.tree.load_children(node.parent_node);
|
||||
frappe.show_alert({ message: __("{0} converted", [node.label]), indicator: "green" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_validat
|
||||
AccountFilterValidator,
|
||||
CalculationFormulaValidator,
|
||||
DependencyValidator,
|
||||
get_valid_api_method,
|
||||
)
|
||||
from erpnext.accounts.report.financial_statements import (
|
||||
get_columns,
|
||||
@@ -1182,10 +1183,12 @@ class RowProcessor:
|
||||
|
||||
def _process_api_row(self, row) -> RowData:
|
||||
api_path = row.calculation_formula
|
||||
# TODO
|
||||
|
||||
method = get_valid_api_method(api_path)
|
||||
|
||||
try:
|
||||
values = frappe.call(api_path, filters=self.context.filters, periods=self.period_list, row=row)
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-codeinjection-eval
|
||||
values = frappe.call(method, filters=self.context.filters, periods=self.period_list, row=row)
|
||||
|
||||
if row.reverse_sign:
|
||||
values = [-1 * v for v in values]
|
||||
|
||||
@@ -236,6 +236,8 @@ async function refresh_tree_view(dialog, account_rows) {
|
||||
parent: wrapper,
|
||||
label: company,
|
||||
root_value: company,
|
||||
// read-only preview: row-mode visuals without actions
|
||||
row_style: true,
|
||||
method: "erpnext.accounts.doctype.financial_report_template.financial_report_engine.get_children_accounts",
|
||||
args: { doctype: "Account", company: company, filtered_accounts: filtered_accounts, missed: missed },
|
||||
toolbar: [],
|
||||
@@ -370,7 +372,7 @@ function update_formula_description(frm, data_source) {
|
||||
description_html = `
|
||||
<div ${container_style}>
|
||||
<h5 ${title_style}>Custom API Setup</h5>
|
||||
<p ${text_style}>Path to your custom method that returns financial data.</p>
|
||||
<p ${text_style}>Path to your custom whitelisted method that returns financial data. It must permit GET requests.</p>
|
||||
|
||||
<h6 ${subtitle_style}>Format:</h6>
|
||||
<ul ${list_style}>
|
||||
@@ -380,7 +382,8 @@ function update_formula_description(frm, data_source) {
|
||||
|
||||
<h6 ${subtitle_style}>Method Signature:</h6>
|
||||
<div ${code_style}>
|
||||
<pre ${pre_style}>def get_custom_data(filters, periods, row): <br> # filters: dict — report filters (company, period, etc.) <br> # periods: list[dict] — period definitions <br> # row: dict — the current report row <br><br> return [1000.0, 1200.0, 1150.0] # one value per period</pre>
|
||||
<!-- is used for line breaks since frappe.render replaces newlines with spaces -->
|
||||
<pre ${pre_style} class="language-python">@frappe.whitelist(methods=["GET"]) def get_custom_data(filters, periods, row): # filters: dict — report filters (company, period, etc.) # periods: list[dict] — period definitions # row: dict — the current report row return [1000.0, 1200.0, 1150.0] # one value per period</pre>
|
||||
</div>
|
||||
|
||||
<h6 ${subtitle_style}>Return Format:</h6>
|
||||
|
||||
@@ -8,10 +8,25 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe import _, is_whitelisted
|
||||
from frappe.database.operator_map import OPERATOR_MAP
|
||||
|
||||
|
||||
def get_valid_api_method(api_path: str):
|
||||
"""Resolve `api_path`, ensuring it is whitelisted and permits GET (i.e. read-only)."""
|
||||
method = frappe.get_attr(api_path)
|
||||
is_whitelisted(method)
|
||||
|
||||
if "GET" not in frappe.allowed_http_methods_for_whitelisted_func.get(method, ()):
|
||||
frappe.throw(
|
||||
_("Method {0} must permit GET requests").format(frappe.bold(api_path)),
|
||||
frappe.PermissionError,
|
||||
title=_("Method Not Allowed"),
|
||||
)
|
||||
|
||||
return method
|
||||
|
||||
|
||||
def get_formula_field_label(data_source: str) -> str:
|
||||
# Must mirror the `labels` map in financial_report_template.js (update_formula_label),
|
||||
labels = {
|
||||
@@ -175,8 +190,10 @@ class TemplateStructureValidator(Validator):
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0} is required for {1}").format(
|
||||
get_formula_field_label(row.data_source), row.data_source
|
||||
message=_("{0} is required when {1} is {2}").format(
|
||||
get_formula_field_label(row.data_source),
|
||||
row.meta.get_translated_label("data_source"),
|
||||
_(row.data_source),
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
@@ -204,7 +221,14 @@ class DependencyValidator(Validator):
|
||||
|
||||
for row in self.template.rows:
|
||||
if row.reference_code and row.data_source == "Calculated Amount" and row.calculation_formula:
|
||||
deps = extract_reference_codes_from_formula(row.calculation_formula, list(available_codes))
|
||||
# skip self-reference, `CalculationFormulaValidator` already reports it
|
||||
deps = [
|
||||
code
|
||||
for code in extract_reference_codes_from_formula(
|
||||
row.calculation_formula, list(available_codes)
|
||||
)
|
||||
if code != row.reference_code
|
||||
]
|
||||
if deps:
|
||||
graph[row.reference_code] = deps
|
||||
|
||||
@@ -266,7 +290,9 @@ class DependencyValidator(Validator):
|
||||
row_idx = self._get_row_idx(ref_code)
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Line References undefined in Formula: {0}").format(", ".join(undefined)),
|
||||
message=_("Line references undefined in {0}: {1}").format(
|
||||
get_formula_field_label("Calculated Amount"), ", ".join(undefined)
|
||||
),
|
||||
row_idx=row_idx,
|
||||
)
|
||||
)
|
||||
@@ -293,17 +319,6 @@ class CalculationFormulaValidator(Validator):
|
||||
if row.data_source != "Calculated Amount":
|
||||
return result
|
||||
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0} is required for Calculated Amount").format(
|
||||
get_formula_field_label(row.data_source)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
formula = self._preprocess_formula(row.calculation_formula)
|
||||
row.calculation_formula = formula
|
||||
|
||||
@@ -328,16 +343,6 @@ class CalculationFormulaValidator(Validator):
|
||||
)
|
||||
)
|
||||
|
||||
# Check undefined references
|
||||
undefined = set(refs) - set(available_codes)
|
||||
if undefined:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Formula references undefined codes: {0}").format(", ".join(undefined)),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
|
||||
# Try to evaluate with dummy values
|
||||
eval_error = self._test_formula_evaluation(formula, available_codes)
|
||||
if eval_error:
|
||||
@@ -400,17 +405,6 @@ class AccountFilterValidator(Validator):
|
||||
if row.data_source != "Account Data":
|
||||
return result
|
||||
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0} is required for Account Data").format(
|
||||
get_formula_field_label(row.data_source)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
try:
|
||||
filter_config = json.loads(row.calculation_formula)
|
||||
error = self._validate_filter_structure(
|
||||
@@ -422,7 +416,9 @@ class AccountFilterValidator(Validator):
|
||||
if error:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0}: {1}").format(get_formula_field_label(row.data_source), error),
|
||||
message=_("[{0}] {1}", context="Financial Report Template").format(
|
||||
get_formula_field_label(row.data_source), error
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -430,8 +426,9 @@ class AccountFilterValidator(Validator):
|
||||
except json.JSONDecodeError as e:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("{0}: Invalid JSON format: {1}").format(
|
||||
get_formula_field_label(row.data_source), str(e)
|
||||
message=_("[{0}] {1}", context="Financial Report Template").format(
|
||||
get_formula_field_label(row.data_source),
|
||||
_("Invalid JSON format: {0}").format(str(e)),
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
@@ -531,29 +528,24 @@ class FormulaValidator(Validator):
|
||||
)
|
||||
return result
|
||||
|
||||
# Method exists?
|
||||
try:
|
||||
module_path, method_name = api_path.rsplit(".", 1)
|
||||
module = frappe.get_module(module_path)
|
||||
|
||||
if not hasattr(module, method_name):
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_(
|
||||
"{0}: Method '{1}' not found in module '{2}' (might be environment-specific)"
|
||||
).format(get_formula_field_label(row.data_source), method_name, module_path),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
get_valid_api_method(api_path)
|
||||
except Exception as e:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Could not validate {0}: {1}").format(
|
||||
get_formula_field_label(row.data_source), str(e)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
if isinstance(e, frappe.PermissionError | frappe.ValidationError):
|
||||
# frappe.throw inside get_valid_api_method logs a message that would pop up in UI
|
||||
frappe.clear_last_message()
|
||||
|
||||
if isinstance(e, frappe.PermissionError):
|
||||
message = _("[{0}] {1}", context="Financial Report Template").format(
|
||||
get_formula_field_label(row.data_source),
|
||||
_("Method '{0}' must be whitelisted and permit GET requests").format(api_path),
|
||||
)
|
||||
)
|
||||
else:
|
||||
message = _("Could not validate {0}: {1}").format(
|
||||
get_formula_field_label(row.data_source), str(e)
|
||||
)
|
||||
|
||||
result.add_error(ValidationIssue(message=message, row_idx=row.idx))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import whitelist_for_tests
|
||||
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
|
||||
FormulaValidator,
|
||||
get_valid_api_method,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -72,3 +77,90 @@ class FinancialReportTemplateTestCase(ERPNextTestSuite):
|
||||
{"doctype": "Financial Report Template", "template_name": template_name, "rows": rows_data}
|
||||
)
|
||||
return template
|
||||
|
||||
|
||||
def not_whitelisted_method(**kwargs):
|
||||
return [42.0]
|
||||
|
||||
|
||||
@whitelist_for_tests(methods=["POST"])
|
||||
def whitelisted_post_only_method(**kwargs):
|
||||
return [42.0]
|
||||
|
||||
|
||||
@whitelist_for_tests(methods=["GET"])
|
||||
def whitelisted_get_method(**kwargs):
|
||||
return [42.0]
|
||||
|
||||
|
||||
class TestCustomAPIValidation(FinancialReportTemplateTestCase):
|
||||
"""Custom API rows must point to whitelisted methods that permit GET"""
|
||||
|
||||
TEST_MODULE = "erpnext.accounts.doctype.financial_report_template.test_financial_report_template"
|
||||
NOT_WHITELISTED = f"{TEST_MODULE}.not_whitelisted_method"
|
||||
WHITELISTED_POST_ONLY = f"{TEST_MODULE}.whitelisted_post_only_method"
|
||||
WHITELISTED_GET = f"{TEST_MODULE}.whitelisted_get_method"
|
||||
|
||||
def create_api_template(self, api_path):
|
||||
template = self.create_test_template_with_rows(
|
||||
[
|
||||
{
|
||||
"reference_code": "API001",
|
||||
"display_name": "API Row",
|
||||
"data_source": "Custom API",
|
||||
"calculation_formula": api_path,
|
||||
}
|
||||
]
|
||||
)
|
||||
template.report_type = "Profit and Loss Statement"
|
||||
return template
|
||||
|
||||
def test_get_valid_api_method(self):
|
||||
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.NOT_WHITELISTED)
|
||||
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.WHITELISTED_POST_ONLY)
|
||||
self.assertEqual(get_valid_api_method(self.WHITELISTED_GET), frappe.get_attr(self.WHITELISTED_GET))
|
||||
|
||||
def test_save_rejects_invalid_api_methods(self):
|
||||
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
|
||||
template = self.create_api_template(api_path)
|
||||
self.assertRaises(frappe.ValidationError, template.insert)
|
||||
|
||||
def test_save_allows_get_whitelisted_method(self):
|
||||
template = self.create_api_template(self.WHITELISTED_GET)
|
||||
template.insert()
|
||||
template.delete()
|
||||
|
||||
def test_engine_rejects_invalid_api_methods(self):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
ReportContext,
|
||||
RowProcessor,
|
||||
)
|
||||
|
||||
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
|
||||
template = self.create_api_template(api_path)
|
||||
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
|
||||
processor = RowProcessor(context)
|
||||
self.assertRaises(frappe.PermissionError, processor._process_api_row, template.rows[0])
|
||||
|
||||
def test_engine_calls_valid_api_method(self):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
ReportContext,
|
||||
RowProcessor,
|
||||
)
|
||||
|
||||
template = self.create_api_template(self.WHITELISTED_GET)
|
||||
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
|
||||
processor = RowProcessor(context)
|
||||
row_data = processor._process_api_row(template.rows[0])
|
||||
self.assertEqual(row_data.values, [42.0])
|
||||
|
||||
def test_validation_keeps_message_log_clean(self):
|
||||
validator = FormulaValidator(frappe._dict(rows=[]))
|
||||
message_count = len(frappe.local.message_log)
|
||||
|
||||
# last path raises AppNotInstalledError, which also logs a message via frappe.throw
|
||||
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY, "missing_app.api.method"):
|
||||
row = frappe._dict(data_source="Custom API", calculation_formula=api_path, idx=1)
|
||||
result = validator.validate(row)
|
||||
self.assertFalse(result.is_valid)
|
||||
self.assertEqual(len(frappe.local.message_log), message_count)
|
||||
|
||||
@@ -56,7 +56,9 @@ class LedgerMerge(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def form_start_merge(docname: str):
|
||||
return frappe.get_doc("Ledger Merge", docname).start_merge()
|
||||
lm_doc = frappe.get_doc("Ledger Merge", docname)
|
||||
lm_doc.check_permission("write")
|
||||
return lm_doc.start_merge()
|
||||
|
||||
|
||||
def start_merge(docname):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import Sum
|
||||
@@ -196,7 +196,7 @@ class TestLoyaltyProgram(ERPNextTestSuite):
|
||||
for d in company_wise_info:
|
||||
self.assertTrue(d.get("loyalty_points"))
|
||||
|
||||
@unittest.mock.patch("erpnext.accounts.doctype.loyalty_program.loyalty_program.get_loyalty_details")
|
||||
@patch("erpnext.accounts.doctype.loyalty_program.loyalty_program.get_loyalty_details")
|
||||
def test_tier_selection(self, mock_get_loyalty_details):
|
||||
# Create a new loyalty program with multiple tiers
|
||||
loyalty_program = frappe.get_doc(
|
||||
|
||||
@@ -297,6 +297,9 @@ def start_import(invoices):
|
||||
invoice_number = d.invoice_number
|
||||
doc = frappe.get_doc(d)
|
||||
doc.flags.ignore_mandatory = True
|
||||
# the outstanding amount is entered inclusive of tax, so taxes must not
|
||||
# be added on top of it
|
||||
doc.flags.dont_auto_add_taxes = True
|
||||
doc.insert(set_name=invoice_number)
|
||||
doc.submit()
|
||||
if not frappe.in_test:
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
import frappe
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
|
||||
get_temporary_opening_account,
|
||||
)
|
||||
from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule
|
||||
from erpnext.projects.doctype.project.test_project import make_project
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -126,6 +128,55 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
|
||||
for invoice in invoices:
|
||||
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings",
|
||||
{"add_taxes_from_taxes_and_charges_template": 1, "add_taxes_from_item_tax_template": 0},
|
||||
)
|
||||
def test_opening_invoice_creation_without_taxes(self):
|
||||
company = "_Test Opening Invoice Company"
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Sales Taxes and Charges Template",
|
||||
"company": company,
|
||||
"title": "_Test Opening Invoice Tax",
|
||||
"taxes": [
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": create_account(
|
||||
account_name="_Test Opening Tax Account",
|
||||
parent_account="Duties and Taxes - _TOIC",
|
||||
account_type="Tax",
|
||||
company=company,
|
||||
),
|
||||
"description": "Test taxes",
|
||||
"rate": 9,
|
||||
}
|
||||
],
|
||||
}
|
||||
).insert()
|
||||
|
||||
# makes the template the default for the party, as it would be on a live site
|
||||
make_tax_rule(tax_type="Sales", company=company, sales_tax_template=template.name, save=1)
|
||||
|
||||
tool = self.make_invoices(company=company, return_doc=True)
|
||||
invoices = tool.make_invoices()
|
||||
self.assertEqual(len(invoices), 2)
|
||||
|
||||
# outstanding amount is entered inclusive of tax, so taxes must not be added on top of it
|
||||
for invoice in invoices:
|
||||
si = frappe.get_doc("Sales Invoice", invoice)
|
||||
self.assertFalse(si.taxes)
|
||||
self.assertEqual(si.grand_total, 200)
|
||||
self.assertEqual(si.outstanding_amount, 200)
|
||||
|
||||
# the same invoice created outside the tool keeps the default taxes,
|
||||
# since adding them there is the user's decision
|
||||
si = frappe.get_doc(tool.get_invoices()[0])
|
||||
si.flags.ignore_mandatory = True
|
||||
si.insert()
|
||||
self.assertTrue(si.taxes)
|
||||
self.assertEqual(si.grand_total, 218)
|
||||
|
||||
def test_opening_entry_project_linking(self):
|
||||
doc = self.make_invoices(
|
||||
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True
|
||||
|
||||
@@ -92,6 +92,7 @@ def get_supplier_query(doctype: str, txt: str, searchfield: str, start: int, pag
|
||||
@frappe.whitelist()
|
||||
def make_payment_records(name: str, supplier: str, mode_of_payment: str | None = None):
|
||||
doc = frappe.get_doc("Payment Order", name)
|
||||
doc.check_permission()
|
||||
make_journal_entry(doc, supplier, mode_of_payment)
|
||||
|
||||
|
||||
|
||||
@@ -1359,6 +1359,7 @@ def get_irequests_of_payment_request(doc: str | None = None) -> list:
|
||||
@frappe.whitelist()
|
||||
def get_available_payment_schedules(reference_doctype: str, reference_name: str):
|
||||
ref_doc = frappe.get_doc(reference_doctype, reference_name)
|
||||
ref_doc.check_permission()
|
||||
|
||||
if not hasattr(ref_doc, "payment_schedule") or not ref_doc.payment_schedule:
|
||||
return []
|
||||
|
||||
@@ -263,12 +263,15 @@ def get_cashiers(doctype: str, txt: str, searchfield: str, start: int, page_len:
|
||||
@frappe.whitelist()
|
||||
def get_invoices(start: str | datetime, end: str | datetime, pos_profile: str, user: str):
|
||||
invoice_doctype = frappe.db.get_single_value("POS Settings", "invoice_type")
|
||||
frappe.has_permission("POS Profile", doc=pos_profile, throw=True)
|
||||
|
||||
frappe.has_permission("Sales Invoice", throw=True)
|
||||
sales_inv_query = build_invoice_query("Sales Invoice", user, pos_profile, start, end)
|
||||
|
||||
query = sales_inv_query
|
||||
|
||||
if invoice_doctype == "POS Invoice":
|
||||
frappe.has_permission("POS Invoice", throw=True)
|
||||
pos_inv_query = build_invoice_query("POS Invoice", user, pos_profile, start, end)
|
||||
query = query + pos_inv_query
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
{
|
||||
"fieldname": "fieldname",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Fieldname"
|
||||
"in_list_view": 1,
|
||||
"label": "Fieldname",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "field",
|
||||
@@ -26,7 +27,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-29 18:08:40.323579",
|
||||
"modified": "2026-08-31 20:41:12.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Search Fields",
|
||||
|
||||
@@ -1,40 +1,9 @@
|
||||
// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
let search_fields_datatypes = [
|
||||
"Data",
|
||||
"Link",
|
||||
"Dynamic Link",
|
||||
"Long Text",
|
||||
"Select",
|
||||
"Small Text",
|
||||
"Text",
|
||||
"Text Editor",
|
||||
];
|
||||
let do_not_include_fields = [
|
||||
"naming_series",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"stock_uom",
|
||||
"asset_naming_series",
|
||||
"default_material_request_type",
|
||||
"valuation_method",
|
||||
"warranty_period",
|
||||
"weight_uom",
|
||||
"batch_number_series",
|
||||
"serial_no_series",
|
||||
"purchase_uom",
|
||||
"customs_tariff_number",
|
||||
"sales_uom",
|
||||
"deferred_revenue_account",
|
||||
"deferred_expense_account",
|
||||
"quality_inspection_template",
|
||||
"route",
|
||||
"slideshow",
|
||||
"website_image_alt",
|
||||
"thumbnail",
|
||||
"web_long_description",
|
||||
];
|
||||
function is_valid_invoice_field(df) {
|
||||
return frappe.model.no_value_type.indexOf(df.fieldtype) === -1 || df.fieldtype === "Button";
|
||||
}
|
||||
|
||||
frappe.ui.form.on("POS Settings", {
|
||||
onload: function (frm) {
|
||||
@@ -42,74 +11,97 @@ frappe.ui.form.on("POS Settings", {
|
||||
frm.trigger("add_search_options");
|
||||
},
|
||||
|
||||
invoice_type: function (frm) {
|
||||
frm.trigger("get_invoice_fields");
|
||||
},
|
||||
|
||||
get_invoice_fields: function (frm) {
|
||||
frappe.model.with_doctype("POS Invoice", () => {
|
||||
var fields = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
|
||||
if (
|
||||
frappe.model.no_value_type.indexOf(d.fieldtype) === -1 ||
|
||||
["Button"].includes(d.fieldtype)
|
||||
) {
|
||||
return { label: d.label + " (" + d.fieldtype + ")", value: d.fieldname };
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const invoice_type = frm.doc.invoice_type;
|
||||
if (!invoice_type) return;
|
||||
|
||||
frappe.model.with_doctype(invoice_type, () => {
|
||||
// the invoice type can change again while the meta loads
|
||||
if (frm.doc.invoice_type !== invoice_type) return;
|
||||
|
||||
const fields = frappe.get_doc("DocType", invoice_type).fields.filter(is_valid_invoice_field);
|
||||
|
||||
frm.fields_dict.invoice_fields.grid.update_docfield_property(
|
||||
"fieldname",
|
||||
"options",
|
||||
[""].concat(fields)
|
||||
[""].concat(
|
||||
fields.map((df) => {
|
||||
return { label: `${df.label} (${df.fieldtype})`, value: df.fieldname };
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
frm.trigger("validate_invoice_fields");
|
||||
});
|
||||
},
|
||||
|
||||
validate_invoice_fields: function (frm) {
|
||||
const valid_fieldnames = frappe
|
||||
.get_doc("DocType", frm.doc.invoice_type)
|
||||
.fields.filter(is_valid_invoice_field)
|
||||
.map((df) => df.fieldname);
|
||||
|
||||
const invalid_fields = (frm.doc.invoice_fields || [])
|
||||
.filter((row) => row.fieldname && !valid_fieldnames.includes(row.fieldname))
|
||||
.map((row) => `#${row.idx} ${row.fieldname}`);
|
||||
|
||||
if (!invalid_fields.length) return;
|
||||
|
||||
frappe.msgprint({
|
||||
title: __("Invalid POS Fields"),
|
||||
indicator: "orange",
|
||||
message: __("The following rows are not valid fields of {0} and have to be removed: {1}", [
|
||||
frm.doc.invoice_type.bold(),
|
||||
invalid_fields.join(", "),
|
||||
]),
|
||||
});
|
||||
},
|
||||
|
||||
add_search_options: function (frm) {
|
||||
frappe.model.with_doctype("Item", () => {
|
||||
var fields = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
|
||||
if (
|
||||
search_fields_datatypes.includes(d.fieldtype) &&
|
||||
!do_not_include_fields.includes(d.fieldname)
|
||||
) {
|
||||
return [d.label];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.pos_settings.pos_settings.get_pos_search_field_options",
|
||||
callback: ({ message }) => {
|
||||
const fields = message || [];
|
||||
|
||||
fields.unshift("");
|
||||
frm.fields_dict.pos_search_fields.grid.update_docfield_property("field", "options", fields);
|
||||
frm.searchable_item_fields = Object.fromEntries(
|
||||
fields.map((df) => [df.option, df.fieldname])
|
||||
);
|
||||
|
||||
frm.fields_dict.pos_search_fields.grid.update_docfield_property(
|
||||
"field",
|
||||
"options",
|
||||
[""].concat(fields.map((df) => df.option))
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("POS Search Fields", {
|
||||
field: function (frm, doctype, name) {
|
||||
var doc = frappe.get_doc(doctype, name);
|
||||
var df = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
|
||||
if (doc.field == d.label && search_fields_datatypes.includes(d.fieldtype)) {
|
||||
return d;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})[0];
|
||||
const doc = frappe.get_doc(doctype, name);
|
||||
|
||||
doc.fieldname = df.fieldname;
|
||||
frm.refresh_field("fields");
|
||||
doc.fieldname = frm.searchable_item_fields?.[doc.field] || "";
|
||||
frm.refresh_field("pos_search_fields");
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("POS Field", {
|
||||
fieldname: function (frm, doctype, name) {
|
||||
var doc = frappe.get_doc(doctype, name);
|
||||
var df = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
|
||||
return doc.fieldname == d.fieldname ? d : null;
|
||||
})[0];
|
||||
const doc = frappe.get_doc(doctype, name);
|
||||
const invoice_meta = frappe.get_doc("DocType", frm.doc.invoice_type);
|
||||
const df = invoice_meta?.fields.find((d) => d.fieldname === doc.fieldname);
|
||||
if (!df) return;
|
||||
|
||||
doc.label = df.label;
|
||||
doc.reqd = df.reqd;
|
||||
doc.options = df.options;
|
||||
doc.fieldtype = df.fieldtype;
|
||||
doc.default_value = df.default;
|
||||
frm.refresh_field("fields");
|
||||
frm.refresh_field("invoice_fields");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,8 +5,46 @@ from collections import Counter
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model import no_value_fields
|
||||
from frappe.model.document import Document
|
||||
|
||||
SEARCH_FIELD_TYPES = (
|
||||
"Data",
|
||||
"Link",
|
||||
"Dynamic Link",
|
||||
"Long Text",
|
||||
"Select",
|
||||
"Small Text",
|
||||
"Text",
|
||||
"Text Editor",
|
||||
)
|
||||
|
||||
# Item fields that are of a searchable fieldtype, but are not meaningful to search a POS item by
|
||||
DO_NOT_INCLUDE_FIELDS = (
|
||||
"naming_series",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"stock_uom",
|
||||
"asset_naming_series",
|
||||
"default_material_request_type",
|
||||
"valuation_method",
|
||||
"warranty_period",
|
||||
"weight_uom",
|
||||
"batch_number_series",
|
||||
"serial_no_series",
|
||||
"purchase_uom",
|
||||
"customs_tariff_number",
|
||||
"sales_uom",
|
||||
"deferred_revenue_account",
|
||||
"deferred_expense_account",
|
||||
"quality_inspection_template",
|
||||
"route",
|
||||
"slideshow",
|
||||
"website_image_alt",
|
||||
"thumbnail",
|
||||
"web_long_description",
|
||||
)
|
||||
|
||||
|
||||
class POSSettings(Document):
|
||||
# begin: auto-generated types
|
||||
@@ -32,17 +70,10 @@ class POSSettings(Document):
|
||||
if old_doc.invoice_type != self.invoice_type:
|
||||
self.validate_invoice_type()
|
||||
|
||||
self.validate_duplicate_invoice_fields()
|
||||
self.validate_invoice_fields()
|
||||
|
||||
def validate_invoice_fields(self):
|
||||
invoice_fields = [field.fieldname for field in self.invoice_fields]
|
||||
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
|
||||
|
||||
if len(duplicate_invoice_fields):
|
||||
for field in duplicate_invoice_fields:
|
||||
frappe.throw(
|
||||
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
|
||||
)
|
||||
self.validate_duplicate_pos_search_fields()
|
||||
self.validate_pos_search_fields()
|
||||
|
||||
def validate_invoice_type(self):
|
||||
pos_opening_entries_count = frappe.db.count(
|
||||
@@ -55,3 +86,94 @@ class POSSettings(Document):
|
||||
),
|
||||
title=_("Invoice Document Type Selection Error"),
|
||||
)
|
||||
|
||||
def validate_duplicate_invoice_fields(self):
|
||||
invoice_fields = [field.fieldname for field in self.invoice_fields]
|
||||
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
|
||||
|
||||
if len(duplicate_invoice_fields):
|
||||
for field in duplicate_invoice_fields:
|
||||
frappe.throw(
|
||||
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
|
||||
)
|
||||
|
||||
def validate_invoice_fields(self):
|
||||
if not self.invoice_type:
|
||||
return
|
||||
|
||||
meta = frappe.get_meta(self.invoice_type)
|
||||
|
||||
for field in self.invoice_fields:
|
||||
df = meta.get_field(field.fieldname)
|
||||
|
||||
if not df or not is_valid_invoice_field(df):
|
||||
frappe.throw(
|
||||
title=_("Invalid POS Field"),
|
||||
msg=_("Row #{0}: '{1}' is not a valid field of {2}.").format(
|
||||
field.idx, frappe.bold(field.fieldname or ""), frappe.bold(_(self.invoice_type))
|
||||
),
|
||||
)
|
||||
|
||||
# read only in the form, so keep them in sync with the invoice
|
||||
field.label = df.label
|
||||
field.fieldtype = df.fieldtype
|
||||
field.options = df.options
|
||||
|
||||
def validate_duplicate_pos_search_fields(self):
|
||||
fieldnames = [field.fieldname for field in self.pos_search_fields]
|
||||
duplicate_fieldnames = {key for key, value in Counter(fieldnames).items() if value > 1}
|
||||
|
||||
for fieldname in duplicate_fieldnames:
|
||||
frappe.throw(
|
||||
title=_("Duplicate POS Search Fields"),
|
||||
msg=_("'{0}' has been already added.").format(fieldname),
|
||||
)
|
||||
|
||||
def validate_pos_search_fields(self):
|
||||
searchable_fields = {df.fieldname: df for df in get_searchable_item_fields()}
|
||||
|
||||
for field in self.pos_search_fields:
|
||||
df = searchable_fields.get(field.fieldname)
|
||||
|
||||
if not df:
|
||||
frappe.throw(
|
||||
title=_("Invalid POS Search Field"),
|
||||
msg=_("Row #{0}: '{1}' cannot be used to search items.").format(
|
||||
field.idx, frappe.bold(field.fieldname or "")
|
||||
),
|
||||
)
|
||||
|
||||
if field.field != get_search_field_option(df):
|
||||
frappe.throw(
|
||||
title=_("Invalid POS Search Field"),
|
||||
msg=_("Row #{0}: '{1}' does not match {2}.").format(
|
||||
field.idx, frappe.bold(field.field or ""), frappe.bold(df.fieldname)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def is_valid_invoice_field(df):
|
||||
return df.fieldtype not in no_value_fields or df.fieldtype == "Button"
|
||||
|
||||
|
||||
def get_searchable_item_fields():
|
||||
return [
|
||||
df
|
||||
for df in frappe.get_meta("Item").fields
|
||||
if df.fieldtype in SEARCH_FIELD_TYPES and df.fieldname not in DO_NOT_INCLUDE_FIELDS
|
||||
]
|
||||
|
||||
|
||||
def get_search_field_option(df):
|
||||
# the fieldname keeps the option unique, two Item fields can share a label
|
||||
return f"{df.label} ({df.fieldname})"
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_pos_search_field_options():
|
||||
frappe.has_permission("POS Settings", throw=True)
|
||||
|
||||
return [
|
||||
{"option": get_search_field_option(df), "fieldname": df.fieldname}
|
||||
for df in get_searchable_item_fields()
|
||||
]
|
||||
|
||||
@@ -1,8 +1,135 @@
|
||||
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.patches.v16_0.append_fieldname_to_pos_search_fields import execute as append_fieldname
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestPOSSettings(ERPNextTestSuite):
|
||||
pass
|
||||
def setUp(self):
|
||||
self.settings = frappe.get_single("POS Settings")
|
||||
self.settings.invoice_fields = []
|
||||
self.settings.pos_search_fields = []
|
||||
|
||||
def assertInvalid(self, message):
|
||||
with self.assertRaises(frappe.ValidationError) as context:
|
||||
self.settings.save()
|
||||
|
||||
self.assertIn(message, str(context.exception))
|
||||
|
||||
def test_invoice_field_is_validated_against_invoice_type(self):
|
||||
# consolidated_invoice exists on POS Invoice only
|
||||
self.settings.invoice_type = "POS Invoice"
|
||||
self.settings.append("invoice_fields", {"fieldname": "consolidated_invoice"})
|
||||
self.settings.save()
|
||||
|
||||
self.settings.invoice_type = "Sales Invoice"
|
||||
self.assertInvalid("is not a valid field of")
|
||||
|
||||
def test_field_common_to_both_invoice_types_is_allowed(self):
|
||||
for invoice_type in ("POS Invoice", "Sales Invoice"):
|
||||
self.settings.invoice_type = invoice_type
|
||||
self.settings.invoice_fields = []
|
||||
self.settings.append("invoice_fields", {"fieldname": "po_no"})
|
||||
self.settings.save()
|
||||
|
||||
def test_unknown_invoice_field_is_not_allowed(self):
|
||||
self.settings.append("invoice_fields", {"fieldname": "not_a_field"})
|
||||
self.assertInvalid("is not a valid field of")
|
||||
|
||||
def test_layout_invoice_field_is_not_allowed(self):
|
||||
self.settings.append("invoice_fields", {"fieldname": "accounting_dimensions_section"})
|
||||
self.assertInvalid("is not a valid field of")
|
||||
|
||||
def test_invoice_field_properties_are_set_from_the_invoice(self):
|
||||
self.settings.append(
|
||||
"invoice_fields", {"fieldname": "customer", "label": "Tampered", "fieldtype": "Data"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
field = self.settings.invoice_fields[0]
|
||||
self.assertEqual(field.label, "Customer")
|
||||
self.assertEqual(field.fieldtype, "Link")
|
||||
self.assertEqual(field.options, "Customer")
|
||||
|
||||
def test_searchable_item_field_is_allowed(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
self.assertEqual(self.settings.pos_search_fields[0].fieldname, "description")
|
||||
|
||||
def test_excluded_search_field_is_not_allowed(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Item Name (item_name)", "fieldname": "item_name"}
|
||||
)
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_search_field_of_unsearchable_type_is_not_allowed(self):
|
||||
# maintain stock is a Check field
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Maintain Stock (is_stock_item)", "fieldname": "is_stock_item"}
|
||||
)
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_unknown_search_field_is_not_allowed(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Nope (not_an_item_field)", "fieldname": "not_an_item_field"}
|
||||
)
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_search_field_without_a_fieldname_is_not_allowed(self):
|
||||
# the form fills the fieldname in, it cannot be picked on its own
|
||||
self.settings.append("pos_search_fields", {"field": "Description (description)"})
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_search_field_option_must_match_its_fieldname(self):
|
||||
self.settings.append("pos_search_fields", {"field": "Brand (brand)", "fieldname": "description"})
|
||||
self.assertInvalid("does not match")
|
||||
|
||||
def test_bare_label_is_not_accepted_as_a_search_field(self):
|
||||
# the stored option carries the fieldname, the patch backfills older rows
|
||||
self.settings.append("pos_search_fields", {"field": "Description", "fieldname": "description"})
|
||||
self.assertInvalid("does not match")
|
||||
|
||||
def test_duplicate_search_fields_are_not_allowed(self):
|
||||
for _ in range(2):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
|
||||
self.assertInvalid("has been already added")
|
||||
|
||||
def test_patch_appends_the_fieldname_to_a_legacy_search_field(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
row = self.settings.pos_search_fields[0].name
|
||||
frappe.db.set_value("POS Search Fields", row, "field", "Description", update_modified=False)
|
||||
|
||||
append_fieldname()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
|
||||
|
||||
def test_patch_leaves_an_already_migrated_search_field_alone(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
append_fieldname()
|
||||
|
||||
row = self.settings.pos_search_fields[0].name
|
||||
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
|
||||
|
||||
def test_invoice_fields_are_skipped_when_no_invoice_type_is_selected(self):
|
||||
self.settings.invoice_type = None
|
||||
self.settings.append("invoice_fields", {"fieldname": "customer"})
|
||||
self.settings.save()
|
||||
|
||||
self.assertEqual(self.settings.invoice_fields[0].fieldname, "customer")
|
||||
|
||||
@@ -142,6 +142,8 @@ def start_pcv_processing(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def pause_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
@@ -157,6 +159,8 @@ def pause_pcv_processing(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="cancel", doc=docname, throw=True)
|
||||
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Cancelled").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
@@ -171,6 +175,8 @@ def cancel_pcv_processing(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def resume_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Running").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.controllers.accounts_controller import merge_taxes
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -52,6 +53,11 @@ def make_purchase_receipt(
|
||||
args = {}
|
||||
args = frappe.parse_json(args)
|
||||
|
||||
mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_invoice_item")
|
||||
|
||||
def received_and_mapped_qty(obj):
|
||||
return flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0))
|
||||
|
||||
def post_parent_process(source_parent, target_parent):
|
||||
remove_items_with_zero_qty(target_parent)
|
||||
set_missing_values(source_parent, target_parent)
|
||||
@@ -75,15 +81,13 @@ def make_purchase_receipt(
|
||||
or {}
|
||||
)
|
||||
|
||||
target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))
|
||||
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
|
||||
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))) * flt(
|
||||
obj.conversion_factor
|
||||
)
|
||||
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
|
||||
target.base_amount = (
|
||||
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
)
|
||||
pending_qty = flt(obj.qty) - received_and_mapped_qty(obj)
|
||||
|
||||
target.qty = pending_qty - flt(returned_qty_map.get("qty"))
|
||||
target.received_qty = pending_qty
|
||||
target.stock_qty = (pending_qty - flt(returned_qty_map.get("qty"))) * flt(obj.conversion_factor)
|
||||
target.amount = pending_qty * flt(obj.rate)
|
||||
target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
|
||||
def select_item(d):
|
||||
filtered_items = args.get("filtered_children", [])
|
||||
@@ -113,7 +117,8 @@ def make_purchase_receipt(
|
||||
"wip_composite_asset": "wip_composite_asset",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
|
||||
"condition": lambda doc: abs(received_and_mapped_qty(doc)) < abs(doc.qty)
|
||||
and select_item(doc),
|
||||
},
|
||||
"Purchase Taxes and Charges": {
|
||||
"doctype": "Purchase Taxes and Charges",
|
||||
|
||||
@@ -235,6 +235,9 @@ class PurchaseInvoice(BuyingController):
|
||||
"overflow_type": "billing",
|
||||
}
|
||||
]
|
||||
self.closed_source_links = [
|
||||
("Purchase Invoice Item", "pr_detail", "Purchase Receipt Item", "Purchase Receipt")
|
||||
]
|
||||
|
||||
def onload(self):
|
||||
super().onload()
|
||||
|
||||
@@ -277,6 +277,15 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
if not (entry.amount or entry.base_amount):
|
||||
continue
|
||||
|
||||
lcv_account_currency = get_account_currency(entry.expense_account)
|
||||
credit_in_transaction_currency = (
|
||||
flt(entry.amount)
|
||||
if lcv_account_currency == doc.currency
|
||||
else flt(
|
||||
entry.base_amount / doc.conversion_rate, item.precision("net_amount")
|
||||
)
|
||||
)
|
||||
|
||||
gl_dict = self.get_gl_dict(
|
||||
{
|
||||
"account": entry.expense_account,
|
||||
@@ -285,7 +294,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"credit": flt(entry.base_amount),
|
||||
"credit_in_account_currency": flt(entry.amount),
|
||||
"credit_in_transaction_currency": item.net_amount,
|
||||
"credit_in_transaction_currency": credit_in_transaction_currency,
|
||||
"project": entry.dimensions.project or item.project or doc.project,
|
||||
},
|
||||
item=item,
|
||||
|
||||
@@ -27,6 +27,7 @@ def start_payment_ledger_repost(docname: str | None = None):
|
||||
"""
|
||||
if docname:
|
||||
repost_doc = frappe.get_doc("Repost Payment Ledger", docname)
|
||||
repost_doc.check_permission("submit")
|
||||
if repost_doc.docstatus.is_submitted() and repost_doc.repost_status in ["Queued", "Failed"]:
|
||||
try:
|
||||
for entry in repost_doc.repost_vouchers:
|
||||
|
||||
@@ -278,6 +278,9 @@ class SalesInvoice(SellingController):
|
||||
"overflow_type": "billing",
|
||||
}
|
||||
]
|
||||
self.closed_source_links = [
|
||||
("Sales Invoice Item", "dn_detail", "Delivery Note Item", "Delivery Note")
|
||||
]
|
||||
|
||||
def set_indicator(self):
|
||||
"""Set indicator for portal"""
|
||||
|
||||
@@ -476,7 +476,7 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
|
||||
for payment_mode in doc.payments:
|
||||
if skip_change_gl_entries and payment_mode.account == doc.account_for_change_amount:
|
||||
payment_mode.base_amount -= flt(doc.change_amount)
|
||||
payment_mode.base_amount -= flt(doc.base_change_amount)
|
||||
|
||||
if payment_mode.base_amount:
|
||||
# POS, make payment entries
|
||||
|
||||
@@ -1583,6 +1583,35 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
|
||||
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
|
||||
|
||||
def test_pos_change_amount_multi_currency_gl_entry(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.services.gl_composer import SalesInvoiceGLComposer
|
||||
|
||||
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 0)
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.is_pos = 1
|
||||
si.currency = "USD"
|
||||
si.conversion_rate = 50
|
||||
si.party_account_currency = "USD"
|
||||
si.account_for_change_amount = "Cash - _TC"
|
||||
si.change_amount = 50
|
||||
si.base_change_amount = 2500
|
||||
si.append(
|
||||
"payments",
|
||||
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 150, "base_amount": 7500},
|
||||
)
|
||||
|
||||
gl_entries = []
|
||||
SalesInvoiceGLComposer(si).make_pos_gl_entries(gl_entries)
|
||||
|
||||
debtors_entry = next(entry for entry in gl_entries if entry["account"] == si.debit_to)
|
||||
cash_entry = next(entry for entry in gl_entries if entry["account"] == "Cash - _TC")
|
||||
|
||||
self.assertEqual(flt(debtors_entry["credit"]), 5000.0)
|
||||
self.assertEqual(flt(cash_entry["debit"]), 5000.0)
|
||||
|
||||
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
|
||||
|
||||
def test_stock_delivered_but_not_billed_gl_on_invoice(self):
|
||||
company = "_Test SDBNB Company"
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
@@ -9,6 +9,7 @@ from frappe.utils import cint, flt
|
||||
|
||||
from erpnext.accounts.report.general_ledger.general_ledger import get_accounts_with_children
|
||||
from erpnext.accounts.report.trial_balance.trial_balance import validate_filters
|
||||
from erpnext.accounts.utils import get_currency_precision
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -43,6 +44,7 @@ def get_data(filters, show_party_name):
|
||||
account_filter = get_accounts_with_children(filters.get("account"))
|
||||
|
||||
company_currency = frappe.get_cached_value("Company", filters.company, "default_currency")
|
||||
precision = get_currency_precision()
|
||||
opening_balances = get_opening_balances(filters, account_filter)
|
||||
balances_within_period = get_balances_within_period(filters, account_filter)
|
||||
|
||||
@@ -65,14 +67,17 @@ def get_data(filters, show_party_name):
|
||||
|
||||
# opening
|
||||
opening_debit, opening_credit = opening_balances.get(party.name, [0, 0])
|
||||
opening_debit, opening_credit = flt(opening_debit, precision), flt(opening_credit, precision)
|
||||
row.update({"opening_debit": opening_debit, "opening_credit": opening_credit})
|
||||
|
||||
# within period
|
||||
debit, credit = balances_within_period.get(party.name, [0, 0])
|
||||
debit, credit = flt(debit, precision), flt(credit, precision)
|
||||
row.update({"debit": debit, "credit": credit})
|
||||
|
||||
# closing
|
||||
closing_debit, closing_credit = toggle_debit_credit(opening_debit + debit, opening_credit + credit)
|
||||
closing_debit, closing_credit = flt(closing_debit, precision), flt(closing_credit, precision)
|
||||
row.update({"closing_debit": closing_debit, "closing_credit": closing_credit})
|
||||
|
||||
row.update({"currency": company_currency})
|
||||
|
||||
@@ -309,6 +309,9 @@ def get_payment_entries(filters, args):
|
||||
pe.mode_of_payment,
|
||||
pe.project,
|
||||
pe.cost_center,
|
||||
pe.payment_type,
|
||||
pe.source_exchange_rate,
|
||||
pe.target_exchange_rate,
|
||||
)
|
||||
.where(
|
||||
(pe.docstatus == 1)
|
||||
@@ -319,6 +322,22 @@ def get_payment_entries(filters, args):
|
||||
)
|
||||
query = apply_common_conditions(filters, query, doctype="Payment Entry", payments=True)
|
||||
payment_entries = query.run(as_dict=True)
|
||||
|
||||
if payment_entries:
|
||||
ded = frappe.qb.DocType("Payment Entry Deduction")
|
||||
deduction_totals = frappe._dict(
|
||||
frappe.qb.from_(ded)
|
||||
.select(ded.parent, Sum(ded.amount))
|
||||
.where(ded.parent.isin([d.name for d in payment_entries]) & (ded.is_exchange_gain_loss == 0))
|
||||
.groupby(ded.parent)
|
||||
.run()
|
||||
)
|
||||
for d in payment_entries:
|
||||
exchange_rate = (
|
||||
d.source_exchange_rate if d.payment_type == "Receive" else d.target_exchange_rate
|
||||
) or 1
|
||||
d.base_grand_total = flt(d.base_grand_total) + flt(deduction_totals.get(d.name)) / exchange_rate
|
||||
|
||||
return payment_entries
|
||||
|
||||
|
||||
|
||||
@@ -32,12 +32,13 @@ class ChildItemUpdater:
|
||||
self.child_docname = child_docname
|
||||
self.parent = frappe.get_doc(parent_doctype, parent_doctype_name)
|
||||
self.allow_zero_qty = get_allow_zero_qty(parent_doctype)
|
||||
self._ordered_items: dict | None = None
|
||||
self._purchased_items: dict | None = None
|
||||
self._transacted_stock_qty: dict | None = None
|
||||
|
||||
def update(self, trans_items: str | list) -> None:
|
||||
"""Process item additions, edits, and deletions from trans_items JSON."""
|
||||
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items
|
||||
from erpnext.buying.doctype.supplier_quotation.mapper import (
|
||||
get_ordered_items as get_ordered_supplier_quotation_items,
|
||||
)
|
||||
from erpnext.selling.doctype.quotation.mapper import get_ordered_items
|
||||
|
||||
data = frappe.parse_json(trans_items)
|
||||
@@ -48,11 +49,15 @@ class ChildItemUpdater:
|
||||
self._check_permissions("write")
|
||||
|
||||
if self.parent_doctype == "Quotation":
|
||||
self._ordered_items = get_ordered_items(self.parent.name)
|
||||
items_added_or_removed |= validate_and_delete_children(self.parent, data, self._ordered_items)
|
||||
self._transacted_stock_qty = get_ordered_items(self.parent.name)
|
||||
items_added_or_removed |= validate_and_delete_children(
|
||||
self.parent, data, self._transacted_stock_qty
|
||||
)
|
||||
elif self.parent_doctype == "Supplier Quotation":
|
||||
self._purchased_items = get_purchased_items(self.parent.name)
|
||||
items_added_or_removed |= validate_and_delete_children(self.parent, data, self._purchased_items)
|
||||
self._transacted_stock_qty = get_ordered_supplier_quotation_items(self.parent.name)
|
||||
items_added_or_removed |= validate_and_delete_children(
|
||||
self.parent, data, self._transacted_stock_qty
|
||||
)
|
||||
else:
|
||||
items_added_or_removed |= validate_and_delete_children(self.parent, data)
|
||||
|
||||
@@ -71,6 +76,7 @@ class ChildItemUpdater:
|
||||
else:
|
||||
self._check_permissions("write")
|
||||
child_item = frappe.get_doc(self.parent_doctype + " Item", d.get("docname"))
|
||||
d["conversion_factor"] = self._get_new_conversion_factor(child_item, d)
|
||||
|
||||
change_state = get_child_item_change_state(self.parent_doctype, child_item, d)
|
||||
rate_unchanged = change_state.rate_unchanged
|
||||
@@ -78,6 +84,13 @@ class ChildItemUpdater:
|
||||
if is_child_item_unchanged(change_state):
|
||||
continue
|
||||
|
||||
if child_item.get("closed"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Cannot change item {1} because it is closed. Reopen the row first."
|
||||
).format(child_item.idx, child_item.item_code)
|
||||
)
|
||||
|
||||
self._validate_quantity_and_rate(child_item, d, rate_unchanged)
|
||||
|
||||
if flt(child_item.get("qty")) != flt(d.get("qty")):
|
||||
@@ -251,6 +264,22 @@ class ChildItemUpdater:
|
||||
item_row,
|
||||
)
|
||||
|
||||
def _get_new_conversion_factor(self, child_item, new_data: dict) -> float:
|
||||
current_factor = flt(child_item.get("conversion_factor")) or 1
|
||||
uom = new_data.get("uom") or child_item.get("uom")
|
||||
|
||||
if uom == child_item.get("stock_uom"):
|
||||
return 1
|
||||
|
||||
requested_factor = flt(new_data.get("conversion_factor"))
|
||||
if requested_factor:
|
||||
return requested_factor
|
||||
|
||||
if uom == child_item.get("uom"):
|
||||
return current_factor
|
||||
|
||||
return flt(get_conversion_factor(child_item.item_code, uom).get("conversion_factor")) or 1
|
||||
|
||||
def _validate_quantity_and_rate(self, child_item, new_data: dict, rate_unchanged: bool | None) -> None:
|
||||
if not flt(new_data.get("qty")) and not self.allow_zero_qty:
|
||||
frappe.throw(
|
||||
@@ -264,24 +293,24 @@ class ChildItemUpdater:
|
||||
"Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity.")),
|
||||
"Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity.")),
|
||||
}
|
||||
old_conversion_factor = flt(child_item.get("conversion_factor")) or 1
|
||||
new_conversion_factor = flt(new_data.get("conversion_factor")) or old_conversion_factor
|
||||
new_stock_qty = flt(new_data.get("qty")) * new_conversion_factor
|
||||
|
||||
if self.parent_doctype in qty_limits:
|
||||
qty_field, error_message = qty_limits[self.parent_doctype]
|
||||
if flt(new_data.get("qty")) < flt(child_item.get(qty_field)):
|
||||
old_stock_qty = flt(child_item.get(qty_field)) * old_conversion_factor
|
||||
if new_stock_qty < old_stock_qty:
|
||||
frappe.throw(
|
||||
_("Row #{0}:").format(new_data.get("idx")) + error_message,
|
||||
title=_("Invalid Qty"),
|
||||
)
|
||||
|
||||
if self.parent_doctype not in ("Quotation", "Supplier Quotation"):
|
||||
if not self._transacted_stock_qty:
|
||||
return
|
||||
|
||||
items_map = self._ordered_items if self.parent_doctype == "Quotation" else self._purchased_items
|
||||
if not items_map:
|
||||
return
|
||||
|
||||
qty_to_check = items_map.get(child_item.name)
|
||||
if not qty_to_check:
|
||||
old_stock_qty = self._transacted_stock_qty.get(child_item.name)
|
||||
if not old_stock_qty:
|
||||
return
|
||||
|
||||
if not rate_unchanged:
|
||||
@@ -291,7 +320,7 @@ class ChildItemUpdater:
|
||||
).format(frappe.bold(new_data.get("item_code")))
|
||||
)
|
||||
|
||||
if flt(new_data.get("qty")) < qty_to_check:
|
||||
if new_stock_qty < old_stock_qty:
|
||||
frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity"))
|
||||
|
||||
def _validate_fg_item_for_subcontracting(self, new_data: dict, is_new: bool) -> None:
|
||||
@@ -458,7 +487,11 @@ def update_bin_on_delete(row, doctype: str) -> None:
|
||||
def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
|
||||
"""Delete child rows not present in data; return True if any were removed."""
|
||||
updated_item_names = [d.get("docname") for d in data]
|
||||
deleted_children = [item for item in parent.items if item.name not in updated_item_names]
|
||||
# A closed row is left out of the payload rather than deleted, so its absence
|
||||
# must not be read as a removal.
|
||||
deleted_children = [
|
||||
item for item in parent.items if item.name not in updated_item_names and not item.get("closed")
|
||||
]
|
||||
|
||||
for d in deleted_children:
|
||||
validate_child_on_delete(d, parent, ordered_item)
|
||||
@@ -581,22 +614,18 @@ def update_child_item_rate_and_discount(
|
||||
|
||||
|
||||
def update_child_item_uom_and_weight(child_item, new_data) -> None:
|
||||
conv_fac_precision = child_item.precision("conversion_factor") or 2
|
||||
|
||||
if new_data.get("conversion_factor"):
|
||||
if child_item.stock_uom == child_item.uom:
|
||||
child_item.conversion_factor = 1
|
||||
else:
|
||||
child_item.conversion_factor = flt(new_data.get("conversion_factor"), conv_fac_precision)
|
||||
child_item.conversion_factor = flt(new_data.get("conversion_factor"))
|
||||
|
||||
if new_data.get("uom"):
|
||||
child_item.uom = new_data.get("uom")
|
||||
conversion_factor = flt(
|
||||
get_conversion_factor(child_item.item_code, child_item.uom).get("conversion_factor")
|
||||
)
|
||||
child_item.conversion_factor = (
|
||||
flt(new_data.get("conversion_factor"), conv_fac_precision) or conversion_factor
|
||||
)
|
||||
child_item.conversion_factor = flt(new_data.get("conversion_factor")) or conversion_factor
|
||||
|
||||
if child_item.get("weight_per_unit"):
|
||||
child_item.total_weight = flt(
|
||||
|
||||
@@ -53,6 +53,11 @@ class TaxService:
|
||||
if doc.get("taxes") or doc.get("is_pos"):
|
||||
return
|
||||
|
||||
# set by the Opening Invoice Creation Tool, where the outstanding amount
|
||||
# entered against a party is already inclusive of tax
|
||||
if doc.flags.dont_auto_add_taxes:
|
||||
return
|
||||
|
||||
if frappe.get_single_value(
|
||||
"Accounts Settings", "add_taxes_from_taxes_and_charges_template"
|
||||
) and hasattr(doc, "taxes_and_charges"):
|
||||
|
||||
@@ -854,6 +854,7 @@
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"filters": "{\"is_return\": 1}",
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
@@ -864,8 +865,7 @@
|
||||
"open_in_new_tab": 0,
|
||||
"route_options": "{\"is_return\": 1}",
|
||||
"show_arrow": 0,
|
||||
"type": "Link",
|
||||
"filters": "{\"is_return\": 1}"
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
@@ -931,6 +931,7 @@
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"filters": "{\"is_return\": 1}",
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
@@ -941,8 +942,7 @@
|
||||
"open_in_new_tab": 0,
|
||||
"route_options": "{\"is_return\": 1}",
|
||||
"show_arrow": 0,
|
||||
"type": "Link",
|
||||
"filters": "{\"is_return\": 1}"
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
@@ -1154,20 +1154,6 @@
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 1,
|
||||
"label": "Payments",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
@@ -1418,27 +1404,12 @@
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 1,
|
||||
"label": "Financial Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"icon": "wallet",
|
||||
"indent": 1,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Financial Reports",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
@@ -1877,7 +1848,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-28 12:00:00.000000",
|
||||
"modified": "2026-09-01 17:33:21.459367",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts",
|
||||
|
||||
@@ -1359,21 +1359,28 @@ def get_children(
|
||||
parent_fieldname = "parent_" + doctype.lower().replace(" ", "_")
|
||||
fields = ["name as value", "is_group as expandable"]
|
||||
filters = [["docstatus", "<", 2]]
|
||||
if frappe.db.has_column(doctype, "disabled") and not include_disabled:
|
||||
filters.append(["disabled", "=", False])
|
||||
if frappe.db.has_column(doctype, "disabled"):
|
||||
if include_disabled:
|
||||
# the tree marks disabled rows, so it needs the flag
|
||||
fields.append("disabled")
|
||||
else:
|
||||
filters.append(["disabled", "=", False])
|
||||
|
||||
# extra columns the tree views render as badges / clean labels
|
||||
node_fields = {
|
||||
"Account": ["root_type", "account_name", "account_number", "account_currency", "freeze_account"],
|
||||
"Cost Center": ["cost_center_name", "cost_center_number"],
|
||||
}
|
||||
fields += node_fields.get(doctype, [])
|
||||
|
||||
if is_root:
|
||||
filters.append(IfNull(Field(parent_fieldname), "") == "")
|
||||
filters.append(["company", "=", company])
|
||||
if doctype == "Account":
|
||||
fields.append("report_type")
|
||||
else:
|
||||
filters.append([parent_fieldname, "=", parent])
|
||||
|
||||
if is_root:
|
||||
fields += ["root_type", "report_type", "account_currency"] if doctype == "Account" else []
|
||||
filters.append(["company", "=", company])
|
||||
|
||||
else:
|
||||
fields += ["root_type", "account_currency"] if doctype == "Account" else []
|
||||
fields += [parent_fieldname + " as parent"]
|
||||
fields.append(parent_fieldname + " as parent")
|
||||
|
||||
acc = frappe.get_list(doctype, fields=fields, filters=filters)
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2026-08-16 00:00:00.000000",
|
||||
"docstatus": 0,
|
||||
"doctype": "Sidebar",
|
||||
"header_icon": "layers",
|
||||
"idx": 0,
|
||||
"items": [
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bulk Transaction Log Detail",
|
||||
"link_to": "Bulk Transaction Log Detail",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bulk Transaction Log",
|
||||
"link_to": "Bulk Transaction Log",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-16 00:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Bulk Transaction",
|
||||
"name": "Bulk Transaction",
|
||||
"owner": "Administrator",
|
||||
"sequence_id": 0.0,
|
||||
"standard": 1,
|
||||
"title": "Bulk Transaction"
|
||||
}
|
||||
@@ -10,6 +10,7 @@ from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.utils import flt, get_link_to_form
|
||||
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
from erpnext.controllers.status_updater import get_allowance_for
|
||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults
|
||||
@@ -34,12 +35,14 @@ def make_purchase_receipt(
|
||||
def is_unit_price_row(source):
|
||||
return has_unit_price_items and source.qty == 0
|
||||
|
||||
mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_order_item")
|
||||
|
||||
def get_max_receivable_qty(source):
|
||||
tolerance = flt(get_allowance_for(source.item_code, qty_or_amount="qty")[0])
|
||||
return flt(source.qty) * (100 + tolerance) / 100
|
||||
|
||||
def update_item(obj, target, source_parent):
|
||||
received_qty = flt(obj.received_qty)
|
||||
received_qty = flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0))
|
||||
qty = flt(obj.qty)
|
||||
pending_qty = qty - received_qty
|
||||
|
||||
@@ -84,11 +87,13 @@ def make_purchase_receipt(
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: (
|
||||
True
|
||||
doc.name not in mapped_qty_by_item
|
||||
if is_unit_price_row(doc)
|
||||
else abs(doc.received_qty) < abs(get_max_receivable_qty(doc))
|
||||
else abs(doc.received_qty) + abs(mapped_qty_by_item.get(doc.name, 0))
|
||||
< abs(get_max_receivable_qty(doc))
|
||||
)
|
||||
and doc.delivered_by_supplier != 1
|
||||
and not doc.closed
|
||||
and select_item(doc),
|
||||
},
|
||||
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},
|
||||
@@ -148,9 +153,13 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
|
||||
)
|
||||
return query.run(pluck="qty")[0] or 0
|
||||
|
||||
mapped_qty_by_item = get_qty_already_mapped(target_doc, "po_detail")
|
||||
|
||||
def get_billed_and_mapped_qty(po_item_name):
|
||||
return flt(get_billed_qty(po_item_name)) + flt(mapped_qty_by_item.get(po_item_name, 0))
|
||||
|
||||
def update_item(obj, target, source_parent):
|
||||
billed_qty = flt(get_billed_qty(obj.name))
|
||||
target.qty = flt(obj.qty) - billed_qty
|
||||
target.qty = flt(obj.qty) - get_billed_and_mapped_qty(obj.name)
|
||||
|
||||
item = get_item_defaults(target.item_code, source_parent.company)
|
||||
item_group = get_item_group_defaults(target.item_code, source_parent.company)
|
||||
@@ -193,6 +202,8 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
|
||||
or abs(doc.billed_amt) < abs(doc.amount)
|
||||
or doc.qty > flt(get_billed_qty(doc.name))
|
||||
)
|
||||
and (doc.name not in mapped_qty_by_item or doc.qty > get_billed_and_mapped_qty(doc.name))
|
||||
and not doc.closed
|
||||
and select_item(doc),
|
||||
},
|
||||
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},
|
||||
|
||||
@@ -14,7 +14,9 @@ frappe.ui.form.on("Purchase Order", {
|
||||
setup: function (frm) {
|
||||
frm.set_indicator_formatter("item_code", function (doc) {
|
||||
let color;
|
||||
if (!doc.qty && frm.doc.has_unit_price_items) {
|
||||
if (doc.closed) {
|
||||
color = "gray";
|
||||
} else if (!doc.qty && frm.doc.has_unit_price_items) {
|
||||
color = "yellow";
|
||||
} else if (doc.qty <= doc.received_qty) {
|
||||
color = "green";
|
||||
@@ -340,7 +342,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
this.frm.page.set_inner_btn_group_as_primary(__("Status"));
|
||||
}
|
||||
} else if (["Closed", "Delivered"].includes(doc.status)) {
|
||||
if (this.frm.has_perm("submit")) {
|
||||
if (this.frm.has_perm("submit") && !doc.items.every((item) => item.closed)) {
|
||||
this.frm.add_custom_button(
|
||||
__("Re-open"),
|
||||
() => this.unclose_purchase_order(),
|
||||
@@ -352,7 +354,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
if (doc.status != "On Hold") {
|
||||
if (
|
||||
(doc.items
|
||||
.filter((item) => !item.delivered_by_supplier)
|
||||
.filter((item) => !item.delivered_by_supplier && !item.closed)
|
||||
.some((item) => item.received_qty < item.qty) ||
|
||||
doc.__onload?.has_pending_receivable_qty) &&
|
||||
allow_receipt
|
||||
@@ -365,7 +367,11 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
__("Create")
|
||||
);
|
||||
if (doc.is_subcontracted) {
|
||||
if (!doc.items.every((item) => item.qty == item.subcontracted_qty)) {
|
||||
if (
|
||||
!doc.items
|
||||
.filter((item) => !item.closed)
|
||||
.every((item) => item.qty == item.subcontracted_qty)
|
||||
) {
|
||||
this.frm.add_custom_button(
|
||||
__("Subcontracting Order"),
|
||||
() => {
|
||||
@@ -433,6 +439,8 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
} else if (doc.docstatus === 0) {
|
||||
this.frm.cscript.add_from_mappers();
|
||||
}
|
||||
|
||||
this.set_item_close_buttons();
|
||||
}
|
||||
|
||||
validate() {
|
||||
@@ -527,7 +535,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
},
|
||||
get_query_filters: {
|
||||
docstatus: 1,
|
||||
status: ["not in", ["Stopped", "Expired"]],
|
||||
status: ["not in", ["Stopped", "Ordered", "Expired"]],
|
||||
},
|
||||
allow_child_item_selection: true,
|
||||
child_fieldname: "items",
|
||||
@@ -697,6 +705,19 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
this.frm.cscript.update_status("Close", "Closed");
|
||||
}
|
||||
|
||||
set_item_close_buttons() {
|
||||
erpnext.item_close.add_buttons(
|
||||
this.frm,
|
||||
erpnext.item_close.fulfilment_config({
|
||||
qty_field: "received_qty",
|
||||
qty_label: __("Received Qty"),
|
||||
help: __(
|
||||
"Closed rows stop being expected. Their pending quantity is written off and they are skipped when creating a Purchase Receipt or Purchase Invoice."
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
update_dropship_delivered_qty() {
|
||||
const data = this.frm.doc.items
|
||||
.filter((item) => item.delivered_by_supplier == 1)
|
||||
|
||||
@@ -178,7 +178,15 @@ class PurchaseOrder(BuyingController):
|
||||
"global_allowance_field": "over_order_allowance",
|
||||
"global_allowance_doctype": "Buying Settings",
|
||||
"item_allowance_field": "over_order_allowance",
|
||||
}
|
||||
},
|
||||
{
|
||||
"source_dt": "Purchase Order Item",
|
||||
"target_dt": "Supplier Quotation Item",
|
||||
"join_field": "supplier_quotation_item",
|
||||
"target_field": "ordered_qty",
|
||||
"target_ref_field": "stock_qty",
|
||||
"source_field": "stock_qty",
|
||||
},
|
||||
]
|
||||
|
||||
def onload(self):
|
||||
@@ -250,6 +258,7 @@ class PurchaseOrder(BuyingController):
|
||||
["conversion_factor", "="],
|
||||
],
|
||||
"is_child_table": True,
|
||||
"allow_duplicate_prev_row_id": True,
|
||||
},
|
||||
"Material Request": {
|
||||
"ref_dn_field": "material_request",
|
||||
@@ -402,6 +411,25 @@ class PurchaseOrder(BuyingController):
|
||||
def update_status(self, status):
|
||||
StatusService(self).update_status(status)
|
||||
|
||||
def on_item_close_status_change(self):
|
||||
StatusService(self).recalculate_after_item_close()
|
||||
|
||||
def is_item_closable(self, item):
|
||||
return flt(item.received_qty) < flt(item.qty) or super().is_item_closable(item)
|
||||
|
||||
def update_prevdoc_status(self):
|
||||
super().update_prevdoc_status()
|
||||
|
||||
for supplier_quotation in {item.supplier_quotation for item in self.items}:
|
||||
if not supplier_quotation:
|
||||
continue
|
||||
|
||||
doc = frappe.get_doc("Supplier Quotation", supplier_quotation)
|
||||
if doc.docstatus.is_cancelled():
|
||||
frappe.throw(_("Supplier Quotation {0} is cancelled").format(supplier_quotation))
|
||||
|
||||
doc.set_status(update=True)
|
||||
|
||||
def on_submit(self):
|
||||
super().on_submit()
|
||||
|
||||
@@ -531,7 +559,7 @@ class PurchaseOrder(BuyingController):
|
||||
considering the configured over_delivery_receipt_allowance.
|
||||
"""
|
||||
for item in self.get("items", []):
|
||||
if item.delivered_by_supplier:
|
||||
if item.delivered_by_supplier or item.closed:
|
||||
continue
|
||||
tolerance = flt(get_allowance_for(item.item_code, qty_or_amount="qty")[0])
|
||||
max_receivable_qty = flt(item.qty) * (100 + tolerance) / 100
|
||||
|
||||
@@ -9,6 +9,7 @@ from frappe.desk.notifications import clear_doctype_notifications
|
||||
from frappe.utils import cstr, flt
|
||||
|
||||
from erpnext.buying.doctype.purchase_order.services.subcontracting import SubcontractingService
|
||||
from erpnext.controllers.item_close import validate_parent_reopen
|
||||
|
||||
|
||||
class StatusService:
|
||||
@@ -18,6 +19,10 @@ class StatusService:
|
||||
def update_status(self, status: str) -> None:
|
||||
doc = self.doc
|
||||
self.check_modified_date()
|
||||
|
||||
if status != "Closed" and doc.status == "Closed":
|
||||
validate_parent_reopen(doc)
|
||||
|
||||
doc.set_status(update=True, status=status)
|
||||
doc.update_requested_qty()
|
||||
doc.update_ordered_qty()
|
||||
@@ -26,6 +31,17 @@ class StatusService:
|
||||
doc.notify_update()
|
||||
clear_doctype_notifications(doc)
|
||||
|
||||
def recalculate_after_item_close(self) -> None:
|
||||
"""Refresh progress after row flags changed.
|
||||
|
||||
`update_billing_percentage` runs last because it reloads the parent and
|
||||
writes the final status from both percentages.
|
||||
"""
|
||||
doc = self.doc
|
||||
self.update_receiving_percentage()
|
||||
doc.update_ordered_qty()
|
||||
doc.update_billing_percentage()
|
||||
|
||||
def check_modified_date(self) -> None:
|
||||
doc = self.doc
|
||||
modified_in_db = frappe.db.get_value("Purchase Order", doc.name, "modified")
|
||||
@@ -39,10 +55,9 @@ class StatusService:
|
||||
def update_receiving_percentage(self) -> None:
|
||||
doc = self.doc
|
||||
total_qty, received_qty = 0.0, 0.0
|
||||
for item in doc.items:
|
||||
for item in [item for item in doc.items if not item.closed] or doc.items:
|
||||
received_qty += min(item.received_qty, item.qty)
|
||||
total_qty += item.qty
|
||||
if total_qty and received_qty:
|
||||
doc.db_set("per_received", flt(received_qty / total_qty) * 100, update_modified=False)
|
||||
else:
|
||||
doc.db_set("per_received", 0, update_modified=False)
|
||||
|
||||
per_received = flt(received_qty / total_qty) * 100 if total_qty else 0
|
||||
doc.db_set("per_received", per_received, update_modified=False)
|
||||
|
||||
@@ -333,6 +333,38 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
self.assertEqual(po.get("items")[0].amount, 1400)
|
||||
self.assertEqual(get_ordered_qty(), existing_ordered_qty + 3)
|
||||
|
||||
def test_update_child_qty_with_conversion_factor_after_receipt(self):
|
||||
item = make_item(uoms=[{"uom": "Box", "conversion_factor": 5}])
|
||||
purchase_order = create_purchase_order(item_code=item.item_code, qty=6, do_not_save=True)
|
||||
purchase_order.items[0].uom = "Box"
|
||||
purchase_order.items[0].conversion_factor = 5
|
||||
purchase_order.save()
|
||||
purchase_order.submit()
|
||||
create_pr_against_po(purchase_order.name, 2)
|
||||
|
||||
row = purchase_order.items[0]
|
||||
trans_items = json.dumps(
|
||||
[
|
||||
{
|
||||
"item_code": row.item_code,
|
||||
"rate": row.rate,
|
||||
"qty": 4,
|
||||
"uom": row.uom,
|
||||
"conversion_factor": 2,
|
||||
"docname": row.name,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Cannot set quantity less than received quantity",
|
||||
update_child_qty_rate,
|
||||
"Purchase Order",
|
||||
trans_items,
|
||||
purchase_order.name,
|
||||
)
|
||||
|
||||
def test_update_child_adding_new_item(self):
|
||||
po = create_purchase_order(do_not_save=1)
|
||||
po.items[0].qty = 4
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"returned_qty",
|
||||
"column_break_60",
|
||||
"billed_amt",
|
||||
"closed",
|
||||
"accounting_details",
|
||||
"expense_account",
|
||||
"column_break_fyqr",
|
||||
@@ -557,6 +558,7 @@
|
||||
"fieldname": "blanket_order_rate",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Blanket Order Rate",
|
||||
"options": "currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -645,6 +647,15 @@
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "closed",
|
||||
"fieldtype": "Check",
|
||||
"label": "Closed",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Tax detail table fetched from item master as a string and stored in this field.\nUsed for Taxes and Charges",
|
||||
"fieldname": "item_tax_rate",
|
||||
@@ -944,7 +955,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified": "2026-08-27 11:55:37.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order Item",
|
||||
|
||||
@@ -29,6 +29,7 @@ class PurchaseOrderItem(Document):
|
||||
blanket_order_rate: DF.Currency
|
||||
bom: DF.Link | None
|
||||
brand: DF.Link | None
|
||||
closed: DF.Check
|
||||
company_total_stock: DF.Float
|
||||
conversion_factor: DF.Float
|
||||
cost_center: DF.Link | None
|
||||
|
||||
@@ -8,6 +8,8 @@ from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_purchase_order(
|
||||
@@ -16,6 +18,9 @@ def make_purchase_order(
|
||||
if args is None:
|
||||
args = {}
|
||||
args = frappe.parse_json(args)
|
||||
ordered_items = get_ordered_items(source_name)
|
||||
|
||||
mapped_items = get_qty_already_mapped(target_doc, "supplier_quotation_item")
|
||||
|
||||
def set_missing_values(source, target):
|
||||
target.run_method("set_missing_values")
|
||||
@@ -23,7 +28,9 @@ def make_purchase_order(
|
||||
target.run_method("calculate_taxes_and_totals")
|
||||
|
||||
def update_item(obj, target, source_parent):
|
||||
target.stock_qty = flt(obj.qty) * flt(obj.conversion_factor)
|
||||
balance_stock_qty = obj.stock_qty - ordered_items.get(obj.name, 0.0)
|
||||
target.stock_qty = balance_stock_qty if balance_stock_qty > 0 else 0
|
||||
target.qty = flt(target.stock_qty) / flt(obj.conversion_factor)
|
||||
|
||||
def select_item(d):
|
||||
filtered_items = args.get("filtered_children", [])
|
||||
@@ -51,7 +58,9 @@ def make_purchase_order(
|
||||
["sales_order", "sales_order"],
|
||||
],
|
||||
"postprocess": update_item,
|
||||
"condition": select_item,
|
||||
"condition": lambda item: item.name not in mapped_items
|
||||
and (item.stock_qty > ordered_items.get(item.name, 0.0) or item.qty == 0)
|
||||
and select_item(item),
|
||||
},
|
||||
"Purchase Taxes and Charges": {
|
||||
"doctype": "Purchase Taxes and Charges",
|
||||
@@ -107,3 +116,14 @@ def make_quotation(source_name: str, target_doc: str | dict | Document | None =
|
||||
)
|
||||
|
||||
return doclist
|
||||
|
||||
|
||||
def get_ordered_items(supplier_quotation: str) -> frappe._dict:
|
||||
return frappe._dict(
|
||||
frappe.get_all(
|
||||
"Supplier Quotation Item",
|
||||
{"docstatus": 1, "parent": supplier_quotation, "ordered_qty": (">", 0)},
|
||||
["name", "ordered_qty"],
|
||||
as_list=True,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -34,7 +34,10 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
|
||||
if (this.frm.doc.__islocal && !this.frm.doc.valid_till) {
|
||||
this.frm.set_value("valid_till", frappe.datetime.add_months(this.frm.doc.transaction_date, 1));
|
||||
}
|
||||
if (this.frm.doc.docstatus === 1) {
|
||||
if (
|
||||
this.frm.doc.docstatus === 1 &&
|
||||
!["Stopped", "Ordered", "Expired"].includes(this.frm.doc.status)
|
||||
) {
|
||||
this.frm.add_custom_button(
|
||||
__("Purchase Order"),
|
||||
this.make_purchase_order.bind(this),
|
||||
|
||||
@@ -767,7 +767,7 @@
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "status",
|
||||
"oldfieldtype": "Select",
|
||||
"options": "\nDraft\nSubmitted\nStopped\nCancelled\nExpired",
|
||||
"options": "\nDraft\nSubmitted\nPartially Ordered\nOrdered\nStopped\nCancelled\nExpired",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1,
|
||||
@@ -948,7 +948,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:50.274992",
|
||||
"modified": "2026-08-30 16:30:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation",
|
||||
|
||||
@@ -6,10 +6,13 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import getdate, nowdate
|
||||
from pypika.terms import ExistsCriterion
|
||||
|
||||
from erpnext.buying.utils import validate_for_items
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
|
||||
from .mapper import get_ordered_items
|
||||
|
||||
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
|
||||
|
||||
|
||||
@@ -86,7 +89,9 @@ class SupplierQuotation(BuyingController):
|
||||
shipping_address: DF.Link | None
|
||||
shipping_address_display: DF.TextEditor | None
|
||||
shipping_rule: DF.Link | None
|
||||
status: DF.Literal["", "Draft", "Submitted", "Stopped", "Cancelled", "Expired"]
|
||||
status: DF.Literal[
|
||||
"", "Draft", "Submitted", "Partially Ordered", "Ordered", "Stopped", "Cancelled", "Expired"
|
||||
]
|
||||
supplier: DF.Link
|
||||
supplier_address: DF.Link | None
|
||||
supplier_name: DF.Data | None
|
||||
@@ -112,13 +117,17 @@ class SupplierQuotation(BuyingController):
|
||||
|
||||
def validate(self):
|
||||
super().validate()
|
||||
self.set_status()
|
||||
|
||||
if not self.status:
|
||||
self.status = "Draft"
|
||||
|
||||
from erpnext.controllers.status_updater import validate_status
|
||||
|
||||
validate_status(self.status, ["Draft", "Submitted", "Stopped", "Cancelled"])
|
||||
validate_status(
|
||||
self.status,
|
||||
["Draft", "Submitted", "Partially Ordered", "Ordered", "Stopped", "Cancelled", "Expired"],
|
||||
)
|
||||
|
||||
validate_for_items(self)
|
||||
self.validate_with_previous_doc()
|
||||
@@ -126,11 +135,11 @@ class SupplierQuotation(BuyingController):
|
||||
self.validate_valid_till()
|
||||
|
||||
def on_submit(self):
|
||||
self.db_set("status", "Submitted")
|
||||
self.set_status(update=True)
|
||||
self.update_rfq_supplier_status(1)
|
||||
|
||||
def on_cancel(self):
|
||||
self.db_set("status", "Cancelled")
|
||||
self.set_status(update=True)
|
||||
self.update_rfq_supplier_status(0)
|
||||
|
||||
def on_trash(self):
|
||||
@@ -166,6 +175,24 @@ class SupplierQuotation(BuyingController):
|
||||
if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date):
|
||||
frappe.throw(_("Valid till Date cannot be before Transaction Date"))
|
||||
|
||||
def get_ordered_status(self):
|
||||
ordered_items = get_ordered_items(self.name)
|
||||
|
||||
if not ordered_items:
|
||||
return "Submitted"
|
||||
|
||||
for row in self.items:
|
||||
if row.name not in ordered_items or row.stock_qty > ordered_items[row.name]:
|
||||
return "Partially Ordered"
|
||||
|
||||
return "Ordered"
|
||||
|
||||
def is_fully_ordered(self):
|
||||
return self.get_ordered_status() == "Ordered"
|
||||
|
||||
def is_partially_ordered(self):
|
||||
return self.get_ordered_status() == "Partially Ordered"
|
||||
|
||||
def update_rfq_supplier_status(self, include_me):
|
||||
from frappe.query_builder.functions import Count
|
||||
|
||||
@@ -243,27 +270,29 @@ def get_list_context(context=None):
|
||||
|
||||
|
||||
def set_expired_status():
|
||||
# Only submitted quotations past their validity should be expired
|
||||
frappe.db.set_value(
|
||||
"Supplier Quotation",
|
||||
{
|
||||
"docstatus": 1,
|
||||
"status": ["not in", ["Cancelled", "Stopped"]],
|
||||
"valid_till": ["<", nowdate()],
|
||||
},
|
||||
"status",
|
||||
"Expired",
|
||||
update_modified=True,
|
||||
)
|
||||
supplier_quotation = frappe.qb.DocType("Supplier Quotation")
|
||||
purchase_order = frappe.qb.DocType("Purchase Order")
|
||||
purchase_order_item = frappe.qb.DocType("Purchase Order Item")
|
||||
|
||||
|
||||
def get_purchased_items(supplier_quotation: str):
|
||||
return frappe._dict(
|
||||
frappe.get_all(
|
||||
"Purchase Order Item",
|
||||
filters={"supplier_quotation": supplier_quotation, "docstatus": 1},
|
||||
fields=["supplier_quotation_item", {"SUM": "qty"}],
|
||||
group_by="supplier_quotation_item",
|
||||
as_list=1,
|
||||
purchase_order_against_quotation = (
|
||||
frappe.qb.from_(purchase_order)
|
||||
.from_(purchase_order_item)
|
||||
.select(purchase_order.name)
|
||||
.where(
|
||||
(purchase_order_item.docstatus == 1)
|
||||
& (purchase_order.docstatus == 1)
|
||||
& (purchase_order_item.parent == purchase_order.name)
|
||||
& (purchase_order_item.supplier_quotation == supplier_quotation.name)
|
||||
)
|
||||
)
|
||||
|
||||
(
|
||||
frappe.qb.update(supplier_quotation)
|
||||
.set(supplier_quotation.status, "Expired")
|
||||
.where(
|
||||
(supplier_quotation.docstatus == 1)
|
||||
& (supplier_quotation.status.notin(["Expired", "Stopped"]))
|
||||
& (supplier_quotation.valid_till < nowdate())
|
||||
& ExistsCriterion(purchase_order_against_quotation).negate()
|
||||
)
|
||||
).run()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
frappe.listview_settings["Supplier Quotation"] = {
|
||||
add_fields: ["supplier", "base_grand_total", "status", "company", "currency"],
|
||||
get_indicator: function (doc) {
|
||||
if (doc.status === "Ordered") {
|
||||
if (doc.status === "Submitted") {
|
||||
return [__("Submitted"), "blue", "status,=,Submitted"];
|
||||
} else if (doc.status === "Partially Ordered") {
|
||||
return [__("Partially Ordered"), "yellow", "status,=,Partially Ordered"];
|
||||
} else if (doc.status === "Ordered") {
|
||||
return [__("Ordered"), "green", "status,=,Ordered"];
|
||||
} else if (doc.status === "Rejected") {
|
||||
return [__("Lost"), "gray", "status,=,Lost"];
|
||||
} else if (doc.status === "Expired") {
|
||||
return [__("Expired"), "gray", "status,=,Expired"];
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ from erpnext.buying.doctype.request_for_quotation.test_request_for_quotation imp
|
||||
from erpnext.buying.doctype.supplier_quotation.mapper import make_purchase_order
|
||||
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import set_expired_status
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate
|
||||
from erpnext.patches.v16_0.set_supplier_quotation_order_status import execute as set_order_status
|
||||
from erpnext.tests.assertions import assert_raises_with_savepoint
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -23,6 +24,23 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.load_test_records("Supplier Quotation")
|
||||
|
||||
def make_order(self, supplier_quotation, qty):
|
||||
purchase_order = make_purchase_order(supplier_quotation.name)
|
||||
purchase_order.naming_series = "_T-Purchase Order-"
|
||||
purchase_order.items[0].qty = qty
|
||||
purchase_order.items[0].schedule_date = add_days(today(), 1)
|
||||
purchase_order.insert()
|
||||
purchase_order.submit()
|
||||
return purchase_order
|
||||
|
||||
def update_order_qty(self, purchase_order, qty):
|
||||
item = purchase_order.items[0]
|
||||
items = json.dumps(
|
||||
[{"item_code": item.item_code, "rate": item.rate, "qty": qty, "docname": item.name}]
|
||||
)
|
||||
update_child_qty_rate("Purchase Order", items, purchase_order.name)
|
||||
purchase_order.reload()
|
||||
|
||||
def test_valid_till_before_transaction_date_rejected(self):
|
||||
rfq = make_request_for_quotation()
|
||||
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[0].supplier)
|
||||
@@ -44,6 +62,14 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
valid.insert()
|
||||
valid.submit()
|
||||
|
||||
partially_ordered = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[1].supplier)
|
||||
partially_ordered.valid_till = add_days(today(), 10)
|
||||
partially_ordered.items[0].qty = 10
|
||||
partially_ordered.insert()
|
||||
partially_ordered.submit()
|
||||
partial_order = self.make_order(partially_ordered, 4)
|
||||
partially_ordered.db_set("valid_till", add_days(today(), -2))
|
||||
|
||||
# A past-validity draft must not be expired - "Expired" applies to submitted quotations only
|
||||
draft = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.suppliers[0].supplier)
|
||||
draft.transaction_date = add_days(today(), -10)
|
||||
@@ -54,8 +80,24 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Supplier Quotation", expired.name, "status"), "Expired")
|
||||
self.assertEqual(frappe.db.get_value("Supplier Quotation", valid.name, "status"), "Submitted")
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Supplier Quotation", partially_ordered.name, "status"),
|
||||
"Partially Ordered",
|
||||
)
|
||||
self.assertEqual(frappe.db.get_value("Supplier Quotation", draft.name, "status"), "Draft")
|
||||
|
||||
partial_order.cancel()
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Supplier Quotation", partially_ordered.name, "status"),
|
||||
"Submitted",
|
||||
)
|
||||
|
||||
set_expired_status()
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Supplier Quotation", partially_ordered.name, "status"),
|
||||
"Expired",
|
||||
)
|
||||
|
||||
def test_submit_and_cancel_updates_rfq_quote_status(self):
|
||||
rfq = make_request_for_quotation()
|
||||
supplier_row = rfq.suppliers[0]
|
||||
@@ -73,6 +115,150 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
"Pending",
|
||||
)
|
||||
|
||||
def test_purchase_order_updates_order_status(self):
|
||||
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
supplier_quotation.submit()
|
||||
self.assertEqual(supplier_quotation.status, "Submitted")
|
||||
|
||||
partial_order = self.make_order(supplier_quotation, 4)
|
||||
supplier_quotation.reload()
|
||||
self.assertEqual(supplier_quotation.status, "Partially Ordered")
|
||||
self.assertEqual(supplier_quotation.items[0].ordered_qty, 4)
|
||||
|
||||
self.update_order_qty(partial_order, 10)
|
||||
supplier_quotation.reload()
|
||||
self.assertEqual(supplier_quotation.status, "Ordered")
|
||||
self.assertEqual(supplier_quotation.items[0].ordered_qty, 10)
|
||||
|
||||
self.update_order_qty(partial_order, 4)
|
||||
supplier_quotation.reload()
|
||||
self.assertEqual(supplier_quotation.status, "Partially Ordered")
|
||||
|
||||
complete_order = self.make_order(supplier_quotation, 6)
|
||||
supplier_quotation.reload()
|
||||
self.assertEqual(supplier_quotation.status, "Ordered")
|
||||
|
||||
complete_order.cancel()
|
||||
supplier_quotation.reload()
|
||||
self.assertEqual(supplier_quotation.status, "Partially Ordered")
|
||||
|
||||
partial_order.cancel()
|
||||
supplier_quotation.reload()
|
||||
self.assertEqual(supplier_quotation.status, "Submitted")
|
||||
self.assertEqual(supplier_quotation.items[0].ordered_qty, 0)
|
||||
|
||||
def test_purchase_order_maps_remaining_quotation_qty(self):
|
||||
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
supplier_quotation.submit()
|
||||
self.make_order(supplier_quotation, 4)
|
||||
|
||||
purchase_order = make_purchase_order(supplier_quotation.name)
|
||||
self.assertEqual(purchase_order.items[0].qty, 6)
|
||||
|
||||
purchase_order.items[0].schedule_date = add_days(today(), 1)
|
||||
purchase_order.submit()
|
||||
|
||||
supplier_quotation.reload()
|
||||
self.assertEqual(supplier_quotation.status, "Ordered")
|
||||
self.assertEqual(make_purchase_order(supplier_quotation.name).items, [])
|
||||
|
||||
def test_purchase_order_skips_fully_ordered_quotation_rows(self):
|
||||
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
supplier_quotation.append("items", {"item_code": "_Test Item 2", "qty": 3, "rate": 100})
|
||||
supplier_quotation.submit()
|
||||
|
||||
first_order = make_purchase_order(
|
||||
supplier_quotation.name,
|
||||
args={"filtered_children": [supplier_quotation.items[0].name]},
|
||||
)
|
||||
first_order.items[0].schedule_date = add_days(today(), 1)
|
||||
first_order.submit()
|
||||
|
||||
supplier_quotation.reload()
|
||||
self.assertEqual(supplier_quotation.status, "Partially Ordered")
|
||||
|
||||
second_order = make_purchase_order(supplier_quotation.name)
|
||||
self.assertEqual(len(second_order.items), 1)
|
||||
self.assertEqual(second_order.items[0].item_code, "_Test Item 2")
|
||||
self.assertEqual(second_order.items[0].qty, 3)
|
||||
|
||||
second_order.items[0].schedule_date = add_days(today(), 1)
|
||||
second_order.submit()
|
||||
supplier_quotation.reload()
|
||||
self.assertEqual(supplier_quotation.status, "Ordered")
|
||||
|
||||
def test_purchase_order_cannot_exceed_supplier_quotation_qty(self):
|
||||
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
supplier_quotation.items[0].qty = 5
|
||||
supplier_quotation.submit()
|
||||
|
||||
first_order = make_purchase_order(supplier_quotation.name)
|
||||
second_order = make_purchase_order(supplier_quotation.name)
|
||||
for purchase_order in (first_order, second_order):
|
||||
purchase_order.items[0].schedule_date = add_days(today(), 1)
|
||||
|
||||
first_order.submit()
|
||||
self.assertRaises(frappe.ValidationError, second_order.submit)
|
||||
|
||||
def test_removing_purchase_order_item_updates_quotation_status(self):
|
||||
first_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
first_quotation.submit()
|
||||
second_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
second_item = second_quotation.items[0]
|
||||
second_item.item_code = second_item.item_name = "_Test Item 2"
|
||||
second_item.stock_uom = second_item.uom = frappe.db.get_value("Item", "_Test Item 2", "stock_uom")
|
||||
second_item.conversion_factor = 1
|
||||
second_quotation.submit()
|
||||
|
||||
purchase_order = make_purchase_order(first_quotation.name)
|
||||
purchase_order = make_purchase_order(second_quotation.name, purchase_order)
|
||||
purchase_order.naming_series = "_T-Purchase Order-"
|
||||
for item in purchase_order.items:
|
||||
item.schedule_date = add_days(today(), 1)
|
||||
purchase_order.insert()
|
||||
purchase_order.submit()
|
||||
|
||||
first_quotation.reload()
|
||||
second_quotation.reload()
|
||||
self.assertEqual(first_quotation.status, "Ordered")
|
||||
self.assertEqual(second_quotation.status, "Ordered")
|
||||
|
||||
remaining_item = next(
|
||||
item for item in purchase_order.items if item.supplier_quotation == second_quotation.name
|
||||
)
|
||||
update_child_qty_rate(
|
||||
"Purchase Order",
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"item_code": remaining_item.item_code,
|
||||
"rate": remaining_item.rate,
|
||||
"qty": remaining_item.qty,
|
||||
"docname": remaining_item.name,
|
||||
}
|
||||
]
|
||||
),
|
||||
purchase_order.name,
|
||||
)
|
||||
|
||||
first_quotation.reload()
|
||||
second_quotation.reload()
|
||||
self.assertEqual(first_quotation.status, "Submitted")
|
||||
self.assertEqual(second_quotation.status, "Ordered")
|
||||
|
||||
def test_order_status_patch_updates_existing_quotation(self):
|
||||
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
supplier_quotation.submit()
|
||||
self.make_order(supplier_quotation, 4)
|
||||
|
||||
supplier_quotation.db_set("status", "Submitted")
|
||||
frappe.db.set_value("Supplier Quotation Item", supplier_quotation.items[0].name, "ordered_qty", 0)
|
||||
set_order_status()
|
||||
|
||||
supplier_quotation.reload()
|
||||
self.assertEqual(supplier_quotation.status, "Partially Ordered")
|
||||
self.assertEqual(supplier_quotation.items[0].ordered_qty, 4)
|
||||
|
||||
def test_update_child_supplier_quotation_add_item(self):
|
||||
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
sq.submit()
|
||||
@@ -127,6 +313,52 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
frappe.ValidationError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name
|
||||
)
|
||||
|
||||
def test_update_child_qty_with_conversion_factor_after_purchase(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item = make_item(uoms=[{"uom": "Box", "conversion_factor": 5}])
|
||||
supplier_quotation = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
supplier_quotation.items[0].item_code = item.item_code
|
||||
supplier_quotation.items[0].qty = 6
|
||||
supplier_quotation.items[0].uom = "Box"
|
||||
supplier_quotation.items[0].conversion_factor = 5
|
||||
supplier_quotation.insert()
|
||||
supplier_quotation.submit()
|
||||
|
||||
purchase_order = make_purchase_order(supplier_quotation.name)
|
||||
purchase_order.schedule_date = add_days(today(), 1)
|
||||
purchase_order.items[0].qty = 2
|
||||
purchase_order.save()
|
||||
purchase_order.submit()
|
||||
|
||||
def update_qty(qty):
|
||||
row = supplier_quotation.items[0]
|
||||
trans_items = json.dumps(
|
||||
[
|
||||
{
|
||||
"item_code": row.item_code,
|
||||
"rate": row.rate,
|
||||
"qty": qty,
|
||||
"uom": row.uom,
|
||||
"conversion_factor": 2,
|
||||
"docname": row.name,
|
||||
}
|
||||
]
|
||||
)
|
||||
update_child_qty_rate("Supplier Quotation", trans_items, supplier_quotation.name)
|
||||
|
||||
update_qty(5)
|
||||
supplier_quotation.reload()
|
||||
self.assertEqual(supplier_quotation.items[0].conversion_factor, 2)
|
||||
self.assertEqual(supplier_quotation.items[0].stock_qty, 10)
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Cannot reduce quantity than ordered or purchased quantity",
|
||||
update_qty,
|
||||
4,
|
||||
)
|
||||
|
||||
def test_update_supplier_quotation_child_remove_item(self):
|
||||
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
sq.submit()
|
||||
|
||||
@@ -230,6 +230,17 @@
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "ordered_qty",
|
||||
"fieldtype": "Float",
|
||||
"hidden": 1,
|
||||
"label": "Ordered Qty",
|
||||
"no_copy": 1,
|
||||
"non_negative": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "base_price_list_rate",
|
||||
"fieldtype": "Currency",
|
||||
@@ -615,7 +626,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified": "2026-08-31 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation Item",
|
||||
|
||||
@@ -44,6 +44,7 @@ class SupplierQuotationItem(Document):
|
||||
material_request_item: DF.Data | None
|
||||
net_amount: DF.Currency
|
||||
net_rate: DF.Currency
|
||||
ordered_qty: DF.Float
|
||||
page_break: DF.Check
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
|
||||
@@ -96,6 +96,17 @@ frappe.query_reports["Supplier Quotation Comparison"] = {
|
||||
],
|
||||
default: "Submitted",
|
||||
},
|
||||
{
|
||||
fieldname: "order_status",
|
||||
label: __("Order Status"),
|
||||
fieldtype: "Select",
|
||||
options: [
|
||||
{ label: "", value: "" },
|
||||
{ label: __("Not Ordered"), value: "Not Ordered" },
|
||||
{ label: __("Partially Ordered"), value: "Partially Ordered" },
|
||||
{ label: __("Ordered"), value: "Ordered" },
|
||||
],
|
||||
},
|
||||
{
|
||||
fieldtype: "Check",
|
||||
label: __("Include Expired"),
|
||||
|
||||
@@ -52,6 +52,7 @@ def get_data(filters):
|
||||
sq_item.request_for_quotation,
|
||||
sq_item.lead_time_days,
|
||||
sq.supplier.as_("supplier_name"),
|
||||
sq.status.as_("supplier_quotation_status"),
|
||||
sq.valid_till,
|
||||
)
|
||||
.where(
|
||||
@@ -82,6 +83,11 @@ def get_data(filters):
|
||||
if filters.get("supplier"):
|
||||
query = query.where(sq.supplier.isin(filters.get("supplier")))
|
||||
|
||||
if filters.get("order_status") == "Not Ordered":
|
||||
query = query.where(sq.status.notin(["Partially Ordered", "Ordered"]))
|
||||
elif filters.get("order_status"):
|
||||
query = query.where(sq.status == filters.get("order_status"))
|
||||
|
||||
if not filters.get("include_expired"):
|
||||
query = query.where(sq.status != "Expired")
|
||||
|
||||
@@ -109,6 +115,7 @@ def prepare_data(supplier_quotation_data, filters):
|
||||
else data.get("item_code"), # leave blank if group by field
|
||||
"supplier_name": "" if group_by_field == "supplier_name" else data.get("supplier_name"),
|
||||
"quotation": data.get("parent"),
|
||||
"order_status": get_order_status(data.get("supplier_quotation_status")),
|
||||
"qty": data.get("qty"),
|
||||
"price": flt(data.get("amount"), float_precision),
|
||||
"uom": data.get("uom"),
|
||||
@@ -164,6 +171,10 @@ def prepare_data(supplier_quotation_data, filters):
|
||||
return out, chart_data
|
||||
|
||||
|
||||
def get_order_status(status):
|
||||
return status if status in ("Partially Ordered", "Ordered") else "Not Ordered"
|
||||
|
||||
|
||||
def prepare_chart_data(suppliers, qty_list, supplier_qty_price_map):
|
||||
data_points_map = {}
|
||||
qty_list.sort()
|
||||
@@ -265,6 +276,12 @@ def get_columns(filters):
|
||||
"options": "Supplier Quotation",
|
||||
"width": 200,
|
||||
},
|
||||
{
|
||||
"fieldname": "order_status",
|
||||
"label": _("Order Status"),
|
||||
"fieldtype": "Data",
|
||||
"width": 130,
|
||||
},
|
||||
{"fieldname": "valid_till", "label": _("Valid Till"), "fieldtype": "Date", "width": 100},
|
||||
{
|
||||
"fieldname": "lead_time_days",
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.buying.doctype.supplier_quotation.mapper import make_purchase_order
|
||||
from erpnext.buying.report.supplier_quotation_comparison.supplier_quotation_comparison import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -38,6 +40,15 @@ class TestSupplierQuotationComparison(ERPNextTestSuite):
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def make_order(self, supplier_quotation, qty):
|
||||
purchase_order = make_purchase_order(supplier_quotation.name)
|
||||
purchase_order.naming_series = "_T-Purchase Order-"
|
||||
purchase_order.items[0].qty = qty
|
||||
purchase_order.items[0].schedule_date = add_days(today(), 1)
|
||||
purchase_order.insert()
|
||||
purchase_order.submit()
|
||||
return purchase_order
|
||||
|
||||
def test_no_filters_returns_empty(self):
|
||||
self.assertEqual(execute(None)[1], [])
|
||||
|
||||
@@ -83,3 +94,34 @@ class TestSupplierQuotationComparison(ERPNextTestSuite):
|
||||
both = names(status="")
|
||||
self.assertIn(draft.name, both)
|
||||
self.assertIn(submitted.name, both)
|
||||
|
||||
def test_order_status_and_filter(self):
|
||||
supplier_quotation = self.make_quotation("_Test Supplier", qty=10, rate=100)
|
||||
|
||||
def get_order_status():
|
||||
return next(
|
||||
row["order_status"]
|
||||
for row in self.run_report(item_code=ITEM)
|
||||
if row["quotation"] == supplier_quotation.name
|
||||
)
|
||||
|
||||
def quotations_with_status(order_status):
|
||||
return {row["quotation"] for row in self.run_report(item_code=ITEM, order_status=order_status)}
|
||||
|
||||
self.assertEqual(get_order_status(), "Not Ordered")
|
||||
self.assertIn(supplier_quotation.name, quotations_with_status("Not Ordered"))
|
||||
|
||||
partial_order = self.make_order(supplier_quotation, qty=4)
|
||||
self.assertEqual(get_order_status(), "Partially Ordered")
|
||||
self.assertIn(supplier_quotation.name, quotations_with_status("Partially Ordered"))
|
||||
self.assertNotIn(supplier_quotation.name, quotations_with_status("Ordered"))
|
||||
|
||||
complete_order = self.make_order(supplier_quotation, qty=6)
|
||||
self.assertEqual(get_order_status(), "Ordered")
|
||||
self.assertIn(supplier_quotation.name, quotations_with_status("Ordered"))
|
||||
|
||||
complete_order.cancel()
|
||||
self.assertEqual(get_order_status(), "Partially Ordered")
|
||||
|
||||
partial_order.cancel()
|
||||
self.assertEqual(get_order_status(), "Not Ordered")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2026-08-16 00:00:00.000000",
|
||||
"creation": "2026-08-16 00:00:00",
|
||||
"docstatus": 0,
|
||||
"doctype": "Sidebar",
|
||||
"header_icon": "shopping-cart",
|
||||
@@ -283,97 +283,6 @@
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"icon": "rocket",
|
||||
"indent": 1,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 1,
|
||||
"label": "Subcontracting",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"icon": "folder-tree",
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Subcontracting BOM",
|
||||
"link_to": "Subcontracting BOM",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Subcontracting Inward Order",
|
||||
"link_to": "Subcontracting Inward Order",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Subcontracting Delivery",
|
||||
"link_to": "Stock Entry",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Subcontracting Order",
|
||||
"link_to": "Subcontracting Order",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Subcontracting Receipt",
|
||||
"link_to": "Subcontracting Receipt",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
@@ -631,12 +540,11 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-16 00:00:00.000000",
|
||||
"modified": "2026-09-01 17:37:29.971719",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Buying",
|
||||
"owner": "Administrator",
|
||||
"sequence_id": 0.0,
|
||||
"standard": 1,
|
||||
"title": "Buying"
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2026-08-16 00:00:00.000000",
|
||||
"docstatus": 0,
|
||||
"doctype": "Sidebar",
|
||||
"header_icon": "messages-square",
|
||||
"idx": 0,
|
||||
"items": [
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Communication Medium",
|
||||
"link_to": "Communication Medium",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-16 00:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Communication",
|
||||
"name": "Communication",
|
||||
"owner": "Administrator",
|
||||
"sequence_id": 0.0,
|
||||
"standard": 1,
|
||||
"title": "Communication"
|
||||
}
|
||||
@@ -39,6 +39,7 @@ from erpnext.accounts.utils import (
|
||||
get_advance_payment_doctypes as _get_advance_payment_doctypes,
|
||||
)
|
||||
from erpnext.accounts.utils import get_fiscal_year, validate_fiscal_year
|
||||
from erpnext.controllers.item_close import clear_closed_rows_on_amend
|
||||
from erpnext.controllers.print_settings import (
|
||||
set_print_templates_for_item_table,
|
||||
set_print_templates_for_taxes,
|
||||
@@ -227,7 +228,23 @@ class AccountsController(TransactionBase):
|
||||
|
||||
return False
|
||||
|
||||
def is_item_closable(self, item):
|
||||
"""A row can be closed while anything is still pending on it.
|
||||
|
||||
Billing is the axis every closable document shares; the order doctypes
|
||||
extend this with their own fulfilment axis.
|
||||
|
||||
Amounts are compared as magnitudes so that return rows stay closable.
|
||||
That is deliberate: writing off a credit note that will never be issued
|
||||
is a real decision, and closing a whole return document is already
|
||||
allowed. Leaving it to the sign of the amount would decide it by
|
||||
accident.
|
||||
"""
|
||||
return abs(flt(item.billed_amt)) < abs(flt(item.amount))
|
||||
|
||||
def validate(self):
|
||||
clear_closed_rows_on_amend(self)
|
||||
|
||||
if not self.get("is_return") and not self.get("is_debit_note"):
|
||||
self.validate_qty_is_not_zero()
|
||||
|
||||
|
||||
145
erpnext/controllers/item_close.py
Normal file
145
erpnext/controllers/item_close.py
Normal file
@@ -0,0 +1,145 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Row level close and reopen for transaction items.
|
||||
|
||||
`REOPEN_STATUS` holds, per closable parent, the status its own Re-open button
|
||||
passes to `update_status`. `set_status` recomputes from `status_map` anyway, so
|
||||
the value is mostly a sentinel for "clear the Closed override" -- but not
|
||||
always: Sales Order re-checks the credit limit only on the literal "Draft".
|
||||
Reusing each doctype's own value keeps reopening a row indistinguishable from
|
||||
reopening the document by hand.
|
||||
"""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint
|
||||
|
||||
REOPEN_STATUS = {
|
||||
"Purchase Order": "Submitted",
|
||||
"Sales Order": "Draft",
|
||||
"Delivery Note": "Submitted",
|
||||
"Purchase Receipt": "Submitted",
|
||||
}
|
||||
|
||||
SETTLED_BY_CLOSE = ("per_ordered", "per_received", "per_delivered", "per_billed")
|
||||
|
||||
|
||||
def has_closable_items(doctype: str | None) -> bool:
|
||||
return doctype in REOPEN_STATUS
|
||||
|
||||
|
||||
def closed_rows_settle(parent_doctype: str, item_doctype: str, percentage_field: str) -> bool:
|
||||
"""Whether closed rows count as fully settled for this progress field.
|
||||
|
||||
Returns are excluded: closing a row writes off what is still pending on it,
|
||||
it does not turn the row into a return.
|
||||
"""
|
||||
return (
|
||||
percentage_field in SETTLED_BY_CLOSE
|
||||
and has_closable_items(parent_doctype)
|
||||
and frappe.get_meta(item_doctype).has_field("closed")
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_closed_status(doctype: str, name: str, item_names: str | list[str], closed: int) -> None:
|
||||
if not has_closable_items(doctype):
|
||||
frappe.throw(_("Rows of {0} cannot be closed individually").format(_(doctype)))
|
||||
|
||||
closed = 1 if cint(closed) else 0
|
||||
item_names = set(frappe.parse_json(item_names) or [])
|
||||
if not item_names:
|
||||
frappe.throw(_("Select at least one row"))
|
||||
|
||||
doc = frappe.get_lazy_doc(doctype, name, check_permission="submit")
|
||||
if doc.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(_(doctype), name))
|
||||
|
||||
changed = [row for row in doc.items if row.name in item_names and cint(row.closed) != closed]
|
||||
if not changed:
|
||||
return
|
||||
|
||||
if closed:
|
||||
settled = [row for row in changed if not doc.is_item_closable(row)]
|
||||
if settled:
|
||||
frappe.throw(
|
||||
_("Row #{0}: {1} is already completed in full, so there is nothing to close").format(
|
||||
settled[0].idx, frappe.bold(settled[0].item_code)
|
||||
)
|
||||
)
|
||||
|
||||
validate_rows = getattr(doc, "validate_item_close", None)
|
||||
if validate_rows:
|
||||
validate_rows(changed)
|
||||
|
||||
for row in changed:
|
||||
row.db_set("closed", closed)
|
||||
|
||||
doc.on_item_close_status_change()
|
||||
doc.reload()
|
||||
|
||||
if closed:
|
||||
close_parent_if_fully_closed(doc)
|
||||
else:
|
||||
reopen_parent_if_closed(doc)
|
||||
|
||||
doc.notify_update()
|
||||
|
||||
|
||||
def close_parent_if_fully_closed(doc) -> None:
|
||||
"""Close the parent once every row has been closed."""
|
||||
if doc.status == "Closed":
|
||||
return
|
||||
|
||||
if all(cint(row.closed) for row in doc.items):
|
||||
doc.update_status("Closed")
|
||||
|
||||
|
||||
def reopen_parent_if_closed(doc) -> None:
|
||||
"""Reopen the parent so the row that was just reopened can be acted on.
|
||||
|
||||
A closed parent suppresses its rows everywhere, so leaving it closed would
|
||||
make reopening a row look like it did nothing.
|
||||
"""
|
||||
if doc.status == "Closed":
|
||||
doc.update_status(REOPEN_STATUS[doc.doctype])
|
||||
|
||||
|
||||
def is_bundle_of_closed_row(packed_item) -> bool:
|
||||
"""A packed item follows the row of its parent document that bundles it."""
|
||||
if not packed_item.parent_detail_docname or not packed_item.parenttype:
|
||||
return False
|
||||
|
||||
item_doctype = f"{packed_item.parenttype} Item"
|
||||
|
||||
return bool(frappe.db.get_value(item_doctype, packed_item.parent_detail_docname, "closed"))
|
||||
|
||||
|
||||
def clear_closed_rows_on_amend(doc) -> None:
|
||||
"""An amended document starts with nothing written off.
|
||||
|
||||
Frappe copies `no_copy` fields when amending so a cancelled document can be
|
||||
corrected and resubmitted, which would otherwise carry a write-off decision
|
||||
that was made against the cancelled document onto the new one.
|
||||
"""
|
||||
if not doc.is_new() or not doc.get("amended_from") or not has_closable_items(doc.doctype):
|
||||
return
|
||||
|
||||
for row in doc.get("items") or []:
|
||||
row.closed = 0
|
||||
|
||||
|
||||
def validate_parent_reopen(doc) -> None:
|
||||
"""Block reopening a parent whose rows are all closed.
|
||||
|
||||
It would read as open while every row stayed suppressed. Reopening the rows
|
||||
is the way back, and that reopens the parent on its own.
|
||||
"""
|
||||
rows = doc.get("items") or []
|
||||
if rows and all(cint(row.get("closed")) for row in rows):
|
||||
frappe.throw(
|
||||
_("Every row of {0} is closed. Reopen the rows you need instead, using {1}.").format(
|
||||
frappe.bold(doc.name), frappe.bold(_("Reopen Items"))
|
||||
)
|
||||
)
|
||||
@@ -13,6 +13,8 @@ services it orchestrates. The whitelisted ``show_*_preview`` entry points stay o
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext import get_company_currency
|
||||
|
||||
|
||||
def get_accounting_ledger_preview(doc, filters):
|
||||
from erpnext.accounts.report.general_ledger.general_ledger import get_columns as get_gl_columns
|
||||
@@ -44,7 +46,7 @@ def get_accounting_ledger_preview(doc, filters):
|
||||
columns = get_gl_columns(filters)
|
||||
gl_entries = get_gl_entries_for_preview(doc.doctype, doc.name, fields)
|
||||
|
||||
gl_columns = get_columns(columns, fields)
|
||||
gl_columns = get_columns(columns, fields, get_company_currency(filters.company))
|
||||
gl_data = get_data(fields, gl_entries)
|
||||
finally:
|
||||
frappe.db.rollback(save_point="ledger_preview")
|
||||
@@ -92,7 +94,7 @@ def get_stock_ledger_preview(doc, filters):
|
||||
columns = get_sl_columns(filters)
|
||||
sl_entries = get_sl_entries_for_preview(doc.doctype, doc.name, fields)
|
||||
|
||||
sl_columns = get_columns(columns, columns_fields)
|
||||
sl_columns = get_columns(columns, columns_fields, get_company_currency(filters.company))
|
||||
sl_data = get_data(columns_fields, sl_entries)
|
||||
finally:
|
||||
frappe.db.rollback(save_point="ledger_preview")
|
||||
@@ -113,7 +115,8 @@ def get_sl_entries_for_preview(doctype, docname, fields):
|
||||
entry["out_qty"] = abs(entry.actual_qty)
|
||||
entry["in_qty"] = 0
|
||||
|
||||
entry["in_out_rate"] = entry["valuation_rate"]
|
||||
if entry.actual_qty < 0:
|
||||
entry["in_out_rate"] = entry.stock_value_difference / entry.actual_qty
|
||||
|
||||
return sl_entries
|
||||
|
||||
@@ -122,12 +125,23 @@ def get_gl_entries_for_preview(doctype, docname, fields):
|
||||
return frappe.get_all("GL Entry", filters={"voucher_type": doctype, "voucher_no": docname}, fields=fields)
|
||||
|
||||
|
||||
def get_columns(raw_columns, fields):
|
||||
return [
|
||||
{"name": d.get("label"), "editable": False, "width": 110, "fieldtype": d.get("fieldtype")}
|
||||
for d in raw_columns
|
||||
if not d.get("hidden") and d.get("fieldname") in fields
|
||||
]
|
||||
def get_columns(raw_columns, fields, currency):
|
||||
columns = []
|
||||
for source_column in raw_columns:
|
||||
if source_column.get("hidden") or source_column.get("fieldname") not in fields:
|
||||
continue
|
||||
|
||||
column = {
|
||||
"name": source_column.get("label"),
|
||||
"editable": False,
|
||||
"width": 110,
|
||||
"fieldtype": source_column.get("fieldtype"),
|
||||
}
|
||||
if column["fieldtype"] == "Currency":
|
||||
column["options"] = currency
|
||||
columns.append(column)
|
||||
|
||||
return columns
|
||||
|
||||
|
||||
def get_data(raw_columns, raw_data):
|
||||
|
||||
25
erpnext/controllers/mapper.py
Normal file
25
erpnext/controllers/mapper.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
|
||||
def get_qty_already_mapped(target_doc, ref_field: str, qty_field: str = "qty") -> frappe._dict:
|
||||
"""Return a map: {source row name: qty} of rows already mapped into the target document.
|
||||
|
||||
"Get Items From" passes the in-progress (unsaved) document back as `target_doc`. Its rows
|
||||
are invisible to the pending-qty queries in the mappers, which only count submitted
|
||||
documents -- so without this, selecting the same source document twice maps every row
|
||||
again. Rows are keyed by `ref_field` (dn_detail, so_detail, ...), and a row is present in
|
||||
the map even when its qty is 0, so mappers without qty tracking can dedupe on presence.
|
||||
"""
|
||||
if isinstance(target_doc, str):
|
||||
target_doc = frappe.parse_json(target_doc)
|
||||
|
||||
qty_map = frappe._dict()
|
||||
for row in (target_doc and target_doc.get("items")) or []:
|
||||
if ref := row.get(ref_field):
|
||||
qty_map[ref] = qty_map.get(ref, 0) + flt(row.get(qty_field))
|
||||
|
||||
return qty_map
|
||||
@@ -801,7 +801,7 @@ def get_blanket_orders(doctype: str, txt: str, searchfield: str, start: int, pag
|
||||
bo = frappe.qb.DocType("Blanket Order")
|
||||
bo_item = frappe.qb.DocType("Blanket Order Item")
|
||||
|
||||
blanket_orders = (
|
||||
query = (
|
||||
frappe.qb.from_(bo)
|
||||
.from_(bo_item)
|
||||
.select(bo.name)
|
||||
@@ -814,10 +814,12 @@ def get_blanket_orders(doctype: str, txt: str, searchfield: str, start: int, pag
|
||||
& (bo.company == filters.get("company"))
|
||||
& (bo.docstatus == 1)
|
||||
)
|
||||
.run()
|
||||
)
|
||||
|
||||
return blanket_orders
|
||||
if currency := filters.get("currency"):
|
||||
query = query.where(bo.currency == currency)
|
||||
|
||||
return query.run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -8,6 +8,8 @@ from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import comma_or, flt, get_link_to_form, getdate, now, nowdate, safe_div
|
||||
|
||||
from erpnext.controllers.item_close import closed_rows_settle, has_closable_items
|
||||
|
||||
|
||||
class OverAllowanceError(frappe.ValidationError):
|
||||
pass
|
||||
@@ -40,6 +42,14 @@ status_map = {
|
||||
["Ordered", "is_fully_ordered"],
|
||||
["Cancelled", "eval:self.docstatus==2"],
|
||||
],
|
||||
"Supplier Quotation": [
|
||||
["Draft", None],
|
||||
["Submitted", "eval:self.docstatus==1"],
|
||||
["Stopped", "eval:self.status=='Stopped'"],
|
||||
["Partially Ordered", "is_partially_ordered"],
|
||||
["Ordered", "is_fully_ordered"],
|
||||
["Cancelled", "eval:self.docstatus==2"],
|
||||
],
|
||||
"Sales Order": [
|
||||
["Draft", None],
|
||||
[
|
||||
@@ -192,9 +202,60 @@ class StatusUpdater(Document):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
def update_prevdoc_status(self):
|
||||
self.validate_closed_source_items()
|
||||
self.update_qty()
|
||||
self.validate_qty()
|
||||
|
||||
def get_closed_source_links(self):
|
||||
"""Row links that must not point at a closed source row.
|
||||
|
||||
`status_updater` covers documents whose progress it already tracks.
|
||||
Delivery Note and Purchase Receipt are billed through their own services
|
||||
instead, so their invoices declare the link in `closed_source_links`.
|
||||
"""
|
||||
links = [
|
||||
(args["source_dt"], args["join_field"], args["target_dt"], args["target_parent_dt"])
|
||||
for args in self.status_updater
|
||||
if args.get("target_dt")
|
||||
and args.get("target_parent_dt")
|
||||
and has_closable_items(args["target_parent_dt"])
|
||||
]
|
||||
|
||||
return links + list(getattr(self, "closed_source_links", []))
|
||||
|
||||
def validate_closed_source_items(self):
|
||||
"""Block submitting against rows that were closed on the source document."""
|
||||
if self.docstatus != 1:
|
||||
return
|
||||
|
||||
for source_dt, join_field, target_dt, target_parent_dt in self.get_closed_source_links():
|
||||
if not frappe.get_meta(target_dt).has_field("closed"):
|
||||
continue
|
||||
|
||||
row_idx = {}
|
||||
for d in self.get_all_children(source_dt):
|
||||
if d.get(join_field):
|
||||
row_idx[d.get(join_field)] = d.idx
|
||||
|
||||
if not row_idx:
|
||||
continue
|
||||
|
||||
closed_rows = frappe.get_all(
|
||||
target_dt,
|
||||
filters={"name": ("in", list(row_idx)), "closed": 1},
|
||||
fields=["name", "item_code", "parent"],
|
||||
)
|
||||
|
||||
for row in closed_rows:
|
||||
frappe.throw(
|
||||
_("Row #{0}: Item {1} is closed in {2} {3} and cannot be processed further").format(
|
||||
row_idx[row.name],
|
||||
frappe.bold(row.item_code),
|
||||
_(target_parent_dt),
|
||||
frappe.bold(row.parent),
|
||||
)
|
||||
)
|
||||
|
||||
def set_status(self, update=False, status=None, update_modified=True):
|
||||
if self.is_new():
|
||||
if self.get("amended_from"):
|
||||
@@ -484,6 +545,7 @@ class StatusUpdater(Document):
|
||||
|
||||
if args["source_dt"] != "Pick List Item" and args["target_dt"] not in [
|
||||
"Quotation Item",
|
||||
"Supplier Quotation Item",
|
||||
"Packed Item",
|
||||
]:
|
||||
if args.get("target_dt") == "Material Request Item":
|
||||
@@ -605,16 +667,28 @@ class StatusUpdater(Document):
|
||||
|
||||
@staticmethod
|
||||
def _calculate_target_parent_percentage(
|
||||
name, target_parent_dt, target_dt, target_ref_field, target_field, exclude_field=None
|
||||
name,
|
||||
target_parent_dt,
|
||||
target_dt,
|
||||
target_ref_field,
|
||||
target_field,
|
||||
target_parent_field=None,
|
||||
exclude_field=None,
|
||||
):
|
||||
filters = {"parent": name, "parenttype": target_parent_dt}
|
||||
if exclude_field:
|
||||
filters[exclude_field] = 0
|
||||
|
||||
tracks_closed_rows = closed_rows_settle(target_parent_dt, target_dt, target_parent_field)
|
||||
|
||||
fields = [target_ref_field, target_field]
|
||||
if tracks_closed_rows:
|
||||
fields.append("closed")
|
||||
|
||||
child_records = frappe.get_all(
|
||||
target_dt,
|
||||
filters=filters,
|
||||
fields=[target_ref_field, target_field],
|
||||
fields=fields,
|
||||
)
|
||||
|
||||
if exclude_field and not child_records:
|
||||
@@ -623,13 +697,19 @@ class StatusUpdater(Document):
|
||||
# For operator dicts, the alias is in the "as" key; for strings, use the field name directly
|
||||
ref_key = target_ref_field.get("as") if isinstance(target_ref_field, dict) else target_ref_field
|
||||
|
||||
sum_ref = sum(abs(record[ref_key]) for record in child_records)
|
||||
# A closed row is written off, so it leaves the denominator rather than
|
||||
# counting as done. The percentage stays a true measure of what was
|
||||
# actually received, delivered or billed against what is still expected.
|
||||
# Once every row is written off there is nothing left to measure against,
|
||||
# so fall back to the whole table and report what actually happened.
|
||||
open_records = [r for r in child_records if not (tracks_closed_rows and r["closed"])]
|
||||
basis = open_records or child_records
|
||||
|
||||
sum_ref = sum(abs(record[ref_key]) for record in basis)
|
||||
|
||||
if sum_ref > 0:
|
||||
percentage = round(
|
||||
sum(min(abs(record[target_field]), abs(record[ref_key])) for record in child_records)
|
||||
/ sum_ref
|
||||
* 100,
|
||||
sum(min(abs(record[target_field]), abs(record[ref_key])) for record in basis) / sum_ref * 100,
|
||||
6,
|
||||
)
|
||||
else:
|
||||
@@ -678,6 +758,7 @@ class StatusUpdater(Document):
|
||||
args["target_dt"],
|
||||
args["target_ref_field"],
|
||||
args["target_field"],
|
||||
args["target_parent_field"],
|
||||
args.get("exclude_field"),
|
||||
)
|
||||
# update field
|
||||
|
||||
@@ -337,8 +337,12 @@ class StockController(AccountsController):
|
||||
items = frappe.get_all(
|
||||
"Delivery Note Item",
|
||||
filters={"parent": self.name, "parenttype": "Delivery Note"},
|
||||
fields=["name", "qty", "returned_qty", "rate", "amount", "billed_amt"],
|
||||
fields=["name", "qty", "returned_qty", "rate", "amount", "billed_amt", "closed"],
|
||||
)
|
||||
# A written off row leaves the basis. Once every row is written off there is
|
||||
# nothing left to measure against, so fall back to the whole table.
|
||||
items = [item for item in items if not item.closed] or items
|
||||
|
||||
total_amount = sum(flt(item.amount) for item in items)
|
||||
total_returned = sum(flt(item.returned_qty) * flt(item.rate) for item in items)
|
||||
# Preserve the original amount basis once the entire Delivery Note is returned.
|
||||
@@ -637,7 +641,7 @@ def show_accounting_ledger_preview(company: str, doctype: str, docname: str):
|
||||
def show_stock_ledger_preview(company: str, doctype: str, docname: str):
|
||||
from erpnext.controllers.ledger_preview import get_stock_ledger_preview
|
||||
|
||||
filters = frappe._dict(company=company)
|
||||
filters = frappe._dict(company=company, valuation_field_type="Currency")
|
||||
doc = frappe.get_lazy_doc(doctype, docname)
|
||||
doc.check_permission("read")
|
||||
doc.run_method("before_sl_preview")
|
||||
|
||||
@@ -151,7 +151,7 @@ class SubcontractingController(StockController):
|
||||
).format(item.idx, get_link_to_form("Item", item.item_code))
|
||||
)
|
||||
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
|
||||
if not item.get("secondary_item_type") and not item.get("valuation_type"):
|
||||
if not is_sub_contracted_item:
|
||||
frappe.throw(
|
||||
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
|
||||
@@ -1248,10 +1248,10 @@ class SubcontractingController(StockController):
|
||||
total_amt = sum(
|
||||
flt(item.amount)
|
||||
for item in self.get("items")
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item")
|
||||
if not item.get("secondary_item_type") and not item.get("valuation_type")
|
||||
)
|
||||
for item in self.items:
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
|
||||
if not item.get("secondary_item_type") and not item.get("valuation_type"):
|
||||
item.additional_cost_per_qty = (
|
||||
(item.amount * self.total_additional_costs) / total_amt
|
||||
) / item.qty
|
||||
@@ -1259,15 +1259,15 @@ class SubcontractingController(StockController):
|
||||
total_qty = sum(
|
||||
flt(item.qty)
|
||||
for item in self.get("items")
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item")
|
||||
if not item.get("secondary_item_type") and not item.get("valuation_type")
|
||||
)
|
||||
additional_cost_per_qty = self.total_additional_costs / total_qty
|
||||
for item in self.items:
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
|
||||
if not item.get("secondary_item_type") and not item.get("valuation_type"):
|
||||
item.additional_cost_per_qty = additional_cost_per_qty
|
||||
else:
|
||||
for item in self.items:
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
|
||||
if not item.get("secondary_item_type") and not item.get("valuation_type"):
|
||||
item.additional_cost_per_qty = 0
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -4,7 +4,6 @@ import frappe
|
||||
from frappe import _, bold
|
||||
from frappe.query_builder import Case
|
||||
from frappe.utils import flt, get_link_to_form
|
||||
from pypika.terms import ValueWrapper
|
||||
|
||||
from erpnext.stock.serial_batch_bundle import get_serial_batch_list_from_item
|
||||
|
||||
@@ -243,7 +242,7 @@ class SubcontractingInwardController:
|
||||
for item in self.get("items")
|
||||
if not item.is_finished_item
|
||||
and not item.secondary_item_type
|
||||
and not item.is_legacy_scrap_item
|
||||
and not item.valuation_type
|
||||
and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item")
|
||||
]
|
||||
|
||||
@@ -380,7 +379,7 @@ class SubcontractingInwardController:
|
||||
if self.purpose in ["Subcontracting Delivery", "Subcontracting Return", "Manufacture"]:
|
||||
for item in self.items:
|
||||
if (
|
||||
item.is_finished_item or item.secondary_item_type or item.is_legacy_scrap_item
|
||||
item.is_finished_item or item.secondary_item_type or item.valuation_type
|
||||
) and item.valuation_rate == 0:
|
||||
item.allow_zero_valuation_rate = 1
|
||||
|
||||
@@ -480,7 +479,7 @@ class SubcontractingInwardController:
|
||||
self.validate_delivery_on_save()
|
||||
else:
|
||||
for item in self.items:
|
||||
if not item.secondary_item_type and not item.is_legacy_scrap_item:
|
||||
if not item.secondary_item_type and not item.valuation_type:
|
||||
delivered_qty, returned_qty = frappe.get_value(
|
||||
"Subcontracting Inward Order Item",
|
||||
item.scio_detail,
|
||||
@@ -509,21 +508,13 @@ class SubcontractingInwardController:
|
||||
)
|
||||
|
||||
table = frappe.qb.DocType("Subcontracting Inward Order Item")
|
||||
allowed_qty = table.produced_qty
|
||||
if not allow_delivery_of_overproduced_qty:
|
||||
allowed_qty = Case().when(table.produced_qty < table.qty, table.produced_qty).else_(table.qty)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(
|
||||
(
|
||||
Case()
|
||||
.when(
|
||||
# bool() so the literal renders as true/false; postgres rejects `OR <integer>`
|
||||
(table.produced_qty < table.qty)
|
||||
| ValueWrapper(bool(allow_delivery_of_overproduced_qty)),
|
||||
table.produced_qty,
|
||||
)
|
||||
.else_(table.qty)
|
||||
- table.delivered_qty
|
||||
).as_("max_allowed_qty")
|
||||
)
|
||||
.select((allowed_qty - table.delivered_qty).as_("max_allowed_qty"))
|
||||
.where((table.name == item.scio_detail) & (table.docstatus == 1))
|
||||
)
|
||||
max_allowed_qty = query.run(pluck="max_allowed_qty")
|
||||
@@ -550,7 +541,7 @@ class SubcontractingInwardController:
|
||||
bold(
|
||||
frappe.get_cached_value(
|
||||
"Subcontracting Inward Order Item"
|
||||
if not item.secondary_item_type and not item.is_legacy_scrap_item
|
||||
if not item.secondary_item_type and not item.valuation_type
|
||||
else "Subcontracting Inward Order Secondary Item",
|
||||
item.scio_detail,
|
||||
"stock_uom",
|
||||
@@ -602,7 +593,7 @@ class SubcontractingInwardController:
|
||||
)
|
||||
|
||||
for item in [item for item in self.items if not item.is_finished_item]:
|
||||
if item.secondary_item_type or item.is_legacy_scrap_item:
|
||||
if item.secondary_item_type or item.valuation_type:
|
||||
scio_secondary_item = frappe.get_value(
|
||||
"Subcontracting Inward Order Secondary Item",
|
||||
{
|
||||
@@ -661,7 +652,7 @@ class SubcontractingInwardController:
|
||||
for item in self.items:
|
||||
doctype = (
|
||||
"Subcontracting Inward Order Item"
|
||||
if not item.secondary_item_type and not item.is_legacy_scrap_item
|
||||
if not item.secondary_item_type and not item.valuation_type
|
||||
else "Subcontracting Inward Order Secondary Item"
|
||||
)
|
||||
qty_map[doctype][item.scio_detail] += (
|
||||
@@ -802,7 +793,7 @@ class SubcontractingInwardController:
|
||||
items = [
|
||||
item
|
||||
for item in self.items
|
||||
if not item.is_finished_item and not item.secondary_item_type and not item.is_legacy_scrap_item
|
||||
if not item.is_finished_item and not item.secondary_item_type and not item.valuation_type
|
||||
]
|
||||
if not items:
|
||||
return
|
||||
@@ -913,7 +904,7 @@ class SubcontractingInwardController:
|
||||
def update_inward_order_secondary_items(self):
|
||||
if (scio := self.subcontracting_inward_order) and self.purpose == "Manufacture":
|
||||
secondary_items_list = [
|
||||
item for item in self.items if item.secondary_item_type or item.is_legacy_scrap_item
|
||||
item for item in self.items if item.secondary_item_type or item.valuation_type
|
||||
]
|
||||
|
||||
secondary_items = defaultdict(float)
|
||||
|
||||
231
erpnext/controllers/tests/test_item_close.py
Normal file
231
erpnext/controllers/tests/test_item_close.py
Normal file
@@ -0,0 +1,231 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, flt, nowdate
|
||||
|
||||
from erpnext.buying.doctype.purchase_order.mapper import (
|
||||
get_mapped_purchase_invoice,
|
||||
make_purchase_receipt,
|
||||
)
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.controllers.item_close import update_closed_status
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
WAREHOUSE = "_Test Warehouse - _TC"
|
||||
|
||||
|
||||
def get_ordered_qty(item_code):
|
||||
return flt(frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": WAREHOUSE}, "ordered_qty"))
|
||||
|
||||
|
||||
class TestPurchaseOrderItemClose(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.first_item = make_item(properties={"is_stock_item": 1}).name
|
||||
self.second_item = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
def make_purchase_order(self):
|
||||
po = create_purchase_order(item_code=self.first_item, qty=10, rate=100, do_not_save=True)
|
||||
po.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": self.second_item,
|
||||
"warehouse": WAREHOUSE,
|
||||
"qty": 10,
|
||||
"rate": 100,
|
||||
"schedule_date": add_days(nowdate(), 1),
|
||||
},
|
||||
)
|
||||
po.set_missing_values()
|
||||
po.insert()
|
||||
po.submit()
|
||||
return po
|
||||
|
||||
def close_items(self, po, rows, closed=1):
|
||||
update_closed_status("Purchase Order", po.name, [row.name for row in rows], closed)
|
||||
po.reload()
|
||||
|
||||
def test_closing_row_releases_ordered_qty(self):
|
||||
po = self.make_purchase_order()
|
||||
self.assertEqual(get_ordered_qty(self.second_item), 10)
|
||||
|
||||
self.close_items(po, [po.items[1]])
|
||||
|
||||
self.assertEqual(get_ordered_qty(self.second_item), 0)
|
||||
self.assertEqual(get_ordered_qty(self.first_item), 10)
|
||||
|
||||
def test_closing_row_settles_receiving_percentage(self):
|
||||
po = self.make_purchase_order()
|
||||
|
||||
receipt = make_purchase_receipt(po.name)
|
||||
receipt.items = [item for item in receipt.items if item.item_code == self.first_item]
|
||||
receipt.insert()
|
||||
receipt.submit()
|
||||
|
||||
po.reload()
|
||||
self.assertEqual(po.per_received, 50)
|
||||
self.assertEqual(po.status, "To Receive and Bill")
|
||||
|
||||
self.close_items(po, [po.items[1]])
|
||||
|
||||
self.assertEqual(po.per_received, 100)
|
||||
self.assertEqual(po.status, "To Bill")
|
||||
|
||||
def test_closing_every_row_closes_the_order(self):
|
||||
po = self.make_purchase_order()
|
||||
|
||||
self.close_items(po, po.items)
|
||||
|
||||
self.assertEqual(po.status, "Closed")
|
||||
self.assertEqual(get_ordered_qty(self.first_item), 0)
|
||||
self.assertEqual(get_ordered_qty(self.second_item), 0)
|
||||
|
||||
def test_parent_reopen_is_blocked_when_all_rows_are_closed(self):
|
||||
po = self.make_purchase_order()
|
||||
self.close_items(po, po.items)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, po.update_status, "Submitted")
|
||||
|
||||
po.reload()
|
||||
self.assertEqual(po.status, "Closed")
|
||||
self.assertTrue(all(row.closed for row in po.items))
|
||||
|
||||
def test_reopening_all_rows_restores_the_order(self):
|
||||
po = self.make_purchase_order()
|
||||
self.close_items(po, po.items)
|
||||
self.assertEqual(po.status, "Closed")
|
||||
|
||||
self.close_items(po, po.items, closed=0)
|
||||
|
||||
self.assertFalse(any(row.closed for row in po.items))
|
||||
self.assertEqual(po.per_received, 0)
|
||||
self.assertEqual(po.status, "To Receive and Bill")
|
||||
self.assertEqual(get_ordered_qty(self.first_item), 10)
|
||||
|
||||
def test_reopening_one_row_reopens_the_parent(self):
|
||||
po = self.make_purchase_order()
|
||||
self.close_items(po, po.items)
|
||||
|
||||
self.close_items(po, [po.items[1]], closed=0)
|
||||
|
||||
self.assertEqual(po.status, "To Receive and Bill")
|
||||
self.assertTrue(po.items[0].closed)
|
||||
self.assertFalse(po.items[1].closed)
|
||||
# nothing received, and the closed row is written off rather than counted
|
||||
self.assertEqual(po.per_received, 0)
|
||||
self.assertEqual(get_ordered_qty(self.second_item), 10)
|
||||
self.assertEqual(get_ordered_qty(self.first_item), 0)
|
||||
|
||||
def test_settled_row_cannot_be_closed(self):
|
||||
po = self.make_purchase_order()
|
||||
|
||||
receipt = make_purchase_receipt(po.name)
|
||||
receipt.insert()
|
||||
receipt.submit()
|
||||
invoice = get_mapped_purchase_invoice(po.name)
|
||||
invoice.insert()
|
||||
invoice.submit()
|
||||
|
||||
po.reload()
|
||||
self.assertEqual(po.status, "Completed")
|
||||
self.assertRaises(frappe.ValidationError, self.close_items, po, [po.items[0]])
|
||||
|
||||
def test_received_but_unbilled_row_can_be_closed(self):
|
||||
po = self.make_purchase_order()
|
||||
|
||||
receipt = make_purchase_receipt(po.name)
|
||||
receipt.insert()
|
||||
receipt.submit()
|
||||
|
||||
po.reload()
|
||||
self.assertEqual(po.status, "To Bill")
|
||||
|
||||
self.close_items(po, po.items)
|
||||
|
||||
# billing written off, but the goods really did arrive
|
||||
self.assertEqual(po.per_billed, 0)
|
||||
self.assertEqual(po.per_received, 100)
|
||||
self.assertEqual(po.status, "Closed")
|
||||
|
||||
def test_receipt_is_not_offered_when_the_rest_is_closed(self):
|
||||
po = self.make_purchase_order()
|
||||
|
||||
receipt = make_purchase_receipt(po.name)
|
||||
receipt.items = [item for item in receipt.items if item.item_code == self.first_item]
|
||||
receipt.insert()
|
||||
receipt.submit()
|
||||
|
||||
po.reload()
|
||||
self.close_items(po, [po.items[1]])
|
||||
|
||||
self.assertEqual(po.status, "To Bill")
|
||||
self.assertFalse(po.has_pending_receivable_qty())
|
||||
self.assertFalse(make_purchase_receipt(po.name).get("items"))
|
||||
|
||||
def test_reopening_partly_closed_order_keeps_row_flags(self):
|
||||
po = self.make_purchase_order()
|
||||
self.close_items(po, [po.items[1]])
|
||||
|
||||
po.update_status("Closed")
|
||||
po.reload()
|
||||
self.assertEqual(po.status, "Closed")
|
||||
|
||||
po.update_status("Submitted")
|
||||
po.reload()
|
||||
|
||||
self.assertFalse(po.items[0].closed)
|
||||
self.assertTrue(po.items[1].closed)
|
||||
self.assertEqual(get_ordered_qty(self.first_item), 10)
|
||||
self.assertEqual(get_ordered_qty(self.second_item), 0)
|
||||
|
||||
def test_closed_row_is_not_mapped_to_purchase_receipt(self):
|
||||
po = self.make_purchase_order()
|
||||
self.close_items(po, [po.items[1]])
|
||||
|
||||
receipt = make_purchase_receipt(po.name)
|
||||
|
||||
self.assertEqual([item.item_code for item in receipt.items], [self.first_item])
|
||||
|
||||
def test_receiving_a_closed_row_is_blocked(self):
|
||||
po = self.make_purchase_order()
|
||||
receipt = make_purchase_receipt(po.name)
|
||||
|
||||
self.close_items(po, [po.items[1]])
|
||||
|
||||
receipt.insert()
|
||||
self.assertRaises(frappe.ValidationError, receipt.submit)
|
||||
|
||||
def test_reopening_a_row_restores_pending_qty(self):
|
||||
po = self.make_purchase_order()
|
||||
self.close_items(po, [po.items[1]])
|
||||
self.assertEqual(get_ordered_qty(self.second_item), 0)
|
||||
|
||||
self.close_items(po, [po.items[1]], closed=0)
|
||||
|
||||
self.assertEqual(get_ordered_qty(self.second_item), 10)
|
||||
self.assertEqual(po.per_received, 0)
|
||||
self.assertEqual(po.status, "To Receive and Bill")
|
||||
|
||||
def test_closing_is_rejected_for_unsupported_doctype(self):
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
update_closed_status,
|
||||
"Material Request",
|
||||
"any-name",
|
||||
["any-row"],
|
||||
1,
|
||||
)
|
||||
|
||||
def test_amending_clears_closed_rows(self):
|
||||
"""Frappe keeps no_copy fields when amending, so the flag must be cleared."""
|
||||
po = self.make_purchase_order()
|
||||
self.close_items(po, [po.items[1]])
|
||||
po.cancel()
|
||||
|
||||
amended = frappe.copy_doc(po, ignore_no_copy=True)
|
||||
amended.docstatus = 0
|
||||
amended.amended_from = po.name
|
||||
amended.insert()
|
||||
|
||||
self.assertFalse(any(row.closed for row in amended.items))
|
||||
264
erpnext/controllers/tests/test_item_close_billing.py
Normal file
264
erpnext/controllers/tests/test_item_close_billing.py
Normal file
@@ -0,0 +1,264 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.controllers.item_close import update_closed_status
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
WAREHOUSE = "_Test Warehouse - _TC"
|
||||
|
||||
|
||||
class TestPurchaseReceiptItemClose(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.first_item = make_item(properties={"is_stock_item": 1}).name
|
||||
self.second_item = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
def make_purchase_receipt(self):
|
||||
receipt = make_purchase_receipt(
|
||||
item_code=self.first_item, qty=10, rate=100, warehouse=WAREHOUSE, do_not_submit=True
|
||||
)
|
||||
receipt.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": self.second_item,
|
||||
"warehouse": WAREHOUSE,
|
||||
"qty": 10,
|
||||
"rate": 100,
|
||||
},
|
||||
)
|
||||
receipt.save()
|
||||
receipt.submit()
|
||||
return receipt
|
||||
|
||||
def close_items(self, doc, rows, closed=1):
|
||||
update_closed_status(doc.doctype, doc.name, [row.name for row in rows], closed)
|
||||
doc.reload()
|
||||
|
||||
def test_closing_a_row_does_not_inflate_billing_percentage(self):
|
||||
receipt = self.make_purchase_receipt()
|
||||
self.assertEqual(receipt.per_billed, 0)
|
||||
|
||||
self.close_items(receipt, [receipt.items[1]])
|
||||
|
||||
# nothing was billed, so the receipt must not read as partly billed
|
||||
self.assertEqual(receipt.per_billed, 0)
|
||||
self.assertEqual(receipt.status, "To Bill")
|
||||
|
||||
def test_closing_every_row_closes_the_receipt(self):
|
||||
receipt = self.make_purchase_receipt()
|
||||
|
||||
self.close_items(receipt, receipt.items)
|
||||
|
||||
# nothing was billed, and writing every row off must not claim otherwise
|
||||
self.assertEqual(receipt.per_billed, 0)
|
||||
self.assertEqual(receipt.status, "Closed")
|
||||
|
||||
def test_closed_row_is_not_mapped_to_purchase_invoice(self):
|
||||
receipt = self.make_purchase_receipt()
|
||||
self.close_items(receipt, [receipt.items[1]])
|
||||
|
||||
invoice = make_purchase_invoice(receipt.name)
|
||||
|
||||
self.assertEqual([item.item_code for item in invoice.items], [self.first_item])
|
||||
|
||||
def test_billing_a_closed_row_is_blocked(self):
|
||||
receipt = self.make_purchase_receipt()
|
||||
invoice = make_purchase_invoice(receipt.name)
|
||||
|
||||
self.close_items(receipt, [receipt.items[1]])
|
||||
|
||||
invoice.insert()
|
||||
self.assertRaises(frappe.ValidationError, invoice.submit)
|
||||
|
||||
def test_parent_reopen_is_blocked_when_all_rows_are_closed(self):
|
||||
receipt = self.make_purchase_receipt()
|
||||
self.close_items(receipt, receipt.items)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, receipt.update_status, "Submitted")
|
||||
|
||||
def test_reopening_one_row_reopens_the_receipt(self):
|
||||
receipt = self.make_purchase_receipt()
|
||||
self.close_items(receipt, receipt.items)
|
||||
|
||||
self.close_items(receipt, [receipt.items[1]], closed=0)
|
||||
|
||||
self.assertNotEqual(receipt.status, "Closed")
|
||||
self.assertEqual(receipt.per_billed, 0)
|
||||
|
||||
def test_unbilled_return_row_can_be_closed(self):
|
||||
"""Return rows are closable by design, not by an accident of sign."""
|
||||
receipt = self.make_purchase_receipt()
|
||||
return_receipt = make_return_doc("Purchase Receipt", receipt.name)
|
||||
return_receipt.insert()
|
||||
return_receipt.submit()
|
||||
|
||||
row = return_receipt.items[0]
|
||||
self.assertLess(row.amount, 0)
|
||||
self.assertTrue(return_receipt.is_item_closable(row))
|
||||
|
||||
self.close_items(return_receipt, [row])
|
||||
self.assertTrue(return_receipt.items[0].closed)
|
||||
|
||||
|
||||
class TestDeliveryNoteItemClose(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.first_item = make_item(properties={"is_stock_item": 1}).name
|
||||
self.second_item = make_item(properties={"is_stock_item": 1}).name
|
||||
for item_code in (self.first_item, self.second_item):
|
||||
make_stock_entry(item_code=item_code, target=WAREHOUSE, qty=100, basic_rate=50)
|
||||
|
||||
def make_delivery_note(self):
|
||||
note = create_delivery_note(
|
||||
item_code=self.first_item, qty=10, rate=100, warehouse=WAREHOUSE, do_not_save=True
|
||||
)
|
||||
note.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": self.second_item,
|
||||
"warehouse": WAREHOUSE,
|
||||
"qty": 10,
|
||||
"rate": 100,
|
||||
},
|
||||
)
|
||||
note.insert()
|
||||
note.submit()
|
||||
return note
|
||||
|
||||
def close_items(self, doc, rows, closed=1):
|
||||
update_closed_status(doc.doctype, doc.name, [row.name for row in rows], closed)
|
||||
doc.reload()
|
||||
|
||||
def test_closing_a_row_does_not_inflate_billing_percentage(self):
|
||||
note = self.make_delivery_note()
|
||||
self.assertEqual(note.per_billed, 0)
|
||||
|
||||
self.close_items(note, [note.items[1]])
|
||||
|
||||
# nothing was billed, so the note must not read as partially billed
|
||||
self.assertEqual(note.per_billed, 0)
|
||||
self.assertEqual(note.status, "To Bill")
|
||||
|
||||
def test_closing_every_row_closes_the_note(self):
|
||||
note = self.make_delivery_note()
|
||||
|
||||
self.close_items(note, note.items)
|
||||
|
||||
# nothing was billed, and writing every row off must not claim otherwise
|
||||
self.assertEqual(note.per_billed, 0)
|
||||
self.assertEqual(note.status, "Closed")
|
||||
|
||||
def test_closed_row_is_not_mapped_to_sales_invoice(self):
|
||||
note = self.make_delivery_note()
|
||||
self.close_items(note, [note.items[1]])
|
||||
|
||||
invoice = make_sales_invoice(note.name)
|
||||
|
||||
self.assertEqual([item.item_code for item in invoice.items], [self.first_item])
|
||||
|
||||
def test_billing_a_closed_row_is_blocked(self):
|
||||
note = self.make_delivery_note()
|
||||
invoice = make_sales_invoice(note.name)
|
||||
|
||||
self.close_items(note, [note.items[1]])
|
||||
|
||||
invoice.insert()
|
||||
self.assertRaises(frappe.ValidationError, invoice.submit)
|
||||
|
||||
def test_closing_a_row_does_not_mark_it_returned(self):
|
||||
note = self.make_delivery_note()
|
||||
|
||||
self.close_items(note, note.items)
|
||||
|
||||
self.assertEqual(note.per_returned, 0)
|
||||
self.assertEqual(note.status, "Closed")
|
||||
|
||||
def test_amending_clears_closed_rows(self):
|
||||
"""Frappe keeps no_copy fields when amending, so the flag must be cleared."""
|
||||
note = self.make_delivery_note()
|
||||
self.close_items(note, [note.items[1]])
|
||||
note.cancel()
|
||||
|
||||
amended = frappe.copy_doc(note, ignore_no_copy=True)
|
||||
amended.docstatus = 0
|
||||
amended.amended_from = note.name
|
||||
amended.insert()
|
||||
|
||||
self.assertFalse(any(row.closed for row in amended.items))
|
||||
|
||||
def test_noncanonical_closed_value_is_normalised(self):
|
||||
"""A truthy non-1 value must not slip past the exact-match submission guard."""
|
||||
note = self.make_delivery_note()
|
||||
|
||||
update_closed_status("Delivery Note", note.name, [note.items[1].name], 2)
|
||||
|
||||
note.reload()
|
||||
self.assertEqual(note.items[1].closed, 1)
|
||||
|
||||
def test_unbilled_return_row_can_be_closed(self):
|
||||
"""Return rows carry negative amounts and must still be closable."""
|
||||
note = self.make_delivery_note()
|
||||
return_note = make_return_doc("Delivery Note", note.name)
|
||||
return_note.insert()
|
||||
return_note.submit()
|
||||
|
||||
row = return_note.items[0]
|
||||
self.assertLess(row.amount, 0)
|
||||
self.assertTrue(return_note.is_item_closable(row))
|
||||
|
||||
self.close_items(return_note, [row])
|
||||
self.assertTrue(return_note.items[0].closed)
|
||||
|
||||
def test_return_row_pending_amount_is_a_magnitude(self):
|
||||
"""The dialog shows what is outstanding, so a return row must not read as zero."""
|
||||
note = self.make_delivery_note()
|
||||
return_note = make_return_doc("Delivery Note", note.name)
|
||||
return_note.insert()
|
||||
return_note.submit()
|
||||
|
||||
row = return_note.items[0]
|
||||
self.assertLess(row.amount, 0)
|
||||
pending = abs(flt(row.amount)) - abs(flt(row.billed_amt))
|
||||
self.assertEqual(pending, abs(flt(note.items[0].amount)))
|
||||
self.assertGreater(pending, 0)
|
||||
|
||||
def test_closing_a_return_row_leaves_the_original_untouched(self):
|
||||
"""Writing off a credit note must not disturb what was returned."""
|
||||
note = self.make_delivery_note()
|
||||
return_note = make_return_doc("Delivery Note", note.name)
|
||||
return_note.insert()
|
||||
return_note.submit()
|
||||
|
||||
note.reload()
|
||||
before = [(row.returned_qty, row.closed) for row in note.items]
|
||||
per_returned_before = note.per_returned
|
||||
|
||||
self.close_items(return_note, [return_note.items[0]])
|
||||
|
||||
note.reload()
|
||||
self.assertEqual([(row.returned_qty, row.closed) for row in note.items], before)
|
||||
self.assertEqual(note.per_returned, per_returned_before)
|
||||
|
||||
def test_closing_the_unbilled_row_completes_the_note(self):
|
||||
"""The point of the feature: a written off row stops holding billing open."""
|
||||
note = self.make_delivery_note()
|
||||
invoice = make_sales_invoice(note.name)
|
||||
invoice.items = [item for item in invoice.items if item.item_code == self.first_item]
|
||||
invoice.insert()
|
||||
invoice.submit()
|
||||
|
||||
note.reload()
|
||||
self.assertEqual(note.per_billed, 50)
|
||||
|
||||
self.close_items(note, [note.items[1]])
|
||||
|
||||
self.assertEqual(note.per_billed, 100)
|
||||
self.assertEqual(note.status, "Completed")
|
||||
141
erpnext/controllers/tests/test_item_close_sales_order.py
Normal file
141
erpnext/controllers/tests/test_item_close_sales_order.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, flt, nowdate
|
||||
|
||||
from erpnext.controllers.item_close import update_closed_status
|
||||
from erpnext.selling.doctype.sales_order.mapper import make_delivery_note, make_sales_invoice
|
||||
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.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
WAREHOUSE = "_Test Warehouse - _TC"
|
||||
|
||||
|
||||
def get_reserved_qty(item_code):
|
||||
return flt(frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": WAREHOUSE}, "reserved_qty"))
|
||||
|
||||
|
||||
class TestSalesOrderItemClose(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.first_item = make_item(properties={"is_stock_item": 1}).name
|
||||
self.second_item = make_item(properties={"is_stock_item": 1}).name
|
||||
for item_code in (self.first_item, self.second_item):
|
||||
make_stock_entry(item_code=item_code, target=WAREHOUSE, qty=100, basic_rate=50)
|
||||
|
||||
def make_sales_order(self):
|
||||
so = make_sales_order(
|
||||
item_code=self.first_item, qty=10, rate=100, warehouse=WAREHOUSE, do_not_submit=True
|
||||
)
|
||||
so.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": self.second_item,
|
||||
"warehouse": WAREHOUSE,
|
||||
"qty": 10,
|
||||
"rate": 100,
|
||||
"delivery_date": add_days(nowdate(), 1),
|
||||
},
|
||||
)
|
||||
so.save()
|
||||
so.submit()
|
||||
return so
|
||||
|
||||
def close_items(self, so, rows, closed=1):
|
||||
update_closed_status("Sales Order", so.name, [row.name for row in rows], closed)
|
||||
so.reload()
|
||||
|
||||
def test_closing_row_releases_reserved_qty(self):
|
||||
so = self.make_sales_order()
|
||||
self.assertEqual(get_reserved_qty(self.second_item), 10)
|
||||
|
||||
self.close_items(so, [so.items[1]])
|
||||
|
||||
self.assertEqual(get_reserved_qty(self.second_item), 0)
|
||||
self.assertEqual(get_reserved_qty(self.first_item), 10)
|
||||
|
||||
def test_closing_row_settles_delivery_percentage(self):
|
||||
so = self.make_sales_order()
|
||||
|
||||
note = make_delivery_note(so.name)
|
||||
note.items = [item for item in note.items if item.item_code == self.first_item]
|
||||
note.insert()
|
||||
note.submit()
|
||||
|
||||
so.reload()
|
||||
self.assertEqual(so.per_delivered, 50)
|
||||
|
||||
self.close_items(so, [so.items[1]])
|
||||
|
||||
self.assertEqual(so.per_delivered, 100)
|
||||
self.assertEqual(so.delivery_status, "Fully Delivered")
|
||||
|
||||
def test_closing_every_row_closes_the_order(self):
|
||||
so = self.make_sales_order()
|
||||
|
||||
self.close_items(so, so.items)
|
||||
|
||||
self.assertEqual(so.status, "Closed")
|
||||
self.assertEqual(get_reserved_qty(self.first_item), 0)
|
||||
self.assertEqual(get_reserved_qty(self.second_item), 0)
|
||||
|
||||
def test_reopening_one_row_reopens_the_parent(self):
|
||||
so = self.make_sales_order()
|
||||
self.close_items(so, so.items)
|
||||
|
||||
self.close_items(so, [so.items[1]], closed=0)
|
||||
|
||||
self.assertNotEqual(so.status, "Closed")
|
||||
self.assertTrue(so.items[0].closed)
|
||||
self.assertFalse(so.items[1].closed)
|
||||
self.assertEqual(get_reserved_qty(self.second_item), 10)
|
||||
self.assertEqual(get_reserved_qty(self.first_item), 0)
|
||||
|
||||
def test_parent_reopen_is_blocked_when_all_rows_are_closed(self):
|
||||
so = self.make_sales_order()
|
||||
self.close_items(so, so.items)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, so.update_status, "Draft")
|
||||
|
||||
so.reload()
|
||||
self.assertEqual(so.status, "Closed")
|
||||
|
||||
def test_closed_row_is_not_mapped_to_delivery_note(self):
|
||||
so = self.make_sales_order()
|
||||
self.close_items(so, [so.items[1]])
|
||||
|
||||
note = make_delivery_note(so.name)
|
||||
|
||||
self.assertEqual([item.item_code for item in note.items], [self.first_item])
|
||||
|
||||
def test_closed_row_is_not_mapped_to_sales_invoice(self):
|
||||
so = self.make_sales_order()
|
||||
self.close_items(so, [so.items[1]])
|
||||
|
||||
invoice = make_sales_invoice(so.name)
|
||||
|
||||
self.assertEqual([item.item_code for item in invoice.items], [self.first_item])
|
||||
|
||||
def test_delivering_a_closed_row_is_blocked(self):
|
||||
so = self.make_sales_order()
|
||||
note = make_delivery_note(so.name)
|
||||
|
||||
self.close_items(so, [so.items[1]])
|
||||
|
||||
note.insert()
|
||||
self.assertRaises(frappe.ValidationError, note.submit)
|
||||
|
||||
def test_settled_row_cannot_be_closed(self):
|
||||
so = self.make_sales_order()
|
||||
|
||||
note = make_delivery_note(so.name)
|
||||
note.insert()
|
||||
note.submit()
|
||||
invoice = make_sales_invoice(so.name)
|
||||
invoice.insert()
|
||||
invoice.submit()
|
||||
|
||||
so.reload()
|
||||
self.assertRaises(frappe.ValidationError, self.close_items, so, [so.items[0]])
|
||||
96
erpnext/controllers/tests/test_item_close_update_items.py
Normal file
96
erpnext/controllers/tests/test_item_close_update_items.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, nowdate
|
||||
|
||||
from erpnext.accounts.services.child_item_update import update_child_qty_rate
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.controllers.item_close import update_closed_status
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
WAREHOUSE = "_Test Warehouse - _TC"
|
||||
|
||||
|
||||
class TestUpdateItemsWithClosedRows(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.first_item = make_item(properties={"is_stock_item": 1}).name
|
||||
self.second_item = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
def make_purchase_order(self):
|
||||
po = create_purchase_order(item_code=self.first_item, qty=10, rate=100, do_not_save=True)
|
||||
po.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": self.second_item,
|
||||
"warehouse": WAREHOUSE,
|
||||
"qty": 10,
|
||||
"rate": 100,
|
||||
"schedule_date": add_days(nowdate(), 1),
|
||||
},
|
||||
)
|
||||
po.set_missing_values()
|
||||
po.insert()
|
||||
po.submit()
|
||||
update_closed_status("Purchase Order", po.name, [po.items[1].name], 1)
|
||||
po.reload()
|
||||
return po
|
||||
|
||||
def as_payload(self, rows, **overrides):
|
||||
return json.dumps(
|
||||
[
|
||||
{
|
||||
"docname": row.name,
|
||||
"item_code": row.item_code,
|
||||
"qty": overrides.get(row.name, row.qty),
|
||||
"rate": row.rate,
|
||||
"uom": row.uom,
|
||||
"conversion_factor": row.conversion_factor,
|
||||
"description": row.description,
|
||||
"schedule_date": str(row.schedule_date),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
)
|
||||
|
||||
def test_payload_without_the_closed_row_does_not_delete_it(self):
|
||||
"""The dialog omits closed rows, and absence must not read as removal."""
|
||||
po = self.make_purchase_order()
|
||||
open_row, closed_row = po.items[0], po.items[1]
|
||||
|
||||
update_child_qty_rate("Purchase Order", self.as_payload([open_row], **{open_row.name: 15}), po.name)
|
||||
|
||||
po.reload()
|
||||
self.assertEqual(len(po.items), 2)
|
||||
self.assertEqual(po.items[0].qty, 15)
|
||||
self.assertTrue(po.items[1].closed)
|
||||
self.assertEqual(po.items[1].name, closed_row.name)
|
||||
|
||||
def test_closed_row_cannot_be_changed_through_the_api(self):
|
||||
"""The dialog hides closed rows, but the whitelisted call is the real gate."""
|
||||
po = self.make_purchase_order()
|
||||
closed_row = po.items[1]
|
||||
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
update_child_qty_rate,
|
||||
"Purchase Order",
|
||||
self.as_payload(po.items, **{closed_row.name: 99}),
|
||||
po.name,
|
||||
)
|
||||
|
||||
po.reload()
|
||||
self.assertEqual(po.items[1].qty, 10)
|
||||
|
||||
def test_unchanged_closed_row_in_the_payload_is_tolerated(self):
|
||||
"""A caller sending the whole table untouched should not be rejected."""
|
||||
po = self.make_purchase_order()
|
||||
|
||||
update_child_qty_rate("Purchase Order", self.as_payload(po.items), po.name)
|
||||
|
||||
po.reload()
|
||||
self.assertEqual(len(po.items), 2)
|
||||
self.assertTrue(po.items[1].closed)
|
||||
23
erpnext/controllers/tests/test_ledger_preview.py
Normal file
23
erpnext/controllers/tests/test_ledger_preview.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.controllers.ledger_preview import get_sl_entries_for_preview
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestLedgerPreview(ERPNextTestSuite):
|
||||
def test_in_out_rate_is_only_set_for_outgoing_entries(self):
|
||||
stock_ledger_entries = [
|
||||
frappe._dict(actual_qty=5, stock_value_difference=10),
|
||||
frappe._dict(actual_qty=-5, stock_value_difference=-15),
|
||||
]
|
||||
|
||||
with patch("frappe.get_all", return_value=stock_ledger_entries):
|
||||
entries = get_sl_entries_for_preview("Delivery Note", "DN-0001", [])
|
||||
|
||||
self.assertIsNone(entries[0].get("in_out_rate"))
|
||||
self.assertEqual(entries[1].in_out_rate, 3)
|
||||
@@ -30,6 +30,97 @@ class TestMapper(ERPNextTestSuite):
|
||||
src_items = item_list_1 + item_list_2 + item_list_3
|
||||
self.assertEqual(set(d for d in src_items), set(d.item_code for d in updated_so.items))
|
||||
|
||||
def test_get_items_from_is_idempotent(self):
|
||||
"""Selecting the same source document twice must not duplicate rows in the target.
|
||||
|
||||
"Get Items From" hands the in-progress document back to the mapper as `target_doc`.
|
||||
Its rows are unsaved, so the mappers' pending-qty queries (submitted documents only)
|
||||
cannot see them -- every mapper has to discount them explicitly.
|
||||
"""
|
||||
for label, make_source, method in self.idempotency_cases():
|
||||
with self.subTest(label):
|
||||
source = make_source()
|
||||
target = frappe.get_attr(method)(source.name)
|
||||
mapped_rows = len(target.items)
|
||||
self.assertTrue(mapped_rows, f"{label}: nothing was mapped")
|
||||
|
||||
target = frappe.get_attr(method)(source.name, target)
|
||||
self.assertEqual(len(target.items), mapped_rows, f"{label}: rows were duplicated")
|
||||
|
||||
def idempotency_cases(self):
|
||||
"""(label, source factory, mapper method) for every "Get Items From" button.
|
||||
|
||||
Quotation -> Sales Invoice is absent: Sales Invoice Item keeps no reference to the
|
||||
Quotation row, so there is nothing to deduplicate on.
|
||||
"""
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.material_request.test_material_request import (
|
||||
make_material_request_for_items,
|
||||
)
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
|
||||
self.load_test_records("Supplier Quotation")
|
||||
|
||||
def make_supplier_quotation():
|
||||
return frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]).submit()
|
||||
|
||||
return [
|
||||
(
|
||||
"Quotation -> Sales Order",
|
||||
lambda: make_quotation(),
|
||||
"erpnext.selling.doctype.quotation.mapper.make_sales_order",
|
||||
),
|
||||
(
|
||||
"Sales Order -> Sales Invoice",
|
||||
lambda: make_sales_order(),
|
||||
"erpnext.selling.doctype.sales_order.mapper.make_sales_invoice",
|
||||
),
|
||||
(
|
||||
"Sales Order -> Delivery Note",
|
||||
lambda: make_sales_order(),
|
||||
"erpnext.selling.doctype.sales_order.mapper.make_delivery_note",
|
||||
),
|
||||
(
|
||||
"Delivery Note -> Sales Invoice",
|
||||
lambda: create_delivery_note(),
|
||||
"erpnext.stock.doctype.delivery_note.mapper.make_sales_invoice",
|
||||
),
|
||||
(
|
||||
"Material Request -> Purchase Order",
|
||||
lambda: make_material_request_for_items(["_Test Item"]),
|
||||
"erpnext.stock.doctype.material_request.mapper.make_purchase_order",
|
||||
),
|
||||
(
|
||||
"Supplier Quotation -> Purchase Order",
|
||||
make_supplier_quotation,
|
||||
"erpnext.buying.doctype.supplier_quotation.mapper.make_purchase_order",
|
||||
),
|
||||
(
|
||||
"Purchase Order -> Purchase Receipt",
|
||||
lambda: create_purchase_order(),
|
||||
"erpnext.buying.doctype.purchase_order.mapper.make_purchase_receipt",
|
||||
),
|
||||
(
|
||||
"Purchase Order -> Purchase Invoice",
|
||||
lambda: create_purchase_order(),
|
||||
"erpnext.buying.doctype.purchase_order.mapper.make_purchase_invoice",
|
||||
),
|
||||
(
|
||||
"Purchase Receipt -> Purchase Invoice",
|
||||
lambda: make_purchase_receipt(),
|
||||
"erpnext.stock.doctype.purchase_receipt.mapper.make_purchase_invoice",
|
||||
),
|
||||
(
|
||||
"Purchase Invoice -> Purchase Receipt",
|
||||
lambda: make_purchase_invoice(),
|
||||
"erpnext.accounts.doctype.purchase_invoice.mapper.make_purchase_receipt",
|
||||
),
|
||||
]
|
||||
|
||||
def make_quotation(self, item_list, customer):
|
||||
qtn = frappe.get_doc(
|
||||
{
|
||||
|
||||
@@ -38,6 +38,7 @@ def get_contract_template(template_name: str, doc: str | dict | Document):
|
||||
doc = frappe.parse_json(doc)
|
||||
|
||||
contract_template = frappe.get_doc("Contract Template", template_name)
|
||||
contract_template.check_permission()
|
||||
contract_terms = None
|
||||
|
||||
if contract_template.contract_terms:
|
||||
|
||||
@@ -29,12 +29,19 @@ class EmailCampaign(Document):
|
||||
|
||||
def validate(self):
|
||||
self.set_date()
|
||||
# checking if email is set for lead. Not checking for contact as email is a mandatory field for contact.
|
||||
if self.email_campaign_for == "Lead":
|
||||
self.validate_lead()
|
||||
self.validate_recipient_email()
|
||||
self.validate_email_campaign_already_exists()
|
||||
self.update_status()
|
||||
|
||||
def validate_recipient_email(self):
|
||||
if not self.recipient:
|
||||
return
|
||||
|
||||
if self.email_campaign_for == "Lead":
|
||||
self.validate_lead()
|
||||
elif self.email_campaign_for == "Contact":
|
||||
self.validate_contact()
|
||||
|
||||
def set_date(self):
|
||||
if getdate(self.start_date) < getdate(today()):
|
||||
frappe.throw(_("Start Date cannot be before the current date"))
|
||||
@@ -56,6 +63,13 @@ class EmailCampaign(Document):
|
||||
lead_name = frappe.db.get_value("Lead", self.recipient, "lead_name")
|
||||
frappe.throw(_("Please set an email id for the Lead {0}").format(lead_name))
|
||||
|
||||
def validate_contact(self):
|
||||
contact = frappe.db.get_value("Contact", self.recipient, ["email_id", "full_name"], as_dict=True)
|
||||
if contact and not contact.email_id:
|
||||
frappe.throw(
|
||||
_("Please set a primary email ID for the Contact {0}").format(frappe.bold(contact.full_name))
|
||||
)
|
||||
|
||||
def validate_email_campaign_already_exists(self):
|
||||
email_campaign_exists = frappe.db.exists(
|
||||
"Email Campaign",
|
||||
|
||||
@@ -59,3 +59,26 @@ class TestEmailCampaign(ERPNextTestSuite):
|
||||
doc.email_campaign_for = "Lead"
|
||||
doc.recipient = lead.name
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_lead)
|
||||
|
||||
def test_contact_without_an_email_is_rejected(self):
|
||||
contact = frappe.get_doc({"doctype": "Contact", "first_name": "_Test Contact No Email"}).insert()
|
||||
campaign = self.make_campaign(schedules=[0])
|
||||
doc = self.make_email_campaign(campaign.name)
|
||||
doc.email_campaign_for = "Contact"
|
||||
doc.recipient = contact.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "primary email ID", doc.insert)
|
||||
|
||||
def test_contact_with_an_email_is_accepted(self):
|
||||
contact = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Contact",
|
||||
"first_name": "_Test Contact With Email",
|
||||
"email_ids": [{"email_id": "_test_email_campaign@example.com", "is_primary": 1}],
|
||||
}
|
||||
).insert()
|
||||
campaign = self.make_campaign(schedules=[0])
|
||||
doc = self.make_email_campaign(campaign.name)
|
||||
doc.email_campaign_for = "Contact"
|
||||
doc.recipient = contact.name
|
||||
doc.insert()
|
||||
self.assertEqual(doc.status, "In Progress")
|
||||
|
||||
@@ -236,6 +236,8 @@ class Lead(SellingController, CRMNote):
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_prospect_and_contact(self, data: dict):
|
||||
self.check_permission("write")
|
||||
|
||||
data = frappe._dict(data)
|
||||
if data.create_contact:
|
||||
self.create_contact()
|
||||
@@ -382,6 +384,9 @@ def get_lead_with_phone_number(number):
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def add_lead_to_prospect(lead: str, prospect: str):
|
||||
if lead:
|
||||
frappe.has_permission("Lead", "read", lead, throw=True)
|
||||
|
||||
prospect = frappe.get_doc("Prospect", prospect)
|
||||
prospect.append("leads", {"lead": lead})
|
||||
prospect.save()
|
||||
|
||||
@@ -133,30 +133,6 @@
|
||||
"link_type": "Sidebar",
|
||||
"title": "Quality"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"hidden": 0,
|
||||
"icon": "messages-square",
|
||||
"link_to": "Communication",
|
||||
"link_type": "Sidebar",
|
||||
"title": "Communication"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"hidden": 0,
|
||||
"icon": "phone",
|
||||
"link_to": "Telephony",
|
||||
"link_type": "Sidebar",
|
||||
"title": "Telephony"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"hidden": 0,
|
||||
"icon": "layers",
|
||||
"link_to": "Bulk Transaction",
|
||||
"link_type": "Sidebar",
|
||||
"title": "Bulk Transaction"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"hidden": 0,
|
||||
@@ -164,17 +140,9 @@
|
||||
"link_to": "Subcontracting",
|
||||
"link_type": "Sidebar",
|
||||
"title": "Subcontracting"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"hidden": 0,
|
||||
"icon": "file-code",
|
||||
"link_to": "EDI",
|
||||
"link_type": "Sidebar",
|
||||
"title": "EDI"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-28 12:00:00.000000",
|
||||
"modified": "2026-09-02 14:58:17.082794",
|
||||
"modified_by": "Administrator",
|
||||
"name": "erpnext",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import frappe
|
||||
@@ -78,8 +79,48 @@ class CodeList(Document):
|
||||
self.url = getattr(root.find(".//Identification/LocationUri"), "text", None)
|
||||
|
||||
|
||||
def _version_key(version: str | None) -> list:
|
||||
"""Natural sort key for the version formats publishers use: integers and ISO dates.
|
||||
|
||||
Orders 3 < 10 (which a lexical sort gets wrong) and 2020-01-01 < 2020-11-05.
|
||||
"""
|
||||
return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", version or "")]
|
||||
|
||||
|
||||
@frappe.request_cache
|
||||
def resolve_code_list(code_list: str) -> str | None:
|
||||
"""Return the Code List for a document name or a canonical URI.
|
||||
|
||||
Code Lists are named after their CanonicalVersionUri, so one canonical URI can
|
||||
map to several documents, one per version. An exact document name takes
|
||||
precedence, which lets a caller request a specific version; a canonical URI
|
||||
resolves to the latest version available.
|
||||
"""
|
||||
if frappe.db.exists("Code List", code_list):
|
||||
return code_list
|
||||
|
||||
candidates = frappe.get_all(
|
||||
"Code List",
|
||||
filters={"canonical_uri": code_list},
|
||||
fields=["name", "version"],
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# ponytail: assumes one publisher sticks to one version format. An integer and an
|
||||
# ISO date under the same canonical URI compare numerically (3 < 2020), so the date
|
||||
# would win; import the genericode ValidityDate and sort on that if it ever happens.
|
||||
return max(candidates, key=lambda cl: _version_key(cl.version)).name
|
||||
|
||||
|
||||
def get_codes_for(code_list: str, doctype: str, name: str) -> tuple[str]:
|
||||
"""Return the common code for a given record"""
|
||||
"""Return the common code for a given record.
|
||||
|
||||
`code_list` may be a Code List name or a canonical URI (latest version wins).
|
||||
"""
|
||||
if not (code_list := resolve_code_list(code_list)):
|
||||
return ()
|
||||
|
||||
CommonCode = frappe.qb.DocType("Common Code")
|
||||
DynamicLink = frappe.qb.DocType("Dynamic Link")
|
||||
|
||||
@@ -101,7 +142,13 @@ def get_codes_for(code_list: str, doctype: str, name: str) -> tuple[str]:
|
||||
|
||||
|
||||
def get_docnames_for(code_list: str, doctype: str, code: str) -> tuple[str]:
|
||||
"""Return the record name for a given common code"""
|
||||
"""Return the record name for a given common code.
|
||||
|
||||
`code_list` may be a Code List name or a canonical URI (latest version wins).
|
||||
"""
|
||||
if not (code_list := resolve_code_list(code_list)):
|
||||
return ()
|
||||
|
||||
CommonCode = frappe.qb.DocType("Common Code")
|
||||
DynamicLink = frappe.qb.DocType("Dynamic Link")
|
||||
|
||||
@@ -123,6 +170,12 @@ def get_docnames_for(code_list: str, doctype: str, code: str) -> tuple[str]:
|
||||
|
||||
|
||||
def get_default_code(code_list: str) -> str | None:
|
||||
"""Return the default common code for a given code list"""
|
||||
"""Return the default common code for a given code list.
|
||||
|
||||
`code_list` may be a Code List name or a canonical URI (latest version wins).
|
||||
"""
|
||||
if not (code_list := resolve_code_list(code_list)):
|
||||
return None
|
||||
|
||||
code_id = frappe.db.get_value("Code List", code_list, "default_common_code")
|
||||
return frappe.db.get_value("Common Code", code_id, "common_code") if code_id else None
|
||||
|
||||
@@ -1,9 +1,83 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import frappe
|
||||
|
||||
from erpnext.edi.doctype.code_list.code_list import (
|
||||
_version_key,
|
||||
get_codes_for,
|
||||
get_default_code,
|
||||
get_docnames_for,
|
||||
resolve_code_list,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
CANONICAL_URI = "urn:test:erpnext:codeliste:resolve"
|
||||
OLD_VERSION = f"{CANONICAL_URI}:3"
|
||||
NEW_VERSION = f"{CANONICAL_URI}:10"
|
||||
UNKNOWN_URI = "urn:test:erpnext:codeliste:missing"
|
||||
|
||||
|
||||
class TestCodeList(ERPNextTestSuite):
|
||||
pass
|
||||
def setUp(self):
|
||||
"""Create two versions of one code list. Test records are rolled back per test."""
|
||||
for name, version in ((OLD_VERSION, "3"), (NEW_VERSION, "10")):
|
||||
if not frappe.db.exists("Code List", name):
|
||||
frappe.get_doc(
|
||||
doctype="Code List",
|
||||
name=name,
|
||||
title=name,
|
||||
canonical_uri=CANONICAL_URI,
|
||||
version=version,
|
||||
).insert()
|
||||
|
||||
default_code = frappe.get_doc(
|
||||
doctype="Common Code",
|
||||
title="Test Default",
|
||||
common_code="XYZ",
|
||||
code_list=NEW_VERSION,
|
||||
).insert()
|
||||
frappe.db.set_value("Code List", NEW_VERSION, "default_common_code", default_code.name)
|
||||
|
||||
# resolution is request-cached, so fixtures must not be masked by earlier lookups
|
||||
frappe.local.request_cache.clear()
|
||||
|
||||
def test_version_key_orders_integers_and_iso_dates(self):
|
||||
"""Integer and ISO date versions must both order correctly, unlike a lexical sort."""
|
||||
self.assertEqual(sorted(["10", "3", None, "9"], key=_version_key), [None, "3", "9", "10"])
|
||||
self.assertEqual(
|
||||
sorted(["2020-11-05", "2019-12-31", "2020-01-01"], key=_version_key),
|
||||
["2019-12-31", "2020-01-01", "2020-11-05"],
|
||||
)
|
||||
|
||||
def test_canonical_uri_resolves_to_latest_version(self):
|
||||
self.assertEqual(resolve_code_list(CANONICAL_URI), NEW_VERSION)
|
||||
|
||||
def test_name_resolves_to_itself(self):
|
||||
"""Passing a version-specific name must return that version, not the latest one."""
|
||||
self.assertEqual(resolve_code_list(OLD_VERSION), OLD_VERSION)
|
||||
|
||||
def test_name_takes_precedence_over_canonical_uri(self):
|
||||
"""A document named like a canonical URI must not redirect to another version."""
|
||||
frappe.get_doc(
|
||||
doctype="Code List",
|
||||
name=CANONICAL_URI,
|
||||
title=CANONICAL_URI,
|
||||
canonical_uri=CANONICAL_URI,
|
||||
version="1",
|
||||
).insert()
|
||||
frappe.local.request_cache.clear()
|
||||
|
||||
self.assertEqual(resolve_code_list(CANONICAL_URI), CANONICAL_URI)
|
||||
|
||||
def test_unknown_uri_resolves_to_none(self):
|
||||
self.assertIsNone(resolve_code_list(UNKNOWN_URI))
|
||||
|
||||
def test_lookups_are_empty_for_unknown_code_list(self):
|
||||
"""An unresolved code list must not fall through to an unfiltered query."""
|
||||
self.assertEqual(get_codes_for(UNKNOWN_URI, "UOM", "Nos"), ())
|
||||
self.assertEqual(get_docnames_for(UNKNOWN_URI, "UOM", "XYZ"), ())
|
||||
self.assertIsNone(get_default_code(UNKNOWN_URI))
|
||||
|
||||
def test_default_code_follows_latest_version(self):
|
||||
self.assertEqual(get_default_code(CANONICAL_URI), "XYZ")
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2026-08-16 00:00:00.000000",
|
||||
"docstatus": 0,
|
||||
"doctype": "Sidebar",
|
||||
"header_icon": "file-code",
|
||||
"idx": 0,
|
||||
"items": [
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Code List",
|
||||
"link_to": "Code List",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Common Code",
|
||||
"link_to": "Common Code",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-16 00:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "EDI",
|
||||
"name": "EDI",
|
||||
"owner": "Administrator",
|
||||
"sequence_id": 0.0,
|
||||
"standard": 1,
|
||||
"title": "EDI"
|
||||
}
|
||||
@@ -21,9 +21,101 @@
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"icon": "phone",
|
||||
"indent": 1,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Telephony",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"icon": "settings",
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Incoming Call Settings",
|
||||
"link_to": "Incoming Call Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"icon": "settings",
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Voice Call Settings",
|
||||
"link_to": "Voice Call Settings",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Call Log",
|
||||
"link_to": "Call Log",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Telephony Call Type",
|
||||
"link_to": "Telephony Call Type",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Communication Medium",
|
||||
"link_to": "Communication Medium",
|
||||
"link_type": "DocType",
|
||||
"open_in_new_tab": 1,
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-16 00:00:00.000000",
|
||||
"modified": "2026-09-02 14:58:17.082794",
|
||||
"modified_by": "Administrator",
|
||||
"module": "ERPNext Integrations",
|
||||
"name": "ERPNext Integrations",
|
||||
|
||||
@@ -21,6 +21,23 @@ add_to_apps_screen = [
|
||||
}
|
||||
]
|
||||
|
||||
# Modules that are a folder of code and nothing else. Their doctypes, reports and controllers stay
|
||||
# where they are; what they no longer own is navigation, which now sits in the sidebar named beside
|
||||
# each. Left in the dock, each would carry an entry of its own for two to four records. See
|
||||
# `frappe.utils.modules.get_code_only_modules`.
|
||||
#
|
||||
# The value names the modules that inherited that navigation, so a Call Log or a Code List resolves
|
||||
# to a sidebar the user can actually navigate to instead of dead-ending in a module the dock never
|
||||
# shows.
|
||||
code_only_modules = {
|
||||
"Telephony": ["ERPNext Integrations"],
|
||||
# Its one doctype, Communication Medium, describes how a call reaches someone, so it sits in
|
||||
# the Telephony section beside the call settings rather than in a shell of its own.
|
||||
"Communication": ["ERPNext Integrations"],
|
||||
"EDI": ["Utilities"],
|
||||
"Bulk Transaction": ["Utilities"],
|
||||
}
|
||||
|
||||
develop_version = "17.x.x-develop"
|
||||
|
||||
app_include_js = "erpnext.bundle.js"
|
||||
@@ -70,8 +87,6 @@ after_install = "erpnext.setup.install.after_install"
|
||||
after_app_install = "erpnext.setup.install.after_app_install"
|
||||
after_app_uninstall = "erpnext.setup.install.after_app_uninstall"
|
||||
|
||||
before_tests = "erpnext.tests.utils.bootstrap_test_data"
|
||||
|
||||
boot_session = "erpnext.startup.boot.boot_session"
|
||||
notification_config = "erpnext.startup.notifications.get_notification_config"
|
||||
get_help_messages = "erpnext.utilities.activation.get_help_messages"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2026-08-16 00:00:00.000000",
|
||||
"creation": "2026-08-16 00:00:00",
|
||||
"docstatus": 0,
|
||||
"doctype": "Sidebar",
|
||||
"header_icon": "wrench",
|
||||
@@ -41,7 +41,7 @@
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"indent": 1,
|
||||
"is_default_module": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Reports",
|
||||
@@ -67,12 +67,11 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-08-16 00:00:00.000000",
|
||||
"modified": "2026-09-01 19:30:16.713938",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Maintenance",
|
||||
"name": "Maintenance",
|
||||
"owner": "Administrator",
|
||||
"sequence_id": 0.0,
|
||||
"standard": 1,
|
||||
"title": "Maintenance"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
frappe.ui.form.on("Blanket Order", {
|
||||
onload: function (frm) {
|
||||
frm.trigger("set_tc_name_filter");
|
||||
if (frm.is_new()) {
|
||||
let has_pricing = frm.doc.currency || frm.doc.selling_price_list || frm.doc.buying_price_list;
|
||||
blanket_order_pricing.apply(frm, null, { reset_party_values: !has_pricing });
|
||||
}
|
||||
},
|
||||
|
||||
setup: function (frm) {
|
||||
@@ -15,10 +19,13 @@ frappe.ui.form.on("Blanket Order", {
|
||||
|
||||
frm.add_fetch("customer", "customer_name", "customer_name");
|
||||
frm.add_fetch("supplier", "supplier_name", "supplier_name");
|
||||
frm.set_query("selling_price_list", () => ({ filters: { selling: 1 } }));
|
||||
frm.set_query("buying_price_list", () => ({ filters: { buying: 1 } }));
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
erpnext.hide_company(frm);
|
||||
blanket_order_pricing.update_labels(frm);
|
||||
if (frm.doc.customer && frm.doc.docstatus === 1 && frm.doc.to_date > frappe.datetime.get_today()) {
|
||||
frm.add_custom_button(
|
||||
__("Sales Order"),
|
||||
@@ -101,5 +108,141 @@ frappe.ui.form.on("Blanket Order", {
|
||||
|
||||
blanket_order_type: function (frm) {
|
||||
frm.trigger("set_tc_name_filter");
|
||||
return reset_party_pricing(frm);
|
||||
},
|
||||
|
||||
company: reset_party_pricing,
|
||||
|
||||
customer: reset_party_pricing,
|
||||
|
||||
supplier: reset_party_pricing,
|
||||
|
||||
currency: function (frm) {
|
||||
return blanket_order_pricing.apply(frm, null, { reset_conversion_rate: true });
|
||||
},
|
||||
|
||||
from_date: function (frm) {
|
||||
return blanket_order_pricing.apply(frm, null, {
|
||||
reset_conversion_rate: true,
|
||||
reset_plc_conversion_rate: true,
|
||||
});
|
||||
},
|
||||
|
||||
conversion_rate: async function (frm) {
|
||||
await blanket_order_pricing.update_base_rates(frm);
|
||||
return blanket_order_pricing.apply(frm);
|
||||
},
|
||||
|
||||
selling_price_list: reset_price_list_exchange_rate,
|
||||
|
||||
buying_price_list: reset_price_list_exchange_rate,
|
||||
|
||||
plc_conversion_rate: function (frm) {
|
||||
return blanket_order_pricing.apply(frm);
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Blanket Order Item", {
|
||||
item_code: apply_item_pricing,
|
||||
|
||||
qty: apply_item_pricing,
|
||||
|
||||
rate: function (frm, cdt, cdn) {
|
||||
return set_base_rate(frm, frappe.get_doc(cdt, cdn));
|
||||
},
|
||||
});
|
||||
|
||||
const blanket_order_pricing = {
|
||||
update_base_rates(frm) {
|
||||
return Promise.all((frm.doc.items || []).map((item) => set_base_rate(frm, item)));
|
||||
},
|
||||
|
||||
update_labels(frm) {
|
||||
let company_currency = this.get_company_currency(frm);
|
||||
let show_base_rate = Boolean(
|
||||
frm.doc.currency && company_currency && frm.doc.currency !== company_currency
|
||||
);
|
||||
|
||||
frm.set_currency_labels(["price_list_rate", "rate"], frm.doc.currency || company_currency, "items");
|
||||
frm.set_currency_labels(["base_price_list_rate", "base_rate"], company_currency, "items");
|
||||
frm.fields_dict.items.grid.set_column_disp("base_price_list_rate", show_base_rate);
|
||||
frm.fields_dict.items.grid.set_column_disp("base_rate", show_base_rate);
|
||||
frm.toggle_display("conversion_rate", show_base_rate);
|
||||
frm.toggle_display(
|
||||
"plc_conversion_rate",
|
||||
Boolean(frm.doc.price_list_currency && frm.doc.price_list_currency !== company_currency)
|
||||
);
|
||||
frm.set_df_property(
|
||||
"conversion_rate",
|
||||
"description",
|
||||
show_base_rate ? `1 ${frm.doc.currency} = [?] ${company_currency}` : ""
|
||||
);
|
||||
frm.refresh_fields();
|
||||
},
|
||||
|
||||
get_company_currency(frm) {
|
||||
return frm.doc.company ? erpnext.get_currency(frm.doc.company) : null;
|
||||
},
|
||||
|
||||
async apply(frm, item_name = null, options = {}) {
|
||||
if (!frm.doc.company || !frm.doc.blanket_order_type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (frm.__applying_blanket_order_price_list) {
|
||||
frm.__pending_blanket_order_price_list = { item_name, options };
|
||||
return;
|
||||
}
|
||||
|
||||
frm.__applying_blanket_order_price_list = true;
|
||||
let pending;
|
||||
try {
|
||||
let response = await frappe.call({
|
||||
method: "erpnext.manufacturing.doctype.blanket_order.blanket_order.apply_price_list",
|
||||
args: {
|
||||
doc: frm.doc,
|
||||
item_name,
|
||||
reset_party_values: options.reset_party_values,
|
||||
reset_conversion_rate: options.reset_conversion_rate,
|
||||
reset_plc_conversion_rate: options.reset_plc_conversion_rate,
|
||||
},
|
||||
});
|
||||
if (response.message) {
|
||||
await frm.set_value(response.message.parent);
|
||||
for (const values of response.message.children) {
|
||||
let { name, ...fields } = values;
|
||||
let item = (frm.doc.items || []).find((row) => row.name === name);
|
||||
if (item) {
|
||||
await frappe.model.set_value(item.doctype, item.name, fields);
|
||||
}
|
||||
}
|
||||
this.update_labels(frm);
|
||||
}
|
||||
} finally {
|
||||
frm.__applying_blanket_order_price_list = false;
|
||||
pending = frm.__pending_blanket_order_price_list;
|
||||
frm.__pending_blanket_order_price_list = null;
|
||||
}
|
||||
if (pending) {
|
||||
return this.apply(frm, pending.item_name, pending.options);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function reset_party_pricing(frm) {
|
||||
return blanket_order_pricing.apply(frm, null, { reset_party_values: true });
|
||||
}
|
||||
|
||||
function reset_price_list_exchange_rate(frm) {
|
||||
return blanket_order_pricing.apply(frm, null, { reset_plc_conversion_rate: true });
|
||||
}
|
||||
|
||||
function apply_item_pricing(frm, cdt, cdn) {
|
||||
return blanket_order_pricing.apply(frm, cdn);
|
||||
}
|
||||
|
||||
function set_base_rate(frm, item) {
|
||||
frappe.model.round_floats_in(item, ["rate"]);
|
||||
let base_rate = flt(flt(item.rate) * flt(frm.doc.conversion_rate), precision("base_rate", item));
|
||||
return frappe.model.set_value(item.doctype, item.name, "base_rate", base_rate);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@
|
||||
"from_date",
|
||||
"to_date",
|
||||
"company",
|
||||
"currency_and_price_list",
|
||||
"currency",
|
||||
"conversion_rate",
|
||||
"column_break_price_list",
|
||||
"selling_price_list",
|
||||
"buying_price_list",
|
||||
"price_list_currency",
|
||||
"plc_conversion_rate",
|
||||
"section_break_12",
|
||||
"items",
|
||||
"amended_from",
|
||||
@@ -96,6 +104,66 @@
|
||||
"reqd": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Currency and Price List"
|
||||
},
|
||||
{
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"label": "Currency",
|
||||
"options": "Currency",
|
||||
"print_hide": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_price_list",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"description": "Rate at which document currency is converted to company currency",
|
||||
"fieldname": "conversion_rate",
|
||||
"fieldtype": "Float",
|
||||
"label": "Exchange Rate",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.blanket_order_type == \"Selling\"",
|
||||
"fieldname": "selling_price_list",
|
||||
"fieldtype": "Link",
|
||||
"label": "Price List",
|
||||
"options": "Price List",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.blanket_order_type == \"Purchasing\"",
|
||||
"fieldname": "buying_price_list",
|
||||
"fieldtype": "Link",
|
||||
"label": "Price List",
|
||||
"options": "Price List",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "price_list_currency",
|
||||
"fieldtype": "Link",
|
||||
"label": "Price List Currency",
|
||||
"options": "Currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Rate at which Price List Currency is converted to Company Currency",
|
||||
"fieldname": "plc_conversion_rate",
|
||||
"fieldtype": "Float",
|
||||
"label": "Price List Exchange Rate",
|
||||
"precision": "9",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_12",
|
||||
"fieldtype": "Section Break"
|
||||
@@ -147,7 +215,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:40.122402",
|
||||
"modified": "2026-08-27 10:55:37.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Blanket Order",
|
||||
|
||||
@@ -9,6 +9,9 @@ from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt, getdate
|
||||
|
||||
from erpnext import get_company_currency
|
||||
from erpnext.accounts.services.taxes import validate_conversion_rate
|
||||
from erpnext.manufacturing.doctype.blanket_order import blanket_order_pricing
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults
|
||||
|
||||
|
||||
@@ -25,7 +28,10 @@ class BlanketOrder(Document):
|
||||
|
||||
amended_from: DF.Link | None
|
||||
blanket_order_type: DF.Literal["", "Selling", "Purchasing"]
|
||||
buying_price_list: DF.Link | None
|
||||
company: DF.Link
|
||||
conversion_rate: DF.Float
|
||||
currency: DF.Link
|
||||
customer: DF.Link | None
|
||||
customer_name: DF.Data | None
|
||||
from_date: DF.Date
|
||||
@@ -33,6 +39,9 @@ class BlanketOrder(Document):
|
||||
naming_series: DF.Literal["MFG-BLR-.YYYY.-"]
|
||||
order_date: DF.Date | None
|
||||
order_no: DF.Data | None
|
||||
plc_conversion_rate: DF.Float
|
||||
price_list_currency: DF.Link | None
|
||||
selling_price_list: DF.Link | None
|
||||
supplier: DF.Link | None
|
||||
supplier_name: DF.Data | None
|
||||
tc_name: DF.Link | None
|
||||
@@ -40,11 +49,42 @@ class BlanketOrder(Document):
|
||||
to_date: DF.Date
|
||||
# end: auto-generated types
|
||||
|
||||
def before_validate(self):
|
||||
self.set_currency()
|
||||
self.set_conversion_rate()
|
||||
blanket_order_pricing.set_price_list(self)
|
||||
|
||||
def validate(self):
|
||||
self.validate_dates()
|
||||
self.validate_duplicate_items()
|
||||
self.validate_item_qty()
|
||||
self.set_party_item_code()
|
||||
self.set_base_rates()
|
||||
|
||||
def set_currency(self):
|
||||
if self.currency:
|
||||
return
|
||||
|
||||
config = blanket_order_pricing.get_order_type_config(self.blanket_order_type)
|
||||
party_type = config["party_type"]
|
||||
party = self.get(config["party_field"])
|
||||
party_currency = frappe.get_cached_value(party_type, party, "default_currency") if party else None
|
||||
self.currency = party_currency or get_company_currency(self.company)
|
||||
|
||||
def set_conversion_rate(self):
|
||||
company_currency = get_company_currency(self.company)
|
||||
if self.currency == company_currency:
|
||||
self.conversion_rate = 1.0
|
||||
elif not self.conversion_rate:
|
||||
self.conversion_rate = blanket_order_pricing.get_exchange_rate_to_company(self, self.currency)
|
||||
|
||||
validate_conversion_rate(
|
||||
self.currency,
|
||||
self.conversion_rate,
|
||||
self.meta.get_translated_label("conversion_rate"),
|
||||
self.company,
|
||||
)
|
||||
self.conversion_rate = flt(self.conversion_rate, self.precision("conversion_rate"))
|
||||
|
||||
def validate_dates(self):
|
||||
if getdate(self.from_date) > getdate(self.to_date):
|
||||
@@ -123,6 +163,26 @@ class BlanketOrder(Document):
|
||||
if flt(d.qty) <= 0:
|
||||
frappe.throw(_("Row {0}: Quantity must be greater than zero.").format(d.idx))
|
||||
|
||||
def set_base_rates(self):
|
||||
blanket_order_pricing.set_base_rates(self)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def apply_price_list(
|
||||
doc: str | dict,
|
||||
item_name: str | None = None,
|
||||
reset_party_values: bool = False,
|
||||
reset_plc_conversion_rate: bool = False,
|
||||
reset_conversion_rate: bool = False,
|
||||
):
|
||||
return blanket_order_pricing.apply_price_list(
|
||||
doc=doc,
|
||||
item_name=item_name,
|
||||
reset_party_values=reset_party_values,
|
||||
reset_plc_conversion_rate=reset_plc_conversion_rate,
|
||||
reset_conversion_rate=reset_conversion_rate,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_order(source_name: str):
|
||||
@@ -136,14 +196,12 @@ def make_order(source_name: str):
|
||||
def update_item(source, target, source_parent):
|
||||
target_qty = source.get("qty") - source.get("ordered_qty")
|
||||
target.qty = target_qty if flt(target_qty) >= 0 else 0
|
||||
target.rate = source.get("rate")
|
||||
item = get_item_defaults(target.item_code, source_parent.company)
|
||||
if item:
|
||||
target.item_name = item.get("item_name")
|
||||
target.description = item.get("description")
|
||||
target.uom = item.get("stock_uom")
|
||||
target.against_blanket_order = 1
|
||||
target.blanket_order = source_name
|
||||
|
||||
target_doc = get_mapped_doc(
|
||||
"Blanket Order",
|
||||
@@ -156,7 +214,10 @@ def make_order(source_name: str):
|
||||
},
|
||||
"Blanket Order Item": {
|
||||
"doctype": doctype + " Item",
|
||||
"field_map": {"rate": "blanket_order_rate", "parent": "blanket_order"},
|
||||
"field_map": {
|
||||
"rate": "blanket_order_rate",
|
||||
"parent": "blanket_order",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda item: not (flt(item.qty)) or (flt(item.qty) - flt(item.ordered_qty)) > 0,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint, flt
|
||||
|
||||
from erpnext import get_company_currency
|
||||
from erpnext.accounts.party import get_default_price_list as get_party_default_price_list
|
||||
from erpnext.accounts.services.taxes import validate_conversion_rate
|
||||
from erpnext.setup.utils import get_exchange_rate
|
||||
from erpnext.stock.get_item_details import get_price_list_rate_for
|
||||
|
||||
_ORDER_TYPE_CONFIG = {
|
||||
"Selling": {
|
||||
"exchange_rate_type": "for_selling",
|
||||
"opposite_price_list_field": "buying_price_list",
|
||||
"party_field": "customer",
|
||||
"party_type": "Customer",
|
||||
"price_list_field": "selling_price_list",
|
||||
"price_list_type": "Selling",
|
||||
"settings_doctype": "Selling Settings",
|
||||
},
|
||||
"Purchasing": {
|
||||
"exchange_rate_type": "for_buying",
|
||||
"opposite_price_list_field": "selling_price_list",
|
||||
"party_field": "supplier",
|
||||
"party_type": "Supplier",
|
||||
"price_list_field": "buying_price_list",
|
||||
"price_list_type": "Buying",
|
||||
"settings_doctype": "Buying Settings",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_order_type_config(blanket_order_type):
|
||||
return _ORDER_TYPE_CONFIG[blanket_order_type]
|
||||
|
||||
|
||||
def get_exchange_rate_to_company(doc, currency):
|
||||
config = get_order_type_config(doc.blanket_order_type)
|
||||
return get_exchange_rate(
|
||||
currency,
|
||||
get_company_currency(doc.company),
|
||||
doc.from_date,
|
||||
config["exchange_rate_type"],
|
||||
)
|
||||
|
||||
|
||||
def set_price_list(doc, set_default=False, force_exchange_rate=False):
|
||||
config = get_order_type_config(doc.blanket_order_type)
|
||||
fieldname = config["price_list_field"]
|
||||
doc.set(config["opposite_price_list_field"], None)
|
||||
|
||||
if not doc.get(fieldname) and (doc.is_new() or set_default):
|
||||
doc.set(fieldname, get_default_price_list(doc))
|
||||
|
||||
price_list = doc.get(fieldname)
|
||||
if not price_list:
|
||||
clear_price_list(doc)
|
||||
return
|
||||
|
||||
price_list_type = config["price_list_type"].lower()
|
||||
price_list_details = frappe.get_cached_value(
|
||||
"Price List", price_list, ["currency", price_list_type, "enabled"], as_dict=True
|
||||
)
|
||||
if not price_list_details or not price_list_details.enabled:
|
||||
frappe.throw(_("Price List {0} is disabled or does not exist").format(frappe.bold(price_list)))
|
||||
if not price_list_details.get(price_list_type):
|
||||
frappe.throw(
|
||||
_("Price List {0} is not enabled for {1}").format(
|
||||
frappe.bold(price_list), frappe.bold(doc.blanket_order_type)
|
||||
)
|
||||
)
|
||||
|
||||
price_list_currency_changed = doc.price_list_currency != price_list_details.currency
|
||||
doc.price_list_currency = price_list_details.currency
|
||||
company_currency = get_company_currency(doc.company)
|
||||
if doc.price_list_currency == company_currency:
|
||||
doc.plc_conversion_rate = 1.0
|
||||
elif price_list_currency_changed or not doc.plc_conversion_rate or force_exchange_rate:
|
||||
doc.plc_conversion_rate = get_exchange_rate_to_company(doc, doc.price_list_currency)
|
||||
|
||||
validate_conversion_rate(
|
||||
doc.price_list_currency,
|
||||
doc.plc_conversion_rate,
|
||||
doc.meta.get_translated_label("plc_conversion_rate"),
|
||||
doc.company,
|
||||
)
|
||||
doc.plc_conversion_rate = flt(doc.plc_conversion_rate, doc.precision("plc_conversion_rate"))
|
||||
|
||||
|
||||
def clear_price_list(doc):
|
||||
doc.price_list_currency = None
|
||||
doc.plc_conversion_rate = 0
|
||||
for item in doc.items:
|
||||
item.price_list_rate = 0
|
||||
item.base_price_list_rate = 0
|
||||
|
||||
|
||||
def get_default_price_list(doc):
|
||||
config = get_order_type_config(doc.blanket_order_type)
|
||||
party_type = config["party_type"]
|
||||
party = doc.get(config["party_field"])
|
||||
if party:
|
||||
party_price_list = get_party_default_price_list(frappe.get_cached_doc(party_type, party))
|
||||
if party_price_list:
|
||||
return party_price_list
|
||||
|
||||
return frappe.db.get_single_value(config["settings_doctype"], config["price_list_field"])
|
||||
|
||||
|
||||
def get_price_list_rates(doc, item_name=None):
|
||||
price_list = doc.get(get_order_type_config(doc.blanket_order_type)["price_list_field"])
|
||||
items = [item for item in doc.items if item.item_code and (not item_name or item.name == item_name)]
|
||||
if not items:
|
||||
return []
|
||||
if not price_list:
|
||||
return [{"name": item.name, "price_list_rate": 0, "base_price_list_rate": 0} for item in items]
|
||||
|
||||
stock_uoms = dict(
|
||||
frappe.get_all(
|
||||
"Item",
|
||||
filters={"name": ("in", [item.item_code for item in items])},
|
||||
fields=["name", "stock_uom"],
|
||||
as_list=True,
|
||||
)
|
||||
)
|
||||
|
||||
ctx = frappe._dict(
|
||||
{
|
||||
"price_list": price_list,
|
||||
"customer": doc.customer,
|
||||
"supplier": doc.supplier,
|
||||
"transaction_date": doc.from_date,
|
||||
}
|
||||
)
|
||||
rates = []
|
||||
for item in items:
|
||||
stock_uom = stock_uoms.get(item.item_code)
|
||||
ctx.update(
|
||||
{
|
||||
"qty": flt(item.qty) or 1,
|
||||
"uom": stock_uom,
|
||||
"stock_uom": stock_uom,
|
||||
"conversion_factor": 1,
|
||||
}
|
||||
)
|
||||
price_list_rate = get_price_list_rate_for(ctx, item.item_code)
|
||||
rate_details = {"name": item.name, "price_list_rate": 0, "base_price_list_rate": 0}
|
||||
if price_list_rate is not None:
|
||||
rate = flt(price_list_rate) * flt(doc.plc_conversion_rate) / flt(doc.conversion_rate)
|
||||
price_list_rate, base_price_list_rate = get_rate_and_base_amount(
|
||||
doc, item, "price_list_rate", rate
|
||||
)
|
||||
rate, base_rate = get_rate_and_base_amount(doc, item, "rate", rate)
|
||||
rate_details.update(
|
||||
{
|
||||
"price_list_rate": price_list_rate,
|
||||
"base_price_list_rate": base_price_list_rate,
|
||||
"rate": rate,
|
||||
"base_rate": base_rate,
|
||||
}
|
||||
)
|
||||
rates.append(rate_details)
|
||||
|
||||
return rates
|
||||
|
||||
|
||||
def set_base_rates(doc):
|
||||
for item in doc.items:
|
||||
for fieldname in ("price_list_rate", "rate"):
|
||||
rate, base_rate = get_rate_and_base_amount(doc, item, fieldname, item.get(fieldname))
|
||||
item.set(fieldname, rate)
|
||||
item.set(f"base_{fieldname}", base_rate)
|
||||
|
||||
|
||||
def get_rate_and_base_amount(doc, item, fieldname, rate):
|
||||
rate = flt(rate, item.precision(fieldname))
|
||||
base_fieldname = f"base_{fieldname}"
|
||||
base_rate = flt(rate * flt(doc.conversion_rate), item.precision(base_fieldname))
|
||||
return rate, base_rate
|
||||
|
||||
|
||||
def apply_price_list(
|
||||
doc,
|
||||
item_name=None,
|
||||
reset_party_values=False,
|
||||
reset_plc_conversion_rate=False,
|
||||
reset_conversion_rate=False,
|
||||
):
|
||||
doc = frappe.get_doc(frappe.parse_json(doc))
|
||||
reset_party_values = cint(reset_party_values)
|
||||
reset_plc_conversion_rate = cint(reset_plc_conversion_rate)
|
||||
reset_conversion_rate = cint(reset_conversion_rate)
|
||||
if reset_party_values:
|
||||
doc.currency = None
|
||||
doc.conversion_rate = 0
|
||||
doc.selling_price_list = None
|
||||
doc.buying_price_list = None
|
||||
doc.price_list_currency = None
|
||||
doc.plc_conversion_rate = 0
|
||||
else:
|
||||
if reset_conversion_rate:
|
||||
doc.conversion_rate = 0
|
||||
if reset_plc_conversion_rate:
|
||||
doc.plc_conversion_rate = 0
|
||||
|
||||
doc.set_currency()
|
||||
doc.set_conversion_rate()
|
||||
set_price_list(
|
||||
doc,
|
||||
set_default=reset_party_values,
|
||||
force_exchange_rate=reset_party_values or reset_plc_conversion_rate,
|
||||
)
|
||||
|
||||
return {
|
||||
"parent": {
|
||||
"currency": doc.currency,
|
||||
"conversion_rate": doc.conversion_rate,
|
||||
"selling_price_list": doc.selling_price_list,
|
||||
"buying_price_list": doc.buying_price_list,
|
||||
"price_list_currency": doc.price_list_currency,
|
||||
"plc_conversion_rate": doc.plc_conversion_rate,
|
||||
},
|
||||
"children": get_price_list_rates(doc, item_name),
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_months, today
|
||||
from frappe.utils import add_months, flt, today
|
||||
|
||||
from erpnext import get_company_currency
|
||||
from erpnext.controllers.queries import get_blanket_orders
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.get_item_details import get_blanket_order_details
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
from .blanket_order import make_order
|
||||
from . import blanket_order_pricing
|
||||
from .blanket_order import apply_price_list, make_order
|
||||
|
||||
|
||||
class TestBlanketOrder(ERPNextTestSuite):
|
||||
@@ -184,21 +189,241 @@ class TestBlanketOrder(ERPNextTestSuite):
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
bo.insert()
|
||||
|
||||
def test_multicurrency_blanket_order(self):
|
||||
company_currency = get_company_currency("_Test Company")
|
||||
transaction_currency = "USD" if company_currency != "USD" else "EUR"
|
||||
conversion_rate = 80
|
||||
rate = 5
|
||||
|
||||
for blanket_order_type, target_doctypes in (
|
||||
("Selling", ("Sales Order", "Quotation")),
|
||||
("Purchasing", ("Purchase Order",)),
|
||||
):
|
||||
blanket_order = make_blanket_order(
|
||||
blanket_order_type=blanket_order_type,
|
||||
currency=transaction_currency,
|
||||
conversion_rate=conversion_rate,
|
||||
rate=rate,
|
||||
)
|
||||
|
||||
self.assertEqual(blanket_order.currency, transaction_currency)
|
||||
self.assertEqual(blanket_order.conversion_rate, conversion_rate)
|
||||
self.assertEqual(blanket_order.items[0].base_rate, rate * conversion_rate)
|
||||
|
||||
for target_doctype in target_doctypes:
|
||||
with self.subTest(target_doctype=target_doctype):
|
||||
frappe.flags.args.doctype = target_doctype
|
||||
target = make_order(blanket_order.name)
|
||||
|
||||
self.assertEqual(target.currency, transaction_currency)
|
||||
self.assertEqual(target.conversion_rate, conversion_rate)
|
||||
self.assertEqual(target.items[0].rate, rate)
|
||||
self.assertEqual(target.items[0].base_rate, rate * conversion_rate)
|
||||
self.assertEqual(target.items[0].blanket_order_rate, rate)
|
||||
self.assertEqual(target.items[0].blanket_order, blanket_order.name)
|
||||
|
||||
def test_price_list_rates_and_mapping(self):
|
||||
company = "_Test Company"
|
||||
company_currency = get_company_currency(company)
|
||||
transaction_currency = "USD" if company_currency != "USD" else "EUR"
|
||||
conversion_rate = 80
|
||||
price_list_rate = 800
|
||||
|
||||
for blanket_order_type, price_list_field, target_doctypes in (
|
||||
("Selling", "selling_price_list", ("Sales Order", "Quotation")),
|
||||
("Purchasing", "buying_price_list", ("Purchase Order",)),
|
||||
):
|
||||
blanket_order, price_list = make_priced_blanket_order(
|
||||
blanket_order_type=blanket_order_type,
|
||||
company=company,
|
||||
currency=transaction_currency,
|
||||
conversion_rate=conversion_rate,
|
||||
price_list_rate=price_list_rate,
|
||||
qty=1000,
|
||||
)
|
||||
blanket_order.insert()
|
||||
blanket_order.submit()
|
||||
|
||||
expected_rate = price_list_rate / conversion_rate
|
||||
self.assertEqual(blanket_order.price_list_currency, company_currency)
|
||||
self.assertEqual(blanket_order.plc_conversion_rate, 1)
|
||||
self.assertEqual(blanket_order.items[0].price_list_rate, expected_rate)
|
||||
self.assertEqual(blanket_order.items[0].base_price_list_rate, price_list_rate)
|
||||
self.assertEqual(blanket_order.items[0].rate, expected_rate)
|
||||
self.assertEqual(blanket_order.items[0].base_rate, price_list_rate)
|
||||
|
||||
for target_doctype in target_doctypes:
|
||||
with self.subTest(target_doctype=target_doctype):
|
||||
frappe.flags.args.doctype = target_doctype
|
||||
target = make_order(blanket_order.name)
|
||||
|
||||
self.assertEqual(target.get(price_list_field), price_list)
|
||||
self.assertEqual(target.price_list_currency, company_currency)
|
||||
self.assertEqual(target.plc_conversion_rate, 1)
|
||||
self.assertEqual(target.items[0].price_list_rate, expected_rate)
|
||||
self.assertEqual(target.items[0].base_price_list_rate, price_list_rate)
|
||||
self.assertEqual(target.items[0].rate, expected_rate)
|
||||
self.assertEqual(target.items[0].blanket_order, blanket_order.name)
|
||||
|
||||
def test_applying_price_list_ignores_empty_item_rows(self):
|
||||
blanket_order = frappe.new_doc("Blanket Order")
|
||||
blanket_order.blanket_order_type = "Selling"
|
||||
blanket_order.company = "_Test Company"
|
||||
blanket_order.customer = "_Test Customer"
|
||||
blanket_order.from_date = today()
|
||||
blanket_order.append("items", {})
|
||||
|
||||
pricing = apply_price_list(blanket_order.as_dict())
|
||||
|
||||
self.assertEqual(pricing["children"], [])
|
||||
|
||||
def test_price_list_rate_is_fetched_on_item_selection(self):
|
||||
company = "_Test Company"
|
||||
company_currency = get_company_currency(company)
|
||||
price_list_rate = 800
|
||||
blanket_order, _price_list = make_priced_blanket_order(
|
||||
company=company,
|
||||
currency=company_currency,
|
||||
conversion_rate=1,
|
||||
price_list_rate=price_list_rate,
|
||||
qty=0,
|
||||
)
|
||||
item = blanket_order.items[0]
|
||||
|
||||
self.assertEqual(item.price_list_rate, price_list_rate)
|
||||
self.assertEqual(item.rate, price_list_rate)
|
||||
|
||||
def test_price_list_rates_fetch_item_uoms_once(self):
|
||||
blanket_order = new_blanket_order("Selling")
|
||||
blanket_order.selling_price_list = "_Test Price List"
|
||||
for item_code in ("ITEM-1", "ITEM-2"):
|
||||
blanket_order.append("items", {"item_code": item_code, "qty": 1})
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
blanket_order_pricing.frappe,
|
||||
"get_all",
|
||||
return_value=[["ITEM-1", "Nos"], ["ITEM-2", "Nos"]],
|
||||
) as get_all,
|
||||
patch.object(blanket_order_pricing, "get_price_list_rate_for", return_value=None),
|
||||
):
|
||||
rates = blanket_order_pricing.get_price_list_rates(blanket_order)
|
||||
|
||||
self.assertEqual(len(rates), 2)
|
||||
get_all.assert_called_once_with(
|
||||
"Item",
|
||||
filters={"name": ("in", ["ITEM-1", "ITEM-2"])},
|
||||
fields=["name", "stock_uom"],
|
||||
as_list=True,
|
||||
)
|
||||
|
||||
def test_price_list_conversion_uses_currency_precision(self):
|
||||
company = "_Test Company"
|
||||
company_currency = get_company_currency(company)
|
||||
transaction_currency = "USD" if company_currency != "USD" else "EUR"
|
||||
conversion_rate = 95.47
|
||||
price_list_rate = 100
|
||||
blanket_order, _price_list = make_priced_blanket_order(
|
||||
company=company,
|
||||
currency=transaction_currency,
|
||||
conversion_rate=conversion_rate,
|
||||
price_list_rate=price_list_rate,
|
||||
)
|
||||
item = blanket_order.items[0]
|
||||
expected_rate = flt(price_list_rate / conversion_rate, item.precision("rate"))
|
||||
expected_base_rate = flt(expected_rate * conversion_rate, item.precision("base_rate"))
|
||||
|
||||
self.assertFalse(frappe.get_meta("Blanket Order Item").get_field("rate").precision)
|
||||
self.assertEqual(item.price_list_rate, expected_rate)
|
||||
self.assertEqual(item.base_price_list_rate, expected_base_rate)
|
||||
self.assertEqual(item.rate, expected_rate)
|
||||
self.assertEqual(item.base_rate, expected_base_rate)
|
||||
|
||||
blanket_order.insert()
|
||||
blanket_order.submit()
|
||||
|
||||
frappe.flags.args.doctype = "Sales Order"
|
||||
sales_order = make_order(blanket_order.name)
|
||||
sales_order.delivery_date = today()
|
||||
sales_order.insert()
|
||||
|
||||
self.assertEqual(sales_order.items[0].price_list_rate, item.price_list_rate)
|
||||
self.assertEqual(sales_order.items[0].base_price_list_rate, item.base_price_list_rate)
|
||||
self.assertEqual(sales_order.items[0].rate, item.rate)
|
||||
self.assertEqual(sales_order.items[0].base_rate, item.base_rate)
|
||||
|
||||
def test_applying_price_list_can_reset_conversion_rate(self):
|
||||
company_currency = get_company_currency("_Test Company")
|
||||
transaction_currency = "USD" if company_currency != "USD" else "EUR"
|
||||
blanket_order, _price_list = make_priced_blanket_order(
|
||||
currency=transaction_currency,
|
||||
conversion_rate=80,
|
||||
price_list_rate=100,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"erpnext.manufacturing.doctype.blanket_order.blanket_order_pricing.get_exchange_rate",
|
||||
return_value=95.47,
|
||||
):
|
||||
pricing = apply_price_list(blanket_order.as_dict(), reset_conversion_rate=True)
|
||||
|
||||
self.assertEqual(pricing["parent"]["conversion_rate"], 95.47)
|
||||
expected_rate = flt(
|
||||
100 / pricing["parent"]["conversion_rate"],
|
||||
blanket_order.items[0].precision("rate"),
|
||||
)
|
||||
expected_base_rate = flt(
|
||||
expected_rate * pricing["parent"]["conversion_rate"],
|
||||
blanket_order.items[0].precision("base_rate"),
|
||||
)
|
||||
self.assertEqual(pricing["children"][0]["base_rate"], expected_base_rate)
|
||||
|
||||
def test_blanket_order_lookup_filters_currency(self):
|
||||
company_currency = get_company_currency("_Test Company")
|
||||
transaction_currency = "USD" if company_currency != "USD" else "EUR"
|
||||
blanket_order = make_blanket_order(
|
||||
blanket_order_type="Selling",
|
||||
currency=transaction_currency,
|
||||
conversion_rate=80,
|
||||
)
|
||||
|
||||
filters = {
|
||||
"company": blanket_order.company,
|
||||
"currency": transaction_currency,
|
||||
"blanket_order_type": "Selling",
|
||||
"item": blanket_order.items[0].item_code,
|
||||
}
|
||||
matching_orders = get_blanket_orders("Blanket Order", "", "name", 0, 20, filters)
|
||||
self.assertIn(blanket_order.name, [order[0] for order in matching_orders])
|
||||
|
||||
filters["currency"] = company_currency
|
||||
other_currency_orders = get_blanket_orders("Blanket Order", "", "name", 0, 20, filters)
|
||||
self.assertNotIn(blanket_order.name, [order[0] for order in other_currency_orders])
|
||||
|
||||
details = get_blanket_order_details(
|
||||
{
|
||||
"blanket_order": blanket_order.name,
|
||||
"company": blanket_order.company,
|
||||
"currency": company_currency,
|
||||
"customer": blanket_order.customer,
|
||||
"doctype": "Sales Order",
|
||||
"item_code": blanket_order.items[0].item_code,
|
||||
"transaction_date": today(),
|
||||
}
|
||||
)
|
||||
self.assertFalse(details)
|
||||
|
||||
|
||||
def make_blanket_order(**args):
|
||||
args = frappe._dict(args)
|
||||
bo = frappe.new_doc("Blanket Order")
|
||||
bo.blanket_order_type = args.blanket_order_type
|
||||
bo.company = args.company or "_Test Company"
|
||||
|
||||
if args.blanket_order_type == "Selling":
|
||||
bo.customer = args.customer or "_Test Customer"
|
||||
else:
|
||||
bo.supplier = args.supplier or "_Test Supplier"
|
||||
|
||||
bo.from_date = today()
|
||||
bo.to_date = add_months(bo.from_date, months=12)
|
||||
|
||||
bo = new_blanket_order(
|
||||
blanket_order_type=args.blanket_order_type,
|
||||
company=args.company or "_Test Company",
|
||||
currency=args.currency,
|
||||
conversion_rate=args.conversion_rate or 1,
|
||||
customer=args.customer,
|
||||
supplier=args.supplier,
|
||||
)
|
||||
bo.append(
|
||||
"items",
|
||||
{
|
||||
@@ -211,3 +436,84 @@ def make_blanket_order(**args):
|
||||
bo.insert()
|
||||
bo.submit()
|
||||
return bo
|
||||
|
||||
|
||||
def make_priced_blanket_order(
|
||||
blanket_order_type="Selling",
|
||||
company="_Test Company",
|
||||
currency=None,
|
||||
conversion_rate=1,
|
||||
price_list_rate=800,
|
||||
qty=1,
|
||||
):
|
||||
price_list = make_blanket_order_price_list(get_company_currency(company), price_list_rate)
|
||||
blanket_order = new_blanket_order(
|
||||
blanket_order_type=blanket_order_type,
|
||||
company=company,
|
||||
currency=currency,
|
||||
conversion_rate=conversion_rate,
|
||||
)
|
||||
config = blanket_order_pricing.get_order_type_config(blanket_order_type)
|
||||
blanket_order.set(config["price_list_field"], price_list)
|
||||
item = blanket_order.append("items", {"item_code": "_Test Item", "qty": qty, "rate": 0})
|
||||
pricing = apply_price_list(blanket_order.as_dict())
|
||||
blanket_order.update(pricing["parent"])
|
||||
item.update({key: value for key, value in pricing["children"][0].items() if key != "name"})
|
||||
|
||||
return blanket_order, price_list
|
||||
|
||||
|
||||
def new_blanket_order(
|
||||
blanket_order_type,
|
||||
company="_Test Company",
|
||||
currency=None,
|
||||
conversion_rate=1,
|
||||
customer=None,
|
||||
supplier=None,
|
||||
):
|
||||
blanket_order = frappe.new_doc("Blanket Order")
|
||||
blanket_order.blanket_order_type = blanket_order_type
|
||||
blanket_order.company = company
|
||||
blanket_order.currency = currency or get_company_currency(company)
|
||||
blanket_order.conversion_rate = conversion_rate
|
||||
blanket_order.from_date = today()
|
||||
blanket_order.to_date = add_months(blanket_order.from_date, months=12)
|
||||
|
||||
config = blanket_order_pricing.get_order_type_config(blanket_order_type)
|
||||
party = customer if config["party_field"] == "customer" else supplier
|
||||
blanket_order.set(config["party_field"], party or f"_Test {config['party_type']}")
|
||||
|
||||
return blanket_order
|
||||
|
||||
|
||||
def make_blanket_order_price_list(currency, price_list_rate):
|
||||
price_list = "_Test Blanket Order Price List"
|
||||
if not frappe.db.exists("Price List", price_list):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Price List",
|
||||
"price_list_name": price_list,
|
||||
"currency": currency,
|
||||
"selling": 1,
|
||||
"buying": 1,
|
||||
}
|
||||
).insert()
|
||||
else:
|
||||
frappe.db.set_value("Price List", price_list, {"currency": currency, "selling": 1, "buying": 1})
|
||||
|
||||
item_price = frappe.db.get_value(
|
||||
"Item Price", {"price_list": price_list, "item_code": "_Test Item"}, "name"
|
||||
)
|
||||
if item_price:
|
||||
frappe.db.set_value("Item Price", item_price, "price_list_rate", price_list_rate)
|
||||
else:
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Price",
|
||||
"price_list": price_list,
|
||||
"item_code": "_Test Item",
|
||||
"price_list_rate": price_list_rate,
|
||||
}
|
||||
).insert()
|
||||
|
||||
return price_list
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"party_item_code",
|
||||
"column_break_3",
|
||||
"qty",
|
||||
"price_list_rate",
|
||||
"base_price_list_rate",
|
||||
"rate",
|
||||
"base_rate",
|
||||
"ordered_qty",
|
||||
"section_break_7",
|
||||
"terms_and_conditions"
|
||||
@@ -41,11 +44,37 @@
|
||||
"in_list_view": 1,
|
||||
"label": "Quantity"
|
||||
},
|
||||
{
|
||||
"fieldname": "price_list_rate",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Price List Rate",
|
||||
"options": "currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "base_price_list_rate",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Price List Rate (Company Currency)",
|
||||
"options": "Company:company:default_currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "rate",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Rate",
|
||||
"options": "currency",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "base_rate",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Rate (Company Currency)",
|
||||
"options": "Company:company:default_currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@@ -74,7 +103,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:40.083042",
|
||||
"modified": "2026-08-27 10:55:37.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Blanket Order Item",
|
||||
@@ -85,4 +114,4 @@
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ class BlanketOrderItem(Document):
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
base_price_list_rate: DF.Currency
|
||||
base_rate: DF.Currency
|
||||
item_code: DF.Link
|
||||
item_name: DF.Data | None
|
||||
ordered_qty: DF.Float
|
||||
@@ -21,6 +23,7 @@ class BlanketOrderItem(Document):
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
party_item_code: DF.Data | None
|
||||
price_list_rate: DF.Currency
|
||||
qty: DF.Float
|
||||
rate: DF.Currency
|
||||
terms_and_conditions: DF.Text | None
|
||||
|
||||
@@ -699,7 +699,9 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
|
||||
get_bom_material_detail(doc, cdt, cdn, secondary_items);
|
||||
}
|
||||
|
||||
buying_price_list(doc) {
|
||||
buying_price_list() {
|
||||
const doc = this.frm.doc;
|
||||
|
||||
if (doc.rm_cost_as_per !== "Price List" && doc.buying_price_list) {
|
||||
this.frm.set_value("buying_price_list", "");
|
||||
return;
|
||||
@@ -710,8 +712,8 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
|
||||
}
|
||||
}
|
||||
|
||||
plc_conversion_rate(doc) {
|
||||
if (!this.in_apply_price_list && doc.rm_cost_as_per === "Price List") {
|
||||
plc_conversion_rate() {
|
||||
if (!this.in_apply_price_list && this.frm.doc.rm_cost_as_per === "Price List") {
|
||||
this.apply_price_list(null, true);
|
||||
}
|
||||
}
|
||||
@@ -767,6 +769,7 @@ var get_bom_material_detail = function (doc, cdt, cdn, secondary_items) {
|
||||
conversion_factor: d.conversion_factor,
|
||||
sourced_by_supplier: d.sourced_by_supplier,
|
||||
do_not_explode: d.do_not_explode,
|
||||
source_warehouse: d.source_warehouse || doc.default_source_warehouse,
|
||||
fetch_rate: !secondary_items,
|
||||
},
|
||||
callback: function (r) {
|
||||
@@ -777,6 +780,10 @@ var get_bom_material_detail = function (doc, cdt, cdn, secondary_items) {
|
||||
doc = locals[doc.doctype][doc.name];
|
||||
erpnext.bom.calculate_rm_cost(doc);
|
||||
erpnext.bom.calculate_total(doc);
|
||||
|
||||
if (secondary_items && d.valuation_type === "Valuation Rate") {
|
||||
erpnext.bom.fetch_secondary_item_cost(doc, cdt, cdn);
|
||||
}
|
||||
},
|
||||
freeze: true,
|
||||
});
|
||||
@@ -790,11 +797,10 @@ cur_frm.cscript.qty = function (doc) {
|
||||
|
||||
cur_frm.cscript.rate = function (doc, cdt, cdn) {
|
||||
var d = locals[cdt][cdn];
|
||||
const is_secondary_item = cdt == "BOM Secondary Item";
|
||||
|
||||
if (d.bom_no) {
|
||||
frappe.msgprint(__("You cannot change the rate if BOM is mentioned against any Item."));
|
||||
get_bom_material_detail(doc, cdt, cdn, is_secondary_item);
|
||||
get_bom_material_detail(doc, cdt, cdn, false);
|
||||
} else {
|
||||
erpnext.bom.calculate_rm_cost(doc);
|
||||
erpnext.bom.calculate_total(doc);
|
||||
@@ -957,6 +963,9 @@ frappe.ui.form.on("BOM Item", {
|
||||
do_not_explode: function (frm, cdt, cdn) {
|
||||
get_bom_material_detail(frm.doc, cdt, cdn, false);
|
||||
},
|
||||
source_warehouse: function (frm, cdt, cdn) {
|
||||
get_bom_material_detail(frm.doc, cdt, cdn, false);
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("BOM Item", "qty", function (frm, cdt, cdn) {
|
||||
@@ -1029,11 +1038,48 @@ frappe.tour["BOM"] = [
|
||||
];
|
||||
|
||||
frappe.ui.form.on("BOM Secondary Item", {
|
||||
item_code(frm, cdt, cdn) {
|
||||
const { item_code } = locals[cdt][cdn];
|
||||
valuation_type(frm, cdt, cdn) {
|
||||
const row = locals[cdt][cdn];
|
||||
if (row.valuation_type !== "% of FG Cost") {
|
||||
frappe.model.set_value(cdt, cdn, "cost_allocation_per", 0);
|
||||
}
|
||||
if (row.valuation_type === "Valuation Rate") {
|
||||
erpnext.bom.fetch_secondary_item_cost(frm.doc, cdt, cdn);
|
||||
} else if (row.valuation_type !== "Manual") {
|
||||
frappe.model.set_value(cdt, cdn, { cost: 0, base_cost: 0 });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
erpnext.bom.fetch_secondary_item_cost = function (doc, cdt, cdn) {
|
||||
const row = locals[cdt][cdn];
|
||||
if (!row.item_code) return;
|
||||
|
||||
frappe.call({
|
||||
doc: doc,
|
||||
method: "get_bom_material_detail",
|
||||
args: {
|
||||
company: doc.company,
|
||||
item_code: row.item_code,
|
||||
uom: row.uom,
|
||||
stock_uom: row.stock_uom,
|
||||
conversion_factor: row.conversion_factor,
|
||||
warehouse: doc.default_target_warehouse,
|
||||
set_rate_based_on_warehouse: 1,
|
||||
force_valuation_rate: 1,
|
||||
fetch_rate: 1,
|
||||
bom_no: "",
|
||||
},
|
||||
callback(r) {
|
||||
const cost = flt(r.message.rate) * flt(row.stock_qty);
|
||||
frappe.model.set_value(cdt, cdn, {
|
||||
cost: cost,
|
||||
base_cost: cost * flt(doc.conversion_rate || 1),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
function trigger_process_loss_qty_prompt(frm, cdt, cdn, item_code) {
|
||||
frappe.prompt(
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user