refactor: provision to filter on dimensions in reconciliation tool (#39054)

* refactor: dimensions section in allocation table in reconciliation

(cherry picked from commit 1cde804c773de41520a6148e7d99ab0c23c39ae1)

# Conflicts:
#	erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json

* refactor: update dimension doctypes in hooks

(cherry picked from commit cfb3d872673844f04f5c9dd3f7d7f56288e5dd22)

* refactor: dimensions filter section in payment reconciliation

(cherry picked from commit 20e0acc20a218029d7101a1ba6ff3c1ae03fac02)

* refactor: column break in dimension section

(cherry picked from commit 20576e0f47ba3c4937121bfab1e0d8d395a590ce)

# Conflicts:
#	erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json

* refactor: handle dimension filters

(cherry picked from commit c1fe4bcc64775507a3bd8e02b61274d8dc2d6447)

* refactor: pass dimension filters to query

(cherry picked from commit ff60ec85b85d5548886e247b72cf1262587feba3)

* refactor: set query filters for dimensions

(cherry picked from commit ad8475cb8b24d40b04f86903feee08ecac6aa1f1)

* refactor: pass dimension details to query

(cherry picked from commit 5dc22e1811bb1841bb8c790cc3a1e1315cef6074)

* refactor: replace sql with query builder for Jourals query

(cherry picked from commit 9c5a79209eb014c90aac46a5dd5ed0d9b7cb8f87)

* refactor: partial change on outstanding invoice popup

(cherry picked from commit 2154502955166243e354897d7dcb22d1987c4693)

* fix: typo's and parameter changes

(cherry picked from commit 0ec17590ae062fbda0c14a2806ec1ac07c638593)

* refactor: Credit Note and its Exc gain/loss JE inherits dimensions

(cherry picked from commit ab939cc6e8ab3669f1e9b0f007e9459be180ac32)

* refactor: apply dimension filters on cr/dr notes

(cherry picked from commit 188ff8cde794bb1ef1043f0e47469d65944aac1e)

* chore: test dimension filter output

(cherry picked from commit e3c44231abbbe389a1f815ab77f2d6ff0c614e1b)

* test: dimension inheritance for cr note reconciliation

(cherry picked from commit ba5a7c8cd8ee6fc09b0d81ffbe8b364e584f1f1b)

* refactor: pass dimension values to Gain/Loss journal

(cherry picked from commit c44eb432a59fb3ffb3748e47356068499f1129b1)

# Conflicts:
#	erpnext/accounts/utils.py

* test: dimension inheritance in PE reconciliation

(cherry picked from commit 6148fb024b7157d637aa2308e7c856969858468d)

* refactor: pass dimensions on advance allocation

(cherry picked from commit cbd443a78afbc7c58055881e534a8aa56ca4bea6)

* test: dimension inheritance on adv allocation

(cherry picked from commit fcf4687c523202436234814af3da4c4d84f5eba9)

* refactor: dynamic dimension filters in pop up

(cherry picked from commit f8bbb0619cbbbaace8f54a9f8758c3962ebe4725)

* refactor: update dimensions, only if provided

(cherry picked from commit ec0f17ca8bd810e41ae73f5a45f304ba38c63d0a)

* refactor: handle dynamic dimension in order query

(cherry picked from commit 7c2cb70387d7dbb7f976d28919ce21f25a0b6acd)

* chore: resolve conflicts

---------

Co-authored-by: ruthra kumar <ruthra@erpnext.com>
This commit is contained in:
mergify[bot] 2024-01-28 13:50:08 +05:30 committed by GitHub
parent a83f3106f3
commit a118417645
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 509 additions and 137 deletions

View File

@ -640,7 +640,7 @@ frappe.ui.form.on('Payment Entry', {
get_outstanding_invoices_or_orders: function(frm, get_outstanding_invoices, get_orders_to_be_billed) { get_outstanding_invoices_or_orders: function(frm, get_outstanding_invoices, get_orders_to_be_billed) {
const today = frappe.datetime.get_today(); const today = frappe.datetime.get_today();
const fields = [ let fields = [
{fieldtype:"Section Break", label: __("Posting Date")}, {fieldtype:"Section Break", label: __("Posting Date")},
{fieldtype:"Date", label: __("From Date"), {fieldtype:"Date", label: __("From Date"),
fieldname:"from_posting_date", default:frappe.datetime.add_days(today, -30)}, fieldname:"from_posting_date", default:frappe.datetime.add_days(today, -30)},
@ -655,18 +655,29 @@ frappe.ui.form.on('Payment Entry', {
fieldname:"outstanding_amt_greater_than", default: 0}, fieldname:"outstanding_amt_greater_than", default: 0},
{fieldtype:"Column Break"}, {fieldtype:"Column Break"},
{fieldtype:"Float", label: __("Less Than Amount"), fieldname:"outstanding_amt_less_than"}, {fieldtype:"Float", label: __("Less Than Amount"), fieldname:"outstanding_amt_less_than"},
{fieldtype:"Section Break"}, ];
{fieldtype:"Link", label:__("Cost Center"), fieldname:"cost_center", options:"Cost Center",
"get_query": function() { if (frm.dimension_filters) {
return { let column_break_insertion_point = Math.ceil((frm.dimension_filters.length)/2);
"filters": {"company": frm.doc.company}
} fields.push({fieldtype:"Section Break"});
frm.dimension_filters.map((elem, idx)=>{
fields.push({
fieldtype: "Link",
label: elem.document_type == "Cost Center" ? "Cost Center" : elem.label,
options: elem.document_type,
fieldname: elem.fieldname || elem.document_type
});
if(idx+1 == column_break_insertion_point) {
fields.push({fieldtype:"Column Break"});
} }
}, });
{fieldtype:"Column Break"}, }
fields = fields.concat([
{fieldtype:"Section Break"}, {fieldtype:"Section Break"},
{fieldtype:"Check", label: __("Allocate Payment Amount"), fieldname:"allocate_payment_amount", default:1}, {fieldtype:"Check", label: __("Allocate Payment Amount"), fieldname:"allocate_payment_amount", default:1},
]; ]);
let btn_text = ""; let btn_text = "";

View File

@ -13,6 +13,7 @@ from pypika import Case
from pypika.functions import Coalesce, Sum from pypika.functions import Coalesce, Sum
import erpnext import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
from erpnext.accounts.doctype.bank_account.bank_account import ( from erpnext.accounts.doctype.bank_account.bank_account import (
get_bank_account_details, get_bank_account_details,
get_party_bank_account, get_party_bank_account,
@ -1604,6 +1605,13 @@ def get_outstanding_reference_documents(args, validate=False):
condition += " and cost_center='%s'" % args.get("cost_center") condition += " and cost_center='%s'" % args.get("cost_center")
accounting_dimensions_filter.append(ple.cost_center == args.get("cost_center")) accounting_dimensions_filter.append(ple.cost_center == args.get("cost_center"))
# dynamic dimension filters
active_dimensions = get_dimensions()[0]
for dim in active_dimensions:
if args.get(dim.fieldname):
condition += " and {0}='{1}'".format(dim.fieldname, args.get(dim.fieldname))
accounting_dimensions_filter.append(ple[dim.fieldname] == args.get(dim.fieldname))
date_fields_dict = { date_fields_dict = {
"posting_date": ["from_posting_date", "to_posting_date"], "posting_date": ["from_posting_date", "to_posting_date"],
"due_date": ["from_due_date", "to_due_date"], "due_date": ["from_due_date", "to_due_date"],
@ -1837,6 +1845,12 @@ def get_orders_to_be_billed(
if doc and hasattr(doc, "cost_center") and doc.cost_center: if doc and hasattr(doc, "cost_center") and doc.cost_center:
condition = " and cost_center='%s'" % cost_center condition = " and cost_center='%s'" % cost_center
# dynamic dimension filters
active_dimensions = get_dimensions()[0]
for dim in active_dimensions:
if filters.get(dim.fieldname):
condition += " and {0}='{1}'".format(dim.fieldname, filters.get(dim.fieldname))
if party_account_currency == company_currency: if party_account_currency == company_currency:
grand_total_field = "base_grand_total" grand_total_field = "base_grand_total"
rounded_total_field = "base_rounded_total" rounded_total_field = "base_rounded_total"

View File

@ -95,6 +95,8 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
this.frm.change_custom_button_type(__('Allocate'), null, 'default'); this.frm.change_custom_button_type(__('Allocate'), null, 'default');
} }
this.frm.trigger("set_query_for_dimension_filters");
// check for any running reconciliation jobs // check for any running reconciliation jobs
if (this.frm.doc.receivable_payable_account) { if (this.frm.doc.receivable_payable_account) {
this.frm.call({ this.frm.call({
@ -125,6 +127,25 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
} }
} }
set_query_for_dimension_filters() {
frappe.call({
method: "erpnext.accounts.doctype.payment_reconciliation.payment_reconciliation.get_queries_for_dimension_filters",
args: {
company: this.frm.doc.company,
},
callback: (r) => {
if (!r.exc && r.message) {
r.message.forEach(x => {
this.frm.set_query(x.fieldname, () => {
return {
'filters': x.filters
};
});
});
}
}
});
}
company() { company() {
this.frm.set_value('party', ''); this.frm.set_value('party', '');

View File

@ -25,7 +25,9 @@
"invoice_limit", "invoice_limit",
"payment_limit", "payment_limit",
"bank_cash_account", "bank_cash_account",
"accounting_dimensions_section",
"cost_center", "cost_center",
"dimension_col_break",
"sec_break1", "sec_break1",
"invoice_name", "invoice_name",
"invoices", "invoices",
@ -208,6 +210,18 @@
"fieldname": "payment_name", "fieldname": "payment_name",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Filter on Payment" "label": "Filter on Payment"
},
{
"collapsible": 1,
"collapsible_depends_on": "eval: doc.invoices.length == 0",
"depends_on": "eval:doc.receivable_payable_account",
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions Filter"
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
} }
], ],
"hide_toolbar": 1, "hide_toolbar": 1,
@ -215,7 +229,7 @@
"is_virtual": 1, "is_virtual": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2023-11-17 17:33:55.701726", "modified": "2023-12-14 13:38:16.264013",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Payment Reconciliation", "name": "Payment Reconciliation",

View File

@ -10,6 +10,7 @@ from frappe.query_builder.custom import ConstantColumn
from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today
import erpnext import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import ( from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
is_any_doc_running, is_any_doc_running,
) )
@ -70,6 +71,7 @@ class PaymentReconciliation(Document):
self.common_filter_conditions = [] self.common_filter_conditions = []
self.accounting_dimension_filter_conditions = [] self.accounting_dimension_filter_conditions = []
self.ple_posting_date_filter = [] self.ple_posting_date_filter = []
self.dimensions = get_dimensions()[0]
def load_from_db(self): def load_from_db(self):
# 'modified' attribute is required for `run_doc_method` to work properly. # 'modified' attribute is required for `run_doc_method` to work properly.
@ -172,6 +174,14 @@ class PaymentReconciliation(Document):
if self.payment_name: if self.payment_name:
condition.update({"name": self.payment_name}) condition.update({"name": self.payment_name})
# pass dynamic dimension filter values to query builder
dimensions = {}
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension):
dimensions.update({dimension: self.get(dimension)})
condition.update({"accounting_dimensions": dimensions})
payment_entries = get_advance_payment_entries_for_regional( payment_entries = get_advance_payment_entries_for_regional(
self.party_type, self.party_type,
self.party, self.party,
@ -185,66 +195,67 @@ class PaymentReconciliation(Document):
return payment_entries return payment_entries
def get_jv_entries(self): def get_jv_entries(self):
condition = self.get_conditions() je = qb.DocType("Journal Entry")
jea = qb.DocType("Journal Entry Account")
conditions = self.get_journal_filter_conditions()
# Dimension filters
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension):
conditions.append(jea[dimension] == self.get(dimension))
if self.payment_name: if self.payment_name:
condition += f" and t1.name like '%%{self.payment_name}%%'" conditions.append(je.name.like(f"%%{self.payment_name}%%"))
if self.get("cost_center"): if self.get("cost_center"):
condition += f" and t2.cost_center = '{self.cost_center}' " conditions.append(jea.cost_center == self.cost_center)
dr_or_cr = ( dr_or_cr = (
"credit_in_account_currency" "credit_in_account_currency"
if erpnext.get_party_account_type(self.party_type) == "Receivable" if erpnext.get_party_account_type(self.party_type) == "Receivable"
else "debit_in_account_currency" else "debit_in_account_currency"
) )
conditions.append(jea[dr_or_cr].gt(0))
bank_account_condition = ( if self.bank_cash_account:
"t2.against_account like %(bank_cash_account)s" if self.bank_cash_account else "1=1" conditions.append(jea.against_account.like(f"%%{self.bank_cash_account}%%"))
journal_query = (
qb.from_(je)
.inner_join(jea)
.on(jea.parent == je.name)
.select(
ConstantColumn("Journal Entry").as_("reference_type"),
je.name.as_("reference_name"),
je.posting_date,
je.remark.as_("remarks"),
jea.name.as_("reference_row"),
jea[dr_or_cr].as_("amount"),
jea.is_advance,
jea.exchange_rate,
jea.account_currency.as_("currency"),
jea.cost_center.as_("cost_center"),
)
.where(
(je.docstatus == 1)
& (jea.party_type == self.party_type)
& (jea.party == self.party)
& (jea.account == self.receivable_payable_account)
& (
(jea.reference_type == "")
| (jea.reference_type.isnull())
| (jea.reference_type.isin(("Sales Order", "Purchase Order")))
)
)
.where(Criterion.all(conditions))
.orderby(je.posting_date)
) )
limit = f"limit {self.payment_limit}" if self.payment_limit else " " if self.payment_limit:
journal_query = journal_query.limit(self.payment_limit)
# nosemgrep journal_entries = journal_query.run(as_dict=True)
journal_entries = frappe.db.sql(
"""
select
"Journal Entry" as reference_type, t1.name as reference_name,
t1.posting_date, t1.remark as remarks, t2.name as reference_row,
{dr_or_cr} as amount, t2.is_advance, t2.exchange_rate,
t2.account_currency as currency, t2.cost_center as cost_center
from
`tabJournal Entry` t1, `tabJournal Entry Account` t2
where
t1.name = t2.parent and t1.docstatus = 1 and t2.docstatus = 1
and t2.party_type = %(party_type)s and t2.party = %(party)s
and t2.account = %(account)s and {dr_or_cr} > 0 {condition}
and (t2.reference_type is null or t2.reference_type = '' or
(t2.reference_type in ('Sales Order', 'Purchase Order')
and t2.reference_name is not null and t2.reference_name != ''))
and (CASE
WHEN t1.voucher_type in ('Debit Note', 'Credit Note')
THEN 1=1
ELSE {bank_account_condition}
END)
order by t1.posting_date
{limit}
""".format(
**{
"dr_or_cr": dr_or_cr,
"bank_account_condition": bank_account_condition,
"condition": condition,
"limit": limit,
}
),
{
"party_type": self.party_type,
"party": self.party,
"account": self.receivable_payable_account,
"bank_cash_account": "%%%s%%" % self.bank_cash_account,
},
as_dict=1,
)
return list(journal_entries) return list(journal_entries)
@ -298,6 +309,7 @@ class PaymentReconciliation(Document):
min_outstanding=-(self.minimum_payment_amount) if self.minimum_payment_amount else None, min_outstanding=-(self.minimum_payment_amount) if self.minimum_payment_amount else None,
max_outstanding=-(self.maximum_payment_amount) if self.maximum_payment_amount else None, max_outstanding=-(self.maximum_payment_amount) if self.maximum_payment_amount else None,
get_payments=True, get_payments=True,
accounting_dimensions=self.accounting_dimension_filter_conditions,
) )
for inv in return_outstanding: for inv in return_outstanding:
@ -446,8 +458,15 @@ class PaymentReconciliation(Document):
row = self.append("allocation", {}) row = self.append("allocation", {})
row.update(entry) row.update(entry)
def update_dimension_values_in_allocated_entries(self, res):
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension):
res[dimension] = self.get(dimension)
return res
def get_allocated_entry(self, pay, inv, allocated_amount): def get_allocated_entry(self, pay, inv, allocated_amount):
return frappe._dict( res = frappe._dict(
{ {
"reference_type": pay.get("reference_type"), "reference_type": pay.get("reference_type"),
"reference_name": pay.get("reference_name"), "reference_name": pay.get("reference_name"),
@ -463,6 +482,9 @@ class PaymentReconciliation(Document):
} }
) )
res = self.update_dimension_values_in_allocated_entries(res)
return res
def reconcile_allocations(self, skip_ref_details_update_for_pe=False): def reconcile_allocations(self, skip_ref_details_update_for_pe=False):
adjust_allocations_for_taxes(self) adjust_allocations_for_taxes(self)
dr_or_cr = ( dr_or_cr = (
@ -485,10 +507,10 @@ class PaymentReconciliation(Document):
reconciled_entry.append(payment_details) reconciled_entry.append(payment_details)
if entry_list: if entry_list:
reconcile_against_document(entry_list, skip_ref_details_update_for_pe) reconcile_against_document(entry_list, skip_ref_details_update_for_pe, self.dimensions)
if dr_or_cr_notes: if dr_or_cr_notes:
reconcile_dr_cr_note(dr_or_cr_notes, self.company) reconcile_dr_cr_note(dr_or_cr_notes, self.company, self.dimensions)
@frappe.whitelist() @frappe.whitelist()
def reconcile(self): def reconcile(self):
@ -517,7 +539,7 @@ class PaymentReconciliation(Document):
self.get_unreconciled_entries() self.get_unreconciled_entries()
def get_payment_details(self, row, dr_or_cr): def get_payment_details(self, row, dr_or_cr):
return frappe._dict( payment_details = frappe._dict(
{ {
"voucher_type": row.get("reference_type"), "voucher_type": row.get("reference_type"),
"voucher_no": row.get("reference_name"), "voucher_no": row.get("reference_name"),
@ -539,6 +561,12 @@ class PaymentReconciliation(Document):
} }
) )
for x in self.dimensions:
if row.get(x.fieldname):
payment_details[x.fieldname] = row.get(x.fieldname)
return payment_details
def check_mandatory_to_fetch(self): def check_mandatory_to_fetch(self):
for fieldname in ["company", "party_type", "party", "receivable_payable_account"]: for fieldname in ["company", "party_type", "party", "receivable_payable_account"]:
if not self.get(fieldname): if not self.get(fieldname):
@ -646,6 +674,13 @@ class PaymentReconciliation(Document):
if not invoices_to_reconcile: if not invoices_to_reconcile:
frappe.throw(_("No records found in Allocation table")) frappe.throw(_("No records found in Allocation table"))
def build_dimensions_filter_conditions(self):
ple = qb.DocType("Payment Ledger Entry")
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension):
self.accounting_dimension_filter_conditions.append(ple[dimension] == self.get(dimension))
def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False): def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False):
self.common_filter_conditions.clear() self.common_filter_conditions.clear()
self.accounting_dimension_filter_conditions.clear() self.accounting_dimension_filter_conditions.clear()
@ -669,40 +704,30 @@ class PaymentReconciliation(Document):
if self.to_payment_date: if self.to_payment_date:
self.ple_posting_date_filter.append(ple.posting_date.lte(self.to_payment_date)) self.ple_posting_date_filter.append(ple.posting_date.lte(self.to_payment_date))
def get_conditions(self, get_payments=False): self.build_dimensions_filter_conditions()
condition = " and company = '{0}' ".format(self.company)
if self.get("cost_center") and get_payments: def get_journal_filter_conditions(self):
condition = " and cost_center = '{0}' ".format(self.cost_center) conditions = []
je = qb.DocType("Journal Entry")
jea = qb.DocType("Journal Entry Account")
conditions.append(je.company == self.company)
condition += ( if self.from_payment_date:
" and posting_date >= {0}".format(frappe.db.escape(self.from_payment_date)) conditions.append(je.posting_date.gte(self.from_payment_date))
if self.from_payment_date
else "" if self.to_payment_date:
) conditions.append(je.posting_date.lte(self.to_payment_date))
condition += (
" and posting_date <= {0}".format(frappe.db.escape(self.to_payment_date))
if self.to_payment_date
else ""
)
if self.minimum_payment_amount: if self.minimum_payment_amount:
condition += ( conditions.append(je.total_debit.gte(self.minimum_payment_amount))
" and unallocated_amount >= {0}".format(flt(self.minimum_payment_amount))
if get_payments
else " and total_debit >= {0}".format(flt(self.minimum_payment_amount))
)
if self.maximum_payment_amount: if self.maximum_payment_amount:
condition += ( conditions.append(je.total_debit.lte(self.maximum_payment_amount))
" and unallocated_amount <= {0}".format(flt(self.maximum_payment_amount))
if get_payments
else " and total_debit <= {0}".format(flt(self.maximum_payment_amount))
)
return condition return conditions
def reconcile_dr_cr_note(dr_cr_notes, company): def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None):
for inv in dr_cr_notes: for inv in dr_cr_notes:
voucher_type = "Credit Note" if inv.voucher_type == "Sales Invoice" else "Debit Note" voucher_type = "Credit Note" if inv.voucher_type == "Sales Invoice" else "Debit Note"
@ -752,6 +777,15 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
} }
) )
# Credit Note(JE) will inherit the same dimension values as payment
dimensions_dict = frappe._dict()
if active_dimensions:
for dim in active_dimensions:
dimensions_dict[dim.fieldname] = inv.get(dim.fieldname)
jv.accounts[0].update(dimensions_dict)
jv.accounts[1].update(dimensions_dict)
jv.flags.ignore_mandatory = True jv.flags.ignore_mandatory = True
jv.flags.ignore_exchange_rate = True jv.flags.ignore_exchange_rate = True
jv.remark = None jv.remark = None
@ -785,9 +819,27 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
inv.against_voucher, inv.against_voucher,
None, None,
inv.cost_center, inv.cost_center,
dimensions_dict,
) )
@erpnext.allow_regional @erpnext.allow_regional
def adjust_allocations_for_taxes(doc): def adjust_allocations_for_taxes(doc):
pass pass
@frappe.whitelist()
def get_queries_for_dimension_filters(company: str = None):
dimensions_with_filters = []
for d in get_dimensions()[0]:
filters = {}
meta = frappe.get_meta(d.document_type)
if meta.has_field("company") and company:
filters.update({"company": company})
if meta.is_tree:
filters.update({"is_group": 0})
dimensions_with_filters.append({"fieldname": d.fieldname, "filters": filters})
return dimensions_with_filters

