mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-07 03:33:03 +00:00
Compare commits
308 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31fa1c9a58 | ||
|
|
631a9bfa7c | ||
|
|
67741f1a21 | ||
|
|
6e699178ae | ||
|
|
0f350ef24d | ||
|
|
56d0357f6f | ||
|
|
fbbae80f92 | ||
|
|
dcfae61a7a | ||
|
|
242a119f95 | ||
|
|
a75931c90f | ||
|
|
96d3bfd2d9 | ||
|
|
165a4fcef6 | ||
|
|
29b35d6eb0 | ||
|
|
39c029133f | ||
|
|
7f55d59a7b | ||
|
|
95f21e5ecd | ||
|
|
bafd9ed15e | ||
|
|
25fabda40a | ||
|
|
b6e5e3347d | ||
|
|
2868446292 | ||
|
|
811fe4fee6 | ||
|
|
b6bf13ff02 | ||
|
|
b82e2585d5 | ||
|
|
9ca96a63c3 | ||
|
|
98cb9c6b96 | ||
|
|
d61a85e316 | ||
|
|
6dbdc36af9 | ||
|
|
b18692c120 | ||
|
|
309ea7b9cf | ||
|
|
775b2432ab | ||
|
|
64ae4e1fec | ||
|
|
005014ef6a | ||
|
|
4a37f2a925 | ||
|
|
870be7a79b | ||
|
|
67d24e9635 | ||
|
|
3ba7bb3ab7 | ||
|
|
72ec3f3d18 | ||
|
|
7ba7d1a2a4 | ||
|
|
7abe199e2a | ||
|
|
09e7bfbacb | ||
|
|
ef7b09fc11 | ||
|
|
0adb7156cd | ||
|
|
7fb557197a | ||
|
|
7733e417a4 | ||
|
|
d8fb11009f | ||
|
|
2bd30e3c46 | ||
|
|
fb56db1166 | ||
|
|
b6908a79bd | ||
|
|
f4551bb918 | ||
|
|
01e975b481 | ||
|
|
91cbf2ec4f | ||
|
|
c2e36daa32 | ||
|
|
74caf8134c | ||
|
|
40faa7f7b9 | ||
|
|
f73e99e9d2 | ||
|
|
2d77e056bc | ||
|
|
0aabe4fd1e | ||
|
|
8d1e855dc8 | ||
|
|
0e5c709f7b | ||
|
|
21612fc230 | ||
|
|
afb44a677c | ||
|
|
4f6aee3f22 | ||
|
|
fc1b1ca5e2 | ||
|
|
f69b8d7e2d | ||
|
|
2147441e64 | ||
|
|
5e5cf68b32 | ||
|
|
7c8245d299 | ||
|
|
a4c9707fdf | ||
|
|
f4a43d07b0 | ||
|
|
c1ed750bcb | ||
|
|
1d139eb94a | ||
|
|
73a418a2bd | ||
|
|
035394ae6a | ||
|
|
1ef7da837f | ||
|
|
85a8adf804 | ||
|
|
d3445b3079 | ||
|
|
ada7821a49 | ||
|
|
3f7dcedf3d | ||
|
|
4fc14b3097 | ||
|
|
4ef2b77973 | ||
|
|
96996bd8a9 | ||
|
|
a9e40bc0d8 | ||
|
|
d15b7ca9af | ||
|
|
171b687611 | ||
|
|
cc8418b7e4 | ||
|
|
0056fb1d0f | ||
|
|
d640c79c1c | ||
|
|
2edd12b26d | ||
|
|
71482261c7 | ||
|
|
9df5727ea4 | ||
|
|
fc8a8b5433 | ||
|
|
409831183b | ||
|
|
afb67f1f0c | ||
|
|
7bc39349b0 | ||
|
|
0927155171 | ||
|
|
c2235e2d17 | ||
|
|
9495a2ac9d | ||
|
|
cb61e0bd18 | ||
|
|
954fec16f4 | ||
|
|
6b1b30a4a6 | ||
|
|
b0399fe948 | ||
|
|
91bcefef8c | ||
|
|
74bdc82bfa | ||
|
|
5b32bb7468 | ||
|
|
8a30a31302 | ||
|
|
a9df1f5f6b | ||
|
|
0763a8d42d | ||
|
|
c02a4a4591 | ||
|
|
1cbcf7532c | ||
|
|
e0cea49236 | ||
|
|
2e6112f21b | ||
|
|
feb4038c06 | ||
|
|
d8c0e7156e | ||
|
|
7baa8f50fb | ||
|
|
62261a276f | ||
|
|
ac3b2ba003 | ||
|
|
93ea2f93b6 | ||
|
|
5c300b893b | ||
|
|
290f0b94e5 | ||
|
|
1fe1563dab | ||
|
|
5f101e7635 | ||
|
|
3e733f6ba1 | ||
|
|
5c4dc7a16e | ||
|
|
925cc40efa | ||
|
|
d947beec88 | ||
|
|
b2294ed6e3 | ||
|
|
f8da1599bb | ||
|
|
636e9e0998 | ||
|
|
727c32d789 | ||
|
|
4bcea55563 | ||
|
|
10d843e490 | ||
|
|
0caba9f70d | ||
|
|
4a29a54804 | ||
|
|
c140fd0f12 | ||
|
|
c0ae1336f4 | ||
|
|
4c3044cd70 | ||
|
|
f7efd006c2 | ||
|
|
8050e653ab | ||
|
|
2ba6c78e5e | ||
|
|
04a1578b53 | ||
|
|
2b05ccfa6f | ||
|
|
680c221f05 | ||
|
|
bbbbb4da55 | ||
|
|
e2f8ca5f87 | ||
|
|
6671f4df41 | ||
|
|
4fba4d49d2 | ||
|
|
982a68b71a | ||
|
|
a09ab902e5 | ||
|
|
ad35021666 | ||
|
|
f6edd5aa7d | ||
|
|
e752f3f914 | ||
|
|
31af933c1c | ||
|
|
a450ce25b9 | ||
|
|
e152c72a7b | ||
|
|
5dc63f97a1 | ||
|
|
8bc00ffbb4 | ||
|
|
9655bfa199 | ||
|
|
cb2b9563e0 | ||
|
|
bb71b91d4a | ||
|
|
05d4c1e6ca | ||
|
|
d7556069e4 | ||
|
|
a3821cf182 | ||
|
|
18e3171cc9 | ||
|
|
1f8fce253d | ||
|
|
9184c40371 | ||
|
|
2f1f229144 | ||
|
|
393e4462f8 | ||
|
|
3b349f44b1 | ||
|
|
c430ce96b3 | ||
|
|
7b5297bb7f | ||
|
|
3d4f3e1be7 | ||
|
|
5535eb4817 | ||
|
|
af35e43555 | ||
|
|
f53a45cfef | ||
|
|
19045bcace | ||
|
|
b8a7f6dac1 | ||
|
|
f2c3150687 | ||
|
|
390780d871 | ||
|
|
7adba1f0f3 | ||
|
|
d6d042868d | ||
|
|
f764b9713b | ||
|
|
16877fade8 | ||
|
|
846b24ba52 | ||
|
|
451b1a19a8 | ||
|
|
ad177e08b8 | ||
|
|
00a8503dd7 | ||
|
|
76a2eb69a0 | ||
|
|
673694ae48 | ||
|
|
0287f34928 | ||
|
|
f07c3d9124 | ||
|
|
d18266d839 | ||
|
|
0c714e2bd9 | ||
|
|
20aba541c4 | ||
|
|
be154a469f | ||
|
|
741f29e57a | ||
|
|
5090108718 | ||
|
|
5c04d183bf | ||
|
|
bb6d6c3edf | ||
|
|
99735e0af4 | ||
|
|
929d177b7d | ||
|
|
7555d27d82 | ||
|
|
4a7b91352b | ||
|
|
188c4f896a | ||
|
|
9bdeacbbfa | ||
|
|
c42f76b15a | ||
|
|
a944853b56 | ||
|
|
b7c3fa23d2 | ||
|
|
073a7a6ca6 | ||
|
|
c8691b6516 | ||
|
|
aa0b93d0b2 | ||
|
|
3f652bd4e1 | ||
|
|
1dc98124dc | ||
|
|
5cb8e5dfbd | ||
|
|
f816934a28 | ||
|
|
1e340ccd9c | ||
|
|
21e94148db | ||
|
|
233a2c08a1 | ||
|
|
11566e20b5 | ||
|
|
393d2459b9 | ||
|
|
537a8efe7a | ||
|
|
50b2196020 | ||
|
|
0ad8935f2c | ||
|
|
148287d8a1 | ||
|
|
f4854a9a02 | ||
|
|
b7cbc66a28 | ||
|
|
5a20b9e94f | ||
|
|
127c7b93ac | ||
|
|
c77f7f4ff0 | ||
|
|
8e02a9bc28 | ||
|
|
640012429e | ||
|
|
603f737c99 | ||
|
|
4fd99ed763 | ||
|
|
77bbaef5a6 | ||
|
|
c58800a929 | ||
|
|
924e9b94b6 | ||
|
|
6bfb3c6905 | ||
|
|
da65f44a47 | ||
|
|
13d3b27a1f | ||
|
|
dedb19e3e9 | ||
|
|
b3e852adfc | ||
|
|
73683b2754 | ||
|
|
f1254e51e5 | ||
|
|
416d9bce2c | ||
|
|
f5fd255e82 | ||
|
|
b2fb4fba51 | ||
|
|
1b8a317de9 | ||
|
|
debfcdc61f | ||
|
|
706092061b | ||
|
|
c6c8aa02eb | ||
|
|
0ff7465e27 | ||
|
|
00b25537f4 | ||
|
|
6dee592ba7 | ||
|
|
8233aadd50 | ||
|
|
e479975f54 | ||
|
|
2431141062 | ||
|
|
b0e8f85a27 | ||
|
|
b0f3d62dd0 | ||
|
|
e47d07d98c | ||
|
|
7ab81b7e54 | ||
|
|
5e1d3f77bb | ||
|
|
00ac8597b0 | ||
|
|
4163b18ec3 | ||
|
|
21f0dcbcc3 | ||
|
|
2e6ba91589 | ||
|
|
14a502c956 | ||
|
|
24c8a06520 | ||
|
|
13f73b59df | ||
|
|
b4efba80a7 | ||
|
|
b819e0a61b | ||
|
|
01aad96b10 | ||
|
|
860aba47ef | ||
|
|
1b6c4b6b4b | ||
|
|
663e2b7e6c | ||
|
|
cc7756dd49 | ||
|
|
d79de4a434 | ||
|
|
b8ef83e1ea | ||
|
|
8e9ddb7a69 | ||
|
|
d43ce7f8e0 | ||
|
|
b5353e4ad1 | ||
|
|
14efeb4acd | ||
|
|
35daf669fe | ||
|
|
0c260baacd | ||
|
|
cf00d42799 | ||
|
|
b06a86541b | ||
|
|
f762f7ec3e | ||
|
|
12bd9af3aa | ||
|
|
0b0a6b8cfa | ||
|
|
830290c859 | ||
|
|
98b75aaa38 | ||
|
|
d319d89988 | ||
|
|
d94ebd0c78 | ||
|
|
7896f8a855 | ||
|
|
7cf83ffce7 | ||
|
|
18e9a9881c | ||
|
|
6fedb7b9db | ||
|
|
9af50528f1 | ||
|
|
e7b5303782 | ||
|
|
0ec026ec7e | ||
|
|
c472af87b2 | ||
|
|
250b67076d | ||
|
|
2ed6c211f9 | ||
|
|
bde55d2a07 | ||
|
|
db647a4e42 | ||
|
|
79ed02bb2c | ||
|
|
42479d9a7f | ||
|
|
6f94ba599b | ||
|
|
6f6574c5ac | ||
|
|
ad05e6dec2 |
@@ -1,5 +1,5 @@
|
||||
exclude: 'node_modules|.git'
|
||||
default_stages: [commit]
|
||||
default_stages: [pre-commit]
|
||||
fail_fast: false
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import inspect
|
||||
import frappe
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "15.57.5"
|
||||
__version__ = "15.61.1"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -77,9 +77,12 @@
|
||||
"reports_tab",
|
||||
"remarks_section",
|
||||
"general_ledger_remarks_length",
|
||||
"ignore_is_opening_check_for_reporting",
|
||||
"column_break_lvjk",
|
||||
"receivable_payable_remarks_length",
|
||||
"accounts_receivable_payable_tuning_section",
|
||||
"receivable_payable_fetch_method",
|
||||
"legacy_section",
|
||||
"ignore_is_opening_check_for_reporting",
|
||||
"payment_request_settings",
|
||||
"create_pr_in_draft_status"
|
||||
],
|
||||
@@ -532,6 +535,34 @@
|
||||
"fieldtype": "Select",
|
||||
"label": "Posting Date Inheritance for Exchange Gain / Loss",
|
||||
"options": "Invoice\nPayment\nReconciliation Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_xrnd",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If enabled, Sales Invoice will be generated instead of POS Invoice in POS Transactions for real-time update of G/L and Stock Ledger.",
|
||||
"fieldname": "use_sales_invoice_in_pos",
|
||||
"fieldtype": "Check",
|
||||
"label": "Use Sales Invoice"
|
||||
},
|
||||
{
|
||||
"default": "Buffered Cursor",
|
||||
"fieldname": "receivable_payable_fetch_method",
|
||||
"fieldtype": "Select",
|
||||
"label": "Data Fetch Method",
|
||||
"options": "Buffered Cursor\nUnBuffered Cursor"
|
||||
},
|
||||
{
|
||||
"fieldname": "accounts_receivable_payable_tuning_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Accounts Receivable / Payable Tuning"
|
||||
},
|
||||
{
|
||||
"fieldname": "legacy_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Legacy Fields"
|
||||
}
|
||||
],
|
||||
"icon": "icon-cog",
|
||||
@@ -539,7 +570,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-23 13:15:44.077853",
|
||||
"modified": "2025-05-05 12:29:38.302027",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
@@ -54,6 +54,7 @@ class AccountsSettings(Document):
|
||||
merge_similar_account_heads: DF.Check
|
||||
over_billing_allowance: DF.Currency
|
||||
post_change_gl_entries: DF.Check
|
||||
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
|
||||
receivable_payable_remarks_length: DF.Int
|
||||
reconciliation_queue_size: DF.Int
|
||||
role_allowed_to_over_bill: DF.Link | None
|
||||
|
||||
@@ -7,6 +7,9 @@ import frappe
|
||||
from frappe.utils import add_months, getdate
|
||||
|
||||
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
|
||||
from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import (
|
||||
set_default_account_for_mode_of_payment,
|
||||
)
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
@@ -191,11 +194,13 @@ def make_pos_sales_invoice():
|
||||
|
||||
customer = make_customer(customer="_Test Customer")
|
||||
|
||||
mode_of_payment = frappe.get_doc("Mode of Payment", "Wire Transfer")
|
||||
|
||||
set_default_account_for_mode_of_payment(mode_of_payment, "_Test Company", "_Test Bank Clearance - _TC")
|
||||
|
||||
si = create_sales_invoice(customer=customer, item="_Test Item", is_pos=1, qty=1, rate=1000, do_not_save=1)
|
||||
si.set("payments", [])
|
||||
si.append(
|
||||
"payments", {"mode_of_payment": "Cash", "account": "_Test Bank Clearance - _TC", "amount": 1000}
|
||||
)
|
||||
si.append("payments", {"mode_of_payment": "Wire Transfer", "amount": 1000})
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool
|
||||
get_linked_payments,
|
||||
reconcile_vouchers,
|
||||
)
|
||||
from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import (
|
||||
set_default_account_for_mode_of_payment,
|
||||
)
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
@@ -434,15 +437,13 @@ def add_vouchers(gl_account="_Test Bank - _TC"):
|
||||
except frappe.DuplicateEntryError:
|
||||
pass
|
||||
|
||||
mode_of_payment = frappe.get_doc({"doctype": "Mode of Payment", "name": "Cash"})
|
||||
mode_of_payment = frappe.get_doc({"doctype": "Mode of Payment", "name": "Wire Transfer"})
|
||||
|
||||
if not frappe.db.get_value("Mode of Payment Account", {"company": "_Test Company", "parent": "Cash"}):
|
||||
mode_of_payment.append("accounts", {"company": "_Test Company", "default_account": gl_account})
|
||||
mode_of_payment.save()
|
||||
set_default_account_for_mode_of_payment(mode_of_payment, "_Test Company", gl_account)
|
||||
|
||||
si = create_sales_invoice(customer="Fayva", qty=1, rate=109080, do_not_save=1)
|
||||
si.is_pos = 1
|
||||
si.append("payments", {"mode_of_payment": "Cash", "account": gl_account, "amount": 109080})
|
||||
si.append("payments", {"mode_of_payment": "Wire Transfer", "amount": 109080})
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe.model import mapper
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import add_days, nowdate, today
|
||||
|
||||
@@ -68,6 +71,36 @@ class TestDunning(FrappeTestCase):
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
|
||||
def test_fetch_overdue_payments(self):
|
||||
"""
|
||||
Create SI with overdue payment. Check if overdue payment is fetched in Dunning.
|
||||
"""
|
||||
si1 = create_sales_invoice_against_cost_center(
|
||||
posting_date=add_days(today(), -1 * 6),
|
||||
qty=1,
|
||||
rate=100,
|
||||
)
|
||||
|
||||
si2 = create_sales_invoice_against_cost_center(
|
||||
posting_date=add_days(today(), -1 * 6),
|
||||
qty=1,
|
||||
rate=300,
|
||||
)
|
||||
|
||||
dunning = create_dunning_from_sales_invoice(si1.name)
|
||||
dunning.overdue_payments = []
|
||||
|
||||
method = "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning"
|
||||
updated_dunning = mapper.map_docs(method, json.dumps([si1.name, si2.name]), dunning)
|
||||
|
||||
self.assertEqual(len(updated_dunning.overdue_payments), 2)
|
||||
|
||||
self.assertEqual(updated_dunning.overdue_payments[0].sales_invoice, si1.name)
|
||||
self.assertEqual(updated_dunning.overdue_payments[0].outstanding, si1.outstanding_amount)
|
||||
|
||||
self.assertEqual(updated_dunning.overdue_payments[1].sales_invoice, si2.name)
|
||||
self.assertEqual(updated_dunning.overdue_payments[1].outstanding, si2.outstanding_amount)
|
||||
|
||||
def test_dunning_and_payment_against_partially_due_invoice(self):
|
||||
"""
|
||||
Create SI with first installment overdue. Check impact of Dunning and Payment Entry.
|
||||
|
||||
@@ -7,7 +7,7 @@ from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.model.naming import set_name_from_naming_options
|
||||
from frappe.utils import flt, fmt_money
|
||||
from frappe.utils import flt, fmt_money, now
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
@@ -456,7 +456,7 @@ def rename_temporarily_named_docs(doctype):
|
||||
set_name_from_naming_options(frappe.get_meta(doctype).autoname, doc)
|
||||
newname = doc.name
|
||||
frappe.db.sql(
|
||||
f"UPDATE `tab{doctype}` SET name = %s, to_rename = 0 where name = %s",
|
||||
(newname, oldname),
|
||||
f"UPDATE `tab{doctype}` SET name = %s, to_rename = 0, modified = %s where name = %s",
|
||||
(newname, now(), oldname),
|
||||
auto_commit=True,
|
||||
)
|
||||
|
||||
@@ -197,7 +197,7 @@ frappe.ui.form.on("Invoice Discounting", {
|
||||
from_date: frm.doc.posting_date,
|
||||
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
|
||||
company: frm.doc.company,
|
||||
group_by: "Group by Voucher (Consolidated)",
|
||||
categorize_by: "Categorize by Voucher (Consolidated)",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
|
||||
@@ -35,7 +35,7 @@ frappe.ui.form.on("Journal Entry", {
|
||||
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
|
||||
company: frm.doc.company,
|
||||
finance_book: frm.doc.finance_book,
|
||||
group_by: "",
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
|
||||
@@ -250,11 +250,20 @@ class JournalEntry(AccountsController):
|
||||
|
||||
def validate_inter_company_accounts(self):
|
||||
if self.voucher_type == "Inter Company Journal Entry" and self.inter_company_journal_entry_reference:
|
||||
doc = frappe.get_doc("Journal Entry", self.inter_company_journal_entry_reference)
|
||||
doc = frappe.db.get_value(
|
||||
"Journal Entry",
|
||||
self.inter_company_journal_entry_reference,
|
||||
["company", "total_debit", "total_credit"],
|
||||
as_dict=True,
|
||||
)
|
||||
account_currency = frappe.get_cached_value("Company", self.company, "default_currency")
|
||||
previous_account_currency = frappe.get_cached_value("Company", doc.company, "default_currency")
|
||||
if account_currency == previous_account_currency:
|
||||
if self.total_credit != doc.total_debit or self.total_debit != doc.total_credit:
|
||||
credit_precision = self.precision("total_credit")
|
||||
debit_precision = self.precision("total_debit")
|
||||
if (flt(self.total_credit, credit_precision) != flt(doc.total_debit, debit_precision)) or (
|
||||
flt(self.total_debit, debit_precision) != flt(doc.total_credit, credit_precision)
|
||||
):
|
||||
frappe.throw(_("Total Credit/ Debit Amount should be same as linked Journal Entry"))
|
||||
|
||||
def validate_depr_entry_voucher_type(self):
|
||||
|
||||
@@ -3,8 +3,25 @@
|
||||
|
||||
import unittest
|
||||
|
||||
# test_records = frappe.get_test_records('Mode of Payment')
|
||||
import frappe
|
||||
|
||||
|
||||
class TestModeofPayment(unittest.TestCase):
|
||||
pass
|
||||
|
||||
|
||||
def set_default_account_for_mode_of_payment(mode_of_payment, company, account):
|
||||
mode_of_payment.reload()
|
||||
if frappe.db.exists(
|
||||
"Mode of Payment Account", {"parent": mode_of_payment.mode_of_payment, "company": company}
|
||||
):
|
||||
frappe.db.set_value(
|
||||
"Mode of Payment Account",
|
||||
{"parent": mode_of_payment.mode_of_payment, "company": company},
|
||||
"default_account",
|
||||
account,
|
||||
)
|
||||
return
|
||||
|
||||
mode_of_payment.append("accounts", {"company": company, "default_account": account})
|
||||
mode_of_payment.save()
|
||||
|
||||
@@ -411,7 +411,7 @@ frappe.ui.form.on("Payment Entry", {
|
||||
from_date: frm.doc.posting_date,
|
||||
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
|
||||
company: frm.doc.company,
|
||||
group_by: "",
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
"party_name",
|
||||
"book_advance_payments_in_separate_party_account",
|
||||
"reconcile_on_advance_payment_date",
|
||||
"advance_reconciliation_takes_effect_on",
|
||||
"column_break_11",
|
||||
"bank_account",
|
||||
"party_bank_account",
|
||||
@@ -203,14 +202,14 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "party",
|
||||
"depends_on": "eval: doc.party && doc.party_type !== \"Employee\"",
|
||||
"fieldname": "contact_person",
|
||||
"fieldtype": "Link",
|
||||
"label": "Contact",
|
||||
"options": "Contact"
|
||||
},
|
||||
{
|
||||
"depends_on": "contact_person",
|
||||
"depends_on": "eval: (doc.contact_person || doc.party_type === \"Employee\") && doc.contact_email",
|
||||
"fieldname": "contact_email",
|
||||
"fieldtype": "Data",
|
||||
"label": "Email",
|
||||
@@ -786,18 +785,9 @@
|
||||
"options": "No\nYes",
|
||||
"print_hide": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"default": "Oldest Of Invoice Or Advance",
|
||||
"fetch_from": "company.reconciliation_takes_effect_on",
|
||||
"fieldname": "advance_reconciliation_takes_effect_on",
|
||||
"fieldtype": "Select",
|
||||
"hidden": 1,
|
||||
"label": "Advance Reconciliation Takes Effect On",
|
||||
"no_copy": 1,
|
||||
"options": "Advance Payment Date\nOldest Of Invoice Or Advance\nReconciliation Date"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [
|
||||
@@ -809,7 +799,7 @@
|
||||
"table_fieldname": "payment_entries"
|
||||
}
|
||||
],
|
||||
"modified": "2025-01-31 17:27:28.555246",
|
||||
"modified": "2025-05-08 11:18:10.238085",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Entry",
|
||||
@@ -849,6 +839,7 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
|
||||
@@ -38,7 +38,11 @@ from erpnext.accounts.general_ledger import (
|
||||
make_reverse_gl_entries,
|
||||
process_gl_map,
|
||||
)
|
||||
from erpnext.accounts.party import complete_contact_details, get_party_account, set_contact_details
|
||||
from erpnext.accounts.party import (
|
||||
complete_contact_details,
|
||||
get_default_contact,
|
||||
get_party_account,
|
||||
)
|
||||
from erpnext.accounts.utils import (
|
||||
cancel_exchange_gain_loss_journal,
|
||||
get_account_currency,
|
||||
@@ -441,12 +445,12 @@ class PaymentEntry(AccountsController):
|
||||
self.party_name = frappe.db.get_value(self.party_type, self.party, "name")
|
||||
|
||||
if self.party:
|
||||
if not self.contact_person:
|
||||
set_contact_details(
|
||||
self, party=frappe._dict({"name": self.party}), party_type=self.party_type
|
||||
)
|
||||
else:
|
||||
complete_contact_details(self)
|
||||
if self.party_type == "Employee":
|
||||
self.contact_person = None
|
||||
elif not self.contact_person:
|
||||
self.contact_person = get_default_contact(self.party_type, self.party)
|
||||
|
||||
complete_contact_details(self)
|
||||
if not self.party_balance:
|
||||
self.party_balance = get_balance_on(
|
||||
party_type=self.party_type, party=self.party, date=self.posting_date, company=self.company
|
||||
@@ -457,15 +461,25 @@ class PaymentEntry(AccountsController):
|
||||
self.set(self.party_account_field, party_account)
|
||||
self.party_account = party_account
|
||||
|
||||
if self.paid_from and not (self.paid_from_account_currency or self.paid_from_account_balance):
|
||||
if self.paid_from and (
|
||||
not self.paid_from_account_currency
|
||||
or not self.paid_from_account_balance
|
||||
or not self.paid_from_account_type
|
||||
):
|
||||
acc = get_account_details(self.paid_from, self.posting_date, self.cost_center)
|
||||
self.paid_from_account_currency = acc.account_currency
|
||||
self.paid_from_account_balance = acc.account_balance
|
||||
self.paid_from_account_type = acc.account_type
|
||||
|
||||
if self.paid_to and not (self.paid_to_account_currency or self.paid_to_account_balance):
|
||||
if self.paid_to and (
|
||||
not self.paid_to_account_currency
|
||||
or not self.paid_to_account_balance
|
||||
or not self.paid_to_account_type
|
||||
):
|
||||
acc = get_account_details(self.paid_to, self.posting_date, self.cost_center)
|
||||
self.paid_to_account_currency = acc.account_currency
|
||||
self.paid_to_account_balance = acc.account_balance
|
||||
self.paid_to_account_type = acc.account_type
|
||||
|
||||
self.party_account_currency = (
|
||||
self.paid_from_account_currency
|
||||
@@ -1478,9 +1492,12 @@ class PaymentEntry(AccountsController):
|
||||
else:
|
||||
# For backwards compatibility
|
||||
# Supporting reposting on payment entries reconciled before select field introduction
|
||||
if self.advance_reconciliation_takes_effect_on == "Advance Payment Date":
|
||||
reconciliation_takes_effect_on = frappe.get_cached_value(
|
||||
"Company", self.company, "reconciliation_takes_effect_on"
|
||||
)
|
||||
if reconciliation_takes_effect_on == "Advance Payment Date":
|
||||
posting_date = self.posting_date
|
||||
elif self.advance_reconciliation_takes_effect_on == "Oldest Of Invoice Or Advance":
|
||||
elif reconciliation_takes_effect_on == "Oldest Of Invoice Or Advance":
|
||||
date_field = "posting_date"
|
||||
if invoice.reference_doctype in ["Sales Order", "Purchase Order"]:
|
||||
date_field = "transaction_date"
|
||||
@@ -1490,7 +1507,7 @@ class PaymentEntry(AccountsController):
|
||||
|
||||
if getdate(posting_date) < getdate(self.posting_date):
|
||||
posting_date = self.posting_date
|
||||
elif self.advance_reconciliation_takes_effect_on == "Reconciliation Date":
|
||||
elif reconciliation_takes_effect_on == "Reconciliation Date":
|
||||
posting_date = nowdate()
|
||||
frappe.db.set_value("Payment Entry Reference", invoice.name, "reconcile_effect_on", posting_date)
|
||||
|
||||
@@ -1980,7 +1997,7 @@ class PaymentEntry(AccountsController):
|
||||
|
||||
# Re allocate amount to those references which have PR set (Higher priority)
|
||||
for ref in self.references:
|
||||
if not ref.payment_request:
|
||||
if not (ref.reference_doctype and ref.reference_name and ref.payment_request):
|
||||
continue
|
||||
|
||||
# fetch outstanding_amount of `Reference` (Payment Term) and `Payment Request` to allocate new amount
|
||||
@@ -2031,7 +2048,7 @@ class PaymentEntry(AccountsController):
|
||||
)
|
||||
# Re allocate amount to those references which have no PR (Lower priority)
|
||||
for ref in self.references:
|
||||
if ref.payment_request:
|
||||
if ref.payment_request or not (ref.reference_doctype and ref.reference_name):
|
||||
continue
|
||||
|
||||
key = (ref.reference_doctype, ref.reference_name, ref.get("payment_term"))
|
||||
@@ -2347,7 +2364,7 @@ def get_outstanding_reference_documents(args, validate=False):
|
||||
accounts = get_party_account(
|
||||
args.get("party_type"), args.get("party"), args.get("company"), include_advance=True
|
||||
)
|
||||
advance_account = accounts[1] if len(accounts) >= 1 else None
|
||||
advance_account = accounts[1] if len(accounts) > 1 else None
|
||||
|
||||
if party_account == advance_account:
|
||||
party_account = accounts[0]
|
||||
@@ -2927,6 +2944,8 @@ def get_payment_entry(
|
||||
party_account_currency if payment_type == "Receive" else bank.account_currency
|
||||
)
|
||||
pe.paid_to_account_currency = party_account_currency if payment_type == "Pay" else bank.account_currency
|
||||
pe.paid_from_account_type = frappe.db.get_value("Account", pe.paid_from, "account_type")
|
||||
pe.paid_to_account_type = frappe.db.get_value("Account", pe.paid_to, "account_type")
|
||||
pe.paid_amount = paid_amount
|
||||
pe.received_amount = received_amount
|
||||
pe.letter_head = doc.get("letter_head")
|
||||
@@ -3286,26 +3305,25 @@ def set_paid_amount_and_received_amount(
|
||||
if party_account_currency == bank.account_currency:
|
||||
paid_amount = received_amount = abs(outstanding_amount)
|
||||
else:
|
||||
company_currency = frappe.get_cached_value("Company", doc.get("company"), "default_currency")
|
||||
if payment_type == "Receive":
|
||||
paid_amount = abs(outstanding_amount)
|
||||
if bank_amount:
|
||||
received_amount = bank_amount
|
||||
else:
|
||||
if bank and company_currency != bank.account_currency:
|
||||
received_amount = paid_amount / doc.get("conversion_rate", 1)
|
||||
else:
|
||||
received_amount = paid_amount * doc.get("conversion_rate", 1)
|
||||
# settings if it is for receive
|
||||
paid_amount = abs(outstanding_amount)
|
||||
if bank_amount:
|
||||
received_amount = bank_amount
|
||||
else:
|
||||
received_amount = abs(outstanding_amount)
|
||||
if bank_amount:
|
||||
paid_amount = bank_amount
|
||||
company_currency = frappe.get_cached_value("Company", doc.get("company"), "default_currency")
|
||||
if bank and company_currency != bank.account_currency:
|
||||
# doc currency can be different from bank currency
|
||||
posting_date = doc.get("posting_date") or doc.get("transaction_date")
|
||||
conversion_rate = get_exchange_rate(
|
||||
bank.account_currency, party_account_currency, posting_date
|
||||
)
|
||||
received_amount = paid_amount / conversion_rate
|
||||
else:
|
||||
if bank and company_currency != bank.account_currency:
|
||||
paid_amount = received_amount / doc.get("conversion_rate", 1)
|
||||
else:
|
||||
# if party account currency and bank currency is different then populate paid amount as well
|
||||
paid_amount = received_amount * doc.get("conversion_rate", 1)
|
||||
received_amount = paid_amount * doc.get("conversion_rate", 1)
|
||||
|
||||
# if payment type is pay, then paid amount and received amount are swapped
|
||||
if payment_type == "Pay":
|
||||
paid_amount, received_amount = received_amount, paid_amount
|
||||
|
||||
return paid_amount, received_amount
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ class TestPaymentEntry(FrappeTestCase):
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
self.assertEqual(pe.paid_to_account_type, "Cash")
|
||||
|
||||
expected_gle = dict(
|
||||
(d[0], d) for d in [["Debtors - _TC", 0, 1000, so.name], ["_Test Cash - _TC", 1000.0, 0, None]]
|
||||
)
|
||||
@@ -560,6 +562,8 @@ class TestPaymentEntry(FrappeTestCase):
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
self.assertEqual(pe.paid_from_account_type, "Bank")
|
||||
|
||||
outstanding_amount, status = frappe.db.get_value(
|
||||
"Purchase Invoice", pi.name, ["outstanding_amount", "status"]
|
||||
)
|
||||
|
||||
@@ -670,7 +670,12 @@ def get_amount(ref_doc, payment_account=None):
|
||||
|
||||
dt = ref_doc.doctype
|
||||
if dt in ["Sales Order", "Purchase Order"]:
|
||||
grand_total = (flt(ref_doc.rounded_total) or flt(ref_doc.grand_total)) - ref_doc.advance_paid
|
||||
advance_amount = flt(ref_doc.advance_paid)
|
||||
if ref_doc.party_account_currency != ref_doc.currency:
|
||||
advance_amount = flt(flt(ref_doc.advance_paid) / ref_doc.conversion_rate)
|
||||
|
||||
grand_total = (flt(ref_doc.rounded_total) or flt(ref_doc.grand_total)) - advance_amount
|
||||
|
||||
elif dt in ["Sales Invoice", "Purchase Invoice"]:
|
||||
if (
|
||||
dt == "Sales Invoice"
|
||||
|
||||
@@ -47,7 +47,7 @@ frappe.ui.form.on("Period Closing Voucher", {
|
||||
from_date: frm.doc.posting_date,
|
||||
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
|
||||
company: frm.doc.company,
|
||||
group_by: "",
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
|
||||
@@ -133,7 +133,12 @@ class PeriodClosingVoucher(AccountsController):
|
||||
self.make_gl_entries()
|
||||
|
||||
def on_cancel(self):
|
||||
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
|
||||
self.ignore_linked_doctypes = (
|
||||
"GL Entry",
|
||||
"Stock Ledger Entry",
|
||||
"Payment Ledger Entry",
|
||||
"Account Closing Balance",
|
||||
)
|
||||
self.block_if_future_closing_voucher_exists()
|
||||
self.db_set("gle_processing_status", "In Progress")
|
||||
self.cancel_gl_entries()
|
||||
|
||||
@@ -323,3 +323,15 @@ frappe.ui.form.on("POS Invoice", {
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Sales Invoice Payment", {
|
||||
mode_of_payment: function (frm) {
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: "set_account_for_mode_of_payment",
|
||||
callback: function (r) {
|
||||
refresh_field("payments");
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -497,8 +497,11 @@ class POSInvoice(SalesInvoice):
|
||||
|
||||
def validate_full_payment(self):
|
||||
invoice_total = flt(self.rounded_total) or flt(self.grand_total)
|
||||
is_partial_payment_allowed = frappe.db.get_value(
|
||||
"POS Profile", self.pos_profile, "allow_partial_payment"
|
||||
)
|
||||
|
||||
if self.docstatus == 1:
|
||||
if self.docstatus == 1 and not is_partial_payment_allowed:
|
||||
if self.is_return and self.paid_amount != invoice_total:
|
||||
frappe.throw(
|
||||
msg=_("Partial Payment in POS Invoice is not allowed."), exc=PartialPaymentValidationError
|
||||
|
||||
@@ -7,6 +7,9 @@ import unittest
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import (
|
||||
set_default_account_for_mode_of_payment,
|
||||
)
|
||||
from erpnext.accounts.doctype.pos_invoice.pos_invoice import PartialPaymentValidationError, make_sales_return
|
||||
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
@@ -31,6 +34,8 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
|
||||
cls.test_user, cls.pos_profile = init_user_and_profile()
|
||||
create_opening_entry(cls.pos_profile, cls.test_user)
|
||||
mode_of_payment = frappe.get_doc("Mode of Payment", "Bank Draft")
|
||||
set_default_account_for_mode_of_payment(mode_of_payment, "_Test Company", "_Test Bank - _TC")
|
||||
|
||||
def tearDown(self):
|
||||
if frappe.session.user != "Administrator":
|
||||
@@ -233,12 +238,8 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
pos = create_pos_invoice(qty=10, do_not_save=True)
|
||||
|
||||
pos.set("payments", [])
|
||||
pos.append(
|
||||
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 500}
|
||||
)
|
||||
pos.append(
|
||||
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 500, "default": 1}
|
||||
)
|
||||
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 500})
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "amount": 500, "default": 1})
|
||||
pos.insert()
|
||||
pos.submit()
|
||||
|
||||
@@ -276,9 +277,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
do_not_save=1,
|
||||
)
|
||||
|
||||
pos.append(
|
||||
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000, "default": 1}
|
||||
)
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "amount": 1000, "default": 1})
|
||||
|
||||
pos.insert()
|
||||
pos.submit()
|
||||
@@ -318,9 +317,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
do_not_save=1,
|
||||
)
|
||||
|
||||
pos.append(
|
||||
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 2000, "default": 1}
|
||||
)
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "amount": 2000, "default": 1})
|
||||
|
||||
pos.insert()
|
||||
pos.submit()
|
||||
@@ -331,9 +328,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
# partial return 1
|
||||
pos_return1.get("items")[0].qty = -1
|
||||
pos_return1.set("payments", [])
|
||||
pos_return1.append(
|
||||
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": -1000, "default": 1}
|
||||
)
|
||||
pos_return1.append("payments", {"mode_of_payment": "Cash", "amount": -1000, "default": 1})
|
||||
pos_return1.paid_amount = -1000
|
||||
pos_return1.submit()
|
||||
pos_return1.reload()
|
||||
@@ -350,9 +345,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
# partial return 2
|
||||
pos_return2 = make_sales_return(pos.name)
|
||||
pos_return2.set("payments", [])
|
||||
pos_return2.append(
|
||||
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": -1000, "default": 1}
|
||||
)
|
||||
pos_return2.append("payments", {"mode_of_payment": "Cash", "amount": -1000, "default": 1})
|
||||
pos_return2.paid_amount = -1000
|
||||
pos_return2.submit()
|
||||
|
||||
@@ -372,10 +365,8 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
)
|
||||
|
||||
pos.set("payments", [])
|
||||
pos.append("payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 50})
|
||||
pos.append(
|
||||
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 60, "default": 1}
|
||||
)
|
||||
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 50})
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "amount": 60, "default": 1})
|
||||
|
||||
pos.insert()
|
||||
pos.submit()
|
||||
@@ -393,7 +384,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
pos_inv = create_pos_invoice(rate=10000, do_not_save=1)
|
||||
pos_inv.append(
|
||||
"payments",
|
||||
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 9000},
|
||||
{"mode_of_payment": "Cash", "amount": 9000},
|
||||
)
|
||||
pos_inv.insert()
|
||||
self.assertRaises(PartialPaymentValidationError, pos_inv.submit)
|
||||
@@ -424,9 +415,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
do_not_save=1,
|
||||
)
|
||||
|
||||
pos.append(
|
||||
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 1000}
|
||||
)
|
||||
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 1000})
|
||||
|
||||
pos.insert()
|
||||
pos.submit()
|
||||
@@ -445,9 +434,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
do_not_save=1,
|
||||
)
|
||||
|
||||
pos2.append(
|
||||
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 1000}
|
||||
)
|
||||
pos2.append("payments", {"mode_of_payment": "Bank Draft", "amount": 1000})
|
||||
|
||||
pos2.insert()
|
||||
self.assertRaises(frappe.ValidationError, pos2.submit)
|
||||
@@ -496,9 +483,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
do_not_save=1,
|
||||
)
|
||||
|
||||
pos2.append(
|
||||
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 1000}
|
||||
)
|
||||
pos2.append("payments", {"mode_of_payment": "Bank Draft", "amount": 1000})
|
||||
|
||||
pos2.insert()
|
||||
self.assertRaises(frappe.ValidationError, pos2.submit)
|
||||
@@ -561,9 +546,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
)
|
||||
pos.get("items")[0].has_serial_no = 1
|
||||
pos.set("payments", [])
|
||||
pos.append(
|
||||
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000, "default": 1}
|
||||
)
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "amount": 1000, "default": 1})
|
||||
pos = pos.save().submit()
|
||||
|
||||
# make a return
|
||||
@@ -609,7 +592,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
inv = create_pos_invoice(customer="Test Loyalty Customer", rate=10000, do_not_save=1)
|
||||
inv.append(
|
||||
"payments",
|
||||
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 10000},
|
||||
{"mode_of_payment": "Cash", "amount": 10000},
|
||||
)
|
||||
inv.insert()
|
||||
inv.submit()
|
||||
@@ -641,7 +624,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
pos_inv = create_pos_invoice(customer="Test Loyalty Customer", rate=10000, do_not_save=1)
|
||||
pos_inv.append(
|
||||
"payments",
|
||||
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 10000},
|
||||
{"mode_of_payment": "Cash", "amount": 10000},
|
||||
)
|
||||
pos_inv.paid_amount = 10000
|
||||
pos_inv.submit()
|
||||
@@ -656,7 +639,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
inv.loyalty_amount = inv.loyalty_points * before_lp_details.conversion_factor
|
||||
inv.append(
|
||||
"payments",
|
||||
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 10000 - inv.loyalty_amount},
|
||||
{"mode_of_payment": "Cash", "amount": 10000 - inv.loyalty_amount},
|
||||
)
|
||||
inv.paid_amount = 10000
|
||||
inv.submit()
|
||||
@@ -677,12 +660,12 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
frappe.db.sql("delete from `tabPOS Invoice`")
|
||||
test_user, pos_profile = init_user_and_profile()
|
||||
pos_inv = create_pos_invoice(rate=300, additional_discount_percentage=10, do_not_submit=1)
|
||||
pos_inv.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 270})
|
||||
pos_inv.append("payments", {"mode_of_payment": "Cash", "amount": 270})
|
||||
pos_inv.save()
|
||||
pos_inv.submit()
|
||||
|
||||
pos_inv2 = create_pos_invoice(rate=3200, do_not_submit=1)
|
||||
pos_inv2.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3200})
|
||||
pos_inv2.append("payments", {"mode_of_payment": "Cash", "amount": 3200})
|
||||
pos_inv2.save()
|
||||
pos_inv2.submit()
|
||||
|
||||
@@ -703,7 +686,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
frappe.db.sql("delete from `tabPOS Invoice`")
|
||||
test_user, pos_profile = init_user_and_profile()
|
||||
pos_inv = create_pos_invoice(rate=300, do_not_submit=1)
|
||||
pos_inv.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 300})
|
||||
pos_inv.append("payments", {"mode_of_payment": "Cash", "amount": 300})
|
||||
pos_inv.append(
|
||||
"taxes",
|
||||
{
|
||||
@@ -720,7 +703,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
|
||||
pos_inv2 = create_pos_invoice(rate=300, qty=2, do_not_submit=1)
|
||||
pos_inv2.additional_discount_percentage = 10
|
||||
pos_inv2.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 540})
|
||||
pos_inv2.append("payments", {"mode_of_payment": "Cash", "amount": 540})
|
||||
pos_inv2.append(
|
||||
"taxes",
|
||||
{
|
||||
@@ -758,7 +741,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
frappe.db.sql("delete from `tabPOS Invoice`")
|
||||
test_user, pos_profile = init_user_and_profile()
|
||||
pos_inv = create_pos_invoice(item=item, rate=300, do_not_submit=1)
|
||||
pos_inv.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 300})
|
||||
pos_inv.append("payments", {"mode_of_payment": "Cash", "amount": 300})
|
||||
pos_inv.append(
|
||||
"taxes",
|
||||
{
|
||||
@@ -773,7 +756,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
self.assertRaises(frappe.ValidationError, pos_inv.submit)
|
||||
|
||||
pos_inv2 = create_pos_invoice(item=item, rate=400, do_not_submit=1)
|
||||
pos_inv2.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 400})
|
||||
pos_inv2.append("payments", {"mode_of_payment": "Cash", "amount": 400})
|
||||
pos_inv2.append(
|
||||
"taxes",
|
||||
{
|
||||
@@ -818,7 +801,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
pos_inv1 = create_pos_invoice(item="_BATCH ITEM Test For Reserve", rate=300, qty=15, do_not_save=1)
|
||||
pos_inv1.append(
|
||||
"payments",
|
||||
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 4500},
|
||||
{"mode_of_payment": "Cash", "amount": 4500},
|
||||
)
|
||||
pos_inv1.items[0].batch_no = batch_no
|
||||
pos_inv1.save()
|
||||
@@ -839,7 +822,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
)
|
||||
pos_inv2.append(
|
||||
"payments",
|
||||
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3000},
|
||||
{"mode_of_payment": "Cash", "amount": 3000},
|
||||
)
|
||||
pos_inv2.save()
|
||||
pos_inv2.submit()
|
||||
@@ -879,7 +862,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
)
|
||||
pos_inv1.append(
|
||||
"payments",
|
||||
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 300},
|
||||
{"mode_of_payment": "Cash", "amount": 300},
|
||||
)
|
||||
pos_inv1.save()
|
||||
pos_inv1.submit()
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"column_break_19",
|
||||
"discount_percentage",
|
||||
"discount_amount",
|
||||
"distributed_discount_amount",
|
||||
"base_rate_with_margin",
|
||||
"section_break1",
|
||||
"rate",
|
||||
@@ -847,11 +848,17 @@
|
||||
{
|
||||
"fieldname": "column_break_ciit",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "distributed_discount_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Distributed Discount Amount",
|
||||
"options": "currency"
|
||||
}
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-05-07 15:56:53.343317",
|
||||
"modified": "2024-05-07 15:56:54.343317",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice Item",
|
||||
|
||||
@@ -39,6 +39,7 @@ class POSInvoiceItem(Document):
|
||||
description: DF.TextEditor
|
||||
discount_amount: DF.Currency
|
||||
discount_percentage: DF.Percent
|
||||
distributed_discount_amount: DF.Currency
|
||||
dn_detail: DF.Data | None
|
||||
enable_deferred_revenue: DF.Check
|
||||
expense_account: DF.Link | None
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import frappe
|
||||
@@ -302,10 +303,17 @@ class POSInvoiceMergeLog(Document):
|
||||
accounting_dimensions = get_checks_for_pl_and_bs_accounts()
|
||||
accounting_dimensions_fields = [d.fieldname for d in accounting_dimensions]
|
||||
dimension_values = frappe.db.get_value(
|
||||
"POS Profile", {"name": invoice.pos_profile}, accounting_dimensions_fields, as_dict=1
|
||||
"POS Profile",
|
||||
{"name": invoice.pos_profile},
|
||||
[*accounting_dimensions_fields, "cost_center", "project"],
|
||||
as_dict=1,
|
||||
)
|
||||
for dimension in accounting_dimensions:
|
||||
dimension_value = dimension_values.get(dimension.fieldname)
|
||||
dimension_value = (
|
||||
data[0].get(dimension.fieldname)
|
||||
if data[0].get(dimension.fieldname)
|
||||
else dimension_values.get(dimension.fieldname)
|
||||
)
|
||||
|
||||
if not dimension_value and (dimension.mandatory_for_pl or dimension.mandatory_for_bs):
|
||||
frappe.throw(
|
||||
@@ -317,6 +325,14 @@ class POSInvoiceMergeLog(Document):
|
||||
|
||||
invoice.set(dimension.fieldname, dimension_value)
|
||||
|
||||
invoice.set(
|
||||
"cost_center",
|
||||
data[0].get("cost_center") if data[0].get("cost_center") else dimension_values.get("cost_center"),
|
||||
)
|
||||
invoice.set(
|
||||
"project", data[0].get("project") if data[0].get("project") else dimension_values.get("project")
|
||||
)
|
||||
|
||||
if self.merge_invoices_based_on == "Customer Group":
|
||||
invoice.flags.ignore_pos_profile = True
|
||||
invoice.pos_profile = ""
|
||||
@@ -336,7 +352,7 @@ class POSInvoiceMergeLog(Document):
|
||||
for doc in invoice_docs:
|
||||
doc.load_from_db()
|
||||
inv = sales_invoice
|
||||
if doc.is_return:
|
||||
if doc.is_return and credit_notes:
|
||||
for key, value in credit_notes.items():
|
||||
if doc.name in value:
|
||||
inv = key
|
||||
@@ -446,9 +462,34 @@ def get_invoice_customer_map(pos_invoices):
|
||||
pos_invoice_customer_map.setdefault(customer, [])
|
||||
pos_invoice_customer_map[customer].append(invoice)
|
||||
|
||||
for customer, invoices in pos_invoice_customer_map.items():
|
||||
pos_invoice_customer_map[customer] = split_invoices_by_accounting_dimension(invoices)
|
||||
|
||||
return pos_invoice_customer_map
|
||||
|
||||
|
||||
def split_invoices_by_accounting_dimension(pos_invoices):
|
||||
# pos_invoices = {
|
||||
# {'dim_field1': 'dim_field1_value1', 'dim_field2': 'dim_field2_value1'}: [],
|
||||
# {'dim_field1': 'dim_field1_value2', 'dim_field2': 'dim_field2_value1'}: []
|
||||
# }
|
||||
pos_invoice_accounting_dimensions_map = {}
|
||||
for invoice in pos_invoices:
|
||||
dimension_fields = [d.fieldname for d in get_checks_for_pl_and_bs_accounts()]
|
||||
accounting_dimensions = frappe.db.get_value(
|
||||
"POS Invoice", invoice.pos_invoice, [*dimension_fields, "cost_center", "project"], as_dict=1
|
||||
)
|
||||
|
||||
accounting_dimensions_dic_hash = hashlib.sha256(
|
||||
json.dumps(accounting_dimensions).encode()
|
||||
).hexdigest()
|
||||
|
||||
pos_invoice_accounting_dimensions_map.setdefault(accounting_dimensions_dic_hash, [])
|
||||
pos_invoice_accounting_dimensions_map[accounting_dimensions_dic_hash].append(invoice)
|
||||
|
||||
return pos_invoice_accounting_dimensions_map
|
||||
|
||||
|
||||
def consolidate_pos_invoices(pos_invoices=None, closing_entry=None):
|
||||
invoices = pos_invoices or (closing_entry and closing_entry.get("pos_transactions"))
|
||||
if frappe.flags.in_test and not invoices:
|
||||
@@ -532,20 +573,21 @@ def split_invoices(invoices):
|
||||
|
||||
def create_merge_logs(invoice_by_customer, closing_entry=None):
|
||||
try:
|
||||
for customer, invoices in invoice_by_customer.items():
|
||||
for _invoices in split_invoices(invoices):
|
||||
merge_log = frappe.new_doc("POS Invoice Merge Log")
|
||||
merge_log.posting_date = (
|
||||
getdate(closing_entry.get("posting_date")) if closing_entry else nowdate()
|
||||
)
|
||||
merge_log.posting_time = (
|
||||
get_time(closing_entry.get("posting_time")) if closing_entry else nowtime()
|
||||
)
|
||||
merge_log.customer = customer
|
||||
merge_log.pos_closing_entry = closing_entry.get("name") if closing_entry else None
|
||||
merge_log.set("pos_invoices", _invoices)
|
||||
merge_log.save(ignore_permissions=True)
|
||||
merge_log.submit()
|
||||
for customer, invoices_acc_dim in invoice_by_customer.items():
|
||||
for invoices in invoices_acc_dim.values():
|
||||
for _invoices in split_invoices(invoices):
|
||||
merge_log = frappe.new_doc("POS Invoice Merge Log")
|
||||
merge_log.posting_date = (
|
||||
getdate(closing_entry.get("posting_date")) if closing_entry else nowdate()
|
||||
)
|
||||
merge_log.posting_time = (
|
||||
get_time(closing_entry.get("posting_time")) if closing_entry else nowtime()
|
||||
)
|
||||
merge_log.customer = customer
|
||||
merge_log.pos_closing_entry = closing_entry.get("name") if closing_entry else None
|
||||
merge_log.set("pos_invoices", _invoices)
|
||||
merge_log.save(ignore_permissions=True)
|
||||
merge_log.submit()
|
||||
if closing_entry:
|
||||
closing_entry.set_status(update=True, status="Submitted")
|
||||
closing_entry.db_set("error_message", "")
|
||||
|
||||
@@ -455,3 +455,58 @@ class TestPOSInvoiceMergeLog(unittest.TestCase):
|
||||
frappe.set_user("Administrator")
|
||||
frappe.db.sql("delete from `tabPOS Profile`")
|
||||
frappe.db.sql("delete from `tabPOS Invoice`")
|
||||
|
||||
def test_separate_consolidated_invoice_for_different_accounting_dimensions(self):
|
||||
"""
|
||||
Creating 3 POS Invoices where first POS Invoice has different Cost Center than the other two.
|
||||
Consolidate the Invoices.
|
||||
Check whether the first POS Invoice is consolidated with a separate Sales Invoice than the other two.
|
||||
Check whether the second and third POS Invoice are consolidated with the same Sales Invoice.
|
||||
"""
|
||||
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
|
||||
|
||||
frappe.db.sql("delete from `tabPOS Invoice`")
|
||||
|
||||
create_cost_center(cost_center_name="_Test POS Cost Center 1", is_group=0)
|
||||
create_cost_center(cost_center_name="_Test POS Cost Center 2", is_group=0)
|
||||
|
||||
try:
|
||||
test_user, pos_profile = init_user_and_profile()
|
||||
|
||||
pos_inv = create_pos_invoice(rate=300, do_not_submit=1)
|
||||
pos_inv.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 300})
|
||||
pos_inv.cost_center = "_Test POS Cost Center 1 - _TC"
|
||||
pos_inv.save()
|
||||
pos_inv.submit()
|
||||
|
||||
pos_inv2 = create_pos_invoice(rate=3200, do_not_submit=1)
|
||||
pos_inv2.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3200})
|
||||
pos_inv.cost_center = "_Test POS Cost Center 2 - _TC"
|
||||
pos_inv2.save()
|
||||
pos_inv2.submit()
|
||||
|
||||
pos_inv3 = create_pos_invoice(rate=2300, do_not_submit=1)
|
||||
pos_inv3.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 2300})
|
||||
pos_inv.cost_center = "_Test POS Cost Center 2 - _TC"
|
||||
pos_inv3.save()
|
||||
pos_inv3.submit()
|
||||
|
||||
consolidate_pos_invoices()
|
||||
|
||||
pos_inv.load_from_db()
|
||||
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv.consolidated_invoice))
|
||||
|
||||
pos_inv2.load_from_db()
|
||||
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv2.consolidated_invoice))
|
||||
|
||||
self.assertFalse(pos_inv.consolidated_invoice == pos_inv3.consolidated_invoice)
|
||||
|
||||
pos_inv3.load_from_db()
|
||||
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv3.consolidated_invoice))
|
||||
|
||||
self.assertTrue(pos_inv2.consolidated_invoice == pos_inv3.consolidated_invoice)
|
||||
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
frappe.db.sql("delete from `tabPOS Profile`")
|
||||
frappe.db.sql("delete from `tabPOS Invoice`")
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"allow_rate_change",
|
||||
"allow_discount_change",
|
||||
"disable_grand_total_to_default_mop",
|
||||
"allow_partial_payment",
|
||||
"section_break_23",
|
||||
"item_groups",
|
||||
"column_break_25",
|
||||
@@ -56,7 +57,8 @@
|
||||
"apply_discount_on",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
"dimension_col_break"
|
||||
"dimension_col_break",
|
||||
"project"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -389,6 +391,20 @@
|
||||
"fieldname": "disable_grand_total_to_default_mop",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disable auto setting Grand Total to default Payment Mode"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"oldfieldname": "cost_center",
|
||||
"oldfieldtype": "Link",
|
||||
"options": "Project"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "allow_partial_payment",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Partial Payment"
|
||||
}
|
||||
],
|
||||
"icon": "icon-cog",
|
||||
@@ -416,7 +432,7 @@
|
||||
"link_fieldname": "pos_profile"
|
||||
}
|
||||
],
|
||||
"modified": "2025-01-29 13:12:30.796630",
|
||||
"modified": "2025-04-14 15:58:20.497426",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Profile",
|
||||
@@ -442,7 +458,8 @@
|
||||
"role": "Accounts User"
|
||||
}
|
||||
],
|
||||
"sort_field": "modified",
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -29,6 +29,7 @@ class POSProfile(Document):
|
||||
|
||||
account_for_change_amount: DF.Link | None
|
||||
allow_discount_change: DF.Check
|
||||
allow_partial_payment: DF.Check
|
||||
allow_rate_change: DF.Check
|
||||
applicable_for_users: DF.Table[POSProfileUser]
|
||||
apply_discount_on: DF.Literal["Grand Total", "Net Total"]
|
||||
@@ -54,6 +55,7 @@ class POSProfile(Document):
|
||||
payments: DF.Table[POSPaymentMethod]
|
||||
print_format: DF.Link | None
|
||||
print_receipt_on_order_complete: DF.Check
|
||||
project: DF.Link | None
|
||||
select_print_heading: DF.Link | None
|
||||
selling_price_list: DF.Link | None
|
||||
tax_category: DF.Link | None
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"posting_date",
|
||||
"company",
|
||||
"account",
|
||||
"group_by",
|
||||
"categorize_by",
|
||||
"cost_center",
|
||||
"territory",
|
||||
"ignore_exchange_rate_revaluation_journals",
|
||||
@@ -174,14 +174,6 @@
|
||||
"fieldtype": "Date",
|
||||
"label": "Start Date"
|
||||
},
|
||||
{
|
||||
"default": "Group by Voucher (Consolidated)",
|
||||
"depends_on": "eval:(doc.report == 'General Ledger');",
|
||||
"fieldname": "group_by",
|
||||
"fieldtype": "Select",
|
||||
"label": "Group By",
|
||||
"options": "\nGroup by Voucher\nGroup by Voucher (Consolidated)"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: (doc.report == 'General Ledger');",
|
||||
"fieldname": "currency",
|
||||
@@ -397,10 +389,18 @@
|
||||
"fieldname": "show_remarks",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Remarks"
|
||||
},
|
||||
{
|
||||
"default": "Categorize by Voucher (Consolidated)",
|
||||
"depends_on": "eval:(doc.report == 'General Ledger');",
|
||||
"fieldname": "categorize_by",
|
||||
"fieldtype": "Select",
|
||||
"label": "Categorize By",
|
||||
"options": "\nCategorize by Voucher\nCategorize by Voucher (Consolidated)"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2024-12-11 12:11:13.543134",
|
||||
"modified": "2025-04-30 14:43:23.643006",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Statement Of Accounts",
|
||||
@@ -436,4 +436,4 @@
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ class ProcessStatementOfAccounts(Document):
|
||||
ageing_based_on: DF.Literal["Due Date", "Posting Date"]
|
||||
based_on_payment_terms: DF.Check
|
||||
body: DF.TextEditor | None
|
||||
categorize_by: DF.Literal["", "Categorize by Voucher", "Categorize by Voucher (Consolidated)"]
|
||||
cc_to: DF.TableMultiSelect[ProcessStatementOfAccountsCC]
|
||||
collection_name: DF.DynamicLink | None
|
||||
company: DF.Link
|
||||
@@ -56,7 +57,6 @@ class ProcessStatementOfAccounts(Document):
|
||||
finance_book: DF.Link | None
|
||||
frequency: DF.Literal["Weekly", "Monthly", "Quarterly"]
|
||||
from_date: DF.Date | None
|
||||
group_by: DF.Literal["", "Group by Voucher", "Group by Voucher (Consolidated)"]
|
||||
ignore_cr_dr_notes: DF.Check
|
||||
ignore_exchange_rate_revaluation_journals: DF.Check
|
||||
include_ageing: DF.Check
|
||||
@@ -204,7 +204,7 @@ def get_gl_filters(doc, entry, tax_id, presentation_currency):
|
||||
"party": [entry.customer],
|
||||
"party_name": [entry.customer_name] if entry.customer_name else None,
|
||||
"presentation_currency": presentation_currency,
|
||||
"group_by": doc.group_by,
|
||||
"categorize_by": doc.categorize_by,
|
||||
"currency": doc.currency,
|
||||
"project": [p.project_name for p in doc.project],
|
||||
"show_opening_entries": 0,
|
||||
|
||||
@@ -143,8 +143,10 @@
|
||||
"contact_mobile",
|
||||
"contact_email",
|
||||
"company_shipping_address_section",
|
||||
"shipping_address",
|
||||
"dispatch_address",
|
||||
"dispatch_address_display",
|
||||
"column_break_126",
|
||||
"shipping_address",
|
||||
"shipping_address_display",
|
||||
"company_billing_address_section",
|
||||
"billing_address",
|
||||
@@ -1546,7 +1548,7 @@
|
||||
{
|
||||
"fieldname": "company_shipping_address_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Company Shipping Address"
|
||||
"label": "Shipping Address"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_126",
|
||||
@@ -1627,13 +1629,28 @@
|
||||
"fieldname": "update_outstanding_for_self",
|
||||
"fieldtype": "Check",
|
||||
"label": "Update Outstanding for Self"
|
||||
},
|
||||
{
|
||||
"fieldname": "dispatch_address_display",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Dispatch Address",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "dispatch_address",
|
||||
"fieldtype": "Link",
|
||||
"label": "Select Dispatch Address ",
|
||||
"options": "Address",
|
||||
"print_hide": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "fa fa-file-text",
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-14 11:39:04.564610",
|
||||
"modified": "2025-04-09 16:49:22.175081",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
@@ -1688,6 +1705,7 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "posting_date, supplier, bill_no, base_grand_total, outstanding_amount",
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "modified",
|
||||
|
||||
@@ -117,6 +117,8 @@ class PurchaseInvoice(BuyingController):
|
||||
currency: DF.Link | None
|
||||
disable_rounded_total: DF.Check
|
||||
discount_amount: DF.Currency
|
||||
dispatch_address: DF.Link | None
|
||||
dispatch_address_display: DF.TextEditor | None
|
||||
due_date: DF.Date | None
|
||||
from_date: DF.Date | None
|
||||
grand_total: DF.Currency
|
||||
|
||||
@@ -1695,6 +1695,9 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
# Configure Buying Settings to allow rate change
|
||||
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 0)
|
||||
|
||||
# Configure Accounts Settings to allow 300% over billing
|
||||
frappe.db.set_single_value("Accounts Settings", "over_billing_allowance", 300)
|
||||
|
||||
# Create PR: rate = 1000, qty = 5
|
||||
pr = make_purchase_receipt(
|
||||
item_code="_Test Non Stock Item", rate=1000, posting_date=add_days(nowdate(), -2)
|
||||
@@ -2688,13 +2691,13 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
To test if after applying discount on grand total,
|
||||
the grand total is calculated correctly without any rounding errors
|
||||
"""
|
||||
invoice = make_purchase_invoice(qty=2, rate=100, do_not_save=True, do_not_submit=True)
|
||||
invoice = make_purchase_invoice(qty=3, rate=100, do_not_save=True, do_not_submit=True)
|
||||
invoice.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"qty": 1,
|
||||
"rate": 21.39,
|
||||
"qty": 3,
|
||||
"rate": 50.3,
|
||||
},
|
||||
)
|
||||
invoice.append(
|
||||
@@ -2703,18 +2706,19 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"description": "VAT",
|
||||
"rate": 15.5,
|
||||
"rate": 15,
|
||||
},
|
||||
)
|
||||
|
||||
# the grand total here will be 255.71
|
||||
# the grand total here will be 518.54
|
||||
invoice.disable_rounded_total = 1
|
||||
# apply discount on grand total to adjust the grand total to 255
|
||||
invoice.discount_amount = 0.71
|
||||
# apply discount on grand total to adjust the grand total to 518
|
||||
invoice.discount_amount = 0.54
|
||||
|
||||
invoice.save()
|
||||
|
||||
# check if grand total is 496 and not something like 254.99 due to rounding errors
|
||||
self.assertEqual(invoice.grand_total, 255)
|
||||
# check if grand total is 518 and not something like 517.99 due to rounding errors
|
||||
self.assertEqual(invoice.grand_total, 518)
|
||||
|
||||
def test_apply_discount_on_grand_total_with_previous_row_total_tax(self):
|
||||
"""
|
||||
@@ -2755,6 +2759,43 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
|
||||
self.assertEqual(invoice.grand_total, 300)
|
||||
|
||||
def test_pr_pi_over_billing(self):
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
make_purchase_invoice as make_purchase_invoice_from_pr,
|
||||
)
|
||||
|
||||
# Configure Buying Settings to allow rate change
|
||||
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 0)
|
||||
|
||||
pr = make_purchase_receipt(qty=10, rate=10)
|
||||
pi = make_purchase_invoice_from_pr(pr.name)
|
||||
|
||||
pi.items[0].rate = 12
|
||||
|
||||
# Test 1 - This will fail because over billing is not allowed
|
||||
self.assertRaises(frappe.ValidationError, pi.submit)
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 1)
|
||||
# Test 2 - This will now submit because over billing allowance is ignored when set_landed_cost_based_on_purchase_invoice_rate is checked
|
||||
pi.submit()
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
|
||||
frappe.db.set_single_value("Accounts Settings", "over_billing_allowance", 20)
|
||||
pi.cancel()
|
||||
pi = make_purchase_invoice_from_pr(pr.name)
|
||||
pi.items[0].rate = 12
|
||||
|
||||
# Test 3 - This will now submit because over billing is allowed upto 20%
|
||||
pi.submit()
|
||||
|
||||
pi.reload()
|
||||
pi.cancel()
|
||||
pi = make_purchase_invoice_from_pr(pr.name)
|
||||
pi.items[0].rate = 13
|
||||
|
||||
# Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail
|
||||
self.assertRaises(frappe.ValidationError, pi.submit)
|
||||
|
||||
|
||||
def set_advance_flag(company, flag, default_account):
|
||||
frappe.db.set_value(
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"column_break_30",
|
||||
"discount_percentage",
|
||||
"discount_amount",
|
||||
"distributed_discount_amount",
|
||||
"base_rate_with_margin",
|
||||
"sec_break2",
|
||||
"rate",
|
||||
@@ -840,7 +841,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval: doc.margin_type || doc.discount_amount",
|
||||
"collapsible_depends_on": "eval: doc.margin_type || doc.discount_amount || doc.distributed_discount_amount",
|
||||
"fieldname": "section_break_26",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Discount and Margin"
|
||||
@@ -971,12 +972,18 @@
|
||||
"no_copy": 1,
|
||||
"options": "Company:company:default_currency",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "distributed_discount_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Distributed Discount Amount",
|
||||
"options": "currency"
|
||||
}
|
||||
],
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-03-12 16:33:12.453290",
|
||||
"modified": "2025-03-12 16:33:13.453290",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice Item",
|
||||
|
||||
@@ -34,6 +34,7 @@ class PurchaseInvoiceItem(Document):
|
||||
description: DF.TextEditor | None
|
||||
discount_amount: DF.Currency
|
||||
discount_percentage: DF.Percent
|
||||
distributed_discount_amount: DF.Currency
|
||||
enable_deferred_expense: DF.Check
|
||||
expense_account: DF.Link | None
|
||||
from_warehouse: DF.Link | None
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
"fieldname": "item_wise_tax_detail",
|
||||
"fieldtype": "Code",
|
||||
"hidden": 1,
|
||||
"label": "Item Wise Tax Detail ",
|
||||
"label": "Item Wise Tax Detail",
|
||||
"oldfieldname": "item_wise_tax_detail",
|
||||
"oldfieldtype": "Small Text",
|
||||
"print_hide": 1,
|
||||
@@ -235,10 +235,11 @@
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-04-08 19:51:36.678551",
|
||||
"modified": "2025-04-15 13:14:48.936047",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Taxes and Charges",
|
||||
|
||||
@@ -782,24 +782,6 @@ frappe.ui.form.on("Sales Invoice", {
|
||||
};
|
||||
};
|
||||
},
|
||||
// When multiple companies are set up. in case company name is changed set default company address
|
||||
company: function (frm) {
|
||||
if (frm.doc.company) {
|
||||
frappe.call({
|
||||
method: "erpnext.setup.doctype.company.company.get_default_company_address",
|
||||
args: { name: frm.doc.company, existing_address: frm.doc.company_address || "" },
|
||||
debounce: 2000,
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frm.set_value("company_address", r.message);
|
||||
} else {
|
||||
frm.set_value("company_address", "");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onload: function (frm) {
|
||||
frm.redemption_conversion_factor = null;
|
||||
},
|
||||
@@ -1102,6 +1084,18 @@ frappe.ui.form.on("Sales Invoice Timesheet", {
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Sales Invoice Payment", {
|
||||
mode_of_payment: function (frm) {
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: "set_account_for_mode_of_payment",
|
||||
callback: function (r) {
|
||||
refresh_field("payments");
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
var set_timesheet_detail_rate = function (cdt, cdn, currency, timelog) {
|
||||
frappe.call({
|
||||
method: "erpnext.projects.doctype.timesheet.timesheet.get_timesheet_detail_rate",
|
||||
|
||||
@@ -751,10 +751,10 @@ class SalesInvoice(SellingController):
|
||||
self.paid_amount = paid_amount
|
||||
self.base_paid_amount = base_paid_amount
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_account_for_mode_of_payment(self):
|
||||
for payment in self.payments:
|
||||
if not payment.account:
|
||||
payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account")
|
||||
payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account")
|
||||
|
||||
def validate_time_sheets_are_submitted(self):
|
||||
for data in self.timesheets:
|
||||
@@ -1348,7 +1348,7 @@ class SalesInvoice(SellingController):
|
||||
)
|
||||
|
||||
for item in self.get("items"):
|
||||
if flt(item.base_net_amount, item.precision("base_net_amount")):
|
||||
if flt(item.base_net_amount, item.precision("base_net_amount")) or item.is_fixed_asset:
|
||||
# Do not book income for transfer within same company
|
||||
if self.is_internal_transfer():
|
||||
continue
|
||||
@@ -2298,7 +2298,10 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
# Invert Addresses
|
||||
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
|
||||
update_address(
|
||||
target_doc, "shipping_address", "shipping_address_display", source_doc.customer_address
|
||||
target_doc, "dispatch_address", "dispatch_address_display", source_doc.dispatch_address_name
|
||||
)
|
||||
update_address(
|
||||
target_doc, "shipping_address", "shipping_address_display", source_doc.shipping_address_name
|
||||
)
|
||||
update_address(
|
||||
target_doc, "billing_address", "billing_address_display", source_doc.customer_address
|
||||
@@ -2720,9 +2723,11 @@ def create_dunning(source_name, target_doc=None, ignore_permissions=False):
|
||||
target.closing_text = letter_text.get("closing_text")
|
||||
target.language = letter_text.get("language")
|
||||
|
||||
# update outstanding
|
||||
# update outstanding from doc
|
||||
if source.payment_schedule and len(source.payment_schedule) == 1:
|
||||
target.overdue_payments[0].outstanding = source.get("outstanding_amount")
|
||||
for row in target.overdue_payments:
|
||||
if row.payment_schedule == source.payment_schedule[0].name:
|
||||
row.outstanding = source.get("outstanding_amount")
|
||||
|
||||
target.validate()
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ from frappe.utils import add_days, flt, format_date, getdate, nowdate, today
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account
|
||||
from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import (
|
||||
set_default_account_for_mode_of_payment,
|
||||
)
|
||||
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import WarehouseMissingError
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
|
||||
@@ -54,6 +57,11 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
create_items(["_Test Internal Transfer Item"], uoms=[{"uom": "Box", "conversion_factor": 10}])
|
||||
create_internal_parties()
|
||||
setup_accounts()
|
||||
mode_of_payment = frappe.get_doc("Mode of Payment", "Bank Draft")
|
||||
set_default_account_for_mode_of_payment(mode_of_payment, "_Test Company", "_Test Bank - _TC")
|
||||
set_default_account_for_mode_of_payment(
|
||||
mode_of_payment, "_Test Company with perpetual inventory", "_Test Bank - TCP1"
|
||||
)
|
||||
frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None)
|
||||
|
||||
def tearDown(self):
|
||||
@@ -964,10 +972,8 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
pos.is_pos = 1
|
||||
pos.update_stock = 1
|
||||
|
||||
pos.append(
|
||||
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - TCP1", "amount": 50}
|
||||
)
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - TCP1", "amount": 50})
|
||||
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 50})
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "amount": 50})
|
||||
|
||||
taxes = get_taxes_and_charges()
|
||||
pos.taxes = []
|
||||
@@ -996,10 +1002,8 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
pos.is_pos = 1
|
||||
pos.pos_profile = pos_profile.name
|
||||
|
||||
pos.append(
|
||||
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 500}
|
||||
)
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 500})
|
||||
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 500})
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "amount": 500})
|
||||
pos.insert()
|
||||
pos.submit()
|
||||
|
||||
@@ -1042,10 +1046,8 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
pos.is_pos = 1
|
||||
pos.update_stock = 1
|
||||
|
||||
pos.append(
|
||||
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - TCP1", "amount": 50}
|
||||
)
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - TCP1", "amount": 60})
|
||||
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 50})
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "amount": 60})
|
||||
|
||||
pos.write_off_outstanding_amount_automatically = 1
|
||||
pos.insert()
|
||||
@@ -1085,10 +1087,8 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
pos.is_pos = 1
|
||||
pos.update_stock = 1
|
||||
|
||||
pos.append(
|
||||
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - TCP1", "amount": 50}
|
||||
)
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - TCP1", "amount": 40})
|
||||
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 50})
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "amount": 40})
|
||||
|
||||
pos.write_off_outstanding_amount_automatically = 1
|
||||
pos.insert()
|
||||
@@ -1102,7 +1102,7 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
|
||||
pos = create_sales_invoice(do_not_save=True)
|
||||
pos.is_pos = 1
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 100})
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "amount": 100})
|
||||
pos.save().submit()
|
||||
self.assertEqual(pos.outstanding_amount, 0.0)
|
||||
self.assertEqual(pos.status, "Paid")
|
||||
@@ -1173,10 +1173,8 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
for tax in taxes:
|
||||
pos.append("taxes", tax)
|
||||
|
||||
pos.append(
|
||||
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - TCP1", "amount": 50}
|
||||
)
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - TCP1", "amount": 60})
|
||||
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 50})
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "amount": 60})
|
||||
|
||||
pos.insert()
|
||||
pos.submit()
|
||||
@@ -3904,10 +3902,8 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
pos = create_sales_invoice(qty=10, do_not_save=True)
|
||||
pos.is_pos = 1
|
||||
pos.pos_profile = pos_profile.name
|
||||
pos.append(
|
||||
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 500}
|
||||
)
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 500})
|
||||
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 500})
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "amount": 500})
|
||||
pos.save().submit()
|
||||
|
||||
pos_return = make_sales_return(pos.name)
|
||||
@@ -4278,7 +4274,7 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
pos.is_pos = 1
|
||||
pos.pos_profile = pos_profile.name
|
||||
pos.debit_to = "_Test Receivable USD - _TC"
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "account": "_Test Bank - _TC", "amount": 20.35})
|
||||
pos.append("payments", {"mode_of_payment": "Cash", "amount": 20.35})
|
||||
pos.save().submit()
|
||||
|
||||
pos_return = make_sales_return(pos.name)
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"column_break_19",
|
||||
"discount_percentage",
|
||||
"discount_amount",
|
||||
"distributed_discount_amount",
|
||||
"base_rate_with_margin",
|
||||
"section_break1",
|
||||
"rate",
|
||||
@@ -259,7 +260,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval: doc.margin_type || doc.discount_amount",
|
||||
"collapsible_depends_on": "eval: doc.margin_type || doc.discount_amount || doc.distributed_discount_amount",
|
||||
"fieldname": "discount_and_margin",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Discount and Margin"
|
||||
@@ -932,6 +933,12 @@
|
||||
"fieldname": "column_break_ytgd",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "distributed_discount_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Distributed Discount Amount",
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "available_quantity_section",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -976,7 +983,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-03-12 16:33:52.503777",
|
||||
"modified": "2025-03-12 16:33:55.503777",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Item",
|
||||
|
||||
@@ -40,6 +40,7 @@ class SalesInvoiceItem(Document):
|
||||
discount_account: DF.Link | None
|
||||
discount_amount: DF.Currency
|
||||
discount_percentage: DF.Percent
|
||||
distributed_discount_amount: DF.Currency
|
||||
dn_detail: DF.Data | None
|
||||
enable_deferred_revenue: DF.Check
|
||||
expense_account: DF.Link | None
|
||||
|
||||
@@ -71,6 +71,7 @@ def get_party_details(
|
||||
party_address=None,
|
||||
company_address=None,
|
||||
shipping_address=None,
|
||||
dispatch_address=None,
|
||||
pos_profile=None,
|
||||
):
|
||||
if not party:
|
||||
@@ -92,6 +93,7 @@ def get_party_details(
|
||||
party_address,
|
||||
company_address,
|
||||
shipping_address,
|
||||
dispatch_address,
|
||||
pos_profile,
|
||||
)
|
||||
|
||||
@@ -111,6 +113,7 @@ def _get_party_details(
|
||||
party_address=None,
|
||||
company_address=None,
|
||||
shipping_address=None,
|
||||
dispatch_address=None,
|
||||
pos_profile=None,
|
||||
):
|
||||
party_details = frappe._dict(
|
||||
@@ -134,6 +137,7 @@ def _get_party_details(
|
||||
party_address,
|
||||
company_address,
|
||||
shipping_address,
|
||||
dispatch_address,
|
||||
ignore_permissions=ignore_permissions,
|
||||
)
|
||||
set_contact_details(party_details, party, party_type)
|
||||
@@ -191,34 +195,51 @@ def set_address_details(
|
||||
party_address=None,
|
||||
company_address=None,
|
||||
shipping_address=None,
|
||||
dispatch_address=None,
|
||||
*,
|
||||
ignore_permissions=False,
|
||||
):
|
||||
billing_address_field = (
|
||||
# party_billing
|
||||
party_billing_field = (
|
||||
"customer_address" if party_type in ["Lead", "Prospect"] else party_type.lower() + "_address"
|
||||
)
|
||||
party_details[billing_address_field] = party_address or get_default_address(party_type, party.name)
|
||||
|
||||
party_details[party_billing_field] = party_address or get_default_address(party_type, party.name)
|
||||
if doctype:
|
||||
party_details.update(
|
||||
get_fetch_values(doctype, billing_address_field, party_details[billing_address_field])
|
||||
get_fetch_values(doctype, party_billing_field, party_details[party_billing_field])
|
||||
)
|
||||
# address display
|
||||
party_details.address_display = render_address(
|
||||
party_details[billing_address_field], check_permissions=not ignore_permissions
|
||||
)
|
||||
# shipping address
|
||||
if party_type in ["Customer", "Lead"]:
|
||||
party_details.shipping_address_name = shipping_address or get_party_shipping_address(
|
||||
party_type, party.name
|
||||
)
|
||||
party_details.shipping_address = render_address(
|
||||
party_details["shipping_address_name"], check_permissions=not ignore_permissions
|
||||
)
|
||||
if doctype:
|
||||
party_details.update(
|
||||
get_fetch_values(doctype, "shipping_address_name", party_details.shipping_address_name)
|
||||
)
|
||||
|
||||
party_details.address_display = render_address(
|
||||
party_details[party_billing_field], check_permissions=not ignore_permissions
|
||||
)
|
||||
|
||||
# party_shipping
|
||||
if party_type in ["Customer", "Lead"]:
|
||||
party_shipping_field = "shipping_address_name"
|
||||
party_shipping_display = "shipping_address"
|
||||
default_shipping = shipping_address
|
||||
|
||||
else:
|
||||
# Supplier
|
||||
party_shipping_field = "dispatch_address"
|
||||
party_shipping_display = "dispatch_address_display"
|
||||
default_shipping = dispatch_address
|
||||
|
||||
party_details[party_shipping_field] = default_shipping or get_party_shipping_address(
|
||||
party_type, party.name
|
||||
)
|
||||
|
||||
party_details[party_shipping_display] = render_address(
|
||||
party_details[party_shipping_field], check_permissions=not ignore_permissions
|
||||
)
|
||||
|
||||
if doctype:
|
||||
party_details.update(
|
||||
get_fetch_values(doctype, party_shipping_field, party_details[party_shipping_field])
|
||||
)
|
||||
|
||||
# company_address
|
||||
if company_address:
|
||||
party_details.company_address = company_address
|
||||
else:
|
||||
@@ -256,22 +277,20 @@ def set_address_details(
|
||||
**get_fetch_values(doctype, "shipping_address", party_details.billing_address),
|
||||
)
|
||||
|
||||
party_address, shipping_address = (
|
||||
party_details.get(billing_address_field),
|
||||
party_details.shipping_address_name,
|
||||
party_billing, party_shipping = (
|
||||
party_details.get(party_billing_field),
|
||||
party_details.get(party_shipping_field),
|
||||
)
|
||||
|
||||
party_details["tax_category"] = get_address_tax_category(
|
||||
party.get("tax_category"),
|
||||
party_address,
|
||||
shipping_address if party_type != "Supplier" else party_address,
|
||||
party.get("tax_category"), party_billing, party_shipping
|
||||
)
|
||||
|
||||
if doctype in TRANSACTION_TYPES:
|
||||
with temporary_flag("company", company):
|
||||
get_regional_address_details(party_details, doctype, company)
|
||||
|
||||
return party_address, shipping_address
|
||||
return party_billing, party_shipping
|
||||
|
||||
|
||||
@erpnext.allow_regional
|
||||
@@ -280,32 +299,50 @@ def get_regional_address_details(party_details, doctype, company):
|
||||
|
||||
|
||||
def complete_contact_details(party_details):
|
||||
if not party_details.contact_person:
|
||||
party_details.update(
|
||||
{
|
||||
"contact_person": None,
|
||||
"contact_display": None,
|
||||
"contact_email": None,
|
||||
"contact_mobile": None,
|
||||
"contact_phone": None,
|
||||
"contact_designation": None,
|
||||
"contact_department": None,
|
||||
}
|
||||
contact_details = frappe._dict()
|
||||
|
||||
if party_details.party_type == "Employee":
|
||||
contact_details = frappe.db.get_value(
|
||||
"Employee",
|
||||
party_details.party,
|
||||
[
|
||||
"employee_name as contact_display",
|
||||
"prefered_email as contact_email",
|
||||
"cell_number as contact_mobile",
|
||||
"designation as contact_designation",
|
||||
"department as contact_department",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
contact_details.update({"contact_person": None, "contact_phone": None})
|
||||
elif party_details.contact_person:
|
||||
contact_details = frappe.db.get_value(
|
||||
"Contact",
|
||||
party_details.contact_person,
|
||||
[
|
||||
"name as contact_person",
|
||||
"full_name as contact_display",
|
||||
"email_id as contact_email",
|
||||
"mobile_no as contact_mobile",
|
||||
"phone as contact_phone",
|
||||
"designation as contact_designation",
|
||||
"department as contact_department",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
else:
|
||||
fields = [
|
||||
"name as contact_person",
|
||||
"full_name as contact_display",
|
||||
"email_id as contact_email",
|
||||
"mobile_no as contact_mobile",
|
||||
"phone as contact_phone",
|
||||
"designation as contact_designation",
|
||||
"department as contact_department",
|
||||
]
|
||||
contact_details = {
|
||||
"contact_person": None,
|
||||
"contact_display": None,
|
||||
"contact_email": None,
|
||||
"contact_mobile": None,
|
||||
"contact_phone": None,
|
||||
"contact_designation": None,
|
||||
"contact_department": None,
|
||||
}
|
||||
|
||||
contact_details = frappe.db.get_value("Contact", party_details.contact_person, fields, as_dict=True)
|
||||
|
||||
party_details.update(contact_details)
|
||||
party_details.update(contact_details)
|
||||
|
||||
|
||||
def set_contact_details(party_details, party, party_type):
|
||||
@@ -620,34 +657,34 @@ def get_due_date_from_template(template_name, posting_date, bill_date):
|
||||
return due_date
|
||||
|
||||
|
||||
def validate_due_date(posting_date, due_date, bill_date=None, template_name=None):
|
||||
def validate_due_date(posting_date, due_date, bill_date=None, template_name=None, doctype=None):
|
||||
if getdate(due_date) < getdate(posting_date):
|
||||
frappe.throw(_("Due Date cannot be before Posting / Supplier Invoice Date"))
|
||||
else:
|
||||
if not template_name:
|
||||
return
|
||||
validate_due_date_with_template(posting_date, due_date, bill_date, template_name, doctype)
|
||||
|
||||
default_due_date = get_due_date_from_template(template_name, posting_date, bill_date).strftime(
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
|
||||
if not default_due_date:
|
||||
return
|
||||
def validate_due_date_with_template(posting_date, due_date, bill_date, template_name, doctype=None):
|
||||
if not template_name:
|
||||
return
|
||||
|
||||
if default_due_date != posting_date and getdate(due_date) > getdate(default_due_date):
|
||||
is_credit_controller = (
|
||||
frappe.db.get_single_value("Accounts Settings", "credit_controller") in frappe.get_roles()
|
||||
default_due_date = format(get_due_date_from_template(template_name, posting_date, bill_date))
|
||||
|
||||
if not default_due_date:
|
||||
return
|
||||
|
||||
if default_due_date != posting_date and getdate(due_date) > getdate(default_due_date):
|
||||
if frappe.db.get_single_value("Accounts Settings", "credit_controller") in frappe.get_roles():
|
||||
party_type = "supplier" if doctype == "Purchase Invoice" else "customer"
|
||||
|
||||
msgprint(
|
||||
_("Note: Due Date exceeds allowed {0} credit days by {1} day(s)").format(
|
||||
party_type, date_diff(due_date, default_due_date)
|
||||
)
|
||||
)
|
||||
if is_credit_controller:
|
||||
msgprint(
|
||||
_("Note: Due / Reference Date exceeds allowed customer credit days by {0} day(s)").format(
|
||||
date_diff(due_date, default_due_date)
|
||||
)
|
||||
)
|
||||
else:
|
||||
frappe.throw(
|
||||
_("Due / Reference Date cannot be after {0}").format(formatdate(default_due_date))
|
||||
)
|
||||
|
||||
else:
|
||||
frappe.throw(_("Due / Reference Date cannot be after {0}").format(formatdate(default_due_date)))
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -771,9 +808,9 @@ def validate_account_party_type(self):
|
||||
account_type = frappe.get_cached_value("Account", self.account, "account_type")
|
||||
if account_type and (account_type not in ["Receivable", "Payable", "Equity"]):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Party Type and Party can only be set for Receivable / Payable account<br><br>" "{0}"
|
||||
).format(self.account)
|
||||
_("Party Type and Party can only be set for Receivable / Payable account<br><br>{0}").format(
|
||||
self.account
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -894,12 +931,16 @@ def get_party_shipping_address(doctype: str, name: str) -> str | None:
|
||||
["is_shipping_address", "=", 1],
|
||||
["address_type", "=", "Shipping"],
|
||||
],
|
||||
pluck="name",
|
||||
limit=1,
|
||||
fields=["name", "is_shipping_address"],
|
||||
order_by="is_shipping_address DESC",
|
||||
)
|
||||
|
||||
return shipping_addresses[0] if shipping_addresses else None
|
||||
if shipping_addresses and shipping_addresses[0].is_shipping_address == 1:
|
||||
return shipping_addresses[0].name
|
||||
if len(shipping_addresses) == 1:
|
||||
return shipping_addresses[0].name
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def get_partywise_advanced_payment_amount(
|
||||
|
||||
@@ -282,4 +282,4 @@
|
||||
{% } %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-right text-muted">{{ __("Printed On ") }}{%= frappe.datetime.str_to_user(frappe.datetime.get_datetime_as_string()) %}</p>
|
||||
<p class="text-right text-muted">{%= __("Printed on {0}", [frappe.datetime.str_to_user(frappe.datetime.get_datetime_as_string())]) %}</p>
|
||||
|
||||
@@ -54,6 +54,10 @@ class ReceivablePayableReport:
|
||||
self.filters.range = "30, 60, 90, 120"
|
||||
self.ranges = [num.strip() for num in self.filters.range.split(",") if num.strip().isdigit()]
|
||||
self.range_numbers = [num for num in range(1, len(self.ranges) + 2)]
|
||||
self.ple_fetch_method = (
|
||||
frappe.db.get_single_value("Accounts Settings", "receivable_payable_fetch_method")
|
||||
or "Buffered Cursor"
|
||||
) # Fail Safe
|
||||
|
||||
def run(self, args):
|
||||
self.filters.update(args)
|
||||
@@ -90,10 +94,7 @@ class ReceivablePayableReport:
|
||||
self.skip_total_row = 1
|
||||
|
||||
def get_data(self):
|
||||
self.get_ple_entries()
|
||||
self.get_sales_invoices_or_customers_based_on_sales_person()
|
||||
self.voucher_balance = OrderedDict()
|
||||
self.init_voucher_balance() # invoiced, paid, credit_note, outstanding
|
||||
|
||||
# Build delivery note map against all sales invoices
|
||||
self.build_delivery_note_map()
|
||||
@@ -110,12 +111,40 @@ class ReceivablePayableReport:
|
||||
# Get Exchange Rate Revaluations
|
||||
self.get_exchange_rate_revaluations()
|
||||
|
||||
self.prepare_ple_query()
|
||||
self.data = []
|
||||
self.voucher_balance = OrderedDict()
|
||||
|
||||
if self.ple_fetch_method == "Buffered Cursor":
|
||||
self.fetch_ple_in_buffered_cursor()
|
||||
elif self.ple_fetch_method == "UnBuffered Cursor":
|
||||
self.fetch_ple_in_unbuffered_cursor()
|
||||
|
||||
self.build_data()
|
||||
|
||||
def fetch_ple_in_buffered_cursor(self):
|
||||
self.ple_entries = frappe.db.sql(self.ple_query.get_sql(), as_dict=True)
|
||||
|
||||
for ple in self.ple_entries:
|
||||
self.init_voucher_balance(ple) # invoiced, paid, credit_note, outstanding
|
||||
|
||||
# This is unavoidable. Initialization and allocation cannot happen in same loop
|
||||
for ple in self.ple_entries:
|
||||
self.update_voucher_balance(ple)
|
||||
|
||||
self.build_data()
|
||||
delattr(self, "ple_entries")
|
||||
|
||||
def fetch_ple_in_unbuffered_cursor(self):
|
||||
self.ple_entries = []
|
||||
with frappe.db.unbuffered_cursor():
|
||||
for ple in frappe.db.sql(self.ple_query.get_sql(), as_dict=True, as_iterator=True):
|
||||
self.init_voucher_balance(ple) # invoiced, paid, credit_note, outstanding
|
||||
self.ple_entries.append(ple)
|
||||
|
||||
# This is unavoidable. Initialization and allocation cannot happen in same loop
|
||||
for ple in self.ple_entries:
|
||||
self.update_voucher_balance(ple)
|
||||
delattr(self, "ple_entries")
|
||||
|
||||
def build_voucher_dict(self, ple):
|
||||
return frappe._dict(
|
||||
@@ -136,26 +165,22 @@ class ReceivablePayableReport:
|
||||
outstanding_in_account_currency=0.0,
|
||||
)
|
||||
|
||||
def init_voucher_balance(self):
|
||||
# build all keys, since we want to exclude vouchers beyond the report date
|
||||
for ple in self.ple_entries:
|
||||
# get the balance object for voucher_type
|
||||
def init_voucher_balance(self, ple):
|
||||
if self.filters.get("ignore_accounts"):
|
||||
key = (ple.voucher_type, ple.voucher_no, ple.party)
|
||||
else:
|
||||
key = (ple.account, ple.voucher_type, ple.voucher_no, ple.party)
|
||||
|
||||
if self.filters.get("ignore_accounts"):
|
||||
key = (ple.voucher_type, ple.voucher_no, ple.party)
|
||||
else:
|
||||
key = (ple.account, ple.voucher_type, ple.voucher_no, ple.party)
|
||||
if key not in self.voucher_balance:
|
||||
self.voucher_balance[key] = self.build_voucher_dict(ple)
|
||||
|
||||
if key not in self.voucher_balance:
|
||||
self.voucher_balance[key] = self.build_voucher_dict(ple)
|
||||
if ple.voucher_type == ple.against_voucher_type and ple.voucher_no == ple.against_voucher_no:
|
||||
self.voucher_balance[key].cost_center = ple.cost_center
|
||||
|
||||
if ple.voucher_type == ple.against_voucher_type and ple.voucher_no == ple.against_voucher_no:
|
||||
self.voucher_balance[key].cost_center = ple.cost_center
|
||||
self.get_invoices(ple)
|
||||
|
||||
self.get_invoices(ple)
|
||||
|
||||
if self.filters.get("group_by_party"):
|
||||
self.init_subtotal_row(ple.party)
|
||||
if self.filters.get("group_by_party"):
|
||||
self.init_subtotal_row(ple.party)
|
||||
|
||||
if self.filters.get("group_by_party") and not self.filters.get("in_party_currency"):
|
||||
self.init_subtotal_row("Total")
|
||||
@@ -778,7 +803,7 @@ class ReceivablePayableReport:
|
||||
)
|
||||
row["range" + str(index + 1)] = row.outstanding
|
||||
|
||||
def get_ple_entries(self):
|
||||
def prepare_ple_query(self):
|
||||
# get all the GL entries filtered by the given filters
|
||||
|
||||
self.prepare_conditions()
|
||||
@@ -831,7 +856,7 @@ class ReceivablePayableReport:
|
||||
else:
|
||||
query = query.orderby(self.ple.posting_date, self.ple.party)
|
||||
|
||||
self.ple_entries = query.run(as_dict=True)
|
||||
self.ple_query = query
|
||||
|
||||
def get_sales_invoices_or_customers_based_on_sales_person(self):
|
||||
if self.filters.get("sales_person"):
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
<div style="margin-bottom: 7px;">
|
||||
{%= frappe.boot.letter_heads[frappe.defaults.get_default("letter_head")] %}
|
||||
</div>
|
||||
<h2 class="text-center">{%= __("Bank Reconciliation Statement") %}</h2>
|
||||
<h4 class="text-center">{%= filters.account && (filters.account + ", "+filters.report_date) || "" %} {%= filters.company %}</h4>
|
||||
<hr>
|
||||
@@ -46,4 +43,4 @@
|
||||
{% } %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-right text-muted">Printed On {%= frappe.datetime.str_to_user(frappe.datetime.get_datetime_as_string()) %}</p>
|
||||
<p class="text-right text-muted">{%= __("Printed on {0}", [frappe.datetime.str_to_user(frappe.datetime.get_datetime_as_string())]) %}</p>
|
||||
|
||||
@@ -9,6 +9,7 @@ frappe.query_reports["Customer Ledger Summary"] = {
|
||||
fieldtype: "Link",
|
||||
options: "Company",
|
||||
default: frappe.defaults.get_user_default("Company"),
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "from_date",
|
||||
|
||||
@@ -4,7 +4,19 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _, qb, scrub
|
||||
from frappe.query_builder import Criterion, Tuple
|
||||
from frappe.query_builder.functions import IfNull
|
||||
from frappe.utils import getdate, nowdate
|
||||
from frappe.utils.nestedset import get_descendants_of
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
get_dimension_with_children,
|
||||
)
|
||||
|
||||
TREE_DOCTYPES = frozenset(
|
||||
["Customer Group", "Terrirtory", "Supplier Group", "Sales Partner", "Sales Person", "Cost Center"]
|
||||
)
|
||||
|
||||
|
||||
class PartyLedgerSummaryReport:
|
||||
@@ -13,59 +25,110 @@ class PartyLedgerSummaryReport:
|
||||
self.filters.from_date = getdate(self.filters.from_date or nowdate())
|
||||
self.filters.to_date = getdate(self.filters.to_date or nowdate())
|
||||
|
||||
if not self.filters.get("company"):
|
||||
self.filters["company"] = frappe.db.get_single_value("Global Defaults", "default_company")
|
||||
|
||||
def run(self, args):
|
||||
if self.filters.from_date > self.filters.to_date:
|
||||
frappe.throw(_("From Date must be before To Date"))
|
||||
|
||||
self.filters.party_type = args.get("party_type")
|
||||
self.party_naming_by = frappe.db.get_value(args.get("naming_by")[0], None, args.get("naming_by")[1])
|
||||
|
||||
self.validate_filters()
|
||||
self.get_party_details()
|
||||
|
||||
if not self.parties:
|
||||
return [], []
|
||||
|
||||
self.get_gl_entries()
|
||||
self.get_additional_columns()
|
||||
self.get_return_invoices()
|
||||
self.get_party_adjustment_amounts()
|
||||
|
||||
self.party_naming_by = frappe.db.get_single_value(args.get("naming_by")[0], args.get("naming_by")[1])
|
||||
columns = self.get_columns()
|
||||
data = self.get_data()
|
||||
|
||||
return columns, data
|
||||
|
||||
def get_additional_columns(self):
|
||||
def validate_filters(self):
|
||||
if not self.filters.get("company"):
|
||||
frappe.throw(_("{0} is mandatory").format(_("Company")))
|
||||
|
||||
if self.filters.from_date > self.filters.to_date:
|
||||
frappe.throw(_("From Date must be before To Date"))
|
||||
|
||||
self.update_hierarchical_filters()
|
||||
|
||||
def update_hierarchical_filters(self):
|
||||
for doctype in TREE_DOCTYPES:
|
||||
key = scrub(doctype)
|
||||
if self.filters.get(key):
|
||||
self.filters[key] = get_children(doctype, self.filters[key])
|
||||
|
||||
def get_party_details(self):
|
||||
"""
|
||||
Additional Columns for 'User Permission' based access control
|
||||
"""
|
||||
self.parties = []
|
||||
self.party_details = frappe._dict()
|
||||
party_type = self.filters.party_type
|
||||
|
||||
if self.filters.party_type == "Customer":
|
||||
self.territories = frappe._dict({})
|
||||
self.customer_group = frappe._dict({})
|
||||
doctype = qb.DocType(party_type)
|
||||
conditions = self.get_party_conditions(doctype)
|
||||
query = (
|
||||
qb.from_(doctype)
|
||||
.select(doctype.name.as_("party"), f"{scrub(party_type)}_name")
|
||||
.where(Criterion.all(conditions))
|
||||
)
|
||||
|
||||
customer = qb.DocType("Customer")
|
||||
result = (
|
||||
frappe.qb.from_(customer)
|
||||
.select(
|
||||
customer.name, customer.territory, customer.customer_group, customer.default_sales_partner
|
||||
)
|
||||
.where(customer.disabled == 0)
|
||||
.run(as_dict=True)
|
||||
from frappe.desk.reportview import build_match_conditions
|
||||
|
||||
query, params = query.walk()
|
||||
match_conditions = build_match_conditions(party_type)
|
||||
|
||||
if match_conditions:
|
||||
query += "and" + match_conditions
|
||||
|
||||
party_details = frappe.db.sql(query, params, as_dict=True)
|
||||
|
||||
for row in party_details:
|
||||
self.parties.append(row.party)
|
||||
self.party_details[row.party] = row
|
||||
|
||||
def get_party_conditions(self, doctype):
|
||||
conditions = []
|
||||
group_field = "customer_group" if self.filters.party_type == "Customer" else "supplier_group"
|
||||
|
||||
if self.filters.party:
|
||||
conditions.append(doctype.name == self.filters.party)
|
||||
|
||||
if self.filters.territory:
|
||||
conditions.append(doctype.territory.isin(self.filters.territory))
|
||||
|
||||
if self.filters.get(group_field):
|
||||
conditions.append(doctype[group_field].isin(self.filters.get(group_field)))
|
||||
|
||||
if self.filters.payment_terms_template:
|
||||
conditions.append(doctype.payment_terms == self.filters.payment_terms_template)
|
||||
|
||||
if self.filters.sales_partner:
|
||||
conditions.append(doctype.default_sales_partner.isin(self.filters.sales_partner))
|
||||
|
||||
if self.filters.sales_person:
|
||||
sales_team = qb.DocType("Sales Team")
|
||||
sales_invoice = qb.DocType("Sales Invoice")
|
||||
|
||||
customers = (
|
||||
qb.from_(sales_team)
|
||||
.select(sales_team.parent)
|
||||
.where(sales_team.sales_person.isin(self.filters.sales_person))
|
||||
.where(sales_team.parenttype == "Customer")
|
||||
) + (
|
||||
qb.from_(sales_team)
|
||||
.join(sales_invoice)
|
||||
.on(sales_team.parent == sales_invoice.name)
|
||||
.select(sales_invoice.customer)
|
||||
.where(sales_team.sales_person.isin(self.filters.sales_person))
|
||||
.where(sales_team.parenttype == "Sales Invoice")
|
||||
)
|
||||
|
||||
for x in result:
|
||||
self.territories[x.name] = x.territory
|
||||
self.customer_group[x.name] = x.customer_group
|
||||
else:
|
||||
self.supplier_group = frappe._dict({})
|
||||
supplier = qb.DocType("Supplier")
|
||||
result = (
|
||||
frappe.qb.from_(supplier)
|
||||
.select(supplier.name, supplier.supplier_group)
|
||||
.where(supplier.disabled == 0)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
conditions.append(doctype.name.isin(customers))
|
||||
|
||||
for x in result:
|
||||
self.supplier_group[x.name] = x.supplier_group
|
||||
return conditions
|
||||
|
||||
def get_columns(self):
|
||||
columns = [
|
||||
@@ -81,10 +144,10 @@ class PartyLedgerSummaryReport:
|
||||
if self.party_naming_by == "Naming Series":
|
||||
columns.append(
|
||||
{
|
||||
"label": _(self.filters.party_type + "Name"),
|
||||
"label": _(self.filters.party_type + " Name"),
|
||||
"fieldtype": "Data",
|
||||
"fieldname": "party_name",
|
||||
"width": 110,
|
||||
"width": 150,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -188,12 +251,14 @@ class PartyLedgerSummaryReport:
|
||||
|
||||
self.party_data = frappe._dict({})
|
||||
for gle in self.gl_entries:
|
||||
party_details = self.party_details.get(gle.party)
|
||||
party_name = party_details.get(f"{scrub(self.filters.party_type)}_name", "")
|
||||
self.party_data.setdefault(
|
||||
gle.party,
|
||||
frappe._dict(
|
||||
{
|
||||
"party": gle.party,
|
||||
"party_name": gle.party_name,
|
||||
**party_details,
|
||||
"party_name": party_name,
|
||||
"opening_balance": 0,
|
||||
"invoiced_amount": 0,
|
||||
"paid_amount": 0,
|
||||
@@ -204,12 +269,6 @@ class PartyLedgerSummaryReport:
|
||||
),
|
||||
)
|
||||
|
||||
if self.filters.party_type == "Customer":
|
||||
self.party_data[gle.party].update({"territory": self.territories.get(gle.party)})
|
||||
self.party_data[gle.party].update({"customer_group": self.customer_group.get(gle.party)})
|
||||
else:
|
||||
self.party_data[gle.party].update({"supplier_group": self.supplier_group.get(gle.party)})
|
||||
|
||||
amount = gle.get(invoice_dr_or_cr) - gle.get(reverse_dr_or_cr)
|
||||
self.party_data[gle.party].closing_balance += amount
|
||||
|
||||
@@ -246,164 +305,122 @@ class PartyLedgerSummaryReport:
|
||||
return out
|
||||
|
||||
def get_gl_entries(self):
|
||||
conditions = self.prepare_conditions()
|
||||
join = join_field = ""
|
||||
if self.filters.party_type == "Customer":
|
||||
join_field = ", p.customer_name as party_name"
|
||||
join = "left join `tabCustomer` p on gle.party = p.name"
|
||||
elif self.filters.party_type == "Supplier":
|
||||
join_field = ", p.supplier_name as party_name"
|
||||
join = "left join `tabSupplier` p on gle.party = p.name"
|
||||
|
||||
self.gl_entries = frappe.db.sql(
|
||||
f"""
|
||||
select
|
||||
gle.posting_date, gle.party, gle.voucher_type, gle.voucher_no, gle.against_voucher_type,
|
||||
gle.against_voucher, gle.debit, gle.credit, gle.is_opening {join_field}
|
||||
from `tabGL Entry` gle
|
||||
{join}
|
||||
where
|
||||
gle.docstatus < 2 and gle.is_cancelled = 0 and gle.party_type=%(party_type)s and ifnull(gle.party, '') != ''
|
||||
and gle.posting_date <= %(to_date)s {conditions}
|
||||
order by gle.posting_date
|
||||
""",
|
||||
self.filters,
|
||||
as_dict=True,
|
||||
gle = qb.DocType("GL Entry")
|
||||
query = (
|
||||
qb.from_(gle)
|
||||
.select(
|
||||
gle.posting_date,
|
||||
gle.party,
|
||||
gle.voucher_type,
|
||||
gle.voucher_no,
|
||||
gle.debit,
|
||||
gle.credit,
|
||||
gle.is_opening,
|
||||
)
|
||||
.where(
|
||||
(gle.docstatus < 2)
|
||||
& (gle.is_cancelled == 0)
|
||||
& (gle.party_type == self.filters.party_type)
|
||||
& (IfNull(gle.party, "") != "")
|
||||
& (gle.posting_date <= self.filters.to_date)
|
||||
& (gle.party.isin(self.parties))
|
||||
)
|
||||
)
|
||||
|
||||
def prepare_conditions(self):
|
||||
conditions = [""]
|
||||
query = self.prepare_conditions(query)
|
||||
|
||||
self.gl_entries = query.run(as_dict=True)
|
||||
|
||||
def prepare_conditions(self, query):
|
||||
gle = qb.DocType("GL Entry")
|
||||
if self.filters.company:
|
||||
conditions.append("gle.company=%(company)s")
|
||||
query = query.where(gle.company == self.filters.company)
|
||||
|
||||
if self.filters.finance_book:
|
||||
conditions.append("ifnull(finance_book,'') in (%(finance_book)s, '')")
|
||||
query = query.where(IfNull(gle.finance_book, "") == self.filters.finance_book)
|
||||
|
||||
if self.filters.get("party"):
|
||||
conditions.append("party=%(party)s")
|
||||
if self.filters.cost_center:
|
||||
query = query.where((gle.cost_center).isin(self.filters.cost_center))
|
||||
|
||||
if self.filters.party_type == "Customer":
|
||||
if self.filters.get("customer_group"):
|
||||
lft, rgt = frappe.get_cached_value(
|
||||
"Customer Group", self.filters["customer_group"], ["lft", "rgt"]
|
||||
)
|
||||
if self.filters.project:
|
||||
query = query.where((gle.project).isin(self.filters.project))
|
||||
|
||||
conditions.append(
|
||||
f"""party in (select name from tabCustomer
|
||||
where exists(select name from `tabCustomer Group` where lft >= {lft} and rgt <= {rgt}
|
||||
and name=tabCustomer.customer_group))"""
|
||||
)
|
||||
accounting_dimensions = get_accounting_dimensions(as_list=False)
|
||||
|
||||
if self.filters.get("territory"):
|
||||
lft, rgt = frappe.db.get_value("Territory", self.filters.get("territory"), ["lft", "rgt"])
|
||||
if accounting_dimensions:
|
||||
for dimension in accounting_dimensions:
|
||||
if self.filters.get(dimension.fieldname):
|
||||
if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"):
|
||||
self.filters[dimension.fieldname] = get_dimension_with_children(
|
||||
dimension.document_type, self.filters.get(dimension.fieldname)
|
||||
)
|
||||
query = query.where(
|
||||
(gle[dimension.fieldname]).isin(self.filters.get(dimension.fieldname))
|
||||
)
|
||||
else:
|
||||
query = query.where(
|
||||
(gle[dimension.fieldname]).isin(self.filters.get(dimension.fieldname))
|
||||
)
|
||||
|
||||
conditions.append(
|
||||
f"""party in (select name from tabCustomer
|
||||
where exists(select name from `tabTerritory` where lft >= {lft} and rgt <= {rgt}
|
||||
and name=tabCustomer.territory))"""
|
||||
)
|
||||
|
||||
if self.filters.get("payment_terms_template"):
|
||||
conditions.append(
|
||||
"party in (select name from tabCustomer where payment_terms=%(payment_terms_template)s)"
|
||||
)
|
||||
|
||||
if self.filters.get("sales_partner"):
|
||||
conditions.append(
|
||||
"party in (select name from tabCustomer where default_sales_partner=%(sales_partner)s)"
|
||||
)
|
||||
|
||||
if self.filters.get("sales_person"):
|
||||
lft, rgt = frappe.db.get_value(
|
||||
"Sales Person", self.filters.get("sales_person"), ["lft", "rgt"]
|
||||
)
|
||||
|
||||
conditions.append(
|
||||
"""exists(select name from `tabSales Team` steam where
|
||||
steam.sales_person in (select name from `tabSales Person` where lft >= {} and rgt <= {})
|
||||
and ((steam.parent = voucher_no and steam.parenttype = voucher_type)
|
||||
or (steam.parent = against_voucher and steam.parenttype = against_voucher_type)
|
||||
or (steam.parent = party and steam.parenttype = 'Customer')))""".format(lft, rgt)
|
||||
)
|
||||
|
||||
if self.filters.party_type == "Supplier":
|
||||
if self.filters.get("supplier_group"):
|
||||
conditions.append(
|
||||
"""party in (select name from tabSupplier
|
||||
where supplier_group=%(supplier_group)s)"""
|
||||
)
|
||||
|
||||
return " and ".join(conditions)
|
||||
return query
|
||||
|
||||
def get_return_invoices(self):
|
||||
doctype = "Sales Invoice" if self.filters.party_type == "Customer" else "Purchase Invoice"
|
||||
self.return_invoices = [
|
||||
d.name
|
||||
for d in frappe.get_all(
|
||||
doctype,
|
||||
filters={
|
||||
"is_return": 1,
|
||||
"docstatus": 1,
|
||||
"posting_date": ["between", [self.filters.from_date, self.filters.to_date]],
|
||||
},
|
||||
)
|
||||
]
|
||||
filters = (
|
||||
{
|
||||
"is_return": 1,
|
||||
"docstatus": 1,
|
||||
"posting_date": ["between", [self.filters.from_date, self.filters.to_date]],
|
||||
f"{scrub(self.filters.party_type)}": ["in", self.parties],
|
||||
},
|
||||
)
|
||||
|
||||
self.return_invoices = frappe.get_all(doctype, filters=filters, pluck="name")
|
||||
|
||||
def get_party_adjustment_amounts(self):
|
||||
conditions = self.prepare_conditions()
|
||||
account_type = "Expense Account" if self.filters.party_type == "Customer" else "Income Account"
|
||||
income_or_expense_accounts = frappe.db.get_all(
|
||||
"Account", filters={"account_type": account_type, "company": self.filters.company}, pluck="name"
|
||||
)
|
||||
|
||||
invoice_dr_or_cr = "debit" if self.filters.party_type == "Customer" else "credit"
|
||||
reverse_dr_or_cr = "credit" if self.filters.party_type == "Customer" else "debit"
|
||||
round_off_account = frappe.get_cached_value("Company", self.filters.company, "round_off_account")
|
||||
|
||||
gl = qb.DocType("GL Entry")
|
||||
if not income_or_expense_accounts:
|
||||
# prevent empty 'in' condition
|
||||
income_or_expense_accounts.append("")
|
||||
else:
|
||||
# escape '%' in account name
|
||||
# ignoring frappe.db.escape as it replaces single quotes with double quotes
|
||||
income_or_expense_accounts = [x.replace("%", "%%") for x in income_or_expense_accounts]
|
||||
|
||||
accounts_query = (
|
||||
qb.from_(gl)
|
||||
.select(gl.voucher_type, gl.voucher_no)
|
||||
.where(
|
||||
(gl.account.isin(income_or_expense_accounts))
|
||||
& (gl.posting_date.gte(self.filters.from_date))
|
||||
& (gl.posting_date.lte(self.filters.to_date))
|
||||
)
|
||||
)
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
f"""
|
||||
select
|
||||
posting_date, account, party, voucher_type, voucher_no, debit, credit
|
||||
from
|
||||
`tabGL Entry`
|
||||
where
|
||||
docstatus < 2 and is_cancelled = 0
|
||||
and (voucher_type, voucher_no) in (
|
||||
{accounts_query}
|
||||
) and (voucher_type, voucher_no) in (
|
||||
select voucher_type, voucher_no from `tabGL Entry` gle
|
||||
where gle.party_type=%(party_type)s and ifnull(party, '') != ''
|
||||
and gle.posting_date between %(from_date)s and %(to_date)s and gle.docstatus < 2 {conditions}
|
||||
)
|
||||
""",
|
||||
self.filters,
|
||||
as_dict=True,
|
||||
)
|
||||
current_period_vouchers = set()
|
||||
adjustment_voucher_entries = {}
|
||||
|
||||
self.party_adjustment_details = {}
|
||||
self.party_adjustment_accounts = set()
|
||||
adjustment_voucher_entries = {}
|
||||
|
||||
for gle in self.gl_entries:
|
||||
if (
|
||||
gle.is_opening != "Yes"
|
||||
and gle.posting_date >= self.filters.from_date
|
||||
and gle.posting_date <= self.filters.to_date
|
||||
):
|
||||
current_period_vouchers.add((gle.voucher_type, gle.voucher_no))
|
||||
adjustment_voucher_entries.setdefault((gle.voucher_type, gle.voucher_no), []).append(gle)
|
||||
|
||||
if not current_period_vouchers:
|
||||
return
|
||||
|
||||
gl = qb.DocType("GL Entry")
|
||||
query = (
|
||||
qb.from_(gl)
|
||||
.select(
|
||||
gl.posting_date, gl.account, gl.party, gl.voucher_type, gl.voucher_no, gl.debit, gl.credit
|
||||
)
|
||||
.where(
|
||||
(gl.docstatus < 2)
|
||||
& (gl.is_cancelled == 0)
|
||||
& (gl.posting_date.gte(self.filters.from_date))
|
||||
& (gl.posting_date.lte(self.filters.to_date))
|
||||
& (Tuple((gl.voucher_type, gl.voucher_no)).isin(current_period_vouchers))
|
||||
& (IfNull(gl.party, "") == "")
|
||||
)
|
||||
)
|
||||
query = self.prepare_conditions(query)
|
||||
gl_entries = query.run(as_dict=True)
|
||||
|
||||
for gle in gl_entries:
|
||||
adjustment_voucher_entries.setdefault((gle.voucher_type, gle.voucher_no), [])
|
||||
adjustment_voucher_entries[(gle.voucher_type, gle.voucher_no)].append(gle)
|
||||
|
||||
for voucher_gl_entries in adjustment_voucher_entries.values():
|
||||
@@ -440,9 +457,16 @@ class PartyLedgerSummaryReport:
|
||||
self.party_adjustment_details[party][account] += amount
|
||||
|
||||
|
||||
def get_children(doctype, value):
|
||||
children = get_descendants_of(doctype, value)
|
||||
|
||||
return [value, *children]
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
args = {
|
||||
"party_type": "Customer",
|
||||
"naming_by": ["Selling Settings", "cust_master_name"],
|
||||
}
|
||||
|
||||
return PartyLedgerSummaryReport(filters).run(args)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import add_days, flt, getdate, today
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.customer_ledger_summary.customer_ledger_summary import execute
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
|
||||
|
||||
class TestCustomerLedgerSummary(FrappeTestCase, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def create_sales_invoice(self, do_not_submit=False, **args):
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debit_to,
|
||||
posting_date=today(),
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=100,
|
||||
qty=10,
|
||||
price_list_rate=100,
|
||||
do_not_save=1,
|
||||
**args,
|
||||
)
|
||||
si = si.save()
|
||||
if not do_not_submit:
|
||||
si = si.submit()
|
||||
return si
|
||||
|
||||
def create_payment_entry(self, docname, do_not_submit=False):
|
||||
pe = get_payment_entry("Sales Invoice", docname, bank_account=self.cash, party_amount=40)
|
||||
pe.paid_from = self.debit_to
|
||||
pe.insert()
|
||||
if not do_not_submit:
|
||||
pe.submit()
|
||||
return pe
|
||||
|
||||
def create_credit_note(self, docname, do_not_submit=False):
|
||||
credit_note = create_sales_invoice(
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
item=self.item,
|
||||
qty=-1,
|
||||
debit_to=self.debit_to,
|
||||
cost_center=self.cost_center,
|
||||
is_return=1,
|
||||
return_against=docname,
|
||||
do_not_submit=do_not_submit,
|
||||
)
|
||||
|
||||
return credit_note
|
||||
|
||||
def test_ledger_summary_basic_output(self):
|
||||
filters = {"company": self.company, "from_date": today(), "to_date": today()}
|
||||
|
||||
si = self.create_sales_invoice(do_not_submit=True)
|
||||
si.save().submit()
|
||||
|
||||
expected = {
|
||||
"party": "_Test Customer",
|
||||
"party_name": "_Test Customer",
|
||||
"opening_balance": 0,
|
||||
"invoiced_amount": 1000.0,
|
||||
"paid_amount": 0,
|
||||
"return_amount": 0,
|
||||
"closing_balance": 1000.0,
|
||||
"currency": "INR",
|
||||
"customer_name": "_Test Customer",
|
||||
}
|
||||
|
||||
report = execute(filters)[1]
|
||||
self.assertEqual(len(report), 1)
|
||||
for field in expected:
|
||||
with self.subTest(field=field):
|
||||
self.assertEqual(report[0].get(field), expected.get(field))
|
||||
|
||||
def test_summary_with_return_and_payment(self):
|
||||
filters = {"company": self.company, "from_date": today(), "to_date": today()}
|
||||
|
||||
si = self.create_sales_invoice(do_not_submit=True)
|
||||
si.save().submit()
|
||||
|
||||
expected = {
|
||||
"party": "_Test Customer",
|
||||
"party_name": "_Test Customer",
|
||||
"opening_balance": 0,
|
||||
"invoiced_amount": 1000.0,
|
||||
"paid_amount": 0,
|
||||
"return_amount": 0,
|
||||
"closing_balance": 1000.0,
|
||||
"currency": "INR",
|
||||
"customer_name": "_Test Customer",
|
||||
}
|
||||
|
||||
report = execute(filters)[1]
|
||||
self.assertEqual(len(report), 1)
|
||||
for field in expected:
|
||||
with self.subTest(field=field):
|
||||
self.assertEqual(report[0].get(field), expected.get(field))
|
||||
|
||||
cr_note = self.create_credit_note(si.name, True)
|
||||
cr_note.items[0].qty = -2
|
||||
cr_note.save().submit()
|
||||
|
||||
expected_after_cr_note = {
|
||||
"party": "_Test Customer",
|
||||
"party_name": "_Test Customer",
|
||||
"opening_balance": 0,
|
||||
"invoiced_amount": 1000.0,
|
||||
"paid_amount": 0,
|
||||
"return_amount": 200.0,
|
||||
"closing_balance": 800.0,
|
||||
"currency": "INR",
|
||||
}
|
||||
report = execute(filters)[1]
|
||||
self.assertEqual(len(report), 1)
|
||||
for field in expected_after_cr_note:
|
||||
with self.subTest(field=field):
|
||||
self.assertEqual(report[0].get(field), expected_after_cr_note.get(field))
|
||||
|
||||
pe = self.create_payment_entry(si.name, True)
|
||||
pe.paid_amount = 500
|
||||
pe.save().submit()
|
||||
|
||||
expected_after_cr_and_payment = {
|
||||
"party": "_Test Customer",
|
||||
"party_name": "_Test Customer",
|
||||
"opening_balance": 0,
|
||||
"invoiced_amount": 1000.0,
|
||||
"paid_amount": 500.0,
|
||||
"return_amount": 200.0,
|
||||
"closing_balance": 300.0,
|
||||
"currency": "INR",
|
||||
}
|
||||
|
||||
report = execute(filters)[1]
|
||||
self.assertEqual(len(report), 1)
|
||||
for field in expected_after_cr_and_payment:
|
||||
with self.subTest(field=field):
|
||||
self.assertEqual(report[0].get(field), expected_after_cr_and_payment.get(field))
|
||||
@@ -67,5 +67,5 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-right text-muted">
|
||||
Printed On {%= frappe.datetime.str_to_user(frappe.datetime.get_datetime_as_string()) %}
|
||||
{%= __("Printed on {0}", [frappe.datetime.str_to_user(frappe.datetime.get_datetime_as_string())]) %}
|
||||
</p>
|
||||
|
||||
@@ -519,9 +519,6 @@ def get_accounting_entries(
|
||||
.where(gl_entry.company == filters.company)
|
||||
)
|
||||
|
||||
if group_by_account:
|
||||
query = query.groupby(gl_entry.account)
|
||||
|
||||
ignore_is_opening = frappe.db.get_single_value(
|
||||
"Accounts Settings", "ignore_is_opening_check_for_reporting"
|
||||
)
|
||||
@@ -551,6 +548,9 @@ def get_accounting_entries(
|
||||
if match_conditions:
|
||||
query += "and" + match_conditions
|
||||
|
||||
if group_by_account:
|
||||
query += " GROUP BY `account`"
|
||||
|
||||
return frappe.db.sql(query, params, as_dict=True)
|
||||
|
||||
|
||||
|
||||
@@ -79,4 +79,4 @@
|
||||
{% } %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-right text-muted">Printed On {%= frappe.datetime.str_to_user(frappe.datetime.get_datetime_as_string()) %}</p>
|
||||
<p class="text-right text-muted">{%= __("Printed on {0}", [frappe.datetime.str_to_user(frappe.datetime.get_datetime_as_string())]) %}</p>
|
||||
|
||||
@@ -49,7 +49,7 @@ frappe.query_reports["General Ledger"] = {
|
||||
label: __("Voucher No"),
|
||||
fieldtype: "Data",
|
||||
on_change: function () {
|
||||
frappe.query_report.set_filter_value("group_by", "Group by Voucher (Consolidated)");
|
||||
frappe.query_report.set_filter_value("categorize_by", "Categorize by Voucher (Consolidated)");
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -112,29 +112,29 @@ frappe.query_reports["General Ledger"] = {
|
||||
hidden: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "group_by",
|
||||
label: __("Group by"),
|
||||
fieldname: "categorize_by",
|
||||
label: __("Categorize by"),
|
||||
fieldtype: "Select",
|
||||
options: [
|
||||
"",
|
||||
{
|
||||
label: __("Group by Voucher"),
|
||||
value: "Group by Voucher",
|
||||
label: __("Categorize by Voucher"),
|
||||
value: "Categorize by Voucher",
|
||||
},
|
||||
{
|
||||
label: __("Group by Voucher (Consolidated)"),
|
||||
value: "Group by Voucher (Consolidated)",
|
||||
label: __("Categorize by Voucher (Consolidated)"),
|
||||
value: "Categorize by Voucher (Consolidated)",
|
||||
},
|
||||
{
|
||||
label: __("Group by Account"),
|
||||
value: "Group by Account",
|
||||
label: __("Categorize by Account"),
|
||||
value: "Categorize by Account",
|
||||
},
|
||||
{
|
||||
label: __("Group by Party"),
|
||||
value: "Group by Party",
|
||||
label: __("Categorize by Party"),
|
||||
value: "Categorize by Party",
|
||||
},
|
||||
],
|
||||
default: "Group by Voucher (Consolidated)",
|
||||
default: "Categorize by Voucher (Consolidated)",
|
||||
},
|
||||
{
|
||||
fieldname: "tax_id",
|
||||
|
||||
@@ -63,13 +63,17 @@ def validate_filters(filters, account_details):
|
||||
if not account_details.get(account):
|
||||
frappe.throw(_("Account {0} does not exists").format(account))
|
||||
|
||||
if filters.get("account") and filters.get("group_by") == "Group by Account":
|
||||
if not filters.get("categorize_by") and filters.get("group_by"):
|
||||
filters["categorize_by"] = filters["group_by"]
|
||||
filters["categorize_by"] = filters["categorize_by"].replace("Group by", "Categorize by")
|
||||
|
||||
if filters.get("account") and filters.get("categorize_by") == "Categorize by Account":
|
||||
filters.account = frappe.parse_json(filters.get("account"))
|
||||
for account in filters.account:
|
||||
if account_details[account].is_group == 0:
|
||||
frappe.throw(_("Can not filter based on Child Account, if grouped by Account"))
|
||||
|
||||
if filters.get("voucher_no") and filters.get("group_by") in ["Group by Voucher"]:
|
||||
if filters.get("voucher_no") and filters.get("categorize_by") in ["Categorize by Voucher"]:
|
||||
frappe.throw(_("Can not filter based on Voucher No, if grouped by Voucher"))
|
||||
|
||||
if filters.from_date > filters.to_date:
|
||||
@@ -163,9 +167,9 @@ def get_gl_entries(filters, accounting_dimensions):
|
||||
if filters.get("include_dimensions"):
|
||||
order_by_statement = "order by posting_date, creation"
|
||||
|
||||
if filters.get("group_by") == "Group by Voucher":
|
||||
if filters.get("categorize_by") == "Categorize by Voucher":
|
||||
order_by_statement = "order by posting_date, voucher_type, voucher_no"
|
||||
if filters.get("group_by") == "Group by Account":
|
||||
if filters.get("categorize_by") == "Categorize by Account":
|
||||
order_by_statement = "order by account, posting_date, creation"
|
||||
|
||||
if filters.get("include_default_book_entries"):
|
||||
@@ -260,7 +264,7 @@ def get_conditions(filters):
|
||||
if filters.get("voucher_no_not_in"):
|
||||
conditions.append("voucher_no not in %(voucher_no_not_in)s")
|
||||
|
||||
if filters.get("group_by") == "Group by Party" and not filters.get("party_type"):
|
||||
if filters.get("categorize_by") == "Categorize by Party" and not filters.get("party_type"):
|
||||
conditions.append("party_type in ('Customer', 'Supplier')")
|
||||
|
||||
if filters.get("party_type"):
|
||||
@@ -272,7 +276,7 @@ def get_conditions(filters):
|
||||
if not (
|
||||
filters.get("account")
|
||||
or filters.get("party")
|
||||
or filters.get("group_by") in ["Group by Account", "Group by Party"]
|
||||
or filters.get("categorize_by") in ["Categorize by Account", "Categorize by Party"]
|
||||
):
|
||||
if not ignore_is_opening:
|
||||
conditions.append("(posting_date >=%(from_date)s or is_opening = 'Yes')")
|
||||
@@ -374,26 +378,26 @@ def get_data_with_opening_closing(filters, account_details, accounting_dimension
|
||||
# Opening for filtered account
|
||||
data.append(totals.opening)
|
||||
|
||||
if filters.get("group_by") != "Group by Voucher (Consolidated)":
|
||||
if filters.get("categorize_by") != "Categorize by Voucher (Consolidated)":
|
||||
for _acc, acc_dict in gle_map.items():
|
||||
# acc
|
||||
if acc_dict.entries:
|
||||
# opening
|
||||
data.append({"debit_in_transaction_currency": None, "credit_in_transaction_currency": None})
|
||||
if (not filters.get("group_by") and not filters.get("voucher_no")) or (
|
||||
filters.get("group_by") and filters.get("group_by") != "Group by Voucher"
|
||||
if (not filters.get("categorize_by") and not filters.get("voucher_no")) or (
|
||||
filters.get("categorize_by") and filters.get("categorize_by") != "Categorize by Voucher"
|
||||
):
|
||||
data.append(acc_dict.totals.opening)
|
||||
|
||||
data += acc_dict.entries
|
||||
|
||||
# totals
|
||||
if filters.get("group_by") or not filters.voucher_no:
|
||||
if filters.get("categorize_by") or not filters.voucher_no:
|
||||
data.append(acc_dict.totals.total)
|
||||
|
||||
# closing
|
||||
if (not filters.get("group_by") and not filters.get("voucher_no")) or (
|
||||
filters.get("group_by") and filters.get("group_by") != "Group by Voucher"
|
||||
if (not filters.get("categorize_by") and not filters.get("voucher_no")) or (
|
||||
filters.get("categorize_by") and filters.get("categorize_by") != "Categorize by Voucher"
|
||||
):
|
||||
data.append(acc_dict.totals.closing)
|
||||
|
||||
@@ -430,9 +434,9 @@ def get_totals_dict():
|
||||
|
||||
|
||||
def group_by_field(group_by):
|
||||
if group_by == "Group by Party":
|
||||
if group_by == "Categorize by Party":
|
||||
return "party"
|
||||
elif group_by in ["Group by Voucher (Consolidated)", "Group by Account"]:
|
||||
elif group_by in ["Categorize by Voucher (Consolidated)", "Categorize by Account"]:
|
||||
return "account"
|
||||
else:
|
||||
return "voucher_no"
|
||||
@@ -440,7 +444,7 @@ def group_by_field(group_by):
|
||||
|
||||
def initialize_gle_map(gl_entries, filters, totals_dict):
|
||||
gle_map = OrderedDict()
|
||||
group_by = group_by_field(filters.get("group_by"))
|
||||
group_by = group_by_field(filters.get("categorize_by"))
|
||||
|
||||
for gle in gl_entries:
|
||||
gle_map.setdefault(gle.get(group_by), _dict(totals=copy.deepcopy(totals_dict), entries=[]))
|
||||
@@ -450,8 +454,8 @@ def initialize_gle_map(gl_entries, filters, totals_dict):
|
||||
def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map, totals):
|
||||
entries = []
|
||||
consolidated_gle = OrderedDict()
|
||||
group_by = group_by_field(filters.get("group_by"))
|
||||
group_by_voucher_consolidated = filters.get("group_by") == "Group by Voucher (Consolidated)"
|
||||
group_by = group_by_field(filters.get("categorize_by"))
|
||||
group_by_voucher_consolidated = filters.get("categorize_by") == "Categorize by Voucher (Consolidated)"
|
||||
|
||||
if filters.get("show_net_values_in_party_account"):
|
||||
account_type_map = get_account_type_map(filters.get("company"))
|
||||
@@ -559,17 +563,20 @@ def get_account_type_map(company):
|
||||
|
||||
|
||||
def get_result_as_list(data, filters):
|
||||
balance, _balance_in_account_currency = 0, 0
|
||||
balance = 0
|
||||
|
||||
for d in data:
|
||||
if not d.get("posting_date"):
|
||||
balance, _balance_in_account_currency = 0, 0
|
||||
balance = 0
|
||||
|
||||
balance = get_balance(d, balance, "debit", "credit")
|
||||
|
||||
d["balance"] = balance
|
||||
|
||||
d["account_currency"] = filters.account_currency
|
||||
|
||||
d["presentation_currency"] = filters.presentation_currency
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -595,11 +602,8 @@ def get_columns(filters):
|
||||
if filters.get("presentation_currency"):
|
||||
currency = filters["presentation_currency"]
|
||||
else:
|
||||
if filters.get("company"):
|
||||
currency = get_company_currency(filters["company"])
|
||||
else:
|
||||
company = get_default_company()
|
||||
currency = get_company_currency(company)
|
||||
company = filters.get("company") or get_default_company()
|
||||
filters["presentation_currency"] = currency = get_company_currency(company)
|
||||
|
||||
columns = [
|
||||
{
|
||||
@@ -620,19 +624,22 @@ def get_columns(filters):
|
||||
{
|
||||
"label": _("Debit ({0})").format(currency),
|
||||
"fieldname": "debit",
|
||||
"fieldtype": "Float",
|
||||
"fieldtype": "Currency",
|
||||
"options": "presentation_currency",
|
||||
"width": 130,
|
||||
},
|
||||
{
|
||||
"label": _("Credit ({0})").format(currency),
|
||||
"fieldname": "credit",
|
||||
"fieldtype": "Float",
|
||||
"fieldtype": "Currency",
|
||||
"options": "presentation_currency",
|
||||
"width": 130,
|
||||
},
|
||||
{
|
||||
"label": _("Balance ({0})").format(currency),
|
||||
"fieldname": "balance",
|
||||
"fieldtype": "Float",
|
||||
"fieldtype": "Currency",
|
||||
"options": "presentation_currency",
|
||||
"width": 130,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -155,7 +155,7 @@ class TestGeneralLedger(FrappeTestCase):
|
||||
"from_date": today(),
|
||||
"to_date": today(),
|
||||
"account": [account.name],
|
||||
"group_by": "Group by Voucher (Consolidated)",
|
||||
"categorize_by": "Categorize by Voucher (Consolidated)",
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -246,7 +246,7 @@ class TestGeneralLedger(FrappeTestCase):
|
||||
"from_date": today(),
|
||||
"to_date": today(),
|
||||
"account": [account.name],
|
||||
"group_by": "Group by Voucher (Consolidated)",
|
||||
"categorize_by": "Categorize by Voucher (Consolidated)",
|
||||
"ignore_err": True,
|
||||
}
|
||||
)
|
||||
@@ -261,7 +261,7 @@ class TestGeneralLedger(FrappeTestCase):
|
||||
"from_date": today(),
|
||||
"to_date": today(),
|
||||
"account": [account.name],
|
||||
"group_by": "Group by Voucher (Consolidated)",
|
||||
"categorize_by": "Categorize by Voucher (Consolidated)",
|
||||
"ignore_err": False,
|
||||
}
|
||||
)
|
||||
@@ -308,7 +308,7 @@ class TestGeneralLedger(FrappeTestCase):
|
||||
"from_date": si.posting_date,
|
||||
"to_date": si.posting_date,
|
||||
"account": [si.debit_to],
|
||||
"group_by": "Group by Voucher (Consolidated)",
|
||||
"categorize_by": "Categorize by Voucher (Consolidated)",
|
||||
"ignore_cr_dr_notes": False,
|
||||
}
|
||||
)
|
||||
@@ -325,7 +325,7 @@ class TestGeneralLedger(FrappeTestCase):
|
||||
"from_date": si.posting_date,
|
||||
"to_date": si.posting_date,
|
||||
"account": [si.debit_to],
|
||||
"group_by": "Group by Voucher (Consolidated)",
|
||||
"categorize_by": "Categorize by Voucher (Consolidated)",
|
||||
"ignore_cr_dr_notes": True,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -35,7 +35,6 @@ def execute(filters=None):
|
||||
filters=filters,
|
||||
accumulated_values=filters.accumulated_values,
|
||||
ignore_closing_entries=True,
|
||||
ignore_accumulated_values_for_fy=True,
|
||||
)
|
||||
|
||||
expense = get_data(
|
||||
@@ -46,7 +45,6 @@ def execute(filters=None):
|
||||
filters=filters,
|
||||
accumulated_values=filters.accumulated_values,
|
||||
ignore_closing_entries=True,
|
||||
ignore_accumulated_values_for_fy=True,
|
||||
)
|
||||
|
||||
net_profit_loss = get_net_profit_loss(
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
# MIT License. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.desk.query_report import export_query
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import getdate, today
|
||||
from frappe.utils import add_days, getdate, today
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.financial_statements import get_period_list
|
||||
@@ -57,7 +58,7 @@ class TestProfitAndLossStatement(AccountsTestMixin, FrappeTestCase):
|
||||
period_end_date=fy.year_end_date,
|
||||
filter_based_on="Fiscal Year",
|
||||
periodicity="Monthly",
|
||||
accumulated_vallues=True,
|
||||
accumulated_values=False,
|
||||
)
|
||||
|
||||
def test_profit_and_loss_output_and_summary(self):
|
||||
@@ -90,3 +91,82 @@ class TestProfitAndLossStatement(AccountsTestMixin, FrappeTestCase):
|
||||
with self.subTest(current_period_key=current_period_key):
|
||||
self.assertEqual(acc[current_period_key], 150)
|
||||
self.assertEqual(acc["total"], 150)
|
||||
|
||||
def test_p_and_l_export(self):
|
||||
self.create_sales_invoice(qty=1, rate=150)
|
||||
|
||||
filters = self.get_report_filters()
|
||||
frappe.local.form_dict = frappe._dict(
|
||||
{
|
||||
"report_name": "Profit and Loss Statement",
|
||||
"file_format_type": "CSV",
|
||||
"filters": filters,
|
||||
"visible_idx": [0, 1, 2, 3, 4, 5, 6],
|
||||
}
|
||||
)
|
||||
export_query()
|
||||
contents = frappe.response["filecontent"].decode()
|
||||
sales_account = frappe.db.get_value("Company", self.company, "default_income_account")
|
||||
|
||||
self.assertIn(sales_account, contents)
|
||||
|
||||
def test_accumulate_filter(self):
|
||||
# ensure 2 fiscal years
|
||||
cur_fy = self.get_fiscal_year()
|
||||
find_for = add_days(cur_fy.year_start_date, -1)
|
||||
_x = frappe.db.get_all(
|
||||
"Fiscal Year",
|
||||
filters={"disabled": 0, "year_start_date": ("<=", find_for), "year_end_date": (">=", find_for)},
|
||||
)[0]
|
||||
prev_fy = frappe.get_doc("Fiscal Year", _x.name)
|
||||
prev_fy.append("companies", {"company": self.company})
|
||||
prev_fy.save()
|
||||
|
||||
# make SI on both of them
|
||||
prev_fy_si = self.create_sales_invoice(qty=1, rate=450, do_not_submit=True)
|
||||
prev_fy_si.posting_date = add_days(prev_fy.year_end_date, -1)
|
||||
prev_fy_si.save().submit()
|
||||
income_acc = prev_fy_si.items[0].income_account
|
||||
|
||||
self.create_sales_invoice(qty=1, rate=120)
|
||||
|
||||
# Unaccumualted
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
from_fiscal_year=prev_fy.name,
|
||||
to_fiscal_year=cur_fy.name,
|
||||
period_start_date=prev_fy.year_start_date,
|
||||
period_end_date=cur_fy.year_end_date,
|
||||
filter_based_on="Date Range",
|
||||
periodicity="Yearly",
|
||||
accumulated_values=False,
|
||||
)
|
||||
result = execute(filters)
|
||||
columns = [result[0][2], result[0][3]]
|
||||
expected = {
|
||||
"account": income_acc,
|
||||
columns[0].get("fieldname"): 450.0,
|
||||
columns[1].get("fieldname"): 120.0,
|
||||
}
|
||||
actual = [x for x in result[1] if x.get("account") == income_acc]
|
||||
self.assertEqual(len(actual), 1)
|
||||
actual = actual[0]
|
||||
for key in expected.keys():
|
||||
with self.subTest(key=key):
|
||||
self.assertEqual(expected.get(key), actual.get(key))
|
||||
|
||||
# accumualted
|
||||
filters.update({"accumulated_values": True})
|
||||
expected = {
|
||||
"account": income_acc,
|
||||
columns[0].get("fieldname"): 450.0,
|
||||
columns[1].get("fieldname"): 570.0,
|
||||
}
|
||||
result = execute(filters)
|
||||
columns = [result[0][2], result[0][3]]
|
||||
actual = [x for x in result[1] if x.get("account") == income_acc]
|
||||
self.assertEqual(len(actual), 1)
|
||||
actual = actual[0]
|
||||
for key in expected.keys():
|
||||
with self.subTest(key=key):
|
||||
self.assertEqual(expected.get(key), actual.get(key))
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.report.supplier_ledger_summary.supplier_ledger_summary import execute
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
|
||||
|
||||
class TestSupplierLedgerSummary(FrappeTestCase, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_supplier()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def create_purchase_invoice(self, do_not_submit=False):
|
||||
frappe.set_user("Administrator")
|
||||
pi = make_purchase_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
supplier=self.supplier,
|
||||
is_return=False,
|
||||
update_stock=False,
|
||||
posting_date=frappe.utils.datetime.date(2021, 5, 1),
|
||||
do_not_save=1,
|
||||
rate=300,
|
||||
price_list_rate=300,
|
||||
qty=1,
|
||||
)
|
||||
|
||||
pi = pi.save()
|
||||
if not do_not_submit:
|
||||
pi = pi.submit()
|
||||
return pi
|
||||
|
||||
def test_basic_supplier_ledger_summary(self):
|
||||
self.create_purchase_invoice()
|
||||
|
||||
filters = {"company": self.company, "from_date": today(), "to_date": today()}
|
||||
|
||||
expected = {
|
||||
"party": "_Test Supplier",
|
||||
"party_name": "_Test Supplier",
|
||||
"opening_balance": 0,
|
||||
"invoiced_amount": 300.0,
|
||||
"paid_amount": 0,
|
||||
"return_amount": 0,
|
||||
"closing_balance": 300.0,
|
||||
"currency": "INR",
|
||||
"supplier_name": "_Test Supplier",
|
||||
}
|
||||
|
||||
report_output = execute(filters)[1]
|
||||
self.assertEqual(len(report_output), 1)
|
||||
for field in expected:
|
||||
with self.subTest(field=field):
|
||||
self.assertEqual(report_output[0].get(field), expected.get(field))
|
||||
|
||||
def test_supplier_ledger_summary_with_filters(self):
|
||||
self.create_purchase_invoice()
|
||||
|
||||
supplier_group = frappe.db.get_value("Supplier", self.supplier, "supplier_group")
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"from_date": today(),
|
||||
"to_date": today(),
|
||||
"supplier_group": supplier_group,
|
||||
}
|
||||
|
||||
expected = {
|
||||
"party": "_Test Supplier",
|
||||
"party_name": "_Test Supplier",
|
||||
"opening_balance": 0,
|
||||
"invoiced_amount": 300.0,
|
||||
"paid_amount": 0,
|
||||
"return_amount": 0,
|
||||
"closing_balance": 300.0,
|
||||
"currency": "INR",
|
||||
"supplier_name": "_Test Supplier",
|
||||
}
|
||||
|
||||
report_output = execute(filters)[1]
|
||||
self.assertEqual(len(report_output), 1)
|
||||
for field in expected:
|
||||
with self.subTest(field=field):
|
||||
self.assertEqual(report_output[0].get(field), expected.get(field))
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import getdate
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -33,6 +34,7 @@ def execute(filters=None):
|
||||
|
||||
def validate_filters(filters):
|
||||
"""Validate if dates are properly set"""
|
||||
filters = frappe._dict(filters or {})
|
||||
if filters.from_date > filters.to_date:
|
||||
frappe.throw(_("From Date must be before To Date"))
|
||||
|
||||
@@ -68,7 +70,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_
|
||||
if not tax_withholding_category:
|
||||
tax_withholding_category = party_map.get(party, {}).get("tax_withholding_category")
|
||||
|
||||
rate = tax_rate_map.get(tax_withholding_category)
|
||||
rate = get_tax_withholding_rates(tax_rate_map.get(tax_withholding_category, []), posting_date)
|
||||
if net_total_map.get((voucher_type, name)):
|
||||
if voucher_type == "Journal Entry" and tax_amount and rate:
|
||||
# back calcalute total amount from rate and tax_amount
|
||||
@@ -435,12 +437,22 @@ def get_doc_info(vouchers, doctype, tax_category_map, net_total_map=None):
|
||||
def get_tax_rate_map(filters):
|
||||
rate_map = frappe.get_all(
|
||||
"Tax Withholding Rate",
|
||||
filters={
|
||||
"from_date": ("<=", filters.get("from_date")),
|
||||
"to_date": (">=", filters.get("to_date")),
|
||||
},
|
||||
fields=["parent", "tax_withholding_rate"],
|
||||
as_list=1,
|
||||
filters={"from_date": ("<=", filters.to_date), "to_date": (">=", filters.from_date)},
|
||||
fields=["parent", "tax_withholding_rate", "from_date", "to_date"],
|
||||
)
|
||||
|
||||
return frappe._dict(rate_map)
|
||||
rate_list = frappe._dict()
|
||||
|
||||
for rate in rate_map:
|
||||
rate_list.setdefault(rate.parent, []).append(frappe._dict(rate))
|
||||
|
||||
return rate_list
|
||||
|
||||
|
||||
def get_tax_withholding_rates(tax_withholding, posting_date):
|
||||
# returns the row that matches with the fiscal year from posting date
|
||||
for rate in tax_withholding:
|
||||
if getdate(rate.from_date) <= getdate(posting_date) <= getdate(rate.to_date):
|
||||
return rate.tax_withholding_rate
|
||||
|
||||
return 0
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import today
|
||||
from frappe.utils import add_to_date, today
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
@@ -60,6 +60,56 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase):
|
||||
]
|
||||
self.check_expected_values(result, expected_values)
|
||||
|
||||
def test_date_filters_in_multiple_tax_withholding_rules(self):
|
||||
create_tax_category("TDS - 3", rate=10, account="TDS - _TC", cumulative_threshold=1)
|
||||
# insert new rate in same fiscal year
|
||||
fiscal_year = get_fiscal_year(today(), company="_Test Company")
|
||||
mid_year = add_to_date(fiscal_year[1], months=6)
|
||||
tds_doc = frappe.get_doc("Tax Withholding Category", "TDS - 3")
|
||||
tds_doc.rates[0].to_date = mid_year
|
||||
tds_doc.append(
|
||||
"rates",
|
||||
{
|
||||
"tax_withholding_rate": 20,
|
||||
"from_date": add_to_date(mid_year, days=1),
|
||||
"to_date": fiscal_year[2],
|
||||
"single_threshold": 1,
|
||||
"cumulative_threshold": 1,
|
||||
},
|
||||
)
|
||||
|
||||
tds_doc.save()
|
||||
|
||||
inv_1 = make_purchase_invoice(rate=1000, do_not_submit=True)
|
||||
inv_1.apply_tds = 1
|
||||
inv_1.tax_withholding_category = "TDS - 3"
|
||||
inv_1.submit()
|
||||
|
||||
inv_2 = make_purchase_invoice(
|
||||
rate=1000, do_not_submit=True, posting_date=add_to_date(mid_year, days=1), do_not_save=True
|
||||
)
|
||||
inv_2.set_posting_time = 1
|
||||
|
||||
inv_1.apply_tds = 1
|
||||
inv_2.tax_withholding_category = "TDS - 3"
|
||||
inv_2.save()
|
||||
inv_2.submit()
|
||||
|
||||
result = execute(
|
||||
frappe._dict(
|
||||
company="_Test Company",
|
||||
party_type="Supplier",
|
||||
from_date=fiscal_year[1],
|
||||
to_date=fiscal_year[2],
|
||||
)
|
||||
)[1]
|
||||
|
||||
expected_values = [
|
||||
[inv_1.name, "TDS - 3", 10.0, 5000, 500, 4500],
|
||||
[inv_2.name, "TDS - 3", 20.0, 5000, 1000, 4000],
|
||||
]
|
||||
self.check_expected_values(result, expected_values)
|
||||
|
||||
def check_expected_values(self, result, expected_values):
|
||||
for i in range(len(result)):
|
||||
voucher = frappe._dict(result[i])
|
||||
|
||||
@@ -12,8 +12,8 @@ DEFAULT_FILTERS = {
|
||||
|
||||
|
||||
REPORT_FILTER_TEST_CASES: list[tuple[ReportName, ReportFilters]] = [
|
||||
("General Ledger", {"group_by": "Group by Voucher (Consolidated)"}),
|
||||
("General Ledger", {"group_by": "Group by Voucher (Consolidated)", "include_dimensions": 1}),
|
||||
("General Ledger", {"categorize_by": "Categorize by Voucher (Consolidated)"}),
|
||||
("General Ledger", {"categorize_by": "Categorize by Voucher (Consolidated)", "include_dimensions": 1}),
|
||||
("Accounts Payable", {"range": "30, 60, 90, 120"}),
|
||||
("Accounts Receivable", {"range": "30, 60, 90, 120"}),
|
||||
("Consolidated Financial Statement", {"report": "Balance Sheet"}),
|
||||
|
||||
@@ -713,10 +713,13 @@ def update_reference_in_payment_entry(
|
||||
update_advance_paid = []
|
||||
|
||||
# Update Reconciliation effect date in reference
|
||||
reconciliation_takes_effect_on = frappe.get_cached_value(
|
||||
"Company", payment_entry.company, "reconciliation_takes_effect_on"
|
||||
)
|
||||
if payment_entry.book_advance_payments_in_separate_party_account:
|
||||
if payment_entry.advance_reconciliation_takes_effect_on == "Advance Payment Date":
|
||||
if reconciliation_takes_effect_on == "Advance Payment Date":
|
||||
reconcile_on = payment_entry.posting_date
|
||||
elif payment_entry.advance_reconciliation_takes_effect_on == "Oldest Of Invoice Or Advance":
|
||||
elif reconciliation_takes_effect_on == "Oldest Of Invoice Or Advance":
|
||||
date_field = "posting_date"
|
||||
if d.against_voucher_type in ["Sales Order", "Purchase Order"]:
|
||||
date_field = "transaction_date"
|
||||
@@ -724,7 +727,7 @@ def update_reference_in_payment_entry(
|
||||
|
||||
if getdate(reconcile_on) < getdate(payment_entry.posting_date):
|
||||
reconcile_on = payment_entry.posting_date
|
||||
elif payment_entry.advance_reconciliation_takes_effect_on == "Reconciliation Date":
|
||||
elif reconciliation_takes_effect_on == "Reconciliation Date":
|
||||
reconcile_on = nowdate()
|
||||
|
||||
reference_details.update({"reconcile_effect_on": reconcile_on})
|
||||
|
||||
@@ -658,10 +658,6 @@ frappe.ui.form.on("Asset", {
|
||||
} else {
|
||||
frm.set_value("purchase_invoice_item", data.purchase_invoice_item);
|
||||
}
|
||||
|
||||
let is_editable = !data.is_multiple_items; // if multiple items, then fields should be read-only
|
||||
frm.set_df_property("gross_purchase_amount", "read_only", is_editable);
|
||||
frm.set_df_property("asset_quantity", "read_only", is_editable);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"options": "ACC-ASS-.YYYY.-"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"depends_on": "item_code",
|
||||
"fetch_from": "item_code.item_name",
|
||||
"fetch_if_empty": 1,
|
||||
@@ -511,6 +512,7 @@
|
||||
"fieldname": "total_asset_cost",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Total Asset Cost",
|
||||
"no_copy": 1,
|
||||
"options": "Company:company:default_currency",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -519,6 +521,7 @@
|
||||
"fieldname": "additional_asset_cost",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Additional Asset Cost",
|
||||
"no_copy": 1,
|
||||
"options": "Company:company:default_currency",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -592,7 +595,7 @@
|
||||
"link_fieldname": "target_asset"
|
||||
}
|
||||
],
|
||||
"modified": "2025-02-20 14:09:05.421913",
|
||||
"modified": "2025-04-24 15:31:47.373274",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset",
|
||||
@@ -630,6 +633,7 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
|
||||
@@ -42,14 +42,15 @@ from erpnext.controllers.accounts_controller import AccountsController
|
||||
|
||||
class Asset(AccountsController):
|
||||
# begin: auto-generated types
|
||||
# ruff: noqa
|
||||
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
from erpnext.assets.doctype.asset_finance_book.asset_finance_book import AssetFinanceBook
|
||||
from frappe.types import DF
|
||||
|
||||
additional_asset_cost: DF.Currency
|
||||
amended_from: DF.Link | None
|
||||
@@ -117,9 +118,11 @@ class Asset(AccountsController):
|
||||
total_asset_cost: DF.Currency
|
||||
total_number_of_depreciations: DF.Int
|
||||
value_after_depreciation: DF.Currency
|
||||
# ruff: noqa
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
self.validate_category()
|
||||
self.validate_precision()
|
||||
self.set_purchase_doc_row_item()
|
||||
self.validate_asset_values()
|
||||
@@ -341,6 +344,17 @@ class Asset(AccountsController):
|
||||
title=_("Missing Finance Book"),
|
||||
)
|
||||
|
||||
def validate_category(self):
|
||||
non_depreciable_category = frappe.db.get_value(
|
||||
"Asset Category", self.asset_category, "non_depreciable_category"
|
||||
)
|
||||
if self.calculate_depreciation and non_depreciable_category:
|
||||
frappe.throw(
|
||||
_(
|
||||
"This asset category is marked as non-depreciable. Please disable depreciation calculation or choose a different category."
|
||||
)
|
||||
)
|
||||
|
||||
def validate_precision(self):
|
||||
if self.gross_purchase_amount:
|
||||
self.gross_purchase_amount = flt(
|
||||
@@ -1167,7 +1181,6 @@ def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
|
||||
frappe.throw(_(f"Selected {doctype} does not contain the Item Code {item_code}"))
|
||||
|
||||
first_item = matching_items[0]
|
||||
is_multiple_items = len(matching_items) > 1
|
||||
|
||||
return {
|
||||
"company": purchase_doc.company,
|
||||
@@ -1176,7 +1189,6 @@ def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
|
||||
"asset_quantity": first_item.qty,
|
||||
"cost_center": first_item.cost_center or purchase_doc.get("cost_center"),
|
||||
"asset_location": first_item.get("asset_location"),
|
||||
"is_multiple_items": is_multiple_items,
|
||||
"purchase_receipt_item": first_item.name if doctype == "Purchase Receipt" else None,
|
||||
"purchase_invoice_item": first_item.name if doctype == "Purchase Invoice" else None,
|
||||
}
|
||||
|
||||
@@ -330,45 +330,6 @@ def _make_journal_entry_for_depreciation(
|
||||
row.db_update()
|
||||
|
||||
|
||||
def get_depreciation_accounts(asset_category, company):
|
||||
fixed_asset_account = accumulated_depreciation_account = depreciation_expense_account = None
|
||||
|
||||
accounts = frappe.db.get_value(
|
||||
"Asset Category Account",
|
||||
filters={"parent": asset_category, "company_name": company},
|
||||
fieldname=[
|
||||
"fixed_asset_account",
|
||||
"accumulated_depreciation_account",
|
||||
"depreciation_expense_account",
|
||||
],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
if accounts:
|
||||
fixed_asset_account = accounts.fixed_asset_account
|
||||
accumulated_depreciation_account = accounts.accumulated_depreciation_account
|
||||
depreciation_expense_account = accounts.depreciation_expense_account
|
||||
|
||||
if not accumulated_depreciation_account or not depreciation_expense_account:
|
||||
accounts = frappe.get_cached_value(
|
||||
"Company", company, ["accumulated_depreciation_account", "depreciation_expense_account"]
|
||||
)
|
||||
|
||||
if not accumulated_depreciation_account:
|
||||
accumulated_depreciation_account = accounts[0]
|
||||
if not depreciation_expense_account:
|
||||
depreciation_expense_account = accounts[1]
|
||||
|
||||
if not fixed_asset_account or not accumulated_depreciation_account or not depreciation_expense_account:
|
||||
frappe.throw(
|
||||
_("Please set Depreciation related Accounts in Asset Category {0} or Company {1}").format(
|
||||
asset_category, company
|
||||
)
|
||||
)
|
||||
|
||||
return fixed_asset_account, accumulated_depreciation_account, depreciation_expense_account
|
||||
|
||||
|
||||
def get_credit_and_debit_accounts(accumulated_depreciation_account, depreciation_expense_account):
|
||||
root_type = frappe.get_value("Account", depreciation_expense_account, "root_type")
|
||||
|
||||
@@ -718,16 +679,15 @@ def get_gl_entries_on_asset_disposal(
|
||||
|
||||
|
||||
def get_asset_details(asset, finance_book=None):
|
||||
value_after_depreciation = asset.get_value_after_depreciation(finance_book)
|
||||
accumulated_depr_amount = flt(asset.gross_purchase_amount) - flt(value_after_depreciation)
|
||||
|
||||
fixed_asset_account, accumulated_depr_account, _ = get_depreciation_accounts(
|
||||
asset.asset_category, asset.company
|
||||
)
|
||||
disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center(asset.company)
|
||||
depreciation_cost_center = asset.cost_center or depreciation_cost_center
|
||||
|
||||
value_after_depreciation = asset.get_value_after_depreciation(finance_book)
|
||||
|
||||
accumulated_depr_amount = flt(asset.gross_purchase_amount) - flt(value_after_depreciation)
|
||||
|
||||
return (
|
||||
fixed_asset_account,
|
||||
asset,
|
||||
@@ -739,6 +699,52 @@ def get_asset_details(asset, finance_book=None):
|
||||
)
|
||||
|
||||
|
||||
def get_depreciation_accounts(asset_category, company):
|
||||
fixed_asset_account = accumulated_depreciation_account = depreciation_expense_account = None
|
||||
|
||||
non_depreciable_category = frappe.db.get_value(
|
||||
"Asset Category", asset_category, "non_depreciable_category"
|
||||
)
|
||||
|
||||
accounts = frappe.db.get_value(
|
||||
"Asset Category Account",
|
||||
filters={"parent": asset_category, "company_name": company},
|
||||
fieldname=[
|
||||
"fixed_asset_account",
|
||||
"accumulated_depreciation_account",
|
||||
"depreciation_expense_account",
|
||||
],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
if accounts:
|
||||
fixed_asset_account = accounts.fixed_asset_account
|
||||
accumulated_depreciation_account = accounts.accumulated_depreciation_account
|
||||
depreciation_expense_account = accounts.depreciation_expense_account
|
||||
|
||||
if not fixed_asset_account:
|
||||
frappe.throw(_("Please set Fixed Asset Account in Asset Category {0}").format(asset_category))
|
||||
|
||||
if not non_depreciable_category:
|
||||
accounts = frappe.get_cached_value(
|
||||
"Company", company, ["accumulated_depreciation_account", "depreciation_expense_account"]
|
||||
)
|
||||
|
||||
if not accumulated_depreciation_account:
|
||||
accumulated_depreciation_account = accounts[0]
|
||||
if not depreciation_expense_account:
|
||||
depreciation_expense_account = accounts[1]
|
||||
|
||||
if not accumulated_depreciation_account or not depreciation_expense_account:
|
||||
frappe.throw(
|
||||
_("Please set Depreciation related Accounts in Asset Category {0} or Company {1}").format(
|
||||
asset_category, company
|
||||
)
|
||||
)
|
||||
|
||||
return fixed_asset_account, accumulated_depreciation_account, depreciation_expense_account
|
||||
|
||||
|
||||
def get_profit_gl_entries(
|
||||
asset, profit_amount, gl_entries, disposal_account, depreciation_cost_center, date=None
|
||||
):
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"column_break_3",
|
||||
"depreciation_options",
|
||||
"enable_cwip_accounting",
|
||||
"non_depreciable_category",
|
||||
"finance_book_detail",
|
||||
"finance_books",
|
||||
"section_break_2",
|
||||
@@ -63,10 +64,16 @@
|
||||
"fieldname": "enable_cwip_accounting",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Capital Work in Progress Accounting"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "non_depreciable_category",
|
||||
"fieldtype": "Check",
|
||||
"label": "Non Depreciable Category"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2021-02-24 15:05:38.621803",
|
||||
"modified": "2025-05-13 15:33:03.791814",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset Category",
|
||||
@@ -111,8 +118,9 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,15 +17,14 @@ class AssetCategory(Document):
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
from erpnext.assets.doctype.asset_category_account.asset_category_account import (
|
||||
AssetCategoryAccount,
|
||||
)
|
||||
from erpnext.assets.doctype.asset_category_account.asset_category_account import AssetCategoryAccount
|
||||
from erpnext.assets.doctype.asset_finance_book.asset_finance_book import AssetFinanceBook
|
||||
|
||||
accounts: DF.Table[AssetCategoryAccount]
|
||||
asset_category_name: DF.Data
|
||||
enable_cwip_accounting: DF.Check
|
||||
finance_books: DF.Table[AssetFinanceBook]
|
||||
non_depreciable_category: DF.Check
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
|
||||
@@ -152,6 +152,9 @@ class AssetMovement(Document):
|
||||
""",
|
||||
args,
|
||||
)
|
||||
|
||||
self.validate_movement_cancellation(d, latest_movement_entry)
|
||||
|
||||
if latest_movement_entry:
|
||||
current_location = latest_movement_entry[0][0]
|
||||
current_employee = latest_movement_entry[0][1]
|
||||
@@ -179,3 +182,12 @@ class AssetMovement(Document):
|
||||
d.asset,
|
||||
_("Asset issued to Employee {0}").format(get_link_to_form("Employee", current_employee)),
|
||||
)
|
||||
|
||||
def validate_movement_cancellation(self, row, latest_movement_entry):
|
||||
asset_doc = frappe.get_doc("Asset", row.asset)
|
||||
if not latest_movement_entry and asset_doc.docstatus == 1:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Asset {0} has only one movement record. Please create another movement before deleting this one to maintain asset tracking."
|
||||
).format(row.asset)
|
||||
)
|
||||
|
||||
@@ -147,6 +147,45 @@ class TestAssetMovement(unittest.TestCase):
|
||||
movement1.cancel()
|
||||
self.assertEqual(frappe.db.get_value("Asset", asset.name, "location"), "Test Location")
|
||||
|
||||
def test_last_movement_cancellation_validation(self):
|
||||
pr = make_purchase_receipt(item_code="Macbook Pro", qty=1, rate=100000.0, location="Test Location")
|
||||
|
||||
asset_name = frappe.db.get_value("Asset", {"purchase_receipt": pr.name}, "name")
|
||||
asset = frappe.get_doc("Asset", asset_name)
|
||||
asset.calculate_depreciation = 1
|
||||
asset.available_for_use_date = "2020-06-06"
|
||||
asset.purchase_date = "2020-06-06"
|
||||
asset.append(
|
||||
"finance_books",
|
||||
{
|
||||
"expected_value_after_useful_life": 10000,
|
||||
"next_depreciation_date": "2020-12-31",
|
||||
"depreciation_method": "Straight Line",
|
||||
"total_number_of_depreciations": 3,
|
||||
"frequency_of_depreciation": 10,
|
||||
},
|
||||
)
|
||||
if asset.docstatus == 0:
|
||||
asset.submit()
|
||||
|
||||
AssetMovement = frappe.qb.DocType("Asset Movement")
|
||||
AssetMovementItem = frappe.qb.DocType("Asset Movement Item")
|
||||
|
||||
asset_movement = (
|
||||
frappe.qb.from_(AssetMovement)
|
||||
.join(AssetMovementItem)
|
||||
.on(AssetMovementItem.parent == AssetMovement.name)
|
||||
.select(AssetMovement.name)
|
||||
.where(
|
||||
(AssetMovementItem.asset == asset.name)
|
||||
& (AssetMovement.company == asset.company)
|
||||
& (AssetMovement.docstatus == 1)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
asset_movement_doc = frappe.get_doc("Asset Movement", asset_movement[0].name)
|
||||
self.assertRaises(frappe.ValidationError, asset_movement_doc.cancel)
|
||||
|
||||
|
||||
def create_asset_movement(**args):
|
||||
args = frappe._dict(args)
|
||||
|
||||
@@ -98,9 +98,11 @@ class AssetRepair(AccountsController):
|
||||
|
||||
self.increase_asset_value()
|
||||
|
||||
total_repair_cost = self.get_total_value_of_stock_consumed()
|
||||
if self.capitalize_repair_cost:
|
||||
self.asset_doc.total_asset_cost += self.repair_cost
|
||||
self.asset_doc.additional_asset_cost += self.repair_cost
|
||||
total_repair_cost += self.repair_cost
|
||||
self.asset_doc.total_asset_cost += total_repair_cost
|
||||
self.asset_doc.additional_asset_cost += total_repair_cost
|
||||
|
||||
if self.get("stock_consumption"):
|
||||
self.check_for_stock_items_and_warehouse()
|
||||
@@ -139,9 +141,11 @@ class AssetRepair(AccountsController):
|
||||
|
||||
self.decrease_asset_value()
|
||||
|
||||
total_repair_cost = self.get_total_value_of_stock_consumed()
|
||||
if self.capitalize_repair_cost:
|
||||
self.asset_doc.total_asset_cost -= self.repair_cost
|
||||
self.asset_doc.additional_asset_cost -= self.repair_cost
|
||||
total_repair_cost += self.repair_cost
|
||||
self.asset_doc.total_asset_cost -= total_repair_cost
|
||||
self.asset_doc.additional_asset_cost -= total_repair_cost
|
||||
|
||||
if self.get("capitalize_repair_cost"):
|
||||
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry")
|
||||
|
||||
@@ -109,8 +109,10 @@
|
||||
"contact_mobile",
|
||||
"contact_email",
|
||||
"shipping_address_section",
|
||||
"shipping_address",
|
||||
"dispatch_address",
|
||||
"dispatch_address_display",
|
||||
"column_break_99",
|
||||
"shipping_address",
|
||||
"shipping_address_display",
|
||||
"company_billing_address_section",
|
||||
"billing_address",
|
||||
@@ -1269,13 +1271,28 @@
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Connections",
|
||||
"show_dashboard": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "dispatch_address",
|
||||
"fieldtype": "Link",
|
||||
"label": "Dispatch Address",
|
||||
"options": "Address",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "dispatch_address_display",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Dispatch Address Details",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "fa fa-file-text",
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-20 16:03:31.611808",
|
||||
"modified": "2025-04-09 16:54:08.836106",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order",
|
||||
@@ -1322,6 +1339,7 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "status, transaction_date, supplier, grand_total",
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "modified",
|
||||
|
||||
@@ -92,6 +92,8 @@ class PurchaseOrder(BuyingController):
|
||||
customer_name: DF.Data | None
|
||||
disable_rounded_total: DF.Check
|
||||
discount_amount: DF.Currency
|
||||
dispatch_address: DF.Link | None
|
||||
dispatch_address_display: DF.TextEditor | None
|
||||
from_date: DF.Date | None
|
||||
grand_total: DF.Currency
|
||||
group_same_items: DF.Check
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"column_break_28",
|
||||
"discount_percentage",
|
||||
"discount_amount",
|
||||
"distributed_discount_amount",
|
||||
"base_rate_with_margin",
|
||||
"sec_break2",
|
||||
"rate",
|
||||
@@ -781,7 +782,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval: doc.margin_type || doc.discount_amount",
|
||||
"collapsible_depends_on": "eval: doc.margin_type || doc.discount_amount || doc.distributed_discount_amount",
|
||||
"fieldname": "discount_and_margin_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Discount and Margin"
|
||||
@@ -911,6 +912,12 @@
|
||||
"fieldname": "column_break_fyqr",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "distributed_discount_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Distributed Discount Amount",
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"depends_on": "eval:parent.is_subcontracted && !parent.is_old_subcontracting_flow",
|
||||
@@ -927,7 +934,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-03-13 17:27:43.468602",
|
||||
"modified": "2025-03-13 17:27:44.468602",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order Item",
|
||||
|
||||
@@ -37,6 +37,7 @@ class PurchaseOrderItem(Document):
|
||||
description: DF.TextEditor | None
|
||||
discount_amount: DF.Currency
|
||||
discount_percentage: DF.Percent
|
||||
distributed_discount_amount: DF.Currency
|
||||
expected_delivery_date: DF.Date | None
|
||||
expense_account: DF.Link | None
|
||||
fg_item: DF.Link | None
|
||||
|
||||
@@ -154,9 +154,31 @@ frappe.ui.form.on("Request for Quotation", {
|
||||
);
|
||||
|
||||
frm.page.set_inner_btn_group_as_primary(__("Create"));
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Supplier Quotation Comparison"),
|
||||
function () {
|
||||
frm.trigger("show_supplier_quotation_comparison");
|
||||
},
|
||||
__("View")
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
show_supplier_quotation_comparison(frm) {
|
||||
const today = new Date();
|
||||
const oneMonthAgo = new Date(today);
|
||||
oneMonthAgo.setMonth(today.getMonth() - 1);
|
||||
|
||||
frappe.route_options = {
|
||||
company: frm.doc.company,
|
||||
from_date: moment(oneMonthAgo).format("YYYY-MM-DD"),
|
||||
to_date: moment(today).format("YYYY-MM-DD"),
|
||||
request_for_quotation: frm.doc.name,
|
||||
};
|
||||
frappe.set_route("query-report", "Supplier Quotation Comparison");
|
||||
},
|
||||
|
||||
make_supplier_quotation: function (frm) {
|
||||
var doc = frm.doc;
|
||||
var dialog = new frappe.ui.Dialog({
|
||||
|
||||
@@ -234,6 +234,7 @@ def make_purchase_order(source_name, target_doc=None):
|
||||
{
|
||||
"Supplier Quotation": {
|
||||
"doctype": "Purchase Order",
|
||||
"field_no_map": ["transaction_date"],
|
||||
"validation": {
|
||||
"docstatus": ["=", 1],
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
|
||||
class TestPurchaseOrder(FrappeTestCase):
|
||||
@@ -25,7 +26,7 @@ class TestPurchaseOrder(FrappeTestCase):
|
||||
|
||||
for doc in po.get("items"):
|
||||
if doc.get("item_code"):
|
||||
doc.set("schedule_date", "2013-04-12")
|
||||
doc.set("schedule_date", add_days(today(), 1))
|
||||
|
||||
po.insert()
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"price_list_rate",
|
||||
"discount_percentage",
|
||||
"discount_amount",
|
||||
"distributed_discount_amount",
|
||||
"col_break_price_list",
|
||||
"base_price_list_rate",
|
||||
"sec_break1",
|
||||
@@ -565,13 +566,19 @@
|
||||
{
|
||||
"fieldname": "dimension_col_break",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "distributed_discount_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Distributed Discount Amount",
|
||||
"options": "currency"
|
||||
}
|
||||
],
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-11-17 12:25:26.235367",
|
||||
"modified": "2024-06-02 06:22:18.864822",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation Item",
|
||||
|
||||
@@ -26,6 +26,7 @@ class SupplierQuotationItem(Document):
|
||||
description: DF.TextEditor | None
|
||||
discount_amount: DF.Currency
|
||||
discount_percentage: DF.Percent
|
||||
distributed_discount_amount: DF.Currency
|
||||
expected_delivery_date: DF.Date | None
|
||||
image: DF.Attach | None
|
||||
is_free_item: DF.Check
|
||||
|
||||
@@ -94,9 +94,6 @@
|
||||
</script>
|
||||
|
||||
</head>
|
||||
<div style="margin-bottom: 7px;" class="text-center">
|
||||
{%= frappe.boot.letter_heads[frappe.defaults.get_default("letter_head")] %}
|
||||
</div>
|
||||
<h2 class="text-center">{%= __(report.report_name) %}</h2>
|
||||
<h4 class="text-center">{%= filters.item %} </h4>
|
||||
|
||||
@@ -124,9 +121,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4 class="text-center"> Analysis Chart </h4>
|
||||
<h4 class="text-center">{%= __("Analysis Chart") %}</h4>
|
||||
<div id="chart_div"></div>
|
||||
|
||||
|
||||
|
||||
<p class="text-right text-muted">Printed On {%= frappe.datetime.str_to_user(frappe.datetime.get_datetime_as_string()) %}</p>
|
||||
<p class="text-right text-muted">{%= __("Printed on {0}", [frappe.datetime.str_to_user(frappe.datetime.get_datetime_as_string())]) %}</p>
|
||||
|
||||
@@ -76,14 +76,14 @@ frappe.query_reports["Supplier Quotation Comparison"] = {
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "group_by",
|
||||
label: __("Group by"),
|
||||
fieldname: "categorize_by",
|
||||
label: __("Categorize by"),
|
||||
fieldtype: "Select",
|
||||
options: [
|
||||
{ label: __("Group by Supplier"), value: "Group by Supplier" },
|
||||
{ label: __("Group by Item"), value: "Group by Item" },
|
||||
{ label: __("Categorize by Supplier"), value: "Categorize by Supplier" },
|
||||
{ label: __("Categorize by Item"), value: "Categorize by Item" },
|
||||
],
|
||||
default: __("Group by Supplier"),
|
||||
default: __("Categorize by Supplier"),
|
||||
},
|
||||
{
|
||||
fieldtype: "Check",
|
||||
|
||||
@@ -15,6 +15,8 @@ def execute(filters=None):
|
||||
if not filters:
|
||||
return [], []
|
||||
|
||||
validate_filters(filters)
|
||||
|
||||
columns = get_columns(filters)
|
||||
supplier_quotation_data = get_data(filters)
|
||||
|
||||
@@ -24,6 +26,12 @@ def execute(filters=None):
|
||||
return columns, data, message, chart_data
|
||||
|
||||
|
||||
def validate_filters(filters):
|
||||
if not filters.get("categorize_by") and filters.get("group_by"):
|
||||
filters["categorize_by"] = filters["group_by"]
|
||||
filters["categorize_by"] = filters["categorize_by"].replace("Group by", "Categorize by")
|
||||
|
||||
|
||||
def get_data(filters):
|
||||
sq = frappe.qb.DocType("Supplier Quotation")
|
||||
sq_item = frappe.qb.DocType("Supplier Quotation Item")
|
||||
@@ -82,20 +90,14 @@ def prepare_data(supplier_quotation_data, filters):
|
||||
group_wise_map = defaultdict(list)
|
||||
supplier_qty_price_map = {}
|
||||
|
||||
group_by_field = "supplier_name" if filters.get("group_by") == "Group by Supplier" else "item_code"
|
||||
company_currency = frappe.db.get_default("currency")
|
||||
group_by_field = (
|
||||
"supplier_name" if filters.get("categorize_by") == "Categorize by Supplier" else "item_code"
|
||||
)
|
||||
float_precision = cint(frappe.db.get_default("float_precision")) or 2
|
||||
|
||||
for data in supplier_quotation_data:
|
||||
group = data.get(group_by_field) # get item or supplier value for this row
|
||||
|
||||
supplier_currency = frappe.db.get_value("Supplier", data.get("supplier_name"), "default_currency")
|
||||
|
||||
if supplier_currency:
|
||||
exchange_rate = get_exchange_rate(supplier_currency, company_currency)
|
||||
else:
|
||||
exchange_rate = 1
|
||||
|
||||
row = {
|
||||
"item_code": ""
|
||||
if group_by_field == "item_code"
|
||||
@@ -103,7 +105,7 @@ def prepare_data(supplier_quotation_data, filters):
|
||||
"supplier_name": "" if group_by_field == "supplier_name" else data.get("supplier_name"),
|
||||
"quotation": data.get("parent"),
|
||||
"qty": data.get("qty"),
|
||||
"price": flt(data.get("amount") * exchange_rate, float_precision),
|
||||
"price": flt(data.get("amount"), float_precision),
|
||||
"uom": data.get("uom"),
|
||||
"price_list_currency": data.get("price_list_currency"),
|
||||
"currency": data.get("currency"),
|
||||
@@ -209,6 +211,13 @@ def get_columns(filters):
|
||||
columns = [
|
||||
{"fieldname": "uom", "label": _("UOM"), "fieldtype": "Link", "options": "UOM", "width": 90},
|
||||
{"fieldname": "qty", "label": _("Quantity"), "fieldtype": "Float", "width": 80},
|
||||
{
|
||||
"fieldname": "stock_uom",
|
||||
"label": _("Stock UOM"),
|
||||
"fieldtype": "Link",
|
||||
"options": "UOM",
|
||||
"width": 90,
|
||||
},
|
||||
{
|
||||
"fieldname": "currency",
|
||||
"label": _("Currency"),
|
||||
@@ -223,13 +232,6 @@ def get_columns(filters):
|
||||
"options": "currency",
|
||||
"width": 110,
|
||||
},
|
||||
{
|
||||
"fieldname": "stock_uom",
|
||||
"label": _("Stock UOM"),
|
||||
"fieldtype": "Link",
|
||||
"options": "UOM",
|
||||
"width": 90,
|
||||
},
|
||||
{
|
||||
"fieldname": "price_per_unit",
|
||||
"label": _("Price per Unit (Stock UOM)"),
|
||||
@@ -274,7 +276,7 @@ def get_columns(filters):
|
||||
},
|
||||
]
|
||||
|
||||
if filters.get("group_by") == "Group by Item":
|
||||
if filters.get("categorize_by") == "Categorize by Item":
|
||||
group_by_columns.reverse()
|
||||
|
||||
columns[0:0] = group_by_columns # add positioned group by columns to the report
|
||||
|
||||
@@ -779,17 +779,10 @@ class AccountsController(TransactionBase):
|
||||
if not self.due_date:
|
||||
frappe.throw(_("Due Date is mandatory"))
|
||||
|
||||
validate_due_date(
|
||||
posting_date,
|
||||
self.due_date,
|
||||
self.payment_terms_template,
|
||||
)
|
||||
validate_due_date(posting_date, self.due_date, None, self.payment_terms_template, self.doctype)
|
||||
elif self.doctype == "Purchase Invoice":
|
||||
validate_due_date(
|
||||
posting_date,
|
||||
self.due_date,
|
||||
self.bill_date,
|
||||
self.payment_terms_template,
|
||||
posting_date, self.due_date, self.bill_date, self.payment_terms_template, self.doctype
|
||||
)
|
||||
|
||||
def set_price_list_currency(self, buying_or_selling):
|
||||
@@ -1841,8 +1834,11 @@ class AccountsController(TransactionBase):
|
||||
and self.get("discount_amount")
|
||||
and self.get("additional_discount_account")
|
||||
):
|
||||
amount = item.amount
|
||||
base_amount = item.base_amount
|
||||
amount += item.distributed_discount_amount
|
||||
base_amount += flt(
|
||||
item.distributed_discount_amount * self.get("conversion_rate"),
|
||||
item.precision("distributed_discount_amount"),
|
||||
)
|
||||
|
||||
return amount, base_amount
|
||||
|
||||
@@ -2395,13 +2391,12 @@ class AccountsController(TransactionBase):
|
||||
base_grand_total * flt(d.invoice_portion) / 100, d.precision("base_payment_amount")
|
||||
)
|
||||
d.outstanding = d.payment_amount
|
||||
d.base_outstanding = flt(
|
||||
d.payment_amount * self.get("conversion_rate"), d.precision("base_outstanding")
|
||||
)
|
||||
d.base_outstanding = d.base_payment_amount
|
||||
elif not d.invoice_portion:
|
||||
d.base_payment_amount = flt(
|
||||
d.payment_amount * self.get("conversion_rate"), d.precision("base_payment_amount")
|
||||
)
|
||||
d.base_outstanding = d.base_payment_amount
|
||||
else:
|
||||
self.fetch_payment_terms_from_order(
|
||||
po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms
|
||||
|
||||
@@ -98,7 +98,29 @@ class BuyingController(SubcontractingController):
|
||||
item.from_warehouse,
|
||||
type_of_transaction="Outward",
|
||||
do_not_submit=True,
|
||||
qty=item.qty,
|
||||
)
|
||||
elif (
|
||||
not self.is_new()
|
||||
and item.serial_and_batch_bundle
|
||||
and next(
|
||||
(
|
||||
old_item
|
||||
for old_item in self.get_doc_before_save().items
|
||||
if old_item.name == item.name and old_item.qty != item.qty
|
||||
),
|
||||
None,
|
||||
)
|
||||
and len(
|
||||
sabe := frappe.get_all(
|
||||
"Serial and Batch Entry",
|
||||
filters={"parent": item.serial_and_batch_bundle, "serial_no": ["is", "not set"]},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
== 1
|
||||
):
|
||||
frappe.set_value("Serial and Batch Entry", sabe[0], "qty", item.qty)
|
||||
|
||||
def set_rate_for_standalone_debit_note(self):
|
||||
if self.get("is_return") and self.get("update_stock") and not self.return_against:
|
||||
@@ -141,6 +163,7 @@ class BuyingController(SubcontractingController):
|
||||
company=self.company,
|
||||
party_address=self.get("supplier_address"),
|
||||
shipping_address=self.get("shipping_address"),
|
||||
dispatch_address=self.get("dispatch_address"),
|
||||
company_address=self.get("billing_address"),
|
||||
fetch_payment_terms_template=not self.get("ignore_default_payment_terms_template"),
|
||||
ignore_permissions=self.flags.ignore_permissions,
|
||||
@@ -238,6 +261,7 @@ class BuyingController(SubcontractingController):
|
||||
address_dict = {
|
||||
"supplier_address": "address_display",
|
||||
"shipping_address": "shipping_address_display",
|
||||
"dispatch_address": "dispatch_address_display",
|
||||
"billing_address": "billing_address_display",
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,10 @@ from collections import OrderedDict, defaultdict
|
||||
import frappe
|
||||
from frappe import qb, scrub
|
||||
from frappe.desk.reportview import get_filters_cond, get_match_cond
|
||||
from frappe.permissions import has_permission
|
||||
from frappe.query_builder import Criterion, CustomFunction
|
||||
from frappe.query_builder.functions import Concat, Locate, Sum
|
||||
from frappe.utils import nowdate, today, unique
|
||||
from frappe.utils import cint, nowdate, today, unique
|
||||
from pypika import Order
|
||||
|
||||
import erpnext
|
||||
@@ -20,10 +21,28 @@ from erpnext.stock.get_item_details import _get_item_tax_template
|
||||
# searches for active employees
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def employee_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
def employee_query(
|
||||
doctype,
|
||||
txt,
|
||||
searchfield,
|
||||
start,
|
||||
page_len,
|
||||
filters,
|
||||
reference_doctype: str | None = None,
|
||||
ignore_user_permissions: bool = False,
|
||||
):
|
||||
doctype = "Employee"
|
||||
conditions = []
|
||||
fields = get_fields(doctype, ["name", "employee_name"])
|
||||
ignore_permissions = False
|
||||
|
||||
if reference_doctype and ignore_user_permissions:
|
||||
ignore_permissions = has_ignored_field(reference_doctype, doctype) and has_permission(
|
||||
doctype,
|
||||
ptype="select" if frappe.only_has_select_perm(doctype) else "read",
|
||||
)
|
||||
|
||||
mcond = "" if ignore_permissions else get_match_cond(doctype)
|
||||
|
||||
return frappe.db.sql(
|
||||
"""select {fields} from `tabEmployee`
|
||||
@@ -42,13 +61,32 @@ def employee_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
"fields": ", ".join(fields),
|
||||
"key": searchfield,
|
||||
"fcond": get_filters_cond(doctype, filters, conditions),
|
||||
"mcond": get_match_cond(doctype),
|
||||
"mcond": mcond,
|
||||
}
|
||||
),
|
||||
{"txt": "%%%s%%" % txt, "_txt": txt.replace("%", ""), "start": start, "page_len": page_len},
|
||||
)
|
||||
|
||||
|
||||
def has_ignored_field(reference_doctype, doctype):
|
||||
meta = frappe.get_meta(reference_doctype)
|
||||
for field in meta.fields:
|
||||
if not field.ignore_user_permissions:
|
||||
continue
|
||||
if field.fieldtype == "Link" and field.options == doctype:
|
||||
return True
|
||||
elif field.fieldtype == "Dynamic Link":
|
||||
options = meta.get_link_doctype(field.fieldname)
|
||||
if not options:
|
||||
continue
|
||||
if isinstance(options, str):
|
||||
options = options.split("\n")
|
||||
if doctype in options or "DocType" in options:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# searches for leads which are not converted
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
@@ -921,7 +959,7 @@ def get_item_uom_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
|
||||
return frappe.get_all(
|
||||
"UOM",
|
||||
filters={"name": ["like", f"%{txt}%"]},
|
||||
filters={"name": ["like", f"%{txt}%"], "enabled": 1},
|
||||
fields=["name"],
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
|
||||
@@ -347,6 +347,16 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
|
||||
"Company", company, "default_warehouse_for_sales_return"
|
||||
)
|
||||
|
||||
if doctype == "Sales Invoice":
|
||||
inv_is_consolidated, inv_is_pos = frappe.db.get_value(
|
||||
"Sales Invoice", source_name, ["is_consolidated", "is_pos"]
|
||||
)
|
||||
if inv_is_consolidated and inv_is_pos:
|
||||
frappe.throw(
|
||||
_("Cannot create return for consolidated invoice {0}.").format(source_name),
|
||||
title=_("Cannot Create Return"),
|
||||
)
|
||||
|
||||
def set_missing_values(source, target):
|
||||
doc = frappe.get_doc(target)
|
||||
doc.is_return = 1
|
||||
|
||||
@@ -84,8 +84,8 @@ status_map = {
|
||||
"Delivery Note": [
|
||||
["Draft", None],
|
||||
["To Bill", "eval:self.per_billed < 100 and self.docstatus == 1"],
|
||||
["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"],
|
||||
["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"],
|
||||
["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"],
|
||||
["Cancelled", "eval:self.docstatus==2"],
|
||||
["Closed", "eval:self.status=='Closed' and self.docstatus != 2"],
|
||||
],
|
||||
|
||||
@@ -811,7 +811,7 @@ class StockController(AccountsController):
|
||||
)
|
||||
|
||||
def make_package_for_transfer(
|
||||
self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None
|
||||
self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None, qty=0
|
||||
):
|
||||
return make_bundle_for_material_transfer(
|
||||
is_new=self.is_new(),
|
||||
@@ -822,6 +822,7 @@ class StockController(AccountsController):
|
||||
warehouse=warehouse,
|
||||
type_of_transaction=type_of_transaction,
|
||||
do_not_submit=do_not_submit,
|
||||
qty=qty,
|
||||
)
|
||||
|
||||
def get_sl_entries(self, d, args):
|
||||
@@ -988,7 +989,13 @@ class StockController(AccountsController):
|
||||
def update_billing_percentage(self, update_modified=True):
|
||||
target_ref_field = "amount"
|
||||
if self.doctype == "Delivery Note":
|
||||
target_ref_field = "amount - (returned_qty * rate)"
|
||||
total_amount = total_returned = 0
|
||||
for item in self.items:
|
||||
total_amount += flt(item.amount)
|
||||
total_returned += flt(item.returned_qty * item.rate)
|
||||
|
||||
if total_returned < total_amount:
|
||||
target_ref_field = "(amount - (returned_qty * rate))"
|
||||
|
||||
self._update_percent_field(
|
||||
{
|
||||
@@ -1041,6 +1048,16 @@ class StockController(AccountsController):
|
||||
|
||||
def validate_qi_presence(self, row):
|
||||
"""Check if QI is present on row level. Warn on save and stop on submit if missing."""
|
||||
if self.doctype in [
|
||||
"Purchase Receipt",
|
||||
"Purchase Invoice",
|
||||
"Sales Invoice",
|
||||
"Delivery Note",
|
||||
] and frappe.db.get_single_value(
|
||||
"Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery"
|
||||
):
|
||||
return
|
||||
|
||||
if not row.quality_inspection:
|
||||
msg = _("Row #{0}: Quality Inspection is required for Item {1}").format(
|
||||
row.idx, frappe.bold(row.item_code)
|
||||
@@ -1153,6 +1170,12 @@ class StockController(AccountsController):
|
||||
if self.doctype not in ["Purchase Invoice", "Purchase Receipt"]:
|
||||
return
|
||||
|
||||
self.__inter_company_reference = (
|
||||
self.get("inter_company_reference")
|
||||
if self.doctype == "Purchase Invoice"
|
||||
else self.get("inter_company_invoice_reference")
|
||||
)
|
||||
|
||||
item_wise_transfer_qty = self.get_item_wise_inter_transfer_qty()
|
||||
if not item_wise_transfer_qty:
|
||||
return
|
||||
@@ -1182,15 +1205,11 @@ class StockController(AccountsController):
|
||||
bold(key[1]),
|
||||
bold(flt(transferred_qty, precision)),
|
||||
bold(parent_doctype),
|
||||
get_link_to_form(parent_doctype, self.get("inter_company_reference")),
|
||||
get_link_to_form(parent_doctype, self.__inter_company_reference),
|
||||
)
|
||||
)
|
||||
|
||||
def get_item_wise_inter_transfer_qty(self):
|
||||
reference_field = "inter_company_reference"
|
||||
if self.doctype == "Purchase Invoice":
|
||||
reference_field = "inter_company_invoice_reference"
|
||||
|
||||
parent_doctype = {
|
||||
"Purchase Receipt": "Delivery Note",
|
||||
"Purchase Invoice": "Sales Invoice",
|
||||
@@ -1210,7 +1229,7 @@ class StockController(AccountsController):
|
||||
child_tab.item_code,
|
||||
child_tab.qty,
|
||||
)
|
||||
.where((parent_tab.name == self.get(reference_field)) & (parent_tab.docstatus == 1))
|
||||
.where((parent_tab.name == self.__inter_company_reference) & (parent_tab.docstatus == 1))
|
||||
)
|
||||
|
||||
data = query.run(as_dict=True)
|
||||
@@ -1797,15 +1816,24 @@ def make_bundle_for_material_transfer(**kwargs):
|
||||
kwargs.type_of_transaction = "Inward"
|
||||
|
||||
bundle_doc = frappe.copy_doc(bundle_doc)
|
||||
bundle_doc.docstatus = 0
|
||||
bundle_doc.warehouse = kwargs.warehouse
|
||||
bundle_doc.type_of_transaction = kwargs.type_of_transaction
|
||||
bundle_doc.voucher_type = kwargs.voucher_type
|
||||
bundle_doc.voucher_no = "" if kwargs.is_new or kwargs.docstatus == 2 else kwargs.voucher_no
|
||||
bundle_doc.is_cancelled = 0
|
||||
|
||||
qty = 0
|
||||
if (
|
||||
len(bundle_doc.entries) == 1
|
||||
and flt(kwargs.qty) < flt(bundle_doc.total_qty)
|
||||
and not bundle_doc.has_serial_no
|
||||
):
|
||||
qty = kwargs.qty
|
||||
|
||||
for row in bundle_doc.entries:
|
||||
row.is_outward = 0
|
||||
row.qty = abs(row.qty)
|
||||
row.qty = abs(qty or row.qty)
|
||||
row.stock_value_difference = abs(row.stock_value_difference)
|
||||
if kwargs.type_of_transaction == "Outward":
|
||||
row.qty *= -1
|
||||
|
||||
@@ -377,20 +377,22 @@ class calculate_taxes_and_totals:
|
||||
self._calculate()
|
||||
|
||||
def calculate_taxes(self):
|
||||
self.grand_total_diff = 0
|
||||
doc = self.doc
|
||||
if not doc.get("taxes"):
|
||||
return
|
||||
|
||||
# maintain actual tax rate based on idx
|
||||
actual_tax_dict = dict(
|
||||
[
|
||||
[tax.idx, flt(tax.tax_amount, tax.precision("tax_amount"))]
|
||||
for tax in self.doc.get("taxes")
|
||||
for tax in doc.taxes
|
||||
if tax.charge_type == "Actual"
|
||||
]
|
||||
)
|
||||
|
||||
for n, item in enumerate(self._items):
|
||||
item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
|
||||
for i, tax in enumerate(self.doc.get("taxes")):
|
||||
for i, tax in enumerate(doc.taxes):
|
||||
# tax_amount represents the amount of tax for the current step
|
||||
current_tax_amount = self.get_current_tax_amount(item, tax, item_tax_map)
|
||||
if frappe.flags.round_row_wise_tax:
|
||||
@@ -425,30 +427,39 @@ class calculate_taxes_and_totals:
|
||||
tax.grand_total_for_current_item = flt(item.net_amount + current_tax_amount)
|
||||
else:
|
||||
tax.grand_total_for_current_item = flt(
|
||||
self.doc.get("taxes")[i - 1].grand_total_for_current_item + current_tax_amount
|
||||
doc.taxes[i - 1].grand_total_for_current_item + current_tax_amount
|
||||
)
|
||||
|
||||
# set precision in the last item iteration
|
||||
if n == len(self._items) - 1:
|
||||
self.round_off_totals(tax)
|
||||
self._set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount"])
|
||||
discount_amount_applied = self.discount_amount_applied
|
||||
if doc.apply_discount_on == "Grand Total" and (
|
||||
discount_amount_applied or doc.discount_amount or doc.additional_discount_percentage
|
||||
):
|
||||
tax_amount_precision = doc.taxes[0].precision("tax_amount")
|
||||
|
||||
self.round_off_base_values(tax)
|
||||
self.set_cumulative_total(i, tax)
|
||||
for i, tax in enumerate(doc.taxes):
|
||||
if discount_amount_applied:
|
||||
tax.tax_amount_after_discount_amount = flt(
|
||||
tax.tax_amount_after_discount_amount, tax_amount_precision
|
||||
)
|
||||
|
||||
self._set_in_company_currency(tax, ["total"])
|
||||
self.set_cumulative_total(i, tax)
|
||||
|
||||
# adjust Discount Amount loss in last tax iteration
|
||||
if (
|
||||
i == (len(self.doc.get("taxes")) - 1)
|
||||
and self.discount_amount_applied
|
||||
and self.doc.discount_amount
|
||||
and self.doc.apply_discount_on == "Grand Total"
|
||||
):
|
||||
self.grand_total_diff = flt(
|
||||
self.doc.grand_total - flt(self.doc.discount_amount) - tax.total,
|
||||
self.doc.precision("rounding_adjustment"),
|
||||
)
|
||||
if not discount_amount_applied:
|
||||
self.grand_total_for_distributing_discount = doc.taxes[-1].total
|
||||
else:
|
||||
self.grand_total_diff = flt(
|
||||
self.grand_total_for_distributing_discount - doc.discount_amount - doc.taxes[-1].total,
|
||||
doc.precision("grand_total"),
|
||||
)
|
||||
|
||||
for i, tax in enumerate(doc.taxes):
|
||||
self.round_off_totals(tax)
|
||||
self._set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount"])
|
||||
|
||||
self.round_off_base_values(tax)
|
||||
self.set_cumulative_total(i, tax)
|
||||
|
||||
self._set_in_company_currency(tax, ["total"])
|
||||
|
||||
def get_tax_amount_if_for_valuation_or_deduction(self, tax_amount, tax):
|
||||
# if just for valuation, do not add the tax amount in total
|
||||
@@ -571,16 +582,20 @@ class calculate_taxes_and_totals:
|
||||
|
||||
if diff and abs(diff) <= (5.0 / 10 ** last_tax.precision("tax_amount")):
|
||||
self.grand_total_diff = diff
|
||||
else:
|
||||
self.grand_total_diff = 0
|
||||
|
||||
def calculate_totals(self):
|
||||
grand_total_diff = getattr(self, "grand_total_diff", 0)
|
||||
|
||||
if self.doc.get("taxes"):
|
||||
self.doc.grand_total = flt(self.doc.get("taxes")[-1].total) + self.grand_total_diff
|
||||
self.doc.grand_total = flt(self.doc.get("taxes")[-1].total) + grand_total_diff
|
||||
else:
|
||||
self.doc.grand_total = flt(self.doc.net_total)
|
||||
|
||||
if self.doc.get("taxes"):
|
||||
self.doc.total_taxes_and_charges = flt(
|
||||
self.doc.grand_total - self.doc.net_total - self.grand_total_diff,
|
||||
self.doc.grand_total - self.doc.net_total - grand_total_diff,
|
||||
self.doc.precision("total_taxes_and_charges"),
|
||||
)
|
||||
else:
|
||||
@@ -695,6 +710,9 @@ class calculate_taxes_and_totals:
|
||||
adjusted_net_amount = item.net_amount - distributed_amount
|
||||
expected_net_total += adjusted_net_amount
|
||||
item.net_amount = flt(adjusted_net_amount, item.precision("net_amount"))
|
||||
item.distributed_discount_amount = flt(
|
||||
distributed_amount, item.precision("distributed_discount_amount")
|
||||
)
|
||||
net_total += item.net_amount
|
||||
|
||||
# discount amount rounding adjustment
|
||||
@@ -704,6 +722,10 @@ class calculate_taxes_and_totals:
|
||||
item.net_amount = flt(
|
||||
item.net_amount + rounding_difference, item.precision("net_amount")
|
||||
)
|
||||
item.distributed_discount_amount = flt(
|
||||
distributed_amount + rounding_difference,
|
||||
item.precision("distributed_discount_amount"),
|
||||
)
|
||||
net_total += rounding_difference
|
||||
|
||||
item.net_rate = (
|
||||
@@ -718,7 +740,8 @@ class calculate_taxes_and_totals:
|
||||
self.doc.base_discount_amount = 0
|
||||
|
||||
def get_total_for_discount_amount(self):
|
||||
if self.doc.apply_discount_on == "Net Total":
|
||||
doc = self.doc
|
||||
if doc.apply_discount_on == "Net Total" or not doc.get("taxes"):
|
||||
return self.doc.net_total
|
||||
|
||||
total_actual_tax = 0
|
||||
@@ -738,7 +761,7 @@ class calculate_taxes_and_totals:
|
||||
"cumulative_tax_amount": total_actual_tax,
|
||||
}
|
||||
|
||||
for tax in self.doc.get("taxes"):
|
||||
for tax in doc.taxes:
|
||||
if tax.charge_type in ["Actual", "On Item Quantity"]:
|
||||
update_actual_tax_dict(tax, tax.tax_amount)
|
||||
continue
|
||||
@@ -757,7 +780,7 @@ class calculate_taxes_and_totals:
|
||||
)
|
||||
update_actual_tax_dict(tax, base_tax_amount * tax.rate / 100)
|
||||
|
||||
return self.doc.grand_total - total_actual_tax
|
||||
return getattr(self, "grand_total_for_distributing_discount", doc.grand_total) - total_actual_tax
|
||||
|
||||
def calculate_total_advance(self):
|
||||
if not self.doc.docstatus.is_cancelled():
|
||||
|
||||
61
erpnext/controllers/tests/test_distributed_discount.py
Normal file
61
erpnext/controllers/tests/test_distributed_discount.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
|
||||
class TestTaxesAndTotals(AccountsTestMixin, FrappeTestCase):
|
||||
def test_distributed_discount_amount(self):
|
||||
so = make_sales_order(do_not_save=1)
|
||||
so.apply_discount_on = "Net Total"
|
||||
so.discount_amount = 100
|
||||
so.items[0].qty = 5
|
||||
so.items[0].rate = 100
|
||||
so.append("items", so.items[0].as_dict())
|
||||
so.items[1].qty = 5
|
||||
so.items[1].rate = 200
|
||||
so.save()
|
||||
|
||||
calculate_taxes_and_totals(so)
|
||||
|
||||
self.assertAlmostEqual(so.items[0].distributed_discount_amount, 33.33, places=2)
|
||||
self.assertAlmostEqual(so.items[1].distributed_discount_amount, 66.67, places=2)
|
||||
self.assertAlmostEqual(so.items[0].net_amount, 466.67, places=2)
|
||||
self.assertAlmostEqual(so.items[1].net_amount, 933.33, places=2)
|
||||
self.assertEqual(so.total, 1500)
|
||||
self.assertEqual(so.net_total, 1400)
|
||||
self.assertEqual(so.grand_total, 1400)
|
||||
|
||||
def test_distributed_discount_amount_with_taxes(self):
|
||||
so = make_sales_order(do_not_save=1)
|
||||
so.apply_discount_on = "Grand Total"
|
||||
so.discount_amount = 100
|
||||
so.items[0].qty = 5
|
||||
so.items[0].rate = 100
|
||||
so.append("items", so.items[0].as_dict())
|
||||
so.items[1].qty = 5
|
||||
so.items[1].rate = 200
|
||||
so.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "VAT",
|
||||
"included_in_print_rate": True,
|
||||
"rate": 10,
|
||||
},
|
||||
)
|
||||
so.save()
|
||||
|
||||
calculate_taxes_and_totals(so)
|
||||
|
||||
# like in test_distributed_discount_amount, but reduced by the included tax
|
||||
self.assertAlmostEqual(so.items[0].distributed_discount_amount, 33.33 / 1.1, places=2)
|
||||
self.assertAlmostEqual(so.items[1].distributed_discount_amount, 66.67 / 1.1, places=2)
|
||||
self.assertAlmostEqual(so.items[0].net_amount, 466.67 / 1.1, places=2)
|
||||
self.assertAlmostEqual(so.items[1].net_amount, 933.33 / 1.1, places=2)
|
||||
self.assertEqual(so.total, 1500)
|
||||
self.assertAlmostEqual(so.net_total, 1272.73, places=2)
|
||||
self.assertEqual(so.grand_total, 1400)
|
||||
@@ -2,6 +2,9 @@ import unittest
|
||||
from functools import partial
|
||||
|
||||
import frappe
|
||||
from frappe.core.doctype.user_permission.test_user_permission import create_user
|
||||
from frappe.core.doctype.user_permission.user_permission import add_user_permissions
|
||||
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
|
||||
|
||||
from erpnext.controllers import queries
|
||||
|
||||
@@ -81,3 +84,54 @@ class TestQueries(unittest.TestCase):
|
||||
|
||||
def test_default_uoms(self):
|
||||
self.assertGreaterEqual(frappe.db.count("UOM", {"enabled": 1}), 10)
|
||||
|
||||
def test_employee_query_with_user_permissions(self):
|
||||
# party field is a dynamic link field in Payment Entry doctype with ignore_user_permissions=0
|
||||
ps = make_property_setter(
|
||||
doctype="Payment Entry",
|
||||
fieldname="party",
|
||||
property="ignore_user_permissions",
|
||||
value=1,
|
||||
property_type="Check",
|
||||
)
|
||||
ps.save()
|
||||
|
||||
user = create_user("test_employee_query@example.com", ("Accounts User", "HR User"))
|
||||
add_user_permissions(
|
||||
{
|
||||
"user": user.name,
|
||||
"doctype": "Employee",
|
||||
"docname": "_T-Employee-00001",
|
||||
"is_default": 1,
|
||||
"apply_to_all_doctypes": 1,
|
||||
"applicable_doctypes": [],
|
||||
"hide_descendants": 0,
|
||||
}
|
||||
)
|
||||
|
||||
frappe.reload_doc("accounts", "doctype", "payment entry")
|
||||
|
||||
frappe.set_user(user.name)
|
||||
params = {
|
||||
"doctype": "Employee",
|
||||
"txt": "",
|
||||
"searchfield": "name",
|
||||
"start": 0,
|
||||
"page_len": 20,
|
||||
"filters": None,
|
||||
"reference_doctype": "Payment Entry",
|
||||
"ignore_user_permissions": 1,
|
||||
}
|
||||
|
||||
result = queries.employee_query(**params)
|
||||
self.assertGreater(len(result), 1)
|
||||
|
||||
ps.delete(ignore_permissions=1, force=1, delete_permanently=1)
|
||||
frappe.reload_doc("accounts", "doctype", "payment entry")
|
||||
frappe.clear_cache()
|
||||
|
||||
# only one employee should be returned even though ignore_user_permissions is passed as 1
|
||||
result = queries.employee_query(**params)
|
||||
self.assertEqual(len(result), 1)
|
||||
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
@@ -111,6 +111,13 @@ frappe.ui.form.on("Opportunity", {
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
|
||||
let company_currency = erpnext.get_currency(frm.doc.company);
|
||||
if (company_currency != frm.doc.currency) {
|
||||
frm.add_custom_button(__("Fetch Latest Exchange Rate"), function () {
|
||||
frm.trigger("currency");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!frm.doc.__islocal && frm.perm[0].write && frm.doc.docstatus == 0) {
|
||||
@@ -152,7 +159,7 @@ frappe.ui.form.on("Opportunity", {
|
||||
|
||||
currency: function (frm) {
|
||||
let company_currency = erpnext.get_currency(frm.doc.company);
|
||||
if (company_currency != frm.doc.company) {
|
||||
if (company_currency != frm.doc.currency) {
|
||||
frappe.call({
|
||||
method: "erpnext.setup.utils.get_exchange_rate",
|
||||
args: {
|
||||
@@ -278,7 +285,6 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
|
||||
}
|
||||
|
||||
this.setup_queries();
|
||||
this.frm.trigger("currency");
|
||||
}
|
||||
|
||||
refresh() {
|
||||
|
||||
@@ -705,6 +705,12 @@ class JobCard(Document):
|
||||
bold("Job Card"), get_link_to_form("Job Card", self.name)
|
||||
)
|
||||
)
|
||||
else:
|
||||
for row in self.time_logs:
|
||||
if not row.from_time or not row.to_time:
|
||||
frappe.throw(
|
||||
_("Row #{0}: From Time and To Time fields are required").format(row.idx),
|
||||
)
|
||||
|
||||
precision = self.precision("total_completed_qty")
|
||||
total_completed_qty = flt(
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import copy
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
@@ -913,8 +914,7 @@ class ProductionPlan(Document):
|
||||
|
||||
if material_request_list:
|
||||
material_request_list = [
|
||||
f"""<a href="/app/Form/Material Request/{m.name}">{m.name}</a>"""
|
||||
for m in material_request_list
|
||||
get_link_to_form("Material Request", m.name) for m in material_request_list
|
||||
]
|
||||
msgprint(_("{0} created").format(comma_and(material_request_list)))
|
||||
else:
|
||||
@@ -925,6 +925,7 @@ class ProductionPlan(Document):
|
||||
"Fetch sub assembly items and optionally combine them."
|
||||
self.sub_assembly_items = []
|
||||
sub_assembly_items_store = [] # temporary store to process all subassembly items
|
||||
bin_details = frappe._dict()
|
||||
|
||||
for row in self.po_items:
|
||||
if self.skip_available_sub_assembly_item and not self.sub_assembly_warehouse:
|
||||
@@ -939,6 +940,8 @@ class ProductionPlan(Document):
|
||||
bom_data = []
|
||||
|
||||
get_sub_assembly_items(
|
||||
[item.production_item for item in sub_assembly_items_store],
|
||||
bin_details,
|
||||
row.bom_no,
|
||||
bom_data,
|
||||
row.planned_qty,
|
||||
@@ -1528,10 +1531,10 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
||||
|
||||
so_item_details = frappe._dict()
|
||||
|
||||
sub_assembly_items = {}
|
||||
sub_assembly_items = defaultdict(int)
|
||||
if doc.get("skip_available_sub_assembly_item") and doc.get("sub_assembly_items"):
|
||||
for d in doc.get("sub_assembly_items"):
|
||||
sub_assembly_items.setdefault((d.get("production_item"), d.get("bom_no")), d.get("qty"))
|
||||
sub_assembly_items[(d.get("production_item"), d.get("bom_no"))] += d.get("qty")
|
||||
|
||||
for data in po_items:
|
||||
if not data.get("include_exploded_items") and doc.get("sub_assembly_items"):
|
||||
@@ -1560,6 +1563,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
||||
item_details = {}
|
||||
if doc.get("sub_assembly_items"):
|
||||
item_details = get_raw_materials_of_sub_assembly_items(
|
||||
so_item_details[doc.get("sales_order")].keys() if so_item_details else [],
|
||||
item_details,
|
||||
company,
|
||||
bom_no,
|
||||
@@ -1737,6 +1741,8 @@ def get_item_data(item_code):
|
||||
|
||||
|
||||
def get_sub_assembly_items(
|
||||
sub_assembly_items,
|
||||
bin_details,
|
||||
bom_no,
|
||||
bom_data,
|
||||
to_produce_qty,
|
||||
@@ -1751,25 +1757,28 @@ def get_sub_assembly_items(
|
||||
parent_item_code = frappe.get_cached_value("BOM", bom_no, "item")
|
||||
stock_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty)
|
||||
|
||||
bin_details = frappe._dict()
|
||||
if skip_available_sub_assembly_item:
|
||||
bin_details = get_bin_details(d, company, for_warehouse=warehouse)
|
||||
if skip_available_sub_assembly_item and d.item_code not in sub_assembly_items:
|
||||
bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse))
|
||||
|
||||
for _bin_dict in bin_details:
|
||||
for _bin_dict in bin_details[d.item_code]:
|
||||
if _bin_dict.projected_qty > 0:
|
||||
if _bin_dict.projected_qty > stock_qty:
|
||||
if _bin_dict.projected_qty >= stock_qty:
|
||||
_bin_dict.projected_qty -= stock_qty
|
||||
stock_qty = 0
|
||||
continue
|
||||
else:
|
||||
stock_qty = stock_qty - _bin_dict.projected_qty
|
||||
sub_assembly_items.append(d.item_code)
|
||||
elif warehouse:
|
||||
bin_details = get_bin_details(d, company, for_warehouse=warehouse)
|
||||
bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse))
|
||||
|
||||
if stock_qty > 0:
|
||||
bom_data.append(
|
||||
frappe._dict(
|
||||
{
|
||||
"actual_qty": bin_details[0].get("actual_qty", 0) if bin_details else 0,
|
||||
"actual_qty": bin_details[d.item_code][0].get("actual_qty", 0)
|
||||
if bin_details.get(d.item_code)
|
||||
else 0,
|
||||
"parent_item_code": parent_item_code,
|
||||
"description": d.description,
|
||||
"production_item": d.item_code,
|
||||
@@ -1787,6 +1796,8 @@ def get_sub_assembly_items(
|
||||
|
||||
if d.value:
|
||||
get_sub_assembly_items(
|
||||
sub_assembly_items,
|
||||
bin_details,
|
||||
d.value,
|
||||
bom_data,
|
||||
stock_qty,
|
||||
@@ -1866,7 +1877,13 @@ def get_non_completed_production_plans():
|
||||
|
||||
|
||||
def get_raw_materials_of_sub_assembly_items(
|
||||
item_details, company, bom_no, include_non_stock_items, sub_assembly_items, planned_qty=1
|
||||
existing_sub_assembly_items,
|
||||
item_details,
|
||||
company,
|
||||
bom_no,
|
||||
include_non_stock_items,
|
||||
sub_assembly_items,
|
||||
planned_qty=1,
|
||||
):
|
||||
bei = frappe.qb.DocType("BOM Item")
|
||||
bom = frappe.qb.DocType("BOM")
|
||||
@@ -1910,12 +1927,13 @@ def get_raw_materials_of_sub_assembly_items(
|
||||
|
||||
for item in items:
|
||||
key = (item.item_code, item.bom_no)
|
||||
if item.bom_no and key not in sub_assembly_items:
|
||||
if (item.bom_no and key not in sub_assembly_items) or (item.item_code in existing_sub_assembly_items):
|
||||
continue
|
||||
|
||||
if item.bom_no:
|
||||
planned_qty = flt(sub_assembly_items[key])
|
||||
get_raw_materials_of_sub_assembly_items(
|
||||
existing_sub_assembly_items,
|
||||
item_details,
|
||||
company,
|
||||
item.bom_no,
|
||||
|
||||
@@ -1635,6 +1635,64 @@ class TestProductionPlan(FrappeTestCase):
|
||||
self.assertEqual(row.production_item, sf_item)
|
||||
self.assertEqual(row.qty, 5.0)
|
||||
|
||||
def test_calculation_of_sub_assembly_items(self):
|
||||
make_item("Sub Assembly Item ", properties={"is_stock_item": 1})
|
||||
make_item("RM Item 1", properties={"is_stock_item": 1})
|
||||
make_item("RM Item 2", properties={"is_stock_item": 1})
|
||||
make_bom(item="Sub Assembly Item", raw_materials=["RM Item 1", "RM Item 2"])
|
||||
make_bom(item="_Test FG Item", raw_materials=["Sub Assembly Item", "RM Item 1"])
|
||||
make_bom(item="_Test FG Item 2", raw_materials=["Sub Assembly Item"])
|
||||
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
make_stock_entry(
|
||||
item_code="Sub Assembly Item",
|
||||
qty=80,
|
||||
purpose="Material Receipt",
|
||||
to_warehouse="_Test Warehouse - _TC",
|
||||
)
|
||||
make_stock_entry(
|
||||
item_code="RM Item 1", qty=90, purpose="Material Receipt", to_warehouse="_Test Warehouse - _TC"
|
||||
)
|
||||
|
||||
plan = create_production_plan(
|
||||
skip_available_sub_assembly_item=1,
|
||||
sub_assembly_warehouse="_Test Warehouse - _TC",
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
item_code="_Test FG Item",
|
||||
skip_getting_mr_items=1,
|
||||
planned_qty=100,
|
||||
do_not_save=1,
|
||||
)
|
||||
plan.get_items_from = ""
|
||||
plan.append(
|
||||
"po_items",
|
||||
{
|
||||
"use_multi_level_bom": 1,
|
||||
"item_code": "_Test FG Item 2",
|
||||
"bom_no": frappe.db.get_value("Item", "_Test FG Item 2", "default_bom"),
|
||||
"planned_qty": 50,
|
||||
"planned_start_date": now_datetime(),
|
||||
"stock_uom": "Nos",
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
},
|
||||
)
|
||||
plan.save()
|
||||
|
||||
plan.get_sub_assembly_items()
|
||||
|
||||
self.assertEqual(plan.sub_assembly_items[0].qty, 20)
|
||||
self.assertEqual(plan.sub_assembly_items[1].qty, 50)
|
||||
|
||||
from erpnext.manufacturing.doctype.production_plan.production_plan import (
|
||||
get_items_for_material_requests,
|
||||
)
|
||||
|
||||
mr_items = get_items_for_material_requests(plan.as_dict())
|
||||
|
||||
self.assertEqual(mr_items[0].get("quantity"), 80)
|
||||
self.assertEqual(mr_items[1].get("quantity"), 70)
|
||||
|
||||
|
||||
def create_production_plan(**args):
|
||||
"""
|
||||
|
||||
@@ -2665,6 +2665,81 @@ class TestWorkOrder(FrappeTestCase):
|
||||
)
|
||||
frappe.db.set_single_value("Stock Settings", "pick_serial_and_batch_based_on", original_based_on)
|
||||
|
||||
def test_operations_time_planning_calculation(self):
|
||||
from erpnext.manufacturing.doctype.routing.test_routing import create_routing, setup_operations
|
||||
|
||||
operations = [
|
||||
{"operation": "Test Operation A", "workstation": "Test Workstation A", "time_in_mins": 1},
|
||||
{"operation": "Test Operation B", "workstation": "Test Workstation A", "time_in_mins": 4},
|
||||
{"operation": "Test Operation C", "workstation": "Test Workstation A", "time_in_mins": 3},
|
||||
{"operation": "Test Operation D", "workstation": "Test Workstation A", "time_in_mins": 2},
|
||||
]
|
||||
setup_operations(operations)
|
||||
routing_doc = create_routing(routing_name="Testing Route", operations=operations)
|
||||
bom = make_bom(
|
||||
item="_Test FG Item", raw_materials=["_Test Item"], with_operations=1, routing=routing_doc.name
|
||||
)
|
||||
|
||||
wo = make_wo_order_test_record(
|
||||
item="_Test FG Item",
|
||||
bom_no=bom.name,
|
||||
qty=5,
|
||||
source_warehouse="_Test Warehouse 1 - _TC",
|
||||
skip_transfer=1,
|
||||
fg_warehouse="_Test Warehouse 2 - _TC",
|
||||
)
|
||||
|
||||
# Initial check
|
||||
self.assertEqual(wo.operations[0].operation, "Test Operation A")
|
||||
self.assertEqual(wo.operations[1].operation, "Test Operation B")
|
||||
self.assertEqual(wo.operations[2].operation, "Test Operation C")
|
||||
self.assertEqual(wo.operations[3].operation, "Test Operation D")
|
||||
|
||||
wo = frappe.copy_doc(wo)
|
||||
wo.operations[3].sequence_id = 2
|
||||
wo.submit()
|
||||
|
||||
# Test 2 : Sort line items in child table based on sequence ID
|
||||
self.assertEqual(wo.operations[0].operation, "Test Operation A")
|
||||
self.assertEqual(wo.operations[1].operation, "Test Operation B")
|
||||
self.assertEqual(wo.operations[2].operation, "Test Operation D")
|
||||
self.assertEqual(wo.operations[3].operation, "Test Operation C")
|
||||
|
||||
wo = frappe.copy_doc(wo)
|
||||
wo.operations[3].sequence_id = 1
|
||||
wo.submit()
|
||||
|
||||
self.assertEqual(wo.operations[0].operation, "Test Operation A")
|
||||
self.assertEqual(wo.operations[1].operation, "Test Operation C")
|
||||
self.assertEqual(wo.operations[2].operation, "Test Operation B")
|
||||
self.assertEqual(wo.operations[3].operation, "Test Operation D")
|
||||
|
||||
wo = frappe.copy_doc(wo)
|
||||
wo.operations[0].sequence_id = 3
|
||||
wo.submit()
|
||||
|
||||
self.assertEqual(wo.operations[0].operation, "Test Operation C")
|
||||
self.assertEqual(wo.operations[1].operation, "Test Operation B")
|
||||
self.assertEqual(wo.operations[2].operation, "Test Operation D")
|
||||
self.assertEqual(wo.operations[3].operation, "Test Operation A")
|
||||
|
||||
wo = frappe.copy_doc(wo)
|
||||
wo.operations[1].sequence_id = 0
|
||||
|
||||
# Test 3 - Error should be thrown if any one operation does not have sequence id but others do
|
||||
self.assertRaises(frappe.ValidationError, wo.submit)
|
||||
|
||||
workstation = frappe.get_doc("Workstation", "Test Workstation A")
|
||||
workstation.production_capacity = 4
|
||||
workstation.save()
|
||||
|
||||
wo = frappe.copy_doc(wo)
|
||||
wo.operations[1].sequence_id = 2
|
||||
wo.submit()
|
||||
|
||||
# Test 4 - If Sequence ID is same then planned start time for both operations should be same
|
||||
self.assertEqual(wo.operations[1].planned_start_time, wo.operations[2].planned_start_time)
|
||||
|
||||
|
||||
def make_stock_in_entries_and_get_batches(rm_item, source_warehouse, wip_warehouse):
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2013-01-10 16:34:16",
|
||||
"creation": "2025-04-09 12:09:40.634472",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"engine": "InnoDB",
|
||||
|
||||
@@ -624,19 +624,30 @@ class WorkOrder(Document):
|
||||
enable_capacity_planning = not cint(manufacturing_settings_doc.disable_capacity_planning)
|
||||
plan_days = cint(manufacturing_settings_doc.capacity_planning_for_days) or 30
|
||||
|
||||
for index, row in enumerate(self.operations):
|
||||
if all([op.sequence_id for op in self.operations]):
|
||||
self.operations = sorted(self.operations, key=lambda op: op.sequence_id)
|
||||
for idx, op in enumerate(self.operations):
|
||||
op.idx = idx + 1
|
||||
elif any([op.sequence_id for op in self.operations]):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Incorrect Sequence ID. If any single operation has a Sequence ID then all other operations must have one too."
|
||||
).format(next((op.idx for op in self.operations if not op.sequence_id), None))
|
||||
)
|
||||
|
||||
for idx, row in enumerate(self.operations):
|
||||
qty = self.qty
|
||||
while qty > 0:
|
||||
qty = split_qty_based_on_batch_size(self, row, qty)
|
||||
if row.job_card_qty > 0:
|
||||
self.prepare_data_for_job_card(row, index, plan_days, enable_capacity_planning)
|
||||
self.prepare_data_for_job_card(row, idx, plan_days, enable_capacity_planning)
|
||||
|
||||
planned_end_date = self.operations and self.operations[-1].planned_end_time
|
||||
if planned_end_date:
|
||||
self.db_set("planned_end_date", planned_end_date)
|
||||
|
||||
def prepare_data_for_job_card(self, row, index, plan_days, enable_capacity_planning):
|
||||
self.set_operation_start_end_time(index, row)
|
||||
def prepare_data_for_job_card(self, row, idx, plan_days, enable_capacity_planning):
|
||||
self.set_operation_start_end_time(row, idx)
|
||||
|
||||
job_card_doc = create_job_card(
|
||||
self, row, auto_create=True, enable_capacity_planning=enable_capacity_planning
|
||||
@@ -661,12 +672,24 @@ class WorkOrder(Document):
|
||||
|
||||
row.db_update()
|
||||
|
||||
def set_operation_start_end_time(self, idx, row):
|
||||
def set_operation_start_end_time(self, row, idx):
|
||||
"""Set start and end time for given operation. If first operation, set start as
|
||||
`planned_start_date`, else add time diff to end time of earlier operation."""
|
||||
if idx == 0:
|
||||
# first operation at planned_start date
|
||||
row.planned_start_time = self.planned_start_date
|
||||
elif self.operations[idx - 1].sequence_id:
|
||||
if self.operations[idx - 1].sequence_id == row.sequence_id:
|
||||
row.planned_start_time = self.operations[idx - 1].planned_start_time
|
||||
else:
|
||||
last_ops_with_same_sequence_ids = sorted(
|
||||
[op for op in self.operations if op.sequence_id == self.operations[idx - 1].sequence_id],
|
||||
key=lambda op: get_datetime(op.planned_end_time),
|
||||
)
|
||||
row.planned_start_time = (
|
||||
get_datetime(last_ops_with_same_sequence_ids[-1].planned_end_time)
|
||||
+ get_mins_between_operations()
|
||||
)
|
||||
else:
|
||||
row.planned_start_time = (
|
||||
get_datetime(self.operations[idx - 1].planned_end_time) + get_mins_between_operations()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"creation": "2014-10-16 14:35:41.950175",
|
||||
"creation": "2025-04-09 12:12:19.824560",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
@@ -102,13 +102,15 @@
|
||||
"fieldname": "planned_start_time",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Planned Start Time",
|
||||
"no_copy": 1
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "planned_end_time",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Planned End Time",
|
||||
"no_copy": 1
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_10",
|
||||
@@ -191,7 +193,6 @@
|
||||
{
|
||||
"fieldname": "sequence_id",
|
||||
"fieldtype": "Int",
|
||||
"hidden": 1,
|
||||
"label": "Sequence ID",
|
||||
"print_hide": 1
|
||||
},
|
||||
@@ -219,10 +220,11 @@
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-06-09 14:03:01.612909",
|
||||
"modified": "2025-04-09 16:21:47.110564",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order Operation",
|
||||
@@ -232,4 +234,4 @@
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +389,6 @@ erpnext.patches.v15_0.enable_allow_existing_serial_no
|
||||
erpnext.patches.v15_0.update_cc_in_process_statement_of_accounts
|
||||
erpnext.patches.v15_0.update_asset_status_to_work_in_progress
|
||||
erpnext.patches.v15_0.rename_manufacturing_settings_field
|
||||
erpnext.patches.v15_0.migrate_checkbox_to_select_for_reconciliation_effect
|
||||
erpnext.patches.v15_0.sync_auto_reconcile_config
|
||||
execute:frappe.db.set_single_value("Accounts Settings", "exchange_gain_loss_posting_date", "Payment")
|
||||
erpnext.patches.v14_0.disable_add_row_in_gross_profit
|
||||
@@ -402,3 +401,6 @@ erpnext.patches.v15_0.recalculate_amount_difference_field #2025-03-18
|
||||
erpnext.patches.v15_0.rename_sla_fields #2025-03-12
|
||||
erpnext.patches.v15_0.set_purchase_receipt_row_item_to_capitalization_stock_item
|
||||
erpnext.patches.v15_0.update_payment_schedule_fields_in_invoices
|
||||
erpnext.patches.v15_0.rename_group_by_to_categorize_by
|
||||
execute:frappe.db.set_single_value("Accounts Settings", "receivable_payable_fetch_method", "Buffered Cursor")
|
||||
erpnext.patches.v14_0.set_update_price_list_based_on
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user