feat: Party auto-matcher from Bank Transaction data

- Created Bank Party Mapper
- Created class to auto match by account/iban or party name/description(fuzzy)
- Automatch and set in transaction or create mapper
- `rapidfuzz` introduced
This commit is contained in:
marination 2023-03-31 16:11:00 +05:30
parent ad31e02616
commit e7745033df
9 changed files with 301 additions and 12 deletions

View File

@ -0,0 +1,8 @@
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Bank Party Mapper", {
// refresh(frm) {
// },
// });

View File

@ -0,0 +1,80 @@
{
"actions": [],
"creation": "2023-03-31 10:48:20.249481",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"party_type",
"party",
"column_break_wbna",
"bank_party_name_desc",
"bank_party_account_number",
"bank_party_iban"
],
"fields": [
{
"fieldname": "bank_party_account_number",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Party Account No. (Bank Statement)"
},
{
"fieldname": "bank_party_iban",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Party IBAN (Bank Statement)"
},
{
"fieldname": "column_break_wbna",
"fieldtype": "Column Break"
},
{
"fieldname": "party_type",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Party Type",
"options": "DocType"
},
{
"fieldname": "party",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Party",
"options": "party_type"
},
{
"fieldname": "bank_party_name_desc",
"fieldtype": "Small Text",
"label": "Party Name/Desc (Bank Statement)"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-04-03 10:11:31.384383",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Party Mapper",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@ -0,0 +1,9 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class BankPartyMapper(Document):
pass

View File

@ -0,0 +1,9 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestBankPartyMapper(FrappeTestCase):
pass

View File

@ -0,0 +1,157 @@
import frappe
from rapidfuzz import fuzz, process
class AutoMatchParty:
def __init__(self, **kwargs) -> None:
self.__dict__.update(kwargs)
def get(self, key):
return self.__dict__.get(key, None)
def match(self):
result = AutoMatchbyAccountIBAN(
bank_party_account_number=self.bank_party_account_number,
bank_party_iban=self.bank_party_iban,
deposit=self.deposit,
).match()
if not result:
result = AutoMatchbyPartyDescription(
bank_party_name=self.bank_party_name, description=self.description, deposit=self.deposit
).match()
return result
class AutoMatchbyAccountIBAN:
def __init__(self, **kwargs) -> None:
self.__dict__.update(kwargs)
def get(self, key):
return self.__dict__.get(key, None)
def match(self):
if not (self.bank_party_account_number or self.bank_party_iban):
return None
result = self.match_account_in_bank_party_mapper()
if not result:
result = self.match_account_in_party()
return result
def match_account_in_bank_party_mapper(self):
filter_field = (
"bank_party_account_number" if self.bank_party_account_number else "bank_party_iban"
)
result = frappe.db.get_value(
"Bank Party Mapper",
filters={filter_field: self.get(filter_field)},
fieldname=["party_type", "party"],
)
if result:
party_type, party = result
return (party_type, party, None)
return result
def match_account_in_party(self):
# If not check if there is a match in Customer/Supplier/Employee
filter_field = "bank_account_no" if self.bank_party_account_number else "iban"
transaction_field = (
"bank_party_account_number" if self.bank_party_account_number else "bank_party_iban"
)
result = None
parties = ["Supplier", "Employee", "Customer"] # most -> least likely to receive
if self.deposit > 0:
parties = ["Customer", "Supplier", "Employee"] # most -> least likely to pay
for party in parties:
party_name = frappe.db.get_value(
party, filters={filter_field: self.get(transaction_field)}, fieldname=["name"]
)
if party_name:
result = (party, party_name, {transaction_field: self.get(transaction_field)})
break
return result
class AutoMatchbyPartyDescription:
def __init__(self, **kwargs) -> None:
self.__dict__.update(kwargs)
def get(self, key):
return self.__dict__.get(key, None)
def match(self):
# Match by Customer, Supplier or Employee Name
# search bank party mapper by party and then description
# fuzzy search by customer/supplier & employee
if not (self.bank_party_name or self.description):
return None
result = self.match_party_name_desc_in_bank_party_mapper()
if not result:
result = self.match_party_name_desc_in_party()
return result
def match_party_name_desc_in_bank_party_mapper(self):
"""Check if match exists for party name or description in Bank Party Mapper"""
result = None
# TODO: or filters
if self.bank_party_name:
result = frappe.db.get_value(
"Bank Party Mapper",
filters={"bank_party_name_desc": self.bank_party_name},
fieldname=["party_type", "party"],
)
if not result and self.description:
result = frappe.db.get_value(
"Bank Party Mapper",
filters={"bank_party_name_desc": self.description},
fieldname=["party_type", "party"],
)
result = result + (None,) if result else result
return result
def match_party_name_desc_in_party(self):
"""Fuzzy search party name and/or description against parties in the system"""
result = None
parties = ["Supplier", "Employee", "Customer"] # most-least likely to receive
if frappe.utils.flt(self.deposit) > 0.0:
parties = ["Customer", "Supplier", "Employee"] # most-least likely to pay
for party in parties:
name_field = party.lower() + "_name"
filters = {"status": "Active"} if party == "Employee" else {"disabled": 0}
names = frappe.get_all(party, filters=filters, pluck=name_field)
for field in ["bank_party_name", "description"]:
if not result and self.get(field):
result = self.fuzzy_search_and_return_result(party, names, field)
if result:
break
return result
def fuzzy_search_and_return_result(self, party, names, field):
result = process.extractOne(query=self.get(field), choices=names, scorer=fuzz.token_set_ratio)
if result:
party_name, score, index = result
if score > 75:
return (party, party_name, {"bank_party_name_desc": self.get(field)})
else:
return None
return result

View File

@ -36,7 +36,7 @@
"party",
"column_break_3czf",
"bank_party_name",
"bank_party_no",
"bank_party_account_number",
"bank_party_iban"
],
"fields": [
@ -216,20 +216,20 @@
"fieldtype": "Data",
"label": "Party Name (Bank Statement)"
},
{
"fieldname": "bank_party_no",
"fieldtype": "Data",
"label": "Party Account No. (Bank Statement)"
},
{
"fieldname": "bank_party_iban",
"fieldtype": "Data",
"label": "Party IBAN (Bank Statement)"
},
{
"fieldname": "bank_party_account_number",
"fieldtype": "Data",
"label": "Party Account No. (Bank Statement)"
}
],
"is_submittable": 1,
"links": [],
"modified": "2023-03-30 15:30:46.485683",
"modified": "2023-03-31 10:45:30.671309",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Transaction",