View File

@ -23,7 +23,9 @@
"difference_account", "difference_account",
"exchange_rate", "exchange_rate",
"currency", "currency",
"cost_center" "accounting_dimensions_section",
"cost_center",
"dimension_col_break"
], ],
"fields": [ "fields": [
{ {
@ -151,12 +153,26 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Cost Center", "label": "Cost Center",
"options": "Cost Center" "options": "Cost Center"
},
{
"fieldname": "gain_loss_posting_date",
"fieldtype": "Date",
"label": "Difference Posting Date"
},
{
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
} }
], ],
"is_virtual": 1, "is_virtual": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-11-17 17:33:38.612615", "modified": "2023-12-14 13:38:26.104150",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Payment Reconciliation Allocation", "name": "Payment Reconciliation Allocation",

View File

@ -456,7 +456,19 @@ def add_cc(args=None):
return cc.name return cc.name
def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # nosemgrep def _build_dimensions_dict_for_exc_gain_loss(
entry: dict | object = None, active_dimensions: list = None
):
dimensions_dict = frappe._dict()
if entry and active_dimensions:
for dim in active_dimensions:
dimensions_dict[dim.fieldname] = entry.get(dim.fieldname)
return dimensions_dict
def reconcile_against_document(
args, skip_ref_details_update_for_pe=False, active_dimensions=None
): # nosemgrep
""" """
Cancel PE or JV, Update against document, split if required and resubmit Cancel PE or JV, Update against document, split if required and resubmit
""" """
@ -485,6 +497,8 @@ def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # n
check_if_advance_entry_modified(entry) check_if_advance_entry_modified(entry)
validate_allocated_amount(entry) validate_allocated_amount(entry)
dimensions_dict = _build_dimensions_dict_for_exc_gain_loss(entry, active_dimensions)
# update ref in advance entry # update ref in advance entry
if voucher_type == "Journal Entry": if voucher_type == "Journal Entry":
referenced_row = update_reference_in_journal_entry(entry, doc, do_not_save=False) referenced_row = update_reference_in_journal_entry(entry, doc, do_not_save=False)
@ -492,10 +506,14 @@ def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # n
# amount and account in args # amount and account in args
# referenced_row is used to deduplicate gain/loss journal # referenced_row is used to deduplicate gain/loss journal
entry.update({"referenced_row": referenced_row}) entry.update({"referenced_row": referenced_row})
doc.make_exchange_gain_loss_journal([entry]) doc.make_exchange_gain_loss_journal([entry], dimensions_dict)
else: else:
referenced_row = update_reference_in_payment_entry( referenced_row = update_reference_in_payment_entry(
entry, doc, do_not_save=True, skip_ref_details_update_for_pe=skip_ref_details_update_for_pe entry,
doc,
do_not_save=True,
skip_ref_details_update_for_pe=skip_ref_details_update_for_pe,
dimensions_dict=dimensions_dict,
) )
doc.save(ignore_permissions=True) doc.save(ignore_permissions=True)
@ -652,7 +670,7 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False):
def update_reference_in_payment_entry( def update_reference_in_payment_entry(
d, payment_entry, do_not_save=False, skip_ref_details_update_for_pe=False d, payment_entry, do_not_save=False, skip_ref_details_update_for_pe=False, dimensions_dict=None
): ):
reference_details = { reference_details = {
"reference_doctype": d.against_voucher_type, "reference_doctype": d.against_voucher_type,
@ -665,6 +683,7 @@ def update_reference_in_payment_entry(
else payment_entry.get_exchange_rate(), else payment_entry.get_exchange_rate(),
"exchange_gain_loss": d.difference_amount, "exchange_gain_loss": d.difference_amount,
"account": d.account, "account": d.account,
"dimensions": d.dimensions,
} }
if d.voucher_detail_no: if d.voucher_detail_no:
@ -697,7 +716,9 @@ def update_reference_in_payment_entry(
if not skip_ref_details_update_for_pe: if not skip_ref_details_update_for_pe:
payment_entry.set_missing_ref_details() payment_entry.set_missing_ref_details()
payment_entry.set_amounts() payment_entry.set_amounts()
payment_entry.make_exchange_gain_loss_journal() payment_entry.make_exchange_gain_loss_journal(
frappe._dict({"difference_posting_date": d.difference_posting_date}), dimensions_dict
)
if not do_not_save: if not do_not_save:
payment_entry.save(ignore_permissions=True) payment_entry.save(ignore_permissions=True)
@ -2034,6 +2055,7 @@ def create_gain_loss_journal(
ref2_dn, ref2_dn,
ref2_detail_no, ref2_detail_no,
cost_center, cost_center,
dimensions,
) -> str: ) -> str:
journal_entry = frappe.new_doc("Journal Entry") journal_entry = frappe.new_doc("Journal Entry")
journal_entry.voucher_type = "Exchange Gain Or Loss" journal_entry.voucher_type = "Exchange Gain Or Loss"
@ -2067,7 +2089,8 @@ def create_gain_loss_journal(
dr_or_cr + "_in_account_currency": 0, dr_or_cr + "_in_account_currency": 0,
} }
) )
if dimensions:
journal_account.update(dimensions)
journal_entry.append("accounts", journal_account) journal_entry.append("accounts", journal_account)
journal_account = frappe._dict( journal_account = frappe._dict(
@ -2083,7 +2106,8 @@ def create_gain_loss_journal(
reverse_dr_or_cr: abs(exc_gain_loss), reverse_dr_or_cr: abs(exc_gain_loss),
} }
) )
if dimensions:
journal_account.update(dimensions)
journal_entry.append("accounts", journal_account) journal_entry.append("accounts", journal_account)
journal_entry.save() journal_entry.save()

