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
This commit is contained in:
Glen Whitney 2020-11-21 23:49:37 +00:00
parent 51ed8a85a8
commit 7958f6baf6
3 changed files with 173 additions and 23 deletions

View File

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
__version__ = '0.0.1'
__version__ = '0.1.0'

View File

@ -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");

View File

@ -1 +1,2 @@
frappe
erpnext