diff --git a/dev_odex30_accounting/.gitignore b/dev_odex30_accounting/.gitignore new file mode 100644 index 0000000..773db4d --- /dev/null +++ b/dev_odex30_accounting/.gitignore @@ -0,0 +1,52 @@ +# sphinx build directories +_build/ + +# dotfiles +.* +!.gitignore +!.github +!.mailmap +!.weblate.json +# compiled python files +*.py[co] +__pycache__/ +# setup.py egg_info +*.egg-info +# emacs backup files +*~ +# hg stuff +*.orig +status +# odoo filestore +odoo/filestore +# maintenance migration scripts +odoo/addons/base/maintenance +# window installation config file +/odoo.conf + +# generated for windows installer? +install/win32/*.bat +install/win32/meta.py + +# needed only when building for win32 +setup/win32/static/less/ +setup/win32/static/wkhtmltopdf/ +setup/win32/static/postgresql*.exe + +# js tooling +node_modules +jsconfig.json +tsconfig.json +package-lock.json +package.json +.husky + +# various virtualenv +/bin/ +/build/ +/dist/ +/include/ +/lib/ +/man/ +/share/ +/src/ diff --git a/dev_odex30_accounting/iap_extract/__init__.py b/dev_odex30_accounting/iap_extract/__init__.py new file mode 100644 index 0000000..a9e3372 --- /dev/null +++ b/dev_odex30_accounting/iap_extract/__init__.py @@ -0,0 +1,2 @@ + +from . import models diff --git a/dev_odex30_accounting/iap_extract/__manifest__.py b/dev_odex30_accounting/iap_extract/__manifest__.py new file mode 100644 index 0000000..321bd4c --- /dev/null +++ b/dev_odex30_accounting/iap_extract/__manifest__.py @@ -0,0 +1,22 @@ + +{ + 'name': 'Iap Extract', + 'version': '1.0', + 'category': 'Hidden/Tools', + 'website': 'http://exp-sa.com', + 'author': 'Expert Co. Ltd.', + 'summary': 'Common module for requesting data from the extract server', + 'depends': ['base', 'iap', 'mail', 'iap_mail'], + 'data': [ + 'data/config_parameter_endpoint.xml', + 'data/iap_service_data.xml', + 'data/mail_template_data.xml', + ], + 'auto_install': True, + 'license': 'OEEL-1', + 'assets': { + 'web.assets_backend': [ + 'iap_extract/static/src/components/**/*', + ] + } +} diff --git a/dev_odex30_accounting/iap_extract/data/config_parameter_endpoint.xml b/dev_odex30_accounting/iap_extract/data/config_parameter_endpoint.xml new file mode 100644 index 0000000..dae43ce --- /dev/null +++ b/dev_odex30_accounting/iap_extract/data/config_parameter_endpoint.xml @@ -0,0 +1,9 @@ + + + + + iap_extract_endpoint + https://extract.api.odoo.com + + + diff --git a/dev_odex30_accounting/iap_extract/data/iap_service_data.xml b/dev_odex30_accounting/iap_extract/data/iap_service_data.xml new file mode 100644 index 0000000..856a7bb --- /dev/null +++ b/dev_odex30_accounting/iap_extract/data/iap_service_data.xml @@ -0,0 +1,12 @@ + + + + + Document Digitization + invoice_ocr + Digitize your scanned or PDF vendor bills, expenses and resumes with OCR and Artificial Intelligence. + Documents + True + + + diff --git a/dev_odex30_accounting/iap_extract/data/mail_template_data.xml b/dev_odex30_accounting/iap_extract/data/mail_template_data.xml new file mode 100644 index 0000000..9aa3749 --- /dev/null +++ b/dev_odex30_accounting/iap_extract/data/mail_template_data.xml @@ -0,0 +1,20 @@ + + + + + IAP Extract Notification + iap@odoo.com + iap@odoo.com + IAP Extract Notification + + +
+

Dear,

+

There are no more credits on your IAP OCR account.
+ You can charge your IAP OCR account in the settings page.

+

Best regards,

+

Exp S.A.

+
+
+
+
diff --git a/dev_odex30_accounting/iap_extract/data/neutralize.sql b/dev_odex30_accounting/iap_extract/data/neutralize.sql new file mode 100644 index 0000000..a5d5e4f --- /dev/null +++ b/dev_odex30_accounting/iap_extract/data/neutralize.sql @@ -0,0 +1 @@ +DELETE FROM ir_config_parameter WHERE key = 'iap_extract_endpoint'; \ No newline at end of file diff --git a/dev_odex30_accounting/iap_extract/i18n/ar.po b/dev_odex30_accounting/iap_extract/i18n/ar.po new file mode 100644 index 0000000..ba3b261 --- /dev/null +++ b/dev_odex30_accounting/iap_extract/i18n/ar.po @@ -0,0 +1,403 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * iap_extract +# +# Translators: +# Wil Odoo, 2024 +# Malaz Abuidris , 2025 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-12-19 09:53+0000\n" +"PO-Revision-Date: 2024-09-25 09:44+0000\n" +"Last-Translator: Malaz Abuidris , 2025\n" +"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#. module: iap_extract +#: model:mail.template,body_html:iap_extract.iap_extract_no_credit +msgid "" +"
\n" +"

Dear,

\n" +"

There are no more credits on your IAP OCR account.
\n" +" You can charge your IAP OCR account in the settings page.

\n" +"

Best regards,

\n" +"

Odoo S.A.

\n" +"
" +msgstr "" +"
\n" +"

عزيزنا،

\n" +"

لم يتبق لديك رصيد في حساب عمليات الشراء داخل التطبيق (IAP) لـ OCR الخاص بحسابك.
\n" +" حساب عمليات الشراء داخل التطبيق (IAP) لـ OCR الخاص بك في صفحة الإعدادات.

\n" +"

مع أطيب التحيات،

\n" +"

Odoo S.A.