View File

@ -7,6 +7,7 @@ import json
import frappe import frappe
from frappe import _, bold, qb, throw from frappe import _, bold, qb, throw
from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied
from frappe.query_builder import Criterion
from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Abs, Sum from frappe.query_builder.functions import Abs, Sum
from frappe.utils import ( from frappe.utils import (
@ -28,6 +29,7 @@ from frappe.utils import (
import erpnext import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions, get_accounting_dimensions,
get_dimensions,
) )
from erpnext.accounts.doctype.pricing_rule.utils import ( from erpnext.accounts.doctype.pricing_rule.utils import (
apply_pricing_rule_for_free_items, apply_pricing_rule_for_free_items,
@ -1246,7 +1248,9 @@ class AccountsController(TransactionBase):
return True return True
return False return False
def make_exchange_gain_loss_journal(self, args: dict = None) -> None: def make_exchange_gain_loss_journal(
self, args: dict = None, dimensions_dict: dict = None
) -> None:
""" """
Make Exchange Gain/Loss journal for Invoices and Payments Make Exchange Gain/Loss journal for Invoices and Payments
""" """
@ -1299,6 +1303,7 @@ class AccountsController(TransactionBase):
self.name, self.name,
arg.get("referenced_row"), arg.get("referenced_row"),
arg.get("cost_center"), arg.get("cost_center"),
dimensions_dict,
) )
frappe.msgprint( frappe.msgprint(
_("Exchange Gain/Loss amount has been booked through {0}").format( _("Exchange Gain/Loss amount has been booked through {0}").format(
@ -1379,6 +1384,7 @@ class AccountsController(TransactionBase):
self.name, self.name,
d.idx, d.idx,
self.cost_center, self.cost_center,
dimensions_dict,
) )
frappe.msgprint( frappe.msgprint(
_("Exchange Gain/Loss amount has been booked through {0}").format( _("Exchange Gain/Loss amount has been booked through {0}").format(
@ -1443,7 +1449,13 @@ class AccountsController(TransactionBase):
if lst: if lst:
from erpnext.accounts.utils import reconcile_against_document from erpnext.accounts.utils import reconcile_against_document
reconcile_against_document(lst) # pass dimension values to utility method
active_dimensions = get_dimensions()[0]
for x in lst:
for dim in active_dimensions:
if self.get(dim.fieldname):
x.update({dim.fieldname: self.get(dim.fieldname)})
reconcile_against_document(lst, active_dimensions=active_dimensions)
def on_cancel(self): def on_cancel(self):
from erpnext.accounts.doctype.bank_transaction.bank_transaction import ( from erpnext.accounts.doctype.bank_transaction.bank_transaction import (
@ -2712,47 +2724,37 @@ def get_common_query(
q = q.select((payment_entry.target_exchange_rate).as_("exchange_rate")) q = q.select((payment_entry.target_exchange_rate).as_("exchange_rate"))
if condition: if condition:
if condition.get("name", None): # conditions should be built as an array and passed as Criterion
q = q.where(payment_entry.name.like(f"%{condition.get('name')}%")) common_filter_conditions = []
common_filter_conditions.append(payment_entry.company == condition["company"])
if condition.get("name", None):
common_filter_conditions.append(payment_entry.name.like(f"%{condition.get('name')}%"))
if condition.get("from_payment_date"):
common_filter_conditions.append(payment_entry.posting_date.gte(condition["from_payment_date"]))
if condition.get("to_payment_date"):
common_filter_conditions.append(payment_entry.posting_date.lte(condition["to_payment_date"]))
q = q.where(payment_entry.company == condition["company"])
q = (
q.where(payment_entry.posting_date >= condition["from_payment_date"])
if condition.get("from_payment_date")
else q
)
q = (
q.where(payment_entry.posting_date <= condition["to_payment_date"])
if condition.get("to_payment_date")
else q
)
if condition.get("get_payments") == True: if condition.get("get_payments") == True:
q = ( if condition.get("cost_center"):
q.where(payment_entry.cost_center == condition["cost_center"]) common_filter_conditions.append(payment_entry.cost_center == condition["cost_center"])
if condition.get("cost_center")
else q if condition.get("accounting_dimensions"):
) for field, val in condition.get("accounting_dimensions").items():
q = ( common_filter_conditions.append(payment_entry[field] == val)
q.where(payment_entry.unallocated_amount >= condition["minimum_payment_amount"])
if condition.get("minimum_payment_amount") if condition.get("minimum_payment_amount"):
else q common_filter_conditions.append(
) payment_entry.unallocated_amount.gte(condition["minimum_payment_amount"])
q = ( )
q.where(payment_entry.unallocated_amount <= condition["maximum_payment_amount"])
if condition.get("maximum_payment_amount") if condition.get("maximum_payment_amount"):
else q common_filter_conditions.append(
) payment_entry.unallocated_amount.lte(condition["maximum_payment_amount"])
else: )
q = ( q = q.where(Criterion.all(common_filter_conditions))
q.where(payment_entry.total_debit >= condition["minimum_payment_amount"])
if condition.get("minimum_payment_amount")
else q
)
q = (
q.where(payment_entry.total_debit <= condition["maximum_payment_amount"])
if condition.get("maximum_payment_amount")
else q
)
q = q.orderby(payment_entry.posting_date) q = q.orderby(payment_entry.posting_date)
q = q.limit(limit) if limit else q q = q.limit(limit) if limit else q

View File

@ -56,6 +56,7 @@ class TestAccountsController(FrappeTestCase):
20 series - Sales Invoice against Journals 20 series - Sales Invoice against Journals
30 series - Sales Invoice against Credit Notes 30 series - Sales Invoice against Credit Notes
40 series - Company default Cost center is unset 40 series - Company default Cost center is unset
50 series - Dimension inheritence
""" """
def setUp(self): def setUp(self):
@ -1188,3 +1189,214 @@ class TestAccountsController(FrappeTestCase):
) )
frappe.db.set_value("Company", self.company, "cost_center", cc) frappe.db.set_value("Company", self.company, "cost_center", cc)
def setup_dimensions(self):
# create dimension
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
create_dimension,
)
create_dimension()
# make it non-mandatory
loc = frappe.get_doc("Accounting Dimension", "Location")
for x in loc.dimension_defaults:
x.mandatory_for_bs = False
x.mandatory_for_pl = False
loc.save()
def test_50_dimensions_filter(self):
"""
Test workings of dimension filters
"""
self.setup_dimensions()
rate_in_account_currency = 1
# Invoices
si1 = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True)
si1.department = "Management"
si1.save().submit()
si2 = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True)
si2.department = "Operations"
si2.save().submit()
# Payments
cr_note1 = self.create_sales_invoice(qty=-1, conversion_rate=75, rate=1, do_not_save=True)
cr_note1.department = "Management"
cr_note1.is_return = 1
cr_note1.save().submit()
cr_note2 = self.create_sales_invoice(qty=-1, conversion_rate=75, rate=1, do_not_save=True)
cr_note2.department = "Legal"
cr_note2.is_return = 1
cr_note2.save().submit()
pe1 = get_payment_entry(si1.doctype, si1.name)
pe1.references = []
pe1.department = "Research & Development"
pe1.save().submit()
pe2 = get_payment_entry(si1.doctype, si1.name)
pe2.references = []
pe2.department = "Management"
pe2.save().submit()
je1 = self.create_journal_entry(
acc1=self.debit_usd,
acc1_exc_rate=75,
acc2=self.cash,
acc1_amount=-1,
acc2_amount=-75,
acc2_exc_rate=1,
)
je1.accounts[0].party_type = "Customer"
je1.accounts[0].party = self.customer
je1.accounts[0].department = "Management"
je1.save().submit()
# assert dimension filter's result
pr = self.create_payment_reconciliation()
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 2)
self.assertEqual(len(pr.payments), 5)
pr.department = "Legal"
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 0)
self.assertEqual(len(pr.payments), 1)
pr.department = "Management"
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 3)
pr.department = "Research & Development"
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 0)
self.assertEqual(len(pr.payments), 1)
def test_51_cr_note_should_inherit_dimension(self):
self.setup_dimensions()
rate_in_account_currency = 1
# Invoice
si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True)
si.department = "Management"
si.save().submit()
# Payment
cr_note = self.create_sales_invoice(qty=-1, conversion_rate=75, rate=1, do_not_save=True)
cr_note.department = "Management"
cr_note.is_return = 1
cr_note.save().submit()
pr = self.create_payment_reconciliation()
pr.department = "Management"
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
pr.reconcile()
self.assertEqual(len(pr.invoices), 0)
self.assertEqual(len(pr.payments), 0)
# There should be 2 journals, JE(Cr Note) and JE(Exchange Gain/Loss)
exc_je_for_si = self.get_journals_for(si.doctype, si.name)
exc_je_for_cr_note = self.get_journals_for(cr_note.doctype, cr_note.name)
self.assertNotEqual(exc_je_for_si, [])
self.assertEqual(len(exc_je_for_si), 2)
self.assertEqual(len(exc_je_for_cr_note), 2)
self.assertEqual(exc_je_for_si, exc_je_for_cr_note)
for x in exc_je_for_si + exc_je_for_cr_note:
with self.subTest(x=x):
self.assertEqual(
[cr_note.department, cr_note.department],
frappe.db.get_all("Journal Entry Account", filters={"parent": x.parent}, pluck="department"),
)
def test_52_dimension_inhertiance_exc_gain_loss(self):
# Sales Invoice in Foreign Currency
self.setup_dimensions()
rate = 80
rate_in_account_currency = 1
dpt = "Research & Development"
si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_save=True)
si.department = dpt
si.save().submit()
pe = self.create_payment_entry(amount=1, source_exc_rate=82).save()
pe.department = dpt
pe = pe.save().submit()
pr = self.create_payment_reconciliation()
pr.department = dpt
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
pr.reconcile()
self.assertEqual(len(pr.invoices), 0)
self.assertEqual(len(pr.payments), 0)
# Exc Gain/Loss journals should inherit dimension from parent
journals = self.get_journals_for(si.doctype, si.name)
self.assertEqual(
[dpt, dpt],
frappe.db.get_all(
"Journal Entry Account",
filters={"parent": ("in", [x.parent for x in journals])},
pluck="department",
),
)
def test_53_dimension_inheritance_on_advance(self):
self.setup_dimensions()
dpt = "Research & Development"
adv = self.create_payment_entry(amount=1, source_exc_rate=85)
adv.department = dpt
adv.save().submit()
adv.reload()
# Sales Invoices in different exchange rates
si = self.create_sales_invoice(qty=1, conversion_rate=82, rate=1, do_not_submit=True)
si.department = dpt
advances = si.get_advance_entries()
self.assertEqual(len(advances), 1)
self.assertEqual(advances[0].reference_name, adv.name)
si.append(
"advances",
{
"doctype": "Sales Invoice Advance",
"reference_type": advances[0].reference_type,
"reference_name": advances[0].reference_name,
"reference_row": advances[0].reference_row,
"advance_amount": 1,
"allocated_amount": 1,
"ref_exchange_rate": advances[0].exchange_rate,
"remarks": advances[0].remarks,
},
)
si = si.save().submit()
# Outstanding in both currencies should be '0'
adv.reload()
self.assertEqual(si.outstanding_amount, 0)
self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
# Exc Gain/Loss journals should inherit dimension from parent
journals = self.get_journals_for(si.doctype, si.name)
self.assertEqual(
[dpt, dpt],
frappe.db.get_all(
"Journal Entry Account",
filters={"parent": ("in", [x.parent for x in journals])},
pluck="department",
),
)

View File

@ -543,6 +543,8 @@ accounting_dimension_doctypes = [
"Account Closing Balance", "Account Closing Balance",
"Supplier Quotation", "Supplier Quotation",
"Supplier Quotation Item", "Supplier Quotation Item",
"Payment Reconciliation",
"Payment Reconciliation Allocation",
] ]
get_matching_queries = ( get_matching_queries = (

View File

@ -25,6 +25,10 @@ erpnext.accounts.dimensions = {
}, },
setup_filters(frm, doctype) { setup_filters(frm, doctype) {
if (doctype == 'Payment Entry' && this.accounting_dimensions) {
frm.dimension_filters = this.accounting_dimensions
}
if (this.accounting_dimensions) { if (this.accounting_dimensions) {
this.accounting_dimensions.forEach((dimension) => { this.accounting_dimensions.forEach((dimension) => {
frappe.model.with_doctype(dimension['document_type'], () => { frappe.model.with_doctype(dimension['document_type'], () => {