diff --git a/CODEOWNERS b/CODEOWNERS
index 9077c6783c7..4a19fc871b5 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -4,21 +4,21 @@
# the repo. Unless a later match takes precedence,
erpnext/accounts/ @deepeshgarg007 @ruthra-kumar
-erpnext/assets/ @anandbaburajan @deepeshgarg007
+erpnext/assets/ @khushi8112 @deepeshgarg007
erpnext/regional @deepeshgarg007 @ruthra-kumar
erpnext/selling @deepeshgarg007 @ruthra-kumar
erpnext/support/ @deepeshgarg007
pos*
-erpnext/buying/ @rohitwaghchaure @s-aga-r
-erpnext/maintenance/ @rohitwaghchaure @s-aga-r
-erpnext/manufacturing/ @rohitwaghchaure @s-aga-r
-erpnext/quality_management/ @rohitwaghchaure @s-aga-r
-erpnext/stock/ @rohitwaghchaure @s-aga-r
-erpnext/subcontracting @rohitwaghchaure @s-aga-r
+erpnext/buying/ @rohitwaghchaure
+erpnext/maintenance/ @rohitwaghchaure
+erpnext/manufacturing/ @rohitwaghchaure
+erpnext/quality_management/ @rohitwaghchaure
+erpnext/stock/ @rohitwaghchaure
+erpnext/subcontracting @rohitwaghchaure
erpnext/controllers/ @deepeshgarg007 @rohitwaghchaure
erpnext/patches/ @deepeshgarg007
.github/ @deepeshgarg007
-pyproject.toml @phot0n
+pyproject.toml @akhilnarang
diff --git a/erpnext/__init__.py b/erpnext/__init__.py
index 4a9236066cc..bb8bf11b7ea 100644
--- a/erpnext/__init__.py
+++ b/erpnext/__init__.py
@@ -2,8 +2,9 @@ import functools
import inspect
import frappe
+from frappe.utils.user import is_website_user
-__version__ = "15.28.2"
+__version__ = "15.45.4"
def get_default_company(user=None):
@@ -149,3 +150,13 @@ def allow_regional(fn):
return frappe.get_attr(overrides[function_path][-1])(*args, **kwargs)
return caller
+
+
+def check_app_permission():
+ if frappe.session.user == "Administrator":
+ return True
+
+ if is_website_user():
+ return False
+
+ return True
diff --git a/erpnext/accounts/deferred_revenue.py b/erpnext/accounts/deferred_revenue.py
index a48ce9b4c63..a88764cf1b2 100644
--- a/erpnext/accounts/deferred_revenue.py
+++ b/erpnext/accounts/deferred_revenue.py
@@ -58,7 +58,7 @@ def build_conditions(process_type, account, company):
)
if account:
- conditions += f"AND {deferred_account}='{account}'"
+ conditions += f"AND {deferred_account}={frappe.db.escape(account)}"
elif company:
conditions += f"AND p.company = {frappe.db.escape(company)}"
diff --git a/erpnext/accounts/doctype/account/account.json b/erpnext/accounts/doctype/account/account.json
index e87b59ea9cb..7b56444e635 100644
--- a/erpnext/accounts/doctype/account/account.json
+++ b/erpnext/accounts/doctype/account/account.json
@@ -121,7 +121,7 @@
"label": "Account Type",
"oldfieldname": "account_type",
"oldfieldtype": "Select",
- "options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nCurrent Asset\nCurrent Liability\nDepreciation\nDirect Expense\nDirect Income\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nIndirect Expense\nIndirect Income\nLiability\nPayable\nReceivable\nRound Off\nStock\nStock Adjustment\nStock Received But Not Billed\nService Received But Not Billed\nTax\nTemporary",
+ "options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nCurrent Asset\nCurrent Liability\nDepreciation\nDirect Expense\nDirect Income\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nIndirect Expense\nIndirect Income\nLiability\nPayable\nReceivable\nRound Off\nRound Off for Opening\nStock\nStock Adjustment\nStock Received But Not Billed\nService Received But Not Billed\nTax\nTemporary",
"search_index": 1
},
{
@@ -191,7 +191,7 @@
"idx": 1,
"is_tree": 1,
"links": [],
- "modified": "2024-06-27 16:23:04.444354",
+ "modified": "2024-08-19 15:19:11.095045",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Account",
diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py
index 69c1e16bb1e..b510651e68f 100644
--- a/erpnext/accounts/doctype/account/account.py
+++ b/erpnext/accounts/doctype/account/account.py
@@ -60,6 +60,7 @@ class Account(NestedSet):
"Payable",
"Receivable",
"Round Off",
+ "Round Off for Opening",
"Stock",
"Stock Adjustment",
"Stock Received But Not Billed",
@@ -103,14 +104,12 @@ class Account(NestedSet):
self.name = get_autoname_with_number(self.account_number, self.account_name, self.company)
def validate(self):
- from erpnext.accounts.utils import validate_field_number
-
if frappe.local.flags.allow_unverified_charts:
return
self.validate_parent()
self.validate_parent_child_account_type()
self.validate_root_details()
- validate_field_number("Account", self.name, self.account_number, self.company, "account_number")
+ self.validate_account_number()
self.validate_group_or_ledger()
self.set_root_and_report_type()
self.validate_mandatory()
@@ -202,7 +201,7 @@ class Account(NestedSet):
msg = _(
"There are ledger entries against this account. Changing {0} to non-{1} in live system will cause incorrect output in 'Accounts {2}' report"
).format(
- frappe.bold("Account Type"), doc_before_save.account_type, doc_before_save.account_type
+ frappe.bold(_("Account Type")), doc_before_save.account_type, doc_before_save.account_type
)
frappe.msgprint(msg)
self.add_comment("Comment", msg)
@@ -311,6 +310,22 @@ class Account(NestedSet):
if frappe.db.get_value("GL Entry", {"account": self.name}):
frappe.throw(_("Currency can not be changed after making entries using some other currency"))
+ def validate_account_number(self, account_number=None):
+ if not account_number:
+ account_number = self.account_number
+
+ if account_number:
+ account_with_same_number = frappe.db.get_value(
+ "Account",
+ {"account_number": account_number, "company": self.company, "name": ["!=", self.name]},
+ )
+ if account_with_same_number:
+ frappe.throw(
+ _("Account Number {0} already used in account {1}").format(
+ account_number, account_with_same_number
+ )
+ )
+
def create_account_for_child_company(self, parent_acc_name_map, descendants, parent_acc_name):
for company in descendants:
company_bold = frappe.bold(company)
@@ -464,19 +479,6 @@ def get_account_autoname(account_number, account_name, company):
return " - ".join(parts)
-def validate_account_number(name, account_number, company):
- if account_number:
- account_with_same_number = frappe.db.get_value(
- "Account", {"account_number": account_number, "company": company, "name": ["!=", name]}
- )
- if account_with_same_number:
- frappe.throw(
- _("Account Number {0} already used in account {1}").format(
- account_number, account_with_same_number
- )
- )
-
-
@frappe.whitelist()
def update_account_number(name, account_name, account_number=None, from_descendant=False):
account = frappe.get_cached_doc("Account", name)
@@ -517,7 +519,7 @@ def update_account_number(name, account_name, account_number=None, from_descenda
frappe.throw(message, title=_("Rename Not Allowed"))
- validate_account_number(name, account_number, account.company)
+ account.validate_account_number(account_number)
if account_number:
frappe.db.set_value("Account", name, "account_number", account_number.strip())
else:
diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/in_standard_chart_of_accounts.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/in_standard_chart_of_accounts.json
index 2ec0b7f70c8..4d807b09c33 100644
--- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/in_standard_chart_of_accounts.json
+++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/in_standard_chart_of_accounts.json
@@ -109,7 +109,8 @@
"Utility Expenses": {},
"Write Off": {},
"Exchange Gain/Loss": {},
- "Gain/Loss on Asset Disposal": {}
+ "Gain/Loss on Asset Disposal": {},
+ "Impairment": {}
},
"root_type": "Expense"
},
@@ -132,7 +133,8 @@
"Source of Funds (Liabilities)": {
"Capital Account": {
"Reserves and Surplus": {},
- "Shareholders Funds": {}
+ "Shareholders Funds": {},
+ "Revaluation Surplus": {}
},
"Current Liabilities": {
"Accounts Payable": {
diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py b/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py
index e30ad24a374..5a5e232db8d 100644
--- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py
+++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py
@@ -72,6 +72,7 @@ def get():
_("Write Off"): {},
_("Exchange Gain/Loss"): {},
_("Gain/Loss on Asset Disposal"): {},
+ _("Impairment"): {},
},
"root_type": "Expense",
},
@@ -104,6 +105,7 @@ def get():
_("Dividends Paid"): {"account_type": "Equity"},
_("Opening Balance Equity"): {"account_type": "Equity"},
_("Retained Earnings"): {"account_type": "Equity"},
+ _("Revaluation Surplus"): {"account_type": "Equity"},
"root_type": "Equity",
},
}
diff --git a/erpnext/accounts/doctype/account_closing_balance/account_closing_balance.py b/erpnext/accounts/doctype/account_closing_balance/account_closing_balance.py
index 82821e140ea..6d5e023f039 100644
--- a/erpnext/accounts/doctype/account_closing_balance/account_closing_balance.py
+++ b/erpnext/accounts/doctype/account_closing_balance/account_closing_balance.py
@@ -113,9 +113,9 @@ def get_previous_closing_entries(company, closing_date, accounting_dimensions):
entries = []
last_period_closing_voucher = frappe.db.get_all(
"Period Closing Voucher",
- filters={"docstatus": 1, "company": company, "posting_date": ("<", closing_date)},
+ filters={"docstatus": 1, "company": company, "period_end_date": ("<", closing_date)},
fields=["name"],
- order_by="posting_date desc",
+ order_by="period_end_date desc",
limit=1,
)
diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.js b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.js
index 4e45dede1d5..6f4f9f8d782 100644
--- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.js
+++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.js
@@ -58,7 +58,7 @@ frappe.ui.form.on("Accounting Dimension", {
},
label: function (frm) {
- frm.set_value("fieldname", frappe.model.scrub(frm.doc.label));
+ frm.set_value("fieldname", frm.doc.label.replace(/ /g, "_").replace(/-/g, "_").toLowerCase());
},
document_type: function (frm) {
diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py
index db99bcd223b..8fc22dd7650 100644
--- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py
+++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py
@@ -7,6 +7,7 @@ import json
import frappe
from frappe import _, scrub
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
+from frappe.database.schema import validate_column_name
from frappe.model import core_doctypes_list
from frappe.model.document import Document
from frappe.utils import cstr
@@ -60,6 +61,7 @@ class AccountingDimension(Document):
if not self.is_new():
self.validate_document_type_change()
+ validate_column_name(self.fieldname)
self.validate_dimension_defaults()
def validate_document_type_change(self):
diff --git a/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.py b/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.py
index 1954b4b0efe..7c843cf552e 100644
--- a/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.py
+++ b/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.py
@@ -74,12 +74,12 @@ def get_dimension_filter_map():
a.applicable_on_account, d.dimension_value, p.accounting_dimension,
p.allow_or_restrict, a.is_mandatory
FROM
- `tabApplicable On Account` a, `tabAllowed Dimension` d,
+ `tabApplicable On Account` a,
`tabAccounting Dimension Filter` p
+ LEFT JOIN `tabAllowed Dimension` d ON d.parent = p.name
WHERE
p.name = a.parent
AND p.disabled = 0
- AND p.name = d.parent
""",
as_dict=1,
)
@@ -97,7 +97,6 @@ def get_dimension_filter_map():
f.allow_or_restrict,
f.is_mandatory,
)
-
frappe.flags.dimension_filter_map = dimension_filter_map
return frappe.flags.dimension_filter_map
diff --git a/erpnext/accounts/doctype/accounting_period/accounting_period.py b/erpnext/accounts/doctype/accounting_period/accounting_period.py
index 172ef93f14d..300d216618e 100644
--- a/erpnext/accounts/doctype/accounting_period/accounting_period.py
+++ b/erpnext/accounts/doctype/accounting_period/accounting_period.py
@@ -101,6 +101,8 @@ def validate_accounting_period_on_doc_save(doc, method=None):
date = doc.available_for_use_date
elif doc.doctype == "Asset Repair":
date = doc.completion_date
+ elif doc.doctype == "Period Closing Voucher":
+ date = doc.period_end_date
else:
date = doc.posting_date
diff --git a/erpnext/accounts/doctype/advance_payment_ledger_entry/__init__.py b/erpnext/accounts/doctype/advance_payment_ledger_entry/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.js b/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.js
new file mode 100644
index 00000000000..1a0dc1e7272
--- /dev/null
+++ b/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Advance Payment Ledger Entry", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.json b/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.json
new file mode 100644
index 00000000000..290ed11c98e
--- /dev/null
+++ b/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.json
@@ -0,0 +1,113 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2024-10-16 16:57:12.085072",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "company",
+ "voucher_type",
+ "voucher_no",
+ "against_voucher_type",
+ "against_voucher_no",
+ "amount",
+ "currency",
+ "event"
+ ],
+ "fields": [
+ {
+ "fieldname": "voucher_type",
+ "fieldtype": "Link",
+ "label": "Voucher Type",
+ "options": "DocType",
+ "read_only": 1
+ },
+ {
+ "fieldname": "voucher_no",
+ "fieldtype": "Dynamic Link",
+ "label": "Voucher No",
+ "options": "voucher_type",
+ "read_only": 1
+ },
+ {
+ "fieldname": "against_voucher_type",
+ "fieldtype": "Link",
+ "label": "Against Voucher Type",
+ "options": "DocType",
+ "read_only": 1
+ },
+ {
+ "fieldname": "against_voucher_no",
+ "fieldtype": "Dynamic Link",
+ "label": "Against Voucher No",
+ "options": "against_voucher_type",
+ "read_only": 1
+ },
+ {
+ "fieldname": "amount",
+ "fieldtype": "Currency",
+ "label": "Amount",
+ "read_only": 1
+ },
+ {
+ "fieldname": "currency",
+ "fieldtype": "Link",
+ "label": "Currency",
+ "options": "Currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "event",
+ "fieldtype": "Data",
+ "label": "Event",
+ "read_only": 1
+ },
+ {
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "label": "Company",
+ "options": "Company",
+ "read_only": 1
+ }
+ ],
+ "in_create": 1,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2024-11-05 10:31:28.736671",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Advance Payment Ledger Entry",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts User",
+ "share": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts Manager",
+ "share": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Auditor",
+ "share": 1
+ }
+ ],
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.py b/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.py
new file mode 100644
index 00000000000..0ec2d411761
--- /dev/null
+++ b/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.py
@@ -0,0 +1,27 @@
+# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class AdvancePaymentLedgerEntry(Document):
+ # begin: auto-generated types
+ # 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
+
+ against_voucher_no: DF.DynamicLink | None
+ against_voucher_type: DF.Link | None
+ amount: DF.Currency
+ company: DF.Link | None
+ currency: DF.Link | None
+ event: DF.Data | None
+ voucher_no: DF.DynamicLink | None
+ voucher_type: DF.Link | None
+ # end: auto-generated types
+
+ pass
diff --git a/erpnext/accounts/doctype/advance_payment_ledger_entry/test_advance_payment_ledger_entry.py b/erpnext/accounts/doctype/advance_payment_ledger_entry/test_advance_payment_ledger_entry.py
new file mode 100644
index 00000000000..2f578aed172
--- /dev/null
+++ b/erpnext/accounts/doctype/advance_payment_ledger_entry/test_advance_payment_ledger_entry.py
@@ -0,0 +1,222 @@
+# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import nowdate, today
+
+from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
+from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
+from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
+from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
+
+
+class TestAdvancePaymentLedgerEntry(AccountsTestMixin, FrappeTestCase):
+ """
+ Integration tests for AdvancePaymentLedgerEntry.
+ Use this class for testing interactions between multiple components.
+ """
+
+ def setUp(self):
+ self.create_company()
+ self.create_usd_receivable_account()
+ self.create_usd_payable_account()
+ self.create_item()
+ self.clear_old_entries()
+
+ def tearDown(self):
+ frappe.db.rollback()
+
+ def create_sales_order(self, qty=1, rate=100, currency="INR", do_not_submit=False):
+ """
+ Helper method
+ """
+ so = make_sales_order(
+ company=self.company,
+ customer=self.customer,
+ currency=currency,
+ item=self.item,
+ qty=qty,
+ rate=rate,
+ transaction_date=today(),
+ do_not_submit=do_not_submit,
+ )
+ return so
+
+ def create_purchase_order(self, qty=1, rate=100, currency="INR", do_not_submit=False):
+ """
+ Helper method
+ """
+ po = create_purchase_order(
+ company=self.company,
+ customer=self.supplier,
+ currency=currency,
+ item=self.item,
+ qty=qty,
+ rate=rate,
+ transaction_date=today(),
+ do_not_submit=do_not_submit,
+ )
+ return po
+
+ def test_so_advance_paid_and_currency_with_payment(self):
+ self.create_customer("_Test USD Customer", "USD")
+
+ so = self.create_sales_order(currency="USD", do_not_submit=True)
+ so.conversion_rate = 80
+ so.submit()
+
+ pe_exchange_rate = 85
+ pe = get_payment_entry(so.doctype, so.name, bank_account=self.cash)
+ pe.reference_no = "1"
+ pe.reference_date = nowdate()
+ pe.paid_from = self.debtors_usd
+ pe.paid_from_account_currency = "USD"
+ pe.source_exchange_rate = pe_exchange_rate
+ pe.paid_amount = so.grand_total
+ pe.received_amount = pe_exchange_rate * pe.paid_amount
+ pe.references[0].outstanding_amount = 100
+ pe.references[0].total_amount = 100
+ pe.references[0].allocated_amount = 100
+ pe.save().submit()
+
+ so.reload()
+ self.assertEqual(so.advance_paid, 100)
+ self.assertEqual(so.party_account_currency, "USD")
+
+ # cancel advance payment
+ pe.reload()
+ pe.cancel()
+
+ so.reload()
+ self.assertEqual(so.advance_paid, 0)
+ self.assertEqual(so.party_account_currency, "USD")
+
+ def test_so_advance_paid_and_currency_with_journal(self):
+ self.create_customer("_Test USD Customer", "USD")
+
+ so = self.create_sales_order(currency="USD", do_not_submit=True)
+ so.conversion_rate = 80
+ so.submit()
+
+ je_exchange_rate = 85
+ je = frappe.get_doc(
+ {
+ "doctype": "Journal Entry",
+ "company": self.company,
+ "voucher_type": "Journal Entry",
+ "posting_date": so.transaction_date,
+ "multi_currency": True,
+ "accounts": [
+ {
+ "account": self.debtors_usd,
+ "party_type": "Customer",
+ "party": so.customer,
+ "credit": 8500,
+ "credit_in_account_currency": 100,
+ "is_advance": "Yes",
+ "reference_type": so.doctype,
+ "reference_name": so.name,
+ "exchange_rate": je_exchange_rate,
+ },
+ {
+ "account": self.cash,
+ "debit": 8500,
+ "debit_in_account_currency": 8500,
+ },
+ ],
+ }
+ )
+ je.save().submit()
+ so.reload()
+ self.assertEqual(so.advance_paid, 100)
+ self.assertEqual(so.party_account_currency, "USD")
+
+ # cancel advance payment
+ je.reload()
+ je.cancel()
+
+ so.reload()
+ self.assertEqual(so.advance_paid, 0)
+ self.assertEqual(so.party_account_currency, "USD")
+
+ def test_po_advance_paid_and_currency_with_payment(self):
+ self.create_supplier("_Test USD Supplier", "USD")
+
+ po = self.create_purchase_order(currency="USD", do_not_submit=True)
+ po.conversion_rate = 80
+ po.submit()
+
+ pe_exchange_rate = 85
+ pe = get_payment_entry(po.doctype, po.name, bank_account=self.cash)
+ pe.reference_no = "1"
+ pe.reference_date = nowdate()
+ pe.paid_to = self.creditors_usd
+ pe.paid_to_account_currency = "USD"
+ pe.target_exchange_rate = pe_exchange_rate
+ pe.received_amount = po.grand_total
+ pe.paid_amount = pe_exchange_rate * pe.received_amount
+ pe.references[0].outstanding_amount = 100
+ pe.references[0].total_amount = 100
+ pe.references[0].allocated_amount = 100
+ pe.save().submit()
+
+ po.reload()
+ self.assertEqual(po.advance_paid, 100)
+ self.assertEqual(po.party_account_currency, "USD")
+
+ # cancel advance payment
+ pe.reload()
+ pe.cancel()
+
+ po.reload()
+ self.assertEqual(po.advance_paid, 0)
+ self.assertEqual(po.party_account_currency, "USD")
+
+ def test_po_advance_paid_and_currency_with_journal(self):
+ self.create_supplier("_Test USD Supplier", "USD")
+
+ po = self.create_purchase_order(currency="USD", do_not_submit=True)
+ po.conversion_rate = 80
+ po.submit()
+
+ je_exchange_rate = 85
+ je = frappe.get_doc(
+ {
+ "doctype": "Journal Entry",
+ "company": self.company,
+ "voucher_type": "Journal Entry",
+ "posting_date": po.transaction_date,
+ "multi_currency": True,
+ "accounts": [
+ {
+ "account": self.creditors_usd,
+ "party_type": "Supplier",
+ "party": po.supplier,
+ "debit": 8500,
+ "debit_in_account_currency": 100,
+ "is_advance": "Yes",
+ "reference_type": po.doctype,
+ "reference_name": po.name,
+ "exchange_rate": je_exchange_rate,
+ },
+ {
+ "account": self.cash,
+ "credit": 8500,
+ "credit_in_account_currency": 8500,
+ },
+ ],
+ }
+ )
+ je.save().submit()
+ po.reload()
+ self.assertEqual(po.advance_paid, 100)
+ self.assertEqual(po.party_account_currency, "USD")
+
+ # cancel advance payment
+ je.reload()
+ je.cancel()
+
+ po.reload()
+ self.assertEqual(po.advance_paid, 0)
+ self.assertEqual(po.party_account_currency, "USD")
diff --git a/erpnext/accounts/doctype/bank_account/bank_account.json b/erpnext/accounts/doctype/bank_account/bank_account.json
index 32f1c675d3b..962551b2417 100644
--- a/erpnext/accounts/doctype/bank_account/bank_account.json
+++ b/erpnext/accounts/doctype/bank_account/bank_account.json
@@ -208,8 +208,49 @@
"label": "Disabled"
}
],
- "links": [],
- "modified": "2023-09-22 21:31:34.763977",
+ "links": [
+ {
+ "group": "Transactions",
+ "link_doctype": "Payment Request",
+ "link_fieldname": "bank_account"
+ },
+ {
+ "group": "Transactions",
+ "link_doctype": "Payment Order",
+ "link_fieldname": "bank_account"
+ },
+ {
+ "group": "Transactions",
+ "link_doctype": "Bank Guarantee",
+ "link_fieldname": "bank_account"
+ },
+ {
+ "group": "Transactions",
+ "link_doctype": "Bank Transaction",
+ "link_fieldname": "bank_account"
+ },
+ {
+ "group": "Accounting",
+ "link_doctype": "Payment Entry",
+ "link_fieldname": "bank_account"
+ },
+ {
+ "group": "Accounting",
+ "link_doctype": "Journal Entry",
+ "link_fieldname": "bank_account"
+ },
+ {
+ "group": "Party",
+ "link_doctype": "Customer",
+ "link_fieldname": "default_bank_account"
+ },
+ {
+ "group": "Party",
+ "link_doctype": "Supplier",
+ "link_fieldname": "default_bank_account"
+ }
+ ],
+ "modified": "2024-10-30 09:41:14.113414",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Account",
@@ -246,4 +287,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/accounts/doctype/bank_account/bank_account_dashboard.py b/erpnext/accounts/doctype/bank_account/bank_account_dashboard.py
deleted file mode 100644
index 8bf8d8a5cd0..00000000000
--- a/erpnext/accounts/doctype/bank_account/bank_account_dashboard.py
+++ /dev/null
@@ -1,20 +0,0 @@
-from frappe import _
-
-
-def get_data():
- return {
- "fieldname": "bank_account",
- "non_standard_fieldnames": {
- "Customer": "default_bank_account",
- "Supplier": "default_bank_account",
- },
- "transactions": [
- {
- "label": _("Payments"),
- "items": ["Payment Entry", "Payment Request", "Payment Order", "Payroll Entry"],
- },
- {"label": _("Party"), "items": ["Customer", "Supplier"]},
- {"items": ["Bank Guarantee"]},
- {"items": ["Journal Entry"]},
- ],
- }
diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.js b/erpnext/accounts/doctype/bank_clearance/bank_clearance.js
index 2993825482c..7ece7c9c2d6 100644
--- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.js
+++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.js
@@ -38,6 +38,11 @@ frappe.ui.form.on("Bank Clearance", {
frm.add_custom_button(__("Get Payment Entries"), () => frm.trigger("get_payment_entries"));
frm.change_custom_button_type(__("Get Payment Entries"), null, "primary");
+ if (frm.doc.payment_entries.length) {
+ frm.add_custom_button(__("Update Clearance Date"), () => frm.trigger("update_clearance_date"));
+ frm.change_custom_button_type(__("Get Payment Entries"), null, "default");
+ frm.change_custom_button_type(__("Update Clearance Date"), null, "primary");
+ }
},
update_clearance_date: function (frm) {
@@ -45,13 +50,7 @@ frappe.ui.form.on("Bank Clearance", {
method: "update_clearance_date",
doc: frm.doc,
callback: function (r, rt) {
- frm.refresh_field("payment_entries");
- frm.refresh_fields();
-
- if (!frm.doc.payment_entries.length) {
- frm.change_custom_button_type(__("Get Payment Entries"), null, "primary");
- frm.change_custom_button_type(__("Update Clearance Date"), null, "default");
- }
+ frm.refresh();
},
});
},
@@ -60,17 +59,8 @@ frappe.ui.form.on("Bank Clearance", {
return frappe.call({
method: "get_payment_entries",
doc: frm.doc,
- callback: function (r, rt) {
- frm.refresh_field("payment_entries");
-
- if (frm.doc.payment_entries.length) {
- frm.add_custom_button(__("Update Clearance Date"), () =>
- frm.trigger("update_clearance_date")
- );
-
- frm.change_custom_button_type(__("Get Payment Entries"), null, "default");
- frm.change_custom_button_type(__("Update Clearance Date"), null, "primary");
- }
+ callback: function () {
+ frm.refresh();
},
});
},
diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py
index 63758a5e7fb..ac7883fce13 100644
--- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py
+++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py
@@ -6,7 +6,7 @@ import frappe
from frappe import _, msgprint
from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn
-from frappe.utils import flt, fmt_money, getdate
+from frappe.utils import flt, fmt_money, get_link_to_form, getdate
from pypika import Order
import erpnext
@@ -96,8 +96,11 @@ class BankClearance(Document):
if d.cheque_date and getdate(d.clearance_date) < getdate(d.cheque_date):
frappe.throw(
- _("Row #{0}: Clearance date {1} cannot be before Cheque Date {2}").format(
- d.idx, d.clearance_date, d.cheque_date
+ _("Row #{0}: For {1} Clearance date {2} cannot be before Cheque Date {3}").format(
+ d.idx,
+ get_link_to_form(d.payment_document, d.payment_entry),
+ d.clearance_date,
+ d.cheque_date,
)
)
@@ -105,8 +108,18 @@ class BankClearance(Document):
if not d.clearance_date:
d.clearance_date = None
- payment_entry = frappe.get_doc(d.payment_document, d.payment_entry)
- payment_entry.db_set("clearance_date", d.clearance_date)
+ if d.payment_document == "Sales Invoice":
+ frappe.db.set_value(
+ "Sales Invoice Payment",
+ {"parent": d.payment_entry, "account": self.get("account"), "amount": [">", 0]},
+ "clearance_date",
+ d.clearance_date,
+ )
+
+ else:
+ frappe.db.set_value(
+ d.payment_document, d.payment_entry, "clearance_date", d.clearance_date
+ )
clearance_date_updated = True
@@ -155,7 +168,7 @@ def get_payment_entries_for_bank_clearance(
"Payment Entry" as payment_document, name as payment_entry,
reference_no as cheque_number, reference_date as cheque_date,
if(paid_from=%(account)s, paid_amount + total_taxes_and_charges, 0) as credit,
- if(paid_from=%(account)s, 0, received_amount) as debit,
+ if(paid_from=%(account)s, 0, received_amount + total_taxes_and_charges) as debit,
posting_date, ifnull(party,if(paid_from=%(account)s,paid_to,paid_from)) as against_account, clearance_date,
if(paid_to=%(account)s, paid_to_account_currency, paid_from_account_currency) as account_currency
from `tabPayment Entry`
diff --git a/erpnext/accounts/doctype/bank_clearance/test_bank_clearance.py b/erpnext/accounts/doctype/bank_clearance/test_bank_clearance.py
index d785bfbfef2..658a69a4803 100644
--- a/erpnext/accounts/doctype/bank_clearance/test_bank_clearance.py
+++ b/erpnext/accounts/doctype/bank_clearance/test_bank_clearance.py
@@ -6,16 +6,29 @@ import unittest
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.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
+from erpnext.stock.doctype.item.test_item import create_item
+from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.tests.utils import if_lending_app_installed, if_lending_app_not_installed
class TestBankClearance(unittest.TestCase):
@classmethod
def setUpClass(cls):
+ create_warehouse(
+ warehouse_name="_Test Warehouse",
+ properties={"parent_warehouse": "All Warehouses - _TC"},
+ company="_Test Company",
+ )
+ create_item("_Test Item")
+ create_cost_center(cost_center_name="_Test Cost Center", company="_Test Company")
+
clear_payment_entries()
clear_loan_transactions()
+ clear_pos_sales_invoices()
make_bank_account()
add_transactions()
@@ -83,11 +96,41 @@ class TestBankClearance(unittest.TestCase):
bank_clearance.get_payment_entries()
self.assertEqual(len(bank_clearance.payment_entries), 3)
+ def test_update_clearance_date_on_si(self):
+ sales_invoice = make_pos_sales_invoice()
+
+ date = getdate()
+ bank_clearance = frappe.get_doc("Bank Clearance")
+ bank_clearance.account = "_Test Bank Clearance - _TC"
+ bank_clearance.from_date = add_months(date, -1)
+ bank_clearance.to_date = date
+ bank_clearance.include_pos_transactions = 1
+ bank_clearance.get_payment_entries()
+
+ self.assertNotEqual(len(bank_clearance.payment_entries), 0)
+ for payment in bank_clearance.payment_entries:
+ if payment.payment_entry == sales_invoice.name:
+ payment.clearance_date = date
+
+ bank_clearance.update_clearance_date()
+
+ si_clearance_date = frappe.db.get_value(
+ "Sales Invoice Payment",
+ {"parent": sales_invoice.name, "account": bank_clearance.account},
+ "clearance_date",
+ )
+
+ self.assertEqual(si_clearance_date, date)
+
def clear_payment_entries():
frappe.db.delete("Payment Entry")
+def clear_pos_sales_invoices():
+ frappe.db.delete("Sales Invoice", {"is_pos": 1})
+
+
@if_lending_app_installed
def clear_loan_transactions():
for dt in [
@@ -115,9 +158,45 @@ def add_transactions():
def make_payment_entry():
- pi = make_purchase_invoice(supplier="_Test Supplier", qty=1, rate=690)
+ from erpnext.buying.doctype.supplier.test_supplier import create_supplier
+
+ supplier = create_supplier(supplier_name="_Test Supplier")
+ pi = make_purchase_invoice(
+ supplier=supplier,
+ supplier_warehouse="_Test Warehouse - _TC",
+ expense_account="Cost of Goods Sold - _TC",
+ uom="Nos",
+ qty=1,
+ rate=690,
+ )
pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Bank Clearance - _TC")
pe.reference_no = "Conrad Oct 18"
pe.reference_date = "2018-10-24"
pe.insert()
pe.submit()
+
+
+def make_pos_sales_invoice():
+ from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
+ make_customer,
+ )
+
+ mode_of_payment = frappe.get_doc({"doctype": "Mode of Payment", "name": "Cash"})
+
+ 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": "_Test Bank Clearance - _TC"}
+ )
+ mode_of_payment.save()
+
+ customer = make_customer(customer="_Test Customer")
+
+ 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.insert()
+ si.submit()
+
+ return si
diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
index 4354f238a42..c13dbe445f1 100644
--- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
+++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
@@ -208,13 +208,17 @@ class BankTransaction(Document):
if self.party_type and self.party:
return
- result = AutoMatchParty(
- bank_party_account_number=self.bank_party_account_number,
- bank_party_iban=self.bank_party_iban,
- bank_party_name=self.bank_party_name,
- description=self.description,
- deposit=self.deposit,
- ).match()
+ result = None
+ try:
+ result = AutoMatchParty(
+ bank_party_account_number=self.bank_party_account_number,
+ bank_party_iban=self.bank_party_iban,
+ bank_party_name=self.bank_party_name,
+ description=self.description,
+ deposit=self.deposit,
+ ).match()
+ except Exception:
+ frappe.log_error(title=_("Error in party matching for Bank Transaction {0}").format(self.name))
if not result:
return
diff --git a/erpnext/accounts/doctype/cost_center_allocation/test_cost_center_allocation.py b/erpnext/accounts/doctype/cost_center_allocation/test_cost_center_allocation.py
index 65784dbb6c7..4abc82d8bec 100644
--- a/erpnext/accounts/doctype/cost_center_allocation/test_cost_center_allocation.py
+++ b/erpnext/accounts/doctype/cost_center_allocation/test_cost_center_allocation.py
@@ -22,8 +22,10 @@ class TestCostCenterAllocation(unittest.TestCase):
cost_centers = [
"Main Cost Center 1",
"Main Cost Center 2",
+ "Main Cost Center 3",
"Sub Cost Center 1",
"Sub Cost Center 2",
+ "Sub Cost Center 3",
]
for cc in cost_centers:
create_cost_center(cost_center_name=cc, company="_Test Company")
@@ -36,7 +38,7 @@ class TestCostCenterAllocation(unittest.TestCase):
)
jv = make_journal_entry(
- "_Test Cash - _TC", "Sales - _TC", 100, cost_center="Main Cost Center 1 - _TC", submit=True
+ "Cash - _TC", "Sales - _TC", 100, cost_center="Main Cost Center 1 - _TC", submit=True
)
expected_values = [["Sub Cost Center 1 - _TC", 0.0, 60], ["Sub Cost Center 2 - _TC", 0.0, 40]]
@@ -120,7 +122,7 @@ class TestCostCenterAllocation(unittest.TestCase):
def test_valid_from_based_on_existing_gle(self):
# GLE posted against Sub Cost Center 1 on today
jv = make_journal_entry(
- "_Test Cash - _TC",
+ "Cash - _TC",
"Sales - _TC",
100,
cost_center="Main Cost Center 1 - _TC",
@@ -141,6 +143,53 @@ class TestCostCenterAllocation(unittest.TestCase):
jv.cancel()
+ def test_multiple_cost_center_allocation_on_same_main_cost_center(self):
+ coa1 = create_cost_center_allocation(
+ "_Test Company",
+ "Main Cost Center 3 - _TC",
+ {"Sub Cost Center 1 - _TC": 30, "Sub Cost Center 2 - _TC": 30, "Sub Cost Center 3 - _TC": 40},
+ valid_from=add_days(today(), -5),
+ )
+
+ coa2 = create_cost_center_allocation(
+ "_Test Company",
+ "Main Cost Center 3 - _TC",
+ {"Sub Cost Center 1 - _TC": 50, "Sub Cost Center 2 - _TC": 50},
+ valid_from=add_days(today(), -1),
+ )
+
+ jv = make_journal_entry(
+ "Cash - _TC",
+ "Sales - _TC",
+ 100,
+ cost_center="Main Cost Center 3 - _TC",
+ posting_date=today(),
+ submit=True,
+ )
+
+ expected_values = {"Sub Cost Center 1 - _TC": 50, "Sub Cost Center 2 - _TC": 50}
+
+ gle = frappe.qb.DocType("GL Entry")
+ gl_entries = (
+ frappe.qb.from_(gle)
+ .select(gle.cost_center, gle.debit, gle.credit)
+ .where(gle.voucher_type == "Journal Entry")
+ .where(gle.voucher_no == jv.name)
+ .where(gle.account == "Sales - _TC")
+ .orderby(gle.cost_center)
+ ).run(as_dict=1)
+
+ self.assertTrue(gl_entries)
+
+ for gle in gl_entries:
+ self.assertTrue(gle.cost_center in expected_values)
+ self.assertEqual(gle.debit, 0)
+ self.assertEqual(gle.credit, expected_values[gle.cost_center])
+
+ coa1.cancel()
+ coa2.cancel()
+ jv.cancel()
+
def create_cost_center_allocation(
company,
diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py
index 8cbb99e9252..160e791978e 100644
--- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py
+++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py
@@ -109,7 +109,7 @@ def get_api_endpoint(service_provider: str | None = None, use_http: bool = False
if service_provider == "exchangerate.host":
api = "api.exchangerate.host/convert"
elif service_provider == "frankfurter.app":
- api = "frankfurter.app/{transaction_date}"
+ api = "api.frankfurter.app/{transaction_date}"
protocol = "https://"
if use_http:
diff --git a/erpnext/accounts/doctype/dunning/dunning.json b/erpnext/accounts/doctype/dunning/dunning.json
index b7e8aeaaafd..496097417ba 100644
--- a/erpnext/accounts/doctype/dunning/dunning.json
+++ b/erpnext/accounts/doctype/dunning/dunning.json
@@ -19,16 +19,6 @@
"currency",
"column_break_11",
"conversion_rate",
- "address_and_contact_section",
- "customer_address",
- "address_display",
- "contact_person",
- "contact_display",
- "column_break_16",
- "company_address",
- "company_address_display",
- "contact_mobile",
- "contact_email",
"section_break_6",
"dunning_type",
"column_break_8",
@@ -56,7 +46,21 @@
"income_account",
"column_break_48",
"cost_center",
- "amended_from"
+ "amended_from",
+ "address_and_contact_tab",
+ "address_and_contact_section",
+ "customer_address",
+ "address_display",
+ "column_break_vodj",
+ "contact_person",
+ "contact_display",
+ "contact_mobile",
+ "contact_email",
+ "section_break_xban",
+ "column_break_16",
+ "company_address",
+ "company_address_display",
+ "column_break_lqmf"
],
"fields": [
{
@@ -178,10 +182,8 @@
"label": "Rate of Interest (%) Yearly"
},
{
- "collapsible": 1,
"fieldname": "address_and_contact_section",
- "fieldtype": "Section Break",
- "label": "Address and Contact"
+ "fieldtype": "Section Break"
},
{
"fieldname": "address_display",
@@ -377,11 +379,28 @@
{
"fieldname": "column_break_48",
"fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "address_and_contact_tab",
+ "fieldtype": "Tab Break",
+ "label": "Address & Contact"
+ },
+ {
+ "fieldname": "column_break_vodj",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_xban",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break_lqmf",
+ "fieldtype": "Column Break"
}
],
"is_submittable": 1,
"links": [],
- "modified": "2023-06-15 15:46:53.865712",
+ "modified": "2024-11-26 13:46:07.760867",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Dunning",
@@ -435,4 +454,4 @@
"states": [],
"title_field": "customer_name",
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py
index ad58761e1bf..d63db3a09a1 100644
--- a/erpnext/accounts/doctype/dunning/dunning.py
+++ b/erpnext/accounts/doctype/dunning/dunning.py
@@ -210,19 +210,31 @@ def get_linked_dunnings_as_per_state(sales_invoice, state):
@frappe.whitelist()
-def get_dunning_letter_text(dunning_type, doc, language=None):
+def get_dunning_letter_text(dunning_type: str, doc: str | dict, language: str | None = None) -> dict:
+ DOCTYPE = "Dunning Letter Text"
+ FIELDS = ["body_text", "closing_text", "language"]
+
if isinstance(doc, str):
doc = json.loads(doc)
+
+ if not language:
+ language = doc.get("language")
+
if language:
- filters = {"parent": dunning_type, "language": language}
- else:
- filters = {"parent": dunning_type, "is_default_language": 1}
- letter_text = frappe.db.get_value(
- "Dunning Letter Text", filters, ["body_text", "closing_text", "language"], as_dict=1
- )
- if letter_text:
- return {
- "body_text": frappe.render_template(letter_text.body_text, doc),
- "closing_text": frappe.render_template(letter_text.closing_text, doc),
- "language": letter_text.language,
- }
+ letter_text = frappe.db.get_value(
+ DOCTYPE, {"parent": dunning_type, "language": language}, FIELDS, as_dict=1
+ )
+
+ if not letter_text:
+ letter_text = frappe.db.get_value(
+ DOCTYPE, {"parent": dunning_type, "is_default_language": 1}, FIELDS, as_dict=1
+ )
+
+ if not letter_text:
+ return {}
+
+ return {
+ "body_text": frappe.render_template(letter_text.body_text, doc),
+ "closing_text": frappe.render_template(letter_text.closing_text, doc),
+ "language": letter_text.language,
+ }
diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py
index 8607d1ed71f..c08bd3878d5 100644
--- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py
+++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py
@@ -74,6 +74,21 @@ class ExchangeRateRevaluation(Document):
if not (self.company and self.posting_date):
frappe.throw(_("Please select Company and Posting Date to getting entries"))
+ def before_submit(self):
+ self.remove_accounts_without_gain_loss()
+
+ def remove_accounts_without_gain_loss(self):
+ self.accounts = [account for account in self.accounts if account.gain_loss]
+
+ if not self.accounts:
+ frappe.throw(_("At least one account with exchange gain or loss is required"))
+
+ frappe.msgprint(
+ _("Removing rows without exchange gain or loss"),
+ alert=True,
+ indicator="yellow",
+ )
+
def on_cancel(self):
self.ignore_linked_doctypes = "GL Entry"
@@ -248,23 +263,23 @@ class ExchangeRateRevaluation(Document):
new_exchange_rate = get_exchange_rate(d.account_currency, company_currency, posting_date)
new_balance_in_base_currency = flt(d.balance_in_account_currency * new_exchange_rate)
gain_loss = flt(new_balance_in_base_currency, precision) - flt(d.balance, precision)
- if gain_loss:
- accounts.append(
- {
- "account": d.account,
- "party_type": d.party_type,
- "party": d.party,
- "account_currency": d.account_currency,
- "balance_in_base_currency": d.balance,
- "balance_in_account_currency": d.balance_in_account_currency,
- "zero_balance": d.zero_balance,
- "current_exchange_rate": current_exchange_rate,
- "new_exchange_rate": new_exchange_rate,
- "new_balance_in_base_currency": new_balance_in_base_currency,
- "new_balance_in_account_currency": d.balance_in_account_currency,
- "gain_loss": gain_loss,
- }
- )
+
+ accounts.append(
+ {
+ "account": d.account,
+ "party_type": d.party_type,
+ "party": d.party,
+ "account_currency": d.account_currency,
+ "balance_in_base_currency": d.balance,
+ "balance_in_account_currency": d.balance_in_account_currency,
+ "zero_balance": d.zero_balance,
+ "current_exchange_rate": current_exchange_rate,
+ "new_exchange_rate": new_exchange_rate,
+ "new_balance_in_base_currency": new_balance_in_base_currency,
+ "new_balance_in_account_currency": d.balance_in_account_currency,
+ "gain_loss": gain_loss,
+ }
+ )
# Handle Accounts with '0' balance in Account/Base Currency
for d in [x for x in account_details if x.zero_balance]:
@@ -288,23 +303,22 @@ class ExchangeRateRevaluation(Document):
current_exchange_rate * d.balance_in_account_currency
)
- if gain_loss:
- accounts.append(
- {
- "account": d.account,
- "party_type": d.party_type,
- "party": d.party,
- "account_currency": d.account_currency,
- "balance_in_base_currency": d.balance,
- "balance_in_account_currency": d.balance_in_account_currency,
- "zero_balance": d.zero_balance,
- "current_exchange_rate": current_exchange_rate,
- "new_exchange_rate": new_exchange_rate,
- "new_balance_in_base_currency": new_balance_in_base_currency,
- "new_balance_in_account_currency": new_balance_in_account_currency,
- "gain_loss": gain_loss,
- }
- )
+ accounts.append(
+ {
+ "account": d.account,
+ "party_type": d.party_type,
+ "party": d.party,
+ "account_currency": d.account_currency,
+ "balance_in_base_currency": d.balance,
+ "balance_in_account_currency": d.balance_in_account_currency,
+ "zero_balance": d.zero_balance,
+ "current_exchange_rate": current_exchange_rate,
+ "new_exchange_rate": new_exchange_rate,
+ "new_balance_in_base_currency": new_balance_in_base_currency,
+ "new_balance_in_account_currency": new_balance_in_account_currency,
+ "gain_loss": gain_loss,
+ }
+ )
return accounts
diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py
index 51053f1f68c..3eef6ab3832 100644
--- a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py
+++ b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py
@@ -188,7 +188,7 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
pe = get_payment_entry(si.doctype, si.name)
pe.paid_amount = 95
- pe.source_exchange_rate = 84.211
+ pe.source_exchange_rate = 84.2105
pe.received_amount = 8000
pe.references = []
pe.save().submit()
@@ -229,7 +229,7 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
row = next(x for x in je.accounts if x.account == self.debtors_usd)
self.assertEqual(flt(row.credit_in_account_currency, precision), 5.0) # in USD
row = next(x for x in je.accounts if x.account != self.debtors_usd)
- self.assertEqual(flt(row.debit_in_account_currency, precision), 421.06) # in INR
+ self.assertEqual(flt(row.debit_in_account_currency, precision), 421.05) # in INR
# total_debit and total_credit will be 0.0, as JV is posting only to account currency fields
self.assertEqual(flt(je.total_debit, precision), 0.0)
diff --git a/erpnext/accounts/doctype/fiscal_year/fiscal_year.js b/erpnext/accounts/doctype/fiscal_year/fiscal_year.js
index a44b52f08f8..aeb9f982b4d 100644
--- a/erpnext/accounts/doctype/fiscal_year/fiscal_year.js
+++ b/erpnext/accounts/doctype/fiscal_year/fiscal_year.js
@@ -4,10 +4,7 @@
frappe.ui.form.on("Fiscal Year", {
onload: function (frm) {
if (frm.doc.__islocal) {
- frm.set_value(
- "year_start_date",
- frappe.datetime.add_days(frappe.defaults.get_default("year_end_date"), 1)
- );
+ frm.set_value("year_start_date", frappe.datetime.year_start());
}
},
year_start_date: function (frm) {
diff --git a/erpnext/accounts/doctype/fiscal_year/fiscal_year.json b/erpnext/accounts/doctype/fiscal_year/fiscal_year.json
index 66db37fe13b..de8f0337a3d 100644
--- a/erpnext/accounts/doctype/fiscal_year/fiscal_year.json
+++ b/erpnext/accounts/doctype/fiscal_year/fiscal_year.json
@@ -72,10 +72,10 @@
},
{
"default": "0",
- "description": "Less than 12 months.",
+ "description": "More/Less than 12 months.",
"fieldname": "is_short_year",
"fieldtype": "Check",
- "label": "Is Short Year",
+ "label": "Is Short/Long Year",
"set_only_once": 1
}
],
diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.json b/erpnext/accounts/doctype/gl_entry/gl_entry.json
index 2d106ad8cee..c285a33f73e 100644
--- a/erpnext/accounts/doctype/gl_entry/gl_entry.json
+++ b/erpnext/accounts/doctype/gl_entry/gl_entry.json
@@ -6,38 +6,50 @@
"document_type": "Document",
"engine": "InnoDB",
"field_order": [
+ "dates_section",
"posting_date",
"transaction_date",
+ "column_break_avko",
+ "fiscal_year",
+ "due_date",
+ "account_details_section",
"account",
+ "account_currency",
+ "column_break_ifvf",
+ "against",
"party_type",
"party",
- "cost_center",
- "debit",
- "credit",
- "account_currency",
- "debit_in_account_currency",
- "credit_in_account_currency",
- "against",
+ "transaction_details_section",
+ "voucher_type",
+ "voucher_no",
+ "voucher_subtype",
+ "transaction_currency",
+ "column_break_dpsx",
"against_voucher_type",
"against_voucher",
- "voucher_type",
- "voucher_subtype",
- "voucher_no",
"voucher_detail_no",
+ "transaction_exchange_rate",
+ "amounts_section",
+ "debit_in_account_currency",
+ "debit",
+ "debit_in_transaction_currency",
+ "column_break_bm1w",
+ "credit_in_account_currency",
+ "credit",
+ "credit_in_transaction_currency",
+ "dimensions_section",
+ "cost_center",
+ "column_break_lmnm",
"project",
- "remarks",
+ "more_info_section",
+ "finance_book",
+ "company",
"is_opening",
"is_advance",
- "fiscal_year",
- "company",
- "finance_book",
+ "column_break_8abq",
"to_rename",
- "due_date",
"is_cancelled",
- "transaction_currency",
- "debit_in_transaction_currency",
- "credit_in_transaction_currency",
- "transaction_exchange_rate"
+ "remarks"
],
"fields": [
{
@@ -285,13 +297,67 @@
"fieldname": "voucher_subtype",
"fieldtype": "Small Text",
"label": "Voucher Subtype"
+ },
+ {
+ "fieldname": "dates_section",
+ "fieldtype": "Section Break",
+ "label": "Dates"
+ },
+ {
+ "fieldname": "column_break_avko",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "account_details_section",
+ "fieldtype": "Section Break",
+ "label": "Account Details"
+ },
+ {
+ "fieldname": "column_break_ifvf",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "transaction_details_section",
+ "fieldtype": "Section Break",
+ "label": "Transaction Details"
+ },
+ {
+ "fieldname": "amounts_section",
+ "fieldtype": "Section Break",
+ "label": "Amounts"
+ },
+ {
+ "fieldname": "column_break_dpsx",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "more_info_section",
+ "fieldtype": "Section Break",
+ "label": "More Info"
+ },
+ {
+ "fieldname": "column_break_bm1w",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "dimensions_section",
+ "fieldtype": "Section Break",
+ "label": "Dimensions"
+ },
+ {
+ "fieldname": "column_break_lmnm",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_8abq",
+ "fieldtype": "Column Break"
}
],
"icon": "fa fa-list",
"idx": 1,
"in_create": 1,
"links": [],
- "modified": "2024-07-02 14:31:51.496466",
+ "modified": "2024-08-22 13:03:39.997475",
"modified_by": "Administrator",
"module": "Accounts",
"name": "GL Entry",
diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py
index d74224c4aa2..a7e7edb098d 100644
--- a/erpnext/accounts/doctype/gl_entry/gl_entry.py
+++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py
@@ -430,8 +430,9 @@ def update_against_account(voucher_type, voucher_no):
def on_doctype_update():
- frappe.db.add_index("GL Entry", ["against_voucher_type", "against_voucher"])
frappe.db.add_index("GL Entry", ["voucher_type", "voucher_no"])
+ frappe.db.add_index("GL Entry", ["posting_date", "company"])
+ frappe.db.add_index("GL Entry", ["party_type", "party"])
def rename_gle_sle_docs():
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js
index d290d794df1..faa38763b80 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.js
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js
@@ -360,21 +360,23 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
accounts_add(doc, cdt, cdn) {
var row = frappe.get_doc(cdt, cdn);
+ row.exchange_rate = 1;
$.each(doc.accounts, function (i, d) {
if (d.account && d.party && d.party_type) {
row.account = d.account;
row.party = d.party;
row.party_type = d.party_type;
+ row.exchange_rate = d.exchange_rate;
}
});
// set difference
if (doc.difference) {
if (doc.difference > 0) {
- row.credit_in_account_currency = doc.difference;
+ row.credit_in_account_currency = doc.difference / row.exchange_rate;
row.credit = doc.difference;
} else {
- row.debit_in_account_currency = -doc.difference;
+ row.debit_in_account_currency = -doc.difference / row.exchange_rate;
row.debit = -doc.difference;
}
}
@@ -680,6 +682,7 @@ $.extend(erpnext.journal_entry, {
callback: function (r) {
if (r.message) {
$.extend(d, r.message);
+ erpnext.journal_entry.set_amount_on_last_row(frm, dt, dn);
erpnext.journal_entry.set_debit_credit_in_company_currency(frm, dt, dn);
refresh_field("accounts");
}
@@ -687,4 +690,26 @@ $.extend(erpnext.journal_entry, {
});
}
},
+ set_amount_on_last_row: function (frm, dt, dn) {
+ let row = locals[dt][dn];
+ let length = frm.doc.accounts.length;
+ if (row.idx != length) return;
+
+ let difference = frm.doc.accounts.reduce((total, row) => {
+ if (row.idx == length) return total;
+
+ return total + row.debit - row.credit;
+ }, 0);
+
+ if (difference) {
+ if (difference > 0) {
+ row.credit_in_account_currency = difference / row.exchange_rate;
+ row.credit = difference;
+ } else {
+ row.debit_in_account_currency = -difference / row.exchange_rate;
+ row.debit = -difference;
+ }
+ }
+ refresh_field("accounts");
+ },
});
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py
index 6adc8be3f7d..ef2388a7eaa 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.py
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py
@@ -127,9 +127,6 @@ class JournalEntry(AccountsController):
self.set_amounts_in_company_currency()
self.validate_debit_credit_amount()
self.set_total_debit_credit()
- # Do not validate while importing via data import
- if not frappe.flags.in_import:
- self.validate_total_debit_and_credit()
if not frappe.flags.is_reverse_depr_entry:
self.validate_against_jv()
@@ -184,10 +181,16 @@ class JournalEntry(AccountsController):
else:
return self._cancel()
+ def before_submit(self):
+ # Do not validate while importing via data import
+ if not frappe.flags.in_import:
+ self.validate_total_debit_and_credit()
+
def on_submit(self):
self.validate_cheque_info()
self.check_credit_limit()
self.make_gl_entries()
+ self.make_advance_payment_ledger_entries()
self.update_advance_paid()
self.update_asset_value()
self.update_inter_company_jv()
@@ -195,6 +198,11 @@ class JournalEntry(AccountsController):
self.update_booked_depreciation()
def on_update_after_submit(self):
+ # Flag will be set on Reconciliation
+ # Reconciliation tool will anyways repost ledger entries. So, no need to check and do implicit repost.
+ if self.flags.get("ignore_reposting_on_reconciliation"):
+ return
+
self.needs_repost = self.check_if_fields_updated(fields_to_check=[], child_tables={"accounts": []})
if self.needs_repost:
self.validate_for_repost()
@@ -213,8 +221,10 @@ class JournalEntry(AccountsController):
"Repost Accounting Ledger Items",
"Unreconcile Payment",
"Unreconcile Payment Entries",
+ "Advance Payment Ledger Entry",
)
self.make_gl_entries(1)
+ self.make_advance_payment_ledger_entries()
self.update_advance_paid()
self.unlink_advance_entry_reference()
self.unlink_asset_reference()
@@ -254,7 +264,7 @@ class JournalEntry(AccountsController):
frappe.throw(_("Journal Entry type should be set as Depreciation Entry for asset depreciation"))
def validate_stock_accounts(self):
- stock_accounts = get_stock_accounts(self.company, self.doctype, self.name)
+ stock_accounts = get_stock_accounts(self.company, accounts=self.accounts)
for account in stock_accounts:
account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(
account, self.posting_date, self.company
@@ -1663,6 +1673,8 @@ def make_reverse_journal_entry(source_name, target_doc=None):
"debit": "credit",
"credit_in_account_currency": "debit_in_account_currency",
"credit": "debit",
+ "reference_type": "reference_type",
+ "reference_name": "reference_name",
},
},
},
diff --git a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py
index c53faf9ff39..8f4c4e3ccda 100644
--- a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py
+++ b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py
@@ -515,6 +515,72 @@ class TestJournalEntry(unittest.TestCase):
self.assertEqual(row.debit_in_account_currency, 100)
self.assertEqual(row.credit_in_account_currency, 100)
+ def test_toggle_debit_credit_if_negative(self):
+ from erpnext.accounts.general_ledger import process_gl_map
+
+ # Create JV with defaut cost center - _Test Cost Center
+ frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 0)
+
+ jv = frappe.new_doc("Journal Entry")
+ jv.posting_date = nowdate()
+ jv.company = "_Test Company"
+ jv.user_remark = "test"
+ jv.extend(
+ "accounts",
+ [
+ {
+ "account": "_Test Cash - _TC",
+ "debit": 100 * -1,
+ "debit_in_account_currency": 100 * -1,
+ "exchange_rate": 1,
+ },
+ {
+ "account": "_Test Bank - _TC",
+ "credit": 100 * -1,
+ "credit_in_account_currency": 100 * -1,
+ "exchange_rate": 1,
+ },
+ ],
+ )
+
+ jv.flags.ignore_validate = True
+ jv.save()
+
+ self.assertEqual(len(jv.accounts), 2)
+
+ gl_map = jv.build_gl_map()
+
+ for row in gl_map:
+ if row.account == "_Test Cash - _TC":
+ self.assertEqual(row.debit, 100 * -1)
+ self.assertEqual(row.debit_in_account_currency, 100 * -1)
+ self.assertEqual(row.debit_in_transaction_currency, 100 * -1)
+
+ gl_map = process_gl_map(gl_map, False)
+
+ for row in gl_map:
+ if row.account == "_Test Cash - _TC":
+ self.assertEqual(row.credit, 100)
+ self.assertEqual(row.credit_in_account_currency, 100)
+ self.assertEqual(row.credit_in_transaction_currency, 100)
+
+ def test_transaction_exchange_rate_on_journals(self):
+ jv = make_journal_entry("_Test Bank - _TC", "_Test Receivable USD - _TC", 100, save=False)
+ jv.accounts[0].update({"debit_in_account_currency": 8500, "exchange_rate": 1})
+ jv.accounts[1].update({"party_type": "Customer", "party": "_Test Customer USD", "exchange_rate": 85})
+ jv.submit()
+ actual = frappe.db.get_all(
+ "GL Entry",
+ filters={"voucher_no": jv.name, "is_cancelled": 0},
+ fields=["account", "transaction_exchange_rate"],
+ order_by="account",
+ )
+ expected = [
+ {"account": "_Test Bank - _TC", "transaction_exchange_rate": 1.0},
+ {"account": "_Test Receivable USD - _TC", "transaction_exchange_rate": 85.0},
+ ]
+ self.assertEqual(expected, actual)
+
def make_journal_entry(
account1,
diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js
index f1efba8a954..4938e6690e5 100644
--- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js
+++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js
@@ -28,7 +28,12 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
frm.refresh_fields();
frm.page.clear_indicator();
frm.dashboard.hide_progress();
- frappe.msgprint(__("Opening {0} Invoices created", [frm.doc.invoice_type]));
+
+ if (frm.doc.invoice_type == "Sales") {
+ frappe.msgprint(__("Opening Sales Invoices have been created."));
+ } else {
+ frappe.msgprint(__("Opening Purchase Invoices have been created."));
+ }
},
1500,
data.title
@@ -48,12 +53,19 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
!frm.doc.import_in_progress && frm.trigger("make_dashboard");
frm.page.set_primary_action(__("Create Invoices"), () => {
let btn_primary = frm.page.btn_primary.get(0);
+ let freeze_message;
+ if (frm.doc.invoice_type == "Sales") {
+ freeze_message = __("Creating Sales Invoices ...");
+ } else {
+ freeze_message = __("Creating Purchase Invoices ...");
+ }
+
return frm.call({
doc: frm.doc,
btn: $(btn_primary),
method: "make_invoices",
freeze: 1,
- freeze_message: __("Creating {0} Invoice", [frm.doc.invoice_type]),
+ freeze_message: freeze_message,
});
});
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js
index c28dcf525df..f2d11ba9ff3 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.js
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js
@@ -26,6 +26,10 @@ frappe.ui.form.on("Payment Entry", {
}
erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
+
+ if (frm.is_new()) {
+ set_default_party_type(frm);
+ }
},
setup: function (frm) {
@@ -35,6 +39,11 @@ frappe.ui.form.on("Payment Entry", {
var account_types = ["Pay", "Internal Transfer"].includes(frm.doc.payment_type)
? ["Bank", "Cash"]
: [frappe.boot.party_account_types[frm.doc.party_type]];
+
+ if (frm.doc.party_type == "Shareholder") {
+ account_types.push("Equity");
+ }
+
return {
filters: {
account_type: ["in", account_types],
@@ -90,6 +99,9 @@ frappe.ui.form.on("Payment Entry", {
var account_types = ["Receive", "Internal Transfer"].includes(frm.doc.payment_type)
? ["Bank", "Cash"]
: [frappe.boot.party_account_types[frm.doc.party_type]];
+ if (frm.doc.party_type == "Shareholder") {
+ account_types.push("Equity");
+ }
return {
filters: {
account_type: ["in", account_types],
@@ -166,6 +178,21 @@ frappe.ui.form.on("Payment Entry", {
};
});
+ frm.set_query("payment_request", "references", function (doc, cdt, cdn) {
+ const row = frappe.get_doc(cdt, cdn);
+ return {
+ query: "erpnext.accounts.doctype.payment_request.payment_request.get_open_payment_requests_query",
+ filters: {
+ reference_doctype: row.reference_doctype,
+ reference_name: row.reference_name,
+ company: doc.company,
+ status: ["!=", "Paid"],
+ outstanding_amount: [">", 0], // for compatibility with old data
+ docstatus: 1,
+ },
+ };
+ });
+
frm.set_query("sales_taxes_and_charges_template", function () {
return {
filters: {
@@ -183,7 +210,15 @@ frappe.ui.form.on("Payment Entry", {
},
};
});
+
+ frm.add_fetch(
+ "payment_request",
+ "outstanding_amount",
+ "payment_request_outstanding",
+ "Payment Entry Reference"
+ );
},
+
refresh: function (frm) {
erpnext.hide_company(frm);
frm.events.hide_unhide_fields(frm);
@@ -208,6 +243,7 @@ frappe.ui.form.on("Payment Entry", {
);
}
erpnext.accounts.unreconcile_payment.add_unreconcile_btn(frm);
+ frappe.flags.allocate_payment_amount = true;
},
validate_company: (frm) => {
@@ -288,16 +324,11 @@ frappe.ui.form.on("Payment Entry", {
"write_off_difference_amount",
frm.doc.difference_amount && frm.doc.party && frm.doc.total_allocated_amount > party_amount
);
-
- frm.toggle_display(
- "set_exchange_gain_loss",
- frm.doc.paid_amount && frm.doc.received_amount && frm.doc.difference_amount
- );
},
set_dynamic_labels: function (frm) {
var company_currency = frm.doc.company
- ? frappe.get_doc(":Company", frm.doc.company).default_currency
+ ? frappe.get_doc(":Company", frm.doc.company)?.default_currency
: "";
frm.set_currency_labels(
@@ -375,9 +406,19 @@ frappe.ui.form.on("Payment Entry", {
},
payment_type: function (frm) {
+ set_default_party_type(frm);
+
if (frm.doc.payment_type == "Internal Transfer") {
$.each(
- ["party", "party_balance", "paid_from", "paid_to", "references", "total_allocated_amount"],
+ [
+ "party",
+ "party_type",
+ "party_balance",
+ "paid_from",
+ "paid_to",
+ "references",
+ "total_allocated_amount",
+ ],
function (i, field) {
frm.set_value(field, null);
}
@@ -412,6 +453,12 @@ frappe.ui.form.on("Payment Entry", {
return {
query: "erpnext.controllers.queries.employee_query",
};
+ } else if (frm.doc.party_type == "Shareholder") {
+ return {
+ filters: {
+ company: frm.doc.company,
+ },
+ };
}
});
@@ -644,7 +691,7 @@ frappe.ui.form.on("Payment Entry", {
frm.set_value("source_exchange_rate", 1);
} else if (frm.doc.paid_from) {
if (["Internal Transfer", "Pay"].includes(frm.doc.payment_type)) {
- let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
+ let company_currency = frappe.get_doc(":Company", frm.doc.company)?.default_currency;
frappe.call({
method: "erpnext.setup.utils.get_exchange_rate",
args: {
@@ -775,7 +822,7 @@ frappe.ui.form.on("Payment Entry", {
);
if (frm.doc.payment_type == "Pay")
- frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.received_amount, 1);
+ frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.received_amount, true);
else frm.events.set_unallocated_amount(frm);
frm.set_paid_amount_based_on_received_amount = false;
@@ -796,7 +843,7 @@ frappe.ui.form.on("Payment Entry", {
}
if (frm.doc.payment_type == "Receive")
- frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.paid_amount, 1);
+ frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.paid_amount, true);
else frm.events.set_unallocated_amount(frm);
},
@@ -967,6 +1014,7 @@ frappe.ui.form.on("Payment Entry", {
c.outstanding_amount = d.outstanding_amount;
c.bill_no = d.bill_no;
c.payment_term = d.payment_term;
+ c.payment_term_outstanding = d.payment_term_outstanding;
c.allocated_amount = d.allocated_amount;
c.account = d.account;
@@ -1016,7 +1064,8 @@ frappe.ui.form.on("Payment Entry", {
frm.events.allocate_party_amount_against_ref_docs(
frm,
- frm.doc.payment_type == "Receive" ? frm.doc.paid_amount : frm.doc.received_amount
+ frm.doc.payment_type == "Receive" ? frm.doc.paid_amount : frm.doc.received_amount,
+ false
);
},
});
@@ -1030,93 +1079,13 @@ frappe.ui.form.on("Payment Entry", {
return ["Sales Invoice", "Purchase Invoice"];
},
- allocate_party_amount_against_ref_docs: function (frm, paid_amount, paid_amount_change) {
- var total_positive_outstanding_including_order = 0;
- var total_negative_outstanding = 0;
- var total_deductions = frappe.utils.sum(
- $.map(frm.doc.deductions || [], function (d) {
- return flt(d.amount);
- })
- );
-
- paid_amount -= total_deductions;
-
- $.each(frm.doc.references || [], function (i, row) {
- if (flt(row.outstanding_amount) > 0)
- total_positive_outstanding_including_order += flt(row.outstanding_amount);
- else total_negative_outstanding += Math.abs(flt(row.outstanding_amount));
+ allocate_party_amount_against_ref_docs: async function (frm, paid_amount, paid_amount_change) {
+ await frm.call("allocate_amount_to_references", {
+ paid_amount: paid_amount,
+ paid_amount_change: paid_amount_change,
+ allocate_payment_amount: frappe.flags.allocate_payment_amount ?? false,
});
- var allocated_negative_outstanding = 0;
- if (
- (frm.doc.payment_type == "Receive" && frm.doc.party_type == "Customer") ||
- (frm.doc.payment_type == "Pay" && frm.doc.party_type == "Supplier") ||
- (frm.doc.payment_type == "Pay" && frm.doc.party_type == "Employee")
- ) {
- if (total_positive_outstanding_including_order > paid_amount) {
- var remaining_outstanding = total_positive_outstanding_including_order - paid_amount;
- allocated_negative_outstanding =
- total_negative_outstanding < remaining_outstanding
- ? total_negative_outstanding
- : remaining_outstanding;
- }
-
- var allocated_positive_outstanding = paid_amount + allocated_negative_outstanding;
- } else if (["Customer", "Supplier"].includes(frm.doc.party_type)) {
- total_negative_outstanding = flt(total_negative_outstanding, precision("outstanding_amount"));
- if (paid_amount > total_negative_outstanding) {
- if (total_negative_outstanding == 0) {
- frappe.msgprint(
- __("Cannot {0} {1} {2} without any negative outstanding invoice", [
- frm.doc.payment_type,
- frm.doc.party_type == "Customer" ? "to" : "from",
- frm.doc.party_type,
- ])
- );
- return false;
- } else {
- frappe.msgprint(
- __("Paid Amount cannot be greater than total negative outstanding amount {0}", [
- total_negative_outstanding,
- ])
- );
- return false;
- }
- } else {
- allocated_positive_outstanding = total_negative_outstanding - paid_amount;
- allocated_negative_outstanding =
- paid_amount +
- (total_positive_outstanding_including_order < allocated_positive_outstanding
- ? total_positive_outstanding_including_order
- : allocated_positive_outstanding);
- }
- }
-
- $.each(frm.doc.references || [], function (i, row) {
- if (frappe.flags.allocate_payment_amount == 0) {
- //If allocate payment amount checkbox is unchecked, set zero to allocate amount
- row.allocated_amount = 0;
- } else if (
- frappe.flags.allocate_payment_amount != 0 &&
- (!row.allocated_amount || paid_amount_change)
- ) {
- if (row.outstanding_amount > 0 && allocated_positive_outstanding >= 0) {
- row.allocated_amount =
- row.outstanding_amount >= allocated_positive_outstanding
- ? allocated_positive_outstanding
- : row.outstanding_amount;
- allocated_positive_outstanding -= flt(row.allocated_amount);
- } else if (row.outstanding_amount < 0 && allocated_negative_outstanding) {
- row.allocated_amount =
- Math.abs(row.outstanding_amount) >= allocated_negative_outstanding
- ? -1 * allocated_negative_outstanding
- : row.outstanding_amount;
- allocated_negative_outstanding -= Math.abs(flt(row.allocated_amount));
- }
- }
- });
-
- frm.refresh_fields();
frm.events.set_total_allocated_amount(frm);
},
@@ -1145,36 +1114,34 @@ frappe.ui.form.on("Payment Entry", {
},
set_unallocated_amount: function (frm) {
- var unallocated_amount = 0;
- var total_deductions = frappe.utils.sum(
- $.map(frm.doc.deductions || [], function (d) {
- return flt(d.amount);
- })
- );
+ let unallocated_amount = 0;
+ let deductions_to_consider = 0;
+
+ for (const row of frm.doc.deductions || []) {
+ if (!row.is_exchange_gain_loss) deductions_to_consider += flt(row.amount);
+ }
+ const included_taxes = get_included_taxes(frm);
if (frm.doc.party) {
if (
frm.doc.payment_type == "Receive" &&
- frm.doc.base_total_allocated_amount < frm.doc.base_received_amount + total_deductions &&
- frm.doc.total_allocated_amount <
- frm.doc.paid_amount + total_deductions / frm.doc.source_exchange_rate
- ) {
- unallocated_amount =
- (frm.doc.base_received_amount +
- total_deductions -
- flt(frm.doc.base_total_taxes_and_charges) -
- frm.doc.base_total_allocated_amount) /
- frm.doc.source_exchange_rate;
- } else if (
- frm.doc.payment_type == "Pay" &&
- frm.doc.base_total_allocated_amount < frm.doc.base_paid_amount - total_deductions &&
- frm.doc.total_allocated_amount <
- frm.doc.received_amount + total_deductions / frm.doc.target_exchange_rate
+ frm.doc.base_total_allocated_amount < frm.doc.base_paid_amount + deductions_to_consider
) {
unallocated_amount =
(frm.doc.base_paid_amount +
- flt(frm.doc.base_total_taxes_and_charges) -
- (total_deductions + frm.doc.base_total_allocated_amount)) /
+ deductions_to_consider -
+ frm.doc.base_total_allocated_amount -
+ included_taxes) /
+ frm.doc.source_exchange_rate;
+ } else if (
+ frm.doc.payment_type == "Pay" &&
+ frm.doc.base_total_allocated_amount < frm.doc.base_received_amount - deductions_to_consider
+ ) {
+ unallocated_amount =
+ (frm.doc.base_received_amount -
+ deductions_to_consider -
+ frm.doc.base_total_allocated_amount -
+ included_taxes) /
frm.doc.target_exchange_rate;
}
}
@@ -1268,77 +1235,85 @@ frappe.ui.form.on("Payment Entry", {
},
write_off_difference_amount: function (frm) {
- frm.events.set_deductions_entry(frm, "write_off_account");
+ frm.events.set_write_off_deduction(frm);
},
- set_exchange_gain_loss: function (frm) {
- frm.events.set_deductions_entry(frm, "exchange_gain_loss_account");
+ base_paid_amount: function (frm) {
+ frm.events.set_exchange_gain_loss_deduction(frm);
},
- set_deductions_entry: function (frm, account) {
- if (frm.doc.difference_amount) {
- frappe.call({
- method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_company_defaults",
- args: {
- company: frm.doc.company,
- },
- callback: function (r, rt) {
- if (r.message) {
- const write_off_row = $.map(frm.doc["deductions"] || [], function (t) {
- return t.account == r.message[account] ? t : null;
- });
+ base_received_amount: function (frm) {
+ frm.events.set_exchange_gain_loss_deduction(frm);
+ },
- const difference_amount = flt(
- frm.doc.difference_amount,
- precision("difference_amount")
- );
+ set_exchange_gain_loss_deduction: async function (frm) {
+ // wait for allocate_party_amount_against_ref_docs to finish
+ await frappe.after_ajax();
+ const base_paid_amount = frm.doc.base_paid_amount || 0;
+ const base_received_amount = frm.doc.base_received_amount || 0;
+ const exchange_gain_loss = flt(
+ base_paid_amount - base_received_amount,
+ get_deduction_amount_precision()
+ );
- const add_deductions = (details) => {
- let row = null;
- if (!write_off_row.length && difference_amount) {
- row = frm.add_child("deductions");
- row.account = details[account];
- row.cost_center = details["cost_center"];
- } else {
- row = write_off_row[0];
- }
-
- if (row) {
- row.amount = flt(row.amount) + difference_amount;
- } else {
- frappe.msgprint(__("No gain or loss in the exchange rate"));
- }
- refresh_field("deductions");
- };
-
- if (!r.message[account]) {
- frappe.prompt(
- {
- label: __("Please Specify Account"),
- fieldname: account,
- fieldtype: "Link",
- options: "Account",
- get_query: () => ({
- filters: {
- company: frm.doc.company,
- },
- }),
- },
- (values) => {
- const details = Object.assign({}, r.message, values);
- add_deductions(details);
- },
- __(frappe.unscrub(account))
- );
- } else {
- add_deductions(r.message);
- }
-
- frm.events.set_unallocated_amount(frm);
- }
- },
- });
+ if (!exchange_gain_loss) {
+ frm.events.delete_exchange_gain_loss(frm);
+ return;
}
+
+ const account_fieldname = "exchange_gain_loss_account";
+ let row = (frm.doc.deductions || []).find((t) => t.is_exchange_gain_loss);
+
+ if (!row) {
+ const response = await get_company_defaults(frm.doc.company);
+
+ const account =
+ response.message?.[account_fieldname] ||
+ (await prompt_for_missing_account(frm, account_fieldname));
+
+ row = frm.add_child("deductions");
+ row.account = account;
+ row.cost_center = response.message?.cost_center;
+ row.is_exchange_gain_loss = 1;
+ }
+
+ row.amount = exchange_gain_loss;
+ frm.refresh_field("deductions");
+ frm.events.set_unallocated_amount(frm);
+ },
+
+ delete_exchange_gain_loss: function (frm) {
+ const exchange_gain_loss_row = (frm.doc.deductions || []).find((row) => row.is_exchange_gain_loss);
+
+ if (!exchange_gain_loss_row) return;
+
+ exchange_gain_loss_row.amount = 0;
+ frm.get_field("deductions").grid.grid_rows[exchange_gain_loss_row.idx - 1].remove();
+ frm.refresh_field("deductions");
+ },
+
+ set_write_off_deduction: async function (frm) {
+ const difference_amount = flt(frm.doc.difference_amount, get_deduction_amount_precision());
+ if (!difference_amount) return;
+
+ const account_fieldname = "write_off_account";
+ const response = await get_company_defaults(frm.doc.company);
+ const write_off_account =
+ response.message?.[account_fieldname] ||
+ (await prompt_for_missing_account(frm, account_fieldname));
+
+ if (!write_off_account) return;
+
+ let row = (frm.doc["deductions"] || []).find((t) => t.account == write_off_account);
+ if (!row) {
+ row = frm.add_child("deductions");
+ row.account = write_off_account;
+ row.cost_center = response.message?.cost_center;
+ }
+
+ row.amount = flt(row.amount) + difference_amount;
+ frm.refresh_field("deductions");
+ frm.events.set_unallocated_amount(frm);
},
bank_account: function (frm) {
@@ -1664,6 +1639,62 @@ frappe.ui.form.on("Payment Entry", {
return current_tax_amount;
},
+
+ cost_center: function (frm) {
+ if (frm.doc.posting_date && (frm.doc.paid_from || frm.doc.paid_to)) {
+ return frappe.call({
+ method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_party_and_account_balance",
+ args: {
+ company: frm.doc.company,
+ date: frm.doc.posting_date,
+ paid_from: frm.doc.paid_from,
+ paid_to: frm.doc.paid_to,
+ ptype: frm.doc.party_type,
+ pty: frm.doc.party,
+ cost_center: frm.doc.cost_center,
+ },
+ callback: function (r, rt) {
+ if (r.message) {
+ frappe.run_serially([
+ () => {
+ frm.set_value(
+ "paid_from_account_balance",
+ r.message.paid_from_account_balance
+ );
+ frm.set_value("paid_to_account_balance", r.message.paid_to_account_balance);
+ frm.set_value("party_balance", r.message.party_balance);
+ },
+ ]);
+ }
+ },
+ });
+ }
+ },
+
+ after_save: function (frm) {
+ const { matched_payment_requests } = frappe.last_response;
+ if (!matched_payment_requests) return;
+
+ const COLUMN_LABEL = [
+ [__("Reference DocType"), __("Reference Name"), __("Allocated Amount"), __("Payment Request")],
+ ];
+
+ frappe.msgprint({
+ title: __("Unset Matched Payment Request"),
+ message: COLUMN_LABEL.concat(matched_payment_requests),
+ as_table: true,
+ wide: true,
+ primary_action: {
+ label: __("Allocate Payment Request"),
+ action() {
+ frappe.hide_msgprint();
+ frm.call("set_matched_payment_requests", { matched_payment_requests }, () => {
+ frm.dirty();
+ });
+ },
+ },
+ });
+ },
});
frappe.ui.form.on("Payment Entry Reference", {
@@ -1748,6 +1779,13 @@ frappe.ui.form.on("Advance Taxes and Charges", {
});
frappe.ui.form.on("Payment Entry Deduction", {
+ before_deductions_remove: function (doc, cdt, cdn) {
+ const row = frappe.get_doc(cdt, cdn);
+ if (row.is_exchange_gain_loss && row.amount) {
+ frappe.throw(__("Cannot delete Exchange Gain/Loss row"));
+ }
+ },
+
amount: function (frm) {
frm.events.set_unallocated_amount(frm);
},
@@ -1756,35 +1794,66 @@ frappe.ui.form.on("Payment Entry Deduction", {
frm.events.set_unallocated_amount(frm);
},
});
-frappe.ui.form.on("Payment Entry", {
- cost_center: function (frm) {
- if (frm.doc.posting_date && (frm.doc.paid_from || frm.doc.paid_to)) {
- return frappe.call({
- method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_party_and_account_balance",
- args: {
- company: frm.doc.company,
- date: frm.doc.posting_date,
- paid_from: frm.doc.paid_from,
- paid_to: frm.doc.paid_to,
- ptype: frm.doc.party_type,
- pty: frm.doc.party,
- cost_center: frm.doc.cost_center,
- },
- callback: function (r, rt) {
- if (r.message) {
- frappe.run_serially([
- () => {
- frm.set_value(
- "paid_from_account_balance",
- r.message.paid_from_account_balance
- );
- frm.set_value("paid_to_account_balance", r.message.paid_to_account_balance);
- frm.set_value("party_balance", r.message.party_balance);
- },
- ]);
- }
- },
- });
+
+function set_default_party_type(frm) {
+ if (frm.doc.party) return;
+
+ let party_type;
+ if (frm.doc.payment_type == "Receive") {
+ party_type = "Customer";
+ } else if (frm.doc.payment_type == "Pay") {
+ party_type = "Supplier";
+ }
+
+ if (party_type) frm.set_value("party_type", party_type);
+}
+
+function get_included_taxes(frm) {
+ let included_taxes = 0;
+ for (const tax of frm.doc.taxes) {
+ if (!tax.included_in_paid_amount) continue;
+
+ if (tax.add_deduct_tax == "Add") {
+ included_taxes += tax.base_tax_amount;
+ } else {
+ included_taxes -= tax.base_tax_amount;
}
- },
-});
+ }
+
+ return included_taxes;
+}
+
+function get_company_defaults(company) {
+ return frappe.call({
+ method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_company_defaults",
+ args: {
+ company: company,
+ },
+ });
+}
+
+function prompt_for_missing_account(frm, account) {
+ return new Promise((resolve) => {
+ const dialog = frappe.prompt(
+ {
+ label: __(frappe.unscrub(account)),
+ fieldname: account,
+ fieldtype: "Link",
+ options: "Account",
+ get_query: () => ({
+ filters: {
+ company: frm.doc.company,
+ },
+ }),
+ },
+ (values) => resolve(values?.[account]),
+ __("Please Specify Account")
+ );
+
+ dialog.on_hide = () => resolve("");
+ });
+}
+
+function get_deduction_amount_precision() {
+ return frappe.meta.get_field_precision(frappe.meta.get_field("Payment Entry Deduction", "amount"));
+}
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.json b/erpnext/accounts/doctype/payment_entry/payment_entry.json
index d420bcca342..69debbec5c7 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.json
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.json
@@ -56,7 +56,6 @@
"section_break_34",
"total_allocated_amount",
"base_total_allocated_amount",
- "set_exchange_gain_loss",
"column_break_36",
"unallocated_amount",
"difference_amount",
@@ -390,11 +389,6 @@
"print_hide": 1,
"read_only": 1
},
- {
- "fieldname": "set_exchange_gain_loss",
- "fieldtype": "Button",
- "label": "Set Exchange Gain / Loss"
- },
{
"fieldname": "column_break_36",
"fieldtype": "Column Break"
@@ -801,7 +795,7 @@
"table_fieldname": "payment_entries"
}
],
- "modified": "2024-05-31 17:07:06.197249",
+ "modified": "2024-11-07 11:19:19.320883",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry",
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index 454101027e7..7e3d8a5833b 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -7,8 +7,10 @@ from functools import reduce
import frappe
from frappe import ValidationError, _, qb, scrub, throw
+from frappe.query_builder import Tuple
+from frappe.query_builder.functions import Count
from frappe.utils import cint, comma_or, flt, getdate, nowdate
-from frappe.utils.data import comma_and, fmt_money
+from frappe.utils.data import comma_and, fmt_money, get_link_to_form
from pypika import Case
from pypika.functions import Coalesce, Sum
@@ -98,13 +100,18 @@ class PaymentEntry(AccountsController):
self.set_status()
self.set_total_in_words()
+ def before_save(self):
+ self.set_matched_unset_payment_requests_to_response()
+
def on_submit(self):
if self.difference_amount:
frappe.throw(_("Difference Amount must be zero"))
self.make_gl_entries()
self.update_outstanding_amounts()
- self.update_advance_paid()
self.update_payment_schedule()
+ self.update_payment_requests()
+ self.make_advance_payment_ledger_entries()
+ self.update_advance_paid() # advance_paid_status depends on the payment request amount
self.set_status()
def set_liability_account(self):
@@ -145,9 +152,21 @@ class PaymentEntry(AccountsController):
self.is_opening = "No"
return
- liability_account = get_party_account(
- self.party_type, self.party, self.company, include_advance=True
- )[1]
+ accounts = get_party_account(self.party_type, self.party, self.company, include_advance=True)
+
+ liability_account = accounts[1] if len(accounts) > 1 else None
+ fieldname = (
+ "default_advance_received_account"
+ if self.party_type == "Customer"
+ else "default_advance_paid_account"
+ )
+
+ if not liability_account:
+ throw(
+ _("Please set default {0} in Company {1}").format(
+ frappe.bold(frappe.get_meta("Company").get_label(fieldname)), frappe.bold(self.company)
+ )
+ )
self.set(self.party_account_field, liability_account)
@@ -172,34 +191,40 @@ class PaymentEntry(AccountsController):
"Repost Accounting Ledger Items",
"Unreconcile Payment",
"Unreconcile Payment Entries",
+ "Advance Payment Ledger Entry",
)
super().on_cancel()
self.make_gl_entries(cancel=1)
self.update_outstanding_amounts()
- self.update_advance_paid()
self.delink_advance_entry_references()
self.update_payment_schedule(cancel=1)
- self.set_payment_req_status()
+ self.update_payment_requests(cancel=True)
+ self.make_advance_payment_ledger_entries()
+ self.update_advance_paid() # advance_paid_status depends on the payment request amount
self.set_status()
- def set_payment_req_status(self):
- from erpnext.accounts.doctype.payment_request.payment_request import update_payment_req_status
+ def update_payment_requests(self, cancel=False):
+ from erpnext.accounts.doctype.payment_request.payment_request import (
+ update_payment_requests_as_per_pe_references,
+ )
- update_payment_req_status(self, None)
+ update_payment_requests_as_per_pe_references(self.references, cancel=cancel)
def update_outstanding_amounts(self):
self.set_missing_ref_details(force=True)
def validate_duplicate_entry(self):
- reference_names = []
+ reference_names = set()
for d in self.get("references"):
- if (d.reference_doctype, d.reference_name, d.payment_term) in reference_names:
+ key = (d.reference_doctype, d.reference_name, d.payment_term, d.payment_request)
+ if key in reference_names:
frappe.throw(
_("Row #{0}: Duplicate entry in References {1} {2}").format(
d.idx, d.reference_doctype, d.reference_name
)
)
- reference_names.append((d.reference_doctype, d.reference_name, d.payment_term))
+
+ reference_names.add(key)
def set_bank_account_data(self):
if self.bank_account:
@@ -225,6 +250,8 @@ class PaymentEntry(AccountsController):
if self.payment_type == "Internal Transfer":
return
+ self.validate_allocated_amount_as_per_payment_request()
+
if self.party_type in ("Customer", "Supplier"):
self.validate_allocated_amount_with_latest_data()
else:
@@ -237,6 +264,27 @@ class PaymentEntry(AccountsController):
if flt(d.allocated_amount) < 0 and flt(d.allocated_amount) < flt(d.outstanding_amount):
frappe.throw(fail_message.format(d.idx))
+ def validate_allocated_amount_as_per_payment_request(self):
+ """
+ Allocated amount should not be greater than the outstanding amount of the Payment Request.
+ """
+ if not self.references:
+ return
+
+ pr_outstanding_amounts = get_payment_request_outstanding_set_in_references(self.references)
+
+ if not pr_outstanding_amounts:
+ return
+
+ for ref in self.references:
+ if ref.payment_request and ref.allocated_amount > pr_outstanding_amounts[ref.payment_request]:
+ frappe.throw(
+ msg=_(
+ "Row #{0}: Allocated Amount cannot be greater than Outstanding Amount of Payment Request {1}"
+ ).format(ref.idx, get_link_to_form("Payment Request", ref.payment_request)),
+ title=_("Invalid Allocated Amount"),
+ )
+
def term_based_allocation_enabled_for_reference(
self, reference_doctype: str, reference_name: str
) -> bool:
@@ -845,6 +893,7 @@ class PaymentEntry(AccountsController):
self.set_amounts_in_company_currency()
self.set_total_allocated_amount()
self.set_unallocated_amount()
+ self.set_exchange_gain_loss()
self.set_difference_amount()
def validate_amounts(self):
@@ -940,10 +989,10 @@ class PaymentEntry(AccountsController):
if d.exchange_rate is None:
d.exchange_rate = 1
- allocated_amount_in_pe_exchange_rate = flt(
+ allocated_amount_in_ref_exchange_rate = flt(
flt(d.allocated_amount) * flt(d.exchange_rate), self.precision("base_paid_amount")
)
- d.exchange_gain_loss = base_allocated_amount - allocated_amount_in_pe_exchange_rate
+ d.exchange_gain_loss = base_allocated_amount - allocated_amount_in_ref_exchange_rate
return base_allocated_amount
def set_total_allocated_amount(self):
@@ -961,29 +1010,80 @@ class PaymentEntry(AccountsController):
def set_unallocated_amount(self):
self.unallocated_amount = 0
- if self.party:
- total_deductions = sum(flt(d.amount) for d in self.get("deductions"))
- included_taxes = self.get_included_taxes()
- if (
- self.payment_type == "Receive"
- and self.base_total_allocated_amount < self.base_received_amount + total_deductions
- and self.total_allocated_amount
- < flt(self.paid_amount) + (total_deductions / self.source_exchange_rate)
- ):
- self.unallocated_amount = (
- self.base_received_amount + total_deductions - self.base_total_allocated_amount
- ) / self.source_exchange_rate
- self.unallocated_amount -= included_taxes
- elif (
- self.payment_type == "Pay"
- and self.base_total_allocated_amount < (self.base_paid_amount - total_deductions)
- and self.total_allocated_amount
- < flt(self.received_amount) + (total_deductions / self.target_exchange_rate)
- ):
- self.unallocated_amount = (
- self.base_paid_amount - (total_deductions + self.base_total_allocated_amount)
- ) / self.target_exchange_rate
- self.unallocated_amount -= included_taxes
+ if not self.party:
+ return
+
+ deductions_to_consider = sum(
+ flt(d.amount) for d in self.get("deductions") if not d.is_exchange_gain_loss
+ )
+ included_taxes = self.get_included_taxes()
+
+ if self.payment_type == "Receive" and self.base_total_allocated_amount < (
+ self.base_paid_amount + deductions_to_consider
+ ):
+ self.unallocated_amount = (
+ self.base_paid_amount
+ + deductions_to_consider
+ - self.base_total_allocated_amount
+ - included_taxes
+ ) / self.source_exchange_rate
+ elif self.payment_type == "Pay" and self.base_total_allocated_amount < (
+ self.base_received_amount - deductions_to_consider
+ ):
+ self.unallocated_amount = (
+ self.base_received_amount
+ - deductions_to_consider
+ - self.base_total_allocated_amount
+ - included_taxes
+ ) / self.target_exchange_rate
+
+ def set_exchange_gain_loss(self):
+ exchange_gain_loss = flt(
+ self.base_paid_amount - self.base_received_amount,
+ self.precision("amount", "deductions"),
+ )
+
+ exchange_gain_loss_rows = [row for row in self.get("deductions") if row.is_exchange_gain_loss]
+ exchange_gain_loss_row = exchange_gain_loss_rows.pop(0) if exchange_gain_loss_rows else None
+
+ for row in exchange_gain_loss_rows:
+ self.remove(row)
+
+ if not exchange_gain_loss:
+ if exchange_gain_loss_row:
+ self.remove(exchange_gain_loss_row)
+
+ return
+
+ if not exchange_gain_loss_row:
+ values = frappe.get_cached_value(
+ "Company", self.company, ("exchange_gain_loss_account", "cost_center"), as_dict=True
+ )
+
+ for fieldname, value in values.items():
+ if value:
+ continue
+
+ label = _(frappe.get_meta("Company").get_label(fieldname))
+ return frappe.msgprint(
+ _("Please set {0} in Company {1} to account for Exchange Gain / Loss").format(
+ label, get_link_to_form("Company", self.company)
+ ),
+ title=_("Missing Default in Company"),
+ indicator="red" if self.docstatus.is_submitted() else "yellow",
+ raise_exception=self.docstatus.is_submitted(),
+ )
+
+ exchange_gain_loss_row = self.append(
+ "deductions",
+ {
+ "account": values.exchange_gain_loss_account,
+ "cost_center": values.cost_center,
+ "is_exchange_gain_loss": 1,
+ },
+ )
+
+ exchange_gain_loss_row.amount = exchange_gain_loss
def set_difference_amount(self):
base_unallocated_amount = flt(self.unallocated_amount) * (
@@ -1011,11 +1111,13 @@ class PaymentEntry(AccountsController):
def get_included_taxes(self):
included_taxes = 0
for tax in self.get("taxes"):
- if tax.included_in_paid_amount:
- if tax.add_deduct_tax == "Add":
- included_taxes += tax.base_tax_amount
- else:
- included_taxes -= tax.base_tax_amount
+ if not tax.included_in_paid_amount:
+ continue
+
+ if tax.add_deduct_tax == "Add":
+ included_taxes += tax.base_tax_amount
+ else:
+ included_taxes -= tax.base_tax_amount
return included_taxes
@@ -1098,6 +1200,12 @@ class PaymentEntry(AccountsController):
if self.payment_type in ("Receive", "Pay") and not self.get("party_account_field"):
self.setup_party_account_field()
+ company_currency = erpnext.get_company_currency(self.company)
+ if self.paid_from_account_currency != company_currency:
+ self.currency = self.paid_from_account_currency
+ elif self.paid_to_account_currency != company_currency:
+ self.currency = self.paid_to_account_currency
+
gl_entries = []
self.add_party_gl_entries(gl_entries)
self.add_bank_gl_entries(gl_entries)
@@ -1121,6 +1229,8 @@ class PaymentEntry(AccountsController):
if not self.party_account:
return
+ advance_payment_doctypes = frappe.get_hooks("advance_payment_doctypes")
+
if self.payment_type == "Receive":
against_account = self.paid_to
else:
@@ -1163,14 +1273,41 @@ class PaymentEntry(AccountsController):
dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
gle.update(
- {
- dr_or_cr: allocated_amount_in_company_currency,
- dr_or_cr + "_in_account_currency": d.allocated_amount,
- "against_voucher_type": d.reference_doctype,
- "against_voucher": d.reference_name,
- "cost_center": cost_center,
- }
+ self.get_gl_dict(
+ {
+ "account": self.party_account,
+ "party_type": self.party_type,
+ "party": self.party,
+ "against": against_account,
+ "account_currency": self.party_account_currency,
+ "cost_center": cost_center,
+ dr_or_cr + "_in_account_currency": d.allocated_amount,
+ dr_or_cr: allocated_amount_in_company_currency,
+ },
+ item=self,
+ )
)
+
+ if self.book_advance_payments_in_separate_party_account:
+ if d.reference_doctype in advance_payment_doctypes:
+ # Upon reconciliation, whole ledger will be reposted. So, reference to SO/PO is fine
+ gle.update(
+ {
+ "against_voucher_type": d.reference_doctype,
+ "against_voucher": d.reference_name,
+ }
+ )
+ else:
+ # Do not reference Invoices while Advance is in separate party account
+ gle.update({"against_voucher_type": self.doctype, "against_voucher": self.name})
+ else:
+ gle.update(
+ {
+ "against_voucher_type": d.reference_doctype,
+ "against_voucher": d.reference_name,
+ }
+ )
+
gl_entries.append(gle)
if self.unallocated_amount:
@@ -1179,13 +1316,22 @@ class PaymentEntry(AccountsController):
base_unallocated_amount = self.unallocated_amount * exchange_rate
gle = party_gl_dict.copy()
- gle.update(
- {
- dr_or_cr + "_in_account_currency": self.unallocated_amount,
- dr_or_cr: base_unallocated_amount,
- }
- )
+ gle.update(
+ self.get_gl_dict(
+ {
+ "account": self.party_account,
+ "party_type": self.party_type,
+ "party": self.party,
+ "against": against_account,
+ "account_currency": self.party_account_currency,
+ "cost_center": self.cost_center,
+ dr_or_cr + "_in_account_currency": self.unallocated_amount,
+ dr_or_cr: base_unallocated_amount,
+ },
+ item=self,
+ )
+ )
if self.book_advance_payments_in_separate_party_account:
gle.update(
{
@@ -1594,6 +1740,380 @@ class PaymentEntry(AccountsController):
return current_tax_fraction
+ def set_matched_unset_payment_requests_to_response(self):
+ """
+ Find matched Payment Requests for those references which have no Payment Request set.\n
+ And set to `frappe.response` to show in the frontend for allocation.
+ """
+ if not self.references:
+ return
+
+ matched_payment_requests = get_matched_payment_request_of_references(
+ [row for row in self.references if not row.payment_request]
+ )
+
+ if not matched_payment_requests:
+ return
+
+ frappe.response["matched_payment_requests"] = matched_payment_requests
+
+ @frappe.whitelist()
+ def allocate_amount_to_references(self, paid_amount, paid_amount_change, allocate_payment_amount):
+ """
+ Allocate `Allocated Amount` and `Payment Request` against `Reference` based on `Paid Amount` and `Outstanding Amount`.\n
+ :param paid_amount: Paid Amount / Received Amount.
+ :param paid_amount_change: Flag to check if `Paid Amount` is changed or not.
+ :param allocate_payment_amount: Flag to allocate amount or not. (Payment Request is also dependent on this flag)
+ """
+ if not self.references:
+ return
+
+ if not allocate_payment_amount:
+ for ref in self.references:
+ ref.allocated_amount = 0
+ return
+
+ # calculating outstanding amounts
+ precision = self.precision("paid_amount")
+ total_positive_outstanding_including_order = 0
+ total_negative_outstanding = 0
+ paid_amount -= sum(flt(d.amount, precision) for d in self.deductions)
+
+ for ref in self.references:
+ reference_outstanding_amount = ref.outstanding_amount
+ abs_outstanding_amount = abs(reference_outstanding_amount)
+
+ if reference_outstanding_amount > 0:
+ total_positive_outstanding_including_order += abs_outstanding_amount
+ else:
+ total_negative_outstanding += abs_outstanding_amount
+
+ # calculating allocated outstanding amounts
+ allocated_negative_outstanding = 0
+ allocated_positive_outstanding = 0
+
+ # checking party type and payment type
+ if (self.payment_type == "Receive" and self.party_type == "Customer") or (
+ self.payment_type == "Pay" and self.party_type in ("Supplier", "Employee")
+ ):
+ if total_positive_outstanding_including_order > paid_amount:
+ remaining_outstanding = flt(
+ total_positive_outstanding_including_order - paid_amount, precision
+ )
+ allocated_negative_outstanding = min(remaining_outstanding, total_negative_outstanding)
+
+ allocated_positive_outstanding = paid_amount + allocated_negative_outstanding
+
+ elif self.party_type in ("Supplier", "Employee"):
+ if paid_amount > total_negative_outstanding:
+ if total_negative_outstanding == 0:
+ frappe.msgprint(
+ _("Cannot {0} from {1} without any negative outstanding invoice").format(
+ self.payment_type,
+ self.party_type,
+ )
+ )
+ else:
+ frappe.msgprint(
+ _("Paid Amount cannot be greater than total negative outstanding amount {0}").format(
+ total_negative_outstanding
+ )
+ )
+
+ return
+
+ else:
+ allocated_positive_outstanding = flt(total_negative_outstanding - paid_amount, precision)
+ allocated_negative_outstanding = paid_amount + min(
+ total_positive_outstanding_including_order, allocated_positive_outstanding
+ )
+
+ # inner function to set `allocated_amount` to those row which have no PR
+ def _allocation_to_unset_pr_row(
+ row, outstanding_amount, allocated_positive_outstanding, allocated_negative_outstanding
+ ):
+ if outstanding_amount > 0 and allocated_positive_outstanding >= 0:
+ row.allocated_amount = min(allocated_positive_outstanding, outstanding_amount)
+ allocated_positive_outstanding = flt(
+ allocated_positive_outstanding - row.allocated_amount, precision
+ )
+ elif outstanding_amount < 0 and allocated_negative_outstanding:
+ row.allocated_amount = min(allocated_negative_outstanding, abs(outstanding_amount)) * -1
+ allocated_negative_outstanding = flt(
+ allocated_negative_outstanding - abs(row.allocated_amount), precision
+ )
+ return allocated_positive_outstanding, allocated_negative_outstanding
+
+ # allocate amount based on `paid_amount` is changed or not
+ if not paid_amount_change:
+ for ref in self.references:
+ allocated_positive_outstanding, allocated_negative_outstanding = _allocation_to_unset_pr_row(
+ ref,
+ ref.outstanding_amount,
+ allocated_positive_outstanding,
+ allocated_negative_outstanding,
+ )
+
+ allocate_open_payment_requests_to_references(self.references, self.precision("paid_amount"))
+
+ else:
+ payment_request_outstanding_amounts = (
+ get_payment_request_outstanding_set_in_references(self.references) or {}
+ )
+ references_outstanding_amounts = get_references_outstanding_amount(self.references) or {}
+ remaining_references_allocated_amounts = references_outstanding_amounts.copy()
+
+ # Re allocate amount to those references which have PR set (Higher priority)
+ for ref in self.references:
+ if not ref.payment_request:
+ continue
+
+ # fetch outstanding_amount of `Reference` (Payment Term) and `Payment Request` to allocate new amount
+ key = (ref.reference_doctype, ref.reference_name, ref.get("payment_term"))
+ reference_outstanding_amount = references_outstanding_amounts[key]
+ pr_outstanding_amount = payment_request_outstanding_amounts[ref.payment_request]
+
+ if reference_outstanding_amount > 0 and allocated_positive_outstanding >= 0:
+ # allocate amount according to outstanding amounts
+ outstanding_amounts = (
+ allocated_positive_outstanding,
+ reference_outstanding_amount,
+ pr_outstanding_amount,
+ )
+
+ ref.allocated_amount = min(outstanding_amounts)
+
+ # update amounts to track allocation
+ allocated_amount = ref.allocated_amount
+ allocated_positive_outstanding = flt(
+ allocated_positive_outstanding - allocated_amount, precision
+ )
+ remaining_references_allocated_amounts[key] = flt(
+ remaining_references_allocated_amounts[key] - allocated_amount, precision
+ )
+ payment_request_outstanding_amounts[ref.payment_request] = flt(
+ payment_request_outstanding_amounts[ref.payment_request] - allocated_amount, precision
+ )
+
+ elif reference_outstanding_amount < 0 and allocated_negative_outstanding:
+ # allocate amount according to outstanding amounts
+ outstanding_amounts = (
+ allocated_negative_outstanding,
+ abs(reference_outstanding_amount),
+ pr_outstanding_amount,
+ )
+
+ ref.allocated_amount = min(outstanding_amounts) * -1
+
+ # update amounts to track allocation
+ allocated_amount = abs(ref.allocated_amount)
+ allocated_negative_outstanding = flt(
+ allocated_negative_outstanding - allocated_amount, precision
+ )
+ remaining_references_allocated_amounts[key] += allocated_amount # negative amount
+ payment_request_outstanding_amounts[ref.payment_request] = flt(
+ payment_request_outstanding_amounts[ref.payment_request] - allocated_amount, precision
+ )
+ # Re allocate amount to those references which have no PR (Lower priority)
+ for ref in self.references:
+ if ref.payment_request:
+ continue
+
+ key = (ref.reference_doctype, ref.reference_name, ref.get("payment_term"))
+ reference_outstanding_amount = remaining_references_allocated_amounts[key]
+
+ allocated_positive_outstanding, allocated_negative_outstanding = _allocation_to_unset_pr_row(
+ ref,
+ reference_outstanding_amount,
+ allocated_positive_outstanding,
+ allocated_negative_outstanding,
+ )
+
+ @frappe.whitelist()
+ def set_matched_payment_requests(self, matched_payment_requests):
+ """
+ Set `Payment Request` against `Reference` based on `matched_payment_requests`.\n
+ :param matched_payment_requests: List of tuple of matched Payment Requests.
+
+ ---
+ Example: [(reference_doctype, reference_name, allocated_amount, payment_request), ...]
+ """
+ if not self.references or not matched_payment_requests:
+ return
+
+ if isinstance(matched_payment_requests, str):
+ matched_payment_requests = json.loads(matched_payment_requests)
+
+ # modify matched_payment_requests
+ # like (reference_doctype, reference_name, allocated_amount): payment_request
+ payment_requests = {}
+
+ for row in matched_payment_requests:
+ key = tuple(row[:3])
+ payment_requests[key] = row[3]
+
+ for ref in self.references:
+ if ref.payment_request:
+ continue
+
+ key = (ref.reference_doctype, ref.reference_name, ref.allocated_amount)
+
+ if key in payment_requests:
+ ref.payment_request = payment_requests[key]
+ del payment_requests[key] # to avoid duplicate allocation
+
+
+def get_matched_payment_request_of_references(references=None):
+ """
+ Get those `Payment Requests` which are matched with `References`.\n
+ - Amount must be same.
+ - Only single `Payment Request` available for this amount.
+
+ Example: [(reference_doctype, reference_name, allocated_amount, payment_request), ...]
+ """
+ if not references:
+ return
+
+ # to fetch matched rows
+ refs = {
+ (row.reference_doctype, row.reference_name, row.allocated_amount)
+ for row in references
+ if row.reference_doctype and row.reference_name and row.allocated_amount
+ }
+
+ if not refs:
+ return
+
+ PR = frappe.qb.DocType("Payment Request")
+
+ # query to group by reference_doctype, reference_name, outstanding_amount
+ subquery = (
+ frappe.qb.from_(PR)
+ .select(
+ PR.reference_doctype,
+ PR.reference_name,
+ PR.outstanding_amount.as_("allocated_amount"),
+ PR.name.as_("payment_request"),
+ Count("*").as_("count"),
+ )
+ .where(Tuple(PR.reference_doctype, PR.reference_name, PR.outstanding_amount).isin(refs))
+ .where(PR.status != "Paid")
+ .where(PR.docstatus == 1)
+ .groupby(PR.reference_doctype, PR.reference_name, PR.outstanding_amount)
+ )
+
+ # query to fetch matched rows which are single
+ matched_prs = (
+ frappe.qb.from_(subquery)
+ .select(
+ subquery.reference_doctype,
+ subquery.reference_name,
+ subquery.allocated_amount,
+ subquery.payment_request,
+ )
+ .where(subquery.count == 1)
+ .run()
+ )
+
+ return matched_prs if matched_prs else None
+
+
+def get_references_outstanding_amount(references=None):
+ """
+ Fetch accurate outstanding amount of `References`.\n
+ - If `Payment Term` is set, then fetch outstanding amount from `Payment Schedule`.
+ - If `Payment Term` is not set, then fetch outstanding amount from `References` it self.
+
+ Example: {(reference_doctype, reference_name, payment_term): outstanding_amount, ...}
+ """
+ if not references:
+ return
+
+ refs_with_payment_term = get_outstanding_of_references_with_payment_term(references) or {}
+ refs_without_payment_term = get_outstanding_of_references_with_no_payment_term(references) or {}
+
+ return {**refs_with_payment_term, **refs_without_payment_term}
+
+
+def get_outstanding_of_references_with_payment_term(references=None):
+ """
+ Fetch outstanding amount of `References` which have `Payment Term` set.\n
+ Example: {(reference_doctype, reference_name, payment_term): outstanding_amount, ...}
+ """
+ if not references:
+ return
+
+ refs = {
+ (row.reference_doctype, row.reference_name, row.payment_term)
+ for row in references
+ if row.reference_doctype and row.reference_name and row.payment_term
+ }
+
+ if not refs:
+ return
+
+ PS = frappe.qb.DocType("Payment Schedule")
+
+ response = (
+ frappe.qb.from_(PS)
+ .select(PS.parenttype, PS.parent, PS.payment_term, PS.outstanding)
+ .where(Tuple(PS.parenttype, PS.parent, PS.payment_term).isin(refs))
+ ).run(as_dict=True)
+
+ if not response:
+ return
+
+ return {(row.parenttype, row.parent, row.payment_term): row.outstanding for row in response}
+
+
+def get_outstanding_of_references_with_no_payment_term(references):
+ """
+ Fetch outstanding amount of `References` which have no `Payment Term` set.\n
+ - Fetch outstanding amount from `References` it self.
+
+ Note: `None` is used for allocation of `Payment Request`
+ Example: {(reference_doctype, reference_name, None): outstanding_amount, ...}
+ """
+ if not references:
+ return
+
+ outstanding_amounts = {}
+
+ for ref in references:
+ if ref.payment_term:
+ continue
+
+ key = (ref.reference_doctype, ref.reference_name, None)
+
+ if key not in outstanding_amounts:
+ outstanding_amounts[key] = ref.outstanding_amount
+
+ return outstanding_amounts
+
+
+def get_payment_request_outstanding_set_in_references(references=None):
+ """
+ Fetch outstanding amount of `Payment Request` which are set in `References`.\n
+ Example: {payment_request: outstanding_amount, ...}
+ """
+ if not references:
+ return
+
+ referenced_payment_requests = {row.payment_request for row in references if row.payment_request}
+
+ if not referenced_payment_requests:
+ return
+
+ PR = frappe.qb.DocType("Payment Request")
+
+ response = (
+ frappe.qb.from_(PR)
+ .select(PR.name, PR.outstanding_amount)
+ .where(PR.name.isin(referenced_payment_requests))
+ ).run()
+
+ return dict(response) if response else None
+
def validate_inclusive_tax(tax, doc):
def _on_previous_row_error(row_range):
@@ -1740,7 +2260,7 @@ def get_outstanding_reference_documents(args, validate=False):
d["bill_no"] = frappe.db.get_value(d.voucher_type, d.voucher_no, "bill_no")
# Get negative outstanding sales /purchase invoices
- if args.get("party_type") != "Employee" and not args.get("voucher_no"):
+ if args.get("party_type") != "Employee":
negative_outstanding_invoices = get_negative_outstanding_invoices(
args.get("party_type"),
args.get("party"),
@@ -2046,7 +2566,9 @@ def get_party_details(company, party_type, party, date, cost_center=None):
account_balance = get_balance_on(party_account, date, cost_center=cost_center)
_party_name = "title" if party_type == "Shareholder" else party_type.lower() + "_name"
party_name = frappe.db.get_value(party_type, party, _party_name)
- party_balance = get_balance_on(party_type=party_type, party=party, cost_center=cost_center)
+ party_balance = get_balance_on(
+ party_type=party_type, party=party, company=company, cost_center=cost_center
+ )
if party_type in ["Customer", "Supplier"]:
party_bank_account = get_party_bank_account(party_type, party)
bank_account = get_default_company_bank_account(company, party_type, party)
@@ -2224,6 +2746,8 @@ def get_payment_entry(
party_type=None,
payment_type=None,
reference_date=None,
+ ignore_permissions=False,
+ created_from_payment_request=False,
):
doc = frappe.get_doc(dt, dn)
over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance")
@@ -2359,9 +2883,6 @@ def get_payment_entry(
update_accounting_dimensions(pe, doc)
if party_account and bank:
- pe.set_exchange_rate(ref_doc=doc)
- pe.set_amounts()
-
if discount_amount:
base_total_discount_loss = 0
if frappe.db.get_single_value("Accounts Settings", "book_tax_discount_loss"):
@@ -2371,11 +2892,183 @@ def get_payment_entry(
pe, doc, discount_amount, base_total_discount_loss, party_account_currency
)
- pe.set_difference_amount()
+ pe.set_exchange_rate(ref_doc=doc)
+ pe.set_amounts()
+
+ # If PE is created from PR directly, then no need to find open PRs for the references
+ if not created_from_payment_request:
+ allocate_open_payment_requests_to_references(pe.references, pe.precision("paid_amount"))
return pe
+def get_open_payment_requests_for_references(references=None):
+ """
+ Fetch all unpaid Payment Requests for the references. \n
+ - Each reference can have multiple Payment Requests. \n
+
+ Example: {("Sales Invoice", "SINV-00001"): {"PREQ-00001": 1000, "PREQ-00002": 2000}}
+ """
+ if not references:
+ return
+
+ refs = {
+ (row.reference_doctype, row.reference_name)
+ for row in references
+ if row.reference_doctype and row.reference_name and row.allocated_amount
+ }
+
+ if not refs:
+ return
+
+ PR = frappe.qb.DocType("Payment Request")
+
+ response = (
+ frappe.qb.from_(PR)
+ .select(PR.name, PR.reference_doctype, PR.reference_name, PR.outstanding_amount)
+ .where(Tuple(PR.reference_doctype, PR.reference_name).isin(list(refs)))
+ .where(PR.status != "Paid")
+ .where(PR.docstatus == 1)
+ .where(PR.outstanding_amount > 0) # to avoid old PRs with 0 outstanding amount
+ .orderby(Coalesce(PR.transaction_date, PR.creation), order=frappe.qb.asc)
+ ).run(as_dict=True)
+
+ if not response:
+ return
+
+ reference_payment_requests = {}
+
+ for row in response:
+ key = (row.reference_doctype, row.reference_name)
+
+ if key not in reference_payment_requests:
+ reference_payment_requests[key] = {row.name: row.outstanding_amount}
+ else:
+ reference_payment_requests[key][row.name] = row.outstanding_amount
+
+ return reference_payment_requests
+
+
+def allocate_open_payment_requests_to_references(references=None, precision=None):
+ """
+ Allocate unpaid Payment Requests to the references. \n
+ ---
+ - Allocation based on below factors
+ - Reference Allocated Amount
+ - Reference Outstanding Amount (With Payment Terms or without Payment Terms)
+ - Reference Payment Request's outstanding amount
+ ---
+ - Allocation based on below scenarios
+ - Reference's Allocated Amount == Payment Request's Outstanding Amount
+ - Allocate the Payment Request to the reference
+ - This PR will not be allocated further
+ - Reference's Allocated Amount < Payment Request's Outstanding Amount
+ - Allocate the Payment Request to the reference
+ - Reduce the PR's outstanding amount by the allocated amount
+ - This PR can be allocated further
+ - Reference's Allocated Amount > Payment Request's Outstanding Amount
+ - Allocate the Payment Request to the reference
+ - Reduce Allocated Amount of the reference by the PR's outstanding amount
+ - Create a new row for the remaining amount until the Allocated Amount is 0
+ - Allocate PR if available
+ ---
+ - Note:
+ - Priority is given to the first Payment Request of respective references.
+ - Single Reference can have multiple rows.
+ - With Payment Terms or without Payment Terms
+ - With Payment Request or without Payment Request
+ """
+ if not references:
+ return
+
+ # get all unpaid payment requests for the references
+ references_open_payment_requests = get_open_payment_requests_for_references(references)
+
+ if not references_open_payment_requests:
+ return
+
+ if not precision:
+ precision = references[0].precision("allocated_amount")
+
+ # to manage new rows
+ row_number = 1
+ MOVE_TO_NEXT_ROW = 1
+ TO_SKIP_NEW_ROW = 2
+
+ while row_number <= len(references):
+ row = references[row_number - 1]
+ reference_key = (row.reference_doctype, row.reference_name)
+
+ # update the idx to maintain the order
+ row.idx = row_number
+
+ # unpaid payment requests for the reference
+ reference_payment_requests = references_open_payment_requests.get(reference_key)
+
+ if not reference_payment_requests:
+ row_number += MOVE_TO_NEXT_ROW # to move to next reference row
+ continue
+
+ # get the first payment request and its outstanding amount
+ payment_request, pr_outstanding_amount = next(iter(reference_payment_requests.items()))
+ allocated_amount = row.allocated_amount
+
+ # allocate the payment request to the reference and PR's outstanding amount
+ row.payment_request = payment_request
+
+ if pr_outstanding_amount == allocated_amount:
+ del reference_payment_requests[payment_request]
+ row_number += MOVE_TO_NEXT_ROW
+
+ elif pr_outstanding_amount > allocated_amount:
+ # reduce the outstanding amount of the payment request
+ reference_payment_requests[payment_request] -= allocated_amount
+ row_number += MOVE_TO_NEXT_ROW
+
+ else:
+ # split the reference row to allocate the remaining amount
+ del reference_payment_requests[payment_request]
+ row.allocated_amount = pr_outstanding_amount
+ allocated_amount = flt(allocated_amount - pr_outstanding_amount, precision)
+
+ # set the remaining amount to the next row
+ while allocated_amount:
+ # create a new row for the remaining amount
+ new_row = frappe.copy_doc(row)
+ references.insert(row_number, new_row)
+
+ # get the first payment request and its outstanding amount
+ payment_request, pr_outstanding_amount = next(
+ iter(reference_payment_requests.items()), (None, None)
+ )
+
+ # update new row
+ new_row.idx = row_number + 1
+ new_row.payment_request = payment_request
+ new_row.allocated_amount = min(
+ pr_outstanding_amount if pr_outstanding_amount else allocated_amount, allocated_amount
+ )
+
+ if not payment_request or not pr_outstanding_amount:
+ row_number += TO_SKIP_NEW_ROW
+ break
+
+ elif pr_outstanding_amount == allocated_amount:
+ del reference_payment_requests[payment_request]
+ row_number += TO_SKIP_NEW_ROW
+ break
+
+ elif pr_outstanding_amount > allocated_amount:
+ reference_payment_requests[payment_request] -= allocated_amount
+ row_number += TO_SKIP_NEW_ROW
+ break
+
+ else:
+ allocated_amount = flt(allocated_amount - pr_outstanding_amount, precision)
+ del reference_payment_requests[payment_request]
+ row_number += MOVE_TO_NEXT_ROW
+
+
def update_accounting_dimensions(pe, doc):
"""
Updates accounting dimensions in Payment Entry based on the accounting dimensions in the reference document
@@ -2547,13 +3240,14 @@ def set_pending_discount_loss(pe, doc, discount_amount, base_total_discount_loss
book_tax_loss = frappe.db.get_single_value("Accounts Settings", "book_tax_discount_loss")
account_type = "round_off_account" if book_tax_loss else "default_discount_account"
- pe.set_gain_or_loss(
- account_details={
+ pe.append(
+ "deductions",
+ {
"account": frappe.get_cached_value("Company", pe.company, account_type),
"cost_center": pe.cost_center
or frappe.get_cached_value("Company", pe.company, "cost_center"),
"amount": discount_amount * positive_negative,
- }
+ },
)
diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
index cc03dc260bb..312628d9f97 100644
--- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
@@ -479,16 +479,9 @@ class TestPaymentEntry(FrappeTestCase):
self.assertEqual(pe.deductions[0].account, "Write Off - _TC")
# Exchange loss
- self.assertEqual(pe.difference_amount, 300.0)
-
- pe.append(
- "deductions",
- {
- "account": "_Test Exchange Gain/Loss - _TC",
- "cost_center": "_Test Cost Center - _TC",
- "amount": 300.0,
- },
- )
+ self.assertEqual(pe.deductions[-1].amount, 300.0)
+ pe.deductions[-1].account = "_Test Exchange Gain/Loss - _TC"
+ pe.deductions[-1].cost_center = "_Test Cost Center - _TC"
pe.insert()
pe.submit()
@@ -552,16 +545,10 @@ class TestPaymentEntry(FrappeTestCase):
pe.reference_no = "1"
pe.reference_date = "2016-01-01"
- self.assertEqual(pe.difference_amount, 100)
+ self.assertEqual(pe.deductions[0].amount, 100)
+ pe.deductions[0].account = "_Test Exchange Gain/Loss - _TC"
+ pe.deductions[0].cost_center = "_Test Cost Center - _TC"
- pe.append(
- "deductions",
- {
- "account": "_Test Exchange Gain/Loss - _TC",
- "cost_center": "_Test Cost Center - _TC",
- "amount": 100,
- },
- )
pe.insert()
pe.submit()
@@ -654,16 +641,9 @@ class TestPaymentEntry(FrappeTestCase):
pe.set_exchange_rate()
pe.set_amounts()
- self.assertEqual(pe.difference_amount, 500)
-
- pe.append(
- "deductions",
- {
- "account": "_Test Exchange Gain/Loss - _TC",
- "cost_center": "_Test Cost Center - _TC",
- "amount": 500,
- },
- )
+ self.assertEqual(pe.deductions[0].amount, 500)
+ pe.deductions[0].account = "_Test Exchange Gain/Loss - _TC"
+ pe.deductions[0].cost_center = "_Test Cost Center - _TC"
pe.insert()
pe.submit()
@@ -956,6 +936,53 @@ class TestPaymentEntry(FrappeTestCase):
self.assertEqual(flt(expected_party_balance), party_balance)
self.assertEqual(flt(expected_party_account_balance, 2), flt(party_account_balance, 2))
+ def test_gl_of_multi_currency_payment_transaction(self):
+ from erpnext.setup.doctype.currency_exchange.test_currency_exchange import (
+ save_new_records,
+ test_records,
+ )
+
+ save_new_records(test_records)
+ paid_from = create_account(
+ parent_account="Current Liabilities - _TC",
+ account_name="_Test Cash USD",
+ company="_Test Company",
+ account_type="Cash",
+ account_currency="USD",
+ )
+ payment_entry = create_payment_entry(
+ party="_Test Supplier USD",
+ paid_from=paid_from,
+ paid_to="_Test Payable USD - _TC",
+ paid_amount=100,
+ save=True,
+ )
+ payment_entry.source_exchange_rate = 84.4
+ payment_entry.target_exchange_rate = 84.4
+ payment_entry.save()
+ payment_entry = payment_entry.submit()
+ gle = qb.DocType("GL Entry")
+ gl_entries = (
+ qb.from_(gle)
+ .select(
+ gle.account,
+ gle.debit,
+ gle.credit,
+ gle.debit_in_account_currency,
+ gle.credit_in_account_currency,
+ gle.debit_in_transaction_currency,
+ gle.credit_in_transaction_currency,
+ )
+ .orderby(gle.account)
+ .where(gle.voucher_no == payment_entry.name)
+ .run()
+ )
+ expected_gl_entries = (
+ (paid_from, 0.0, 8440.0, 0.0, 100.0, 0.0, 100.0),
+ ("_Test Payable USD - _TC", 8440.0, 0.0, 100.0, 0.0, 100.0, 0.0),
+ )
+ self.assertEqual(gl_entries, expected_gl_entries)
+
def test_multi_currency_payment_entry_with_taxes(self):
payment_entry = create_payment_entry(
party="_Test Supplier USD", paid_to="_Test Payable USD - _TC", save=True
@@ -1791,6 +1818,79 @@ class TestPaymentEntry(FrappeTestCase):
# 'Is Opening' should always be 'No' for normal advance payments
self.assertEqual(gl_with_opening_set, [])
+ @change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
+ def test_delete_linked_exchange_gain_loss_journal(self):
+ from erpnext.accounts.doctype.account.test_account import create_account
+ from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
+ make_customer,
+ )
+
+ debtors = create_account(
+ account_name="Debtors USD",
+ parent_account="Accounts Receivable - _TC",
+ company="_Test Company",
+ account_currency="USD",
+ account_type="Receivable",
+ )
+
+ # create a customer
+ customer = make_customer(customer="_Test Party USD")
+ cust_doc = frappe.get_doc("Customer", customer)
+ cust_doc.default_currency = "USD"
+ test_account_details = {
+ "company": "_Test Company",
+ "account": debtors,
+ }
+ cust_doc.append("accounts", test_account_details)
+ cust_doc.save()
+
+ # create a sales invoice
+ si = create_sales_invoice(
+ customer=customer,
+ currency="USD",
+ conversion_rate=83.970000000,
+ debit_to=debtors,
+ do_not_save=1,
+ )
+ si.party_account_currency = "USD"
+ si.save()
+ si.submit()
+
+ # create a payment entry for the invoice
+ pe = get_payment_entry("Sales Invoice", si.name)
+ pe.reference_no = "1"
+ pe.reference_date = frappe.utils.nowdate()
+ pe.paid_amount = 100
+ pe.source_exchange_rate = 90
+ pe.append(
+ "deductions",
+ {
+ "account": "_Test Exchange Gain/Loss - _TC",
+ "cost_center": "_Test Cost Center - _TC",
+ "amount": 2710,
+ },
+ )
+ pe.save()
+ pe.submit()
+
+ # check creation of journal entry
+ jv = frappe.get_all(
+ "Journal Entry Account",
+ {"reference_type": pe.doctype, "reference_name": pe.name, "docstatus": 1},
+ pluck="parent",
+ )
+ self.assertTrue(jv)
+
+ # check cancellation of payment entry and journal entry
+ pe.cancel()
+ self.assertTrue(pe.docstatus == 2)
+ self.assertTrue(frappe.db.get_value("Journal Entry", {"name": jv[0]}, "docstatus") == 2)
+
+ # check deletion of payment entry and journal entry
+ pe.delete()
+ self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, pe.doctype, pe.name)
+ self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, "Journal Entry", jv[0])
+
def create_payment_entry(**args):
payment_entry = frappe.new_doc("Payment Entry")
diff --git a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json
index 1c31829f0ea..e47b51ae028 100644
--- a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json
+++ b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json
@@ -9,6 +9,7 @@
"cost_center",
"amount",
"column_break_2",
+ "is_exchange_gain_loss",
"description"
],
"fields": [
@@ -45,12 +46,20 @@
"fieldname": "description",
"fieldtype": "Small Text",
"label": "Description"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval:doc.is_exchange_gain_loss",
+ "fieldname": "is_exchange_gain_loss",
+ "fieldtype": "Check",
+ "label": "Is Exchange Gain / Loss?",
+ "read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2023-03-06 07:11:57.739619",
+ "modified": "2024-11-05 16:07:47.307971",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Deduction",
diff --git a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.py b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.py
index fc67c526b28..ae4134fc27a 100644
--- a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.py
+++ b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.py
@@ -18,6 +18,7 @@ class PaymentEntryDeduction(Document):
amount: DF.Currency
cost_center: DF.Link
description: DF.SmallText | None
+ is_exchange_gain_loss: DF.Check
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
diff --git a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json
index 23ed8252333..361f516b830 100644
--- a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json
+++ b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json
@@ -10,6 +10,7 @@
"due_date",
"bill_no",
"payment_term",
+ "payment_term_outstanding",
"account_type",
"payment_type",
"column_break_4",
@@ -18,7 +19,9 @@
"allocated_amount",
"exchange_rate",
"exchange_gain_loss",
- "account"
+ "account",
+ "payment_request",
+ "payment_request_outstanding"
],
"fields": [
{
@@ -120,12 +123,33 @@
"fieldname": "payment_type",
"fieldtype": "Data",
"label": "Payment Type"
+ },
+ {
+ "fieldname": "payment_request",
+ "fieldtype": "Link",
+ "label": "Payment Request",
+ "options": "Payment Request"
+ },
+ {
+ "depends_on": "eval: doc.payment_term",
+ "fieldname": "payment_term_outstanding",
+ "fieldtype": "Float",
+ "label": "Payment Term Outstanding",
+ "read_only": 1
+ },
+ {
+ "depends_on": "eval: doc.payment_request && doc.payment_request_outstanding",
+ "fieldname": "payment_request_outstanding",
+ "fieldtype": "Float",
+ "is_virtual": 1,
+ "label": "Payment Request Outstanding",
+ "read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2024-04-05 09:44:08.310593",
+ "modified": "2024-09-16 18:11:50.019343",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Reference",
diff --git a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.py b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.py
index 4a027b4ee32..2ac92ba4a84 100644
--- a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.py
+++ b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.py
@@ -1,7 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
-
+import frappe
from frappe.model.document import Document
@@ -25,11 +25,19 @@ class PaymentEntryReference(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
+ payment_request: DF.Link | None
+ payment_request_outstanding: DF.Float
payment_term: DF.Link | None
+ payment_term_outstanding: DF.Float
payment_type: DF.Data | None
reference_doctype: DF.Link
reference_name: DF.DynamicLink
total_amount: DF.Float
# end: auto-generated types
- pass
+ @property
+ def payment_request_outstanding(self):
+ if not self.payment_request:
+ return
+
+ return frappe.db.get_value("Payment Request", self.payment_request, "outstanding_amount")
diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
index 8481fd8ffa4..db4a4b0f268 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
@@ -153,10 +153,7 @@ class PaymentReconciliation(Document):
self.add_payment_entries(non_reconciled_payments)
def get_payment_entries(self):
- if self.default_advance_account:
- party_account = [self.receivable_payable_account, self.default_advance_account]
- else:
- party_account = [self.receivable_payable_account]
+ party_account = [self.receivable_payable_account]
order_doctype = "Sales Order" if self.party_type == "Customer" else "Purchase Order"
condition = frappe._dict(
@@ -187,6 +184,7 @@ class PaymentReconciliation(Document):
self.party,
party_account,
order_doctype,
+ default_advance_account=self.default_advance_account,
against_all_orders=True,
limit=self.payment_limit,
condition=condition,
@@ -211,12 +209,14 @@ class PaymentReconciliation(Document):
if self.get("cost_center"):
conditions.append(jea.cost_center == self.cost_center)
- dr_or_cr = (
- "credit_in_account_currency"
- if erpnext.get_party_account_type(self.party_type) == "Receivable"
- else "debit_in_account_currency"
- )
- conditions.append(jea[dr_or_cr].gt(0))
+ account_type = erpnext.get_party_account_type(self.party_type)
+
+ if account_type == "Receivable":
+ dr_or_cr = jea.credit_in_account_currency - jea.debit_in_account_currency
+ elif account_type == "Payable":
+ dr_or_cr = jea.debit_in_account_currency - jea.credit_in_account_currency
+
+ conditions.append(dr_or_cr.gt(0))
if self.bank_cash_account:
conditions.append(jea.against_account.like(f"%%{self.bank_cash_account}%%"))
@@ -231,7 +231,7 @@ class PaymentReconciliation(Document):
je.posting_date,
je.remark.as_("remarks"),
jea.name.as_("reference_row"),
- jea[dr_or_cr].as_("amount"),
+ dr_or_cr.as_("amount"),
jea.is_advance,
jea.exchange_rate,
jea.account_currency.as_("currency"),
@@ -323,6 +323,7 @@ class PaymentReconciliation(Document):
"posting_date": inv.posting_date,
"currency": inv.currency,
"cost_center": inv.cost_center,
+ "remarks": inv.remarks,
}
)
)
@@ -370,6 +371,10 @@ class PaymentReconciliation(Document):
if self.invoice_limit:
non_reconciled_invoices = non_reconciled_invoices[: self.invoice_limit]
+ non_reconciled_invoices = sorted(
+ non_reconciled_invoices, key=lambda k: k["posting_date"] or getdate(nowdate())
+ )
+
self.add_invoice_entries(non_reconciled_invoices)
def add_invoice_entries(self, non_reconciled_invoices):
diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
index 5aa411158a8..3f0fb29d671 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
@@ -5,7 +5,7 @@
import frappe
from frappe import qb
from frappe.tests.utils import FrappeTestCase, change_settings
-from frappe.utils import add_days, flt, nowdate
+from frappe.utils import add_days, add_years, flt, getdate, nowdate, today
from erpnext import get_default_cost_center
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
@@ -13,6 +13,7 @@ from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_pay
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
from erpnext.accounts.party import get_party_account
+from erpnext.accounts.utils import get_fiscal_year
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.stock.doctype.item.test_item import create_item
@@ -631,6 +632,42 @@ class TestPaymentReconciliation(FrappeTestCase):
self.assertEqual(len(pr.get("invoices")), 0)
self.assertEqual(len(pr.get("payments")), 0)
+ def test_negative_debit_or_credit_journal_against_invoice(self):
+ transaction_date = nowdate()
+ amount = 100
+ si = self.create_sales_invoice(qty=1, rate=amount, posting_date=transaction_date)
+
+ # credit debtors account to record a payment
+ je = self.create_journal_entry(self.bank, self.debit_to, amount, transaction_date)
+ je.accounts[1].party_type = "Customer"
+ je.accounts[1].party = self.customer
+ je.accounts[1].credit_in_account_currency = 0
+ je.accounts[1].debit_in_account_currency = -1 * amount
+ je.save()
+ je.submit()
+
+ pr = self.create_payment_reconciliation()
+
+ pr.get_unreconciled_entries()
+ invoices = [x.as_dict() for x in pr.get("invoices")]
+ payments = [x.as_dict() for x in pr.get("payments")]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+
+ # Difference amount should not be calculated for base currency accounts
+ for row in pr.allocation:
+ self.assertEqual(flt(row.get("difference_amount")), 0.0)
+
+ pr.reconcile()
+
+ # assert outstanding
+ si.reload()
+ self.assertEqual(si.status, "Paid")
+ self.assertEqual(si.outstanding_amount, 0)
+
+ # check PR tool output
+ self.assertEqual(len(pr.get("invoices")), 0)
+ self.assertEqual(len(pr.get("payments")), 0)
+
def test_journal_against_journal(self):
transaction_date = nowdate()
sales = "Sales - _PR"
@@ -953,6 +990,100 @@ class TestPaymentReconciliation(FrappeTestCase):
frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
)
+ def test_difference_amount_via_negative_debit_or_credit_journal_entry(self):
+ # Make Sale Invoice
+ si = self.create_sales_invoice(
+ qty=1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True
+ )
+ si.customer = self.customer4
+ si.currency = "EUR"
+ si.conversion_rate = 85
+ si.debit_to = self.debtors_eur
+ si.save().submit()
+
+ # Make payment using Journal Entry
+ je1 = self.create_journal_entry("HDFC - _PR", self.debtors_eur, 100, nowdate())
+ je1.multi_currency = 1
+ je1.accounts[0].exchange_rate = 1
+ je1.accounts[0].credit_in_account_currency = -8000
+ je1.accounts[0].credit = -8000
+ je1.accounts[0].debit_in_account_currency = 0
+ je1.accounts[0].debit = 0
+ je1.accounts[1].party_type = "Customer"
+ je1.accounts[1].party = self.customer4
+ je1.accounts[1].exchange_rate = 80
+ je1.accounts[1].credit_in_account_currency = 100
+ je1.accounts[1].credit = 8000
+ je1.accounts[1].debit_in_account_currency = 0
+ je1.accounts[1].debit = 0
+ je1.save()
+ je1.submit()
+
+ je2 = self.create_journal_entry("HDFC - _PR", self.debtors_eur, 200, nowdate())
+ je2.multi_currency = 1
+ je2.accounts[0].exchange_rate = 1
+ je2.accounts[0].credit_in_account_currency = -16000
+ je2.accounts[0].credit = -16000
+ je2.accounts[0].debit_in_account_currency = 0
+ je2.accounts[0].debit = 0
+ je2.accounts[1].party_type = "Customer"
+ je2.accounts[1].party = self.customer4
+ je2.accounts[1].exchange_rate = 80
+ je2.accounts[1].credit_in_account_currency = 200
+ je1.accounts[1].credit = 16000
+ je1.accounts[1].debit_in_account_currency = 0
+ je1.accounts[1].debit = 0
+ je2.save()
+ je2.submit()
+
+ pr = self.create_payment_reconciliation()
+ pr.party = self.customer4
+ pr.receivable_payable_account = self.debtors_eur
+ pr.get_unreconciled_entries()
+
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 2)
+
+ # Test exact payment allocation
+ invoices = [x.as_dict() for x in pr.invoices]
+ payments = [pr.payments[0].as_dict()]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+
+ self.assertEqual(pr.allocation[0].allocated_amount, 100)
+ self.assertEqual(pr.allocation[0].difference_amount, -500)
+
+ # Test partial payment allocation (with excess payment entry)
+ pr.set("allocation", [])
+ pr.get_unreconciled_entries()
+ invoices = [x.as_dict() for x in pr.invoices]
+ payments = [pr.payments[1].as_dict()]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ pr.allocation[0].difference_account = "Exchange Gain/Loss - _PR"
+
+ self.assertEqual(pr.allocation[0].allocated_amount, 100)
+ self.assertEqual(pr.allocation[0].difference_amount, -500)
+
+ # Check if difference journal entry gets generated for difference amount after reconciliation
+ pr.reconcile()
+ total_credit_amount = frappe.db.get_all(
+ "Journal Entry Account",
+ {"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name},
+ "sum(credit) as amount",
+ group_by="reference_name",
+ )[0].amount
+
+ # total credit includes the exchange gain/loss amount
+ self.assertEqual(flt(total_credit_amount, 2), 8500)
+
+ jea_parent = frappe.db.get_all(
+ "Journal Entry Account",
+ filters={"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name, "credit": 500},
+ fields=["parent"],
+ )[0]
+ self.assertEqual(
+ frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
+ )
+
def test_difference_amount_via_payment_entry(self):
# Make Sale Invoice
si = self.create_sales_invoice(
@@ -1845,6 +1976,78 @@ class TestPaymentReconciliation(FrappeTestCase):
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
+ def test_reconciliation_on_closed_period_payment(self):
+ # create backdated fiscal year
+ first_fy_start_date = frappe.db.get_value("Fiscal Year", {"disabled": 0}, "min(year_start_date)")
+ prev_fy_start_date = add_years(first_fy_start_date, -1)
+ prev_fy_end_date = add_days(first_fy_start_date, -1)
+ create_fiscal_year(
+ company=self.company, year_start_date=prev_fy_start_date, year_end_date=prev_fy_end_date
+ )
+
+ # make journal entry for previous year
+ je_1 = frappe.new_doc("Journal Entry")
+ je_1.posting_date = add_days(prev_fy_start_date, 20)
+ je_1.company = self.company
+ je_1.user_remark = "test"
+ je_1.set(
+ "accounts",
+ [
+ {
+ "account": self.debit_to,
+ "cost_center": self.cost_center,
+ "party_type": "Customer",
+ "party": self.customer,
+ "debit_in_account_currency": 0,
+ "credit_in_account_currency": 1000,
+ },
+ {
+ "account": self.bank,
+ "cost_center": self.sub_cc.name,
+ "credit_in_account_currency": 0,
+ "debit_in_account_currency": 500,
+ },
+ {
+ "account": self.cash,
+ "cost_center": self.sub_cc.name,
+ "credit_in_account_currency": 0,
+ "debit_in_account_currency": 500,
+ },
+ ],
+ )
+ je_1.submit()
+
+ # make period closing voucher
+ pcv = make_period_closing_voucher(
+ company=self.company, cost_center=self.cost_center, posting_date=prev_fy_end_date
+ )
+ pcv.reload()
+ # check if period closing voucher is completed
+ self.assertEqual(pcv.gle_processing_status, "Completed")
+
+ # make journal entry for active year
+ je_2 = self.create_journal_entry(
+ acc1=self.debit_to, acc2=self.income_account, amount=1000, posting_date=today()
+ )
+ je_2.accounts[0].party_type = "Customer"
+ je_2.accounts[0].party = self.customer
+ je_2.submit()
+
+ # process reconciliation on closed period payment
+ pr = self.create_payment_reconciliation(party_is_customer=True)
+ pr.from_invoice_date = pr.to_invoice_date = pr.from_payment_date = pr.to_payment_date = None
+ pr.get_unreconciled_entries()
+ invoices = [invoice.as_dict() for invoice in pr.invoices]
+ payments = [payment.as_dict() for payment in pr.payments]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ pr.reconcile()
+ je_1.reload()
+ je_2.reload()
+
+ # check whether the payment reconciliation is done on the closed period
+ self.assertEqual(pr.get("invoices"), [])
+ self.assertEqual(pr.get("payments"), [])
+
def make_customer(customer_name, currency=None):
if not frappe.db.exists("Customer", customer_name):
@@ -1872,3 +2075,63 @@ def make_supplier(supplier_name, currency=None):
return supplier.name
else:
return supplier_name
+
+
+def create_fiscal_year(company, year_start_date, year_end_date):
+ fy_docname = frappe.db.exists(
+ "Fiscal Year", {"year_start_date": year_start_date, "year_end_date": year_end_date}
+ )
+ if not fy_docname:
+ fy_doc = frappe.get_doc(
+ {
+ "doctype": "Fiscal Year",
+ "year": f"{getdate(year_start_date).year}-{getdate(year_end_date).year}",
+ "year_start_date": year_start_date,
+ "year_end_date": year_end_date,
+ "companies": [{"company": company}],
+ }
+ ).save()
+ return fy_doc
+ else:
+ fy_doc = frappe.get_doc("Fiscal Year", fy_docname)
+ if not frappe.db.exists("Fiscal Year Company", {"parent": fy_docname, "company": company}):
+ fy_doc.append("companies", {"company": company})
+ fy_doc.save()
+ return fy_doc
+
+
+def make_period_closing_voucher(company, cost_center, posting_date=None, submit=True):
+ from erpnext.accounts.doctype.account.test_account import create_account
+
+ parent_account = frappe.db.get_value(
+ "Account", {"company": company, "account_name": "Current Liabilities", "is_group": 1}, "name"
+ )
+ surplus_account = create_account(
+ account_name="Reserve and Surplus",
+ is_group=0,
+ company=company,
+ root_type="Liability",
+ report_type="Balance Sheet",
+ account_currency="INR",
+ parent_account=parent_account,
+ doctype="Account",
+ )
+ fy = get_fiscal_year(posting_date, company=company)
+ pcv = frappe.get_doc(
+ {
+ "doctype": "Period Closing Voucher",
+ "transaction_date": posting_date or today(),
+ "period_start_date": fy[1],
+ "period_end_date": fy[2],
+ "company": company,
+ "fiscal_year": fy[0],
+ "cost_center": cost_center,
+ "closing_account_head": surplus_account,
+ "remarks": "test",
+ }
+ )
+ pcv.insert()
+ if submit:
+ pcv.submit()
+
+ return pcv
diff --git a/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json b/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json
index d199236ae99..010e93558cf 100644
--- a/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json
+++ b/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json
@@ -14,7 +14,7 @@
"amount",
"difference_amount",
"sec_break1",
- "remark",
+ "remarks",
"currency",
"exchange_rate",
"cost_center"
@@ -74,12 +74,6 @@
"fieldname": "sec_break1",
"fieldtype": "Section Break"
},
- {
- "fieldname": "remark",
- "fieldtype": "Small Text",
- "label": "Remark",
- "read_only": 1
- },
{
"fieldname": "currency",
"fieldtype": "Link",
@@ -105,12 +99,18 @@
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
+ },
+ {
+ "fieldname": "remarks",
+ "fieldtype": "Small Text",
+ "label": "Remarks",
+ "read_only": 1
}
],
"is_virtual": 1,
"istable": 1,
"links": [],
- "modified": "2023-11-17 17:33:34.818530",
+ "modified": "2024-10-29 16:24:43.021230",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Reconciliation Payment",
diff --git a/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.py b/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.py
index 4ab80ecaafe..49c17eae41b 100644
--- a/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.py
+++ b/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.py
@@ -27,7 +27,7 @@ class PaymentReconciliationPayment(Document):
reference_name: DF.DynamicLink | None
reference_row: DF.Data | None
reference_type: DF.Link | None
- remark: DF.SmallText | None
+ remarks: DF.SmallText | None
# end: auto-generated types
@staticmethod
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.js b/erpnext/accounts/doctype/payment_request/payment_request.js
index e45aa512fe8..50f96a4e2b6 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.js
+++ b/erpnext/accounts/doctype/payment_request/payment_request.js
@@ -48,8 +48,8 @@ frappe.ui.form.on("Payment Request", "refresh", function (frm) {
}
if (
- (!frm.doc.payment_gateway_account || frm.doc.payment_request_type == "Outward") &&
- frm.doc.status == "Initiated"
+ frm.doc.payment_request_type == "Outward" &&
+ ["Initiated", "Partially Paid"].includes(frm.doc.status)
) {
frm.add_custom_button(__("Create Payment Entry"), function () {
frappe.call({
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.json b/erpnext/accounts/doctype/payment_request/payment_request.json
index 0537ee6d3a3..2eef429cd3a 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.json
+++ b/erpnext/accounts/doctype/payment_request/payment_request.json
@@ -9,18 +9,22 @@
"transaction_date",
"column_break_2",
"naming_series",
+ "company",
"mode_of_payment",
"party_details",
"party_type",
"party",
+ "party_name",
"column_break_4",
"reference_doctype",
"reference_name",
"transaction_details",
"grand_total",
+ "currency",
"is_a_subscription",
"column_break_18",
- "currency",
+ "outstanding_amount",
+ "party_account_currency",
"subscription_section",
"subscription_plans",
"bank_account_details",
@@ -68,6 +72,7 @@
{
"fieldname": "transaction_date",
"fieldtype": "Date",
+ "in_preview": 1,
"label": "Transaction Date"
},
{
@@ -132,7 +137,8 @@
"no_copy": 1,
"options": "reference_doctype",
"print_hide": 1,
- "read_only": 1
+ "read_only": 1,
+ "search_index": 1
},
{
"fieldname": "transaction_details",
@@ -140,12 +146,14 @@
"label": "Transaction Details"
},
{
- "description": "Amount in customer's currency",
+ "description": "Amount in transaction currency",
"fieldname": "grand_total",
"fieldtype": "Currency",
+ "in_preview": 1,
"label": "Amount",
"non_negative": 1,
- "options": "currency"
+ "options": "currency",
+ "reqd": 1
},
{
"default": "0",
@@ -390,13 +398,44 @@
"options": "Payment Request",
"print_hide": 1,
"read_only": 1
+ },
+ {
+ "depends_on": "eval: doc.docstatus === 1",
+ "description": "Amount in party's bank account currency",
+ "fieldname": "outstanding_amount",
+ "fieldtype": "Currency",
+ "in_preview": 1,
+ "label": "Outstanding Amount",
+ "non_negative": 1,
+ "options": "party_account_currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "label": "Company",
+ "options": "Company",
+ "read_only": 1
+ },
+ {
+ "fieldname": "party_account_currency",
+ "fieldtype": "Link",
+ "label": "Party Account Currency",
+ "options": "Currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "party_name",
+ "fieldtype": "Data",
+ "label": "Party Name",
+ "read_only": 1
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2024-06-20 13:54:55.245774",
+ "modified": "2024-10-23 12:23:40.117336",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Request",
@@ -431,7 +470,8 @@
"write": 1
}
],
+ "show_preview_popup": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
-}
+}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py
index a2c6a9d856a..61bb2932d2b 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/payment_request.py
@@ -1,11 +1,13 @@
import json
import frappe
-from frappe import _
+from frappe import _, qb
from frappe.model.document import Document
+from frappe.query_builder.functions import Abs, Sum
from frappe.utils import flt, nowdate
from frappe.utils.background_jobs import enqueue
+from erpnext import get_company_currency
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
@@ -15,9 +17,18 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import (
)
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.accounts.party import get_party_account, get_party_bank_account
-from erpnext.accounts.utils import get_account_currency
+from erpnext.accounts.utils import get_account_currency, get_currency_precision
from erpnext.utilities import payment_app_import_guard
+ALLOWED_DOCTYPES_FOR_PAYMENT_REQUEST = [
+ "Sales Order",
+ "Purchase Order",
+ "Sales Invoice",
+ "Purchase Invoice",
+ "POS Invoice",
+ "Fees",
+]
+
def _get_payment_gateway_controller(*args, **kwargs):
with payment_app_import_guard():
@@ -45,6 +56,7 @@ class PaymentRequest(Document):
bank_account: DF.Link | None
bank_account_no: DF.ReadOnly | None
branch_code: DF.ReadOnly | None
+ company: DF.Link | None
cost_center: DF.Link | None
currency: DF.Link | None
email_to: DF.Data | None
@@ -56,16 +68,19 @@ class PaymentRequest(Document):
mode_of_payment: DF.Link | None
mute_email: DF.Check
naming_series: DF.Literal["ACC-PRQ-.YYYY.-"]
+ outstanding_amount: DF.Currency
party: DF.DynamicLink | None
+ party_account_currency: DF.Link | None
+ party_name: DF.Data | None
party_type: DF.Link | None
payment_account: DF.ReadOnly | None
- payment_channel: DF.Literal["", "Email", "Phone"]
+ payment_channel: DF.Literal["", "Email", "Phone", "Other"]
payment_gateway: DF.ReadOnly | None
payment_gateway_account: DF.Link | None
payment_order: DF.Link | None
payment_request_type: DF.Literal["Outward", "Inward"]
payment_url: DF.Data | None
- print_format: DF.Literal
+ print_format: DF.Literal[None]
project: DF.Link | None
reference_doctype: DF.Link | None
reference_name: DF.DynamicLink | None
@@ -99,6 +114,12 @@ class PaymentRequest(Document):
frappe.throw(_("To create a Payment Request reference document is required"))
def validate_payment_request_amount(self):
+ if self.grand_total == 0:
+ frappe.throw(
+ _("{0} cannot be zero").format(self.get_label_from_fieldname("grand_total")),
+ title=_("Invalid Amount"),
+ )
+
existing_payment_request_amount = flt(
get_existing_payment_request_amount(self.reference_doctype, self.reference_name)
)
@@ -146,6 +167,28 @@ class PaymentRequest(Document):
).format(self.grand_total, amount)
)
+ def before_submit(self):
+ if (
+ self.currency != self.party_account_currency
+ and self.party_account_currency == get_company_currency(self.company)
+ ):
+ # set outstanding amount in party account currency
+ invoice = frappe.get_value(
+ self.reference_doctype,
+ self.reference_name,
+ ["rounded_total", "grand_total", "base_rounded_total", "base_grand_total"],
+ as_dict=1,
+ )
+ grand_total = invoice.get("rounded_total") or invoice.get("grand_total")
+ base_grand_total = invoice.get("base_rounded_total") or invoice.get("base_grand_total")
+ self.outstanding_amount = flt(
+ self.grand_total / grand_total * base_grand_total,
+ self.precision("outstanding_amount"),
+ )
+
+ else:
+ self.outstanding_amount = self.grand_total
+
def on_submit(self):
if self.payment_request_type == "Outward":
self.db_set("status", "Initiated")
@@ -261,12 +304,12 @@ class PaymentRequest(Document):
return controller.get_payment_url(
**{
"amount": flt(self.grand_total, self.precision("grand_total")),
- "title": data.company.encode("utf-8"),
- "description": self.subject.encode("utf-8"),
+ "title": data.company,
+ "description": self.subject,
"reference_doctype": "Payment Request",
"reference_docname": self.name,
"payer_email": self.email_to or frappe.session.user,
- "payer_name": frappe.safe_encode(data.customer_name),
+ "payer_name": data.customer_name,
"order_id": self.name,
"currency": self.currency,
}
@@ -274,7 +317,7 @@ class PaymentRequest(Document):
def set_as_paid(self):
if self.payment_channel == "Phone":
- self.db_set("status", "Paid")
+ self.db_set({"status": "Paid", "outstanding_amount": 0})
else:
payment_entry = self.create_payment_entry()
@@ -295,26 +338,32 @@ class PaymentRequest(Document):
else:
party_account = get_party_account("Customer", ref_doc.get("customer"), ref_doc.company)
- party_account_currency = ref_doc.get("party_account_currency") or get_account_currency(party_account)
+ party_account_currency = (
+ self.get("party_account_currency")
+ or ref_doc.get("party_account_currency")
+ or get_account_currency(party_account)
+ )
+
+ party_amount = bank_amount = self.outstanding_amount
- bank_amount = self.grand_total
if party_account_currency == ref_doc.company_currency and party_account_currency != self.currency:
- party_amount = ref_doc.get("base_rounded_total") or ref_doc.get("base_grand_total")
- else:
- party_amount = self.grand_total
+ exchange_rate = ref_doc.get("conversion_rate")
+ bank_amount = flt(self.outstanding_amount / exchange_rate, self.precision("grand_total"))
+ # outstanding amount is already in Part's account currency
payment_entry = get_payment_entry(
self.reference_doctype,
self.reference_name,
party_amount=party_amount,
bank_account=self.payment_account,
bank_amount=bank_amount,
+ created_from_payment_request=True,
)
payment_entry.update(
{
"mode_of_payment": self.mode_of_payment,
- "reference_no": self.name,
+ "reference_no": self.name, # to prevent validation error
"reference_date": nowdate(),
"remarks": "Payment Entry against {} {} via Payment Request {}".format(
self.reference_doctype, self.reference_name, self.name
@@ -322,6 +371,9 @@ class PaymentRequest(Document):
}
)
+ # Allocate payment_request for each reference in payment_entry (Payment Term can splits the row)
+ self._allocate_payment_request_to_pe_references(references=payment_entry.references)
+
# Update dimensions
payment_entry.update(
{
@@ -330,14 +382,6 @@ class PaymentRequest(Document):
}
)
- if party_account_currency == ref_doc.company_currency and party_account_currency != self.currency:
- amount = payment_entry.base_paid_amount
- else:
- amount = self.grand_total
-
- payment_entry.received_amount = amount
- payment_entry.get("references")[0].allocated_amount = amount
-
# Update 'Paid Amount' on Forex transactions
if self.currency != ref_doc.company_currency:
if (
@@ -428,6 +472,62 @@ class PaymentRequest(Document):
return create_stripe_subscription(gateway_controller, data)
+ def _allocate_payment_request_to_pe_references(self, references):
+ """
+ Allocate the Payment Request to the Payment Entry references based on\n
+ - Allocated Amount.
+ - Outstanding Amount of Payment Request.\n
+ Payment Request is doc itself and references are the rows of Payment Entry.
+ """
+ if len(references) == 1:
+ references[0].payment_request = self.name
+ return
+
+ precision = references[0].precision("allocated_amount")
+ outstanding_amount = self.outstanding_amount
+
+ # to manage rows
+ row_number = 1
+ MOVE_TO_NEXT_ROW = 1
+ TO_SKIP_NEW_ROW = 2
+ NEW_ROW_ADDED = False
+
+ while row_number <= len(references):
+ row = references[row_number - 1]
+
+ # update the idx to maintain the order
+ row.idx = row_number
+
+ if outstanding_amount == 0:
+ if not NEW_ROW_ADDED:
+ break
+
+ row_number += MOVE_TO_NEXT_ROW
+ continue
+
+ # allocate the payment request to the row
+ row.payment_request = self.name
+
+ if row.allocated_amount <= outstanding_amount:
+ outstanding_amount = flt(outstanding_amount - row.allocated_amount, precision)
+ row_number += MOVE_TO_NEXT_ROW
+ else:
+ remaining_allocated_amount = flt(row.allocated_amount - outstanding_amount, precision)
+ row.allocated_amount = outstanding_amount
+ outstanding_amount = 0
+
+ # create a new row without PR for remaining unallocated amount
+ new_row = frappe.copy_doc(row)
+ references.insert(row_number, new_row)
+
+ # update new row
+ new_row.idx = row_number + 1
+ new_row.payment_request = None
+ new_row.allocated_amount = remaining_allocated_amount
+
+ NEW_ROW_ADDED = True
+ row_number += TO_SKIP_NEW_ROW
+
@frappe.whitelist(allow_guest=True)
def make_payment_request(**args):
@@ -435,6 +535,9 @@ def make_payment_request(**args):
args = frappe._dict(args)
+ if args.dt not in ALLOWED_DOCTYPES_FOR_PAYMENT_REQUEST:
+ frappe.throw(_("Payment Requests cannot be created against: {0}").format(frappe.bold(args.dt)))
+
ref_doc = frappe.get_doc(args.dt, args.dn)
gateway_account = get_gateway_details(args) or frappe._dict()
@@ -458,10 +561,38 @@ def make_payment_request(**args):
{"reference_doctype": args.dt, "reference_name": args.dn, "docstatus": 0},
)
- existing_payment_request_amount = get_existing_payment_request_amount(args.dt, args.dn)
+ # fetches existing payment request `grand_total` amount
+ existing_payment_request_amount = get_existing_payment_request_amount(ref_doc.doctype, ref_doc.name)
+
+ existing_paid_amount = get_existing_paid_amount(ref_doc.doctype, ref_doc.name)
+
+ def validate_and_calculate_grand_total(grand_total, existing_payment_request_amount):
+ grand_total -= existing_payment_request_amount
+ if not grand_total:
+ frappe.throw(_("Payment Request is already created"))
+ return grand_total
if existing_payment_request_amount:
- grand_total -= existing_payment_request_amount
+ if args.order_type == "Shopping Cart":
+ # If Payment Request is in an advanced stage, then create for remaining amount.
+ if get_existing_payment_request_amount(
+ ref_doc.doctype, ref_doc.name, ["Initiated", "Partially Paid", "Payment Ordered", "Paid"]
+ ):
+ grand_total = validate_and_calculate_grand_total(grand_total, existing_payment_request_amount)
+ else:
+ # If PR's are processed, cancel all of them.
+ cancel_old_payment_requests(ref_doc.doctype, ref_doc.name)
+ else:
+ grand_total = validate_and_calculate_grand_total(grand_total, existing_payment_request_amount)
+
+ if existing_paid_amount:
+ if ref_doc.party_account_currency == ref_doc.currency:
+ if ref_doc.conversion_rate:
+ grand_total -= flt(existing_paid_amount / ref_doc.conversion_rate)
+ else:
+ grand_total -= flt(existing_paid_amount)
+ else:
+ grand_total -= flt(existing_paid_amount / ref_doc.conversion_rate)
if draft_payment_request:
frappe.db.set_value(
@@ -476,6 +607,13 @@ def make_payment_request(**args):
"Outward" if args.get("dt") in ["Purchase Order", "Purchase Invoice"] else "Inward"
)
+ party_type = args.get("party_type") or "Customer"
+ party_account_currency = ref_doc.get("party_account_currency")
+
+ if not party_account_currency:
+ party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
+ party_account_currency = get_account_currency(party_account)
+
pr.update(
{
"payment_gateway_account": gateway_account.get("name"),
@@ -484,6 +622,7 @@ def make_payment_request(**args):
"payment_channel": gateway_account.get("payment_channel"),
"payment_request_type": args.get("payment_request_type"),
"currency": ref_doc.currency,
+ "party_account_currency": party_account_currency,
"grand_total": grand_total,
"mode_of_payment": args.mode_of_payment,
"email_to": args.recipient_id or ref_doc.owner,
@@ -491,9 +630,11 @@ def make_payment_request(**args):
"message": gateway_account.get("message") or get_dummy_message(ref_doc),
"reference_doctype": args.dt,
"reference_name": args.dn,
- "party_type": args.get("party_type") or "Customer",
+ "company": ref_doc.get("company"),
+ "party_type": party_type,
"party": args.get("party") or ref_doc.get("customer"),
"bank_account": bank_account,
+ "party_name": args.get("party_name") or ref_doc.get("customer_name"),
}
)
@@ -514,6 +655,8 @@ def make_payment_request(**args):
if frappe.db.get_single_value("Accounts Settings", "create_pr_in_draft_status", cache=True):
pr.insert(ignore_permissions=True)
if args.submit_doc:
+ if pr.get("__unsaved"):
+ pr.insert(ignore_permissions=True)
pr.submit()
if args.order_type == "Shopping Cart":
@@ -535,9 +678,11 @@ def get_amount(ref_doc, payment_account=None):
elif dt in ["Sales Invoice", "Purchase Invoice"]:
if not ref_doc.get("is_pos"):
if ref_doc.party_account_currency == ref_doc.currency:
- grand_total = flt(ref_doc.grand_total)
+ grand_total = flt(ref_doc.rounded_total or ref_doc.grand_total)
else:
- grand_total = flt(ref_doc.base_grand_total) / ref_doc.conversion_rate
+ grand_total = flt(
+ flt(ref_doc.base_rounded_total or ref_doc.base_grand_total) / ref_doc.conversion_rate
+ )
elif dt == "Sales Invoice":
for pay in ref_doc.payments:
if pay.type == "Phone" and pay.account == payment_account:
@@ -552,31 +697,94 @@ def get_amount(ref_doc, payment_account=None):
grand_total = ref_doc.outstanding_amount
if grand_total > 0:
- return grand_total
+ return flt(grand_total, get_currency_precision())
else:
frappe.throw(_("Payment Entry is already created"))
-def get_existing_payment_request_amount(ref_dt, ref_dn):
+def get_irequest_status(payment_requests: None | list = None) -> list:
+ IR = frappe.qb.DocType("Integration Request")
+ res = []
+ if payment_requests:
+ res = (
+ frappe.qb.from_(IR)
+ .select(IR.name)
+ .where(IR.reference_doctype.eq("Payment Request"))
+ .where(IR.reference_docname.isin(payment_requests))
+ .where(IR.status.isin(["Authorized", "Completed"]))
+ .run(as_dict=True)
+ )
+ return res
+
+
+def cancel_old_payment_requests(ref_dt, ref_dn):
+ PR = frappe.qb.DocType("Payment Request")
+
+ if res := (
+ frappe.qb.from_(PR)
+ .select(PR.name)
+ .where(PR.reference_doctype == ref_dt)
+ .where(PR.reference_name == ref_dn)
+ .where(PR.docstatus == 1)
+ .where(PR.status.isin(["Draft", "Requested"]))
+ .run(as_dict=True)
+ ):
+ if get_irequest_status([x.name for x in res]):
+ frappe.throw(_("Another Payment Request is already processed"))
+ else:
+ for x in res:
+ doc = frappe.get_doc("Payment Request", x.name)
+ doc.flags.ignore_permissions = True
+ doc.cancel()
+
+ if ireqs := get_irequests_of_payment_request(doc.name):
+ for ireq in ireqs:
+ frappe.db.set_value("Integration Request", ireq.name, "status", "Cancelled")
+
+
+def get_existing_payment_request_amount(ref_dt, ref_dn, statuses: list | None = None) -> list:
"""
- Get the existing payment request which are unpaid or partially paid for payment channel other than Phone
- and get the summation of existing paid payment request for Phone payment channel.
+ Return the total amount of Payment Requests against a reference document.
"""
- existing_payment_request_amount = frappe.db.sql(
- """
- select sum(grand_total)
- from `tabPayment Request`
- where
- reference_doctype = %s
- and reference_name = %s
- and docstatus = 1
- and (status != 'Paid'
- or (payment_channel = 'Phone'
- and status = 'Paid'))
- """,
- (ref_dt, ref_dn),
+ PR = frappe.qb.DocType("Payment Request")
+
+ query = (
+ frappe.qb.from_(PR)
+ .select(Sum(PR.grand_total))
+ .where(PR.reference_doctype == ref_dt)
+ .where(PR.reference_name == ref_dn)
+ .where(PR.docstatus == 1)
)
- return flt(existing_payment_request_amount[0][0]) if existing_payment_request_amount else 0
+
+ if statuses:
+ query = query.where(PR.status.isin(statuses))
+
+ response = query.run()
+
+ return response[0][0] if response[0] else 0
+
+
+def get_existing_paid_amount(doctype, name):
+ PL = frappe.qb.DocType("Payment Ledger Entry")
+ PER = frappe.qb.DocType("Payment Entry Reference")
+
+ query = (
+ frappe.qb.from_(PL)
+ .left_join(PER)
+ .on(
+ (PER.reference_doctype == PL.against_voucher_type) & (PER.reference_name == PL.against_voucher_no)
+ )
+ .select(Abs(Sum(PL.amount)).as_("total_paid_amount"))
+ .where(PL.against_voucher_type.eq(doctype))
+ .where(PL.against_voucher_no.eq(name))
+ .where(PL.amount < 0)
+ .where(PL.delinked == 0)
+ .where(PER.docstatus == 1)
+ .where(PER.payment_request.isnull())
+ )
+ response = query.run()
+
+ return response[0][0] if response[0] else 0
def get_gateway_details(args): # nosemgrep
@@ -623,41 +831,66 @@ def make_payment_entry(docname):
return doc.create_payment_entry(submit=False).as_dict()
-def update_payment_req_status(doc, method):
- from erpnext.accounts.doctype.payment_entry.payment_entry import get_reference_details
+def update_payment_requests_as_per_pe_references(references=None, cancel=False):
+ """
+ Update Payment Request's `Status` and `Outstanding Amount` based on Payment Entry Reference's `Allocated Amount`.
+ """
+ if not references:
+ return
- for ref in doc.references:
- payment_request_name = frappe.db.get_value(
- "Payment Request",
- {
- "reference_doctype": ref.reference_doctype,
- "reference_name": ref.reference_name,
- "docstatus": 1,
- },
+ precision = references[0].precision("allocated_amount")
+
+ referenced_payment_requests = frappe.get_all(
+ "Payment Request",
+ filters={"name": ["in", {row.payment_request for row in references if row.payment_request}]},
+ fields=[
+ "name",
+ "grand_total",
+ "outstanding_amount",
+ "payment_request_type",
+ ],
+ )
+
+ referenced_payment_requests = {pr.name: pr for pr in referenced_payment_requests}
+
+ for ref in references:
+ if not ref.payment_request:
+ continue
+
+ payment_request = referenced_payment_requests[ref.payment_request]
+ pr_outstanding = payment_request["outstanding_amount"]
+
+ # update outstanding amount
+ new_outstanding_amount = flt(
+ pr_outstanding + ref.allocated_amount if cancel else pr_outstanding - ref.allocated_amount,
+ precision,
)
- if payment_request_name:
- ref_details = get_reference_details(
- ref.reference_doctype,
- ref.reference_name,
- doc.party_account_currency,
- doc.party_type,
- doc.party,
+ # to handle same payment request for the multiple allocations
+ payment_request["outstanding_amount"] = new_outstanding_amount
+
+ if not cancel and new_outstanding_amount < 0:
+ frappe.throw(
+ msg=_(
+ "The allocated amount is greater than the outstanding amount of Payment Request {0}"
+ ).format(ref.payment_request),
+ title=_("Invalid Allocated Amount"),
)
- pay_req_doc = frappe.get_doc("Payment Request", payment_request_name)
- status = pay_req_doc.status
- if status != "Paid" and not ref_details.outstanding_amount:
- status = "Paid"
- elif status != "Partially Paid" and ref_details.outstanding_amount != ref_details.total_amount:
- status = "Partially Paid"
- elif ref_details.outstanding_amount == ref_details.total_amount:
- if pay_req_doc.payment_request_type == "Outward":
- status = "Initiated"
- elif pay_req_doc.payment_request_type == "Inward":
- status = "Requested"
+ # update status
+ if new_outstanding_amount == payment_request["grand_total"]:
+ status = "Initiated" if payment_request["payment_request_type"] == "Outward" else "Requested"
+ elif new_outstanding_amount == 0:
+ status = "Paid"
+ elif new_outstanding_amount > 0:
+ status = "Partially Paid"
- pay_req_doc.db_set("status", status)
+ # update database
+ frappe.db.set_value(
+ "Payment Request",
+ ref.payment_request,
+ {"outstanding_amount": new_outstanding_amount, "status": status},
+ )
def get_dummy_message(doc):
@@ -741,3 +974,45 @@ def validate_payment(doc, method=None):
doc.reference_docname
)
)
+
+
+@frappe.whitelist()
+def get_open_payment_requests_query(doctype, txt, searchfield, start, page_len, filters):
+ # permission checks in `get_list()`
+ filters = frappe._dict(filters)
+
+ if not filters.reference_doctype or not filters.reference_name:
+ return []
+
+ if txt:
+ filters.name = ["like", f"%{txt}%"]
+
+ open_payment_requests = frappe.get_list(
+ "Payment Request",
+ filters=filters,
+ fields=["name", "grand_total", "outstanding_amount"],
+ order_by="transaction_date ASC,creation ASC",
+ )
+
+ return [
+ (
+ pr.name,
+ _("Grand Total: {0}").format(pr.grand_total),
+ _("Outstanding Amount: {0}").format(pr.outstanding_amount),
+ )
+ for pr in open_payment_requests
+ ]
+
+
+def get_irequests_of_payment_request(doc: str | None = None) -> list:
+ res = []
+ if doc:
+ res = frappe.db.get_all(
+ "Integration Request",
+ {
+ "reference_doctype": "Payment Request",
+ "reference_docname": doc,
+ "status": "Queued",
+ },
+ )
+ return res
diff --git a/erpnext/accounts/doctype/payment_request/payment_request_dashboard.py b/erpnext/accounts/doctype/payment_request/payment_request_dashboard.py
new file mode 100644
index 00000000000..02ad5684792
--- /dev/null
+++ b/erpnext/accounts/doctype/payment_request/payment_request_dashboard.py
@@ -0,0 +1,14 @@
+from frappe import _
+
+
+def get_data():
+ return {
+ "fieldname": "payment_request",
+ "internal_links": {
+ "Payment Entry": ["references", "payment_request"],
+ "Payment Order": ["references", "payment_order"],
+ },
+ "transactions": [
+ {"label": _("Payment"), "items": ["Payment Entry", "Payment Order"]},
+ ],
+ }
diff --git a/erpnext/accounts/doctype/payment_request/payment_request_list.js b/erpnext/accounts/doctype/payment_request/payment_request_list.js
index 183ca7c4584..6e4aada66c6 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request_list.js
+++ b/erpnext/accounts/doctype/payment_request/payment_request_list.js
@@ -1,19 +1,18 @@
+const INDICATORS = {
+ "Partially Paid": "orange",
+ Cancelled: "red",
+ Draft: "red",
+ Failed: "red",
+ Initiated: "green",
+ Paid: "blue",
+ Requested: "green",
+};
+
frappe.listview_settings["Payment Request"] = {
add_fields: ["status"],
get_indicator: function (doc) {
- if (doc.status == "Draft") {
- return [__("Draft"), "gray", "status,=,Draft"];
- }
- if (doc.status == "Requested") {
- return [__("Requested"), "green", "status,=,Requested"];
- } else if (doc.status == "Initiated") {
- return [__("Initiated"), "green", "status,=,Initiated"];
- } else if (doc.status == "Partially Paid") {
- return [__("Partially Paid"), "orange", "status,=,Partially Paid"];
- } else if (doc.status == "Paid") {
- return [__("Paid"), "blue", "status,=,Paid"];
- } else if (doc.status == "Cancelled") {
- return [__("Cancelled"), "red", "status,=,Cancelled"];
- }
+ if (!doc.status || !INDICATORS[doc.status]) return;
+
+ return [__(doc.status), INDICATORS[doc.status], `status,=,${doc.status}`];
},
};
diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py
index 6d15f84d7cf..eadb714baa3 100644
--- a/erpnext/accounts/doctype/payment_request/test_payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py
@@ -1,11 +1,14 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
+import re
import unittest
import frappe
-from frappe.tests.utils import FrappeTestCase
+from frappe.tests.utils import FrappeTestCase, change_settings
+from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_terms_template
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
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
@@ -15,6 +18,7 @@ from erpnext.setup.utils import get_exchange_rate
test_dependencies = ["Currency Exchange", "Journal Entry", "Contact", "Address"]
+
payment_gateway = {"doctype": "Payment Gateway", "gateway": "_Test Gateway"}
payment_method = [
@@ -278,3 +282,291 @@ class TestPaymentRequest(FrappeTestCase):
self.assertEqual(pe.paid_amount, 800)
self.assertEqual(pe.base_received_amount, 800)
self.assertEqual(pe.received_amount, 10)
+
+ def test_multiple_payment_if_partially_paid_for_same_currency(self):
+ so = make_sales_order(currency="INR", qty=1, rate=1000)
+
+ pr = make_payment_request(
+ dt="Sales Order",
+ dn=so.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ self.assertEqual(pr.grand_total, 1000)
+ self.assertEqual(pr.outstanding_amount, pr.grand_total)
+ self.assertEqual(pr.party_account_currency, pr.currency) # INR
+
+ so.load_from_db()
+
+ # to make partial payment
+ pe = pr.create_payment_entry(submit=False)
+ pe.paid_amount = 200
+ pe.references[0].allocated_amount = 200
+ pe.submit()
+
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ so.load_from_db()
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Partially Paid")
+ self.assertEqual(pr.outstanding_amount, 800)
+ self.assertEqual(pr.grand_total, 1000)
+
+ # complete payment
+ pe = pr.create_payment_entry()
+
+ self.assertEqual(pe.paid_amount, 800) # paid amount set from pr's outstanding amount
+ self.assertEqual(pe.references[0].allocated_amount, 800)
+ self.assertEqual(pe.references[0].outstanding_amount, 800) # for Orders it is not zero
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ so.load_from_db()
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Paid")
+ self.assertEqual(pr.outstanding_amount, 0)
+ self.assertEqual(pr.grand_total, 1000)
+
+ # creating a more payment Request must not allowed
+ self.assertRaisesRegex(
+ frappe.exceptions.ValidationError,
+ re.compile(r"Payment Request is already created"),
+ make_payment_request,
+ dt="Sales Order",
+ dn=so.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ @change_settings("Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1})
+ def test_multiple_payment_if_partially_paid_for_multi_currency(self):
+ pi = make_purchase_invoice(currency="USD", conversion_rate=50, qty=1, rate=100, do_not_save=1)
+ pi.credit_to = "Creditors - _TC"
+ pi.submit()
+
+ pr = make_payment_request(
+ dt="Purchase Invoice",
+ dn=pi.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ # 100 USD -> 5000 INR
+ self.assertEqual(pr.grand_total, 100)
+ self.assertEqual(pr.outstanding_amount, 5000)
+ self.assertEqual(pr.currency, "USD")
+ self.assertEqual(pr.party_account_currency, "INR")
+ self.assertEqual(pr.status, "Initiated")
+
+ # to make partial payment
+ pe = pr.create_payment_entry(submit=False)
+ pe.paid_amount = 2000
+ pe.references[0].allocated_amount = 2000
+ pe.submit()
+
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Partially Paid")
+ self.assertEqual(pr.outstanding_amount, 3000)
+ self.assertEqual(pr.grand_total, 100)
+
+ # complete payment
+ pe = pr.create_payment_entry()
+ self.assertEqual(pe.paid_amount, 3000) # paid amount set from pr's outstanding amount
+ self.assertEqual(pe.references[0].allocated_amount, 3000)
+ self.assertEqual(pe.references[0].outstanding_amount, 0) # for Invoices it will zero
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Paid")
+ self.assertEqual(pr.outstanding_amount, 0)
+ self.assertEqual(pr.grand_total, 100)
+
+ # creating a more payment Request must not allowed
+ self.assertRaisesRegex(
+ frappe.exceptions.ValidationError,
+ re.compile(r"Payment Request is already created"),
+ make_payment_request,
+ dt="Purchase Invoice",
+ dn=pi.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ def test_single_payment_with_payment_term_for_same_currency(self):
+ create_payment_terms_template()
+
+ po = create_purchase_order(do_not_save=1, currency="INR", qty=1, rate=20000)
+ po.payment_terms_template = "Test Receivable Template" # 84.746 and 15.254
+ po.save()
+ po.submit()
+
+ pr = make_payment_request(
+ dt="Purchase Order",
+ dn=po.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ self.assertEqual(pr.grand_total, 20000)
+ self.assertEqual(pr.outstanding_amount, pr.grand_total)
+ self.assertEqual(pr.party_account_currency, pr.currency) # INR
+ self.assertEqual(pr.status, "Initiated")
+
+ po.load_from_db()
+
+ pe = pr.create_payment_entry()
+
+ self.assertEqual(len(pe.references), 2)
+ self.assertEqual(pe.paid_amount, 20000)
+
+ # check 1st payment term
+ self.assertEqual(pe.references[0].allocated_amount, 16949.2)
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ # check 2nd payment term
+ self.assertEqual(pe.references[1].allocated_amount, 3050.8)
+ self.assertEqual(pe.references[1].payment_request, pr.name)
+
+ po.load_from_db()
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Paid")
+ self.assertEqual(pr.outstanding_amount, 0)
+ self.assertEqual(pr.grand_total, 20000)
+
+ @change_settings("Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1})
+ def test_single_payment_with_payment_term_for_multi_currency(self):
+ create_payment_terms_template()
+
+ si = create_sales_invoice(
+ do_not_save=1, currency="USD", debit_to="Debtors - _TC", qty=1, rate=200, conversion_rate=50
+ )
+ si.payment_terms_template = "Test Receivable Template" # 84.746 and 15.254
+ si.save()
+ si.submit()
+
+ pr = make_payment_request(
+ dt="Sales Invoice",
+ dn=si.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ # 200 USD -> 10000 INR
+ self.assertEqual(pr.grand_total, 200)
+ self.assertEqual(pr.outstanding_amount, 10000)
+ self.assertEqual(pr.currency, "USD")
+ self.assertEqual(pr.party_account_currency, "INR")
+
+ pe = pr.create_payment_entry()
+ self.assertEqual(len(pe.references), 2)
+ self.assertEqual(pe.paid_amount, 10000)
+
+ # check 1st payment term
+ # convert it via dollar and conversion_rate
+ self.assertEqual(pe.references[0].allocated_amount, 8474.5) # multi currency conversion
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ # check 2nd payment term
+ self.assertEqual(pe.references[1].allocated_amount, 1525.5) # multi currency conversion
+ self.assertEqual(pe.references[1].payment_request, pr.name)
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Paid")
+ self.assertEqual(pr.outstanding_amount, 0)
+ self.assertEqual(pr.grand_total, 200)
+
+ def test_payment_cancel_process(self):
+ so = make_sales_order(currency="INR", qty=1, rate=1000)
+
+ pr = make_payment_request(
+ dt="Sales Order",
+ dn=so.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ self.assertEqual(pr.grand_total, 1000)
+ self.assertEqual(pr.outstanding_amount, pr.grand_total)
+
+ so.load_from_db()
+
+ pe = pr.create_payment_entry(submit=False)
+ pe.paid_amount = 800
+ pe.references[0].allocated_amount = 800
+ pe.submit()
+
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ so.load_from_db()
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Partially Paid")
+ self.assertEqual(pr.outstanding_amount, 200)
+ self.assertEqual(pr.grand_total, 1000)
+
+ # cancelling PE
+ pe.cancel()
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Requested")
+ self.assertEqual(pr.outstanding_amount, 1000)
+ self.assertEqual(pr.grand_total, 1000)
+
+ so.load_from_db()
+
+ def test_partial_paid_invoice_with_payment_request(self):
+ si = create_sales_invoice(currency="INR", qty=1, rate=5000)
+ si.save()
+ si.submit()
+
+ pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC")
+ pe.reference_no = "PAYEE0002"
+ pe.reference_date = frappe.utils.nowdate()
+ pe.paid_amount = 2500
+ pe.references[0].allocated_amount = 2500
+ pe.save()
+ pe.submit()
+
+ si.load_from_db()
+ pr = make_payment_request(dt="Sales Invoice", dn=si.name, mute_email=1)
+
+ self.assertEqual(pr.grand_total, si.outstanding_amount)
+
+
+def test_partial_paid_invoice_with_submitted_payment_entry(self):
+ pi = make_purchase_invoice(currency="INR", qty=1, rate=5000)
+ pi.save()
+ pi.submit()
+
+ pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Bank - _TC")
+ pe.reference_no = "PURINV0001"
+ pe.reference_date = frappe.utils.nowdate()
+ pe.paid_amount = 2500
+ pe.references[0].allocated_amount = 2500
+ pe.save()
+ pe.submit()
+ pe.cancel()
+
+ pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Bank - _TC")
+ pe.reference_no = "PURINV0002"
+ pe.reference_date = frappe.utils.nowdate()
+ pe.paid_amount = 2500
+ pe.references[0].allocated_amount = 2500
+ pe.save()
+ pe.submit()
+
+ pi.load_from_db()
+ pr = make_payment_request(dt="Purchase Invoice", dn=pi.name, mute_email=1)
+ self.assertEqual(pr.grand_total, pi.outstanding_amount)
diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js
index 82d8cb37fe7..095310c7e70 100644
--- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js
+++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js
@@ -19,6 +19,24 @@ frappe.ui.form.on("Period Closing Voucher", {
});
},
+ fiscal_year: function (frm) {
+ if (frm.doc.fiscal_year) {
+ frappe.call({
+ method: "erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.get_period_start_end_date",
+ args: {
+ fiscal_year: frm.doc.fiscal_year,
+ company: frm.doc.company,
+ },
+ callback: function (r) {
+ if (r.message) {
+ frm.set_value("period_start_date", r.message[0]);
+ frm.set_value("period_end_date", r.message[1]);
+ }
+ },
+ });
+ }
+ },
+
refresh: function (frm) {
if (frm.doc.docstatus > 0) {
frm.add_custom_button(
diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.json b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.json
index 624b5f82f64..f41cff0e0d8 100644
--- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.json
+++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.json
@@ -6,39 +6,32 @@
"engine": "InnoDB",
"field_order": [
"transaction_date",
- "posting_date",
- "fiscal_year",
- "year_start_date",
- "amended_from",
"company",
+ "fiscal_year",
+ "period_start_date",
+ "period_end_date",
+ "amended_from",
"column_break1",
"closing_account_head",
- "remarks",
"gle_processing_status",
+ "remarks",
"error_message"
],
"fields": [
{
+ "default": "Today",
"fieldname": "transaction_date",
"fieldtype": "Date",
"label": "Transaction Date",
"oldfieldname": "transaction_date",
"oldfieldtype": "Date"
},
- {
- "fieldname": "posting_date",
- "fieldtype": "Date",
- "label": "Posting Date",
- "oldfieldname": "posting_date",
- "oldfieldtype": "Date",
- "reqd": 1
- },
{
"fieldname": "fiscal_year",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
- "label": "Closing Fiscal Year",
+ "label": "Fiscal Year",
"oldfieldname": "fiscal_year",
"oldfieldtype": "Select",
"options": "Fiscal Year",
@@ -103,16 +96,25 @@
"read_only": 1
},
{
- "fieldname": "year_start_date",
+ "fieldname": "period_end_date",
"fieldtype": "Date",
- "label": "Year Start Date"
+ "label": "Period End Date",
+ "reqd": 1
+ },
+ {
+ "fieldname": "period_start_date",
+ "fieldtype": "Date",
+ "label": "Period Start Date",
+ "oldfieldname": "posting_date",
+ "oldfieldtype": "Date",
+ "reqd": 1
}
],
"icon": "fa fa-file-text",
"idx": 1,
"is_submittable": 1,
"links": [],
- "modified": "2023-09-11 20:19:11.810533",
+ "modified": "2024-09-15 17:22:45.291628",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Period Closing Voucher",
@@ -148,7 +150,7 @@
"write": 1
}
],
- "search_fields": "posting_date, fiscal_year",
+ "search_fields": "fiscal_year, period_start_date, period_end_date",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
index 9bc110d243e..7e0145e91a4 100644
--- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
+++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
@@ -2,15 +2,20 @@
# License: GNU General Public License v3. See license.txt
+import copy
+
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
-from frappe.utils import add_days, flt
+from frappe.utils import add_days, flt, formatdate, getdate
+from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
+ make_closing_entries,
+)
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
-from erpnext.accounts.utils import get_account_currency, get_fiscal_year, validate_fiscal_year
+from erpnext.accounts.utils import get_account_currency, get_fiscal_year
from erpnext.controllers.accounts_controller import AccountsController
@@ -29,38 +34,397 @@ class PeriodClosingVoucher(AccountsController):
error_message: DF.Text | None
fiscal_year: DF.Link
gle_processing_status: DF.Literal["In Progress", "Completed", "Failed"]
- posting_date: DF.Date
+ period_end_date: DF.Date
+ period_start_date: DF.Date
remarks: DF.SmallText
transaction_date: DF.Date | None
- year_start_date: DF.Date | None
# end: auto-generated types
def validate(self):
- self.validate_account_head()
- self.validate_posting_date()
+ self.validate_start_and_end_date()
+ self.check_if_previous_year_closed()
+ self.block_if_future_closing_voucher_exists()
+ self.check_closing_account_type()
+ self.check_closing_account_currency()
+
+ def validate_start_and_end_date(self):
+ self.fy_start_date, self.fy_end_date = frappe.db.get_value(
+ "Fiscal Year", self.fiscal_year, ["year_start_date", "year_end_date"]
+ )
+
+ prev_closed_period_end_date = get_previous_closed_period_in_current_year(
+ self.fiscal_year, self.company
+ )
+ valid_start_date = (
+ add_days(prev_closed_period_end_date, 1) if prev_closed_period_end_date else self.fy_start_date
+ )
+
+ if getdate(self.period_start_date) != getdate(valid_start_date):
+ frappe.throw(_("Period Start Date must be {0}").format(formatdate(valid_start_date)))
+
+ if getdate(self.period_start_date) > getdate(self.period_end_date):
+ frappe.throw(_("Period Start Date cannot be greater than Period End Date"))
+
+ if getdate(self.period_end_date) > getdate(self.fy_end_date):
+ frappe.throw(_("Period End Date cannot be greater than Fiscal Year End Date"))
+
+ def check_if_previous_year_closed(self):
+ last_year_closing = add_days(self.fy_start_date, -1)
+ previous_fiscal_year = get_fiscal_year(last_year_closing, company=self.company, boolean=True)
+ if not previous_fiscal_year:
+ return
+
+ previous_fiscal_year_start_date = previous_fiscal_year[0][1]
+ gle_exists_in_previous_year = frappe.db.exists(
+ "GL Entry",
+ {
+ "posting_date": ("between", [previous_fiscal_year_start_date, last_year_closing]),
+ "company": self.company,
+ "is_cancelled": 0,
+ },
+ )
+ if not gle_exists_in_previous_year:
+ return
+
+ previous_fiscal_year_closed = frappe.db.exists(
+ "Period Closing Voucher",
+ {
+ "period_end_date": ("between", [previous_fiscal_year_start_date, last_year_closing]),
+ "docstatus": 1,
+ "company": self.company,
+ },
+ )
+ if not previous_fiscal_year_closed:
+ frappe.throw(_("Previous Year is not closed, please close it first"))
+
+ def block_if_future_closing_voucher_exists(self):
+ future_closing_voucher = self.get_future_closing_voucher()
+ if future_closing_voucher and future_closing_voucher[0][0]:
+ action = "cancel" if self.docstatus == 2 else "create"
+ frappe.throw(
+ _(
+ "You cannot {0} this document because another Period Closing Entry {1} exists after {2}"
+ ).format(action, future_closing_voucher[0][0], self.period_end_date)
+ )
+
+ def get_future_closing_voucher(self):
+ return frappe.db.get_value(
+ "Period Closing Voucher",
+ {"period_end_date": (">", self.period_end_date), "docstatus": 1, "company": self.company},
+ "name",
+ )
+
+ def check_closing_account_type(self):
+ closing_account_type = frappe.get_cached_value("Account", self.closing_account_head, "root_type")
+
+ if closing_account_type not in ["Liability", "Equity"]:
+ frappe.throw(
+ _("Closing Account {0} must be of type Liability / Equity").format(self.closing_account_head)
+ )
+
+ def check_closing_account_currency(self):
+ account_currency = get_account_currency(self.closing_account_head)
+ company_currency = frappe.get_cached_value("Company", self.company, "default_currency")
+ if account_currency != company_currency:
+ frappe.throw(_("Currency of the Closing Account must be {0}").format(company_currency))
def on_submit(self):
self.db_set("gle_processing_status", "In Progress")
- get_opening_entries = False
-
- if not frappe.db.exists(
- "Period Closing Voucher", {"company": self.company, "docstatus": 1, "name": ("!=", self.name)}
- ):
- get_opening_entries = True
-
- self.make_gl_entries(get_opening_entries=get_opening_entries)
+ self.make_gl_entries()
def on_cancel(self):
- self.validate_future_closing_vouchers()
- self.db_set("gle_processing_status", "In Progress")
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
- gle_count = frappe.db.count(
- "GL Entry",
- {"voucher_type": "Period Closing Voucher", "voucher_no": self.name, "is_cancelled": 0},
- )
- if gle_count > 5000:
+ self.block_if_future_closing_voucher_exists()
+ self.db_set("gle_processing_status", "In Progress")
+ self.cancel_gl_entries()
+
+ def make_gl_entries(self):
+ if self.get_gle_count_in_selected_period() > 5000:
frappe.enqueue(
- make_reverse_gl_entries,
+ process_gl_and_closing_entries,
+ doc=self,
+ timeout=1800,
+ )
+ frappe.msgprint(
+ _(
+ "The GL Entries and closing balances will be processed in the background, it can take a few minutes."
+ ),
+ alert=True,
+ )
+ else:
+ process_gl_and_closing_entries(self)
+
+ def get_gle_count_in_selected_period(self):
+ return frappe.db.count(
+ "GL Entry",
+ {
+ "posting_date": ["between", [self.period_start_date, self.period_end_date]],
+ "company": self.company,
+ "is_cancelled": 0,
+ },
+ )
+
+ def get_pcv_gl_entries(self):
+ self.pl_accounts_reverse_gle = []
+ self.closing_account_gle = []
+
+ pl_account_balances = self.get_account_balances_based_on_dimensions(report_type="Profit and Loss")
+ for dimensions, account_balances in pl_account_balances.items():
+ for acc, balances in account_balances.items():
+ balance_in_company_currency = flt(balances.debit) - flt(balances.credit)
+ if balance_in_company_currency and acc != "balances":
+ self.pl_accounts_reverse_gle.append(
+ self.get_gle_for_pl_account(acc, balances, dimensions)
+ )
+
+ # closing liability account
+ self.closing_account_gle.append(
+ self.get_gle_for_closing_account(account_balances["balances"], dimensions)
+ )
+
+ return self.pl_accounts_reverse_gle + self.closing_account_gle
+
+ def get_gle_for_pl_account(self, acc, balances, dimensions):
+ balance_in_account_currency = flt(balances.debit_in_account_currency) - flt(
+ balances.credit_in_account_currency
+ )
+ balance_in_company_currency = flt(balances.debit) - flt(balances.credit)
+ gl_entry = frappe._dict(
+ {
+ "company": self.company,
+ "posting_date": self.period_end_date,
+ "account": acc,
+ "account_currency": balances.account_currency,
+ "debit_in_account_currency": abs(balance_in_account_currency)
+ if balance_in_account_currency < 0
+ else 0,
+ "debit": abs(balance_in_company_currency) if balance_in_company_currency < 0 else 0,
+ "credit_in_account_currency": abs(balance_in_account_currency)
+ if balance_in_account_currency > 0
+ else 0,
+ "credit": abs(balance_in_company_currency) if balance_in_company_currency > 0 else 0,
+ "is_period_closing_voucher_entry": 1,
+ "voucher_type": "Period Closing Voucher",
+ "voucher_no": self.name,
+ "fiscal_year": self.fiscal_year,
+ "remarks": self.remarks,
+ "is_opening": "No",
+ }
+ )
+ self.update_default_dimensions(gl_entry, dimensions)
+ return gl_entry
+
+ def get_gle_for_closing_account(self, dimension_balance, dimensions):
+ balance_in_account_currency = flt(dimension_balance.balance_in_account_currency)
+ balance_in_company_currency = flt(dimension_balance.balance_in_company_currency)
+ gl_entry = frappe._dict(
+ {
+ "company": self.company,
+ "posting_date": self.period_end_date,
+ "account": self.closing_account_head,
+ "account_currency": frappe.db.get_value(
+ "Account", self.closing_account_head, "account_currency"
+ ),
+ "debit_in_account_currency": balance_in_account_currency
+ if balance_in_account_currency > 0
+ else 0,
+ "debit": balance_in_company_currency if balance_in_company_currency > 0 else 0,
+ "credit_in_account_currency": abs(balance_in_account_currency)
+ if balance_in_account_currency < 0
+ else 0,
+ "credit": abs(balance_in_company_currency) if balance_in_company_currency < 0 else 0,
+ "is_period_closing_voucher_entry": 1,
+ "voucher_type": "Period Closing Voucher",
+ "voucher_no": self.name,
+ "fiscal_year": self.fiscal_year,
+ "remarks": self.remarks,
+ "is_opening": "No",
+ }
+ )
+ self.update_default_dimensions(gl_entry, dimensions)
+ return gl_entry
+
+ def update_default_dimensions(self, gl_entry, dimensions):
+ for i, dimension in enumerate(self.accounting_dimension_fields):
+ gl_entry[dimension] = dimensions[i]
+
+ def get_account_balances_based_on_dimensions(self, report_type):
+ """Get balance for dimension-wise pl accounts"""
+ self.get_accounting_dimension_fields()
+ acc_bal_dict = frappe._dict()
+ gl_entries = []
+
+ with frappe.db.unbuffered_cursor():
+ gl_entries = self.get_gl_entries_for_current_period(report_type, as_iterator=True)
+ for gle in gl_entries:
+ acc_bal_dict = self.set_account_balance_dict(gle, acc_bal_dict)
+
+ if report_type == "Balance Sheet" and self.is_first_period_closing_voucher():
+ opening_entries = self.get_gl_entries_for_current_period(report_type, only_opening_entries=True)
+ for gle in opening_entries:
+ acc_bal_dict = self.set_account_balance_dict(gle, acc_bal_dict)
+
+ return acc_bal_dict
+
+ def get_accounting_dimension_fields(self):
+ default_dimensions = ["cost_center", "finance_book", "project"]
+ self.accounting_dimension_fields = default_dimensions + get_accounting_dimensions()
+
+ def get_gl_entries_for_current_period(self, report_type, only_opening_entries=False, as_iterator=False):
+ date_condition = ""
+ if only_opening_entries:
+ date_condition = "is_opening = 'Yes'"
+ else:
+ date_condition = f"posting_date BETWEEN '{self.period_start_date}' AND '{self.period_end_date}' and is_opening = 'No'"
+
+ # nosemgrep
+ return frappe.db.sql(
+ """
+ SELECT
+ name,
+ posting_date,
+ account,
+ account_currency,
+ debit_in_account_currency,
+ credit_in_account_currency,
+ debit,
+ credit,
+ {}
+ FROM `tabGL Entry`
+ WHERE
+ {}
+ AND company = %s
+ AND voucher_type != 'Period Closing Voucher'
+ AND EXISTS(SELECT name FROM `tabAccount` WHERE name = account AND report_type = %s)
+ AND is_cancelled = 0
+ """.format(
+ ", ".join(self.accounting_dimension_fields),
+ date_condition,
+ ),
+ (self.company, report_type),
+ as_dict=1,
+ as_iterator=as_iterator,
+ )
+
+ def set_account_balance_dict(self, gle, acc_bal_dict):
+ key = self.get_key(gle)
+
+ acc_bal_dict.setdefault(key, frappe._dict()).setdefault(
+ gle.account,
+ frappe._dict(
+ {
+ "debit_in_account_currency": 0,
+ "credit_in_account_currency": 0,
+ "debit": 0,
+ "credit": 0,
+ "account_currency": gle.account_currency,
+ }
+ ),
+ )
+
+ acc_bal_dict[key][gle.account].debit_in_account_currency += flt(gle.debit_in_account_currency)
+ acc_bal_dict[key][gle.account].credit_in_account_currency += flt(gle.credit_in_account_currency)
+ acc_bal_dict[key][gle.account].debit += flt(gle.debit)
+ acc_bal_dict[key][gle.account].credit += flt(gle.credit)
+
+ # dimension-wise total balances
+ acc_bal_dict[key].setdefault(
+ "balances",
+ frappe._dict(
+ {
+ "balance_in_account_currency": 0,
+ "balance_in_company_currency": 0,
+ }
+ ),
+ )
+
+ balance_in_account_currency = flt(gle.debit_in_account_currency) - flt(gle.credit_in_account_currency)
+ balance_in_company_currency = flt(gle.debit) - flt(gle.credit)
+
+ acc_bal_dict[key]["balances"].balance_in_account_currency += balance_in_account_currency
+ acc_bal_dict[key]["balances"].balance_in_company_currency += balance_in_company_currency
+
+ return acc_bal_dict
+
+ def get_key(self, gle):
+ return tuple([gle.get(dimension) for dimension in self.accounting_dimension_fields])
+
+ def get_account_closing_balances(self):
+ pl_closing_entries = self.get_closing_entries_for_pl_accounts()
+ bs_closing_entries = self.get_closing_entries_for_balance_sheet_accounts()
+ closing_entries_for_closing_account = self.get_closing_entries_for_closing_account()
+ closing_entries = pl_closing_entries + bs_closing_entries + closing_entries_for_closing_account
+ return closing_entries
+
+ def get_closing_entries_for_pl_accounts(self):
+ closing_entries = copy.deepcopy(self.pl_accounts_reverse_gle)
+ for d in self.pl_accounts_reverse_gle:
+ # reverse debit and credit
+ gle_copy = copy.deepcopy(d)
+ gle_copy.debit = d.credit
+ gle_copy.credit = d.debit
+ gle_copy.debit_in_account_currency = d.credit_in_account_currency
+ gle_copy.credit_in_account_currency = d.debit_in_account_currency
+ gle_copy.is_period_closing_voucher_entry = 0
+ gle_copy.period_closing_voucher = self.name
+ closing_entries.append(gle_copy)
+
+ return closing_entries
+
+ def get_closing_entries_for_balance_sheet_accounts(self):
+ closing_entries = []
+ balance_sheet_account_balances = self.get_account_balances_based_on_dimensions(
+ report_type="Balance Sheet"
+ )
+
+ for dimensions, account_balances in balance_sheet_account_balances.items():
+ for acc, balances in account_balances.items():
+ balance_in_company_currency = flt(balances.debit) - flt(balances.credit)
+ if acc != "balances" and balance_in_company_currency:
+ closing_entries.append(self.get_closing_entry(acc, balances, dimensions))
+
+ return closing_entries
+
+ def get_closing_entry(self, account, balances, dimensions):
+ closing_entry = frappe._dict(
+ {
+ "company": self.company,
+ "closing_date": self.period_end_date,
+ "period_closing_voucher": self.name,
+ "account": account,
+ "account_currency": balances.account_currency,
+ "debit_in_account_currency": flt(balances.debit_in_account_currency),
+ "debit": flt(balances.debit),
+ "credit_in_account_currency": flt(balances.credit_in_account_currency),
+ "credit": flt(balances.credit),
+ "is_period_closing_voucher_entry": 0,
+ }
+ )
+ self.update_default_dimensions(closing_entry, dimensions)
+ return closing_entry
+
+ def get_closing_entries_for_closing_account(self):
+ closing_entries = copy.deepcopy(self.closing_account_gle)
+ for d in closing_entries:
+ d.period_closing_voucher = self.name
+
+ return closing_entries
+
+ def is_first_period_closing_voucher(self):
+ first_pcv = frappe.db.get_value(
+ "Period Closing Voucher",
+ {"company": self.company, "docstatus": 1},
+ "name",
+ order_by="period_end_date asc",
+ )
+
+ if not first_pcv or first_pcv == self.name:
+ return True
+
+ def cancel_gl_entries(self):
+ if self.get_gle_count_against_current_pcv() > 5000:
+ frappe.enqueue(
+ process_cancellation,
voucher_type="Period Closing Voucher",
voucher_no=self.name,
queue="long",
@@ -71,341 +435,74 @@ class PeriodClosingVoucher(AccountsController):
alert=True,
)
else:
- make_reverse_gl_entries(voucher_type="Period Closing Voucher", voucher_no=self.name)
+ process_cancellation(voucher_type="Period Closing Voucher", voucher_no=self.name)
- self.delete_closing_entries()
-
- def validate_future_closing_vouchers(self):
- if frappe.db.exists(
- "Period Closing Voucher",
- {"posting_date": (">", self.posting_date), "docstatus": 1, "company": self.company},
- ):
- frappe.throw(
- _(
- "You can not cancel this Period Closing Voucher, please cancel the future Period Closing Vouchers first"
- )
- )
-
- def delete_closing_entries(self):
- closing_balance = frappe.qb.DocType("Account Closing Balance")
- frappe.qb.from_(closing_balance).delete().where(
- closing_balance.period_closing_voucher == self.name
- ).run()
-
- def validate_account_head(self):
- closing_account_type = frappe.get_cached_value("Account", self.closing_account_head, "root_type")
-
- if closing_account_type not in ["Liability", "Equity"]:
- frappe.throw(
- _("Closing Account {0} must be of type Liability / Equity").format(self.closing_account_head)
- )
-
- account_currency = get_account_currency(self.closing_account_head)
- company_currency = frappe.get_cached_value("Company", self.company, "default_currency")
- if account_currency != company_currency:
- frappe.throw(_("Currency of the Closing Account must be {0}").format(company_currency))
-
- def validate_posting_date(self):
- validate_fiscal_year(
- self.posting_date, self.fiscal_year, self.company, label=_("Posting Date"), doc=self
- )
-
- self.year_start_date = get_fiscal_year(self.posting_date, self.fiscal_year, company=self.company)[1]
-
- self.check_if_previous_year_closed()
-
- pcv = frappe.qb.DocType("Period Closing Voucher")
- existing_entry = (
- frappe.qb.from_(pcv)
- .select(pcv.name)
- .where(
- (pcv.posting_date >= self.posting_date)
- & (pcv.fiscal_year == self.fiscal_year)
- & (pcv.docstatus == 1)
- & (pcv.company == self.company)
- )
- .run()
- )
-
- if existing_entry and existing_entry[0][0]:
- frappe.throw(
- _("Another Period Closing Entry {0} has been made after {1}").format(
- existing_entry[0][0], self.posting_date
- )
- )
-
- def check_if_previous_year_closed(self):
- last_year_closing = add_days(self.year_start_date, -1)
- previous_fiscal_year = get_fiscal_year(last_year_closing, company=self.company, boolean=True)
- if not previous_fiscal_year:
- return
-
- previous_fiscal_year_start_date = previous_fiscal_year[0][1]
- if not frappe.db.exists(
+ def get_gle_count_against_current_pcv(self):
+ return frappe.db.count(
"GL Entry",
- {
- "posting_date": ("between", [previous_fiscal_year_start_date, last_year_closing]),
- "company": self.company,
- "is_cancelled": 0,
- },
- ):
- return
-
- if not frappe.db.exists(
- "Period Closing Voucher",
- {
- "posting_date": ("between", [previous_fiscal_year_start_date, last_year_closing]),
- "docstatus": 1,
- "company": self.company,
- },
- ):
- frappe.throw(_("Previous Year is not closed, please close it first"))
-
- def make_gl_entries(self, get_opening_entries=False):
- gl_entries = self.get_gl_entries()
- closing_entries = self.get_grouped_gl_entries(get_opening_entries=get_opening_entries)
- if len(gl_entries + closing_entries) > 3000:
- frappe.enqueue(
- process_gl_entries,
- gl_entries=gl_entries,
- voucher_name=self.name,
- timeout=3000,
- )
-
- frappe.enqueue(
- process_closing_entries,
- gl_entries=gl_entries,
- closing_entries=closing_entries,
- voucher_name=self.name,
- company=self.company,
- closing_date=self.posting_date,
- timeout=3000,
- )
-
- frappe.msgprint(
- _("The GL Entries will be processed in the background, it can take a few minutes."),
- alert=True,
- )
- else:
- process_gl_entries(gl_entries, self.name)
- process_closing_entries(gl_entries, closing_entries, self.name, self.company, self.posting_date)
-
- def get_grouped_gl_entries(self, get_opening_entries=False):
- closing_entries = []
- for acc in self.get_balances_based_on_dimensions(
- group_by_account=True, for_aggregation=True, get_opening_entries=get_opening_entries
- ):
- closing_entries.append(self.get_closing_entries(acc))
-
- return closing_entries
-
- def get_gl_entries(self):
- gl_entries = []
-
- # pl account
- for acc in self.get_balances_based_on_dimensions(
- group_by_account=True, report_type="Profit and Loss"
- ):
- if flt(acc.bal_in_company_currency):
- gl_entries.append(self.get_gle_for_pl_account(acc))
-
- # closing liability account
- for acc in self.get_balances_based_on_dimensions(
- group_by_account=False, report_type="Profit and Loss"
- ):
- if flt(acc.bal_in_company_currency):
- gl_entries.append(self.get_gle_for_closing_account(acc))
-
- return gl_entries
-
- def get_gle_for_pl_account(self, acc):
- gl_entry = self.get_gl_dict(
- {
- "company": self.company,
- "closing_date": self.posting_date,
- "account": acc.account,
- "cost_center": acc.cost_center,
- "finance_book": acc.finance_book,
- "account_currency": acc.account_currency,
- "debit_in_account_currency": abs(flt(acc.bal_in_account_currency))
- if flt(acc.bal_in_account_currency) < 0
- else 0,
- "debit": abs(flt(acc.bal_in_company_currency)) if flt(acc.bal_in_company_currency) < 0 else 0,
- "credit_in_account_currency": abs(flt(acc.bal_in_account_currency))
- if flt(acc.bal_in_account_currency) > 0
- else 0,
- "credit": abs(flt(acc.bal_in_company_currency))
- if flt(acc.bal_in_company_currency) > 0
- else 0,
- "is_period_closing_voucher_entry": 1,
- },
- item=acc,
- )
- self.update_default_dimensions(gl_entry, acc)
- return gl_entry
-
- def get_gle_for_closing_account(self, acc):
- gl_entry = self.get_gl_dict(
- {
- "company": self.company,
- "closing_date": self.posting_date,
- "account": self.closing_account_head,
- "cost_center": acc.cost_center,
- "finance_book": acc.finance_book,
- "account_currency": acc.account_currency,
- "debit_in_account_currency": abs(flt(acc.bal_in_account_currency))
- if flt(acc.bal_in_account_currency) > 0
- else 0,
- "debit": abs(flt(acc.bal_in_company_currency)) if flt(acc.bal_in_company_currency) > 0 else 0,
- "credit_in_account_currency": abs(flt(acc.bal_in_account_currency))
- if flt(acc.bal_in_account_currency) < 0
- else 0,
- "credit": abs(flt(acc.bal_in_company_currency))
- if flt(acc.bal_in_company_currency) < 0
- else 0,
- "is_period_closing_voucher_entry": 1,
- },
- item=acc,
- )
- self.update_default_dimensions(gl_entry, acc)
- return gl_entry
-
- def get_closing_entries(self, acc):
- closing_entry = self.get_gl_dict(
- {
- "company": self.company,
- "closing_date": self.posting_date,
- "period_closing_voucher": self.name,
- "account": acc.account,
- "cost_center": acc.cost_center,
- "finance_book": acc.finance_book,
- "account_currency": acc.account_currency,
- "debit_in_account_currency": flt(acc.debit_in_account_currency),
- "debit": flt(acc.debit),
- "credit_in_account_currency": flt(acc.credit_in_account_currency),
- "credit": flt(acc.credit),
- },
- item=acc,
+ {"voucher_type": "Period Closing Voucher", "voucher_no": self.name, "is_cancelled": 0},
)
- for dimension in self.accounting_dimensions:
- closing_entry.update({dimension: acc.get(dimension)})
- return closing_entry
-
- def update_default_dimensions(self, gl_entry, acc):
- if not self.accounting_dimensions:
- self.accounting_dimensions = get_accounting_dimensions()
-
- for dimension in self.accounting_dimensions:
- gl_entry.update({dimension: acc.get(dimension)})
-
- def get_balances_based_on_dimensions(
- self, group_by_account=False, report_type=None, for_aggregation=False, get_opening_entries=False
- ):
- """Get balance for dimension-wise pl accounts"""
-
- qb_dimension_fields = ["cost_center", "finance_book", "project"]
-
- self.accounting_dimensions = get_accounting_dimensions()
- for dimension in self.accounting_dimensions:
- qb_dimension_fields.append(dimension)
-
- if group_by_account:
- qb_dimension_fields.append("account")
-
- account_filters = {
- "company": self.company,
- "is_group": 0,
- }
-
- if report_type:
- account_filters.update({"report_type": report_type})
-
- accounts = frappe.get_all("Account", filters=account_filters, pluck="name")
-
- gl_entry = frappe.qb.DocType("GL Entry")
- query = frappe.qb.from_(gl_entry).select(gl_entry.account, gl_entry.account_currency)
-
- if not for_aggregation:
- query = query.select(
- (Sum(gl_entry.debit_in_account_currency) - Sum(gl_entry.credit_in_account_currency)).as_(
- "bal_in_account_currency"
- ),
- (Sum(gl_entry.debit) - Sum(gl_entry.credit)).as_("bal_in_company_currency"),
- )
- else:
- query = query.select(
- (Sum(gl_entry.debit_in_account_currency)).as_("debit_in_account_currency"),
- (Sum(gl_entry.credit_in_account_currency)).as_("credit_in_account_currency"),
- (Sum(gl_entry.debit)).as_("debit"),
- (Sum(gl_entry.credit)).as_("credit"),
- )
-
- for dimension in qb_dimension_fields:
- query = query.select(gl_entry[dimension])
-
- query = query.where(
- (gl_entry.company == self.company)
- & (gl_entry.is_cancelled == 0)
- & (gl_entry.account.isin(accounts))
- )
-
- if get_opening_entries:
- query = query.where(
- gl_entry.posting_date.between(self.get("year_start_date"), self.posting_date)
- | gl_entry.is_opening
- == "Yes"
- )
- else:
- query = query.where(
- gl_entry.posting_date.between(self.get("year_start_date"), self.posting_date)
- & gl_entry.is_opening
- == "No"
- )
-
- if for_aggregation:
- query = query.where(gl_entry.voucher_type != "Period Closing Voucher")
-
- for dimension in qb_dimension_fields:
- query = query.groupby(gl_entry[dimension])
-
- return query.run(as_dict=1)
-
-
-def process_gl_entries(gl_entries, voucher_name):
+def process_gl_and_closing_entries(doc):
from erpnext.accounts.general_ledger import make_gl_entries
try:
+ gl_entries = doc.get_pcv_gl_entries()
if gl_entries:
make_gl_entries(gl_entries, merge_entries=False)
- frappe.db.set_value("Period Closing Voucher", voucher_name, "gle_processing_status", "Completed")
+
+ closing_entries = doc.get_account_closing_balances()
+ make_closing_entries(closing_entries, doc.name, doc.company, doc.period_end_date)
+
+ frappe.db.set_value(doc.doctype, doc.name, "gle_processing_status", "Completed")
except Exception as e:
frappe.db.rollback()
frappe.log_error(e)
- frappe.db.set_value("Period Closing Voucher", voucher_name, "gle_processing_status", "Failed")
+ frappe.db.set_value(doc.doctype, doc.name, "gle_processing_status", "Failed")
-def process_closing_entries(gl_entries, closing_entries, voucher_name, company, closing_date):
- from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
- make_closing_entries,
- )
-
- try:
- if gl_entries + closing_entries:
- make_closing_entries(gl_entries + closing_entries, voucher_name, company, closing_date)
- except Exception as e:
- frappe.db.rollback()
- frappe.log_error(e)
-
-
-def make_reverse_gl_entries(voucher_type, voucher_no):
+def process_cancellation(voucher_type, voucher_no):
from erpnext.accounts.general_ledger import make_reverse_gl_entries
try:
make_reverse_gl_entries(voucher_type=voucher_type, voucher_no=voucher_no)
+ delete_closing_entries(voucher_no)
frappe.db.set_value("Period Closing Voucher", voucher_no, "gle_processing_status", "Completed")
except Exception as e:
frappe.db.rollback()
frappe.log_error(e)
frappe.db.set_value("Period Closing Voucher", voucher_no, "gle_processing_status", "Failed")
+
+
+def delete_closing_entries(voucher_no):
+ closing_balance = frappe.qb.DocType("Account Closing Balance")
+ frappe.qb.from_(closing_balance).delete().where(
+ closing_balance.period_closing_voucher == voucher_no
+ ).run()
+
+
+@frappe.whitelist()
+def get_period_start_end_date(fiscal_year, company):
+ fy_start_date, fy_end_date = frappe.db.get_value(
+ "Fiscal Year", fiscal_year, ["year_start_date", "year_end_date"]
+ )
+ prev_closed_period_end_date = get_previous_closed_period_in_current_year(fiscal_year, company)
+ period_start_date = (
+ add_days(prev_closed_period_end_date, 1) if prev_closed_period_end_date else fy_start_date
+ )
+ return period_start_date, fy_end_date
+
+
+def get_previous_closed_period_in_current_year(fiscal_year, company):
+ prev_closed_period_end_date = frappe.db.get_value(
+ "Period Closing Voucher",
+ filters={
+ "company": company,
+ "fiscal_year": fiscal_year,
+ "docstatus": 1,
+ },
+ fieldname=["period_end_date"],
+ order_by="period_end_date desc",
+ )
+ return prev_closed_period_end_date
diff --git a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py
index 1bd565e1b36..e9d65f7f856 100644
--- a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py
+++ b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py
@@ -317,16 +317,18 @@ class TestPeriodClosingVoucher(unittest.TestCase):
repost_doc.posting_date = today()
repost_doc.save()
- def make_period_closing_voucher(self, posting_date=None, submit=True):
+ def make_period_closing_voucher(self, posting_date, submit=True):
surplus_account = create_account()
cost_center = create_cost_center("Test Cost Center 1")
+ fy = get_fiscal_year(posting_date, company="Test PCV Company")
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": posting_date or today(),
- "posting_date": posting_date or today(),
+ "period_start_date": fy[1],
+ "period_end_date": fy[2],
"company": "Test PCV Company",
- "fiscal_year": get_fiscal_year(today(), company="Test PCV Company")[0],
+ "fiscal_year": fy[0],
"cost_center": cost_center,
"closing_account_head": surplus_account,
"remarks": "test",
diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js
index 68a85f7ee96..7504c79141b 100644
--- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js
+++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js
@@ -80,8 +80,10 @@ frappe.ui.form.on("POS Closing Entry", {
) {
reset_values(frm);
frappe.run_serially([
+ () => frappe.dom.freeze(__("Loading Invoices! Please Wait...")),
() => frm.trigger("set_opening_amounts"),
() => frm.trigger("get_pos_invoices"),
+ () => frappe.dom.unfreeze(),
]);
}
},
@@ -145,7 +147,7 @@ frappe.ui.form.on("POS Closing Entry", {
frm.doc.grand_total += flt(doc.grand_total);
frm.doc.net_total += flt(doc.net_total);
frm.doc.total_quantity += flt(doc.total_qty);
- refresh_payments(doc, frm);
+ refresh_payments(doc, frm, false);
refresh_taxes(doc, frm);
refresh_fields(frm);
set_html_data(frm);
@@ -170,7 +172,7 @@ function set_form_data(data, frm) {
frm.doc.grand_total += flt(d.grand_total);
frm.doc.net_total += flt(d.net_total);
frm.doc.total_quantity += flt(d.total_qty);
- refresh_payments(d, frm);
+ refresh_payments(d, frm, true);
refresh_taxes(d, frm);
});
}
@@ -184,7 +186,7 @@ function add_to_pos_transaction(d, frm) {
});
}
-function refresh_payments(d, frm) {
+function refresh_payments(d, frm, is_new) {
d.payments.forEach((p) => {
const payment = frm.doc.payment_reconciliation.find(
(pay) => pay.mode_of_payment === p.mode_of_payment
@@ -194,7 +196,7 @@ function refresh_payments(d, frm) {
}
if (payment) {
payment.expected_amount += flt(p.amount);
- payment.closing_amount = payment.expected_amount;
+ if (is_new) payment.closing_amount = payment.expected_amount;
payment.difference = payment.closing_amount - payment.expected_amount;
} else {
frm.add_child("payment_reconciliation", {
diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py
index 9faf8693a8a..fda868cfe51 100644
--- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py
+++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py
@@ -87,19 +87,15 @@ class POSClosingEntry(StatusUpdater):
as_dict=1,
)[0]
if pos_invoice.consolidated_invoice:
- invalid_row.setdefault("msg", []).append(
- _("POS Invoice is {}").format(frappe.bold("already consolidated"))
- )
+ invalid_row.setdefault("msg", []).append(_("POS Invoice is already consolidated"))
invalid_rows.append(invalid_row)
continue
if pos_invoice.pos_profile != self.pos_profile:
invalid_row.setdefault("msg", []).append(
- _("POS Profile doesn't matches {}").format(frappe.bold(self.pos_profile))
+ _("POS Profile doesn't match {}").format(frappe.bold(self.pos_profile))
)
if pos_invoice.docstatus != 1:
- invalid_row.setdefault("msg", []).append(
- _("POS Invoice is not {}").format(frappe.bold("submitted"))
- )
+ invalid_row.setdefault("msg", []).append(_("POS Invoice is not submitted"))
if pos_invoice.owner != self.user:
invalid_row.setdefault("msg", []).append(
_("POS Invoice isn't created by user {}").format(frappe.bold(self.owner))
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js
index a6e8bfa6286..6a537a2559a 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js
@@ -40,10 +40,24 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex
};
});
+ this.frm.set_query("item_code", "items", function (doc) {
+ return {
+ query: "erpnext.accounts.doctype.pos_invoice.pos_invoice.item_query",
+ filters: {
+ has_variants: ["=", 0],
+ is_sales_item: ["=", 1],
+ disabled: ["=", 0],
+ is_fixed_asset: ["=", 0],
+ pos_profile: ["=", doc.pos_profile],
+ },
+ };
+ });
+
erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype);
}
onload_post_render(frm) {
+ super.onload_post_render();
this.pos_profile(frm);
}
@@ -51,7 +65,7 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex
super.refresh();
if (doc.docstatus == 1 && !doc.is_return) {
- this.frm.add_custom_button(__("Return"), this.make_sales_return, __("Create"));
+ this.frm.add_custom_button(__("Return"), this.make_sales_return.bind(this), __("Create"));
this.frm.page.set_inner_btn_group_as_primary(__("Create"));
}
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json
index d7b173667ec..42861140494 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json
@@ -48,6 +48,7 @@
"shipping_address",
"company_address",
"company_address_display",
+ "company_contact_person",
"currency_and_price_list",
"currency",
"conversion_rate",
@@ -1558,12 +1559,19 @@
"fieldname": "update_billed_amount_in_delivery_note",
"fieldtype": "Check",
"label": "Update Billed Amount in Delivery Note"
+ },
+ {
+ "fieldname": "company_contact_person",
+ "fieldtype": "Link",
+ "label": "Company Contact Person",
+ "options": "Contact",
+ "print_hide": 1
}
],
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
- "modified": "2024-03-20 16:00:34.268756",
+ "modified": "2024-11-26 13:10:50.309570",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
index ef4db1dac98..ab5a4092c33 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
@@ -6,6 +6,7 @@ import frappe
from frappe import _, bold
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate
+from frappe.utils.nestedset import get_descendants_of
from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_loyalty_points
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
@@ -15,6 +16,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
update_multi_mode_option,
)
from erpnext.accounts.party import get_due_date, get_party_account
+from erpnext.controllers.queries import item_query as _item_query
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
@@ -30,12 +32,8 @@ class POSInvoice(SalesInvoice):
from erpnext.accounts.doctype.payment_schedule.payment_schedule import PaymentSchedule
from erpnext.accounts.doctype.pos_invoice_item.pos_invoice_item import POSInvoiceItem
from erpnext.accounts.doctype.pricing_rule_detail.pricing_rule_detail import PricingRuleDetail
- from erpnext.accounts.doctype.sales_invoice_advance.sales_invoice_advance import (
- SalesInvoiceAdvance,
- )
- from erpnext.accounts.doctype.sales_invoice_payment.sales_invoice_payment import (
- SalesInvoicePayment,
- )
+ from erpnext.accounts.doctype.sales_invoice_advance.sales_invoice_advance import SalesInvoiceAdvance
+ from erpnext.accounts.doctype.sales_invoice_payment.sales_invoice_payment import SalesInvoicePayment
from erpnext.accounts.doctype.sales_invoice_timesheet.sales_invoice_timesheet import (
SalesInvoiceTimesheet,
)
@@ -73,6 +71,7 @@ class POSInvoice(SalesInvoice):
company: DF.Link
company_address: DF.Link | None
company_address_display: DF.SmallText | None
+ company_contact_person: DF.Link | None
consolidated_invoice: DF.Link | None
contact_display: DF.SmallText | None
contact_email: DF.Data | None
@@ -188,7 +187,7 @@ class POSInvoice(SalesInvoice):
def validate(self):
if not cint(self.is_pos):
frappe.throw(
- _("POS Invoice should have {} field checked.").format(frappe.bold("Include Payment"))
+ _("POS Invoice should have the field {0} checked.").format(frappe.bold(_("Include Payment")))
)
# run on validate method of selling controller
@@ -449,7 +448,7 @@ class POSInvoice(SalesInvoice):
if self.is_return and entry.amount > 0:
frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx))
- if self.is_return:
+ if self.is_return and self.docstatus != 0:
invoice_total = self.rounded_total or self.grand_total
total_amount_in_payments = flt(total_amount_in_payments, self.precision("grand_total"))
if total_amount_in_payments and total_amount_in_payments < invoice_total:
@@ -837,3 +836,30 @@ def add_return_modes(doc, pos_profile):
]:
payment_mode = get_mode_of_payment_info(mode_of_payment, doc.company)
append_payment(payment_mode[0])
+
+
+@frappe.whitelist()
+@frappe.validate_and_sanitize_search_inputs
+def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=False):
+ if pos_profile := filters.get("pos_profile")[1]:
+ pos_profile = frappe.get_cached_doc("POS Profile", pos_profile)
+ if item_groups := get_item_group(pos_profile):
+ filters["item_group"] = ["in", tuple(item_groups)]
+
+ del filters["pos_profile"]
+
+ else:
+ filters.pop("pos_profile", None)
+
+ return _item_query(doctype, txt, searchfield, start, page_len, filters, as_dict)
+
+
+def get_item_group(pos_profile):
+ item_groups = []
+ if pos_profile.get("item_groups"):
+ # Get items based on the item groups defined in the POS profile
+ for row in pos_profile.get("item_groups"):
+ item_groups.append(row.item_group)
+ item_groups.extend(get_descendants_of("Item Group", row.item_group))
+
+ return list(set(item_groups))
diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
index f210a6434cf..1dbc630e62e 100644
--- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
@@ -93,7 +93,7 @@ class TestPOSInvoice(unittest.TestCase):
inv.save()
- self.assertEqual(inv.net_total, 4298.25)
+ self.assertEqual(inv.net_total, 4298.24)
self.assertEqual(inv.grand_total, 4900.00)
def test_tax_calculation_with_multiple_items(self):
diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py
index 2cf204dd347..5bb43b3fa72 100644
--- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py
+++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py
@@ -97,16 +97,15 @@ class POSInvoiceMergeLog(Document):
return_against_status = frappe.db.get_value("POS Invoice", return_against, "status")
if return_against_status != "Consolidated":
# if return entry is not getting merged in the current pos closing and if it is not consolidated
- bold_unconsolidated = frappe.bold("not Consolidated")
- msg = _("Row #{}: Original Invoice {} of return invoice {} is {}.").format(
- d.idx, bold_return_against, bold_pos_invoice, bold_unconsolidated
- )
+ msg = _(
+ "Row #{}: The original Invoice {} of return invoice {} is not consolidated."
+ ).format(d.idx, bold_return_against, bold_pos_invoice)
msg += " "
msg += _(
- "Original invoice should be consolidated before or along with the return invoice."
+ "The original invoice should be consolidated before or along with the return invoice."
)
msg += "
"
- msg += _("You can add original invoice {} manually to proceed.").format(
+ msg += _("You can add the original invoice {} manually to proceed.").format(
bold_return_against
)
frappe.throw(msg)
@@ -439,7 +438,9 @@ def split_invoices(invoices):
if not item.serial_no and not item.serial_and_batch_bundle:
continue
- return_against_is_added = any(d for d in _invoices if d.pos_invoice == pos_invoice.return_against)
+ return_against_is_added = any(
+ d for d in _invoices if d and d[0].pos_invoice == pos_invoice.return_against
+ )
if return_against_is_added:
break
diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py
index 20f6ddb5ba9..904d8e83b9c 100644
--- a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py
+++ b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py
@@ -343,7 +343,7 @@ class TestPOSInvoiceMergeLog(unittest.TestCase):
inv.load_from_db()
consolidated_invoice = frappe.get_doc("Sales Invoice", inv.consolidated_invoice)
self.assertEqual(consolidated_invoice.status, "Return")
- self.assertEqual(consolidated_invoice.rounding_adjustment, -0.001)
+ self.assertEqual(consolidated_invoice.rounding_adjustment, -0.002)
finally:
frappe.set_user("Administrator")
diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.json b/erpnext/accounts/doctype/pricing_rule/pricing_rule.json
index 6f191c106c9..ee9dd2be8c3 100644
--- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.json
+++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.json
@@ -419,7 +419,8 @@
"depends_on": "eval:doc.rate_or_discount==\"Rate\"",
"fieldname": "rate",
"fieldtype": "Currency",
- "label": "Rate"
+ "label": "Rate",
+ "options": "currency"
},
{
"default": "0",
@@ -647,7 +648,7 @@
"icon": "fa fa-gift",
"idx": 1,
"links": [],
- "modified": "2024-05-17 13:16:34.496704",
+ "modified": "2024-09-16 18:14:51.314765",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Pricing Rule",
@@ -709,4 +710,4 @@
"sort_order": "DESC",
"states": [],
"title_field": "title"
-}
+}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
index de29ec0f004..73cb2483811 100644
--- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
+++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
@@ -186,7 +186,8 @@ class PricingRule(Document):
if not self.priority:
throw(
_("As the field {0} is enabled, the field {1} is mandatory.").format(
- frappe.bold("Apply Discount on Discounted Rate"), frappe.bold("Priority")
+ frappe.bold(_("Apply Discount on Discounted Rate")),
+ frappe.bold(_("Priority")),
)
)
@@ -194,7 +195,7 @@ class PricingRule(Document):
throw(
_(
"As the field {0} is enabled, the value of the field {1} should be more than 1."
- ).format(frappe.bold("Apply Discount on Discounted Rate"), frappe.bold("Priority"))
+ ).format(frappe.bold(_("Apply Discount on Discounted Rate")), frappe.bold(_("Priority")))
)
def validate_applicable_for_selling_or_buying(self):
@@ -445,7 +446,20 @@ def get_pricing_rule_for_item(args, doc=None, for_validate=False):
if isinstance(pricing_rule, str):
pricing_rule = frappe.get_cached_doc("Pricing Rule", pricing_rule)
update_pricing_rule_uom(pricing_rule, args)
- pricing_rule.apply_rule_on_other_items = get_pricing_rule_items(pricing_rule) or []
+ fetch_other_item = True if pricing_rule.apply_rule_on_other else False
+ pricing_rule.apply_rule_on_other_items = (
+ get_pricing_rule_items(pricing_rule, other_items=fetch_other_item) or []
+ )
+
+ if pricing_rule.coupon_code_based == 1:
+ if not args.coupon_code:
+ return item_details
+
+ coupon_code = frappe.db.get_value(
+ doctype="Coupon Code", filters={"pricing_rule": pricing_rule.name}, fieldname="name"
+ )
+ if args.coupon_code != coupon_code:
+ continue
if pricing_rule.get("suggestion"):
continue
@@ -472,9 +486,6 @@ def get_pricing_rule_for_item(args, doc=None, for_validate=False):
pricing_rule.apply_rule_on_other_items
)
- if pricing_rule.coupon_code_based == 1 and args.coupon_code is None:
- return item_details
-
if not pricing_rule.validate_applied_rule:
if pricing_rule.price_or_product_discount == "Price":
apply_price_discount_rule(pricing_rule, item_details, args)
diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
index 235fddf3ab3..965e2b267a3 100644
--- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
+++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
@@ -5,6 +5,7 @@
import unittest
import frappe
+from frappe.tests.utils import FrappeTestCase, change_settings
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
@@ -14,7 +15,7 @@ from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.get_item_details import get_item_details
-class TestPricingRule(unittest.TestCase):
+class TestPricingRule(FrappeTestCase):
def setUp(self):
delete_existing_pricing_rules()
setup_pricing_rule_data()
@@ -1130,6 +1131,51 @@ class TestPricingRule(unittest.TestCase):
self.assertEqual(so.items[1].item_code, "_Test Item")
self.assertEqual(so.items[1].qty, 3)
+ so = make_sales_order(item_code="_Test Item", qty=5, do_not_submit=1)
+ so.items[0].qty = 1
+ del so.items[-1]
+ so.save()
+ self.assertEqual(len(so.items), 1)
+
+ def test_pricing_rule_for_product_free_item_round_free_qty(self):
+ frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule")
+ test_record = {
+ "doctype": "Pricing Rule",
+ "title": "_Test Pricing Rule",
+ "apply_on": "Item Code",
+ "currency": "USD",
+ "items": [
+ {
+ "item_code": "_Test Item",
+ }
+ ],
+ "selling": 1,
+ "rate": 0,
+ "min_qty": 100,
+ "max_qty": 0,
+ "price_or_product_discount": "Product",
+ "same_item": 1,
+ "free_qty": 10,
+ "round_free_qty": 1,
+ "is_recursive": 1,
+ "recurse_for": 100,
+ "company": "_Test Company",
+ }
+ frappe.get_doc(test_record.copy()).insert()
+
+ # With pricing rule
+ so = make_sales_order(item_code="_Test Item", qty=100)
+ so.load_from_db()
+ self.assertEqual(so.items[1].is_free_item, 1)
+ self.assertEqual(so.items[1].item_code, "_Test Item")
+ self.assertEqual(so.items[1].qty, 10)
+
+ so = make_sales_order(item_code="_Test Item", qty=150)
+ so.load_from_db()
+ self.assertEqual(so.items[1].is_free_item, 1)
+ self.assertEqual(so.items[1].item_code, "_Test Item")
+ self.assertEqual(so.items[1].qty, 10)
+
def test_apply_multiple_pricing_rules_for_discount_percentage_and_amount(self):
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule 1")
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule 2")
diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py
index 9c7911d7cae..551eaa3d1ce 100644
--- a/erpnext/accounts/doctype/pricing_rule/utils.py
+++ b/erpnext/accounts/doctype/pricing_rule/utils.py
@@ -486,7 +486,7 @@ def get_qty_and_rate_for_other_item(doc, pr_doc, pricing_rules, row_item):
continue
stock_qty = row.get("qty") * (row.get("conversion_factor") or 1.0)
- amount = stock_qty * (row.get("price_list_rate") or row.get("rate"))
+ amount = stock_qty * (flt(row.get("price_list_rate")) or flt(row.get("rate")))
pricing_rules = filter_pricing_rules_for_qty_amount(stock_qty, amount, pricing_rules, row)
if pricing_rules and pricing_rules[0]:
@@ -651,11 +651,23 @@ def get_product_discount_rule(pricing_rule, item_details, args=None, doc=None):
qty = pricing_rule.free_qty or 1
if pricing_rule.is_recursive:
- transaction_qty = (args.get("qty") if args else doc.total_qty) - pricing_rule.apply_recursion_over
- if transaction_qty:
+ transaction_qty = sum(
+ [
+ row.qty
+ for row in doc.items
+ if not row.is_free_item
+ and row.item_code == args.item_code
+ and row.pricing_rules == args.pricing_rules
+ ]
+ )
+ transaction_qty = transaction_qty - pricing_rule.apply_recursion_over
+ if transaction_qty and transaction_qty > 0:
qty = flt(transaction_qty) * qty / pricing_rule.recurse_for
if pricing_rule.round_free_qty:
- qty = math.floor(qty)
+ qty = (flt(transaction_qty) // pricing_rule.recurse_for) * (pricing_rule.free_qty or 1)
+
+ if not qty:
+ return
free_item_data_args = {
"item_code": free_item,
@@ -725,14 +737,11 @@ def get_pricing_rule_items(pr_doc, other_items=False) -> list:
def validate_coupon_code(coupon_name):
coupon = frappe.get_doc("Coupon Code", coupon_name)
-
- if coupon.valid_from:
- if coupon.valid_from > getdate(today()):
- frappe.throw(_("Sorry, this coupon code's validity has not started"))
- elif coupon.valid_upto:
- if coupon.valid_upto < getdate(today()):
- frappe.throw(_("Sorry, this coupon code's validity has expired"))
- elif coupon.used >= coupon.maximum_use:
+ if coupon.valid_from and coupon.valid_from > getdate(today()):
+ frappe.throw(_("Sorry, this coupon code's validity has not started"))
+ elif coupon.valid_upto and coupon.valid_upto < getdate(today()):
+ frappe.throw(_("Sorry, this coupon code's validity has expired"))
+ elif coupon.maximum_use and coupon.used >= coupon.maximum_use:
frappe.throw(_("Sorry, this coupon code is no longer valid"))
diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.js b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.js
index 0f52a4d998e..d72c4724690 100644
--- a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.js
+++ b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.js
@@ -20,6 +20,17 @@ frappe.ui.form.on("Process Payment Reconciliation", {
},
};
});
+
+ frm.set_query("default_advance_account", function (doc) {
+ return {
+ filters: {
+ company: doc.company,
+ is_group: 0,
+ account_type: doc.party_type == "Customer" ? "Receivable" : "Payable",
+ root_type: doc.party_type == "Customer" ? "Liability" : "Asset",
+ },
+ };
+ });
frm.set_query("cost_center", function (doc) {
return {
filters: {
@@ -102,6 +113,7 @@ frappe.ui.form.on("Process Payment Reconciliation", {
company(frm) {
frm.set_value("party", "");
frm.set_value("receivable_payable_account", "");
+ frm.set_value("default_advance_account", "");
},
party_type(frm) {
frm.set_value("party", "");
@@ -109,6 +121,7 @@ frappe.ui.form.on("Process Payment Reconciliation", {
party(frm) {
frm.set_value("receivable_payable_account", "");
+ frm.set_value("default_advance_account", "");
if (!frm.doc.receivable_payable_account && frm.doc.party_type && frm.doc.party) {
return frappe.call({
method: "erpnext.accounts.party.get_party_account",
@@ -116,10 +129,16 @@ frappe.ui.form.on("Process Payment Reconciliation", {
company: frm.doc.company,
party_type: frm.doc.party_type,
party: frm.doc.party,
+ include_advance: 1,
},
callback: (r) => {
if (!r.exc && r.message) {
- frm.set_value("receivable_payable_account", r.message);
+ if (typeof r.message === "string") {
+ frm.set_value("receivable_payable_account", r.message);
+ } else if (Array.isArray(r.message)) {
+ frm.set_value("receivable_payable_account", r.message[0]);
+ frm.set_value("default_advance_account", r.message[1]);
+ }
}
frm.refresh();
},
diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.json b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.json
index 1a1ab4d800e..0511571d754 100644
--- a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.json
+++ b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.json
@@ -13,6 +13,7 @@
"column_break_io6c",
"party",
"receivable_payable_account",
+ "default_advance_account",
"filter_section",
"from_invoice_date",
"to_invoice_date",
@@ -141,12 +142,23 @@
{
"fieldname": "section_break_a8yx",
"fieldtype": "Section Break"
+ },
+ {
+ "depends_on": "eval:doc.party",
+ "description": "Only 'Payment Entries' made against this advance account are supported.",
+ "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account",
+ "fieldname": "default_advance_account",
+ "fieldtype": "Link",
+ "label": "Default Advance Account",
+ "mandatory_depends_on": "doc.party_type",
+ "options": "Account",
+ "reqd": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2023-08-11 10:56:51.699137",
+ "modified": "2024-08-27 14:48:56.715320",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Payment Reconciliation",
@@ -180,4 +192,4 @@
"sort_order": "DESC",
"states": [],
"title_field": "company"
-}
\ No newline at end of file
+}
diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py
index 5048fc5e25e..882a2c4ad1c 100644
--- a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py
+++ b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py
@@ -23,6 +23,7 @@ class ProcessPaymentReconciliation(Document):
bank_cash_account: DF.Link | None
company: DF.Link
cost_center: DF.Link | None
+ default_advance_account: DF.Link
error_log: DF.LongText | None
from_invoice_date: DF.Date | None
from_payment_date: DF.Date | None
@@ -101,6 +102,7 @@ def get_pr_instance(doc: str):
"party_type",
"party",
"receivable_payable_account",
+ "default_advance_account",
"from_invoice_date",
"to_invoice_date",
"from_payment_date",
diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json
index 22be5299280..763607c22a1 100644
--- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json
+++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json
@@ -25,6 +25,7 @@
"payment_terms_template",
"sales_partner",
"sales_person",
+ "show_remarks",
"based_on_payment_terms",
"section_break_3",
"customer_collection",
@@ -390,10 +391,16 @@
"fieldname": "ignore_cr_dr_notes",
"fieldtype": "Check",
"label": "Ignore System Generated Credit / Debit Notes"
+ },
+ {
+ "default": "0",
+ "fieldname": "show_remarks",
+ "fieldtype": "Check",
+ "label": "Show Remarks"
}
],
"links": [],
- "modified": "2024-08-13 10:41:18.381165",
+ "modified": "2024-10-18 17:51:39.108481",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Statement Of Accounts",
diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py
index 509199ccae6..bf1c8c0b66e 100644
--- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py
+++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py
@@ -70,6 +70,7 @@ class ProcessStatementOfAccounts(Document):
sales_person: DF.Link | None
sender: DF.Link | None
show_net_values_in_party_account: DF.Check
+ show_remarks: DF.Check
start_date: DF.Date | None
subject: DF.Data | None
terms_and_conditions: DF.Link | None
@@ -187,6 +188,7 @@ def get_common_filters(doc):
"finance_book": doc.finance_book if doc.finance_book else None,
"account": [doc.account] if doc.account else None,
"cost_center": [cc.cost_center_name for cc in doc.cost_center],
+ "show_remarks": doc.show_remarks,
}
)
@@ -472,6 +474,7 @@ def send_emails(document_name, from_scheduler=False, posting_date=None):
reference_doctype="Process Statement Of Accounts",
reference_name=document_name,
attachments=attachments,
+ expose_recipients="header",
)
if doc.enable_auto_email and from_scheduler:
diff --git a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py
index 86bd2135515..4cc87394b4f 100644
--- a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py
+++ b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py
@@ -5,6 +5,8 @@
import frappe
from frappe import _
from frappe.model.document import Document
+from frappe.query_builder import Criterion
+from frappe.query_builder.functions import IfNull
pricing_rule_fields = [
"apply_on",
@@ -162,22 +164,50 @@ class PromotionalScheme(Document):
if self.is_new():
return
- transaction_exists = False
- docnames = []
+ invalid_pricing_rule = self.get_invalid_pricing_rules()
- # If user has changed applicable for
- if self.get_doc_before_save() and self.get_doc_before_save().applicable_for == self.applicable_for:
+ if not invalid_pricing_rule:
return
- docnames = frappe.get_all("Pricing Rule", filters={"promotional_scheme": self.name})
+ if frappe.db.exists(
+ "Pricing Rule Detail",
+ {
+ "pricing_rule": ["in", invalid_pricing_rule],
+ "docstatus": ["<", 2],
+ },
+ ):
+ raise_for_transaction_exists(self.name)
- for docname in docnames:
- if frappe.db.exists("Pricing Rule Detail", {"pricing_rule": docname.name, "docstatus": ("<", 2)}):
- raise_for_transaction_exists(self.name)
+ for doc in invalid_pricing_rule:
+ frappe.delete_doc("Pricing Rule", doc)
- if docnames and not transaction_exists:
- for docname in docnames:
- frappe.delete_doc("Pricing Rule", docname.name)
+ frappe.msgprint(
+ _("The following invalid Pricing Rules are deleted:")
+ + "
This is a preview of the email to be sent. A PDF of the document will - automatically be attached with the email.
`); + const msg = __( + "This is a preview of the email to be sent. A PDF of the document will automatically be attached with the email." + ); + dialog.fields_dict.note.$wrapper.append(`${msg}
`); dialog.show(); }, diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index c8ef833e0e9..3a71733a003 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -390,6 +390,7 @@ def make_supplier_quotation_from_rfq(source_name, target_doc=None, for_supplier= "Request for Quotation": { "doctype": "Supplier Quotation", "validation": {"docstatus": ["=", 1]}, + "field_map": {"opportunity": "opportunity"}, }, "Request for Quotation Item": { "doctype": "Supplier Quotation Item", @@ -455,6 +456,7 @@ def create_rfq_items(sq_doc, supplier, data): "material_request", "material_request_item", "stock_qty", + "uom", ]: args[field] = data.get(field) diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index bccab8b01e0..3b72953c563 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -97,7 +97,7 @@ class Supplier(TransactionBase): elif supp_master_name == "Naming Series": set_name_by_naming_series(self) else: - self.name = set_name_from_naming_options(frappe.get_meta(self.doctype).autoname, self) + set_name_from_naming_options(frappe.get_meta(self.doctype).autoname, self) def on_update(self): self.create_primary_contact() diff --git a/erpnext/buying/report/item_wise_purchase_history/item_wise_purchase_history.js b/erpnext/buying/report/item_wise_purchase_history/item_wise_purchase_history.js new file mode 100644 index 00000000000..37870b43b6d --- /dev/null +++ b/erpnext/buying/report/item_wise_purchase_history/item_wise_purchase_history.js @@ -0,0 +1,62 @@ +// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.query_reports["Item-wise Purchase History"] = { + filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1, + }, + { + fieldname: "from_date", + reqd: 1, + label: __("From Date"), + fieldtype: "Date", + default: frappe.datetime.add_months(frappe.datetime.get_today(), -1), + }, + { + fieldname: "to_date", + reqd: 1, + default: frappe.datetime.get_today(), + label: __("To Date"), + fieldtype: "Date", + }, + { + fieldname: "item_group", + label: __("Item Group"), + fieldtype: "Link", + options: "Item Group", + }, + { + fieldname: "item_code", + label: __("Item"), + fieldtype: "Link", + options: "Item", + get_query: () => { + return { + query: "erpnext.controllers.queries.item_query", + }; + }, + }, + { + fieldname: "supplier", + label: __("Supplier"), + fieldtype: "Link", + options: "Supplier", + }, + ], + + formatter: function (value, row, column, data, default_formatter) { + value = default_formatter(value, row, column, data); + let format_fields = ["received_qty", "billed_amt"]; + + if (format_fields.includes(column.fieldname) && data && data[column.fieldname] > 0) { + value = "" + value + ""; + } + return value; + }, +}; diff --git a/erpnext/buying/report/item_wise_purchase_history/item_wise_purchase_history.json b/erpnext/buying/report/item_wise_purchase_history/item_wise_purchase_history.json index 521c68c5329..35045afcf8b 100644 --- a/erpnext/buying/report/item_wise_purchase_history/item_wise_purchase_history.json +++ b/erpnext/buying/report/item_wise_purchase_history/item_wise_purchase_history.json @@ -1,30 +1,30 @@ { - "add_total_row": 1, - "apply_user_permissions": 1, - "creation": "2013-05-03 14:55:53", - "disabled": 0, - "docstatus": 0, - "doctype": "Report", - "idx": 3, - "is_standard": "Yes", - "modified": "2017-02-24 20:08:57.446613", - "modified_by": "Administrator", - "module": "Buying", - "name": "Item-wise Purchase History", - "owner": "Administrator", - "query": "select\n po_item.item_code as \"Item Code:Link/Item:120\",\n\tpo_item.item_name as \"Item Name::120\",\n po_item.item_group as \"Item Group:Link/Item Group:120\",\n\tpo_item.description as \"Description::150\",\n\tpo_item.qty as \"Qty:Float:100\",\n\tpo_item.uom as \"UOM:Link/UOM:80\",\n\tpo_item.base_rate as \"Rate:Currency:120\",\n\tpo_item.base_amount as \"Amount:Currency:120\",\n\tpo.name as \"Purchase Order:Link/Purchase Order:120\",\n\tpo.transaction_date as \"Transaction Date:Date:140\",\n\tpo.supplier as \"Supplier:Link/Supplier:130\",\n sup.supplier_name as \"Supplier Name::150\",\n\tpo_item.project as \"Project:Link/Project:130\",\n\tifnull(po_item.received_qty, 0) as \"Received Qty:Float:120\",\n\tpo.company as \"Company:Link/Company:\"\nfrom\n\t`tabPurchase Order` po, `tabPurchase Order Item` po_item, `tabSupplier` sup\nwhere\n\tpo.name = po_item.parent and po.supplier = sup.name and po.docstatus = 1\norder by po.name desc", - "ref_doctype": "Purchase Order", - "report_name": "Item-wise Purchase History", - "report_type": "Query Report", + "add_total_row": 1, + "creation": "2013-05-03 14:55:53", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "idx": 5, + "is_standard": "Yes", + "modified": "2024-06-19 12:12:15.418799", + "modified_by": "Administrator", + "module": "Buying", + "name": "Item-wise Purchase History", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Purchase Order", + "report_name": "Item-wise Purchase History", + "report_type": "Script Report", "roles": [ { "role": "Stock User" - }, + }, { "role": "Purchase Manager" - }, + }, { "role": "Purchase User" } - ] + ] } \ No newline at end of file diff --git a/erpnext/buying/report/item_wise_purchase_history/item_wise_purchase_history.py b/erpnext/buying/report/item_wise_purchase_history/item_wise_purchase_history.py new file mode 100644 index 00000000000..a8950af3ea3 --- /dev/null +++ b/erpnext/buying/report/item_wise_purchase_history/item_wise_purchase_history.py @@ -0,0 +1,276 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.utils import flt +from frappe.utils.nestedset import get_descendants_of + + +def execute(filters=None): + filters = frappe._dict(filters or {}) + if filters.from_date > filters.to_date: + frappe.throw(_("From Date cannot be greater than To Date")) + + columns = get_columns(filters) + data = get_data(filters) + + chart_data = get_chart_data(data) + + return columns, data, None, chart_data + + +def get_columns(filters): + return [ + { + "label": _("Item Code"), + "fieldtype": "Link", + "fieldname": "item_code", + "options": "Item", + "width": 120, + }, + { + "label": _("Item Name"), + "fieldtype": "Data", + "fieldname": "item_name", + "width": 140, + }, + { + "label": _("Item Group"), + "fieldtype": "Link", + "fieldname": "item_group", + "options": "Item Group", + "width": 120, + }, + { + "label": _("Description"), + "fieldtype": "Data", + "fieldname": "description", + "width": 140, + }, + { + "label": _("Quantity"), + "fieldtype": "Float", + "fieldname": "quantity", + "width": 120, + }, + { + "label": _("UOM"), + "fieldtype": "Link", + "fieldname": "uom", + "options": "UOM", + "width": 90, + }, + { + "label": _("Rate"), + "fieldname": "rate", + "fieldtype": "Currency", + "options": "currency", + "width": 120, + }, + { + "label": _("Amount"), + "fieldname": "amount", + "fieldtype": "Currency", + "options": "currency", + "width": 120, + }, + { + "label": _("Purchase Order"), + "fieldtype": "Link", + "fieldname": "purchase_order", + "options": "Purchase Order", + "width": 160, + }, + { + "label": _("Transaction Date"), + "fieldtype": "Date", + "fieldname": "transaction_date", + "width": 110, + }, + { + "label": _("Supplier"), + "fieldtype": "Link", + "fieldname": "supplier", + "options": "Supplier", + "width": 100, + }, + { + "label": _("Supplier Name"), + "fieldtype": "Data", + "fieldname": "supplier_name", + "width": 140, + }, + { + "label": _("Supplier Group"), + "fieldtype": "Link", + "fieldname": "supplier_group", + "options": "Supplier Group", + "width": 120, + }, + { + "label": _("Project"), + "fieldtype": "Link", + "fieldname": "project", + "options": "Project", + "width": 100, + }, + { + "label": _("Received Quantity"), + "fieldtype": "Float", + "fieldname": "received_qty", + "width": 150, + }, + { + "label": _("Billed Amount"), + "fieldtype": "Currency", + "fieldname": "billed_amt", + "options": "currency", + "width": 120, + }, + { + "label": _("Company"), + "fieldtype": "Link", + "fieldname": "company", + "options": "Company", + "width": 100, + }, + { + "label": _("Currency"), + "fieldtype": "Link", + "fieldname": "currency", + "options": "Currency", + "hidden": 1, + }, + ] + + +def get_data(filters): + data = [] + + company_list = get_descendants_of("Company", filters.get("company")) + company_list.append(filters.get("company")) + + supplier_details = get_supplier_details() + item_details = get_item_details() + purchase_order_records = get_purchase_order_details(company_list, filters) + + for record in purchase_order_records: + supplier_record = supplier_details.get(record.supplier) + item_record = item_details.get(record.item_code) + row = { + "item_code": record.get("item_code"), + "item_name": item_record.get("item_name"), + "item_group": item_record.get("item_group"), + "description": record.get("description"), + "quantity": record.get("qty"), + "uom": record.get("uom"), + "rate": record.get("base_rate"), + "amount": record.get("base_amount"), + "purchase_order": record.get("name"), + "transaction_date": record.get("transaction_date"), + "supplier": record.get("supplier"), + "supplier_name": supplier_record.get("supplier_name"), + "supplier_group": supplier_record.get("supplier_group"), + "project": record.get("project"), + "received_qty": flt(record.get("received_qty")), + "billed_amt": flt(record.get("billed_amt")), + "company": record.get("company"), + } + row["currency"] = frappe.get_cached_value("Company", row["company"], "default_currency") + data.append(row) + + return data + + +def get_supplier_details(): + details = frappe.get_all("Supplier", fields=["name", "supplier_name", "supplier_group"]) + supplier_details = {} + for d in details: + supplier_details.setdefault( + d.name, + frappe._dict({"supplier_name": d.supplier_name, "supplier_group": d.supplier_group}), + ) + return supplier_details + + +def get_item_details(): + details = frappe.db.get_all("Item", fields=["name", "item_name", "item_group"]) + item_details = {} + for d in details: + item_details.setdefault(d.name, frappe._dict({"item_name": d.item_name, "item_group": d.item_group})) + return item_details + + +def get_purchase_order_details(company_list, filters): + db_po = frappe.qb.DocType("Purchase Order") + db_po_item = frappe.qb.DocType("Purchase Order Item") + + query = ( + frappe.qb.from_(db_po) + .inner_join(db_po_item) + .on(db_po_item.parent == db_po.name) + .select( + db_po.name, + db_po.supplier, + db_po.transaction_date, + db_po.project, + db_po.company, + db_po_item.item_code, + db_po_item.description, + db_po_item.qty, + db_po_item.uom, + db_po_item.base_rate, + db_po_item.base_amount, + db_po_item.received_qty, + (db_po_item.billed_amt * db_po.conversion_rate).as_("billed_amt"), + ) + .where(db_po.docstatus == 1) + .where(db_po.company.isin(tuple(company_list))) + ) + + for field in ("item_code", "item_group"): + if filters.get(field): + query = query.where(db_po_item[field] == filters[field]) + + if filters.get("from_date"): + query = query.where(db_po.transaction_date >= filters.from_date) + + if filters.get("to_date"): + query = query.where(db_po.transaction_date <= filters.to_date) + + if filters.get("supplier"): + query = query.where(db_po.supplier == filters.supplier) + + return query.run(as_dict=1) + + +def get_chart_data(data): + item_wise_purchase_map = {} + labels, datapoints = [], [] + + for row in data: + item_key = row.get("item_code") + + if item_key not in item_wise_purchase_map: + item_wise_purchase_map[item_key] = 0 + + item_wise_purchase_map[item_key] = flt(item_wise_purchase_map[item_key]) + flt(row.get("amount")) + + item_wise_purchase_map = { + item: value + for item, value in (sorted(item_wise_purchase_map.items(), key=lambda i: i[1], reverse=True)) + } + + for key in item_wise_purchase_map: + labels.append(key) + datapoints.append(item_wise_purchase_map[key]) + + return { + "data": { + "labels": labels[:30], # show max of 30 items in chart + "datasets": [{"name": _("Total Purchase Amount"), "values": datapoints[:30]}], + }, + "type": "bar", + "fieldtype": "Currency", + } diff --git a/erpnext/buying/report/procurement_tracker/procurement_tracker.py b/erpnext/buying/report/procurement_tracker/procurement_tracker.py index a7e03c08fac..bd0798236b3 100644 --- a/erpnext/buying/report/procurement_tracker/procurement_tracker.py +++ b/erpnext/buying/report/procurement_tracker/procurement_tracker.py @@ -175,7 +175,7 @@ def get_data(filters): "purchase_order": po.parent, "supplier": po.supplier, "estimated_cost": flt(mr_record.get("amount")), - "actual_cost": flt(pi_records.get(po.name)), + "actual_cost": flt(pi_records.get(po.name)) or flt(po.amount), "purchase_order_amt": flt(po.amount), "purchase_order_amt_in_company_currency": flt(po.base_amount), "expected_delivery_date": po.schedule_date, diff --git a/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.py b/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.py index da1c70d3179..6d2034d1878 100644 --- a/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.py +++ b/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.py @@ -18,6 +18,7 @@ def execute(filters=None): columns = get_columns(filters) data = get_data(filters) + update_received_amount(data) if not data: return [], [], None, [] @@ -60,7 +61,6 @@ def get_data(filters): (po_item.qty - po_item.received_qty).as_("pending_qty"), Sum(IfNull(pi_item.qty, 0)).as_("billed_qty"), po_item.base_amount.as_("amount"), - (po_item.received_qty * po_item.base_rate).as_("received_qty_amount"), (po_item.billed_amt * IfNull(po.conversion_rate, 1)).as_("billed_amount"), (po_item.base_amount - (po_item.billed_amt * IfNull(po.conversion_rate, 1))).as_( "pending_amount" @@ -92,6 +92,39 @@ def get_data(filters): return data +def update_received_amount(data): + pr_data = get_received_amount_data(data) + + for row in data: + row.received_qty_amount = flt(pr_data.get(row.name)) + + +def get_received_amount_data(data): + pr = frappe.qb.DocType("Purchase Receipt") + pr_item = frappe.qb.DocType("Purchase Receipt Item") + + query = ( + frappe.qb.from_(pr) + .inner_join(pr_item) + .on(pr_item.parent == pr.name) + .select( + pr_item.purchase_order_item, + Sum(pr_item.base_amount).as_("received_qty_amount"), + ) + .where((pr_item.parent == pr.name) & (pr.docstatus == 1)) + .groupby(pr_item.purchase_order_item) + ) + + query = query.where(pr_item.purchase_order_item.isin([row.name for row in data])) + + data = query.run() + + if not data: + return frappe._dict() + + return frappe._dict(data) + + def prepare_data(data, filters): completed, pending = 0, 0 pending_field = "pending_amount" @@ -147,7 +180,7 @@ def prepare_data(data, filters): def prepare_chart_data(pending, completed): - labels = ["Amount to Bill", "Billed Amount"] + labels = [_("Amount to Bill"), _("Billed Amount")] return { "data": {"labels": labels, "datasets": [{"values": [pending, completed]}]}, diff --git a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js index 56684a8659b..9b193a34d83 100644 --- a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js +++ b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js @@ -2,3 +2,10 @@ // License: GNU General Public License v3. See license.txt frappe.query_reports["Purchase Order Trends"] = $.extend({}, erpnext.purchase_trends_filters); + +frappe.query_reports["Purchase Order Trends"]["filters"].push({ + fieldname: "include_closed_orders", + label: __("Include Closed Orders"), + fieldtype: "Check", + default: 0, +}); diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 63d0404642a..e1a7d5803c9 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -233,7 +233,7 @@ class AccountsController(TransactionBase): ).format( frappe.bold(document_type), get_link_to_form(self.doctype, self.get("return_against")), - frappe.bold("Update Outstanding for Self"), + frappe.bold(_("Update Outstanding for Self")), get_link_to_form("Payment Reconciliation", "Payment Reconciliation"), ) ) @@ -345,13 +345,30 @@ class AccountsController(TransactionBase): repost_doc.flags.ignore_links = True repost_doc.save(ignore_permissions=True) + def _remove_advance_payment_ledger_entries(self): + adv = qb.DocType("Advance Payment Ledger Entry") + qb.from_(adv).delete().where(adv.voucher_type.eq(self.doctype) & adv.voucher_no.eq(self.name)).run() + + advance_payment_doctypes = frappe.get_hooks("advance_payment_doctypes") + + if self.doctype in advance_payment_doctypes: + qb.from_(adv).delete().where( + adv.against_voucher_type.eq(self.doctype) & adv.against_voucher_no.eq(self.name) + ).run() + def on_trash(self): + from erpnext.accounts.utils import delete_exchange_gain_loss_journal + + self._remove_advance_payment_ledger_entries() self._remove_references_in_repost_doctypes() self._remove_references_in_unreconcile() self.remove_serial_and_batch_bundle() # delete sl and gl entries on deletion of transaction if frappe.db.get_single_value("Accounts Settings", "delete_linked_ledger_entries"): + # delete linked exchange gain/loss journal + delete_exchange_gain_loss_journal(self) + ple = frappe.qb.DocType("Payment Ledger Entry") frappe.qb.from_(ple).delete().where( (ple.voucher_type == self.doctype) & (ple.voucher_no == self.name) @@ -388,12 +405,15 @@ class AccountsController(TransactionBase): def validate_return_against_account(self): if self.doctype in ["Sales Invoice", "Purchase Invoice"] and self.is_return and self.return_against: cr_dr_account_field = "debit_to" if self.doctype == "Sales Invoice" else "credit_to" - cr_dr_account_label = "Debit To" if self.doctype == "Sales Invoice" else "Credit To" - cr_dr_account = self.get(cr_dr_account_field) - if frappe.get_value(self.doctype, self.return_against, cr_dr_account_field) != cr_dr_account: + original_account = frappe.get_value(self.doctype, self.return_against, cr_dr_account_field) + if original_account != self.get(cr_dr_account_field): frappe.throw( - _("'{0}' account: '{1}' should match the Return Against Invoice").format( - frappe.bold(cr_dr_account_label), frappe.bold(cr_dr_account) + _( + "Please set {0} to {1}, the same account that was used in the original invoice {2}." + ).format( + frappe.bold(_(self.meta.get_label(cr_dr_account_field), context=self.doctype)), + frappe.bold(original_account), + frappe.bold(self.return_against), ) ) @@ -443,6 +463,11 @@ class AccountsController(TransactionBase): ) def validate_invoice_documents_schedule(self): + if self.is_return: + self.payment_terms_template = "" + self.payment_schedule = [] + return + self.validate_payment_schedule_dates() self.set_due_date() self.set_payment_schedule() @@ -457,7 +482,7 @@ class AccountsController(TransactionBase): self.validate_payment_schedule_amount() def validate_all_documents_schedule(self): - if self.doctype in ("Sales Invoice", "Purchase Invoice") and not self.is_return: + if self.doctype in ("Sales Invoice", "Purchase Invoice"): self.validate_invoice_documents_schedule() elif self.doctype in ("Quotation", "Purchase Order", "Sales Order"): self.validate_non_invoice_documents_schedule() @@ -1031,7 +1056,9 @@ class AccountsController(TransactionBase): gl_dict.update( { "transaction_currency": self.get("currency") or self.company_currency, - "transaction_exchange_rate": self.get("conversion_rate", 1), + "transaction_exchange_rate": item.get("exchange_rate", 1) + if self.doctype == "Journal Entry" and item + else self.get("conversion_rate", 1), "debit_in_transaction_currency": self.get_value_in_transaction_currency( account_currency, gl_dict, "debit" ), @@ -1056,16 +1083,26 @@ class AccountsController(TransactionBase): "Stock Entry": "stock_entry_type", "Asset Capitalization": "entry_type", } + + for method_name in frappe.get_hooks("voucher_subtypes"): + voucher_subtype = frappe.get_attr(method_name)(self) + + if voucher_subtype: + return voucher_subtype + if self.doctype in voucher_subtypes: return self.get(voucher_subtypes[self.doctype]) elif self.doctype == "Purchase Receipt" and self.is_return: return "Purchase Return" elif self.doctype == "Delivery Note" and self.is_return: return "Sales Return" - elif (self.doctype == "Sales Invoice" and self.is_return) or self.doctype == "Purchase Invoice": + elif self.doctype == "Sales Invoice" and self.is_return: return "Credit Note" - elif (self.doctype == "Purchase Invoice" and self.is_return) or self.doctype == "Sales Invoice": + elif self.doctype == "Sales Invoice" and self.is_debit_note: return "Debit Note" + elif self.doctype == "Purchase Invoice" and self.is_return: + return "Debit Note" + return self.doctype def get_value_in_transaction_currency(self, account_currency, gl_dict, field): @@ -1261,7 +1298,11 @@ class AccountsController(TransactionBase): d.exchange_gain_loss = difference def make_precision_loss_gl_entry(self, gl_entries): - round_off_account, round_off_cost_center = get_round_off_account_and_cost_center( + ( + round_off_account, + round_off_cost_center, + round_off_for_opening, + ) = get_round_off_account_and_cost_center( self.company, "Purchase Invoice", self.name, self.use_company_roundoff_cost_center ) @@ -1334,6 +1375,12 @@ class AccountsController(TransactionBase): # Cancelling existing exchange gain/loss journals is handled during the `on_cancel` event. # see accounts/utils.py:cancel_exchange_gain_loss_journal() if self.docstatus == 1: + if dimensions_dict is None: + dimensions_dict = frappe._dict() + active_dimensions = get_dimensions()[0] + for dim in active_dimensions: + dimensions_dict[dim.fieldname] = self.get(dim.fieldname) + if self.get("doctype") == "Journal Entry": # 'args' is populated with exchange gain/loss account and the amount to be booked. # These are generated by Sales/Purchase Invoice during reconciliation and advance allocation. @@ -1574,6 +1621,7 @@ class AccountsController(TransactionBase): remove_from_bank_transaction, ) from erpnext.accounts.utils import ( + cancel_common_party_journal, cancel_exchange_gain_loss_journal, unlink_ref_doc_from_payment_entries, ) @@ -1585,6 +1633,7 @@ class AccountsController(TransactionBase): # Cancel Exchange Gain/Loss Journal before unlinking cancel_exchange_gain_loss_journal(self) + cancel_common_party_journal(self) if frappe.db.get_single_value("Accounts Settings", "unlink_payment_on_cancellation_of_invoice"): unlink_ref_doc_from_payment_entries(self) @@ -1904,21 +1953,23 @@ class AccountsController(TransactionBase): return stock_items - def set_total_advance_paid(self): - ple = frappe.qb.DocType("Payment Ledger Entry") - party = self.customer if self.doctype == "Sales Order" else self.supplier + def calculate_total_advance_from_ledger(self): + adv = frappe.qb.DocType("Advance Payment Ledger Entry") advance = ( - frappe.qb.from_(ple) - .select(ple.account_currency, Abs(Sum(ple.amount_in_account_currency)).as_("amount")) + frappe.qb.from_(adv) + .select(adv.currency.as_("account_currency"), Abs(Sum(adv.amount)).as_("amount")) .where( - (ple.against_voucher_type == self.doctype) - & (ple.against_voucher_no == self.name) - & (ple.party == party) - & (ple.delinked == 0) - & (ple.company == self.company) + (adv.against_voucher_type == self.doctype) + & (adv.against_voucher_no == self.name) + & (adv.company == self.company) ) .run(as_dict=True) ) + return advance + + def set_total_advance_paid(self): + advance = self.calculate_total_advance_from_ledger() + advance_paid, order_total = None, None if advance: advance = advance[0] @@ -1951,7 +2002,7 @@ class AccountsController(TransactionBase): ).format(formatted_advance_paid, self.name, formatted_order_total) ) - frappe.db.set_value(self.doctype, self.name, "advance_paid", advance_paid) + self.db_set("advance_paid", advance_paid) @property def company_abbr(self): @@ -1962,7 +2013,9 @@ class AccountsController(TransactionBase): def raise_missing_debit_credit_account_error(self, party_type, party): """Raise an error if debit to/credit to account does not exist.""" - db_or_cr = frappe.bold("Debit To") if self.doctype == "Sales Invoice" else frappe.bold("Credit To") + db_or_cr = ( + frappe.bold(_("Debit To")) if self.doctype == "Sales Invoice" else frappe.bold(_("Credit To")) + ) rec_or_pay = "Receivable" if self.doctype == "Sales Invoice" else "Payable" link_to_party = frappe.utils.get_link_to_form(party_type, party) @@ -2410,12 +2463,21 @@ class AccountsController(TransactionBase): primary_account = get_party_account(primary_party_type, primary_party, self.company) secondary_account = get_party_account(secondary_party_type, secondary_party, self.company) + primary_account_currency = get_account_currency(primary_account) + secondary_account_currency = get_account_currency(secondary_account) + default_currency = erpnext.get_company_currency(self.company) + + # Determine if multi-currency journal entry is needed + multi_currency = ( + primary_account_currency != default_currency or secondary_account_currency != default_currency + ) jv = frappe.new_doc("Journal Entry") jv.voucher_type = "Journal Entry" jv.posting_date = self.posting_date jv.company = self.company jv.remark = f"Adjustment for {self.doctype} {self.name}" + jv.is_system_generated = True reconcilation_entry = frappe._dict() advance_entry = frappe._dict() @@ -2433,7 +2495,7 @@ class AccountsController(TransactionBase): advance_entry.cost_center = self.cost_center or erpnext.get_default_cost_center(self.company) advance_entry.is_advance = "Yes" - # update dimesions + # Update dimensions dimensions_dict = frappe._dict() active_dimensions = get_dimensions()[0] for dim in active_dimensions: @@ -2442,13 +2504,58 @@ class AccountsController(TransactionBase): reconcilation_entry.update(dimensions_dict) advance_entry.update(dimensions_dict) - if self.doctype == "Sales Invoice": - reconcilation_entry.credit_in_account_currency = self.outstanding_amount - advance_entry.debit_in_account_currency = self.outstanding_amount - else: - advance_entry.credit_in_account_currency = self.outstanding_amount - reconcilation_entry.debit_in_account_currency = self.outstanding_amount + # Calculate exchange rates if necessary + if multi_currency: + # Exchange rates for primary and secondary accounts + exc_rate_primary_to_default = ( + 1 + if primary_account_currency == default_currency + else get_exchange_rate(primary_account_currency, default_currency, self.posting_date) + ) + exc_rate_secondary_to_default = ( + 1 + if secondary_account_currency == default_currency + else get_exchange_rate(secondary_account_currency, default_currency, self.posting_date) + ) + exc_rate_secondary_to_primary = ( + 1 + if secondary_account_currency == primary_account_currency + else get_exchange_rate( + secondary_account_currency, primary_account_currency, self.posting_date + ) + ) + # Convert outstanding amount from secondary to primary account currency, if needed + + os_in_default_currency = self.outstanding_amount * exc_rate_secondary_to_default + os_in_primary_currency = self.outstanding_amount * exc_rate_secondary_to_primary + + if self.doctype == "Sales Invoice": + # Calculate credit and debit values for reconciliation and advance entries + reconcilation_entry.credit_in_account_currency = self.outstanding_amount + reconcilation_entry.credit = os_in_default_currency + + advance_entry.debit_in_account_currency = os_in_primary_currency + advance_entry.debit = os_in_default_currency + else: + advance_entry.credit_in_account_currency = os_in_primary_currency + advance_entry.credit = os_in_default_currency + + reconcilation_entry.debit_in_account_currency = self.outstanding_amount + reconcilation_entry.debit = os_in_default_currency + + # Set exchange rates for entries + reconcilation_entry.exchange_rate = exc_rate_secondary_to_default + advance_entry.exchange_rate = exc_rate_primary_to_default + else: + if self.doctype == "Sales Invoice": + reconcilation_entry.credit_in_account_currency = self.outstanding_amount + advance_entry.debit_in_account_currency = self.outstanding_amount + else: + advance_entry.credit_in_account_currency = self.outstanding_amount + reconcilation_entry.debit_in_account_currency = self.outstanding_amount + + jv.multi_currency = multi_currency jv.append("accounts", reconcilation_entry) jv.append("accounts", advance_entry) @@ -2506,6 +2613,67 @@ class AccountsController(TransactionBase): repost_ledger.insert() repost_ledger.submit() + def get_advance_payment_doctypes(self) -> list: + return frappe.get_hooks("advance_payment_doctypes") + + def make_advance_payment_ledger_for_journal(self): + advance_payment_doctypes = self.get_advance_payment_doctypes() + advance_doctype_references = [ + x for x in self.accounts if x.reference_type in advance_payment_doctypes + ] + + for x in advance_doctype_references: + # Looking for payments + dr_or_cr = ( + "credit_in_account_currency" + if x.account_type == "Receivable" + else "debit_in_account_currency" + ) + + amount = x.get(dr_or_cr) + if amount > 0: + doc = frappe.new_doc("Advance Payment Ledger Entry") + doc.company = self.company + doc.voucher_type = self.doctype + doc.voucher_no = self.name + doc.against_voucher_type = x.reference_type + doc.against_voucher_no = x.reference_name + doc.amount = amount if self.docstatus == 1 else -1 * amount + doc.event = "Submit" if self.docstatus == 1 else "Cancel" + doc.currency = x.account_currency + doc.flags.ignore_permissions = 1 + doc.save() + + def make_advance_payment_ledger_for_payment(self): + advance_payment_doctypes = self.get_advance_payment_doctypes() + advance_doctype_references = [ + x for x in self.references if x.reference_doctype in advance_payment_doctypes + ] + currency = ( + self.paid_from_account_currency + if self.payment_type == "Receive" + else self.paid_to_account_currency + ) + for x in advance_doctype_references: + doc = frappe.new_doc("Advance Payment Ledger Entry") + doc.company = self.company + doc.voucher_type = self.doctype + doc.voucher_no = self.name + doc.against_voucher_type = x.reference_doctype + doc.against_voucher_no = x.reference_name + doc.amount = x.allocated_amount if self.docstatus == 1 else -1 * x.allocated_amount + doc.currency = currency + doc.event = "Submit" if self.docstatus == 1 else "Cancel" + doc.flags.ignore_permissions = 1 + doc.save() + + def make_advance_payment_ledger_entries(self): + if self.docstatus != 0: + if self.doctype == "Journal Entry": + self.make_advance_payment_ledger_for_journal() + elif self.doctype == "Payment Entry": + self.make_advance_payment_ledger_for_payment() + @frappe.whitelist() def get_tax_rate(account_head): @@ -2748,6 +2916,7 @@ def get_advance_payment_entries( party_account, order_doctype, order_list=None, + default_advance_account=None, include_unallocated=True, against_all_orders=False, limit=None, @@ -2761,6 +2930,7 @@ def get_advance_payment_entries( party_type, party, party_account, + default_advance_account, limit, condition, ) @@ -2784,6 +2954,7 @@ def get_advance_payment_entries( party_type, party, party_account, + default_advance_account, limit, condition, ) @@ -2799,6 +2970,7 @@ def get_common_query( party_type, party, party_account, + default_advance_account, limit, condition, ): @@ -2820,14 +2992,22 @@ def get_common_query( .where(payment_entry.docstatus == 1) ) - if payment_type == "Receive": - q = q.select((payment_entry.paid_from_account_currency).as_("currency")) - q = q.select(payment_entry.paid_from) - q = q.where(payment_entry.paid_from.isin(party_account)) + field = "paid_from" if payment_type == "Receive" else "paid_to" + + q = q.select((payment_entry[f"{field}_account_currency"]).as_("currency")) + q = q.select(payment_entry[field]) + account_condition = payment_entry[field].isin(party_account) + if default_advance_account: + q = q.where( + account_condition + | ( + (payment_entry[field] == default_advance_account) + & (payment_entry.book_advance_payments_in_separate_party_account == 1) + ) + ) + else: - q = q.select((payment_entry.paid_to_account_currency).as_("currency")) - q = q.select(payment_entry.paid_to) - q = q.where(payment_entry.paid_to.isin(party_account)) + q = q.where(account_condition) if payment_type == "Receive": q = q.select((payment_entry.source_exchange_rate).as_("exchange_rate")) @@ -3085,9 +3265,9 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child child_item.warehouse = get_item_warehouse(item, p_doc, overwrite_warehouse=True) if not child_item.warehouse: frappe.throw( - _("Cannot find {} for item {}. Please set the same in Item Master or Stock Settings.").format( - frappe.bold("default warehouse"), frappe.bold(item.item_code) - ) + _( + "Cannot find a default warehouse for item {0}. Please set one in the Item Master or in Stock Settings." + ).format(frappe.bold(item.item_code)) ) set_child_tax_template_and_map(item, child_item, p_doc) @@ -3285,7 +3465,6 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil items_added_or_removed = False # updated to true if any new item is added or removed any_conversion_factor_changed = False - sales_doctypes = ["Sales Order", "Sales Invoice", "Delivery Note", "Quotation"] parent = frappe.get_doc(parent_doctype, parent_doctype_name) check_doc_permissions(parent, "write") @@ -3401,25 +3580,21 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil # if rate is greater than price_list_rate, set margin # or set discount child_item.discount_percentage = 0 - - if parent_doctype in sales_doctypes: - child_item.margin_type = "Amount" - child_item.margin_rate_or_amount = flt( - child_item.rate - child_item.price_list_rate, - child_item.precision("margin_rate_or_amount"), - ) - child_item.rate_with_margin = child_item.rate + child_item.margin_type = "Amount" + child_item.margin_rate_or_amount = flt( + child_item.rate - child_item.price_list_rate, + child_item.precision("margin_rate_or_amount"), + ) + child_item.rate_with_margin = child_item.rate else: child_item.discount_percentage = flt( (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0, child_item.precision("discount_percentage"), ) child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate) - - if parent_doctype in sales_doctypes: - child_item.margin_type = "" - child_item.margin_rate_or_amount = 0 - child_item.rate_with_margin = 0 + child_item.margin_type = "" + child_item.margin_rate_or_amount = 0 + child_item.rate_with_margin = 0 child_item.flags.ignore_validate_update_after_submit = True if new_child_flag: @@ -3494,6 +3669,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.update_billing_percentage() parent.set_status() + parent.validate_uom_is_integer("uom", "qty") + parent.validate_uom_is_integer("stock_uom", "stock_qty") + # Cancel and Recreate Stock Reservation Entries. if parent_doctype == "Sales Order": from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index a55eded2a4c..6020dce0761 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -356,14 +356,14 @@ class BuyingController(SubcontractingController): if not self.is_internal_transfer(): return + self.set_sales_incoming_rate_for_internal_transfer() + allow_at_arms_length_price = frappe.get_cached_value( "Stock Settings", None, "allow_internal_transfer_at_arms_length_price" ) if allow_at_arms_length_price: return - self.set_sales_incoming_rate_for_internal_transfer() - for d in self.get("items"): d.discount_percentage = 0.0 d.discount_amount = 0.0 @@ -689,9 +689,11 @@ class BuyingController(SubcontractingController): if self.doctype in ["Purchase Receipt", "Purchase Invoice"]: self.process_fixed_asset() - if self.doctype in ["Purchase Order", "Purchase Receipt"] and not frappe.db.get_single_value( - "Buying Settings", "disable_last_purchase_rate" - ): + if self.doctype in [ + "Purchase Order", + "Purchase Receipt", + "Purchase Invoice", + ] and not frappe.db.get_single_value("Buying Settings", "disable_last_purchase_rate"): update_last_purchase_rate(self, is_submit=1) def on_cancel(self): @@ -700,9 +702,11 @@ class BuyingController(SubcontractingController): if self.get("is_return"): return - if self.doctype in ["Purchase Order", "Purchase Receipt"] and not frappe.db.get_single_value( - "Buying Settings", "disable_last_purchase_rate" - ): + if self.doctype in [ + "Purchase Order", + "Purchase Receipt", + "Purchase Invoice", + ] and not frappe.db.get_single_value("Buying Settings", "disable_last_purchase_rate"): update_last_purchase_rate(self, is_submit=0) if self.doctype in ["Purchase Receipt", "Purchase Invoice"]: @@ -820,6 +824,8 @@ class BuyingController(SubcontractingController): "asset_quantity": asset_quantity, "purchase_receipt": self.name if self.doctype == "Purchase Receipt" else None, "purchase_invoice": self.name if self.doctype == "Purchase Invoice" else None, + "purchase_receipt_item": row.name if self.doctype == "Purchase Receipt" else None, + "purchase_invoice_item": row.name if self.doctype == "Purchase Invoice" else None, } ) diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index cc6870f892a..5dace4af884 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -150,7 +150,7 @@ def validate_item_attribute_value(attributes_list, attribute, attribute_value, i ) msg += "This action will also delete all associated Common Code documents.
", + [frm.docname.bold()] + ), + function () { + return frappe.call({ + method: "frappe.client.delete", + args: { + doctype: frm.doctype, + name: frm.docname, + }, + freeze: true, + freeze_message: __("Deleting {0} and all associated Common Code documents...", [ + frm.docname, + ]), + callback: function (r) { + if (!r.exc) { + frappe.utils.play_sound("delete"); + frappe.model.clear_doc(frm.doctype, frm.docname); + window.history.back(); + } + }, + }); + } + ); + }; + + frm.set_query("default_common_code", function (doc) { + return { + filters: { + code_list: doc.name, + }, + }; + }); + }, +}); diff --git a/erpnext/edi/doctype/code_list/code_list.json b/erpnext/edi/doctype/code_list/code_list.json new file mode 100644 index 00000000000..ffcc2f2b605 --- /dev/null +++ b/erpnext/edi/doctype/code_list/code_list.json @@ -0,0 +1,112 @@ +{ + "actions": [], + "allow_copy": 1, + "allow_rename": 1, + "autoname": "prompt", + "creation": "2024-09-29 06:55:03.920375", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "title", + "canonical_uri", + "url", + "default_common_code", + "column_break_nkls", + "version", + "publisher", + "publisher_id", + "section_break_npxp", + "description" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "label": "Title" + }, + { + "fieldname": "publisher", + "fieldtype": "Data", + "in_standard_filter": 1, + "label": "Publisher" + }, + { + "columns": 1, + "fieldname": "version", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Version" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description" + }, + { + "fieldname": "canonical_uri", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Canonical URI" + }, + { + "fieldname": "column_break_nkls", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_npxp", + "fieldtype": "Section Break" + }, + { + "fieldname": "publisher_id", + "fieldtype": "Data", + "in_standard_filter": 1, + "label": "Publisher ID" + }, + { + "fieldname": "url", + "fieldtype": "Data", + "label": "URL", + "options": "URL" + }, + { + "description": "This value shall be used when no matching Common Code for a record is found.", + "fieldname": "default_common_code", + "fieldtype": "Link", + "label": "Default Common Code", + "options": "Common Code" + } + ], + "index_web_pages_for_search": 1, + "links": [ + { + "link_doctype": "Common Code", + "link_fieldname": "code_list" + } + ], + "modified": "2024-11-16 17:01:40.260293", + "modified_by": "Administrator", + "module": "EDI", + "name": "Code List", + "naming_rule": "Set by user", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "search_fields": "description", + "show_title_field_in_link": 1, + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "title" +} \ No newline at end of file diff --git a/erpnext/edi/doctype/code_list/code_list.py b/erpnext/edi/doctype/code_list/code_list.py new file mode 100644 index 00000000000..8957c6565b9 --- /dev/null +++ b/erpnext/edi/doctype/code_list/code_list.py @@ -0,0 +1,125 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from typing import TYPE_CHECKING + +import frappe +from frappe.model.document import Document + +if TYPE_CHECKING: + from lxml.etree import Element + + +class CodeList(Document): + # begin: auto-generated types + # 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 + + canonical_uri: DF.Data | None + default_common_code: DF.Link | None + description: DF.SmallText | None + publisher: DF.Data | None + publisher_id: DF.Data | None + title: DF.Data | None + url: DF.Data | None + version: DF.Data | None + # end: auto-generated types + + def on_trash(self): + if not frappe.flags.in_bulk_delete: + self.__delete_linked_docs() + + def __delete_linked_docs(self): + self.db_set("default_common_code", None) + + linked_docs = frappe.get_all( + "Common Code", + filters={"code_list": self.name}, + fields=["name"], + ) + + for doc in linked_docs: + frappe.delete_doc("Common Code", doc.name) + + def get_codes_for(self, doctype: str, name: str) -> tuple[str]: + """Get the applicable codes for a doctype and name""" + return get_codes_for(self.name, doctype, name) + + def get_docnames_for(self, doctype: str, code: str) -> tuple[str]: + """Get the mapped docnames for a doctype and code""" + return get_docnames_for(self.name, doctype, code) + + def get_default_code(self) -> str | None: + """Get the default common code for this code list""" + return ( + frappe.db.get_value("Common Code", self.default_common_code, "common_code") + if self.default_common_code + else None + ) + + def from_genericode(self, root: "Element"): + """Extract Code List details from genericode XML""" + self.title = root.find(".//Identification/ShortName").text + self.version = root.find(".//Identification/Version").text + self.canonical_uri = root.find(".//CanonicalUri").text + # optionals + self.description = getattr(root.find(".//Identification/LongName"), "text", None) + self.publisher = getattr(root.find(".//Identification/Agency/ShortName"), "text", None) + if not self.publisher: + self.publisher = getattr(root.find(".//Identification/Agency/LongName"), "text", None) + self.publisher_id = getattr(root.find(".//Identification/Agency/Identifier"), "text", None) + self.url = getattr(root.find(".//Identification/LocationUri"), "text", None) + + +def get_codes_for(code_list: str, doctype: str, name: str) -> tuple[str]: + """Return the common code for a given record""" + CommonCode = frappe.qb.DocType("Common Code") + DynamicLink = frappe.qb.DocType("Dynamic Link") + + codes = ( + frappe.qb.from_(CommonCode) + .join(DynamicLink) + .on((CommonCode.name == DynamicLink.parent) & (DynamicLink.parenttype == "Common Code")) + .select(CommonCode.common_code) + .where( + (DynamicLink.link_doctype == doctype) + & (DynamicLink.link_name == name) + & (CommonCode.code_list == code_list) + ) + .distinct() + .orderby(CommonCode.common_code) + ).run() + + return tuple(c[0] for c in codes) if codes else () + + +def get_docnames_for(code_list: str, doctype: str, code: str) -> tuple[str]: + """Return the record name for a given common code""" + CommonCode = frappe.qb.DocType("Common Code") + DynamicLink = frappe.qb.DocType("Dynamic Link") + + docnames = ( + frappe.qb.from_(CommonCode) + .join(DynamicLink) + .on((CommonCode.name == DynamicLink.parent) & (DynamicLink.parenttype == "Common Code")) + .select(DynamicLink.link_name) + .where( + (DynamicLink.link_doctype == doctype) + & (CommonCode.common_code == code) + & (CommonCode.code_list == code_list) + ) + .distinct() + .orderby(DynamicLink.idx) + ).run() + + return tuple(d[0] for d in docnames) if docnames else () + + +def get_default_code(code_list: str) -> str | None: + """Return the default common code for a given code list""" + code_id = frappe.db.get_value("Code List", code_list, "default_common_code") + return frappe.db.get_value("Common Code", code_id, "common_code") if code_id else None diff --git a/erpnext/edi/doctype/code_list/code_list_import.js b/erpnext/edi/doctype/code_list/code_list_import.js new file mode 100644 index 00000000000..4a33f3e2fe6 --- /dev/null +++ b/erpnext/edi/doctype/code_list/code_list_import.js @@ -0,0 +1,218 @@ +frappe.provide("erpnext.edi"); + +erpnext.edi.import_genericode = function (listview_or_form) { + let doctype = "Code List"; + let docname = undefined; + if (listview_or_form.doc !== undefined) { + docname = listview_or_form.doc.name; + } + new frappe.ui.FileUploader({ + method: "erpnext.edi.doctype.code_list.code_list_import.import_genericode", + doctype: doctype, + docname: docname, + allow_toggle_private: false, + allow_take_photo: false, + on_success: function (_file_doc, r) { + listview_or_form.refresh(); + show_column_selection_dialog(r.message); + }, + }); +}; + +function show_column_selection_dialog(context) { + let title_description = __("If there is no title column, use the code column for the title."); + let default_title = get_default(context.columns, ["name", "Name", "code-name", "scheme-name"]); + let fields = [ + { + fieldtype: "HTML", + fieldname: "code_list_info", + options: `| ${__("Title")} | `; + if (code_column) html += `${__("Code")} | `; + if (description_column) html += `${__("Description")} | `; + + // Add headers for filterable columns + for (let column in context.filterable_columns) { + if (dialog.get_value(`filter_${column}`)) { + html += `${__(column)} | `; + } + } + + html += "
|---|---|---|---|
| ${truncate(title)} | `; + } + if (code_column) { + let code = context.example_values[code_column][i] || ""; + html += `${truncate(code)} | `; + } + if (description_column) { + let description = context.example_values[description_column][i] || ""; + html += `${truncate(description)} | `; + } + + // Add values for filterable columns + for (let column in context.filterable_columns) { + if (dialog.get_value(`filter_${column}`)) { + let value = context.example_values[column][i] || ""; + html += `${truncate(value)} | `; + } + } + + html += "
{e!s}", title=_("Fetching Error"))
+
+ if file_url := frappe.local.uploaded_file_url:
+ file_path = frappe.utils.file_manager.get_file_path(file_url)
+ with open(file_path.encode(), mode="rb") as f:
+ content = f.read()
+
+ # Parse the xml content
+ parser = etree.XMLParser(remove_blank_text=True)
+ try:
+ root = etree.fromstring(content, parser=parser)
+ except Exception as e:
+ frappe.throw(f"{e!s}", title=_("Parsing Error"))
+
+ # Extract the name (CanonicalVersionUri) from the parsed XML
+ name = root.find(".//CanonicalVersionUri").text
+ docname = docname or name
+
+ if frappe.db.exists(doctype, docname):
+ code_list = frappe.get_doc(doctype, docname)
+ if code_list.name != name:
+ frappe.throw(_("The uploaded file does not match the selected Code List."))
+ else:
+ # Create a new Code List document with the extracted name
+ code_list = frappe.new_doc(doctype)
+ code_list.name = name
+
+ code_list.from_genericode(root)
+ code_list.save()
+
+ # Attach the file and provide a recoverable identifier
+ file_doc = frappe.get_doc(
+ {
+ "doctype": "File",
+ "attached_to_doctype": "Code List",
+ "attached_to_name": code_list.name,
+ "folder": "Home/Attachments",
+ "file_name": frappe.local.uploaded_filename,
+ "file_url": frappe.local.uploaded_file_url,
+ "is_private": 1,
+ "content": content,
+ }
+ ).save()
+
+ # Get available columns and example values
+ columns, example_values, filterable_columns = get_genericode_columns_and_examples(root)
+
+ return {
+ "code_list": code_list.name,
+ "code_list_title": code_list.title,
+ "file": file_doc.name,
+ "columns": columns,
+ "example_values": example_values,
+ "filterable_columns": filterable_columns,
+ }
+
+
+@frappe.whitelist()
+def process_genericode_import(
+ code_list_name: str,
+ file_name: str,
+ code_column: str,
+ title_column: str | None = None,
+ description_column: str | None = None,
+ filters: str | None = None,
+):
+ from erpnext.edi.doctype.common_code.common_code import import_genericode
+
+ column_map = {"code": code_column, "title": title_column, "description": description_column}
+
+ return import_genericode(code_list_name, file_name, column_map, json.loads(filters) if filters else None)
+
+
+def get_genericode_columns_and_examples(root):
+ columns = []
+ example_values = {}
+ filterable_columns = {}
+
+ # Get column names
+ for column in root.findall(".//Column"):
+ column_id = column.get("Id")
+ columns.append(column_id)
+ example_values[column_id] = []
+ filterable_columns[column_id] = set()
+
+ # Get all values and count unique occurrences
+ for row in root.findall(".//SimpleCodeList/Row"):
+ for value in row.findall("Value"):
+ column_id = value.get("ColumnRef")
+ if column_id not in columns:
+ # Handle undeclared column
+ columns.append(column_id)
+ example_values[column_id] = []
+ filterable_columns[column_id] = set()
+
+ simple_value = value.find("./SimpleValue")
+ if simple_value is None:
+ continue
+
+ filterable_columns[column_id].add(simple_value.text)
+
+ # Get example values (up to 3) and filter columns with cardinality <= 5
+ for row in root.findall(".//SimpleCodeList/Row")[:3]:
+ for value in row.findall("Value"):
+ column_id = value.get("ColumnRef")
+ simple_value = value.find("./SimpleValue")
+ if simple_value is None:
+ continue
+
+ example_values[column_id].append(simple_value.text)
+
+ filterable_columns = {k: list(v) for k, v in filterable_columns.items() if len(v) <= 5}
+
+ return columns, example_values, filterable_columns
diff --git a/erpnext/edi/doctype/code_list/code_list_list.js b/erpnext/edi/doctype/code_list/code_list_list.js
new file mode 100644
index 00000000000..08125de2903
--- /dev/null
+++ b/erpnext/edi/doctype/code_list/code_list_list.js
@@ -0,0 +1,8 @@
+frappe.listview_settings["Code List"] = {
+ onload: function (listview) {
+ listview.page.add_inner_button(__("Import Genericode File"), function () {
+ erpnext.edi.import_genericode(listview);
+ });
+ },
+ hide_name_column: true,
+};
diff --git a/erpnext/edi/doctype/code_list/test_code_list.py b/erpnext/edi/doctype/code_list/test_code_list.py
new file mode 100644
index 00000000000..d37b1ee8f5a
--- /dev/null
+++ b/erpnext/edi/doctype/code_list/test_code_list.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests.utils import FrappeTestCase
+
+
+class TestCodeList(FrappeTestCase):
+ pass
diff --git a/erpnext/edi/doctype/common_code/__init__.py b/erpnext/edi/doctype/common_code/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/edi/doctype/common_code/common_code.js b/erpnext/edi/doctype/common_code/common_code.js
new file mode 100644
index 00000000000..646d5c85b74
--- /dev/null
+++ b/erpnext/edi/doctype/common_code/common_code.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Common Code", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/erpnext/edi/doctype/common_code/common_code.json b/erpnext/edi/doctype/common_code/common_code.json
new file mode 100644
index 00000000000..b2cb43fa575
--- /dev/null
+++ b/erpnext/edi/doctype/common_code/common_code.json
@@ -0,0 +1,103 @@
+{
+ "actions": [],
+ "autoname": "hash",
+ "creation": "2024-09-29 07:01:18.133067",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "code_list",
+ "title",
+ "common_code",
+ "description",
+ "column_break_wxsw",
+ "additional_data",
+ "section_break_rhgh",
+ "applies_to"
+ ],
+ "fields": [
+ {
+ "fieldname": "code_list",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Code List",
+ "options": "Code List",
+ "reqd": 1,
+ "search_index": 1
+ },
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Title",
+ "length": 300,
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_wxsw",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_rhgh",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "applies_to",
+ "fieldtype": "Table",
+ "label": "Applies To",
+ "options": "Dynamic Link"
+ },
+ {
+ "fieldname": "common_code",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Common Code",
+ "length": 300,
+ "reqd": 1,
+ "search_index": 1
+ },
+ {
+ "fieldname": "additional_data",
+ "fieldtype": "Code",
+ "label": "Additional Data",
+ "max_height": "190px",
+ "read_only": 1
+ },
+ {
+ "fieldname": "description",
+ "fieldtype": "Small Text",
+ "in_list_view": 1,
+ "label": "Description",
+ "max_height": "60px"
+ }
+ ],
+ "links": [],
+ "modified": "2024-11-06 07:46:17.175687",
+ "modified_by": "Administrator",
+ "module": "EDI",
+ "name": "Common Code",
+ "naming_rule": "Random",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "search_fields": "common_code,description",
+ "show_title_field_in_link": 1,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "title"
+}
\ No newline at end of file
diff --git a/erpnext/edi/doctype/common_code/common_code.py b/erpnext/edi/doctype/common_code/common_code.py
new file mode 100644
index 00000000000..d558b2d282f
--- /dev/null
+++ b/erpnext/edi/doctype/common_code/common_code.py
@@ -0,0 +1,114 @@
+# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import hashlib
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.utils.data import get_link_to_form
+from lxml import etree
+
+
+class CommonCode(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.core.doctype.dynamic_link.dynamic_link import DynamicLink
+ from frappe.types import DF
+
+ additional_data: DF.Code | None
+ applies_to: DF.Table[DynamicLink]
+ code_list: DF.Link
+ common_code: DF.Data
+ description: DF.SmallText | None
+ title: DF.Data
+ # end: auto-generated types
+
+ def validate(self):
+ self.validate_distinct_references()
+
+ def validate_distinct_references(self):
+ """Ensure no two Common Codes of the same Code List are linked to the same document."""
+ for link in self.applies_to:
+ existing_links = frappe.get_all(
+ "Common Code",
+ filters=[
+ ["name", "!=", self.name],
+ ["code_list", "=", self.code_list],
+ ["Dynamic Link", "link_doctype", "=", link.link_doctype],
+ ["Dynamic Link", "link_name", "=", link.link_name],
+ ],
+ fields=["name", "common_code"],
+ )
+
+ if existing_links:
+ existing_link = existing_links[0]
+ frappe.throw(
+ _("{0} {1} is already linked to Common Code {2}.").format(
+ link.link_doctype,
+ link.link_name,
+ get_link_to_form("Common Code", existing_link["name"], existing_link["common_code"]),
+ )
+ )
+
+ def from_genericode(self, column_map: dict, xml_element: "etree.Element"):
+ """Populate the Common Code document from a genericode XML element
+
+ Args:
+ column_map (dict): A mapping of column names to XML column references. Keys: code, title, description
+ code (etree.Element): The XML element representing a code in the genericode file
+ """
+ title_column = column_map.get("title")
+ code_column = column_map["code"]
+ description_column = column_map.get("description")
+
+ self.common_code = xml_element.find(f"./Value[@ColumnRef='{code_column}']/SimpleValue").text
+
+ if title_column:
+ simple_value_title = xml_element.find(f"./Value[@ColumnRef='{title_column}']/SimpleValue")
+ self.title = simple_value_title.text if simple_value_title is not None else self.common_code
+
+ if description_column:
+ simple_value_descr = xml_element.find(f"./Value[@ColumnRef='{description_column}']/SimpleValue")
+ self.description = simple_value_descr.text if simple_value_descr is not None else None
+
+ self.additional_data = etree.tostring(xml_element, encoding="unicode", pretty_print=True)
+
+
+def simple_hash(input_string, length=6):
+ return hashlib.blake2b(input_string.encode(), digest_size=length // 2).hexdigest()
+
+
+def import_genericode(code_list: str, file_name: str, column_map: dict, filters: dict | None = None):
+ """Import genericode file and create Common Code entries"""
+ file_path = frappe.utils.file_manager.get_file_path(file_name)
+ parser = etree.XMLParser(remove_blank_text=True)
+ tree = etree.parse(file_path, parser=parser)
+ root = tree.getroot()
+
+ # Construct the XPath expression
+ xpath_expr = ".//SimpleCodeList/Row"
+ filter_conditions = [
+ f"Value[@ColumnRef='{column_ref}']/SimpleValue='{value}'" for column_ref, value in filters.items()
+ ]
+ if filter_conditions:
+ xpath_expr += "[" + " and ".join(filter_conditions) + "]"
+
+ elements = root.xpath(xpath_expr)
+ total_elements = len(elements)
+ for i, xml_element in enumerate(elements, start=1):
+ common_code: "CommonCode" = frappe.new_doc("Common Code")
+ common_code.code_list = code_list
+ common_code.from_genericode(column_map, xml_element)
+ common_code.save()
+ frappe.publish_progress(i / total_elements * 100, title=_("Importing Common Codes"))
+
+ return total_elements
+
+
+def on_doctype_update():
+ frappe.db.add_index("Common Code", ["code_list", "common_code"])
diff --git a/erpnext/edi/doctype/common_code/common_code_list.js b/erpnext/edi/doctype/common_code/common_code_list.js
new file mode 100644
index 00000000000..de1b665b161
--- /dev/null
+++ b/erpnext/edi/doctype/common_code/common_code_list.js
@@ -0,0 +1,8 @@
+frappe.listview_settings["Common Code"] = {
+ onload: function (listview) {
+ listview.page.add_inner_button(__("Import Genericode File"), function () {
+ erpnext.edi.import_genericode(listview);
+ });
+ },
+ hide_name_column: true,
+};
diff --git a/erpnext/edi/doctype/common_code/test_common_code.py b/erpnext/edi/doctype/common_code/test_common_code.py
new file mode 100644
index 00000000000..e9c67b2cc82
--- /dev/null
+++ b/erpnext/edi/doctype/common_code/test_common_code.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests.utils import FrappeTestCase
+
+
+class TestCommonCode(FrappeTestCase):
+ pass
diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py
index edfab4777a7..e187c075077 100644
--- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py
+++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py
@@ -96,7 +96,7 @@ def add_bank_accounts(response, bank, company):
frappe.throw(
_(
"Please setup and enable a group account with the Account Type - {0} for the company {1}"
- ).format(frappe.bold("Bank"), company)
+ ).format(frappe.bold(_("Bank")), company)
)
for account in response["accounts"]:
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index 527be6ab337..882adec4d51 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -10,7 +10,17 @@ source_link = "https://github.com/frappe/erpnext"
app_logo_url = "/assets/erpnext/images/erpnext-logo.svg"
-develop_version = "14.x.x-develop"
+add_to_apps_screen = [
+ {
+ "name": "erpnext",
+ "logo": "/assets/erpnext/images/erpnext-logo-blue.png",
+ "title": "ERPNext",
+ "route": "/app/home",
+ "has_permission": "erpnext.check_app_permission",
+ }
+]
+
+develop_version = "15.x.x-develop"
app_include_js = "erpnext.bundle.js"
app_include_css = "erpnext.bundle.css"
@@ -25,6 +35,14 @@ doctype_js = {
"Newsletter": "public/js/newsletter.js",
"Contact": "public/js/contact.js",
}
+doctype_list_js = {
+ "Code List": [
+ "edi/doctype/code_list/code_list_import.js",
+ ],
+ "Common Code": [
+ "edi/doctype/code_list/code_list_import.js",
+ ],
+}
override_doctype_class = {"Address": "erpnext.accounts.custom.address.ERPNextAddress"}
@@ -355,7 +373,6 @@ doc_events = {
"Payment Entry": {
"on_submit": [
"erpnext.regional.create_transaction_log",
- "erpnext.accounts.doctype.payment_request.payment_request.update_payment_req_status",
"erpnext.accounts.doctype.dunning.dunning.resolve_dunning",
],
"on_cancel": ["erpnext.accounts.doctype.dunning.dunning.resolve_dunning"],
diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js
index 6267ee4d029..fe498ca32f9 100644
--- a/erpnext/manufacturing/doctype/bom/bom.js
+++ b/erpnext/manufacturing/doctype/bom/bom.js
@@ -182,25 +182,30 @@ frappe.ui.form.on("BOM", {
},
make_work_order(frm) {
- frm.events.setup_variant_prompt(frm, "Work Order", (frm, item, data, variant_items) => {
- frappe.call({
- method: "erpnext.manufacturing.doctype.work_order.work_order.make_work_order",
- args: {
- bom_no: frm.doc.name,
- item: item,
- qty: data.qty || 0.0,
- project: frm.doc.project,
- variant_items: variant_items,
- },
- freeze: true,
- callback(r) {
- if (r.message) {
- let doc = frappe.model.sync(r.message)[0];
- frappe.set_route("Form", doc.doctype, doc.name);
- }
- },
- });
- });
+ frm.events.setup_variant_prompt(
+ frm,
+ "Work Order",
+ (frm, item, data, variant_items, use_multi_level_bom) => {
+ frappe.call({
+ method: "erpnext.manufacturing.doctype.work_order.work_order.make_work_order",
+ args: {
+ bom_no: frm.doc.name,
+ item: item,
+ qty: data.qty || 0.0,
+ project: frm.doc.project,
+ variant_items: variant_items,
+ use_multi_level_bom: use_multi_level_bom,
+ },
+ freeze: true,
+ callback(r) {
+ if (r.message) {
+ let doc = frappe.model.sync(r.message)[0];
+ frappe.set_route("Form", doc.doctype, doc.name);
+ }
+ },
+ });
+ }
+ );
},
make_variant_bom(frm) {
@@ -250,6 +255,15 @@ frappe.ui.form.on("BOM", {
});
}
+ if (!skip_qty_field) {
+ fields.push({
+ fieldtype: "Check",
+ label: __("Use Multi-Level BOM"),
+ fieldname: "use_multi_level_bom",
+ default: 1,
+ });
+ }
+
if (!skip_qty_field) {
fields.push({
fieldtype: "Float",
@@ -277,6 +291,13 @@ frappe.ui.form.on("BOM", {
cur_dialog.refresh();
},
});
+
+ fields.push({
+ fieldtype: "Check",
+ label: __("Use Multi-Level BOM"),
+ fieldname: "use_multi_level_bom",
+ default: frm.doc?.__onload.use_multi_level_bom,
+ });
}
var has_template_rm = frm.doc.items.filter((d) => d.has_variants === 1) || [];
@@ -285,6 +306,7 @@ frappe.ui.form.on("BOM", {
fieldname: "items",
fieldtype: "Table",
label: __("Raw Materials"),
+ depends_on: "eval:!doc.use_multi_level_bom",
fields: [
{
fieldname: "item_code",
@@ -293,6 +315,13 @@ frappe.ui.form.on("BOM", {
fieldtype: "Link",
in_list_view: 1,
reqd: 1,
+ get_query() {
+ return {
+ filters: {
+ has_variants: 1,
+ },
+ };
+ },
},
{
fieldname: "variant_item_code",
@@ -313,6 +342,13 @@ frappe.ui.form.on("BOM", {
},
};
},
+ change() {
+ let doc = this.doc;
+ if (!doc.qty) {
+ doc.qty = 1.0;
+ this.grid.set_value("qty", 1.0, doc);
+ }
+ },
},
{
fieldname: "qty",
@@ -347,14 +383,15 @@ frappe.ui.form.on("BOM", {
(data) => {
let item = data.item || frm.doc.item;
let variant_items = data.items || [];
+ let use_multi_level_bom = data.use_multi_level_bom || 0;
variant_items.forEach((d) => {
- if (!d.variant_item_code) {
+ if (!d.variant_item_code && !use_multi_level_bom) {
frappe.throw(__("Select variant item code for the template item {0}", [d.item_code]));
}
});
- callback(frm, item, data, variant_items);
+ callback(frm, item, data, variant_items, use_multi_level_bom);
},
__(title),
__("Create")
@@ -364,7 +401,7 @@ frappe.ui.form.on("BOM", {
dialog.fields_dict.items.df.data.push({
item_code: d.item_code,
variant_item_code: "",
- qty: d.qty,
+ qty: (d.qty / frm.doc.quantity) * (dialog.fields_dict.qty.value || 1),
source_warehouse: d.source_warehouse,
operation: d.operation,
});
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index 303670cee72..6b2dd77c471 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -176,13 +176,10 @@ class BOM(WebsiteGenerator):
search_key = f"{self.doctype}-{self.item}%"
existing_boms = frappe.get_all(
- "BOM", filters={"name": ("like", search_key), "amended_from": ["is", "not set"]}, pluck="name"
+ "BOM", filters={"name": search_key, "amended_from": ["is", "not set"]}, pluck="name"
)
- if existing_boms:
- index = self.get_next_version_index(existing_boms)
- else:
- index = 1
+ index = self.get_index_for_bom(existing_boms)
prefix = self.doctype
suffix = "%.3i" % index # convert index to string (1 -> "001")
@@ -200,21 +197,40 @@ class BOM(WebsiteGenerator):
name = f"{prefix}-{truncated_item_name}-{suffix}"
if frappe.db.exists("BOM", name):
- conflicting_bom = frappe.get_doc("BOM", name)
+ existing_boms = frappe.get_all(
+ "BOM", filters={"name": ("like", search_key), "amended_from": ["is", "not set"]}, pluck="name"
+ )
- if conflicting_bom.item != self.item:
- msg = _("A BOM with name {0} already exists for item {1}.").format(
- frappe.bold(name), frappe.bold(conflicting_bom.item)
- )
-
- frappe.throw(
- _("{0}{1} Did you rename the item? Please contact Administrator / Tech support").format(
- msg, "{{row.status}}
-| {d.batch_no} | {d.serial_no} | {abs(d.qty)} |
|---|---|---|
| {d.batch_no} | {d.serial_no} | {abs(d.qty)} |
| {d.batch_no} | {abs(d.qty)} |
| Child Document | +Non Child Document | +
|---|---|
|
+ To access parent document field use parent.fieldname and to access child table document field use doc.fieldname + + |
+
+ To access document field use doc.fieldname + |
+
|
+ Example: parent.doctype == ""Stock Entry"" and doc.item_code == ""Test"" + + |
+
+ Example: doc.doctype == ""Stock Entry"" and doc.purpose == ""Manufacture"" + |
+
| Documento secundario | +Documento no secundario | +
|---|---|
|
+ Para acceder al campo del documento principal, utilice parent.fieldname y para acceder al campo del documento de la tabla secundaria, utilice doc.fieldname + + |
+
+ Para acceder al campo del documento, utilice doc.fieldname + |
+
|
+ Ejemplo: parent.doctype == ""Entrada de stock"" y doc.item_code == ""Prueba"" + + |
+
+ Ejemplo: doc.doctype == ""Entrada de stock"" y doc.purpose == ""Fabricación"" + |
+
Aggregate group of Items into another Item. This is useful if you are bundling a certain Items into a package and you maintain stock of the packed Items and not the aggregate Item.
+The package Item will have Is Stock Item as No and Is Sales Item as Yes.
If you are selling Laptops and Backpacks separately and have a special price if the customer buys both, then the Laptop + Backpack will be a new Product Bundle Item.
","گروهی از آیتمها را در آیتم دیگری جمع آوری کنید. اگر آیتمهای خاصی را در یک باندل قرار دهید و موجودی آیتمهای بسته بندی شده را حفظ کنید و نه آیتمهای جمع آوری شده، مفید است.
+بسته آیتم دارای آیتم موجودی است به عنوان خیر و آیتم فروش است به عنوان بله خواهد بود. .
اگر لپتاپ و کولهپشتی را جداگانه میفروشید و اگر مشتری هر دو را بخرد، قیمت ویژهای دارید، لپتاپ + کولهپشتی یک کالای باندل محصول جدید خواهد بود.
", +"Statement Of Accounts for {{ customer.customer_name }}Hello {{ customer.customer_name }},
PFA your Statement Of Accounts from {{ doc.from_date }} to {{ doc.to_date }}. Konto besked för {{ customer.customer_name }}Hej {{ customer.customer_name }},
Kontoutdrag från {{ doc.from_date }} till {{ doc.to_date }}. li>
+Aggregate group of Items into another Item. This is useful if you are bundling a certain Items into a package and you maintain stock of the packed Items and not the aggregate Item.
+The package Item will have Is Stock Item as No and Is Sales Item as Yes.
If you are selling Laptops and Backpacks separately and have a special price if the customer buys both, then the Laptop + Backpack will be a new Product Bundle Item.
","Paketera grupp av artiklar till annan Artikel. Det är användbart om man paketerar vissa Artiklar i paket och har lager av packade Artiklar och inte paketarad Artikel.Artikel kommer att ha Artikel. Är Lagerar Artikel som Nej och Är Försäljning Artikel som Ja.
Om man säljer bärbara datorer och ryggsäckar separat och har specialpris om kunder köper båda, så kommer det att vara bärbar dator + ryggsäck som paket artikel.", +"
There are 3 variables that could be used within the endpoint, result key and in values of the parameter.
+Exchange rate between {from_currency} and {to_currency} on {transaction_date} is fetched by the API.
+Example: If your endpoint is exchange.com/2021-08-01, then, you will have to input exchange.com/{transaction_date}
","Det finns 3 variabler som kan användas av slutpunkt, resultat nyckel och i parameter värde.
+Växelkurs mellan {from_currency} och {to_currency} {transaction_date} hämtas av API.
+Exempel: Om slutpunkt är exchange.com/2021-08-01 måste du ange exchange.com/{transaction_date}
", +"The fieldnames you can use in your template are the fields in the document. You can find out the fields of any documents via Setup > Customize Form View and selecting the document type (e.g. Sales Invoice)
+ +Templates are compiled using the Jinja Templating Language. To learn more about Jinja, read this documentation.
","Fältnamn man kan använda i mall är fält i dokument. Du kan ta reda på fält namn för alla dokument via Inställningar > Anpassa formulärvy och välj dokument typ (t.ex. Försäljning Faktura)
+ +Mallar kompileras med Jinja Templating Language. Om du vill veta mer om Jinja läs denna dokumentation.
", +"Contract for Customer {{ party_name }}
+
+-Valid From : {{ start_date }}
+-Valid To : {{ end_date }}
+
+
+The field names you can use in your Contract Template are the fields in the Contract for which you are creating the template. You can find out the fields of any documents via Setup > Customize Form View and selecting the document type (e.g. Contract)
+ +Templates are compiled using the Jinja Templating Language. To learn more about Jinja, read this documentation.
","Avtal för Kund {{ party_name }}
+
+-Giltigt från: {{ start_date }}
+-Gäller till: {{ end_date }}
+
+
+Fältnamn du kan använda i avtal mall är fält i avtal som du skapar mallen för. Du kan ta reda på fält namn för alla dokument via Inställningar > Anpassa formulärvy och välj dokument typ (t.ex. Avtal)
+ +Mallar kompileras med Jinja Templating Language. Om du vill veta mer om Jinja läser du den här dokumentationen.
", +"Delivery Terms for Order number {{ name }}
+
+-Order Date : {{ transaction_date }}
+-Expected Delivery Date : {{ delivery_date }}
+
+
+The fieldnames you can use in your email template are the fields in the document from which you are sending the email. You can find out the fields of any documents via Setup > Customize Form View and selecting the document type (e.g. Sales Invoice)
+ +Templates are compiled using the Jinja Templating Language. To learn more about Jinja, read this documentation.
","Leverans Villkor för Order Nummer {{ name }}
+
+-Order Datum: {{ transaction_date }}
+-Förväntat Leverans Datum: {{ delivery_date }}
+
+
+Fältnamn som kan användas i E-post Mall är fält i Dokument som man skickar E-post meddelande från. Man kan ta reda på fält namn för alla dokument via Inställning > Anpassa Formulär Vy och välja Dokument Typ (t.ex. Försäljning Faktura)
+ +Mallar kompileras med Jinja Mall Språk. Läs mer om Jinja dokumentation:
", +"In your Email Template, you can use the following special variables: +
+{{ update_password_link }}: A link where your supplier can set a new password to log into your portal.
+ {{ portal_link }}: A link to this RFQ in your supplier portal.
+ {{ supplier_name }}: The company name of your supplier.
+ {{ contact.salutation }} {{ contact.last_name }}: The contact person of your supplier.
+ {{ user_fullname }}: Your full name.
+ Apart from these, you can access all values in this RFQ, like {{ message_for_supplier }} or {{ terms }}.
I E-post Mall kan följande specialvariabler användas: +
+{{ update_password_link }} : Länk där leverantör kan ange nytt lösenord för att logga in på leverantör portal.
+ {{ portal_link }}: Länk till offert begäran på leverantör portal.
+ {{ supplier_name }}: Leverantör namn.
+ {{ contact.salutation }} {{ contact.last_name }}: Kontakt person för leverantör.
+ {{ user_fullname } }: Användarens fullständiga namn.
+ +
+Förutom dessa kan man komma åt alla värden i offert begäran, som {{ message_for_supplier }} eller {{ terms }}.
+","Message Example
+ +<p> Thank You for being a part of {{ doc.company }}! We hope you are enjoying the service.</p> + +<p> Please find enclosed the E Bill statement. The outstanding amount is {{ doc.grand_total }}.</p> + +<p> We don't want you to be spending time running around in order to pay for your Bill.
After all, life is beautiful and the time you have in hand should be spent to enjoy it!
So here are our little ways to help you get more time for life! </p> + +<a href=""{{ payment_url }}""> click here to pay </a> + +
+", +"Meddelande Exempel
+ +<p> Tack för att ni är en del av {{ doc.company }}! Vi hoppas att ni gillar tjänst.</p> + +<p> Vänligen se bifogat E-faktura. Utestående belopp är {{ doc.grand_total }}.</p> + +<p> Vi vill inte att ni ska spendera tid med att springa runt för att betala faktura.
Livet är trots allt vackert och den tid man har bör spenderas för att njuta av livet!
Så här är våra små sätt att hjälpa er att få mer tid för livet! < /p> + +<a href=""{{ payment_url }}""> klicka här för att betala </a> + +
+","Message Example
+ +<p>Dear {{ doc.contact_person }},</p> + +<p>Requesting payment for {{ doc.doctype }}, {{ doc.name }} for {{ doc.grand_total }}.</p> + +<a href=""{{ payment_url }}""> click here to pay </a> + +
+", +"Masters & Reports","Inställningar & Rapporter", +"Quick Access","Genvägar", +"Reports & Masters","Rapporter & Inställningar", +"Reports & Masters","Rapporter & Inställningar", +"Settings","Inställningar", +"Shortcuts","Genvägar", +"Your Shortcuts + + + + + + ","Genvägar + + + + + + ", +"Your Shortcuts","Genvägar", +Grand Total: {0},Totalt: {0}, +Outstanding Amount: {0},Utestående belopp: {0}, +"Meddelande Exempel
+ +<p>Hej {{ doc.contact_person }},</p> + +<p>Begär betalning för {{ doc.doctype }}, {{ doc.name }} för {{ doc.grand_total }}.</p> + +<a href=""{{ payment_url }}""> klicka här för att betala </a> + +
| Child Document | +Non Child Document | +
|---|---|
|
+ To access parent document field use parent.fieldname and to access child table document field use doc.fieldname + + |
+
+ To access document field use doc.fieldname + |
+
|
+ Example: parent.doctype == ""Stock Entry"" and doc.item_code == ""Test"" + + |
+
+ Example: doc.doctype == ""Stock Entry"" and doc.purpose == ""Manufacture"" + |
+
För att komma åt överordnad dokument fält använd parent.fieldname och för att komma åt underordnad dokument fält använd doc.fieldname
+ +För att komma åt dokument fält använd doc.fieldname
+Exampel: parent.doctype == ""Stock Entry"" and doc.item_code == ""Test""
+ +Exampel: doc.doctype == ""Stock Entry"" and doc.purpose == ""Produktion""
+