\n" +"
" + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__message_needaction +msgid "Action Needed" +msgstr "إجراء مطلوب" + +#. module: iap_extract +#. odoo-javascript +#: code:addons/iap_extract/static/src/components/status_header/status.xml:0 +msgid "" +"All fields will be automatically populated by Artificial Intelligence, it " +"might take 5 seconds." +msgstr "" +"ستتم تعبئة كافة الحقول تلقائياً بواسطة الذكاء الاصطناعي، وقد يستغرق الأمر 5 " +"ثوانٍ. " + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +#: model:ir.model.fields.selection,name:iap_extract.selection__account_bank_statement__extract_state__error_status +#: model:ir.model.fields.selection,name:iap_extract.selection__account_move__extract_state__error_status +#: model:ir.model.fields.selection,name:iap_extract.selection__extract_mixin__extract_state__error_status +#: model:ir.model.fields.selection,name:iap_extract.selection__hr_candidate__extract_state__error_status +#: model:ir.model.fields.selection,name:iap_extract.selection__hr_expense__extract_state__error_status +msgid "An error occurred" +msgstr "حدث خطأ ما " + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "An error occurred during the upload" +msgstr "حدث خطأ أثناء عملية الرفع " + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__message_attachment_count +msgid "Attachment Count" +msgstr "عدد المرفقات" + +#. module: iap_extract +#: model:ir.model,name:iap_extract.model_extract_mixin +msgid "Base class to extract data from documents" +msgstr "الفئة الأساسية لاستخلاص المستندات منها " + +#. module: iap_extract +#. odoo-javascript +#: code:addons/iap_extract/static/src/components/status_header/status.xml:0 +msgid "Buy credits" +msgstr "شراء رصيد" + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__extract_can_show_send_button +msgid "Can show the ocr send button" +msgstr "بإمكانه عرض زر إرسال ملف بتمييز الرموز ضوئياً " + +#. module: iap_extract +#: model:ir.model.fields.selection,name:iap_extract.selection__account_bank_statement__extract_state__done +#: model:ir.model.fields.selection,name:iap_extract.selection__account_move__extract_state__done +#: model:ir.model.fields.selection,name:iap_extract.selection__extract_mixin__extract_state__done +#: model:ir.model.fields.selection,name:iap_extract.selection__hr_candidate__extract_state__done +#: model:ir.model.fields.selection,name:iap_extract.selection__hr_expense__extract_state__done +msgid "Completed flow" +msgstr "سير العمل المكتمل " + +#. module: iap_extract +#: model:iap.service,description:iap_extract.iap_service_ocr +msgid "" +"Digitize your scanned or PDF vendor bills, expenses and resumes with OCR and" +" Artificial Intelligence." +msgstr "" +"قم برقمنة فواتير المورِّدين والنفقات والسير الذاتية الممسوحة ضوئياً أو بصيغة" +" PDF باستخدام تقنية التعرف الضوئي على الأحرف (OCR) والذكاء الاصطناعي. " + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "Document is being digitized" +msgstr "تتم رقمنة المستند " + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "Document sent for digitization" +msgstr "تم إرسال المستند من أجل الرقمنة " + +#. module: iap_extract +#. odoo-javascript +#: code:addons/iap_extract/static/src/components/status_header/status.xml:0 +msgid "Document successfully parsed. Please refresh." +msgstr "تم تحليل المستند بنجاح. يرجى التحديث. " + +#. module: iap_extract +#: model:iap.service,unit_name:iap_extract.iap_service_ocr +msgid "Documents" +msgstr "المستندات" + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "Documents sent for digitization" +msgstr "تم إرسال المستندات من أجل الرقمنة " + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__extract_error_message +msgid "Error message" +msgstr "رسالة خطأ" + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__extract_state_processed +msgid "Extract State Processed" +msgstr "تمت معالجة حالة الاستخلاص " + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__extract_state +msgid "Extract state" +msgstr "استخلاص حالة " + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__extract_status +msgid "Extract status" +msgstr "حالة الاستخلاص " + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__message_follower_ids +msgid "Followers" +msgstr "المتابعين" + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__message_partner_ids +msgid "Followers (Partners)" +msgstr "المتابعين (الشركاء) " + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__has_message +msgid "Has Message" +msgstr "يحتوي على رسالة " + +#. module: iap_extract +#: model:mail.template,name:iap_extract.iap_extract_no_credit +#: model:mail.template,subject:iap_extract.iap_extract_no_credit +msgid "IAP Extract Notification" +msgstr "إشعار استخلاص IAP " + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__extract_document_uuid +msgid "ID of the request to IAP-OCR" +msgstr "مُعرف طلب IAP-OCR" + +#. module: iap_extract +#: model:ir.model.fields,help:iap_extract.field_extract_mixin__message_needaction +msgid "If checked, new messages require your attention." +msgstr "إذا كان محددًا، فهناك رسائل جديدة عليك رؤيتها. " + +#. module: iap_extract +#: model:ir.model.fields,help:iap_extract.field_extract_mixin__message_has_error +msgid "If checked, some messages have a delivery error." +msgstr "إذا كان محددًا، فقد حدث خطأ في تسليم بعض الرسائل." + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "Invalid PDF (Conversion error)" +msgstr "ملف PDF غير صالح (خطأ في التحويل) " + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "Invalid PDF (Unable to get page count)" +msgstr "ملف PDF غير صالح (لم نتمكن من الحصول على عدد الصفحات) " + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__message_is_follower +msgid "Is Follower" +msgstr "متابع" + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__is_in_extractable_state +msgid "Is In Extractable State" +msgstr "في حالة قابلة للاستخلاص " + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__message_main_attachment_id +msgid "Main Attachment" +msgstr "المرفق الرئيسي" + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__message_has_error +msgid "Message Delivery error" +msgstr "خطأ في تسليم الرسائل" + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__message_ids +msgid "Messages" +msgstr "الرسائل" + +#. module: iap_extract +#: model:ir.model.fields.selection,name:iap_extract.selection__account_bank_statement__extract_state__no_extract_requested +#: model:ir.model.fields.selection,name:iap_extract.selection__account_move__extract_state__no_extract_requested +#: model:ir.model.fields.selection,name:iap_extract.selection__extract_mixin__extract_state__no_extract_requested +#: model:ir.model.fields.selection,name:iap_extract.selection__hr_candidate__extract_state__no_extract_requested +#: model:ir.model.fields.selection,name:iap_extract.selection__hr_expense__extract_state__no_extract_requested +msgid "No extract requested" +msgstr "لم يتم طلب استخلاص " + +#. module: iap_extract +#: model:ir.model.fields.selection,name:iap_extract.selection__account_bank_statement__extract_state__not_enough_credit +#: model:ir.model.fields.selection,name:iap_extract.selection__account_move__extract_state__not_enough_credit +#: model:ir.model.fields.selection,name:iap_extract.selection__extract_mixin__extract_state__not_enough_credit +#: model:ir.model.fields.selection,name:iap_extract.selection__hr_candidate__extract_state__not_enough_credit +#: model:ir.model.fields.selection,name:iap_extract.selection__hr_expense__extract_state__not_enough_credit +msgid "Not enough credits" +msgstr "لا يوجد رصيد كاف" + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "Not enough credits for data extraction" +msgstr "ليس هناك رصيد كافٍ لاستخلاص البيانات " + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__message_needaction_counter +msgid "Number of Actions" +msgstr "عدد الإجراءات" + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__message_has_error_counter +msgid "Number of errors" +msgstr "عدد الأخطاء " + +#. module: iap_extract +#: model:ir.model.fields,help:iap_extract.field_extract_mixin__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "عدد الرسائل التي تتطلب اتخاذ إجراء" + +#. module: iap_extract +#: model:ir.model.fields,help:iap_extract.field_extract_mixin__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "عدد الرسائل الحادث بها خطأ في التسليم" + +#. module: iap_extract +#: model:ir.model.fields,field_description:iap_extract.field_extract_mixin__rating_ids +msgid "Ratings" +msgstr "التقييمات " + +#. module: iap_extract +#. odoo-javascript +#: code:addons/iap_extract/static/src/components/status_header/status.xml:0 +msgid "Refresh" +msgstr "تحديث " + +#. module: iap_extract +#. odoo-javascript +#: code:addons/iap_extract/static/src/components/status_header/status.xml:0 +msgid "Retry" +msgstr "إعادة المحاولة" + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "Server is currently under maintenance. Please retry later" +msgstr "الخادم قيد الصيانة حالياً. الرجاء إعادة المحاولة لاحقاً " + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "Server not available. Please retry later" +msgstr "الخاتم غير متاح. الرجاء إعادة المحاولة مجدداً " + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "Some documents were skipped as they were already digitized" +msgstr "تم تخطي بعض المستندات حيث إنه تتم رقمنتها " + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "" +"The 'invoice_ocr' IAP account token is invalid. Please delete it to let Odoo" +" generate a new one or fill it with a valid token." +msgstr "" +"رمز حساب الوكيل المدرك للهوية (IAP) 'invoice_ocr' غير صالح. قم بحذه رجاءً " +"حتى يتمكن أودو من إنشاء واحد جديد لتزويده برمز صالح. " + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "The document could not be found" +msgstr "لم يتم العثور على المستند " + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "The document has been rejected because it is too small" +msgstr "لقد تم رفض المستند لصغر حجمه " + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "The selected documents are already digitized" +msgstr "المستندات المحددة قد تمت رقمنتها بالفعل " + +#. module: iap_extract +#: model:ir.model.fields.selection,name:iap_extract.selection__account_bank_statement__extract_state__to_validate +#: model:ir.model.fields.selection,name:iap_extract.selection__account_move__extract_state__to_validate +#: model:ir.model.fields.selection,name:iap_extract.selection__extract_mixin__extract_state__to_validate +#: model:ir.model.fields.selection,name:iap_extract.selection__hr_candidate__extract_state__to_validate +#: model:ir.model.fields.selection,name:iap_extract.selection__hr_expense__extract_state__to_validate +msgid "To validate" +msgstr "بانتظار التصديق " + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "Unsupported image format" +msgstr "صيغة الصورة غير مدعومة " + +#. module: iap_extract +#: model:ir.model.fields.selection,name:iap_extract.selection__account_bank_statement__extract_state__waiting_extraction +#: model:ir.model.fields.selection,name:iap_extract.selection__account_move__extract_state__waiting_extraction +#: model:ir.model.fields.selection,name:iap_extract.selection__extract_mixin__extract_state__waiting_extraction +#: model:ir.model.fields.selection,name:iap_extract.selection__hr_candidate__extract_state__waiting_extraction +#: model:ir.model.fields.selection,name:iap_extract.selection__hr_expense__extract_state__waiting_extraction +msgid "Waiting extraction" +msgstr "بانتظار الاستخلاص " + +#. module: iap_extract +#: model:ir.model.fields.selection,name:iap_extract.selection__account_bank_statement__extract_state__waiting_validation +#: model:ir.model.fields.selection,name:iap_extract.selection__account_move__extract_state__waiting_validation +#: model:ir.model.fields.selection,name:iap_extract.selection__extract_mixin__extract_state__waiting_validation +#: model:ir.model.fields.selection,name:iap_extract.selection__hr_candidate__extract_state__waiting_validation +#: model:ir.model.fields.selection,name:iap_extract.selection__hr_expense__extract_state__waiting_validation +msgid "Waiting validation" +msgstr "بانتظار التصديق " + +#. module: iap_extract +#. odoo-javascript +#: code:addons/iap_extract/static/src/components/status_header/status.xml:0 +msgid "You don't have enough credit to extract data from your document." +msgstr "ليس لديك الرصيد الكافي لاستخلاص البيانات من المستند. " + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "" +"Your PDF file is protected by a password. The OCR can't extract data from it" +msgstr "" +"ملف PDF الخاص بك محمي بكلمة مرور. لن يستطيع التعرف البصري على الأحرف استخلاص" +" البيانات منه " + +#. module: iap_extract +#. odoo-python +#: code:addons/iap_extract/models/extract_mixin.py:0 +msgid "Your document contains too many pages" +msgstr "يحتوي مستندك على عدد كبير من الصفحات " + +#. module: iap_extract +#: model:ir.model.fields.selection,name:iap_extract.selection__account_bank_statement__extract_state__extract_not_ready +#: model:ir.model.fields.selection,name:iap_extract.selection__account_move__extract_state__extract_not_ready +#: model:ir.model.fields.selection,name:iap_extract.selection__extract_mixin__extract_state__extract_not_ready +#: model:ir.model.fields.selection,name:iap_extract.selection__hr_candidate__extract_state__extract_not_ready +#: model:ir.model.fields.selection,name:iap_extract.selection__hr_expense__extract_state__extract_not_ready +msgid "waiting extraction, but it is not ready" +msgstr "بانتظار الاستخلاص، ولكنها ليست جاهزة " diff --git a/dev_odex30_accounting/iap_extract/models/__init__.py b/dev_odex30_accounting/iap_extract/models/__init__.py new file mode 100644 index 0000000..3496a3e --- /dev/null +++ b/dev_odex30_accounting/iap_extract/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import extract_mixin diff --git a/dev_odex30_accounting/iap_extract/models/extract_mixin.py b/dev_odex30_accounting/iap_extract/models/extract_mixin.py new file mode 100644 index 0000000..3de68bb --- /dev/null +++ b/dev_odex30_accounting/iap_extract/models/extract_mixin.py @@ -0,0 +1,384 @@ + +import logging + +from dateutil.relativedelta import relativedelta +from psycopg2 import IntegrityError, OperationalError + +from odoo import api, fields, models +from odoo.exceptions import AccessError, UserError +from odoo.tools import _, LazyTranslate + +_lt = LazyTranslate(__name__) +_logger = logging.getLogger(__name__) + +ERROR_MESSAGES = { + 'error_internal': _lt("An error occurred"), + 'error_document_not_found': _lt("The document could not be found"), + 'error_unsupported_format': _lt("Unsupported image format"), + 'error_no_connection': _lt("Server not available. Please retry later"), + 'error_maintenance': _lt("Server is currently under maintenance. Please retry later"), + 'error_password_protected': _lt("Your PDF file is protected by a password. The OCR can't extract data from it"), + 'error_too_many_pages': _lt("Your document contains too many pages"), + 'error_invalid_account_token': _lt( + "The 'invoice_ocr' IAP account token is invalid. " + "Please delete it to let Odoo generate a new one or fill it with a valid token."), + 'error_unsupported_size': _lt("The document has been rejected because it is too small"), + 'error_no_page_count': _lt("Invalid PDF (Unable to get page count)"), + 'error_pdf_conversion_to_images': _lt("Invalid PDF (Conversion error)"), +} + + +class ExtractMixin(models.AbstractModel): + _name = 'extract.mixin' + _inherit = 'mail.thread.main.attachment' + _description = 'Base class to extract data from documents' + + extract_state = fields.Selection([ + ('no_extract_requested', 'No extract requested'), + ('not_enough_credit', 'Not enough credits'), + ('error_status', 'An error occurred'), + ('waiting_extraction', 'Waiting extraction'), + ('extract_not_ready', 'waiting extraction, but it is not ready'), + ('waiting_validation', 'Waiting validation'), + ('to_validate', 'To validate'), + ('done', 'Completed flow'), + ], + 'Extract state', default='no_extract_requested', required=False, copy=False) + extract_status = fields.Char('Extract status', copy=False) + extract_error_message = fields.Text('Error message', compute='_compute_error_message') + extract_document_uuid = fields.Char('ID of the request to IAP-OCR', copy=False, readonly=True) + extract_can_show_send_button = fields.Boolean('Can show the ocr send button', compute='_compute_show_send_button') + is_in_extractable_state = fields.Boolean(compute='_compute_is_in_extractable_state', store=True) + extract_state_processed = fields.Boolean(compute='_compute_extract_state_processed', store=True) + + @api.depends('extract_status') + def _compute_error_message(self): + for record in self: + if record.extract_status in ('success', 'processing'): + record.extract_error_message = '' + else: + lazy_message = ERROR_MESSAGES.get( + record.extract_status, ERROR_MESSAGES['error_internal'] + ) + record.extract_error_message = self.env._(lazy_message) # pylint: disable=gettext-variable + + @api.depends('extract_state') + def _compute_extract_state_processed(self): + for record in self: + record.extract_state_processed = record.extract_state == 'waiting_extraction' + + @api.depends('is_in_extractable_state', 'extract_state', 'message_main_attachment_id') + def _compute_show_send_button(self): + for record in self: + record.extract_can_show_send_button = ( + record._get_ocr_option_can_extract() + and record.message_main_attachment_id + and record.extract_state == 'no_extract_requested' + and record.is_in_extractable_state + ) + + @api.depends() + def _compute_is_in_extractable_state(self): + return None + + def _get_iap_account(self): + if self.company_id: + return self.env['iap.account'].with_context(allowed_company_ids=[self.company_id.id]).get('invoice_ocr') + else: + return self.env['iap.account'].get('invoice_ocr') + + @api.model + def check_all_status(self): + for record in self.search(self._get_to_check_domain()): + record._try_to_check_ocr_status() + + @api.model + def _contact_iap_extract(self, pathinfo, params): + return {} + + @api.model + def _cron_validate(self): + records_to_validate = self.with_context(skip_is_manually_modified=True).search(self._get_validation_domain()) + + for record in records_to_validate: + try: + record._contact_iap_extract( + 'validate', + params={ + 'document_token': record.extract_document_uuid, + 'values': { + field: record._get_validation(field) for field in self._get_validation_fields() + } + } + ) + except AccessError: + pass + + records_to_validate.extract_state = 'done' + return records_to_validate + + def _get_extract_status_channel(self): + return f"extract.mixin.status#{self.extract_document_uuid}" + + @staticmethod + def _get_ocr_selected_value(ocr_results, feature, default=None): + return ocr_results.get(feature, {}).get('selected_value', {}).get('content', default) + + def _safe_upload(self): + + try: + with self.env.cr.savepoint(): + self.with_company(self.company_id)._upload_to_extract() + except Exception as e: + if not isinstance(e, (IntegrityError, OperationalError)): + self.extract_state = 'error_status' + self.extract_status = 'error_internal' + self.env['iap.account']._send_error_notification( + message=self._get_iap_bus_notification_error(), + ) + _logger.warning("Couldn't upload %s with id %d: %s", self._name, self.id, str(e)) + + def _send_batch_for_digitization(self): + for rec in self: + rec._safe_upload() + + def action_send_batch_for_digitization(self): + if any(not document.is_in_extractable_state for document in self): + raise UserError(self._get_user_error_invalid_state_message()) + + documents_to_send = self.filtered( + lambda doc: doc.extract_state in ('no_extract_requested', 'not_enough_credit', 'error_status') + ) + + if not documents_to_send: + self.env['iap.account']._send_status_notification( + message=_('The selected documents are already digitized'), + status='info', + ) + return + + if len(documents_to_send) < len(self): + self.env['iap.account']._send_status_notification( + message=_('Some documents were skipped as they were already digitized'), + status='info', + ) + + documents_to_send._send_batch_for_digitization() + + if len(documents_to_send) == 1: + return { + 'name': _('Document sent for digitization'), + 'type': 'ir.actions.act_window', + 'res_model': self._name, + 'view_mode': 'form', + 'views': [[False, 'form']], + 'res_id': documents_to_send[0].id, + } + return { + 'name': _('Documents sent for digitization'), + 'type': 'ir.actions.act_window', + 'res_model': self._name, + 'view_mode': 'list,form', + 'target': 'current', + 'domain': [('id', 'in', documents_to_send.ids)], + } + + def action_manual_send_for_digitization(self): + + self._upload_to_extract() + return self.extract_state, self.extract_error_message, self.extract_document_uuid + + def buy_credits(self): + url = self.env['iap.account'].get_credits_url(base_url='', service_name='invoice_ocr') + return { + 'type': 'ir.actions.act_url', + 'url': url, + } + + def check_ocr_status(self): + + records_to_check = self.with_context(skip_is_manually_modified=True).filtered(lambda a: a.extract_state in ['waiting_extraction', 'extract_not_ready']) + + for record in records_to_check: + record._check_ocr_status() + + limit = max(0, 20 - len(records_to_check)) + if limit > 0: + records_to_preupdate = self.search([ + ('extract_state', 'in', ['waiting_extraction', 'extract_not_ready']), + ('id', 'not in', records_to_check.ids), + ('is_in_extractable_state', '=', True)], limit=limit) + for record in records_to_preupdate: + record._try_to_check_ocr_status() + + return [(rec.extract_state, rec.extract_error_message) for rec in self] + + def _get_user_infos(self): + user_infos = { + 'user_lang': self.env.user.lang, + 'user_email': self.env.user.email, + } + return user_infos + + def _get_validation(self, field): + return None + + def _upload_to_extract(self): + self.ensure_one() + if not self._get_ocr_option_can_extract(): + return False + attachment = self.message_main_attachment_id + if attachment and self.extract_state in ['no_extract_requested', 'not_enough_credit', 'error_status']: + account_token = self._get_iap_account() + + if not account_token.account_token: + self.extract_state = 'error_status' + self.extract_status = 'error_invalid_account_token' + return + + user_infos = self._get_user_infos() + params = { + 'dbuuid': self.env['ir.config_parameter'].sudo().get_param('database.uuid'), + 'documents': [x.datas.decode('utf-8') for x in attachment], + 'user_infos': user_infos, + 'webhook_url': self._get_webhook_url(), + } + try: + result = self._contact_iap_extract('parse', params=params) + self.extract_status = result['status'] + if result['status'] == 'success': + self.extract_state = 'waiting_extraction' + self.extract_document_uuid = result['document_token'] + if self.env['ir.config_parameter'].sudo().get_param("iap_extract.already_notified", True): + self.env['ir.config_parameter'].sudo().set_param("iap_extract.already_notified", False) + self.env['iap.account']._send_success_notification( + message=self._get_iap_bus_notification_success(), + ) + self.env.user._bus_send("extract_mixin_new_document", { + 'status': self.extract_state, + 'error_message': self.extract_error_message, + 'extract_document_uuid': self.extract_document_uuid, + }) + self._upload_to_extract_success_callback() + elif result['status'] == 'error_no_credit': + self._send_no_credit_notification() + self.extract_state = 'not_enough_credit' + else: + self.extract_state = 'error_status' + _logger.warning( + 'An error occurred during OCR parsing of %s %d. Status: %s', + self._name, self.id, self.extract_status, + ) + except AccessError: + self.extract_state = 'error_status' + self.extract_status = 'error_no_connection' + if self.extract_state == 'error_status': + self.env['iap.account']._send_error_notification( + message=self._get_iap_bus_notification_error(), + ) + + def _send_no_credit_notification(self): + + self.env['iap.account']._send_no_credit_notification( + service_name='invoice_ocr', + title=_("Not enough credits for data extraction"), + ) + + already_notified = self.env['ir.config_parameter'].sudo().get_param("iap_extract.already_notified", True) + if already_notified: + return + try: + mail_template = self.env.ref('iap_extract.iap_extract_no_credit') + except ValueError: + return + iap_account = self._get_iap_account() + if iap_account: + res = self.env['res.users'].search_read([('id', '=', 2)], ['email']) + if res: + email_values = { + 'email_to': res[0]['email'] + } + mail_template.send_mail(iap_account.id, force_send=True, email_values=email_values) + self.env['ir.config_parameter'].sudo().set_param("iap_extract.already_notified", True) + + def _validate_ocr(self): + documents_to_validate = self.filtered(lambda doc: doc.extract_state == 'waiting_validation') + documents_to_validate.extract_state = 'to_validate' + + if documents_to_validate: + ocr_trigger_datetime = fields.Datetime.now() + relativedelta(minutes=self.env.context.get('ocr_trigger_delta', 0)) + self._get_cron_ocr('validate')._trigger(at=ocr_trigger_datetime) + + def _check_ocr_status(self): + self.ensure_one() + self = self.with_context(skip_is_manually_modified=True) # noqa: PLW0642 + result = self._contact_iap_extract('get_result', params={'document_token': self.extract_document_uuid}) + self.extract_status = result['status'] + if result['status'] == 'success': + self.extract_state = 'waiting_validation' + ocr_results = result['results'][0] + self.with_company(self.company_id)._fill_document_with_results(ocr_results) + # Set OdooBot as the author of the tracking message + self._track_set_author(self.env.ref('base.partner_root')) + if 'full_text_annotation' in ocr_results: + self.message_main_attachment_id.index_content = ocr_results['full_text_annotation'] + + elif result['status'] == 'processing': + self.extract_state = 'extract_not_ready' + else: + self.extract_state = 'error_status' + self.env["bus.bus"]._sendone(self._get_extract_status_channel(), "state_change", { + 'status': self.extract_state, + 'error_message': self.extract_error_message, + }) + + def _fill_document_with_results(self, ocr_results): + raise NotImplementedError() + + def _get_cron_ocr(self, ocr_action): + + module_name = self._get_ocr_module_name() + return self.env.ref(f'{module_name}.ir_cron_ocr_{ocr_action}') + + def _get_iap_bus_notification_success(self): + return _("Document is being digitized") + + def _get_iap_bus_notification_error(self): + return _("An error occurred during the upload") + + def _get_ocr_module_name(self): + return 'iap_extract' + + def _get_ocr_option_can_extract(self): + return False + + def _get_to_check_domain(self): + return [('is_in_extractable_state', '=', True), + ('extract_state', 'in', ['waiting_extraction', 'extract_not_ready'])] + + def _get_validation_domain(self): + return [('extract_state', '=', 'to_validate')] + + def _get_validation_fields(self): + return [] + + def _get_webhook_url(self): + baseurl = self.get_base_url() + module_name = self._get_ocr_module_name() + return f'{baseurl}/{module_name}/request_done' + + def _get_user_error_invalid_state_message(self): + + return '' + + def _upload_to_extract_success_callback(self): + return None + + def _try_to_check_ocr_status(self): + self.ensure_one() + try: + with self.env.cr.savepoint(): + self._check_ocr_status() + self.env.cr.commit() + except Exception as e: + _logger.warning("Couldn't check OCR status of %s with id %d: %s", self._name, self.id, str(e)) diff --git a/dev_odex30_accounting/iap_extract/static/src/components/status_header/status.js b/dev_odex30_accounting/iap_extract/static/src/components/status_header/status.js new file mode 100644 index 0000000..2867f7c --- /dev/null +++ b/dev_odex30_accounting/iap_extract/static/src/components/status_header/status.js @@ -0,0 +1,125 @@ +/** @odoo-module **/ + +import { Component, onWillDestroy, onWillStart, onWillUpdateProps, useState } from "@odoo/owl"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; + +import { useService } from "@web/core/utils/hooks"; +import { registry } from "@web/core/registry"; + +const CHECK_OCR_WAIT_DELAY = 5*1000; + +export class StatusHeader extends Component { + static template = "odex30_account_invoice_extract.Status"; + static props = standardFieldProps; + + setup() { + this.state = useState({ + status: this.props.record.data.extract_state, + errorMessage: this.props.record.data.extract_error_message, + retryLoading: false, + checkStatusLoading: false, + }); + this.orm = useService("orm"); + this.action = useService("action"); + this.busService = this.env.services.bus_service; + + onWillStart(() => { + this.busService.subscribe("extract_mixin_new_document", (params) => { + this.state.status = params.status; + this.state.errorMessage = params.error_message; + this.subscribeToChannel(params.extract_document_uuid); + }); + this.busService.subscribe("state_change", ({status, error_message})=> { + this.state.status = status; + this.state.errorMessage = error_message; + }); + this.enableTimeout(); + }); + + onWillDestroy(() => { + this.busService.deleteChannel(this.channelName); + this.state.status = 'no_extract_requested'; + clearTimeout(this.timeoutId); + }); + + onWillUpdateProps((nextProps) => { + if (nextProps.record.id !== this.props.record.id) { + this.state.errorMessage = nextProps.record.data.extract_error_message; + this.state.status = nextProps.record.data.extract_state; + this.subscribeToChannel(nextProps.record.data.extract_document_uuid); + this.enableTimeout(); + } + }); + } + + subscribeToChannel(documentUUID) { + if (!documentUUID) { + return; + } + this.busService.deleteChannel(this.channelName); + this.channelName = `extract.mixin.status#${documentUUID}`; + this.busService.addChannel(this.channelName); + } + + enableTimeout () { + if (!['waiting_extraction', 'extract_not_ready'].includes(this.state.status)) { + return; + } + + clearTimeout(this.timeoutId); + + this.timeoutId = setTimeout(async () => { + if (['waiting_extraction', 'extract_not_ready'].includes(this.state.status)) { + const [status, errorMessage] = (await this.orm.call( + this.props.record.resModel, + "check_ocr_status", + [this.props.record.resId], + {} + ))[0]; + this.state.status = status; + this.state.errorMessage = errorMessage; + } + }, CHECK_OCR_WAIT_DELAY); + } + + async checkOcrStatus() { + this.state.checkStatusLoading = true; + const [status, errorMessage] = (await this.orm.call( + this.props.record.resModel, + "check_ocr_status", + [this.props.record.resId], + {} + ))[0]; + if (status === "waiting_validation") { + await this.refreshPage(); + return; + } + this.state.status = status; + this.state.errorMessage = errorMessage; + this.state.checkStatusLoading = false; + } + + async refreshPage() { + await this.action.switchView("form", { + resId: this.props.record.resId, + resIds: this.props.record.resIds + }); + } + + async buyCredits() { + const actionData = await this.orm.call(this.props.record.resModel, "buy_credits", [this.props.record.resId], {}); + this.action.doAction(actionData); + } + + async retryDigitalization() { + this.state.retryLoading = true; + const [status, errorMessage, documentUUID] = await this.orm.call(this.props.record.resModel, "action_manual_send_for_digitization", [this.props.record.resId], {}); + this.subscribeToChannel(documentUUID); + this.state.status = status; + this.state.errorMessage = errorMessage; + this.state.retryLoading = false; + this.enableTimeout(); + } +} + +registry.category("fields").add("extract_state_header", {component: StatusHeader}); diff --git a/dev_odex30_accounting/iap_extract/static/src/components/status_header/status.xml b/dev_odex30_accounting/iap_extract/static/src/components/status_header/status.xml new file mode 100644 index 0000000..e70a611 --- /dev/null +++ b/dev_odex30_accounting/iap_extract/static/src/components/status_header/status.xml @@ -0,0 +1,37 @@ + + + +
+ + +
+
+ All fields will be automatically populated by Artificial Intelligence, it might take 5 seconds. + +
+
+ You don't have enough credit to extract data from your document. + + +
+
+ Document successfully parsed. Please refresh. + +
+
+
diff --git a/dev_odex30_accounting/iap_extract/tests/__init__.py b/dev_odex30_accounting/iap_extract/tests/__init__.py new file mode 100644 index 0000000..24efd02 --- /dev/null +++ b/dev_odex30_accounting/iap_extract/tests/__init__.py @@ -0,0 +1,2 @@ + +from . import test_extract_mixin diff --git a/dev_odex30_accounting/iap_extract/tests/test_extract_mixin.py b/dev_odex30_accounting/iap_extract/tests/test_extract_mixin.py new file mode 100644 index 0000000..9501067 --- /dev/null +++ b/dev_odex30_accounting/iap_extract/tests/test_extract_mixin.py @@ -0,0 +1,71 @@ + +from contextlib import contextmanager +from datetime import datetime +from freezegun import freeze_time +from unittest.mock import patch + +from odoo.addons.base.models.ir_cron import ir_cron +from odoo.addons.iap.models.iap_account import IapAccount +from odoo.addons.iap.tools import iap_tools +from odoo.addons.iap_extract.models.extract_mixin import ExtractMixin +from odoo.addons.partner_autocomplete.models.iap_autocomplete_api import IapAutocompleteEnrichAPI +from odoo.sql_db import Cursor +from odoo.tests import common + + +class TestExtractMixin(common.TransactionCase): + def parse_success_response(self): + return {'status': 'success', 'document_token': 'some_token'} + + def parse_processing_response(self): + return {'status': 'processing'} + + def parse_credit_error_response(self): + return {'status': 'error_no_credit'} + + def validate_success_response(self): + return {'status': 'success'} + + @classmethod + def setUpClass(cls): + super(TestExtractMixin, cls).setUpClass() + + + cls.startClassPatcher(freeze_time('2019-04-15')) + cls.env.cr._now = datetime.now() + + partner_autocomplete = cls.env.ref('partner_autocomplete.iap_service_partner_autocomplete') + invoice_ocr = cls.env.ref('iap_extract.iap_service_ocr') + cls.env['iap.account'].create([ + { + 'service_id': partner_autocomplete.id, + }, + { + 'service_id': invoice_ocr.id, + 'account_token': 'test_token', + } + ]) + + @contextmanager + def _mock_iap_extract(self, extract_response=None, partner_autocomplete_response=None, assert_params=None): + def _trigger(self, *args, **kwargs): + self.method_direct_trigger() + + def _mock_autocomplete(*args, **kwargs): + return partner_autocomplete_response or {} + + def _mock_iap_jsonrpc(*args, **kwargs): + if assert_params is not None: + self.assertDictEqual(kwargs['params'], assert_params) + return extract_response or {} + + def _mock_try_to_check_ocr_status(self, *args, **kwargs): + self._check_ocr_status() + + with patch.object(iap_tools, 'iap_jsonrpc', side_effect=_mock_iap_jsonrpc), \ + patch.object(ExtractMixin, '_try_to_check_ocr_status', side_effect=_mock_try_to_check_ocr_status, autospec=True), \ + patch.object(IapAutocompleteEnrichAPI, '_contact_iap', side_effect=_mock_autocomplete), \ + patch.object(IapAccount, 'get_credits', side_effect=lambda *args, **kwargs: 1), \ + patch.object(Cursor, 'commit', side_effect=lambda *args, **kwargs: None), \ + patch.object(ir_cron, '_trigger', side_effect=_trigger, autospec=True): + yield diff --git a/dev_odex30_accounting/odex30_account_base_import/__init__.py b/dev_odex30_accounting/odex30_account_base_import/__init__.py new file mode 100644 index 0000000..0217b0f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/__init__.py @@ -0,0 +1,3 @@ + +from . import models +from . import wizard diff --git a/dev_odex30_accounting/odex30_account_base_import/__manifest__.py b/dev_odex30_accounting/odex30_account_base_import/__manifest__.py new file mode 100644 index 0000000..1239b6c --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/__manifest__.py @@ -0,0 +1,32 @@ + +{ + 'name': 'Accounting Import', + 'summary': 'Improved Import in Accounting', + 'category': 'Odex30-Accounting/Odex30-Accounting', + 'description': """ +Accounting Import +================== + """, + 'author': "Expert Co. Ltd.", + 'website': "http://www.exp-sa.com", + 'depends': ['odex30_account_accountant', 'base_import'], + 'data': [ + 'security/ir.model.access.csv', + 'views/account_import_views.xml', + 'views/account_account_views.xml', + 'views/account_move_views.xml', + 'views/res_partner_views.xml', + 'wizard/account_import_summary_views.xml', + 'wizard/setup_wizards_views.xml', + 'views/res_config_settings_views.xml', + ], + 'auto_install': True, + 'installable': True, + 'license': 'OEEL-1', + 'assets': { + 'web.assets_backend': [ + 'odex30_account_base_import/static/src/js/**/*', + 'odex30_account_base_import/static/src/xml/**/*', + ], + } +} diff --git a/dev_odex30_accounting/odex30_account_base_import/i18n/ar.po b/dev_odex30_accounting/odex30_account_base_import/i18n/ar.po new file mode 100644 index 0000000..d9fa223 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/i18n/ar.po @@ -0,0 +1,495 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_base_import +# +# Translators: +# Wil Odoo, 2024 +# Malaz Abuidris , 2025 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-03-26 20:46+0000\n" +"PO-Revision-Date: 2024-09-25 09:43+0000\n" +"Last-Translator: Malaz Abuidris , 2025\n" +"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#. module: odex30_account_base_import +#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.res_config_settings_import_view_form +msgid "(end of year balances)" +msgstr "(أرصدة نهاية العام)" + +#. module: odex30_account_base_import +#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.res_config_settings_import_view_form +msgid "(for full history)" +msgstr "(للسجل الكامل) " + +#. module: odex30_account_base_import +#: model:ir.model,name:odex30_account_base_import.model_account_account +msgid "Account" +msgstr "الحساب " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "Account Winbooks Import module" +msgstr "تطبيق استيراد حساب Winbooks " + +#. module: odex30_account_base_import +#: model:ir.model,name:odex30_account_base_import.model_account_import_summary +msgid "Account import summary view" +msgstr "عرض ملخص استيراد الحساب " + +#. module: odex30_account_base_import +#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.res_config_settings_import_view_form +msgid "Accounting Import" +msgstr "استيراد المحاسبة " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/js/account_import_guide.js:0 +#: model:ir.actions.client,name:odex30_account_base_import.action_open_import_guide +msgid "Accounting Import Guide" +msgstr "دليل استيراد المحاسبة " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "Accounting Import Options" +msgstr "خيارات استيراد المحاسبة " + +#. module: odex30_account_base_import +#: model:ir.model,name:odex30_account_base_import.model_base_import_import +msgid "Base Import" +msgstr "الاستيراد الأساسي" + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/js/account_import_action.js:0 +#: model:ir.actions.act_window,name:odex30_account_base_import.action_open_coa_setup +msgid "Chart of Accounts" +msgstr "شجرة الحسابات " + +#. module: odex30_account_base_import +#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.res_config_settings_import_view_form +msgid "Choose how you want to setup your CoA" +msgstr "اختر الطريقة التي تود إعداد شجرة حساباتك بها " + +#. module: odex30_account_base_import +#: model:ir.model.fields,field_description:odex30_account_base_import.field_account_import_summary__create_uid +msgid "Created by" +msgstr "أنشئ بواسطة" + +#. module: odex30_account_base_import +#: model:ir.model.fields,field_description:odex30_account_base_import.field_account_import_summary__create_date +msgid "Created on" +msgstr "أنشئ في" + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/js/account_import_action.js:0 +msgid "Customers" +msgstr "العملاء" + +#. module: odex30_account_base_import +#: model:ir.model.fields,field_description:odex30_account_base_import.field_account_import_summary__display_name +msgid "Display Name" +msgstr "اسم العرض " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "Download" +msgstr "تنزيل " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "Excel Import" +msgstr "استيراد ملف Excel " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "FEC" +msgstr "ملف القيد المحاسبي " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "FEC Import module" +msgstr "تطبيق استيراد ملف القيد المحاسبي " + +#. module: odex30_account_base_import +#: model:ir.model.fields,field_description:odex30_account_base_import.field_account_import_summary__id +msgid "ID" +msgstr "المُعرف" + +#. module: odex30_account_base_import +#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.res_config_settings_import_view_form +#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.view_account_base_import_list +#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.view_account_setup_base_import_list +msgid "Import" +msgstr "استيراد" + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +#: model:ir.actions.client,name:odex30_account_base_import.action_account_import +msgid "Import Chart of Accounts" +msgstr "استيراد شجرة الحسابات " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "Import CoA" +msgstr "استيراد شجرة الحسابات " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "Import Contacts" +msgstr "استيراد جهات الاتصال" + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +#: model:ir.actions.client,name:odex30_account_base_import.action_account_move_line_import +msgid "Import Journal Items" +msgstr "استيراد عناصر دفتر اليومية" + +#. module: odex30_account_base_import +#: model:ir.actions.client,name:odex30_account_base_import.action_partner_import +msgid "Import Partners" +msgstr "استيراد الشركاء " + +#. module: odex30_account_base_import +#. odoo-python +#: code:addons/odex30_account_base_import/wizard/account_import_summary.py:0 +msgid "Import Summary" +msgstr "ملخص الاستيراد " + +#. module: odex30_account_base_import +#: model:ir.model.fields,field_description:odex30_account_base_import.field_account_import_summary__import_summary_account_ids +msgid "Import Summary Account" +msgstr "ملخص استيراد الحساب " + +#. module: odex30_account_base_import +#: model:ir.model.fields,field_description:odex30_account_base_import.field_account_import_summary__import_summary_journal_ids +msgid "Import Summary Journal" +msgstr "ملخص استيراد دفتر اليومية " + +#. module: odex30_account_base_import +#: model:ir.model.fields,field_description:odex30_account_base_import.field_account_import_summary__import_summary_move_ids +msgid "Import Summary Move" +msgstr "ملخص استيراد الحركة " + +#. module: odex30_account_base_import +#: model:ir.model.fields,field_description:odex30_account_base_import.field_account_import_summary__import_summary_name +msgid "Import Summary Name" +msgstr "اسم ملخص الاستيراد " + +#. module: odex30_account_base_import +#: model:ir.model.fields,field_description:odex30_account_base_import.field_account_import_summary__import_summary_partner_ids +msgid "Import Summary Partner" +msgstr "شريك ملخص الاستيراد " + +#. module: odex30_account_base_import +#: model:ir.model.fields,field_description:odex30_account_base_import.field_account_import_summary__import_summary_tax_ids +msgid "Import Summary Tax" +msgstr "ضريبة ملخص الاستيراد " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "Import contacts" +msgstr "استيراد جهات الاتصال" + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "Import customers or suppliers (partners) and their contacts using a" +msgstr "استيراد العملاء أو المورّدين (الشركاء) وجهات اتصالهم باستخدام " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "Import the Chart of Accounts and initial balances using a" +msgstr "استيراد شجرة الحسابات والأرصدة المبدئية باستخدام " + +#. module: odex30_account_base_import +#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.account_import_summary_form +msgid "Imported Data" +msgstr "البيانات المستوردة " + +#. module: odex30_account_base_import +#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.res_config_settings_import_view_form +msgid "Initial Setup" +msgstr "الإعداد المبدئي " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/js/account_import_guide.js:0 +msgid "Install a module" +msgstr "قم بتثبيت تطبيق " + +#. module: odex30_account_base_import +#: model:ir.model,name:odex30_account_base_import.model_account_move_line +msgid "Journal Item" +msgstr "عنصر اليومية" + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/js/account_import_action.js:0 +msgid "Journal Items" +msgstr "عناصر اليومية" + +#. module: odex30_account_base_import +#: model:ir.model.fields,field_description:odex30_account_base_import.field_account_import_summary__write_uid +msgid "Last Updated by" +msgstr "آخر تحديث بواسطة" + +#. module: odex30_account_base_import +#: model:ir.model.fields,field_description:odex30_account_base_import.field_account_import_summary__write_date +msgid "Last Updated on" +msgstr "آخر تحديث في" + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "" +"Most accounting software in Europe support exporting SAF-T file for audit purposes.\n" +" Use the" +msgstr "" +"معظم برامج المحاسبة في أوروبا تدعم تصدير ملف SAF-T لأغراض التدقيق.\n" +" استخدم " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "" +"Most accounting software in France support exporting FEC file for audit purposes.\n" +" Use the" +msgstr "" +"Most accounting software in France support exporting FEC file for audit purposes.\n" +" Use the" + +#. module: odex30_account_base_import +#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.account_import_summary_form +msgid "No data was imported." +msgstr "لم يتم استيراد أي بيانات. " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "Optional, but useful to import open receivables & payables using a" +msgstr "" +"اختياري، ولكن مفيد لاستيراد المبالغ مستحقة القبض و ومستحقة الدفع باستخدام " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.res_config_settings_import_view_form +msgid "Review Manually" +msgstr "المراجعة يدوياً " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "SAF-T" +msgstr "SAF-T" + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "SAF-T Import module" +msgstr "تطبيق استيراد SAF-T " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "SIE 4 Import module" +msgstr "SIE 4 Import module" + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "SIE 4/5" +msgstr "SIE 4/5" + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "SIE 5 Import module" +msgstr "SIE 5 Import module" + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "" +"The SIE standard file format is very common in Sweden for several purposes such as auditing, importing and exporting data\n" +" from and to other accounting applications. Odoo support importing data from both type 4 and 5 of SIE." +msgstr "" +"The SIE standard file format is very common in Sweden for several purposes such as auditing, importing and exporting data\n" +" from and to other accounting applications. Odoo support importing data from both type 4 and 5 of SIE." + +#. module: odex30_account_base_import +#. odoo-python +#: code:addons/odex30_account_base_import/models/account_move_line.py:0 +msgid "The import file is missing the following required columns: %s" +msgstr "الأعمدة التالية المطلوبة غير موجودة في ملف الاستيراد: %s " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "" +"Tip: we recommend importing your initial balances using the Chart of Account" +" import. Only use the Journal Items import for unreconciled entries in your " +"Payable and Receivable Accounts." +msgstr "" +"نصيحة: نوصي باستيراد رصيدك الابتدائي باستخدام استيراد شجرة الحسابات. استخدم " +"خاصية استيراد عناصر اليومية فقط للقيود غير المسواة في حساباتك الدائنة " +"والمدينة. " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "Use predefined format to import your data faster." +msgstr "استخدم الصيغة المحددة مسبقاً لاستيراد بياناتك بشكل أسرع. " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "Use templates to import CSV or Excel for your accounting setup." +msgstr "" +"استخدم القوالب لاستيراد ملفات CSV أو Excel لإعدادات المحاسبة الخاصة بك. " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "" +"We will setup your charts of accounts and the history of journal entries, " +"that will stay in draft." +msgstr "" +"سنقوم بإعداد أشجار الحسابات الخاصة بك وسجل قيود دفتر اليومية، والتي ستبقى في" +" حالة المسودة. " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "Winbooks" +msgstr "Winbooks" + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "" +"Winbooks is an old school Belgian accounting software acquired by Exact.\n" +" Use the" +msgstr "" +"Winbooks is an old school Belgian accounting software acquired by Exact.\n" +" Use the" + +#. module: odex30_account_base_import +#. odoo-python +#: code:addons/odex30_account_base_import/models/account_account.py:0 +msgid "" +"You must provide both the `code_mapping_ids/company_id` and the " +"`code_mapping_ids/code` columns." +msgstr "" +"يجب عليك توفير كل من عمودي ”code_mapping_ids_id/company_id“ " +"و”code_mapping_ids/ode“. " + +#. module: odex30_account_base_import +#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.account_import_summary_form +msgid "accounts imported" +msgstr "الحسابات التي تم استيرادها " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "" +"is required to import the SIE 4 files.\n" +" By design, it's also backward compatible with previous SIE versions (version 1 up to 3).\n" +" This import will not validate the correctness of the file." +msgstr "" +"is required to import the SIE 4 files.\n" +" By design, it's also backward compatible with previous SIE versions (version 1 up to 3).\n" +" This import will not validate the correctness of the file." + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "" +"is required to import the SIE 5 and SIE 5 entry files.\n" +" For general SIE 5, we will set up your charts of accounts balances, journals, partners, and the history of journal entries\n" +" (journals data must be present in the file).\n" +" For the SIE 5 entry, only entries and partners will be created, the rest must already be present in the system." +msgstr "" +"is required to import the SIE 5 and SIE 5 entry files.\n" +" For general SIE 5, we will set up your charts of accounts balances, journals, partners, and the history of journal entries\n" +" (journals data must be present in the file).\n" +" For the SIE 5 entry, only entries and partners will be created, the rest must already be present in the system." + +#. module: odex30_account_base_import +#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.account_import_summary_form +msgid "journals imported" +msgstr "دفاتر اليومية التي تم استيرادها " + +#. module: odex30_account_base_import +#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.account_import_summary_form +msgid "moves imported" +msgstr "الحركات التي تم استيرادها " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "or" +msgstr "أو" + +#. module: odex30_account_base_import +#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.account_import_summary_form +msgid "partners imported" +msgstr "الوكلاء الذين تم استيرادهم " + +#. module: odex30_account_base_import +#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.account_import_summary_form +msgid "taxes imported" +msgstr "الضرائب التي تم استيرادها " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "template." +msgstr "قالب. " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "" +"to import a Winbooks full back-up (Maintenance > Backup) to get the chart of accounts, contacts, taxes, history of journal entries, and documents.\n" +" Support versions: Winbooks Desktop 5.50, 6, 7, 8." +msgstr "" +"to import a Winbooks full back-up (Maintenance > Backup) to get the chart of accounts, contacts, taxes, history of journal entries, and documents.\n" +" Support versions: Winbooks Desktop 5.50, 6, 7, 8." + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "" +"to import the FEC file. We will setup your charts of accounts and the " +"history of journal entries." +msgstr "" +"لاستيراد ملف FEC. سنقوم بإعداد شجرة الحسابات الخاصة بك وسجل قيود يوميتك. " + +#. module: odex30_account_base_import +#. odoo-javascript +#: code:addons/odex30_account_base_import/static/src/xml/account_import.xml:0 +msgid "to import the SAF-T file." +msgstr "لاستيراد ملف SAF-T. " diff --git a/dev_odex30_accounting/odex30_account_base_import/models/__init__.py b/dev_odex30_accounting/odex30_account_base_import/models/__init__.py new file mode 100644 index 0000000..373f620 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/models/__init__.py @@ -0,0 +1,3 @@ + +from . import account_account +from . import account_move_line diff --git a/dev_odex30_accounting/odex30_account_base_import/models/account_account.py b/dev_odex30_accounting/odex30_account_base_import/models/account_account.py new file mode 100644 index 0000000..db97f9a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/models/account_account.py @@ -0,0 +1,32 @@ + +from odoo import _, api, models +from odoo.exceptions import UserError + +class AccountAccount(models.Model): + _inherit = ["account.account"] + + @api.model + def load(self, fields, data): + + if "import_file" in self.env.context: + if len({'code_mapping_ids/company_id', 'code_mapping_ids/code'} & set(fields)) == 1: + raise UserError(_( + "You must provide both the `code_mapping_ids/company_id` " + "and the `code_mapping_ids/code` columns." + )) + + if not {'id', '.id'} & set(fields) and 'code' in fields: + + accounts = self.search_fetch( + domain=self._check_company_domain(self.env.company), + field_names=['code'], + ) + account_id_by_code = {account.code: account.id for account in accounts} + + fields.append('.id') + code_index = fields.index('code') + for row in data: + account_code = row[code_index] + row.append(account_id_by_code.get(account_code, False)) + + return super().load(fields, data) diff --git a/dev_odex30_accounting/odex30_account_base_import/models/account_move_line.py b/dev_odex30_accounting/odex30_account_base_import/models/account_move_line.py new file mode 100644 index 0000000..b579cc3 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/models/account_move_line.py @@ -0,0 +1,65 @@ + +from odoo import api, models, _ +from odoo.exceptions import UserError + +class AccountMoveLine(models.Model): + _inherit = ["account.move.line"] + + @api.model + def load(self, fields, data): + def _sequence_override(journals, regex=False): + for journal in journals: + journal.sequence_override_regex = regex + + if "import_file" in self.env.context: + account_move_data = [] + processed_move_ids = set() + journal_data = set() + required_fields = ("journal_id", "move_id", "date") + if not all(field in fields for field in required_fields): + missing_fields = ", ".join(field for field in required_fields if field not in fields) + raise UserError(_("The import file is missing the following required columns: %s", missing_fields)) + journal_index = fields.index("journal_id") + move_index = fields.index("move_id") + date_index = fields.index("date") + + for row in data: + journal_id = row[journal_index] + move_id = row[move_index] + date = row[date_index] + if move_id in processed_move_ids: + continue + account_move_data.append([journal_id, move_id, date]) + processed_move_ids.add(move_id) + journal_data.add(journal_id) + + + journal_codes_ids = {} + journal_codes = self.env["account.journal"].search_read( + domain=self.env['account.journal']._check_company_domain(self.env.company), + fields=["code"] + ) + for journal in journal_codes: + journal_codes_ids[journal["code"]] = journal["id"] + + journal_ids = self.env["account.journal"]._load_records([ + { + "values": ( + {"id": journal_codes_ids[journal_name[:5]]} + if journal_name[:5] in journal_codes_ids + else {"name": journal_name} + ) + } + for journal_name in journal_data + ]) + _sequence_override(journal_ids, r"^(?P.*?)(?P\d{0,9})(?P\D*?)$") + self.env["account.move"].load(["journal_id", "name", "date"], account_move_data) + + _sequence_override(journal_ids) + + if 'matching_number' in fields: + matching_index = fields.index('matching_number') + for row in data: + row[matching_index] = row[matching_index] and f"I{row[matching_index]}" + + return super().load(fields, data) diff --git a/dev_odex30_accounting/odex30_account_base_import/security/ir.model.access.csv b/dev_odex30_accounting/odex30_account_base_import/security/ir.model.access.csv new file mode 100644 index 0000000..22529c1 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +"access_account_import_summary","access.account.import.summary","model_account_import_summary","account.group_account_manager",1,1,1,1 diff --git a/dev_odex30_accounting/odex30_account_base_import/static/src/csv/coa_import_with_code_mapping.csv b/dev_odex30_accounting/odex30_account_base_import/static/src/csv/coa_import_with_code_mapping.csv new file mode 100644 index 0000000..684d0a6 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/static/src/csv/coa_import_with_code_mapping.csv @@ -0,0 +1,17 @@ +"code","name","opening_balance","code_mapping_ids/company_id","code_mapping_ids/code" +"100000","Issued capital",12500,"Company 2","100001" +,,,"Company 3","100002" +"101000","Uncalled capital",3500,, +"110000","Share premium account",,, +"120000","Revaluation surpluses on intangible fixed assets",,, +"121000","Revaluation surpluses on tangible fixed assets",,, +"400000","Trade debtors within one year - Customer",,, +"455000","Remuneration and social security - Remuneration",,, +"550003","Bank",-3500,"Company 2","550004" +,,,"Company 3","550005" +"570001","Cash",,, +"600000","Purchases of raw material",-12500,, +"620200","Remuneration and direct social benefits - Employees",,, +"654000","Financial charges - Exchange differences",,, +"700000","Sales rendered in Belgium (marchandises)",,, +"700200","Sales rendered for export (marchandises)",,, diff --git a/dev_odex30_accounting/odex30_account_base_import/static/src/csv/coa_import_with_code_mapping_and_no_code.csv b/dev_odex30_accounting/odex30_account_base_import/static/src/csv/coa_import_with_code_mapping_and_no_code.csv new file mode 100644 index 0000000..46c5aae --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/static/src/csv/coa_import_with_code_mapping_and_no_code.csv @@ -0,0 +1,29 @@ +"name","company_ids","code_mapping_ids/company_id","code_mapping_ids/code" +"Issued capital","company_1_data,Company 2","company_1_data","100000" +,,"Company 2","100001" +"Uncalled capital","company_1_data,Company 2","company_1_data","101010" +,,"Company 2","101010" +"Share premium account","company_1_data,Company 2","company_1_data","110000" +,,"Company 2","110000" +"Revaluation surpluses on intangible fixed assets","company_1_data,Company 2","company_1_data","120000" +,,"Company 2","120000" +"Revaluation surpluses on tangible fixed assets","company_1_data,Company 2","company_1_data","121010" +,,"Company 2","121010" +"Trade debtors within one year - Customer","company_1_data,Company 2","company_1_data","400010" +,,"Company 2","400010" +"Remuneration and social security - Remuneration","company_1_data,Company 2","company_1_data","455000" +,,"Company 2","455000" +"Bank of Company 1","company_1_data","company_1_data","550003" +"Bank of Company 2","Company 2","Company 2","550003" +"Cash","company_1_data,Company 2","company_1_data","570001" +,,"Company 2","570001" +"Purchases of raw material","company_1_data,Company 2","company_1_data","600010" +,,"Company 2","600010" +"Remuneration and direct social benefits - Employees","company_1_data,Company 2","company_1_data","620200" +,,"Company 2","620200" +"Financial charges - Exchange differences","company_1_data,Company 2","company_1_data","654000" +,,"Company 2","654000" +"Sales rendered in Belgium (marchandises)","company_1_data,Company 2","company_1_data","700000" +,,"Company 2","700000" +"Sales rendered for export (marchandises)","company_1_data,Company 2","company_1_data","700200" +,,"Company 2","700200" diff --git a/dev_odex30_accounting/odex30_account_base_import/static/src/js/account_import_action.js b/dev_odex30_accounting/odex30_account_base_import/static/src/js/account_import_action.js new file mode 100644 index 0000000..3212038 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/static/src/js/account_import_action.js @@ -0,0 +1,46 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { ImportAction } from "@base_import/import_action/import_action"; +import { useService } from "@web/core/utils/hooks"; +import { _t } from "@web/core/l10n/translation"; +import { useAccountMoveLineImportModel } from "./account_import_model"; + +export class AccountImportAction extends ImportAction { + setup() { + super.setup(); + this.actionService = useService("action"); + + this.model = useAccountMoveLineImportModel({ + env: this.env, + resModel: this.resModel, + context: this.props.action.params.context || {}, + orm: this.orm, + }); + } + + exit(resIds = null) { + if (resIds && ["account.move.line", "account.account", "res.partner"].includes(this.resModel)) { + const names = { + "account.move.line": _t("Journal Items"), + "account.account": _t("Chart of Accounts"), + "res.partner": _t("Customers"), + } + const action = { + name: names[this.resModel], + res_model: this.resModel, + type: "ir.actions.act_window", + views: [[false, "list"], [false, "form"]], + view_mode: "list", + domain: [["id", "in", resIds]], + } + if (this.resModel == "account.move.line") { + action.context = { "search_default_posted": 0 }; + } + return this.actionService.doAction(action); + } + super.exit(); + } +}; + +registry.category("actions").add("account_import_action", AccountImportAction); diff --git a/dev_odex30_accounting/odex30_account_base_import/static/src/js/account_import_guide.js b/dev_odex30_accounting/odex30_account_base_import/static/src/js/account_import_guide.js new file mode 100644 index 0000000..7011fca --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/static/src/js/account_import_guide.js @@ -0,0 +1,47 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { ControlPanel } from "@web/search/control_panel/control_panel"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { Component, onWillStart, onWillRender } from "@odoo/owl"; +import { standardActionServiceProps } from "@web/webclient/actions/action_service"; + +export class AccountImportGuide extends Component { + static template = "odex30_account_base_import.accountImportTemplate"; + static components = { ControlPanel }; + static props = { ...standardActionServiceProps }; + setup() { + this.actionService = useService("action"); + this.orm = useService("orm"); + this.env.config.setDisplayName(_t("Accounting Import Guide")) + onWillStart(async () => { + const current_company_id = this.env.services.company.currentCompany.id + this.data = await this.orm.searchRead("res.company", [["id", "=", current_company_id]], ["country_code"]) + this.isFecImportModuleInstalled = await this.orm.searchCount("ir.module.module", [["name", "=", "l10n_fr_fec_import"], ["state", "=", "installed"]]); + }); + onWillRender(() => { + this.countryCode = this.data[0].country_code + }); + } + + _importAccountGuideAction(action) { + this.actionService.doAction(action); + } + + _openModuleInstallation(module) { + this.actionService.doAction({ + name: _t("Install a module"), + res_model: "ir.module.module", + type: "ir.actions.act_window", + views: [[false, "kanban"], [false, "list"], [false, "form"]], + view_mode: "kanban,list,form", + context: { + "search_default_name": module, + "search_default_extra": true, + }, + }); + } +}; + +registry.category("actions").add("account_import_guide", AccountImportGuide); diff --git a/dev_odex30_accounting/odex30_account_base_import/static/src/js/account_import_model.js b/dev_odex30_accounting/odex30_account_base_import/static/src/js/account_import_model.js new file mode 100644 index 0000000..8a7489a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/static/src/js/account_import_model.js @@ -0,0 +1,23 @@ +/** @odoo-module **/ + +import { useState } from "@odoo/owl"; +import { BaseImportModel } from "@base_import/import_model"; + +class AccountMoveLineImportModel extends BaseImportModel { + get importOptions() { + const options = super.importOptions; + if (this.resModel === "account.move.line") { + Object.assign(options.name_create_enabled_fields, { + journal_id: true, + account_id: true, + partner_id: true, + }); + } + return options; + } +} + + +export function useAccountMoveLineImportModel({ env, resModel, context, orm }) { + return useState(new AccountMoveLineImportModel({ env, resModel, context, orm })); +} diff --git a/dev_odex30_accounting/odex30_account_base_import/static/src/xls/coa_import.xlsx b/dev_odex30_accounting/odex30_account_base_import/static/src/xls/coa_import.xlsx new file mode 100644 index 0000000..88fccda Binary files /dev/null and b/dev_odex30_accounting/odex30_account_base_import/static/src/xls/coa_import.xlsx differ diff --git a/dev_odex30_accounting/odex30_account_base_import/static/src/xls/duplicate_journals_import.xlsx b/dev_odex30_accounting/odex30_account_base_import/static/src/xls/duplicate_journals_import.xlsx new file mode 100644 index 0000000..fbbbe1b Binary files /dev/null and b/dev_odex30_accounting/odex30_account_base_import/static/src/xls/duplicate_journals_import.xlsx differ diff --git a/dev_odex30_accounting/odex30_account_base_import/static/src/xls/journal_items_import.xlsx b/dev_odex30_accounting/odex30_account_base_import/static/src/xls/journal_items_import.xlsx new file mode 100644 index 0000000..4efec1f Binary files /dev/null and b/dev_odex30_accounting/odex30_account_base_import/static/src/xls/journal_items_import.xlsx differ diff --git a/dev_odex30_accounting/odex30_account_base_import/static/src/xml/account_import.xml b/dev_odex30_accounting/odex30_account_base_import/static/src/xml/account_import.xml new file mode 100644 index 0000000..61b5085 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/static/src/xml/account_import.xml @@ -0,0 +1,156 @@ + + + + + + +
+
+
+

+ Accounting Import Options +

+

+ Use predefined format to import your data faster. +

+
+ +
+

+ Winbooks +

+

+ Winbooks is an old school Belgian accounting software acquired by Exact. + Use the + + Account Winbooks Import module + + to import a Winbooks full back-up (Maintenance > Backup) to get the chart of accounts, contacts, taxes, history of journal entries, and documents. + Support versions: Winbooks Desktop 5.50, 6, 7, 8. +

+
+
+ +
+

+ FEC +

+

+ Most accounting software in France support exporting FEC file for audit purposes. + Use the + + FEC Import module + + to import the FEC file. We will setup your charts of accounts and the history of journal entries. +

+
+
+ +
+

+ SAF-T +

+

+ Most accounting software in Europe support exporting SAF-T file for audit purposes. + Use the + + SAF-T Import module + + to import the SAF-T file.
+ We will setup your charts of accounts and the history of journal entries, that will stay in draft. +

+
+
+ +
+

+ SIE 4/5 +

+

+ The SIE standard file format is very common in Sweden for several purposes such as auditing, importing and exporting data + from and to other accounting applications. Odoo support importing data from both type 4 and 5 of SIE. +

    +
  • + + SIE 4 Import module + + is required to import the SIE 4 files. + By design, it's also backward compatible with previous SIE versions (version 1 up to 3). + This import will not validate the correctness of the file. +
  • +
  • + + SIE 5 Import module + + is required to import the SIE 5 and SIE 5 entry files. + For general SIE 5, we will set up your charts of accounts balances, journals, partners, and the history of journal entries + (journals data must be present in the file). + For the SIE 5 entry, only entries and partners will be created, the rest must already be present in the system. +
  • +
+

+
+
+ +
+

+ Excel Import +

+

