├── tests ├── __init__.py ├── test_ofd_platforma.py ├── test_ofd_taxcom.py └── test_ofd_ya.py ├── requirements.txt ├── LICENSE ├── config.py ├── .gitignore ├── report.py ├── main.py ├── README.md ├── qr.py ├── drebedengi.py └── ofd.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | zbar 3 | pillow==2.1.0 4 | pygame==1.9.3 5 | qrtools 6 | bs4 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 ohbobbyboy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /tests/test_ofd_platforma.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | import unittest 4 | import ofd 5 | import config 6 | 7 | 8 | class TestOFDPlatforma(unittest.TestCase): 9 | 10 | """ E2E unittest OFD-interactions """ 11 | OFD = None 12 | 13 | @classmethod 14 | def setUpClass(cls): 15 | """ Setup """ 16 | config.debug = False 17 | cls.OFD = ofd.OFDProvider(True).detect( 18 | "t=20170714T1311&s=35.00&fn=8710000100837497&i=231&fp=2921685295&n=1") 19 | 20 | def test_search(self): 21 | self.assertIsNotNone(self.OFD) 22 | 23 | def test_items_parsing(self): 24 | self.assertNotEqual(self.OFD.get_items(), []) 25 | 26 | def test_items_count(self): 27 | self.assertEqual(len(self.OFD.get_items()), 1) 28 | 29 | def test_first_item(self): 30 | item_name = self.OFD.get_items()[0][0] 31 | self.assertEqual(item_name, "Пицца Маргарита 1 шт.") 32 | 33 | def test_receipt_total_sum(self): 34 | self.assertEqual(self.OFD.total_sum, '35.00') 35 | 36 | def test_receipt_final_sum(self): 37 | self.assertEqual(self.OFD.raw_sum, '35.00') 38 | 39 | if __name__ == '__main__': 40 | unittest.main() 41 | -------------------------------------------------------------------------------- /tests/test_ofd_taxcom.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | import unittest 4 | import ofd 5 | import config 6 | 7 | 8 | class TestOFDTaxcom(unittest.TestCase): 9 | 10 | """ E2E unittest OFD-interactions """ 11 | OFD = None 12 | 13 | @classmethod 14 | def setUpClass(cls): 15 | """ Setup """ 16 | config.debug = False 17 | cls.OFD = ofd.OFDProvider(True).detect( 18 | "t=20170712T133051&s=32.50&fn=8710000100924702&i=1666&fp=3502055476&n=1") 19 | 20 | def test_search(self): 21 | self.assertIsNotNone(self.OFD) 22 | 23 | def test_items_parsing(self): 24 | self.assertNotEqual(self.OFD.get_items(), []) 25 | 26 | def test_items_count(self): 27 | self.assertEqual(len(self.OFD.get_items()), 1) 28 | 29 | def test_first_item(self): 30 | item_name = self.OFD.get_items()[0][0] 31 | self.assertEqual(item_name, "Газ вода Пепси Вайлд Черри 0,6л ЖЦ по 31,08,17 ВЫГ") 32 | 33 | def test_receipt_total_sum(self): 34 | self.assertEqual(self.OFD.total_sum, '32.90') 35 | 36 | def test_receipt_final_sum(self): 37 | self.assertEqual(self.OFD.raw_sum, '32.50') 38 | 39 | if __name__ == '__main__': 40 | unittest.main() 41 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # настройки интеграции с Дребеденьгами 5 | username = "demo@example.com" 6 | password = "demo" 7 | api_key = "demo_api" # не используется 8 | 9 | # настройки автоопределения по тратам 10 | currency_name = "RUB" # валюта для сохранения данных по умолчанию 11 | payment_method = { 12 | "default": "Кошелёк", # место хранения и траты денег по умолчанию 13 | "sms_based": { # определение по подстрокам на основе текста СМС (для карт) 14 | "VISA1234": "VISA", 15 | "Karta *1234": "MASTER CARD" 16 | } 17 | } 18 | category_name = "Продукты" # категория покупок по умолчанию 19 | 20 | # строка запуска приложения для редактирования CSV перед отправлением 21 | edit_cmdline = "libreoffice" 22 | 23 | # настройки сканирования и сохранения 24 | camera_number = 1 # порядковый номер камеры в системе 25 | qr_scan_waiting = 0.1 # пауза взятия скриншотов, оптимально 0.1 с 26 | receipt_dir = "receipts" # директория для сохранения данных по чекам 27 | report_dir = "reports" # директория для сохранения CSV для импорта 28 | already_recognized_send = False # разрешение передавать на сервер уже сохранённые локально чеки 29 | 30 | # отладка для расширенного отображения ошибок 31 | debug = False -------------------------------------------------------------------------------- /tests/test_ofd_ya.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | import unittest 4 | import ofd 5 | import config 6 | 7 | 8 | class TestOFDYa(unittest.TestCase): 9 | 10 | """ E2E unittest OFD-interactions """ 11 | OFD = None 12 | 13 | @classmethod 14 | def setUpClass(cls): 15 | """ Setup """ 16 | config.debug = False 17 | cls.OFD = ofd.OFDProvider(True).detect( 18 | "t=20170305T005100&s=140.00&fn=8710000100161943&i=8018&fp=2398195357&n=1", 19 | "0000069245023747") 20 | 21 | def test_search(self): 22 | self.assertIsNotNone(self.OFD) 23 | 24 | def test_items_parsing(self): 25 | self.assertEqual(self.OFD.get_items(), [('Хлеб Ржаной пол. рез. 0,415 кг (Каравай', '-28.40'), ('ФО Картофель, кг (17.9 * 1.132)', '-20.26'), ('ФО Огурцы Эстафета, кг (161.9 * 0.18)', '-29.14'), ('Яйцо фас. С0 10шт ', '-62.20')]) 26 | 27 | def test_items_count(self): 28 | self.assertEqual(len(self.OFD.get_items()), 4) 29 | 30 | def test_first_item(self): 31 | item_name = self.OFD.get_items()[0][0] 32 | self.assertEqual(item_name, "Хлеб Ржаной пол. рез. 0,415 кг (Каравай") 33 | 34 | def test_receipt_final_sum(self): 35 | self.assertEqual(self.OFD.raw_sum, '140.00') 36 | 37 | if __name__ == '__main__': 38 | unittest.main() 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # bobby_boy 101 | materials 102 | receipts 103 | reports 104 | -------------------------------------------------------------------------------- /report.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | import csv 4 | import config 5 | import subprocess 6 | 7 | 8 | def edit(filename): 9 | process = subprocess.Popen([config.edit_cmdline, filename]) 10 | 11 | 12 | def make(items, 13 | categories, 14 | filename, 15 | datetime, 16 | receipt_sum, 17 | calculated_sum, 18 | payment_method): 19 | 20 | with open(filename, 'wb') as csvfile: 21 | report = csv.writer(csvfile, delimiter=';', 22 | lineterminator="\r\n", quotechar='"') 23 | 24 | report.writerow(["#", "", "Импорт данных в Drebedengi"]) 25 | report.writerow(["#", "", "Строки с # являются служебными,", 26 | "при импорте", "будут удалены"]) 27 | report.writerow(["#", "Категории:"]) 28 | for category in categories: 29 | report.writerow(["#", "", category]) 30 | 31 | report.writerow(["#"]) 32 | report.writerow(["#"]) 33 | report.writerow(["# Сумма", "Валюта", "Категория", "Кошелёк", 34 | "Время", "Комментарий", "Пользователь", "Группа"]) 35 | for name, summa in items: 36 | report.writerow([summa, config.currency_name, config.category_name, 37 | payment_method, datetime, name, "", ""]) 38 | 39 | report.writerow(["# Сумма", calculated_sum]) 40 | report.writerow(["# По чеку", receipt_sum]) 41 | 42 | 43 | def clear(filename): 44 | rows = [] 45 | 46 | with open(filename, 'rb') as csvfile: 47 | reader = csv.reader(csvfile, delimiter=';') 48 | rows = [row for row in reader] 49 | 50 | with open(filename, 'wb') as csvfile: 51 | writer = csv.writer(csvfile, delimiter=';', quotechar='"', skipinitialspace=True, 52 | quoting=csv.QUOTE_NONNUMERIC) # QUOTE_MINIMAL vs QUOTE_NONNUMERIC 53 | 54 | for row in rows: 55 | if not row[0].startswith("#"): 56 | # удаляем неразрывные пробелы в названиях категорий 57 | row[2] = row[2].strip(' ') 58 | writer.writerow(row) 59 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import sys 5 | import argparse 6 | import qr 7 | import config 8 | import report 9 | from ofd import OFDProvider 10 | from drebedengi import Drebedengi 11 | 12 | 13 | # создаём необходимые директории если отсутствуют 14 | def init(): 15 | if not os.path.exists(config.receipt_dir): 16 | os.makedirs(config.receipt_dir) 17 | if not os.path.exists(config.report_dir): 18 | os.makedirs(config.report_dir) 19 | 20 | 21 | # resend - разрешение повторно передавать данные по чеку, который уже сохранен 22 | def recognize(resend, receipt_text): 23 | 24 | ofd_receipt = OFDProvider(resend).detect(receipt_text) 25 | 26 | if not type(ofd_receipt) is bool: 27 | items = ofd_receipt.get_items() 28 | if items: 29 | return ofd_receipt 30 | elif ofd_receipt: 31 | kkt = raw_input("Enter `PH KKT`: ") 32 | inn = raw_input("Enter `INN`: ") 33 | ofd_receipt = OFDProvider(resend).detect(receipt_text, kkt, inn) 34 | 35 | if not type(ofd_receipt) is bool: 36 | items = ofd_receipt.get_items() 37 | if items: 38 | return ofd_receipt 39 | return False 40 | 41 | parser = argparse.ArgumentParser(description='Import receipts data from OFD to Drebedengi') 42 | parser.add_argument('--text', help='take receipt data from string') 43 | parser.add_argument('--noediting', action='store_false', help='disable manual report editing') 44 | args = parser.parse_args() 45 | 46 | init() 47 | 48 | if args.text is not None: 49 | # распознаём из введённого текста 50 | receipt = recognize(config.already_recognized_send, args.text) 51 | else: 52 | receipt = recognize(config.already_recognized_send, 53 | qr.get_content_with_gui()) 54 | 55 | if receipt is not None: 56 | report_name = receipt.get_csv_file_name() 57 | dreb_session = Drebedengi(config.username, config.password) 58 | if not dreb_session.logged_in(): 59 | print("Auth is not successful!") 60 | sys.exit(-1) 61 | 62 | categories = dreb_session.get_categories() 63 | 64 | sms_saved_receipt = dreb_session.search(receipt.dreb_time, receipt.raw_sum) 65 | 66 | if sms_saved_receipt: 67 | receipt.payment_method = sms_saved_receipt['payment_method'] 68 | 69 | report.make(receipt.items, 70 | categories, 71 | report_name, 72 | receipt.dreb_time, 73 | receipt.raw_sum, 74 | receipt.total_sum, 75 | receipt.payment_method) 76 | 77 | if args.noediting: 78 | report.edit(report_name) 79 | 80 | raw_input("Press Enter to export report to Drebedengi...") 81 | 82 | report.clear(report_name) 83 | 84 | import_result = dreb_session.send_csv(report_name) 85 | 86 | if import_result and sms_saved_receipt: 87 | dreb_session.delete_item(sms_saved_receipt['id']) 88 | 89 | else: 90 | print("Receipt search failed!") 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | bobby_boy 2 | === 3 | 4 | ### Предназначение 5 | 6 | Получение данных по кассовым чекам через распознавание QR-кодов и запросы к ОФД с занесением трат в систему [Drebedengi.ru](http://drebedengi.ru). 7 | 8 | ### Рабочий сценарий 9 | 10 | 1. Запускаете программу 11 | 1. Подносите чек к веб-камере 12 | - распознаётся строка с данными чека с QR-кода 13 | - поочередно у всех ОФД запрашивается чек 14 | - данные сохраняются в сыром виде и в CSV-таблице 15 | - происходит вход в Drebedengi, подтягиваются категории, проверяется наличие СМС по чеку 16 | - открывается табличный редактор 17 | 1. Проверяете список, редактируете категории трат или просто нажимаете Enter 18 | - список трат импортируется в Дребеденьги 19 | - если была СМС по чеку с общей суммой, то она удаляется 20 | 21 | При редактировании в файле отображаются доступные категории трат, которые можно копировать в 22 | соответствующие позиции в чеке. 23 | 24 | ### Поддерживаемые ОФД 25 | 26 | Официальный список операторов фискальных данных расположен [здесь](https://www.nalog.ru/rn77/related_activities/registries/fiscaloperators/). 27 | 28 | На 06.08.2017 из 12 ОФД публичный API для проверки кассовых чеков есть у 9. 29 | 30 | Приложение умеет работает с ОФД (по ссылкам страницы с формами проверки): 31 | - [Первый ОФД](https://consumer.1-ofd.ru/#/landing) (Ашан, Виктория, Пятёрочка, BILLA, Магнит) 32 | - [Платформа ОФД](https://lk.platformaofd.ru/web/noauth/cheque/search) (Дикси, Крошка-картошка) 33 | - [Такском](https://receipt.taxcom.ru/) (Карусель, KFC) 34 | - [OFD.RU](https://ofd.ru/checkinfo) (Связной) - потребуется ручной ввод РН ККТ и ИНН с чека 35 | - [ОФД-Я](https://ofd-ya.ru/check) (Перекрёсток) - потребуется ручной ввод РН ККТ с чека (ИНН необязателен) 36 | - ~~[Астрал ОФД](https://ofd.astralnalog.ru/)~~ 37 | - ~~[ОФД Яндекс](https://ofd.yandex.ru/check)~~ 38 | - ~~[СБИС](https://ofd.sbis.ru/)~~ 39 | - ~~[КОРУС ОФД](https://ofd.esphere.ru/CheckWebApp/fiscaldocsearch.zul)~~ 40 | 41 | ### Использование 42 | 43 | Для выполнения программы достаточно запустить `main.py`. 44 | 45 | По умолчанию происходит распознавание QR-кода через веб-камеру. Для ручного ввода уже распознанного текста следует добавить ключ запуска `--text`. 46 | 47 | Для интеграции в `config.py` необходимо ввести свои данные: 48 | - Логин и пароль от аккаунта в Дребеденьгах 49 | - Валюту 50 | - Место списания (счёт) 51 | - Категорию трат по умолчанию 52 | - Путь к приложению для редактирования таблиц (e.g. LibreOffice, OpenOffice, Excel) 53 | 54 | ### Установка 55 | 56 | Необходим `Python 2.7`, фреймворки Pygame и ZBar и дополнительные библиотеки. 57 | 58 | Работа проверена на Ubuntu Linux 16.04, для установки выполнить: 59 | ``` 60 | sudo apt-get install python-dev libzbar-dev 61 | sudo apt-get build-dep python-pygame 62 | pip install -r requirements.txt 63 | ``` 64 | Если сборка PIL прекращается на `#include `, то выполнить: 65 | ``` 66 | sudo ln -s /usr/include/freetype2 /usr/local/include/freetype 67 | ``` 68 | 69 | ### Тестирование 70 | 71 | ``` 72 | python -m unittest discover 73 | ``` 74 | 75 | ### Обсуждение 76 | 77 | Отзывы и предложения по программе отправлять в соответствующую [ветку форума Drebedengi.ru](https://www.drebedengi.ru/?module=forumMessageList&topic_id=8486). -------------------------------------------------------------------------------- /qr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | import sys 4 | import time 5 | import zbar 6 | import config 7 | import pygame 8 | from pygame import camera 9 | from PIL import Image 10 | from qrtools import QR 11 | 12 | 13 | def get_qr_content(with_gui=False, manual_detect=False): 14 | 15 | screen = None 16 | detected = False 17 | camera.init() 18 | if not camera.list_cameras(): 19 | print("No camera detected!") 20 | sys.exit(-1) 21 | cam = camera.Camera(camera.list_cameras()[config.camera_number-1]) 22 | size = cam.get_size() 23 | width, height = size 24 | 25 | if not manual_detect: 26 | sys.stdout.write("QR detection started, wait several seconds...") 27 | sys.stdout.flush() 28 | cam.start() 29 | 30 | if with_gui: 31 | screen = pygame.display.set_mode(size) 32 | pygame.display.set_caption("Check QR recognize") 33 | else: 34 | with_gui = True 35 | print("QR detection through GUI, press any key when green line flash") 36 | 37 | data = 0 38 | while not detected: 39 | try: 40 | 41 | if manual_detect: 42 | qr = QR() 43 | qr.decode_webcam() 44 | data = qr.data 45 | 46 | else: 47 | img = cam.get_image() 48 | 49 | # we can use file buffer for recognition 50 | # pygame.image.save(img, "file.jpg") 51 | # pil_string_image = Image.open("file.jpg").convert('L').tostring() 52 | 53 | pygame_img = pygame.image.tostring(img, 'RGB', False) 54 | pil_string_image = Image.fromstring( 55 | 'RGB', size, pygame_img).convert('L').tostring() 56 | 57 | if with_gui: 58 | screen.blit(img, (0, 0)) 59 | pygame.display.flip() # display update 60 | 61 | zbar_image = zbar.Image( 62 | width, height, 'Y800', pil_string_image) 63 | 64 | scanner = zbar.ImageScanner() 65 | scanner.parse_config('enable') 66 | data = scanner.scan(zbar_image) 67 | 68 | sys.stdout.write('.') 69 | sys.stdout.flush() 70 | 71 | for qr in zbar_image: 72 | if data: 73 | print("Additional QR recognized!") 74 | data = qr.data 75 | 76 | if data: 77 | print("\nRecognized: `{}`".format(data)) 78 | detected = True 79 | 80 | except Exception as e: 81 | print("Error! " + str(e)) 82 | finally: 83 | time.sleep(config.qr_scan_waiting) 84 | 85 | if not manual_detect: 86 | pygame.display.quit() 87 | cam.stop() 88 | 89 | return 0 if data == "NULL" else data 90 | 91 | 92 | # through Zbar recognizer 93 | def get_content_with_gui_manual(): 94 | return get_qr_content(True, True) 95 | 96 | 97 | # show in Pygame window 98 | def get_content_with_gui(): 99 | return get_qr_content(True, False) 100 | 101 | 102 | # do not show GUI 103 | def get_content_no_gui(): 104 | return get_qr_content(False, False) 105 | -------------------------------------------------------------------------------- /drebedengi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | import requests 4 | from bs4 import BeautifulSoup 5 | from config import payment_method 6 | 7 | # взаимодействие реализовано по HTTP - притворяемся браузер-клиентом 8 | # возможна реализация через SOAP: http://www.drebedengi.ru/soap/dd.wsdl 9 | 10 | http_login_url = "https://www.drebedengi.ru/?module=v2_start&action=login" 11 | http_csv_send_url = "https://www.drebedengi.ru/?module=v2_homeBuhPrivateImport&action=csv_submit" 12 | http_csv_confirm_url = "https://www.drebedengi.ru/?module=v2_homeBuhPrivateImport&action=confirm" 13 | http_search_url = "https://www.drebedengi.ru/?module=v2_homeBuhPrivateReport" 14 | http_delete_item_url = "https://www.drebedengi.ru/?module=v2_homeBuhPrivateTextReportMain" 15 | 16 | 17 | class Drebedengi: 18 | session = None 19 | categories = [] 20 | 21 | def __init__(self, user, passw): 22 | session = requests.Session() 23 | 24 | data = { 25 | "o": "", 26 | "email": user, 27 | "password": passw, 28 | "ssl": "on" 29 | } 30 | 31 | login = session.post(http_login_url, data) 32 | soup = BeautifulSoup(login.content, 'html.parser') 33 | categories = [option.text.encode( 34 | 'utf-8') for option in soup.find(id="add_w_category_id").find_all("option")] 35 | 36 | self.categories = categories[1:] 37 | self.session = session 38 | 39 | def logged_in(self): 40 | return self.session is not None 41 | 42 | def get_categories(self): 43 | return self.categories 44 | 45 | def send_csv(self, filename): 46 | data = { 47 | 'imp_fmt': 'imp_in_fmt', 48 | 'csvFile': (filename, 49 | open(filename, 'rb'), 50 | 'text/csv') 51 | 52 | } 53 | r = self.session.post(http_csv_send_url, files=data) 54 | post1 = r.status_code 55 | r = self.session.post(http_csv_confirm_url) 56 | post2 = r.status_code 57 | 58 | if post1 == 200 and post2 == 200: # TODO: improve error checking by page content 59 | print("Successfully imported!") 60 | return True 61 | else: 62 | print("Something went wrong...") 63 | return False 64 | 65 | def delete_item(self, item_id): 66 | payload = { 67 | 'action': 'delete_item', 68 | 'wasteId': item_id, 69 | 'is_report': '1', 70 | 'pref': 'waste' 71 | } 72 | r = self.session.post(http_delete_item_url, data=payload) 73 | if r.status_code == 200: 74 | print("Old SMS item successfully removed!") 75 | 76 | def search(self, date, summa): 77 | date = date.split()[0] 78 | 79 | payload = { 80 | 'r_what': '3', 81 | 'r_how': '1', 82 | 'r_period': '0', 83 | 'r_who': '0', 84 | 'period_to': date, 85 | 'period_from': date, 86 | 'r_middle': '0', 87 | 'r_is_place': '0', 88 | 'r_is_category': '0', 89 | 'r_currency': '0', 90 | 'r_search_comment': '', 91 | 'r_is_tag': '0', 92 | 'is_cat_childs': 'true', 93 | 'is_with_rest': 'false', 94 | 'is_with_planned': 'false', 95 | 'is_course_hist': 'false', 96 | 'r_duty': '0', 97 | 'r_sum': '1', 98 | 'r_sum_from': summa, 99 | 'r_sum_to': '', 100 | 'r_place[]': '0', 101 | 'r_category[]': '0', 102 | 'r_tag[]': '0', 103 | 'action': 'show_report', 104 | } 105 | 106 | request = self.session.post(http_search_url, data=payload) 107 | soup = BeautifulSoup(request.content, 'html.parser') 108 | 109 | # remove totally sum of results from html 110 | total_sum_tag = soup.find("div", text="Итого") 111 | 112 | if total_sum_tag is None: 113 | return None 114 | else: 115 | total_sum_tag.next_sibling.next_sibling.decompose() 116 | 117 | sum_blocks = soup.find_all("span", class_="red") 118 | 119 | for sum_block in sum_blocks: 120 | if sum_block.text == "-"+str(summa): 121 | print("SMS with the receipt was found, it will be deleted after import") 122 | 123 | parent_tag = sum_block.parent.parent.parent 124 | desc_tag = parent_tag.next_sibling.next_sibling.next_sibling.next_sibling.next_sibling.next_sibling 125 | id_tag = parent_tag.previous_sibling.previous_sibling 126 | 127 | item_text = desc_tag.text 128 | item_id = id_tag.get('id').split('_')[1] 129 | 130 | method = payment_method["default"] 131 | 132 | # TODO: additional detecting by payment method of item (if SMS text was not saved) 133 | for substr in payment_method['sms_based']: 134 | if item_text.find(substr) != -1: 135 | method = payment_method['sms_based'][substr] 136 | print("Detected payment method by SMS text: "+method) 137 | 138 | return { 139 | "payment_method": method, 140 | "id": item_id 141 | } 142 | -------------------------------------------------------------------------------- /ofd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | import datetime 4 | import json 5 | import os 6 | import re 7 | import sys 8 | 9 | import requests 10 | from bs4 import BeautifulSoup 11 | 12 | import config 13 | 14 | 15 | class OFDProvider: 16 | # заводской номер фискального накопителя 17 | # fiscalDriveId 18 | # fn 19 | # ФН 20 | fiscal_drive_id = None 21 | # номер фискального документа 22 | # fiscalDocumentNumber 23 | # i 24 | # ФД 25 | fiscal_document_number = None 26 | # номер ФПД 27 | # фискальный признак документа (подпись) 28 | # fp 29 | # ФП 30 | fiscal_id = None 31 | # регистрационный номер ККТ 32 | kkt = None 33 | # инн 34 | inn = None 35 | # время покупки 36 | time = None 37 | # сумма чека из ОФД 38 | raw_sum = 0 39 | # подсчитанная сумма чека 40 | total_sum = 0 41 | # номер чека 42 | number = 0 43 | # идентификатор чека на сервере 44 | receipt_id = "" 45 | # данные чека 46 | receipt_data = None 47 | # опция для возможности повторной отправки данных уже сохраненного чека 48 | resend = False 49 | # место хранения и траты денег по умолчанию 50 | payment_method = config.payment_method['default'] 51 | # время из чека 52 | raw_time = None 53 | 54 | # регулярное выражение для проверки соответствия формату текста QR 55 | ofd_type1_match_regexp = "t=([\dT]+)&s=([\d\.]+)&fn=(\d+)&i=(\d+)&fp=(\d+)&n=(\d+)" 56 | 57 | def __init__(self, resend): 58 | self.resend = resend 59 | 60 | def load(self, data): 61 | for key in data: 62 | setattr(self, key, data[key]) 63 | 64 | @staticmethod 65 | def parse_data(fields): 66 | time = datetime.datetime.strptime(fields[0], "%Y%m%dT%H%M%S") 67 | drebtime = time.strftime("%Y-%m-%d %H:%M:%S") 68 | 69 | return { 70 | "raw_time": fields[0], 71 | "time": time, 72 | "dreb_time": drebtime, 73 | "raw_sum": "{0:.2f}".format(float(fields[1])), 74 | "fiscal_drive_id": fields[2], 75 | "fiscal_document_number": fields[3], 76 | "fiscal_id": fields[4], 77 | "number": fields[5] 78 | } 79 | 80 | # определение ОФД по данным чека и запросами 81 | def detect(self, text, kkt=None, inn=None): 82 | ofd_type1_match = re.match(self.ofd_type1_match_regexp, text) 83 | # проверка чека по обычному на данный момент содержанию QR 84 | 85 | if ofd_type1_match: 86 | # получаем данные чека 87 | data = self.parse_data(ofd_type1_match.groups()) 88 | 89 | print("Ticket {3} at {0} with sum {1}, FPD {4}, fiscal drive {2} (n={5})".format( 90 | data['time'], data['raw_sum'], data['fiscal_drive_id'], data['fiscal_document_number'], 91 | data['fiscal_id'], data['number'])) 92 | 93 | data['kkt'] = kkt 94 | data['inn'] = inn 95 | 96 | # для списка известных провайдеров 97 | for provider in [PlatformaOFD, Taxcom, OFDRU, OFD1, OFDYA]: 98 | # проверяем что данные удовлетворяют требованиям ОФД 99 | if provider(self.resend).is_suitable(data): 100 | # инициализируем и загружаем данные 101 | ofd = provider(self.resend) 102 | ofd.load(data) 103 | # если поиск успешен, то возвращаем инстанс чека этого ОФД 104 | try: 105 | if ofd.search(): 106 | return ofd 107 | except Exception as e: 108 | print("Request error: " + str(e)) 109 | 110 | return True 111 | 112 | elif text.startswith("http://check.egais.ru"): 113 | print("This is an EGAIS receipt without sum!") 114 | return False 115 | # добавить распознавание ЕГАИС 116 | else: 117 | print("No match with known OFD in content!") 118 | return False 119 | 120 | # имя файла для сохранения контента чека из ОФД 121 | def get_receipt_file_name(self): 122 | filename = os.path.dirname(os.path.realpath(__file__)) + \ 123 | self.raw_time + "_" + self.fiscal_id + \ 124 | "_" + self.fiscal_drive_id + ".txt" 125 | return os.path.join(config.receipt_dir, filename) 126 | 127 | # имя файла для сохранения файла загрузки в Дребеденьги 128 | def get_csv_file_name(self): 129 | filename = self.raw_time + "_" + self.fiscal_id + \ 130 | "_" + self.fiscal_drive_id + ".csv" 131 | return os.path.join(config.report_dir, filename) 132 | 133 | 134 | class OFDRU(OFDProvider): 135 | url_receipt_get = "https://ofd.ru/api/rawdoc/RecipeInfo?Fn={}&Kkt={}&Inn={}&Num={}&Sign={}" 136 | 137 | @staticmethod 138 | def is_suitable(data): 139 | return data['fiscal_drive_id'] and data['fiscal_id'] and data['fiscal_document_number'] and data['kkt'] and \ 140 | data['inn'] 141 | 142 | def search(self): 143 | print("Search in OFD.RU...") 144 | url = self.url_receipt_get.format( 145 | self.fiscal_drive_id, self.kkt, self.inn, self.fiscal_document_number, self.fiscal_id) 146 | request = requests.get(url) 147 | if request.status_code == 404: 148 | print("Not found!") 149 | return False 150 | else: 151 | self.receipt_data = request.content 152 | filename = self.get_receipt_file_name() 153 | 154 | if not os.path.exists(filename): 155 | with open(filename, 'w') as outfile: 156 | outfile.write(self.receipt_data) 157 | return True 158 | 159 | def get_items(self): 160 | if self.receipt_data: 161 | self.total_sum = 0 162 | self.receipt_data = json.loads(self.receipt_data) 163 | items_count = len(self.receipt_data["Document"]["Items"]) 164 | print("Found items: {}".format(items_count)) 165 | 166 | items = [] 167 | for item in self.receipt_data["Document"]["Items"]: 168 | name = item["Name"].encode('utf8') 169 | summa = float(item["Total"]) / 100.0 170 | price = float(item["Price"]) / 100.0 171 | count = item["Quantity"] 172 | self.total_sum += summa 173 | 174 | if count != 1: 175 | items.append( 176 | ("{} ({} * {})".format(name, price, count), 177 | "-{0:.2f}".format(summa))) 178 | else: 179 | items.append((name, "-{0:.2f}".format(summa))) 180 | 181 | print("Items total sum: {}".format(self.total_sum)) 182 | self.total_sum = "{0:.2f}".format(self.total_sum) 183 | if self.total_sum != self.raw_sum: 184 | print("WARNING! Manually calculated sum {} is not equal to the receipt sum {}!".format( 185 | self.total_sum, self.raw_sum)) 186 | 187 | self.items = items 188 | return items 189 | else: 190 | print("No receipt data!") 191 | return False 192 | 193 | 194 | class Taxcom(OFDProvider): 195 | url_receipt_get = "https://receipt.taxcom.ru/v01/show?fp={}&s={}" 196 | 197 | @staticmethod 198 | def is_suitable(data): 199 | return data['fiscal_id'] and not data['kkt'] 200 | 201 | def search(self): 202 | print("Search in Taxcom...") 203 | request = requests.get(self.url_receipt_get.format( 204 | self.fiscal_id, self.raw_sum)) 205 | if "Такой чек не найден" in request.content: 206 | print("Not found!") 207 | return False 208 | else: 209 | self.receipt_data = request.content 210 | filename = self.get_receipt_file_name() 211 | 212 | if not os.path.exists(filename): 213 | with open(filename, 'w') as outfile: 214 | outfile.write(self.receipt_data) 215 | return True 216 | 217 | def get_items(self): 218 | if self.receipt_data: 219 | soup = BeautifulSoup(self.receipt_data, "lxml") 220 | rows = soup.select("td.position")[:-1] 221 | price_counts = soup.select("tr.result") 222 | self.total_sum = 0 223 | 224 | def extract_count(row_obj): 225 | return row_obj.find_all('span')[0].get_text().encode("utf-8") 226 | 227 | def extract_price(row_obj): 228 | return row_obj.find_all('span')[1].get_text().encode("utf-8") 229 | 230 | items = [] 231 | for i, row in enumerate(rows): 232 | 233 | name = row.get_text().encode("utf-8") 234 | 235 | price = float(extract_price(price_counts[i]).replace(',', '.')) 236 | count = float(extract_count(price_counts[i]).replace(',', '.')) 237 | summa = price * count 238 | self.total_sum += summa 239 | if count != 1: 240 | items.append( 241 | ("{} ({} * {})".format(name, price, count), 242 | "-{0:.2f}".format(summa))) 243 | else: 244 | items.append((name, "-{0:.2f}".format(summa))) 245 | 246 | print("Items total sum: {}".format(self.total_sum)) 247 | self.total_sum = "{0:.2f}".format(self.total_sum) 248 | if self.total_sum != self.raw_sum: 249 | print("WARNING! Manually calculated sum {} is not equal to the receipt sum {}!".format( 250 | self.total_sum, self.raw_sum)) 251 | 252 | self.items = items 253 | return items 254 | else: 255 | print("No receipt data!") 256 | return False 257 | 258 | 259 | class PlatformaOFD(OFDProvider): 260 | url_receipt_get = "https://lk.platformaofd.ru/web/noauth/cheque?fn={}&fp={}" 261 | 262 | @staticmethod 263 | def is_suitable(data): 264 | return data['fiscal_drive_id'] and data['fiscal_id'] and not data['kkt'] 265 | 266 | def search(self): 267 | print("Search in Platforma OFD...") 268 | request = requests.get(self.url_receipt_get.format( 269 | self.fiscal_drive_id, self.fiscal_id), verify=False) 270 | # ssl verification disabled 271 | # workaround for error: hostname 'lk.platformaofd.ru' doesn't match either of '*.evotor.ru', 'evotor.ru' 272 | if "Чек не найден" in request.content: 273 | print("Not found!") 274 | return False 275 | else: 276 | self.receipt_data = request.content 277 | filename = self.get_receipt_file_name() 278 | 279 | if not os.path.exists(filename): 280 | with open(filename, 'w') as outfile: 281 | outfile.write(self.receipt_data) 282 | return True 283 | 284 | def get_items(self): 285 | if self.receipt_data: 286 | soup = BeautifulSoup(self.receipt_data, "lxml") 287 | rows = soup.select("div.row") 288 | self.total_sum = 0 289 | 290 | def extract_value(row_obj): 291 | return row_obj.find('div', {'class': 'col-xs-4'}).get_text().encode("utf-8") 292 | 293 | def extract_key(row_obj): 294 | return row_obj.find('div', {'class': 'col-xs-8'}).get_text().encode("utf-8") 295 | 296 | items = [] 297 | for i, row in enumerate(rows): 298 | if row.get_text().encode("utf-8") != "наименование товара (реквизиты)": 299 | continue 300 | name = extract_value(rows[i + 1]) 301 | if extract_key(rows[i + 2]) == "штриховой код EAN13": 302 | i += 1 303 | price = float(extract_value(rows[i + 2])) 304 | count = int(float(extract_value(rows[i + 3]))) 305 | summa = float(extract_value(rows[i + 4])) 306 | self.total_sum += summa 307 | if count != 1: 308 | items.append( 309 | ("{} ({} * {})".format(name, price, count), 310 | "-{0:.2f}".format(summa))) 311 | else: 312 | items.append((name, "-{0:.2f}".format(summa))) 313 | 314 | print("Items total sum: {}".format(self.total_sum)) 315 | self.total_sum = "{0:.2f}".format(self.total_sum) 316 | if self.total_sum != self.raw_sum: 317 | print("WARNING! Manually calculated sum {} is not equal to the receipt sum {}!".format( 318 | self.total_sum, self.raw_sum)) 319 | 320 | self.items = items 321 | return items 322 | else: 323 | print("No receipt data!") 324 | return False 325 | 326 | 327 | class OFD1(OFDProvider): 328 | url_first_get = "https://consumer.1-ofd.ru/#/landing" 329 | url_receipt_get = "https://consumer.1-ofd.ru/api/tickets/ticket/{}" 330 | url_receipt_find = "https://consumer.1-ofd.ru/api/tickets/find-ticket" 331 | 332 | @staticmethod 333 | def is_suitable(data): 334 | return data['fiscal_drive_id'] and data['fiscal_id'] and data['fiscal_document_number'] and not data['kkt'] 335 | 336 | def search(self): 337 | print("Search in ofd1...") 338 | 339 | ofd1_payload = { 340 | "fiscalDocumentNumber": self.fiscal_document_number, 341 | "fiscalDriveId": self.fiscal_drive_id, 342 | "fiscalId": self.fiscal_id 343 | } 344 | # fix for single quotes server error 345 | ofd1_payload = json.dumps(ofd1_payload, sort_keys=True) 346 | 347 | session = requests.Session() 348 | session.get(self.url_first_get) 349 | 350 | session.headers.update({ 351 | 'Content-Type': 'application/json', # fix 415 error 352 | 'X-XSRF-TOKEN': session.cookies.get_dict()['XSRF-TOKEN'], 353 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36' 354 | }) 355 | 356 | # print(session.headers) 357 | # print(session.cookies.get_dict()) 358 | # cookies = session.cookies.get_dict().copy() 359 | # cookies.update({ 360 | # 'PLAY_LANG': 'ru' 361 | # }) 362 | # print(cookies) 363 | 364 | ofd1 = session.post(self.url_receipt_find, data=ofd1_payload) 365 | 366 | if ofd1.status_code == 200: 367 | answer = ofd1.json() 368 | status = answer["status"] 369 | self.receipt_id = answer["uid"] 370 | 371 | print("Getting the receipt...") 372 | ofd1 = requests.get(self.url_receipt_get.format(self.receipt_id)) 373 | 374 | if ofd1.status_code == 200: 375 | self.raw = json.dumps( 376 | ofd1.json(), ensure_ascii=False).encode('utf8') 377 | self.receipt_data = json.loads(self.raw) 378 | 379 | filename = self.get_receipt_file_name() 380 | 381 | if not os.path.exists(filename): 382 | with open(filename, 'w') as outfile: 383 | outfile.write(self.raw) 384 | else: 385 | print("Receipt already saved!") 386 | if not self.resend: 387 | print("Skipping...") 388 | return False 389 | 390 | return True 391 | else: 392 | print("Error {} while getting receipt from ofd1!".format( 393 | ofd1.status_code)) 394 | if config.debug: 395 | print(ofd1.text) 396 | 397 | elif ofd1.status_code == 404: 398 | print("Not found!") 399 | 400 | else: 401 | print("Error {} while searching in ofd1!".format(ofd1.status_code)) 402 | if config.debug: 403 | print(ofd1.text) 404 | 405 | return False 406 | 407 | def get_items(self): 408 | if self.receipt_data: 409 | self.total_sum = 0 410 | items_count = len(self.receipt_data["ticket"]["items"]) 411 | print("Found items: {}".format(items_count)) 412 | 413 | items = [] 414 | for item in self.receipt_data["ticket"]["items"]: 415 | data = item["commodity"] 416 | name = data["name"].encode('utf8') 417 | summa = float(data["sum"]) 418 | price = data["price"] 419 | count = data["quantity"] 420 | self.total_sum += summa 421 | 422 | if count != 1: 423 | items.append( 424 | ("{} ({} * {})".format(name, price, count), 425 | "-{0:.2f}".format(summa))) 426 | else: 427 | items.append((name, "-{0:.2f}".format(summa))) 428 | 429 | print("Items total sum: {}".format(self.total_sum)) 430 | self.total_sum = "{0:.2f}".format(self.total_sum) 431 | if self.total_sum != self.raw_sum: 432 | print("WARNING! Manually calculated sum {} is not equal to the receipt sum {}!".format( 433 | self.total_sum, self.raw_sum)) 434 | 435 | self.items = items 436 | return items 437 | else: 438 | print("No receipt data!") 439 | return False 440 | 441 | 442 | class OFDYA(OFDProvider): 443 | url_receipt_get = "https://ofd-ya.ru/getFiscalDoc?kktRegId={}&fiscalSign={}&json=true" 444 | 445 | @staticmethod 446 | def is_suitable(data): 447 | return data['fiscal_document_number'] and data['kkt'] 448 | 449 | def search(self): 450 | print("Search in OFD-YA...") 451 | url = self.url_receipt_get.format(self.kkt, self.fiscal_id) 452 | request = requests.get(url) 453 | if request.status_code == 200 and request.text != '{}': 454 | self.raw = json.dumps( 455 | request.json(), ensure_ascii=False).encode('utf8') 456 | self.receipt_data = json.loads(self.raw) 457 | filename = self.get_receipt_file_name() 458 | 459 | if not os.path.exists(filename): 460 | with open(filename, 'w') as outfile: 461 | outfile.write(self.receipt_data) 462 | 463 | return True 464 | else: 465 | print("Error {} while searching in ofd-ya!".format(request.status_code)) 466 | if config.debug: 467 | print(request.text) 468 | return False 469 | 470 | def get_items(self): 471 | if self.receipt_data: 472 | self.total_sum = 0 473 | items_count = len(self.receipt_data["requestmessage"]["items"]) 474 | print("Found items: {}".format(items_count)) 475 | 476 | items = [] 477 | for item in self.receipt_data["requestmessage"]["items"]: 478 | name = item["name"].encode('utf8') 479 | summa = int(item["sum"]) / 100.0 480 | price = int(item["price"]) / 100.0 481 | count = item["quantity"] 482 | self.total_sum += summa 483 | 484 | if count != 1: 485 | items.append( 486 | ("{} ({} * {})".format(name, price, count), 487 | "-{0:.2f}".format(summa))) 488 | else: 489 | items.append((name, "-{0:.2f}".format(summa))) 490 | 491 | print("Items total sum: {}".format(self.total_sum)) 492 | self.total_sum = "{0:.2f}".format(self.total_sum) 493 | if self.total_sum != self.raw_sum: 494 | print("WARNING! Manually calculated sum {} is not equal to the receipt sum {}!".format( 495 | self.total_sum, self.raw_sum)) 496 | 497 | self.items = items 498 | return items 499 | else: 500 | print("No receipt data!") 501 | return False 502 | --------------------------------------------------------------------------------