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