+ Use templates to import CSV or Excel for your accounting setup. +

+
    +
  1. +
    +
    +

    Import contacts

    +

    Import customers or suppliers (partners) and their contacts using a + + template. + +

    + +
    +
    +
    +
  2. +
  3. +
    +
    +

    Import Chart of Accounts

    +

    Import the Chart of Accounts and initial balances using a + + template. + +

    + + or +
    +
    +
    +
  4. +
  5. +
    +
    +

    Import Journal Items

    +

    Optional, but useful to import open receivables & payables using a + + template. + +

    + +
    +
    +
  6. +
+
+

Tip: we recommend importing your initial balances using the Chart of Account import. Only use the Journal Items import for unreconciled entries in your Payable and Receivable Accounts.

+
+
+ +
+ +
+ + + diff --git a/dev_odex30_accounting/odex30_account_base_import/tests/__init__.py b/dev_odex30_accounting/odex30_account_base_import/tests/__init__.py new file mode 100644 index 0000000..0eb1e9e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import test_account_import diff --git a/dev_odex30_accounting/odex30_account_base_import/tests/test_account_import.py b/dev_odex30_accounting/odex30_account_base_import/tests/test_account_import.py new file mode 100644 index 0000000..ebc29a8 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/tests/test_account_import.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import unittest + +from odoo.tests import tagged +from odoo.tests.common import can_import +from odoo.tools import file_open + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + +@tagged("post_install", "-at_install") +class TestBaseImport(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + coa_file = "odex30_account_base_import/static/src/xls/coa_import.xlsx" + coa_file_with_code_mapping = "odex30_account_base_import/static/src/csv/coa_import_with_code_mapping.csv" + coa_file_with_code_mapping_and_no_code = "odex30_account_base_import/static/src/csv/coa_import_with_code_mapping_and_no_code.csv" + journal_items_file = "odex30_account_base_import/static/src/xls/journal_items_import.xlsx" + duplicate_journals_file = "odex30_account_base_import/static/src/xls/duplicate_journals_import.xlsx" + with file_open(coa_file, "rb") as f: + cls.coa_file_content = f.read() + with file_open(coa_file_with_code_mapping, "rb") as f: + cls.coa_file_with_code_mapping_content = f.read() + with file_open(coa_file_with_code_mapping_and_no_code, "rb") as f: + cls.coa_file_with_code_mapping_and_no_code_content = f.read() + with file_open(journal_items_file, "rb") as f: + cls.journal_items_file_content = f.read() + with file_open(duplicate_journals_file, "rb") as f: + cls.duplicate_journals_file_content = f.read() + + def _create_save_import(self, res_model, file, companies=None, is_csv=False): + if companies is None: + companies = self.env.company + + import_wizard = self.env["base_import.import"].with_context(allowed_company_ids=companies.ids).create({ + "res_model": res_model, + "file": file, + "file_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" if not is_csv else "text/csv", + }) + preview = import_wizard.parse_preview({ + "has_headers": True, + "quoting": '"', + }) + preview["options"]["name_create_enabled_fields"] = { + "journal_id": True, + "account_id": True, + "partner_id": True, + } + + return import_wizard.with_context(allowed_company_ids=companies.ids).execute_import( + preview["headers"], + preview["headers"], + preview["options"] + ) + + def _create_records(self, res_model, amount=1): + vals = [] + for i in range(amount): + match res_model: + case 'account.account': + vals.append({'code': f"9999{i}", 'name': f"Test Account {i}"}) + case 'account.journal': + vals.append({'code': f"TBNK{i}", 'name': f"Test Journal {i}", 'type': 'bank'}) + case 'account.move': + vals.append({'move_type': 'entry', 'name': f"Test Move {i}"}) + case 'res.partner': + vals.append({'name': f"Test Partner {i}"}) + case 'account.tax': + vals.append({'name': f"Test Tax {i}"}) + case _: + raise + return self.env[res_model].create(vals) + + @unittest.skipUnless(can_import("xlrd.xlsx") or can_import("openpyxl"), "XLRD module not available") + def test_account_xlsx_import(self): + existing_id = self.env["account.account"].with_context(import_file=True).create({"code":"550003", "name": "Existing Account"}).id + + result = self._create_save_import("account.account", self.coa_file_content) + self.cr.precommit.run() + self.env.company.account_opening_move_id.action_post() + + self.assertEqual(result["messages"], [], "The import should have been successful without error") + + existing_account = self.env["account.account"].browse(existing_id) + self.assertEqual(len(result["ids"]), 14, "14 Accounts should have been imported") + self.assertEqual(existing_account.name, "Bank", "The existing account should have been updated") + self.assertEqual(existing_account.current_balance, -3500.0, "The balance should have been updated") + + @unittest.skipUnless(can_import('xlrd.xlsx') or can_import("openpyxl"), "XLRD module not available") + def test_account_xlsx_import_fresh_company(self): + new_company = self.env['res.company'].create({'name': 'New Test Company'}) + + self.env['account.journal'].create({ + 'name': 'Miscellaneous', + 'code': 'MISC', + 'type': 'general', + 'company_id': new_company.id, + }) + + result = self._create_save_import('account.account', self.coa_file_content, companies=new_company) + self.cr.precommit.run() + new_company.account_opening_move_id.action_post() + + self.assertEqual(result['messages'], [], "The import should have been successful without error") + self.assertEqual(len(result['ids']), 14, "14 Accounts should have been imported") + + bank_account = self.env['account.account'].with_company(new_company).search([('code', '=', '550003')]) + self.assertRecordValues(bank_account, [{ + 'name': "Bank", + 'current_balance': -3500, + }]) + + num_accounts = self.env['account.account'].with_company(new_company).search_count([]) + self.assertEqual(num_accounts, 15) + + def test_account_csv_import_with_code_mapping(self): + company_2, company_3 = self.env['res.company'].create([ + {'name': 'Company 2'}, + {'name': 'Company 3'}, + ]) + + existing_id = self.env['account.account'].with_context(import_file=True).create({'code': '550003', 'name': "Existing Account"}).id + + result = self._create_save_import('account.account', self.coa_file_with_code_mapping_content, is_csv=True) + self.assertEqual(result['messages'], [], "The import should have been successful without error") + self.assertEqual(len(result['ids']), 14, "14 Accounts should have been imported") + + first_account = self.env['account.account'].browse(result['ids'][0]) + self.assertRecordValues(first_account, [{ + 'company_ids': self.company_data['company'].ids, + 'code': '100000', + }]) + self.assertRecordValues(first_account.with_company(company_2.id), [{'code': '100001'}]) + self.assertRecordValues(first_account.with_company(company_3.id), [{'code': '100002'}]) + + existing_account = self.env['account.account'].browse(existing_id) + self.assertEqual(existing_account.name, "Bank", "The existing account should have been updated") + self.assertRecordValues(existing_account.with_company(company_2.id), [{'code': '550004'}]) + self.assertRecordValues(existing_account.with_company(company_3.id), [{'code': '550005'}]) + + def test_account_csv_import_with_code_mapping_and_no_code(self): + company_2 = self.env['res.company'].create([{'name': "Company 2"}]) + + result = self._create_save_import( + 'account.account', + self.coa_file_with_code_mapping_and_no_code_content, + companies=(self.company_data['company'] | company_2), + is_csv=True, + ) + self.assertEqual(result['messages'], [], "The import should have been successful without error") + self.assertEqual(len(result['ids']), 15, "15 Accounts should have been imported") + + first_account = self.env['account.account'].browse(result['ids'][0]) + self.assertRecordValues(first_account, [{ + 'company_ids': (self.company_data['company'] | company_2).ids, + 'code': '100000', + }]) + self.assertRecordValues(first_account.with_company(company_2.id), [{'code': '100001'}]) + + @unittest.skipUnless(can_import("xlrd.xlsx") or can_import("openpyxl"), "XLRD module not available") + def test_account_move_line_xlsx_import(self): + result = self._create_save_import("account.move.line", self.journal_items_file_content) + + account_move_lines = self.env["account.move.line"].browse(result["ids"]) + self.assertEqual(len(account_move_lines.mapped("move_id").ids), 4, "4 moves should have been created") + self.assertEqual(account_move_lines.mapped("journal_id.code"), ["MISC", "SAL", "BNK1"], "The journals should be set correctly") + self.assertEqual(account_move_lines.mapped("account_id.code"), ["700200", "400000", "455000", "620200"], "The accounts should be set correctly") + account_move_lines.move_id.action_post() + self.assertTrue(account_move_lines.full_reconcile_id) + + @unittest.skipUnless(can_import("xlrd.xlsx") or can_import("openpyxl"), "XLRD module not available") + def test_duplicate_journals_import(self): + existing_journal = self.env["account.journal"].with_context(import_file=True).create({"name": "OD26_18"}) + self.assertEqual(existing_journal.code, 'OD26_') + + result = self._create_save_import("account.move.line", self.duplicate_journals_file_content) + + account_move_lines = self.env["account.move.line"].browse(result["ids"]) + self.assertEqual(len(account_move_lines.mapped("move_id").ids), 3, "3 moves should have been created") + self.assertEqual(sorted(account_move_lines.mapped("journal_id.code")), ["GEN1", "OD26_", "OD_BL"], "The journals should be set correctly") + + def test_import_summary_fields(self): + import_summary = self.env['account.import.summary'].create({ + 'import_summary_account_ids': self._create_records('account.account', 3), + 'import_summary_journal_ids': self._create_records('account.journal', 5), + 'import_summary_move_ids': self._create_records('account.move', 2), + 'import_summary_partner_ids': self._create_records('res.partner', 4), + 'import_summary_tax_ids': self._create_records('account.tax', 6), + }) + self.assertRecordValues(import_summary, [{ + 'import_summary_len_account': 3, + 'import_summary_len_journal': 5, + 'import_summary_len_move': 2, + 'import_summary_len_partner': 4, + 'import_summary_len_tax': 6, + 'import_summary_have_data': True, + }]) + + def test_import_summary_have_data(self): + import_summary = self.env['account.import.summary'].create({}) + self.assertFalse(import_summary.import_summary_have_data) + import_summary = self.env['account.import.summary'].create({'import_summary_move_ids': self._create_records('account.move')}) + self.assertTrue(import_summary.import_summary_have_data) + + @unittest.skipUnless(can_import("xlrd.xlsx") or can_import("openpyxl"), "XLRD module not available") + def test_journal_name_preserved_on_import(self): + """Test that existing journal names are not overwritten when importing journal items.""" + misc_journal = self.env["account.journal"].search([('code', '=', 'MISC'), ('company_id', '=', self.env.company.id)], limit=1) + original_name = misc_journal.name + + result = self._create_save_import("account.move.line", self.journal_items_file_content) + + misc_journal.invalidate_recordset() + self.assertEqual(misc_journal.name, original_name, "Existing journal name should not be overwritten") + self.assertEqual(result["messages"], [], "The import should have been successful without error") diff --git a/dev_odex30_accounting/odex30_account_base_import/views/account_account_views.xml b/dev_odex30_accounting/odex30_account_base_import/views/account_account_views.xml new file mode 100644 index 0000000..5cc3c13 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/views/account_account_views.xml @@ -0,0 +1,24 @@ + + + + + account.base.import.account.account.list + account.account + + + +
+
+
+
+
+ + + Import Chart of Accounts + account_import_action + current + + + +
diff --git a/dev_odex30_accounting/odex30_account_base_import/views/account_import_views.xml b/dev_odex30_accounting/odex30_account_base_import/views/account_import_views.xml new file mode 100644 index 0000000..ac9cf0d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/views/account_import_views.xml @@ -0,0 +1,18 @@ + + + + + Accounting Import Guide + account_import_guide + current + + + + Chart of Accounts + account.account + list + + + + + diff --git a/dev_odex30_accounting/odex30_account_base_import/views/account_move_views.xml b/dev_odex30_accounting/odex30_account_base_import/views/account_move_views.xml new file mode 100644 index 0000000..d9629be --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/views/account_move_views.xml @@ -0,0 +1,11 @@ + + + + + Import Journal Items + account_import_action + current + + + + diff --git a/dev_odex30_accounting/odex30_account_base_import/views/res_config_settings_views.xml b/dev_odex30_accounting/odex30_account_base_import/views/res_config_settings_views.xml new file mode 100644 index 0000000..bfeca6a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/views/res_config_settings_views.xml @@ -0,0 +1,28 @@ + + + + res.config.settings.view.form.inherit.account.base.import + res.config.settings + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_base_import/views/res_partner_views.xml b/dev_odex30_accounting/odex30_account_base_import/views/res_partner_views.xml new file mode 100644 index 0000000..32a2132 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/views/res_partner_views.xml @@ -0,0 +1,11 @@ + + + + + Import Partners + account_import_action + current + + + + diff --git a/dev_odex30_accounting/odex30_account_base_import/wizard/__init__.py b/dev_odex30_accounting/odex30_account_base_import/wizard/__init__.py new file mode 100644 index 0000000..30a5fcf --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/wizard/__init__.py @@ -0,0 +1,3 @@ + +from . import account_import_summary +from . import account_move_line_import diff --git a/dev_odex30_accounting/odex30_account_base_import/wizard/account_import_summary.py b/dev_odex30_accounting/odex30_account_base_import/wizard/account_import_summary.py new file mode 100644 index 0000000..86eb45a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/wizard/account_import_summary.py @@ -0,0 +1,99 @@ + +from odoo import api, fields, models, _ + + +class AccountImportSummary(models.TransientModel): + _name = "account.import.summary" + _description = "Account import summary view" + _rec_name = 'import_summary_name' + + import_summary_account_ids = fields.Many2many('account.account') + import_summary_journal_ids = fields.Many2many('account.journal') + import_summary_move_ids = fields.Many2many('account.move') + import_summary_partner_ids = fields.Many2many('res.partner') + import_summary_tax_ids = fields.Many2many('account.tax') + + import_summary_name = fields.Char(default="Import Summary") + import_summary_len_account = fields.Integer(compute='_compute_import_summary_len_account', export_string_translation=False) + import_summary_len_journal = fields.Integer(compute='_compute_import_summary_len_journal', export_string_translation=False) + import_summary_len_move = fields.Integer(compute='_compute_import_summary_len_move', export_string_translation=False) + import_summary_len_partner = fields.Integer(compute='_compute_import_summary_len_partner', export_string_translation=False) + import_summary_len_tax = fields.Integer(compute='_compute_import_summary_len_tax', export_string_translation=False) + import_summary_have_data = fields.Boolean(compute='_compute_import_summary_have_data', export_string_translation=False) + + @api.depends('import_summary_account_ids') + def _compute_import_summary_len_account(self): + for record in self: + record.import_summary_len_account = len(record.import_summary_account_ids) + + @api.depends('import_summary_journal_ids') + def _compute_import_summary_len_journal(self): + for record in self: + record.import_summary_len_journal = len(record.import_summary_journal_ids) + + @api.depends('import_summary_move_ids') + def _compute_import_summary_len_move(self): + for record in self: + record.import_summary_len_move = len(record.import_summary_move_ids) + + @api.depends('import_summary_partner_ids') + def _compute_import_summary_len_partner(self): + for record in self: + record.import_summary_len_partner = len(record.import_summary_partner_ids) + + @api.depends('import_summary_tax_ids') + def _compute_import_summary_len_tax(self): + for record in self: + record.import_summary_len_tax = len(record.import_summary_tax_ids) + + @api.depends( + 'import_summary_account_ids', + 'import_summary_journal_ids', + 'import_summary_move_ids', + 'import_summary_partner_ids', + 'import_summary_tax_ids' + ) + def _compute_import_summary_have_data(self): + for record in self: + record.import_summary_have_data = bool( + record.import_summary_account_ids or + record.import_summary_journal_ids or + record.import_summary_move_ids or + record.import_summary_partner_ids or + record.import_summary_tax_ids + ) + + def action_open_summary_view(self): + self.ensure_one() + return { + "name": _("Import Summary"), + "type": "ir.actions.act_window", + "res_id": self.id, + "view_mode": "form", + "res_model": "account.import.summary", + } + + def action_open_account_view(self): + action = self.env['ir.actions.act_window']._for_xml_id('odex30_account_base_import.action_open_coa_setup') + action['domain'] = [('id', 'in', self.import_summary_account_ids.ids)] + return action + + def action_open_journal_view(self): + action = self.env['ir.actions.act_window']._for_xml_id('account.action_account_journal_form') + action['domain'] = [('id', 'in', self.import_summary_journal_ids.ids)] + return action + + def action_open_move_view(self): + action = self.env['ir.actions.act_window']._for_xml_id('account.action_move_line_form') + action['domain'] = [('id', 'in', self.import_summary_move_ids.ids)] + return action + + def action_open_partner_view(self): + action = self.env['ir.actions.act_window']._for_xml_id('base.action_partner_form') + action['domain'] = [('id', 'in', self.import_summary_partner_ids.ids)] + return action + + def action_open_tax_view(self): + action = self.env['ir.actions.act_window']._for_xml_id('account.action_tax_form') + action['domain'] = [('id', 'in', self.import_summary_tax_ids.ids)] + return action diff --git a/dev_odex30_accounting/odex30_account_base_import/wizard/account_import_summary_views.xml b/dev_odex30_accounting/odex30_account_base_import/wizard/account_import_summary_views.xml new file mode 100644 index 0000000..23bd2e2 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/wizard/account_import_summary_views.xml @@ -0,0 +1,53 @@ + + + + + account.import.summary.form + account.import.summary + +
+ + + + + + + +

Imported Data

+
+ +
+ No data was imported. +
+
+
+
+
+ +
diff --git a/dev_odex30_accounting/odex30_account_base_import/wizard/account_move_line_import.py b/dev_odex30_accounting/odex30_account_base_import/wizard/account_move_line_import.py new file mode 100644 index 0000000..d4c094a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/wizard/account_move_line_import.py @@ -0,0 +1,31 @@ + +from odoo import api, models +from odoo.addons.base_import.models.base_import import FIELDS_RECURSION_LIMIT + + +class AccountMoveLineImport(models.TransientModel): + _inherit = "base_import.import" + + @api.model + def get_fields_tree(self, model, depth=FIELDS_RECURSION_LIMIT): + + if model != "account.move.line": + return super().get_fields_tree(model, depth=depth) + fields_list = super().get_fields_tree(model, depth=depth) + Model = self.env[model] + model_fields = Model.fields_get() + add_fields = [] + for field in ("move_id", "journal_id", "date"): + field_value = { + "id": field, + "name": field, + "string": model_fields[field]["string"], + "required": bool(model_fields[field].get("required")), + "fields": [], + "type": model_fields[field]["type"], + "model_name": model + } + add_fields.append(field_value) + fields_list.extend(add_fields) + + return fields_list diff --git a/dev_odex30_accounting/odex30_account_base_import/wizard/setup_wizards_views.xml b/dev_odex30_accounting/odex30_account_base_import/wizard/setup_wizards_views.xml new file mode 100644 index 0000000..40b421f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_base_import/wizard/setup_wizards_views.xml @@ -0,0 +1,17 @@ + + + + + account.setup.opening.account.account.list.account.base.import + account.account + + + +
+
+
+
+
+ +
diff --git a/dev_odex30_accounting/odex30_account_extract/__init__.py b/dev_odex30_accounting/odex30_account_extract/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_extract/__init__.py @@ -0,0 +1 @@ + diff --git a/dev_odex30_accounting/odex30_account_extract/__manifest__.py b/dev_odex30_accounting/odex30_account_extract/__manifest__.py new file mode 100644 index 0000000..e4759bd --- /dev/null +++ b/dev_odex30_accounting/odex30_account_extract/__manifest__.py @@ -0,0 +1,9 @@ +{ + 'name': 'Account Extract', + 'version': '1.0', + 'category': 'Accounting/Accounting', + 'summary': 'Digitise and extract data from documents.', + 'depends': ['account', 'iap_extract', 'iap_mail', 'odex30_mail', 'odex30_account_bank_statement_import'], + 'auto_install': True, + 'license': 'OEEL-1', +} diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/__init__.py b/dev_odex30_accounting/odex30_account_invoice_extract/__init__.py new file mode 100644 index 0000000..4dcac26 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/__init__.py @@ -0,0 +1,3 @@ + +from . import controllers +from . import models diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/__manifest__.py b/dev_odex30_accounting/odex30_account_invoice_extract/__manifest__.py new file mode 100644 index 0000000..7b8824f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/__manifest__.py @@ -0,0 +1,28 @@ + +{ + 'name': 'Account Invoice Extract', + 'version': '18.0', + 'category': 'Accounting/Accounting', + 'summary': 'Extract data from invoice scans to fill them automatically', + 'author': "Expert Co. Ltd.", + 'website': "http://www.exp-sa.com", + 'depends': ['odex30_account_extract'], + 'data': [ + 'security/ir.model.access.csv', + 'data/crons.xml', + 'views/account_move_views.xml', + 'views/res_config_settings_views.xml', + ], + 'auto_install': True, + 'license': 'OEEL-1', + 'assets': { + 'web.assets_backend': [ + 'odex30_account_invoice_extract/static/src/js/*.js', + 'odex30_account_invoice_extract/static/src/css/*.css', + 'odex30_account_invoice_extract/static/src/xml/*.xml', + ], + 'web.assets_unit_tests': [ + 'odex30_account_invoice_extract/static/src/tests/**/*', + ], + } +} diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/controllers/__init__.py b/dev_odex30_accounting/odex30_account_invoice_extract/controllers/__init__.py new file mode 100644 index 0000000..8b6d05e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/controllers/__init__.py @@ -0,0 +1,2 @@ + +from . import main diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/controllers/main.py b/dev_odex30_accounting/odex30_account_invoice_extract/controllers/main.py new file mode 100644 index 0000000..9eba472 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/controllers/main.py @@ -0,0 +1,14 @@ + +from odoo import http +from odoo.http import request + + +class AccountInvoiceExtractController(http.Controller): + @http.route('/odex30_account_invoice_extract/request_done/', type='http', auth='public', csrf=False) + def request_done(self, extract_document_uuid): + move_to_update = request.env['account.move'].sudo().search([('extract_document_uuid', '=', extract_document_uuid), + ('extract_state', 'in', ['waiting_extraction', 'extract_not_ready']), + ('state', '=', 'draft')]) + for move in move_to_update: + move._check_ocr_status() + return 'OK' diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/data/crons.xml b/dev_odex30_accounting/odex30_account_invoice_extract/data/crons.xml new file mode 100644 index 0000000..587a48a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/data/crons.xml @@ -0,0 +1,22 @@ + + + + + Invoice OCR: Update All Status + + code + model.check_all_status() + 1 + days + + + + Invoice OCR: Validate Invoices + + code + model._cron_validate() + 1 + days + + + diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/i18n/ar.po b/dev_odex30_accounting/odex30_account_invoice_extract/i18n/ar.po new file mode 100644 index 0000000..3716280 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/i18n/ar.po @@ -0,0 +1,395 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_invoice_extract +# +# Translators: +# Wil Odoo, 2024 +# Malaz Abuidris , 2024 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-09-25 09:26+0000\n" +"PO-Revision-Date: 2024-09-25 09:44+0000\n" +"Last-Translator: Malaz Abuidris , 2024\n" +"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__message_needaction +msgid "Action Needed" +msgstr "إجراء مطلوب" + +#. module: odex30_account_invoice_extract +#: model:ir.model,name:odex30_account_invoice_extract.model_ir_attachment +msgid "Attachment" +msgstr "مرفق" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__message_attachment_count +msgid "Attachment Count" +msgstr "عدد المرفقات" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_bank_statement_line__extract_can_show_banners +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__extract_can_show_banners +msgid "Can show the ocr banners" +msgstr "بإمكانه عرضعارضات بتمييز الرموز ضوئياً " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__extract_can_show_send_button +msgid "Can show the ocr send button" +msgstr "بإمكانه عرض زر إرسال ملف بتمييز الرموز ضوئياً " + +#. module: odex30_account_invoice_extract +#: model:ir.model,name:odex30_account_invoice_extract.model_res_company +msgid "Companies" +msgstr "الشركات" + +#. module: odex30_account_invoice_extract +#: model:ir.model,name:odex30_account_invoice_extract.model_res_config_settings +msgid "Config Settings" +msgstr "تهيئة الإعدادات " + +#. module: odex30_account_invoice_extract +#. odoo-python +#: code:addons/odex30_account_invoice_extract/models/account_invoice.py:0 +msgid "Couldn't reload AI data." +msgstr "تعذر تحميل معلومات الذكاء الاصطناعي " + +#. module: odex30_account_invoice_extract +#. odoo-javascript +#: code:addons/odex30_account_invoice_extract/static/src/js/invoice_extract_form_renderer.js:0 +msgid "Create" +msgstr "إنشاء" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__create_uid +msgid "Created by" +msgstr "أنشئ بواسطة" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__create_date +msgid "Created on" +msgstr "أنشئ في" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_res_config_settings__extract_out_invoice_digitalization_mode +msgid "Customer Invoices" +msgstr "فواتير العملاء" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_res_company__extract_out_invoice_digitalization_mode +msgid "Digitization mode on customer invoices" +msgstr "وضع الرقمنة في فواتير العملاء " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_res_company__extract_in_invoice_digitalization_mode +msgid "Digitization mode on vendor bills" +msgstr "وضع الرقمنة في فواتير الموردين " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields.selection,name:odex30_account_invoice_extract.selection__res_company__extract_in_invoice_digitalization_mode__auto_send +#: model:ir.model.fields.selection,name:odex30_account_invoice_extract.selection__res_company__extract_out_invoice_digitalization_mode__auto_send +msgid "Digitize automatically" +msgstr "الرقمنة تلقائياً " + +#. module: odex30_account_invoice_extract +#: model_terms:ir.ui.view,arch_db:odex30_account_invoice_extract.view_move_form_inherit_ocr +msgid "Digitize document" +msgstr "رقمنة المستند " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields.selection,name:odex30_account_invoice_extract.selection__res_company__extract_in_invoice_digitalization_mode__manual_send +#: model:ir.model.fields.selection,name:odex30_account_invoice_extract.selection__res_company__extract_out_invoice_digitalization_mode__manual_send +msgid "Digitize on demand only" +msgstr "رقمنة عند الطلب فقط" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__display_name +msgid "Display Name" +msgstr "اسم العرض " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields.selection,name:odex30_account_invoice_extract.selection__res_company__extract_in_invoice_digitalization_mode__no_send +#: model:ir.model.fields.selection,name:odex30_account_invoice_extract.selection__res_company__extract_out_invoice_digitalization_mode__no_send +msgid "Do not digitize" +msgstr "لا تقم بالرقمنة" + +#. module: odex30_account_invoice_extract +#: model_terms:ir.ui.view,arch_db:odex30_account_invoice_extract.res_config_settings_view_form +msgid "Enable to get only one invoice line per tax" +msgstr "التمكين للحصول على بند فاتورة واحد لكل ضريبة " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__extract_error_message +msgid "Error message" +msgstr "رسالة خطأ" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_bank_statement_line__extract_attachment_id +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__extract_attachment_id +msgid "Extract Attachment" +msgstr "استخراج المرفق " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_bank_statement_line__extract_detected_layout +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__extract_detected_layout +msgid "Extract Detected Layout Id" +msgstr "استخلاص معرف المخطط الذي تم رصده " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_bank_statement_line__extract_partner_name +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__extract_partner_name +msgid "Extract Detected Partner Name" +msgstr "استخلاص اسم الشريك الذي تم رصده " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_bank_statement_line__extract_prefill_data +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__extract_prefill_data +msgid "Extract Prefill Data" +msgstr "استخلاص البيانات التي تم ملؤها مسبقاً " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__extract_state_processed +msgid "Extract State Processed" +msgstr "تمت معالجة حالة الاستخلاص " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_bank_statement_line__extract_word_ids +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__extract_word_ids +msgid "Extract Word" +msgstr "استخلاص كلمة " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__extract_state +msgid "Extract state" +msgstr "استخلاص حالة " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__extract_status +msgid "Extract status" +msgstr "حالة الاستخلاص " + +#. module: odex30_account_invoice_extract +#: model:ir.model,name:odex30_account_invoice_extract.model_account_invoice_extract_words +msgid "Extracted words from invoice scan" +msgstr "الكلمات المستخرجة من المسح الضوئي للفاتورة" + +#. module: odex30_account_invoice_extract +#: model_terms:ir.ui.view,arch_db:odex30_account_invoice_extract.view_move_form_inherit_ocr +msgid "Extraction Information" +msgstr "معلومات الاستخلاص " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__field +msgid "Field" +msgstr "حقل" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__message_follower_ids +msgid "Followers" +msgstr "المتابعين" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__message_partner_ids +msgid "Followers (Partners)" +msgstr "المتابعين (الشركاء) " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__has_message +msgid "Has Message" +msgstr "يحتوي على رسالة " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__id +msgid "ID" +msgstr "المُعرف" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__extract_document_uuid +msgid "ID of the request to IAP-OCR" +msgstr "مُعرف طلب IAP-OCR" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,help:odex30_account_invoice_extract.field_account_move__message_needaction +msgid "If checked, new messages require your attention." +msgstr "إذا كان محددًا، فهناك رسائل جديدة عليك رؤيتها. " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,help:odex30_account_invoice_extract.field_account_move__message_has_error +#: model:ir.model.fields,help:odex30_account_invoice_extract.field_account_move__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "إذا كان محددًا، فقد حدث خطأ في تسليم بعض الرسائل." + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__invoice_id +msgid "Invoice" +msgstr "الفاتورة" + +#. module: odex30_account_invoice_extract +#: model:ir.actions.server,name:odex30_account_invoice_extract.ir_cron_update_ocr_status_ir_actions_server +msgid "Invoice OCR: Update All Status" +msgstr "OCR الفاتورة: تحديث كافة الحالات " + +#. module: odex30_account_invoice_extract +#: model:ir.actions.server,name:odex30_account_invoice_extract.ir_cron_ocr_validate_ir_actions_server +msgid "Invoice OCR: Validate Invoices" +msgstr "تمييز رموز الفاتورة ضوئياً (OCR): تصديق الفواتير " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__message_is_follower +msgid "Is Follower" +msgstr "متابع" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__is_in_extractable_state +msgid "Is In Extractable State" +msgstr "في حالة قابلة للاستخلاص " + +#. module: odex30_account_invoice_extract +#: model:ir.model,name:odex30_account_invoice_extract.model_account_move +msgid "Journal Entry" +msgstr "قيد اليومية" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__write_uid +msgid "Last Updated by" +msgstr "آخر تحديث بواسطة" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__write_date +msgid "Last Updated on" +msgstr "آخر تحديث في" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__message_main_attachment_id +msgid "Main Attachment" +msgstr "المرفق الرئيسي" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__message_has_error +msgid "Message Delivery error" +msgstr "خطأ في تسليم الرسائل" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__message_ids +msgid "Messages" +msgstr "الرسائل" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__message_needaction_counter +msgid "Number of Actions" +msgstr "عدد الإجراءات" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__message_has_error_counter +msgid "Number of errors" +msgstr "عدد الأخطاء " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,help:odex30_account_invoice_extract.field_account_move__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "عدد الرسائل التي تتطلب اتخاذ إجراء" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,help:odex30_account_invoice_extract.field_account_move__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "عدد الرسائل الحادث بها خطأ في التسليم" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__ocr_selected +msgid "Ocr Selected" +msgstr "تم تحديد تمييز الرموز ضوئياً " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__rating_ids +msgid "Ratings" +msgstr "التقييمات " + +#. module: odex30_account_invoice_extract +#: model_terms:ir.ui.view,arch_db:odex30_account_invoice_extract.view_move_form_inherit_ocr +msgid "Reload AI Data" +msgstr "إعادة تحميل بيانات الذكاء الاصطناعي " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__message_has_sms_error +msgid "SMS Delivery error" +msgstr "خطأ في تسليم الرسائل النصية القصيرة " + +#. module: odex30_account_invoice_extract +#: model:ir.actions.server,name:odex30_account_invoice_extract.model_account_send_for_digitalization +msgid "Send Bills for digitization" +msgstr "إرسال فواتير الموردين من أجل الرقمنة " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_res_company__extract_single_line_per_tax +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_res_config_settings__extract_single_line_per_tax +msgid "Single Invoice Line Per Tax" +msgstr "بند فاتورة واحد لكل ضريبة " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__user_selected +msgid "User Selected" +msgstr "المستخدم المحدد " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_res_config_settings__extract_in_invoice_digitalization_mode +msgid "Vendor Bills" +msgstr "فواتير المورد" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_move__website_message_ids +msgid "Website Messages" +msgstr "رسائل الموقع الإلكتروني " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,help:odex30_account_invoice_extract.field_account_move__website_message_ids +msgid "Website communication history" +msgstr "سجل تواصل الموقع الإلكتروني " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__word_box_angle +msgid "Word Box Angle" +msgstr "زاوية مربع النص" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__word_box_height +msgid "Word Box Height" +msgstr "ارتفاع مربع النص" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__word_box_midX +msgid "Word Box Midx" +msgstr "المنتصف الأفقي لمربع النص" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__word_box_midY +msgid "Word Box Midy" +msgstr "المنتصف الرأسي لمربع النص" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__word_box_width +msgid "Word Box Width" +msgstr "عرض مربع النص" + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__word_page +msgid "Word Page" +msgstr "صفحة Word " + +#. module: odex30_account_invoice_extract +#: model:ir.model.fields,field_description:odex30_account_invoice_extract.field_account_invoice_extract_words__word_text +msgid "Word Text" +msgstr "نص Word " + +#. module: odex30_account_invoice_extract +#. odoo-python +#: code:addons/odex30_account_invoice_extract/models/account_invoice.py:0 +msgid "You cannot send a expense that is not in draft state!" +msgstr "لا يمكنك إرسال نفقة ليست بحالة المسودة! " diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/models/__init__.py b/dev_odex30_accounting/odex30_account_invoice_extract/models/__init__.py new file mode 100644 index 0000000..83abedd --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/models/__init__.py @@ -0,0 +1,5 @@ + +from . import account_invoice +from . import ir_attachment +from . import res_config_settings +from . import res_company diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/models/account_invoice.py b/dev_odex30_accounting/odex30_account_invoice_extract/models/account_invoice.py new file mode 100644 index 0000000..3f8e0e0 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/models/account_invoice.py @@ -0,0 +1,927 @@ +import copy +import json +import logging +import re +from difflib import SequenceMatcher +from stdnum.eu.vat import guess_country + +from odoo import api, fields, models, Command +from odoo.addons.iap.tools import iap_tools +from odoo.exceptions import AccessError +from odoo.tools import _, float_compare +from odoo.tools.misc import clean_context, formatLang + +_logger = logging.getLogger(__name__) + +PARTNER_AUTOCOMPLETE_ENDPOINT = 'https://partner-autocomplete.odoo.com' +OCR_VERSION = 122 + + +class AccountInvoiceExtractionWords(models.Model): + _name = "account.invoice_extract.words" + _description = "Extracted words from invoice scan" + + invoice_id = fields.Many2one("account.move", required=True, ondelete='cascade', index=True, string="Invoice") + field = fields.Char() + + ocr_selected = fields.Boolean() + user_selected = fields.Boolean() + word_text = fields.Char() + word_page = fields.Integer() + word_box_midX = fields.Float() + word_box_midY = fields.Float() + word_box_width = fields.Float() + word_box_height = fields.Float() + word_box_angle = fields.Float() + + +class AccountMove(models.Model): + _name = 'account.move' + _inherit = ['extract.mixin', 'account.move'] + + @api.depends('state') + def _compute_is_in_extractable_state(self): + for record in self: + record.is_in_extractable_state = record.state == 'draft' and record.is_invoice() + + @api.depends( + 'state', + 'extract_state', + 'move_type', + 'company_id.extract_in_invoice_digitalization_mode', + 'company_id.extract_out_invoice_digitalization_mode', + ) + def _compute_show_banners(self): + for record in self: + record.extract_can_show_banners = ( + record.state == 'draft' and + ( + (record.is_purchase_document() and record.company_id.extract_in_invoice_digitalization_mode != 'no_send') or + (record.is_sale_document() and record.company_id.extract_out_invoice_digitalization_mode != 'no_send') + ) + ) + + extract_prefill_data = fields.Json() + extract_word_ids = fields.One2many("account.invoice_extract.words", inverse_name="invoice_id", copy=False) + extract_attachment_id = fields.Many2one('ir.attachment', readonly=True, ondelete='set null', copy=False, index='btree_not_null') + extract_can_show_banners = fields.Boolean("Can show the ocr banners", compute=_compute_show_banners) + + extract_detected_layout = fields.Integer("Extract Detected Layout Id", readonly=True) + extract_partner_name = fields.Char("Extract Detected Partner Name", readonly=True) + + def action_reload_ai_data(self): + self = self.with_context(skip_is_manually_modified=True) + try: + with self._get_edi_creation() as move_form: + move_form.partner_id = False + move_form.invoice_date = False + move_form.invoice_payment_term_id = False + move_form.invoice_date_due = False + + if move_form.is_purchase_document(): + move_form.ref = False + elif move_form.is_sale_document() and move_form.quick_edit_mode: + move_form.name = False + + move_form.payment_reference = False + move_form.currency_id = move_form.company_currency_id + move_form.invoice_line_ids = [Command.clear()] + self._check_ocr_status() + except Exception as e: + _logger.warning("Error while reloading AI data on account.move %d: %s", self.id, e) + raise AccessError(self.env._("Couldn't reload AI data.")) + + @api.model + def _contact_iap_extract(self, pathinfo, params): + params['version'] = OCR_VERSION + params['account_token'] = self._get_iap_account().account_token + endpoint = self.env['ir.config_parameter'].sudo().get_param('iap_extract_endpoint', 'https://extract.api.odoo.com') + return iap_tools.iap_jsonrpc(endpoint + '/api/extract/invoice/2/' + pathinfo, params=params) + + @api.model + def _contact_iap_partner_autocomplete(self, local_endpoint, params): + return iap_tools.iap_jsonrpc(PARTNER_AUTOCOMPLETE_ENDPOINT + local_endpoint, params=params) + + def _check_digitalization_mode(self, company, document_type, mode): + if document_type in self.get_purchase_types(): + return company.extract_in_invoice_digitalization_mode == mode + elif document_type in self.get_sale_types(): + return company.extract_out_invoice_digitalization_mode == mode + + def _needs_auto_extract(self, new_document=False): + self.ensure_one() + + if ( + self.extract_state != "no_extract_requested" + or not self._check_digitalization_mode(self.company_id, self.move_type, 'auto_send') + or not self.is_in_extractable_state + ): + return False + + if new_document: + return True + + return self.is_purchase_document() + + def _get_ocr_module_name(self): + return 'odex30_account_invoice_extract' + + def _get_ocr_option_can_extract(self): + self.ensure_one() + return not self._check_digitalization_mode(self.company_id, self.move_type, 'no_send') + + def _get_validation_domain(self): + base_domain = super()._get_validation_domain() + return base_domain + [('state', '=', 'posted')] + + def _get_validation_fields(self): + return [ + 'total', 'subtotal', 'total_tax_amount', 'date', 'due_date', 'invoice_id', 'partner', + 'VAT_Number', 'currency', 'payment_ref', 'iban', 'SWIFT_code', 'merged_lines', 'invoice_lines', + ] + + def _get_user_error_invalid_state_message(self): + return _("You cannot send a expense that is not in draft state!") + + def _upload_to_extract_success_callback(self): + super()._upload_to_extract_success_callback() + self.extract_attachment_id = self.message_main_attachment_id + + def is_indian_taxes(self): + l10n_in = self.env['ir.module.module'].sudo().search([('name', '=', 'l10n_in')]) + return self.company_id.country_id.code == "IN" and l10n_in and l10n_in.state == 'installed' + + def _get_user_infos(self): + user_infos = super()._get_user_infos() + user_infos.update({ + 'user_company_VAT': self.company_id.vat, + 'user_company_name': self.company_id.name, + 'user_company_country_code': self.company_id.country_id.code, + 'perspective': 'supplier' if self.is_sale_document() else 'client', + }) + return user_infos + + def _upload_to_extract(self): + self.ensure_one() + if self.is_invoice(): + super()._upload_to_extract() + + def _get_validation(self, field): + + text_to_send = {} + if field == "total": + text_to_send["content"] = self.amount_total + elif field == "subtotal": + text_to_send["content"] = self.amount_untaxed + elif field == "total_tax_amount": + text_to_send["content"] = self.amount_tax + elif field == "date": + text_to_send["content"] = str(self.invoice_date) if self.invoice_date else False + elif field == "due_date": + text_to_send["content"] = str(self.invoice_date_due) if self.invoice_date_due else False + elif field == "invoice_id": + if self.is_purchase_document(): + text_to_send["content"] = self.ref + else: + text_to_send["content"] = self.name + elif field == "partner": + text_to_send["content"] = self.partner_id.name + elif field == "VAT_Number": + text_to_send["content"] = self.partner_id.vat + elif field == "currency": + text_to_send["content"] = self.currency_id.name + elif field == "payment_ref": + text_to_send["content"] = self.payment_reference + elif field == "iban": + text_to_send["content"] = self.partner_bank_id.acc_number if self.partner_bank_id else False + elif field == "SWIFT_code": + text_to_send["content"] = self.partner_bank_id.bank_bic if self.partner_bank_id else False + elif field == 'merged_lines': + return self.env.company.extract_single_line_per_tax + elif field == "invoice_lines": + text_to_send = {'lines': []} + for il in self.invoice_line_ids: + line = { + "description": il.name, + "quantity": il.quantity, + "unit_price": il.price_unit, + "product": il.product_id.id, + "taxes_amount": round(il.price_total - il.price_subtotal, 2), + "taxes": [{ + 'amount': tax.amount, + 'type': tax.amount_type, + 'price_include': tax.price_include} for tax in il.tax_ids], + "subtotal": il.price_subtotal, + "total": il.price_total + } + text_to_send['lines'].append(line) + if self.is_indian_taxes(): + lines = text_to_send['lines'] + for index, line in enumerate(text_to_send['lines']): + for tax in line['taxes']: + taxes = [] + if tax['type'] == 'group': + taxes.extend([{ + 'amount': tax['amount'] / 2, + 'type': 'percent', + 'price_include': tax['price_include'] + } for _ in range(2)]) + else: + taxes.append(tax) + lines[index]['taxes'] = taxes + text_to_send['lines'] = lines + else: + return None + + user_selected_box = self.env['account.invoice_extract.words'].search([ + ('invoice_id', '=', self.id), + ('field', '=', field), + ('user_selected', '=', True), + ('ocr_selected', '=', False), + ]) + if user_selected_box and user_selected_box.word_text == text_to_send['content']: + text_to_send['box'] = [ + user_selected_box.word_text, + user_selected_box.word_page, + user_selected_box.word_box_midX, + user_selected_box.word_box_midY, + user_selected_box.word_box_width, + user_selected_box.word_box_height, + user_selected_box.word_box_angle, + ] + return text_to_send + + @api.model + def _cron_validate(self): + validated = super()._cron_validate() + validated.mapped('extract_word_ids').unlink() + for record in validated: + record.extract_prefill_data = None + return validated + + def _post(self, soft=True): + posted = super()._post(soft) + self.with_context(skip_is_manually_modified=True)._validate_ocr() + return posted + + def get_boxes(self): + return [{ + "id": data.id, + "feature": data.field, + "text": data.word_text, + "ocr_selected": data.ocr_selected, + "user_selected": data.user_selected, + "page": data.word_page, + "box_midX": data.word_box_midX, + "box_midY": data.word_box_midY, + "box_width": data.word_box_width, + "box_height": data.word_box_height, + "box_angle": data.word_box_angle} for data in self.extract_word_ids] + + def get_partner_create_data(self, context): + default_values = self.extract_prefill_data + if values := self._fetch_autocomplete_values(context.get('default_vat') or default_values.get('vat')): + default_values |= values + return {f'default_{k}': v for k, v in default_values.items()} + + def set_user_selected_box(self, id): + + self.ensure_one() + word = self.env["account.invoice_extract.words"].browse(int(id)) + to_unselect = self.env["account.invoice_extract.words"].search([("invoice_id", "=", self.id), ("field", "=", word.field), ("user_selected", "=", True)]) + for box in to_unselect: + box.user_selected = False + + word.user_selected = True + if word.field == "currency": + text = word.word_text + currency = None + currencies = self.env["res.currency"].search([]) + for curr in currencies: + if text == curr.currency_unit_label: + currency = curr + if text == curr.name or text == curr.symbol: + currency = curr + if currency: + return currency.id + return self.currency_id.id + if word.field == "VAT_Number": + partner_vat = False + if word.word_text != "": + partner_vat = self._find_partner_id_with_vat(word.word_text) + if partner_vat: + return partner_vat.id + else: + vat = word.word_text + partner = self._create_supplier_from_vat(vat) + if partner and self.is_purchase_document(): + self.partner_id = partner + return [partner.id, self.partner_bank_id.id] if partner else False + + if word.field == "supplier": + return self._find_partner_id_with_name(word.word_text) + return word.word_text + + def _find_partner_from_previous_extracts(self): + + match_conditions = [ + ('extract_detected_layout', '=', self.extract_detected_layout), + ('extract_partner_name', '=', self.extract_partner_name), + ] + for condition in match_conditions: + invoice_layout = self.search([ + condition, + ('extract_state', '=', 'done'), + ('move_type', '=', self.move_type), + ('company_id', '=', self.company_id.id), + ], limit=1000, order='id desc') + if invoice_layout: + break + + if len(invoice_layout.mapped('partner_id')) == 1: + return invoice_layout.partner_id + return None + + def _find_partner_id_with_vat(self, vat_number_ocr): + rank_field = 'supplier_rank' if self.is_purchase_document() else 'customer_rank' + partner_vat = self.env["res.partner"].search([ + *self.env['res.partner']._check_company_domain(self.company_id), + ("vat", "=ilike", vat_number_ocr), + ], order=f'{rank_field} desc', limit=1) + if not partner_vat: + partner_vat = self.env["res.partner"].search([ + *self.env['res.partner']._check_company_domain(self.company_id), + ("vat", "=ilike", vat_number_ocr[2:]), + ], order=f'{rank_field} desc', limit=1) + if not partner_vat: + for partner in self.env["res.partner"].search([ + *self.env['res.partner']._check_company_domain(self.company_id), + ("vat", "!=", False), + ], order=f'{rank_field} desc', limit=1000): + vat = partner.vat.upper() + vat_cleaned = vat.replace("BTW", "").replace("MWST", "").replace("ABN", "") + vat_cleaned = re.sub(r'[^A-Z0-9]', '', vat_cleaned) + if vat_cleaned == vat_number_ocr or vat_cleaned == vat_number_ocr[2:]: + partner_vat = partner + break + return partner_vat + + def _fetch_autocomplete_values(self, vat_number): + if not vat_number: + return None + + try: + response, error = self.env['iap.autocomplete.api']._request_partner_autocomplete('enrich_by_vat', { + 'vat': vat_number, + }) + if error: + raise Exception(error) + if 'credit_error' in response and response['credit_error']: + _logger.warning("Credit error on partner_autocomplete call") + except KeyError: + _logger.warning("Partner autocomplete isn't installed, supplier creation from VAT is disabled") + return None + except Exception as exception: + _logger.error('Check VAT error: %s' % str(exception)) + return None + + if response and response.get('data'): + country_id = self.env['res.country'].search([('code', '=', response.get('data').get('country_code',''))]) + resp_values = response.get('data') + + values = {field: resp_values[field] for field in ('name', 'vat', 'street', 'city', 'zip', 'phone', 'email') if field in resp_values} + values['is_company'] = True + + if country_id: + values['country_id'] = country_id.id + state_id = self.env['res.country.state'].search([ + ('name', '=', response.get('data').get('state_name','')), + ('country_id', '=', country_id.id), + ], limit = 1) + if state_id: + values['state_id'] = state_id.id + return values + return None + + def _create_supplier_from_vat(self, vat_number_ocr): + values = self._fetch_autocomplete_values(vat_number_ocr) + if not values: + return False + + for field, val in (self.extract_prefill_data or {}).items(): + if field not in values: + values[field] = val + return self.env["res.partner"].with_context(clean_context(self.env.context)).create(values) + + def _find_partner_id_with_name(self, partner_name): + if not partner_name: + return 0 + + rank_field = 'supplier_rank' if self.is_purchase_document() else 'customer_rank' + partner = self.env["res.partner"].search([ + *self.env['res.partner']._check_company_domain(self.company_id), + ("name", "=", partner_name), + ], order=f'{rank_field} desc', limit=1) + if partner: + return partner.id if partner.id != self.company_id.partner_id.id else 0 + + self.env.cr.execute(*self.env['res.partner']._where_calc([ + *self.env['res.partner']._check_company_domain(self.company_id), + ('active', '=', True), + ('name', '!=', False), + (rank_field, '>', 0), + ]).select('res_partner.id', 'res_partner.name')) + + partners_dict = {name.lower().replace('-', ' '): partner_id for partner_id, name in self.env.cr.fetchall()} + partner_name = partner_name.lower().strip() + + partners = {} + for single_word in [word for word in re.findall(r"\w+", partner_name) if len(word) >= 3]: + partners_matched = [partner for partner in partners_dict if single_word in partner.split()] + for partner in partners_matched: + # Record only if the whole sequence is a very close match + if SequenceMatcher(None, partner.lower(), partner_name.lower()).ratio() > 0.8: + partners[partner] = partners[partner] + 1 if partner in partners else 1 + + if partners: + sorted_partners = sorted(partners, key=partners.get, reverse=True) + if len(sorted_partners) == 1 or partners[sorted_partners[0]] != partners[sorted_partners[1]]: + partner = sorted_partners[0] + if partners_dict[partner] != self.company_id.partner_id.id: + return partners_dict[partner] + return 0 + + def _find_partner_with_iban(self, iban_ocr, partner_name): + bank_accounts = self.env['res.partner.bank'].search([ + *self.env['res.partner.bank']._check_company_domain(self.company_id), + ('acc_number', '=ilike', iban_ocr), + ]) + + bank_account_match_ratios = sorted([ + (account, SequenceMatcher(None, partner_name.lower(), account.partner_id.name.lower()).ratio()) + for account in bank_accounts + ], key=lambda x: x[1], reverse=True) + + if bank_account_match_ratios and bank_account_match_ratios[0][1] > 0.3: + return bank_account_match_ratios[0][0].partner_id + return None + + def _get_partner(self, ocr_results): + vat_number_ocr = self._get_ocr_selected_value(ocr_results, 'VAT_Number', "") + iban_ocr = self._get_ocr_selected_value(ocr_results, 'iban', "") + + if vat_number_ocr: + partner_vat = self._find_partner_id_with_vat(vat_number_ocr) + if partner_vat: + return partner_vat, False + + if self.is_purchase_document() and self.extract_detected_layout: + partner = self._find_partner_from_previous_extracts() + if partner: + return partner, False + + if self.is_purchase_document() and iban_ocr: + partner = self._find_partner_with_iban(iban_ocr, self.extract_partner_name) + if partner: + return partner, False + + partner_id = self._find_partner_id_with_name(self.extract_partner_name) + if partner_id != 0: + return self.env["res.partner"].browse(partner_id), False + + if vat_number_ocr: + created_supplier = self._create_supplier_from_vat(vat_number_ocr) + if created_supplier: + return created_supplier, True + return False, False + + def _get_taxes_record(self, taxes_ocr, taxes_type_ocr): + + taxes_found = self.env['account.tax'] + type_tax_use = 'purchase' if self.is_purchase_document() else 'sale' + if self.is_indian_taxes() and len(taxes_ocr) > 1: + total_tax = sum(taxes_ocr) + grouped_taxes_records = self.env['account.tax'].search([ + *self.env['account.tax']._check_company_domain(self.company_id), + ('amount', '=', total_tax), + ('amount_type', '=', 'group'), + ('type_tax_use', '=', type_tax_use), + ]) + for grouped_tax in grouped_taxes_records: + children_taxes = grouped_tax.children_tax_ids.mapped('amount') + if set(taxes_ocr) == set(children_taxes): + return grouped_tax + for (taxes, taxes_type) in zip(taxes_ocr, taxes_type_ocr): + if taxes != 0.0: + related_documents = self.env['account.move'].search([ + ('state', '!=', 'draft'), + ('move_type', '=', self.move_type), + ('partner_id', '=', self.partner_id.id), + ('company_id', '=', self.company_id.id), + ], limit=100, order='id desc') + lines = related_documents.mapped('invoice_line_ids') + taxes_ids = related_documents.mapped('invoice_line_ids.tax_ids') + taxes_ids = taxes_ids.filtered( + lambda tax: + tax.active and + tax.amount == taxes and + tax.amount_type == taxes_type and + tax.type_tax_use == type_tax_use + ) + taxes_by_document = [] + for tax in taxes_ids: + taxes_by_document.append((tax, lines.filtered(lambda line: tax in line.tax_ids))) + if len(taxes_by_document) != 0: + taxes_found |= max(taxes_by_document, key=lambda tax: len(tax[1]))[0] + else: + tax_domain = [ + *self.env['account.tax']._check_company_domain(self.company_id), + ('amount', '=', taxes), + ('amount_type', '=', taxes_type), + ('type_tax_use', '=', type_tax_use), + ] + default_taxes = self.journal_id.default_account_id.tax_ids + matching_default_tax = default_taxes.filtered_domain(tax_domain) + if matching_default_tax: + taxes_found |= matching_default_tax + else: + taxes_records = self.env['account.tax'].search(tax_domain) + if taxes_records: + taxes_records_setting_based = taxes_records.filtered(lambda r: not r.price_include) + if taxes_records_setting_based: + taxes_record = taxes_records_setting_based[0] + else: + taxes_record = taxes_records[0] + taxes_found |= taxes_record + return taxes_found + + def _get_currency(self, currency_ocr, partner_id): + for comparison in ['=ilike', 'ilike']: + possible_currencies = self.env["res.currency"].search([ + '|', '|', + ('currency_unit_label', comparison, currency_ocr), + ('name', comparison, currency_ocr), + ('symbol', comparison, currency_ocr), + ]) + if possible_currencies: + break + + partner_last_invoice_currency = partner_id.invoice_ids[:1].currency_id + if partner_last_invoice_currency in possible_currencies: + return partner_last_invoice_currency + if self.company_id.currency_id in possible_currencies: + return self.company_id.currency_id + return possible_currencies if len(possible_currencies) == 1 else None + + + def _get_invoice_lines(self, ocr_results): + + self.ensure_one() + + invoice_lines = ocr_results.get('invoice_lines', []) + subtotal_ocr = self._get_ocr_selected_value(ocr_results, 'subtotal', 0.0) + supplier_ocr = self._get_ocr_selected_value(ocr_results, 'supplier', "") + date_ocr = self._get_ocr_selected_value(ocr_results, 'date', "") + + invoice_lines_to_create = [] + if self.company_id.extract_single_line_per_tax: + merged_lines = {} + for il in invoice_lines: + total = self._get_ocr_selected_value(il, 'total', 0.0) + subtotal = self._get_ocr_selected_value(il, 'subtotal', total) + taxes_ocr = [value['content'] for value in il.get('taxes', {}).get('selected_values', [])] + taxes_type_ocr = [value.get('amount_type', 'percent') for value in il.get('taxes', {}).get('selected_values', [])] + taxes_records = self._get_taxes_record(taxes_ocr, taxes_type_ocr) + + if not taxes_records and taxes_ocr: + taxes_ids = ('not found', *sorted(taxes_ocr)) + else: + taxes_ids = ('found', *sorted(taxes_records.ids)) + + if taxes_ids not in merged_lines: + merged_lines[taxes_ids] = {'subtotal': subtotal} + else: + merged_lines[taxes_ids]['subtotal'] += subtotal + merged_lines[taxes_ids]['taxes_records'] = taxes_records + + if len(merged_lines) == 1: + merged_lines[list(merged_lines.keys())[0]]['subtotal'] = subtotal_ocr + + description_fields = [] + if supplier_ocr: + description_fields.append(supplier_ocr) + if date_ocr: + description_fields.append(date_ocr.split()[0]) + description = ' - '.join(description_fields) + + for il in merged_lines.values(): + vals = { + 'name': description, + 'price_unit': il['subtotal'], + 'quantity': 1.0, + 'tax_ids': il['taxes_records'], + } + + invoice_lines_to_create.append(vals) + else: + for il in invoice_lines: + description = self._get_ocr_selected_value(il, 'description', "/") + total = self._get_ocr_selected_value(il, 'total', 0.0) + subtotal = self._get_ocr_selected_value(il, 'subtotal', total) + unit_price = self._get_ocr_selected_value(il, 'unit_price', subtotal) + quantity = self._get_ocr_selected_value(il, 'quantity', 1.0) + taxes_ocr = [value['content'] for value in il.get('taxes', {}).get('selected_values', [])] + taxes_type_ocr = [value.get('amount_type', 'percent') for value in il.get('taxes', {}).get('selected_values', [])] + + vals = { + 'name': description, + 'price_unit': unit_price, + 'quantity': quantity, + 'tax_ids': self._get_taxes_record(taxes_ocr, taxes_type_ocr) + } + + invoice_lines_to_create.append(vals) + + return invoice_lines_to_create + + def _get_bank_account_vals(self, iban_ocr, SWIFT_code_ocr): + vals = {'acc_number': iban_ocr} + if SWIFT_code_ocr: + bank_id = self.env['res.bank'].search([('bic', '=', SWIFT_code_ocr['bic'])], limit=1) + if bank_id: + vals['bank_id'] = bank_id.id + if not bank_id and SWIFT_code_ocr['verified_bic']: + country_id = self.env['res.country'].search([('code', '=', SWIFT_code_ocr['country_code'])], limit=1) + if country_id: + vals['bank_id'] = self.env['res.bank'].create({ + 'name': SWIFT_code_ocr['name'], + 'country': country_id.id, + 'city': SWIFT_code_ocr['city'], + 'bic': SWIFT_code_ocr['bic'], + }).id + return vals + + def _fill_document_with_results(self, ocr_results): + self = self.with_context(skip_is_manually_modified=True) + if self.state != 'draft' or ocr_results is None: + return + + if 'detected_layout_id' in ocr_results: + self.extract_detected_layout = ocr_results['detected_layout_id'] + + if ocr_results.get('type') == 'refund' and self.move_type in ('in_invoice', 'out_invoice'): + self.action_switch_move_type() + + def get_first_value_without(feature, not_allowed): + return next(( + candidate['content'] + for candidate in ocr_results.get(feature, {}).get('candidates', []) + if candidate['content'] not in not_allowed + ), None) + + country_code = self._get_ocr_selected_value(ocr_results, 'country', "") + iban_ocr = self._get_ocr_selected_value(ocr_results, 'iban', "") + SWIFT_code_ocr = json.loads(self._get_ocr_selected_value(ocr_results, 'SWIFT_code', "{}")) + self.extract_prefill_data = { + 'country_id': self.env['res.country'].search([('code', '=', country_code)], limit=1).id, + 'email': get_first_value_without('email', (self.company_id.email,)), + 'website': get_first_value_without('website', (self.company_id.website,)), + 'phone': get_first_value_without('phone', (self.company_id.phone,)), + 'mobile': get_first_value_without('mobile', (self.company_id.mobile,)), + 'vat': next(( + candidate['content'] + for candidate in ocr_results.get('VAT_Number', {}).get('candidates', []) + if next(iter(guess_country(candidate['content'])), "").lower() == country_code.lower() + ), next(iter(ocr_results.get('VAT_Number', {}).get('candidates', [])), {}).get('content')), + } + self.extract_prefill_data = {k: v for k, v in self.extract_prefill_data.items() if v is not None} + + self._save_form(ocr_results) + + if self.extract_word_ids: + return + + fields_with_boxes = ['supplier', 'date', 'due_date', 'invoice_id', 'currency', 'VAT_Number', 'total'] + for field in filter(ocr_results.get, fields_with_boxes): + value = ocr_results[field] + selected_value = value.get('selected_value') + data = [] + + ocr_chosen_candidate_found = False + for candidate in value.get('candidates', []): + ocr_chosen = selected_value == candidate and not ocr_chosen_candidate_found + if ocr_chosen: + ocr_chosen_candidate_found = True + data.append((0, 0, { + "field": field, + "ocr_selected": ocr_chosen, + "user_selected": ocr_chosen, + "word_text": candidate['content'], + "word_page": candidate['page'], + "word_box_midX": candidate['coords'][0], + "word_box_midY": candidate['coords'][1], + "word_box_width": candidate['coords'][2], + "word_box_height": candidate['coords'][3], + "word_box_angle": candidate['coords'][4], + })) + self.write({'extract_word_ids': data}) + self._autopost_bill() + + def _save_form(self, ocr_results): + self = self.with_context(skip_is_manually_modified=True) + + date_ocr = self._get_ocr_selected_value(ocr_results, 'date', "") + due_date_ocr = self._get_ocr_selected_value(ocr_results, 'due_date', "") + total_ocr = self._get_ocr_selected_value(ocr_results, 'total', 0.0) + invoice_id_ocr = self._get_ocr_selected_value(ocr_results, 'invoice_id', "") + currency_ocr = self._get_ocr_selected_value(ocr_results, 'currency', "") + payment_ref_ocr = self._get_ocr_selected_value(ocr_results, 'payment_ref', "") + iban_ocr = self._get_ocr_selected_value(ocr_results, 'iban', "") + SWIFT_code_ocr = json.loads(self._get_ocr_selected_value(ocr_results, 'SWIFT_code', "{}")) or None + qr_bill_ocr = self._get_ocr_selected_value(ocr_results, 'qr-bill') + supplier_ocr = self._get_ocr_selected_value(ocr_results, 'supplier', "") + client_ocr = self._get_ocr_selected_value(ocr_results, 'client', "") + total_tax_amount_ocr = self._get_ocr_selected_value(ocr_results, 'total_tax_amount', 0.0) + + self.extract_partner_name = client_ocr if self.is_sale_document() else supplier_ocr + + with self._get_edi_creation() as move_form: + if not move_form.partner_id: + partner_id, created = self._get_partner(ocr_results) + if partner_id: + move_form.partner_id = partner_id + if created and iban_ocr and not move_form.partner_bank_id and self.is_purchase_document(): + bank_account = self.env['res.partner.bank'].search([ + *self.env['res.partner.bank']._check_company_domain(self.company_id), + ('acc_number', '=ilike', iban_ocr), + ]) + if bank_account: + if bank_account.partner_id == move_form.partner_id.id: + move_form.partner_bank_id = bank_account + else: + bank_vals = self._get_bank_account_vals(iban_ocr, SWIFT_code_ocr) + bank_vals['partner_id'] = move_form.partner_id.id + move_form.partner_bank_id = self.with_context(clean_context(self.env.context)).env['res.partner.bank'].create(bank_vals) + + if qr_bill_ocr: + qr_content_list = qr_bill_ocr.splitlines() + index_offset = 16 if self.is_sale_document() else 0 + if not move_form.partner_id: + partner_name = qr_content_list[5 + index_offset] + move_form.partner_id = self.env["res.partner"].with_context(clean_context(self.env.context)).create({ + 'name': partner_name, + 'is_company': True, + }) + + partner = move_form.partner_id + address_type = qr_content_list[4 + index_offset] + if address_type == 'S': + if not partner.street: + street = qr_content_list[6 + index_offset] + house_nb = qr_content_list[7 + index_offset] + partner.street = " ".join((street, house_nb)) + + if not partner.zip: + partner.zip = qr_content_list[8 + index_offset] + + if not partner.city: + partner.city = qr_content_list[9 + index_offset] + + elif address_type == 'K': + if not partner.street: + partner.street = qr_content_list[6 + index_offset] + partner.street2 = qr_content_list[7 + index_offset] + + country_code = qr_content_list[10 + index_offset] + if not partner.country_id and country_code: + country = self.env['res.country'].search([('code', '=', country_code)]) + partner.country_id = country and country.id + + if self.is_purchase_document(): + iban = qr_content_list[3] + if iban and not self.env['res.partner.bank'].search_count([('acc_number', '=ilike', iban)], limit=1): + move_form.partner_bank_id = self.with_context(clean_context(self.env.context)).env['res.partner.bank'].create({ + 'acc_number': iban, + 'company_id': move_form.company_id.id, + 'currency_id': move_form.currency_id.id, + 'partner_id': partner.id, + }) + + due_date_move_form = move_form.invoice_date_due + context_create_date = fields.Date.context_today(self, self.create_date) + if date_ocr and (not move_form.invoice_date or move_form.invoice_date == context_create_date): + move_form.invoice_date = date_ocr + if due_date_ocr and due_date_move_form == context_create_date: + if date_ocr == due_date_ocr and move_form.partner_id and move_form.partner_id.property_supplier_payment_term_id: + move_form.invoice_payment_term_id = move_form.partner_id.property_supplier_payment_term_id + else: + move_form.invoice_date_due = due_date_ocr + + if self.is_purchase_document() and not move_form.ref: + move_form.ref = invoice_id_ocr + + if self.is_sale_document() and self.quick_edit_mode: + move_form.name = invoice_id_ocr + + if payment_ref_ocr and not move_form.payment_reference: + move_form.payment_reference = payment_ref_ocr + + add_lines = not move_form.invoice_line_ids + if add_lines: + if currency_ocr and move_form.currency_id == move_form.company_currency_id: + currency = self._get_currency(currency_ocr, move_form.partner_id) + if currency: + move_form.currency_id = currency + + vals_invoice_lines = self._get_invoice_lines(ocr_results) + move_form.invoice_line_ids = [ + Command.create({'name': line_vals.pop('name')}) + for line_vals in vals_invoice_lines + ] + + if add_lines: + with self._get_edi_creation() as move_form: + for line, ocr_line_vals in zip(move_form.invoice_line_ids[-len(vals_invoice_lines):], vals_invoice_lines): + line.write({ + 'price_unit': ocr_line_vals['price_unit'], + 'quantity': ocr_line_vals['quantity'], + }) + taxes_dict = {} + for tax in line.tax_ids: + taxes_dict[(tax.amount, tax.amount_type, tax.price_include)] = { + 'found_by_OCR': False, + 'tax_record': tax, + } + for taxes_record in ocr_line_vals['tax_ids']: + tax_tuple = (taxes_record.amount, taxes_record.amount_type, taxes_record.price_include) + if tax_tuple not in taxes_dict: + line.tax_ids = [Command.link(taxes_record.id)] + else: + taxes_dict[tax_tuple]['found_by_OCR'] = True + if taxes_record.price_include: + line.price_unit *= 1 + taxes_record.amount / 100 + for tax_info in taxes_dict.values(): + if not tax_info['found_by_OCR']: + amount_before = line.price_total + line.tax_ids = [Command.unlink(tax_info['tax_record'].id)] + + if line.price_total == amount_before: + line.tax_ids = [Command.link(tax_info['tax_record'].id)] + + tax_amount_rounding_error = total_ocr - self.tax_totals['total_amount_currency'] + threshold = len(vals_invoice_lines) * move_form.currency_id.rounding + if not move_form.currency_id.is_zero(tax_amount_rounding_error) and self.is_indian_taxes(): + fixed_rounding_error = total_ocr - total_tax_amount_ocr - self.tax_totals['base_amount_currency'] + tax_totals = self.tax_totals + if move_form.currency_id.is_zero(fixed_rounding_error) and tax_totals['has_tax_groups']: + max_tax_group = max( + [ + tax_group + for subtotal in tax_totals['subtotals'] + for tax_group in subtotal['tax_groups'] + ], + key=lambda tax_group: tax_group['tax_amount_currency'], + ) + max_tax_group['tax_amount_currency'] += fixed_rounding_error + self.tax_totals = tax_totals + + move_form.invoice_line_ids.is_imported = True + + if ( + not move_form.currency_id.is_zero(tax_amount_rounding_error) and + float_compare(abs(tax_amount_rounding_error), threshold, precision_digits=2) <= 0 + ): + self._check_total_amount(total_ocr) + + + @api.model + def _import_invoice_ocr(self, invoice, file_data, new=False): + with invoice._get_edi_creation() as invoice: + invoice._message_set_main_attachment_id(file_data['attachment'], force=True, filter_xml=False) + invoice._send_batch_for_digitization() + return True + + def _get_edi_decoder(self, file_data, new=False): + self.ensure_one() + decoder = super()._get_edi_decoder(file_data, new=new) + if not decoder and file_data['type'] in ('pdf', 'binary') and self._needs_auto_extract(new_document=new): + return self._import_invoice_ocr + return super()._get_edi_decoder(file_data, new=new) + + @api.model + def _get_view(self, view_id=None, view_type='form', **options): + arch, view = super()._get_view(view_id, view_type, **options) + + if view_type == 'form': + for node in arch.xpath("//field[@name='partner_id'][@widget='res_partner_many2one']"): + + node_with_placeholder = copy.deepcopy(node) + placeholder_condition = "(extract_state == 'waiting_validation' and not partner_id)" + if node.get("invisible"): + node.set('invisible', f"{placeholder_condition} or ({node.attrib.pop('invisible')})") + else: + node.set('invisible', placeholder_condition) + node_with_placeholder.set('invisible', f"not {node.get('invisible')}") + node_with_placeholder.set('placeholder', "Click here and select the vendor on the bill to create it") + node.addnext(node_with_placeholder) + return arch, view diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/models/ir_attachment.py b/dev_odex30_accounting/odex30_account_invoice_extract/models/ir_attachment.py new file mode 100644 index 0000000..ce67895 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/models/ir_attachment.py @@ -0,0 +1,15 @@ + +from odoo import models + + +class IrAttachment(models.Model): + _inherit = 'ir.attachment' + + def register_as_main_attachment(self, force=True): + + super().register_as_main_attachment(force=force) + + move_attachments = self.filtered(lambda a: a.res_model == "account.move") + for move in self.env["account.move"].browse(move_attachments.mapped("res_id")): + if move._needs_auto_extract(): + move._send_batch_for_digitization() diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/models/res_company.py b/dev_odex30_accounting/odex30_account_invoice_extract/models/res_company.py new file mode 100644 index 0000000..724559e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/models/res_company.py @@ -0,0 +1,20 @@ + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + extract_in_invoice_digitalization_mode = fields.Selection([ + ('no_send', "Do not digitize"), + ('manual_send', "Digitize on demand only"), + ('auto_send', "Digitize automatically")], + string="Digitization mode on vendor bills", + default='auto_send') + extract_out_invoice_digitalization_mode = fields.Selection([ + ('no_send', "Do not digitize"), + ('manual_send', "Digitize on demand only"), + ('auto_send', "Digitize automatically")], + string="Digitization mode on customer invoices", + default='manual_send') + extract_single_line_per_tax = fields.Boolean(string="Single Invoice Line Per Tax", default=True) diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/models/res_config_settings.py b/dev_odex30_accounting/odex30_account_invoice_extract/models/res_config_settings.py new file mode 100644 index 0000000..5054166 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/models/res_config_settings.py @@ -0,0 +1,13 @@ + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + extract_in_invoice_digitalization_mode = fields.Selection(related='company_id.extract_in_invoice_digitalization_mode', + string='Vendor Bills', readonly=False) + extract_out_invoice_digitalization_mode = fields.Selection(related='company_id.extract_out_invoice_digitalization_mode', + string='Customer Invoices', readonly=False) + extract_single_line_per_tax = fields.Boolean(related='company_id.extract_single_line_per_tax', + string='Single Invoice Line Per Tax', readonly=False) diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/security/ir.model.access.csv b/dev_odex30_accounting/odex30_account_invoice_extract/security/ir.model.access.csv new file mode 100644 index 0000000..4600041 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_account_invoice_extract_words,access_account_invoice_extract_words,model_account_invoice_extract_words,base.group_user,1,1,1,1 \ No newline at end of file diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/static/src/css/box_layer.css b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/css/box_layer.css new file mode 100644 index 0000000..4e24fd3 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/css/box_layer.css @@ -0,0 +1,70 @@ + + .o_invoice_extract_box_layer { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + overflow: hidden; + opacity: 0.8; + z-index: 999; + } + + .o_invoice_extract_box_layer > .o_invoice_extract_box { + color: transparent; + position: absolute; + white-space: pre; + cursor: pointer; + border-radius: 2px; + border: 2px solid rgba(100,100,255,0.3); + -webkit-transform-origin: center center; + -moz-transform-origin: center center; + -o-transform-origin: center center; + -ms-transform-origin: center center; + transform-origin: center center; + } + + .o_invoice_extract_box_layer > .o_invoice_extract_box[data-field-name=VAT_Number] { + border: 2px solid rgba(100,100,255,0.6); + } + + .o_invoice_extract_box_layer > .o_invoice_extract_box.ocr_chosen:not(.selected) { + border: 2px solid rgba(100,100,255,0.6); + background-color: rgba(255,100,0,0.6); + } + + .o_invoice_extract_box_layer > .o_invoice_extract_box.selected { + border: 2px solid rgba(100,100,255,0.6); + background-color: rgba(100,255,100,0.7); + } + + .o_invoice_extract_box_layer > .o_invoice_extract_box:hover { + border: 2px solid rgba(0,0,255,0.8) !important; + } + + .o_invoice_extract_box_layer .highlight { + margin: -1px; + padding: 1px; + + background-color: rgb(180, 0, 170); + border-radius: 4px; + } + + .o_invoice_extract_box_layer .highlight.begin { + border-radius: 4px 0px 0px 4px; + } + + .o_invoice_extract_box_layer .highlight.end { + border-radius: 0px 4px 4px 0px; + } + + .o_invoice_extract_box_layer .highlight.middle { + border-radius: 0px; + } + + .o_invoice_extract_box_layer .highlight.selected { + background-color: rgb(0, 100, 0); + } + + .o_invoice_extract_box_layer ::selection { background: rgb(0,0,255); } + .o_invoice_extract_box_layer ::-moz-selection { background: rgb(0,0,255); } diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/static/src/js/box.js b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/js/box.js new file mode 100644 index 0000000..c290a4f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/js/box.js @@ -0,0 +1,38 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; + +export class Box extends Component { + static template = "odex30_account_invoice_extract.Box"; + static props = { + box: Object, + pageWidth: String, + pageHeight: String, + onClickBoxCallback: Function, + }; + /** + * @override + */ + setup() { + this.state = this.props.box; + } + + + get style() { + const style = [ + `left: calc(${this.state.box_midX} * ${this.props.pageWidth})`, + `top: calc(${this.state.box_midY} * ${this.props.pageHeight})`, + `width: calc(${this.state.box_width} * ${this.props.pageWidth})`, + `height: calc(${this.state.box_height} * ${this.props.pageHeight})`, + `transform: translate(-50%, -50%) rotate(${this.state.box_angle}deg)`, + `-ms-transform: translate(-50%, -50%) rotate(${this.state.box_angle}deg)`, + `-webkit-transform: translate(-50%, -50%) rotate(${this.state.box_angle}deg)`, + ].join('; '); + return style; + } + + + onClick() { + this.props.onClickBoxCallback(this.state.id, this.state.page); + } +}; diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/static/src/js/box_layer.js b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/js/box_layer.js new file mode 100644 index 0000000..66188cb --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/js/box_layer.js @@ -0,0 +1,61 @@ +/** @odoo-module **/ + +import { Box } from '@odex30_account_invoice_extract/js/box'; +import { Component } from "@odoo/owl"; + +export class BoxLayer extends Component { + static components = { Box }; + static template = "odex30_account_invoice_extract.BoxLayer"; + static props = { + boxes: Array, + pageLayer: { + validate: (pageLayer) => { + const Element = pageLayer?.ownerDocument?.defaultView?.Element; + return ( + (Boolean(Element) && + (pageLayer instanceof Element || pageLayer instanceof window.Element)) || + (typeof pageLayer === "object" && pageLayer?.constructor?.name?.endsWith("Element")) + ); + }, + }, + onClickBoxCallback: Function, + mode: String, + }; + /** + * @override + */ + setup() { + this.state = { + boxes: this.props.boxes, + }; + + if (this.isOnPDF) { + this.pageWidth = this.props.pageLayer.style.width; + this.pageHeight = this.props.pageLayer.style.height; + } else if (this.isOnImg) { + this.pageWidth = `${this.props.pageLayer.clientWidth}px`; + this.pageHeight = `${this.props.pageLayer.clientHeight}px`; + } + } + + + get style() { + if (this.isOnPDF) { + return 'width: ' + this.props.pageLayer.style.width + '; ' + + 'height: ' + this.props.pageLayer.style.height + ';'; + } else if (this.isOnImg) { + return 'width: ' + this.props.pageLayer.clientWidth + 'px; ' + + 'height: ' + this.props.pageLayer.clientHeight + 'px; ' + + 'left: ' + this.props.pageLayer.offsetLeft + 'px; ' + + 'top: ' + this.props.pageLayer.offsetTop + 'px;'; + } + } + + get isOnImg() { + return this.props.mode === 'img'; + } + + get isOnPDF() { + return this.props.mode === 'pdf'; + } +}; diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/static/src/js/invoice_extract_form_renderer.js b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/js/invoice_extract_form_renderer.js new file mode 100644 index 0000000..2e72966 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/js/invoice_extract_form_renderer.js @@ -0,0 +1,316 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { getTemplate } from "@web/core/templates"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog"; + +import { AccountMoveFormRenderer } from '@account/components/account_move_form/account_move_form'; +import { BoxLayer } from '@odex30_account_invoice_extract/js/box_layer'; + +import { App, onWillUnmount, reactive, useExternalListener, useState } from "@odoo/owl"; + + +export class InvoiceExtractFormRenderer extends AccountMoveFormRenderer { + /** + * @override + */ + setup() { + super.setup(); + + this.store = useState(useService("mail.store")); + this.dialog = useService("dialog"); + this.orm = useService("orm"); + this.mailPopoutService = useService("mail.popout"); + + this._fieldsMapping = { + 'partner_id': 'supplier', + 'ref': 'invoice_id', + 'invoice_date': 'date', + 'invoice_date_due': 'due_date', + 'currency_id': 'currency', + 'quick_edit_total_amount': 'total', + }; + + this.dataMoveId = -1; + + this.boxLayerApps = []; + this.activeField = undefined; + this.activeFieldEl = undefined; + this.boxes = []; + this.selectedBoxes = {}; + + this.state = useState({ + visibleBoxes: {}, + }); + + useExternalListener(window, "focusin", (event) => { + const field_widget = event.target.closest(".o_field_widget"); + if (field_widget){ + this.onFocusFieldWidget(field_widget); + } + }); + + useExternalListener(window, "focusout", (event) => { + if (event.target.closest(".o_field_widget") && !this.mailPopoutService.externalWindow){ + this.onBlurFieldWidget(); + } + }); + + onWillUnmount (() => { + this.destroyBoxLayers(); + }); + } + + fetchBoxData() { + this.dataMoveId = this.props.record.resId; + return this.orm.call('account.move', 'get_boxes', [this.props.record.resId]); + } + + + createBoxLayerApp(props) { + props.onClickBoxCallback = this.onClickBox.bind(this); + return new App(BoxLayer, { + env: this.env, + dev: this.env.debug, + getTemplate, + props, + translatableAttributes: ["data-tooltip"], + translateFn: _t, + }); + } + + + renderBoxLayers(element) { + const proms = []; + if (element.classList.contains('img-fluid')) { + this.destroyBoxLayers(); + const boxLayerApp = this.createBoxLayerApp({ + boxes: this.state.visibleBoxes[0] || [], + mode: 'img', + pageLayer: element, + }); + proms.push(boxLayerApp.mount(element.parentElement)); + this.boxLayerApps = [boxLayerApp]; + } + if (element.tagName === 'IFRAME') { + const pdfDocument = element.contentDocument; + if (!pdfDocument.querySelector('head link#box_layer')) { + const win = this.mailPopoutService.externalWindow || window; + const boxLayerStylesheet = win.document.createElement('link'); + boxLayerStylesheet.setAttribute('id', 'box_layer'); + boxLayerStylesheet.setAttribute('rel', 'stylesheet'); + boxLayerStylesheet.setAttribute('type', 'text/css'); + boxLayerStylesheet.setAttribute('href', '/odex30_account_invoice_extract/static/src/css/box_layer.css'); + pdfDocument.querySelector('head').append(boxLayerStylesheet); + } + const pageLayers = pdfDocument.querySelectorAll('.page'); + for (const pageLayer of pageLayers) { + const pageNum = pageLayer.dataset['pageNumber'] - 1; + const boxLayerApp = this.createBoxLayerApp({ + boxes: this.state.visibleBoxes[pageNum] || [], + mode: 'pdf', + pageLayer: pageLayer, + }); + proms.push(boxLayerApp.mount(pageLayer)); + this.boxLayerApps.push(boxLayerApp); + } + } + return Promise.all(proms); + } + + + renderInvoiceExtract(attachment) { + const thread = this.store.Thread.insert({ + id: this.props.record.resId, + model: this.props.record.resModel, + }); + const preview_attachment_id = thread.mainAttachment.id; + if ( + ['in_invoice', 'in_refund', 'out_invoice', 'out_refund'].includes(this.props.record.data.move_type) && + this.props.record.data.state === 'draft' && + ['waiting_validation', 'validation_to_send'].includes(this.props.record.data.extract_state) && + this.props.record.data.extract_attachment_id && + preview_attachment_id === this.props.record.data.extract_attachment_id[0] + ) { + if (this.activeField !== undefined) { + if (this.dataMoveId !== this.props.record.resId) { + for (const boxesForPage of Object.values(this.boxes)) { + boxesForPage.length = 0; + } + } + const dataToFetch = this.boxes.length === 0 || (this.dataMoveId !== this.props.record.resId); + const prom = dataToFetch ? this.fetchBoxData() : new Promise(resolve => resolve([])); + prom.then((boxes) => { + boxes.map(b => reactive(b)).forEach((box) => { + if (box.page in this.boxes) { + this.boxes[box.page].push(box); + } + else { + this.boxes[box.page] = [box]; + } + if (box.user_selected) { + this.selectedBoxes[box.feature] = box; + } + }); + for (const [page, boxesForPage] of Object.entries(this.boxes)) { + if (page in this.state.visibleBoxes) { + this.state.visibleBoxes[page].length = 0; + } + else { + this.state.visibleBoxes[page] = []; + } + + const visibleBoxesForPage = boxesForPage.filter((box) => { + return ( + box.feature === this.activeField || + (box.feature === "VAT_Number" && this.activeField === "supplier") + ); + }); + this.state.visibleBoxes[page].push(...visibleBoxesForPage); + } + this.renderBoxLayers(attachment) + }); + } + } + } + + + showBoxesForField(fieldName) { + const win = this.mailPopoutService.externalWindow || window; + const iframe = win.document.querySelector('.o-mail-Attachment iframe'); + if (iframe) { + const iframeDoc = iframe.contentDocument; + if (iframeDoc) { + this.renderInvoiceExtract(iframe); + return; + } + } + const attachment = win.document.getElementById('attachment_img'); + if (attachment && attachment.complete) { + this.renderInvoiceExtract(attachment); + return; + } + } + + resetActiveField() { + Object.values(this.state.visibleBoxes).forEach(boxesForPage => { + boxesForPage.length = 0; + }); + this.activeField = undefined; + this.activeFieldEl = undefined; + this.destroyBoxLayers(); + } + + destroyBoxLayers() { + for (const boxLayerApp of this.boxLayerApps) { + boxLayerApp.destroy(); + } + this.boxLayerApps = []; + } + + async openCreatePartnerDialog(context) { + const ctx_from_db = await this.orm.call('account.move', 'get_partner_create_data', [[this.props.record.resId], context]); + this.dialog.add( + FormViewDialog, + { + resModel: 'res.partner', + context: Object.assign( + ctx_from_db, + Object.fromEntries(Object.entries(context).filter(([k, v]) => v !== undefined)) + ), + title: _t("Create"), + onRecordSaved: (record) => { + this.props.record.update({ partner_id: [record.resId] }); + }, + } + ); + } + + + async handleFieldChanged(fieldName, newFieldValue) { + let changes = {}; + switch (fieldName) { + case 'date': + changes = { invoice_date: registry.category("parsers").get("date")(newFieldValue.split(' ')[0]) }; + break; + case 'supplier': + case 'VAT_Number': + if (Array.isArray(newFieldValue) && newFieldValue.length == 2){ + changes = { partner_id: [newFieldValue[0]], partner_bank_id: [newFieldValue[1]] }; + } + else if (Number.isFinite(newFieldValue) && newFieldValue !== 0) { + changes = { partner_id: [newFieldValue] }; + } else { + await this.openCreatePartnerDialog({ + default_name: this.selectedBoxes['supplier']?.text, + default_vat: this.selectedBoxes['VAT_Number']?.text + }); + return; + } + break; + case 'due_date': + changes = { invoice_date_due: registry.category("parsers").get("date")(newFieldValue.split(' ')[0]) }; + break; + case 'invoice_id': + changes = ['out_invoice', 'out_refund'].includes(this.props.record.context.default_move_type) ? { name: newFieldValue } : { ref: newFieldValue }; + break; + case 'currency': + changes = { currency_id: [newFieldValue] }; + break; + case 'total': + changes = { quick_edit_total_amount: Number(newFieldValue) }; + break; + } + this.props.record.update(changes) + } + + + onFocusFieldWidget(field_widget) { + const fieldName = this._fieldsMapping[field_widget.getAttribute('name')]; + + if (fieldName === undefined) { + this.resetActiveField(); + return; + } + + this.activeField = fieldName; + this.activeFieldEl = field_widget; + + this.showBoxesForField(fieldName); + } + + + onBlurFieldWidget() { + this.resetActiveField(); + } + + async onClickBox(boxId, boxPage) { + const box = this.boxes[boxPage].find(box => box.id === boxId); + const fieldName = box.feature; + + if (this.selectedBoxes[fieldName]) { + this.selectedBoxes[fieldName].user_selected = false; + } + + box.user_selected = true; + this.selectedBoxes[fieldName] = box; + + const newFieldValue = await this.orm.call( + 'account.move', + 'set_user_selected_box', + [[this.dataMoveId], boxId], + ) + + await this.handleFieldChanged(fieldName, newFieldValue); + + if (['supplier', 'VAT_Number'].includes(box.feature)) { + this.activeFieldEl.querySelector('.o-autocomplete--dropdown-menu')?.classList.toggle('show'); + } else if (['date', 'due_date'].includes(box.feature)) { + this.activeFieldEl.querySelector('input').dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', + })); + } + } +}; diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/static/src/js/invoice_extract_form_view.js b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/js/invoice_extract_form_view.js new file mode 100644 index 0000000..02432b3 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/js/invoice_extract_form_view.js @@ -0,0 +1,12 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { AccountMoveFormView } from '@account/components/account_move_form/account_move_form'; +import { InvoiceExtractFormRenderer } from '@odex30_account_invoice_extract/js/invoice_extract_form_renderer'; + +const AccountMoveFormViewExtract = { + ...AccountMoveFormView, + Renderer: InvoiceExtractFormRenderer, +}; + +registry.category("views").add("account_move_form", AccountMoveFormViewExtract, { force: true }); diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/static/src/tests/account_invoice_extract_test_helpers.js b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/tests/account_invoice_extract_test_helpers.js new file mode 100644 index 0000000..87668d5 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/tests/account_invoice_extract_test_helpers.js @@ -0,0 +1,160 @@ +import { ResCurrency } from "./mock_server/mock_models/res_currency"; +import { accountModels } from "@account/../tests/account_test_helpers"; +import { mailModels } from "@mail/../tests/mail_test_helpers"; +import { defineModels } from "@web/../tests/web_test_helpers"; + +export const accountInvoiceExtractModels = { + ResCurrency, +}; + +export function defineAccountInvoiceExtractModels() { + return defineModels({ ...accountModels, ...mailModels, ...accountInvoiceExtractModels }); +} + + +function createBoxData(params) { + return { + text: params.text || "", + box_angle: 0, + box_height: 0.2, + box_midX: 0.5, + box_midY: 0.5, + box_width: 0.2, + feature: params.fieldName, + id: params.id, + page: params.page || 0, + ocr_selected: params.ocr_selected, + user_selected: params.user_selected, + }; +} + + +export function createBoxesData() { + const vatBoxes = [ + createBoxData({ + fieldName: "VAT_Number", + id: 1, + ocr_selected: false, + user_selected: false, + }), + createBoxData({ + fieldName: "VAT_Number", + id: 2, + ocr_selected: true, + user_selected: false, + text: "BE0477472701", + }), + createBoxData({ + fieldName: "VAT_Number", + id: 3, + ocr_selected: false, + user_selected: true, + }), + ]; + const invoiceIdBoxes = [ + createBoxData({ + fieldName: "invoice_id", + id: 4, + ocr_selected: false, + user_selected: false, + }), + createBoxData({ + fieldName: "invoice_id", + id: 5, + ocr_selected: true, + user_selected: true, + }), + ]; + const supplierBoxes = [ + createBoxData({ + fieldName: "supplier", + id: 6, + ocr_selected: false, + user_selected: true, + }), + createBoxData({ + fieldName: "supplier", + id: 7, + ocr_selected: true, + user_selected: false, + text: "Some partner", + }), + createBoxData({ + fieldName: "supplier", + id: 8, + ocr_selected: false, + user_selected: false, + }), + ]; + const totalBoxes = [ + createBoxData({ + fieldName: "total", + id: 9, + ocr_selected: true, + user_selected: true, + }), + createBoxData({ + fieldName: "total", + id: 10, + ocr_selected: false, + user_selected: false, + }), + ]; + const dateBoxes = [ + createBoxData({ + fieldName: "date", + id: 11, + ocr_selected: true, + user_selected: true, + }), + createBoxData({ + fieldName: "date", + id: 12, + ocr_selected: false, + user_selected: false, + }), + createBoxData({ + fieldName: "date", + id: 13, + ocr_selected: false, + user_selected: false, + }), + ]; + const dueDateBoxes = [ + createBoxData({ + fieldName: "due_date", + id: 14, + ocr_selected: true, + user_selected: false, + }), + createBoxData({ + fieldName: "due_date", + id: 15, + ocr_selected: false, + user_selected: true, + }), + ]; + const currencyBoxes = [ + createBoxData({ + fieldName: "currency", + id: 16, + ocr_selected: true, + user_selected: false, + }), + createBoxData({ + fieldName: "currency", + id: 17, + ocr_selected: false, + user_selected: true, + }), + ]; + return [].concat( + vatBoxes, + invoiceIdBoxes, + supplierBoxes, + totalBoxes, + dateBoxes, + dueDateBoxes, + currencyBoxes + ); +} diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/static/src/tests/invoice_extract_form_view.test.js b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/tests/invoice_extract_form_view.test.js new file mode 100644 index 0000000..7e1730b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/tests/invoice_extract_form_view.test.js @@ -0,0 +1,200 @@ +import { + click, + contains, + focus, + openFormView, + patchUiSize, + SIZES, + start, + startServer, +} from "@mail/../tests/mail_test_helpers"; +import { beforeEach, expect, test } from "@odoo/hoot"; +import { animationFrame } from "@odoo/hoot-mock"; +import { onRpc } from "@web/../tests/web_test_helpers"; +import { + createBoxesData, + defineAccountInvoiceExtractModels, +} from "./account_invoice_extract_test_helpers"; + +defineAccountInvoiceExtractModels(); + +beforeEach(() => { + patchUiSize({ size: SIZES.XXL }); +}); + +test("basic", async () => { + const pyEnv = await startServer(); + pyEnv["res.partner"]._views.form = /* xml */ ` +
+ + + + +
+ `; + const resCurrencyId1 = pyEnv["res.currency"].create({ name: "USD" }); + const resCurrencyId2 = pyEnv["res.currency"].create({ name: "EUR" }); + const resPartnerId1 = pyEnv["res.partner"].create({ + name: "Odoo", + vat: "BE0477472701", + }); + const accountMoveId1 = pyEnv["account.move"].create({ + amount_total: 100, + currency_id: resCurrencyId1, + date: "1984-12-15", + invoice_date_due: "1984-12-20", + display_name: "MyInvoice", + invoice_date: "1984-12-15", + state: "draft", + move_type: "in_invoice", + extract_state: "waiting_validation", + }); + const irAttachmentId1 = pyEnv["ir.attachment"].create({ + mimetype: "image/jpeg", + res_model: "account.move", + res_id: accountMoveId1, + }); + pyEnv["account.move"].write([accountMoveId1], { + extract_attachment_id: irAttachmentId1, + }); + pyEnv["mail.message"].create({ + attachment_ids: [irAttachmentId1], + model: "account.move", + res_id: accountMoveId1, + }); + onRpc("account.move", "get_boxes", () => { + return createBoxesData(); + }); + onRpc("account.move", "get_partner_create_data", () => { + return {}; + }); + onRpc("account.move", "set_user_selected_box", (args) => { + const boxId = args.args[1]; + switch (boxId) { + case 1: + return resPartnerId1; + case 2: + return false; + case 4: + return "some invoice_id"; + case 7: + return false; + case 8: + return resPartnerId1; + case 10: + return 123; + case 12: + return "2022-01-01 00:00:00"; + case 14: + return "2022-01-15 00:00:00"; + case 16: + return resCurrencyId2; + } + }); + await start(); + await openFormView("account.move", accountMoveId1, { + arch: ` +
+ + + + + + + + + + + + +
+ + `, + }); + await contains(".o-mail-Attachment-imgContainer"); + const attachmentPreview = document.querySelector(".o-mail-Attachment-imgContainer"); + await focus(".o_field_widget[name=partner_id] input"); + await contains(".o_invoice_extract_box", { count: 6 }); + await contains(".o_invoice_extract_box[data-field-name=supplier]", { count: 3 }); + await contains(".o_invoice_extract_box[data-field-name=VAT_Number]", { count: 3 }); + expect(attachmentPreview.querySelector('.o_invoice_extract_box[data-id="1"]')).not.toHaveClass( + "ocr_chosen" + ); + expect(attachmentPreview.querySelector('.o_invoice_extract_box[data-id="1"]')).not.toHaveClass( + "selected" + ); + expect(attachmentPreview.querySelector('.o_invoice_extract_box[data-id="2"]')).toHaveClass( + "ocr_chosen" + ); + expect(attachmentPreview.querySelector('.o_invoice_extract_box[data-id="2"]')).not.toHaveClass( + "selected" + ); + expect(attachmentPreview.querySelector('.o_invoice_extract_box[data-id="3"]')).not.toHaveClass( + "ocr_chosen" + ); + expect(attachmentPreview.querySelector('.o_invoice_extract_box[data-id="3"]')).toHaveClass( + "selected" + ); + // Check selection of supplier boxes + expect(attachmentPreview.querySelector('.o_invoice_extract_box[data-id="6"]')).not.toHaveClass( + "ocr_chosen" + ); + expect(attachmentPreview.querySelector('.o_invoice_extract_box[data-id="6"]')).toHaveClass( + "selected" + ); + expect(attachmentPreview.querySelector('.o_invoice_extract_box[data-id="7"]')).toHaveClass( + "ocr_chosen" + ); + expect(attachmentPreview.querySelector('.o_invoice_extract_box[data-id="7"]')).not.toHaveClass( + "selected" + ); + expect(attachmentPreview.querySelector('.o_invoice_extract_box[data-id="8"]')).not.toHaveClass( + "ocr_chosen" + ); + expect(attachmentPreview.querySelector('.o_invoice_extract_box[data-id="8"]')).not.toHaveClass( + "selected" + ); + await click('.o_invoice_extract_box[data-id="1"]'); + await contains(".o_field_widget[name=partner_id] input", { value: "Odoo" }); + await contains('.selected.o_invoice_extract_box[data-id="1"]'); + expect(attachmentPreview.querySelector('.o_invoice_extract_box[data-id="2"]')).not.toHaveClass( + "selected" + ); + await click('.o_invoice_extract_box[data-id="2"]'); + await contains(".o_dialog input#vat_0", { value: "BE0477472701" }); + await click(".o_dialog .o_form_button_cancel"); + + await focus(".o_field_widget[name=partner_id] input"); + await click('.o_invoice_extract_box[data-id="7"]'); + await contains(".o_dialog input#name_0", { value: "Some partner" }); + await click(".o_dialog .o_form_button_cancel"); + await focus(".o_field_widget[name=partner_id] input"); + await click('.o_invoice_extract_box[data-id="8"]'); + await contains(".o_field_widget[name=partner_id] input", { value: "Odoo" }); + await focus(".o_field_widget[name=ref] input"); + await contains(".o_invoice_extract_box", { count: 2 }); + await contains(".o_invoice_extract_box[data-field-name=invoice_id]", { count: 2 }); + await click('.o_invoice_extract_box[data-id="4"]'); + await animationFrame(); + await contains(".o_field_widget[name=ref] input", { value: "some invoice_id" }); + await focus(".o_field_widget[name=quick_edit_total_amount] input"); + await contains(".o_invoice_extract_box", { count: 2 }); + await contains(".o_invoice_extract_box[data-field-name=total]", { count: 2 }); + await click('.o_invoice_extract_box[data-id="10"]'); + await contains(".o_field_widget[name=quick_edit_total_amount] input", { value: "123.00" }); + await focus(".o_field_widget[name=invoice_date] input"); + await contains(".o_invoice_extract_box", { count: 3 }); + await contains(".o_invoice_extract_box[data-field-name=date]", { count: 3 }); + await click('.o_invoice_extract_box[data-id="12"]'); + await contains(".o_field_widget[name=invoice_date] input", { value: "01/01/2022" }); + await focus(".o_field_widget[name=invoice_date_due] input"); + await contains(".o_invoice_extract_box", { count: 2 }); + await contains(".o_invoice_extract_box[data-field-name=due_date]", { count: 2 }); + await click('.o_invoice_extract_box[data-id="14"]'); + await contains(".o_field_widget[name=invoice_date_due] input", { value: "01/15/2022" }); + await focus(".o_field_widget[name=currency_id] input"); + await contains(".o_invoice_extract_box", { count: 2 }); + await contains(".o_invoice_extract_box[data-field-name=currency]", { count: 2 }); + await click('.o_invoice_extract_box[data-id="16"]'); + await contains(".o_field_widget[name=currency_id] input", { value: "EUR" }); +}); diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/static/src/tests/mock_server/mock_models/res_currency.js b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/tests/mock_server/mock_models/res_currency.js new file mode 100644 index 0000000..904159b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/tests/mock_server/mock_models/res_currency.js @@ -0,0 +1,5 @@ +import { models } from "@web/../tests/web_test_helpers"; + +export class ResCurrency extends models.ServerModel { + _name = "res.currency"; +} diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/static/src/xml/box.xml b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/xml/box.xml new file mode 100644 index 0000000..6cf0f68 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/static/src/xml/box.xml @@ -0,0 +1,24 @@ + + + +
+ + +
+ + + +
+
+ diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/tests/__init__.py b/dev_odex30_accounting/odex30_account_invoice_extract/tests/__init__.py new file mode 100644 index 0000000..e64171b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/tests/__init__.py @@ -0,0 +1,2 @@ + +from . import test_invoice_extract diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/tests/test_invoice_extract.py b/dev_odex30_accounting/odex30_account_invoice_extract/tests/test_invoice_extract.py new file mode 100644 index 0000000..517738c --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/tests/test_invoice_extract.py @@ -0,0 +1,1016 @@ + +import base64 +import json +import textwrap +import unittest + +from odoo import fields + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.addons.iap_extract.tests.test_extract_mixin import TestExtractMixin +from odoo.addons.mail.tests.common import MailCommon +from odoo.tests import tagged +from odoo.tools import file_open + +from ..models.account_invoice import OCR_VERSION + + +@tagged('post_install', '-at_install') +class TestInvoiceExtract(AccountTestInvoicingCommon, TestExtractMixin, MailCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.env.user.groups_id |= cls.env.ref('base.group_system') + + config = cls.env['res.config.settings'].create({}) + config.execute() + + cls.journal_with_alias = cls.env['account.journal'].search( + [('company_id', '=', cls.env.user.company_id.id), ('type', '=', 'sale')], + limit=1, + ) + + @classmethod + def default_env_context(cls): + return {} + + def get_result_success_response(self): + return { + 'results': [{ + 'client': {'selected_value': {'content': "Test"}, 'candidates': []}, + 'supplier': {'selected_value': {'content': "Test"}, 'candidates': []}, + 'total': {'selected_value': {'content': 330}, 'candidates': []}, + 'subtotal': {'selected_value': {'content': 300}, 'candidates': []}, + 'invoice_id': {'selected_value': {'content': 'INV0001'}, 'candidates': []}, + 'total_tax_amount': {'selected_value': {'content': 30.0}, 'words': []}, + 'currency': {'selected_value': {'content': 'EUR'}, 'candidates': []}, + 'VAT_Number': {'selected_value': {'content': 'BE0477472701'}, 'candidates': []}, + 'date': {'selected_value': {'content': '2019-04-12 00:00:00'}, 'candidates': []}, + 'due_date': {'selected_value': {'content': '2019-04-19 00:00:00'}, 'candidates': []}, + 'email': {'selected_value': {'content': 'test@email.com'}, 'candidates': []}, + 'website': {'selected_value': {'content': 'www.test.com'}, 'candidates': []}, + 'payment_ref': {'selected_value': {'content': '+++123/1234/12345+++'}, 'candidates': []}, + 'iban': {'selected_value': {'content': 'BE01234567890123'}, 'candidates': []}, + 'invoice_lines': [ + { + 'description': {'selected_value': {'content': 'Test 1'}}, + 'unit_price': {'selected_value': {'content': 100}}, + 'quantity': {'selected_value': {'content': 1}}, + 'taxes': {'selected_values': [{'content': 15, 'amount_type': 'percent'}]}, + 'subtotal': {'selected_value': {'content': 100}}, + 'total': {'selected_value': {'content': 115}}, + }, + { + 'description': {'selected_value': {'content': 'Test 2'}}, + 'unit_price': {'selected_value': {'content': 50}}, + 'quantity': {'selected_value': {'content': 2}}, + 'taxes': {'selected_values': [{'content': 0, 'amount_type': 'percent'}]}, + 'subtotal': {'selected_value': {'content': 100}}, + 'total': {'selected_value': {'content': 100}}, + }, + { + 'description': {'selected_value': {'content': 'Test 3'}}, + 'unit_price': {'selected_value': {'content': 20}}, + 'quantity': {'selected_value': {'content': 5}}, + 'taxes': {'selected_values': [{'content': 15, 'amount_type': 'percent'}]}, + 'subtotal': {'selected_value': {'content': 100}}, + 'total': {'selected_value': {'content': 115}}, + }, + ], + }], + 'status': 'success', + } + + def _get_email_for_journal_alias(self, attachment=b'My attachment', attach_content_type='application/octet-stream', message_id='some_msg_id'): + attachment = base64.b64encode(attachment).decode() + alias = self.journal_with_alias.alias_id + return textwrap.dedent(f'''\ + MIME-Version: 1.0 + Date: Fri, 26 Nov 2021 16:27:45 +0100 + Message-ID: {message_id} + Subject: Incoming bill + From: Someone + To: {alias.display_name} + Content-Type: multipart/alternative; boundary="000000000000a47519057e029630" + + --000000000000a47519057e029630 + Content-Type: text/plain; charset=\"UTF-8\" + + + --000000000000a47519057e029630 + Content-Type: {attach_content_type} + Content-Transfer-Encoding: base64 + + {attachment} + + --000000000000a47519057e029630-- + ''') + + def get_partner_autocomplete_response(self): + return { + 'data': { + 'name': 'Partner', + 'country_code': 'BE', + 'vat': 'BE0477472701', + 'city': 'Namur', + 'bank_ids': [], + 'zip': '2110', + 'street': 'OCR street' + } + } + + def test_no_merge_check_ocr_status(self): + self.env.company.extract_single_line_per_tax = False + self.env.company.quick_edit_mode = "out_and_in_invoices" + + for move_type in ('in_invoice', 'out_invoice'): + invoice = self.env['account.move'].create({ + 'move_type': move_type, + 'extract_state': 'waiting_extraction', + 'extract_document_uuid': 'some_token', + }) + + extract_response = self.get_result_success_response() + + expected_get_results_params = { + 'version': OCR_VERSION, + 'document_token': 'some_token', + 'account_token': invoice._get_iap_account().account_token, + } + + with self._mock_iap_extract( + extract_response=extract_response, + assert_params=expected_get_results_params, + ): + invoice._check_ocr_status() + + self.assertEqual(invoice.extract_state, 'waiting_validation') + self.assertEqual(invoice.extract_status, 'success') + self.assertEqual(invoice.amount_total, 330) + self.assertEqual(invoice.amount_untaxed, 300) + self.assertEqual(invoice.amount_tax, 30) + self.assertEqual(invoice.invoice_date, fields.Date.from_string('2019-04-12')) + self.assertEqual(invoice.invoice_date_due, fields.Date.from_string('2019-04-19')) + self.assertEqual(invoice.payment_reference, "+++123/1234/12345+++") + if move_type == 'in_invoice': + self.assertEqual(invoice.ref, 'INV0001') + else: + self.assertEqual(invoice.name, 'INV0001') + + self.assertEqual(len(invoice.invoice_line_ids), 3) + for i, invoice_line in enumerate(invoice.invoice_line_ids): + self.assertEqual(invoice_line.name, extract_response['results'][0]['invoice_lines'][i]['description']['selected_value']['content']) + self.assertEqual(invoice_line.price_unit, extract_response['results'][0]['invoice_lines'][i]['unit_price']['selected_value']['content']) + self.assertEqual(invoice_line.quantity, extract_response['results'][0]['invoice_lines'][i]['quantity']['selected_value']['content']) + tax = extract_response['results'][0]['invoice_lines'][i]['taxes']['selected_values'][0] + if tax['content'] == 0: + self.assertEqual(len(invoice_line.tax_ids), 0) + else: + self.assertEqual(len(invoice_line.tax_ids), 1) + self.assertEqual(invoice_line.tax_ids[0].amount, tax['content']) + self.assertEqual(invoice_line.tax_ids[0].amount_type, 'percent') + self.assertEqual(invoice_line.price_subtotal, extract_response['results'][0]['invoice_lines'][i]['subtotal']['selected_value']['content']) + self.assertEqual(invoice_line.price_total, extract_response['results'][0]['invoice_lines'][i]['total']['selected_value']['content']) + + def test_included_default_tax(self): + tax_10_included = self.env['account.tax'].create({ + 'name': 'Tax 10% included', + 'amount': 10, + 'type_tax_use': 'purchase', + 'price_include_override': 'tax_included', + }) + self.company_data['default_account_expense'].write({ + 'tax_ids': tax_10_included + }) + + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + extract_response = self.get_result_success_response() + extract_response['results'][0]['total']['selected_value']['content'] = 300 + for line in extract_response['results'][0]['invoice_lines']: + line['total'] = line['subtotal'] + line['taxes']['selected_values'] = [] + + with self._mock_iap_extract(extract_response=extract_response): + invoice._check_ocr_status() + + self.assertEqual(invoice.amount_total, 300) + for line in invoice.invoice_line_ids: + self.assertEqual(line.tax_ids[0], tax_10_included) + + tax_15_included = self.env['account.tax'].create({ + 'name': 'Tax 15% included', + 'amount': 15, + 'type_tax_use': 'purchase', + 'price_include_override': 'tax_included', + }) + self.company_data['default_account_expense'].write({ + 'tax_ids': tax_15_included + }) + + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + + with self._mock_iap_extract(extract_response=self.get_result_success_response()): + invoice._check_ocr_status() + + self.assertEqual(invoice.amount_total, 330) + for line in invoice.invoice_line_ids: + self.assertEqual(line.tax_ids[0], tax_15_included) + self.assertTrue(line.is_imported) + + def test_merge_check_ocr_status(self): + for move_type in ('in_invoice', 'out_invoice'): + invoice = self.env['account.move'].create({'move_type': move_type, 'extract_state': 'waiting_extraction'}) + self.env.company.extract_single_line_per_tax = True + + with self._mock_iap_extract(extract_response=self.get_result_success_response()): + invoice._check_ocr_status() + + self.assertEqual(len(invoice.invoice_line_ids), 2) + + self.assertEqual(invoice.invoice_line_ids[0].name, "Test - 2019-04-12") + self.assertEqual(invoice.invoice_line_ids[0].price_unit, 200) + self.assertEqual(invoice.invoice_line_ids[0].quantity, 1) + self.assertEqual(len(invoice.invoice_line_ids[0].tax_ids), 1) + self.assertEqual(invoice.invoice_line_ids[0].tax_ids[0].amount, 15) + self.assertEqual(invoice.invoice_line_ids[0].tax_ids[0].amount_type, 'percent') + self.assertEqual(invoice.invoice_line_ids[0].price_subtotal, 200) + self.assertEqual(invoice.invoice_line_ids[0].price_total, 230) + + self.assertEqual(invoice.invoice_line_ids[1].name, "Test - 2019-04-12") + self.assertEqual(invoice.invoice_line_ids[1].price_unit, 100) + self.assertEqual(invoice.invoice_line_ids[1].quantity, 1) + self.assertEqual(len(invoice.invoice_line_ids[1].tax_ids), 0) + self.assertEqual(invoice.invoice_line_ids[1].price_subtotal, 100) + self.assertEqual(invoice.invoice_line_ids[1].price_total, 100) + + def test_partner_creation_from_vat(self): + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + + with self._mock_iap_extract(extract_response=self.get_result_success_response()): + invoice._check_ocr_status() + + self.assertFalse(invoice.partner_id) + + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + + with self._mock_iap_extract( + extract_response=self.get_result_success_response(), + partner_autocomplete_response=self.get_partner_autocomplete_response(), + ): + invoice._check_ocr_status() + + self.assertEqual(invoice.partner_id.name, 'Partner') + self.assertEqual(invoice.partner_id.vat, 'BE0477472701') + + def test_partner_selection_from_vat(self): + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + existing_partner = self.env['res.partner'].create({'name': 'Existing partner', 'vat': 'BE0477472701'}) + + with self._mock_iap_extract( + extract_response=self.get_result_success_response(), + partner_autocomplete_response={'name': 'A new partner', 'vat': 'BE0477472701'}, + ): + invoice._check_ocr_status() + + self.assertEqual(invoice.partner_id, existing_partner) + + def test_partner_selection_from_iban_and_good_name(self): + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + existing_partner = self.env['res.partner'].create({ + 'name': 'test', + 'bank_ids': [(0, 0, {'acc_number': "BE01234567890123"})], + }) + + with self._mock_iap_extract(extract_response=self.get_result_success_response()): + invoice._check_ocr_status() + + self.assertEqual(invoice.partner_id, existing_partner) + + def test_partner_selection_from_iban_and_bad_name(self): + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + self.env['res.partner'].create({ + 'name': 'Existing partner', + 'bank_ids': [(0, 0, {'acc_number': "BE01234567890123"})], + }) + + with self._mock_iap_extract(extract_response=self.get_result_success_response()): + invoice._check_ocr_status() + + self.assertFalse(invoice.partner_id) + + def test_partner_selection_from_name(self): + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + existing_partner = self.env['res.partner'].create({'name': 'Test'}) + + self.env['res.partner'].create({'name': 'Partner'}) + self.env['res.partner'].create({'name': 'Another supplier'}) + + with self._mock_iap_extract( + extract_response=self.get_result_success_response(), + partner_autocomplete_response={'name': 'A new partner', 'vat': 'BE0477472701'} + ): + invoice._check_ocr_status() + + self.assertEqual(invoice.partner_id, existing_partner) + + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + extract_response = self.get_result_success_response() + extract_response['results'][0]['supplier']['selected_value']['content'] = 'Blablablablabla' + + with self._mock_iap_extract(extract_response=extract_response): + invoice._check_ocr_status() + + self.assertFalse(invoice.partner_id) + + def test_multi_currency(self): + # test that if the multi currency is disabled, the currency isn't changed + self.env['res.currency'].search([('name', '!=', 'USD')]).with_context(force_deactivate=True).active = False + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + test_user = self.env.ref('base.user_root') + test_user.groups_id = [(3, self.env.ref('base.group_multi_currency').id)] + + usd_currency = self.env['res.currency'].search([('name', '=', 'USD')]) + eur_currency = self.env['res.currency'].with_context({'active_test': False}).search([('name', '=', 'EUR')]) + invoice.currency_id = usd_currency.id + + with self._mock_iap_extract(extract_response=self.get_result_success_response()): + invoice.with_user(test_user)._check_ocr_status() + + self.assertEqual(invoice.currency_id, usd_currency) + + # test that if multi currency is enabled, the currency is changed + # group_multi_currency is automatically activated on currency activation + eur_currency.active = True + + # test with the name of the currency + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + invoice.currency_id = usd_currency.id + + with self._mock_iap_extract(extract_response=self.get_result_success_response()): + invoice.with_user(test_user)._check_ocr_status() + + self.assertEqual(invoice.currency_id, eur_currency) + + # test with the symbol of the currency + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + invoice.currency_id = usd_currency.id + extract_response = self.get_result_success_response() + extract_response['results'][0]['currency']['selected_value']['content'] = '€' + + with self._mock_iap_extract(extract_response=extract_response): + invoice.with_user(test_user)._check_ocr_status() + + self.assertEqual(invoice.currency_id, eur_currency) + + # test with the invoice having an invoice line + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + invoice.currency_id = usd_currency.id + self.env['account.move.line'].create({ + 'move_id': invoice.id, + 'account_id': self.company_data['default_account_expense'].id, + 'name': 'Test Invoice Line', + }) + + extract_response = self.get_result_success_response() + extract_response['results'][0]['currency']['selected_value']['content'] = '€' + with self._mock_iap_extract(extract_response, {}): + invoice.with_user(test_user)._check_ocr_status() + + # test if the currency is still the same after extracting the invoice + self.assertEqual(invoice.currency_id, usd_currency) + + def test_same_name_currency(self): + # test that when we have several currencies with the same name, and no antecedants with the partner, we take the one that is on our company. + cad_currency = self.env['res.currency'].with_context({'active_test': False}).search([('name', '=', 'CAD')]) + usd_currency = self.env['res.currency'].with_context({'active_test': False}).search([('name', '=', 'USD')]) + (cad_currency | usd_currency).active = True + + test_user = self.env.user + test_user.groups_id = [(3, self.env.ref('base.group_multi_currency').id)] + self.assertEqual(test_user.currency_id, usd_currency) + + extract_response = self.get_result_success_response() + extract_response['results'][0]['currency']['selected_value']['content'] = 'dollars' + + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + with self._mock_iap_extract(extract_response=extract_response): + invoice.with_user(test_user)._check_ocr_status() + + self.assertEqual(invoice.currency_id, usd_currency) + + # test that the currency of the last invoice (with a currency) of the partner is used for its next invoice + partner = self.env['res.partner'].create({'name': 'O Canada'}) + # create an existing invoice with a currency for this partner + self.env['account.move'].create({'move_type': 'in_invoice', 'partner_id': partner.id, 'currency_id': cad_currency.id}) + invoice = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'partner_id': partner.id, + 'extract_state': 'waiting_extraction', + }) + with self._mock_iap_extract(extract_response=extract_response): + invoice.with_user(test_user)._check_ocr_status() + + self.assertEqual(invoice.currency_id, cad_currency) + + def test_tax_adjustments(self): + # test that if the total computed by Odoo doesn't exactly match the total found by the OCR, the tax are adjusted accordingly + for move_type in ('in_invoice', 'out_invoice'): + self.env['res.currency'].search([('name', '!=', 'USD')]).with_context(force_deactivate=True).active = False + invoice = self.env['account.move'].create({'move_type': move_type, 'extract_state': 'waiting_extraction'}) + extract_response = self.get_result_success_response() + extract_response['results'][0]['total']['selected_value']['content'] += 0.01 + + with self._mock_iap_extract(extract_response=extract_response): + invoice._check_ocr_status() + + self.assertEqual(invoice.amount_tax, 30.01) + self.assertEqual(invoice.amount_untaxed, 300) + self.assertEqual(invoice.amount_total, 330.01) + + def test_non_existing_tax(self): + # test that if there is an invoice line with a tax which doesn't exist in database it is ignored + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + extract_response = self.get_result_success_response() + extract_response['results'][0]['total']['selected_value']['content'] = 123.4 + extract_response['results'][0]['subtotal']['selected_value']['content'] = 100 + extract_response['results'][0]['invoice_lines'] = [ + { + 'description': {'selected_value': {'content': 'Test 1'}}, + 'unit_price': {'selected_value': {'content': 100}}, + 'quantity': {'selected_value': {'content': 1}}, + 'taxes': {'selected_values': [{'content': 12.34, 'amount_type': 'percent'}]}, + 'subtotal': {'selected_value': {'content': 100}}, + 'total': {'selected_value': {'content': 123.4}}, + }, + ] + + with self._mock_iap_extract(extract_response=extract_response): + invoice._check_ocr_status() + + self.assertEqual(len(invoice.invoice_line_ids), 1) + self.assertEqual(invoice.invoice_line_ids[0].price_unit, 100) + self.assertEqual(invoice.invoice_line_ids[0].quantity, 1) + self.assertEqual(len(invoice.invoice_line_ids[0].tax_ids), 0) + self.assertEqual(invoice.invoice_line_ids[0].price_subtotal, 100) + self.assertEqual(invoice.invoice_line_ids[0].price_total, 100) + + def test_server_error(self): + # test that the extract state is set to 'error' if the OCR returned an error + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + + with self._mock_iap_extract(extract_response={'status': 'error_internal'}): + invoice._check_ocr_status() + + self.assertEqual(invoice.extract_state, 'error_status') + self.assertEqual(invoice.extract_status, 'error_internal') + + def test_server_not_ready(self): + # test that the extract state is set to 'not_ready' if the OCR didn't finish to process the invoice + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + + with self._mock_iap_extract(extract_response=self.parse_processing_response()): + invoice._check_ocr_status() + + self.assertEqual(invoice.extract_state, 'extract_not_ready') + self.assertEqual(invoice.extract_status, 'processing') + + def test_preupdate_other_waiting_invoices(self): + # test that when we update an invoice, other invoices waiting for extraction are updated as well + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + invoice2 = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + + with self._mock_iap_extract(extract_response=self.get_result_success_response()): + invoice.check_ocr_status() + + self.assertEqual(invoice.extract_state, 'waiting_validation') + self.assertEqual(invoice2.extract_state, 'waiting_validation') + + def test_no_overwrite_client_values(self): + # test that we are not overwriting the values entered by the client + partner = self.env['res.partner'].create({'name': 'Blabla', 'vat': 'BE0477472701'}) + invoice = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'extract_state': 'waiting_extraction', + 'invoice_date': '2019-04-01', + 'date': '2019-04-01', + 'invoice_date_due': '2019-05-01', + 'ref': 'INV1234', + 'partner_id': partner.id, + 'invoice_line_ids': [(0, 0, { + 'name': 'Blabla', + 'price_unit': 13.0, + 'quantity': 2.0, + 'account_id': self.company_data['default_account_revenue'].id, + })], + }) + self.env['res.partner'].create({'name': 'Test', 'vat': 'BE0477472701'}) # this match the partner found in the server response + + with self._mock_iap_extract(extract_response=self.get_result_success_response()): + invoice.check_ocr_status() + + self.assertEqual(invoice.extract_state, 'waiting_validation') + self.assertEqual(invoice.ref, 'INV1234') + self.assertEqual(invoice.invoice_date, fields.Date.from_string('2019-04-01')) + self.assertEqual(invoice.invoice_date_due, fields.Date.from_string('2019-05-01')) + self.assertEqual(invoice.partner_id, partner) + + self.assertEqual(len(invoice.invoice_line_ids), 1) + self.assertEqual(invoice.invoice_line_ids[0].name, "Blabla") + self.assertEqual(invoice.invoice_line_ids[0].price_unit, 13) + self.assertEqual(invoice.invoice_line_ids[0].quantity, 2) + + def test_invoice_validation(self): + # test that when we post the invoice, the validation is sent to the server + invoice = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'extract_state': 'waiting_extraction', + 'extract_document_uuid': 'some_token', + }) + + with self._mock_iap_extract( + extract_response=self.get_result_success_response(), + partner_autocomplete_response=self.get_partner_autocomplete_response(), + ): + invoice._check_ocr_status() + + expected_validation_params = { + 'version': OCR_VERSION, + 'values': { + 'total': {'content': invoice.amount_total}, + 'subtotal': {'content': invoice.amount_untaxed}, + 'total_tax_amount': {'content': invoice.amount_tax}, + 'date': {'content': str(invoice.invoice_date)}, + 'due_date': {'content': str(invoice.invoice_date_due)}, + 'invoice_id': {'content': invoice.ref}, + 'partner': {'content': invoice.partner_id.name}, + 'VAT_Number': {'content': invoice.partner_id.vat}, + 'currency': {'content': invoice.currency_id.name}, + 'payment_ref': {'content': invoice.payment_reference}, + 'iban': {'content': invoice.partner_bank_id.acc_number}, + 'SWIFT_code': {'content': invoice.partner_bank_id.bank_bic}, + 'merged_lines': True, + 'invoice_lines': { + 'lines': [ + { + 'description': il.name, + 'quantity': il.quantity, + 'unit_price': il.price_unit, + 'product': il.product_id.id, + 'taxes_amount': round(il.price_total - il.price_subtotal, 2), + 'taxes': [ + { + 'amount': tax.amount, + 'type': tax.amount_type, + 'price_include': tax.price_include, + } for tax in il.tax_ids + ], + 'subtotal': il.price_subtotal, + 'total': il.price_total, + } for il in invoice.invoice_line_ids + ] + } + }, + 'document_token': 'some_token', + 'account_token': invoice._get_iap_account().account_token, + } + + with self._mock_iap_extract( + extract_response=self.validate_success_response(), + assert_params=expected_validation_params, + ): + invoice.action_post() + + self.assertEqual(invoice.extract_state, 'done') + self.assertEqual(invoice._get_validation('total')['content'], invoice.amount_total) + self.assertEqual(invoice._get_validation('subtotal')['content'], invoice.amount_untaxed) + self.assertEqual(invoice._get_validation('date')['content'], str(invoice.invoice_date)) + self.assertEqual(invoice._get_validation('due_date')['content'], str(invoice.invoice_date_due)) + self.assertEqual(invoice._get_validation('invoice_id')['content'], invoice.ref) + self.assertEqual(invoice._get_validation('partner')['content'], invoice.partner_id.name) + self.assertEqual(invoice._get_validation('total_tax_amount')['content'], invoice.amount_tax) + self.assertEqual(invoice._get_validation('VAT_Number')['content'], invoice.partner_id.vat) + self.assertEqual(invoice._get_validation('currency')['content'], invoice.currency_id.name) + self.assertEqual(invoice._get_validation('payment_ref')['content'], invoice.payment_reference) + validation_invoice_lines = invoice._get_validation('invoice_lines')['lines'] + for i, il in enumerate(invoice.invoice_line_ids): + self.assertDictEqual(validation_invoice_lines[i], { + 'description': il.name, + 'quantity': il.quantity, + 'unit_price': il.price_unit, + 'product': il.product_id.id, + 'taxes_amount': round(il.price_total - il.price_subtotal, 2), + 'taxes': [{ + 'amount': tax.amount, + 'type': tax.amount_type, + 'price_include': tax.price_include} for tax in il.tax_ids], + 'subtotal': il.price_subtotal, + 'total': il.price_total, + }) + + def test_automatic_sending_vendor_bill_message_post(self): + # test that a vendor bill is automatically sent to the OCR server when a message with attachment is posted and the option is enabled + self.env.company.extract_in_invoice_digitalization_mode = 'auto_send' + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'no_extract_requested'}) + test_attachment = self.env['ir.attachment'].create({ + 'name': "an attachment", + 'datas': base64.b64encode(b'My attachment'), + }) + + expected_parse_params = { + 'version': OCR_VERSION, + 'account_token': 'test_token', + 'dbuuid': self.env['ir.config_parameter'].sudo().get_param('database.uuid'), + 'documents': [test_attachment.datas.decode('utf-8')], + 'user_infos': { + 'perspective': 'client', + 'user_company_VAT': invoice.company_id.vat, + 'user_company_country_code': invoice.company_id.country_id.code, + 'user_company_name': invoice.company_id.name, + 'user_email': self.env.user.email, + 'user_lang': self.env.ref('base.user_root').lang, + }, + 'webhook_url': f'{invoice.get_base_url()}/odex30_account_invoice_extract/request_done', + } + + if self.env['ir.module.module']._get('odex30_account_invoice_extract_purchase').state == 'installed': + expected_parse_params['user_infos']['purchase_order_regex'] = r'P\d{5}' + + with self._mock_iap_extract( + extract_response=self.parse_success_response(), + assert_params=expected_parse_params, + ): + invoice.message_post(attachment_ids=[test_attachment.id]) + + self.assertEqual(invoice.extract_state, 'waiting_extraction') + self.assertEqual(invoice.extract_document_uuid, 'some_token') + + def test_automatic_sending_vendor_bill_main_attachment(self): + # test that a vendor bill is automatically sent to the OCR server when a main attachment is registered and the option is enabled + self.env.company.extract_in_invoice_digitalization_mode = 'auto_send' + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'no_extract_requested'}) + test_attachment = self.env['ir.attachment'].create({ + 'name': "an attachment", + "mimetype": "application/pdf", + 'datas': base64.b64encode(b'My attachment'), + 'res_model': 'account.move', + 'res_id': invoice.id, + }) + + with self._mock_iap_extract(extract_response=self.parse_success_response()): + test_attachment.register_as_main_attachment() + + self.assertEqual(invoice.extract_state, 'waiting_extraction') + self.assertEqual(invoice.extract_document_uuid, 'some_token') + + def test_not_automatic_sending_entry_main_attachment(self): + # test that an entry is not automatically sent to the OCR server when a main attachment is registered and the option is enabled + self.env.company.extract_in_invoice_digitalization_mode = 'auto_send' + invoice = self.env['account.move'].create({'move_type': 'entry', 'extract_state': 'no_extract_requested'}) + test_attachment = self.env['ir.attachment'].create({ + 'name': "an attachment", + 'datas': base64.b64encode(b'My attachment'), + 'res_model': 'account.move', + 'res_id': invoice.id, + }) + + with self._mock_iap_extract(extract_response=self.parse_success_response()): + test_attachment.register_as_main_attachment() + + self.assertEqual(invoice.extract_state, 'no_extract_requested') + self.assertFalse(invoice.extract_document_uuid) + + def test_automatic_sending_multiple_vendor_bill_message_post(self): + # test that when multiple pdf attachments are posted and the option is enabled each one is split + # into a separate move + self.env.company.extract_in_invoice_digitalization_mode = 'auto_send' + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'no_extract_requested'}) + with file_open('base/tests/minimal.pdf', 'rb') as file: + pdf_bytes = file.read() + test_attachments = self.env['ir.attachment'].create([{ + 'name': 'Attachment 1', + 'datas': base64.b64encode(pdf_bytes), + 'mimetype': 'application/pdf', + }, { + 'name': 'Attachment 2', + 'datas': base64.b64encode(pdf_bytes), + 'mimetype': 'application/pdf', + }]) + + with self._mock_iap_extract( + extract_response=self.parse_success_response(), + ): + invoice.with_context(from_alias=True, default_move_type='in_invoice', default_journal_id=invoice.journal_id.id).message_post(attachment_ids=test_attachments.ids) + + new_invoice_id = invoice.id + 1 + invoices = invoice + invoices |= self.env['account.move'].search([('id', '=', new_invoice_id)]) + + self.assertEqual(len(invoices), 2, "Two separate bills should have been created") + for inv, att in zip(invoices, test_attachments): + self.assertEqual(inv.extract_state, 'waiting_extraction') + self.assertEqual(inv.extract_document_uuid, 'some_token') + self.assertEqual(inv.message_main_attachment_id, att) + + def test_automatic_sending_customer_invoice_upload(self): + # test that a customer invoice is automatically sent to the OCR server when uploaded and the option is enabled + self.env.company.extract_out_invoice_digitalization_mode = 'auto_send' + test_attachment = self.env['ir.attachment'].create({ + 'name': "an attachment", + 'datas': base64.b64encode(b'My attachment'), + }) + with self._mock_iap_extract(extract_response=self.parse_success_response()): + action = self.env['account.journal'].with_context(default_move_type='out_invoice').create_document_from_attachment(test_attachment.id) + + invoice = self.env['account.move'].browse(action['res_id']) + self.assertEqual(invoice.extract_state, 'waiting_extraction') + self.assertEqual(invoice.extract_document_uuid, 'some_token') + + def test_automatic_sending_customer_invoice_email_alias(self): + # test that a customer invoice is automatically sent to the OCR server when sent via email alias and the option is enabled + self.env.company.extract_out_invoice_digitalization_mode = 'auto_send' + with file_open('base/tests/minimal.pdf', 'rb') as file: + pdf_bytes = file.read() + mail = self._get_email_for_journal_alias( + attachment=pdf_bytes, + attach_content_type='application/pdf', + message_id='message_2' + ) + with self._mock_iap_extract(self.parse_success_response()): + invoice = self.env['account.move'].browse(self.env['mail.thread'].message_process('account.move', mail)) + self.assertEqual(invoice.extract_state, 'waiting_extraction') + self.assertEqual(invoice.extract_document_uuid, 'some_token') + + def test_no_automatic_sending_customer_invoice_email_alias(self): + # test that a customer invoice isn't automatically sent to the OCR server when sent via email alias and the option is disabled + self.env.company.extract_out_invoice_digitalization_mode = 'manual_send' + mail = self._get_email_for_journal_alias() + with self._mock_iap_extract(self.parse_success_response()): + invoice = self.env['account.move'].browse(self.env['mail.thread'].message_process('account.move', mail)) + self.assertEqual(invoice.extract_state, 'no_extract_requested') + + def test_no_automatic_sending_customer_invoice_message_post(self): + # test that a customer invoice isn't automatically sent to the OCR server when a message with attachment is posted and the option is enabled + self.env.company.extract_out_invoice_digitalization_mode = 'auto_send' + invoice = self.env['account.move'].create({'move_type': 'out_invoice', 'extract_state': 'no_extract_requested'}) + test_attachment = self.env['ir.attachment'].create({ + 'name': "an attachment", + 'datas': base64.b64encode(b'My attachment'), + }) + + with self._mock_iap_extract(extract_response=self.parse_success_response()): + invoice.message_post(attachment_ids=[test_attachment.id]) + + self.assertEqual(invoice.extract_state, 'no_extract_requested') + self.assertFalse(invoice.extract_document_uuid) + + def test_no_automatic_sending_customer_invoice_main_attachment(self): + # test that a customer invoice isn't automatically sent to the OCR server when a main attachment is registered and the option is enabled + self.env.company.extract_out_invoice_digitalization_mode = 'auto_send' + invoice = self.env['account.move'].create({'move_type': 'out_invoice', 'extract_state': 'no_extract_requested'}) + test_attachment = self.env['ir.attachment'].create({ + 'name': "an attachment", + 'datas': base64.b64encode(b'My attachment'), + 'res_model': 'account.move', + 'res_id': invoice.id, + }) + + with self._mock_iap_extract(extract_response=self.parse_success_response()): + test_attachment.register_as_main_attachment() + + self.assertEqual(invoice.extract_state, 'no_extract_requested') + self.assertFalse(invoice.extract_document_uuid) + + def test_no_automatic_sending_option_disabled(self): + # test that an invoice isn't automatically sent to the OCR server when the option is disabled + self.env.company.extract_in_invoice_digitalization_mode = 'manual_send' + self.env.company.extract_out_invoice_digitalization_mode = 'manual_send' + for move_type in ('in_invoice', 'out_invoice'): + # test with message_post() + invoice = self.env['account.move'].create({'move_type': move_type, 'extract_state': 'no_extract_requested'}) + test_attachment = self.env['ir.attachment'].create({ + 'name': "an attachment", + 'datas': base64.b64encode(b'My attachment'), + }) + + with self._mock_iap_extract(extract_response=self.parse_success_response()): + invoice.message_post(attachment_ids=[test_attachment.id]) + + self.assertEqual(invoice.extract_state, 'no_extract_requested') + + # test with register_as_main_attachment() + invoice = self.env['account.move'].create({'move_type': move_type, 'extract_state': 'no_extract_requested'}) + test_attachment = self.env['ir.attachment'].create({ + 'name': "another attachment", + 'datas': base64.b64encode(b'My other attachment'), + 'res_model': 'account.move', + 'res_id': invoice.id, + }) + + with self._mock_iap_extract(extract_response=self.parse_success_response()): + test_attachment.register_as_main_attachment() + + self.assertEqual(invoice.extract_state, 'no_extract_requested') + self.assertFalse(invoice.extract_document_uuid) + + def test_bank_account(self): + # test that the bank account is set when an iban is found + + # test that an account is created if no existing matches the account number + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + + with self._mock_iap_extract( + extract_response=self.get_result_success_response(), + partner_autocomplete_response=self.get_partner_autocomplete_response(), + ): + invoice._check_ocr_status() + + self.assertEqual(invoice.partner_bank_id.acc_number, 'BE01234567890123') + + # test that it uses the existing bank account if it exists + created_bank_account = invoice.partner_bank_id + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + + with self._mock_iap_extract(extract_response=self.get_result_success_response()): + invoice._check_ocr_status() + + self.assertEqual(invoice.partner_bank_id, created_bank_account) + + def test_tax_price_included(self): + self.env['account.tax'].create({ + 'name': 'Tax 12% included', + 'amount': 12, + 'amount_type': 'percent', + 'type_tax_use': 'purchase', + 'price_include_override': 'tax_included', + 'company_id': self.company_data['company'].id + }) + + invoice = self._create_invoice_with_tax() + + self.assertRecordValues(invoice.invoice_line_ids, [{ + 'price_unit': 112, + 'quantity': 1, + 'price_subtotal': 100, + 'price_total': 112, + }]) + + def test_tax_price_excluded(self): + self.env['account.tax'].create({ + 'name': 'Tax 12% excluded', + 'amount': 12, + 'amount_type': 'percent', + 'type_tax_use': 'purchase', + 'company_id': self.company_data['company'].id, + 'price_include_override': 'tax_excluded', + }) + + invoice = self._create_invoice_with_tax() + + self.assertRecordValues(invoice.invoice_line_ids, [{ + 'price_unit': 100, + 'quantity': 1, + 'price_subtotal': 100, + 'price_total': 112, + }]) + + def _create_invoice_with_tax(self): + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + extract_response = self.get_result_success_response() + extract_response['results'][0]['total']['selected_value']['content'] = 112 + extract_response['results'][0]['subtotal']['selected_value']['content'] = 100 + extract_response['results'][0]['invoice_lines'] = [ + { + 'description': {'selected_value': {'content': 'Test 1'}}, + 'unit_price': {'selected_value': {'content': 100}}, + 'quantity': {'selected_value': {'content': 1}}, + 'taxes': {'selected_values': [{'content': 12, 'amount_type': 'percent'}]}, + 'subtotal': {'selected_value': {'content': 100}}, + 'total': {'selected_value': {'content': 112}}, + }, + ] + + with self._mock_iap_extract(extract_response, {}): + invoice._check_ocr_status() + + return invoice + + def test_credit_note_detection(self): + # test that move type changes, if and only if the type in the ocr results is refund the current move type is invoice + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + + extract_response = self.get_result_success_response() + extract_response['results'][0]['type'] = 'refund' + with self._mock_iap_extract(extract_response=extract_response): + invoice._check_ocr_status() + + self.assertEqual(invoice.move_type, 'in_refund') + + invoice = self.env['account.move'].create({'move_type': 'out_refund', 'extract_state': 'waiting_extraction'}) + + extract_response['results'][0]['type'] = 'invoice' + with self._mock_iap_extract(extract_response=extract_response): + invoice._check_ocr_status() + + self.assertEqual(invoice.move_type, 'out_refund') + + def test_action_reload_ai_data(self): + # test that the "Reload AI data" button overwrites the content of the invoice with the OCR results + self.env.company.extract_single_line_per_tax = False + ocr_partner = self.env['res.partner'].create({'name': 'Test', 'vat': 'BE0477472701'}) + + invoice = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'extract_state': 'waiting_validation', + 'invoice_date': '2019-04-01', + 'date': '2019-04-01', + 'invoice_date_due': '2019-05-01', + 'ref': 'INV1234', + 'payment_reference': '+++111/2222/33333+++', + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [(0, 0, { + 'name': 'Blabla', + 'price_unit': 13.0, + 'quantity': 2.0, + 'account_id': self.company_data['default_account_revenue'].id, + })], + }) + + extract_response = self.get_result_success_response() + with self._mock_iap_extract(extract_response=extract_response): + invoice.action_reload_ai_data() + + self.assertEqual(invoice.extract_state, 'waiting_validation') + + # Check that the fields have been overwritten with the OCR results + self.assertEqual(invoice.amount_total, 330) + self.assertEqual(invoice.amount_untaxed, 300) + self.assertEqual(invoice.amount_tax, 30) + self.assertEqual(invoice.partner_id, ocr_partner) + self.assertEqual(invoice.invoice_date, fields.Date.from_string('2019-04-12')) + self.assertEqual(invoice.invoice_date_due, fields.Date.from_string('2019-04-19')) + self.assertEqual(invoice.payment_reference, '+++123/1234/12345+++') + self.assertEqual(invoice.ref, 'INV0001') + self.assertEqual(invoice.invoice_line_ids.mapped('name'), ["Test 1", "Test 2", "Test 3"]) + + def test_autopost_bills_ocr(self): + # Test that when we validate 3 bills without modification from the OCR data, we show + # the user a wizard to automate the posting for that vendor + + def create_bill_with_ocr(ref): + move = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'ref': ref, + 'extract_state': 'waiting_extraction', + 'extract_document_uuid': 'some_token', + }) + with self._mock_iap_extract( + extract_response=self.get_result_success_response(), + partner_autocomplete_response=self.get_partner_autocomplete_response(), + ): + move._check_ocr_status() + return move + + # Do it two times, no wizard should be shown + for _ in range(2): + bill = create_bill_with_ocr("ref1") + self.assertEqual(bill.state, "draft") + autopost_bills_wizard = bill.action_post() + self.assertFalse(autopost_bills_wizard) + self.assertFalse(bill.is_manually_modified) + + # Create a third invoice, No modification for the third time, we should show the wizard + bill = create_bill_with_ocr("ref2") + self.assertEqual(bill.state, "draft") + post_result = bill.action_post() + self.assertEqual(post_result.get('res_model'), 'account.autopost.bills.wizard') + autopost_bills_wizard = self.env[post_result.get('res_model')].browse(post_result.get('res_id')) + self.assertTrue(autopost_bills_wizard) + autopost_bills_wizard.action_automate_partner() + + # Now, next time the OCR is finished, we should autopost the bill + bill = create_bill_with_ocr("ref3") + self.assertEqual(bill.state, "posted") + + def test_invoice_ocr_note_author(self): + invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'}) + attachment = self.env['ir.attachment'].create({ + 'name': 'test_attachment.png', + 'res_model': 'account.move', + 'raw': b'My invoice', + }) + with self._mock_iap_extract(extract_response=self.parse_success_response()): + invoice.message_post(attachment_ids=[attachment.id]) + + with self._mock_iap_extract(extract_response=self.get_result_success_response()): + invoice.check_all_status() + + self.env.cr.flush() + message = self.env['mail.message'].search([ + ('model', '=', 'account.move'), + ('res_id', '=', invoice.id), + ('tracking_value_ids', '!=', False), + ]).ensure_one() + author_name = message.author_id.complete_name + self.assertEqual(author_name, 'OdooBot') diff --git a/dev_odex30_accounting/odex30_account_invoice_extract/views/account_move_views.xml b/dev_odex30_accounting/odex30_account_invoice_extract/views/account_move_views.xml new file mode 100644 index 0000000..271d662 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_invoice_extract/views/account_move_views.xml @@ -0,0 +1,50 @@ + + + + + invoice.move.form.inherit.ocr + account.move + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_winbooks_import/tests/__init__.py b/dev_odex30_accounting/odex30_account_winbooks_import/tests/__init__.py new file mode 100644 index 0000000..973784f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_winbooks_import/tests/__init__.py @@ -0,0 +1,2 @@ + +from . import test_winbooks_import diff --git a/dev_odex30_accounting/odex30_account_winbooks_import/tests/test_winbooks_import.py b/dev_odex30_accounting/odex30_account_winbooks_import/tests/test_winbooks_import.py new file mode 100644 index 0000000..aaf144a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_winbooks_import/tests/test_winbooks_import.py @@ -0,0 +1,44 @@ + +from odoo.tests import common, tagged +import base64 +import requests + +TESTURL = 'https://files.exact.com/static/downloads/winbooks/PARFILUX_2013.04.08.zip' +FILENAME = 'PARFILUX_2013.04.08.zip' + + +@tagged('post_install', '-at_install', 'external', '-standard') +class TestWinbooksImport(common.TransactionCase): + + def download_test_db(self): + response = requests.get(TESTURL, timeout=30) + response.raise_for_status() + return self.env['ir.attachment'].create({ + 'datas': base64.b64encode(response.content), + 'name': FILENAME, + 'mimetype': 'application/zip', + }) + + def test_winbooks_import(self): + attachment = ( + self.env['ir.attachment'].search([('name', '=', FILENAME)]) + or self.download_test_db() + ) + test_company = self.env['res.company'].create({ + 'name': 'My Winbooks Company', + 'currency_id': self.env['res.currency'].with_context(active_test=False).search([('name', '=', 'EUR')]).id, + 'country_id': self.env.ref('base.be').id, + }) + self.env['account.chart.template'].try_loading('be_comp', test_company) + wizard = self.env['account.winbooks.import.wizard'].with_company(test_company).create({ + 'zip_file': attachment.datas, + }) + last = self.env['account.move'].search([('company_id', '=', test_company.id)], order='id desc', limit=1) + wizard.with_company(test_company).import_winbooks_file() + new_moves = self.env['account.move'].search([ + ('company_id', '=', test_company.id), + ('id', '>', last.id), + ]) + self.assertTrue(new_moves) + new_moves.action_post() + self.assertTrue(new_moves.line_ids.full_reconcile_id, "There should be at least one full reconciliation after the import") diff --git a/dev_odex30_accounting/odex30_account_winbooks_import/wizard/__init__.py b/dev_odex30_accounting/odex30_account_winbooks_import/wizard/__init__.py new file mode 100644 index 0000000..e0c5452 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_winbooks_import/wizard/__init__.py @@ -0,0 +1,3 @@ + +from . import account_import_summary +from . import import_wizard diff --git a/dev_odex30_accounting/odex30_account_winbooks_import/wizard/account_import_summary.py b/dev_odex30_accounting/odex30_account_winbooks_import/wizard/account_import_summary.py new file mode 100644 index 0000000..8c0188a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_winbooks_import/wizard/account_import_summary.py @@ -0,0 +1,38 @@ + +from odoo import api, fields, models + + +class AccountWinbooksImportSummary(models.TransientModel): + _inherit = 'account.import.summary' + + import_summary_analytic_ids = fields.Many2many('account.analytic.account') + import_summary_analytic_line_ids = fields.Many2many('account.analytic.line') + + import_summary_len_analytic = fields.Integer(compute='_compute_import_summary_len_analytic') + import_summary_len_analytic_line = fields.Integer(compute='_compute_import_summary_len_analytic_line') + + @api.depends('import_summary_analytic_ids') + def _compute_import_summary_len_analytic(self): + self.import_summary_len_analytic = len(self.import_summary_analytic_ids) + + @api.depends('import_summary_analytic_line_ids') + def _compute_import_summary_len_analytic_line(self): + self.import_summary_len_analytic_line = len(self.import_summary_analytic_line_ids) + + @api.depends('import_summary_analytic_ids', 'import_summary_analytic_line_ids') + def _compute_import_summary_have_data(self): + # EXTENDS 'odex30_account_base_import' + super()._compute_import_summary_have_data() + for record in self: + if not record.import_summary_have_data: + record.import_summary_have_data = bool(record.import_summary_analytic_ids or record.import_summary_analytic_line_ids) + + def action_open_analytic_view(self): + action = self.env['ir.actions.act_window']._for_xml_id('analytic.action_account_analytic_account_form') + action['domain'] = [('id', 'in', self.import_summary_analytic_ids.ids)] + return action + + def action_open_analytic_line_view(self): + action = self.env['ir.actions.act_window']._for_xml_id('analytic.account_analytic_line_action_entries') + action['domain'] = [('id', 'in', self.import_summary_analytic_line_ids.ids)] + return action diff --git a/dev_odex30_accounting/odex30_account_winbooks_import/wizard/account_import_summary_views.xml b/dev_odex30_accounting/odex30_account_winbooks_import/wizard/account_import_summary_views.xml new file mode 100644 index 0000000..42799a3 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_winbooks_import/wizard/account_import_summary_views.xml @@ -0,0 +1,26 @@ + + + + + account.import.summary.form + account.import.summary + + + + + +
  • + + account analytics imported + +
  • +
  • + + account analytic lines imported + +
  • +
    +
    +
    + +
    diff --git a/dev_odex30_accounting/odex30_account_winbooks_import/wizard/import_wizard.py b/dev_odex30_accounting/odex30_account_winbooks_import/wizard/import_wizard.py new file mode 100644 index 0000000..f197093 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_winbooks_import/wizard/import_wizard.py @@ -0,0 +1,832 @@ + +import base64 +import collections +import io +import itertools +import logging +import os +import zipfile +import re +from datetime import datetime, timedelta +from tempfile import TemporaryDirectory + +from dbfread import DBF + +from odoo import models, fields, _ +from odoo.exceptions import UserError, RedirectWarning +from odoo.tools import frozendict + +_logger = logging.getLogger(__name__) + + +PURCHASE_CODE = '0' +SALE_CODE = '2' +CREDIT_NOTE_PURCHASE_CODE = '1' +CREDIT_NOTE_SALE_CODE = '3' + +class WinbooksImportWizard(models.TransientModel): + _name = "account.winbooks.import.wizard" + _description = 'Account Winbooks import wizard' + + zip_file = fields.Binary('File', required=True) + only_open = fields.Boolean('Import only open years', help="Years closed in Winbooks are likely to have incomplete data. The counter part of incomplete entries will be set in a suspense account", default=True) + suspense_code = fields.Char(string="Suspense Account Code", help="This is the code of the account in which you want to put the counterpart of unbalanced moves. This might be an account from your Winbooks data, or an account that you created in Odoo before the import.") + + def _import_partner_info(self, dbf_records): + + _logger.info("Import Partner Infos") + civility_data = {} + category_data = {} + ResPartnerTitle = self.env['res.partner.title'] + ResPartnerCategory = self.env['res.partner.category'] + for rec in dbf_records: + if rec.get('TTYPE') == 'CIVILITY': + shortcut = rec.get('TID') + title = ResPartnerTitle.search([('shortcut', '=', shortcut)], limit=1) + if not title: + title = ResPartnerTitle.create({'shortcut': shortcut, 'name': rec.get('TDESC')}) + civility_data[shortcut] = title.id + elif rec.get('TTYPE').startswith('CAT'): + category = ResPartnerCategory.search([('name', '=', rec.get('TDESC'))], limit=1) + if not category: + category = ResPartnerCategory.create({'name': rec.get('TDESC')}) + category_data[rec.get('TID')] = category.id + return civility_data, category_data + + def _import_partner(self, dbf_records, civility_data, category_data, account_data): + """Import partners from *_csf*.dbf files. + The data in those files is the partner details, its type, its category, + bank informations, and central accounts. + :return: a dictionary whose keys are the Winbooks partner references and + the values are the partner ids in Odoo. + """ + _logger.info("Import Partners") + partner_data = {} + ResBank = self.env['res.bank'] + ResCountry = self.env['res.country'] + ResPartner = self.env['res.partner'] + ResPartnerBank = self.env['res.partner.bank'] + partner_data_dict = {} + for rec in dbf_records: + if not rec.get('NUMBER'): + continue + partner = ResPartner.search([('ref', '=', rec.get('NUMBER'))], limit=1) + if partner: + partner_data[rec.get('NUMBER')] = partner.id + if not partner: + vatcode = rec.get('VATNUMBER') and rec.get('COUNTRY') and (rec.get('COUNTRY') + rec.get('VATNUMBER').replace('.', '')) + if not rec.get('VATNUMBER') or not rec.get('COUNTRY') or not ResPartner.simple_vat_check(rec.get('COUNTRY').lower(), vatcode): + vatcode = '' + data = { + 'ref': rec.get('NUMBER'), + 'name': rec.get('NAME1'), + 'street': rec.get('ADRESS1'), + 'country_id': ResCountry.search([('code', '=', rec.get('COUNTRY'))], limit=1).id, + 'city': rec.get('CITY'), + 'street2': rec.get('ADRESS2'), + 'vat': vatcode, + 'phone': rec.get('TELNUMBER'), + 'zip': rec.get('ZIPCODE') and ''.join([n for n in rec.get('ZIPCODE') if n.isdigit()]), + 'email': rec.get('EMAIL'), + 'active': not rec.get('ISLOCKED'), + 'title': civility_data.get(rec.get('CIVNAME1'), False), + 'category_id': [(6, 0, [category_data.get(rec.get('CATEGORY'))])] if category_data.get(rec.get('CATEGORY')) else False + } + if partner_data_dict.get(rec.get('NUMBER')): + for key, value in partner_data_dict[rec.get('NUMBER')].items(): + if value: # Winbooks has different partners for customer/supplier. Here we merge the data of the 2 + data[key] = value + if rec.get('NAME2'): + data.update({ + 'child_ids': [(0, 0, {'name': rec.get('NAME2'), 'title': civility_data.get(rec.get('CIVNAME2'), False)})] + }) + # manage the bank account of the partner + if rec.get('IBANAUTO'): + partner_bank = ResPartnerBank.search([('acc_number', '=', rec.get('IBANAUTO'))], limit=1) + if partner_bank: + data['bank_ids'] = [(4, partner_bank.id)] + else: + bank = ResBank.search([('name', '=', rec.get('BICAUTO'))], limit=1) + if not bank: + bank = ResBank.create({'name': rec.get('BICAUTO')}) + data.update({ + 'bank_ids': [(0, 0, { + 'acc_number': rec.get('IBANAUTO'), + 'bank_id': bank.id + })], + }) + # manage the default payable/receivable accounts for the partner + if rec.get('CENTRAL'): + if rec.get('TYPE') == '1': + data['property_account_receivable_id'] = account_data[rec.get('CENTRAL')] + else: + data['property_account_payable_id'] = account_data[rec.get('CENTRAL')] + + partner_data_dict[rec.get('NUMBER')] = data + if len(partner_data_dict) % 100 == 0: + _logger.info("Advancement: %s", len(partner_data_dict)) + + partner_ids = ResPartner.create(partner_data_dict.values()) + for partner in partner_ids: + partner_data[partner.ref] = partner.id + return partner_data, partner_ids + + def _import_account(self, dbf_records): + """Import accounts from *_acf*.dbf files. + The data in those files are the type, name, code and currency of the + account as well as wether it is used as a default central account for + partners or taxes. + :return: (account_data, account_central, account_deprecated_ids, account_tax) + account_data is a dictionary whose keys are the Winbooks account + references and the values are the account ids in Odoo. + account_central is a dictionary whose keys are the Winbooks central + account references and the values are the account ids in Odoo. + account_deprecated_ids is a recordset of account that need to be + deprecated after the import. + account_tax is a dictionary whose keys are the Winbooks account + references and the values are the Winbooks tax references. + """ + def manage_centralid(account, centralid, skip_constraints_check=False): + "Set account to being a central account" + property_name = None + account_central[centralid] = account.id + tax_group_name = None + if centralid == 'S1': + property_name = 'property_account_payable_id' + model_name = 'res.partner' + if centralid == 'C1': + property_name = 'property_account_receivable_id' + model_name = 'res.partner' + if centralid == 'V01': + tax_group_name = 'tax_receivable_account_id' + if centralid == 'V03': + tax_group_name = 'tax_payable_account_id' + if property_name: + self.env['ir.default'].set(model_name, property_name, account.id, company_id=self.env.company.id) + if tax_group_name: + self.env['account.tax.group'].search(self.env['account.tax.group']._check_company_domain(self.env.company)).with_context(skip_constraints_check=skip_constraints_check)[tax_group_name] = account + + _logger.info("Import Accounts") + account_data = {} + account_central = {} + account_tax = {} + grouped = collections.defaultdict(list) + AccountAccount = self.env['account.account'] + ResCurrency = self.env['res.currency'] + AccountGroup = self.env['account.group'] + account_types = [ + {'min': 100, 'max': 160, 'id': 'equity'}, + {'min': 160, 'max': 200, 'id': 'liability_non_current'}, + {'min': 200, 'max': 280, 'id': 'asset_non_current'}, + {'min': 280, 'max': 290, 'id': 'asset_fixed'}, + {'min': 290, 'max': 420, 'id': 'asset_current'}, + {'min': 420, 'max': 490, 'id': 'liability_current'}, + {'min': 490, 'max': 492, 'id': 'asset_current'}, + {'min': 492, 'max': 500, 'id': 'liability_current'}, + {'min': 500, 'max': 600, 'id': 'asset_cash'}, + {'min': 600, 'max': 700, 'id': 'expense'}, + {'min': 700, 'max': 822, 'id': 'income'}, + {'min': 822, 'max': 860, 'id': 'expense'}, + ] + for rec in dbf_records: + grouped[rec.get('TYPE')].append(rec) + rec_number_list = [] + account_data_list = [] + journal_centered_list = [] + is_deprecated_list = [] + account_deprecated_ids = self.env['account.account'] + for key, val in grouped.items(): + if key == '3': # 3=general account, 9=title account + for rec in val: + account = AccountAccount.search([ + *AccountAccount._check_company_domain(self.env.company), + ('code', '=', rec.get('NUMBER')), + ], limit=1) + if account: + account_data[rec.get('NUMBER')] = account.id + rec['CENTRALID'] and manage_centralid(account, rec['CENTRALID'], skip_constraints_check=True) + if not account and rec.get('NUMBER') not in rec_number_list: + data = { + 'code': rec.get('NUMBER'), + 'name': rec.get('NAME11'), + 'group_id': AccountGroup.search([('code_prefix_start', '=', rec.get('CATEGORY'))], limit=1).id, + 'currency_id': ResCurrency.search([('name', '=', rec.get('CURRENCY'))], limit=1).id + } + if rec.get('VATCODE'): + account_tax[rec.get('NUMBER')] = rec.get('VATCODE') + try: + account_code = int(rec.get('NUMBER')[:3]) + except Exception: + _logger.warning('%s is not a valid account number for %s.', rec.get('NUMBER'), rec.get('NAME11')) + account_code = 300 # set Current Asset by default for deprecated accounts + for account_type in account_types: + if account_code in range(account_type['min'], account_type['max']): + if rec.get('CENTRALID', '').startswith('C') or rec.get('CENTRALID', '').startswith('V01'): + data['account_type'] = 'asset_receivable' + data['reconcile'] = True + data['non_trade'] = True + elif rec.get('CENTRALID', '').startswith('S') or rec.get('CENTRALID', '').startswith('V03'): + data['account_type'] = 'liability_payable' + data['reconcile'] = True + data['non_trade'] = True + else: + data['account_type'] = account_type['id'] + data['reconcile'] = False + break + # fallback for accounts not in range(100000,860000) + if not data.get('account_type'): + data['account_type'] = 'income_other' + account_data_list.append(data) + rec_number_list.append(rec.get('NUMBER')) + journal_centered_list.append(rec.get('CENTRALID')) + is_deprecated_list.append(rec.get('ISLOCKED')) + + if len(account_data_list) % 100 == 0: + _logger.info("Advancement: %s", len(account_data_list)) + account_ids = AccountAccount.create(account_data_list) + for account, rec_number, journal_centred, is_deprecated in zip(account_ids, rec_number_list, journal_centered_list, is_deprecated_list): + account_data[rec_number] = account.id + # create the ir.default if this is marked as a default account for something + journal_centred and manage_centralid(account, journal_centred) + # we can't deprecate the account now as we still need to add lines with this account + # keep the list in memory so that we can deprecate later + if is_deprecated: + account_deprecated_ids += account + return account_data, account_central, account_deprecated_ids, account_tax, account_ids + + def _post_process_account(self, account_data, vatcode_data, account_tax): + """Post process the accounts after the taxes creation to add the taxes + on the accounts""" + for account, vat in account_tax.items(): + if vat in vatcode_data: + self.env['account.account'].browse(account_data[account]).write({'tax_ids': [(4, vatcode_data[vat])]}) + + def _post_process_tax(self, tax_ids, account_deprecated_ids): + """Post process the tax data in order to avoid deprecating accounts + used in repartition lines + """ + account_deprecated_ids -= tax_ids.repartition_line_ids.filtered(lambda l: l.repartition_type == 'tax').account_id + return account_deprecated_ids + + def _import_journal(self, dbf_records): + """Import journals from *_dbk*.dbf files. + The data in those files are the name, code and type of the journal. + :return: a dictionary whose keys are the Winbooks journal references and + the values the journal ids in Odoo + """ + _logger.info("Import Journals") + journal_types = { + '0': 'purchase', + '1': 'purchase', + '2': 'sale', + '3': 'sale', + '5': 'general', + } + journal_data = {} + journals = self.env['account.journal'] + AccountJournal = self.env['account.journal'] + existing_journals = AccountJournal.search(AccountJournal._check_company_domain(self.env.company)) + used_codes = set(existing_journals.mapped('code')) + processed_records = set() # used to filter out duplicate records + code_inc = 0 + for rec in dbf_records: + if not rec.get('DBKID') or rec.get('DBKID') in processed_records: + continue + journal = existing_journals.filtered(lambda j: j.code == rec.get('DBKID')) + if not journal: + if rec.get('DBKTYPE') == '4': + journal_type = 'bank' if 'IBAN' in rec.get('DBKOPT') else 'cash' + else: + journal_type = journal_types.get(rec.get('DBKTYPE'), 'general') + # The code of a journal is limited to a size of 5 characters. + # The following process is applied to the received code: + # 1) Check if the 5 first characters is used. + # 2) Check if the 5 last characters is used. + # 3) Fall back on a generic code. + # The format of this code will be the "*" character followed by an incremented number. + # The possible values will range from "*1" to "*9999". + # There are only 9999 possibilities, but it should be more than enough to handle the duplicate codes. + # The purpose of this generic code is to not prevent the jounal creation and to be able + # to quickly find it once created if we want to change it manually. + code = rec.get('DBKID')[:5] + if code in used_codes: + code = rec.get('DBKID')[-5:] + while code in used_codes and code_inc < 10000: + code_inc += 1 + code = '*%s' % code_inc + used_codes.add(code) + data = { + 'name': rec.get('DBKDESC'), + 'code': code, + 'type': journal_type, + } + if data['type'] == 'sale': + data['default_account_id'] = self.env['product.category']._fields['property_account_income_categ_id'].get_company_dependent_fallback(self.env['product.category']).id + if data['type'] == 'purchase': + data['default_account_id'] = self.env['product.category']._fields['property_account_expense_categ_id'].get_company_dependent_fallback(self.env['product.category']).id + journal = AccountJournal.create(data) + journal_data[rec.get('DBKID')] = journal.id + journals += journal + processed_records.add(rec.get('DBKID')) + return journal_data, journals + + def _import_move(self, dbf_records, pdffiles, account_data, account_central, journal_data, partner_data, vatcode_data, param_data): + """Import the journal entries from *_act*.dfb and @scandbk.zip files. + The data in *_act*.dfb files are related to the moves and the data in + @scandbk.zip files are the attachments. + """ + _logger.info("Import Moves") + ResCurrency = self.env['res.currency'] + IrAttachment = self.env['ir.attachment'] + suspense_account = self.env['account.account'].search([('code', '=', self.suspense_code)], limit=1) + if not self.only_open and not suspense_account: + raise UserError(_("The code for the Suspense Account you entered doesn't match any account")) + counter_part_created = False + result = [ + tupleized + for tupleized in set( + item + for item in dbf_records + if item.get("BOOKYEAR") and item.get("DOCNUMBER") != "99999999" + ) + ] + grouped = collections.defaultdict(list) + currency_codes = set() + for item in result: + # Group by number/year/period + grouped[item['DOCNUMBER'], item['DBKCODE'], item['DBKTYPE'], item['BOOKYEAR'], item['PERIOD']] += [item] + + # Get all currencies to search them in batch + currency_codes.add(item.get('CURRCODE')) + currencies = ResCurrency.with_context(active_test=False).search([('name', 'in', list(currency_codes))]) + if currencies: + currencies.active = True + currency_map = {currency.name: currency for currency in currencies} + + move_data_list = [] + pdf_file_list = [] + for key, val in grouped.items(): + journal_id = self.env['account.journal'].browse(journal_data.get(key[1])) + if not journal_id: + continue + bookyear = int(key[3], 36) + if not bookyear or (self.only_open and bookyear not in param_data['openyears']): + continue + perdiod_number = len(param_data['period_date'][bookyear]) - 2 + period = min(int(key[4]), perdiod_number + 1) # closing is 99 in winbooks, not 13 + start_period_date = param_data['period_date'][bookyear][period] + if 1 <= period < perdiod_number: + end_period_date = param_data['period_date'][bookyear][period + 1] + timedelta(days=-1) + elif period == perdiod_number: # take the last day of the year = day of closing + end_period_date = param_data['period_date'][bookyear][period + 1] + else: # opening (0) or closing (99) are at a fixed date + end_period_date = start_period_date + move_date = val[0].get('DATEDOC') + move_data_dict = { + 'journal_id': journal_id.id, + 'move_type': 'out_invoice' if journal_id.type == 'sale' else 'in_invoice' if journal_id.type == 'purchase' else 'entry', + 'ref': '%s_%s' % (key[1], key[0]), + 'company_id': self.env.company.id, + 'date': min(max(start_period_date, move_date), end_period_date), + 'payment_state': 'not_paid', + } + if not move_data_dict.get('journal_id') and key[1] == 'MATCHG': + continue + move_line_data_list = [] + move_amount_total = 0 + move_total_receivable_payable = 0 + + tmp_val = [] + for rec in val: + tmp_val += [rec.copy()] + if (rec['AMOUNTEUR'] or 0) * (rec['CURRAMOUNT'] or 0) < 0: + tmp_val[-1]['CURRAMOUNT'] = 0 + tmp_val += [rec.copy()] + tmp_val[-1]['AMOUNTEUR'] = 0 + val = tmp_val + + for rec in val: + currency = currency_map.get(rec.get('CURRCODE')) + partner_id = self.env['res.partner'].browse(partner_data.get(rec.get('ACCOUNTRP'), False)) + account_id = self.env['account.account'].browse(account_data.get(rec.get('ACCOUNTGL'))) + matching_number = rec.get('MATCHNO') and '%s-%s' % (rec.get('ACCOUNTGL'), rec.get('MATCHNO')) or False + balance = rec.get('AMOUNTEUR', 0.0) + amount_currency = rec.get('CURRAMOUNT') if currency and rec.get('CURRAMOUNT') else balance + if balance and not account_id: + account_id = suspense_account + line_data = { + 'date': rec.get('DATE', False), + 'account_id': account_id.id, + 'partner_id': partner_id.id, + 'date_maturity': rec.get('DUEDATE', False), + 'name': rec.get('COMMENT'), + 'balance': balance, + 'amount_currency': amount_currency, + 'amount_residual_currency': amount_currency, + 'matching_number': balance != 0.0 and matching_number and f"I{matching_number}", + 'winbooks_line_id': rec['DOCORDER'], + } + if currency: + line_data['currency_id'] = currency.id + + if move_data_dict['move_type'] != 'entry': + if rec.get('DOCORDER') == 'VAT': + line_data['display_type'] = 'tax' + elif account_id and account_id.account_type in ('asset_receivable', 'liability_payable'): + line_data['display_type'] = 'payment_term' + elif rec.get('DBKTYPE') in (CREDIT_NOTE_PURCHASE_CODE, SALE_CODE): + line_data['price_unit'] = -amount_currency + elif rec.get('DBKTYPE') in (PURCHASE_CODE, CREDIT_NOTE_SALE_CODE): + line_data['price_unit'] = amount_currency + + if rec.get('AMOUNTEUR'): + move_amount_total = round(move_amount_total, 2) + round(rec.get('AMOUNTEUR'), 2) + move_line_data_list.append((0, 0, line_data)) + if account_id.account_type in ('asset_receivable', 'liability_payable'): + move_total_receivable_payable += rec.get('AMOUNTEUR') + + if journal_id.type in ('sale', 'purchase'): + is_refund = move_total_receivable_payable < 0 if journal_id.type == 'sale' else move_total_receivable_payable > 0 + if is_refund and key[2] in (PURCHASE_CODE, SALE_CODE): + for move_line_data in move_line_data_list: + if move_line_data[2].get('price_unit'): + move_line_data[2]['price_unit'] = -move_line_data[2]['price_unit'] + else: + is_refund = False + + for line_data, rec in zip(move_line_data_list, val): + if self.env['account.account'].browse(account_data.get(rec.get('ACCOUNTGL'))).account_type in ('asset_receivable', 'liability_payable'): + continue + tax_line = self.env['account.tax'].browse(vatcode_data.get(rec.get('VATCODE') or rec.get('VATIMPUT', []))) + if not tax_line and line_data[2]['account_id'] in account_central.values(): + try: + counterpart = next(r for r in val if r['AMOUNTEUR'] == -rec['AMOUNTEUR'] and r['DOCORDER'] == 'VAT' and r['VATCODE']) + tax_line = self.env['account.tax'].browse(vatcode_data.get(counterpart['VATCODE'])) + except StopIteration: + pass + is_vat_account = ( + self.env.company.country_id.code == 'BE' and rec.get('ACCOUNTGL')[:3] in ('411', '451') + or self.env.company.country_id.code == 'LU' and rec.get('ACCOUNTGL')[:4] in ('4614', '4216') + ) + is_vat = ( + rec.get('DOCORDER') == 'VAT' + or move_data_dict['move_type'] == 'entry' and is_vat_account + ) + repartition_line = is_refund and tax_line.refund_repartition_line_ids or tax_line.invoice_repartition_line_ids + repartition_type = 'tax' if is_vat else 'base' + line_data[2].update({ + 'tax_ids': tax_line and not is_vat and [(4, tax_line.id)] or [], + 'tax_tag_ids': [(6, 0, tax_line.get_tax_tags(is_refund, repartition_type).ids)], + 'tax_repartition_line_id': is_vat and repartition_line.filtered(lambda x: x.repartition_type == repartition_type and x.account_id.id == line_data[2]['account_id']).id or False, + }) + move_line_data_list = [line for line in move_line_data_list if line[2]['account_id']] # Remove empty lines + + if move_data_dict['move_type'] != 'entry': + move_data_dict['currency_id'] = currency_map.get(val[0].get('CURRCODE'), self.env.company.currency_id).id + move_data_dict['partner_id'] = move_line_data_list[0][2]['partner_id'] + move_data_dict['invoice_date_due'] = move_line_data_list[0][2]['date_maturity'] + move_data_dict['invoice_date'] = move_line_data_list[0][2]['date'] + if is_refund: + move_data_dict['move_type'] = move_data_dict['move_type'].replace('invoice', 'refund') + + if move_amount_total: + if not counter_part_created: + _logger.warning(_('At least one automatic counterpart has been created at import. This is probably an error. Please check entry lines with reference: Counterpart (generated at import from Winbooks)')) + counter_part_created = True + account_id = journal_id.default_account_id + account_id = account_id or (partner_id.property_account_payable_id if rec.get('DOCTYPE') in ['0', '1'] else partner_id.property_account_receivable_id) + account_id = account_id or suspense_account # Use suspense account as fallback + line_data = { + 'account_id': account_id.id, + 'date_maturity': rec.get('DUEDATE', False), + 'name': _('Counterpart (generated at import from Winbooks)'), + 'balance': -move_amount_total, + 'amount_currency': -move_amount_total, + 'price_unit': abs(move_amount_total), + } + move_line_data_list.append((0, 0, line_data)) + + if ( + move_data_dict['move_type'] != 'entry' + and len(move_line_data_list) == 1 + and move_line_data_list[0][2].get('display_type') == 'payment_term' + and move_line_data_list[0][2]['balance'] == 0 + ): + line_data = { + 'account_id': journal_id.default_account_id.id, + 'name': _('Counterpart (generated at import from Winbooks)'), + 'balance': 0, + } + move_line_data_list.append((0, 0, line_data)) + + move_data_dict['line_ids'] = move_line_data_list + attachment_key = '%s_%s_%s' % (key[1], key[4], key[0]) + pdf_files = {name: fd for name, fd in pdffiles.items() if attachment_key in name} + pdf_file_list.append(pdf_files) + move_data_list.append(move_data_dict) + + if len(move_data_list) % 100 == 0: + _logger.info("Advancement: %s", len(move_data_list)) + + _logger.info("Creating moves") + move_ids = self.env['account.move'].with_context(skip_invoice_sync=True).create(move_data_list) + _logger.info("Creating attachments") + attachment_data_list = [] + for move, pdf_files in zip(move_ids, pdf_file_list): + if pdf_files: + for name, fd in pdf_files.items(): + attachment_data = { + 'name': name.split('/')[-1], + 'type': 'binary', + 'datas': base64.b64encode(fd.read()), + 'res_model': move._name, + 'res_id': move.id, + 'res_name': move.name + } + attachment_data_list.append(attachment_data) + self.env['ir.attachment'].create(attachment_data_list) + return {f"{m.date.year}_{m.ref}" : m for m in move_ids}, move_ids + + def _import_analytic_account(self, dbf_records, param_data): + + _logger.info("Import Analytic Accounts") + analytic_account_data = {} + analytic_plan_dict = {} + analytic_accounts = self.env['account.analytic.account'] + AccountAnalyticAccount = self.env['account.analytic.account'] + AccountAnalyticPlan = self.env['account.analytic.plan'] + for rec in dbf_records: + if not rec.get('NUMBER'): + continue + analytic_account = AccountAnalyticAccount.search( + [('code', '=', rec.get('NUMBER')), ('company_id', '=', self.env.company.id)], limit=1) + plan_name = param_data['ZONANA' + rec['TYPE']] + if not analytic_plan_dict.get(plan_name): + analytic_plan_dict[plan_name] = ( + AccountAnalyticPlan.search([('name', '=', plan_name)], limit=1) + or AccountAnalyticPlan.create({'name': plan_name}) + ) + if not analytic_account: + data = { + 'code': rec.get('NUMBER'), + 'name': rec.get('NAME1'), + 'active': not rec.get('INVISIBLE'), + 'plan_id': analytic_plan_dict[plan_name].id, + } + analytic_account = AccountAnalyticAccount.create(data) + analytic_accounts += analytic_account + analytic_account_data[rec['NUMBER']] = analytic_account + return analytic_account_data, analytic_accounts + + def _import_analytic_account_line(self, dbf_records, analytic_account_data, account_data, move_data, param_data): + + _logger.info("Import Analytic Account Lines") + analytic_line_data_list = [] + analytic_list = None + line2analytics2amount = collections.defaultdict(lambda: collections.defaultdict(float)) # {account.move.line: {analytic_ids: amount}} + for rec in dbf_records: + bookyear = int(rec['BOOKYEAR'] or '0', 36) + if not bookyear or (self.only_open and bookyear not in param_data['openyears']): + continue + if not analytic_list: + analytic_list = [k for k in rec.keys() if 'ZONANA' in k] + bookyear_first_year = param_data['period_date'][int(rec['BOOKYEAR'], 36)][0].year + bookyear_last_year = param_data['period_date'][int(rec['BOOKYEAR'], 36)][-1].year + journal_code, move_number = rec['DBKCODE'], rec['DOCNUMBER'] + account_id = account_data.get(rec.get('ACCOUNTGL')) + analytic_accounts = [ + analytic_account_data.get(rec[analytic]) + for analytic in analytic_list + if analytic_account_data.get(rec.get(analytic)) + ] + move = move_data.get(f"{bookyear_first_year}_{journal_code}_{move_number}") or move_data.get(f"{bookyear_last_year}_{journal_code}_{move_number}") + if move: + # Since the moves are in draft, the analytic lines can't exist yet + move_line = move.line_ids.filtered(lambda l: + l.winbooks_line_id == rec['DOCORDER'] + and l.account_id.id == account_id + and round(l.balance, 1) == round(rec.get('AMOUNTGL'), 1) + )[:1] + line2analytics2amount[move_line][','.join(str(a.id) for a in analytic_accounts)] += rec.get('AMOUNTEUR') + else: + analytic_line_data_list.append({ + 'date': rec.get('DATE', False), + 'name': rec.get('COMMENT'), + 'amount': -rec.get('AMOUNTEUR'), + 'general_account_id': account_id, + **{account.plan_id._column_name(): account.id for account in analytic_accounts}, + }) + if len(analytic_line_data_list) % 100 == 0: + _logger.info("Advancement: %s", len(analytic_line_data_list)) + if line2analytics2amount or analytic_line_data_list: + group_user = self.env.ref('base.group_user', raise_if_not_found=False) + group_analytic = self.env.ref('analytic.group_analytic_accounting', raise_if_not_found=False) + if group_user and group_analytic: + group_user.sudo()._apply_group(group_analytic) + if line2analytics2amount: + _logger.info("Updating Analytic Distributions on %s lines", len(line2analytics2amount)) + self.env['decimal.precision'].search([('name', '=', 'Percentage Analytic')]).digits = 6 + for line, analytics2amount in line2analytics2amount.items(): + line.analytic_distribution = { + analytics: 100 * (amount / line.balance if amount and line.balance else 1) + for analytics, amount in analytics2amount.items() + } + if analytic_line_data_list: + _logger.info("Creating Analytic Lines") + return self.env['account.analytic.line'].create(analytic_line_data_list) + + def _import_vat(self, dbf_records, account_central): + + _logger.info("Import VAT") + vatcode_data = {} + treelib = {} + AccountTax = self.env['account.tax'] + tags_cache = {} + + def get_tags(string): + tag_ids = self.env['account.account.tag'] + if not string: + return tag_ids + indexes = [i for i, x in enumerate(string) if x in ('+', '-')] + [len(string)] + for i in range(len(indexes) - 1): + tag_name = string[indexes[i]: indexes[i + 1]] + tag_id = tags_cache.get(tag_name, False) + if not tag_id: + tag_id = self.env['account.account.tag'].with_context(lang='en_US').search([('name', '=', tag_name), ('applicability', '=', 'taxes')]) + tags_cache[tag_name] = tag_id + if not tag_id: + tag_id = self.env['account.account.tag'].create({'name': tag_name, 'applicability': 'taxes', 'country_id': self.env.company.account_fiscal_country_id.id}) + tag_ids += tag_id + return [(4, id, 0) for id in tag_ids.ids] + + data_list = [] + code_list = [] + for rec in sorted(dbf_records, key=lambda rec: len(rec.get('TREELEVEL'))): + treelib[rec.get('TREELEVEL')] = rec.get('TREELIB1') + if not rec.get('USRCODE1'): + continue + tax_name = " ".join([treelib[x] for x in [rec.get('TREELEVEL')[:i] for i in range(2, len(rec.get('TREELEVEL')) + 1, 2)]]) + tax = AccountTax.search([('company_id', '=', self.env.company.id), ('name', '=', tax_name), + ('type_tax_use', '=', 'sale' if rec.get('CODE')[0] == '2' else 'purchase')], limit=1) + if tax.amount != rec.get('RATE') if rec.get('TAXFORM') else 0.0: + tax.amount = rec.get('RATE') if rec.get('TAXFORM') else 0.0 + if tax: + vatcode_data[rec.get('CODE')] = tax.id + else: + data = { + 'amount_type': 'percent', + 'name': tax_name, + 'company_id': self.env.company.id, + 'amount': rec.get('RATE') if rec.get('TAXFORM') else 0.0, + 'type_tax_use': 'sale' if rec.get('CODE')[0] == '2' else 'purchase', + 'price_include_override': 'tax_excluded' if rec.get('TAXFORM') or rec.get('BASFORM') == 'BAL' else 'tax_included', + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'tag_ids': get_tags(rec.get('BASE_CN')), 'company_id': self.env.company.id}), + (0, 0, {'repartition_type': 'tax', 'tag_ids': get_tags(rec.get('TAX_CN')), 'company_id': self.env.company.id, 'account_id': account_central.get(rec.get('ACCCN1'), False)}), + ], + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'tag_ids': get_tags(rec.get('BASE_INV')), 'company_id': self.env.company.id}), + (0, 0, {'repartition_type': 'tax', 'tag_ids': get_tags(rec.get('TAX_INV')), 'company_id': self.env.company.id, 'account_id': account_central.get(rec.get('ACCINV1'), False)}), + ], + } + if rec.get('ACCCN2'): + data['refund_repartition_line_ids'] += [(0, 0, {'repartition_type': 'tax', 'factor_percent': -100.0, 'tag_ids': [], 'company_id': self.env.company.id, 'account_id': account_central.get(rec.get('ACCCN2'), False)})] + if rec.get('ACCINV2'): + data['invoice_repartition_line_ids'] += [(0, 0, {'repartition_type': 'tax', 'factor_percent': -100.0, 'tag_ids': [], 'company_id': self.env.company.id, 'account_id': account_central.get(rec.get('ACCINV2'), False)})] + data_list.append(data) + code_list.append(rec.get('CODE')) + + if len(data_list) % 100 == 0: + _logger.info("Advancement: %s", len(data_list)) + tax_ids = AccountTax.create(data_list) + for tax_id, code in zip(tax_ids, code_list): + vatcode_data[code] = tax_id.id + return vatcode_data, tax_ids + + def _import_param(self, dbf_records): + + def parse_csv_value(csv_values): + return dict(pair.split('=') for pair in csv_values.split(',')) + + param_data = {} + param_data['openyears'] = [] + param_data['period_date'] = {} + for rec in dbf_records: + if not rec.get('ID'): + continue + rec_id = rec.get('ID') + value = rec.get('VALUE') + search = re.search(r'BOOKYEAR(\d+).STATUS', rec_id) + if search and search.group(1) and value.lower() == 'open': + param_data['openyears'].append(int(search.group(1))) + search = re.search(r'BOOKYEAR(\d+).PERDATE', rec_id) + if search and search.group(1): + param_data['period_date'][int(search.group(1))] = [datetime.strptime(value[i*8:(i+1)*8], '%d%m%Y').date() for i in range(int(len(value)/8))] + try: + csv_values = parse_csv_value(value + rec.get('VALUEEXT')) + except ValueError: + csv_values = {} + if csv_values.get('NAME', '').startswith('ZONANA'): # get the names of analytic plans + param_data[csv_values['NAME']] = csv_values['TIT1'] + return param_data + + def _post_import(self, account_deprecated_ids): + account_deprecated_ids.write({'deprecated': True}) # We can't set it before because of a constraint in aml's create + + def import_winbooks_file(self): + + if not self.env.company.country_id: + action = self.env.ref('base.action_res_company_form') + raise RedirectWarning(_('Please define the country on your company.'), action.id, _('Company Settings')) + if not self.env.company.chart_template: + action = self.env.ref('account.action_account_config') + raise RedirectWarning(_('You should install a Fiscal Localization first.'), action.id, _('Accounting Settings')) + self = self.with_context(active_test=False) + with TemporaryDirectory() as file_dir: + def get_dbfrecords(filterfunc): + return itertools.chain.from_iterable( + DBF(os.path.join(file_dir, file), encoding='latin', recfactory=frozendict).records + for file in [s for s in dbffiles if filterfunc(s)] + ) + + with zipfile.ZipFile(io.BytesIO(base64.decodebytes(self.zip_file))) as zip_ref: + sub_zips = [ + filename + for filename in zip_ref.namelist() + if filename.lower().endswith('.zip') + ] + zip_ref.extractall(file_dir, members=sub_zips) + + try: + cie_zip_name = next(filename for filename in sub_zips if "@cie@" in filename.lower()) + except StopIteration: + raise UserError(_("No data zip in the main archive. Please use the complete Winbooks export.")) + with zipfile.ZipFile(os.path.join(file_dir, cie_zip_name), 'r') as child_zip_ref: + dbffiles = [ + filename + for filename in child_zip_ref.namelist() + if filename.lower().endswith('.dbf') + ] + child_zip_ref.extractall(file_dir, members=dbffiles) + + pdffiles = {} + scan_zip_names = [filename for filename in sub_zips if "@scandbk" in filename.lower()] + try: + for scan_zip_name in scan_zip_names: + with zipfile.ZipFile(os.path.join(file_dir, scan_zip_name), 'r') as scan_zip: + _pdffiles = [ + filename + for filename in scan_zip.namelist() + if filename.lower().endswith('.pdf') + ] + scan_zip.extractall(file_dir, members=_pdffiles) + for filename in _pdffiles: + pdffiles[filename] = open(os.path.join(file_dir, filename), "rb") + + param_recs = get_dbfrecords(lambda file: file.lower().endswith("_param.dbf")) + param_data = self._import_param(param_recs) + + dbk_recs = get_dbfrecords(lambda file: "dbk" in file.lower() and file.lower().endswith('.dbf')) + journal_data, journal_ids = self._import_journal(dbk_recs) + + acf_recs = get_dbfrecords(lambda file: file.lower().endswith("_acf.dbf")) + account_data, account_central, account_deprecated_ids, account_tax, account_ids = self._import_account(acf_recs) + + vat_recs = get_dbfrecords(lambda file: file.lower().endswith("_codevat.dbf")) + vatcode_data, tax_ids = self._import_vat(vat_recs, account_central) + + account_deprecated_ids = self._post_process_tax(tax_ids, account_deprecated_ids) + self._post_process_account(account_data, vatcode_data, account_tax) + + table_recs = get_dbfrecords(lambda file: file.lower().endswith("_table.dbf")) + civility_data, category_data = self._import_partner_info(table_recs) + + csf_recs = get_dbfrecords(lambda file: file.lower().endswith("_csf.dbf")) + partner_data, partner_ids = self._import_partner(csf_recs, civility_data, category_data, account_data) + + act_recs = get_dbfrecords(lambda file: file.lower().endswith("_act.dbf")) + move_data, move_ids = self._import_move(act_recs, pdffiles, account_data, account_central, journal_data, partner_data, vatcode_data, param_data) + + anf_recs = get_dbfrecords(lambda file: file.lower().endswith("_anf.dbf")) + analytic_account_data, analytic_account_ids = self._import_analytic_account(anf_recs, param_data) + + ant_recs = get_dbfrecords(lambda file: file.lower().endswith("_ant.dbf")) + analytic_account_line_ids = self._import_analytic_account_line(ant_recs, analytic_account_data, account_data, move_data, param_data) + + self._post_import(account_deprecated_ids) + _logger.info("Completed") + self.env['onboarding.onboarding.step'].sudo().action_validate_step('account.onboarding_onboarding_step_chart_of_accounts') + + import_summary = self.env['account.import.summary'].create({ + 'import_summary_account_ids': account_ids, + 'import_summary_journal_ids': journal_ids, + 'import_summary_move_ids': move_ids, + 'import_summary_partner_ids': partner_ids, + 'import_summary_tax_ids': tax_ids, + 'import_summary_analytic_ids': analytic_account_ids, + 'import_summary_analytic_line_ids': analytic_account_line_ids, + }) + finally: + for fd in pdffiles.values(): + fd.close() + return import_summary.action_open_summary_view() diff --git a/dev_odex30_accounting/odex30_account_winbooks_import/wizard/import_wizard_views.xml b/dev_odex30_accounting/odex30_account_winbooks_import/wizard/import_wizard_views.xml new file mode 100644 index 0000000..ad82044 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_winbooks_import/wizard/import_wizard_views.xml @@ -0,0 +1,33 @@ + + + + + Winbooks.import.form + account.winbooks.import.wizard + +
    + + + + + + The export of data from Winbooks for closed years might contain unbalanced entries. However if you want to try to import everything, Odoo will set the difference of balance in a Suspense Account. + + + +
    +
    +
    +
    +
    + + + Winbooks Import + account.winbooks.import.wizard + form + new + + +
    diff --git a/dev_odex30_accounting/odex30_mail/__init__.py b/dev_odex30_accounting/odex30_mail/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/dev_odex30_accounting/odex30_mail/__manifest__.py b/dev_odex30_accounting/odex30_mail/__manifest__.py new file mode 100644 index 0000000..9e71b24 --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/__manifest__.py @@ -0,0 +1,29 @@ + +{ + 'name': 'odex30 Mail', + 'category': 'Productivity/Discuss', + 'depends': ['mail', 'web_mobile'], + 'description': """ +Bridge module for mail +===================================== + +Display a preview of the last chatter attachment in the form view for large +screen devices. +""", + 'website': 'http://exp-sa.com', + 'author': 'Expert Co. Ltd.', + 'auto_install': True, + 'license': 'OEEL-1', + 'assets': { + 'web.assets_backend': [ + 'odex30_mail/static/src/core/common/**/*', + 'odex30_mail/static/src/**/*', + ], + 'web.assets_tests': [ + 'odex30_mail/static/tests/tours/**/*', + ], + 'web.assets_unit_tests': [ + 'odex30_mail/static/tests/**/*', + ], + } +} diff --git a/dev_odex30_accounting/odex30_mail/i18n/ar.po b/dev_odex30_accounting/odex30_mail/i18n/ar.po new file mode 100644 index 0000000..9e14576 --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/i18n/ar.po @@ -0,0 +1,25 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_mail +# +# Translators: +# Wil Odoo, 2024 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-09-25 09:27+0000\n" +"PO-Revision-Date: 2024-09-25 09:44+0000\n" +"Last-Translator: Wil Odoo, 2024\n" +"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#. module: odex30_mail +#: model:ir.model,name:odex30_mail.model_publisher_warranty_contract +msgid "Publisher Warranty Contract For IoT Box" +msgstr "عقد ضمان الناشر لجهاز IoT " diff --git a/dev_odex30_accounting/odex30_mail/models/__init__.py b/dev_odex30_accounting/odex30_mail/models/__init__.py new file mode 100644 index 0000000..d138acf --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import update diff --git a/dev_odex30_accounting/odex30_mail/models/update.py b/dev_odex30_accounting/odex30_mail/models/update.py new file mode 100644 index 0000000..8b55957 --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/models/update.py @@ -0,0 +1,42 @@ +from odoo import api +from odoo.models import AbstractModel +from odoo.tools import cloc + +class PublisherWarrantyContract(AbstractModel): + _inherit = "publisher_warranty.contract" + + @api.model + def _get_message(self): + msg = super()._get_message() + + ICP = self.env["ir.config_parameter"] + if ICP.get_param('publisher_warranty.maintenance_disable') is not False: + return msg + + msg['maintenance'] = { + "version": cloc.VERSION, + } + try: + c = cloc.Cloc() + c.count_env(self.env) + if c.code: + msg["maintenance"]["modules"] = c.code + if c.errors: + msg["maintenance"]["errors"] = list(c.errors.keys()) + except Exception: + msg["maintenance"]["errors"] = ['cloc/error'] + + ICP.set_param('publisher_warranty.cloc', str(msg['maintenance'])) + return msg + + @api.model + def _get_verbose_maintenance(self): + """ can be called by a SA to debug cloc issue + Without runing odoo-bin cloc which is not always possible + """ + c = cloc.Cloc() + c.count_env(self.env) + return { + "modules_count": c.modules, + "modules_excluded": c.excluded, + } diff --git a/dev_odex30_accounting/odex30_mail/static/src/attachments/attachment_viewer_patch.js b/dev_odex30_accounting/odex30_mail/static/src/attachments/attachment_viewer_patch.js new file mode 100644 index 0000000..b5bb4dc --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/static/src/attachments/attachment_viewer_patch.js @@ -0,0 +1,11 @@ +import { FileViewer } from "@web/core/file_viewer/file_viewer"; +import { patch } from "@web/core/utils/patch"; + +import { useBackButton } from "@web_mobile/js/core/hooks"; + +patch(FileViewer.prototype, { + setup() { + super.setup(); + useBackButton(() => this.close()); + }, +}); diff --git a/dev_odex30_accounting/odex30_mail/static/src/core/common/core.scss b/dev_odex30_accounting/odex30_mail/static/src/core/common/core.scss new file mode 100644 index 0000000..7323ea9 --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/static/src/core/common/core.scss @@ -0,0 +1,3 @@ +.o-discuss-badge { + --o-discuss-badge-bg: #{$o-danger}; +} diff --git a/dev_odex30_accounting/odex30_mail/static/src/core/common/message_patch.js b/dev_odex30_accounting/odex30_mail/static/src/core/common/message_patch.js new file mode 100644 index 0000000..30b9fdb --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/static/src/core/common/message_patch.js @@ -0,0 +1,7 @@ +import { patch } from "@web/core/utils/patch"; +import { Message } from "@mail/core/common/message"; + +patch(Message, { + SHADOW_LINK_COLOR: "#017e84", + SHADOW_LINK_HOVER_COLOR: "#016b70", +}); diff --git a/dev_odex30_accounting/odex30_mail/static/src/web/chat_window/chat_window_patch.js b/dev_odex30_accounting/odex30_mail/static/src/web/chat_window/chat_window_patch.js new file mode 100644 index 0000000..e0bf955 --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/static/src/web/chat_window/chat_window_patch.js @@ -0,0 +1,14 @@ +import { ChatWindow } from "@mail/core/common/chat_window"; + +import { useService } from "@web/core/utils/hooks"; +import { patch } from "@web/core/utils/patch"; + +import { useBackButton } from "@web_mobile/js/core/hooks"; + +patch(ChatWindow.prototype, { + setup() { + super.setup(); + useBackButton(() => this.props.chatWindow.close()); + this.homeMenuService = useService("home_menu"); + }, +}); diff --git a/dev_odex30_accounting/odex30_mail/static/src/web/messaging_menu/messaging_menu_patch.js b/dev_odex30_accounting/odex30_mail/static/src/web/messaging_menu/messaging_menu_patch.js new file mode 100644 index 0000000..fd0ecbb --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/static/src/web/messaging_menu/messaging_menu_patch.js @@ -0,0 +1,15 @@ +import { MessagingMenu } from "@mail/core/public_web/messaging_menu"; + +import { patch } from "@web/core/utils/patch"; + +import { useBackButton } from "@web_mobile/js/core/hooks"; + +patch(MessagingMenu.prototype, { + setup() { + super.setup(); + useBackButton( + () => this.dropdown.close(), + () => this.dropdown.isOpen + ); + }, +}); diff --git a/dev_odex30_accounting/odex30_mail/static/src/web/thread_action_patch.js b/dev_odex30_accounting/odex30_mail/static/src/web/thread_action_patch.js new file mode 100644 index 0000000..dacfdee --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/static/src/web/thread_action_patch.js @@ -0,0 +1,5 @@ +import { threadActionsRegistry } from "@mail/core/common/thread_actions"; + +threadActionsRegistry.get("expand-discuss").shouldClearBreadcrumbs = (component) => { + return component.homeMenuService.hasHomeMenu; +}; diff --git a/dev_odex30_accounting/odex30_mail/static/tests/attachment_patch.test.js b/dev_odex30_accounting/odex30_mail/static/tests/attachment_patch.test.js new file mode 100644 index 0000000..99eb80a --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/static/tests/attachment_patch.test.js @@ -0,0 +1,83 @@ +import { + click, + contains, + defineMailModels, + openDiscuss, + openFormView, + patchUiSize, + SIZES, + start, + startServer, +} from "@mail/../tests/mail_test_helpers"; +import { describe, expect, test } from "@odoo/hoot"; +import { patchWithCleanup } from "@web/../tests/web_test_helpers"; +import { methods } from "@web_mobile/js/services/core"; + +describe.current.tags("desktop"); +defineMailModels(); + +test("'backbutton' event should close attachment viewer", async () => { + patchWithCleanup(methods, { + overrideBackButton({ enabled }) {}, + }); + + patchUiSize({ size: SIZES.SM }); + const pyEnv = await startServer(); + const channelId = pyEnv["discuss.channel"].create({ + channel_type: "channel", + name: "channel", + }); + const attachmentId = pyEnv["ir.attachment"].create({ + name: "test.png", + mimetype: "image/png", + }); + pyEnv["mail.message"].create({ + attachment_ids: [attachmentId], + body: "

    Test

    ", + model: "discuss.channel", + res_id: channelId, + }); + await start(); + await openDiscuss(); + await contains("button.active", { text: "Inbox" }); + await click("button", { text: "Channel" }); + await click(".o-mail-NotificationItem", { text: "channel" }); + await click(".o-mail-AttachmentImage"); + await contains(".o-FileViewer"); + const backButtonEvent = new Event("backbutton"); + document.dispatchEvent(backButtonEvent); + await contains(".o-FileViewer", { count: 0 }); +}); + +test("[technical] attachment viewer should properly override the back button", async () => { + let overrideBackButton = false; + patchWithCleanup(methods, { + overrideBackButton({ enabled }) { + overrideBackButton = enabled; + }, + }); + + patchUiSize({ size: SIZES.SM }); + const pyEnv = await startServer(); + const partnerId = pyEnv["res.partner"].create({ name: "partner 1" }); + const messageAttachmentId = pyEnv["ir.attachment"].create({ + name: "test.png", + mimetype: "image/png", + }); + pyEnv["mail.message"].create({ + attachment_ids: [messageAttachmentId], + body: "

    Test

    ", + model: "res.partner", + res_id: partnerId, + }); + await start(); + await openFormView("res.partner", partnerId); + + await click(".o-mail-AttachmentImage"); + await contains(".o-FileViewer"); + expect(overrideBackButton).toBe(true); + + await click(".o-FileViewer div[aria-label='Close']"); + await contains(".o-FileViewer", { count: 0 }); + expect(overrideBackButton).toBe(false); +}); diff --git a/dev_odex30_accounting/odex30_mail/static/tests/chat_window_patch.test.js b/dev_odex30_accounting/odex30_mail/static/tests/chat_window_patch.test.js new file mode 100644 index 0000000..ce8ba01 --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/static/tests/chat_window_patch.test.js @@ -0,0 +1,64 @@ +import { + click, + contains, + defineMailModels, + patchUiSize, + SIZES, + start, + startServer, +} from "@mail/../tests/mail_test_helpers"; +import { describe, expect, test } from "@odoo/hoot"; +import { Command, patchWithCleanup, serverState } from "@web/../tests/web_test_helpers"; +import { methods } from "@web_mobile/js/services/core"; + +describe.current.tags("desktop"); +defineMailModels(); + +test("'backbutton' event should close chat window", async () => { + patchWithCleanup(methods, { + overrideBackButton({ enabled }) {}, + }); + const pyEnv = await startServer(); + pyEnv["discuss.channel"].create({ + channel_member_ids: [ + Command.create({ + fold_state: "open", + partner_id: serverState.partnerId, + }), + ], + }); + await start(); + + await contains(".o-mail-ChatWindow"); + const backButtonEvent = new Event("backbutton"); + document.dispatchEvent(backButtonEvent); + await contains(".o-mail-ChatWindow", { count: 0 }); +}); + +test("[technical] chat window should properly override the back button", async () => { + let overrideBackButton = false; + patchWithCleanup(methods, { + overrideBackButton({ enabled }) { + overrideBackButton = enabled; + }, + }); + const pyEnv = await startServer(); + pyEnv["discuss.channel"].create({ name: "test" }); + patchUiSize({ size: SIZES.SM }); + await start(); + + await click(".o_menu_systray i[aria-label='Messages']"); + await contains(".o-mail-MessagingMenu"); + await click(".o-mail-NotificationItem", { text: "test" }); + await contains(".o-mail-ChatWindow"); + await contains(".o-mail-MessagingMenu", { count: 0 }); + expect(overrideBackButton).toBe(true); + + await click(".o-mail-ChatWindow [title*='Close']"); + await contains(".o-mail-MessagingMenu"); + + await click(".o_menu_systray i[aria-label='Messages']"); + await contains(".o-mail-ChatWindow", { count: 0 }); + await contains(".o-mail-MessagingMenu", { count: 0 }); + expect(overrideBackButton).toBe(false); +}); diff --git a/dev_odex30_accounting/odex30_mail/static/tests/chatter_patch.test.js b/dev_odex30_accounting/odex30_mail/static/tests/chatter_patch.test.js new file mode 100644 index 0000000..15e3645 --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/static/tests/chatter_patch.test.js @@ -0,0 +1,97 @@ +import { + click, + contains, + defineMailModels, + insertText, + openFormView, + patchUiSize, + registerArchs, + scroll, + SIZES, + start, + startServer, +} from "@mail/../tests/mail_test_helpers"; +import { beforeEach, describe, test } from "@odoo/hoot"; + +describe.current.tags("desktop"); +defineMailModels(); +beforeEach(() => patchUiSize({ size: SIZES.XXL })); + +test("Message list loads new messages on scroll", async () => { + const pyEnv = await startServer(); + const partnerId = pyEnv["res.partner"].create({ + display_name: "Partner 11", + description: [...Array(61).keys()].join("\n"), + }); + for (let i = 0; i < 60; i++) { + pyEnv["mail.message"].create({ + body: "

    not empty

    ", + model: "res.partner", + res_id: partnerId, + }); + } + registerArchs({ + "res.partner,false,form": ` +
    + + + + + + `, + }); + + await start(); + await openFormView("res.partner", partnerId); + await contains(".o-mail-Message", { count: 30 }); + await scroll(".o-mail-Chatter", "bottom"); + await contains(".o-mail-Message", { count: 60 }); +}); + +test("Message list is scrolled to new message after posting a message", async () => { + const pyEnv = await startServer(); + const partnerId = pyEnv["res.partner"].create({ + activity_ids: [], + display_name: "

    Partner 11

    ", + description: [...Array(60).keys()].join("\n"), + message_ids: [], + message_follower_ids: [], + }); + for (let i = 0; i < 60; i++) { + pyEnv["mail.message"].create({ + body: "

    not empty

    ", + model: "res.partner", + res_id: partnerId, + }); + } + registerArchs({ + "res.partner,false,form": ` +
    +
    +
    + + + + + + `, + }); + await start(); + await openFormView("res.partner", partnerId); + await contains(".o-mail-Message", { count: 30 }); + await contains(".o-mail-Form-chatter.o-aside"); + await scroll(".o_content", 0); + await scroll(".o-mail-Chatter", 0); + await scroll(".o-mail-Chatter", "bottom"); + await contains(".o-mail-Message", { count: 60 }); + await scroll(".o_content", 0); + await click("button", { text: "Log note" }); + await insertText(".o-mail-Composer-input", "New Message"); + await click(".o-mail-Composer-send:enabled"); + await contains(".o-mail-Composer-input", { count: 0 }); + await contains(".o-mail-Message", { count: 61 }); + await contains(".o-mail-Message-content", { text: "New Message" }); + await scroll(".o_content", 0); + await scroll(".o-mail-Chatter", 0); +}); diff --git a/dev_odex30_accounting/odex30_mail/static/tests/messaging_menu_patch.test.js b/dev_odex30_accounting/odex30_mail/static/tests/messaging_menu_patch.test.js new file mode 100644 index 0000000..3a84af8 --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/static/tests/messaging_menu_patch.test.js @@ -0,0 +1,48 @@ +import { + click, + contains, + defineMailModels, + patchUiSize, + SIZES, + start, +} from "@mail/../tests/mail_test_helpers"; +import { describe, expect, test } from "@odoo/hoot"; +import { patchWithCleanup } from "@web/../tests/web_test_helpers"; +import { methods } from "@web_mobile/js/services/core"; + +describe.current.tags("desktop"); +defineMailModels(); + +test("'backbutton' event should close messaging menu", async () => { + patchWithCleanup(methods, { + overrideBackButton({ enabled }) {}, + }); + await start(); + await click(".o_menu_systray i[aria-label='Messages']"); + + await contains(".o-mail-MessagingMenu"); + // simulate 'backbutton' event triggered by the mobile app + const backButtonEvent = new Event("backbutton"); + document.dispatchEvent(backButtonEvent); + await contains(".o-mail-MessagingMenu", { count: 0 }); +}); + +test("[technical] messaging menu should properly override the back button", async () => { + + let overrideBackButton = false; + patchWithCleanup(methods, { + overrideBackButton({ enabled }) { + overrideBackButton = enabled; + }, + }); + patchUiSize({ size: SIZES.SM }); + await start(); + + await click(".o_menu_systray i[aria-label='Messages']"); + await contains(".o-mail-MessagingMenu"); + expect(overrideBackButton).toBe(true); + + await click(".o_menu_systray i[aria-label='Messages']"); + await contains(".o-mail-MessagingMenu", { count: 0 }); + expect(overrideBackButton).toBe(false); +}); diff --git a/dev_odex30_accounting/odex30_mail/static/tests/thread_patch.test.js b/dev_odex30_accounting/odex30_mail/static/tests/thread_patch.test.js new file mode 100644 index 0000000..ace3740 --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/static/tests/thread_patch.test.js @@ -0,0 +1,43 @@ +import { + contains, + defineMailModels, + openFormView, + patchUiSize, + scroll, + SIZES, + start, + startServer, +} from "@mail/../tests/mail_test_helpers"; +import { describe, expect, test } from "@odoo/hoot"; +import { waitFor } from "@odoo/hoot-dom"; + +describe.current.tags("desktop"); +defineMailModels(); + +test("message list desc order", async () => { + const pyEnv = await startServer(); + const partnerId = pyEnv["res.partner"].create({ name: "partner 1" }); + for (let i = 0; i <= 60; i++) { + pyEnv["mail.message"].create({ + body: "not empty", + model: "res.partner", + res_id: partnerId, + }); + } + patchUiSize({ size: SIZES.XXL }); + await start(); + await openFormView("res.partner", partnerId); + + const messageEl = await waitFor(".o-mail-Message"); + const loadMoreButton = await waitFor("button:contains(Load More)"); + const siblings = [...messageEl.parentElement.children]; + + expect(siblings.indexOf(messageEl)).toBeLessThan(siblings.indexOf(loadMoreButton), { + message: "load more link should be after messages", + }); + await contains(".o-mail-Message", { count: 30 }); + await scroll(".o-mail-Chatter", "bottom"); + await contains(".o-mail-Message", { count: 60 }); + await scroll(".o-mail-Chatter", 0); + await contains(".o-mail-Message", { count: 60 }); +}); diff --git a/dev_odex30_accounting/odex30_mail/static/tests/tours/discuss_channel_expand_test_tour.js b/dev_odex30_accounting/odex30_mail/static/tests/tours/discuss_channel_expand_test_tour.js new file mode 100644 index 0000000..ff4e89d --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/static/tests/tours/discuss_channel_expand_test_tour.js @@ -0,0 +1,27 @@ +import { registry } from "@web/core/registry"; + + +registry + .category("web_tour.tours") + .add("odex30_mail/static/tests/tours/discuss_channel_expand_test_tour.js", { + steps: () => [ + { + content: + "Click on 'Open Actions Menu' in the chat window header to show expand button", + trigger: + '.o-mail-ChatWindow:contains("test-mail-channel-expand-tour") [title="Open Actions Menu"]', + run: "click", + }, + { + content: "Click on expand button to open channel in Discuss", + trigger: '.o-dropdown-item:contains("Open in Discuss")', + run: "click", + }, + { + content: + "Check that first message of #test-mail-channel-expand-tour is shown in Discuss app", + trigger: + '.o-mail-Discuss-content .o-mail-Message-body:contains("test-message-mail-channel-expand-tour")', + }, + ], + }); diff --git a/dev_odex30_accounting/odex30_mail/tests/__init__.py b/dev_odex30_accounting/odex30_mail/tests/__init__.py new file mode 100644 index 0000000..bafe5ea --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/tests/__init__.py @@ -0,0 +1,3 @@ + +from . import odex30_test_discuss_channel_expand +from . import odex30_test_update_notification diff --git a/dev_odex30_accounting/odex30_mail/tests/odex30_test_discuss_channel_expand.py b/dev_odex30_accounting/odex30_mail/tests/odex30_test_discuss_channel_expand.py new file mode 100644 index 0000000..79e5d9d --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/tests/odex30_test_discuss_channel_expand.py @@ -0,0 +1,29 @@ + +from markupsafe import Markup + +from odoo.tests.common import tagged, HttpCase +from odoo.addons.mail.tests.common import MailCommon + + +@tagged('-at_install', 'post_install') +class TestDiscussChannelExpand(HttpCase, MailCommon): + + def test_channel_expand_tour(self): + testuser = self.env['res.users'].create({ + 'email': 'testuser@testuser.com', + 'groups_id': [(6, 0, [self.ref('base.group_user')])], + 'name': 'Test User', + 'login': 'testuser', + 'password': 'testuser', + }) + DiscussChannelAsUser = self.env['discuss.channel'].with_user(testuser) + channel = DiscussChannelAsUser.channel_create(name="test-mail-channel-expand-tour", group_id=self.ref("base.group_user")) + channel.channel_member_ids.filtered(lambda m: m.is_self)._channel_fold(state='open', state_count=0) + channel.message_post( + body=Markup("

    test-message-mail-channel-expand-tour

    "), + message_type='comment', + subtype_xmlid='mail.mt_comment' + ) + + self._reset_bus() + self.start_tour("/odoo", 'odex30_mail/static/tests/tours/discuss_channel_expand_test_tour.js', login='testuser') diff --git a/dev_odex30_accounting/odex30_mail/tests/odex30_test_update_notification.py b/dev_odex30_accounting/odex30_mail/tests/odex30_test_update_notification.py new file mode 100644 index 0000000..81aebc6 --- /dev/null +++ b/dev_odex30_accounting/odex30_mail/tests/odex30_test_update_notification.py @@ -0,0 +1,13 @@ + +from odoo.addons.base.tests.test_cloc import TestClocCustomization +from ast import literal_eval + + +class TestClocICP(TestClocCustomization): + + def test_check_cloc_result_in_icp(self): + self.create_field('x_invoice_count') + message = self.env["publisher_warranty.contract"]._get_message() + self.assertTrue('maintenance' in message) + store_cloc = self.env["ir.config_parameter"].get_param('publisher_warranty.cloc') + self.assertEqual(literal_eval(store_cloc)['modules']['odoo/studio'], 1)