inv
This commit is contained in:
parent
5e9a34ce9a
commit
ec2ee4cf5b
|
|
@ -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/
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
from . import models
|
||||
|
|
@ -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/**/*',
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" ?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="iap_extract_endpoint" model="ir.config_parameter">
|
||||
<field name="key">iap_extract_endpoint</field>
|
||||
<field name="value">https://extract.api.odoo.com</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="iap_service_ocr" model="iap.service">
|
||||
<field name="name">Document Digitization</field>
|
||||
<field name="technical_name">invoice_ocr</field>
|
||||
<field name="description">Digitize your scanned or PDF vendor bills, expenses and resumes with OCR and Artificial Intelligence.</field>
|
||||
<field name="unit_name">Documents</field>
|
||||
<field name="integer_balance">True</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="iap_extract_no_credit" model="mail.template">
|
||||
<field name="name">IAP Extract Notification</field>
|
||||
<field name="email_from">iap@odoo.com</field>
|
||||
<field name="email_to">iap@odoo.com</field>
|
||||
<field name="subject">IAP Extract Notification</field>
|
||||
<field name="model_id" ref="iap.model_iap_account"/>
|
||||
<field name="body_html" type="html">
|
||||
<div style="margin: 0px; padding: 0px;">
|
||||
<p>Dear,<br/></p>
|
||||
<p>There are no more credits on your IAP OCR account.<br/>
|
||||
You can charge your IAP OCR account in the settings page.</p>
|
||||
<p>Best regards,<br/></p>
|
||||
<p>Exp S.A.</p>
|
||||
</div></field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1 @@
|
|||
DELETE FROM ir_config_parameter WHERE key = 'iap_extract_endpoint';
|
||||
|
|
@ -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 <msea@odoo.com>, 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 <msea@odoo.com>, 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 ""
|
||||
"<div style=\"margin: 0px; padding: 0px;\">\n"
|
||||
" <p>Dear,<br/></p>\n"
|
||||
" <p>There are no more credits on your IAP OCR account.<br/>\n"
|
||||
" You can charge your IAP OCR account in the settings page.</p>\n"
|
||||
" <p>Best regards,<br/></p>\n"
|
||||
" <p>Odoo S.A.</p>\n"
|
||||
"</div>"
|
||||
msgstr ""
|
||||
"<div style=\"margin: 0px; padding: 0px;\">\n"
|
||||
" <p>عزيزنا،<br/></p>\n"
|
||||
" <p>لم يتبق لديك رصيد في حساب عمليات الشراء داخل التطبيق (IAP) لـ OCR الخاص بحسابك.<br/>\n"
|
||||
" حساب عمليات الشراء داخل التطبيق (IAP) لـ OCR الخاص بك في صفحة الإعدادات.</p>\n"
|
||||
" <p>مع أطيب التحيات،<br/></p>\n"
|
||||
" <p>Odoo S.A.</p>\n"
|
||||
"</div>"
|
||||
|
||||
#. 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 "بانتظار الاستخلاص، ولكنها ليست جاهزة "
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import extract_mixin
|
||||
|
|
@ -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))
|
||||
|
|
@ -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});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="odex30_account_invoice_extract.Status">
|
||||
<div t-if="state.status === 'error_status'" class="alert alert-danger text-center">
|
||||
<t t-out="state.errorMessage" class="oe_inline" style="width:auto !important;"/>
|
||||
<button t-att-disabled="state.retryLoading" type="object" t-on-click="retryDigitalization" class="btn btn-link">
|
||||
<i class="oi oi-fw o_button_icon oi-arrow-right"/>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
<div t-if="['waiting_extraction', 'extract_not_ready'].includes(state.status)" class="alert alert-info text-center">
|
||||
All fields will be automatically populated by Artificial Intelligence, it might take 5 seconds.
|
||||
<button t-att-disabled="state.checkStatusLoading" type="object" class="btn btn-link" t-on-click="checkOcrStatus">
|
||||
<i class="oi oi-fw o_button_icon oi-arrow-right"/>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div t-if="state.status === 'not_enough_credit'" class="alert alert-danger text-center">
|
||||
You don't have enough credit to extract data from your document.
|
||||
<button t-att-disabled="state.retryLoading" type="object" t-on-click="buyCredits" class="btn btn-link">
|
||||
<i class="oi oi-fw o_button_icon oi-arrow-right"/>
|
||||
Buy credits
|
||||
</button>
|
||||
<button t-att-disabled="state.retryLoading" type="object" t-on-click="retryDigitalization" class="btn btn-link">
|
||||
<i class="oi oi-fw o_button_icon oi-arrow-right"/>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
<div t-if="state.status === 'waiting_validation'" class="alert alert-success text-center">
|
||||
Document successfully parsed. Please refresh.
|
||||
<button type="object" class="btn btn-link" t-on-click="refreshPage">
|
||||
<i class="oi oi-fw o_button_icon oi-arrow-right"/>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
from . import test_extract_mixin
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
from . import models
|
||||
from . import wizard
|
||||
|
|
@ -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/**/*',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <msea@odoo.com>, 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 <msea@odoo.com>, 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 "<span class=\"text-muted\">(end of year balances)</span>"
|
||||
msgstr "<span class=\"text-muted\">(أرصدة نهاية العام)</span>"
|
||||
|
||||
#. module: odex30_account_base_import
|
||||
#: model_terms:ir.ui.view,arch_db:odex30_account_base_import.res_config_settings_import_view_form
|
||||
msgid "<span class=\"text-muted\">(for full history)</span>"
|
||||
msgstr "<span class=\"text-muted\">(للسجل الكامل)</span> "
|
||||
|
||||
#. 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. "
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
from . import account_account
|
||||
from . import account_move_line
|
||||
|
|
@ -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)
|
||||
|
|
@ -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<prefix1>.*?)(?P<seq>\d{0,9})(?P<suffix>\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)
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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)",,,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 }));
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,156 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates>
|
||||
|
||||
<t t-name="odex30_account_base_import.accountImportTemplate">
|
||||
<ControlPanel/>
|
||||
<div class="container text-justify overflow-auto h-100">
|
||||
<div class="d-flex flex-column my-5 pt-4">
|
||||
<div class="title">
|
||||
<h1>
|
||||
Accounting Import Options
|
||||
</h1>
|
||||
<p>
|
||||
Use predefined format to import your data faster.
|
||||
</p>
|
||||
</div>
|
||||
<t t-if="countryCode === 'BE'">
|
||||
<div class="winbooks-import pt-4">
|
||||
<h2 class="text-primary">
|
||||
Winbooks
|
||||
</h2>
|
||||
<p>
|
||||
Winbooks is an old school Belgian accounting software acquired by Exact.
|
||||
Use the
|
||||
<a class="fw-bold" href="#" t-on-click.prevent="() => this._openModuleInstallation('account_winbooks_import')">
|
||||
Account Winbooks Import module
|
||||
</a>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="['FR', 'LU'].includes(countryCode) || isFecImportModuleInstalled">
|
||||
<div class="fec-import pt-4">
|
||||
<h2 class="text-primary">
|
||||
FEC
|
||||
</h2>
|
||||
<p>
|
||||
Most accounting software in France support exporting FEC file for audit purposes.
|
||||
Use the
|
||||
<a class="fw-bold" href="#" t-on-click.prevent="() => this._openModuleInstallation('l10n_fr_fec_import')">
|
||||
FEC Import module
|
||||
</a>
|
||||
to import the FEC file. We will setup your charts of accounts and the history of journal entries.
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="['DK', 'LT', 'LU', 'NO', 'RO'].includes(countryCode)">
|
||||
<div class="saft-import pt-4">
|
||||
<h2 class="text-primary">
|
||||
SAF-T
|
||||
</h2>
|
||||
<p>
|
||||
Most accounting software in Europe support exporting SAF-T file for audit purposes.
|
||||
Use the
|
||||
<a class="fw-bold" href="#" t-on-click.prevent="() => this._openModuleInstallation('account_saft_import')">
|
||||
SAF-T Import module
|
||||
</a>
|
||||
to import the SAF-T file. <br/>
|
||||
We will setup your charts of accounts and the history of journal entries, that will stay in draft.
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="countryCode === 'SE'">
|
||||
<div class="sie-import pt-4">
|
||||
<h2 class="text-odoo">
|
||||
SIE 4/5
|
||||
</h2>
|
||||
<p>
|
||||
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.
|
||||
<ul>
|
||||
<li>
|
||||
<a class="fw-bold" href="#" t-on-click.prevent="() => this._openModuleInstallation('l10n_se_sie4_import')">
|
||||
SIE 4 Import module
|
||||
</a>
|
||||
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.
|
||||
</li>
|
||||
<li>
|
||||
<a class="fw-bold" href="#" t-on-click.prevent="() => this._openModuleInstallation('l10n_se_sie_import')">
|
||||
SIE 5 Import module
|
||||
</a>
|
||||
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.
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
<div class="d-flex gap-2 sie-import-buttons"/>
|
||||
</div>
|
||||
</t>
|
||||
<div class="excel-import pt-4">
|
||||
<h2 class="text-primary">
|
||||
Excel Import
|
||||
</h2>
|
||||
<p>
|
||||
Use templates to import CSV or Excel for your accounting setup.
|
||||
</p>
|
||||
<ol class="d-flex flex-column flex-lg-row">
|
||||
<li class="pt-4">
|
||||
<div class="row">
|
||||
<div class="col-lg-10">
|
||||
<h3>Import contacts</h3>
|
||||
<p>Import customers or suppliers (partners) and their contacts using a
|
||||
<a class="fw-bold" href="/base/static/xls/res_partner.xlsx" aria-label="Download" title="Download">
|
||||
template.
|
||||
</a>
|
||||
</p>
|
||||
<button type="button" class="btn btn-primary" t-on-click="() => this._importAccountGuideAction('odex30_account_base_import.action_partner_import')">Import Contacts</button>
|
||||
</div>
|
||||
<div class="col-lg-2 border-start"/>
|
||||
</div>
|
||||
</li>
|
||||
<li class="pt-4">
|
||||
<div class="row">
|
||||
<div class="col-lg-10">
|
||||
<h3>Import Chart of Accounts</h3>
|
||||
<p>Import the Chart of Accounts and initial balances using a
|
||||
<a class="fw-bold" href="/account/static/xls/coa_import_template.xlsx" aria-label="Download" title="Download">
|
||||
template.
|
||||
</a>
|
||||
</p>
|
||||
<button type="button" class="btn btn-primary" t-on-click="() => this._importAccountGuideAction('odex30_account_base_import.action_account_import')" style="margin-right: 2px;">Import CoA</button>
|
||||
or<button type="button" class="btn btn-link px-2" t-on-click="() => this._importAccountGuideAction('odex30_account_base_import.action_open_coa_setup')">Review Manually</button>
|
||||
</div>
|
||||
<div class="col-lg-2 border-start"/>
|
||||
</div>
|
||||
</li>
|
||||
<li class="pt-4">
|
||||
<div class="row">
|
||||
<div class="col-lg-10">
|
||||
<h3>Import Journal Items</h3>
|
||||
<p>Optional, but useful to import open receivables & payables using a
|
||||
<a class="fw-bold" href="/account/static/xls/aml_import_template.xlsx" aria-label="Download" title="Download">
|
||||
template.
|
||||
</a>
|
||||
</p>
|
||||
<button type="button" class="btn btn-primary" t-on-click="() => this._importAccountGuideAction('odex30_account_base_import.action_account_move_line_import')">Import Journal Items</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="pt-5 pb-5">
|
||||
<p>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.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import test_account_import
|
||||
|
|
@ -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")
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_account_base_import_list" model="ir.ui.view">
|
||||
<field name="name">account.base.import.account.account.list</field>
|
||||
<field name="model">account.account</field>
|
||||
<field name="inherit_id" ref="account.view_account_list"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//list" position="inside">
|
||||
<header>
|
||||
<button name="%(odex30_account_base_import.action_open_import_guide)d" type="action" string="Import" display="always"/>
|
||||
</header>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_account_import" model="ir.actions.client">
|
||||
<field name="name">Import Chart of Accounts</field>
|
||||
<field name="tag">account_import_action</field>
|
||||
<field name="target">current</field>
|
||||
<field name="params" eval="{'model': 'account.account'}"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_open_import_guide" model="ir.actions.client">
|
||||
<field name="name">Accounting Import Guide</field>
|
||||
<field name="tag">account_import_guide</field>
|
||||
<field name="target">current</field>
|
||||
</record>
|
||||
|
||||
<record id="action_open_coa_setup" model="ir.actions.act_window">
|
||||
<field name="name">Chart of Accounts</field>
|
||||
<field name="res_model">account.account</field>
|
||||
<field name="view_mode">list</field>
|
||||
<field name="search_view_id" ref="account.view_account_search"/>
|
||||
<field name="view_id" ref="account.init_accounts_tree"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_account_move_line_import" model="ir.actions.client">
|
||||
<field name="name">Import Journal Items</field>
|
||||
<field name="tag">account_import_action</field>
|
||||
<field name="target">current</field>
|
||||
<field name="params" eval="{'model': 'account.move.line'}"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_import_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.account.base.import</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//block[@name='fiscal_localization_setting_container']" position="after">
|
||||
<t groups="account.group_account_user">
|
||||
<block title="Accounting Import" name="accounting_import_setting_container">
|
||||
<setting string="Initial Setup" help="Choose how you want to setup your CoA">
|
||||
<div class="content-group">
|
||||
<div class="d-flex mt4 align-items-center">
|
||||
<button name="%(odex30_account_base_import.action_open_coa_setup)d" icon="oi-arrow-right" type="action" string="Review Manually" class="btn-link"/>
|
||||
<span class="text-muted">(end of year balances)</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<button name="%(odex30_account_base_import.action_open_import_guide)d" icon="oi-arrow-right" type="action" string="Import" class="btn-link"/>
|
||||
<span class="text-muted">(for full history)</span>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
</t>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_partner_import" model="ir.actions.client">
|
||||
<field name="name">Import Partners</field>
|
||||
<field name="tag">account_import_action</field>
|
||||
<field name="target">current</field>
|
||||
<field name="params" eval="{'model': 'res.partner'}"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
from . import account_import_summary
|
||||
from . import account_move_line_import
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="account_import_summary_form" model="ir.ui.view">
|
||||
<field name="name">account.import.summary.form</field>
|
||||
<field name="model">account.import.summary</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<field name="import_summary_account_ids" invisible="1"/>
|
||||
<field name="import_summary_journal_ids" invisible="1"/>
|
||||
<field name="import_summary_move_ids" invisible="1"/>
|
||||
<field name="import_summary_partner_ids" invisible="1"/>
|
||||
<field name="import_summary_tax_ids" invisible="1"/>
|
||||
<field name="import_summary_have_data" invisible="1"/>
|
||||
<h4>Imported Data</h4>
|
||||
<hr/>
|
||||
<ul class="o_import_summary">
|
||||
<li invisible="not import_summary_account_ids">
|
||||
<a name="action_open_account_view" type="object">
|
||||
<field name="import_summary_len_account" readonly="1"/> accounts imported
|
||||
</a>
|
||||
</li>
|
||||
<li invisible="not import_summary_journal_ids">
|
||||
<a name="action_open_journal_view" type="object">
|
||||
<field name="import_summary_len_journal" readonly="1"/> journals imported
|
||||
</a>
|
||||
</li>
|
||||
<li invisible="not import_summary_move_ids">
|
||||
<a name="action_open_move_view" type="object">
|
||||
<field name="import_summary_len_move" readonly="1"/> moves imported
|
||||
</a>
|
||||
</li>
|
||||
<li invisible="not import_summary_partner_ids">
|
||||
<a name="action_open_partner_view" type="object">
|
||||
<field name="import_summary_len_partner" readonly="1"/> partners imported
|
||||
</a>
|
||||
</li>
|
||||
<li invisible="not import_summary_tax_ids">
|
||||
<a name="action_open_tax_view" type="object">
|
||||
<field name="import_summary_len_tax" readonly="1"/> taxes imported
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div invisible="import_summary_have_data" class="text-muted">
|
||||
No data was imported.
|
||||
</div>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_account_setup_base_import_list" model="ir.ui.view">
|
||||
<field name="name">account.setup.opening.account.account.list.account.base.import</field>
|
||||
<field name="model">account.account</field>
|
||||
<field name="inherit_id" ref="account.init_accounts_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//list" position="inside">
|
||||
<header>
|
||||
<button name="%(odex30_account_base_import.action_open_import_guide)d" type="action" string="Import" display="always"/>
|
||||
</header>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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',
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
from . import controllers
|
||||
from . import models
|
||||
|
|
@ -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/**/*',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
from . import main
|
||||
|
|
@ -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/<string:extract_document_uuid>', 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'
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id='ir_cron_update_ocr_status' model='ir.cron'>
|
||||
<field name='name'>Invoice OCR: Update All Status</field>
|
||||
<field name='model_id' ref='model_account_move'/>
|
||||
<field name='state'>code</field>
|
||||
<field name='code'>model.check_all_status()</field>
|
||||
<field name='interval_number'>1</field>
|
||||
<field name='interval_type'>days</field>
|
||||
</record>
|
||||
|
||||
<record id='ir_cron_ocr_validate' model='ir.cron'>
|
||||
<field name='name'>Invoice OCR: Validate Invoices</field>
|
||||
<field name='model_id' ref='model_account_move'/>
|
||||
<field name='state'>code</field>
|
||||
<field name='code'>model._cron_validate()</field>
|
||||
<field name='interval_number'>1</field>
|
||||
<field name='interval_type'>days</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -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 <msea@odoo.com>, 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 <msea@odoo.com>, 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 "لا يمكنك إرسال نفقة ليست بحالة المسودة! "
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
|
||||
from . import account_invoice
|
||||
from . import ir_attachment
|
||||
from . import res_config_settings
|
||||
from . import res_company
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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); }
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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';
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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 });
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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 */ `
|
||||
<form>
|
||||
<group>
|
||||
<field name="name" readonly="0"/>
|
||||
<field name="vat" readonly="0"/>
|
||||
</group>
|
||||
</form>
|
||||
`;
|
||||
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: `
|
||||
<form string="Account Invoice" js_class="account_move_form">
|
||||
<group>
|
||||
<field name="extract_state" invisible="1"/>
|
||||
<field name="state" invisible="1"/>
|
||||
<field name="move_type" invisible="1"/>
|
||||
<field name="extract_attachment_id" invisible="1"/>
|
||||
<field name="partner_id" readonly="0"/>
|
||||
<field name="ref" readonly="0"/>
|
||||
<field name="invoice_date" readonly="0"/>
|
||||
<field name="invoice_date_due" readonly="0"/>
|
||||
<field name="currency_id" readonly="0"/>
|
||||
<field name="quick_edit_total_amount" readonly="0"/>
|
||||
</group>
|
||||
<div class="o_attachment_preview"/>
|
||||
<chatter/>
|
||||
</form>`,
|
||||
});
|
||||
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" });
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
export class ResCurrency extends models.ServerModel {
|
||||
_name = "res.currency";
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="odex30_account_invoice_extract.Box">
|
||||
<div
|
||||
t-attf-class="o_invoice_extract_box #{state.ocr_selected ? 'ocr_chosen' : ''} #{state.user_selected ? 'selected' : ''}"
|
||||
t-att-data-id="state.id"
|
||||
t-att-data-field-name="state.feature"
|
||||
t-att-style="style"
|
||||
t-on-click="onClick"
|
||||
/>
|
||||
</t>
|
||||
<t t-name="odex30_account_invoice_extract.BoxLayer">
|
||||
<div t-attf-class="o_invoice_extract_box_layer" t-att-style="style" t-on-mousedown.prevent="">
|
||||
<t t-foreach="state.boxes" t-as="box" t-key="box.id">
|
||||
<Box
|
||||
box="box"
|
||||
pageWidth="pageWidth"
|
||||
pageHeight="pageHeight"
|
||||
onClickBoxCallback="props.onClickBoxCallback"
|
||||
/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
from . import test_invoice_extract
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,50 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_move_form_inherit_ocr" model="ir.ui.view">
|
||||
<field name="name">invoice.move.form.inherit.ocr</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_move_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="action_manual_send_for_digitization" class="oe_highlight" string="Digitize document" type="object"
|
||||
invisible="not extract_can_show_send_button" data-hotkey="w" />
|
||||
<button name="action_reload_ai_data" string="Reload AI Data" type="object"
|
||||
invisible="move_type not in ('in_invoice', 'in_refund', 'out_invoice', 'out_refund') or state != 'draft' or extract_state not in ['waiting_validation', 'validation_to_send']" />
|
||||
</xpath>
|
||||
<xpath expr="//sheet" position='before'>
|
||||
<field name="extract_error_message" invisible="1"/>
|
||||
<field name="extract_document_uuid" invisible="1"/>
|
||||
<field name="extract_state" class="d-block" invisible="not extract_can_show_banners or extract_state == 'waiting_validation'" widget="extract_state_header"/>
|
||||
<field name="extract_attachment_id" invisible="True"/>
|
||||
<field name="extract_can_show_send_button" invisible="True"/>
|
||||
<field name="extract_can_show_banners" invisible="True"/>
|
||||
</xpath>
|
||||
<xpath expr="//chatter" position="attributes">
|
||||
<attribute name="reload_on_post">True</attribute>
|
||||
<attribute name="reload_on_attachment">True</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//page[@id='other_tab']//group[@name='accounting_info_group']" position="after">
|
||||
<group string="Extraction Information"
|
||||
name="extraction_info_group"
|
||||
invisible="move_type not in ('in_invoice', 'in_refund', 'out_invoice', 'out_refund') or extract_state in ('no_extract_requested', 'not_enough_credit')"
|
||||
groups="base.group_no_one">
|
||||
<field name="extract_document_uuid" widget="CopyClipboardChar"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="model_account_send_for_digitalization" model="ir.actions.server">
|
||||
<field name="name">Send Bills for digitization</field>
|
||||
<field name="model_id" ref="account.model_account_move"/>
|
||||
<field name="binding_model_id" ref="account.model_account_move"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
if records:
|
||||
action = records.action_send_batch_for_digitization()
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.account.invoice.ocr</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<div id="msg_invoice_extract" position="replace"/>
|
||||
|
||||
<setting id="account_ocr_settings" position="after">
|
||||
<setting invisible="not module_odex30_account_extract">
|
||||
<widget name="iap_buy_more_credits" service_name="invoice_ocr"/>
|
||||
</setting>
|
||||
<setting invisible="not module_account_invoice_extract">
|
||||
<field name="extract_in_invoice_digitalization_mode" class="o_light_label" widget="radio" required="True"/>
|
||||
</setting>
|
||||
<setting invisible="not module_account_invoice_extract">
|
||||
<field name="extract_out_invoice_digitalization_mode" class="o_light_label" widget="radio" required="True"/>
|
||||
</setting>
|
||||
<setting invisible="not module_account_invoice_extract" company_dependent="1" help="Enable to get only one invoice line per tax">
|
||||
<field name="extract_single_line_per_tax"/>
|
||||
</setting>
|
||||
</setting>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
from . import models
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
|
||||
{
|
||||
'name': 'Account Invoice Extract Purchase',
|
||||
'version': '1.0',
|
||||
'author': "Expert Co. Ltd.",
|
||||
'website': "http://www.exp-sa.com",
|
||||
'category': 'Accounting',
|
||||
'summary': 'Automatically finds the purchase order linked to a vendor bill when using invoice extraction',
|
||||
'depends': ['odex30_account_invoice_extract', 'purchase'],
|
||||
'auto_install': True,
|
||||
'license': 'OEEL-1',
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * odex30_account_invoice_extract_purchase
|
||||
#
|
||||
# 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:26+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_account_invoice_extract_purchase
|
||||
#: model:ir.model,name:odex30_account_invoice_extract_purchase.model_account_move
|
||||
msgid "Journal Entry"
|
||||
msgstr "قيد دفتر اليومية "
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
from . import account_invoice
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import re
|
||||
|
||||
from odoo import models, _
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = ['account.move']
|
||||
|
||||
def _get_user_infos(self):
|
||||
def transform_numbers_to_regex(string):
|
||||
digits_count = 0
|
||||
new_string = ''
|
||||
for c in string:
|
||||
if c.isdigit():
|
||||
digits_count += 1
|
||||
else:
|
||||
if digits_count:
|
||||
new_string += r'\d{{{}}}'.format(digits_count) if digits_count > 1 else r'\d'
|
||||
digits_count = 0
|
||||
new_string += c
|
||||
if digits_count:
|
||||
new_string += r'\d{{{}}}'.format(digits_count) if digits_count > 1 else r'\d'
|
||||
return new_string
|
||||
|
||||
user_infos = super(AccountMove, self)._get_user_infos()
|
||||
po_sequence = self.env['ir.sequence'].search([('code', '=', 'purchase.order'), ('company_id', 'in', [self.company_id.id, False])], order='company_id', limit=1)
|
||||
if po_sequence:
|
||||
po_regex_prefix, po_regex_suffix = po_sequence._get_prefix_suffix()
|
||||
po_regex_prefix = transform_numbers_to_regex(re.escape(po_regex_prefix))
|
||||
po_regex_suffix = transform_numbers_to_regex(re.escape(po_regex_suffix))
|
||||
po_regex_sequence = r'\d{{{}}}'.format(po_sequence.padding)
|
||||
user_infos['purchase_order_regex'] = po_regex_prefix + po_regex_sequence + po_regex_suffix
|
||||
return user_infos
|
||||
|
||||
def _save_form(self, ocr_results):
|
||||
if self.move_type == 'in_invoice' and not self.invoice_line_ids:
|
||||
total_ocr = self._get_ocr_selected_value(ocr_results, 'total', 0.0)
|
||||
|
||||
purchase_orders_ocr = ocr_results['purchase_order']['selected_values'] if 'purchase_order' in ocr_results else []
|
||||
purchase_orders_found = [po['content'] for po in purchase_orders_ocr]
|
||||
|
||||
supplier_ocr = self._get_ocr_selected_value(ocr_results, 'supplier', "")
|
||||
vat_number_ocr = self._get_ocr_selected_value(ocr_results, 'VAT_Number', "")
|
||||
partner_id = self._find_partner_id_with_vat(vat_number_ocr).id or self._find_partner_id_with_name(supplier_ocr)
|
||||
|
||||
self._find_and_set_purchase_orders(purchase_orders_found, partner_id, total_ocr, from_ocr=True)
|
||||
|
||||
return super()._save_form(ocr_results)
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
from . import test_odex30_invoice_extract_purchase
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
|
||||
from odoo import Command
|
||||
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
|
||||
from odoo.addons.iap_extract.tests.test_extract_mixin import TestExtractMixin
|
||||
from odoo.tests import Form, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestInvoiceExtractPurchase(AccountTestInvoicingCommon, TestExtractMixin):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
cls.env.user.groups_id |= cls.env.ref('base.group_system')
|
||||
cls.env.company.write({'account_purchase_tax_id': None})
|
||||
|
||||
config = cls.env['res.config.settings'].create({})
|
||||
config.execute()
|
||||
|
||||
cls.vendor = cls.env['res.partner'].create({'name': 'Odoo', 'vat': 'BE0477472701'})
|
||||
cls.product1 = cls.env['product.product'].create({'name': 'Test 1', 'list_price': 100.0})
|
||||
cls.product2 = cls.env['product.product'].create({'name': 'Test 2', 'list_price': 50.0})
|
||||
cls.product3 = cls.env['product.product'].create({'name': 'Test 3', 'list_price': 20.0})
|
||||
|
||||
po = Form(cls.env['purchase.order'])
|
||||
po.partner_id = cls.vendor
|
||||
po.partner_ref = "INV1234"
|
||||
with po.order_line.new() as po_line:
|
||||
po_line.product_id = cls.product1
|
||||
po_line.product_qty = 1
|
||||
po_line.price_unit = 100
|
||||
with po.order_line.new() as po_line:
|
||||
po_line.product_id = cls.product2
|
||||
po_line.product_qty = 2
|
||||
po_line.price_unit = 50
|
||||
with po.order_line.new() as po_line:
|
||||
po_line.product_id = cls.product3
|
||||
po_line.product_qty = 5
|
||||
po_line.price_unit = 20
|
||||
cls.purchase_order = po.save()
|
||||
cls.purchase_order.button_confirm()
|
||||
for line in cls.purchase_order.order_line:
|
||||
line.qty_received = line.product_qty
|
||||
|
||||
def get_result_success_response(self):
|
||||
return {
|
||||
'results': [{
|
||||
'supplier': {'selected_value': {'content': "Test"}, 'candidates': []},
|
||||
'total': {'selected_value': {'content': 300}, 'candidates': []},
|
||||
'subtotal': {'selected_value': {'content': 300}, 'candidates': []},
|
||||
'total_tax_amount': {'selected_value': {'content': 0.0}, 'words': []},
|
||||
'invoice_id': {'selected_value': {'content': 'INV0001'}, 'candidates': []},
|
||||
'currency': {'selected_value': {'content': 'EUR'}, 'candidates': []},
|
||||
'VAT_Number': {'selected_value': {'content': 'BE123456789'}, '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': []},
|
||||
'purchase_order': {'selected_values': [{'content': self.purchase_order.name}], 'candidates': []},
|
||||
'invoice_lines': [
|
||||
{
|
||||
'description': {'selected_value': {'content': 'Test 1'}},
|
||||
'unit_price': {'selected_value': {'content': 50}},
|
||||
'quantity': {'selected_value': {'content': 1}},
|
||||
'taxes': {'selected_values': [{'content': 0, 'amount_type': 'percent'}]},
|
||||
'subtotal': {'selected_value': {'content': 50}},
|
||||
'total': {'selected_value': {'content': 50}},
|
||||
},
|
||||
{
|
||||
'description': {'selected_value': {'content': 'Test 2'}},
|
||||
'unit_price': {'selected_value': {'content': 75}},
|
||||
'quantity': {'selected_value': {'content': 2}},
|
||||
'taxes': {'selected_values': [{'content': 0, 'amount_type': 'percent'}]},
|
||||
'subtotal': {'selected_value': {'content': 150}},
|
||||
'total': {'selected_value': {'content': 150}},
|
||||
},
|
||||
{
|
||||
'description': {'selected_value': {'content': 'Test 3'}},
|
||||
'unit_price': {'selected_value': {'content': 20}},
|
||||
'quantity': {'selected_value': {'content': 5}},
|
||||
'taxes': {'selected_values': [{'content': 0, 'amount_type': 'percent'}]},
|
||||
'subtotal': {'selected_value': {'content': 100}},
|
||||
'total': {'selected_value': {'content': 100}},
|
||||
},
|
||||
],
|
||||
}],
|
||||
'status': 'success',
|
||||
}
|
||||
|
||||
def test_match_po_by_name(self):
|
||||
invoice = self.env['account.move'].create({'move_type': 'in_invoice', 'extract_state': 'waiting_extraction'})
|
||||
extract_response = self.get_result_success_response()
|
||||
|
||||
with self._mock_iap_extract(extract_response=extract_response):
|
||||
invoice._check_ocr_status()
|
||||
|
||||
self.assertTrue(invoice.id in self.purchase_order.invoice_ids.ids)
|
||||
|
||||
def test_match_po_by_supplier_and_total(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]['supplier']['selected_value']['content'] = self.purchase_order.partner_id.name
|
||||
|
||||
with self._mock_iap_extract(extract_response=extract_response):
|
||||
invoice._check_ocr_status()
|
||||
|
||||
self.assertTrue(invoice.id in self.purchase_order.invoice_ids.ids)
|
||||
|
||||
def test_match_subset_of_order_lines(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'] = 200
|
||||
extract_response['results'][0]['subtotal']['selected_value']['content'] = 200
|
||||
extract_response['results'][0]['invoice_lines'] = extract_response['results'][0]['invoice_lines'][:2]
|
||||
|
||||
with self._mock_iap_extract(extract_response=extract_response):
|
||||
invoice._check_ocr_status()
|
||||
|
||||
self.assertTrue(invoice.id in self.purchase_order.invoice_ids.ids)
|
||||
self.assertEqual(invoice.amount_total, 200)
|
||||
|
||||
def test_no_match_subset_of_order_lines(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'] = 150
|
||||
extract_response['results'][0]['subtotal']['selected_value']['content'] = 150
|
||||
extract_response['results'][0]['invoice_lines'] = [extract_response['results'][0]['invoice_lines'][1]]
|
||||
|
||||
with self._mock_iap_extract(extract_response=extract_response):
|
||||
invoice._check_ocr_status()
|
||||
|
||||
self.assertTrue(invoice.id in self.purchase_order.invoice_ids.ids)
|
||||
self.assertEqual(invoice.amount_total, 300)
|
||||
|
||||
def test_no_match(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]['purchase_order']['selected_values'][0]['content'] = self.purchase_order.name + '123'
|
||||
|
||||
with self._mock_iap_extract(extract_response=extract_response):
|
||||
invoice._check_ocr_status()
|
||||
|
||||
self.assertTrue(invoice.id not in self.purchase_order.invoice_ids.ids)
|
||||
|
||||
def test_action_reload_ai_data(self):
|
||||
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': 'INV0000',
|
||||
'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.assertTrue(invoice.id in self.purchase_order.invoice_ids.ids)
|
||||
self.assertEqual(invoice.amount_total, 300)
|
||||
self.assertEqual(invoice.amount_untaxed, 300)
|
||||
self.assertEqual(invoice.amount_tax, 0)
|
||||
self.assertEqual(invoice.partner_id, self.vendor)
|
||||
self.assertEqual(invoice.ref, 'INV1234')
|
||||
self.assertEqual(invoice.invoice_line_ids.mapped('product_id'), self.product1 | self.product2 | self.product3)
|
||||
|
||||
def test_no_purchase_extraction_when_bill_lines(self):
|
||||
|
||||
# Step 1: create 2 identical POs
|
||||
partner = self.company_data['company'].partner_id
|
||||
po1 = self.env['purchase.order'].create({
|
||||
"partner_id": partner.id,
|
||||
"order_line": [Command.create({
|
||||
'product_id': self.product_a.id,
|
||||
'name': self.product_a.name,
|
||||
'product_qty': 1.0,
|
||||
'price_unit': 100,
|
||||
'taxes_id': False,
|
||||
})],
|
||||
})
|
||||
po2 = po1.copy()
|
||||
po2.order_line.write({'price_unit': 200})
|
||||
(po1 + po2).button_confirm()
|
||||
(po1 + po2).order_line.write({'qty_received': 1})
|
||||
|
||||
bill_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice'))
|
||||
bill_form.purchase_vendor_bill_id = self.env['purchase.bill.union'].browse(-po1.id)
|
||||
bill = bill_form.save()
|
||||
|
||||
self.assertEqual(bill.invoice_origin, po1.name)
|
||||
|
||||
extract_response = {
|
||||
'results': [{
|
||||
'supplier': {'selected_value': {'content': "company_1_data"}, 'candidates': []},
|
||||
'total': {'selected_value': {'content': 200}, 'candidates': []},
|
||||
'subtotal': {'selected_value': {'content': 200}, 'candidates': []},
|
||||
'total_tax_amount': {'selected_value': {'content': 0.0}, 'words': []},
|
||||
'currency': {'selected_value': {'content': 'EUR'}, 'candidates': []},
|
||||
'purchase_order': {'selected_values': [{'content': po2.name}], 'candidates': []},
|
||||
'invoice_lines': [
|
||||
{
|
||||
'description': {'selected_value': {'content': 'product_a'}},
|
||||
'unit_price': {'selected_value': {'content': 200}},
|
||||
'quantity': {'selected_value': {'content': 1}},
|
||||
'taxes': {'selected_values': [{'content': 0, 'amount_type': 'percent'}]},
|
||||
'subtotal': {'selected_value': {'content': 200}},
|
||||
'total': {'selected_value': {'content': 200}},
|
||||
},
|
||||
],
|
||||
}],
|
||||
'status': 'success',
|
||||
}
|
||||
bill.extract_state = 'waiting_extraction'
|
||||
with self._mock_iap_extract(extract_response=extract_response):
|
||||
bill._check_ocr_status()
|
||||
|
||||
self.assertEqual(bill.extract_status, 'success')
|
||||
self.assertEqual(bill.invoice_origin, po1.name)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
from . import models
|
||||
from . import wizard
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
|
||||
{
|
||||
'name': "Account Winbooks Import",
|
||||
'summary': """Import Data From Winbooks""",
|
||||
'description': """
|
||||
Import Data From Winbooks
|
||||
""",
|
||||
'category': 'Odex30-Accounting/Odex30-Accounting',
|
||||
'author': "Expert Co. Ltd.",
|
||||
'website': "http://www.exp-sa.com",
|
||||
'company': 'Expert',
|
||||
'depends': ['odex30_account_accountant', 'base_vat', 'odex30_account_base_import'],
|
||||
'external_dependencies': {'python': ['dbfread']},
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'wizard/account_import_summary_views.xml',
|
||||
'wizard/import_wizard_views.xml',
|
||||
],
|
||||
'license': 'OEEL-1',
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'odex30_account_winbooks_import/static/src/xml/**/*',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * odex30_account_winbooks_import
|
||||
#
|
||||
# Translators:
|
||||
# Wil Odoo, 2024
|
||||
# Malaz Abuidris <msea@odoo.com>, 2025
|
||||
# Weblate <noreply-mt-weblate@weblate.org>, 2025.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 18.0+e\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-21 18:47+0000\n"
|
||||
"PO-Revision-Date: 2025-11-17 14:06+0000\n"
|
||||
"Last-Translator: Weblate <noreply-mt-weblate@weblate.org>\n"
|
||||
"Language-Team: Arabic <https://translate.odoo.com/projects/odoo-18/"
|
||||
"odex30_account_winbooks_import/ar/>\n"
|
||||
"Language: ar\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \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 ? 4 : 5;\n"
|
||||
"X-Generator: Weblate 5.12.2\n"
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model_terms:ir.ui.view,arch_db:odex30_account_winbooks_import.winbooks_import_form
|
||||
msgid ""
|
||||
"<span invisible=\"only_open\"/>\n"
|
||||
" <span class=\"text-warning mb4 mt16\" "
|
||||
"invisible=\"only_open\">\n"
|
||||
" 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.\n"
|
||||
" </span>"
|
||||
msgstr ""
|
||||
"<span invisible=\"only_open\"/>\n"
|
||||
" <span class=\"text-warning mb4 mt16\" "
|
||||
"invisible=\"only_open\">\n"
|
||||
" قد تحتوي البيانات المستوردة من Winbooks للسنوات "
|
||||
"المغلقة على قيود غير مسواة. ولكن إذا حاولت استيراد كل شيء، سوف يقوم أودو "
|
||||
"بتعيين الفرق في حساب معلق.\n"
|
||||
" </span>"
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model,name:odex30_account_winbooks_import.model_account_winbooks_import_wizard
|
||||
msgid "Account Winbooks import wizard"
|
||||
msgstr "مُعالج استيراد حساب Winbooks "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model,name:odex30_account_winbooks_import.model_account_import_summary
|
||||
msgid "Account import summary view"
|
||||
msgstr "عرض ملخص استيراد الحساب "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#. odoo-python
|
||||
#: code:addons/odex30_account_winbooks_import/wizard/import_wizard.py:0
|
||||
msgid "Accounting Settings"
|
||||
msgstr "إعدادات المحاسبة "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#. odoo-python
|
||||
#: code:addons/odex30_account_winbooks_import/wizard/import_wizard.py:0
|
||||
msgid ""
|
||||
"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)"
|
||||
msgstr ""
|
||||
"تم إنشاء نظير تلقائي واحد على الأقل عند الاستيراد. من المرجح أن يكون ذلك "
|
||||
"خطأ. يرجى التحقق من قيود الإدخال مع المرجع: النظير (تم إنشاؤه عند الاستيراد "
|
||||
"من Winbooks) "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model_terms:ir.ui.view,arch_db:odex30_account_winbooks_import.winbooks_import_form
|
||||
msgid "Cancel"
|
||||
msgstr "إلغاء"
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model,name:odex30_account_winbooks_import.model_res_company
|
||||
msgid "Companies"
|
||||
msgstr "الشركات"
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#. odoo-python
|
||||
#: code:addons/odex30_account_winbooks_import/wizard/import_wizard.py:0
|
||||
msgid "Company Settings"
|
||||
msgstr "إعدادات الشركة "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#. odoo-python
|
||||
#: code:addons/odex30_account_winbooks_import/wizard/import_wizard.py:0
|
||||
msgid "Counterpart (generated at import from Winbooks)"
|
||||
msgstr "حساب قيد مقابل (مُنشأ في الإستيراد من Winbooks) "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,field_description:odex30_account_winbooks_import.field_account_winbooks_import_wizard__create_uid
|
||||
msgid "Created by"
|
||||
msgstr "أنشئ بواسطة"
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,field_description:odex30_account_winbooks_import.field_account_winbooks_import_wizard__create_date
|
||||
msgid "Created on"
|
||||
msgstr "أنشئ في"
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,field_description:odex30_account_winbooks_import.field_account_winbooks_import_wizard__display_name
|
||||
msgid "Display Name"
|
||||
msgstr "اسم العرض "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,field_description:odex30_account_winbooks_import.field_account_winbooks_import_wizard__zip_file
|
||||
msgid "File"
|
||||
msgstr "الملف"
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,field_description:odex30_account_winbooks_import.field_account_winbooks_import_wizard__id
|
||||
msgid "ID"
|
||||
msgstr "المُعرف"
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model_terms:ir.ui.view,arch_db:odex30_account_winbooks_import.winbooks_import_form
|
||||
msgid "Import"
|
||||
msgstr "استيراد"
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,field_description:odex30_account_winbooks_import.field_account_import_summary__import_summary_analytic_ids
|
||||
msgid "Import Summary Analytic"
|
||||
msgstr "استيراد الملخص التحليلي "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,field_description:odex30_account_winbooks_import.field_account_import_summary__import_summary_analytic_line_ids
|
||||
msgid "Import Summary Analytic Line"
|
||||
msgstr "استيراد بند الملخص التحليلي "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,field_description:odex30_account_winbooks_import.field_account_import_summary__import_summary_len_analytic
|
||||
msgid "Import Summary Len Analytic"
|
||||
msgstr "استيراد الملخص التحليلي Len "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,field_description:odex30_account_winbooks_import.field_account_import_summary__import_summary_len_analytic_line
|
||||
msgid "Import Summary Len Analytic Line"
|
||||
msgstr "استيراد بند الملخص التحليلي Len "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_account_winbooks_import/static/src/xml/account_winbooks_import.xml:0
|
||||
msgid "Import WBK"
|
||||
msgstr "استيراد WBK "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,field_description:odex30_account_winbooks_import.field_account_winbooks_import_wizard__only_open
|
||||
msgid "Import only open years"
|
||||
msgstr "استيراد السنوات المفتوحة فقط "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model,name:odex30_account_winbooks_import.model_account_move_line
|
||||
msgid "Journal Item"
|
||||
msgstr "عنصر اليومية"
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,field_description:odex30_account_winbooks_import.field_account_winbooks_import_wizard__write_uid
|
||||
msgid "Last Updated by"
|
||||
msgstr "آخر تحديث بواسطة"
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,field_description:odex30_account_winbooks_import.field_account_winbooks_import_wizard__write_date
|
||||
msgid "Last Updated on"
|
||||
msgstr "آخر تحديث في"
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,help:odex30_account_winbooks_import.field_account_move_line__winbooks_line_id
|
||||
msgid "Line ID that was used in Winbooks"
|
||||
msgstr "معرف البند الذي تم استخدامه في Winbooks "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#. odoo-python
|
||||
#: code:addons/odex30_account_winbooks_import/wizard/import_wizard.py:0
|
||||
msgid ""
|
||||
"No data zip in the main archive. Please use the complete Winbooks export."
|
||||
msgstr ""
|
||||
"لا توجد بيانات مضغوطة في ملف zip في الأرشيف الرئيسي. يرجى استخدام عملية "
|
||||
"استيراد Winbooks الكاملة. "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#. odoo-python
|
||||
#: code:addons/odex30_account_winbooks_import/wizard/import_wizard.py:0
|
||||
msgid "Please define the country on your company."
|
||||
msgstr "الرجاء تحديد الدولة في شركتك. "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model_terms:ir.ui.view,arch_db:odex30_account_winbooks_import.winbooks_import_form
|
||||
msgid "Stage Search"
|
||||
msgstr "البحث في المراحل"
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,field_description:odex30_account_winbooks_import.field_account_winbooks_import_wizard__suspense_code
|
||||
msgid "Suspense Account Code"
|
||||
msgstr "كود الحساب المعلق "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#. odoo-python
|
||||
#: code:addons/odex30_account_winbooks_import/wizard/import_wizard.py:0
|
||||
msgid "The code for the Suspense Account you entered doesn't match any account"
|
||||
msgstr "كود الحساب المعلق الذي قمت بإدخاله لا يطابق أي حساب "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,help:odex30_account_winbooks_import.field_account_winbooks_import_wizard__suspense_code
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
"هذا هو كود الحساب الذي ترغب في وضع نظير الحركات غير المتساوية فيه. قد يكون "
|
||||
"ذلك حساباً من بيانات Winbooks، أو حساباً قمت بإنشائه في أودو قبل الاستيراد. "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.actions.act_window,name:odex30_account_winbooks_import.winbooks_import_action
|
||||
msgid "Winbooks Import"
|
||||
msgstr "استيراد Winbooks "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,field_description:odex30_account_winbooks_import.field_account_move_line__winbooks_line_id
|
||||
msgid "Winbooks Line"
|
||||
msgstr "بند Winbooks "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model:ir.model.fields,help:odex30_account_winbooks_import.field_account_winbooks_import_wizard__only_open
|
||||
msgid ""
|
||||
"Years closed in Winbooks are likely to have incomplete data. The counter "
|
||||
"part of incomplete entries will be set in a suspense account"
|
||||
msgstr ""
|
||||
"السنوات المغلقة في Winbooks غالباً تحتوي على بيانات غير مكتملة. سوف يتم تعيين "
|
||||
"نظير القيود غير المكتملة في حساب معلق "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#. odoo-python
|
||||
#: code:addons/odex30_account_winbooks_import/wizard/import_wizard.py:0
|
||||
msgid "You should install a Fiscal Localization first."
|
||||
msgstr "عليك تثبيت الأقلمة المالية أولا. "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model_terms:ir.ui.view,arch_db:odex30_account_winbooks_import.account_import_summary_form
|
||||
msgid "account analytic lines imported"
|
||||
msgstr "تم استيراد بنود تحليلات الحساب "
|
||||
|
||||
#. module: odex30_account_winbooks_import
|
||||
#: model_terms:ir.ui.view,arch_db:odex30_account_winbooks_import.account_import_summary_form
|
||||
msgid "account analytics imported"
|
||||
msgstr "تم استيراد تحليلات الحساب "
|
||||
|
||||
#~ msgid "Tax Group"
|
||||
#~ msgstr "مجموعة الضريبة"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
from . import account_move
|
||||
from . import res_company
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
_inherit = 'account.move.line'
|
||||
|
||||
winbooks_line_id = fields.Char(help="Line ID that was used in Winbooks")
|
||||
|
||||
@api.constrains('account_id', 'display_type')
|
||||
def _check_payable_receivable(self):
|
||||
winbooks_lines = self.filtered('winbooks_line_id')
|
||||
super(AccountMoveLine, self - winbooks_lines)._check_payable_receivable()
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
@api.model
|
||||
def winbooks_import_action(self):
|
||||
return self.env["ir.actions.actions"]._for_xml_id("odex30_account_winbooks_import.winbooks_import_action")
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
|
||||
"access_account_winbooks_import_wizard","access.account.winbooks.import.wizard","model_account_winbooks_import_wizard","account.group_account_manager",1,1,1,0
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates>
|
||||
<t t-name="winbooks_import_template_button" t-inherit="odex30_account_base_import.accountImportTemplate" t-inherit-mode="extension">
|
||||
<xpath expr="//div[hasclass('winbooks-import')]" position="inside">
|
||||
<button class="btn btn-primary mb-3" t-on-click="() => this._importAccountGuideAction('odex30_account_winbooks_import.winbooks_import_action')">Import WBK</button>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
from . import test_winbooks_import
|
||||
|
|
@ -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")
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
from . import account_import_summary
|
||||
from . import import_wizard
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="account_import_summary_form" model="ir.ui.view">
|
||||
<field name="name">account.import.summary.form</field>
|
||||
<field name="model">account.import.summary</field>
|
||||
<field name="inherit_id" ref="odex30_account_base_import.account_import_summary_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//ul[hasclass('o_import_summary')]" position="inside">
|
||||
<field name="import_summary_analytic_ids" invisible="1"/>
|
||||
<field name="import_summary_analytic_line_ids" invisible="1"/>
|
||||
<li invisible="not import_summary_analytic_ids">
|
||||
<a name="action_open_analytic_view" type="object">
|
||||
<field name="import_summary_len_analytic" readonly="1"/> account analytics imported
|
||||
</a>
|
||||
</li>
|
||||
<li invisible="not import_summary_analytic_line_ids">
|
||||
<a name="action_open_analytic_line_view" type="object">
|
||||
<field name="import_summary_len_analytic_line" readonly="1"/> account analytic lines imported
|
||||
</a>
|
||||
</li>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
|
||||
<record id="winbooks_import_form" model="ir.ui.view">
|
||||
<field name="name">Winbooks.import.form</field>
|
||||
<field name="model">account.winbooks.import.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Stage Search">
|
||||
<group>
|
||||
<field name="zip_file"/>
|
||||
<field name="only_open"/>
|
||||
<span invisible="only_open"/>
|
||||
<span class="text-warning mb4 mt16" invisible="only_open">
|
||||
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.
|
||||
</span>
|
||||
<field name="suspense_code" required="not only_open"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="import_winbooks_file" string="Import" type="object" class="btn-primary" data-hotkey="q"/>
|
||||
<button special="cancel" string="Cancel" class="btn-secondary"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="winbooks_import_action" model="ir.actions.act_window">
|
||||
<field name="name">Winbooks Import</field>
|
||||
<field name="res_model">account.winbooks.import.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
|
|
@ -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/**/*',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
|
@ -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 "
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import update
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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());
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.o-discuss-badge {
|
||||
--o-discuss-badge-bg: #{$o-danger};
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue