mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-04 00:50:18 +00:00
Compare commits
1 Commits
develop
...
l10n_crowd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c7c0161fb |
32
.github/helper/install.sh
vendored
32
.github/helper/install.sh
vendored
@@ -4,36 +4,6 @@ set -e
|
||||
|
||||
cd ~ || exit
|
||||
|
||||
# Authenticate git against github.com with the job token: anonymous git-over-HTTPS from the
|
||||
# runners gets throttled to a 401, which kills whichever clone is in flight — the frappe fetch
|
||||
# below, or payments under `bench get-app`. See the PR description.
|
||||
#
|
||||
# A credential helper rather than a url.insteadOf rewrite, because `git clone` PERSISTS a
|
||||
# rewritten URL into the new repo's .git/config: an insteadOf would leave the token sitting in
|
||||
# apps/payments/.git/config on the runner. A helper is consulted only when github.com actually
|
||||
# challenges, and leaves the stored remote URL untouched. Passing it through GIT_CONFIG_* keeps
|
||||
# the token out of ~/.gitconfig too, and child processes inherit it (bench shells out to git).
|
||||
ci_github_token=${CI_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}
|
||||
if [ -n "$ci_github_token" ]; then
|
||||
export CI_GITHUB_TOKEN="$ci_github_token"
|
||||
export GIT_CONFIG_COUNT=3
|
||||
# Reset first: git runs EVERY configured helper and calls `store` on them after a successful
|
||||
# auth, so a `credential.helper=store` inherited from the image's gitconfig would write the
|
||||
# token to ~/.git-credentials. An empty value clears the list before ours is added.
|
||||
export GIT_CONFIG_KEY_0="credential.helper"
|
||||
export GIT_CONFIG_VALUE_0=""
|
||||
export GIT_CONFIG_KEY_1="credential.https://github.com.username"
|
||||
export GIT_CONFIG_VALUE_1="x-access-token"
|
||||
export GIT_CONFIG_KEY_2="credential.https://github.com.helper"
|
||||
# Single-quoted: $CI_GITHUB_TOKEN is expanded by the shell git runs the helper in, so the
|
||||
# token is read from the environment at call time and never stored anywhere. Answering only
|
||||
# `get` makes the helper inert for git's `store`/`erase` calls.
|
||||
export GIT_CONFIG_VALUE_2='!f() { test "$1" = get && echo "password=$CI_GITHUB_TOKEN"; }; f'
|
||||
fi
|
||||
|
||||
# Whatever happens, never sit on a credential prompt: fail fast and legibly instead.
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
|
||||
frappeuser=${FRAPPE_USER:-"frappe"}
|
||||
frappecommitish=${FRAPPE_BRANCH:-}
|
||||
@@ -218,7 +188,7 @@ restore_warm_bench() {
|
||||
# Phase 1 already fetched ~/frappe to the exact live develop SHA. Fetch that commit
|
||||
# straight from it (bench init names the remote 'upstream', not 'origin', and points
|
||||
# it at this local clone — so a plain `git fetch origin` does not work).
|
||||
git fetch --no-tags --update-shallow "$HOME/frappe" HEAD || exit 1
|
||||
git fetch --no-tags "$HOME/frappe" HEAD || exit 1
|
||||
git checkout --force FETCH_HEAD || exit 1
|
||||
); then
|
||||
echo "Fast-forward to ${frappe_sha} failed; falling back to full init"
|
||||
|
||||
2
.github/workflows/patch.yml
vendored
2
.github/workflows/patch.yml
vendored
@@ -121,8 +121,6 @@ jobs:
|
||||
env:
|
||||
DB: mariadb
|
||||
TYPE: server
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Run Patch Tests
|
||||
run: |
|
||||
|
||||
2
.github/workflows/run-individual-tests.yml
vendored
2
.github/workflows/run-individual-tests.yml
vendored
@@ -129,8 +129,6 @@ jobs:
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
|
||||
2
.github/workflows/server-tests-mariadb.yml
vendored
2
.github/workflows/server-tests-mariadb.yml
vendored
@@ -102,8 +102,6 @@ jobs:
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_USER_HOST: '%'
|
||||
WKHTMLTOX_DEB: /tmp/wkhtmltox.deb
|
||||
|
||||
2
.github/workflows/server-tests-postgres.yml
vendored
2
.github/workflows/server-tests-postgres.yml
vendored
@@ -103,8 +103,6 @@ jobs:
|
||||
DB: postgres
|
||||
TYPE: server
|
||||
FRAPPE_BRANCH: develop
|
||||
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
|
||||
CI_GITHUB_TOKEN: ${{ github.token }}
|
||||
BENCH_CACHE_DIR: /home/runner/bench-cache
|
||||
|
||||
- name: Warm up test data
|
||||
|
||||
@@ -1489,10 +1489,10 @@ balanced-match@^4.0.2:
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
|
||||
integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
|
||||
|
||||
baseline-browser-mapping@^2.11.12:
|
||||
version "2.11.20"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz#26078c7a4b08299656ea7ddceaebec955dc44303"
|
||||
integrity sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==
|
||||
baseline-browser-mapping@^2.10.38:
|
||||
version "2.10.40"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz#f372c8eb36ff4ad0b5e7ae467014abef124554ba"
|
||||
integrity sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==
|
||||
|
||||
brace-expansion@^5.0.5:
|
||||
version "5.0.7"
|
||||
@@ -1509,15 +1509,15 @@ brace-expansion@^5.0.8:
|
||||
balanced-match "^4.0.2"
|
||||
|
||||
browserslist@^4.24.0:
|
||||
version "4.28.8"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.8.tgz#a3c79ceb70028527e5da7dafc887f3200b5168c0"
|
||||
integrity sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==
|
||||
version "4.28.4"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.4.tgz#dd8b8167a32845ff5f8cd6ce13f5abba16cd04c9"
|
||||
integrity sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==
|
||||
dependencies:
|
||||
baseline-browser-mapping "^2.11.12"
|
||||
caniuse-lite "^1.0.30001809"
|
||||
electron-to-chromium "^1.5.402"
|
||||
node-releases "^2.0.53"
|
||||
update-browserslist-db "^1.3.0"
|
||||
baseline-browser-mapping "^2.10.38"
|
||||
caniuse-lite "^1.0.30001799"
|
||||
electron-to-chromium "^1.5.376"
|
||||
node-releases "^2.0.48"
|
||||
update-browserslist-db "^1.2.3"
|
||||
|
||||
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
|
||||
version "1.0.2"
|
||||
@@ -1527,10 +1527,10 @@ call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
|
||||
es-errors "^1.3.0"
|
||||
function-bind "^1.1.2"
|
||||
|
||||
caniuse-lite@^1.0.30001809:
|
||||
version "1.0.30001810"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz#4970b477dea3278374de9bc43aa8f5d39fc3cda2"
|
||||
integrity sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==
|
||||
caniuse-lite@^1.0.30001799:
|
||||
version "1.0.30001800"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz#b896c773e1c39400809415162bb5320371291b36"
|
||||
integrity sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -1697,10 +1697,10 @@ dunder-proto@^1.0.1:
|
||||
es-errors "^1.3.0"
|
||||
gopd "^1.2.0"
|
||||
|
||||
electron-to-chromium@^1.5.402:
|
||||
version "1.5.420"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz#fc66d26a722d6f227e2092acdf38dd55b198cb44"
|
||||
integrity sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==
|
||||
electron-to-chromium@^1.5.376:
|
||||
version "1.5.383"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.383.tgz#5bd22306497d454103b289b0fef97260c56d0855"
|
||||
integrity sha512-I2484/KkAvl8lm9VyjH2JnbOIV0d/UCqT7gbzs6l+o6Vmn9wgB66uVcKX+Vk6HrXtY6fbWTOEXuv8waDTuFNCw==
|
||||
|
||||
engine.io-client@~6.5.1:
|
||||
version "6.5.4"
|
||||
@@ -3012,10 +3012,10 @@ natural-compare@^1.4.0:
|
||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
||||
|
||||
node-releases@^2.0.53:
|
||||
version "2.0.54"
|
||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.54.tgz#09af17d5647aa9f221ec5cf2becb95b68a981afe"
|
||||
integrity sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==
|
||||
node-releases@^2.0.48:
|
||||
version "2.0.50"
|
||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.50.tgz#597197a852071ce42fc2550e58e223242bcba969"
|
||||
integrity sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==
|
||||
|
||||
object-assign@^4.1.1:
|
||||
version "4.1.1"
|
||||
@@ -3589,10 +3589,10 @@ unist-util-visit@^5.0.0:
|
||||
unist-util-is "^6.0.0"
|
||||
unist-util-visit-parents "^6.0.0"
|
||||
|
||||
update-browserslist-db@^1.3.0:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz#9d99fbff56c50bb11ba5fd35cece5916da595836"
|
||||
integrity sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==
|
||||
update-browserslist-db@^1.2.3:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d"
|
||||
integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==
|
||||
dependencies:
|
||||
escalade "^3.2.0"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
@@ -122,7 +122,6 @@
|
||||
"description": "Setting Account Type helps in selecting this Account in transactions.",
|
||||
"fieldname": "account_type",
|
||||
"fieldtype": "Select",
|
||||
"in_preview": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Account Type",
|
||||
"oldfieldname": "account_type",
|
||||
@@ -204,7 +203,7 @@
|
||||
"idx": 1,
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
"modified": "2026-09-03 12:59:42.190900",
|
||||
"modified": "2026-08-21 23:11:37.851001",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Account",
|
||||
|
||||
@@ -52,42 +52,6 @@ frappe.treeview_settings["Account"] = {
|
||||
],
|
||||
root_label: "Accounts",
|
||||
get_tree_nodes: "erpnext.accounts.utils.get_children",
|
||||
get_label: function (node) {
|
||||
// clean display name — the account number renders as a badge (see
|
||||
// onrender) instead of being glued into the name
|
||||
return frappe.utils.escape_html(node.data.account_name || node.title || node.label);
|
||||
},
|
||||
onrender: function (node) {
|
||||
if (node.is_root || !node.data) return;
|
||||
|
||||
const flags = [];
|
||||
if (node.data.account_number) {
|
||||
flags.push(frappe.ui.badge({ label: node.data.account_number }));
|
||||
}
|
||||
|
||||
const company = frappe.treeview_settings["Account"].treeview?.page?.fields_dict?.company?.get_value();
|
||||
const company_currency = company && erpnext.get_currency(company);
|
||||
if (
|
||||
node.data.account_currency &&
|
||||
company_currency &&
|
||||
node.data.account_currency !== company_currency
|
||||
) {
|
||||
flags.push(frappe.ui.badge({ label: node.data.account_currency, theme: "blue" }));
|
||||
}
|
||||
|
||||
if (node.data.freeze_account === "Yes") {
|
||||
flags.push(
|
||||
frappe.ui.badge({
|
||||
label: __("Frozen"),
|
||||
icon: "lock",
|
||||
title: __("Frozen - entries restricted"),
|
||||
theme: "orange",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
erpnext.utils.render_tree_node_flags(node, flags);
|
||||
},
|
||||
on_node_render: function (node, deep) {
|
||||
const render_balances = () => {
|
||||
for (let account of cur_tree.account_balance_data) {
|
||||
@@ -268,7 +232,7 @@ frappe.treeview_settings["Account"] = {
|
||||
frappe.treeview_settings["Account"].treeview["tree"] = treeview.tree;
|
||||
if (treeview.can_create) {
|
||||
treeview.page.set_primary_action(
|
||||
{ label: __("Add Account"), short_label: __("Add") },
|
||||
__("New"),
|
||||
function () {
|
||||
let root_company = treeview.page.fields_dict.root_company.get_value();
|
||||
if (root_company) {
|
||||
@@ -279,14 +243,13 @@ frappe.treeview_settings["Account"] = {
|
||||
treeview.new_node();
|
||||
}
|
||||
},
|
||||
"plus"
|
||||
"add"
|
||||
);
|
||||
}
|
||||
},
|
||||
toolbar: [
|
||||
{
|
||||
label: __("Add Child"),
|
||||
icon: "plus",
|
||||
condition: function (node) {
|
||||
return (
|
||||
frappe.boot.user.can_create.indexOf("Account") !== -1 &&
|
||||
@@ -309,7 +272,6 @@ frappe.treeview_settings["Account"] = {
|
||||
return !node.root && frappe.boot.user.can_read.indexOf("GL Entry") !== -1;
|
||||
},
|
||||
label: __("View Ledger"),
|
||||
icon: "book-open",
|
||||
click: function (node, btn) {
|
||||
frappe.route_options = {
|
||||
from_date: erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[1],
|
||||
@@ -324,106 +286,6 @@ frappe.treeview_settings["Account"] = {
|
||||
},
|
||||
btnClass: "hidden-xs",
|
||||
},
|
||||
{
|
||||
// same label and mechanism as the Account form's Actions button:
|
||||
// NOT frappe's generic rename (Allow Rename stays off) — this is
|
||||
// ERPNext's controlled update that rebuilds the derived
|
||||
// "number - name - abbr" document name
|
||||
label: __("Update Account Name / Number"),
|
||||
icon: "text-cursor-input",
|
||||
condition: function (node) {
|
||||
return !node.is_root && frappe.model.can_write("Account");
|
||||
},
|
||||
click: function (node) {
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Update Account Number / Name"),
|
||||
fields: [
|
||||
{
|
||||
fieldtype: "Data",
|
||||
fieldname: "account_name",
|
||||
label: __("Account Name"),
|
||||
reqd: 1,
|
||||
default: node.data.account_name,
|
||||
},
|
||||
{
|
||||
fieldtype: "Data",
|
||||
fieldname: "account_number",
|
||||
label: __("Account Number"),
|
||||
default: node.data.account_number,
|
||||
},
|
||||
],
|
||||
primary_action_label: __("Update"),
|
||||
primary_action(values) {
|
||||
dialog.hide();
|
||||
frappe.dom.freeze(__("Updating {0}", [node.label]));
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.account.account.update_account_number",
|
||||
args: {
|
||||
name: node.label,
|
||||
account_name: values.account_name,
|
||||
account_number: values.account_number,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.exc) return;
|
||||
const treeview = frappe.views.trees["Account"];
|
||||
node.parent_node && treeview.tree.load_children(node.parent_node);
|
||||
},
|
||||
always: function () {
|
||||
frappe.dom.unfreeze();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
dialog.show();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __("Convert to Group"),
|
||||
icon: "folder-tree",
|
||||
condition: function (node) {
|
||||
return !node.is_root && !node.expandable && frappe.model.can_write("Account");
|
||||
},
|
||||
click: function (node) {
|
||||
erpnext.accounts.convert_tree_node("Account", node, "convert_ledger_to_group");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __("Convert to Non-Group"),
|
||||
icon: "file-text",
|
||||
condition: function (node) {
|
||||
// only on groups the user has opened and found empty — a
|
||||
// group with children can't convert, so don't offer it
|
||||
return (
|
||||
!node.is_root &&
|
||||
node.expandable &&
|
||||
node.loaded &&
|
||||
!node.$ul.children().length &&
|
||||
frappe.model.can_write("Account")
|
||||
);
|
||||
},
|
||||
click: function (node) {
|
||||
erpnext.accounts.convert_tree_node("Account", node, "convert_group_to_ledger");
|
||||
},
|
||||
},
|
||||
],
|
||||
extend_toolbar: true,
|
||||
};
|
||||
|
||||
frappe.provide("erpnext.accounts");
|
||||
// shared by the Account and Cost Center tree views (defined in both files,
|
||||
// whichever loads first wins): run the doctype's whitelisted convert method,
|
||||
// then re-render the branch so the node's group/leaf state updates
|
||||
erpnext.accounts.convert_tree_node =
|
||||
erpnext.accounts.convert_tree_node ||
|
||||
function (doctype, node, method) {
|
||||
frappe.call({
|
||||
method: "run_doc_method",
|
||||
args: { dt: doctype, dn: node.label, method: method },
|
||||
callback: function (r) {
|
||||
if (r.exc) return;
|
||||
const treeview = frappe.views.trees[doctype];
|
||||
node.parent_node && treeview.tree.load_children(node.parent_node);
|
||||
frappe.show_alert({ message: __("{0} converted", [node.label]), indicator: "green" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -729,7 +729,6 @@ def get_ordered_amount(params):
|
||||
(child.item_code == item_code)
|
||||
& (parent.docstatus == 1)
|
||||
& (child.amount > child.billed_amt)
|
||||
& (child.closed == 0)
|
||||
& (parent.status != "Closed")
|
||||
& Criterion.all(get_other_condition(params, child, parent, "Purchase Order"))
|
||||
)
|
||||
|
||||
@@ -16,8 +16,6 @@ frappe.ui.form.on("Chart of Accounts Importer", {
|
||||
() => generate_tree_preview(frm),
|
||||
() => create_import_button(frm),
|
||||
() => frm.set_df_property("chart_preview", "hidden", 0),
|
||||
// the preview is the point of this page — open it right away
|
||||
() => frm.fields_dict.chart_preview.collapse(false),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -130,6 +128,7 @@ var create_import_button = function (frm) {
|
||||
freeze_message: __("Creating Accounts..."),
|
||||
callback: function (r) {
|
||||
if (!r.exc) {
|
||||
clearInterval(frm.page["interval"]);
|
||||
frm.page.set_indicator(__("Import Successful"), "blue");
|
||||
create_reset_button(frm);
|
||||
}
|
||||
@@ -143,95 +142,42 @@ var create_reset_button = function (frm) {
|
||||
frm.page
|
||||
.set_primary_action(__("Reset"), function () {
|
||||
frm.page.clear_primary_action();
|
||||
delete frm.page["show_import_button"];
|
||||
frm.reload_doc();
|
||||
})
|
||||
.addClass("btn btn-primary");
|
||||
};
|
||||
|
||||
var generate_tree_preview = function (frm) {
|
||||
let parent = __("All Accounts");
|
||||
const wrapper = $(frm.fields_dict["chart_tree"].wrapper).empty(); // empty wrapper to load new data
|
||||
|
||||
// search + expand/collapse-all lean on frappe.ui.Tree helpers added with
|
||||
// row mode; when running against an older frappe that predates them, skip
|
||||
// this toolbar so the preview still renders (just without the extras)
|
||||
const has_row_helpers =
|
||||
typeof frappe.ui.Tree.prototype.get_expansion_state === "function" &&
|
||||
typeof frappe.ui.Tree.prototype.filter_nodes === "function";
|
||||
|
||||
let tree;
|
||||
let deep_loaded = false;
|
||||
let search_text = "";
|
||||
let update_buttons = () => {};
|
||||
|
||||
if (has_row_helpers) {
|
||||
// same toolbar anatomy as the tree view: search on the left,
|
||||
// expand/collapse-all on the right (three-state: fully collapsed ->
|
||||
// Expand All, fully expanded -> Collapse All, partially expanded -> both)
|
||||
const $toolbar = $('<div class="flex items-center gap-2 mb-2"></div>').appendTo(wrapper);
|
||||
|
||||
const search_control = frappe.ui.form.make_control({
|
||||
df: { fieldtype: "Data", fieldname: "preview_search", placeholder: __("Search") },
|
||||
parent: $toolbar,
|
||||
only_input: true,
|
||||
});
|
||||
search_control.refresh();
|
||||
$(search_control.wrapper).addClass("m-0").css("width", "220px");
|
||||
search_control.$input.addClass("input-xs");
|
||||
search_control.$input.on(
|
||||
"input",
|
||||
frappe.utils.debounce(() => {
|
||||
search_text = search_control.$input.val();
|
||||
const run = () => {
|
||||
// a newer keystroke superseded this one while the deep load ran
|
||||
if (search_text !== search_control.$input.val()) return;
|
||||
tree.filter_nodes(search_text);
|
||||
};
|
||||
if (!search_text || deep_loaded) {
|
||||
run();
|
||||
return;
|
||||
var validate_coa = function (frm) {
|
||||
if (frm.doc.import_file) {
|
||||
let parent = __("All Accounts");
|
||||
return frappe.call({
|
||||
method: "erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer.get_coa",
|
||||
args: {
|
||||
file_name: frm.doc.import_file,
|
||||
parent: parent,
|
||||
doctype: "Chart of Accounts Importer",
|
||||
file_type: frm.doc.file_type,
|
||||
for_validate: 1,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message["show_import_button"]) {
|
||||
frm.page["show_import_button"] = Boolean(r.message["show_import_button"]);
|
||||
}
|
||||
tree.load_children(tree.root_node, true).then(() => {
|
||||
deep_loaded = true;
|
||||
run();
|
||||
});
|
||||
}, 300)
|
||||
);
|
||||
|
||||
const $actions = $('<div class="ms-auto flex items-center gap-1"></div>').appendTo($toolbar);
|
||||
update_buttons = () => {
|
||||
const state = tree.get_expansion_state();
|
||||
$expand_all.prop("disabled", !(state === "collapsed" || state === "partial"));
|
||||
$collapse_all.prop("disabled", !(state === "expanded" || state === "partial"));
|
||||
};
|
||||
// tooltip on a wrapper: a disabled es-button has pointer-events:none,
|
||||
// so hover falls through to the wrapper and the tooltip still shows
|
||||
const make_action = (icon, label, onclick) => {
|
||||
const $btn = $(
|
||||
frappe.ui.button({ icon, disabled: true, onclick, attrs: { "aria-label": label } })
|
||||
);
|
||||
const $wrapper = $('<span class="inline-flex"></span>').append($btn).appendTo($actions);
|
||||
frappe.ui.tooltip($wrapper, { text: label });
|
||||
return $btn;
|
||||
};
|
||||
var $expand_all = make_action("chevrons-up-down", __("Expand All"), () => {
|
||||
tree.load_children(tree.root_node, true).then(() => {
|
||||
deep_loaded = true;
|
||||
});
|
||||
});
|
||||
var $collapse_all = make_action("chevrons-down-up", __("Collapse All"), () => {
|
||||
tree.load_children(tree.root_node, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var generate_tree_preview = function (frm) {
|
||||
let parent = __("All Accounts");
|
||||
$(frm.fields_dict["chart_tree"].wrapper).empty(); // empty wrapper to load new data
|
||||
|
||||
// generate tree structure based on the csv data
|
||||
tree = new frappe.ui.Tree({
|
||||
parent: wrapper,
|
||||
return new frappe.ui.Tree({
|
||||
parent: $(frm.fields_dict["chart_tree"].wrapper),
|
||||
label: parent,
|
||||
expandable: true,
|
||||
// read-only preview: row-mode visuals without actions or hover cards
|
||||
// (ignored by an older frappe, which renders the legacy tree)
|
||||
row_style: true,
|
||||
method: "erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer.get_coa",
|
||||
args: {
|
||||
file_name: frm.doc.import_file,
|
||||
@@ -239,9 +185,8 @@ var generate_tree_preview = function (frm) {
|
||||
doctype: "Chart of Accounts Importer",
|
||||
file_type: frm.doc.file_type,
|
||||
},
|
||||
on_node_render: () => update_buttons(),
|
||||
// expanded flips right after this callback — check on the next tick
|
||||
on_click: () => setTimeout(update_buttons, 0),
|
||||
onclick: function (node) {
|
||||
parent = node.value;
|
||||
},
|
||||
});
|
||||
return tree;
|
||||
};
|
||||
|
||||
@@ -12,19 +12,6 @@ frappe.treeview_settings["Cost Center"] = {
|
||||
],
|
||||
root_label: "Cost Centers",
|
||||
get_tree_nodes: "erpnext.accounts.utils.get_children",
|
||||
get_label: function (node) {
|
||||
// clean display name — the number renders as a badge (see onrender)
|
||||
return frappe.utils.escape_html(node.data.cost_center_name || node.title || node.label);
|
||||
},
|
||||
onrender: function (node) {
|
||||
if (node.is_root || !node.data) return;
|
||||
|
||||
const flags = [];
|
||||
if (node.data.cost_center_number) {
|
||||
flags.push(frappe.ui.badge({ label: node.data.cost_center_number }));
|
||||
}
|
||||
erpnext.utils.render_tree_node_flags(node, flags);
|
||||
},
|
||||
add_tree_node: "erpnext.accounts.utils.add_cc",
|
||||
menu_items: [
|
||||
{
|
||||
@@ -55,37 +42,6 @@ frappe.treeview_settings["Cost Center"] = {
|
||||
},
|
||||
],
|
||||
ignore_fields: ["parent_cost_center"],
|
||||
toolbar: [
|
||||
{
|
||||
label: __("Convert to Group"),
|
||||
icon: "folder-tree",
|
||||
condition: function (node) {
|
||||
return !node.is_root && !node.expandable && frappe.model.can_write("Cost Center");
|
||||
},
|
||||
click: function (node) {
|
||||
erpnext.accounts.convert_tree_node("Cost Center", node, "convert_ledger_to_group");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __("Convert to Non-Group"),
|
||||
icon: "file-text",
|
||||
condition: function (node) {
|
||||
// only on groups the user has opened and found empty — a
|
||||
// group with children can't convert, so don't offer it
|
||||
return (
|
||||
!node.is_root &&
|
||||
node.expandable &&
|
||||
node.loaded &&
|
||||
!node.$ul.children().length &&
|
||||
frappe.model.can_write("Cost Center")
|
||||
);
|
||||
},
|
||||
click: function (node) {
|
||||
erpnext.accounts.convert_tree_node("Cost Center", node, "convert_group_to_ledger");
|
||||
},
|
||||
},
|
||||
],
|
||||
extend_toolbar: true,
|
||||
onload: function (treeview) {
|
||||
function get_company() {
|
||||
return treeview.page.fields_dict.company.get_value();
|
||||
@@ -126,22 +82,3 @@ frappe.treeview_settings["Cost Center"] = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
frappe.provide("erpnext.accounts");
|
||||
// shared by the Account and Cost Center tree views (defined in both files,
|
||||
// whichever loads first wins): run the doctype's whitelisted convert method,
|
||||
// then re-render the branch so the node's group/leaf state updates
|
||||
erpnext.accounts.convert_tree_node =
|
||||
erpnext.accounts.convert_tree_node ||
|
||||
function (doctype, node, method) {
|
||||
frappe.call({
|
||||
method: "run_doc_method",
|
||||
args: { dt: doctype, dn: node.label, method: method },
|
||||
callback: function (r) {
|
||||
if (r.exc) return;
|
||||
const treeview = frappe.views.trees[doctype];
|
||||
node.parent_node && treeview.tree.load_children(node.parent_node);
|
||||
frappe.show_alert({ message: __("{0} converted", [node.label]), indicator: "green" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -32,7 +32,6 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_validat
|
||||
AccountFilterValidator,
|
||||
CalculationFormulaValidator,
|
||||
DependencyValidator,
|
||||
get_valid_api_method,
|
||||
)
|
||||
from erpnext.accounts.report.financial_statements import (
|
||||
get_columns,
|
||||
@@ -1183,12 +1182,10 @@ class RowProcessor:
|
||||
|
||||
def _process_api_row(self, row) -> RowData:
|
||||
api_path = row.calculation_formula
|
||||
|
||||
method = get_valid_api_method(api_path)
|
||||
# TODO
|
||||
|
||||
try:
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-codeinjection-eval
|
||||
values = frappe.call(method, filters=self.context.filters, periods=self.period_list, row=row)
|
||||
values = frappe.call(api_path, filters=self.context.filters, periods=self.period_list, row=row)
|
||||
|
||||
if row.reverse_sign:
|
||||
values = [-1 * v for v in values]
|
||||
|
||||
@@ -236,8 +236,6 @@ async function refresh_tree_view(dialog, account_rows) {
|
||||
parent: wrapper,
|
||||
label: company,
|
||||
root_value: company,
|
||||
// read-only preview: row-mode visuals without actions
|
||||
row_style: true,
|
||||
method: "erpnext.accounts.doctype.financial_report_template.financial_report_engine.get_children_accounts",
|
||||
args: { doctype: "Account", company: company, filtered_accounts: filtered_accounts, missed: missed },
|
||||
toolbar: [],
|
||||
@@ -372,7 +370,7 @@ function update_formula_description(frm, data_source) {
|
||||
description_html = `
|
||||
<div ${container_style}>
|
||||
<h5 ${title_style}>Custom API Setup</h5>
|
||||
<p ${text_style}>Path to your custom whitelisted method that returns financial data. It must permit GET requests.</p>
|
||||
<p ${text_style}>Path to your custom method that returns financial data.</p>
|
||||
|
||||
<h6 ${subtitle_style}>Format:</h6>
|
||||
<ul ${list_style}>
|
||||
@@ -382,8 +380,7 @@ function update_formula_description(frm, data_source) {
|
||||
|
||||
<h6 ${subtitle_style}>Method Signature:</h6>
|
||||
<div ${code_style}>
|
||||
<!-- 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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<h6 ${subtitle_style}>Return Format:</h6>
|
||||
|
||||
@@ -8,25 +8,10 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import frappe
|
||||
from frappe import _, is_whitelisted
|
||||
from frappe import _
|
||||
from frappe.database.operator_map import OPERATOR_MAP
|
||||
|
||||
|
||||
def get_valid_api_method(api_path: str):
|
||||
"""Resolve `api_path`, ensuring it is whitelisted and permits GET (i.e. read-only)."""
|
||||
method = frappe.get_attr(api_path)
|
||||
is_whitelisted(method)
|
||||
|
||||
if "GET" not in frappe.allowed_http_methods_for_whitelisted_func.get(method, ()):
|
||||
frappe.throw(
|
||||
_("Method {0} must permit GET requests").format(frappe.bold(api_path)),
|
||||
frappe.PermissionError,
|
||||
title=_("Method Not Allowed"),
|
||||
)
|
||||
|
||||
return method
|
||||
|
||||
|
||||
def get_formula_field_label(data_source: str) -> str:
|
||||
# Must mirror the `labels` map in financial_report_template.js (update_formula_label),
|
||||
labels = {
|
||||
@@ -546,23 +531,29 @@ class FormulaValidator(Validator):
|
||||
)
|
||||
return result
|
||||
|
||||
# Method exists?
|
||||
try:
|
||||
get_valid_api_method(api_path)
|
||||
module_path, method_name = api_path.rsplit(".", 1)
|
||||
module = frappe.get_module(module_path)
|
||||
|
||||
if not hasattr(module, method_name):
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_(
|
||||
"{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,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, frappe.PermissionError | frappe.ValidationError):
|
||||
# frappe.throw inside get_valid_api_method logs a message that would pop up in UI
|
||||
frappe.clear_last_message()
|
||||
|
||||
if isinstance(e, frappe.PermissionError):
|
||||
message = _("{0}: Method '{1}' must be whitelisted and permit GET requests").format(
|
||||
get_formula_field_label(row.data_source), api_path
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=_("Could not validate {0}: {1}").format(
|
||||
get_formula_field_label(row.data_source), str(e)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
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,12 +2,7 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import whitelist_for_tests
|
||||
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
|
||||
FormulaValidator,
|
||||
get_valid_api_method,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -77,90 +72,3 @@ class FinancialReportTemplateTestCase(ERPNextTestSuite):
|
||||
{"doctype": "Financial Report Template", "template_name": template_name, "rows": rows_data}
|
||||
)
|
||||
return template
|
||||
|
||||
|
||||
def not_whitelisted_method(**kwargs):
|
||||
return [42.0]
|
||||
|
||||
|
||||
@whitelist_for_tests(methods=["POST"])
|
||||
def whitelisted_post_only_method(**kwargs):
|
||||
return [42.0]
|
||||
|
||||
|
||||
@whitelist_for_tests(methods=["GET"])
|
||||
def whitelisted_get_method(**kwargs):
|
||||
return [42.0]
|
||||
|
||||
|
||||
class TestCustomAPIValidation(FinancialReportTemplateTestCase):
|
||||
"""Custom API rows must point to whitelisted methods that permit GET"""
|
||||
|
||||
TEST_MODULE = "erpnext.accounts.doctype.financial_report_template.test_financial_report_template"
|
||||
NOT_WHITELISTED = f"{TEST_MODULE}.not_whitelisted_method"
|
||||
WHITELISTED_POST_ONLY = f"{TEST_MODULE}.whitelisted_post_only_method"
|
||||
WHITELISTED_GET = f"{TEST_MODULE}.whitelisted_get_method"
|
||||
|
||||
def create_api_template(self, api_path):
|
||||
template = self.create_test_template_with_rows(
|
||||
[
|
||||
{
|
||||
"reference_code": "API001",
|
||||
"display_name": "API Row",
|
||||
"data_source": "Custom API",
|
||||
"calculation_formula": api_path,
|
||||
}
|
||||
]
|
||||
)
|
||||
template.report_type = "Profit and Loss Statement"
|
||||
return template
|
||||
|
||||
def test_get_valid_api_method(self):
|
||||
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.NOT_WHITELISTED)
|
||||
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.WHITELISTED_POST_ONLY)
|
||||
self.assertEqual(get_valid_api_method(self.WHITELISTED_GET), frappe.get_attr(self.WHITELISTED_GET))
|
||||
|
||||
def test_save_rejects_invalid_api_methods(self):
|
||||
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
|
||||
template = self.create_api_template(api_path)
|
||||
self.assertRaises(frappe.ValidationError, template.insert)
|
||||
|
||||
def test_save_allows_get_whitelisted_method(self):
|
||||
template = self.create_api_template(self.WHITELISTED_GET)
|
||||
template.insert()
|
||||
template.delete()
|
||||
|
||||
def test_engine_rejects_invalid_api_methods(self):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
ReportContext,
|
||||
RowProcessor,
|
||||
)
|
||||
|
||||
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
|
||||
template = self.create_api_template(api_path)
|
||||
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
|
||||
processor = RowProcessor(context)
|
||||
self.assertRaises(frappe.PermissionError, processor._process_api_row, template.rows[0])
|
||||
|
||||
def test_engine_calls_valid_api_method(self):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
ReportContext,
|
||||
RowProcessor,
|
||||
)
|
||||
|
||||
template = self.create_api_template(self.WHITELISTED_GET)
|
||||
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
|
||||
processor = RowProcessor(context)
|
||||
row_data = processor._process_api_row(template.rows[0])
|
||||
self.assertEqual(row_data.values, [42.0])
|
||||
|
||||
def test_validation_keeps_message_log_clean(self):
|
||||
validator = FormulaValidator(frappe._dict(rows=[]))
|
||||
message_count = len(frappe.local.message_log)
|
||||
|
||||
# last path raises AppNotInstalledError, which also logs a message via frappe.throw
|
||||
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY, "missing_app.api.method"):
|
||||
row = frappe._dict(data_source="Custom API", calculation_formula=api_path, idx=1)
|
||||
result = validator.validate(row)
|
||||
self.assertFalse(result.is_valid)
|
||||
self.assertEqual(len(frappe.local.message_log), message_count)
|
||||
|
||||
@@ -56,9 +56,7 @@ class LedgerMerge(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def form_start_merge(docname: str):
|
||||
lm_doc = frappe.get_doc("Ledger Merge", docname)
|
||||
lm_doc.check_permission("write")
|
||||
return lm_doc.start_merge()
|
||||
return frappe.get_doc("Ledger Merge", docname).start_merge()
|
||||
|
||||
|
||||
def start_merge(docname):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
from unittest.mock import patch
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import Sum
|
||||
@@ -196,7 +196,7 @@ class TestLoyaltyProgram(ERPNextTestSuite):
|
||||
for d in company_wise_info:
|
||||
self.assertTrue(d.get("loyalty_points"))
|
||||
|
||||
@patch("erpnext.accounts.doctype.loyalty_program.loyalty_program.get_loyalty_details")
|
||||
@unittest.mock.patch("erpnext.accounts.doctype.loyalty_program.loyalty_program.get_loyalty_details")
|
||||
def test_tier_selection(self, mock_get_loyalty_details):
|
||||
# Create a new loyalty program with multiple tiers
|
||||
loyalty_program = frappe.get_doc(
|
||||
|
||||
@@ -297,9 +297,6 @@ def start_import(invoices):
|
||||
invoice_number = d.invoice_number
|
||||
doc = frappe.get_doc(d)
|
||||
doc.flags.ignore_mandatory = True
|
||||
# the outstanding amount is entered inclusive of tax, so taxes must not
|
||||
# be added on top of it
|
||||
doc.flags.dont_auto_add_taxes = True
|
||||
doc.insert(set_name=invoice_number)
|
||||
doc.submit()
|
||||
if not frappe.in_test:
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
import frappe
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
|
||||
get_temporary_opening_account,
|
||||
)
|
||||
from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule
|
||||
from erpnext.projects.doctype.project.test_project import make_project
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -128,55 +126,6 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
|
||||
for invoice in invoices:
|
||||
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings",
|
||||
{"add_taxes_from_taxes_and_charges_template": 1, "add_taxes_from_item_tax_template": 0},
|
||||
)
|
||||
def test_opening_invoice_creation_without_taxes(self):
|
||||
company = "_Test Opening Invoice Company"
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Sales Taxes and Charges Template",
|
||||
"company": company,
|
||||
"title": "_Test Opening Invoice Tax",
|
||||
"taxes": [
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": create_account(
|
||||
account_name="_Test Opening Tax Account",
|
||||
parent_account="Duties and Taxes - _TOIC",
|
||||
account_type="Tax",
|
||||
company=company,
|
||||
),
|
||||
"description": "Test taxes",
|
||||
"rate": 9,
|
||||
}
|
||||
],
|
||||
}
|
||||
).insert()
|
||||
|
||||
# makes the template the default for the party, as it would be on a live site
|
||||
make_tax_rule(tax_type="Sales", company=company, sales_tax_template=template.name, save=1)
|
||||
|
||||
tool = self.make_invoices(company=company, return_doc=True)
|
||||
invoices = tool.make_invoices()
|
||||
self.assertEqual(len(invoices), 2)
|
||||
|
||||
# outstanding amount is entered inclusive of tax, so taxes must not be added on top of it
|
||||
for invoice in invoices:
|
||||
si = frappe.get_doc("Sales Invoice", invoice)
|
||||
self.assertFalse(si.taxes)
|
||||
self.assertEqual(si.grand_total, 200)
|
||||
self.assertEqual(si.outstanding_amount, 200)
|
||||
|
||||
# the same invoice created outside the tool keeps the default taxes,
|
||||
# since adding them there is the user's decision
|
||||
si = frappe.get_doc(tool.get_invoices()[0])
|
||||
si.flags.ignore_mandatory = True
|
||||
si.insert()
|
||||
self.assertTrue(si.taxes)
|
||||
self.assertEqual(si.grand_total, 218)
|
||||
|
||||
def test_opening_entry_project_linking(self):
|
||||
doc = self.make_invoices(
|
||||
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True
|
||||
|
||||
@@ -92,7 +92,6 @@ def get_supplier_query(doctype: str, txt: str, searchfield: str, start: int, pag
|
||||
@frappe.whitelist()
|
||||
def make_payment_records(name: str, supplier: str, mode_of_payment: str | None = None):
|
||||
doc = frappe.get_doc("Payment Order", name)
|
||||
doc.check_permission()
|
||||
make_journal_entry(doc, supplier, mode_of_payment)
|
||||
|
||||
|
||||
|
||||
@@ -1359,7 +1359,6 @@ def get_irequests_of_payment_request(doc: str | None = None) -> list:
|
||||
@frappe.whitelist()
|
||||
def get_available_payment_schedules(reference_doctype: str, reference_name: str):
|
||||
ref_doc = frappe.get_doc(reference_doctype, reference_name)
|
||||
ref_doc.check_permission()
|
||||
|
||||
if not hasattr(ref_doc, "payment_schedule") or not ref_doc.payment_schedule:
|
||||
return []
|
||||
|
||||
@@ -263,15 +263,12 @@ def get_cashiers(doctype: str, txt: str, searchfield: str, start: int, page_len:
|
||||
@frappe.whitelist()
|
||||
def get_invoices(start: str | datetime, end: str | datetime, pos_profile: str, user: str):
|
||||
invoice_doctype = frappe.db.get_single_value("POS Settings", "invoice_type")
|
||||
frappe.has_permission("POS Profile", doc=pos_profile, throw=True)
|
||||
|
||||
frappe.has_permission("Sales Invoice", throw=True)
|
||||
sales_inv_query = build_invoice_query("Sales Invoice", user, pos_profile, start, end)
|
||||
|
||||
query = sales_inv_query
|
||||
|
||||
if invoice_doctype == "POS Invoice":
|
||||
frappe.has_permission("POS Invoice", throw=True)
|
||||
pos_inv_query = build_invoice_query("POS Invoice", user, pos_profile, start, end)
|
||||
query = query + pos_inv_query
|
||||
|
||||
|
||||
@@ -12,9 +12,8 @@
|
||||
{
|
||||
"fieldname": "fieldname",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Fieldname",
|
||||
"read_only": 1
|
||||
"hidden": 1,
|
||||
"label": "Fieldname"
|
||||
},
|
||||
{
|
||||
"fieldname": "field",
|
||||
@@ -27,7 +26,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-31 20:41:12.000000",
|
||||
"modified": "2025-07-29 18:08:40.323579",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Search Fields",
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
function is_valid_invoice_field(df) {
|
||||
return frappe.model.no_value_type.indexOf(df.fieldtype) === -1 || df.fieldtype === "Button";
|
||||
}
|
||||
let search_fields_datatypes = [
|
||||
"Data",
|
||||
"Link",
|
||||
"Dynamic Link",
|
||||
"Long Text",
|
||||
"Select",
|
||||
"Small Text",
|
||||
"Text",
|
||||
"Text Editor",
|
||||
];
|
||||
let do_not_include_fields = [
|
||||
"naming_series",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"stock_uom",
|
||||
"asset_naming_series",
|
||||
"default_material_request_type",
|
||||
"valuation_method",
|
||||
"warranty_period",
|
||||
"weight_uom",
|
||||
"batch_number_series",
|
||||
"serial_no_series",
|
||||
"purchase_uom",
|
||||
"customs_tariff_number",
|
||||
"sales_uom",
|
||||
"deferred_revenue_account",
|
||||
"deferred_expense_account",
|
||||
"quality_inspection_template",
|
||||
"route",
|
||||
"slideshow",
|
||||
"website_image_alt",
|
||||
"thumbnail",
|
||||
"web_long_description",
|
||||
];
|
||||
|
||||
frappe.ui.form.on("POS Settings", {
|
||||
onload: function (frm) {
|
||||
@@ -11,97 +42,74 @@ frappe.ui.form.on("POS Settings", {
|
||||
frm.trigger("add_search_options");
|
||||
},
|
||||
|
||||
invoice_type: function (frm) {
|
||||
frm.trigger("get_invoice_fields");
|
||||
},
|
||||
|
||||
get_invoice_fields: function (frm) {
|
||||
const invoice_type = frm.doc.invoice_type;
|
||||
if (!invoice_type) return;
|
||||
|
||||
frappe.model.with_doctype(invoice_type, () => {
|
||||
// the invoice type can change again while the meta loads
|
||||
if (frm.doc.invoice_type !== invoice_type) return;
|
||||
|
||||
const fields = frappe.get_doc("DocType", invoice_type).fields.filter(is_valid_invoice_field);
|
||||
frappe.model.with_doctype("POS Invoice", () => {
|
||||
var fields = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
|
||||
if (
|
||||
frappe.model.no_value_type.indexOf(d.fieldtype) === -1 ||
|
||||
["Button"].includes(d.fieldtype)
|
||||
) {
|
||||
return { label: d.label + " (" + d.fieldtype + ")", value: d.fieldname };
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
frm.fields_dict.invoice_fields.grid.update_docfield_property(
|
||||
"fieldname",
|
||||
"options",
|
||||
[""].concat(
|
||||
fields.map((df) => {
|
||||
return { label: `${df.label} (${df.fieldtype})`, value: df.fieldname };
|
||||
})
|
||||
)
|
||||
[""].concat(fields)
|
||||
);
|
||||
|
||||
frm.trigger("validate_invoice_fields");
|
||||
});
|
||||
},
|
||||
|
||||
validate_invoice_fields: function (frm) {
|
||||
const valid_fieldnames = frappe
|
||||
.get_doc("DocType", frm.doc.invoice_type)
|
||||
.fields.filter(is_valid_invoice_field)
|
||||
.map((df) => df.fieldname);
|
||||
|
||||
const invalid_fields = (frm.doc.invoice_fields || [])
|
||||
.filter((row) => row.fieldname && !valid_fieldnames.includes(row.fieldname))
|
||||
.map((row) => `#${row.idx} ${row.fieldname}`);
|
||||
|
||||
if (!invalid_fields.length) return;
|
||||
|
||||
frappe.msgprint({
|
||||
title: __("Invalid POS Fields"),
|
||||
indicator: "orange",
|
||||
message: __("The following rows are not valid fields of {0} and have to be removed: {1}", [
|
||||
frm.doc.invoice_type.bold(),
|
||||
invalid_fields.join(", "),
|
||||
]),
|
||||
});
|
||||
},
|
||||
|
||||
add_search_options: function (frm) {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.pos_settings.pos_settings.get_pos_search_field_options",
|
||||
callback: ({ message }) => {
|
||||
const fields = message || [];
|
||||
frappe.model.with_doctype("Item", () => {
|
||||
var fields = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
|
||||
if (
|
||||
search_fields_datatypes.includes(d.fieldtype) &&
|
||||
!do_not_include_fields.includes(d.fieldname)
|
||||
) {
|
||||
return [d.label];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
frm.searchable_item_fields = Object.fromEntries(
|
||||
fields.map((df) => [df.option, df.fieldname])
|
||||
);
|
||||
|
||||
frm.fields_dict.pos_search_fields.grid.update_docfield_property(
|
||||
"field",
|
||||
"options",
|
||||
[""].concat(fields.map((df) => df.option))
|
||||
);
|
||||
},
|
||||
fields.unshift("");
|
||||
frm.fields_dict.pos_search_fields.grid.update_docfield_property("field", "options", fields);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("POS Search Fields", {
|
||||
field: function (frm, doctype, name) {
|
||||
const doc = frappe.get_doc(doctype, name);
|
||||
var doc = frappe.get_doc(doctype, name);
|
||||
var df = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
|
||||
if (doc.field == d.label && search_fields_datatypes.includes(d.fieldtype)) {
|
||||
return d;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})[0];
|
||||
|
||||
doc.fieldname = frm.searchable_item_fields?.[doc.field] || "";
|
||||
frm.refresh_field("pos_search_fields");
|
||||
doc.fieldname = df.fieldname;
|
||||
frm.refresh_field("fields");
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("POS Field", {
|
||||
fieldname: function (frm, doctype, name) {
|
||||
const doc = frappe.get_doc(doctype, name);
|
||||
const invoice_meta = frappe.get_doc("DocType", frm.doc.invoice_type);
|
||||
const df = invoice_meta?.fields.find((d) => d.fieldname === doc.fieldname);
|
||||
if (!df) return;
|
||||
var doc = frappe.get_doc(doctype, name);
|
||||
var df = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
|
||||
return doc.fieldname == d.fieldname ? d : null;
|
||||
})[0];
|
||||
|
||||
doc.label = df.label;
|
||||
doc.reqd = df.reqd;
|
||||
doc.options = df.options;
|
||||
doc.fieldtype = df.fieldtype;
|
||||
doc.default_value = df.default;
|
||||
frm.refresh_field("invoice_fields");
|
||||
frm.refresh_field("fields");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,46 +5,8 @@ from collections import Counter
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model import no_value_fields
|
||||
from frappe.model.document import Document
|
||||
|
||||
SEARCH_FIELD_TYPES = (
|
||||
"Data",
|
||||
"Link",
|
||||
"Dynamic Link",
|
||||
"Long Text",
|
||||
"Select",
|
||||
"Small Text",
|
||||
"Text",
|
||||
"Text Editor",
|
||||
)
|
||||
|
||||
# Item fields that are of a searchable fieldtype, but are not meaningful to search a POS item by
|
||||
DO_NOT_INCLUDE_FIELDS = (
|
||||
"naming_series",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"stock_uom",
|
||||
"asset_naming_series",
|
||||
"default_material_request_type",
|
||||
"valuation_method",
|
||||
"warranty_period",
|
||||
"weight_uom",
|
||||
"batch_number_series",
|
||||
"serial_no_series",
|
||||
"purchase_uom",
|
||||
"customs_tariff_number",
|
||||
"sales_uom",
|
||||
"deferred_revenue_account",
|
||||
"deferred_expense_account",
|
||||
"quality_inspection_template",
|
||||
"route",
|
||||
"slideshow",
|
||||
"website_image_alt",
|
||||
"thumbnail",
|
||||
"web_long_description",
|
||||
)
|
||||
|
||||
|
||||
class POSSettings(Document):
|
||||
# begin: auto-generated types
|
||||
@@ -70,10 +32,17 @@ class POSSettings(Document):
|
||||
if old_doc.invoice_type != self.invoice_type:
|
||||
self.validate_invoice_type()
|
||||
|
||||
self.validate_duplicate_invoice_fields()
|
||||
self.validate_invoice_fields()
|
||||
self.validate_duplicate_pos_search_fields()
|
||||
self.validate_pos_search_fields()
|
||||
|
||||
def validate_invoice_fields(self):
|
||||
invoice_fields = [field.fieldname for field in self.invoice_fields]
|
||||
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
|
||||
|
||||
if len(duplicate_invoice_fields):
|
||||
for field in duplicate_invoice_fields:
|
||||
frappe.throw(
|
||||
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
|
||||
)
|
||||
|
||||
def validate_invoice_type(self):
|
||||
pos_opening_entries_count = frappe.db.count(
|
||||
@@ -86,94 +55,3 @@ class POSSettings(Document):
|
||||
),
|
||||
title=_("Invoice Document Type Selection Error"),
|
||||
)
|
||||
|
||||
def validate_duplicate_invoice_fields(self):
|
||||
invoice_fields = [field.fieldname for field in self.invoice_fields]
|
||||
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
|
||||
|
||||
if len(duplicate_invoice_fields):
|
||||
for field in duplicate_invoice_fields:
|
||||
frappe.throw(
|
||||
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
|
||||
)
|
||||
|
||||
def validate_invoice_fields(self):
|
||||
if not self.invoice_type:
|
||||
return
|
||||
|
||||
meta = frappe.get_meta(self.invoice_type)
|
||||
|
||||
for field in self.invoice_fields:
|
||||
df = meta.get_field(field.fieldname)
|
||||
|
||||
if not df or not is_valid_invoice_field(df):
|
||||
frappe.throw(
|
||||
title=_("Invalid POS Field"),
|
||||
msg=_("Row #{0}: '{1}' is not a valid field of {2}.").format(
|
||||
field.idx, frappe.bold(field.fieldname or ""), frappe.bold(_(self.invoice_type))
|
||||
),
|
||||
)
|
||||
|
||||
# read only in the form, so keep them in sync with the invoice
|
||||
field.label = df.label
|
||||
field.fieldtype = df.fieldtype
|
||||
field.options = df.options
|
||||
|
||||
def validate_duplicate_pos_search_fields(self):
|
||||
fieldnames = [field.fieldname for field in self.pos_search_fields]
|
||||
duplicate_fieldnames = {key for key, value in Counter(fieldnames).items() if value > 1}
|
||||
|
||||
for fieldname in duplicate_fieldnames:
|
||||
frappe.throw(
|
||||
title=_("Duplicate POS Search Fields"),
|
||||
msg=_("'{0}' has been already added.").format(fieldname),
|
||||
)
|
||||
|
||||
def validate_pos_search_fields(self):
|
||||
searchable_fields = {df.fieldname: df for df in get_searchable_item_fields()}
|
||||
|
||||
for field in self.pos_search_fields:
|
||||
df = searchable_fields.get(field.fieldname)
|
||||
|
||||
if not df:
|
||||
frappe.throw(
|
||||
title=_("Invalid POS Search Field"),
|
||||
msg=_("Row #{0}: '{1}' cannot be used to search items.").format(
|
||||
field.idx, frappe.bold(field.fieldname or "")
|
||||
),
|
||||
)
|
||||
|
||||
if field.field != get_search_field_option(df):
|
||||
frappe.throw(
|
||||
title=_("Invalid POS Search Field"),
|
||||
msg=_("Row #{0}: '{1}' does not match {2}.").format(
|
||||
field.idx, frappe.bold(field.field or ""), frappe.bold(df.fieldname)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def is_valid_invoice_field(df):
|
||||
return df.fieldtype not in no_value_fields or df.fieldtype == "Button"
|
||||
|
||||
|
||||
def get_searchable_item_fields():
|
||||
return [
|
||||
df
|
||||
for df in frappe.get_meta("Item").fields
|
||||
if df.fieldtype in SEARCH_FIELD_TYPES and df.fieldname not in DO_NOT_INCLUDE_FIELDS
|
||||
]
|
||||
|
||||
|
||||
def get_search_field_option(df):
|
||||
# the fieldname keeps the option unique, two Item fields can share a label
|
||||
return f"{df.label} ({df.fieldname})"
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_pos_search_field_options():
|
||||
frappe.has_permission("POS Settings", throw=True)
|
||||
|
||||
return [
|
||||
{"option": get_search_field_option(df), "fieldname": df.fieldname}
|
||||
for df in get_searchable_item_fields()
|
||||
]
|
||||
|
||||
@@ -1,135 +1,8 @@
|
||||
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.patches.v16_0.append_fieldname_to_pos_search_fields import execute as append_fieldname
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestPOSSettings(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.settings = frappe.get_single("POS Settings")
|
||||
self.settings.invoice_fields = []
|
||||
self.settings.pos_search_fields = []
|
||||
|
||||
def assertInvalid(self, message):
|
||||
with self.assertRaises(frappe.ValidationError) as context:
|
||||
self.settings.save()
|
||||
|
||||
self.assertIn(message, str(context.exception))
|
||||
|
||||
def test_invoice_field_is_validated_against_invoice_type(self):
|
||||
# consolidated_invoice exists on POS Invoice only
|
||||
self.settings.invoice_type = "POS Invoice"
|
||||
self.settings.append("invoice_fields", {"fieldname": "consolidated_invoice"})
|
||||
self.settings.save()
|
||||
|
||||
self.settings.invoice_type = "Sales Invoice"
|
||||
self.assertInvalid("is not a valid field of")
|
||||
|
||||
def test_field_common_to_both_invoice_types_is_allowed(self):
|
||||
for invoice_type in ("POS Invoice", "Sales Invoice"):
|
||||
self.settings.invoice_type = invoice_type
|
||||
self.settings.invoice_fields = []
|
||||
self.settings.append("invoice_fields", {"fieldname": "po_no"})
|
||||
self.settings.save()
|
||||
|
||||
def test_unknown_invoice_field_is_not_allowed(self):
|
||||
self.settings.append("invoice_fields", {"fieldname": "not_a_field"})
|
||||
self.assertInvalid("is not a valid field of")
|
||||
|
||||
def test_layout_invoice_field_is_not_allowed(self):
|
||||
self.settings.append("invoice_fields", {"fieldname": "accounting_dimensions_section"})
|
||||
self.assertInvalid("is not a valid field of")
|
||||
|
||||
def test_invoice_field_properties_are_set_from_the_invoice(self):
|
||||
self.settings.append(
|
||||
"invoice_fields", {"fieldname": "customer", "label": "Tampered", "fieldtype": "Data"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
field = self.settings.invoice_fields[0]
|
||||
self.assertEqual(field.label, "Customer")
|
||||
self.assertEqual(field.fieldtype, "Link")
|
||||
self.assertEqual(field.options, "Customer")
|
||||
|
||||
def test_searchable_item_field_is_allowed(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
self.assertEqual(self.settings.pos_search_fields[0].fieldname, "description")
|
||||
|
||||
def test_excluded_search_field_is_not_allowed(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Item Name (item_name)", "fieldname": "item_name"}
|
||||
)
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_search_field_of_unsearchable_type_is_not_allowed(self):
|
||||
# maintain stock is a Check field
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Maintain Stock (is_stock_item)", "fieldname": "is_stock_item"}
|
||||
)
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_unknown_search_field_is_not_allowed(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Nope (not_an_item_field)", "fieldname": "not_an_item_field"}
|
||||
)
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_search_field_without_a_fieldname_is_not_allowed(self):
|
||||
# the form fills the fieldname in, it cannot be picked on its own
|
||||
self.settings.append("pos_search_fields", {"field": "Description (description)"})
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_search_field_option_must_match_its_fieldname(self):
|
||||
self.settings.append("pos_search_fields", {"field": "Brand (brand)", "fieldname": "description"})
|
||||
self.assertInvalid("does not match")
|
||||
|
||||
def test_bare_label_is_not_accepted_as_a_search_field(self):
|
||||
# the stored option carries the fieldname, the patch backfills older rows
|
||||
self.settings.append("pos_search_fields", {"field": "Description", "fieldname": "description"})
|
||||
self.assertInvalid("does not match")
|
||||
|
||||
def test_duplicate_search_fields_are_not_allowed(self):
|
||||
for _ in range(2):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
|
||||
self.assertInvalid("has been already added")
|
||||
|
||||
def test_patch_appends_the_fieldname_to_a_legacy_search_field(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
row = self.settings.pos_search_fields[0].name
|
||||
frappe.db.set_value("POS Search Fields", row, "field", "Description", update_modified=False)
|
||||
|
||||
append_fieldname()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
|
||||
|
||||
def test_patch_leaves_an_already_migrated_search_field_alone(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
append_fieldname()
|
||||
|
||||
row = self.settings.pos_search_fields[0].name
|
||||
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
|
||||
|
||||
def test_invoice_fields_are_skipped_when_no_invoice_type_is_selected(self):
|
||||
self.settings.invoice_type = None
|
||||
self.settings.append("invoice_fields", {"fieldname": "customer"})
|
||||
self.settings.save()
|
||||
|
||||
self.assertEqual(self.settings.invoice_fields[0].fieldname, "customer")
|
||||
pass
|
||||
|
||||
@@ -142,8 +142,6 @@ def start_pcv_processing(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def pause_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
@@ -159,8 +157,6 @@ def pause_pcv_processing(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="cancel", doc=docname, throw=True)
|
||||
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Cancelled").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
@@ -175,8 +171,6 @@ def cancel_pcv_processing(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def resume_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Running").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.controllers.accounts_controller import merge_taxes
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -53,11 +52,6 @@ def make_purchase_receipt(
|
||||
args = {}
|
||||
args = frappe.parse_json(args)
|
||||
|
||||
mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_invoice_item")
|
||||
|
||||
def received_and_mapped_qty(obj):
|
||||
return flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0))
|
||||
|
||||
def post_parent_process(source_parent, target_parent):
|
||||
remove_items_with_zero_qty(target_parent)
|
||||
set_missing_values(source_parent, target_parent)
|
||||
@@ -81,13 +75,15 @@ def make_purchase_receipt(
|
||||
or {}
|
||||
)
|
||||
|
||||
pending_qty = flt(obj.qty) - received_and_mapped_qty(obj)
|
||||
|
||||
target.qty = pending_qty - flt(returned_qty_map.get("qty"))
|
||||
target.received_qty = pending_qty
|
||||
target.stock_qty = (pending_qty - flt(returned_qty_map.get("qty"))) * flt(obj.conversion_factor)
|
||||
target.amount = pending_qty * flt(obj.rate)
|
||||
target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))
|
||||
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
|
||||
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))) * flt(
|
||||
obj.conversion_factor
|
||||
)
|
||||
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
|
||||
target.base_amount = (
|
||||
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
)
|
||||
|
||||
def select_item(d):
|
||||
filtered_items = args.get("filtered_children", [])
|
||||
@@ -117,8 +113,7 @@ def make_purchase_receipt(
|
||||
"wip_composite_asset": "wip_composite_asset",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: abs(received_and_mapped_qty(doc)) < abs(doc.qty)
|
||||
and select_item(doc),
|
||||
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
|
||||
},
|
||||
"Purchase Taxes and Charges": {
|
||||
"doctype": "Purchase Taxes and Charges",
|
||||
|
||||
@@ -235,9 +235,6 @@ class PurchaseInvoice(BuyingController):
|
||||
"overflow_type": "billing",
|
||||
}
|
||||
]
|
||||
self.closed_source_links = [
|
||||
("Purchase Invoice Item", "pr_detail", "Purchase Receipt Item", "Purchase Receipt")
|
||||
]
|
||||
|
||||
def onload(self):
|
||||
super().onload()
|
||||
|
||||
@@ -277,15 +277,6 @@ 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,
|
||||
@@ -294,7 +285,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": credit_in_transaction_currency,
|
||||
"credit_in_transaction_currency": item.net_amount,
|
||||
"project": entry.dimensions.project or item.project or doc.project,
|
||||
},
|
||||
item=item,
|
||||
|
||||
@@ -27,7 +27,6 @@ def start_payment_ledger_repost(docname: str | None = None):
|
||||
"""
|
||||
if docname:
|
||||
repost_doc = frappe.get_doc("Repost Payment Ledger", docname)
|
||||
repost_doc.check_permission("submit")
|
||||
if repost_doc.docstatus.is_submitted() and repost_doc.repost_status in ["Queued", "Failed"]:
|
||||
try:
|
||||
for entry in repost_doc.repost_vouchers:
|
||||
|
||||
@@ -278,9 +278,6 @@ class SalesInvoice(SellingController):
|
||||
"overflow_type": "billing",
|
||||
}
|
||||
]
|
||||
self.closed_source_links = [
|
||||
("Sales Invoice Item", "dn_detail", "Delivery Note Item", "Delivery Note")
|
||||
]
|
||||
|
||||
def set_indicator(self):
|
||||
"""Set indicator for portal"""
|
||||
|
||||
@@ -476,7 +476,7 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
|
||||
for payment_mode in doc.payments:
|
||||
if skip_change_gl_entries and payment_mode.account == doc.account_for_change_amount:
|
||||
payment_mode.base_amount -= flt(doc.base_change_amount)
|
||||
payment_mode.base_amount -= flt(doc.change_amount)
|
||||
|
||||
if payment_mode.base_amount:
|
||||
# POS, make payment entries
|
||||
|
||||
@@ -1583,35 +1583,6 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
|
||||
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
|
||||
|
||||
def test_pos_change_amount_multi_currency_gl_entry(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.services.gl_composer import SalesInvoiceGLComposer
|
||||
|
||||
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 0)
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.is_pos = 1
|
||||
si.currency = "USD"
|
||||
si.conversion_rate = 50
|
||||
si.party_account_currency = "USD"
|
||||
si.account_for_change_amount = "Cash - _TC"
|
||||
si.change_amount = 50
|
||||
si.base_change_amount = 2500
|
||||
si.append(
|
||||
"payments",
|
||||
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 150, "base_amount": 7500},
|
||||
)
|
||||
|
||||
gl_entries = []
|
||||
SalesInvoiceGLComposer(si).make_pos_gl_entries(gl_entries)
|
||||
|
||||
debtors_entry = next(entry for entry in gl_entries if entry["account"] == si.debit_to)
|
||||
cash_entry = next(entry for entry in gl_entries if entry["account"] == "Cash - _TC")
|
||||
|
||||
self.assertEqual(flt(debtors_entry["credit"]), 5000.0)
|
||||
self.assertEqual(flt(cash_entry["debit"]), 5000.0)
|
||||
|
||||
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
|
||||
|
||||
def test_stock_delivered_but_not_billed_gl_on_invoice(self):
|
||||
company = "_Test SDBNB Company"
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
@@ -9,7 +9,6 @@ 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):
|
||||
@@ -44,7 +43,6 @@ 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)
|
||||
|
||||
@@ -67,17 +65,14 @@ 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,9 +309,6 @@ 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)
|
||||
@@ -322,22 +319,6 @@ 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,13 +32,12 @@ 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._transacted_stock_qty: dict | None = None
|
||||
self._ordered_items: dict | None = None
|
||||
self._purchased_items: 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.mapper import (
|
||||
get_ordered_items as get_ordered_supplier_quotation_items,
|
||||
)
|
||||
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items
|
||||
from erpnext.selling.doctype.quotation.mapper import get_ordered_items
|
||||
|
||||
data = frappe.parse_json(trans_items)
|
||||
@@ -49,15 +48,11 @@ class ChildItemUpdater:
|
||||
self._check_permissions("write")
|
||||
|
||||
if self.parent_doctype == "Quotation":
|
||||
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
|
||||
)
|
||||
self._ordered_items = get_ordered_items(self.parent.name)
|
||||
items_added_or_removed |= validate_and_delete_children(self.parent, data, self._ordered_items)
|
||||
elif self.parent_doctype == "Supplier Quotation":
|
||||
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
|
||||
)
|
||||
self._purchased_items = get_purchased_items(self.parent.name)
|
||||
items_added_or_removed |= validate_and_delete_children(self.parent, data, self._purchased_items)
|
||||
else:
|
||||
items_added_or_removed |= validate_and_delete_children(self.parent, data)
|
||||
|
||||
@@ -76,7 +71,6 @@ 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
|
||||
@@ -84,13 +78,6 @@ 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")):
|
||||
@@ -264,22 +251,6 @@ 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(
|
||||
@@ -293,24 +264,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]
|
||||
old_stock_qty = flt(child_item.get(qty_field)) * old_conversion_factor
|
||||
if new_stock_qty < old_stock_qty:
|
||||
if flt(new_data.get("qty")) < flt(child_item.get(qty_field)):
|
||||
frappe.throw(
|
||||
_("Row #{0}:").format(new_data.get("idx")) + error_message,
|
||||
title=_("Invalid Qty"),
|
||||
)
|
||||
|
||||
if not self._transacted_stock_qty:
|
||||
if self.parent_doctype not in ("Quotation", "Supplier Quotation"):
|
||||
return
|
||||
|
||||
old_stock_qty = self._transacted_stock_qty.get(child_item.name)
|
||||
if not old_stock_qty:
|
||||
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:
|
||||
return
|
||||
|
||||
if not rate_unchanged:
|
||||
@@ -320,7 +291,7 @@ class ChildItemUpdater:
|
||||
).format(frappe.bold(new_data.get("item_code")))
|
||||
)
|
||||
|
||||
if new_stock_qty < old_stock_qty:
|
||||
if flt(new_data.get("qty")) < qty_to_check:
|
||||
frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity"))
|
||||
|
||||
def _validate_fg_item_for_subcontracting(self, new_data: dict, is_new: bool) -> None:
|
||||
@@ -487,11 +458,7 @@ 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]
|
||||
# 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")
|
||||
]
|
||||
deleted_children = [item for item in parent.items if item.name not in updated_item_names]
|
||||
|
||||
for d in deleted_children:
|
||||
validate_child_on_delete(d, parent, ordered_item)
|
||||
@@ -614,18 +581,22 @@ 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"))
|
||||
child_item.conversion_factor = flt(new_data.get("conversion_factor"), conv_fac_precision)
|
||||
|
||||
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")) or conversion_factor
|
||||
child_item.conversion_factor = (
|
||||
flt(new_data.get("conversion_factor"), conv_fac_precision) or conversion_factor
|
||||
)
|
||||
|
||||
if child_item.get("weight_per_unit"):
|
||||
child_item.total_weight = flt(
|
||||
|
||||
@@ -53,11 +53,6 @@ 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,7 +854,6 @@
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"filters": "{\"is_return\": 1}",
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
@@ -865,7 +864,8 @@
|
||||
"open_in_new_tab": 0,
|
||||
"route_options": "{\"is_return\": 1}",
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
"type": "Link",
|
||||
"filters": "{\"is_return\": 1}"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
@@ -931,7 +931,6 @@
|
||||
"added": 0,
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"filters": "{\"is_return\": 1}",
|
||||
"hidden": 0,
|
||||
"indent": 0,
|
||||
"is_default_module": 0,
|
||||
@@ -942,7 +941,8 @@
|
||||
"open_in_new_tab": 0,
|
||||
"route_options": "{\"is_return\": 1}",
|
||||
"show_arrow": 0,
|
||||
"type": "Link"
|
||||
"type": "Link",
|
||||
"filters": "{\"is_return\": 1}"
|
||||
},
|
||||
{
|
||||
"added": 0,
|
||||
@@ -1154,6 +1154,20 @@
|
||||
"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,
|
||||
@@ -1404,12 +1418,27 @@
|
||||
"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": 1,
|
||||
"open_in_new_tab": 0,
|
||||
"show_arrow": 0,
|
||||
"type": "Section Break"
|
||||
},
|
||||
@@ -1848,7 +1877,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-09-01 17:33:21.459367",
|
||||
"modified": "2026-08-28 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts",
|
||||
|
||||
@@ -1359,28 +1359,21 @@ 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"):
|
||||
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 frappe.db.has_column(doctype, "disabled") and not include_disabled:
|
||||
filters.append(["disabled", "=", False])
|
||||
|
||||
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])
|
||||
fields.append(parent_fieldname + " as 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"]
|
||||
|
||||
acc = frappe.get_list(doctype, fields=fields, filters=filters)
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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,7 +10,6 @@ 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
|
||||
@@ -35,14 +34,12 @@ 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) + flt(mapped_qty_by_item.get(obj.name, 0))
|
||||
received_qty = flt(obj.received_qty)
|
||||
qty = flt(obj.qty)
|
||||
pending_qty = qty - received_qty
|
||||
|
||||
@@ -87,13 +84,11 @@ def make_purchase_receipt(
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: (
|
||||
doc.name not in mapped_qty_by_item
|
||||
True
|
||||
if is_unit_price_row(doc)
|
||||
else abs(doc.received_qty) + abs(mapped_qty_by_item.get(doc.name, 0))
|
||||
< abs(get_max_receivable_qty(doc))
|
||||
else abs(doc.received_qty) < 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},
|
||||
@@ -153,13 +148,9 @@ 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):
|
||||
target.qty = flt(obj.qty) - get_billed_and_mapped_qty(obj.name)
|
||||
billed_qty = flt(get_billed_qty(obj.name))
|
||||
target.qty = flt(obj.qty) - billed_qty
|
||||
|
||||
item = get_item_defaults(target.item_code, source_parent.company)
|
||||
item_group = get_item_group_defaults(target.item_code, source_parent.company)
|
||||
@@ -202,8 +193,6 @@ 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,9 +14,7 @@ frappe.ui.form.on("Purchase Order", {
|
||||
setup: function (frm) {
|
||||
frm.set_indicator_formatter("item_code", function (doc) {
|
||||
let color;
|
||||
if (doc.closed) {
|
||||
color = "gray";
|
||||
} else if (!doc.qty && frm.doc.has_unit_price_items) {
|
||||
if (!doc.qty && frm.doc.has_unit_price_items) {
|
||||
color = "yellow";
|
||||
} else if (doc.qty <= doc.received_qty) {
|
||||
color = "green";
|
||||
@@ -342,7 +340,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") && !doc.items.every((item) => item.closed)) {
|
||||
if (this.frm.has_perm("submit")) {
|
||||
this.frm.add_custom_button(
|
||||
__("Re-open"),
|
||||
() => this.unclose_purchase_order(),
|
||||
@@ -354,7 +352,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
if (doc.status != "On Hold") {
|
||||
if (
|
||||
(doc.items
|
||||
.filter((item) => !item.delivered_by_supplier && !item.closed)
|
||||
.filter((item) => !item.delivered_by_supplier)
|
||||
.some((item) => item.received_qty < item.qty) ||
|
||||
doc.__onload?.has_pending_receivable_qty) &&
|
||||
allow_receipt
|
||||
@@ -367,11 +365,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
__("Create")
|
||||
);
|
||||
if (doc.is_subcontracted) {
|
||||
if (
|
||||
!doc.items
|
||||
.filter((item) => !item.closed)
|
||||
.every((item) => item.qty == item.subcontracted_qty)
|
||||
) {
|
||||
if (!doc.items.every((item) => item.qty == item.subcontracted_qty)) {
|
||||
this.frm.add_custom_button(
|
||||
__("Subcontracting Order"),
|
||||
() => {
|
||||
@@ -439,8 +433,6 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
} else if (doc.docstatus === 0) {
|
||||
this.frm.cscript.add_from_mappers();
|
||||
}
|
||||
|
||||
this.set_item_close_buttons();
|
||||
}
|
||||
|
||||
validate() {
|
||||
@@ -535,7 +527,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
},
|
||||
get_query_filters: {
|
||||
docstatus: 1,
|
||||
status: ["not in", ["Stopped", "Ordered", "Expired"]],
|
||||
status: ["not in", ["Stopped", "Expired"]],
|
||||
},
|
||||
allow_child_item_selection: true,
|
||||
child_fieldname: "items",
|
||||
@@ -705,19 +697,6 @@ 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,15 +178,7 @@ 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):
|
||||
@@ -258,7 +250,6 @@ class PurchaseOrder(BuyingController):
|
||||
["conversion_factor", "="],
|
||||
],
|
||||
"is_child_table": True,
|
||||
"allow_duplicate_prev_row_id": True,
|
||||
},
|
||||
"Material Request": {
|
||||
"ref_dn_field": "material_request",
|
||||
@@ -411,25 +402,6 @@ 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()
|
||||
|
||||
@@ -559,7 +531,7 @@ class PurchaseOrder(BuyingController):
|
||||
considering the configured over_delivery_receipt_allowance.
|
||||
"""
|
||||
for item in self.get("items", []):
|
||||
if item.delivered_by_supplier or item.closed:
|
||||
if item.delivered_by_supplier:
|
||||
continue
|
||||
tolerance = flt(get_allowance_for(item.item_code, qty_or_amount="qty")[0])
|
||||
max_receivable_qty = flt(item.qty) * (100 + tolerance) / 100
|
||||
|
||||
@@ -9,7 +9,6 @@ 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:
|
||||
@@ -19,10 +18,6 @@ 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()
|
||||
@@ -31,17 +26,6 @@ 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")
|
||||
@@ -55,9 +39,10 @@ class StatusService:
|
||||
def update_receiving_percentage(self) -> None:
|
||||
doc = self.doc
|
||||
total_qty, received_qty = 0.0, 0.0
|
||||
for item in [item for item in doc.items if not item.closed] or doc.items:
|
||||
for item in doc.items:
|
||||
received_qty += min(item.received_qty, item.qty)
|
||||
total_qty += item.qty
|
||||
|
||||
per_received = flt(received_qty / total_qty) * 100 if total_qty else 0
|
||||
doc.db_set("per_received", per_received, update_modified=False)
|
||||
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)
|
||||
|
||||
@@ -333,38 +333,6 @@ 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,7 +86,6 @@
|
||||
"returned_qty",
|
||||
"column_break_60",
|
||||
"billed_amt",
|
||||
"closed",
|
||||
"accounting_details",
|
||||
"expense_account",
|
||||
"column_break_fyqr",
|
||||
@@ -558,7 +557,6 @@
|
||||
"fieldname": "blanket_order_rate",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Blanket Order Rate",
|
||||
"options": "currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -647,15 +645,6 @@
|
||||
"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",
|
||||
@@ -955,7 +944,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-27 11:55:37.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order Item",
|
||||
|
||||
@@ -29,7 +29,6 @@ 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,8 +8,6 @@ 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(
|
||||
@@ -18,9 +16,6 @@ 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")
|
||||
@@ -28,9 +23,7 @@ def make_purchase_order(
|
||||
target.run_method("calculate_taxes_and_totals")
|
||||
|
||||
def update_item(obj, target, source_parent):
|
||||
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)
|
||||
target.stock_qty = flt(obj.qty) * flt(obj.conversion_factor)
|
||||
|
||||
def select_item(d):
|
||||
filtered_items = args.get("filtered_children", [])
|
||||
@@ -58,9 +51,7 @@ def make_purchase_order(
|
||||
["sales_order", "sales_order"],
|
||||
],
|
||||
"postprocess": update_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),
|
||||
"condition": select_item,
|
||||
},
|
||||
"Purchase Taxes and Charges": {
|
||||
"doctype": "Purchase Taxes and Charges",
|
||||
@@ -116,14 +107,3 @@ 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,10 +34,7 @@ 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 &&
|
||||
!["Stopped", "Ordered", "Expired"].includes(this.frm.doc.status)
|
||||
) {
|
||||
if (this.frm.doc.docstatus === 1) {
|
||||
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\nPartially Ordered\nOrdered\nStopped\nCancelled\nExpired",
|
||||
"options": "\nDraft\nSubmitted\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-30 16:30:00.000000",
|
||||
"modified": "2026-08-21 23:11:50.274992",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation",
|
||||
|
||||
@@ -6,13 +6,10 @@ 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"}
|
||||
|
||||
|
||||
@@ -89,9 +86,7 @@ class SupplierQuotation(BuyingController):
|
||||
shipping_address: DF.Link | None
|
||||
shipping_address_display: DF.TextEditor | None
|
||||
shipping_rule: DF.Link | None
|
||||
status: DF.Literal[
|
||||
"", "Draft", "Submitted", "Partially Ordered", "Ordered", "Stopped", "Cancelled", "Expired"
|
||||
]
|
||||
status: DF.Literal["", "Draft", "Submitted", "Stopped", "Cancelled", "Expired"]
|
||||
supplier: DF.Link
|
||||
supplier_address: DF.Link | None
|
||||
supplier_name: DF.Data | None
|
||||
@@ -117,17 +112,13 @@ 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", "Partially Ordered", "Ordered", "Stopped", "Cancelled", "Expired"],
|
||||
)
|
||||
validate_status(self.status, ["Draft", "Submitted", "Stopped", "Cancelled"])
|
||||
|
||||
validate_for_items(self)
|
||||
self.validate_with_previous_doc()
|
||||
@@ -135,11 +126,11 @@ class SupplierQuotation(BuyingController):
|
||||
self.validate_valid_till()
|
||||
|
||||
def on_submit(self):
|
||||
self.set_status(update=True)
|
||||
self.db_set("status", "Submitted")
|
||||
self.update_rfq_supplier_status(1)
|
||||
|
||||
def on_cancel(self):
|
||||
self.set_status(update=True)
|
||||
self.db_set("status", "Cancelled")
|
||||
self.update_rfq_supplier_status(0)
|
||||
|
||||
def on_trash(self):
|
||||
@@ -175,24 +166,6 @@ 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
|
||||
|
||||
@@ -270,29 +243,27 @@ def get_list_context(context=None):
|
||||
|
||||
|
||||
def set_expired_status():
|
||||
supplier_quotation = frappe.qb.DocType("Supplier Quotation")
|
||||
purchase_order = frappe.qb.DocType("Purchase Order")
|
||||
purchase_order_item = frappe.qb.DocType("Purchase Order Item")
|
||||
|
||||
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)
|
||||
)
|
||||
# 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,
|
||||
)
|
||||
|
||||
(
|
||||
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()
|
||||
|
||||
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,
|
||||
)
|
||||
).run()
|
||||
)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
frappe.listview_settings["Supplier Quotation"] = {
|
||||
add_fields: ["supplier", "base_grand_total", "status", "company", "currency"],
|
||||
get_indicator: function (doc) {
|
||||
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") {
|
||||
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,7 +15,6 @@ 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
|
||||
|
||||
@@ -24,23 +23,6 @@ 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)
|
||||
@@ -62,14 +44,6 @@ 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)
|
||||
@@ -80,24 +54,8 @@ 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]
|
||||
@@ -115,150 +73,6 @@ 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()
|
||||
@@ -313,52 +127,6 @@ 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,17 +230,6 @@
|
||||
"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",
|
||||
@@ -626,7 +615,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-31 12:00:00.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation Item",
|
||||
|
||||
@@ -44,7 +44,6 @@ 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,17 +96,6 @@ 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,7 +52,6 @@ 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(
|
||||
@@ -83,11 +82,6 @@ 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")
|
||||
|
||||
@@ -115,7 +109,6 @@ 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"),
|
||||
@@ -171,10 +164,6 @@ 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()
|
||||
@@ -276,12 +265,6 @@ 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,9 +2,7 @@
|
||||
# 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
|
||||
|
||||
@@ -40,15 +38,6 @@ 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], [])
|
||||
|
||||
@@ -94,34 +83,3 @@ 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",
|
||||
"creation": "2026-08-16 00:00:00.000000",
|
||||
"docstatus": 0,
|
||||
"doctype": "Sidebar",
|
||||
"header_icon": "shopping-cart",
|
||||
@@ -283,6 +283,97 @@
|
||||
"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,
|
||||
@@ -540,11 +631,12 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-09-01 17:37:29.971719",
|
||||
"modified": "2026-08-16 00:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Buying",
|
||||
"owner": "Administrator",
|
||||
"sequence_id": 0.0,
|
||||
"standard": 1,
|
||||
"title": "Buying"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"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,7 +39,6 @@ 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,
|
||||
@@ -228,23 +227,7 @@ 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()
|
||||
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
# 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"))
|
||||
)
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
# 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")
|
||||
|
||||
query = (
|
||||
blanket_orders = (
|
||||
frappe.qb.from_(bo)
|
||||
.from_(bo_item)
|
||||
.select(bo.name)
|
||||
@@ -814,12 +814,10 @@ def get_blanket_orders(doctype: str, txt: str, searchfield: str, start: int, pag
|
||||
& (bo.company == filters.get("company"))
|
||||
& (bo.docstatus == 1)
|
||||
)
|
||||
.run()
|
||||
)
|
||||
|
||||
if currency := filters.get("currency"):
|
||||
query = query.where(bo.currency == currency)
|
||||
|
||||
return query.run()
|
||||
return blanket_orders
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -8,8 +8,6 @@ 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
|
||||
@@ -42,14 +40,6 @@ 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],
|
||||
[
|
||||
@@ -202,60 +192,9 @@ 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"):
|
||||
@@ -545,7 +484,6 @@ 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":
|
||||
@@ -667,28 +605,16 @@ class StatusUpdater(Document):
|
||||
|
||||
@staticmethod
|
||||
def _calculate_target_parent_percentage(
|
||||
name,
|
||||
target_parent_dt,
|
||||
target_dt,
|
||||
target_ref_field,
|
||||
target_field,
|
||||
target_parent_field=None,
|
||||
exclude_field=None,
|
||||
name, target_parent_dt, target_dt, target_ref_field, target_field, 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=fields,
|
||||
fields=[target_ref_field, target_field],
|
||||
)
|
||||
|
||||
if exclude_field and not child_records:
|
||||
@@ -697,19 +623,13 @@ 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
|
||||
|
||||
# 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)
|
||||
sum_ref = sum(abs(record[ref_key]) for record in child_records)
|
||||
|
||||
if sum_ref > 0:
|
||||
percentage = round(
|
||||
sum(min(abs(record[target_field]), abs(record[ref_key])) for record in basis) / sum_ref * 100,
|
||||
sum(min(abs(record[target_field]), abs(record[ref_key])) for record in child_records)
|
||||
/ sum_ref
|
||||
* 100,
|
||||
6,
|
||||
)
|
||||
else:
|
||||
@@ -758,7 +678,6 @@ class StatusUpdater(Document):
|
||||
args["target_dt"],
|
||||
args["target_ref_field"],
|
||||
args["target_field"],
|
||||
args["target_parent_field"],
|
||||
args.get("exclude_field"),
|
||||
)
|
||||
# update field
|
||||
|
||||
@@ -337,12 +337,8 @@ 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", "closed"],
|
||||
fields=["name", "qty", "returned_qty", "rate", "amount", "billed_amt"],
|
||||
)
|
||||
# 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.
|
||||
|
||||
@@ -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("valuation_type"):
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
|
||||
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("valuation_type")
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item")
|
||||
)
|
||||
for item in self.items:
|
||||
if not item.get("secondary_item_type") and not item.get("valuation_type"):
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
|
||||
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("valuation_type")
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item")
|
||||
)
|
||||
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("valuation_type"):
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
|
||||
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("valuation_type"):
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
|
||||
item.additional_cost_per_qty = 0
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
|
||||
@@ -242,7 +243,7 @@ class SubcontractingInwardController:
|
||||
for item in self.get("items")
|
||||
if not item.is_finished_item
|
||||
and not item.secondary_item_type
|
||||
and not item.valuation_type
|
||||
and not item.is_legacy_scrap_item
|
||||
and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item")
|
||||
]
|
||||
|
||||
@@ -379,7 +380,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.valuation_type
|
||||
item.is_finished_item or item.secondary_item_type or item.is_legacy_scrap_item
|
||||
) and item.valuation_rate == 0:
|
||||
item.allow_zero_valuation_rate = 1
|
||||
|
||||
@@ -479,7 +480,7 @@ class SubcontractingInwardController:
|
||||
self.validate_delivery_on_save()
|
||||
else:
|
||||
for item in self.items:
|
||||
if not item.secondary_item_type and not item.valuation_type:
|
||||
if not item.secondary_item_type and not item.is_legacy_scrap_item:
|
||||
delivered_qty, returned_qty = frappe.get_value(
|
||||
"Subcontracting Inward Order Item",
|
||||
item.scio_detail,
|
||||
@@ -508,13 +509,21 @@ 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((allowed_qty - table.delivered_qty).as_("max_allowed_qty"))
|
||||
.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")
|
||||
)
|
||||
.where((table.name == item.scio_detail) & (table.docstatus == 1))
|
||||
)
|
||||
max_allowed_qty = query.run(pluck="max_allowed_qty")
|
||||
@@ -541,7 +550,7 @@ class SubcontractingInwardController:
|
||||
bold(
|
||||
frappe.get_cached_value(
|
||||
"Subcontracting Inward Order Item"
|
||||
if not item.secondary_item_type and not item.valuation_type
|
||||
if not item.secondary_item_type and not item.is_legacy_scrap_item
|
||||
else "Subcontracting Inward Order Secondary Item",
|
||||
item.scio_detail,
|
||||
"stock_uom",
|
||||
@@ -593,7 +602,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.valuation_type:
|
||||
if item.secondary_item_type or item.is_legacy_scrap_item:
|
||||
scio_secondary_item = frappe.get_value(
|
||||
"Subcontracting Inward Order Secondary Item",
|
||||
{
|
||||
@@ -652,7 +661,7 @@ class SubcontractingInwardController:
|
||||
for item in self.items:
|
||||
doctype = (
|
||||
"Subcontracting Inward Order Item"
|
||||
if not item.secondary_item_type and not item.valuation_type
|
||||
if not item.secondary_item_type and not item.is_legacy_scrap_item
|
||||
else "Subcontracting Inward Order Secondary Item"
|
||||
)
|
||||
qty_map[doctype][item.scio_detail] += (
|
||||
@@ -793,7 +802,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.valuation_type
|
||||
if not item.is_finished_item and not item.secondary_item_type and not item.is_legacy_scrap_item
|
||||
]
|
||||
if not items:
|
||||
return
|
||||
@@ -904,7 +913,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.valuation_type
|
||||
item for item in self.items if item.secondary_item_type or item.is_legacy_scrap_item
|
||||
]
|
||||
|
||||
secondary_items = defaultdict(float)
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
# 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))
|
||||
@@ -1,264 +0,0 @@
|
||||
# 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")
|
||||
@@ -1,141 +0,0 @@
|
||||
# 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]])
|
||||
@@ -1,96 +0,0 @@
|
||||
# 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)
|
||||
@@ -30,97 +30,6 @@ 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,7 +38,6 @@ 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,18 +29,11 @@ class EmailCampaign(Document):
|
||||
|
||||
def validate(self):
|
||||
self.set_date()
|
||||
self.validate_recipient_email()
|
||||
self.validate_email_campaign_already_exists()
|
||||
self.update_status()
|
||||
|
||||
def validate_recipient_email(self):
|
||||
if not self.recipient:
|
||||
return
|
||||
|
||||
# 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()
|
||||
elif self.email_campaign_for == "Contact":
|
||||
self.validate_contact()
|
||||
self.validate_email_campaign_already_exists()
|
||||
self.update_status()
|
||||
|
||||
def set_date(self):
|
||||
if getdate(self.start_date) < getdate(today()):
|
||||
@@ -63,13 +56,6 @@ 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,26 +59,3 @@ 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,8 +236,6 @@ 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()
|
||||
@@ -384,9 +382,6 @@ 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,6 +133,30 @@
|
||||
"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,
|
||||
@@ -140,9 +164,17 @@
|
||||
"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-09-02 14:58:17.082794",
|
||||
"modified": "2026-08-28 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"name": "erpnext",
|
||||
"owner": "Administrator",
|
||||
|
||||
48
erpnext/edi/sidebar/edi/edi.json
Normal file
48
erpnext/edi/sidebar/edi/edi.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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,101 +21,9 @@
|
||||
"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-09-02 14:58:17.082794",
|
||||
"modified": "2026-08-16 00:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "ERPNext Integrations",
|
||||
"name": "ERPNext Integrations",
|
||||
|
||||
@@ -21,23 +21,6 @@ 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"
|
||||
@@ -87,6 +70,8 @@ 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"
|
||||
|
||||
4556
erpnext/locale/ar.po
4556
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
4548
erpnext/locale/bg.po
4548
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
4556
erpnext/locale/bs.po
4556
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
4548
erpnext/locale/cs.po
4548
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
4562
erpnext/locale/da.po
4562
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
4584
erpnext/locale/de.po
4584
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
4556
erpnext/locale/eo.po
4556
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
4558
erpnext/locale/es.po
4558
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
4588
erpnext/locale/fa.po
4588
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
4548
erpnext/locale/fr.po
4548
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
4550
erpnext/locale/hi.po
4550
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
4556
erpnext/locale/hr.po
4556
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
4562
erpnext/locale/hu.po
4562
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
4554
erpnext/locale/id.po
4554
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
4548
erpnext/locale/it.po
4548
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
4548
erpnext/locale/km.po
4548
erpnext/locale/km.po
File diff suppressed because it is too large
Load Diff
4550
erpnext/locale/ko.po
4550
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
4621
erpnext/locale/mn.po
4621
erpnext/locale/mn.po
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user