diff --git a/l10n_ch_qr_bill_scan/README.rst b/l10n_ch_qr_bill_scan/README.rst new file mode 100644 index 0000000000..5962d35786 --- /dev/null +++ b/l10n_ch_qr_bill_scan/README.rst @@ -0,0 +1,55 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +========================== +Switzerland - QR bill Scan +========================== + +Import Vendor bills with a scanner or with a pdf/png file. + + +Usage +===== + +Launch wizard XY + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/125/10.0 + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed feedback `here `_. + + +Credits +======= + +Contributors +------------ + +* Yannick Vaucher + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit http://odoo-community.org. diff --git a/l10n_ch_qr_bill_scan/__init__.py b/l10n_ch_qr_bill_scan/__init__.py new file mode 100644 index 0000000000..40272379f7 --- /dev/null +++ b/l10n_ch_qr_bill_scan/__init__.py @@ -0,0 +1 @@ +from . import wizard diff --git a/l10n_ch_qr_bill_scan/__manifest__.py b/l10n_ch_qr_bill_scan/__manifest__.py new file mode 100644 index 0000000000..77a3e393e1 --- /dev/null +++ b/l10n_ch_qr_bill_scan/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + 'name': 'Switzerland - QR-bill scan', + 'summary': 'Scan QR-bill to create vendor bills', + 'version': '13.0.1.0.0', + 'author': "Camptocamp,Odoo Community Association (OCA)", + 'category': 'Localization', + 'website': 'https://github.com/OCA/l10n-switzerland', + 'license': 'AGPL-3', + 'depends': ['l10n_ch', 'account_invoice_import'], + 'external_dependencies': { + 'python': ['pyzbar'], + }, + 'data': [ + "wizard/account_invoice_import_view.xml" + ], + 'auto_install': False, + 'installable': True, +} diff --git a/l10n_ch_qr_bill_scan/models/business_document_import.py b/l10n_ch_qr_bill_scan/models/business_document_import.py new file mode 100644 index 0000000000..73b7ecd80b --- /dev/null +++ b/l10n_ch_qr_bill_scan/models/business_document_import.py @@ -0,0 +1,15 @@ +# Copyright 2020 Camptocamp +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import api, models + + +class BusinessDocumentImport(models.AbstractModel): + _name = "business.document.import" + _description = "Common methods to import business documents" + + @api.model + def _hook_match_partner( + self, partner_dict, chatter_msg, domain, partner_type_label + ): + # TODO search by iban + return False diff --git a/l10n_ch_qr_bill_scan/readme/README.rst b/l10n_ch_qr_bill_scan/readme/README.rst new file mode 100644 index 0000000000..5962d35786 --- /dev/null +++ b/l10n_ch_qr_bill_scan/readme/README.rst @@ -0,0 +1,55 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +========================== +Switzerland - QR bill Scan +========================== + +Import Vendor bills with a scanner or with a pdf/png file. + + +Usage +===== + +Launch wizard XY + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/125/10.0 + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed feedback `here `_. + + +Credits +======= + +Contributors +------------ + +* Yannick Vaucher + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit http://odoo-community.org. diff --git a/l10n_ch_qr_bill_scan/tests/__init__.py b/l10n_ch_qr_bill_scan/tests/__init__.py new file mode 100644 index 0000000000..46ce7a0e81 --- /dev/null +++ b/l10n_ch_qr_bill_scan/tests/__init__.py @@ -0,0 +1 @@ +from . import test_scan_qrbill diff --git a/l10n_ch_qr_bill_scan/tests/data/QR-bill-000111.pdf b/l10n_ch_qr_bill_scan/tests/data/QR-bill-000111.pdf new file mode 100644 index 0000000000..89c7883671 Binary files /dev/null and b/l10n_ch_qr_bill_scan/tests/data/QR-bill-000111.pdf differ diff --git a/l10n_ch_qr_bill_scan/tests/data/qrbill.png b/l10n_ch_qr_bill_scan/tests/data/qrbill.png new file mode 100644 index 0000000000..324a4bc10a Binary files /dev/null and b/l10n_ch_qr_bill_scan/tests/data/qrbill.png differ diff --git a/l10n_ch_qr_bill_scan/tests/test_scan_qrbill.py b/l10n_ch_qr_bill_scan/tests/test_scan_qrbill.py new file mode 100644 index 0000000000..c2615c93d5 --- /dev/null +++ b/l10n_ch_qr_bill_scan/tests/test_scan_qrbill.py @@ -0,0 +1,266 @@ +# Copyright 2020 Camptocamp (http://www.camptocamp.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import base64 +import time + +from odoo.addons.account.tests.account_test_classes import AccountingTestCase + +from odoo.modules.module import get_resource_path +from odoo import tools + + +class TestScanQRBill(AccountingTestCase): + + def setUp(self): + super().setUp() + self.env.user.company_id.invoice_import_create_bank_account = True + self.supplier = self.env['res.partner'].create( + { + "name": "Camptocamp", + "street": "EPFL Innovation Park", + "street2": "Bldg A", + "zip": "1015", + "city": "Lausanne", + "country_id": self.env.ref("base.ch").id, + } + ) + self.supplier.supplier_rank = 1 + + self.expense_account = self.env["account.account"].create( + { + "code": "612AII", + "name": "expense account invoice import", + "user_type_id": self.env.ref("account.data_account_type_expenses").id, + } + ) + self.env["account.invoice.import.config"].create({ + "name": "Camptocamp - one line no product", + "partner_id": self.supplier.id, + "invoice_line_method": "1line_no_product", + "account_id": self.expense_account.id, + }) + + def import_invoice_file(self, file_path, file_name): + """ Import a file of a vendor bill """ + with tools.file_open(file_path, 'rb') as f: + invoice_file = base64.b64encode(f.read()) + wiz = self.env["account.invoice.import"].create({}) + wiz.invoice_file = invoice_file + wiz.invoice_filename = file_name + res = wiz.import_invoice() + invoice = self.env['account.move'].browse(res['res_id']) + return invoice + + def import_invoice_scan(self, invoice_scan): + """ Import scanned data from a vendor bill """ + wiz = self.env["account.invoice.import"].create({}) + wiz.invoice_scan = invoice_scan + res = wiz.import_invoice() + if 'res_id' in res: + invoice = self.env['account.move'].browse(res['res_id']) + return invoice + + def test_scan_QR_free_ref(self): + scan_data = ( + "SPC\n" + "0200\n" + "1\n" + "CH4431999123000889012\n" + "S\n" + "Camptocamp\n" + "EPFL Innovation Park\n" + "Bldg A\n" + "1015\n" + "Lausanne\n" + "CH\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "1949.75\n" + "CHF\n" + "S\n" + "Your company\n" + "Bahnhofplatz\n" + "10\n" + "3011\n" + "Bern\n" + "CH\n" + "NON\n" + "\n" + "Instruction of 15.09.2019\n" + "EPD\n" + "\n" + "\n" + "" + ) + invoice = self.import_invoice_scan(scan_data) + + self.assertEqual(invoice.partner_id, self.supplier) + self.assertFalse(invoice.invoice_payment_ref) + self.assertEqual(invoice.state, "draft") + iban = invoice.invoice_partner_bank_id.acc_number + self.assertEqual(iban, "CH44 3199 9123 0008 8901 2") + self.assertEqual(invoice.amount_total, 1949.75) + + def test_scan_QR_QRR(self): + scan_data = ( + "SPC\n" + "0200\n" + "1\n" + "CH4431999123000889012\n" + "S\n" + "Camptocamp\n" + "EPFL Innovation Park\n" + "Bldg A\n" + "1015\n" + "Lausanne\n" + "CH\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "1949.75\n" + "CHF\n" + "S\n" + "Your company\n" + "Bahnhofplatz\n" + "10\n" + "3011\n" + "Bern\n" + "CH\n" + "QRR\n" + "210000000003139471430009017\n" + "Instruction of 15.09.2019\n" + "EPD\n" + "\n" + "\n" + "" + ) + invoice = self.import_invoice_scan(scan_data) + + self.assertEqual(invoice.partner_id, self.supplier) + self.assertEqual(invoice.invoice_payment_ref, "210000000003139471430009017") + self.assertEqual(invoice.state, "draft") + iban = invoice.invoice_partner_bank_id.acc_number + # XXX we should check this is a qr_iban + self.assertEqual(iban, "CH44 3199 9123 0008 8901 2") + self.assertEqual(invoice.amount_total, 1949.75) + + def test_scan_QR_CF(self): + scan_data = ( + "SPC\n" + "0200\n" + "1\n" + "CH4431999123000889012\n" + "S\n" + "Camptocamp\n" + "EPFL Innovation Park\n" + "Bldg A\n" + "1015\n" + "Lausanne\n" + "CH\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "1949.75\n" + "CHF\n" + "S\n" + "Your company\n" + "Bahnhofplatz\n" + "10\n" + "3011\n" + "Bern\n" + "CH\n" + "SCOR\n" + "RF18539007547034\n" + "Instruction of 15.09.2019\n" + "EPD\n" + "\n" + "\n" + "" + ) + invoice = self.import_invoice_scan(scan_data) + + self.assertEqual(invoice.partner_id, self.supplier) + self.assertEqual(invoice.invoice_payment_ref, "RF18539007547034") + self.assertEqual(invoice.state, "draft") + iban = invoice.invoice_partner_bank_id.acc_number + self.assertEqual(iban, "CH44 3199 9123 0008 8901 2") + self.assertEqual(invoice.amount_total, 1949.75) + + def test_scan_new_partner(self): + scan_data = ( + "SPC\n" + "0200\n" + "1\n" + "CH4431999123000889012\n" + "S\n" + "New Vendor\n" + "EPFL Innovation Park\n" + "Bldg Z\n" + "1015\n" + "Lausanne\n" + "CH\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "1949.75\n" + "CHF\n" + "S\n" + "Your company\n" + "Bahnhofplatz\n" + "10\n" + "3011\n" + "Bern\n" + "CH\n" + "SCOR\n" + "RF18539007547034\n" + "Instruction of 15.09.2019\n" + "EPD\n" + "\n" + "\n" + "" + ) + invoice = self.import_invoice_scan(scan_data) + + self.assertEqual(invoice.partner_id, self.supplier) + self.assertEqual(invoice.invoice_payment_ref, "RF18539007547034") + self.assertEqual(invoice.state, "draft") + iban = invoice.invoice_partner_bank_id.acc_number + self.assertEqual(iban, "CH44 3199 9123 0008 8901 2") + self.assertEqual(invoice.amount_total, 1949.75) + + def test_scan_QR_swico(self): + self.assertTrue(False) + + def test_scan_QR_wrong_swico(self): + self.assertTrue(False) + + def test_scan_giberish(self): + self.import_invoice_scan("oeashueoa4254") + self.assertTrue(False) + + def test_import_QR_pdf(self): + invoice_fp = get_resource_path('l10n_ch_qr_bill_scan', 'tests', 'data', 'QR-bill-000111.pdf') + res = self.import_invoice_file(invoice_fp, "bill.pdf") + self.assertTrue(False) + + def test_import_QR_png(self): + invoice_fp = get_resource_path('l10n_ch_qr_bill_scan', 'tests', 'data', 'qrbill.png') + res = self.import_invoice_file(invoice_fp, "bill.png") + self.assertTrue(False) diff --git a/l10n_ch_qr_bill_scan/wizard/__init__.py b/l10n_ch_qr_bill_scan/wizard/__init__.py new file mode 100644 index 0000000000..9817a01c22 --- /dev/null +++ b/l10n_ch_qr_bill_scan/wizard/__init__.py @@ -0,0 +1 @@ +from . import account_invoice_import diff --git a/l10n_ch_qr_bill_scan/wizard/account_invoice_import.py b/l10n_ch_qr_bill_scan/wizard/account_invoice_import.py new file mode 100644 index 0000000000..bfec5589f0 --- /dev/null +++ b/l10n_ch_qr_bill_scan/wizard/account_invoice_import.py @@ -0,0 +1,156 @@ +# Copyright 2020 Camptocamp +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import logging +import mimetypes +from datetime import datetime + +from pyzbar.pyzbar import decode + +import json +from PIL import Image +from io import BytesIO + + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools import config, float_compare, float_is_zero, float_round +import odoo.addons.decimal_precision as dp + + +# Positions +QR_IBAN = 3 +QR_CREDITOR = 4 # Till 10 +QR_AMOUNT = 18 +QR_CURRENCY = 19 +QR_REF = 28 +QR_MSG = 29 +QR_BILL_INFO = 31 + +logger = logging.getLogger(__name__) + + +class AccountInvoiceImport(models.TransientModel): + _inherit = "account.invoice.import" + + invoice_scan = fields.Text(string="Scan of the invoice") + invoice_file = fields.Binary(string="PDF, PNG or XML Invoice", required=False) + + state = fields.Selection( + selection_add=[ + ("select-partner", "Select partner"), + ], + default="import", + ) + + partner_name = fields.Char("Name", readonly=True) + partner_street = fields.Char("Street", readonly=True) + partner_zip = fields.Char("ZIP", readonly=True) + partner_city = fields.Char("City", readonly=True) + partner_country_id = fields.Many2one(string="Country", comodel_name="res.country", readonly=True) + + def get_parsed_invoice(self): + if self.invoice_scan: + return self.parse_qrbill(self.invoice_scan) + return super().get_parsed_invoice() + + @api.model + def _get_qr_address(self, address_lines): + adr_type, name, adr_line1, adr_line2, zzip, city, country = address_lines + address = { + "name": name, + "country_code": country, + } + if adr_type == "S": + address["street"] = " ".join([adr_line1, adr_line2]) + address["zip"] = zzip + address["city"] = city + else: + address["street"] = adr_line1 + address["zip"] = adr_line2.split(" ")[0] + address["city"] = adr_line2.split(" ")[1] + + return address + + @api.model + def _parse_billing_info(self, bill_info): + # TODO not implemented + return {} + + @api.model + def parse_qrbill(self, qr_data): + qr_data = qr_data.split("\n") + parsed_inv = { + "iban": qr_data[QR_IBAN], + "partner": self._get_qr_address(qr_data[QR_CREDITOR:QR_CREDITOR + 7]), + "amount_total": float(qr_data[QR_AMOUNT]), + "currency": { + "iso": qr_data[QR_CURRENCY], + }, + "invoice_number": qr_data[QR_REF], + "description": qr_data[QR_MSG], + } + parsed_inv.update(self._parse_billing_info(qr_data[QR_BILL_INFO])) + # pre_process_parsed_inv() will be called again a second time, + # but it's OK + pp_parsed_inv = self.pre_process_parsed_inv(parsed_inv) + return pp_parsed_inv + + + @api.model + def parse_invoice(self, invoice_file_b64, invoice_filename): + assert invoice_file_b64, "No invoice file" + logger.debug("Detect PNG format for invoice %s", invoice_filename) + file_data = base64.b64decode(invoice_file_b64) + filetype = mimetypes.guess_type(invoice_filename) + logger.debug("Invoice mimetype: %s", filetype) + if filetype and filetype[0] in ["application/png"]: + parsed_inv = self.parse_png_invoice(file_data) + else: + super().parse_invoice(invoice_file_b64, invoice_filename) + + if "attachments" not in parsed_inv: + parsed_inv["attachments"] = {} + parsed_inv["attachments"][invoice_filename] = invoice_file_b64 + # pre_process_parsed_inv() will be called again a second time, + # but it's OK + pp_parsed_inv = self.pre_process_parsed_inv(parsed_inv) + return pp_parsed_inv + + def parse_pdf_invoice(self, file_data): + pdf_as_image = '' # TODO + + qr_data = decode(Image.open(pdf_as_image)) # TODO + if True: # TODO + return self.parse_qrbill(qr_data) + else: + return super().parse_pdf_invoice(file_data) + + def parse_png_invoice(self, file_data): + qr_data = decode(Image.open(file_data)) + if True: # TODO + return self.parse_qrrbill(qr_data) + else: + return super().parse_pdf_invoice(file_data) + + def _hook_no_partner_found(self, partner_dict): + """Switch wizard to partner creation. + """ + country = self.env["res.country"].search( + [("code", "=", partner_dict["country_code"])], limit=1) + wiz_vals = { + "state": "select-partner", + "partner_name": partner_dict["name"], + "partner_street": partner_dict["street"], + "partner_zip": partner_dict["zip"], + "partner_city": partner_dict["city"], + "partner_country_id": country.id, + } + act_window = self.env["ir.actions.act_window"] + action = act_window.for_xml_id( + "account_invoice_import", "account_invoice_import_action" + ) + action["res_id"] = self.id + self.write(wiz_vals) + return action diff --git a/l10n_ch_qr_bill_scan/wizard/account_invoice_import_view.xml b/l10n_ch_qr_bill_scan/wizard/account_invoice_import_view.xml new file mode 100644 index 0000000000..cc01a3cf50 --- /dev/null +++ b/l10n_ch_qr_bill_scan/wizard/account_invoice_import_view.xml @@ -0,0 +1,74 @@ + + + + + Vendors + ir.actions.act_window + res.partner + [] + form + new + {'res_partner_search_mode': 'supplier', 'default_is_company': True} + + +

+ Create a new vendor in your address book +

+ Odoo helps you easily track all activities related to a vendor. +

+
+
+ + account.invoice.import + + + +
+

Scan a QR-bill code from a supplier invoice

+
+ + + +
+

OR

+
+
+

Partner was not found or is not set as a supplier. Please create or select the partner before continuing

+
+
+ + {'default_name': partner_name, 'default_street': partner_street, 'default_zip': partner_zip, 'default_city': partner_city, 'default_country_id': partner_country_id} + config,update,update-from-invoice,select-partner + {'readonly': [('state', '!=', 'select-partner')], 'required': [('state', '=', 'select-partner')]} + + + + + + + + + + config,update-from-invoice,update,select-partner + {'required': [('state', 'in', ('config', 'update-from-invoice', 'update', 'select-partner'))]} + {'default_partner_id': partner_id} + + +
+
+