mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-04 09:00:21 +00:00
Compare commits
199 Commits
v16.32.3
...
version-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b50853985 | ||
|
|
c19ddf187e | ||
|
|
5f13a04633 | ||
|
|
7aad59b129 | ||
|
|
7c61dfe3e0 | ||
|
|
41374019ba | ||
|
|
d6f9dde3cb | ||
|
|
80c2c9e0e0 | ||
|
|
600cbf36af | ||
|
|
3c63fffcb6 | ||
|
|
d3b8324819 | ||
|
|
46db04ecf8 | ||
|
|
75356265be | ||
|
|
124231681a | ||
|
|
f22f84a58a | ||
|
|
5af60cf2e3 | ||
|
|
4435f166ea | ||
|
|
5adcb28b3c | ||
|
|
9b1d907b6a | ||
|
|
ec392a7bcf | ||
|
|
f4ed4cae64 | ||
|
|
2b8e12ef44 | ||
|
|
86e9480362 | ||
|
|
4f409e5ccf | ||
|
|
01fecfe093 | ||
|
|
956a6ed407 | ||
|
|
c745558168 | ||
|
|
fb74a030de | ||
|
|
de28957008 | ||
|
|
9ee421424f | ||
|
|
4b89ddffde | ||
|
|
1b44df480f | ||
|
|
ff656b9ad2 | ||
|
|
22755c4ad6 | ||
|
|
8a8bafd6cb | ||
|
|
9bb8d4ff93 | ||
|
|
30f0cd9c95 | ||
|
|
f8c29274a5 | ||
|
|
f3379b4694 | ||
|
|
2390d48c01 | ||
|
|
765a7736ed | ||
|
|
d95eeb0f6a | ||
|
|
32393caa39 | ||
|
|
c7582e986d | ||
|
|
bfbc13d147 | ||
|
|
40fe739c9e | ||
|
|
bd35957582 | ||
|
|
e64fddfad5 | ||
|
|
ac61e6ac0d | ||
|
|
cb4ec72370 | ||
|
|
06758f2792 | ||
|
|
01a398f263 | ||
|
|
446b5375e4 | ||
|
|
c6082617ec | ||
|
|
c2261f6129 | ||
|
|
eb4c327a2b | ||
|
|
629bc20e90 | ||
|
|
cc73c3b879 | ||
|
|
4e74831a08 | ||
|
|
23290a60da | ||
|
|
ee3b22403d | ||
|
|
f0d2d40d35 | ||
|
|
60252cacdd | ||
|
|
8287906a71 | ||
|
|
d732d06e25 | ||
|
|
4538689e5f | ||
|
|
7464f6c76e | ||
|
|
1411835507 | ||
|
|
76d0cb382d | ||
|
|
eedf991b43 | ||
|
|
38aaceafc6 | ||
|
|
a43de7ce95 | ||
|
|
f7f34ffd3b | ||
|
|
f19450858c | ||
|
|
8dc3bc3485 | ||
|
|
7b9cdb4926 | ||
|
|
f0e603aec5 | ||
|
|
bfb27039ed | ||
|
|
07f8250e40 | ||
|
|
65466464df | ||
|
|
60599ec692 | ||
|
|
6093316ba3 | ||
|
|
cabecd2527 | ||
|
|
c924de3c55 | ||
|
|
655ed81575 | ||
|
|
9f3dd7eabd | ||
|
|
2cd865fffc | ||
|
|
e32a8f008e | ||
|
|
9e00888d67 | ||
|
|
5b92dff1df | ||
|
|
a1aabaff18 | ||
|
|
1a3e02ee5b | ||
|
|
abf311e8ca | ||
|
|
fe98974cb6 | ||
|
|
6dbaa5048e | ||
|
|
b24c9eba55 | ||
|
|
1a745cd433 | ||
|
|
7238ecb306 | ||
|
|
97b2e07d5e | ||
|
|
ac1c6921da | ||
|
|
01e0844d40 | ||
|
|
c9550023f8 | ||
|
|
6b61146d2f | ||
|
|
9209912374 | ||
|
|
f1181fa1e3 | ||
|
|
37a1fd11e9 | ||
|
|
f71bc4d3ef | ||
|
|
efe5571ca7 | ||
|
|
39e15c7b2d | ||
|
|
2329ef6424 | ||
|
|
83cc51a5d2 | ||
|
|
feb51a475a | ||
|
|
5933476778 | ||
|
|
fb9edb3bf8 | ||
|
|
9d9d9f09fd | ||
|
|
8a2109e62a | ||
|
|
abe4c0a6b7 | ||
|
|
f56cdac45f | ||
|
|
0bf34c054a | ||
|
|
d23b9197d5 | ||
|
|
8dbe4bd7fa | ||
|
|
d321eb4a8a | ||
|
|
a1520d06ee | ||
|
|
647c93321b | ||
|
|
2077e844d4 | ||
|
|
c3313f5b34 | ||
|
|
dfc2301f08 | ||
|
|
53b61e5ac6 | ||
|
|
a3a6efbcea | ||
|
|
9f99ba6ddc | ||
|
|
1f21c0b0af | ||
|
|
f813774f63 | ||
|
|
98d69b2ae3 | ||
|
|
29f6a5b9cf | ||
|
|
edb9b33264 | ||
|
|
94b6622d9d | ||
|
|
d3573105c4 | ||
|
|
9e2173e4c2 | ||
|
|
6b7b4796b4 | ||
|
|
27a04d8e08 | ||
|
|
9b0db26c25 | ||
|
|
a62a949b2a | ||
|
|
dd4d10862d | ||
|
|
861fb26b2c | ||
|
|
56ac87292f | ||
|
|
81461ea56c | ||
|
|
2b685ed982 | ||
|
|
6a0b9e1c70 | ||
|
|
28fe021d79 | ||
|
|
c3cf7f2e91 | ||
|
|
832a0bd1ed | ||
|
|
2b84ed78e8 | ||
|
|
32ebd69abc | ||
|
|
900064e395 | ||
|
|
d805f4d3fc | ||
|
|
d3a4d476eb | ||
|
|
4bb7799919 | ||
|
|
c15d5d1a0b | ||
|
|
7b0df4b28b | ||
|
|
1ea1d5d6a5 | ||
|
|
aa03ddc8e6 | ||
|
|
0255314ea7 | ||
|
|
f8c3270049 | ||
|
|
ea238a90ac | ||
|
|
f03b7ba40b | ||
|
|
8a26834704 | ||
|
|
271d22fff1 | ||
|
|
c18881b37d | ||
|
|
61b549960e | ||
|
|
ea52ab36d1 | ||
|
|
d66dc143e3 | ||
|
|
795cf8544c | ||
|
|
4f3ffd3820 | ||
|
|
f9fd3bc526 | ||
|
|
5afaffe7b4 | ||
|
|
569ad8e89b | ||
|
|
cbe7931b3b | ||
|
|
03124b9560 | ||
|
|
1ec2a2cd5d | ||
|
|
2a9e4304a2 | ||
|
|
aecc551b5e | ||
|
|
72114fa738 | ||
|
|
e51df54439 | ||
|
|
cf6c058f53 | ||
|
|
3fd3f9485b | ||
|
|
007748346c | ||
|
|
956be58c9c | ||
|
|
0fe5436a13 | ||
|
|
0b01a93e2e | ||
|
|
58a489f73f | ||
|
|
1b37fd2edc | ||
|
|
5b68db0156 | ||
|
|
de06cb7f45 | ||
|
|
c014e01144 | ||
|
|
98bef1cac8 | ||
|
|
b412266e18 | ||
|
|
3141387949 | ||
|
|
6bee70596b | ||
|
|
5764d1d173 |
54
.github/workflows/crowdin-actions-update-main-pot.yml
vendored
Normal file
54
.github/workflows/crowdin-actions-update-main-pot.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Upload main.pot to Crowdin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- version-16-hotfix
|
||||
paths:
|
||||
- "erpnext/locale/main.pot"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: crowdin-upload-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upload-sources:
|
||||
name: Upload sources from ${{ github.ref_name }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ github.ref_name }}
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Restore Crowdin cache
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .crowdin
|
||||
key: crowdin-${{ github.ref_name }}
|
||||
restore-keys: crowdin-${{ github.ref_name }}-
|
||||
|
||||
- name: Upload main.pot to Crowdin
|
||||
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
|
||||
with:
|
||||
config: crowdin.yml
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
create_pull_request: false
|
||||
crowdin_branch_name: "[frappe.erpnext] ${{ github.ref_name }}"
|
||||
upload_sources_args: "--cache"
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Save Crowdin cache
|
||||
uses: actions/cache/save@v6
|
||||
if: always()
|
||||
with:
|
||||
path: .crowdin
|
||||
key: crowdin-${{ github.ref_name }}-${{ github.run_id }}
|
||||
@@ -22,4 +22,4 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: alyf-de/po-review-action@v1.1.0
|
||||
- uses: alyf-de/po-review-action@5928f84d6bc9094f9ad6e2c5780f01c0044b800e # v1.1.1
|
||||
|
||||
@@ -13,6 +13,7 @@ on:
|
||||
- 'crowdin.yml'
|
||||
- '.coderabbit.yml'
|
||||
- '.mergify.yml'
|
||||
- '**.po'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
1
.github/workflows/server-tests-mariadb.yml
vendored
1
.github/workflows/server-tests-mariadb.yml
vendored
@@ -13,6 +13,7 @@ on:
|
||||
- 'crowdin.yml'
|
||||
- '.coderabbit.yml'
|
||||
- '.mergify.yml'
|
||||
- '**.po'
|
||||
schedule:
|
||||
# Run everday at midnight UTC / 5:30 IST
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"frappe-react-sdk": "^1.17.0",
|
||||
"frappe-react-sdk": "^1.17.1",
|
||||
"fuse.js": "^7.3.0",
|
||||
"jotai": "^2.20.1",
|
||||
"jotai-family": "^1.0.2",
|
||||
"jotai": "^2.20.2",
|
||||
"jotai-family": "^1.1.0",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lucide-react": "^1.14.0",
|
||||
"radix-ui": "^1.6.1",
|
||||
@@ -39,7 +39,7 @@
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-hotkeys-hook": "^5.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^8.1.0",
|
||||
"react-router": "^8.3.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"safe-expr-eval": "^1.0.4",
|
||||
@@ -48,18 +48,18 @@
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.62.1"
|
||||
"typescript-eslint": "^8.67.0"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
13
crowdin.yml
13
crowdin.yml
@@ -1,14 +1,5 @@
|
||||
preserve_hierarchy: true
|
||||
|
||||
files:
|
||||
- source: /erpnext/locale/main.pot
|
||||
translation: /erpnext/locale/%two_letters_code%.po
|
||||
pull_request_title: "fix: sync translations from crowdin"
|
||||
pull_request_labels:
|
||||
- translation
|
||||
- skip-release-notes
|
||||
pull_request_reviewers:
|
||||
- barredterra # change to your GitHub username if you copied this file
|
||||
commit_message: "fix: %language% translations"
|
||||
append_commit_message: false
|
||||
languages_mapping:
|
||||
two_letters_code:
|
||||
pt-BR: pt_BR
|
||||
|
||||
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "16.32.3"
|
||||
__version__ = "16.34.1"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,8 @@ frappe.ui.form.on("Accounting Dimension", {
|
||||
return {
|
||||
filters: {
|
||||
name: ["not in", invalid_doctypes],
|
||||
istable: 0,
|
||||
issingle: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -60,6 +60,14 @@ class AccountingDimension(Document):
|
||||
msg = _("Not allowed to create accounting dimension for {0}").format(self.document_type)
|
||||
frappe.throw(msg)
|
||||
|
||||
meta = frappe.get_meta(self.document_type)
|
||||
if meta.istable or meta.issingle:
|
||||
frappe.throw(
|
||||
_(
|
||||
"{0} cannot be used as an accounting dimension as it is not a standalone document type."
|
||||
).format(frappe.bold(self.document_type))
|
||||
)
|
||||
|
||||
exists = frappe.db.get_value("Accounting Dimension", {"document_type": self.document_type}, ["name"])
|
||||
|
||||
if exists and self.is_new():
|
||||
|
||||
@@ -52,6 +52,23 @@ class TestAccountingDimension(ERPNextTestSuite):
|
||||
self.assertEqual(gle.get("department"), "_Test Department - _TC")
|
||||
self.assertEqual(gle1.get("department"), "_Test Department - _TC")
|
||||
|
||||
def test_child_table_not_allowed_as_dimension(self):
|
||||
dimension = frappe.get_doc({"doctype": "Accounting Dimension", "document_type": "Sales Team"})
|
||||
self.assertRaises(frappe.ValidationError, dimension.insert)
|
||||
|
||||
def test_single_doctype_not_allowed_as_dimension(self):
|
||||
dimension = frappe.get_doc({"doctype": "Accounting Dimension", "document_type": "Selling Settings"})
|
||||
self.assertRaises(frappe.ValidationError, dimension.insert)
|
||||
|
||||
def test_non_scalar_dimension_value_skipped_in_gl_dict(self):
|
||||
si = create_sales_invoice(do_not_save=1)
|
||||
|
||||
si.department = "_Test Department - _TC"
|
||||
self.assertEqual(si.get_gl_dict({}).get("department"), "_Test Department - _TC")
|
||||
|
||||
si.department = ["_Test Department - _TC"]
|
||||
self.assertNotIn("department", si.get_gl_dict({}))
|
||||
|
||||
def test_mandatory(self):
|
||||
location = frappe.get_doc("Accounting Dimension", "Location")
|
||||
location.dimension_defaults[0].mandatory_for_bs = True
|
||||
|
||||
@@ -94,13 +94,14 @@
|
||||
"column_break_25",
|
||||
"reports_tab",
|
||||
"remarks_section",
|
||||
"general_ledger_remarks_length",
|
||||
"receivable_payable_remarks_length",
|
||||
"disable_include_dimensions",
|
||||
"column_break_lvjk",
|
||||
"general_ledger_remarks_length",
|
||||
"accounts_receivable_payable_tuning_section",
|
||||
"receivable_payable_fetch_method",
|
||||
"default_ageing_range",
|
||||
"column_break_ntmi",
|
||||
"receivable_payable_remarks_length",
|
||||
"legacy_section",
|
||||
"ignore_is_opening_check_for_reporting",
|
||||
"tab_break_dpet",
|
||||
@@ -198,10 +199,12 @@
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"description": "System will use the latest saved Currency Exchange rate on or before the transaction date, however old it is. <br>\nUncheck to ignore rates older than Stale Days and fetch a fresh rate from the exchange rate provider instead.",
|
||||
"fieldname": "allow_stale",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Allow Stale Exchange Rates"
|
||||
"label": "Allow Stale Exchange Rates",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
@@ -277,10 +280,10 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Block a new Sales Invoice when the customer's overdue amount exceeds the Overdue Limit set on the customer.",
|
||||
"description": "Enabling this option prevents the creation of a new Sales Invoice when the customer has an overdue limit set and their outstanding overdue amount exceeds that limit.",
|
||||
"fieldname": "enable_overdue_billing_threshold",
|
||||
"fieldtype": "Check",
|
||||
"label": "Restrict Customer Over Billing"
|
||||
"label": "Prevent Sales Invoice when Customer is Overdue"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.enable_overdue_billing_threshold",
|
||||
@@ -474,7 +477,7 @@
|
||||
{
|
||||
"fieldname": "remarks_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Remarks Column Length"
|
||||
"label": "General Ledger Report"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -548,7 +551,7 @@
|
||||
{
|
||||
"fieldname": "accounts_receivable_payable_tuning_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Accounts Receivable / Payable Tuning"
|
||||
"label": "Accounts Receivable / Payable Report"
|
||||
},
|
||||
{
|
||||
"fieldname": "legacy_section",
|
||||
@@ -780,6 +783,12 @@
|
||||
"fieldname": "book_stock_expense_gl_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Book Stock Expense GL Entries"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "disable_include_dimensions",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disable \"Consider Accounting Dimension\" Filter"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -788,7 +797,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-27 12:00:00.000000",
|
||||
"modified": "2026-08-14 15:26:49.070889",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
@@ -72,6 +72,7 @@ class AccountsSettings(Document):
|
||||
default_ageing_range: DF.Data | None
|
||||
delete_linked_ledger_entries: DF.Check
|
||||
determine_address_tax_category_from: DF.Literal["Billing Address", "Shipping Address"]
|
||||
disable_include_dimensions: DF.Check
|
||||
enable_accounting_dimensions: DF.Check
|
||||
enable_common_party_accounting: DF.Check
|
||||
enable_discounts_and_margin: DF.Check
|
||||
|
||||
@@ -167,9 +167,10 @@ def get_transaction_reference(txn_data: dict) -> str:
|
||||
).strip()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def convert_mt940_to_csv(data_import, mt940_file_path):
|
||||
doc = frappe.get_doc("Bank Statement Import", data_import)
|
||||
doc.check_permission("write")
|
||||
|
||||
_file_doc, content = get_file(mt940_file_path)
|
||||
|
||||
@@ -234,26 +235,30 @@ def convert_mt940_to_csv(data_import, mt940_file_path):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_preview_from_template(data_import, import_file=None, google_sheets_url=None):
|
||||
return frappe.get_doc("Bank Statement Import", data_import).get_preview_from_template(
|
||||
import_file, google_sheets_url
|
||||
)
|
||||
bsi = frappe.get_doc("Bank Statement Import", data_import)
|
||||
bsi.check_permission()
|
||||
return bsi.get_preview_from_template(import_file, google_sheets_url)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def form_start_import(data_import):
|
||||
job_id = frappe.get_doc("Bank Statement Import", data_import).start_import()
|
||||
return job_id is not None
|
||||
bsi = frappe.get_doc("Bank Statement Import", data_import)
|
||||
bsi.check_permission("write")
|
||||
return bsi.start_import()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_errored_template(data_import_name):
|
||||
data_import = frappe.get_doc("Bank Statement Import", data_import_name)
|
||||
data_import.check_permission()
|
||||
data_import.export_errored_rows()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_import_log(data_import_name):
|
||||
return frappe.get_doc("Bank Statement Import", data_import_name).download_import_log()
|
||||
bsi = frappe.get_doc("Bank Statement Import", data_import_name)
|
||||
bsi.check_permission()
|
||||
return bsi.download_import_log()
|
||||
|
||||
|
||||
def is_mt940_format(content: str) -> bool:
|
||||
@@ -392,6 +397,7 @@ def get_import_status(docname):
|
||||
import_status = {}
|
||||
|
||||
data_import = frappe.get_doc("Bank Statement Import", docname)
|
||||
data_import.check_permission()
|
||||
import_status["status"] = data_import.status
|
||||
|
||||
logs = frappe.get_all(
|
||||
|
||||
@@ -234,8 +234,10 @@ frappe.ui.form.on("Dunning", {
|
||||
dn: frm.doc.name,
|
||||
},
|
||||
callback: function (r) {
|
||||
var doc = frappe.model.sync(r.message);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
if (!r.exc) {
|
||||
var doc = frappe.model.sync(r.message);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -275,6 +275,7 @@ def get_linked_dunnings_as_per_state(sales_invoice, state):
|
||||
.join(overdue_payment)
|
||||
.on(overdue_payment.parent == dunning.name)
|
||||
.select(dunning.name)
|
||||
.distinct()
|
||||
.where(
|
||||
(dunning.status == state)
|
||||
& (dunning.docstatus != 2)
|
||||
|
||||
@@ -122,6 +122,41 @@ class TestDunning(ERPNextTestSuite):
|
||||
self.assertEqual(sales_invoice.status, "Overdue")
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
|
||||
def test_payment_against_invoice_with_multiple_overdue_installments_in_dunning(self):
|
||||
"""
|
||||
When an invoice has more than one overdue installment, its Dunning holds one
|
||||
Overdue Payment row per installment. Submitting a Payment Entry for the invoice
|
||||
must resolve the Dunning without raising a TimestampMismatchError caused by the
|
||||
same Dunning being loaded and saved more than once.
|
||||
"""
|
||||
create_payment_terms_template_for_dunning()
|
||||
# Post far enough in the past that BOTH installments (5 and 10 credit days) are overdue.
|
||||
sales_invoice = create_sales_invoice_against_cost_center(
|
||||
posting_date=add_days(today(), -15),
|
||||
qty=1,
|
||||
rate=100,
|
||||
do_not_submit=True,
|
||||
)
|
||||
sales_invoice.payment_terms_template = "_Test 50-50 for Dunning"
|
||||
sales_invoice.submit()
|
||||
|
||||
dunning = create_dunning_from_sales_invoice(sales_invoice.name)
|
||||
# Two overdue installments -> two overdue payment rows for the same invoice.
|
||||
self.assertEqual(len(dunning.overdue_payments), 2)
|
||||
dunning.submit()
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
|
||||
# Pay the invoice in full. This previously raised TimestampMismatchError on the Dunning.
|
||||
pe = get_payment_entry("Sales Invoice", sales_invoice.name)
|
||||
pe.reference_no, pe.reference_date = "3", nowdate()
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
sales_invoice.reload()
|
||||
dunning.reload()
|
||||
self.assertEqual(sales_invoice.outstanding_amount, 0)
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
|
||||
def test_dunning_resolution_from_credit_note(self):
|
||||
"""
|
||||
Test that dunning is resolved when a credit note is issued against the original invoice.
|
||||
|
||||
@@ -31,6 +31,7 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_validat
|
||||
AccountFilterValidator,
|
||||
CalculationFormulaValidator,
|
||||
DependencyValidator,
|
||||
get_valid_api_method,
|
||||
)
|
||||
from erpnext.accounts.report.financial_statements import (
|
||||
get_columns,
|
||||
@@ -1164,10 +1165,12 @@ class RowProcessor:
|
||||
|
||||
def _process_api_row(self, row) -> RowData:
|
||||
api_path = row.calculation_formula
|
||||
# TODO
|
||||
|
||||
method = get_valid_api_method(api_path)
|
||||
|
||||
try:
|
||||
values = frappe.call(api_path, filters=self.context.filters, periods=self.period_list, row=row)
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-codeinjection-eval
|
||||
values = frappe.call(method, filters=self.context.filters, periods=self.period_list, row=row)
|
||||
|
||||
if row.reverse_sign:
|
||||
values = [-1 * v for v in values]
|
||||
|
||||
@@ -163,7 +163,7 @@ function show_accounts_tree(template_rows, has_selection) {
|
||||
fieldname: "company",
|
||||
fieldtype: "Link",
|
||||
options: "Company",
|
||||
label: "Company",
|
||||
label: __("Company"),
|
||||
reqd: 1,
|
||||
default: frappe.defaults.get_user_default("Company"),
|
||||
onchange: () => {
|
||||
@@ -176,7 +176,7 @@ function show_accounts_tree(template_rows, has_selection) {
|
||||
fieldname: "view_type",
|
||||
fieldtype: "Select",
|
||||
options: ["Missing Accounts", "Filtered Accounts"],
|
||||
label: "View",
|
||||
label: __("View"),
|
||||
default: has_selection ? "Filtered Accounts" : "Missing Accounts",
|
||||
reqd: 1,
|
||||
onchange: () => {
|
||||
@@ -192,10 +192,10 @@ function show_accounts_tree(template_rows, has_selection) {
|
||||
{
|
||||
fieldname: "tip",
|
||||
fieldtype: "HTML",
|
||||
label: "Tip",
|
||||
label: __("Tip"),
|
||||
options: `
|
||||
<div class="alert alert-success" role="alert">
|
||||
Tip: Select report lines to view their accounts
|
||||
${__("Tip: Select report lines to view their accounts")}
|
||||
</div>
|
||||
`,
|
||||
depends_on: has_selection ? "eval: false" : "eval: true",
|
||||
@@ -203,7 +203,7 @@ function show_accounts_tree(template_rows, has_selection) {
|
||||
{
|
||||
fieldname: "tree_area",
|
||||
fieldtype: "HTML",
|
||||
label: "Chart of Accounts",
|
||||
label: __("Chart of Accounts"),
|
||||
read_only: 1,
|
||||
depends_on: "eval: doc.company",
|
||||
},
|
||||
@@ -288,14 +288,14 @@ function update_formula_label(frm, data_source) {
|
||||
if (!field) return;
|
||||
|
||||
const labels = {
|
||||
"Account Data": "Account Filter",
|
||||
"Custom API": "API Method Path",
|
||||
"Account Data": __("Account Filter"),
|
||||
"Custom API": __("API Method Path"),
|
||||
};
|
||||
|
||||
grid.update_docfield_property(
|
||||
"calculation_formula",
|
||||
"label",
|
||||
labels[data_source] || "Calculation Formula"
|
||||
labels[data_source] || __("Calculation Formula")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -370,7 +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 method that returns financial data.</p>
|
||||
<p ${text_style}>Path to your custom whitelisted method that returns financial data. It must permit GET requests.</p>
|
||||
|
||||
<h6 ${subtitle_style}>Format:</h6>
|
||||
<ul ${list_style}>
|
||||
@@ -380,7 +380,8 @@ function update_formula_description(frm, data_source) {
|
||||
|
||||
<h6 ${subtitle_style}>Method Signature:</h6>
|
||||
<div ${code_style}>
|
||||
<pre ${pre_style}>def get_custom_data(filters, periods, row): <br> # filters: dict — report filters (company, period, etc.) <br> # periods: list[dict] — period definitions <br> # row: dict — the current report row <br><br> return [1000.0, 1200.0, 1150.0] # one value per period</pre>
|
||||
<!-- is used for line breaks since frappe.render replaces newlines with spaces -->
|
||||
<pre ${pre_style} class="language-python">@frappe.whitelist(methods=["GET"]) def get_custom_data(filters, periods, row): # filters: dict — report filters (company, period, etc.) # periods: list[dict] — period definitions # row: dict — the current report row return [1000.0, 1200.0, 1150.0] # one value per period</pre>
|
||||
</div>
|
||||
|
||||
<h6 ${subtitle_style}>Return Format:</h6>
|
||||
|
||||
@@ -10,18 +10,41 @@ from enum import Enum
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe import _, is_whitelisted
|
||||
from frappe.database.operator_map import OPERATOR_MAP
|
||||
from frappe.database.query import SQLFunctionParser
|
||||
|
||||
|
||||
def get_valid_api_method(api_path: str):
|
||||
"""Resolve `api_path`, ensuring it is whitelisted and permits GET (i.e. read-only)."""
|
||||
method = frappe.get_attr(api_path)
|
||||
is_whitelisted(method)
|
||||
|
||||
if "GET" not in frappe.allowed_http_methods_for_whitelisted_func.get(method, ()):
|
||||
frappe.throw(
|
||||
_("Method {0} must permit GET requests").format(frappe.bold(api_path)),
|
||||
frappe.PermissionError,
|
||||
title=_("Method Not Allowed"),
|
||||
)
|
||||
|
||||
return method
|
||||
|
||||
|
||||
def get_formula_field_label(data_source: str) -> str:
|
||||
# Must mirror the `labels` map in financial_report_template.js (update_formula_label),
|
||||
labels = {
|
||||
"Account Data": _("Account Filter"),
|
||||
"Custom API": _("API Method Path"),
|
||||
}
|
||||
return labels.get(data_source, _("Calculation Formula"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationIssue:
|
||||
"""Represents a single validation issue"""
|
||||
|
||||
message: str
|
||||
row_idx: int | None = None
|
||||
field: str | None = None
|
||||
details: dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
@@ -29,10 +52,9 @@ class ValidationIssue:
|
||||
self.details = {}
|
||||
|
||||
def __str__(self) -> str:
|
||||
prefix = f"Row {self.row_idx}: " if self.row_idx else ""
|
||||
field_info = f"[{self.field}] " if self.field else ""
|
||||
message = f"{prefix}{field_info}{self.message}"
|
||||
return _(message)
|
||||
if self.row_idx:
|
||||
return _("Row {0}: {1}", context="Financial Report Template").format(self.row_idx, self.message)
|
||||
return self.message
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -134,7 +156,9 @@ class TemplateStructureValidator(Validator):
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_-]*$", ref_code):
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Invalid line reference format: '{ref_code}'. Must start with letter and contain only letters, numbers, underscores, and hyphens",
|
||||
message=_(
|
||||
"Invalid line reference format: '{0}'. Must start with letter and contain only letters, numbers, underscores, and hyphens"
|
||||
).format(ref_code),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -143,7 +167,7 @@ class TemplateStructureValidator(Validator):
|
||||
if ref_code in used_codes:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Duplicate line reference: '{ref_code}'",
|
||||
message=_("Duplicate line reference: '{0}'").format(ref_code),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -159,7 +183,7 @@ class TemplateStructureValidator(Validator):
|
||||
if row.data_source == "Account Data" and not row.balance_type:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message="Balance Type is required for Account Data",
|
||||
message=_("Balance Type is required for Account Data"),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -169,7 +193,9 @@ class TemplateStructureValidator(Validator):
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Formula is required for {row.data_source}",
|
||||
message=_("{0} is required for {1}").format(
|
||||
get_formula_field_label(row.data_source), row.data_source
|
||||
),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -226,7 +252,7 @@ class DependencyValidator(Validator):
|
||||
cycle = [*path[cycle_start:], node]
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Circular dependency detected: {' → '.join(cycle)}",
|
||||
message=_("Circular dependency detected: {0}").format(" → ".join(cycle)),
|
||||
)
|
||||
)
|
||||
return
|
||||
@@ -258,7 +284,7 @@ class DependencyValidator(Validator):
|
||||
row_idx = self._get_row_idx(ref_code)
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Line References undefined in Formula: {', '.join(undefined)}",
|
||||
message=_("Line References undefined in Formula: {0}").format(", ".join(undefined)),
|
||||
row_idx=row_idx,
|
||||
)
|
||||
)
|
||||
@@ -288,9 +314,10 @@ class CalculationFormulaValidator(Validator):
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message="Formula is required for Calculated Amount",
|
||||
message=_("{0} is required for Calculated Amount").format(
|
||||
get_formula_field_label(row.data_source)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
field="Formula",
|
||||
)
|
||||
)
|
||||
return result
|
||||
@@ -302,7 +329,7 @@ class CalculationFormulaValidator(Validator):
|
||||
if not self._are_parentheses_balanced(formula):
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message="Formula has unbalanced parentheses",
|
||||
message=_("Formula has unbalanced parentheses"),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -314,7 +341,7 @@ class CalculationFormulaValidator(Validator):
|
||||
if row.reference_code and row.reference_code in refs:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Formula references itself ('{row.reference_code}')",
|
||||
message=_("Formula references itself ('{0}')").format(row.reference_code),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -324,7 +351,7 @@ class CalculationFormulaValidator(Validator):
|
||||
if undefined:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Formula references undefined codes: {', '.join(undefined)}",
|
||||
message=_("Formula references undefined codes: {0}").format(", ".join(undefined)),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -334,7 +361,7 @@ class CalculationFormulaValidator(Validator):
|
||||
if eval_error:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Formula evaluation error: {eval_error}",
|
||||
message=_("Formula evaluation error: {0}").format(eval_error),
|
||||
row_idx=row.idx,
|
||||
)
|
||||
)
|
||||
@@ -371,7 +398,7 @@ class CalculationFormulaValidator(Validator):
|
||||
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
|
||||
|
||||
if not isinstance(result, (int, float)): # noqa: UP038
|
||||
return f"Formula must return a numeric value, got {type(result).__name__}"
|
||||
return _("Formula must return a numeric value, got {0}").format(type(result).__name__)
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
@@ -394,9 +421,10 @@ class AccountFilterValidator(Validator):
|
||||
if not row.calculation_formula:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message="Account filter is required for Account Data",
|
||||
message=_("{0} is required for Account Data").format(
|
||||
get_formula_field_label(row.data_source)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
field="Formula",
|
||||
)
|
||||
)
|
||||
return result
|
||||
@@ -412,18 +440,18 @@ class AccountFilterValidator(Validator):
|
||||
if error:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=error,
|
||||
message=_("{0}: {1}").format(get_formula_field_label(row.data_source), error),
|
||||
row_idx=row.idx,
|
||||
field="Account Filter",
|
||||
)
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Invalid JSON format: {e!s}",
|
||||
message=_("{0}: Invalid JSON format: {1}").format(
|
||||
get_formula_field_label(row.data_source), str(e)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
field="Account Filter",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -438,36 +466,36 @@ class AccountFilterValidator(Validator):
|
||||
# simple condition: [field, operator, value]
|
||||
if isinstance(filter_config, list):
|
||||
if len(filter_config) != 3:
|
||||
return "Filter must be [field, operator, value]"
|
||||
return _("Filter must be [field, operator, value]")
|
||||
|
||||
field, operator, value = filter_config
|
||||
|
||||
if not isinstance(field, str) or not isinstance(operator, str):
|
||||
return "Field and operator must be strings"
|
||||
return _("Field and operator must be strings")
|
||||
|
||||
display = (field if advanced_filtering else self.account_meta.get_label(field)) or field
|
||||
|
||||
if field not in account_fields:
|
||||
return f"Field '{display}' is not a valid Account field"
|
||||
return _("Field '{0}' is not a valid Account field").format(display)
|
||||
|
||||
if operator.casefold() not in OPERATOR_MAP:
|
||||
return f"Invalid operator '{operator}'"
|
||||
return _("Invalid operator '{0}'").format(operator)
|
||||
|
||||
if operator in ["in", "not in"] and not isinstance(value, list):
|
||||
return f"Operator '{operator}' requires a list value"
|
||||
return _("Operator '{0}' requires a list value").format(operator)
|
||||
|
||||
# logical condition: {"and": [condition1, condition2]}
|
||||
elif isinstance(filter_config, dict):
|
||||
if len(filter_config) != 1:
|
||||
return "Logical condition must have exactly one operator"
|
||||
return _("Logical condition must have exactly one operator")
|
||||
|
||||
op = next(iter(filter_config.keys())).lower()
|
||||
if op not in ["and", "or"]:
|
||||
return "Logical operators must be 'and' or 'or'"
|
||||
return _("Logical operators must be 'and' or 'or'")
|
||||
|
||||
conditions = filter_config[next(iter(filter_config.keys()))]
|
||||
if not isinstance(conditions, list) or len(conditions) < 1:
|
||||
return "Logical conditions need at least 1 sub-condition"
|
||||
return _("Logical conditions need at least 1 sub-condition")
|
||||
|
||||
# recursive
|
||||
for condition in conditions:
|
||||
@@ -475,7 +503,7 @@ class AccountFilterValidator(Validator):
|
||||
if error:
|
||||
return error
|
||||
else:
|
||||
return "Filter must be a list or dict"
|
||||
return _("Filter must be a list or dict")
|
||||
|
||||
return None
|
||||
|
||||
@@ -511,34 +539,31 @@ class FormulaValidator(Validator):
|
||||
if "." not in api_path:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message="Custom API path should be in format: app.module.method",
|
||||
message=_("{0} should be in format: app.module.method").format(
|
||||
get_formula_field_label(row.data_source)
|
||||
),
|
||||
row_idx=row.idx,
|
||||
field="Formula",
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
# Method exists?
|
||||
try:
|
||||
module_path, method_name = api_path.rsplit(".", 1)
|
||||
module = frappe.get_module(module_path)
|
||||
|
||||
if not hasattr(module, method_name):
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Method '{method_name}' not found in module '{module_path}' (might be environment-specific)",
|
||||
row_idx=row.idx,
|
||||
field="Formula",
|
||||
)
|
||||
)
|
||||
get_valid_api_method(api_path)
|
||||
except Exception as e:
|
||||
result.add_error(
|
||||
ValidationIssue(
|
||||
message=f"Could not validate API path: {e!s}",
|
||||
row_idx=row.idx,
|
||||
field="Formula",
|
||||
if isinstance(e, frappe.PermissionError | frappe.ValidationError):
|
||||
# frappe.throw inside get_valid_api_method logs a message that would pop up in UI
|
||||
frappe.clear_last_message()
|
||||
|
||||
if isinstance(e, frappe.PermissionError):
|
||||
message = _("{0}: Method '{1}' must be whitelisted and permit GET requests").format(
|
||||
get_formula_field_label(row.data_source), api_path
|
||||
)
|
||||
)
|
||||
else:
|
||||
message = _("Could not validate {0}: {1}").format(
|
||||
get_formula_field_label(row.data_source), str(e)
|
||||
)
|
||||
|
||||
result.add_error(ValidationIssue(message=message, row_idx=row.idx))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import whitelist_for_tests
|
||||
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
|
||||
FormulaValidator,
|
||||
get_valid_api_method,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -72,3 +77,90 @@ class FinancialReportTemplateTestCase(ERPNextTestSuite):
|
||||
{"doctype": "Financial Report Template", "template_name": template_name, "rows": rows_data}
|
||||
)
|
||||
return template
|
||||
|
||||
|
||||
def not_whitelisted_method(**kwargs):
|
||||
return [42.0]
|
||||
|
||||
|
||||
@whitelist_for_tests(methods=["POST"])
|
||||
def whitelisted_post_only_method(**kwargs):
|
||||
return [42.0]
|
||||
|
||||
|
||||
@whitelist_for_tests(methods=["GET"])
|
||||
def whitelisted_get_method(**kwargs):
|
||||
return [42.0]
|
||||
|
||||
|
||||
class TestCustomAPIValidation(FinancialReportTemplateTestCase):
|
||||
"""Custom API rows must point to whitelisted methods that permit GET"""
|
||||
|
||||
TEST_MODULE = "erpnext.accounts.doctype.financial_report_template.test_financial_report_template"
|
||||
NOT_WHITELISTED = f"{TEST_MODULE}.not_whitelisted_method"
|
||||
WHITELISTED_POST_ONLY = f"{TEST_MODULE}.whitelisted_post_only_method"
|
||||
WHITELISTED_GET = f"{TEST_MODULE}.whitelisted_get_method"
|
||||
|
||||
def create_api_template(self, api_path):
|
||||
template = self.create_test_template_with_rows(
|
||||
[
|
||||
{
|
||||
"reference_code": "API001",
|
||||
"display_name": "API Row",
|
||||
"data_source": "Custom API",
|
||||
"calculation_formula": api_path,
|
||||
}
|
||||
]
|
||||
)
|
||||
template.report_type = "Profit and Loss Statement"
|
||||
return template
|
||||
|
||||
def test_get_valid_api_method(self):
|
||||
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.NOT_WHITELISTED)
|
||||
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.WHITELISTED_POST_ONLY)
|
||||
self.assertEqual(get_valid_api_method(self.WHITELISTED_GET), frappe.get_attr(self.WHITELISTED_GET))
|
||||
|
||||
def test_save_rejects_invalid_api_methods(self):
|
||||
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
|
||||
template = self.create_api_template(api_path)
|
||||
self.assertRaises(frappe.ValidationError, template.insert)
|
||||
|
||||
def test_save_allows_get_whitelisted_method(self):
|
||||
template = self.create_api_template(self.WHITELISTED_GET)
|
||||
template.insert()
|
||||
template.delete()
|
||||
|
||||
def test_engine_rejects_invalid_api_methods(self):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
ReportContext,
|
||||
RowProcessor,
|
||||
)
|
||||
|
||||
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
|
||||
template = self.create_api_template(api_path)
|
||||
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
|
||||
processor = RowProcessor(context)
|
||||
self.assertRaises(frappe.PermissionError, processor._process_api_row, template.rows[0])
|
||||
|
||||
def test_engine_calls_valid_api_method(self):
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
ReportContext,
|
||||
RowProcessor,
|
||||
)
|
||||
|
||||
template = self.create_api_template(self.WHITELISTED_GET)
|
||||
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
|
||||
processor = RowProcessor(context)
|
||||
row_data = processor._process_api_row(template.rows[0])
|
||||
self.assertEqual(row_data.values, [42.0])
|
||||
|
||||
def test_validation_keeps_message_log_clean(self):
|
||||
validator = FormulaValidator(frappe._dict(rows=[]))
|
||||
message_count = len(frappe.local.message_log)
|
||||
|
||||
# last path raises AppNotInstalledError, which also logs a message via frappe.throw
|
||||
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY, "missing_app.api.method"):
|
||||
row = frappe._dict(data_source="Custom API", calculation_formula=api_path, idx=1)
|
||||
result = validator.validate(row)
|
||||
self.assertFalse(result.is_valid)
|
||||
self.assertEqual(len(frappe.local.message_log), message_count)
|
||||
|
||||
@@ -8,6 +8,6 @@ def get_data():
|
||||
{"label": _("Pre Sales"), "items": ["Quotation", "Supplier Quotation"]},
|
||||
{"label": _("Sales"), "items": ["Sales Invoice", "Sales Order", "Delivery Note"]},
|
||||
{"label": _("Purchase"), "items": ["Purchase Invoice", "Purchase Order", "Purchase Receipt"]},
|
||||
{"label": _("Stock"), "items": ["Item Groups", "Item"]},
|
||||
{"label": _("Stock"), "items": ["Item Group", "Item"]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ frappe.ui.form.on("Journal Entry", {
|
||||
);
|
||||
}
|
||||
|
||||
if (frm.doc.docstatus == 1) {
|
||||
if (frm.doc.docstatus == 1 && !frm.doc.reversal_of) {
|
||||
frm.add_custom_button(
|
||||
__("Reverse Journal Entry"),
|
||||
function () {
|
||||
|
||||
@@ -1402,6 +1402,7 @@ def get_payment_entry_against_order(
|
||||
dt, dn, amount=None, debit_in_account_currency=None, journal_entry=False, bank_account=None
|
||||
):
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
ref_doc.check_permission()
|
||||
|
||||
if flt(ref_doc.per_billed, 2) > 0:
|
||||
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
|
||||
@@ -1447,6 +1448,8 @@ def get_payment_entry_against_invoice(
|
||||
dt, dn, amount=None, debit_in_account_currency=None, journal_entry=False, bank_account=None
|
||||
):
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
ref_doc.check_permission()
|
||||
|
||||
if dt == "Sales Invoice":
|
||||
party_type = "Customer"
|
||||
party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to
|
||||
@@ -1482,6 +1485,8 @@ def get_payment_entry_against_invoice(
|
||||
|
||||
|
||||
def get_payment_entry(ref_doc, args):
|
||||
frappe.has_permission("Journal Entry", ptype="create", throw=True)
|
||||
|
||||
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
|
||||
"Company", ref_doc.company, "cost_center"
|
||||
)
|
||||
@@ -1777,6 +1782,20 @@ def make_inter_company_journal_entry(name, voucher_type, company):
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_reverse_journal_entry(source_name, target_doc=None):
|
||||
# `get_mapped_doc` checks this as well, but the guards below disclose which entry
|
||||
# reverses which, so read access has to be settled before they run
|
||||
if not frappe.has_permission("Journal Entry", doc=source_name):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
reversal_of = frappe.db.get_value("Journal Entry", source_name, "reversal_of")
|
||||
if reversal_of:
|
||||
frappe.throw(
|
||||
_("{0} is already a Reverse Journal Entry of {1}. Cancel it instead of reversing it.").format(
|
||||
get_link_to_form("Journal Entry", source_name),
|
||||
get_link_to_form("Journal Entry", reversal_of),
|
||||
)
|
||||
)
|
||||
|
||||
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
|
||||
if existing_reverse:
|
||||
frappe.throw(
|
||||
|
||||
@@ -248,6 +248,27 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
|
||||
self.check_gl_entries()
|
||||
|
||||
def test_disallow_reversal_of_a_reversal_journal_entry(self):
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
|
||||
|
||||
jv = make_journal_entry("_Test Bank - _TC", "Sales - _TC", 100, submit=True)
|
||||
|
||||
rjv = make_reverse_journal_entry(jv.name)
|
||||
rjv.posting_date = nowdate()
|
||||
rjv.submit()
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"is already a Reverse Journal Entry",
|
||||
make_reverse_journal_entry,
|
||||
rjv.name,
|
||||
)
|
||||
|
||||
# the guard must not disclose the reversal to a user who cannot read the entry
|
||||
frappe.set_user("Guest")
|
||||
self.addCleanup(frappe.set_user, "Administrator")
|
||||
self.assertRaises(frappe.PermissionError, make_reverse_journal_entry, rjv.name)
|
||||
|
||||
def test_disallow_change_in_account_currency_for_a_party(self):
|
||||
# create jv in USD
|
||||
jv = make_journal_entry("_Test Bank USD - _TC", "_Test Receivable USD - _TC", 100, save=False)
|
||||
|
||||
@@ -56,7 +56,9 @@ class LedgerMerge(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def form_start_merge(docname):
|
||||
return frappe.get_doc("Ledger Merge", docname).start_merge()
|
||||
lm_doc = frappe.get_doc("Ledger Merge", docname)
|
||||
lm_doc.check_permission("write")
|
||||
return lm_doc.start_merge()
|
||||
|
||||
|
||||
def start_merge(docname):
|
||||
|
||||
@@ -297,6 +297,9 @@ def start_import(invoices):
|
||||
invoice_number = d.invoice_number
|
||||
doc = frappe.get_doc(d)
|
||||
doc.flags.ignore_mandatory = True
|
||||
# the outstanding amount is entered inclusive of tax, so taxes must not
|
||||
# be added on top of it
|
||||
doc.flags.dont_auto_add_taxes = True
|
||||
doc.insert(set_name=invoice_number)
|
||||
doc.submit()
|
||||
if not frappe.in_test:
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
import frappe
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
|
||||
get_temporary_opening_account,
|
||||
)
|
||||
from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule
|
||||
from erpnext.projects.doctype.project.test_project import make_project
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -126,6 +128,55 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
|
||||
for invoice in invoices:
|
||||
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings",
|
||||
{"add_taxes_from_taxes_and_charges_template": 1, "add_taxes_from_item_tax_template": 0},
|
||||
)
|
||||
def test_opening_invoice_creation_without_taxes(self):
|
||||
company = "_Test Opening Invoice Company"
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Sales Taxes and Charges Template",
|
||||
"company": company,
|
||||
"title": "_Test Opening Invoice Tax",
|
||||
"taxes": [
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": create_account(
|
||||
account_name="_Test Opening Tax Account",
|
||||
parent_account="Duties and Taxes - _TOIC",
|
||||
account_type="Tax",
|
||||
company=company,
|
||||
),
|
||||
"description": "Test taxes",
|
||||
"rate": 9,
|
||||
}
|
||||
],
|
||||
}
|
||||
).insert()
|
||||
|
||||
# makes the template the default for the party, as it would be on a live site
|
||||
make_tax_rule(tax_type="Sales", company=company, sales_tax_template=template.name, save=1)
|
||||
|
||||
tool = self.make_invoices(company=company, return_doc=True)
|
||||
invoices = tool.make_invoices()
|
||||
self.assertEqual(len(invoices), 2)
|
||||
|
||||
# outstanding amount is entered inclusive of tax, so taxes must not be added on top of it
|
||||
for invoice in invoices:
|
||||
si = frappe.get_doc("Sales Invoice", invoice)
|
||||
self.assertFalse(si.taxes)
|
||||
self.assertEqual(si.grand_total, 200)
|
||||
self.assertEqual(si.outstanding_amount, 200)
|
||||
|
||||
# the same invoice created outside the tool keeps the default taxes,
|
||||
# since adding them there is the user's decision
|
||||
si = frappe.get_doc(tool.get_invoices()[0])
|
||||
si.flags.ignore_mandatory = True
|
||||
si.insert()
|
||||
self.assertTrue(si.taxes)
|
||||
self.assertEqual(si.grand_total, 218)
|
||||
|
||||
def test_opening_entry_project_linking(self):
|
||||
doc = self.make_invoices(
|
||||
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True
|
||||
|
||||
@@ -2893,7 +2893,11 @@ def get_payment_entry(
|
||||
reference_date=None,
|
||||
created_from_payment_request=False,
|
||||
):
|
||||
frappe.has_permission("Payment Entry", ptype="create", throw=True)
|
||||
|
||||
doc = frappe.get_doc(dt, dn)
|
||||
doc.check_permission()
|
||||
|
||||
over_billing_allowance = frappe.get_single_value("Accounts Settings", "over_billing_allowance")
|
||||
if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= (100.0 + over_billing_allowance):
|
||||
frappe.throw(_("Can only make payment against unbilled {0}").format(_(dt)))
|
||||
|
||||
@@ -83,6 +83,7 @@ def get_supplier_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
@frappe.whitelist()
|
||||
def make_payment_records(name, supplier, mode_of_payment=None):
|
||||
doc = frappe.get_doc("Payment Order", name)
|
||||
doc.check_permission()
|
||||
make_journal_entry(doc, supplier, mode_of_payment)
|
||||
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ frappe.ui.form.on("Payment Request", "is_a_subscription", function (frm) {
|
||||
freeze: true,
|
||||
callback: function (data) {
|
||||
if (!data.exc) {
|
||||
frm.clear_table("subscription_plans");
|
||||
$.each(data.message || [], function (i, v) {
|
||||
var d = frappe.model.add_child(
|
||||
frm.doc,
|
||||
|
||||
@@ -711,6 +711,7 @@ def make_payment_request(**args):
|
||||
party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
|
||||
party_account_currency = get_account_currency(party_account)
|
||||
|
||||
subscription_plans = get_subscription_details(ref_doc.doctype, ref_doc.name)
|
||||
pr.update(
|
||||
{
|
||||
"payment_gateway_account": gateway_account.get("name"),
|
||||
@@ -742,12 +743,24 @@ def make_payment_request(**args):
|
||||
or gateway_account.get("payment_channel", "Email") != "Email"
|
||||
),
|
||||
"phone_number": args.get("phone_number") if args.get("phone_number") else None,
|
||||
"is_a_subscription": 1 if subscription_plans else 0,
|
||||
}
|
||||
)
|
||||
|
||||
if selected_payment_schedules:
|
||||
apply_payment_references(pr, payment_reference)
|
||||
|
||||
if subscription_plans:
|
||||
pr.set(
|
||||
"subscription_plans",
|
||||
[
|
||||
{
|
||||
"plan": row.plan,
|
||||
"qty": row.qty,
|
||||
}
|
||||
for row in subscription_plans
|
||||
],
|
||||
)
|
||||
# Dimensions
|
||||
pr.update(
|
||||
{
|
||||
@@ -1061,19 +1074,25 @@ def get_dummy_message(doc):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_subscription_details(reference_doctype, reference_name):
|
||||
if reference_doctype == "Sales Invoice":
|
||||
subscriptions = frappe.db.sql(
|
||||
"""SELECT parent as sub_name FROM `tabSubscription Invoice` WHERE invoice=%s""",
|
||||
reference_name,
|
||||
as_dict=1,
|
||||
)
|
||||
subscription_plans = []
|
||||
for subscription in subscriptions:
|
||||
plans = frappe.get_doc("Subscription", subscription.sub_name).plans
|
||||
for plan in plans:
|
||||
subscription_plans.append(plan)
|
||||
return subscription_plans
|
||||
def get_subscription_details(reference_doctype: str, reference_name: str) -> list[dict]:
|
||||
frappe.has_permission(reference_doctype, "read", reference_name, throw=True)
|
||||
|
||||
if not frappe.get_meta(reference_doctype).has_field("subscription"):
|
||||
return []
|
||||
|
||||
subscription = frappe.db.get_value(reference_doctype, reference_name, "subscription")
|
||||
|
||||
if not subscription:
|
||||
return []
|
||||
|
||||
return frappe.get_all(
|
||||
"Subscription Plan Detail",
|
||||
filters={"parent": subscription, "parenttype": "Subscription", "parentfield": "plans"},
|
||||
fields=[
|
||||
"plan",
|
||||
"qty",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -1169,6 +1188,7 @@ def get_irequests_of_payment_request(doc: str | None = None) -> list:
|
||||
@frappe.whitelist()
|
||||
def get_available_payment_schedules(reference_doctype, reference_name):
|
||||
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 []
|
||||
|
||||
@@ -261,12 +261,15 @@ def get_cashiers(doctype, txt, searchfield, start, page_len, filters):
|
||||
@frappe.whitelist()
|
||||
def get_invoices(start, end, pos_profile, user):
|
||||
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
|
||||
|
||||
|
||||
@@ -497,6 +497,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"depends_on": "customer",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -1636,7 +1637,7 @@
|
||||
"icon": "fa fa-file-text",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-28 12:22:50.253090",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice",
|
||||
|
||||
@@ -280,40 +280,40 @@ def pos_profile_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
user = frappe.session["user"]
|
||||
company = filters.get("company") or frappe.defaults.get_user_default("company")
|
||||
|
||||
args = {
|
||||
"user": user,
|
||||
"start": start,
|
||||
"company": company,
|
||||
"page_len": page_len,
|
||||
"txt": "%%%s%%" % txt,
|
||||
}
|
||||
allowed_pos_profiles = frappe.get_list("POS Profile", pluck="name")
|
||||
|
||||
pos_profile = frappe.db.sql(
|
||||
"""select pf.name
|
||||
from
|
||||
`tabPOS Profile` pf, `tabPOS Profile User` pfu
|
||||
where
|
||||
pfu.parent = pf.name and pfu.user = %(user)s and pf.company = %(company)s
|
||||
and (pf.name like %(txt)s)
|
||||
and pf.disabled = 0 limit %(page_len)s offset %(start)s""",
|
||||
args,
|
||||
if not allowed_pos_profiles:
|
||||
return {}
|
||||
|
||||
pf = frappe.qb.DocType("POS Profile")
|
||||
pfu = frappe.qb.DocType("POS Profile User")
|
||||
|
||||
pos_profile = (
|
||||
frappe.qb.from_(pf)
|
||||
.inner_join(pfu)
|
||||
.on(pfu.parent == pf.name)
|
||||
.select(pf.name)
|
||||
.where((pfu.user == user) & (pf.company == company) & pf.name.like(f"%{txt}%") & (pf.disabled == 0))
|
||||
.where(pf.name.isin(allowed_pos_profiles))
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
.run()
|
||||
)
|
||||
|
||||
if not pos_profile:
|
||||
del args["user"]
|
||||
|
||||
pos_profile = frappe.db.sql(
|
||||
"""select pf.name
|
||||
from
|
||||
`tabPOS Profile` pf left join `tabPOS Profile User` pfu
|
||||
on
|
||||
pf.name = pfu.parent
|
||||
where
|
||||
ifnull(pfu.user, '') = ''
|
||||
and pf.company = %(company)s
|
||||
and pf.name like %(txt)s
|
||||
and pf.disabled = 0""",
|
||||
args,
|
||||
pos_profile = (
|
||||
frappe.qb.from_(pf)
|
||||
.left_join(pfu)
|
||||
.on(pf.name == pfu.parent)
|
||||
.select(pf.name)
|
||||
.where(
|
||||
(pfu.user.isnull() | (pfu.user == ""))
|
||||
& (pf.company == company)
|
||||
& pf.name.like(f"%{txt}%")
|
||||
& (pf.disabled == 0)
|
||||
& (pf.name.isin(allowed_pos_profiles))
|
||||
)
|
||||
.run()
|
||||
)
|
||||
|
||||
return pos_profile
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
{
|
||||
"fieldname": "fieldname",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Fieldname"
|
||||
"in_list_view": 1,
|
||||
"label": "Fieldname",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "field",
|
||||
@@ -26,7 +27,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-29 18:08:40.323579",
|
||||
"modified": "2026-08-31 20:41:12.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Search Fields",
|
||||
|
||||
@@ -1,40 +1,9 @@
|
||||
// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
let search_fields_datatypes = [
|
||||
"Data",
|
||||
"Link",
|
||||
"Dynamic Link",
|
||||
"Long Text",
|
||||
"Select",
|
||||
"Small Text",
|
||||
"Text",
|
||||
"Text Editor",
|
||||
];
|
||||
let do_not_include_fields = [
|
||||
"naming_series",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"stock_uom",
|
||||
"asset_naming_series",
|
||||
"default_material_request_type",
|
||||
"valuation_method",
|
||||
"warranty_period",
|
||||
"weight_uom",
|
||||
"batch_number_series",
|
||||
"serial_no_series",
|
||||
"purchase_uom",
|
||||
"customs_tariff_number",
|
||||
"sales_uom",
|
||||
"deferred_revenue_account",
|
||||
"deferred_expense_account",
|
||||
"quality_inspection_template",
|
||||
"route",
|
||||
"slideshow",
|
||||
"website_image_alt",
|
||||
"thumbnail",
|
||||
"web_long_description",
|
||||
];
|
||||
function is_valid_invoice_field(df) {
|
||||
return frappe.model.no_value_type.indexOf(df.fieldtype) === -1 || df.fieldtype === "Button";
|
||||
}
|
||||
|
||||
frappe.ui.form.on("POS Settings", {
|
||||
onload: function (frm) {
|
||||
@@ -42,74 +11,97 @@ frappe.ui.form.on("POS Settings", {
|
||||
frm.trigger("add_search_options");
|
||||
},
|
||||
|
||||
invoice_type: function (frm) {
|
||||
frm.trigger("get_invoice_fields");
|
||||
},
|
||||
|
||||
get_invoice_fields: function (frm) {
|
||||
frappe.model.with_doctype("POS Invoice", () => {
|
||||
var fields = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
|
||||
if (
|
||||
frappe.model.no_value_type.indexOf(d.fieldtype) === -1 ||
|
||||
["Button"].includes(d.fieldtype)
|
||||
) {
|
||||
return { label: d.label + " (" + d.fieldtype + ")", value: d.fieldname };
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const invoice_type = frm.doc.invoice_type;
|
||||
if (!invoice_type) return;
|
||||
|
||||
frappe.model.with_doctype(invoice_type, () => {
|
||||
// the invoice type can change again while the meta loads
|
||||
if (frm.doc.invoice_type !== invoice_type) return;
|
||||
|
||||
const fields = frappe.get_doc("DocType", invoice_type).fields.filter(is_valid_invoice_field);
|
||||
|
||||
frm.fields_dict.invoice_fields.grid.update_docfield_property(
|
||||
"fieldname",
|
||||
"options",
|
||||
[""].concat(fields)
|
||||
[""].concat(
|
||||
fields.map((df) => {
|
||||
return { label: `${df.label} (${df.fieldtype})`, value: df.fieldname };
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
frm.trigger("validate_invoice_fields");
|
||||
});
|
||||
},
|
||||
|
||||
validate_invoice_fields: function (frm) {
|
||||
const valid_fieldnames = frappe
|
||||
.get_doc("DocType", frm.doc.invoice_type)
|
||||
.fields.filter(is_valid_invoice_field)
|
||||
.map((df) => df.fieldname);
|
||||
|
||||
const invalid_fields = (frm.doc.invoice_fields || [])
|
||||
.filter((row) => row.fieldname && !valid_fieldnames.includes(row.fieldname))
|
||||
.map((row) => `#${row.idx} ${row.fieldname}`);
|
||||
|
||||
if (!invalid_fields.length) return;
|
||||
|
||||
frappe.msgprint({
|
||||
title: __("Invalid POS Fields"),
|
||||
indicator: "orange",
|
||||
message: __("The following rows are not valid fields of {0} and have to be removed: {1}", [
|
||||
frm.doc.invoice_type.bold(),
|
||||
invalid_fields.join(", "),
|
||||
]),
|
||||
});
|
||||
},
|
||||
|
||||
add_search_options: function (frm) {
|
||||
frappe.model.with_doctype("Item", () => {
|
||||
var fields = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
|
||||
if (
|
||||
search_fields_datatypes.includes(d.fieldtype) &&
|
||||
!do_not_include_fields.includes(d.fieldname)
|
||||
) {
|
||||
return [d.label];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.pos_settings.pos_settings.get_pos_search_field_options",
|
||||
callback: ({ message }) => {
|
||||
const fields = message || [];
|
||||
|
||||
fields.unshift("");
|
||||
frm.fields_dict.pos_search_fields.grid.update_docfield_property("field", "options", fields);
|
||||
frm.searchable_item_fields = Object.fromEntries(
|
||||
fields.map((df) => [df.option, df.fieldname])
|
||||
);
|
||||
|
||||
frm.fields_dict.pos_search_fields.grid.update_docfield_property(
|
||||
"field",
|
||||
"options",
|
||||
[""].concat(fields.map((df) => df.option))
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("POS Search Fields", {
|
||||
field: function (frm, doctype, name) {
|
||||
var doc = frappe.get_doc(doctype, name);
|
||||
var df = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
|
||||
if (doc.field == d.label && search_fields_datatypes.includes(d.fieldtype)) {
|
||||
return d;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})[0];
|
||||
const doc = frappe.get_doc(doctype, name);
|
||||
|
||||
doc.fieldname = df.fieldname;
|
||||
frm.refresh_field("fields");
|
||||
doc.fieldname = frm.searchable_item_fields?.[doc.field] || "";
|
||||
frm.refresh_field("pos_search_fields");
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("POS Field", {
|
||||
fieldname: function (frm, doctype, name) {
|
||||
var doc = frappe.get_doc(doctype, name);
|
||||
var df = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
|
||||
return doc.fieldname == d.fieldname ? d : null;
|
||||
})[0];
|
||||
const doc = frappe.get_doc(doctype, name);
|
||||
const invoice_meta = frappe.get_doc("DocType", frm.doc.invoice_type);
|
||||
const df = invoice_meta?.fields.find((d) => d.fieldname === doc.fieldname);
|
||||
if (!df) return;
|
||||
|
||||
doc.label = df.label;
|
||||
doc.reqd = df.reqd;
|
||||
doc.options = df.options;
|
||||
doc.fieldtype = df.fieldtype;
|
||||
doc.default_value = df.default;
|
||||
frm.refresh_field("fields");
|
||||
frm.refresh_field("invoice_fields");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,8 +5,46 @@ from collections import Counter
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model import no_value_fields
|
||||
from frappe.model.document import Document
|
||||
|
||||
SEARCH_FIELD_TYPES = (
|
||||
"Data",
|
||||
"Link",
|
||||
"Dynamic Link",
|
||||
"Long Text",
|
||||
"Select",
|
||||
"Small Text",
|
||||
"Text",
|
||||
"Text Editor",
|
||||
)
|
||||
|
||||
# Item fields that are of a searchable fieldtype, but are not meaningful to search a POS item by
|
||||
DO_NOT_INCLUDE_FIELDS = (
|
||||
"naming_series",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"stock_uom",
|
||||
"asset_naming_series",
|
||||
"default_material_request_type",
|
||||
"valuation_method",
|
||||
"warranty_period",
|
||||
"weight_uom",
|
||||
"batch_number_series",
|
||||
"serial_no_series",
|
||||
"purchase_uom",
|
||||
"customs_tariff_number",
|
||||
"sales_uom",
|
||||
"deferred_revenue_account",
|
||||
"deferred_expense_account",
|
||||
"quality_inspection_template",
|
||||
"route",
|
||||
"slideshow",
|
||||
"website_image_alt",
|
||||
"thumbnail",
|
||||
"web_long_description",
|
||||
)
|
||||
|
||||
|
||||
class POSSettings(Document):
|
||||
# begin: auto-generated types
|
||||
@@ -32,17 +70,10 @@ class POSSettings(Document):
|
||||
if old_doc.invoice_type != self.invoice_type:
|
||||
self.validate_invoice_type()
|
||||
|
||||
self.validate_duplicate_invoice_fields()
|
||||
self.validate_invoice_fields()
|
||||
|
||||
def validate_invoice_fields(self):
|
||||
invoice_fields = [field.fieldname for field in self.invoice_fields]
|
||||
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
|
||||
|
||||
if len(duplicate_invoice_fields):
|
||||
for field in duplicate_invoice_fields:
|
||||
frappe.throw(
|
||||
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
|
||||
)
|
||||
self.validate_duplicate_pos_search_fields()
|
||||
self.validate_pos_search_fields()
|
||||
|
||||
def validate_invoice_type(self):
|
||||
pos_opening_entries_count = frappe.db.count(
|
||||
@@ -55,3 +86,94 @@ class POSSettings(Document):
|
||||
),
|
||||
title=_("Invoice Document Type Selection Error"),
|
||||
)
|
||||
|
||||
def validate_duplicate_invoice_fields(self):
|
||||
invoice_fields = [field.fieldname for field in self.invoice_fields]
|
||||
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
|
||||
|
||||
if len(duplicate_invoice_fields):
|
||||
for field in duplicate_invoice_fields:
|
||||
frappe.throw(
|
||||
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
|
||||
)
|
||||
|
||||
def validate_invoice_fields(self):
|
||||
if not self.invoice_type:
|
||||
return
|
||||
|
||||
meta = frappe.get_meta(self.invoice_type)
|
||||
|
||||
for field in self.invoice_fields:
|
||||
df = meta.get_field(field.fieldname)
|
||||
|
||||
if not df or not is_valid_invoice_field(df):
|
||||
frappe.throw(
|
||||
title=_("Invalid POS Field"),
|
||||
msg=_("Row #{0}: '{1}' is not a valid field of {2}.").format(
|
||||
field.idx, frappe.bold(field.fieldname or ""), frappe.bold(_(self.invoice_type))
|
||||
),
|
||||
)
|
||||
|
||||
# read only in the form, so keep them in sync with the invoice
|
||||
field.label = df.label
|
||||
field.fieldtype = df.fieldtype
|
||||
field.options = df.options
|
||||
|
||||
def validate_duplicate_pos_search_fields(self):
|
||||
fieldnames = [field.fieldname for field in self.pos_search_fields]
|
||||
duplicate_fieldnames = {key for key, value in Counter(fieldnames).items() if value > 1}
|
||||
|
||||
for fieldname in duplicate_fieldnames:
|
||||
frappe.throw(
|
||||
title=_("Duplicate POS Search Fields"),
|
||||
msg=_("'{0}' has been already added.").format(fieldname),
|
||||
)
|
||||
|
||||
def validate_pos_search_fields(self):
|
||||
searchable_fields = {df.fieldname: df for df in get_searchable_item_fields()}
|
||||
|
||||
for field in self.pos_search_fields:
|
||||
df = searchable_fields.get(field.fieldname)
|
||||
|
||||
if not df:
|
||||
frappe.throw(
|
||||
title=_("Invalid POS Search Field"),
|
||||
msg=_("Row #{0}: '{1}' cannot be used to search items.").format(
|
||||
field.idx, frappe.bold(field.fieldname or "")
|
||||
),
|
||||
)
|
||||
|
||||
if field.field != get_search_field_option(df):
|
||||
frappe.throw(
|
||||
title=_("Invalid POS Search Field"),
|
||||
msg=_("Row #{0}: '{1}' does not match {2}.").format(
|
||||
field.idx, frappe.bold(field.field or ""), frappe.bold(df.fieldname)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def is_valid_invoice_field(df):
|
||||
return df.fieldtype not in no_value_fields or df.fieldtype == "Button"
|
||||
|
||||
|
||||
def get_searchable_item_fields():
|
||||
return [
|
||||
df
|
||||
for df in frappe.get_meta("Item").fields
|
||||
if df.fieldtype in SEARCH_FIELD_TYPES and df.fieldname not in DO_NOT_INCLUDE_FIELDS
|
||||
]
|
||||
|
||||
|
||||
def get_search_field_option(df):
|
||||
# the fieldname keeps the option unique, two Item fields can share a label
|
||||
return f"{df.label} ({df.fieldname})"
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_pos_search_field_options():
|
||||
frappe.has_permission("POS Settings", throw=True)
|
||||
|
||||
return [
|
||||
{"option": get_search_field_option(df), "fieldname": df.fieldname}
|
||||
for df in get_searchable_item_fields()
|
||||
]
|
||||
|
||||
@@ -2,8 +2,135 @@
|
||||
# See license.txt
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.patches.v16_0.append_fieldname_to_pos_search_fields import execute as append_fieldname
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestPOSSettings(ERPNextTestSuite):
|
||||
pass
|
||||
def setUp(self):
|
||||
self.settings = frappe.get_single("POS Settings")
|
||||
self.settings.invoice_fields = []
|
||||
self.settings.pos_search_fields = []
|
||||
|
||||
def assertInvalid(self, message):
|
||||
with self.assertRaises(frappe.ValidationError) as context:
|
||||
self.settings.save()
|
||||
|
||||
self.assertIn(message, str(context.exception))
|
||||
|
||||
def test_invoice_field_is_validated_against_invoice_type(self):
|
||||
# consolidated_invoice exists on POS Invoice only
|
||||
self.settings.invoice_type = "POS Invoice"
|
||||
self.settings.append("invoice_fields", {"fieldname": "consolidated_invoice"})
|
||||
self.settings.save()
|
||||
|
||||
self.settings.invoice_type = "Sales Invoice"
|
||||
self.assertInvalid("is not a valid field of")
|
||||
|
||||
def test_field_common_to_both_invoice_types_is_allowed(self):
|
||||
for invoice_type in ("POS Invoice", "Sales Invoice"):
|
||||
self.settings.invoice_type = invoice_type
|
||||
self.settings.invoice_fields = []
|
||||
self.settings.append("invoice_fields", {"fieldname": "po_no"})
|
||||
self.settings.save()
|
||||
|
||||
def test_unknown_invoice_field_is_not_allowed(self):
|
||||
self.settings.append("invoice_fields", {"fieldname": "not_a_field"})
|
||||
self.assertInvalid("is not a valid field of")
|
||||
|
||||
def test_layout_invoice_field_is_not_allowed(self):
|
||||
self.settings.append("invoice_fields", {"fieldname": "accounting_dimensions_section"})
|
||||
self.assertInvalid("is not a valid field of")
|
||||
|
||||
def test_invoice_field_properties_are_set_from_the_invoice(self):
|
||||
self.settings.append(
|
||||
"invoice_fields", {"fieldname": "customer", "label": "Tampered", "fieldtype": "Data"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
field = self.settings.invoice_fields[0]
|
||||
self.assertEqual(field.label, "Customer")
|
||||
self.assertEqual(field.fieldtype, "Link")
|
||||
self.assertEqual(field.options, "Customer")
|
||||
|
||||
def test_searchable_item_field_is_allowed(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
self.assertEqual(self.settings.pos_search_fields[0].fieldname, "description")
|
||||
|
||||
def test_excluded_search_field_is_not_allowed(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Item Name (item_name)", "fieldname": "item_name"}
|
||||
)
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_search_field_of_unsearchable_type_is_not_allowed(self):
|
||||
# maintain stock is a Check field
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Maintain Stock (is_stock_item)", "fieldname": "is_stock_item"}
|
||||
)
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_unknown_search_field_is_not_allowed(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Nope (not_an_item_field)", "fieldname": "not_an_item_field"}
|
||||
)
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_search_field_without_a_fieldname_is_not_allowed(self):
|
||||
# the form fills the fieldname in, it cannot be picked on its own
|
||||
self.settings.append("pos_search_fields", {"field": "Description (description)"})
|
||||
self.assertInvalid("cannot be used to search items")
|
||||
|
||||
def test_search_field_option_must_match_its_fieldname(self):
|
||||
self.settings.append("pos_search_fields", {"field": "Brand (brand)", "fieldname": "description"})
|
||||
self.assertInvalid("does not match")
|
||||
|
||||
def test_bare_label_is_not_accepted_as_a_search_field(self):
|
||||
# the stored option carries the fieldname, the patch backfills older rows
|
||||
self.settings.append("pos_search_fields", {"field": "Description", "fieldname": "description"})
|
||||
self.assertInvalid("does not match")
|
||||
|
||||
def test_duplicate_search_fields_are_not_allowed(self):
|
||||
for _ in range(2):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
|
||||
self.assertInvalid("has been already added")
|
||||
|
||||
def test_patch_appends_the_fieldname_to_a_legacy_search_field(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
row = self.settings.pos_search_fields[0].name
|
||||
frappe.db.set_value("POS Search Fields", row, "field", "Description", update_modified=False)
|
||||
|
||||
append_fieldname()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
|
||||
|
||||
def test_patch_leaves_an_already_migrated_search_field_alone(self):
|
||||
self.settings.append(
|
||||
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
|
||||
)
|
||||
self.settings.save()
|
||||
|
||||
append_fieldname()
|
||||
|
||||
row = self.settings.pos_search_fields[0].name
|
||||
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
|
||||
|
||||
def test_invoice_fields_are_skipped_when_no_invoice_type_is_selected(self):
|
||||
self.settings.invoice_type = None
|
||||
self.settings.append("invoice_fields", {"fieldname": "customer"})
|
||||
self.settings.save()
|
||||
|
||||
self.assertEqual(self.settings.invoice_fields[0].fieldname, "customer")
|
||||
|
||||
@@ -142,6 +142,8 @@ def start_pcv_processing(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def pause_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
@@ -157,6 +159,8 @@ def pause_pcv_processing(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="cancel", doc=docname, throw=True)
|
||||
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Cancelled").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
@@ -171,6 +175,8 @@ def cancel_pcv_processing(docname: str):
|
||||
|
||||
@frappe.whitelist()
|
||||
def resume_pcv_processing(docname: str):
|
||||
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
|
||||
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Running").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
|
||||
@@ -504,6 +504,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Currency and Price List",
|
||||
@@ -1703,7 +1704,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-05 15:40:16.519774",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -42,6 +42,7 @@ from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
|
||||
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
|
||||
from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
update_billed_amount_based_on_po,
|
||||
)
|
||||
@@ -286,9 +287,7 @@ class PurchaseInvoice(BuyingController):
|
||||
self.check_conversion_rate()
|
||||
self.validate_credit_to_acc()
|
||||
self.clear_unallocated_advances("Purchase Invoice Advance", "advances")
|
||||
self.check_for_on_hold_or_closed_status(
|
||||
"Purchase Order", "purchase_order", exclude_if_field="purchase_receipt"
|
||||
)
|
||||
self.check_purchase_order_on_hold_or_close("purchase_order", exclude_if_field="purchase_receipt")
|
||||
self.validate_with_previous_doc()
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.validate_uom_is_integer("stock_uom", "stock_qty")
|
||||
@@ -300,6 +299,7 @@ class PurchaseInvoice(BuyingController):
|
||||
self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
|
||||
self.set_status()
|
||||
self.validate_purchase_receipt_if_update_stock()
|
||||
self.validate_exchange_rate_with_purchase_receipt()
|
||||
validate_inter_company_party(
|
||||
self.doctype, self.supplier, self.company, self.inter_company_invoice_reference
|
||||
)
|
||||
@@ -323,6 +323,47 @@ class PurchaseInvoice(BuyingController):
|
||||
if total_billed_qty and total_received_qty:
|
||||
self.per_received = total_received_qty / total_billed_qty * 100
|
||||
|
||||
def validate_exchange_rate_with_purchase_receipt(self):
|
||||
if self.is_internal_transfer() or not erpnext.is_perpetual_inventory_enabled(self.company):
|
||||
return
|
||||
|
||||
stock_items = self.get_stock_items()
|
||||
receipts = {
|
||||
item.purchase_receipt
|
||||
for item in self.items
|
||||
if item.purchase_receipt and item.item_code in stock_items
|
||||
}
|
||||
if not receipts:
|
||||
return
|
||||
|
||||
if frappe.db.get_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"):
|
||||
return
|
||||
|
||||
mismatched = [
|
||||
f"{frappe.bold(row.name)} ({row.conversion_rate})"
|
||||
for row in frappe.get_all(
|
||||
"Purchase Receipt",
|
||||
filters={"name": ("in", list(receipts))},
|
||||
fields=["name", "currency", "conversion_rate"],
|
||||
)
|
||||
if row.currency == self.currency
|
||||
and flt(row.conversion_rate)
|
||||
and flt(row.conversion_rate) != flt(self.conversion_rate)
|
||||
]
|
||||
if not mismatched:
|
||||
return
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"Exchange rate {0} does not match the exchange rate of Purchase Receipt {1}. Use the same exchange rate as the Purchase Receipt or enable {2} in {3} to adjust the landed cost based on this invoice."
|
||||
).format(
|
||||
frappe.bold(self.conversion_rate),
|
||||
", ".join(mismatched),
|
||||
frappe.bold(_("Set Landed Cost Based on Purchase Invoice Rate")),
|
||||
get_link_to_form("Buying Settings", "Buying Settings", _("Buying Settings")),
|
||||
)
|
||||
)
|
||||
|
||||
def validate_invoice_hold(self):
|
||||
if self.is_return:
|
||||
frappe.throw(_("Return Purchase Invoice cannot be held."))
|
||||
@@ -1752,9 +1793,7 @@ class PurchaseInvoice(BuyingController):
|
||||
super().on_cancel()
|
||||
PurchaseTaxWithholding(self).on_cancel()
|
||||
|
||||
self.check_for_on_hold_or_closed_status(
|
||||
"Purchase Order", "purchase_order", exclude_if_field="purchase_receipt"
|
||||
)
|
||||
self.check_purchase_order_on_hold_or_close("purchase_order", exclude_if_field="purchase_receipt")
|
||||
|
||||
if self.is_return and not self.update_billed_amount_in_purchase_order:
|
||||
# NOTE status updating bypassed for is_return
|
||||
@@ -2081,6 +2120,11 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
if isinstance(args, str):
|
||||
args = json.loads(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)
|
||||
@@ -2104,15 +2148,13 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
or {}
|
||||
)
|
||||
|
||||
target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))
|
||||
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
|
||||
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))) * flt(
|
||||
obj.conversion_factor
|
||||
)
|
||||
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
|
||||
target.base_amount = (
|
||||
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
)
|
||||
pending_qty = flt(obj.qty) - received_and_mapped_qty(obj)
|
||||
|
||||
target.qty = pending_qty - flt(returned_qty_map.get("qty"))
|
||||
target.received_qty = pending_qty
|
||||
target.stock_qty = (pending_qty - flt(returned_qty_map.get("qty"))) * flt(obj.conversion_factor)
|
||||
target.amount = pending_qty * flt(obj.rate)
|
||||
target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
|
||||
def select_item(d):
|
||||
filtered_items = args.get("filtered_children", [])
|
||||
@@ -2142,7 +2184,9 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
"wip_composite_asset": "wip_composite_asset",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
|
||||
"condition": lambda doc: (
|
||||
abs(received_and_mapped_qty(doc)) < abs(doc.qty) and select_item(doc)
|
||||
),
|
||||
},
|
||||
"Purchase Taxes and Charges": {
|
||||
"doctype": "Purchase Taxes and Charges",
|
||||
|
||||
@@ -507,6 +507,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
)
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
|
||||
self.addCleanup(
|
||||
frappe.db.set_single_value,
|
||||
"Buying Settings",
|
||||
"set_landed_cost_based_on_purchase_invoice_rate",
|
||||
original_value,
|
||||
)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
company="_Test Company with perpetual inventory",
|
||||
@@ -518,25 +524,15 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
pi = create_purchase_invoice(pr.name)
|
||||
pi.conversion_rate = 80
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.insert)
|
||||
|
||||
pi.conversion_rate = 70
|
||||
pi.insert()
|
||||
pi.submit()
|
||||
|
||||
# Get exchnage gain and loss account
|
||||
exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account")
|
||||
|
||||
# fetching the latest GL Entry with exchange gain and loss account account
|
||||
amount = frappe.db.get_value(
|
||||
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit"
|
||||
)
|
||||
|
||||
discrepancy_caused_by_exchange_rate_diff = abs(
|
||||
pi.items[0].base_net_amount - pr.items[0].base_net_amount
|
||||
)
|
||||
|
||||
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
|
||||
|
||||
frappe.db.set_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
|
||||
self.assertFalse(
|
||||
frappe.db.exists("GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name})
|
||||
)
|
||||
|
||||
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
|
||||
@@ -544,7 +540,17 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
make_purchase_invoice as create_purchase_invoice,
|
||||
)
|
||||
|
||||
# Creating Purchase Invoice with USD currency
|
||||
original_value = frappe.db.get_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
|
||||
)
|
||||
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
|
||||
self.addCleanup(
|
||||
frappe.db.set_single_value,
|
||||
"Buying Settings",
|
||||
"set_landed_cost_based_on_purchase_invoice_rate",
|
||||
original_value,
|
||||
)
|
||||
|
||||
pr = frappe.new_doc("Purchase Receipt")
|
||||
pr.currency = "USD"
|
||||
pr.company = "_Test Company with perpetual inventory"
|
||||
@@ -558,34 +564,20 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
"rate": 100,
|
||||
},
|
||||
)
|
||||
pr.append(
|
||||
"items",
|
||||
{"item_code": "_Test Item", "qty": 1, "rate": 5, "warehouse": "Stores - TCP1"},
|
||||
)
|
||||
pr.insert()
|
||||
pr.submit()
|
||||
|
||||
# Createing purchase invoice against Purchase Receipt
|
||||
pi = create_purchase_invoice(pr.name)
|
||||
pi.conversion_rate = 80
|
||||
pi.credit_to = "_Test Payable USD - TCP1"
|
||||
pi.insert()
|
||||
pi.submit()
|
||||
|
||||
# Get exchnage gain and loss account
|
||||
exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account")
|
||||
|
||||
# fetching the latest GL Entry with exchange gain and loss account account
|
||||
amount = frappe.db.get_value(
|
||||
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "credit"
|
||||
self.assertFalse(
|
||||
frappe.db.exists("GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name})
|
||||
)
|
||||
|
||||
discrepancy_caused_by_exchange_rate_diff = abs(
|
||||
pi.items[1].base_net_amount - pr.items[1].base_net_amount
|
||||
)
|
||||
|
||||
self.assertEqual(flt(discrepancy_caused_by_exchange_rate_diff, 2), amount)
|
||||
|
||||
def test_purchase_invoice_change_naming_series(self):
|
||||
pi = frappe.copy_doc(self.globalTestRecords["Purchase Invoice"][1])
|
||||
pi.insert()
|
||||
|
||||
@@ -27,6 +27,7 @@ def start_payment_ledger_repost(docname=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:
|
||||
|
||||
@@ -612,6 +612,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"depends_on": "customer",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -2364,7 +2365,7 @@
|
||||
"link_fieldname": "consolidated_invoice"
|
||||
}
|
||||
],
|
||||
"modified": "2026-05-28 12:15:12.486443",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice",
|
||||
|
||||
@@ -7,10 +7,9 @@ def get_data():
|
||||
"non_standard_fieldnames": {
|
||||
"Tax Rule": "sales_tax_template",
|
||||
"Subscription": "sales_tax_template",
|
||||
"Restaurant": "default_tax_template",
|
||||
},
|
||||
"transactions": [
|
||||
{"label": _("Transactions"), "items": ["Sales Invoice", "Sales Order", "Delivery Note"]},
|
||||
{"label": _("References"), "items": ["POS Profile", "Subscription", "Restaurant", "Tax Rule"]},
|
||||
{"label": _("References"), "items": ["POS Profile", "Subscription", "Tax Rule"]},
|
||||
],
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -94,10 +94,15 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
options: get_party_type_options(),
|
||||
on_change: function () {
|
||||
frappe.query_report.set_filter_value("party", "");
|
||||
frappe.query_report.toggle_filter_display(
|
||||
"supplier_group",
|
||||
frappe.query_report.get_filter_value("party_type") !== "Supplier"
|
||||
);
|
||||
let is_supplier = frappe.query_report.get_filter_value("party_type") === "Supplier";
|
||||
let supplier_group_filter = frappe.query_report.get_filter("supplier_group");
|
||||
if (supplier_group_filter) {
|
||||
supplier_group_filter.df.hidden = !is_supplier;
|
||||
}
|
||||
frappe.query_report.toggle_filter_display("supplier_group", !is_supplier);
|
||||
if (!is_supplier) {
|
||||
frappe.query_report.set_filter_value("supplier_group", []);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ from collections import OrderedDict
|
||||
import frappe
|
||||
from frappe import _, qb, query_builder, scrub
|
||||
from frappe.database.schema import get_definition
|
||||
from frappe.permissions import get_allowed_docs_for_doctype
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder.functions import Date, Substring, Sum
|
||||
from frappe.utils import cint, cstr, flt, getdate, nowdate
|
||||
@@ -52,6 +53,7 @@ class ReceivablePayableReport:
|
||||
self.filters = frappe._dict(filters or {})
|
||||
self.qb_selection_filter = []
|
||||
self.ple = qb.DocType("Payment Ledger Entry")
|
||||
self.sales_person_records = None
|
||||
self.filters.report_date = getdate(self.filters.report_date or nowdate())
|
||||
self.age_as_on = (
|
||||
getdate(nowdate())
|
||||
@@ -92,6 +94,7 @@ class ReceivablePayableReport:
|
||||
self.party_type = get_party_types_from_account_type(self.account_type)
|
||||
self.party_details = {}
|
||||
self.invoices = set()
|
||||
self.sales_person_records = None
|
||||
self.skip_total_row = 0
|
||||
self.advance_payment_doctypes = get_advance_payment_doctypes()
|
||||
|
||||
@@ -206,7 +209,7 @@ class ReceivablePayableReport:
|
||||
|
||||
def get_invoices(self, ple):
|
||||
if ple.voucher_type in ("Sales Invoice", "Purchase Invoice"):
|
||||
if self.filters.get("sales_person"):
|
||||
if self.sales_person_records is not None:
|
||||
if ple.voucher_no in self.sales_person_records.get(
|
||||
"Sales Invoice", []
|
||||
) or ple.party in self.sales_person_records.get("Customer", []):
|
||||
@@ -237,7 +240,7 @@ class ReceivablePayableReport:
|
||||
]
|
||||
|
||||
def get_voucher_balance(self, ple):
|
||||
if self.filters.get("sales_person"):
|
||||
if self.sales_person_records is not None:
|
||||
if not (
|
||||
ple.party in self.sales_person_records.get("Customer", [])
|
||||
or ple.against_voucher_no in self.sales_person_records.get("Sales Invoice", [])
|
||||
@@ -896,25 +899,54 @@ class ReceivablePayableReport:
|
||||
|
||||
self.ple_query = query
|
||||
|
||||
def get_permitted_sales_persons(self, parenttype):
|
||||
if self.account_type != "Receivable":
|
||||
return None
|
||||
|
||||
permissions = frappe.permissions.get_user_permissions(frappe.session.user).get("Sales Person", [])
|
||||
if not permissions:
|
||||
return None
|
||||
|
||||
return get_allowed_docs_for_doctype(permissions, parenttype)
|
||||
|
||||
def get_sales_invoices_or_customers_based_on_sales_person(self):
|
||||
parenttypes = ["Customer", "Sales Invoice"]
|
||||
permitted = {p: self.get_permitted_sales_persons(p) for p in parenttypes}
|
||||
|
||||
if not (self.filters.get("sales_person") or any(p is not None for p in permitted.values())):
|
||||
return
|
||||
|
||||
steam = frappe.qb.DocType("Sales Team")
|
||||
|
||||
scope = []
|
||||
for parenttype in parenttypes:
|
||||
criterion = steam.parenttype == parenttype
|
||||
if (allowed := permitted[parenttype]) is not None:
|
||||
criterion &= steam.sales_person.isin(allowed or [""])
|
||||
scope.append(criterion)
|
||||
|
||||
conditions = [Criterion.any(scope)]
|
||||
|
||||
if self.filters.get("sales_person"):
|
||||
lft, rgt = frappe.db.get_value("Sales Person", self.filters.get("sales_person"), ["lft", "rgt"])
|
||||
|
||||
# nosemgrep
|
||||
records = frappe.db.sql(
|
||||
"""
|
||||
select distinct parent, parenttype
|
||||
from `tabSales Team` steam
|
||||
where parenttype in ('Customer', 'Sales Invoice')
|
||||
and exists(select name from `tabSales Person` where lft >= %s and rgt <= %s and name = steam.sales_person)
|
||||
""",
|
||||
(lft, rgt),
|
||||
as_dict=1,
|
||||
sp = frappe.qb.DocType("Sales Person")
|
||||
conditions.append(
|
||||
steam.sales_person.isin(
|
||||
frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt))
|
||||
)
|
||||
)
|
||||
|
||||
self.sales_person_records = frappe._dict()
|
||||
for d in records:
|
||||
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
|
||||
records = (
|
||||
frappe.qb.from_(steam)
|
||||
.select(steam.parent, steam.parenttype)
|
||||
.distinct()
|
||||
.where(Criterion.all(conditions))
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
self.sales_person_records = frappe._dict()
|
||||
for d in records:
|
||||
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
|
||||
|
||||
def get_invoices_based_on_sales_partner(self):
|
||||
if not self.filters.get("sales_partner"):
|
||||
|
||||
@@ -175,7 +175,7 @@ frappe.query_reports["General Ledger"] = {
|
||||
fieldname: "include_dimensions",
|
||||
label: __("Consider Accounting Dimensions"),
|
||||
fieldtype: "Check",
|
||||
default: 1,
|
||||
default: frappe.boot.sysdefaults.disable_include_dimensions ? 0 : 1,
|
||||
},
|
||||
{
|
||||
fieldname: "disable_opening_balance_calculation",
|
||||
|
||||
@@ -510,6 +510,7 @@ class GrossProfitGenerator:
|
||||
self.average_buying_rate = {}
|
||||
self.filters = frappe._dict(filters)
|
||||
self.load_invoice_items()
|
||||
self.load_drop_ship_buying_rates()
|
||||
self.get_delivery_notes()
|
||||
|
||||
self.load_product_bundle()
|
||||
@@ -518,6 +519,7 @@ class GrossProfitGenerator:
|
||||
|
||||
self.load_non_stock_items()
|
||||
self.get_returned_invoice_items()
|
||||
self.allocate_legacy_return_items()
|
||||
self.process()
|
||||
|
||||
def process(self):
|
||||
@@ -534,6 +536,8 @@ class GrossProfitGenerator:
|
||||
base_amount = 0
|
||||
|
||||
for row in reversed(self.si_list):
|
||||
sales_invoice_item = row.item_row
|
||||
|
||||
if self.filters.get("group_by") == "Monthly":
|
||||
row.monthly = formatdate(row.posting_date, "MMM YYYY")
|
||||
|
||||
@@ -596,7 +600,7 @@ class GrossProfitGenerator:
|
||||
row.buying_rate, row.base_rate = 0.0, 0.0
|
||||
|
||||
if self.is_not_invoice_row(row):
|
||||
self.update_return_invoices(row)
|
||||
self.update_return_invoices(row, sales_invoice_item)
|
||||
|
||||
if grouped_by_invoice and row.indent == 1.0:
|
||||
buying_amount += row.buying_amount
|
||||
@@ -623,26 +627,32 @@ class GrossProfitGenerator:
|
||||
if self.grouped:
|
||||
self.get_average_rate_based_on_group_by()
|
||||
|
||||
def update_return_invoices(self, row):
|
||||
if row.parent in self.returned_invoices and row.item_code in self.returned_invoices[row.parent]:
|
||||
returned_item_rows = self.returned_invoices[row.parent][row.item_code]
|
||||
for returned_item_row in returned_item_rows:
|
||||
# returned_items 'qty' should be stateful
|
||||
if returned_item_row.qty != 0:
|
||||
if row.qty >= abs(returned_item_row.qty):
|
||||
row.qty += returned_item_row.qty
|
||||
row.base_amount += flt(returned_item_row.base_amount, self.currency_precision)
|
||||
returned_item_row.qty = 0
|
||||
returned_item_row.base_amount = 0
|
||||
def update_return_invoices(self, row, sales_invoice_item):
|
||||
returned_item_rows = self.returned_invoices.get(row.parent, {}).get(sales_invoice_item)
|
||||
if not returned_item_rows:
|
||||
return
|
||||
|
||||
else:
|
||||
row.qty = 0
|
||||
row.base_amount = 0
|
||||
returned_item_row.qty += row.qty
|
||||
returned_item_row.base_amount += row.base_amount
|
||||
for returned_item_row in returned_item_rows:
|
||||
# returned_items 'qty' should be stateful
|
||||
if returned_item_row.qty != 0:
|
||||
if row.qty >= abs(returned_item_row.qty):
|
||||
row.qty += returned_item_row.qty
|
||||
row.base_amount += flt(returned_item_row.base_amount, self.currency_precision)
|
||||
returned_item_row.qty = 0
|
||||
returned_item_row.base_amount = 0
|
||||
|
||||
if not row.delivered_by_supplier:
|
||||
row.buying_amount = flt(flt(row.qty) * flt(row.buying_rate), self.currency_precision)
|
||||
else:
|
||||
returned_item_row.qty += row.qty
|
||||
returned_item_row.base_amount += row.base_amount
|
||||
row.qty = 0
|
||||
row.base_amount = 0
|
||||
|
||||
if row.delivered_by_supplier:
|
||||
buying_amount = self.get_drop_ship_buying_amount(row)
|
||||
if buying_amount is not None:
|
||||
row.buying_amount = flt(buying_amount, self.currency_precision)
|
||||
else:
|
||||
row.buying_amount = flt(flt(row.qty) * flt(row.buying_rate), self.currency_precision)
|
||||
|
||||
def get_average_rate_based_on_group_by(self):
|
||||
for key in list(self.grouped):
|
||||
@@ -723,7 +733,8 @@ class GrossProfitGenerator:
|
||||
returned_invoices = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
si.name, si_item.item_code, si_item.stock_qty as qty, si_item.base_net_amount as base_amount, si.return_against
|
||||
si.name, si_item.item_code, si_item.sales_invoice_item, si_item.stock_qty as qty,
|
||||
si_item.base_net_amount as base_amount, si.return_against
|
||||
from
|
||||
`tabSales Invoice` si, `tabSales Invoice Item` si_item
|
||||
where
|
||||
@@ -737,17 +748,73 @@ class GrossProfitGenerator:
|
||||
)
|
||||
|
||||
self.returned_invoices = frappe._dict()
|
||||
self.legacy_returned_invoices = frappe._dict()
|
||||
for inv in returned_invoices:
|
||||
self.returned_invoices.setdefault(inv.return_against, frappe._dict()).setdefault(
|
||||
inv.item_code, []
|
||||
invoice_returns = (
|
||||
self.returned_invoices if inv.sales_invoice_item else self.legacy_returned_invoices
|
||||
)
|
||||
invoice_returns.setdefault(inv.return_against, frappe._dict()).setdefault(
|
||||
inv.sales_invoice_item or inv.item_code, []
|
||||
).append(inv)
|
||||
|
||||
def skip_row(self, row):
|
||||
if self.filters.get("group_by") != "Invoice":
|
||||
if not row.get(scrub(self.filters.get("group_by", ""))):
|
||||
return True
|
||||
def allocate_legacy_return_items(self):
|
||||
source_invoice_items = {}
|
||||
for row in reversed(self.si_list):
|
||||
if row.is_return or not row.parent or self.skip_row(row):
|
||||
continue
|
||||
|
||||
return False
|
||||
source_invoice_items.setdefault((row.parent, row.item_code), {}).setdefault(row.item_row, row.qty)
|
||||
|
||||
for invoice, legacy_invoice_items in self.legacy_returned_invoices.items():
|
||||
returned_invoice_items = self.returned_invoices.setdefault(invoice, frappe._dict())
|
||||
for item_code, legacy_item_rows in legacy_invoice_items.items():
|
||||
targets = self.get_legacy_return_targets(
|
||||
source_invoice_items.get((invoice, item_code), {}), returned_invoice_items
|
||||
)
|
||||
for legacy_item_row in legacy_item_rows:
|
||||
self.allocate_legacy_return_item(legacy_item_row, targets, returned_invoice_items)
|
||||
|
||||
def get_legacy_return_targets(self, source_invoice_items, returned_invoice_items):
|
||||
targets = []
|
||||
for item_row, qty in source_invoice_items.items():
|
||||
linked_return_qty = sum(
|
||||
flt(returned_item.qty) for returned_item in returned_invoice_items.get(item_row, [])
|
||||
)
|
||||
if available_qty := max(flt(qty) + linked_return_qty, 0):
|
||||
targets.append(frappe._dict(item_row=item_row, available_qty=available_qty))
|
||||
|
||||
targets.sort(key=lambda target: bool(returned_invoice_items.get(target.item_row)))
|
||||
return targets
|
||||
|
||||
def allocate_legacy_return_item(self, legacy_item_row, targets, returned_invoice_items):
|
||||
remaining_qty = abs(flt(legacy_item_row.qty))
|
||||
remaining_base_amount = flt(legacy_item_row.base_amount)
|
||||
if not remaining_qty:
|
||||
return
|
||||
|
||||
qty_sign = -1 if legacy_item_row.qty < 0 else 1
|
||||
for target in targets:
|
||||
if not target.available_qty:
|
||||
continue
|
||||
|
||||
allocated_qty = min(target.available_qty, remaining_qty)
|
||||
allocated_item_row = frappe._dict(legacy_item_row.copy())
|
||||
allocated_item_row.qty = qty_sign * allocated_qty
|
||||
allocated_item_row.base_amount = remaining_base_amount * allocated_qty / remaining_qty
|
||||
returned_invoice_items.setdefault(target.item_row, []).append(allocated_item_row)
|
||||
|
||||
target.available_qty -= allocated_qty
|
||||
remaining_qty -= allocated_qty
|
||||
remaining_base_amount -= allocated_item_row.base_amount
|
||||
if not remaining_qty:
|
||||
break
|
||||
|
||||
def skip_row(self, row):
|
||||
group_by = self.filters.get("group_by")
|
||||
if group_by in {"Invoice", "Monthly"}:
|
||||
return False
|
||||
|
||||
return not row.get(scrub(group_by))
|
||||
|
||||
def get_buying_amount_from_product_bundle(self, row, product_bundle):
|
||||
buying_amount = 0.0
|
||||
@@ -781,28 +848,12 @@ class GrossProfitGenerator:
|
||||
# IMP NOTE
|
||||
# stock_ledger_entries should already be filtered by item_code and warehouse and
|
||||
# sorted by posting_date desc, posting_time desc
|
||||
if (
|
||||
row.delivered_by_supplier
|
||||
and row.so_detail
|
||||
and (
|
||||
po_details := frappe.get_all(
|
||||
"Purchase Order Item",
|
||||
filters={"sales_order_item": row.so_detail, "docstatus": 1},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
):
|
||||
from frappe.query_builder.functions import Sum
|
||||
if row.delivered_by_supplier:
|
||||
buying_amount = self.get_drop_ship_buying_amount(row)
|
||||
if buying_amount is not None:
|
||||
return buying_amount
|
||||
|
||||
table = frappe.qb.DocType("Purchase Invoice Item")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Sum(table.qty * table.base_net_rate))
|
||||
.where((table.po_detail.isin(po_details)) & (table.docstatus == 1))
|
||||
)
|
||||
return flt(query.run()[0][0])
|
||||
|
||||
elif item_code in self.non_stock_items and (row.project or row.cost_center):
|
||||
if item_code in self.non_stock_items and (row.project or row.cost_center):
|
||||
# Issue 6089-Get last purchasing rate for non-stock item
|
||||
item_rate = self.get_last_purchase_rate(item_code, row)
|
||||
return flt(row.qty) * item_rate
|
||||
@@ -833,6 +884,49 @@ class GrossProfitGenerator:
|
||||
|
||||
return flt(row.qty) * self.get_average_buying_rate(row, item_code)
|
||||
|
||||
def load_drop_ship_buying_rates(self):
|
||||
self.drop_ship_buying_rates = {}
|
||||
sales_order_items = {
|
||||
row.so_detail for row in self.si_list if row.delivered_by_supplier and row.so_detail
|
||||
}
|
||||
if not sales_order_items:
|
||||
return
|
||||
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
purchase_order_item = frappe.qb.DocType("Purchase Order Item")
|
||||
purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item")
|
||||
buying_amounts = (
|
||||
frappe.qb.from_(purchase_order_item)
|
||||
.left_join(purchase_invoice_item)
|
||||
.on(
|
||||
(purchase_invoice_item.po_detail == purchase_order_item.name)
|
||||
& (purchase_invoice_item.docstatus == 1)
|
||||
)
|
||||
.select(
|
||||
purchase_order_item.sales_order_item,
|
||||
Sum(purchase_invoice_item.qty * purchase_invoice_item.base_net_rate).as_("buying_amount"),
|
||||
Sum(purchase_invoice_item.stock_qty).as_("stock_qty"),
|
||||
)
|
||||
.where(
|
||||
(purchase_order_item.sales_order_item.isin(sales_order_items))
|
||||
& (purchase_order_item.docstatus == 1)
|
||||
)
|
||||
.groupby(purchase_order_item.sales_order_item)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
for row in buying_amounts:
|
||||
self.drop_ship_buying_rates[row.sales_order_item] = (
|
||||
flt(row.buying_amount) / flt(row.stock_qty) if flt(row.stock_qty) else 0
|
||||
)
|
||||
|
||||
def get_drop_ship_buying_amount(self, row):
|
||||
if row.so_detail not in self.drop_ship_buying_rates:
|
||||
return
|
||||
|
||||
return flt(row.qty) * self.drop_ship_buying_rates[row.so_detail]
|
||||
|
||||
def get_buying_amount_from_so_dn(self, sales_order, so_detail, item_code):
|
||||
from frappe.query_builder.functions import Avg
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from frappe.utils import add_days, flt, get_first_day, get_last_day, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note, make_sales_return
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.gross_profit.gross_profit import execute
|
||||
from erpnext.accounts.report.gross_profit.gross_profit import GrossProfitGenerator, execute
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note 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 create_item
|
||||
@@ -676,19 +676,9 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
self.assertEqual(total[8], 0.0) # gross profit %
|
||||
|
||||
def test_drop_ship(self):
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order, 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.selling.doctype.sales_order.sales_order import make_sales_invoice
|
||||
|
||||
item = make_item("_Test Drop Ship Item", properties={"is_stock_item": 1, "delivered_by_supplier": 1})
|
||||
|
||||
so = make_sales_order(item=item.name, qty=10, rate=100)
|
||||
po = make_purchase_order(so.name, selected_items=[so.items[0]])[0]
|
||||
po.items[0].rate = 80
|
||||
po.supplier = "_Test Supplier"
|
||||
po.submit()
|
||||
make_purchase_invoice(po.name).submit()
|
||||
so = self.create_drop_ship_order()
|
||||
si = make_sales_invoice(so.name).submit()
|
||||
|
||||
filters = frappe._dict(
|
||||
@@ -700,6 +690,357 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
self.assertIsNone(data[1].buying_rate)
|
||||
self.assertEqual(data[1]["gross_profit_%"], 20)
|
||||
|
||||
def test_drop_ship_partial_billing_and_return(self):
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
||||
|
||||
so = self.create_drop_ship_order()
|
||||
first_invoice = make_sales_invoice(so.name)
|
||||
first_invoice.items[0].qty = 4
|
||||
first_invoice.submit()
|
||||
second_invoice = make_sales_invoice(so.name).submit()
|
||||
|
||||
filters = frappe._dict(
|
||||
company=first_invoice.company,
|
||||
from_date=first_invoice.posting_date,
|
||||
to_date=first_invoice.posting_date,
|
||||
group_by="Invoice",
|
||||
)
|
||||
_, data = execute(filters=filters)
|
||||
invoice_rows = {
|
||||
row.parent_invoice: row
|
||||
for row in data
|
||||
if row.parent_invoice in {first_invoice.name, second_invoice.name} and row.indent == 1
|
||||
}
|
||||
self.assertEqual(invoice_rows[first_invoice.name].buying_amount, 320)
|
||||
self.assertEqual(invoice_rows[second_invoice.name].buying_amount, 480)
|
||||
|
||||
sales_return = make_sales_return(first_invoice.name)
|
||||
sales_return.items[0].qty = -2
|
||||
sales_return.submit()
|
||||
|
||||
_, data = execute(filters=filters)
|
||||
first_invoice_row = next(
|
||||
row for row in data if row.parent_invoice == first_invoice.name and row.indent == 1
|
||||
)
|
||||
self.assertEqual(first_invoice_row.qty, 2)
|
||||
self.assertEqual(first_invoice_row.buying_amount, 160)
|
||||
self.assertEqual(first_invoice_row.gross_profit, 40)
|
||||
|
||||
def test_drop_ship_return_matches_sales_invoice_item(self):
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order, 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
|
||||
|
||||
item = make_item(
|
||||
"_Test Drop Ship Consolidated Return Item",
|
||||
properties={"is_stock_item": 1, "delivered_by_supplier": 1},
|
||||
)
|
||||
sales_orders = []
|
||||
for qty, selling_rate, buying_rate in [(4, 100, 50), (6, 200, 80)]:
|
||||
sales_order = make_sales_order(item=item.name, qty=qty, rate=selling_rate, do_not_submit=True)
|
||||
sales_order.items[0].delivered_by_supplier = 1
|
||||
sales_order.items[0].supplier = "_Test Supplier"
|
||||
sales_order.submit()
|
||||
sales_orders.append(sales_order)
|
||||
|
||||
purchase_order = make_purchase_order(sales_order.name, selected_items=[sales_order.items[0]])[0]
|
||||
purchase_order.items[0].rate = buying_rate
|
||||
purchase_order.supplier = "_Test Supplier"
|
||||
purchase_order.submit()
|
||||
make_purchase_invoice(purchase_order.name).submit()
|
||||
|
||||
sales_invoice = make_sales_invoice(sales_orders[0].name)
|
||||
sales_invoice = make_sales_invoice(sales_orders[1].name, target_doc=sales_invoice).submit()
|
||||
sales_return = make_sales_return(sales_invoice.name)
|
||||
sales_return.set("items", [sales_return.items[0]])
|
||||
sales_return.items[0].qty = -1
|
||||
sales_return.submit()
|
||||
|
||||
filters = frappe._dict(
|
||||
company=sales_invoice.company,
|
||||
from_date=sales_invoice.posting_date,
|
||||
to_date=sales_invoice.posting_date,
|
||||
group_by="Invoice",
|
||||
)
|
||||
_, data = execute(filters=filters)
|
||||
invoice_rows = [row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1]
|
||||
invoice_rows.sort(key=lambda row: row["avg._selling_rate"])
|
||||
self.assertEqual([row.qty for row in invoice_rows], [3, 6])
|
||||
self.assertEqual([row.buying_amount for row in invoice_rows], [150, 480])
|
||||
|
||||
def test_return_matches_sales_invoice_item_for_delivery_note(self):
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=self.item,
|
||||
target=self.warehouse,
|
||||
qty=4,
|
||||
basic_rate=50,
|
||||
)
|
||||
delivery_note = self.create_delivery_note(qty=4, rate=100)
|
||||
sales_invoice = make_sales_invoice(delivery_note.name).submit()
|
||||
sales_return = make_sales_return(sales_invoice.name)
|
||||
sales_return.items[0].qty = -1
|
||||
sales_return.submit()
|
||||
|
||||
filters = frappe._dict(
|
||||
company=sales_invoice.company,
|
||||
from_date=sales_invoice.posting_date,
|
||||
to_date=sales_invoice.posting_date,
|
||||
group_by="Invoice",
|
||||
)
|
||||
_, data = execute(filters=filters)
|
||||
invoice_row = next(
|
||||
row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1
|
||||
)
|
||||
self.assertEqual(invoice_row.qty, 3)
|
||||
self.assertEqual(invoice_row.selling_amount, 300)
|
||||
|
||||
def test_return_combines_linked_and_legacy_item_buckets(self):
|
||||
sales_invoice = self.create_sales_invoice(qty=4, rate=100)
|
||||
linked_return = make_sales_return(sales_invoice.name)
|
||||
linked_return.items[0].qty = -1
|
||||
linked_return.submit()
|
||||
|
||||
legacy_return = make_sales_return(sales_invoice.name)
|
||||
legacy_return.items[0].qty = -1
|
||||
legacy_return.submit()
|
||||
frappe.db.set_value("Sales Invoice Item", legacy_return.items[0].name, "sales_invoice_item", None)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=sales_invoice.company,
|
||||
from_date=sales_invoice.posting_date,
|
||||
to_date=sales_invoice.posting_date,
|
||||
group_by="Invoice",
|
||||
)
|
||||
_, data = execute(filters=filters)
|
||||
invoice_row = next(
|
||||
row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1
|
||||
)
|
||||
self.assertEqual(invoice_row.qty, 2)
|
||||
self.assertEqual(invoice_row.selling_amount, 200)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": True})
|
||||
def test_legacy_return_prefers_item_without_linked_return(self):
|
||||
sales_invoice = self.create_sales_invoice(qty=2, rate=100, do_not_submit=True)
|
||||
second_item = frappe.copy_doc(sales_invoice.items[0], ignore_no_copy=False)
|
||||
second_item.rate = 200
|
||||
sales_invoice.append("items", second_item)
|
||||
sales_invoice.submit()
|
||||
|
||||
linked_return = make_sales_return(sales_invoice.name)
|
||||
linked_return.set("items", [linked_return.items[0]])
|
||||
linked_return.items[0].qty = -1
|
||||
linked_return.submit()
|
||||
|
||||
legacy_return = make_sales_return(sales_invoice.name)
|
||||
legacy_return.set("items", [legacy_return.items[1]])
|
||||
legacy_return.items[0].qty = -1
|
||||
legacy_return.submit()
|
||||
frappe.db.set_value("Sales Invoice Item", legacy_return.items[0].name, "sales_invoice_item", None)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=sales_invoice.company,
|
||||
from_date=sales_invoice.posting_date,
|
||||
to_date=sales_invoice.posting_date,
|
||||
group_by="Invoice",
|
||||
)
|
||||
_, data = execute(filters=filters)
|
||||
invoice_rows = [row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1]
|
||||
invoice_rows.sort(key=lambda row: row["avg._selling_rate"])
|
||||
self.assertEqual([row.qty for row in invoice_rows], [1, 1])
|
||||
self.assertEqual([row.selling_amount for row in invoice_rows], [100, 200])
|
||||
|
||||
def test_legacy_return_remainder_spills_into_linked_item(self):
|
||||
invoice = "SINV-TEST-RETURN-ALLOCATION"
|
||||
linked_item = "SINV-ITEM-LINKED"
|
||||
unlinked_item = "SINV-ITEM-LEGACY"
|
||||
generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
|
||||
generator.currency_precision = 3
|
||||
generator.filters = frappe._dict(group_by="Invoice")
|
||||
generator.returned_invoices = frappe._dict(
|
||||
{invoice: frappe._dict({linked_item: [frappe._dict(qty=-1, base_amount=-100)]})}
|
||||
)
|
||||
generator.legacy_returned_invoices = frappe._dict(
|
||||
{invoice: frappe._dict({self.item: [frappe._dict(qty=-2, base_amount=-200)]})}
|
||||
)
|
||||
linked_row = frappe._dict(
|
||||
parent=invoice,
|
||||
item_code=self.item,
|
||||
item_row=linked_item,
|
||||
is_return=False,
|
||||
qty=3,
|
||||
base_amount=300,
|
||||
buying_rate=50,
|
||||
delivered_by_supplier=False,
|
||||
)
|
||||
unlinked_row = frappe._dict(
|
||||
parent=invoice,
|
||||
item_code=self.item,
|
||||
item_row=unlinked_item,
|
||||
is_return=False,
|
||||
qty=1,
|
||||
base_amount=100,
|
||||
buying_rate=50,
|
||||
delivered_by_supplier=False,
|
||||
)
|
||||
|
||||
generator.si_list = [unlinked_row, linked_row]
|
||||
generator.allocate_legacy_return_items()
|
||||
generator.update_return_invoices(linked_row, linked_item)
|
||||
generator.update_return_invoices(unlinked_row, unlinked_item)
|
||||
|
||||
self.assertEqual((linked_row.qty, linked_row.base_amount), (1, 100))
|
||||
self.assertEqual((unlinked_row.qty, unlinked_row.base_amount), (0, 0))
|
||||
|
||||
def test_legacy_return_ignores_skipped_group_rows(self):
|
||||
invoice = "SINV-TEST-SKIPPED-RETURN-ALLOCATION"
|
||||
visible_item = "SINV-ITEM-WITH-PROJECT"
|
||||
skipped_item = "SINV-ITEM-WITHOUT-PROJECT"
|
||||
generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
|
||||
generator.currency_precision = 3
|
||||
generator.filters = frappe._dict(group_by="Project")
|
||||
generator.returned_invoices = frappe._dict(
|
||||
{invoice: frappe._dict({visible_item: [frappe._dict(qty=-1, base_amount=-100)]})}
|
||||
)
|
||||
generator.legacy_returned_invoices = frappe._dict(
|
||||
{invoice: frappe._dict({self.item: [frappe._dict(qty=-1, base_amount=-100)]})}
|
||||
)
|
||||
visible_row = frappe._dict(
|
||||
parent=invoice,
|
||||
item_code=self.item,
|
||||
item_row=visible_item,
|
||||
is_return=False,
|
||||
project="_Test Project",
|
||||
qty=2,
|
||||
base_amount=200,
|
||||
buying_rate=50,
|
||||
delivered_by_supplier=False,
|
||||
)
|
||||
skipped_row = frappe._dict(
|
||||
parent=invoice,
|
||||
item_code=self.item,
|
||||
item_row=skipped_item,
|
||||
is_return=False,
|
||||
project=None,
|
||||
qty=1,
|
||||
)
|
||||
|
||||
generator.si_list = [visible_row, skipped_row]
|
||||
generator.allocate_legacy_return_items()
|
||||
generator.update_return_invoices(visible_row, visible_item)
|
||||
|
||||
self.assertNotIn(skipped_item, generator.returned_invoices[invoice])
|
||||
self.assertEqual((visible_row.qty, visible_row.base_amount), (0, 0))
|
||||
|
||||
def test_monthly_group_allocates_legacy_return(self):
|
||||
invoice = "SINV-TEST-MONTHLY-RETURN-ALLOCATION"
|
||||
item_row = "SINV-ITEM-MONTHLY-RETURN"
|
||||
generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
|
||||
generator.currency_precision = 3
|
||||
generator.filters = frappe._dict(group_by="Monthly")
|
||||
generator.returned_invoices = frappe._dict()
|
||||
generator.legacy_returned_invoices = frappe._dict(
|
||||
{invoice: frappe._dict({self.item: [frappe._dict(qty=-1, base_amount=-100)]})}
|
||||
)
|
||||
invoice_row = frappe._dict(
|
||||
parent=invoice,
|
||||
item_code=self.item,
|
||||
item_row=item_row,
|
||||
is_return=False,
|
||||
posting_date=nowdate(),
|
||||
qty=1,
|
||||
base_amount=100,
|
||||
buying_rate=50,
|
||||
delivered_by_supplier=False,
|
||||
)
|
||||
|
||||
generator.si_list = [invoice_row]
|
||||
generator.allocate_legacy_return_items()
|
||||
generator.update_return_invoices(invoice_row, item_row)
|
||||
|
||||
self.assertEqual((invoice_row.qty, invoice_row.base_amount), (0, 0))
|
||||
|
||||
def test_return_remainder_stays_available_for_next_row(self):
|
||||
invoice = "SINV-TEST-RETURN-REMAINDER"
|
||||
item_row = "SINV-ITEM-RETURN-REMAINDER"
|
||||
returned_item = frappe._dict(qty=-2, base_amount=-200)
|
||||
generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
|
||||
generator.currency_precision = 3
|
||||
generator.returned_invoices = frappe._dict({invoice: frappe._dict({item_row: [returned_item]})})
|
||||
first_row = frappe._dict(
|
||||
parent=invoice,
|
||||
item_code=self.item,
|
||||
qty=1,
|
||||
base_amount=100,
|
||||
buying_rate=50,
|
||||
delivered_by_supplier=False,
|
||||
)
|
||||
second_row = first_row.copy()
|
||||
|
||||
generator.update_return_invoices(first_row, item_row)
|
||||
self.assertEqual((returned_item.qty, returned_item.base_amount), (-1, -100))
|
||||
|
||||
generator.update_return_invoices(second_row, item_row)
|
||||
self.assertEqual((returned_item.qty, returned_item.base_amount), (0, 0))
|
||||
self.assertEqual((first_row.qty, second_row.qty), (0, 0))
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": True})
|
||||
def test_return_keeps_buying_amount_of_unreturned_row(self):
|
||||
unreturned_item = create_item(
|
||||
"_Test Gross Profit Unreturned Item", warehouse=self.warehouse, company=self.company
|
||||
)
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=unreturned_item.name,
|
||||
target=self.warehouse,
|
||||
qty=40000,
|
||||
basic_rate=33.33333,
|
||||
)
|
||||
sales_invoice = self.create_sales_invoice(qty=1, rate=100, do_not_submit=True)
|
||||
second_item = frappe.copy_doc(sales_invoice.items[0], ignore_no_copy=False)
|
||||
second_item.item_code = unreturned_item.name
|
||||
second_item.item_name = unreturned_item.name
|
||||
second_item.qty = 30000
|
||||
sales_invoice.append("items", second_item)
|
||||
sales_invoice.submit()
|
||||
|
||||
sales_return = make_sales_return(sales_invoice.name)
|
||||
sales_return.set("items", [sales_return.items[0]])
|
||||
sales_return.items[0].qty = -1
|
||||
sales_return.submit()
|
||||
|
||||
filters = frappe._dict(
|
||||
company=sales_invoice.company,
|
||||
from_date=sales_invoice.posting_date,
|
||||
to_date=sales_invoice.posting_date,
|
||||
group_by="Invoice",
|
||||
)
|
||||
_, data = execute(filters=filters)
|
||||
invoice_row = next(
|
||||
row
|
||||
for row in data
|
||||
if row.parent_invoice == sales_invoice.name and row.item_code == unreturned_item.name
|
||||
)
|
||||
self.assertEqual(invoice_row.qty, 30000)
|
||||
self.assertEqual(invoice_row.buying_amount, 999999.9)
|
||||
|
||||
def create_drop_ship_order(self, qty=10, selling_rate=100, buying_rate=80):
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item = make_item("_Test Drop Ship Item", properties={"is_stock_item": 1, "delivered_by_supplier": 1})
|
||||
so = make_sales_order(item=item.name, qty=qty, rate=selling_rate)
|
||||
purchase_order = make_purchase_order(so.name, selected_items=[so.items[0]])[0]
|
||||
purchase_order.items[0].rate = buying_rate
|
||||
purchase_order.supplier = "_Test Supplier"
|
||||
purchase_order.submit()
|
||||
make_purchase_invoice(purchase_order.name).submit()
|
||||
|
||||
return so
|
||||
|
||||
def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None):
|
||||
"""Create a rate adjustment debit note with no stock movement."""
|
||||
dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True)
|
||||
|
||||
@@ -9,6 +9,7 @@ from frappe.utils import cint, flt
|
||||
|
||||
from erpnext.accounts.report.general_ledger.general_ledger import get_accounts_with_children
|
||||
from erpnext.accounts.report.trial_balance.trial_balance import validate_filters
|
||||
from erpnext.accounts.utils import get_currency_precision
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -43,6 +44,7 @@ def get_data(filters, show_party_name):
|
||||
account_filter = get_accounts_with_children(filters.get("account"))
|
||||
|
||||
company_currency = frappe.get_cached_value("Company", filters.company, "default_currency")
|
||||
precision = get_currency_precision()
|
||||
opening_balances = get_opening_balances(filters, account_filter)
|
||||
balances_within_period = get_balances_within_period(filters, account_filter)
|
||||
|
||||
@@ -65,14 +67,17 @@ def get_data(filters, show_party_name):
|
||||
|
||||
# opening
|
||||
opening_debit, opening_credit = opening_balances.get(party.name, [0, 0])
|
||||
opening_debit, opening_credit = flt(opening_debit, precision), flt(opening_credit, precision)
|
||||
row.update({"opening_debit": opening_debit, "opening_credit": opening_credit})
|
||||
|
||||
# within period
|
||||
debit, credit = balances_within_period.get(party.name, [0, 0])
|
||||
debit, credit = flt(debit, precision), flt(credit, precision)
|
||||
row.update({"debit": debit, "credit": credit})
|
||||
|
||||
# closing
|
||||
closing_debit, closing_credit = toggle_debit_credit(opening_debit + debit, opening_credit + credit)
|
||||
closing_debit, closing_credit = flt(closing_debit, precision), flt(closing_credit, precision)
|
||||
row.update({"closing_debit": closing_debit, "closing_credit": closing_credit})
|
||||
|
||||
row.update({"currency": company_currency})
|
||||
|
||||
@@ -304,6 +304,9 @@ def get_payment_entries(filters, args):
|
||||
pe.mode_of_payment,
|
||||
pe.project,
|
||||
pe.cost_center,
|
||||
pe.payment_type,
|
||||
pe.source_exchange_rate,
|
||||
pe.target_exchange_rate,
|
||||
)
|
||||
.where(
|
||||
(pe.docstatus == 1)
|
||||
@@ -314,6 +317,22 @@ def get_payment_entries(filters, args):
|
||||
)
|
||||
query = apply_common_conditions(filters, query, doctype="Payment Entry", payments=True)
|
||||
payment_entries = query.run(as_dict=True)
|
||||
|
||||
if payment_entries:
|
||||
ded = frappe.qb.DocType("Payment Entry Deduction")
|
||||
deduction_totals = frappe._dict(
|
||||
frappe.qb.from_(ded)
|
||||
.select(ded.parent, Sum(ded.amount))
|
||||
.where(ded.parent.isin([d.name for d in payment_entries]) & (ded.is_exchange_gain_loss == 0))
|
||||
.groupby(ded.parent)
|
||||
.run()
|
||||
)
|
||||
for d in payment_entries:
|
||||
exchange_rate = (
|
||||
d.source_exchange_rate if d.payment_type == "Receive" else d.target_exchange_rate
|
||||
) or 1
|
||||
d.base_grand_total = flt(d.base_grand_total) + flt(deduction_totals.get(d.name)) / exchange_rate
|
||||
|
||||
return payment_entries
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.party import get_party_shipping_address
|
||||
from erpnext.accounts.utils import (
|
||||
get_currency_precision,
|
||||
get_future_stock_vouchers,
|
||||
get_voucherwise_gl_entries,
|
||||
get_zero_cutoff,
|
||||
@@ -155,3 +156,18 @@ class TestUtils(ERPNextTestSuite):
|
||||
self.assertEqual(get_zero_cutoff(None), 0.005)
|
||||
self.assertEqual(get_zero_cutoff("EUR"), 0.005)
|
||||
self.assertEqual(get_zero_cutoff("BHD"), 0.0005)
|
||||
|
||||
def test_get_currency_precision_respects_zero_and_fallback(self):
|
||||
currency_precision = frappe.db.get_default("currency_precision")
|
||||
number_format = frappe.db.get_default("number_format")
|
||||
|
||||
try:
|
||||
frappe.db.set_default("number_format", "#,###.##")
|
||||
frappe.db.set_default("currency_precision", "0")
|
||||
self.assertEqual(get_currency_precision(), 0)
|
||||
|
||||
frappe.db.set_default("currency_precision", "")
|
||||
self.assertEqual(get_currency_precision(), 2)
|
||||
finally:
|
||||
frappe.db.set_default("currency_precision", currency_precision or "")
|
||||
frappe.db.set_default("number_format", number_format or "#,###.##")
|
||||
|
||||
@@ -1189,12 +1189,12 @@ def fix_total_debit_credit():
|
||||
|
||||
|
||||
def get_currency_precision():
|
||||
precision = cint(frappe.db.get_default("currency_precision"))
|
||||
if not precision:
|
||||
number_format = frappe.db.get_default("number_format") or "#,###.##"
|
||||
precision = get_number_format_info(number_format)[2]
|
||||
currency_precision = frappe.db.get_default("currency_precision")
|
||||
if currency_precision not in (None, ""):
|
||||
return cint(currency_precision)
|
||||
|
||||
return precision
|
||||
number_format = frappe.db.get_default("number_format") or "#,###.##"
|
||||
return get_number_format_info(number_format)[2]
|
||||
|
||||
|
||||
def get_fraction_units(currency: str) -> int:
|
||||
|
||||
@@ -158,6 +158,8 @@ class AssetCapitalization(StockController):
|
||||
if d.meta.has_field(k) and (not d.get(k) or k in force_fields):
|
||||
d.set(k, v)
|
||||
|
||||
self.split_valuation_rate_for_grouped_stock_items()
|
||||
|
||||
for d in self.asset_items:
|
||||
args = self.as_dict()
|
||||
args.update(d.as_dict())
|
||||
@@ -179,6 +181,30 @@ class AssetCapitalization(StockController):
|
||||
if d.meta.has_field(k) and (not d.get(k) or k in force_fields):
|
||||
d.set(k, v)
|
||||
|
||||
def split_valuation_rate_for_grouped_stock_items(self):
|
||||
groups = {}
|
||||
for d in self.stock_items:
|
||||
if d.item_code and d.warehouse and not (d.serial_no or d.batch_no or d.serial_and_batch_bundle):
|
||||
groups.setdefault((d.item_code, d.warehouse), []).append(d)
|
||||
|
||||
for rows in groups.values():
|
||||
if len(rows) < 2:
|
||||
continue
|
||||
|
||||
cumulative_qty = 0.0
|
||||
prev_cumulative_value = 0.0
|
||||
for d in rows:
|
||||
cumulative_qty += flt(d.stock_qty)
|
||||
args = self.get_args_for_incoming_rate(d)
|
||||
args["qty"] = -1 * cumulative_qty
|
||||
cumulative_rate = flt(get_incoming_rate(args, raise_error_if_no_rate=False))
|
||||
cumulative_value = cumulative_rate * cumulative_qty
|
||||
|
||||
row_value = cumulative_value - prev_cumulative_value
|
||||
d.valuation_rate = flt(row_value / d.stock_qty) if flt(d.stock_qty) else 0.0
|
||||
d.amount = flt(flt(d.stock_qty) * d.valuation_rate, d.precision("amount"))
|
||||
prev_cumulative_value = cumulative_value
|
||||
|
||||
def validate_target_item(self):
|
||||
target_item = frappe.get_cached_doc("Item", self.target_item_code)
|
||||
|
||||
@@ -312,6 +338,8 @@ class AssetCapitalization(StockController):
|
||||
warehouse_details = get_warehouse_details(args)
|
||||
d.update(warehouse_details)
|
||||
|
||||
self.split_valuation_rate_for_grouped_stock_items()
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_asset_values(self):
|
||||
for d in self.get("asset_items"):
|
||||
|
||||
@@ -400,6 +400,33 @@ class TestAssetCapitalization(ERPNextTestSuite):
|
||||
actual_gle = get_actual_gle_dict(asset_capitalization.name)
|
||||
self.assertEqual(actual_gle, {})
|
||||
|
||||
def test_grouped_stock_item_rows_split_fifo_rate(self):
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
company = "_Test Company"
|
||||
warehouse = create_warehouse("_Test Warehouse for Grouped FIFO Rows", company=company)
|
||||
item = create_item(
|
||||
"_Test Grouped FIFO Rows Item", is_stock_item=1, is_fixed_asset=0, is_purchase_item=1
|
||||
)
|
||||
target_item = create_fixed_asset_item("_Test Grouped FIFO Rows Target Item")
|
||||
|
||||
make_purchase_receipt(item_code=item.item_code, qty=1, rate=100, company=company, warehouse=warehouse)
|
||||
make_purchase_receipt(item_code=item.item_code, qty=1, rate=200, company=company, warehouse=warehouse)
|
||||
|
||||
asset_capitalization = frappe.new_doc("Asset Capitalization")
|
||||
asset_capitalization.company = company
|
||||
asset_capitalization.target_item_code = target_item.name
|
||||
asset_capitalization.append(
|
||||
"stock_items", {"item_code": item.item_code, "warehouse": warehouse, "stock_qty": 1}
|
||||
)
|
||||
asset_capitalization.append(
|
||||
"stock_items", {"item_code": item.item_code, "warehouse": warehouse, "stock_qty": 1}
|
||||
)
|
||||
asset_capitalization.insert()
|
||||
|
||||
rates = [d.valuation_rate for d in asset_capitalization.stock_items]
|
||||
self.assertEqual(rates, [100, 200])
|
||||
|
||||
|
||||
def create_asset_capitalization_data():
|
||||
create_item("Capitalization Target Stock Item", is_stock_item=1, is_fixed_asset=0, is_purchase_item=0)
|
||||
|
||||
@@ -388,6 +388,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Currency and Price List",
|
||||
@@ -1323,7 +1324,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-28 12:34:19.659621",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order",
|
||||
|
||||
@@ -18,6 +18,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
||||
from erpnext.accounts.party import get_party_account, get_party_account_currency
|
||||
from erpnext.buying.utils import validate_for_items
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
|
||||
validate_against_blanket_order,
|
||||
)
|
||||
@@ -769,13 +770,16 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
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 update_item(obj, target, source_parent):
|
||||
target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty)
|
||||
target.stock_qty = (flt(obj.qty) - flt(obj.received_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)
|
||||
)
|
||||
received_qty = flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0))
|
||||
pending_qty = flt(obj.qty) - received_qty
|
||||
|
||||
target.qty = flt(obj.qty) if is_unit_price_row(obj) else pending_qty
|
||||
target.stock_qty = pending_qty * flt(obj.conversion_factor)
|
||||
target.amount = pending_qty * flt(obj.rate)
|
||||
target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
|
||||
def select_item(d):
|
||||
filtered_items = args.get("filtered_children", [])
|
||||
@@ -807,7 +811,9 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: (
|
||||
True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty)
|
||||
doc.name not in mapped_qty_by_item
|
||||
if is_unit_price_row(doc)
|
||||
else abs(doc.received_qty) + abs(mapped_qty_by_item.get(doc.name, 0)) < abs(doc.qty)
|
||||
)
|
||||
and doc.delivered_by_supplier != 1
|
||||
and select_item(doc),
|
||||
@@ -866,9 +872,13 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
|
||||
)
|
||||
return query.run(pluck="qty")[0] or 0
|
||||
|
||||
mapped_qty_by_item = get_qty_already_mapped(target_doc, "po_detail")
|
||||
|
||||
def get_billed_and_mapped_qty(po_item_name):
|
||||
return flt(get_billed_qty(po_item_name)) + flt(mapped_qty_by_item.get(po_item_name, 0))
|
||||
|
||||
def update_item(obj, target, source_parent):
|
||||
billed_qty = flt(get_billed_qty(obj.name))
|
||||
target.qty = flt(obj.qty) - billed_qty
|
||||
target.qty = flt(obj.qty) - get_billed_and_mapped_qty(obj.name)
|
||||
|
||||
item = get_item_defaults(target.item_code, source_parent.company)
|
||||
item_group = get_item_group_defaults(target.item_code, source_parent.company)
|
||||
@@ -911,6 +921,7 @@ 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 select_item(doc),
|
||||
},
|
||||
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},
|
||||
|
||||
@@ -559,6 +559,7 @@
|
||||
"fieldname": "blanket_order_rate",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Blanket Order Rate",
|
||||
"options": "currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -954,7 +955,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified": "2026-08-27 10:55:37.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order Item",
|
||||
|
||||
@@ -9,13 +9,18 @@ from frappe import _
|
||||
from frappe.contacts.doctype.contact.contact import get_full_name
|
||||
from frappe.core.doctype.communication.email import make
|
||||
from frappe.desk.form.load import get_attachments
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.query_builder import Order
|
||||
from frappe.utils import get_url
|
||||
from frappe.utils.print_format import download_pdf
|
||||
from frappe.utils.user import get_user_fullname
|
||||
|
||||
from erpnext.accounts.party import _get_party_details, get_party_account_currency
|
||||
from erpnext.accounts.party import (
|
||||
_get_party_details,
|
||||
get_party_account_currency,
|
||||
validate_party_frozen_disabled,
|
||||
)
|
||||
from erpnext.buying.utils import validate_for_items
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.stock.doctype.material_request.material_request import set_missing_values
|
||||
@@ -126,6 +131,8 @@ class RequestforQuotation(BuyingController):
|
||||
|
||||
def validate_supplier_list(self):
|
||||
for d in self.suppliers:
|
||||
validate_party_frozen_disabled(self.company, "Supplier", d.supplier)
|
||||
|
||||
prevent_rfqs = frappe.db.get_value("Supplier", d.supplier, "prevent_rfqs")
|
||||
if prevent_rfqs:
|
||||
standing = frappe.db.get_value("Supplier Scorecard", d.supplier, "status")
|
||||
@@ -483,36 +490,73 @@ def make_supplier_quotation_from_rfq(source_name, target_doc=None, for_supplier=
|
||||
|
||||
# This method is used to make supplier quotation from supplier's portal.
|
||||
@frappe.whitelist()
|
||||
def create_supplier_quotation(doc):
|
||||
def create_supplier_quotation(doc: str | Document | dict):
|
||||
if isinstance(doc, str):
|
||||
doc = json.loads(doc)
|
||||
supplier = doc.get("supplier")
|
||||
|
||||
if frappe.session.user not in frappe.get_all(
|
||||
"Portal User", {"parent": doc.get("supplier")}, pluck="user"
|
||||
):
|
||||
if frappe.session.user not in frappe.get_all("Portal User", {"parent": supplier}, pluck="user"):
|
||||
frappe.throw(_("Not Permitted"), frappe.PermissionError)
|
||||
|
||||
try:
|
||||
sq_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Supplier Quotation",
|
||||
"supplier": doc.get("supplier"),
|
||||
"terms": doc.get("terms"),
|
||||
"company": doc.get("company"),
|
||||
"currency": doc.get("currency")
|
||||
or get_party_account_currency("Supplier", doc.get("supplier"), doc.get("company")),
|
||||
"buying_price_list": doc.get("buying_price_list")
|
||||
or frappe.db.get_single_value("Buying Settings", "buying_price_list"),
|
||||
}
|
||||
validate_existing_supplier_quotation(supplier, doc.get("items"))
|
||||
|
||||
sq_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Supplier Quotation",
|
||||
"supplier": supplier,
|
||||
"terms": doc.get("terms"),
|
||||
"company": doc.get("company"),
|
||||
"currency": doc.get("currency")
|
||||
or get_party_account_currency("Supplier", supplier, doc.get("company")),
|
||||
"buying_price_list": doc.get("buying_price_list")
|
||||
or frappe.db.get_single_value("Buying Settings", "buying_price_list"),
|
||||
}
|
||||
)
|
||||
add_items(sq_doc, supplier, doc.get("items"))
|
||||
sq_doc.flags.ignore_permissions = True
|
||||
sq_doc.run_method("set_missing_values")
|
||||
sq_doc.save()
|
||||
frappe.msgprint(_("Supplier Quotation {0} Created").format(sq_doc.name))
|
||||
return sq_doc.name
|
||||
|
||||
|
||||
def validate_existing_supplier_quotation(supplier, items):
|
||||
request_for_quotations = {item.get("parent") for item in items if item.get("parent")}
|
||||
if not request_for_quotations:
|
||||
return
|
||||
|
||||
rfq = frappe.qb.DocType("Request for Quotation")
|
||||
(
|
||||
frappe.qb.from_(rfq)
|
||||
.select(rfq.name)
|
||||
.where(rfq.name.isin(request_for_quotations))
|
||||
.orderby(rfq.name)
|
||||
.for_update()
|
||||
).run()
|
||||
|
||||
sq = frappe.qb.DocType("Supplier Quotation")
|
||||
sqi = frappe.qb.DocType("Supplier Quotation Item")
|
||||
existing_quotation = (
|
||||
frappe.qb.from_(sq)
|
||||
.inner_join(sqi)
|
||||
.on(sq.name == sqi.parent)
|
||||
.select(sq.name, sqi.request_for_quotation)
|
||||
.where(
|
||||
(sq.docstatus < 2)
|
||||
& (sq.supplier == supplier)
|
||||
& (sqi.request_for_quotation.isin(request_for_quotations))
|
||||
)
|
||||
.limit(1)
|
||||
).run(as_dict=True)
|
||||
|
||||
if existing_quotation:
|
||||
existing_quotation = existing_quotation[0]
|
||||
frappe.throw(
|
||||
_("Supplier Quotation {0} already exists against Request for Quotation {1}").format(
|
||||
frappe.bold(existing_quotation.name),
|
||||
frappe.bold(existing_quotation.request_for_quotation),
|
||||
)
|
||||
)
|
||||
add_items(sq_doc, doc.get("supplier"), doc.get("items"))
|
||||
sq_doc.flags.ignore_permissions = True
|
||||
sq_doc.run_method("set_missing_values")
|
||||
sq_doc.save()
|
||||
frappe.msgprint(_("Supplier Quotation {0} Created").format(sq_doc.name))
|
||||
return sq_doc.name
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def add_items(sq_doc, supplier, items):
|
||||
|
||||
@@ -17,6 +17,7 @@ from erpnext.buying.doctype.request_for_quotation.request_for_quotation import (
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError
|
||||
from erpnext.crm.doctype.opportunity.opportunity import make_request_for_quotation as make_rfq
|
||||
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
|
||||
from erpnext.exceptions import PartyDisabled
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.templates.pages.rfq import check_supplier_has_docname_access
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -58,6 +59,17 @@ class TestRequestforQuotation(ERPNextTestSuite):
|
||||
self.assertEqual(rfq.get("suppliers")[0].quote_status, "Received")
|
||||
self.assertEqual(rfq.get("suppliers")[1].quote_status, "Pending")
|
||||
|
||||
def test_rfq_blocked_for_disabled_supplier(self):
|
||||
frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 1)
|
||||
rfq = make_request_for_quotation(
|
||||
supplier_data=[{"supplier": "_Test Supplier", "supplier_name": "_Test Supplier"}],
|
||||
do_not_save=True,
|
||||
)
|
||||
self.assertRaises(PartyDisabled, rfq.save)
|
||||
|
||||
frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 0)
|
||||
rfq.save()
|
||||
|
||||
def test_make_supplier_quotation(self):
|
||||
rfq = make_request_for_quotation()
|
||||
|
||||
@@ -150,6 +162,18 @@ class TestRequestforQuotation(ERPNextTestSuite):
|
||||
self.assertEqual(supplier_quotation_doc.get("items")[0].qty, 5)
|
||||
self.assertEqual(supplier_quotation_doc.get("items")[0].amount, 500)
|
||||
|
||||
def test_make_duplicate_supplier_quotation_from_portal(self):
|
||||
rfq = make_request_for_quotation()
|
||||
rfq.supplier = rfq.suppliers[0].supplier
|
||||
supplier_quotation = frappe.get_doc("Supplier Quotation", create_supplier_quotation(rfq))
|
||||
supplier_quotation.submit()
|
||||
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "already exists"):
|
||||
create_supplier_quotation(rfq)
|
||||
|
||||
supplier_quotation.cancel()
|
||||
self.assertTrue(create_supplier_quotation(rfq))
|
||||
|
||||
def test_make_multi_uom_supplier_quotation(self):
|
||||
item_code = "_Test Multi UOM RFQ Item"
|
||||
if not frappe.db.exists("Item", item_code):
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Supplier",
|
||||
"link_filters": "[[\"Supplier\",\"disabled\",\"=\",0]]",
|
||||
"options": "Supplier",
|
||||
"reqd": 1
|
||||
},
|
||||
|
||||
@@ -363,7 +363,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Frozen suppliers block ledger entries until unfrozen. Use this to temporarily lock accounting activity without disabling the supplier.",
|
||||
"description": "Frozen suppliers block new transactions and ledger entries until unfrozen. Only users with the role set in Company's \"Roles Allowed to Set and Edit Frozen Account Entries\" can transact.",
|
||||
"fieldname": "is_frozen",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Frozen"
|
||||
@@ -562,7 +562,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-27 16:12:33.190257",
|
||||
"modified": "2026-08-14 16:10:58.600553",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier",
|
||||
|
||||
@@ -10,6 +10,7 @@ from frappe.contacts.address_and_contact import (
|
||||
load_address_and_contact,
|
||||
)
|
||||
from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options
|
||||
from frappe.utils import get_link_to_form
|
||||
|
||||
from erpnext.accounts.party import (
|
||||
get_dashboard_info,
|
||||
@@ -181,10 +182,15 @@ class Supplier(TransactionBase):
|
||||
)
|
||||
|
||||
if internal_supplier:
|
||||
internal_supplier_link = get_link_to_form("Supplier", internal_supplier)
|
||||
frappe.throw(
|
||||
_("Internal Supplier for company {0} already exists").format(
|
||||
frappe.bold(self.represents_company)
|
||||
)
|
||||
_(
|
||||
"Internal Supplier {0} already exists for {1}. Disable it to make this Supplier internal."
|
||||
).format(
|
||||
internal_supplier_link,
|
||||
frappe.bold(self.represents_company),
|
||||
),
|
||||
title=_("Internal Supplier Already Exists"),
|
||||
)
|
||||
|
||||
def create_primary_contact(self):
|
||||
|
||||
@@ -257,6 +257,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Currency and Price List",
|
||||
@@ -947,7 +948,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-28 12:29:37.509487",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation",
|
||||
|
||||
@@ -11,6 +11,7 @@ from frappe.utils import flt, getdate, nowdate
|
||||
|
||||
from erpnext.buying.utils import validate_for_items
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
|
||||
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
|
||||
|
||||
@@ -246,6 +247,8 @@ def make_purchase_order(source_name, target_doc=None, args=None):
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
|
||||
mapped_items = get_qty_already_mapped(target_doc, "supplier_quotation_item")
|
||||
|
||||
def set_missing_values(source, target):
|
||||
target.run_method("set_missing_values")
|
||||
target.run_method("get_schedule_dates")
|
||||
@@ -280,7 +283,8 @@ def make_purchase_order(source_name, target_doc=None, args=None):
|
||||
["sales_order", "sales_order"],
|
||||
],
|
||||
"postprocess": update_item,
|
||||
"condition": select_item,
|
||||
# no qty tracking between the two, so dedupe on the row reference alone
|
||||
"condition": lambda d: d.name not in mapped_items and select_item(d),
|
||||
},
|
||||
"Purchase Taxes and Charges": {
|
||||
"doctype": "Purchase Taxes and Charges",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -10,6 +10,26 @@ frappe.query_reports["Purchase Analytics"] = {
|
||||
options: ["Supplier Group", "Supplier", "Item Group", "Item"],
|
||||
default: "Supplier",
|
||||
reqd: 1,
|
||||
on_change: function () {
|
||||
const entity_filter = frappe.query_report.get_filter("entity");
|
||||
if (entity_filter) {
|
||||
entity_filter.df.label = __(frappe.query_report.get_filter_value("tree_type"));
|
||||
entity_filter.set_value([]);
|
||||
entity_filter.refresh();
|
||||
}
|
||||
frappe.query_report.refresh();
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "entity",
|
||||
label: __("Entity"),
|
||||
fieldtype: "MultiSelectList",
|
||||
get_data: function (txt) {
|
||||
const tree_type = frappe.query_report.get_filter_value("tree_type");
|
||||
if (!tree_type || tree_type === "Order Type") return [];
|
||||
return frappe.db.get_link_options(tree_type, txt);
|
||||
},
|
||||
depends_on: "eval:doc.tree_type != 'Order Type'",
|
||||
},
|
||||
{
|
||||
fieldname: "doc_type",
|
||||
@@ -65,6 +85,19 @@ frappe.query_reports["Purchase Analytics"] = {
|
||||
default: "Monthly",
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "curves",
|
||||
label: __("Curves"),
|
||||
fieldtype: "Select",
|
||||
options: [
|
||||
{ value: "select", label: __("Select") },
|
||||
{ value: "all", label: __("All") },
|
||||
{ value: "non-zeros", label: __("Non-Zeros") },
|
||||
{ value: "total", label: __("Total Only") },
|
||||
],
|
||||
default: "select",
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "show_aggregate_value_from_subsidiary_companies",
|
||||
label: __("Show Aggregate Value from Subsidiary Companies"),
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.buying.report.purchase_analytics.purchase_analytics import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
SUPPLIER = "_Test Supplier"
|
||||
SUPPLIER_GROUP = "_Test Supplier Group"
|
||||
# A historical window that ordinary test fixtures don't post into.
|
||||
FROM_DATE = "2019-04-01"
|
||||
TO_DATE = "2019-06-30"
|
||||
|
||||
|
||||
class TestPurchaseAnalytics(ERPNextTestSuite):
|
||||
"""purchase_analytics reuses the shared Analytics engine; these tests lock its
|
||||
wiring (doc_type=Purchase Order) across the Supplier Group / Item Group trees."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def _filters(self, **overrides):
|
||||
filters = {
|
||||
"doc_type": "Purchase Order",
|
||||
"value_quantity": "Value",
|
||||
"range": "Monthly",
|
||||
"company": COMPANY,
|
||||
"from_date": FROM_DATE,
|
||||
"to_date": TO_DATE,
|
||||
}
|
||||
filters.update(overrides)
|
||||
return frappe._dict(filters)
|
||||
|
||||
def _rows(self, filters):
|
||||
return {row["entity"]: row for row in execute(filters)[1]}
|
||||
|
||||
def make_po(self, qty=4, rate=250):
|
||||
return create_purchase_order(
|
||||
company=COMPANY, supplier=SUPPLIER, qty=qty, rate=rate, transaction_date="2019-04-10"
|
||||
)
|
||||
|
||||
def test_supplier_entity_filter(self):
|
||||
filters = self._filters(tree_type="Supplier", entity=[SUPPLIER], curves="all")
|
||||
base_total = flt(self._rows(filters).get(SUPPLIER, {}).get("total", 0.0))
|
||||
|
||||
po = self.make_po()
|
||||
columns, data, _message, chart, *_rest = execute(filters)
|
||||
|
||||
self.assertTrue(columns)
|
||||
self.assertEqual({row["entity"] for row in data}, {SUPPLIER})
|
||||
self.assertAlmostEqual(data[0]["total"] - base_total, flt(po.base_net_total), places=2)
|
||||
|
||||
supplier_name = frappe.db.get_value("Supplier", SUPPLIER, "supplier_name")
|
||||
self.assertEqual({dataset["name"] for dataset in chart["data"]["datasets"]}, {supplier_name})
|
||||
|
||||
def test_parent_supplier_group_filter_preserves_rollup(self):
|
||||
self.make_po()
|
||||
filters = self._filters(tree_type="Supplier Group")
|
||||
unfiltered = self._rows(filters)
|
||||
filtered = self._rows(self._filters(tree_type="Supplier Group", entity=["All Supplier Groups"]))
|
||||
|
||||
self.assertEqual(set(filtered), {"All Supplier Groups"})
|
||||
self.assertAlmostEqual(
|
||||
filtered["All Supplier Groups"]["total"],
|
||||
unfiltered["All Supplier Groups"]["total"],
|
||||
places=2,
|
||||
)
|
||||
|
||||
def test_supplier_group_entity_filter(self):
|
||||
self.make_po()
|
||||
unfiltered = self._rows(self._filters(tree_type="Supplier Group"))
|
||||
filtered = self._rows(self._filters(tree_type="Supplier Group", entity=[SUPPLIER_GROUP]))
|
||||
|
||||
self.assertEqual(set(filtered), {SUPPLIER_GROUP})
|
||||
self.assertEqual(filtered[SUPPLIER_GROUP]["indent"], 0)
|
||||
self.assertAlmostEqual(
|
||||
filtered[SUPPLIER_GROUP]["total"], unfiltered[SUPPLIER_GROUP]["total"], places=2
|
||||
)
|
||||
|
||||
def test_supplier_group_tree_rolls_up_to_root(self):
|
||||
filters = self._filters(tree_type="Supplier Group")
|
||||
base = self._rows(filters)
|
||||
base_group = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0))
|
||||
|
||||
po = self.make_po(qty=4, rate=250)
|
||||
rows = self._rows(filters)
|
||||
|
||||
# supplier is remapped to its group; the root sits at indent 0
|
||||
self.assertIn(SUPPLIER_GROUP, rows)
|
||||
self.assertIn("All Supplier Groups", rows)
|
||||
self.assertNotIn(SUPPLIER, rows)
|
||||
self.assertEqual(rows["All Supplier Groups"]["indent"], 0)
|
||||
|
||||
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_group, flt(po.base_net_total), places=2)
|
||||
self.assertGreaterEqual(flt(rows["All Supplier Groups"]["total"]), flt(po.base_net_total))
|
||||
|
||||
def test_item_group_tree_rolls_up_to_root(self):
|
||||
item_group = frappe.db.get_value("Item", "_Test Item", "item_group")
|
||||
filters = self._filters(tree_type="Item Group")
|
||||
base = self._rows(filters)
|
||||
base_group = flt(base.get(item_group, {}).get("total", 0.0))
|
||||
|
||||
po = self.make_po(qty=4, rate=250)
|
||||
rows = self._rows(filters)
|
||||
|
||||
self.assertIn(item_group, rows)
|
||||
self.assertIn("All Item Groups", rows)
|
||||
# the raw item code must not leak as its own entity; the root sits at indent 0
|
||||
self.assertNotIn("_Test Item", rows)
|
||||
self.assertEqual(rows["All Item Groups"]["indent"], 0)
|
||||
self.assertAlmostEqual(rows[item_group]["total"] - base_group, flt(po.base_net_total), places=2)
|
||||
self.assertGreaterEqual(flt(rows["All Item Groups"]["total"]), flt(po.base_net_total))
|
||||
|
||||
def test_supplier_group_by_quantity(self):
|
||||
filters = self._filters(tree_type="Supplier Group", value_quantity="Quantity")
|
||||
base = self._rows(filters)
|
||||
base_qty = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0))
|
||||
base_root_qty = flt(base.get("All Supplier Groups", {}).get("total", 0.0))
|
||||
|
||||
po = self.make_po(qty=7, rate=100)
|
||||
rows = self._rows(filters)
|
||||
|
||||
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_qty, flt(po.total_qty), places=2)
|
||||
# the quantity must roll up to the root too, not just the leaf group
|
||||
self.assertAlmostEqual(
|
||||
rows["All Supplier Groups"]["total"] - base_root_qty, flt(po.total_qty), places=2
|
||||
)
|
||||
@@ -85,6 +85,17 @@ frappe.query_reports["Supplier Quotation Comparison"] = {
|
||||
],
|
||||
default: __("Categorize by Supplier"),
|
||||
},
|
||||
{
|
||||
fieldname: "status",
|
||||
label: __("Status"),
|
||||
fieldtype: "Select",
|
||||
options: [
|
||||
{ label: "", value: "" },
|
||||
{ label: __("Draft"), value: "Draft" },
|
||||
{ label: __("Submitted"), value: "Submitted" },
|
||||
],
|
||||
default: "Submitted",
|
||||
},
|
||||
{
|
||||
fieldtype: "Check",
|
||||
label: __("Include Expired"),
|
||||
|
||||
@@ -58,13 +58,20 @@ def get_data(filters):
|
||||
)
|
||||
.where(
|
||||
(sq_item.parent == sq.name)
|
||||
& (sq_item.docstatus < 2)
|
||||
& (sq.company == filters.get("company"))
|
||||
& (sq.transaction_date.between(filters.get("from_date"), filters.get("to_date")))
|
||||
)
|
||||
.orderby(sq.transaction_date, sq_item.item_code)
|
||||
)
|
||||
|
||||
# blank -> Draft + Submitted, else filter to the chosen docstatus
|
||||
if filters.get("status") == "Draft":
|
||||
query = query.where(sq_item.docstatus == 0)
|
||||
elif filters.get("status") == "Submitted":
|
||||
query = query.where(sq_item.docstatus == 1)
|
||||
else:
|
||||
query = query.where(sq_item.docstatus < 2)
|
||||
|
||||
if filters.get("item_code"):
|
||||
query = query.where(sq_item.item_code == filters.get("item_code"))
|
||||
|
||||
|
||||
@@ -738,6 +738,8 @@ class AccountsController(TransactionBase):
|
||||
self.validate_non_invoice_documents_schedule()
|
||||
|
||||
def before_print(self, settings=None):
|
||||
self.set_missing_terms()
|
||||
|
||||
if self.doctype in [
|
||||
"Purchase Order",
|
||||
"Sales Order",
|
||||
@@ -761,6 +763,16 @@ class AccountsController(TransactionBase):
|
||||
set_print_templates_for_item_table(self, settings)
|
||||
set_print_templates_for_taxes(self, settings)
|
||||
|
||||
def set_missing_terms(self):
|
||||
if not self.get("tc_name") or self.get("terms"):
|
||||
return
|
||||
|
||||
from erpnext.setup.doctype.terms_and_conditions.terms_and_conditions import (
|
||||
get_terms_and_conditions,
|
||||
)
|
||||
|
||||
self.terms = get_terms_and_conditions(self.tc_name, self.as_dict())
|
||||
|
||||
def calculate_paid_amount(self):
|
||||
if hasattr(self, "is_pos") or hasattr(self, "is_paid"):
|
||||
is_paid = self.get("is_pos") or self.get("is_paid")
|
||||
@@ -1293,6 +1305,11 @@ class AccountsController(TransactionBase):
|
||||
if self.get("taxes") or self.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 self.flags.dont_auto_add_taxes:
|
||||
return
|
||||
|
||||
if frappe.get_single_value(
|
||||
"Accounts Settings", "add_taxes_from_taxes_and_charges_template"
|
||||
) and hasattr(self, "taxes_and_charges"):
|
||||
@@ -1409,9 +1426,12 @@ class AccountsController(TransactionBase):
|
||||
dimension_dict = frappe._dict()
|
||||
|
||||
for dimension in accounting_dimensions:
|
||||
dimension_dict[dimension] = self.get(dimension)
|
||||
value = self.get(dimension)
|
||||
if item and item.get(dimension):
|
||||
dimension_dict[dimension] = item.get(dimension)
|
||||
value = item.get(dimension)
|
||||
if isinstance(value, list | dict):
|
||||
continue
|
||||
dimension_dict[dimension] = value
|
||||
|
||||
gl_dict.update(dimension_dict)
|
||||
gl_dict.update(args)
|
||||
|
||||
@@ -85,18 +85,10 @@ class BuyingController(SubcontractingController):
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
self.get("company")
|
||||
and (
|
||||
default_buying_terms := frappe.get_value(
|
||||
"Company", self.get("company"), "default_buying_terms"
|
||||
)
|
||||
)
|
||||
and not self.get("tc_name")
|
||||
and not self.get("terms")
|
||||
):
|
||||
self.tc_name = default_buying_terms
|
||||
self.terms = frappe.get_value("Terms and Conditions", self.get("tc_name"), "terms")
|
||||
if self.get("company") and not self.get("terms"):
|
||||
if not self.get("tc_name"):
|
||||
self.tc_name = frappe.get_value("Company", self.company, "default_buying_terms")
|
||||
self.set_missing_terms()
|
||||
|
||||
def validate_posting_date_with_po(self):
|
||||
po_list = {x.purchase_order for x in self.items if x.purchase_order}
|
||||
@@ -981,6 +973,14 @@ class BuyingController(SubcontractingController):
|
||||
item.serial_and_batch_bundle, warehouse, type_of_transaction=type_of_transaction
|
||||
)
|
||||
|
||||
def check_purchase_order_on_hold_or_close(self, ref_fieldname, exclude_if_field=None):
|
||||
if self.get("is_return"):
|
||||
return
|
||||
|
||||
self.check_for_on_hold_or_closed_status(
|
||||
"Purchase Order", ref_fieldname, exclude_if_field=exclude_if_field
|
||||
)
|
||||
|
||||
def update_ordered_and_reserved_qty(self):
|
||||
po_map = {}
|
||||
for d in self.get("items"):
|
||||
@@ -994,7 +994,7 @@ class BuyingController(SubcontractingController):
|
||||
if po and po_item_rows:
|
||||
po_obj = frappe.get_lazy_doc("Purchase Order", po)
|
||||
|
||||
if po_obj.status in ["Closed", "Cancelled"]:
|
||||
if po_obj.status == "Cancelled" or (po_obj.status == "Closed" and not self.get("is_return")):
|
||||
frappe.throw(
|
||||
_("{doctype} {name} is cancelled or closed.").format(
|
||||
doctype=frappe.bold(_("Purchase Order")),
|
||||
|
||||
25
erpnext/controllers/mapper.py
Normal file
25
erpnext/controllers/mapper.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
|
||||
def get_qty_already_mapped(target_doc, ref_field: str, qty_field: str = "qty") -> frappe._dict:
|
||||
"""Return a map: {source row name: qty} of rows already mapped into the target document.
|
||||
|
||||
"Get Items From" passes the in-progress (unsaved) document back as `target_doc`. Its rows
|
||||
are invisible to the pending-qty queries in the mappers, which only count submitted
|
||||
documents -- so without this, selecting the same source document twice maps every row
|
||||
again. Rows are keyed by `ref_field` (dn_detail, so_detail, ...), and a row is present in
|
||||
the map even when its qty is 0, so mappers without qty tracking can dedupe on presence.
|
||||
"""
|
||||
if isinstance(target_doc, str):
|
||||
target_doc = frappe.parse_json(target_doc)
|
||||
|
||||
qty_map = frappe._dict()
|
||||
for row in (target_doc and target_doc.get("items")) or []:
|
||||
if ref := row.get(ref_field):
|
||||
qty_map[ref] = qty_map.get(ref, 0) + flt(row.get(qty_field))
|
||||
|
||||
return qty_map
|
||||
@@ -639,7 +639,7 @@ def get_blanket_orders(doctype, txt, searchfield, start, page_len, filters):
|
||||
bo = frappe.qb.DocType("Blanket Order")
|
||||
bo_item = frappe.qb.DocType("Blanket Order Item")
|
||||
|
||||
blanket_orders = (
|
||||
query = (
|
||||
frappe.qb.from_(bo)
|
||||
.from_(bo_item)
|
||||
.select(bo.name)
|
||||
@@ -652,10 +652,12 @@ def get_blanket_orders(doctype, txt, searchfield, start, page_len, filters):
|
||||
& (bo.company == filters.get("company"))
|
||||
& (bo.docstatus == 1)
|
||||
)
|
||||
.run()
|
||||
)
|
||||
|
||||
return blanket_orders
|
||||
if currency := filters.get("currency"):
|
||||
query = query.where(bo.currency == currency)
|
||||
|
||||
return query.run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -214,7 +214,7 @@ def validate_quantity(doc, key, args, ref, valid_items, already_returned_items):
|
||||
else 0
|
||||
)
|
||||
|
||||
if column == "stock_qty" and not args.get("return_qty_from_rejected_warehouse"):
|
||||
if column in ("stock_qty", "qty") and not args.get("return_qty_from_rejected_warehouse"):
|
||||
reference_qty = ref.get(column)
|
||||
current_stock_qty = args.get(column)
|
||||
elif args.get("return_qty_from_rejected_warehouse"):
|
||||
|
||||
@@ -43,18 +43,10 @@ class SellingController(StockController):
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
self.get("company")
|
||||
and (
|
||||
default_selling_terms := frappe.get_value(
|
||||
"Company", self.get("company"), "default_selling_terms"
|
||||
)
|
||||
)
|
||||
and not self.get("tc_name")
|
||||
and not self.get("terms")
|
||||
):
|
||||
self.tc_name = default_selling_terms
|
||||
self.terms = frappe.get_value("Terms and Conditions", self.get("tc_name"), "terms")
|
||||
if self.get("company") and not self.get("terms"):
|
||||
if not self.get("tc_name"):
|
||||
self.tc_name = frappe.get_value("Company", self.company, "default_selling_terms")
|
||||
self.set_missing_terms()
|
||||
|
||||
def validate(self):
|
||||
super().validate()
|
||||
@@ -573,12 +565,12 @@ class SellingController(StockController):
|
||||
reset_incoming_rate()
|
||||
|
||||
if (
|
||||
not d.incoming_rate
|
||||
(not d.incoming_rate or self.is_new())
|
||||
and not is_standalone
|
||||
or self.is_internal_transfer()
|
||||
or (
|
||||
get_valuation_method(d.item_code, self.company) == "Moving Average"
|
||||
and self.get("is_return")
|
||||
and not is_standalone
|
||||
)
|
||||
):
|
||||
d.incoming_rate = get_incoming_rate(
|
||||
|
||||
@@ -75,7 +75,7 @@ SECONDARY_ITEM_PURPOSES = ("Manufacture", "Repack", "Disassemble")
|
||||
|
||||
def is_inspection_exempt_secondary_row(doc, row) -> bool:
|
||||
"""Whether the row is a secondary item on a document that produces secondary items."""
|
||||
if not (row.get("type") or row.get("is_legacy_scrap_item")):
|
||||
if not (row.get("secondary_item_type") or row.get("valuation_type")):
|
||||
return False
|
||||
|
||||
if doc.doctype == "Stock Entry":
|
||||
@@ -86,7 +86,7 @@ def is_inspection_exempt_secondary_row(doc, row) -> bool:
|
||||
|
||||
def stock_entry_row_requires_inspection(purpose, row):
|
||||
"""Check if this Stock Entry row need a Quality Inspection."""
|
||||
if purpose in SECONDARY_ITEM_PURPOSES and (row.get("type") or row.get("is_legacy_scrap_item")):
|
||||
if purpose in SECONDARY_ITEM_PURPOSES and (row.get("secondary_item_type") or row.get("valuation_type")):
|
||||
return False
|
||||
if purpose == "Manufacture":
|
||||
return bool(row.is_finished_item)
|
||||
|
||||
@@ -161,7 +161,7 @@ class SubcontractingController(StockController):
|
||||
).format(item.idx, get_link_to_form("Item", item.item_code))
|
||||
)
|
||||
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item"):
|
||||
if not item.get("secondary_item_type") and not item.get("valuation_type"):
|
||||
if not is_sub_contracted_item:
|
||||
frappe.throw(
|
||||
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
|
||||
@@ -1288,10 +1288,10 @@ class SubcontractingController(StockController):
|
||||
total_amt = sum(
|
||||
flt(item.amount)
|
||||
for item in self.get("items")
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item")
|
||||
if not item.get("secondary_item_type") and not item.get("valuation_type")
|
||||
)
|
||||
for item in self.items:
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item"):
|
||||
if not item.get("secondary_item_type") and not item.get("valuation_type"):
|
||||
item.additional_cost_per_qty = (
|
||||
(item.amount * self.total_additional_costs) / total_amt
|
||||
) / item.qty
|
||||
@@ -1299,15 +1299,15 @@ class SubcontractingController(StockController):
|
||||
total_qty = sum(
|
||||
flt(item.qty)
|
||||
for item in self.get("items")
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item")
|
||||
if not item.get("secondary_item_type") and not item.get("valuation_type")
|
||||
)
|
||||
additional_cost_per_qty = self.total_additional_costs / total_qty
|
||||
for item in self.items:
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item"):
|
||||
if not item.get("secondary_item_type") and not item.get("valuation_type"):
|
||||
item.additional_cost_per_qty = additional_cost_per_qty
|
||||
else:
|
||||
for item in self.items:
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item"):
|
||||
if not item.get("secondary_item_type") and not item.get("valuation_type"):
|
||||
item.additional_cost_per_qty = 0
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -241,8 +241,8 @@ class SubcontractingInwardController:
|
||||
item
|
||||
for item in self.get("items")
|
||||
if not item.is_finished_item
|
||||
and not item.type
|
||||
and not item.is_legacy_scrap_item
|
||||
and not item.secondary_item_type
|
||||
and not item.valuation_type
|
||||
and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item")
|
||||
]
|
||||
|
||||
@@ -372,7 +372,7 @@ class SubcontractingInwardController:
|
||||
if self.purpose in ["Subcontracting Delivery", "Subcontracting Return", "Manufacture"]:
|
||||
for item in self.items:
|
||||
if (
|
||||
item.is_finished_item or item.type or item.is_legacy_scrap_item
|
||||
item.is_finished_item or item.secondary_item_type or item.valuation_type
|
||||
) and item.valuation_rate == 0:
|
||||
item.allow_zero_valuation_rate = 1
|
||||
|
||||
@@ -472,7 +472,7 @@ class SubcontractingInwardController:
|
||||
self.validate_delivery_on_save()
|
||||
else:
|
||||
for item in self.items:
|
||||
if not item.type and not item.is_legacy_scrap_item:
|
||||
if not item.secondary_item_type and not item.valuation_type:
|
||||
delivered_qty, returned_qty = frappe.get_value(
|
||||
"Subcontracting Inward Order Item",
|
||||
item.scio_detail,
|
||||
@@ -543,7 +543,7 @@ class SubcontractingInwardController:
|
||||
bold(
|
||||
frappe.get_cached_value(
|
||||
"Subcontracting Inward Order Item"
|
||||
if not item.type and not item.is_legacy_scrap_item
|
||||
if not item.secondary_item_type and not item.valuation_type
|
||||
else "Subcontracting Inward Order Secondary Item",
|
||||
item.scio_detail,
|
||||
"stock_uom",
|
||||
@@ -595,7 +595,7 @@ class SubcontractingInwardController:
|
||||
)
|
||||
|
||||
for item in [item for item in self.items if not item.is_finished_item]:
|
||||
if item.type or item.is_legacy_scrap_item:
|
||||
if item.secondary_item_type or item.valuation_type:
|
||||
scio_secondary_item = frappe.get_value(
|
||||
"Subcontracting Inward Order Secondary Item",
|
||||
{
|
||||
@@ -655,7 +655,7 @@ class SubcontractingInwardController:
|
||||
for item in self.items:
|
||||
doctype = (
|
||||
"Subcontracting Inward Order Item"
|
||||
if not item.type and not item.is_legacy_scrap_item
|
||||
if not item.secondary_item_type and not item.valuation_type
|
||||
else "Subcontracting Inward Order Secondary Item"
|
||||
)
|
||||
qty_map[doctype][item.scio_detail] += (
|
||||
@@ -791,7 +791,7 @@ class SubcontractingInwardController:
|
||||
items = [
|
||||
item
|
||||
for item in self.items
|
||||
if not item.is_finished_item and not item.type and not item.is_legacy_scrap_item
|
||||
if not item.is_finished_item and not item.secondary_item_type and not item.valuation_type
|
||||
]
|
||||
item_code_wh = frappe._dict(
|
||||
{
|
||||
@@ -893,7 +893,9 @@ 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.type or item.is_legacy_scrap_item]
|
||||
secondary_items_list = [
|
||||
item for item in self.items if item.secondary_item_type or item.valuation_type
|
||||
]
|
||||
|
||||
secondary_items = defaultdict(float)
|
||||
for item in secondary_items_list:
|
||||
@@ -967,7 +969,7 @@ class SubcontractingInwardController:
|
||||
stock_uom=secondary_item.stock_uom,
|
||||
warehouse=secondary_item.t_warehouse,
|
||||
produced_qty=secondary_item.transfer_qty,
|
||||
type=secondary_item.type,
|
||||
secondary_item_type=secondary_item.secondary_item_type,
|
||||
delivered_qty=0,
|
||||
reference_name=frappe.get_value(
|
||||
"Work Order", self.work_order, "subcontracting_inward_order_item"
|
||||
|
||||
@@ -314,33 +314,32 @@ class calculate_taxes_and_totals:
|
||||
for item in self.doc.items:
|
||||
item._unrounded_net_amount = None
|
||||
item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
|
||||
cumulated_tax_fraction = 0
|
||||
total_inclusive_tax_amount_per_qty = 0
|
||||
total_tax_slope = 0
|
||||
total_tax_intercept = 0
|
||||
for i, tax in enumerate(self.doc.get("taxes")):
|
||||
(
|
||||
tax.tax_fraction_for_current_item,
|
||||
inclusive_tax_amount_per_qty,
|
||||
) = self.get_current_tax_fraction(tax, item_tax_map)
|
||||
tax_intercept_per_qty,
|
||||
) = self.get_current_tax_fraction(tax, item_tax_map, item)
|
||||
tax.inclusive_amount_per_qty = tax_intercept_per_qty
|
||||
|
||||
if i == 0:
|
||||
tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item
|
||||
tax.grand_total_amount_per_qty = tax_intercept_per_qty
|
||||
else:
|
||||
prev = self.doc.get("taxes")[i - 1]
|
||||
tax.grand_total_fraction_for_current_item = (
|
||||
self.doc.get("taxes")[i - 1].grand_total_fraction_for_current_item
|
||||
+ tax.tax_fraction_for_current_item
|
||||
prev.grand_total_fraction_for_current_item + tax.tax_fraction_for_current_item
|
||||
)
|
||||
tax.grand_total_amount_per_qty = prev.grand_total_amount_per_qty + tax_intercept_per_qty
|
||||
|
||||
cumulated_tax_fraction += tax.tax_fraction_for_current_item
|
||||
total_inclusive_tax_amount_per_qty += inclusive_tax_amount_per_qty * flt(item.qty)
|
||||
total_tax_slope += tax.tax_fraction_for_current_item
|
||||
total_tax_intercept += tax_intercept_per_qty * flt(item.qty)
|
||||
|
||||
if (
|
||||
not self.discount_amount_applied
|
||||
and item.qty
|
||||
and (cumulated_tax_fraction or total_inclusive_tax_amount_per_qty)
|
||||
):
|
||||
amount = flt(item.amount) - total_inclusive_tax_amount_per_qty
|
||||
if not self.discount_amount_applied and item.qty and (total_tax_slope or total_tax_intercept):
|
||||
amount = flt(item.amount) - total_tax_intercept
|
||||
|
||||
item._unrounded_net_amount = amount / (1 + cumulated_tax_fraction)
|
||||
item._unrounded_net_amount = amount / (1 + total_tax_slope)
|
||||
item.net_amount = flt(item._unrounded_net_amount, item.precision("net_amount"))
|
||||
item.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate"))
|
||||
item.discount_percentage = flt(
|
||||
@@ -352,41 +351,48 @@ class calculate_taxes_and_totals:
|
||||
def _load_item_tax_rate(self, item_tax_rate):
|
||||
return json.loads(item_tax_rate) if item_tax_rate else {}
|
||||
|
||||
def get_current_tax_fraction(self, tax, item_tax_map):
|
||||
def get_current_tax_fraction(self, tax, item_tax_map, item):
|
||||
"""
|
||||
Get tax fraction for calculating tax exclusive amount
|
||||
from tax inclusive amount
|
||||
tax = slope * net + intercept.
|
||||
Returns (slope, intercept_per_qty)
|
||||
"""
|
||||
current_tax_fraction = 0
|
||||
inclusive_tax_amount_per_qty = 0
|
||||
tax_slope = 0
|
||||
tax_intercept = 0
|
||||
|
||||
if cint(tax.included_in_print_rate):
|
||||
tax_rate = self._get_tax_rate(tax, item_tax_map)
|
||||
|
||||
if tax_rate == NOT_APPLICABLE_TAX:
|
||||
return current_tax_fraction, inclusive_tax_amount_per_qty
|
||||
return tax_slope, tax_intercept
|
||||
|
||||
if tax.charge_type == "On Net Total":
|
||||
current_tax_fraction = tax_rate / 100.0
|
||||
tax_slope = tax_rate / 100.0
|
||||
|
||||
elif tax.charge_type == "On Previous Row Amount":
|
||||
current_tax_fraction = (tax_rate / 100.0) * self.doc.get("taxes")[
|
||||
cint(tax.row_id) - 1
|
||||
].tax_fraction_for_current_item
|
||||
row = self.doc.get("taxes")[cint(tax.row_id) - 1]
|
||||
tax_slope = (tax_rate / 100.0) * row.tax_fraction_for_current_item
|
||||
tax_intercept = (tax_rate / 100.0) * flt(getattr(row, "inclusive_amount_per_qty", 0))
|
||||
|
||||
elif tax.charge_type == "On Previous Row Total":
|
||||
current_tax_fraction = (tax_rate / 100.0) * self.doc.get("taxes")[
|
||||
cint(tax.row_id) - 1
|
||||
].grand_total_fraction_for_current_item
|
||||
row = self.doc.get("taxes")[cint(tax.row_id) - 1]
|
||||
tax_slope = (tax_rate / 100.0) * row.grand_total_fraction_for_current_item
|
||||
tax_intercept = (tax_rate / 100.0) * flt(getattr(row, "grand_total_amount_per_qty", 0))
|
||||
|
||||
elif tax.charge_type == "On Item Quantity":
|
||||
inclusive_tax_amount_per_qty = flt(tax_rate)
|
||||
tax_intercept = flt(tax_rate)
|
||||
|
||||
else:
|
||||
# Custom charge_type: the rate applies to a resolved (fixed) base,
|
||||
# e.g. a tax on MRP included in the printed price.
|
||||
qty = flt(item.qty) or 1
|
||||
base = self.get_item_taxable_base(item, tax)
|
||||
tax_intercept = (tax_rate / 100.0) * base / qty
|
||||
|
||||
if getattr(tax, "add_deduct_tax", None) and tax.add_deduct_tax == "Deduct":
|
||||
current_tax_fraction *= -1.0
|
||||
inclusive_tax_amount_per_qty *= -1.0
|
||||
tax_slope *= -1.0
|
||||
tax_intercept *= -1.0
|
||||
|
||||
return current_tax_fraction, inclusive_tax_amount_per_qty
|
||||
return tax_slope, tax_intercept
|
||||
|
||||
def _get_tax_rate(self, tax, item_tax_map):
|
||||
if tax.account_head in item_tax_map:
|
||||
@@ -612,7 +618,6 @@ class calculate_taxes_and_totals:
|
||||
elif tax.charge_type == "On Net Total":
|
||||
if tax.account_head in item_tax_map:
|
||||
current_net_amount = item.net_amount
|
||||
|
||||
# Use unrounded net for inclusive taxes to avoid double rounding
|
||||
if (
|
||||
cint(tax.included_in_print_rate)
|
||||
@@ -631,12 +636,46 @@ class calculate_taxes_and_totals:
|
||||
elif tax.charge_type == "On Item Quantity":
|
||||
# don't sum current net amount due to the field being a currency field
|
||||
current_tax_amount = tax_rate * item.qty
|
||||
else:
|
||||
# Custom charge_type: rate applies to the resolver-provided base.
|
||||
base = self.get_item_taxable_base(item, tax)
|
||||
current_net_amount = base
|
||||
current_tax_amount = (tax_rate / 100.0) * base
|
||||
|
||||
if not tax.get("dont_recompute_tax"):
|
||||
self.set_item_wise_tax(item, tax, tax_rate, current_tax_amount, current_net_amount)
|
||||
|
||||
return current_net_amount, current_tax_amount
|
||||
|
||||
def get_item_taxable_base(self, item, tax):
|
||||
"""Per-item base a custom charge_type's rate is applied to.
|
||||
|
||||
Override the base (gross, MRP, net of other taxes, …) via the
|
||||
`erpnext_taxable_base_resolvers` hook
|
||||
|
||||
Register a resolver in `hooks.py`, keyed by charge_type:
|
||||
|
||||
erpnext_taxable_base_resolvers = {"On Gross Amount": "my_app.taxes.gross_base"}
|
||||
|
||||
It receives (calc, item, tax) — calc is this instance, calc.doc the parent —
|
||||
and returns the base (flt-coerced by the caller):
|
||||
|
||||
def gross_base(calc, item, tax):
|
||||
return item.custom_field_mrp * item.qty
|
||||
|
||||
A resolver may stamp transient attributes on `item`; it can be called more than once
|
||||
per item, so such stamping must be idempotent.
|
||||
"""
|
||||
resolvers = frappe.get_hooks("erpnext_taxable_base_resolvers") or {}
|
||||
path = resolvers.get(tax.charge_type)
|
||||
|
||||
if path:
|
||||
method = path[-1] if isinstance(path, list | tuple) else path
|
||||
return flt(frappe.get_attr(method)(self, item, tax))
|
||||
|
||||
# fallback
|
||||
return flt(item.net_amount)
|
||||
|
||||
def set_item_wise_tax(self, item, tax, tax_rate, current_tax_amount, current_net_amount):
|
||||
# store tax breakup for each item
|
||||
multiplier = -1 if tax.get("add_deduct_tax") == "Deduct" else 1
|
||||
|
||||
@@ -31,6 +31,97 @@ class TestMapper(ERPNextTestSuite):
|
||||
src_items = item_list_1 + item_list_2 + item_list_3
|
||||
self.assertEqual(set(d for d in src_items), set(d.item_code for d in updated_so.items))
|
||||
|
||||
def test_get_items_from_is_idempotent(self):
|
||||
"""Selecting the same source document twice must not duplicate rows in the target.
|
||||
|
||||
"Get Items From" hands the in-progress document back to the mapper as `target_doc`.
|
||||
Its rows are unsaved, so the mappers' pending-qty queries (submitted documents only)
|
||||
cannot see them -- every mapper has to discount them explicitly.
|
||||
"""
|
||||
for label, make_source, method in self.idempotency_cases():
|
||||
with self.subTest(label):
|
||||
source = make_source()
|
||||
target = frappe.get_attr(method)(source.name)
|
||||
mapped_rows = len(target.items)
|
||||
self.assertTrue(mapped_rows, f"{label}: nothing was mapped")
|
||||
|
||||
target = frappe.get_attr(method)(source.name, target)
|
||||
self.assertEqual(len(target.items), mapped_rows, f"{label}: rows were duplicated")
|
||||
|
||||
def idempotency_cases(self):
|
||||
"""(label, source factory, mapper method) for every "Get Items From" button.
|
||||
|
||||
Quotation -> Sales Invoice is absent: Sales Invoice Item keeps no reference to the
|
||||
Quotation row, so there is nothing to deduplicate on.
|
||||
"""
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.material_request.test_material_request import (
|
||||
make_material_request_for_items,
|
||||
)
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
|
||||
self.load_test_records("Supplier Quotation")
|
||||
|
||||
def make_supplier_quotation():
|
||||
return frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]).submit()
|
||||
|
||||
return [
|
||||
(
|
||||
"Quotation -> Sales Order",
|
||||
lambda: make_quotation(),
|
||||
"erpnext.selling.doctype.quotation.quotation.make_sales_order",
|
||||
),
|
||||
(
|
||||
"Sales Order -> Sales Invoice",
|
||||
lambda: make_sales_order(),
|
||||
"erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice",
|
||||
),
|
||||
(
|
||||
"Sales Order -> Delivery Note",
|
||||
lambda: make_sales_order(),
|
||||
"erpnext.selling.doctype.sales_order.sales_order.make_delivery_note",
|
||||
),
|
||||
(
|
||||
"Delivery Note -> Sales Invoice",
|
||||
lambda: create_delivery_note(),
|
||||
"erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
|
||||
),
|
||||
(
|
||||
"Material Request -> Purchase Order",
|
||||
lambda: make_material_request_for_items(["_Test Item"]),
|
||||
"erpnext.stock.doctype.material_request.material_request.make_purchase_order",
|
||||
),
|
||||
(
|
||||
"Supplier Quotation -> Purchase Order",
|
||||
make_supplier_quotation,
|
||||
"erpnext.buying.doctype.supplier_quotation.supplier_quotation.make_purchase_order",
|
||||
),
|
||||
(
|
||||
"Purchase Order -> Purchase Receipt",
|
||||
lambda: create_purchase_order(),
|
||||
"erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_receipt",
|
||||
),
|
||||
(
|
||||
"Purchase Order -> Purchase Invoice",
|
||||
lambda: create_purchase_order(),
|
||||
"erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_invoice",
|
||||
),
|
||||
(
|
||||
"Purchase Receipt -> Purchase Invoice",
|
||||
lambda: make_purchase_receipt(),
|
||||
"erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_invoice",
|
||||
),
|
||||
(
|
||||
"Purchase Invoice -> Purchase Receipt",
|
||||
lambda: make_purchase_invoice(),
|
||||
"erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_purchase_receipt",
|
||||
),
|
||||
]
|
||||
|
||||
def make_quotation(self, item_list, customer):
|
||||
qtn = frappe.get_doc(
|
||||
{
|
||||
|
||||
@@ -88,3 +88,35 @@ class TestSalesAndPurchaseReturn(ERPNextTestSuite):
|
||||
return_si.items[0].qty = 0
|
||||
|
||||
self.assertRaises(frappe.ValidationError, return_si.save)
|
||||
|
||||
def test_sales_invoice_partial_return_with_different_stock_uom(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_properties = {"is_stock_item": 1, "stock_uom": "Kg"}
|
||||
if frappe.get_meta("Item").has_field("gst_hsn_code") and frappe.db.exists("GST HSN Code", "010121"):
|
||||
item_properties["gst_hsn_code"] = "010121"
|
||||
|
||||
item = make_item(
|
||||
"_Test SI Return Different Stock UOM",
|
||||
item_properties,
|
||||
uoms=[{"uom": "Nos", "conversion_factor": 0.013888889}],
|
||||
)
|
||||
|
||||
si = create_sales_invoice(item_code=item.name, qty=48, do_not_save=True)
|
||||
si.items[0].uom = "Nos"
|
||||
si.items[0].stock_uom = "Kg"
|
||||
si.items[0].conversion_factor = 0.013888889
|
||||
si.save().submit()
|
||||
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
|
||||
|
||||
first_return = make_return_doc(si.doctype, si.name)
|
||||
first_return.items[0].qty = -24
|
||||
first_return.save().submit()
|
||||
self.addCleanup(self._cancel_and_delete, "Sales Invoice", first_return.name)
|
||||
|
||||
second_return = make_return_doc(si.doctype, si.name)
|
||||
self.assertEqual(second_return.items[0].qty, -24)
|
||||
second_return.save().submit()
|
||||
self.addCleanup(self._cancel_and_delete, "Sales Invoice", second_return.name)
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
from unittest import mock
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
def resolve_on_gross(calc, item, tax):
|
||||
# base = gross printed line amount
|
||||
return flt(item.amount)
|
||||
|
||||
|
||||
def resolve_on_mrp(calc, item, tax):
|
||||
# base = MRP, not net
|
||||
return flt(item.price_list_rate) * flt(item.qty)
|
||||
|
||||
|
||||
class TestTaxesAndTotals(ERPNextTestSuite):
|
||||
def test_regional_round_off_accounts(self):
|
||||
"""
|
||||
@@ -30,6 +42,93 @@ class TestTaxesAndTotals(ERPNextTestSuite):
|
||||
|
||||
self.assertIn(test_account, frappe.flags.round_off_applicable_accounts)
|
||||
|
||||
def test_exclusive_custom_charge_on_resolved_base(self):
|
||||
"""Added (exclusive) custom charge_type whose base is resolved by the
|
||||
`erpnext_taxable_base_resolvers` hook. IPI 10% on the gross product value 1000
|
||||
-> tax 100, net 1000, grand 1100."""
|
||||
so = make_sales_order(do_not_save=True)
|
||||
so.items = []
|
||||
so.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"qty": 1,
|
||||
"rate": 1000,
|
||||
"price_list_rate": 1000,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
},
|
||||
)
|
||||
so.set("taxes", [])
|
||||
so.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Gross Value",
|
||||
"account_head": "_Test Account Excise Duty - _TC",
|
||||
"description": "IPI 10% on gross product value",
|
||||
"rate": 10,
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
},
|
||||
)
|
||||
|
||||
real_get_hooks = frappe.get_hooks
|
||||
|
||||
def fake_get_hooks(hook=None, *args, **kwargs):
|
||||
if hook == "erpnext_taxable_base_resolvers":
|
||||
return {
|
||||
"On Gross Value": ["erpnext.controllers.tests.test_taxes_and_totals.resolve_on_gross"]
|
||||
}
|
||||
return real_get_hooks(hook, *args, **kwargs)
|
||||
|
||||
with mock.patch("frappe.get_hooks", side_effect=fake_get_hooks):
|
||||
calculate_taxes_and_totals(so)
|
||||
|
||||
self.assertEqual(so.net_total, 1000.0)
|
||||
self.assertEqual(so.taxes[0].tax_amount, 100.0)
|
||||
self.assertEqual(so.grand_total, 1100.0)
|
||||
|
||||
def test_inclusive_custom_charge_on_resolved_base(self):
|
||||
"""Inclusive custom charge on a resolved base backs out non-compounding
|
||||
(tax = rate x resolved base) — a resolved base is fixed, so it never
|
||||
compounds. MRP 1200, printed 1000, rate 10%: tax 120, net 880."""
|
||||
so = make_sales_order(do_not_save=True)
|
||||
so.items = []
|
||||
so.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"qty": 1,
|
||||
"rate": 1000,
|
||||
"price_list_rate": 1200,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
},
|
||||
)
|
||||
so.set("taxes", [])
|
||||
so.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On MRP",
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"description": "Tax 10% on MRP, inclusive",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
},
|
||||
)
|
||||
|
||||
real_get_hooks = frappe.get_hooks
|
||||
|
||||
def fake_get_hooks(hook=None, *args, **kwargs):
|
||||
if hook == "erpnext_taxable_base_resolvers":
|
||||
return {"On MRP": ["erpnext.controllers.tests.test_taxes_and_totals.resolve_on_mrp"]}
|
||||
return real_get_hooks(hook, *args, **kwargs)
|
||||
|
||||
with mock.patch("frappe.get_hooks", side_effect=fake_get_hooks):
|
||||
calculate_taxes_and_totals(so)
|
||||
|
||||
self.assertEqual(so.taxes[0].tax_amount, 120.0)
|
||||
self.assertEqual(so.net_total, 880.0)
|
||||
self.assertEqual(so.grand_total, 1000.0)
|
||||
|
||||
def test_disabling_rounded_total_resets_base_fields(self):
|
||||
"""Disabling rounded total should also clear base rounded values."""
|
||||
so = make_sales_order(do_not_save=True)
|
||||
|
||||
@@ -39,6 +39,7 @@ def get_contract_template(template_name, doc):
|
||||
doc = json.loads(doc)
|
||||
|
||||
contract_template = frappe.get_doc("Contract Template", template_name)
|
||||
contract_template.check_permission()
|
||||
contract_terms = None
|
||||
|
||||
if contract_template.contract_terms:
|
||||
|
||||
@@ -69,6 +69,13 @@ class CRMSettings(Document):
|
||||
self.allowed_users = []
|
||||
|
||||
def custom_fields_for_frappe_crm_data_sync(self):
|
||||
custom_fields = self.get_frappe_crm_custom_fields()
|
||||
|
||||
if self.enable_frappe_crm_data_synchronization:
|
||||
create_custom_fields(custom_fields, ignore_validate=True)
|
||||
|
||||
@staticmethod
|
||||
def get_frappe_crm_custom_fields():
|
||||
custom_fields = {
|
||||
"Quotation": [
|
||||
{
|
||||
@@ -88,4 +95,4 @@ class CRMSettings(Document):
|
||||
],
|
||||
}
|
||||
|
||||
create_custom_fields(custom_fields, ignore_validate=True)
|
||||
return custom_fields
|
||||
|
||||
@@ -29,12 +29,19 @@ class EmailCampaign(Document):
|
||||
|
||||
def validate(self):
|
||||
self.set_date()
|
||||
# checking if email is set for lead. Not checking for contact as email is a mandatory field for contact.
|
||||
if self.email_campaign_for == "Lead":
|
||||
self.validate_lead()
|
||||
self.validate_recipient_email()
|
||||
self.validate_email_campaign_already_exists()
|
||||
self.update_status()
|
||||
|
||||
def validate_recipient_email(self):
|
||||
if not self.recipient:
|
||||
return
|
||||
|
||||
if self.email_campaign_for == "Lead":
|
||||
self.validate_lead()
|
||||
elif self.email_campaign_for == "Contact":
|
||||
self.validate_contact()
|
||||
|
||||
def set_date(self):
|
||||
if getdate(self.start_date) < getdate(today()):
|
||||
frappe.throw(_("Start Date cannot be before the current date"))
|
||||
@@ -56,6 +63,13 @@ class EmailCampaign(Document):
|
||||
lead_name = frappe.db.get_value("Lead", self.recipient, "lead_name")
|
||||
frappe.throw(_("Please set an email id for the Lead {0}").format(lead_name))
|
||||
|
||||
def validate_contact(self):
|
||||
contact = frappe.db.get_value("Contact", self.recipient, ["email_id", "full_name"], as_dict=True)
|
||||
if contact and not contact.email_id:
|
||||
frappe.throw(
|
||||
_("Please set a primary email ID for the Contact {0}").format(frappe.bold(contact.full_name))
|
||||
)
|
||||
|
||||
def validate_email_campaign_already_exists(self):
|
||||
email_campaign_exists = frappe.db.exists(
|
||||
"Email Campaign",
|
||||
|
||||
@@ -1,10 +1,84 @@
|
||||
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
# import frappe
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, getdate, today
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestEmailCampaign(ERPNextTestSuite):
|
||||
pass
|
||||
"""Email Campaign derives its window from the linked Campaign schedule and
|
||||
guards the start date and the recipient's email."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_email_template(self):
|
||||
name = "_Test EC Email Template"
|
||||
if not frappe.db.exists("Email Template", name):
|
||||
frappe.get_doc(
|
||||
{"doctype": "Email Template", "name": name, "subject": "Test", "response": "Hello"}
|
||||
).insert()
|
||||
return name
|
||||
|
||||
def make_campaign(self, schedules):
|
||||
campaign = frappe.new_doc("Campaign")
|
||||
campaign.campaign_name = f"_Test EC Campaign {frappe.generate_hash(length=6)}"
|
||||
for days in schedules:
|
||||
campaign.append(
|
||||
"campaign_schedules",
|
||||
{"send_after_days": days, "email_template": self.make_email_template()},
|
||||
)
|
||||
return campaign.insert()
|
||||
|
||||
def make_email_campaign(self, campaign_name, start_date=None):
|
||||
doc = frappe.new_doc("Email Campaign")
|
||||
doc.campaign_name = campaign_name
|
||||
doc.start_date = start_date or today()
|
||||
return doc
|
||||
|
||||
def test_start_date_cannot_be_in_the_past(self):
|
||||
doc = self.make_email_campaign("irrelevant", start_date=add_days(today(), -1))
|
||||
self.assertRaises(frappe.ValidationError, doc.set_date)
|
||||
|
||||
def test_end_date_is_start_plus_max_send_after_days(self):
|
||||
campaign = self.make_campaign(schedules=[0, 5])
|
||||
doc = self.make_email_campaign(campaign.name)
|
||||
doc.set_date()
|
||||
self.assertEqual(getdate(doc.end_date), add_days(getdate(today()), 5))
|
||||
|
||||
def test_campaign_without_a_schedule_is_rejected(self):
|
||||
campaign = self.make_campaign(schedules=[])
|
||||
doc = self.make_email_campaign(campaign.name)
|
||||
self.assertRaises(frappe.ValidationError, doc.set_date)
|
||||
|
||||
def test_lead_without_an_email_is_rejected(self):
|
||||
lead = frappe.get_doc({"doctype": "Lead", "lead_name": "_Test Lead No Email"}).insert()
|
||||
doc = frappe.new_doc("Email Campaign")
|
||||
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")
|
||||
|
||||
@@ -13,6 +13,7 @@ from frappe.query_builder import DocType, Interval
|
||||
from frappe.query_builder.functions import Now
|
||||
from frappe.utils import flt, get_fullname
|
||||
|
||||
from erpnext.accounts.party import validate_party_frozen_disabled
|
||||
from erpnext.crm.utils import (
|
||||
CRMNote,
|
||||
copy_comments,
|
||||
@@ -133,6 +134,7 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
self.validate_item_details()
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.validate_cust_name()
|
||||
self.validate_party()
|
||||
self.map_fields()
|
||||
self.validate_qty()
|
||||
self.set_exchange_rate()
|
||||
@@ -348,6 +350,10 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
return False
|
||||
return True
|
||||
|
||||
def validate_party(self) -> None:
|
||||
if self.opportunity_from == "Customer":
|
||||
validate_party_frozen_disabled(self.company, "Customer", self.party_name)
|
||||
|
||||
def validate_cust_name(self):
|
||||
if self.party_name:
|
||||
if self.opportunity_from == "Customer":
|
||||
|
||||
@@ -9,6 +9,7 @@ from erpnext.crm.doctype.lead.lead import make_customer
|
||||
from erpnext.crm.doctype.lead.test_lead import make_lead
|
||||
from erpnext.crm.doctype.opportunity.opportunity import make_quotation
|
||||
from erpnext.crm.utils import get_linked_communication_list
|
||||
from erpnext.exceptions import PartyDisabled
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -71,6 +72,23 @@ class TestOpportunity(ERPNextTestSuite):
|
||||
opportunity_doc = make_opportunity(with_items=1, rate=1100, qty=2)
|
||||
self.assertEqual(opportunity_doc.total, 2200)
|
||||
|
||||
def test_disabled_customer_not_allowed(self):
|
||||
frappe.db.set_value("Customer", "_Test Customer", "disabled", 1)
|
||||
|
||||
self.assertRaises(PartyDisabled, make_opportunity, with_items=0)
|
||||
|
||||
frappe.db.set_value("Customer", "_Test Customer", "disabled", 0)
|
||||
make_opportunity(with_items=0)
|
||||
|
||||
def test_disabled_lead_not_blocked(self):
|
||||
# Lead.disabled isn't enforced anywhere else (e.g. the Lead picker query only
|
||||
# excludes Converted leads), so it shouldn't block Opportunity creation either.
|
||||
lead_doc = make_lead()
|
||||
frappe.db.set_value("Lead", lead_doc.name, "disabled", 1)
|
||||
|
||||
opp_doc = make_opportunity(opportunity_from="Lead", lead=lead_doc.name)
|
||||
self.assertEqual(opp_doc.party_name, lead_doc.name)
|
||||
|
||||
def test_carry_forward_of_email_and_comments(self):
|
||||
frappe.db.set_single_value("CRM Settings", "carry_forward_communication_and_comments", 1)
|
||||
lead_doc = make_lead()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user