View File

@ -9,11 +9,6 @@ from erpnext.controllers.status_updater import StatusUpdater
class BankTransaction(StatusUpdater):
# TODO
# On BT save:
# - Match by account no/iban in Customer/Supplier/Employee
# - Match by Party Name
# - If match found, set party type and party name.
# On submit/update after submit
# - Create/Update a Bank Party Map record
# - User can edit after submit.
@ -22,6 +17,12 @@ class BankTransaction(StatusUpdater):
def after_insert(self):
self.unallocated_amount = abs(flt(self.withdrawal) - flt(self.deposit))
def on_update(self):
if self.party_type and self.party:
return
self.auto_set_party()
def on_submit(self):
self.clear_linked_payment_entries()
self.set_status()
@ -157,6 +158,30 @@ class BankTransaction(StatusUpdater):
payment_entry.payment_document, payment_entry.payment_entry, clearance_date, self
)
def auto_set_party(self):
# TODO: check if enabled
from erpnext.accounts.doctype.bank_transaction.auto_match_party import AutoMatchParty
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()
if result:
self.party_type, self.party, mapper = result
if not mapper:
return
mapper_doc = frappe.get_doc(
{"doctype": "Bank Party Mapper", "party_type": self.party_type, "party": self.party}
)
mapper_doc.update(mapper)
mapper_doc.insert()
@frappe.whitelist()
def get_doctypes_for_bank_reconciliation():

View File

@ -12,6 +12,7 @@ dependencies = [
"pycountry~=20.7.3",
"Unidecode~=1.2.0",
"barcodenumber~=0.5.0",
"rapidfuzz~=2.15.0",
# integration dependencies
"gocardless-pro~=1.22.0",