From 7958f6baf6b171fabe2d595b5cab9559e653071c Mon Sep 17 00:00:00 2001 From: Glen Whitney Date: Sat, 21 Nov 2020 23:49:37 +0000 Subject: [PATCH] feat: Generate entries for invoice lines w/ disposition "Journal" With this change, although still no Payment Entry or Collection records can be generated, the app actually becomes potentially useful, although it still requires significant further development, including at least: * Generate remaining types of entries * Update Data status of an invoice line when reference_doc or account changes * Update Disposition of an invoice when data_status changes Given the major milestone, bumping version number to 0.1.0 --- justworks_erpnext/__init__.py | 2 +- .../doctype/jw_import/jw_import.py | 191 ++++++++++++++++-- requirements.txt | 3 +- 3 files changed, 173 insertions(+), 23 deletions(-) diff --git a/justworks_erpnext/__init__.py b/justworks_erpnext/__init__.py index 95d1338..a5d4763 100644 --- a/justworks_erpnext/__init__.py +++ b/justworks_erpnext/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -__version__ = '0.0.1' +__version__ = '0.1.0' diff --git a/justworks_erpnext/justworks_erpnext_connector/doctype/jw_import/jw_import.py b/justworks_erpnext/justworks_erpnext_connector/doctype/jw_import/jw_import.py index 9004a01..5352ca3 100644 --- a/justworks_erpnext/justworks_erpnext_connector/doctype/jw_import/jw_import.py +++ b/justworks_erpnext/justworks_erpnext_connector/doctype/jw_import/jw_import.py @@ -10,13 +10,19 @@ from frappe.utils.csvutils import read_csv_content from frappe.utils.data import flt from frappe.utils.dateutils import parse_date +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions + +from collections import defaultdict from fnmatch import fnmatch as wildcard_match +import json from operator import itemgetter from os.path import commonprefix import re class JWImport(Document): account_mapping_fields = 'charge_type charge_subtype map_to_account'.split() + jea_remark_fields = 'voucher_id voucher_type charge_subtype department reference_work_id notes'.split() + je_remark_fields = 'voucher_pay_date period_start_date period_end_date'.split() collection_ref = 'Collection Summary' def process_invoice_file(self): @@ -31,11 +37,19 @@ class JWImport(Document): id_idx = header.index('invoice_id') cur_id = '' rows.append(['THIS_IS_END_SENTINEL'] * (id_idx+1)) + si_date = '' + if self.start_invoice_date: + si_date = get_datetime(self.start_invoice_date).date() + ei_date = '' + if self.end_invoice_date: + ei_date = get_datetime(self.end_invoice_date).date() # Load the base data from the file for row in rows: # Emit a collection record if invoice is complete if ccr_flag and cur_id and cur_id != row[id_idx]: lrec = self.lines[-1] + factor = 10.0**lrec.precision('amount') + inv_total = int(inv_total*factor + 0.5)/factor ctmp = dict(invoice_id=cur_id, invoice_date=lrec.invoice_date, debit_date=lrec.debit_date, @@ -44,6 +58,7 @@ class JWImport(Document): crec = self.append('lines', {}) for k in ctmp: crec.set(k, ctmp[k]) inv_total = 0.0 + cur_id = '' if row[id_idx] == 'THIS_IS_END_SENTINEL': break temp = {} @@ -54,9 +69,9 @@ class JWImport(Document): elif fieldname == 'amount': val = re.sub('^[^.,\d]*', '', val) temp[fieldname] = val - if (self.start_invoice_date and temp['invoice_date'] < self.start_invoice_date): + if (si_date and temp['invoice_date'] < si_date): continue - if (self.end_invoice_date and temp['invoice_date'] > self.end_invoice_date): + if (ei_date and temp['invoice_date'] > ei_date): continue cur_id = temp['invoice_id'] rec = self.append('lines', {}) @@ -87,8 +102,8 @@ class JWImport(Document): def determine_references(self, recs): expense_acct = frappe.get_value('Company', self.justworks_company, - 'default_expense_claim_payable_account') - # First determine the reference type for each record + 'default_expense_claim_payable_account') + # First determine the reference type for each record for rec in recs: acct = rec.get('account', '') if not acct: continue @@ -140,13 +155,15 @@ class JWImport(Document): """Look for a purchase invoice with the same supplier and amount as rec and fill in the reference_doc if found """ + fullref = rec.reference + (lastref, comma, restref) = fullref.partition(',') # Reference should be a supplier suppliers = (frappe.get_all('Supplier', - {'supplier_name': rec.reference}) or - frappe.get_all('Supplier', { - 'supplier_name': - ['like', f"%{rec.reference}%"]}) - ) + {'supplier_name': fullref}) + or frappe.get_all('Supplier', { + 'supplier_name': ['like', f"%{fullref}%"]}) + or (comma and frappe.get_all('Supplier', { + 'supplier_name': ['like', f"%{lastref}"]}))) if not suppliers or len(suppliers) != 1: return suplr = suppliers[0].name invoices = frappe.get_all('Purchase Invoice', @@ -158,18 +175,19 @@ class JWImport(Document): if len(invoices) == 1: rec.set('reference_doc', invoices[0].name) return + rec_amt= flt(rec.amount) match_inv = ([inv for inv in invoices - if inv.outstanding_amount == rec.amount] + if inv.outstanding_amount == rec_amt] or [inv for inv in invoices - if inv.outstanding_amount > rec.amount]) + if inv.outstanding_amount > rec_amt]) if len(match_inv) > 0: # Best match is earliest unpaid invoice earliest = min(match_inv, key=itemgetter('date')) rec.set('reference_doc', earliest.name) return match_inv = [inv for inv in invoices - if inv.grand_total == rec.amount or - inv.rounded_total == rec.amount] + if inv.grand_total == rec_amt or + inv.rounded_total == rec_amt] if len(match_inv) > 0: latest = max(match_inv, key=itemgetter('date')) rec.set('reference_doc', latest.name) @@ -191,9 +209,10 @@ class JWImport(Document): if len(claims) == 1: rec.set('reference_doc', claims[0].name) return - match_claims = {c for c in claims if c.total == rec.amount} + rec_amt = flt(rec.amount) + match_claims = {c for c in claims if c.total == rec_amt} unpaid_claims = {c for c in claims - if c.total >= rec.amount and not c.is_paid} + if c.total >= rec_amt and not c.is_paid} mu_claims = match_claims & unpaid_claims if len(mu_claims) > 0: earliest = min(mu_claims, key=itemgetter('date')) @@ -208,7 +227,7 @@ class JWImport(Document): rec.set('reference_doc', earliest.name) return # Hmmm, employee has claims but no large enough unpaid ones - # and none with matching amount, so punt. + # and none with matching amount, so punt. def check_data(self, recs): """Make sure all of the data needed to create entries @@ -238,7 +257,10 @@ class JWImport(Document): def determine_disposition(self, recs): for rec in recs: if rec.data_status != 'OK': - rec.dispostion = 'Data Missing' + rec.disposition = 'Data Missing' + continue + if flt(rec.amount) < 0.01: + rec.disposition = 'Ignore' continue if rec.reference == self.collection_ref: @@ -263,6 +285,7 @@ class JWImport(Document): if rec.disposition == 'Collection': jes = frappe.get_all('Journal Entry', filters={ 'company': self.justworks_company, + 'docstatus': 1, 'posting_date': rec.debit_date}) if not jes: return False je_names = [je.name for je in jes] @@ -270,10 +293,11 @@ class JWImport(Document): 'doctype': 'Journal Entry Account', 'parent': ('in', je_names), 'account': self.jw_collection_debits, - 'debit_in_account_currency': rec.amount}) + 'debit_in_account_currency': flt(rec.amount)}) if rec.disposition[:3] == 'Pay': pes = frappe.get_all('Payment Entry', filters={ 'company': self.justworks_company, + 'docstatus': 1, 'paid_from': self.jw_payment_account, 'paid_amount': rec.amount}) if not pes: return False @@ -285,7 +309,8 @@ class JWImport(Document): if rec.disposition == 'Journal': jes = frappe.get_all('Journal Entry', filters={ 'company': self.justworks_company, - 'posting_date': rec.debit_date}) + 'docstatus': 1, + 'posting_date': rec.invoice_date}) if not jes: return False je_names = [je.name for je in jes] jeas = frappe.get_all('Journal Entry Account', @@ -294,6 +319,7 @@ class JWImport(Document): 'account': rec.account }, fields=['name','parent']) + if not jeas: return False jea_parents= [jea.parent for jea in jeas] return frappe.db.exists({ 'doctype': 'Journal Entry Account', @@ -307,7 +333,130 @@ def get_employee_allocation(emp, date, cache): alloc = frappe.get_all('JW Employee Allocation', filters={'employee': emp, 'docstatus': 1}, fields=['name','percentage','start_date','end_date']) - alloc = [r for r in alloc if (not r.start_date or date >= r.start_date) - and (not r.end_date or date <= r.end_date)] + alloc = [frappe.get_doc('JW Employee Allocation',r.name) for r + in alloc if (not r.start_date or date >= r.start_date) + and (not r.end_date or date <= r.end_date)] cache[emp] = alloc return alloc + +@frappe.whitelist() +def record_entries(doc): + importer = frappe.get_doc(json.loads(doc)) + n_detail = len(importer.lines) + n_entries = 0 + last_l = n_detail - 1 + current_jeas = [] + current_total = 0.0 + acc_dims = get_accounting_dimensions() + acc_dims.append('cost_center') + rebalance_dims = [dim for dim in acc_dims if importer.get(dim,'')] + current_dim_imbalance = defaultdict(float) + standard_dims = {dim:importer.get(dim,'') for dim in acc_dims} + standard_alloc = [dict(standard_dims, percentage=100)] + standard_remark = 'Transfer to ' + ",".join(f"{d} {v}" + for (d,v) in standard_dims.items()) + # The following makes a tuple from a comprehension: + standard_key = *((dim,standard_dims[dim]) for dim in rebalance_dims), + alloc_cache = {} + je_remarks = {field:'' for field in importer.je_remark_fields} + for l in range(n_detail): + rec = importer.lines[l] + if rec.disposition == 'Journal': + rec_amt =flt(rec.amount) + current_total += rec_amt + for field in importer.je_remark_fields: + if rec.get(field,''): je_remarks[field] = rec.get(field) + jea_remarks = "\n".join(f"{f}: {rec.get(f)}" + for f in importer.jea_remark_fields + if rec.get(f,'')) + alloc = standard_alloc + if rec.reference_doctype == 'Employee': + alloc = get_employee_allocation( + rec.reference_doc, + get_datetime(rec.invoice_date).date(), + alloc_cache) + so_far = 0.0 + for s in range(len(alloc)-1, -1, -1): + share = alloc[s] + amnt = int(rec_amt*share.percentage)/100.0 + if s == 0: amnt = rec_amt - so_far + so_far += amnt + if importer.dim_balance_account: + imb_key = *((dim,share.get(dim)) + for dim in rebalance_dims), + if imb_key != standard_key: + current_dim_imbalance[imb_key] += amnt + current_jeas.append(dict( + {d:share.get(d) for d in acc_dims}, + account=rec.account, + party_type=rec.reference_doctype, + party=rec.reference_doc, + account_currency=importer.justworks_currency, + debit_in_account_currency=amnt, + credit_in_account_currency=0.0, + user_remark=jea_remarks)) + rec.set('disposition', 'Duplicate') + # Should we emit a journal entry? + if (current_total > 0.0 and (l == last_l + or importer.lines[l+1].invoice_id != rec.invoice_id)): + je_remarks = "\n".join(f"{f}: {je_remarks[f]}" + for f in importer.je_remark_fields + if je_remarks[f]) + je = frappe.get_doc(doctype='Journal Entry', + title='Justworks Invoice '+rec.invoice_date, + voucher_type='Journal Entry', + company=importer.justworks_company, + posting_date=rec.invoice_date, + cheque_no=rec.invoice_id, + cheque_date=rec.invoice_date, + user_remark=je_remarks, + pay_to_recd_from='Justworks') + for jeaf in current_jeas: + jea = je.append('accounts', {}) + for f in jeaf: jea.set(f, jeaf[f]) + # Now generate "other sides": + if importer.dim_balance_account: + for dimvec in current_dim_imbalance: + if not current_dim_imbalance[dimvec] > 0.0: + continue + jea = je.append('accounts', {}) + jea.set('account', + importer.dim_balance_account) + for (dim, val) in dimvec: + jea.set(dim, val) + jea.set('account_currency', + importer.justworks_currency) + jea.set('debit_in_account_currency', 0.0) + jea.set('credit_in_account_currency', + current_dim_imbalance[dimvec]) + jea.set('user_remark', standard_remark) + oja = je.append('accounts', {}) + oja.set('account', + importer.dim_balance_account) + for (dim,val) in standard_dims.items(): + oja.set(dim,val) + oja.set('account_currency', + importer.justworks_currency) + oja.set('debit_in_account_currency', + current_dim_imbalance[dimvec]) + oja.set('credit_in_account_currency', 0.0) + oja.set('user_remark', standard_remark) + bja = je.append('accounts', {}) + bja.set('account', importer.jw_obligation_account) + for (dim,val) in standard_dims.items(): + bja.set(dim,val) + bja.set('account_currency', importer.justworks_currency) + bja.set('debit_in_account_currency', 0.0) + bja.set('credit_in_account_currency', current_total) + bja.set('user_remark', 'Total Justworks expenses') + je.insert() + if importer.auto_submit: je.submit() + n_entries += 1 + current_jeas = [] + current_total = 0.0 + for k in current_dim_imbalance: + current_dim_imbalance[k] = 0.0 + je_remarks = {field:'' for field + in importer.je_remark_fields} + importer.save() + frappe.msgprint(f"Entries created: {n_entries}", "Justworks Import"); diff --git a/requirements.txt b/requirements.txt index 5ac1c81..d700de5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -frappe \ No newline at end of file +frappe +erpnext