# -*- coding: utf-8 -*- # Copyright (c) 2020, Studio Infinity and contributors # For license information, please see license.txt from __future__ import unicode_literals import frappe from frappe.model.document import Document from frappe.utils import get_datetime 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): # See if we should be generating extra collection entries ccr_flag = self.jw_collection_debits and self.jw_collection_credits fcontent = frappe.get_doc('File', dict(file_url=self.load_from_file)).get_content() rows = read_csv_content(fcontent) header = [frappe.scrub(h) for h in rows.pop(0)] self.set('lines', []) inv_total = 0.0 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, reference=self.collection_ref, amount=inv_total) 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 = {} for fieldname, val in zip(header, row): if val: if fieldname[-4:] == 'date': val = get_datetime(parse_date(val)).date() elif fieldname == 'amount': val = re.sub('^[^-.,\d]*', '', val) temp[fieldname] = val if (si_date and temp['invoice_date'] < si_date): continue if (ei_date and temp['invoice_date'] > ei_date): continue cur_id = temp['invoice_id'] rec = self.append('lines', {}) for k in temp: rec.set(k, temp[k]) inv_total += flt(rec.amount) # Now determine all of the derived data self.map_accounts(self.lines) self.determine_references(self.lines) self.check_data(self.lines) self.determine_disposition(self.lines) def map_accounts(self, recs): amf = self.account_mapping_fields acc_maps = frappe.get_all('JW Account Mapping', fields=amf) for rec in recs: if rec.reference == self.collection_ref: rec.set('account', self.jw_collection_debits) continue for amap in acc_maps: if all(wildcard_match((rec.get(fld,'') or ''), amap.get(fld)) for fld in amf[:-1]): rec.set('account', amap.get(amf[-1])) break 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 for rec in recs: acct = rec.get('account', '') if not acct: continue if acct == expense_acct: rec.set('reference_doctype', 'Expense Claim') continue if frappe.get_value('Account', acct, 'account_type') == 'Payable': rec.set('reference_doctype', 'Purchase Invoice') continue if frappe.get_value('Account', acct, 'root_type') == 'Expense': rec.set('reference_doctype', 'Employee') # Now try to determine the matching doc for each record for rec in recs: doctype = rec.get('reference_doctype', '') if not doctype or not rec.get('reference', ''): continue if doctype == 'Purchase Invoice': self.search_invoice(rec) continue emp = self.search_employee(rec) if not emp: continue if doctype == 'Employee': rec.set('reference_doc', emp) continue # Ok, this is an expense claim: self.search_expense_claim(rec, emp) def search_employee(self, rec): """Look for and return an employee matching the reference of the given record, if any """ fullref = rec.reference (last, comma, restref) = fullref.partition(',') emps = (frappe.get_all('Employee', filters={'justworks_name': fullref}) or frappe.get_all('Employee', filters={'last_name': last}, fields=['name', 'first_name'])) if not emps: return '' if len(emps) == 1: return emps[0].name # We have multiple employees with same last name, take # best match for first name first = restref.partition(' ')[0] closest = max(emps, key=lambda e: len(commonprefix(e, first))) return closest.name def search_invoice(self, rec): """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': fullref}) or frappe.get_all('Supplier', { 'supplier_name': ['like', f"%{fullref}%"]}) or (comma and frappe.get_all('Supplier', { 'supplier_name': ['like', f"%{lastref}"]})) 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', filters={'supplier': suplr, 'posting_date': ['<=', rec.debit_date], 'company': self.justworks_company}, fields=['name', 'posting_date as date', 'grand_total', 'rounded_total', 'outstanding_amount']) if not invoices: return 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_amt] or [inv for inv in invoices 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_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) # Hmmm, supplier has invoices but none with # enough left to be paid and none with matching # amount, so punt. def search_expense_claim(self, rec, emp): """Look for an expense claim with given emp and the same amount as the given rec, and if one is found, fill in the reference_doc """ claims = frappe.get_all('Expense Claim', filters={'employee':emp, 'company': self.justworks_company}, fields=['name', 'total_amount_reimbursed as total', 'is_paid', 'posting_date as date']) if not claims: return if len(claims) == 1: rec.set('reference_doc', claims[0].name) return 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_amt and not c.is_paid} mu_claims = match_claims & unpaid_claims if len(mu_claims) > 0: earliest = min(mu_claims, key=itemgetter('date')) rec.set('reference_doc', earliest.name) return if len(match_claims) > 0: latest = max(match_claims, key=itemgetter('date')) rec.set('reference_doc', latest.name) return if len(unpaid_claims) > 0: earliest = min(unpaid_claims, key=itemgetter('date')) rec.set('reference_doc', earliest.name) return # Hmmm, employee has claims but no large enough unpaid ones # and none with matching amount, so punt. def check_data(self, recs): """Make sure all of the data needed to create entries is available. """ alloc_cache = {} for rec in recs: if not rec.account: rec.data_status = 'No Account' continue if rec.reference == self.collection_ref: rec.data_status = 'OK' continue if not rec.reference_doc: rec.data_status = 'No Party' continue if rec.reference_doctype != 'Employee': rec.data_status = 'OK' continue alloc = get_employee_allocation(rec.reference_doc, rec.invoice_date, alloc_cache) if sum(a.percentage for a in alloc) != 100: rec.data_status = 'Bad Alloc' continue rec.data_status = 'OK' def determine_disposition(self, recs): for rec in recs: if rec.data_status != 'OK': rec.disposition = 'Data Missing' continue if abs(flt(rec.amount)) < 0.01: rec.disposition = 'Ignore' continue if rec.reference == self.collection_ref: rec.disposition = 'Collection' elif rec.reference_doctype == 'Expense Claim': rec.disposition = 'Pay Expense' elif rec.reference_doctype == 'Purchase Invoice': rec.disposition = 'Pay Invoice' elif self.jw_obligation_account: rec.disposition = 'Journal' else: rec.disposition = 'Ledger Unset' continue if rec.disposition[:3] == 'Pay' and not self.jw_payment_account: rec.disposition = 'Ledger Unset' continue if self.apparent_duplicate(rec): rec.disposition = 'Duplicate' def apparent_duplicate(self, rec): 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] return frappe.db.exists({ 'doctype': 'Journal Entry Account', 'parent': ('in', je_names), 'account': self.jw_collection_debits, '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 pe_names = [pe.name for pe in pes] return frappe.db.exists({ 'doctype': 'Payment Entry Reference', 'parent': ('in', pe_names), 'reference_name': rec.reference_doc}) if rec.disposition == 'Journal': jes = frappe.get_all('Journal Entry', filters={ 'company': self.justworks_company, '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', filters={ 'parent': ('in', je_names), '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', 'parent': ('in', jea_parents), 'account': self.jw_obligation_account}) # shouldn't get here but just in case return False def get_employee_allocation(emp, date, cache): if emp in cache: return cache[emp] alloc = frappe.get_all('JW Employee Allocation', filters={'employee': emp, 'docstatus': 1}, fields=['name','percentage','start_date','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 cred = 0.0; deb = amnt; if (amnt < 0.0): cred = -amnt; deb = 0.0; 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=deb, credit_in_account_currency=cred, 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: amnt = current_dim_imbalance[dimvec] if amnt == 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 if amnt > 0.0 else -amnt) jea.set('credit_in_account_currency', amnt if amnt > 0.0 else 0.0) 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', amnt if amnt > 0.0 else 0.0) oja.set('credit_in_account_currency', 0.0 if amnt > 0.0 else -amnt) 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 if current_total > 0.0 else -current_total) bja.set('credit_in_account_currency', current_total if current_total > 0.0 else 0.0) 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");