import_payments_from_csv.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. # -*- coding: utf-8 -*-
  2. """
  3. Import payments from a CSV file from a bank. The payments will automatically be
  4. parsed, and there'll be an attempt to automatically match payments with members.
  5. The matching is performed using the label of the payment.
  6. - First, try to find a string such as 'ID-42' where 42 is the member's ID
  7. - Second (if no ID found), try to find a member username (with no ambiguity with
  8. respect to other usernames)
  9. - Third (if no username found), try to find a member family name (with no
  10. ambiguity with respect to other family name)
  11. This script will check if a payment has already been registered with same
  12. properies (date, label, price) to avoid creating duplicate payments inside coin.
  13. By default, only a dry-run is perfomed to let you see what will happen ! You
  14. should run this command with --commit if you agree with the dry-run.
  15. """
  16. from __future__ import unicode_literals
  17. # Standard python libs
  18. import csv
  19. import datetime
  20. import json
  21. import logging
  22. import os
  23. import re
  24. import unidecode
  25. # Django specific imports
  26. from argparse import RawTextHelpFormatter
  27. from django.core.management.base import BaseCommand, CommandError
  28. # Coin specific imports
  29. from coin.members.models import Member
  30. from coin.billing.models import Payment
  31. # Parser / import / matcher configuration
  32. # The CSV delimiter
  33. DELIMITER=str(';')
  34. # The date format in the CSV
  35. DATE_FORMAT="%d/%m/%Y"
  36. # The default regex used to match the label of a payment with a member ID
  37. ID_REGEX=r"(?i)(\b|_)ID[\s\-\_\/]*(\d+)(\b|_)"
  38. # If the label of the payment contains one of these, the payment won't be
  39. # matched to a member when importing it.
  40. KEYWORDS_TO_NOTMATCH=[ "DON", "MECENAT", "REM CHQ" ]
  41. class Command(BaseCommand):
  42. help = __doc__
  43. def create_parser(self, *args, **kwargs):
  44. parser = super(Command, self).create_parser(*args, **kwargs)
  45. parser.formatter_class = RawTextHelpFormatter
  46. return parser
  47. def add_arguments(self, parser):
  48. parser.add_argument(
  49. 'filename',
  50. type=str,
  51. help="The CSV filename to be parsed"
  52. )
  53. parser.add_argument(
  54. '--commit',
  55. action='store_true',
  56. dest='commit',
  57. default=False,
  58. help='Agree with the proposed change and commit them'
  59. )
  60. def handle(self, *args, **options):
  61. assert options["filename"] != ""
  62. if not os.path.isfile(options["filename"]):
  63. raise CommandError("This file does not exists.")
  64. payments = self.convert_csv_to_dicts(self.clean_csv(self.load_csv(options["filename"])))
  65. payments = self.try_to_match_payment_with_members(payments)
  66. new_payments = self.filter_already_known_payments(payments)
  67. new_payments = self.unmatch_payment_with_keywords(new_payments)
  68. number_of_already_known_payments = len(payments)-len(new_payments)
  69. number_of_new_payments = len(new_payments)
  70. if (number_of_new_payments > 0) :
  71. print("======================================================")
  72. print(" > New payments found")
  73. print(json.dumps(new_payments, indent=4, separators=(',', ': ')))
  74. print("======================================================")
  75. print("Number of already known payments found : " + str(number_of_already_known_payments))
  76. print("Number of new payments found : " + str(number_of_new_payments))
  77. print("Number of new payments matched : " + str(len([p for p in new_payments if p["member_matched"]])))
  78. print("Number of payments not matched : " + str(len([p for p in new_payments if not p["member_matched"]])))
  79. print("======================================================")
  80. if number_of_new_payments == 0:
  81. print("Nothing to do, everything looks up to date !")
  82. return
  83. if not options["commit"]:
  84. print("Please carefully review the matches, then if everything \n" \
  85. "looks alright, use --commit to register these new payments.")
  86. else:
  87. self.add_new_payments(new_payments)
  88. def is_date(self, text):
  89. try:
  90. datetime.datetime.strptime(text, DATE_FORMAT)
  91. return True
  92. except ValueError:
  93. return False
  94. def is_money_amount(self, text):
  95. try:
  96. float(text.replace(",","."))
  97. return True
  98. except ValueError:
  99. return False
  100. def load_csv(self, filename):
  101. with open(filename, "r") as f:
  102. return list(csv.reader(f, delimiter=DELIMITER))
  103. def clean_csv(self, data):
  104. output = []
  105. for i, row in enumerate(data):
  106. for j in range(len(row)):
  107. row[j] = row[j].decode('utf-8')
  108. if len(row) < 4:
  109. continue
  110. if not self.is_date(row[0]):
  111. logging.warning("Ignoring the following row (bad format for date in the first column) :")
  112. logging.warning(str(row))
  113. continue
  114. if self.is_money_amount(row[2]):
  115. logging.warning("Ignoring row %s (not a payment)" % str(i))
  116. logging.warning(str(row))
  117. continue
  118. if not self.is_money_amount(row[3]):
  119. logging.warning("Ignoring the following row (bad format for money amount in colun three) :")
  120. logging.warning(str(row))
  121. continue
  122. # Clean the date
  123. row[0] = datetime.datetime.strptime(row[0], DATE_FORMAT).strftime("%Y-%m-%d")
  124. # Clean the label ...
  125. row[4] = row[4].replace('\r', ' ')
  126. row[4] = row[4].replace('\n', ' ')
  127. output.append(row)
  128. return output
  129. def convert_csv_to_dicts(self, data):
  130. output = []
  131. for row in data:
  132. payment = {}
  133. payment["date"] = row[0]
  134. payment["label"] = row[4]
  135. payment["amount"] = float(row[3].replace(",","."))
  136. output.append(payment)
  137. return output
  138. def try_to_match_payment_with_members(self, payments):
  139. #members = Member.objects.filter(status="member")
  140. members = Member.objects.all()
  141. idregex = re.compile(ID_REGEX)
  142. for payment in payments:
  143. payment_label = payment["label"].upper()
  144. # First, attempt to match the member ID
  145. idmatches = idregex.findall(payment_label)
  146. if len(idmatches) == 1:
  147. i = int(idmatches[0][1])
  148. member_matches = [ member.username for member in members if member.pk==i ]
  149. if len(member_matches) == 1:
  150. payment["member_matched"] = member_matches[0]
  151. #print("Matched by ID to "+member_matches[0])
  152. continue
  153. # Second, attempt to find the username
  154. usernamematch = None
  155. for member in members:
  156. username = self.flatten(member.username)
  157. matches = re.compile(r"(?i)(\b|_)"+re.escape(username)+r"(\b|_)") \
  158. .findall(payment_label)
  159. # If not found, try next
  160. if len(matches) == 0:
  161. continue
  162. # If we already had a match, abort the whole search because we
  163. # have multiple usernames matched !
  164. if usernamematch != None:
  165. usernamematch = None
  166. break
  167. usernamematch = member.username
  168. if usernamematch != None:
  169. payment["member_matched"] = usernamematch
  170. #print("Matched by username to "+usernamematch)
  171. continue
  172. # Third, attempt to match by family name
  173. familynamematch = None
  174. for member in members:
  175. if member.last_name == "":
  176. continue
  177. # "Flatten" accents in the last name... (probably the CSV
  178. # don't contain 'special' chars like accents
  179. member_last_name = self.flatten(member.last_name)
  180. matches = re.compile(r"(?i)(\b|_)"+re.escape(member_last_name)+r"(\b|_)") \
  181. .findall(payment_label)
  182. # If not found, try next
  183. if len(matches) == 0:
  184. continue
  185. # If this familyname was matched several time, abort the whole search
  186. #if len(matches) > 1:
  187. # print("Several matches ! Aborting !")
  188. # familynamematch = None
  189. # break
  190. # If we already had a match, abort the whole search because we
  191. # have multiple familynames matched !
  192. if familynamematch != None:
  193. familynamematch = None
  194. break
  195. familynamematch = member_last_name
  196. usernamematch = str(member.username)
  197. if familynamematch != None:
  198. payment["member_matched"] = usernamematch
  199. #print("Matched by familyname to "+familynamematch)
  200. continue
  201. #print("Could not match")
  202. payment["member_matched"] = None
  203. return payments
  204. def unmatch_payment_with_keywords(self, payments):
  205. matchers = {}
  206. for keyword in KEYWORDS_TO_NOTMATCH:
  207. matchers[keyword] = re.compile(r"(?i)(\b|_|-)"+re.escape(keyword)+r"(\b|_|-)")
  208. for i, payment in enumerate(payments):
  209. # If no match found, don't filter anyway
  210. if payment["member_matched"] == None:
  211. continue
  212. for keyword, matcher in matchers.items():
  213. matches = matcher.findall(payment["label"])
  214. # If not found, try next
  215. if len(matches) == 0:
  216. continue
  217. print("Ignoring possible match for payment '%s' because " \
  218. "it contains the keyword %s" \
  219. % (payment["label"], keyword))
  220. payments[i]["member_matched"] = None
  221. break
  222. return payments
  223. def filter_already_known_payments(self, payments):
  224. new_payments = []
  225. known_payments = Payment.objects.all()
  226. for payment in payments:
  227. found_match = False
  228. for known_payment in known_payments:
  229. if (str(known_payment.date) == payment["date"].encode('utf-8')) \
  230. and (known_payment.label == payment["label"]) \
  231. and (float(known_payment.amount) == float(payment["amount"])):
  232. found_match = True
  233. break
  234. if not found_match:
  235. new_payments.append(payment)
  236. return new_payments
  237. def add_new_payments(self, new_payments):
  238. for new_payment in new_payments:
  239. # Get the member if there's a member matched
  240. member = None
  241. if new_payment["member_matched"]:
  242. member = Member.objects.filter(username=new_payment["member_matched"])
  243. assert len(member) == 1
  244. member = member[0]
  245. print("Adding new payment : ")
  246. print(new_payment)
  247. # Create the payment
  248. payment = Payment.objects.create(amount=float(new_payment["amount"]),
  249. label=new_payment["label"],
  250. date=new_payment["date"],
  251. member=member)
  252. def flatten(self, some_string):
  253. return unidecode.unidecode(some_string).upper()