Merge pull request #81 from expsa/invo_extrct_import

inv
This commit is contained in:
esam-sermah 2026-01-08 16:57:21 +03:00 committed by GitHub
commit 56ccd84d63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
111 changed files with 8225 additions and 0 deletions

52
dev_odex30_accounting/.gitignore vendored Normal file
View File

@ -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/

View File

@ -0,0 +1,2 @@
from . import models

View File

@ -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/**/*',
]
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1 @@
DELETE FROM ir_config_parameter WHERE key = 'iap_extract_endpoint';

View File

@ -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 "بانتظار الاستخلاص، ولكنها ليست جاهزة "

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import extract_mixin

View File

@ -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))

View File

@ -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});

View File

@ -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>

View File

@ -0,0 +1,2 @@
from . import test_extract_mixin

View File

@ -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

View File

@ -0,0 +1,3 @@
from . import models
from . import wizard

View File

@ -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/**/*',
],
}
}

View File

@ -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. "

View File

@ -0,0 +1,3 @@
from . import account_account
from . import account_move_line

View File

@ -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)

View File

@ -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)

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_account_import_summary access.account.import.summary model_account_import_summary account.group_account_manager 1 1 1 1

View File

@ -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)",,,
1 code name opening_balance code_mapping_ids/company_id code_mapping_ids/code
2 100000 Issued capital 12500 Company 2 100001
3 Company 3 100002
4 101000 Uncalled capital 3500
5 110000 Share premium account
6 120000 Revaluation surpluses on intangible fixed assets
7 121000 Revaluation surpluses on tangible fixed assets
8 400000 Trade debtors within one year - Customer
9 455000 Remuneration and social security - Remuneration
10 550003 Bank -3500 Company 2 550004
11 Company 3 550005
12 570001 Cash
13 600000 Purchases of raw material -12500
14 620200 Remuneration and direct social benefits - Employees
15 654000 Financial charges - Exchange differences
16 700000 Sales rendered in Belgium (marchandises)
17 700200 Sales rendered for export (marchandises)

View File

@ -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"
1 name company_ids code_mapping_ids/company_id code_mapping_ids/code
2 Issued capital company_1_data,Company 2 company_1_data 100000
3 Company 2 100001
4 Uncalled capital company_1_data,Company 2 company_1_data 101010
5 Company 2 101010
6 Share premium account company_1_data,Company 2 company_1_data 110000
7 Company 2 110000
8 Revaluation surpluses on intangible fixed assets company_1_data,Company 2 company_1_data 120000
9 Company 2 120000
10 Revaluation surpluses on tangible fixed assets company_1_data,Company 2 company_1_data 121010
11 Company 2 121010
12 Trade debtors within one year - Customer company_1_data,Company 2 company_1_data 400010
13 Company 2 400010
14 Remuneration and social security - Remuneration company_1_data,Company 2 company_1_data 455000
15 Company 2 455000
16 Bank of Company 1 company_1_data company_1_data 550003
17 Bank of Company 2 Company 2 Company 2 550003
18 Cash company_1_data,Company 2 company_1_data 570001
19 Company 2 570001
20 Purchases of raw material company_1_data,Company 2 company_1_data 600010
21 Company 2 600010
22 Remuneration and direct social benefits - Employees company_1_data,Company 2 company_1_data 620200
23 Company 2 620200
24 Financial charges - Exchange differences company_1_data,Company 2 company_1_data 654000
25 Company 2 654000
26 Sales rendered in Belgium (marchandises) company_1_data,Company 2 company_1_data 700000
27 Company 2 700000
28 Sales rendered for export (marchandises) company_1_data,Company 2 company_1_data 700200
29 Company 2 700200

View File

@ -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);

View File

@ -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);

View File

@ -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 }));
}

View File

@ -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 &amp; 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>

View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import test_account_import

View File

@ -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")

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,3 @@
from . import account_import_summary
from . import account_move_line_import

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -0,0 +1 @@

View File

@ -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',
}

View File

@ -0,0 +1,3 @@
from . import controllers
from . import models

View File

@ -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/**/*',
],
}
}

View File

@ -0,0 +1,2 @@
from . import main

View File

@ -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'

View File

@ -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>

View File

@ -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 "لا يمكنك إرسال نفقة ليست بحالة المسودة! "

View File

@ -0,0 +1,5 @@
from . import account_invoice
from . import ir_attachment
from . import res_config_settings
from . import res_company

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_account_invoice_extract_words access_account_invoice_extract_words model_account_invoice_extract_words base.group_user 1 1 1 1

View File

@ -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); }

View File

@ -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);
}
};

View File

@ -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';
}
};

View File

@ -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',
}));
}
}
};

View File

@ -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 });

View File

@ -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
);
}

View File

@ -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" });
});

View File

@ -0,0 +1,5 @@
import { models } from "@web/../tests/web_test_helpers";
export class ResCurrency extends models.ServerModel {
_name = "res.currency";
}

View File

@ -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>

View File

@ -0,0 +1,2 @@
from . import test_invoice_extract

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,2 @@
from . import models

View File

@ -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',
}

View File

@ -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 "قيد دفتر اليومية "

View File

@ -0,0 +1,2 @@
from . import account_invoice

View File

@ -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)

View File

@ -0,0 +1,2 @@
from . import test_odex30_invoice_extract_purchase

View File

@ -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)

View File

@ -0,0 +1,3 @@
from . import models
from . import wizard

View File

@ -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/**/*',
],
},
}

View File

@ -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 "مجموعة الضريبة"

View File

@ -0,0 +1,3 @@
from . import account_move
from . import res_company

View File

@ -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()

View File

@ -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")

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_account_winbooks_import_wizard access.account.winbooks.import.wizard model_account_winbooks_import_wizard account.group_account_manager 1 1 1 0

View File

@ -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>

View File

@ -0,0 +1,2 @@
from . import test_winbooks_import

View File

@ -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")

View File

@ -0,0 +1,3 @@
from . import account_import_summary
from . import import_wizard

View File

@ -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

View File

@ -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>

View File

@ -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()

View File

@ -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>

View File

@ -0,0 +1 @@
from . import models

View File

@ -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/**/*',
],
}
}

View File

@ -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 "

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import update

View File

@ -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,
}

View File

@ -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());
},
});

View File

@ -0,0 +1,3 @@
.o-discuss-badge {
--o-discuss-badge-bg: #{$o-danger};
}

View File

@ -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",
});

View File

@ -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