commit
938230b402
|
|
@ -0,0 +1,4 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
{
|
||||||
|
'name': 'ODEX Account: 3-Way Matching',
|
||||||
|
'category': 'Account/Accounting',
|
||||||
|
'author': 'ODEX',
|
||||||
|
'summary': 'Manage 3-way matching on bills',
|
||||||
|
'description': """
|
||||||
|
Manage 3-way matching on supplier bills
|
||||||
|
=======================================
|
||||||
|
|
||||||
|
In this system, you can manage the verification process for supplier bills against
|
||||||
|
received goods. This ensures that payments are only made when the items
|
||||||
|
have actually been delivered.
|
||||||
|
|
||||||
|
This feature allows creating the supplier bill based on ordered quantities
|
||||||
|
while keeping the payment pending until the received quantities on the purchase lines
|
||||||
|
match the recorded supplier bill.
|
||||||
|
|
||||||
|
The system introduces a "release to pay" status that marks for each bill
|
||||||
|
whether it is ready for payment.
|
||||||
|
|
||||||
|
Each bill receives one of the following three states:
|
||||||
|
|
||||||
|
- Yes (The bill can be paid)
|
||||||
|
- No (The bill cannot be paid, delivery is pending)
|
||||||
|
- Exception (Differences found between received and billed quantities)
|
||||||
|
""",
|
||||||
|
'depends': ['purchase'],
|
||||||
|
'data': [
|
||||||
|
'views/account_invoice_view.xml',
|
||||||
|
'views/account_journal_dashboard_view.xml'
|
||||||
|
],
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
# Translation of ODEX Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * odex30_account_3way_match
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: ODEX Server 18.0+e\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2025-01-27 13:54+0000\n"
|
||||||
|
"PO-Revision-Date: 2025-01-27 13:54+0000\n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_bank_statement_line__release_to_pay_manual
|
||||||
|
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_move__release_to_pay_manual
|
||||||
|
msgid ""
|
||||||
|
" * Yes: you should pay the bill, you have received the products\n"
|
||||||
|
" * No, you should not pay the bill, you have not received the products\n"
|
||||||
|
" * Exception, there is a difference between received and billed quantities\n"
|
||||||
|
"This status is defined automatically, but you can force it by ticking the 'Force Status' checkbox."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_3way_match.account_invoice_filter_inherit_odex30_account_3way_match
|
||||||
|
msgid "Bills in Exception"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_3way_match.account_invoice_filter_inherit_odex30_account_3way_match
|
||||||
|
msgid "Bills to Pay"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_3way_match.account_invoice_filter_inherit_odex30_account_3way_match
|
||||||
|
msgid "Bills to Validate"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay__exception
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay_manual__exception
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move_line__can_be_paid__exception
|
||||||
|
msgid "Exception"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_bank_statement_line__force_release_to_pay
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_move__force_release_to_pay
|
||||||
|
msgid "Force Status"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_bank_statement_line__force_release_to_pay
|
||||||
|
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_move__force_release_to_pay
|
||||||
|
msgid ""
|
||||||
|
"Indicates whether the 'Should Be Paid' status is defined automatically or "
|
||||||
|
"manually."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model,name:odex30_account_3way_match.model_account_journal
|
||||||
|
msgid "Journal"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model,name:odex30_account_3way_match.model_account_move
|
||||||
|
msgid "Journal Entry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model,name:odex30_account_3way_match.model_account_move_line
|
||||||
|
msgid "Journal Item"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay__no
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay_manual__no
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move_line__can_be_paid__no
|
||||||
|
msgid "No"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_bank_statement_line__release_to_pay
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_move__release_to_pay
|
||||||
|
msgid "Release To Pay"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_move_line__can_be_paid
|
||||||
|
msgid "Release to Pay"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_bank_statement_line__release_to_pay_manual
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_move__release_to_pay_manual
|
||||||
|
msgid "Should Be Paid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_bank_statement_line__release_to_pay
|
||||||
|
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_move__release_to_pay
|
||||||
|
msgid ""
|
||||||
|
"This field can take the following values :\n"
|
||||||
|
" * Yes: you should pay the bill, you have received the products\n"
|
||||||
|
" * No, you should not pay the bill, you have not received the products\n"
|
||||||
|
" * Exception, there is a difference between received and billed quantities\n"
|
||||||
|
"This status is defined automatically, but you can force it by ticking the 'Force Status' checkbox."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay__yes
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay_manual__yes
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move_line__can_be_paid__yes
|
||||||
|
msgid "Yes"
|
||||||
|
msgstr ""
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
# Translation of ODEX Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * odex30_account_3way_match
|
||||||
|
#
|
||||||
|
# Translators:
|
||||||
|
# Wil ODEX, 2025
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: ODEX Server 18.0+e\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2025-01-27 13:54+0000\n"
|
||||||
|
"PO-Revision-Date: 2024-09-25 09:43+0000\n"
|
||||||
|
"Last-Translator: Wil ODEX, 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_3way_match
|
||||||
|
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_bank_statement_line__release_to_pay_manual
|
||||||
|
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_move__release_to_pay_manual
|
||||||
|
msgid ""
|
||||||
|
" * Yes: you should pay the bill, you have received the products\n"
|
||||||
|
" * No, you should not pay the bill, you have not received the products\n"
|
||||||
|
" * Exception, there is a difference between received and billed quantities\n"
|
||||||
|
"This status is defined automatically, but you can force it by ticking the 'Force Status' checkbox."
|
||||||
|
msgstr ""
|
||||||
|
"* نعم: عليك سداد قيمة الفاتورة، لقد استلمت المنتجات \n"
|
||||||
|
"* لا: ليس عليك سداد قيمة الفاتورة، لم تستلم المنتجات\n"
|
||||||
|
" * استثناء: هناك فرق بين الكمية المستلمة والكمية المدفوع قيمتها\n"
|
||||||
|
"هذه الحالة تُحدد تلقائياً، لكن يمكنك فرض الحالة من خلال تحديد اختيار 'فرض الحالة'."
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_3way_match.account_invoice_filter_inherit_odex30_account_3way_match
|
||||||
|
msgid "Bills in Exception"
|
||||||
|
msgstr "الفواتير المستثناة "
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_3way_match.account_invoice_filter_inherit_odex30_account_3way_match
|
||||||
|
msgid "Bills to Pay"
|
||||||
|
msgstr "الفواتير بانتظار السداد "
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_3way_match.account_invoice_filter_inherit_odex30_account_3way_match
|
||||||
|
msgid "Bills to Validate"
|
||||||
|
msgstr "الفواتير بانتظار التصديق "
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay__exception
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay_manual__exception
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move_line__can_be_paid__exception
|
||||||
|
msgid "Exception"
|
||||||
|
msgstr "استثناء "
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_bank_statement_line__force_release_to_pay
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_move__force_release_to_pay
|
||||||
|
msgid "Force Status"
|
||||||
|
msgstr "فرض الحالة"
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_bank_statement_line__force_release_to_pay
|
||||||
|
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_move__force_release_to_pay
|
||||||
|
msgid ""
|
||||||
|
"Indicates whether the 'Should Be Paid' status is defined automatically or "
|
||||||
|
"manually."
|
||||||
|
msgstr "يحدد إذا ما كانت الحالة 'واجبة السداد' تُعين تلقائياً أم يدوياً. "
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model,name:odex30_account_3way_match.model_account_journal
|
||||||
|
msgid "Journal"
|
||||||
|
msgstr "دفتر اليومية"
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model,name:odex30_account_3way_match.model_account_move
|
||||||
|
msgid "Journal Entry"
|
||||||
|
msgstr "قيد اليومية"
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model,name:odex30_account_3way_match.model_account_move_line
|
||||||
|
msgid "Journal Item"
|
||||||
|
msgstr "عنصر اليومية"
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay__no
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay_manual__no
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move_line__can_be_paid__no
|
||||||
|
msgid "No"
|
||||||
|
msgstr "لا"
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_bank_statement_line__release_to_pay
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_move__release_to_pay
|
||||||
|
msgid "Release To Pay"
|
||||||
|
msgstr "جاهزة للسداد"
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_move_line__can_be_paid
|
||||||
|
msgid "Release to Pay"
|
||||||
|
msgstr "جاهزة للسداد"
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_bank_statement_line__release_to_pay_manual
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_move__release_to_pay_manual
|
||||||
|
msgid "Should Be Paid"
|
||||||
|
msgstr "واجبة السداد"
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_bank_statement_line__release_to_pay
|
||||||
|
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_move__release_to_pay
|
||||||
|
msgid ""
|
||||||
|
"This field can take the following values :\n"
|
||||||
|
" * Yes: you should pay the bill, you have received the products\n"
|
||||||
|
" * No, you should not pay the bill, you have not received the products\n"
|
||||||
|
" * Exception, there is a difference between received and billed quantities\n"
|
||||||
|
"This status is defined automatically, but you can force it by ticking the 'Force Status' checkbox."
|
||||||
|
msgstr ""
|
||||||
|
"يتحمل هذا الحقل القيم التالية:\n"
|
||||||
|
" * نعم: عليك سداد قيمة الفاتورة، لقد استلمت المنتجات\n"
|
||||||
|
" * لا: ليس عليك سداد قيمة الفاتورة، لم تستلم المنتجات\n"
|
||||||
|
" * استثناء: هناك فرق بين الكمية المستلمة والكمية المدفوع قيمتها\n"
|
||||||
|
"هذه الحالة تُحدد تلقائياً، لكن يمكنك فرض الحالة من خلال تحديد اختيار 'فرض الحالة'."
|
||||||
|
|
||||||
|
#. module: odex30_account_3way_match
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay__yes
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay_manual__yes
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move_line__can_be_paid__yes
|
||||||
|
msgid "Yes"
|
||||||
|
msgstr "نعم"
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import account_invoice
|
||||||
|
from . import account_journal_dashboard
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
from odoo.tools.float_utils import float_compare
|
||||||
|
from odoo.tools.sql import column_exists, create_column
|
||||||
|
|
||||||
|
# Available values for the release_to_pay field.
|
||||||
|
_release_to_pay_status_list = [('yes', 'Yes'), ('no', 'No'), ('exception', 'Exception')]
|
||||||
|
|
||||||
|
class AccountMove(models.Model):
|
||||||
|
_inherit = 'account.move'
|
||||||
|
|
||||||
|
def _auto_init(self):
|
||||||
|
if not column_exists(self.env.cr, "account_move", "release_to_pay"):
|
||||||
|
# Create column manually to set default value to 'exception' on postgres level.
|
||||||
|
# This way we avoid heavy computation on module installation.
|
||||||
|
self.env.cr.execute("ALTER TABLE account_move ADD COLUMN release_to_pay VARCHAR DEFAULT 'exception'")
|
||||||
|
|
||||||
|
return super()._auto_init()
|
||||||
|
|
||||||
|
release_to_pay = fields.Selection(
|
||||||
|
_release_to_pay_status_list,
|
||||||
|
compute='_compute_release_to_pay',
|
||||||
|
copy=False,
|
||||||
|
store=True,
|
||||||
|
help="This field can take the following values :\n"
|
||||||
|
" * Yes: you should pay the bill, you have received the products\n"
|
||||||
|
" * No, you should not pay the bill, you have not received the products\n"
|
||||||
|
" * Exception, there is a difference between received and billed quantities\n"
|
||||||
|
"This status is defined automatically, but you can force it by ticking the 'Force Status' checkbox.")
|
||||||
|
release_to_pay_manual = fields.Selection(
|
||||||
|
_release_to_pay_status_list,
|
||||||
|
string='Should Be Paid',
|
||||||
|
compute='_compute_release_to_pay_manual', store='True', readonly=False,
|
||||||
|
help=" * Yes: you should pay the bill, you have received the products\n"
|
||||||
|
" * No, you should not pay the bill, you have not received the products\n"
|
||||||
|
" * Exception, there is a difference between received and billed quantities\n"
|
||||||
|
"This status is defined automatically, but you can force it by ticking the 'Force Status' checkbox.")
|
||||||
|
force_release_to_pay = fields.Boolean(
|
||||||
|
string="Force Status",
|
||||||
|
help="Indicates whether the 'Should Be Paid' status is defined automatically or manually.")
|
||||||
|
|
||||||
|
@api.depends('invoice_line_ids.can_be_paid', 'force_release_to_pay', 'payment_state')
|
||||||
|
def _compute_release_to_pay(self):
|
||||||
|
records = self
|
||||||
|
if self.env.context.get('module') == 'odex30_account_3way_match':
|
||||||
|
# on module installation we set 'no' for all paid bills and other non relevant records at once
|
||||||
|
records = records.filtered(lambda r: r.payment_state != 'paid' and r.move_type in ('in_invoice', 'in_refund'))
|
||||||
|
(self - records).release_to_pay = 'no'
|
||||||
|
for invoice in records:
|
||||||
|
if invoice.payment_state == 'paid' or not invoice.is_invoice(include_receipts=True):
|
||||||
|
# no need to pay, if it's already paid
|
||||||
|
invoice.release_to_pay = 'no'
|
||||||
|
elif invoice.force_release_to_pay:
|
||||||
|
#we must use the manual value contained in release_to_pay_manual
|
||||||
|
invoice.release_to_pay = invoice.release_to_pay_manual
|
||||||
|
else:
|
||||||
|
#otherwise we must compute the field
|
||||||
|
result = None
|
||||||
|
for invoice_line in invoice.invoice_line_ids.filtered(lambda l: l.display_type not in ('line_section', 'line_note')):
|
||||||
|
line_status = invoice_line.can_be_paid
|
||||||
|
if line_status == 'exception':
|
||||||
|
#If one line is in exception, the entire bill is
|
||||||
|
result = 'exception'
|
||||||
|
break
|
||||||
|
elif not result:
|
||||||
|
result = line_status
|
||||||
|
elif line_status != result:
|
||||||
|
result = 'exception'
|
||||||
|
break
|
||||||
|
#The last two elif conditions model the fact that a
|
||||||
|
#bill will be in exception if its lines have different status.
|
||||||
|
#Otherwise, its status will be the one all its lines share.
|
||||||
|
|
||||||
|
#'result' can be None if the bill was entirely empty.
|
||||||
|
invoice.release_to_pay = result or 'no'
|
||||||
|
|
||||||
|
@api.depends('release_to_pay', 'force_release_to_pay')
|
||||||
|
def _compute_release_to_pay_manual(self):
|
||||||
|
for invoice in self:
|
||||||
|
if not (invoice.payment_state == 'paid' or not invoice.is_invoice(include_receipts=True) or invoice.force_release_to_pay):
|
||||||
|
invoice.release_to_pay_manual = invoice.release_to_pay
|
||||||
|
|
||||||
|
@api.onchange('release_to_pay_manual')
|
||||||
|
def _onchange_release_to_pay_manual(self):
|
||||||
|
if self.release_to_pay and self.release_to_pay_manual != self.release_to_pay:
|
||||||
|
self.force_release_to_pay = True
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMoveLine(models.Model):
|
||||||
|
_inherit = 'account.move.line'
|
||||||
|
|
||||||
|
def _auto_init(self):
|
||||||
|
if not column_exists(self.env.cr, "account_move_line", "can_be_paid"):
|
||||||
|
# Create column manually to set default value to 'exception' on postgres level.
|
||||||
|
# This way we avoid heavy computation on module installation.
|
||||||
|
self.env.cr.execute("ALTER TABLE account_move_line ADD COLUMN can_be_paid VARCHAR DEFAULT 'exception'")
|
||||||
|
|
||||||
|
return super()._auto_init()
|
||||||
|
|
||||||
|
|
||||||
|
@api.depends('purchase_line_id.qty_received', 'purchase_line_id.qty_invoiced', 'purchase_line_id.product_qty', 'price_unit')
|
||||||
|
def _can_be_paid(self):
|
||||||
|
""" Computes the 'release to pay' status of an invoice line, depending on
|
||||||
|
the invoicing policy of the product linked to it, by calling the dedicated
|
||||||
|
subfunctions. This function also ensures the line is linked to a purchase
|
||||||
|
order (otherwise, can_be_paid will be set as 'exception'), and the price
|
||||||
|
between this order and the invoice did not change (otherwise, again,
|
||||||
|
the line is put in exception).
|
||||||
|
"""
|
||||||
|
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||||
|
for invoice_line in self:
|
||||||
|
po_line = invoice_line.purchase_line_id
|
||||||
|
if po_line:
|
||||||
|
invoiced_qty = po_line.qty_invoiced
|
||||||
|
received_qty = po_line.qty_received
|
||||||
|
ordered_qty = po_line.product_qty
|
||||||
|
|
||||||
|
# A price difference between the original order and the invoice results in an exception
|
||||||
|
invoice_currency = invoice_line.currency_id
|
||||||
|
order_currency = po_line.currency_id
|
||||||
|
invoice_converted_price = invoice_currency._convert(
|
||||||
|
invoice_line.price_unit, order_currency, invoice_line.company_id, fields.Date.today())
|
||||||
|
if order_currency.compare_amounts(po_line.price_unit, invoice_converted_price) != 0:
|
||||||
|
invoice_line.can_be_paid = 'exception'
|
||||||
|
continue
|
||||||
|
|
||||||
|
if po_line.product_id.purchase_method == 'purchase': # 'on ordered quantities'
|
||||||
|
invoice_line._can_be_paid_ordered_qty(invoiced_qty, received_qty, ordered_qty, precision)
|
||||||
|
else: # 'on received quantities'
|
||||||
|
invoice_line._can_be_paid_received_qty(invoiced_qty, received_qty, ordered_qty, precision)
|
||||||
|
|
||||||
|
else: # Serves as default if the line is not linked to any Purchase.
|
||||||
|
invoice_line.can_be_paid = 'exception'
|
||||||
|
|
||||||
|
def _can_be_paid_ordered_qty(self, invoiced_qty, received_qty, ordered_qty, precision):
|
||||||
|
"""
|
||||||
|
Gives the release_to_pay status of an invoice line for 'on ordered
|
||||||
|
quantity' billing policy, if this line's invoice is related to a purchase order.
|
||||||
|
|
||||||
|
This function sets can_be_paid field to one of the following:
|
||||||
|
'yes': the content of the line has been ordered and can be invoiced
|
||||||
|
'no' : the content of the line hasn't been ordered at all, and cannot be invoiced
|
||||||
|
'exception' : only part of the invoice has been ordered
|
||||||
|
"""
|
||||||
|
if float_compare(invoiced_qty - self.quantity, ordered_qty, precision_digits=precision) >= 0:
|
||||||
|
self.can_be_paid = 'no'
|
||||||
|
elif float_compare(invoiced_qty, ordered_qty, precision_digits=precision) <= 0:
|
||||||
|
self.can_be_paid = 'yes'
|
||||||
|
else:
|
||||||
|
self.can_be_paid = 'exception'
|
||||||
|
|
||||||
|
def _can_be_paid_received_qty(self, invoiced_qty, received_qty, ordered_qty, precision):
|
||||||
|
"""
|
||||||
|
Gives the release_to_pay status of an invoice line for 'on received
|
||||||
|
quantity' billing policy, if this line's invoice is related to a purchase order.
|
||||||
|
|
||||||
|
This function sets can_be_paid field to one of the following:
|
||||||
|
'yes': the content of the line has been received and can be invoiced
|
||||||
|
'no' : the content of the line hasn't been received at all, and cannot be invoiced
|
||||||
|
'exception' : ordered and received quantities differ
|
||||||
|
"""
|
||||||
|
if float_compare(invoiced_qty, received_qty, precision_digits=precision) <= 0:
|
||||||
|
self.can_be_paid = 'yes'
|
||||||
|
elif received_qty == 0 and float_compare(invoiced_qty, ordered_qty, precision_digits=precision) <= 0: # "and" part to ensure a too high billed quantity results in an exception:
|
||||||
|
self.can_be_paid = 'no'
|
||||||
|
else:
|
||||||
|
self.can_be_paid = 'exception'
|
||||||
|
|
||||||
|
can_be_paid = fields.Selection(
|
||||||
|
_release_to_pay_status_list,
|
||||||
|
compute='_can_be_paid',
|
||||||
|
copy=False,
|
||||||
|
store=True,
|
||||||
|
string='Release to Pay')
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
from odoo import fields, models
|
||||||
|
from odoo.osv import expression
|
||||||
|
from odoo.tools import SQL
|
||||||
|
|
||||||
|
|
||||||
|
class AccountJournal(models.Model):
|
||||||
|
_inherit = 'account.journal'
|
||||||
|
|
||||||
|
def open_action(self):
|
||||||
|
action = super(AccountJournal, self).open_action()
|
||||||
|
view = self.env.ref('account.action_move_in_invoice_type')
|
||||||
|
if view and action.get("id") == view.id:
|
||||||
|
action['context']['search_default_in_invoice'] = 0
|
||||||
|
account_purchase_filter = self.env.ref('odex30_account_3way_match.account_invoice_filter_inherit_odex30_account_3way_match', False)
|
||||||
|
action['search_view_id'] = account_purchase_filter and [account_purchase_filter.id, account_purchase_filter.name] or False
|
||||||
|
return action
|
||||||
|
|
||||||
|
def _get_open_sale_purchase_query(self, journal_type):
|
||||||
|
# OVERRIDE
|
||||||
|
assert journal_type in ('sale', 'purchase')
|
||||||
|
query = self.env['account.move']._where_calc([
|
||||||
|
*self.env['account.move']._check_company_domain(self.env.companies),
|
||||||
|
('journal_id', 'in', self.ids),
|
||||||
|
('payment_state', 'in', ('not_paid', 'partial')),
|
||||||
|
('move_type', 'in', ('out_invoice', 'out_refund') if journal_type == 'sale' else ('in_invoice', 'in_refund')),
|
||||||
|
('state', '=', 'posted'),
|
||||||
|
])
|
||||||
|
|
||||||
|
selects = [
|
||||||
|
SQL("journal_id"),
|
||||||
|
SQL("company_id"),
|
||||||
|
SQL("currency_id AS currency"),
|
||||||
|
SQL("invoice_date_due < %s AS late", fields.Date.context_today(self)),
|
||||||
|
SQL("SUM(amount_residual_signed) AS amount_total_company"),
|
||||||
|
SQL("SUM((CASE WHEN move_type = 'in_invoice' THEN -1 ELSE 1 END) * amount_residual) AS amount_total"),
|
||||||
|
SQL("COUNT(*)"),
|
||||||
|
SQL("release_to_pay IN ('yes', 'exception') AS to_pay")
|
||||||
|
]
|
||||||
|
|
||||||
|
return query, selects
|
||||||
|
|
||||||
|
def _get_draft_sales_purchases_query(self):
|
||||||
|
# OVERRIDE
|
||||||
|
domain_sale = [
|
||||||
|
('journal_id', 'in', self.filtered(lambda j: j.type == 'sale').ids),
|
||||||
|
('move_type', 'in', self.env['account.move'].get_sale_types(include_receipts=True))
|
||||||
|
]
|
||||||
|
|
||||||
|
domain_purchase = [
|
||||||
|
('journal_id', 'in', self.filtered(lambda j: j.type == 'purchase').ids),
|
||||||
|
('move_type', 'in', self.env['account.move'].get_purchase_types(include_receipts=False)),
|
||||||
|
'|',
|
||||||
|
('invoice_date_due', '<', fields.Date.today()),
|
||||||
|
('release_to_pay', '=', 'yes')
|
||||||
|
]
|
||||||
|
domain = expression.AND([
|
||||||
|
[('state', '=', 'draft'), ('payment_state', 'in', ('not_paid', 'partial'))],
|
||||||
|
expression.OR([domain_sale, domain_purchase])
|
||||||
|
])
|
||||||
|
return self.env['account.move']._where_calc([
|
||||||
|
*self.env['account.move']._check_company_domain(self.env.companies),
|
||||||
|
*domain
|
||||||
|
])
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import test_release_to_pay_invoice
|
||||||
|
from . import test_account_journal_dashboard
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
from freezegun import freeze_time
|
||||||
|
|
||||||
|
from odoo.addons.account.tests.test_account_journal_dashboard_common import TestAccountJournalDashboardCommon
|
||||||
|
|
||||||
|
from odoo.tests import tagged
|
||||||
|
from odoo.tools.misc import format_amount
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class AccountJournalDashboard3WayWatchTest(TestAccountJournalDashboardCommon):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def init_invoice(cls, move_type, partner=None, invoice_date=None, post=False, products=None, amounts=None, taxes=None, company=False, currency=None, journal=None, invoice_date_due=None, release_to_pay=None):
|
||||||
|
move = super().init_invoice(move_type, partner, invoice_date, False, products, amounts, taxes, company, currency, journal)
|
||||||
|
if invoice_date_due:
|
||||||
|
move.invoice_date_due = invoice_date_due
|
||||||
|
if release_to_pay:
|
||||||
|
move.release_to_pay = release_to_pay
|
||||||
|
if post:
|
||||||
|
move.action_post()
|
||||||
|
return move
|
||||||
|
|
||||||
|
def test_sale_purchase_journal_for_purchase(self):
|
||||||
|
"""
|
||||||
|
Test different purchase journal setups with or without multicurrency:
|
||||||
|
1) Journal with no currency, bills in foreign currency -> dashboard data should be displayed in company currency
|
||||||
|
2) Journal in foreign currency, bills in foreign currency -> dashboard data should be displayed in foreign currency
|
||||||
|
3) Journal in foreign currency, bills in company currency -> dashboard data should be displayed in foreign currency
|
||||||
|
4) Journal in company currency, bills in company currency -> dashboard data should be displayed in company currency
|
||||||
|
5) Journal in company currency, bills in foreign currency -> dashboard data should be displayed in company currency
|
||||||
|
"""
|
||||||
|
foreign_currency = self.other_currency
|
||||||
|
company_currency = self.company_data['currency']
|
||||||
|
|
||||||
|
setup_values = [
|
||||||
|
[self.company_data['default_journal_purchase'], foreign_currency],
|
||||||
|
[self.company_data['default_journal_purchase'].copy({'currency_id': foreign_currency.id, 'default_account_id': self.company_data['default_account_expense'].id}), foreign_currency],
|
||||||
|
[self.company_data['default_journal_purchase'].copy({'currency_id': foreign_currency.id, 'default_account_id': self.company_data['default_account_expense'].id}), company_currency],
|
||||||
|
[self.company_data['default_journal_purchase'].copy({'currency_id': company_currency.id, 'default_account_id': self.company_data['default_account_expense'].id}), company_currency],
|
||||||
|
[self.company_data['default_journal_purchase'].copy({'currency_id': company_currency.id, 'default_account_id': self.company_data['default_account_expense'].id}), foreign_currency],
|
||||||
|
]
|
||||||
|
|
||||||
|
expected_vals_list = [
|
||||||
|
# number_draft, sum_draft, number_waiting, sum_waiting, number_late, sum_late, currency
|
||||||
|
[ 1, 100, 1, 55, 1, 55, company_currency],
|
||||||
|
[ 1, 200, 1, 110, 1, 110, foreign_currency],
|
||||||
|
[ 1, 400, 1, 220, 1, 220, foreign_currency],
|
||||||
|
[ 1, 200, 1, 110, 1, 110, company_currency],
|
||||||
|
[ 1, 100, 1, 55, 1, 55, company_currency],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (purchase_journal, bill_currency), expected_vals in zip(setup_values, expected_vals_list):
|
||||||
|
with self.subTest(purchase_journal_currency=purchase_journal.currency_id, bill_currency=bill_currency, expected_vals=expected_vals):
|
||||||
|
bill = self.init_invoice('in_invoice', invoice_date='2017-01-01', post=True, amounts=[200], currency=bill_currency, journal=purchase_journal)
|
||||||
|
_draft_bill = self.init_invoice('in_invoice', invoice_date='2017-01-01', post=False, amounts=[200], currency=bill_currency, journal=purchase_journal)
|
||||||
|
|
||||||
|
payment = self.init_payment(-90, post=True, date='2017-01-01', currency=bill_currency)
|
||||||
|
(bill + payment.move_id).line_ids.filtered_domain([
|
||||||
|
('account_id', '=', self.company_data['default_account_payable'].id)
|
||||||
|
]).reconcile()
|
||||||
|
|
||||||
|
self.assertDashboardPurchaseSaleData(purchase_journal, *expected_vals)
|
||||||
|
|
||||||
|
@freeze_time("2023-03-15")
|
||||||
|
def test_purchase_journal_numbers_and_sums(self):
|
||||||
|
company_currency = self.company_data['currency']
|
||||||
|
journal = self.company_data['default_journal_purchase']
|
||||||
|
|
||||||
|
self._create_test_vendor_bills(journal)
|
||||||
|
|
||||||
|
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
|
||||||
|
# Expected behavior is to have three moves waiting for payment for a total amount of 4440$ one of which would be late
|
||||||
|
# for a total amount of 40$ (second move has one of three lines late but that's not enough to make the move late)
|
||||||
|
self.assertEqual(3, dashboard_data['number_waiting'])
|
||||||
|
self.assertEqual(format_amount(self.env, 4440, company_currency), dashboard_data['sum_waiting'])
|
||||||
|
self.assertEqual(1, dashboard_data['number_late'])
|
||||||
|
self.assertEqual(format_amount(self.env, 40, company_currency), dashboard_data['sum_late'])
|
||||||
|
|
||||||
|
@freeze_time("2019-01-22")
|
||||||
|
def test_customer_invoice_dashboard(self):
|
||||||
|
journal = self.company_data['default_journal_sale']
|
||||||
|
|
||||||
|
invoice = self.env['account.move'].create({
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'journal_id': journal.id,
|
||||||
|
'partner_id': self.partner_a.id,
|
||||||
|
'invoice_date': '2019-01-21',
|
||||||
|
'date': '2019-01-21',
|
||||||
|
'invoice_line_ids': [(0, 0, {
|
||||||
|
'product_id': self.product_a.id,
|
||||||
|
'quantity': 40.0,
|
||||||
|
'name': 'product test 1',
|
||||||
|
'discount': 10.00,
|
||||||
|
'price_unit': 2.27,
|
||||||
|
'tax_ids': [],
|
||||||
|
})]
|
||||||
|
})
|
||||||
|
refund = self.env['account.move'].create({
|
||||||
|
'move_type': 'out_refund',
|
||||||
|
'journal_id': journal.id,
|
||||||
|
'partner_id': self.partner_a.id,
|
||||||
|
'invoice_date': '2019-01-21',
|
||||||
|
'date': '2019-01-21',
|
||||||
|
'invoice_line_ids': [(0, 0, {
|
||||||
|
'product_id': self.product_a.id,
|
||||||
|
'quantity': 1.0,
|
||||||
|
'name': 'product test 1',
|
||||||
|
'price_unit': 13.3,
|
||||||
|
'tax_ids': [],
|
||||||
|
})]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check Draft
|
||||||
|
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
|
||||||
|
|
||||||
|
self.assertEqual(dashboard_data['number_draft'], 2)
|
||||||
|
self.assertIn('68.42', dashboard_data['sum_draft'])
|
||||||
|
|
||||||
|
self.assertEqual(dashboard_data['number_waiting'], 0)
|
||||||
|
self.assertIn('0.00', dashboard_data['sum_waiting'])
|
||||||
|
|
||||||
|
# Check Both
|
||||||
|
invoice.action_post()
|
||||||
|
|
||||||
|
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
|
||||||
|
self.assertEqual(dashboard_data['number_draft'], 1)
|
||||||
|
self.assertIn('-\N{ZERO WIDTH NO-BREAK SPACE}13.30', dashboard_data['sum_draft'])
|
||||||
|
|
||||||
|
self.assertEqual(dashboard_data['number_waiting'], 1)
|
||||||
|
self.assertIn('81.72', dashboard_data['sum_waiting'])
|
||||||
|
|
||||||
|
# Check partial on invoice
|
||||||
|
partial_payment = self.env['account.payment'].create({
|
||||||
|
'amount': 13.3,
|
||||||
|
'payment_type': 'inbound',
|
||||||
|
'partner_type': 'customer',
|
||||||
|
'partner_id': self.partner_a.id,
|
||||||
|
})
|
||||||
|
partial_payment.action_post()
|
||||||
|
|
||||||
|
(invoice + partial_payment.move_id).line_ids.filtered(lambda line: line.account_type == 'asset_receivable').reconcile()
|
||||||
|
|
||||||
|
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
|
||||||
|
self.assertEqual(dashboard_data['number_draft'], 1)
|
||||||
|
self.assertIn('13.3', dashboard_data['sum_draft'])
|
||||||
|
|
||||||
|
self.assertEqual(dashboard_data['number_waiting'], 1)
|
||||||
|
self.assertIn('68.42', dashboard_data['sum_waiting'])
|
||||||
|
|
||||||
|
# Check waiting payment
|
||||||
|
refund.action_post()
|
||||||
|
|
||||||
|
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
|
||||||
|
self.assertEqual(dashboard_data['number_draft'], 0)
|
||||||
|
self.assertIn('0.00', dashboard_data['sum_draft'])
|
||||||
|
|
||||||
|
self.assertEqual(dashboard_data['number_waiting'], 2)
|
||||||
|
self.assertIn('55.12', dashboard_data['sum_waiting'])
|
||||||
|
|
||||||
|
# Check partial on refund
|
||||||
|
payment = self.env['account.payment'].create({
|
||||||
|
'amount': 10.0,
|
||||||
|
'payment_type': 'outbound',
|
||||||
|
'partner_type': 'customer',
|
||||||
|
'partner_id': self.partner_a.id,
|
||||||
|
})
|
||||||
|
payment.action_post()
|
||||||
|
|
||||||
|
(refund + payment.move_id).line_ids\
|
||||||
|
.filtered(lambda line: line.account_type == 'asset_receivable')\
|
||||||
|
.reconcile()
|
||||||
|
|
||||||
|
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
|
||||||
|
self.assertEqual(dashboard_data['number_draft'], 0)
|
||||||
|
self.assertIn('0.00', dashboard_data['sum_draft'])
|
||||||
|
|
||||||
|
self.assertEqual(dashboard_data['number_waiting'], 2)
|
||||||
|
self.assertIn('65.12', dashboard_data['sum_waiting'])
|
||||||
|
|
||||||
|
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
|
||||||
|
self.assertEqual(dashboard_data['number_late'], 2)
|
||||||
|
self.assertIn('65.12', dashboard_data['sum_late'])
|
||||||
|
|
||||||
|
def test_sale_purchase_journal_for_multi_currency_sale(self):
|
||||||
|
currency = self.other_currency
|
||||||
|
company_currency = self.company_data['currency']
|
||||||
|
|
||||||
|
invoice = self.env['account.move'].create({
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'invoice_date': '2017-01-01',
|
||||||
|
'date': '2017-01-01',
|
||||||
|
'partner_id': self.partner_a.id,
|
||||||
|
'currency_id': currency.id,
|
||||||
|
'invoice_line_ids': [
|
||||||
|
(0, 0, {'name': 'test', 'price_unit': 200})
|
||||||
|
],
|
||||||
|
})
|
||||||
|
invoice.action_post()
|
||||||
|
|
||||||
|
payment = self.env['account.payment'].create({
|
||||||
|
'amount': 90.0,
|
||||||
|
'date': '2016-01-01',
|
||||||
|
'payment_type': 'inbound',
|
||||||
|
'partner_type': 'customer',
|
||||||
|
'partner_id': self.partner_a.id,
|
||||||
|
'currency_id': currency.id,
|
||||||
|
})
|
||||||
|
payment.action_post()
|
||||||
|
|
||||||
|
(invoice + payment.move_id).line_ids.filtered_domain([
|
||||||
|
('account_id', '=', self.company_data['default_account_receivable'].id)
|
||||||
|
]).reconcile()
|
||||||
|
|
||||||
|
default_journal_sale = self.company_data['default_journal_sale']
|
||||||
|
dashboard_data = default_journal_sale._get_journal_dashboard_data_batched()[default_journal_sale.id]
|
||||||
|
self.assertEqual(format_amount(self.env, 55, company_currency), dashboard_data['sum_waiting'])
|
||||||
|
self.assertEqual(format_amount(self.env, 55, company_currency), dashboard_data['sum_late'])
|
||||||
|
|
||||||
|
@freeze_time("2023-03-15")
|
||||||
|
def test_purchase_journal_numbers_and_sums_to_validate(self):
|
||||||
|
company_currency = self.company_data['currency']
|
||||||
|
journal = self.company_data['default_journal_purchase']
|
||||||
|
|
||||||
|
datas = [
|
||||||
|
{'invoice_date_due': '2023-04-30'},
|
||||||
|
{'invoice_date_due': '2023-04-30', 'release_to_pay': 'yes'},
|
||||||
|
{'invoice_date_due': '2023-04-30', 'release_to_pay': 'no'},
|
||||||
|
{'invoice_date_due': '2023-03-01'},
|
||||||
|
{'invoice_date_due': '2023-03-01', 'release_to_pay': 'yes'},
|
||||||
|
{'invoice_date_due': '2023-03-01', 'release_to_pay': 'no'},
|
||||||
|
]
|
||||||
|
|
||||||
|
for data in datas:
|
||||||
|
self.init_invoice('in_invoice', invoice_date='2023-03-01', post=False, amounts=[4000], journal=journal, invoice_date_due=data['invoice_date_due'], release_to_pay=data.get('release_to_pay'))
|
||||||
|
|
||||||
|
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
|
||||||
|
# Expected behavior is to have six amls waiting for payment for a total amount of 4440$
|
||||||
|
# three of which would be late for a total amount of 140$
|
||||||
|
self.assertEqual(4, dashboard_data['number_draft'])
|
||||||
|
self.assertEqual(format_amount(self.env, 16000, company_currency), dashboard_data['sum_draft'])
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import Command, fields
|
||||||
|
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
|
||||||
|
from odoo.tests import tagged, Form
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReleaseToPayInvoice(AccountTestInvoicingCommon):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
|
||||||
|
cls.partner = cls.env['res.partner'].create({'name': 'Zizizapartner'})
|
||||||
|
cls.product = cls.env['product.product'].create({
|
||||||
|
'name': 'VR Computer',
|
||||||
|
'standard_price': 2500.0,
|
||||||
|
'list_price': 2899.0,
|
||||||
|
'type': 'service',
|
||||||
|
'default_code': 'VR-01',
|
||||||
|
'weight': 1.0,
|
||||||
|
'purchase_method': 'receive',
|
||||||
|
})
|
||||||
|
cls.other_currency = cls.setup_other_currency('HRK', rounding=0.001)
|
||||||
|
|
||||||
|
def check_release_to_pay_scenario(self, ordered_qty, scenario, invoicing_policy='receive', order_price=500.0):
|
||||||
|
""" Generic test function to check that each use scenario behaves properly.
|
||||||
|
"""
|
||||||
|
self.product.purchase_method = invoicing_policy
|
||||||
|
|
||||||
|
purchase_order = self.env['purchase.order'].create({
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'order_line': [
|
||||||
|
(0, 0, {
|
||||||
|
'name': self.product.name,
|
||||||
|
'product_id': self.product.id,
|
||||||
|
'product_qty': ordered_qty,
|
||||||
|
'product_uom': self.product.uom_po_id.id,
|
||||||
|
'price_unit': order_price,
|
||||||
|
'date_planned': fields.Datetime.now(),
|
||||||
|
})]
|
||||||
|
})
|
||||||
|
purchase_order.button_confirm()
|
||||||
|
|
||||||
|
invoices_list = []
|
||||||
|
purchase_line = purchase_order.order_line[-1]
|
||||||
|
AccountMove = self.env['account.move'].with_context(default_move_type='in_invoice')
|
||||||
|
for (action, params) in scenario:
|
||||||
|
if action == 'invoice':
|
||||||
|
# <field name="purchase_id" invisible="1"/>
|
||||||
|
move_form = Form(AccountMove.with_context(default_purchase_id=purchase_order.id))
|
||||||
|
with move_form.invoice_line_ids.edit(0) as line_form:
|
||||||
|
if 'price' in params:
|
||||||
|
line_form.price_unit = params['price']
|
||||||
|
if 'qty' in params:
|
||||||
|
line_form.quantity = params['qty']
|
||||||
|
new_invoice = move_form.save()
|
||||||
|
new_invoice.write({'invoice_line_ids': [
|
||||||
|
Command.create({'display_type': 'line_section', 'name': 'Section'}),
|
||||||
|
Command.create({'display_type': 'line_note', 'name': 'Note'}),
|
||||||
|
]})
|
||||||
|
invoices_list.append(new_invoice)
|
||||||
|
|
||||||
|
self.assertEqual(new_invoice.release_to_pay, params['rslt'], "Wrong invoice release to pay status for scenario " + str(scenario))
|
||||||
|
|
||||||
|
elif action == 'receive':
|
||||||
|
purchase_line.write({'qty_received': params['qty']}) # as the product is a service, its recieved quantity is set manually
|
||||||
|
|
||||||
|
if 'rslt' in params:
|
||||||
|
for (invoice_index, status) in params['rslt']:
|
||||||
|
self.assertEqual(invoices_list[invoice_index].release_to_pay, status, "Wrong invoice release to pay status for scenario " + str(scenario))
|
||||||
|
|
||||||
|
def test_3_way_match(self):
|
||||||
|
self.check_release_to_pay_scenario(10, [('receive',{'qty': 5}), ('invoice', {'qty': 5, 'rslt': 'yes'})], invoicing_policy='purchase')
|
||||||
|
self.check_release_to_pay_scenario(10, [('receive',{'qty': 5}), ('invoice', {'qty': 10, 'rslt': 'yes'})], invoicing_policy='purchase')
|
||||||
|
self.check_release_to_pay_scenario(10, [('invoice', {'qty': 10, 'rslt': 'yes'})], invoicing_policy='purchase')
|
||||||
|
self.check_release_to_pay_scenario(10, [('invoice', {'qty': 5, 'rslt': 'yes'}), ('receive',{'qty': 5}), ('invoice', {'qty': 6, 'rslt': 'exception'})], invoicing_policy='purchase')
|
||||||
|
self.check_release_to_pay_scenario(10, [('invoice', {'qty': 10, 'rslt': 'yes'}), ('invoice', {'qty': 10, 'rslt': 'no'})], invoicing_policy='purchase')
|
||||||
|
self.check_release_to_pay_scenario(10, [('receive',{'qty': 5}), ('invoice', {'qty': 5, 'rslt': 'yes'})])
|
||||||
|
self.check_release_to_pay_scenario(10, [('receive',{'qty': 5}), ('invoice', {'qty': 10, 'rslt': 'exception'})])
|
||||||
|
self.check_release_to_pay_scenario(10, [('invoice', {'qty': 5, 'rslt': 'no'})])
|
||||||
|
self.check_release_to_pay_scenario(10, [('invoice', {'qty': 5, 'rslt': 'no'}), ('receive', {'qty': 5, 'rslt': [(-1, 'yes')]})])
|
||||||
|
self.check_release_to_pay_scenario(10, [('invoice', {'qty': 5, 'rslt': 'no'}), ('receive', {'qty': 3, 'rslt': [(-1, 'exception')]})])
|
||||||
|
self.check_release_to_pay_scenario(10, [('invoice', {'qty': 5, 'rslt': 'no'}), ('receive', {'qty': 10, 'rslt': [(-1, 'yes')]})])
|
||||||
|
|
||||||
|
# Special use case : a price change between order and invoice should always put the bill in exception
|
||||||
|
self.check_release_to_pay_scenario(10, [('receive',{'qty': 5}), ('invoice', {'qty': 5, 'rslt': 'exception', 'price':42})])
|
||||||
|
self.check_release_to_pay_scenario(10, [('receive',{'qty': 5}), ('invoice', {'qty': 5, 'rslt': 'exception', 'price':42})], invoicing_policy='purchase')
|
||||||
|
|
||||||
|
def test_amount_currency_edit(self):
|
||||||
|
"""
|
||||||
|
Ensure that editing the `amount_currency` of a journal item on an invoice is possible.
|
||||||
|
In 17.0 changes to Binary fields and web_save were made (related to context key 'bin_size').
|
||||||
|
They led to tracebacks in the flow tested here.
|
||||||
|
"""
|
||||||
|
move_form = Form(self.env['account.move'].with_context(default_move_type='out_invoice'))
|
||||||
|
move_form.invoice_date = fields.Date.from_string('2023-01-01')
|
||||||
|
move_form.partner_id = self.partner_a
|
||||||
|
move_form.currency_id = self.other_currency
|
||||||
|
with move_form.invoice_line_ids.new() as line_form:
|
||||||
|
line_form.quantity = 1
|
||||||
|
line_form.price_unit = 10
|
||||||
|
move_form.save()
|
||||||
|
with move_form.line_ids.edit(0) as line_form:
|
||||||
|
line_form.amount_currency = -30
|
||||||
|
move_form.save()
|
||||||
|
self.assertEqual(move_form.line_ids.edit(0).amount_currency, -30)
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="account_invoice_form_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">account.move.form.inherit</field>
|
||||||
|
<field name="model">account.move</field>
|
||||||
|
<field name="inherit_id" ref="account.view_move_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//page[@id='other_tab']//field[@name='fiscal_position_id']" position='after'>
|
||||||
|
<label for="release_to_pay_manual" invisible="move_type not in ('in_invoice', 'in_refund')"/>
|
||||||
|
<div class="o_row" col="4" invisible="move_type not in ('in_invoice', 'in_refund')">
|
||||||
|
<field name="release_to_pay" invisible="True" force_save="1"/>
|
||||||
|
<field name="release_to_pay_manual"/>
|
||||||
|
<label class="fw-bold" for="force_release_to_pay" invisible="not force_release_to_pay"/>
|
||||||
|
<field name="force_release_to_pay" invisible="not force_release_to_pay"/>
|
||||||
|
</div>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="account_invoice_filter_inherit_odex30_account_3way_match" model="ir.ui.view">
|
||||||
|
<field name="name">account.invoice.select.inherit.odex30_account_3way_match</field>
|
||||||
|
<field name="mode">primary</field>
|
||||||
|
<field name="model">account.move</field>
|
||||||
|
<field name="inherit_id" ref="account.view_account_bill_filter"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//filter[@name='late']" position='after'>
|
||||||
|
<separator/>
|
||||||
|
<filter name="bills_to_validate" string="Bills to Validate" domain="['&', '|', ('release_to_pay','=', 'yes'), ('invoice_date_due', '<', time.strftime('%Y-%m-%d')), ('state', '=', 'draft')]"/>
|
||||||
|
<filter name="bills_to_pay" string="Bills to Pay" domain="['&', '&', ('state', '=', 'posted'), ('payment_state', 'in', ('not_paid', 'partial')), ('release_to_pay','in', ('yes', 'exception'))]"/>
|
||||||
|
<filter name="exception" string="Bills in Exception" domain="[('release_to_pay','=', 'exception')]"/>
|
||||||
|
<separator/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!--This action has been redefined and the account_invoice_filter_inherit_odex30_account_3way_match
|
||||||
|
created in order to only display 'bills_to_pay' and 'exception' filters
|
||||||
|
in the view related to vendor bills, as it makes no sense to propose them
|
||||||
|
in the view related to sales invoices, which share the same model.-->
|
||||||
|
<record id="account.action_move_in_invoice_type" model="ir.actions.act_window">
|
||||||
|
<field name="search_view_id" ref="account_invoice_filter_inherit_odex30_account_3way_match"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="account.action_move_in_refund_type" model="ir.actions.act_window">
|
||||||
|
<field name="search_view_id" ref="account_invoice_filter_inherit_odex30_account_3way_match"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="account_journal_dashboard_kanban_view_3_way_match" model="ir.ui.view">
|
||||||
|
<field name="name">account.journal.dashboard.kanban</field>
|
||||||
|
<field name="model">account.journal</field>
|
||||||
|
<field name="inherit_id" ref="account.account_journal_dashboard_kanban_view"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//a[span[@id='account_dashboard_purchase_draft']]" position="attributes">
|
||||||
|
<attribute name="context">
|
||||||
|
{'search_default_bills_to_validate': 1}
|
||||||
|
</attribute>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//a[span[@id='account_dashboard_bills_to_pay']]" position="attributes">
|
||||||
|
<attribute name="context">
|
||||||
|
{'search_default_bills_to_pay':1}
|
||||||
|
</attribute>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//a[span[@id='account_dashboard_bills_late']]" position="attributes">
|
||||||
|
<attribute name="context">
|
||||||
|
{'search_default_late':1}
|
||||||
|
</attribute>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
{
|
||||||
|
'name': 'ODEX Account: Advanced Check Processing',
|
||||||
|
'version': '1.0',
|
||||||
|
'category': 'Account/Accounting',
|
||||||
|
'author': 'ODEX',
|
||||||
|
'summary': 'Advanced verification and reconciliation for issued checks.',
|
||||||
|
'description': """
|
||||||
|
This system provides advanced tools for managing financial check distributions.
|
||||||
|
It enables seamless verification between your internal accounting records and
|
||||||
|
issued physical checks within the financial management interface.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Seamless verification of check payments.
|
||||||
|
- Real-time reconciliation within the accounting workspace.
|
||||||
|
- Enhanced audit trails for distributed checks.
|
||||||
|
""",
|
||||||
|
'depends': ['odex30_account_accountant', 'account_check_printing'],
|
||||||
|
'data': [
|
||||||
|
'views/bank_rec_widget_views.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'auto_install': True,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Translation of ODEX Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * odex30_account_accountant_check_printing
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: ODEX 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:26+0000\n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#. module: odex30_account_accountant_check_printing
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_accountant_check_printing.field_account_move_line__check_number
|
||||||
|
msgid "Check Number"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_accountant_check_printing
|
||||||
|
#: model:ir.model,name:odex30_account_accountant_check_printing.model_account_move_line
|
||||||
|
msgid "Journal Item"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_accountant_check_printing
|
||||||
|
#: model:ir.model.fields,help:odex30_account_accountant_check_printing.field_account_move_line__check_number
|
||||||
|
msgid ""
|
||||||
|
"The selected journal is configured to print check numbers. If your pre-"
|
||||||
|
"printed check paper already has numbers or if the current numbering is "
|
||||||
|
"wrong, you can change it in the journal configuration page."
|
||||||
|
msgstr ""
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Translation of ODEX Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * odex30_account_accountant_check_printing
|
||||||
|
#
|
||||||
|
# Translators:
|
||||||
|
# Wil ODEX, 2024
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: ODEX 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:43+0000\n"
|
||||||
|
"Last-Translator: Wil ODEX, 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_accountant_check_printing
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_accountant_check_printing.field_account_move_line__check_number
|
||||||
|
msgid "Check Number"
|
||||||
|
msgstr "رقم الشيك "
|
||||||
|
|
||||||
|
#. module: odex30_account_accountant_check_printing
|
||||||
|
#: model:ir.model,name:odex30_account_accountant_check_printing.model_account_move_line
|
||||||
|
msgid "Journal Item"
|
||||||
|
msgstr "عنصر دفتر اليومية "
|
||||||
|
|
||||||
|
#. module: odex30_account_accountant_check_printing
|
||||||
|
#: model:ir.model.fields,help:odex30_account_accountant_check_printing.field_account_move_line__check_number
|
||||||
|
msgid ""
|
||||||
|
"The selected journal is configured to print check numbers. If your pre-"
|
||||||
|
"printed check paper already has numbers or if the current numbering is "
|
||||||
|
"wrong, you can change it in the journal configuration page."
|
||||||
|
msgstr ""
|
||||||
|
"تمت تهيئة قيد اليومية المُختار لطباعة أرقام الشيكات. إذا كان لشيكاتك "
|
||||||
|
"المطبوعة مسبقًا أرقام بالفعل أو إذا كان الترقيم الحالي خاطئًا، فيمكنك تغييره"
|
||||||
|
" في صفحة تهيئة دفتر اليومية. "
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import account_move_line
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMoveLine(models.Model):
|
||||||
|
_name = "account.move.line"
|
||||||
|
_inherit = "account.move.line"
|
||||||
|
|
||||||
|
check_number = fields.Char(
|
||||||
|
string="Check Number",
|
||||||
|
related='payment_id.check_number',
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="view_account_move_line_list_bank_rec_widget" model="ir.ui.view">
|
||||||
|
<field name="name">account.move.line.list.bank_rec_widget</field>
|
||||||
|
<field name="model">account.move.line</field>
|
||||||
|
<field name="inherit_id" ref="odex30_account_accountant.view_account_move_line_list_bank_rec_widget"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='name']" position="after">
|
||||||
|
<field name="check_number"
|
||||||
|
optional="hidden"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_account_move_line_search_bank_rec_widget" model="ir.ui.view">
|
||||||
|
<field name="name">account.move.line.search.bank_rec_widget</field>
|
||||||
|
<field name="model">account.move.line</field>
|
||||||
|
<field name="inherit_id" ref="odex30_account_accountant.view_account_move_line_search_bank_rec_widget"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='name']" position="after">
|
||||||
|
<field name="check_number"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<templates id="template" xml:space="preserve">
|
<templates id="template" xml:space="preserve">
|
||||||
<t t-name="odex30_account_accountant_fleet.BankRecRecordFormLineIds" t-inherit="account_accountant.BankRecRecordFormLineIds" t-inherit-mode="extension">
|
<t t-name="odex30_account_accountant_fleet.BankRecRecordFormLineIds" t-inherit="odex30_account_accountant.BankRecRecordFormLineIds" t-inherit-mode="extension">
|
||||||
<xpath expr="//t[@name='col_taxes']" position="after">
|
<xpath expr="//t[@name='col_taxes']" position="after">
|
||||||
<t t-if="column[0] === 'vehicle'" name="col_vehicle">
|
<t t-if="column[0] === 'vehicle'" name="col_vehicle">
|
||||||
<td class="o_data_cell o_field_cell o_field_widget o_list_many2one"
|
<td class="o_data_cell o_field_cell o_field_widget o_list_many2one"
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
</xpath>
|
</xpath>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
<t t-name="odex30_account_accountant_fleet.BankRecRecordNotebookManualOperations" t-inherit="account_accountant.BankRecRecordNotebookManualOperations" t-inherit-mode="extension">
|
<t t-name="odex30_account_accountant_fleet.BankRecRecordNotebookManualOperations" t-inherit="odex30_account_accountant.BankRecRecordNotebookManualOperations" t-inherit-mode="extension">
|
||||||
<xpath expr="//div[@name='suggestion']" position="before">
|
<xpath expr="//div[@name='suggestion']" position="before">
|
||||||
<div name="vehicle"
|
<div name="vehicle"
|
||||||
t-if="!['liquidity', 'new_batch'].includes(line.data.flag)"
|
t-if="!['liquidity', 'new_batch'].includes(line.data.flag)"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
{
|
||||||
|
'name': 'ODEX Account: Asset-Fleet Integration',
|
||||||
|
'category': 'Odex30-Accounting/Odex30-Accounting',
|
||||||
|
'author': "Expert Co. Ltd.",
|
||||||
|
'website': "http://www.exp-sa.com",
|
||||||
|
'summary': 'Advanced integration between fixed assets and vehicle fleet management.',
|
||||||
|
'description': """
|
||||||
|
This module provides a robust bridge between your fixed asset records and
|
||||||
|
the company's vehicle fleet management system.
|
||||||
|
|
||||||
|
It allows for:
|
||||||
|
- Direct linkage of assets to specific fleet vehicles.
|
||||||
|
- Unified tracking of depreciation and maintenance costs for transport assets.
|
||||||
|
- Integrated reporting across financial and operational fleet data.
|
||||||
|
""",
|
||||||
|
'version': '1.0',
|
||||||
|
'depends': ['account_fleet', 'odex30_account_asset'],
|
||||||
|
'data': [
|
||||||
|
'views/account_asset_views.xml',
|
||||||
|
'views/account_move_views.xml',
|
||||||
|
],
|
||||||
|
'auto_install': True,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Translation of ODEX Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * odex30_account_asset_fleet
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: ODEX 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:26+0000\n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#. module: odex30_account_asset_fleet
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/odex30_account_asset_fleet/models/account_asset.py:0
|
||||||
|
msgid "All the lines should be from the same vehicle"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_asset_fleet
|
||||||
|
#: model:ir.model,name:odex30_account_asset_fleet.model_account_asset
|
||||||
|
msgid "Asset/Revenue Recognition"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_asset_fleet
|
||||||
|
#: model:ir.model,name:odex30_account_asset_fleet.model_account_move
|
||||||
|
msgid "Journal Entry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_asset_fleet
|
||||||
|
#: model:ir.model,name:odex30_account_asset_fleet.model_fleet_vehicle_log_services
|
||||||
|
msgid "Services for vehicles"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_asset_fleet
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_asset_fleet.field_account_asset__vehicle_id
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_asset_fleet.view_odex30_account_asset_fleet_form
|
||||||
|
msgid "Vehicle"
|
||||||
|
msgstr ""
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Translation of ODEX Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * odex30_account_asset_fleet
|
||||||
|
#
|
||||||
|
# Translators:
|
||||||
|
# Wil ODEX, 2024
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: ODEX 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:43+0000\n"
|
||||||
|
"Last-Translator: Wil ODEX, 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_asset_fleet
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/odex30_account_asset_fleet/models/account_asset.py:0
|
||||||
|
msgid "All the lines should be from the same vehicle"
|
||||||
|
msgstr "يجب أن تكون كافة البنود من نفس المركبة "
|
||||||
|
|
||||||
|
#. module: odex30_account_asset_fleet
|
||||||
|
#: model:ir.model,name:odex30_account_asset_fleet.model_account_asset
|
||||||
|
msgid "Asset/Revenue Recognition"
|
||||||
|
msgstr "إثبات الأصل/الإيرادات "
|
||||||
|
|
||||||
|
#. module: odex30_account_asset_fleet
|
||||||
|
#: model:ir.model,name:odex30_account_asset_fleet.model_account_move
|
||||||
|
msgid "Journal Entry"
|
||||||
|
msgstr "قيد اليومية"
|
||||||
|
|
||||||
|
#. module: odex30_account_asset_fleet
|
||||||
|
#: model:ir.model,name:odex30_account_asset_fleet.model_fleet_vehicle_log_services
|
||||||
|
msgid "Services for vehicles"
|
||||||
|
msgstr "خدمات المركبات "
|
||||||
|
|
||||||
|
#. module: odex30_account_asset_fleet
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_asset_fleet.field_account_asset__vehicle_id
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_asset_fleet.view_odex30_account_asset_fleet_form
|
||||||
|
msgid "Vehicle"
|
||||||
|
msgstr "المركبة"
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import account_asset
|
||||||
|
from . import account_move
|
||||||
|
from . import fleet_vehicle_log_services
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class AccountAsset(models.Model):
|
||||||
|
_inherit = 'account.asset'
|
||||||
|
|
||||||
|
vehicle_id = fields.Many2one('fleet.vehicle', compute='_compute_vehicle_id', readonly=False, store=True)
|
||||||
|
|
||||||
|
@api.depends('original_move_line_ids')
|
||||||
|
def _compute_vehicle_id(self):
|
||||||
|
for record in self:
|
||||||
|
if len(record.original_move_line_ids.vehicle_id) > 1:
|
||||||
|
raise UserError(_("All the lines should be from the same vehicle"))
|
||||||
|
record.vehicle_id = record.original_move_line_ids.vehicle_id
|
||||||
|
|
||||||
|
def action_open_vehicle(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'fleet.vehicle',
|
||||||
|
'res_id': self.vehicle_id.id,
|
||||||
|
'view_ids': [(False, 'form')],
|
||||||
|
'view_mode': 'form',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import models
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMove(models.Model):
|
||||||
|
_inherit = 'account.move'
|
||||||
|
|
||||||
|
def _prepare_move_for_asset_depreciation(self, vals):
|
||||||
|
# Overridden in order to link the depreciation entries with the vehicle_id
|
||||||
|
move_vals = super()._prepare_move_for_asset_depreciation(vals)
|
||||||
|
if vals['asset_id'].vehicle_id:
|
||||||
|
for _command, _id, line_vals in move_vals['line_ids']:
|
||||||
|
line_vals['vehicle_id'] = vals['asset_id'].vehicle_id.id
|
||||||
|
return move_vals
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
class FleetVehicleLogServices(models.Model):
|
||||||
|
_inherit = 'fleet.vehicle.log.services'
|
||||||
|
|
||||||
|
@api.depends('account_move_line_id.price_subtotal',
|
||||||
|
'account_move_line_id.non_deductible_tax_value',
|
||||||
|
'account_move_line_id.account_id.multiple_assets_per_line')
|
||||||
|
def _compute_amount(self):
|
||||||
|
for log_service in self:
|
||||||
|
if not log_service.account_move_line_id:
|
||||||
|
continue
|
||||||
|
account_move_line_id = log_service.account_move_line_id
|
||||||
|
quantity = 1
|
||||||
|
if account_move_line_id.account_id.multiple_assets_per_line:
|
||||||
|
quantity = account_move_line_id.quantity
|
||||||
|
log_service.amount = account_move_line_id.currency_id.round(
|
||||||
|
(account_move_line_id.debit + account_move_line_id.non_deductible_tax_value) / quantity)
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_odex30_account_asset_fleet_form" model="ir.ui.view">
|
||||||
|
<field name="name">account.asset.fleet.form</field>
|
||||||
|
<field name="model">account.asset</field>
|
||||||
|
<field name="inherit_id" ref="odex30_account_asset.view_account_asset_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//sheet/div[@name='button_box']" position="inside">
|
||||||
|
<field name='vehicle_id' invisible="1"/>
|
||||||
|
<button class="oe_stat_button" string="Vehicle" name="action_open_vehicle" type="object" icon="fa-car" invisible="not vehicle_id"/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//sheet/notebook/page[@name='related_items']//field[@name='account_id']" position="after">
|
||||||
|
<field name='vehicle_id' optional='hidden'/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//field[@name='already_depreciated_amount_import']" position="after">
|
||||||
|
<field name='vehicle_id'/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_account_move_fleet_form" model="ir.ui.view">
|
||||||
|
<field name="name">account.move.fleet.form</field>
|
||||||
|
<field name="model">account.move</field>
|
||||||
|
<field name="inherit_id" ref="account_fleet.view_move_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='line_ids']//field[@name='vehicle_id']" position="attributes">
|
||||||
|
<attribute name="column_invisible">parent.move_type not in ('entry', 'in_invoice', 'in_refund')</attribute>
|
||||||
|
<attribute name="required">need_vehicle and parent.move_type in ('in_invoice', 'in_refund')</attribute>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import models
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
{
|
||||||
|
'name': 'ODEX Account: Inter-Organization Compliance',
|
||||||
|
'version': '1.1',
|
||||||
|
'summary': 'Advanced synchronization of financial documents across internal entities.',
|
||||||
|
'category': 'Odex30-Accounting/Odex30-Accounting',
|
||||||
|
'author': "Expert Co. Ltd.",
|
||||||
|
'website': "http://www.exp-sa.com",
|
||||||
|
'description': """
|
||||||
|
This system facilitates the seamless synchronization of financial documents between
|
||||||
|
different organizational entities. It ensures automated document flow for internal operations.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Automated creation of matching Sales/Purchase orders across entities.
|
||||||
|
- Synchronization of invoices and credit notes.
|
||||||
|
- Unified compliance across the organizational structure.
|
||||||
|
""",
|
||||||
|
'depends': [
|
||||||
|
'account',
|
||||||
|
],
|
||||||
|
'data': [
|
||||||
|
'views/res_company_views.xml',
|
||||||
|
'views/res_config_settings_views.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
# Translation of ODEX Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * odex30_account_inter_company_rules
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: ODEX 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:26+0000\n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/odex30_account_inter_company_rules/models/account_move.py:0
|
||||||
|
msgid "%(company)s Invoice: %(entry)s"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model,name:odex30_account_inter_company_rules.model_account_move_send
|
||||||
|
msgid "Account Move Send"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_account_bank_statement_line__auto_generated
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_account_move__auto_generated
|
||||||
|
msgid "Auto Generated Document"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/odex30_account_inter_company_rules/models/account_move.py:0
|
||||||
|
msgid "Automatically generated from %(origin)s of company %(company)s."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_company__intercompany_document_state
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_config_settings__intercompany_document_state
|
||||||
|
msgid "Automation"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model,name:odex30_account_inter_company_rules.model_res_company
|
||||||
|
msgid "Companies"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model,name:odex30_account_inter_company_rules.model_res_config_settings
|
||||||
|
msgid "Config Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_inter_company_rules.selection__res_company__intercompany_document_state__posted
|
||||||
|
msgid "Create and validate"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_company__intercompany_user_id
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_config_settings__intercompany_user_id
|
||||||
|
msgid "Create as"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_inter_company_rules.selection__res_company__intercompany_document_state__draft
|
||||||
|
msgid "Create in draft"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_company__intercompany_generate_bills_refund
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_config_settings__intercompany_generate_bills_refund
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_inter_company_rules.res_config_settings_view_form
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_inter_company_rules.view_company_inter_change_inherit_form
|
||||||
|
msgid "Generate Bills and Refunds"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_inter_company_rules.view_company_inter_change_inherit_form
|
||||||
|
msgid "Inter-Company Transactions"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model,name:odex30_account_inter_company_rules.model_account_move
|
||||||
|
msgid "Journal Entry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model,name:odex30_account_inter_company_rules.model_account_move_line
|
||||||
|
msgid "Journal Item"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_company__intercompany_purchase_journal_id
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_config_settings__intercompany_purchase_journal_id
|
||||||
|
msgid "Purchase Journal"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields,help:odex30_account_inter_company_rules.field_res_company__intercompany_user_id
|
||||||
|
#: model:ir.model.fields,help:odex30_account_inter_company_rules.field_res_config_settings__intercompany_user_id
|
||||||
|
msgid ""
|
||||||
|
"Responsible user for creation of documents triggered by intercompany rules."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_account_bank_statement_line__auto_invoice_id
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_account_move__auto_invoice_id
|
||||||
|
msgid "Source Invoice"
|
||||||
|
msgstr ""
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
# Translation of ODEX Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * odex30_account_inter_company_rules
|
||||||
|
#
|
||||||
|
# Translators:
|
||||||
|
# Wil ODEX, 2024
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: ODEX 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 ODEX, 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_inter_company_rules
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/odex30_account_inter_company_rules/models/account_move.py:0
|
||||||
|
msgid "%(company)s Invoice: %(entry)s"
|
||||||
|
msgstr "%(company)s الفاتورة: %(entry)s "
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model,name:odex30_account_inter_company_rules.model_account_move_send
|
||||||
|
msgid "Account Move Send"
|
||||||
|
msgstr "إرسال حركة الحساب "
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_account_bank_statement_line__auto_generated
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_account_move__auto_generated
|
||||||
|
msgid "Auto Generated Document"
|
||||||
|
msgstr "المستند المنشأ تلقائيًا"
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/odex30_account_inter_company_rules/models/account_move.py:0
|
||||||
|
msgid "Automatically generated from %(origin)s of company %(company)s."
|
||||||
|
msgstr "مُنشأ تلقائياً من %(origin)s للشركة %(company)s. "
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_company__intercompany_document_state
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_config_settings__intercompany_document_state
|
||||||
|
msgid "Automation"
|
||||||
|
msgstr "الأتمتة "
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model,name:odex30_account_inter_company_rules.model_res_company
|
||||||
|
msgid "Companies"
|
||||||
|
msgstr "الشركات"
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model,name:odex30_account_inter_company_rules.model_res_config_settings
|
||||||
|
msgid "Config Settings"
|
||||||
|
msgstr "تهيئة الإعدادات "
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_inter_company_rules.selection__res_company__intercompany_document_state__posted
|
||||||
|
msgid "Create and validate"
|
||||||
|
msgstr "إنشاء وتصديق "
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_company__intercompany_user_id
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_config_settings__intercompany_user_id
|
||||||
|
msgid "Create as"
|
||||||
|
msgstr "إنشاء كـ"
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_inter_company_rules.selection__res_company__intercompany_document_state__draft
|
||||||
|
msgid "Create in draft"
|
||||||
|
msgstr "إنشاء في مسودة "
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_company__intercompany_generate_bills_refund
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_config_settings__intercompany_generate_bills_refund
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_inter_company_rules.res_config_settings_view_form
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_inter_company_rules.view_company_inter_change_inherit_form
|
||||||
|
msgid "Generate Bills and Refunds"
|
||||||
|
msgstr "إنشاء الفواتير ورد الأموال "
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_inter_company_rules.view_company_inter_change_inherit_form
|
||||||
|
msgid "Inter-Company Transactions"
|
||||||
|
msgstr "المعاملات بين الشركات "
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model,name:odex30_account_inter_company_rules.model_account_move
|
||||||
|
msgid "Journal Entry"
|
||||||
|
msgstr "قيد اليومية"
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model,name:odex30_account_inter_company_rules.model_account_move_line
|
||||||
|
msgid "Journal Item"
|
||||||
|
msgstr "عنصر اليومية"
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_company__intercompany_purchase_journal_id
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_config_settings__intercompany_purchase_journal_id
|
||||||
|
msgid "Purchase Journal"
|
||||||
|
msgstr "دفتر يومية المشتريات "
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields,help:odex30_account_inter_company_rules.field_res_company__intercompany_user_id
|
||||||
|
#: model:ir.model.fields,help:odex30_account_inter_company_rules.field_res_config_settings__intercompany_user_id
|
||||||
|
msgid ""
|
||||||
|
"Responsible user for creation of documents triggered by intercompany rules."
|
||||||
|
msgstr ""
|
||||||
|
"المستخدم المسؤول عن إنشاء مستندات تُفعل من خلال قواعد ما بين الشركات. "
|
||||||
|
|
||||||
|
#. module: odex30_account_inter_company_rules
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_account_bank_statement_line__auto_invoice_id
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_account_move__auto_invoice_id
|
||||||
|
msgid "Source Invoice"
|
||||||
|
msgstr "الفاتورة المصدر"
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
from . import account_move
|
||||||
|
from . import account_move_send
|
||||||
|
from . import res_config_settings
|
||||||
|
from . import res_company
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import fields, models, _
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMove(models.Model):
|
||||||
|
_inherit = 'account.move'
|
||||||
|
|
||||||
|
auto_generated = fields.Boolean(string='Auto Generated Document', copy=False, default=False)
|
||||||
|
auto_invoice_id = fields.Many2one('account.move', string='Source Invoice', readonly=True, copy=False, index='btree_not_null')
|
||||||
|
|
||||||
|
def _post(self, soft=True):
|
||||||
|
# OVERRIDE to generate cross invoice based on company rules.
|
||||||
|
invoices_map = {}
|
||||||
|
posted = super()._post(soft)
|
||||||
|
for invoice in posted.filtered(lambda move: move.is_sale_document()):
|
||||||
|
company_sudo = self.env['res.company'].sudo()._find_company_from_partner(invoice.partner_id.id)
|
||||||
|
if company_sudo and company_sudo.intercompany_generate_bills_refund and not invoice.auto_generated:
|
||||||
|
invoices_map.setdefault(company_sudo, self.env['account.move'])
|
||||||
|
invoices_map[company_sudo] += invoice
|
||||||
|
for company_sudo, invoices in invoices_map.items():
|
||||||
|
context = dict(self.env.context, default_company_id=company_sudo.id)
|
||||||
|
context.pop('default_journal_id', None)
|
||||||
|
invoices.with_user(company_sudo.intercompany_user_id.id).with_context(context).with_company(company_sudo.id)._inter_company_create_invoices()
|
||||||
|
return posted
|
||||||
|
|
||||||
|
def _inter_company_create_invoices(self):
|
||||||
|
''' Create cross company invoices.
|
||||||
|
:return: The newly created invoices.
|
||||||
|
'''
|
||||||
|
|
||||||
|
# Prepare invoice values.
|
||||||
|
invoices_vals_per_type = {}
|
||||||
|
inverse_types = {
|
||||||
|
'out_invoice': 'in_invoice',
|
||||||
|
'out_refund': 'in_refund',
|
||||||
|
}
|
||||||
|
for inv in self:
|
||||||
|
invoice_vals = inv._inter_company_prepare_invoice_data(inverse_types[inv.move_type])
|
||||||
|
invoice_vals['invoice_line_ids'] = []
|
||||||
|
for line in inv.invoice_line_ids:
|
||||||
|
invoice_vals['invoice_line_ids'].append((0, 0, line._inter_company_prepare_invoice_line_data()))
|
||||||
|
|
||||||
|
inv_new = inv.with_context(default_move_type=invoice_vals['move_type']).new(invoice_vals)
|
||||||
|
for line in inv_new.invoice_line_ids.filtered(lambda l: l.display_type not in ('line_note', 'line_section')):
|
||||||
|
# We need to adapt the taxes following the fiscal position, but we must keep the
|
||||||
|
# price unit.
|
||||||
|
price_unit = line.price_unit
|
||||||
|
line.tax_ids = line._get_computed_taxes()
|
||||||
|
line.price_unit = price_unit
|
||||||
|
|
||||||
|
invoice_vals = inv_new._convert_to_write(inv_new._cache)
|
||||||
|
invoice_vals.pop('line_ids', None)
|
||||||
|
invoice_vals['origin_invoice'] = inv
|
||||||
|
|
||||||
|
invoices_vals_per_type.setdefault(invoice_vals['move_type'], [])
|
||||||
|
invoices_vals_per_type[invoice_vals['move_type']].append(invoice_vals)
|
||||||
|
|
||||||
|
# Create invoices.
|
||||||
|
moves = self.env['account.move']
|
||||||
|
for invoice_type, invoices_vals in invoices_vals_per_type.items():
|
||||||
|
for invoice in invoices_vals:
|
||||||
|
origin_invoice = invoice['origin_invoice']
|
||||||
|
invoice.pop('origin_invoice')
|
||||||
|
msg = _("Automatically generated from %(origin)s of company %(company)s.", origin=origin_invoice.name, company=origin_invoice.company_id.name)
|
||||||
|
am = self.with_context(default_type=invoice_type).create(invoice)
|
||||||
|
am.message_post(body=msg)
|
||||||
|
if self.env.company.intercompany_document_state == "posted":
|
||||||
|
am._post(soft=True)
|
||||||
|
moves += am
|
||||||
|
return moves
|
||||||
|
|
||||||
|
def _inter_company_prepare_invoice_data(self, invoice_type):
|
||||||
|
r''' Get values to create the invoice.
|
||||||
|
/!\ Doesn't care about lines, see '_inter_company_prepare_invoice_line_data'.
|
||||||
|
:return: Python dictionary of values.
|
||||||
|
'''
|
||||||
|
self.ensure_one()
|
||||||
|
# We need the fiscal position in the company (already in context) we are creating the
|
||||||
|
# invoice, not the fiscal position of the current invoice (self.company)
|
||||||
|
delivery_partner_id = self.company_id.partner_id.address_get(['delivery'])['delivery']
|
||||||
|
delivery_partner = self.env['res.partner'].browse(delivery_partner_id)
|
||||||
|
fiscal_position_id = self.env['account.fiscal.position']._get_fiscal_position(
|
||||||
|
self.company_id.partner_id, delivery=delivery_partner
|
||||||
|
)
|
||||||
|
invoice_vals = {
|
||||||
|
'move_type': invoice_type,
|
||||||
|
'ref': self.name,
|
||||||
|
'partner_id': self.company_id.partner_id.id,
|
||||||
|
'currency_id': self.currency_id.id,
|
||||||
|
'auto_generated': True,
|
||||||
|
'auto_invoice_id': self.id,
|
||||||
|
'company_id': self.env.company.id,
|
||||||
|
'invoice_date': self.invoice_date,
|
||||||
|
'invoice_date_due': self.invoice_date_due,
|
||||||
|
'payment_reference': self.payment_reference,
|
||||||
|
'invoice_origin': _('%(company)s Invoice: %(entry)s', company=self.company_id.name, entry=self.name),
|
||||||
|
'fiscal_position_id': fiscal_position_id,
|
||||||
|
'journal_id': self.env.company.intercompany_purchase_journal_id.id,
|
||||||
|
'invoice_payment_term_id': self.invoice_payment_term_id.id if self.invoice_payment_term_id and not self.invoice_payment_term_id.company_id else False,
|
||||||
|
}
|
||||||
|
|
||||||
|
return invoice_vals
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMoveLine(models.Model):
|
||||||
|
_inherit = 'account.move.line'
|
||||||
|
|
||||||
|
def _inter_company_prepare_invoice_line_data(self):
|
||||||
|
''' Get values to create the invoice line.
|
||||||
|
We prioritize the analytic distribution in the following order:
|
||||||
|
- Default Analytic Distribution model specific to Company B
|
||||||
|
- Analytic Distribution set for the line in Company A's document if available to Company B
|
||||||
|
:return: Python dictionary of values.
|
||||||
|
'''
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
vals = {
|
||||||
|
'display_type': self.display_type,
|
||||||
|
'sequence': self.sequence,
|
||||||
|
'name': self.name,
|
||||||
|
'quantity': self.quantity,
|
||||||
|
'discount': self.discount,
|
||||||
|
'price_unit': self.price_unit,
|
||||||
|
}
|
||||||
|
if self.product_id.company_id:
|
||||||
|
vals['name'] = self.product_id.name
|
||||||
|
else:
|
||||||
|
vals.update({
|
||||||
|
'product_id': self.product_id.id,
|
||||||
|
'product_uom_id': self.product_uom_id.id
|
||||||
|
})
|
||||||
|
|
||||||
|
company_b = self.env['res.company']._find_company_from_partner(self.move_id.partner_id.id)
|
||||||
|
company_a_partner = self.company_id.partner_id
|
||||||
|
company_b_default_distribution = self.env['account.analytic.distribution.model']._get_distribution({
|
||||||
|
"product_id": self.product_id.id,
|
||||||
|
"product_categ_id": self.product_id.categ_id.id,
|
||||||
|
"partner_id": company_a_partner.id,
|
||||||
|
"partner_category_id": company_a_partner.category_id.ids,
|
||||||
|
"account_prefix": self.account_id.code,
|
||||||
|
"company_id": company_b.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
analytic_distribution = {}
|
||||||
|
if self.analytic_distribution:
|
||||||
|
accounts_with_company = self.distribution_analytic_account_ids.filtered('company_id')
|
||||||
|
|
||||||
|
for key, val in self.analytic_distribution.items():
|
||||||
|
is_company_account = False
|
||||||
|
for account_id in key.split(','):
|
||||||
|
if int(account_id) in accounts_with_company.ids:
|
||||||
|
is_company_account = True
|
||||||
|
break
|
||||||
|
if not is_company_account:
|
||||||
|
analytic_distribution[key] = val
|
||||||
|
|
||||||
|
if company_b_default_distribution or analytic_distribution:
|
||||||
|
vals['analytic_distribution'] = dict(company_b_default_distribution, **analytic_distribution)
|
||||||
|
|
||||||
|
return vals
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
from odoo import models
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMoveSend(models.AbstractModel):
|
||||||
|
_inherit = 'account.move.send'
|
||||||
|
|
||||||
|
def _generate_and_send_invoices(self, moves, from_cron=False, allow_raising=True, allow_fallback_pdf=False, **custom_settings):
|
||||||
|
# EXTENDS 'account' - to create the pdf attachment
|
||||||
|
# in the matching inter-company move
|
||||||
|
res = super()._generate_and_send_invoices(moves, from_cron=from_cron, allow_raising=allow_raising, allow_fallback_pdf=allow_fallback_pdf, **custom_settings)
|
||||||
|
|
||||||
|
partner_companies = self.env['res.company'].sudo().search([]).partner_id.ids
|
||||||
|
|
||||||
|
moves_with_attachments = moves.filtered(
|
||||||
|
lambda move: bool(move.message_main_attachment_id)
|
||||||
|
and move.is_sale_document(include_receipts=True)
|
||||||
|
and move.commercial_partner_id.id in partner_companies
|
||||||
|
)
|
||||||
|
|
||||||
|
ico_moves = self.env['account.move'].sudo().search([
|
||||||
|
('move_type', 'in', self.env['account.move'].get_purchase_types(include_receipts=True)),
|
||||||
|
('auto_generated', '=', True),
|
||||||
|
('auto_invoice_id', 'in', moves_with_attachments.ids)
|
||||||
|
])
|
||||||
|
|
||||||
|
for ico_move in ico_moves:
|
||||||
|
original_move = ico_move.auto_invoice_id
|
||||||
|
move_attachment = original_move.message_main_attachment_id
|
||||||
|
if not move_attachment: # shouldn't happen but just in case
|
||||||
|
continue
|
||||||
|
|
||||||
|
ico_move.message_main_attachment_id = self.env['ir.attachment']\
|
||||||
|
.with_user(ico_move.company_id.intercompany_user_id.id).with_company(ico_move.company_id.id).create({
|
||||||
|
'name': f'{original_move.name}.pdf',
|
||||||
|
'type': 'binary',
|
||||||
|
'mimetype': 'application/pdf',
|
||||||
|
'raw': move_attachment.raw,
|
||||||
|
'res_model': 'account.move',
|
||||||
|
'res_id': ico_move.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
from odoo import api, fields, models, SUPERUSER_ID
|
||||||
|
|
||||||
|
|
||||||
|
class res_company(models.Model):
|
||||||
|
_inherit = 'res.company'
|
||||||
|
|
||||||
|
intercompany_generate_bills_refund = fields.Boolean(string="Generate Bills and Refunds")
|
||||||
|
intercompany_document_state = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
('draft', "Create in draft"),
|
||||||
|
('posted', "Create and validate"),
|
||||||
|
],
|
||||||
|
string="Automation",
|
||||||
|
default='draft',
|
||||||
|
)
|
||||||
|
intercompany_purchase_journal_id = fields.Many2one(
|
||||||
|
comodel_name='account.journal',
|
||||||
|
string="Purchase Journal",
|
||||||
|
domain='[("type", "=", "purchase")]',
|
||||||
|
compute='_compute_intercompany_purchase_journal_id', store=True, readonly=False,
|
||||||
|
)
|
||||||
|
intercompany_user_id = fields.Many2one(
|
||||||
|
comodel_name='res.users',
|
||||||
|
string="Create as",
|
||||||
|
default=SUPERUSER_ID,
|
||||||
|
domain=['|', ['active', '=', True], ['id', '=', SUPERUSER_ID]],
|
||||||
|
help="Responsible user for creation of documents triggered by intercompany rules.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _find_company_from_partner(self, partner_id):
|
||||||
|
if not partner_id:
|
||||||
|
return False
|
||||||
|
company = self.sudo().search([('partner_id', 'parent_of', partner_id)], limit=1)
|
||||||
|
return company or False
|
||||||
|
|
||||||
|
@api.depends('chart_template')
|
||||||
|
def _compute_intercompany_purchase_journal_id(self):
|
||||||
|
journals_by_company = dict(self.env['account.journal']._read_group(domain=[('type', '=', 'purchase')], groupby=['company_id'], aggregates=['id:recordset']))
|
||||||
|
|
||||||
|
for company in self:
|
||||||
|
if not company.intercompany_purchase_journal_id:
|
||||||
|
company.intercompany_purchase_journal_id = journals_by_company.get(company, [False])[0]
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResConfigSettings(models.TransientModel):
|
||||||
|
_inherit = 'res.config.settings'
|
||||||
|
|
||||||
|
intercompany_generate_bills_refund = fields.Boolean(related='company_id.intercompany_generate_bills_refund', readonly=False)
|
||||||
|
intercompany_user_id = fields.Many2one(related='company_id.intercompany_user_id', readonly=False, required=True)
|
||||||
|
intercompany_purchase_journal_id = fields.Many2one(related='company_id.intercompany_purchase_journal_id', readonly=False)
|
||||||
|
intercompany_document_state = fields.Selection(related='company_id.intercompany_document_state', readonly=False)
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import test_inter_company_invoice
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
from odoo.tests import common
|
||||||
|
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
|
||||||
|
|
||||||
|
|
||||||
|
class TestInterCompanyRulesCommon(AccountTestInvoicingCommon):
|
||||||
|
"""This test needs sale_purchase_inter_company_rules to run."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super(TestInterCompanyRulesCommon, cls).setUpClass()
|
||||||
|
|
||||||
|
cls.company_a = cls.company_data['company']
|
||||||
|
cls.company_b = cls.setup_other_company()['company']
|
||||||
|
|
||||||
|
# Create a new product named product_consultant
|
||||||
|
cls.product_consultant = cls.env['product.product'].create({
|
||||||
|
'name': 'Service',
|
||||||
|
'uom_id': cls.env.ref('uom.product_uom_hour').id,
|
||||||
|
'uom_po_id': cls.env.ref('uom.product_uom_hour').id,
|
||||||
|
'categ_id': cls.env.ref('product.product_category_all').id,
|
||||||
|
'type': 'service',
|
||||||
|
'taxes_id': [(6, 0, (cls.company_a.account_sale_tax_id + cls.company_b.account_sale_tax_id).ids)],
|
||||||
|
'supplier_taxes_id': [(6, 0, (cls.company_a.account_purchase_tax_id + cls.company_b.account_purchase_tax_id).ids)],
|
||||||
|
'company_id': False
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create user of company_a
|
||||||
|
cls.res_users_company_a = cls.env['res.users'].create({
|
||||||
|
'name': 'User A',
|
||||||
|
'login': 'usera',
|
||||||
|
'email': 'usera@yourcompany.com',
|
||||||
|
'company_id': cls.company_a.id,
|
||||||
|
'company_ids': [(6, 0, [cls.company_a.id])],
|
||||||
|
'groups_id': [(6, 0, [
|
||||||
|
cls.env.ref('account.group_account_user').id,
|
||||||
|
cls.env.ref('account.group_account_manager').id
|
||||||
|
])]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create user of company_b
|
||||||
|
cls.res_users_company_b = cls.env['res.users'].create({
|
||||||
|
'name': 'User B',
|
||||||
|
'login': 'userb',
|
||||||
|
'email': 'userb@yourcompany.com',
|
||||||
|
'company_id': cls.company_b.id,
|
||||||
|
'company_ids': [(6, 0, [cls.company_b.id])],
|
||||||
|
'groups_id': [(6, 0, [
|
||||||
|
cls.env.ref('account.group_account_user').id
|
||||||
|
])]
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
from odoo import Command, tests
|
||||||
|
from .common import TestInterCompanyRulesCommon
|
||||||
|
|
||||||
|
|
||||||
|
@tests.tagged('post_install', '-at_install')
|
||||||
|
class TestInterCompanyInvoice(TestInterCompanyRulesCommon):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super(TestInterCompanyInvoice, cls).setUpClass()
|
||||||
|
# Enable auto generate invoice in company.
|
||||||
|
(cls.company_a + cls.company_b).write({
|
||||||
|
'intercompany_generate_bills_refund': True,
|
||||||
|
})
|
||||||
|
# Configure Chart of Account for company_b.
|
||||||
|
cls.env.user.company_id = cls.company_b
|
||||||
|
cls.env['account.chart.template'].try_loading('generic_coa', cls.company_b, install_demo=False)
|
||||||
|
# Configure Chart of Account for company_a.
|
||||||
|
cls.env.user.company_id = cls.company_a
|
||||||
|
cls.env['account.chart.template'].try_loading('generic_coa', cls.company_a, install_demo=False)
|
||||||
|
|
||||||
|
def _configure_analytic(self, product=None, company=None, partner=None):
|
||||||
|
"""
|
||||||
|
Configure Analytic Distribution Model for company_a based on Product A
|
||||||
|
return: analytic account
|
||||||
|
"""
|
||||||
|
display_name = "Inter Company"
|
||||||
|
if company:
|
||||||
|
self.env.user.company_id = company
|
||||||
|
display_name = company.display_name
|
||||||
|
analytic_plan = self.env['account.analytic.plan'].create({'name': f'Analytic Plan {display_name}'})
|
||||||
|
analytic_account = self.env['account.analytic.account'].create({
|
||||||
|
'name': f'Account {display_name}',
|
||||||
|
'company_id': company and company.id,
|
||||||
|
'plan_id': analytic_plan.id,
|
||||||
|
})
|
||||||
|
self.env['account.analytic.distribution.model'].create({
|
||||||
|
'analytic_distribution': {analytic_account.id: 100},
|
||||||
|
'product_id': product and product.id,
|
||||||
|
'company_id': company and company.id,
|
||||||
|
'partner_id': partner and partner.id,
|
||||||
|
})
|
||||||
|
return analytic_account
|
||||||
|
|
||||||
|
def _create_post_invoice(self, product_id, analytic_distribution=None):
|
||||||
|
"""Create a Company A invoice with Company B as the customer and post it"""
|
||||||
|
invoice_line_vals = {
|
||||||
|
'product_id': product_id,
|
||||||
|
'price_unit': 100.0,
|
||||||
|
'quantity': 1.0,
|
||||||
|
}
|
||||||
|
if analytic_distribution:
|
||||||
|
invoice_line_vals['analytic_distribution'] = analytic_distribution
|
||||||
|
|
||||||
|
customer_invoice = self.env['account.move'].with_user(self.res_users_company_a).create({
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'partner_id': self.company_b.partner_id.id,
|
||||||
|
'invoice_line_ids': [(0, 0, invoice_line_vals)]
|
||||||
|
})
|
||||||
|
customer_invoice.with_user(self.res_users_company_a).action_post()
|
||||||
|
|
||||||
|
def test_00_inter_company_invoice_flow(self):
|
||||||
|
""" Test inter company invoice flow """
|
||||||
|
|
||||||
|
self.env.ref('base.EUR').active = True
|
||||||
|
|
||||||
|
# Create customer invoice for company A. (No need to call onchange as all the needed values are specified)
|
||||||
|
self.res_users_company_a.company_ids = [(4, self.company_b.id)]
|
||||||
|
customer_invoice = self.env['account.move'].with_user(self.res_users_company_a).create({
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'partner_id': self.company_b.partner_id.id,
|
||||||
|
'currency_id': self.env.ref('base.EUR').id,
|
||||||
|
'invoice_line_ids': [(0, 0, {
|
||||||
|
'product_id': self.product_consultant.id,
|
||||||
|
'price_unit': 450.0,
|
||||||
|
'quantity': 1.0,
|
||||||
|
'name': 'test'
|
||||||
|
})]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check account invoice state should be draft.
|
||||||
|
self.assertEqual(customer_invoice.state, 'draft', 'Initially customer invoice should be in the "Draft" state')
|
||||||
|
|
||||||
|
# Validate invoice
|
||||||
|
customer_invoice.with_user(self.res_users_company_a).action_post()
|
||||||
|
|
||||||
|
# Check Invoice status should be open after validate.
|
||||||
|
self.assertEqual(customer_invoice.state, 'posted', 'Invoice should be in Open state.')
|
||||||
|
|
||||||
|
# I check that the vendor bill is created with proper data.
|
||||||
|
supplier_invoice = self.env['account.move'].with_user(self.res_users_company_b).search([('move_type', '=', 'in_invoice')], limit=1)
|
||||||
|
|
||||||
|
self.assertTrue(supplier_invoice.invoice_line_ids[0].quantity == 1, "Quantity in invoice line is incorrect.")
|
||||||
|
self.assertTrue(supplier_invoice.invoice_line_ids[0].product_id.id == self.product_consultant.id, "Product in line is incorrect.")
|
||||||
|
self.assertTrue(supplier_invoice.invoice_line_ids[0].price_unit == 450, "Unit Price in invoice line is incorrect.")
|
||||||
|
self.assertTrue(supplier_invoice.invoice_line_ids[0].account_id.company_ids == self.company_b, "Applied account in created invoice line is not relevant to company.")
|
||||||
|
self.assertTrue(supplier_invoice.state == "draft", "invoice should be in draft state.")
|
||||||
|
self.assertEqual(supplier_invoice.amount_total, 517.5, "Total amount is incorrect.")
|
||||||
|
self.assertTrue(supplier_invoice.company_id.id == self.company_b.id, "Applied company in created invoice is incorrect.")
|
||||||
|
|
||||||
|
def test_default_analytic_distribution_company_b(self):
|
||||||
|
"""
|
||||||
|
[Analytic Distribution Model is set for Company B + Inter Company Analytic Account is set]
|
||||||
|
- With Company A, create an Invoice for Company B with product A set with an analytic distribution model available for Company B
|
||||||
|
-> The Analytic Distribution set on the Supplier Invoice Line should be the same as defined in the analytic distribution model set by default for Company B
|
||||||
|
and the one manually set when the analytic account is also available for Company B
|
||||||
|
"""
|
||||||
|
analytic_account_company_b = self._configure_analytic(company=self.company_b, product=self.product_a)
|
||||||
|
inter_company_analytic_account = self._configure_analytic(product=self.product_b)
|
||||||
|
|
||||||
|
self._create_post_invoice(product_id=self.product_a.id, analytic_distribution={inter_company_analytic_account.id: 100})
|
||||||
|
supplier_invoice = self.env['account.move'].with_user(self.res_users_company_b).search([('move_type', '=', 'in_invoice')], limit=1)
|
||||||
|
|
||||||
|
self.assertEqual(supplier_invoice.invoice_line_ids.analytic_distribution, {str(analytic_account_company_b.id): 100, str(inter_company_analytic_account.id): 100})
|
||||||
|
|
||||||
|
def test_no_default_analytic_distribution_company_b(self):
|
||||||
|
"""
|
||||||
|
[Analytic Distribution Model is not set for Company B + Inter Company Analytic Account is set]
|
||||||
|
- With Company A, create an Invoice for Company B with a line set with an analytic distribution model available for Company B
|
||||||
|
-> The analytic distribution set on the supplier invoice line should be the same as defined in the customer invoice line created in Company A
|
||||||
|
as the analytic account is available for Company B and there are no default analytic distribution model set for Company B
|
||||||
|
"""
|
||||||
|
inter_company_analytic_account = self._configure_analytic(product=self.product_b)
|
||||||
|
|
||||||
|
self._create_post_invoice(product_id=self.product_a.id, analytic_distribution={inter_company_analytic_account.id: 100})
|
||||||
|
supplier_invoice = self.env['account.move'].with_user(self.res_users_company_b).search([('move_type', '=', 'in_invoice')], limit=1)
|
||||||
|
|
||||||
|
self.assertEqual(supplier_invoice.invoice_line_ids.analytic_distribution, {str(inter_company_analytic_account.id): 100})
|
||||||
|
|
||||||
|
def test_multi_analytic_account_distribution_company_b(self):
|
||||||
|
"""
|
||||||
|
Test that the analytic distribution is set properly when multiple analytic accounts (with or without a company) are set on the invoice line
|
||||||
|
"""
|
||||||
|
analytic_account_company_a = self._configure_analytic(company=self.company_a, product=self.product_a)
|
||||||
|
inter_company_analytic_account = self._configure_analytic(product=self.product_a)
|
||||||
|
|
||||||
|
self._create_post_invoice(product_id=self.product_a.id, analytic_distribution={
|
||||||
|
analytic_account_company_a.id: 50,
|
||||||
|
inter_company_analytic_account.id: 50,
|
||||||
|
f"{analytic_account_company_a.id},{inter_company_analytic_account.id}": 100
|
||||||
|
})
|
||||||
|
supplier_invoice = self.env['account.move'].with_user(self.res_users_company_b).search([('move_type', '=', 'in_invoice')], limit=1)
|
||||||
|
|
||||||
|
self.assertEqual(supplier_invoice.invoice_line_ids.analytic_distribution, {str(inter_company_analytic_account.id): 50})
|
||||||
|
|
||||||
|
def test_default_analytic_distribution_company_a(self):
|
||||||
|
"""
|
||||||
|
[Analytic Distribution Model is set for Company A]
|
||||||
|
- With Company A, create an Invoice for Company B with a line set with an analytic distribution model not available for Company B
|
||||||
|
-> There should be no analytic distribution set on the supplier invoice line as there is no analytic distribution model available for Company B
|
||||||
|
and the analytic account is not available for Company B
|
||||||
|
"""
|
||||||
|
analytic_account_company_a = self._configure_analytic(company=self.company_a, product=self.product_a)
|
||||||
|
|
||||||
|
self._create_post_invoice(product_id=self.product_a.id, analytic_distribution={analytic_account_company_a.id: 100})
|
||||||
|
supplier_invoice = self.env['account.move'].with_user(self.res_users_company_b).search([('move_type', '=', 'in_invoice')], limit=1)
|
||||||
|
|
||||||
|
self.assertFalse(supplier_invoice.invoice_line_ids.analytic_distribution, "Analytic distribution should not be set on the invoice line.")
|
||||||
|
|
||||||
|
def test_inter_company_invoice_flow_sub_companies(self):
|
||||||
|
"""
|
||||||
|
Test that the flow with inter company invoice is also working properly with sub companies
|
||||||
|
"""
|
||||||
|
# Create branches for company a
|
||||||
|
self.company_a.write({'child_ids': [
|
||||||
|
Command.create({'name': 'Branch 1 of company a'}),
|
||||||
|
Command.create({'name': 'Branch 2 of company a'}),
|
||||||
|
]})
|
||||||
|
self.cr.precommit.run() # load the COA
|
||||||
|
|
||||||
|
branch_1, branch_2 = self.company_a.child_ids
|
||||||
|
(branch_1 + branch_2).write({
|
||||||
|
'intercompany_generate_bills_refund': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
# It's required to have an intercompany_journal_id set to be able to do the generation
|
||||||
|
for branch in [branch_1, branch_2]:
|
||||||
|
branch.intercompany_purchase_journal_id = self.env['account.journal'].create({
|
||||||
|
'name': 'Vendor Bills - Test',
|
||||||
|
'code': 'TEXJ',
|
||||||
|
'type': 'purchase',
|
||||||
|
'company_id': branch.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Select the two branches
|
||||||
|
self.env.user.write({
|
||||||
|
'company_ids': [Command.set((branch_1 + branch_2).ids)],
|
||||||
|
'company_id': branch_1.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Invoice from Branch 1 to Branch 2
|
||||||
|
customer_invoice = self.env['account.move'].with_context(allowed_company_ids=branch_1.ids).create({
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'invoice_date': '2023-05-01',
|
||||||
|
'partner_id': branch_2.partner_id.id,
|
||||||
|
'invoice_line_ids': [Command.create({
|
||||||
|
'product_id': self.product_a.id,
|
||||||
|
'price_unit': 100.0,
|
||||||
|
'quantity': 1.0,
|
||||||
|
'tax_ids': False,
|
||||||
|
})]
|
||||||
|
})
|
||||||
|
|
||||||
|
customer_invoice.action_post()
|
||||||
|
bill = self.env['account.move'].search([('move_type', '=', 'in_invoice')], limit=1)
|
||||||
|
|
||||||
|
self.assertRecordValues(bill, [{
|
||||||
|
'partner_id': branch_1.partner_id.id,
|
||||||
|
'company_id': branch_2.id,
|
||||||
|
'payment_reference': customer_invoice.payment_reference,
|
||||||
|
}])
|
||||||
|
|
||||||
|
def test_inter_company_invoice_product_not_accessible(self):
|
||||||
|
"""
|
||||||
|
Whenever Company A invoices Company B with a Product A defined only for Company A
|
||||||
|
We don't set Product A (access error) but we define only the invoice line's label
|
||||||
|
with Product A's name
|
||||||
|
"""
|
||||||
|
self.product_a.company_id = self.company_a
|
||||||
|
self._create_post_invoice(self.product_a.id)
|
||||||
|
supplier_invoice = self.env['account.move'].with_user(self.res_users_company_b).search([('move_type', '=', 'in_invoice')], limit=1)
|
||||||
|
self.assertFalse(supplier_invoice.invoice_line_ids.product_id, "No product should be set")
|
||||||
|
self.assertEqual(supplier_invoice.invoice_line_ids.name, self.product_a.name)
|
||||||
|
|
||||||
|
def test_analytic_distribution_model_partner(self):
|
||||||
|
"""
|
||||||
|
If company B defines Company A as a partner in its distribution model, the distribution should be retrieved
|
||||||
|
"""
|
||||||
|
inter_company_analytic_account = self._configure_analytic(product=self.product_b)
|
||||||
|
analytic_account_company_b = self._configure_analytic(company=self.company_b, partner=self.company_a.partner_id)
|
||||||
|
self._create_post_invoice(product_id=self.product_a.id, analytic_distribution={inter_company_analytic_account.id: 100})
|
||||||
|
|
||||||
|
supplier_invoice = self.env['account.move'].with_user(self.res_users_company_b).search([('move_type', '=', 'in_invoice')], limit=1)
|
||||||
|
|
||||||
|
expected_distribution = {
|
||||||
|
str(inter_company_analytic_account.id): 100,
|
||||||
|
str(analytic_account_company_b.id): 100
|
||||||
|
}
|
||||||
|
self.assertEqual(supplier_invoice.invoice_line_ids.analytic_distribution, expected_distribution)
|
||||||
|
|
||||||
|
def test_inter_company_attachment_with_contact_as_partner(self):
|
||||||
|
"""
|
||||||
|
Test that when creating and printing an invoice in company A for an individual contact belonging to company B,
|
||||||
|
the corresponding bill in company B there is an attachment.
|
||||||
|
"""
|
||||||
|
company_partner = self.env['res.partner'].create({
|
||||||
|
'name': 'company partner',
|
||||||
|
'parent_id': self.company_b.partner_id.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
customer_invoice = self.env['account.move'].create({
|
||||||
|
'company_id': self.company_a.id,
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'invoice_date': '2023-05-01',
|
||||||
|
'partner_id': company_partner.id,
|
||||||
|
'invoice_line_ids': [Command.create({
|
||||||
|
'product_id': self.product_a.id,
|
||||||
|
'price_unit': 100.0,
|
||||||
|
'quantity': 1.0,
|
||||||
|
'tax_ids': False,
|
||||||
|
})]
|
||||||
|
})
|
||||||
|
|
||||||
|
customer_invoice.action_post()
|
||||||
|
|
||||||
|
self.env['account.move.send.wizard'].with_context(active_model='account.move', active_ids=customer_invoice.id)._generate_and_send_invoices(customer_invoice)
|
||||||
|
|
||||||
|
bill = self.env['account.move'].search([('move_type', '=', 'in_invoice'), ('company_id', '=', self.company_b.id)], limit=1)
|
||||||
|
self.assertTrue(bill.attachment_ids)
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record model="ir.ui.view" id="view_company_inter_change_inherit_form">
|
||||||
|
<field name="name">res.company.form.inherit</field>
|
||||||
|
<field name="inherit_id" ref="base.view_company_form"/>
|
||||||
|
<field name="model">res.company</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//notebook" position="inside">
|
||||||
|
<page string="Inter-Company Transactions" name="inter_company_transactions" groups="base.group_no_one">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<label string="Generate Bills and Refunds" for="intercompany_generate_bills_refund" class="text-nowrap col-lg-6 o_light_label"/>
|
||||||
|
<field name="intercompany_generate_bills_refund" nolabel="1"/>
|
||||||
|
<field name="intercompany_user_id" options="{'no_create_edit': True}"/>
|
||||||
|
<field name="intercompany_purchase_journal_id" options="{'no_create_edit': True}" invisible="not intercompany_generate_bills_refund" required="intercompany_generate_bills_refund"/>
|
||||||
|
<field name="intercompany_document_state" widget="radio"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.inherit.inter.company.rules</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//div[@id='inter_companies_rules']" position='replace'>
|
||||||
|
<div class="mt16">
|
||||||
|
<div class="content-group" name="module_odex30_account_inter_company_rules_company_id" >
|
||||||
|
<div class="row">
|
||||||
|
<field name="intercompany_generate_bills_refund" class="oe_inline o_light_label"/>
|
||||||
|
<label string="Generate Bills and Refunds" for="intercompany_generate_bills_refund" class="col-lg-6 o_light_label"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="content-group" name="module_inter_company_rules_set_so_po"
|
||||||
|
>
|
||||||
|
<div class="row ml16">
|
||||||
|
<label for="intercompany_user_id" class="col-4 col-lg-5 o_light_label"/>
|
||||||
|
<field name="intercompany_user_id" options="{'no_create_edit': True}"/>
|
||||||
|
|
||||||
|
<label for="intercompany_purchase_journal_id" class="col-4 col-lg-5 o_light_label" invisible="not intercompany_generate_bills_refund"/>
|
||||||
|
<field name="intercompany_purchase_journal_id" options="{'no_create_edit': True}" invisible="not intercompany_generate_bills_refund" required="intercompany_generate_bills_refund"/>
|
||||||
|
|
||||||
|
<label for="intercompany_document_state" class="col-4 col-lg-5 o_light_label"/>
|
||||||
|
<field name="intercompany_document_state" widget="radio"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import re
|
||||||
|
|
||||||
|
from . import models # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
def post_init_hook(env):
|
||||||
|
"""Replace total_overdue by total_overdue_followup in followup email templates."""
|
||||||
|
mail_templates = env['account_followup.followup.line'].search([]).mapped('mail_template_id')
|
||||||
|
for template in mail_templates:
|
||||||
|
template.body_html = re.sub(
|
||||||
|
r'''t-out=("([^"]+)|'([^']+))object\.total_overdue\b''',
|
||||||
|
r't-out=\1object.total_overdue_followup',
|
||||||
|
# unwrap Markup as it cause issue with python <3.12
|
||||||
|
str(template.body_html),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def uninstall_hook(env):
|
||||||
|
"""Restore total_overdue instead of total_overdue_followup in followup email templates."""
|
||||||
|
mail_templates = env['account_followup.followup.line'].search([]).mapped('mail_template_id')
|
||||||
|
for template in mail_templates:
|
||||||
|
template.body_html = re.sub(
|
||||||
|
r'''t-out=("([^"]+)|'([^']+))object\.total_overdue_followup\b''',
|
||||||
|
r't-out=\1object.total_overdue',
|
||||||
|
# unwrap Markup as it cause issue with python <3.12
|
||||||
|
str(template.body_html),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
'name': 'ODEX Account: Transaction Exception Control',
|
||||||
|
'summary': 'Advanced exclusion of specific transactions from automated follow-up cycles.',
|
||||||
|
'category': 'Odex30-Accounting/Odex30-Accounting',
|
||||||
|
'author': "Expert Co. Ltd.",
|
||||||
|
'website': "http://www.exp-sa.com",
|
||||||
|
'description': """
|
||||||
|
This module provides the ability to exclude specific ledger transactions from
|
||||||
|
the automated communication and follow-up workflows. It ensures precision in
|
||||||
|
managing customer and vendor relations.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Flag individual transactions for follow-up exclusion.
|
||||||
|
- Maintains clarity in automated debt collection reports.
|
||||||
|
- Flexible control over transaction-level visibility in follow-up cycles.
|
||||||
|
""",
|
||||||
|
'depends': ['odex30_account_followup'],
|
||||||
|
'data': [
|
||||||
|
'views/account_move_views.xml',
|
||||||
|
],
|
||||||
|
'auto_install': True,
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'odex30_account_no_followup/static/src/components/**/*',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'post_init_hook': 'post_init_hook',
|
||||||
|
'uninstall_hook': "uninstall_hook",
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
# Translation of ODEX Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * odex30_account_no_followup
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: ODEX 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-21 18:47+0000\n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#. module: odex30_account_no_followup
|
||||||
|
#: model:ir.model,name:odex30_account_no_followup.model_res_partner
|
||||||
|
msgid "Contact"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_no_followup
|
||||||
|
#: model:ir.model,name:odex30_account_no_followup.model_account_customer_statement_report_handler
|
||||||
|
msgid "Customer Statement Custom Handler"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_no_followup
|
||||||
|
#: model:ir.model.fields,help:odex30_account_no_followup.field_account_bank_statement_line__no_followup
|
||||||
|
#: model:ir.model.fields,help:odex30_account_no_followup.field_account_move__no_followup
|
||||||
|
msgid "Exclude this journal entry from follow-up reports."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_no_followup
|
||||||
|
#: model:ir.model.fields,help:odex30_account_no_followup.field_account_move_line__no_followup
|
||||||
|
msgid "Exclude this journal item from follow-up reports."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_no_followup
|
||||||
|
#: model:ir.model,name:odex30_account_no_followup.model_account_followup_report_handler
|
||||||
|
msgid "Follow-Up Report Custom Handler"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_no_followup
|
||||||
|
#: model:ir.model,name:odex30_account_no_followup.model_account_move
|
||||||
|
msgid "Journal Entry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_no_followup
|
||||||
|
#: model:ir.model,name:odex30_account_no_followup.model_account_move_line
|
||||||
|
msgid "Journal Item"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_no_followup
|
||||||
|
#. odoo-javascript
|
||||||
|
#: code:addons/odex30_account_no_followup/static/src/components/partner_ledger_followup/header.xml:0
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_no_followup.field_account_bank_statement_line__no_followup
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_no_followup.field_account_move__no_followup
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_no_followup.field_account_move_line__no_followup
|
||||||
|
msgid "No Follow-Up"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_no_followup
|
||||||
|
#: model:ir.model,name:odex30_account_no_followup.model_account_partner_ledger_report_handler
|
||||||
|
msgid "Partner Ledger Custom Handler"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_no_followup
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_no_followup.field_res_partner__total_overdue_followup
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_no_followup.field_res_users__total_overdue_followup
|
||||||
|
msgid "Total Overdue Followup"
|
||||||
|
msgstr ""
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
from . import account_customer_statement
|
||||||
|
from . import account_followup_report
|
||||||
|
from . import account_move_line
|
||||||
|
from . import account_move
|
||||||
|
from . import account_partner_ledger
|
||||||
|
from . import res_partner
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
from odoo import models
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerStatementCustomHandler(models.AbstractModel):
|
||||||
|
_inherit = 'account.customer.statement.report.handler'
|
||||||
|
|
||||||
|
def _get_custom_display_config(self):
|
||||||
|
display_config = super()._get_custom_display_config()
|
||||||
|
display_config['components']['AccountReportLine'] = 'odex30_account_no_followup.PartnerLedgerFollowupLine'
|
||||||
|
display_config['templates']['AccountReportHeader'] = 'odex30_account_no_followup.PartnerLedgerFollowupHeader'
|
||||||
|
|
||||||
|
return display_config
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
from odoo import models
|
||||||
|
|
||||||
|
|
||||||
|
class AccountFollowupCustomHandler(models.AbstractModel):
|
||||||
|
_inherit = 'account.followup.report.handler'
|
||||||
|
|
||||||
|
def _custom_options_initializer(self, report, options, previous_options):
|
||||||
|
super()._custom_options_initializer(report, options, previous_options)
|
||||||
|
|
||||||
|
if options['export_mode'] == 'print':
|
||||||
|
# When printing the report, we don't want to include `no_followup` lines.
|
||||||
|
options['forced_domain'] = options.get('forced_domain', []) + [('no_followup', '=', False)]
|
||||||
|
|
||||||
|
def _get_custom_display_config(self):
|
||||||
|
config = super()._get_custom_display_config()
|
||||||
|
config['components']['AccountReportLine'] = 'odex30_account_no_followup.PartnerLedgerFollowupLine'
|
||||||
|
config['templates']['AccountReportHeader'] = 'odex30_account_no_followup.PartnerLedgerFollowupHeader'
|
||||||
|
return config
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMove(models.Model):
|
||||||
|
_inherit = 'account.move'
|
||||||
|
|
||||||
|
no_followup = fields.Boolean(
|
||||||
|
string="No Follow-Up",
|
||||||
|
compute='_compute_no_followup',
|
||||||
|
inverse='_inverse_no_followup',
|
||||||
|
readonly=False,
|
||||||
|
help="Exclude this journal entry from follow-up reports."
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('line_ids.no_followup')
|
||||||
|
def _compute_no_followup(self):
|
||||||
|
for move in self:
|
||||||
|
if move.is_invoice():
|
||||||
|
move.no_followup = move.line_ids.filtered(
|
||||||
|
lambda line: line.account_type in ('asset_receivable', 'liability_payable'),
|
||||||
|
)[:1].no_followup
|
||||||
|
else:
|
||||||
|
move.no_followup = True
|
||||||
|
|
||||||
|
def _inverse_no_followup(self):
|
||||||
|
for move in self:
|
||||||
|
if move.is_invoice():
|
||||||
|
move.line_ids.filtered(
|
||||||
|
lambda line: line.account_type in ('asset_receivable', 'liability_payable'),
|
||||||
|
).no_followup = move.no_followup
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Part of ODEX. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class AccountMoveLine(models.Model):
|
||||||
|
_inherit = 'account.move.line'
|
||||||
|
|
||||||
|
no_followup = fields.Boolean(
|
||||||
|
string="No Follow-Up",
|
||||||
|
compute='_compute_no_followup',
|
||||||
|
inverse='_inverse_no_followup',
|
||||||
|
store=True,
|
||||||
|
readonly=False,
|
||||||
|
help="Exclude this journal item from follow-up reports.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('move_id.move_type')
|
||||||
|
def _compute_no_followup(self):
|
||||||
|
for aml in self:
|
||||||
|
aml.no_followup = aml.move_id.is_entry() and not aml.move_id.origin_payment_id
|
||||||
|
|
||||||
|
def _inverse_no_followup(self):
|
||||||
|
# If one line of an invoice gets excluded from or included in the follow up report, we want all
|
||||||
|
# payable/receivable lines of that invoice to do the same.
|
||||||
|
for aml in self:
|
||||||
|
move = aml.move_id
|
||||||
|
if move.is_invoice():
|
||||||
|
move.no_followup = aml.no_followup
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
from odoo import api, models
|
||||||
|
from odoo.tools import SQL
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerLedgerCustomHandler(models.AbstractModel):
|
||||||
|
_inherit = 'account.partner.ledger.report.handler'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def action_toggle_no_followup(self, line_id, all_line_ids):
|
||||||
|
"""Toggle the `no_followup` field on the journal item corresponding to the given `line_id`.
|
||||||
|
|
||||||
|
Toggling this field may result in other journal items of the same report having their field toggled as well.
|
||||||
|
This function will return all impacted lines, so the report can be updated dynamically.
|
||||||
|
|
||||||
|
:param line_id: The report line ID.
|
||||||
|
:param all_line_ids: A list containing all the report's line IDs.
|
||||||
|
:return: A dict containing:
|
||||||
|
- `updated_value`: the updated `no_followup` value (`True` or `False`)
|
||||||
|
- `updated_line_ids`: a list of the impacted report lines
|
||||||
|
"""
|
||||||
|
model, aml_id = self.env['account.report']._get_model_info_from_id(line_id)
|
||||||
|
if model != 'account.move.line':
|
||||||
|
return None
|
||||||
|
aml = self.env['account.move.line'].browse(aml_id)
|
||||||
|
aml.no_followup = not aml.no_followup
|
||||||
|
|
||||||
|
aml_id_to_line_id = {}
|
||||||
|
for cur_line_id in all_line_ids:
|
||||||
|
model, record_id = self.env['account.report']._get_model_info_from_id(cur_line_id)
|
||||||
|
if model == 'account.move.line':
|
||||||
|
aml_id_to_line_id[record_id] = cur_line_id
|
||||||
|
|
||||||
|
res = {'updated_value': aml.no_followup, 'updated_line_ids': [aml_id_to_line_id[aml.id]]}
|
||||||
|
move = aml.move_id
|
||||||
|
if move.is_invoice():
|
||||||
|
# For invoices, the `no_followup` toggle will impact all its receivable/payable lines.
|
||||||
|
res['updated_line_ids'] = move.line_ids.filtered(
|
||||||
|
lambda line: line.account_type in ('asset_receivable', 'liability_payable'),
|
||||||
|
).mapped(lambda line: aml_id_to_line_id[line.id])
|
||||||
|
return res
|
||||||
|
|
||||||
|
def _get_report_line_move_line(self, options, aml_query_result, partner_line_id, init_bal_by_col_group, level_shift=0):
|
||||||
|
line = super()._get_report_line_move_line(options, aml_query_result, partner_line_id, init_bal_by_col_group, level_shift)
|
||||||
|
line['no_followup'] = aml_query_result['no_followup']
|
||||||
|
return line
|
||||||
|
|
||||||
|
def _get_aml_value_extra_select(self):
|
||||||
|
return super()._get_aml_value_extra_select() + [
|
||||||
|
SQL(', account_move_line.no_followup')
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResPartner(models.Model):
|
||||||
|
_inherit = 'res.partner'
|
||||||
|
|
||||||
|
total_overdue_followup = fields.Monetary(
|
||||||
|
compute='_compute_total_due_followup',
|
||||||
|
groups='account.group_account_readonly,account.group_account_invoice'
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_open_overdue_entries(self):
|
||||||
|
action = super().action_open_overdue_entries()
|
||||||
|
action.pop('view_mode', None)
|
||||||
|
action['views'] = [(self.env.ref('odex30_account_no_followup.view_followup_invoice_list').id, 'list'), (None, 'form')]
|
||||||
|
return action
|
||||||
|
|
||||||
|
@api.depends('invoice_ids.line_ids.no_followup')
|
||||||
|
def _compute_total_due_followup(self):
|
||||||
|
receivable_overdue_followup_data = defaultdict(float)
|
||||||
|
|
||||||
|
for account_type, overdue, partner, no_followup, amount_residual_sum in self.env['account.move.line']._read_group(
|
||||||
|
domain=self._get_unreconciled_aml_domain(),
|
||||||
|
groupby=['account_type', 'followup_overdue', 'partner_id', 'no_followup'],
|
||||||
|
aggregates=['amount_residual:sum'],
|
||||||
|
):
|
||||||
|
if account_type == 'asset_receivable' and overdue and not no_followup:
|
||||||
|
receivable_overdue_followup_data[partner] += amount_residual_sum
|
||||||
|
for partner in self:
|
||||||
|
partner.total_overdue_followup = receivable_overdue_followup_data.get(partner, 0.0)
|
||||||
|
|
||||||
|
def _get_followup_data_query_extra_join_conditions(self):
|
||||||
|
return super()._get_followup_data_query_extra_join_conditions() + 'AND line.no_followup IS NOT TRUE\n'
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
/** @odoo-module */
|
||||||
|
import { AccountReportController } from "@odex30_account_reports/components/account_report/controller";
|
||||||
|
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
|
||||||
|
patch(AccountReportController.prototype, {
|
||||||
|
updateLines(lineIds, key, value) {
|
||||||
|
for (const lineId of lineIds) {
|
||||||
|
const lineIndex = this.lines.findIndex((line) => line.id === lineId);
|
||||||
|
this.lines.splice(lineIndex, 1, { ...this.lines[lineIndex], [key]: value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="odex30_account_no_followup.PartnerLedgerFollowupHeader" t-inherit="odex30_account_reports.AccountReportHeaderCustomizable">
|
||||||
|
<xpath expr="//tr[@data-id='column_headers_row']/th[1]" position="attributes">
|
||||||
|
<attribute name="colspan">2</attribute>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<xpath expr="//tr[@data-id='column_custom_subheaders_row']/th[1]" position="attributes">
|
||||||
|
<attribute name="colspan">2</attribute>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<xpath expr="//tr[@data-id='column_subheaders_row']/th[1]" position="after">
|
||||||
|
<th>No Follow-Up</th>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { AccountReport } from "@odex30_account_reports/components/account_report/account_report";
|
||||||
|
import { AccountReportLine } from "@odex30_account_reports/components/account_report/line/line";
|
||||||
|
import { PartnerLedgerFollowupLineCell } from "@odex30_account_no_followup/components/partner_ledger_followup/line_cell/line_cell";
|
||||||
|
|
||||||
|
export class PartnerLedgerFollowupLine extends AccountReportLine {
|
||||||
|
static template = "odex30_account_no_followup.PartnerLedgerFollowupLine";
|
||||||
|
static components = {
|
||||||
|
...AccountReportLine.components,
|
||||||
|
PartnerLedgerFollowupLineCell,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
AccountReport.registerCustomComponent(PartnerLedgerFollowupLine);
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="odex30_account_no_followup.PartnerLedgerFollowupLine" t-inherit="odex30_account_reports.AccountReportLineCustomizable">
|
||||||
|
<xpath expr="//t[@data-id='line_column']" position="before">
|
||||||
|
<PartnerLedgerFollowupLineCell
|
||||||
|
t-if="props.line.id.includes('account.move.line')"
|
||||||
|
t-props="{ line: props.line, cell: {} }"
|
||||||
|
/>
|
||||||
|
<td t-else="" data-id="line_cell" class="line_cell"/>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { AccountReportLineCell } from "@odex30_account_reports/components/account_report/line_cell/line_cell";
|
||||||
|
|
||||||
|
export class PartnerLedgerFollowupLineCell extends AccountReportLineCell {
|
||||||
|
static template = "odex30_account_reports.PartnerLedgerFollowupLineCell";
|
||||||
|
|
||||||
|
async toggleNoFollowup(ev) {
|
||||||
|
const res = await this.orm.call(
|
||||||
|
"account.partner.ledger.report.handler",
|
||||||
|
"action_toggle_no_followup",
|
||||||
|
[this.props.line.id, this.controller.lines.map((line) => line.id)]
|
||||||
|
);
|
||||||
|
this.controller.updateLines(res.updated_line_ids, "no_followup", res.updated_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates>
|
||||||
|
<t t-name="odex30_account_reports.PartnerLedgerFollowupLineCell" t-inherit="odex30_account_reports.AccountReportLineCellCustomizable">
|
||||||
|
<xpath expr="//div[@class='content']" position="replace">
|
||||||
|
<div class="content">
|
||||||
|
<div
|
||||||
|
class="o-checkbox form-check o_boolean_toggle form-switch"
|
||||||
|
t-on-click.stop.prevent="toggleNoFollowup"
|
||||||
|
>
|
||||||
|
<input type="checkbox" class="form-check-input" role="switch" t-att-checked="props.line.no_followup"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
from . import test_account_followup
|
||||||
|
from . import test_account_move_out_invoice
|
||||||
|
from . import test_partner_ledger_report
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
from freezegun import freeze_time
|
||||||
|
from odoo.tests import tagged
|
||||||
|
from odoo.addons.odex30_account_followup.tests.test_account_followup import TestAccountFollowupReports
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestNoFollowupAccountFollowupReports(TestAccountFollowupReports):
|
||||||
|
|
||||||
|
def test_followup_line_and_status(self):
|
||||||
|
followup_line = self.create_followup(delay=-10)
|
||||||
|
|
||||||
|
self.create_invoice('2022-01-02')
|
||||||
|
|
||||||
|
with freeze_time('2022-02-03'):
|
||||||
|
aml_ids = self.partner_a.unreconciled_aml_ids
|
||||||
|
|
||||||
|
# Exclude every unreconciled invoice line.
|
||||||
|
aml_ids.no_followup = True
|
||||||
|
# Every unreconciled invoice line is excluded, so the result should be `no_action_needed`.
|
||||||
|
self.assertPartnerFollowup(self.partner_a, 'no_action_needed', followup_line)
|
||||||
|
|
||||||
|
# It resets if we don't exclude them anymore.
|
||||||
|
aml_ids.no_followup = False
|
||||||
|
self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_line)
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
from odoo.addons.account.tests.test_account_move_out_invoice import TestAccountMoveOutInvoiceOnchanges
|
||||||
|
from odoo.tests import tagged
|
||||||
|
from odoo import fields, Command
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestAccountMoveOutInvoiceOnchangesNoFollowup(TestAccountMoveOutInvoiceOnchanges):
|
||||||
|
|
||||||
|
def test_invoice_no_followup(self):
|
||||||
|
"""Make sure that excluding an invoice from follow-up excludes all its receivable lines."""
|
||||||
|
installments_payment_term = self.env['account.payment.term'].create({
|
||||||
|
'name': "3 installments",
|
||||||
|
'line_ids': [
|
||||||
|
Command.create({'value_amount': 40, 'value': 'percent', 'nb_days': 0}),
|
||||||
|
Command.create({'value_amount': 30, 'value': 'percent', 'nb_days': 30}),
|
||||||
|
Command.create({'value_amount': 30, 'value': 'percent', 'nb_days': 60}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
invoice = self.env['account.move'].create({
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'invoice_date': fields.Date.from_string('2024-08-01'),
|
||||||
|
'partner_id': self.partner_a.id,
|
||||||
|
'invoice_line_ids': [Command.create({'quantity': 1, 'price_unit': 1000})],
|
||||||
|
'invoice_payment_term_id': installments_payment_term.id,
|
||||||
|
})
|
||||||
|
invoice_terms = invoice.line_ids.filtered(lambda line: line.display_type == 'payment_term')
|
||||||
|
self.assertFalse(invoice.no_followup)
|
||||||
|
self.assertEqual(invoice_terms.mapped('no_followup'), [False, False, False])
|
||||||
|
|
||||||
|
invoice.no_followup = True
|
||||||
|
self.assertTrue(invoice.no_followup)
|
||||||
|
self.assertEqual(invoice_terms.mapped('no_followup'), [True, True, True])
|
||||||
|
|
||||||
|
invoice.no_followup = False
|
||||||
|
self.assertFalse(invoice.no_followup)
|
||||||
|
self.assertEqual(invoice_terms.mapped('no_followup'), [False, False, False])
|
||||||
|
|
||||||
|
def test_invoice_line_no_followup(self):
|
||||||
|
"""Make sure that excluding one receivable line from an invoice excludes all the others."""
|
||||||
|
installments_payment_term = self.env['account.payment.term'].create({
|
||||||
|
'name': "3 installments",
|
||||||
|
'line_ids': [
|
||||||
|
Command.create({'value_amount': 40, 'value': 'percent', 'nb_days': 0}),
|
||||||
|
Command.create({'value_amount': 30, 'value': 'percent', 'nb_days': 30}),
|
||||||
|
Command.create({'value_amount': 30, 'value': 'percent', 'nb_days': 60}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
invoice = self.env['account.move'].create({
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'invoice_date': fields.Date.from_string('2024-08-01'),
|
||||||
|
'partner_id': self.partner_a.id,
|
||||||
|
'invoice_line_ids': [Command.create({'quantity': 1, 'price_unit': 1000})],
|
||||||
|
'invoice_payment_term_id': installments_payment_term.id,
|
||||||
|
})
|
||||||
|
invoice_terms = invoice.line_ids.filtered(lambda line: line.display_type == 'payment_term')
|
||||||
|
self.assertFalse(invoice.no_followup)
|
||||||
|
self.assertEqual(invoice_terms.mapped('no_followup'), [False, False, False])
|
||||||
|
|
||||||
|
invoice_terms[0].no_followup = True
|
||||||
|
self.assertTrue(invoice.no_followup)
|
||||||
|
self.assertEqual(invoice_terms.mapped('no_followup'), [True, True, True])
|
||||||
|
|
||||||
|
invoice_terms[1].no_followup = False
|
||||||
|
self.assertFalse(invoice.no_followup)
|
||||||
|
self.assertEqual(invoice_terms.mapped('no_followup'), [False, False, False])
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
from odoo.addons.odex30_account_reports.tests.test_partner_ledger_report import TestPartnerLedgerReport
|
||||||
|
|
||||||
|
from odoo import Command, fields
|
||||||
|
from odoo.tests import tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestNoFollowupPartnerLedgerReport(TestPartnerLedgerReport):
|
||||||
|
|
||||||
|
def test_partner_ledger_toggle_followup(self):
|
||||||
|
"""Make sure that toggling the followup also (and only) toggles other lines of the same invoice."""
|
||||||
|
installments_payment_term = self.env['account.payment.term'].create({
|
||||||
|
'name': "3 installments",
|
||||||
|
'line_ids': [
|
||||||
|
Command.create({'value_amount': 40, 'value': 'percent', 'nb_days': 0}),
|
||||||
|
Command.create({'value_amount': 30, 'value': 'percent', 'nb_days': 30}),
|
||||||
|
Command.create({'value_amount': 30, 'value': 'percent', 'nb_days': 60}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
invoices = self.env['account.move'].create([
|
||||||
|
{
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'invoice_date': fields.Date.from_string('2024-08-01'),
|
||||||
|
'partner_id': self.partner_a.id,
|
||||||
|
'invoice_line_ids': [Command.create({'quantity': 1, 'price_unit': 1000})],
|
||||||
|
'invoice_payment_term_id': installments_payment_term.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'invoice_date': fields.Date.from_string('2024-08-10'),
|
||||||
|
'partner_id': self.partner_a.id,
|
||||||
|
'invoice_line_ids': [Command.create({'quantity': 1, 'price_unit': 500})],
|
||||||
|
'invoice_payment_term_id': installments_payment_term.id,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
invoices.action_post()
|
||||||
|
invoice_name = invoices[0].name
|
||||||
|
options = self._generate_options(self.report, '2024-01-01', '2024-12-31', default_options={'unfold_all': True})
|
||||||
|
lines = self.report._get_lines(options)
|
||||||
|
line_ids = [line['id'] for line in lines]
|
||||||
|
invoice_1_line_ids = [line['id'] for line in lines if invoice_name in line['name']]
|
||||||
|
self.assertEqual(
|
||||||
|
self.env['account.partner.ledger.report.handler'].action_toggle_no_followup(invoice_1_line_ids[0], line_ids)['updated_line_ids'],
|
||||||
|
invoice_1_line_ids,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="view_followup_invoice_list" model="ir.ui.view">
|
||||||
|
<field name="name">account.followup.invoice.list</field>
|
||||||
|
<field name="model">account.move</field>
|
||||||
|
<field name="inherit_id" ref="account.view_invoice_tree"/>
|
||||||
|
<field name="mode">primary</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<field name="invoice_date_due" position="after">
|
||||||
|
<field name="no_followup" widget="boolean_toggle"/>
|
||||||
|
</field>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
from . import models
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
'name': 'ODEX Account: Secure Digital Settlement',
|
||||||
|
'summary': 'Advanced gateway for secure digital financial transactions.',
|
||||||
|
'category': 'Odex30-Accounting/Odex30-Accounting',
|
||||||
|
'author': "Expert Co. Ltd.",
|
||||||
|
'website': "http://www.exp-sa.com",
|
||||||
|
'description': """
|
||||||
|
This module enables advanced digital settlement capabilities for financial obligations.
|
||||||
|
It provides a secure interface for handling transactions through integrated
|
||||||
|
financial service providers.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Secure digital payment initiation.
|
||||||
|
- Integration with major financial settlement frameworks.
|
||||||
|
- Automated reconciliation of digital receipts.
|
||||||
|
""",
|
||||||
|
'depends': ['odex30_account_online_synchronization', 'odex30_account_batch_payment',],
|
||||||
|
'data': [
|
||||||
|
'data/actions.xml',
|
||||||
|
'views/account_batch_payment_views.xml',
|
||||||
|
],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'odex30_account_online_payment/static/src/components/**/*',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'auto_install': True,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="ir_cron_bank_sync_update_payment_status" model="ir.cron">
|
||||||
|
<field name="name">Account: Update payment status</field>
|
||||||
|
<field name="model_id" ref="model_account_batch_payment"/>
|
||||||
|
<field name="interval_number">6</field>
|
||||||
|
<field name="interval_type">hours</field>
|
||||||
|
<field name="code">model._cron_check_payment_status()</field>
|
||||||
|
<field name="state">code</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record model="ir.actions.server" id="action_odex30_account_online_payment_check_status">
|
||||||
|
<field name="name">Check Status</field>
|
||||||
|
<field name="model_id" ref="model_account_batch_payment"/>
|
||||||
|
<field name="binding_model_id" ref="model_account_batch_payment"/>
|
||||||
|
<field name="binding_view_types">list</field>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">
|
||||||
|
if records:
|
||||||
|
action = records.check_online_payment_status()
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
# Translation of ODEX Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * odex30_account_online_payment
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: ODEX Server 18.0+e\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2025-06-27 18:48+0000\n"
|
||||||
|
"PO-Revision-Date: 2025-06-27 18:48+0000\n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/odex30_account_online_payment/models/account_batch_payment.py:0
|
||||||
|
msgid ""
|
||||||
|
"\n"
|
||||||
|
" This payment requires a KYC flow. As this process can take a few days, please use SEPA XML export in the meantime.\n"
|
||||||
|
" You will be notified once the KYC flow is completed and you can proceed with the online payment.\n"
|
||||||
|
" "
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__accepted
|
||||||
|
msgid "Accepted"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_batch_payment__account_online_linked
|
||||||
|
msgid "Account Online Linked"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.actions.server,name:odex30_account_online_payment.ir_cron_bank_sync_update_payment_status_ir_actions_server
|
||||||
|
msgid "Account: Update payment status"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model,name:odex30_account_online_payment.model_account_online_link
|
||||||
|
msgid "Bank Connection"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_batch_payment__payment_identifier
|
||||||
|
msgid "Batch ID"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model,name:odex30_account_online_payment.model_account_batch_payment
|
||||||
|
msgid "Batch Payment"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__canceled
|
||||||
|
msgid "Canceled"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.actions.server,name:odex30_account_online_payment.action_odex30_account_online_payment_check_status
|
||||||
|
msgid "Check Status"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_payment__end_to_end_id
|
||||||
|
msgid "End to End ID"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_online_payment.view_batch_payment_form_inherit
|
||||||
|
msgid "Initiate Payment"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_batch_payment__payment_online_status
|
||||||
|
msgid "PIS Status"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/odex30_account_online_payment/models/account_batch_payment.py:0
|
||||||
|
msgid "Payment already been signed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model,name:odex30_account_online_payment.model_account_payment
|
||||||
|
msgid "Payments"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__pending
|
||||||
|
msgid "Pending"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/odex30_account_online_payment/models/account_batch_payment.py:0
|
||||||
|
msgid ""
|
||||||
|
"Please be aware that signed payments may have already been processed and "
|
||||||
|
"sent to the bank."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_batch_payment__redirect_url
|
||||||
|
msgid "Redirect URL"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__rejected
|
||||||
|
msgid "Rejected"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_online_payment.view_batch_payment_form_inherit
|
||||||
|
msgid "Sign Payment"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/odex30_account_online_payment/models/account_batch_payment.py:0
|
||||||
|
msgid ""
|
||||||
|
"This payment might have already been signed. Refreshing the payment "
|
||||||
|
"status..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__uninitiated
|
||||||
|
msgid "Uninitiated"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__unsigned
|
||||||
|
msgid "Unsigned"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_online_payment.view_batch_payment_form_inherit
|
||||||
|
msgid "XML"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/odex30_account_online_payment/models/account_payment.py:0
|
||||||
|
msgid "You cannot modify a payment that has already been sent to the bank."
|
||||||
|
msgstr ""
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
# Translation of ODEX Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * odex30_account_online_payment
|
||||||
|
#
|
||||||
|
# Translators:
|
||||||
|
# Malaz Abuidris <msea@odoo.com>, 2025
|
||||||
|
# Wil ODEX, 2025
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: ODEX Server 18.0+e\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2025-06-27 18:48+0000\n"
|
||||||
|
"PO-Revision-Date: 2024-09-25 09:44+0000\n"
|
||||||
|
"Last-Translator: Wil ODEX, 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_online_payment
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/odex30_account_online_payment/models/account_batch_payment.py:0
|
||||||
|
msgid ""
|
||||||
|
"\n"
|
||||||
|
" This payment requires a KYC flow. As this process can take a few days, please use SEPA XML export in the meantime.\n"
|
||||||
|
" You will be notified once the KYC flow is completed and you can proceed with the online payment.\n"
|
||||||
|
" "
|
||||||
|
msgstr ""
|
||||||
|
"\n"
|
||||||
|
" This payment requires a KYC flow. As this process can take a few days, please use SEPA XML export in the meantime.\n"
|
||||||
|
" You will be notified once the KYC flow is completed and you can proceed with the online payment.\n"
|
||||||
|
" "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__accepted
|
||||||
|
msgid "Accepted"
|
||||||
|
msgstr "تم القبول"
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_batch_payment__account_online_linked
|
||||||
|
msgid "Account Online Linked"
|
||||||
|
msgstr "تم ربط الحساب عبر الإنترنت "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.actions.server,name:odex30_account_online_payment.ir_cron_bank_sync_update_payment_status_ir_actions_server
|
||||||
|
msgid "Account: Update payment status"
|
||||||
|
msgstr "الحساب: تحديث حالة الدفع "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model,name:odex30_account_online_payment.model_account_online_link
|
||||||
|
msgid "Bank Connection"
|
||||||
|
msgstr "اتصال البنك"
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_batch_payment__payment_identifier
|
||||||
|
msgid "Batch ID"
|
||||||
|
msgstr "معرّف الدفعة "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model,name:odex30_account_online_payment.model_account_batch_payment
|
||||||
|
msgid "Batch Payment"
|
||||||
|
msgstr "دفعة مجمعة "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__canceled
|
||||||
|
msgid "Canceled"
|
||||||
|
msgstr "تم الإلغاء "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.actions.server,name:odex30_account_online_payment.action_odex30_account_online_payment_check_status
|
||||||
|
msgid "Check Status"
|
||||||
|
msgstr "تحقق من الحالة "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_payment__end_to_end_id
|
||||||
|
msgid "End to End ID"
|
||||||
|
msgstr "معرف طرف إلى طرف "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_online_payment.view_batch_payment_form_inherit
|
||||||
|
msgid "Initiate Payment"
|
||||||
|
msgstr "بدء عملية الدفع "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_batch_payment__payment_online_status
|
||||||
|
msgid "PIS Status"
|
||||||
|
msgstr "حالة PIS "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/odex30_account_online_payment/models/account_batch_payment.py:0
|
||||||
|
msgid "Payment already been signed"
|
||||||
|
msgstr "لقد تم التوقيع على المدفوعات بالفعل "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model,name:odex30_account_online_payment.model_account_payment
|
||||||
|
msgid "Payments"
|
||||||
|
msgstr "المدفوعات"
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__pending
|
||||||
|
msgid "Pending"
|
||||||
|
msgstr "قيد الانتظار "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/odex30_account_online_payment/models/account_batch_payment.py:0
|
||||||
|
msgid ""
|
||||||
|
"Please be aware that signed payments may have already been processed and "
|
||||||
|
"sent to the bank."
|
||||||
|
msgstr ""
|
||||||
|
"يُرجى العلم بأنه قد تكون المدفوعات الموقعة قد تمت معالجتها بالفعل وإرسالها "
|
||||||
|
"إلى البنك. "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_batch_payment__redirect_url
|
||||||
|
msgid "Redirect URL"
|
||||||
|
msgstr "إعادة توجيه رابط URL "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__rejected
|
||||||
|
msgid "Rejected"
|
||||||
|
msgstr "تم الرفض "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_online_payment.view_batch_payment_form_inherit
|
||||||
|
msgid "Sign Payment"
|
||||||
|
msgstr "التوقيع على الدفع "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/odex30_account_online_payment/models/account_batch_payment.py:0
|
||||||
|
msgid ""
|
||||||
|
"This payment might have already been signed. Refreshing the payment "
|
||||||
|
"status..."
|
||||||
|
msgstr "قد تكون هذه الدفعة قد تم توقيعها بالفعل. جاري تحديث حالة الدفع... "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__uninitiated
|
||||||
|
msgid "Uninitiated"
|
||||||
|
msgstr "لم يتم البدء به "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__unsigned
|
||||||
|
msgid "Unsigned"
|
||||||
|
msgstr "لم يتم توقيعه "
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#: model_terms:ir.ui.view,arch_db:odex30_account_online_payment.view_batch_payment_form_inherit
|
||||||
|
msgid "XML"
|
||||||
|
msgstr "XML"
|
||||||
|
|
||||||
|
#. module: odex30_account_online_payment
|
||||||
|
#. odoo-python
|
||||||
|
#: code:addons/odex30_account_online_payment/models/account_payment.py:0
|
||||||
|
msgid "You cannot modify a payment that has already been sent to the bank."
|
||||||
|
msgstr "لا يمكنك تعديل دفعة تم إرسالها بالفعل إلى البنك. "
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
from . import account_batch_payment
|
||||||
|
from . import account_online_link
|
||||||
|
from . import account_payment
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
from odoo import api, fields, models, SUPERUSER_ID, _
|
||||||
|
from odoo.addons.account.tools.structured_reference import is_valid_structured_reference
|
||||||
|
|
||||||
|
STATUSES = [
|
||||||
|
('uninitiated', 'Uninitiated'),
|
||||||
|
('unsigned', 'Unsigned'),
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('accepted', 'Accepted'),
|
||||||
|
('canceled', 'Canceled'),
|
||||||
|
('rejected', 'Rejected'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AccountBatchPayment(models.Model):
|
||||||
|
_inherit = 'account.batch.payment'
|
||||||
|
|
||||||
|
payment_identifier = fields.Char(string='Batch ID', readonly=True)
|
||||||
|
redirect_url = fields.Char(string='Redirect URL', readonly=True)
|
||||||
|
payment_online_status = fields.Selection(selection=STATUSES, string='PIS Status', default='uninitiated', readonly=True)
|
||||||
|
account_online_linked = fields.Boolean(compute='_compute_account_online_linked')
|
||||||
|
|
||||||
|
def initiate_payment(self):
|
||||||
|
"""
|
||||||
|
This function handles the two currently supported flows for validating batch payments:
|
||||||
|
- Signing the payment online through ODEXfin
|
||||||
|
- Using the regular batch validation and exporting an SCT XML file
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if self.payment_online_status == 'unsigned' and self.state == 'sent':
|
||||||
|
self.check_online_payment_status()
|
||||||
|
if self.payment_online_status != 'unsigned':
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'title': _('Payment already been signed'),
|
||||||
|
'message': _('This payment might have already been signed. Refreshing the payment status...'),
|
||||||
|
'type': 'warning',
|
||||||
|
'next': {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'soft_reload',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return self._sign_payment()
|
||||||
|
return self.with_context(xml_export=False).validate_batch()
|
||||||
|
|
||||||
|
def validate_batch(self):
|
||||||
|
if not self.payment_method_code == 'sepa_ct' or not self.account_online_linked or self._context.get('xml_export'):
|
||||||
|
return super().validate_batch()
|
||||||
|
|
||||||
|
action = self._check_batch_validity()
|
||||||
|
if action and action.get('res_model') == 'account.batch.error.wizard':
|
||||||
|
return action
|
||||||
|
|
||||||
|
account_online_link = self.journal_id.account_online_link_id
|
||||||
|
data = self._prepare_payment_data()
|
||||||
|
while True:
|
||||||
|
response = account_online_link._fetch_odoo_fin('/proxy/v1/initiate_payment', data)
|
||||||
|
# In case of token expiration, we receive a special next_data field that we use to redo the request
|
||||||
|
if not response.get('next_data'):
|
||||||
|
break
|
||||||
|
data['next_data'] = response['next_data']
|
||||||
|
|
||||||
|
if response.get('kyc_flow'):
|
||||||
|
self.with_user(SUPERUSER_ID).message_post(body=_("""
|
||||||
|
This payment requires a KYC flow. As this process can take a few days, please use SEPA XML export in the meantime.
|
||||||
|
You will be notified once the KYC flow is completed and you can proceed with the online payment.
|
||||||
|
"""))
|
||||||
|
else:
|
||||||
|
self._send_after_validation()
|
||||||
|
self.write({
|
||||||
|
'payment_identifier': response.get('payment_identifier'),
|
||||||
|
'payment_online_status': response.get('payment_online_status'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_url',
|
||||||
|
'url': response.get('redirect_url'),
|
||||||
|
'target': '_blank',
|
||||||
|
}
|
||||||
|
|
||||||
|
def check_online_payment_status(self):
|
||||||
|
statuses = {}
|
||||||
|
for batch in self:
|
||||||
|
account_online_account = batch.journal_id.account_online_account_id
|
||||||
|
data = {
|
||||||
|
"payment_identifier": batch.payment_identifier,
|
||||||
|
"account_id": account_online_account.online_identifier,
|
||||||
|
"payment_type": "bulk",
|
||||||
|
"provider_data": account_online_account.account_online_link_id.provider_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
response = batch.journal_id.account_online_link_id._fetch_odoo_fin('/proxy/v1/get_payment_status', data)
|
||||||
|
# In case of token expiration, we receive a special next_data field that we use to redo the request
|
||||||
|
if not response.get('next_data'):
|
||||||
|
break
|
||||||
|
data['next_data'] = response['next_data']
|
||||||
|
|
||||||
|
batch.payment_online_status = response.get('payment_online_status')
|
||||||
|
statuses[batch.id] = batch.payment_online_status
|
||||||
|
return statuses
|
||||||
|
|
||||||
|
def export_batch_payment(self):
|
||||||
|
to_be_exported = self.env['account.batch.payment']
|
||||||
|
|
||||||
|
for record in self:
|
||||||
|
if record.payment_method_code == 'sepa_ct' and record.account_online_linked and not self.env.context.get('xml_export'):
|
||||||
|
continue
|
||||||
|
to_be_exported += record
|
||||||
|
|
||||||
|
super(AccountBatchPayment, to_be_exported).export_batch_payment()
|
||||||
|
if any(payment.payment_online_status in {'pending', 'accepted'} for payment in to_be_exported):
|
||||||
|
self.with_user(SUPERUSER_ID).message_post(body=_("Please be aware that signed payments may have already been processed and sent to the bank."))
|
||||||
|
|
||||||
|
def _sign_payment(self):
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
account_online_link = self.journal_id.account_online_link_id
|
||||||
|
data = {
|
||||||
|
**self._prepare_payment_data(),
|
||||||
|
"payment_identifier": self.payment_identifier,
|
||||||
|
}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
response = account_online_link._fetch_odoo_fin('/proxy/v1/sign_payment', data)
|
||||||
|
|
||||||
|
if not response.get('next_data'):
|
||||||
|
break
|
||||||
|
data['next_data'] = response['next_data']
|
||||||
|
|
||||||
|
self.payment_online_status = response['payment_online_status']
|
||||||
|
self.payment_identifier = response['payment_identifier']
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_url',
|
||||||
|
'url': response['redirect_url'],
|
||||||
|
'target': '_blank',
|
||||||
|
}
|
||||||
|
|
||||||
|
def _cron_check_payment_status(self):
|
||||||
|
self.env['account.batch.payment'].search([
|
||||||
|
('state', '!=', 'reconciled'),
|
||||||
|
('payment_method_code', '=', 'sepa_ct'),
|
||||||
|
('journal_id.account_online_link_id.provider_type', '=ilike', '%activated'),
|
||||||
|
('payment_online_status', 'in', ('unsigned', 'pending')),
|
||||||
|
]).check_online_payment_status()
|
||||||
|
|
||||||
|
@api.depends('journal_id.account_online_link_id', 'journal_id.account_online_link_id.provider_type')
|
||||||
|
def _compute_account_online_linked(self):
|
||||||
|
for batch in self:
|
||||||
|
account_online_link = batch.journal_id.account_online_link_id
|
||||||
|
batch.account_online_linked = account_online_link.provider_type and 'payment' in account_online_link.provider_type
|
||||||
|
|
||||||
|
def _prepare_payment_data(self):
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
payments = []
|
||||||
|
for payment in self.payment_ids:
|
||||||
|
payments.append({
|
||||||
|
"amount": payment.amount,
|
||||||
|
"account_number": payment.partner_bank_id.sanitized_acc_number,
|
||||||
|
"account_type": "IBAN",
|
||||||
|
"creditor_name": payment.partner_id.name,
|
||||||
|
"currency": payment.currency_id.display_name,
|
||||||
|
"date": fields.Date.to_string(payment.date),
|
||||||
|
"reference": payment.memo,
|
||||||
|
"structured_reference": is_valid_structured_reference(payment.memo),
|
||||||
|
"end_to_end_id": payment.end_to_end_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"account_id": self.journal_id.account_online_account_id.online_identifier,
|
||||||
|
"batch_booking": self.iso20022_batch_booking,
|
||||||
|
"date": fields.Date.to_string(self.date),
|
||||||
|
"payment_type": "bulk",
|
||||||
|
"payments": payments,
|
||||||
|
"provider_data": self.journal_id.account_online_link_id.provider_data,
|
||||||
|
"reference": self.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_payment_vals(self, payment):
|
||||||
|
return {**super()._get_payment_vals(payment), 'end_to_end_id': payment.end_to_end_id}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
from odoo import models
|
||||||
|
|
||||||
|
|
||||||
|
class AccountOnlineLink(models.Model):
|
||||||
|
_inherit = 'account.online.link'
|
||||||
|
|
||||||
|
def _update_payments_activated(self, data):
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if not self.provider_type:
|
||||||
|
self.provider_type = ''
|
||||||
|
|
||||||
|
# Hacky way to know whether the synchronization has payment enabled/activated or not
|
||||||
|
if data.get('is_payment_enabled'):
|
||||||
|
if 'payment' not in self.provider_type:
|
||||||
|
self.provider_type = f'{self.provider_type}_payment'
|
||||||
|
else:
|
||||||
|
self.provider_type = self.provider_type.replace('_payment', '')
|
||||||
|
|
||||||
|
if data.get('is_payment_activated'):
|
||||||
|
if 'activated' not in self.provider_type:
|
||||||
|
self.provider_type = f'{self.provider_type}_activated'
|
||||||
|
else:
|
||||||
|
self.provider_type = self.provider_type.replace('_activated', '')
|
||||||
|
|
||||||
|
def _update_connection_status(self):
|
||||||
|
# EXTENDS odex30_account_online_synchronization
|
||||||
|
data = super()._update_connection_status()
|
||||||
|
|
||||||
|
self._update_payments_activated(data)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
from time import time
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class AccountPayment(models.Model):
|
||||||
|
_inherit = 'account.payment'
|
||||||
|
|
||||||
|
end_to_end_id = fields.Char(string='End to End ID', readonly=True, compute='_compute_end_to_end_id', store=True)
|
||||||
|
|
||||||
|
def action_draft(self):
|
||||||
|
if any(payment.batch_payment_id and payment.payment_method_code == 'sepa_ct' and payment.batch_payment_id.payment_online_status in {'pending', 'accepted'} for payment in self):
|
||||||
|
raise UserError(_('You cannot modify a payment that has already been sent to the bank.'))
|
||||||
|
|
||||||
|
return super().action_draft()
|
||||||
|
|
||||||
|
@api.depends('journal_id')
|
||||||
|
def _compute_end_to_end_id(self):
|
||||||
|
for payment in self:
|
||||||
|
payment.end_to_end_id = f"{time()}{payment.journal_id.id}{payment.id}".strip()[-30:]
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Component, useState } from "@odoo/owl";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
export class RefreshButton extends Component {
|
||||||
|
static template = "odex30_account_online_payment.RefreshButton";
|
||||||
|
static props = ["name", "id", "record", "readonly"];
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.state = useState({
|
||||||
|
status: this.props.record.data.payment_online_status,
|
||||||
|
isFetching: false,
|
||||||
|
});
|
||||||
|
this.orm = useService("orm");
|
||||||
|
}
|
||||||
|
|
||||||
|
async onClickFetchStatus() {
|
||||||
|
this.state.isFetching = true;
|
||||||
|
|
||||||
|
const response = await this.orm.call(
|
||||||
|
"account.batch.payment",
|
||||||
|
"check_online_payment_status",
|
||||||
|
[this.props.record.data.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
this.state.status = response[this.props.record.data.id];
|
||||||
|
this.state.isFetching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const refreshButtonComp = {
|
||||||
|
component: RefreshButton,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("fields").add("odex30_account_online_payment_refresh_button", refreshButtonComp);
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<templates>
|
||||||
|
<div t-name="odex30_account_online_payment.RefreshButton" class="w-100 d-sm-contents">
|
||||||
|
<div class="o_field_widget o_readonly_modifier o_field_char d-flex gap-2">
|
||||||
|
<span class="w-25" t-esc="state.status"/>
|
||||||
|
<t t-if="state.isFetching">
|
||||||
|
<div>
|
||||||
|
<i class="fa fa-refresh fa-spin"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<div name="check_online_payment_status" t-on-click="onClickFetchStatus">
|
||||||
|
<i class="fa fa-refresh"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
from . import test_account_online_link
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from odoo.tests import tagged
|
||||||
|
from odoo.addons.odex30_account_online_synchronization.tests.common import AccountOnlineSynchronizationCommon
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestAccountOnlineLinkPayment(AccountOnlineSynchronizationCommon):
|
||||||
|
|
||||||
|
@patch("odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineLink._update_connection_status")
|
||||||
|
def test_update_status_when_payment_enabled(self, patched_update_connection_status):
|
||||||
|
self.account_online_link.provider_type = 'provider_A'
|
||||||
|
|
||||||
|
patched_update_connection_status.return_value = {
|
||||||
|
'consent_expiring_date': None,
|
||||||
|
'is_payment_enabled': True,
|
||||||
|
'is_payment_activated': False,
|
||||||
|
}
|
||||||
|
self.account_online_link._update_connection_status()
|
||||||
|
self.assertEqual(self.account_online_link.provider_type, 'provider_A_payment')
|
||||||
|
|
||||||
|
@patch("odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineLink._update_connection_status")
|
||||||
|
def test_update_status_when_payment_deactivated(self, patched_update_connection_status):
|
||||||
|
self.account_online_link.provider_type = 'provider_A_payment_activated'
|
||||||
|
|
||||||
|
patched_update_connection_status.return_value = {
|
||||||
|
'consent_expiring_date': None,
|
||||||
|
'is_payment_enabled': True,
|
||||||
|
'is_payment_activated': False,
|
||||||
|
}
|
||||||
|
self.account_online_link._update_connection_status()
|
||||||
|
self.assertEqual(self.account_online_link.provider_type, 'provider_A_payment')
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_batch_payment_form_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">sct.account.batch.payment.form.inherit</field>
|
||||||
|
<field name="model">account.batch.payment</field>
|
||||||
|
<field name="inherit_id" ref="account_batch_payment.view_batch_payment_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//button[@id='validate_button']" position="attributes">
|
||||||
|
<attribute name="invisible">(account_online_linked and batch_type == 'outbound' and payment_method_code == 'sepa_ct') or state != 'draft' or not payment_ids</attribute>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//button[@id='regenerate_file_button']" position="attributes">
|
||||||
|
<attribute name="invisible" add="payment_online_status != 'uninitiated'" separator=" or "/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//button[@id='validate_button']" position="after">
|
||||||
|
<button
|
||||||
|
name="initiate_payment"
|
||||||
|
class="oe_highlight"
|
||||||
|
type="object"
|
||||||
|
string="Initiate Payment"
|
||||||
|
invisible="not account_online_linked or state != 'draft' or payment_online_status not in ['uninitiated'] or batch_type != 'outbound' or payment_method_code != 'sepa_ct'"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
name="validate_batch_button"
|
||||||
|
type="object"
|
||||||
|
string="XML"
|
||||||
|
context="{'xml_export': True}"
|
||||||
|
invisible="not account_online_linked or state != 'draft' or payment_online_status not in ['uninitiated'] or batch_type != 'outbound' or payment_method_code != 'sepa_ct'"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
name="export_batch_payment"
|
||||||
|
type="object"
|
||||||
|
string="XML"
|
||||||
|
context="{'xml_export': True}"
|
||||||
|
invisible="not account_online_linked or state == 'draft' or payment_method_code != 'sepa_ct'"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
name="initiate_payment"
|
||||||
|
type="object"
|
||||||
|
string="Sign Payment"
|
||||||
|
invisible="not account_online_linked or state == 'draft' or payment_online_status != 'unsigned' or batch_type != 'outbound' or payment_method_code != 'sepa_ct'"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
name="initiate_payment"
|
||||||
|
type="object"
|
||||||
|
string="Initiate Payment"
|
||||||
|
invisible="not account_online_linked or state == 'draft' or payment_online_status != 'uninitiated' or batch_type != 'outbound' or payment_method_code != 'sepa_ct'"
|
||||||
|
/>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<xpath expr="//field[@name='iso20022_batch_booking']" position="after">
|
||||||
|
<field name="payment_online_status" widget="odex30_account_online_payment_refresh_button" invisible="state == 'draft' or not account_online_linked or payment_online_status == 'uninitiated'"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_batch_payment_tree_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">sct.account.batch.payment.tree.inherit</field>
|
||||||
|
<field name="model">account.batch.payment</field>
|
||||||
|
<field name="inherit_id" ref="account_batch_payment.view_batch_payment_tree"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='state']" position="before">
|
||||||
|
<field
|
||||||
|
name="payment_online_status"
|
||||||
|
widget="badge"
|
||||||
|
decoration-success="payment_online_status == 'accepted'"
|
||||||
|
decoration-info="payment_online_status in ['pending', 'unsigned']"
|
||||||
|
decoration-danger="payment_online_status in ['rejected', 'canceled']"
|
||||||
|
/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import controllers
|
||||||
|
from . import models
|
||||||
|
from . import wizard
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': "ODEX Account: Financial Data Stream Synchronization",
|
||||||
|
'summary': "Advanced real-time synchronization of external financial data streams.",
|
||||||
|
'category': 'Odex30-Accounting/Odex30-Accounting',
|
||||||
|
'author': "Expert Co. Ltd.",
|
||||||
|
'website': "http://www.exp-sa.com",
|
||||||
|
'description': """
|
||||||
|
This system enables automated streams of financial data from external institutions
|
||||||
|
directly into your accounting workspace. It provides tools for configuring periodic
|
||||||
|
data pulls and maintaining up-to-date ledger balances.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Seamless linkage with external financial data sources.
|
||||||
|
- Automated periodic synchronization cycles.
|
||||||
|
- Real-time visibility into account balances and transaction history.
|
||||||
|
""",
|
||||||
|
|
||||||
|
'version': '1.0',
|
||||||
|
'depends': ['odex30_account_accountant'],
|
||||||
|
|
||||||
|
'data': [
|
||||||
|
'data/config_parameter.xml',
|
||||||
|
'data/ir_cron.xml',
|
||||||
|
'data/mail_activity_type_data.xml',
|
||||||
|
'data/sync_reminder_email_template.xml',
|
||||||
|
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'security/account_online_sync_security.xml',
|
||||||
|
|
||||||
|
'views/account_online_sync_views.xml',
|
||||||
|
'views/account_bank_statement_view.xml',
|
||||||
|
'views/account_journal_view.xml',
|
||||||
|
'views/account_online_sync_portal_templates.xml',
|
||||||
|
'views/account_journal_dashboard_view.xml',
|
||||||
|
|
||||||
|
'wizard/account_bank_selection_wizard.xml',
|
||||||
|
'wizard/account_journal_missing_transactions.xml',
|
||||||
|
'wizard/account_journal_duplicate_transactions.xml',
|
||||||
|
'wizard/account_bank_statement_line.xml',
|
||||||
|
],
|
||||||
|
'auto_install': True,
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'odex30_account_online_synchronization/static/src/components/**/*',
|
||||||
|
'odex30_account_online_synchronization/static/src/js/odex_fin_connector.js',
|
||||||
|
],
|
||||||
|
'web.assets_frontend': [
|
||||||
|
'odex30_account_online_synchronization/static/src/js/online_sync_portal.js',
|
||||||
|
],
|
||||||
|
'web.qunit_suite_tests': [
|
||||||
|
'odex30_account_online_synchronization/static/tests/helpers/*.js',
|
||||||
|
'odex30_account_online_synchronization/static/tests/*.js',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
from . import portal
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||||
|
from odoo.tools import format_amount, format_date
|
||||||
|
from odoo.exceptions import AccessError, MissingError, UserError
|
||||||
|
|
||||||
|
|
||||||
|
class OnlineSynchronizationPortal(CustomerPortal):
|
||||||
|
|
||||||
|
@http.route(['/renew_consent/<int:journal_id>'], type='http', auth="public", website=True, sitemap=False)
|
||||||
|
def portal_online_sync_renew_consent(self, journal_id, access_token=None, **kw):
|
||||||
|
# Display a page to the user allowing to renew the consent for his bank sync.
|
||||||
|
# Requires the same rights as the button in odoo.
|
||||||
|
try:
|
||||||
|
journal_sudo = self._document_check_access('account.journal', journal_id, access_token)
|
||||||
|
except (AccessError, MissingError):
|
||||||
|
return request.redirect('/my')
|
||||||
|
values = self._prepare_portal_layout_values()
|
||||||
|
# Ignore the route if the journal isn't one using bank sync.
|
||||||
|
if not journal_sudo.account_online_account_id:
|
||||||
|
raise request.not_found()
|
||||||
|
|
||||||
|
balance = journal_sudo.account_online_account_id.balance
|
||||||
|
if journal_sudo.account_online_account_id.currency_id:
|
||||||
|
formatted_balance = format_amount(request.env, balance, journal_sudo.account_online_account_id.currency_id)
|
||||||
|
else:
|
||||||
|
formatted_balance = format_amount(request.env, balance, journal_sudo.currency_id or journal_sudo.company_id.currency_id)
|
||||||
|
|
||||||
|
values.update({
|
||||||
|
'bank': journal_sudo.bank_account_id.bank_name or journal_sudo.account_online_account_id.name,
|
||||||
|
'bank_account': journal_sudo.bank_account_id.acc_number,
|
||||||
|
'journal': journal_sudo.name,
|
||||||
|
'latest_balance_formatted': formatted_balance,
|
||||||
|
'latest_balance': balance,
|
||||||
|
'latest_sync': format_date(request.env, journal_sudo.account_online_account_id.last_sync, date_format="MMM dd, YYYY"),
|
||||||
|
'iframe_params': json.dumps(journal_sudo.action_extend_consent()),
|
||||||
|
})
|
||||||
|
return request.render("odex30_account_online_synchronization.portal_renew_consent", values)
|
||||||
|
|
||||||
|
|
||||||
|
@http.route(['/renew_consent/<int:journal_id>/complete'], type='http', auth="public", methods=['POST'], website=True)
|
||||||
|
def portal_online_sync_action_complete(self, journal_id, access_token=None, **kw):
|
||||||
|
# Complete the consent renewal process
|
||||||
|
try:
|
||||||
|
journal_sudo = self._document_check_access('account.journal', journal_id, access_token)
|
||||||
|
except (AccessError, MissingError):
|
||||||
|
return request.redirect('/my')
|
||||||
|
# Ignore the route if the journal isn't one using bank sync.
|
||||||
|
if not journal_sudo.account_online_link_id:
|
||||||
|
raise request.not_found()
|
||||||
|
try:
|
||||||
|
journal_sudo.account_online_link_id._update_connection_status()
|
||||||
|
journal_sudo.manual_sync()
|
||||||
|
except UserError:
|
||||||
|
pass
|
||||||
|
return request.make_response(json.dumps({'status': 'done'}))
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record forcecreate="True" id="config_online_sync_proxy_mode" model="ir.config_parameter">
|
||||||
|
<field name="key">odex30_account_online_synchronization.proxy_mode</field>
|
||||||
|
<field name="value">production</field>
|
||||||
|
</record>
|
||||||
|
<record forcecreate="True" id="config_online_sync_request_timeout" model="ir.config_parameter">
|
||||||
|
<field name="key">odex30_account_online_synchronization.request_timeout</field>
|
||||||
|
<field name="value">60</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<!-- Cron to synchronize transaction -->
|
||||||
|
<record id="online_sync_cron" model="ir.cron">
|
||||||
|
<field name="name">Account: Journal online sync</field>
|
||||||
|
<field name="model_id" ref="account.model_account_journal"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_fetch_online_transactions()</field>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
<field name="user_id" ref="base.user_root"/>
|
||||||
|
<field name="interval_number">12</field>
|
||||||
|
<field name="interval_type">hours</field>
|
||||||
|
</record>
|
||||||
|
<record id="online_sync_cron_waiting_synchronization" model="ir.cron">
|
||||||
|
<field name="name">Account: Journal online Waiting Synchronization</field>
|
||||||
|
<field name="model_id" ref="account.model_account_journal"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_fetch_waiting_online_transactions()</field>
|
||||||
|
<field name="active" eval="False"/>
|
||||||
|
<field name="user_id" ref="base.user_root"/>
|
||||||
|
<field name="interval_number">5</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
</record>
|
||||||
|
<!-- Cron to handle sending of reminder email -->
|
||||||
|
<record id="online_sync_mail_cron" model="ir.cron">
|
||||||
|
<field name="name">Account: Journal online sync reminder</field>
|
||||||
|
<field name="model_id" ref="account.model_account_journal"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_send_reminder_email()</field>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
<field name="user_id" ref="base.user_root"/>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
</record>
|
||||||
|
<!-- Cron to delete unused connections -->
|
||||||
|
<record id="online_sync_unused_connection_cron" model="ir.cron">
|
||||||
|
<field name="name">Account: Journal online sync cleanup unused connections</field>
|
||||||
|
<field name="model_id" ref="odex30_account_online_synchronization.model_account_online_link"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_delete_unused_connection()</field>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
<field name="user_id" ref="base.user_root"/>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="bank_sync_activity_update_consent" model="mail.activity.type">
|
||||||
|
<field name="name">Bank Synchronization: Update consent</field>
|
||||||
|
<field name="icon">fa-university</field>
|
||||||
|
<field name="decoration_type">warning</field>
|
||||||
|
<field name="res_model">account.journal</field>
|
||||||
|
<field name="delay_count">0</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="bank_sync_consent_renewal" model="mail.message.subtype">
|
||||||
|
<field name="name">Consent Renewal</field>
|
||||||
|
<field name="default" eval="False"/>
|
||||||
|
<field name="hidden" eval="True"/>
|
||||||
|
<field name="res_model">account.journal</field>
|
||||||
|
<field name="sequence" eval="900"/>
|
||||||
|
<field name="track_recipients" eval="True"/>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- disable bank synchronisation links
|
||||||
|
UPDATE account_online_link
|
||||||
|
SET provider_data = '',
|
||||||
|
client_id = 'duplicate';
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="email_template_sync_reminder" model="mail.template">
|
||||||
|
<field name="name">Bank connection expiration reminder</field>
|
||||||
|
<field name="subject">Your bank connection is expiring soon</field>
|
||||||
|
<field name="email_from">{{ object.company_id.email_formatted or user.email_formatted }}</field>
|
||||||
|
<field name="email_to">{{ object.renewal_contact_email }}</field>
|
||||||
|
<field name="model_id" ref="odex30_account_online_synchronization.model_account_journal"/>
|
||||||
|
<field name="auto_delete" eval="True"/>
|
||||||
|
<field name="body_html" type="html">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #FFFFFF; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 16px; background-color: #FFFFFF; color: #454748; border-collapse:separate;">
|
||||||
|
<tbody>
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="min-width: 590px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||||
|
<tr>
|
||||||
|
<td valign="top" style="font-size: 13px;">
|
||||||
|
<div>
|
||||||
|
Hello,<br /><br />
|
||||||
|
The connection between <b><a t-att-href='object.get_base_url()' t-out="object.get_base_url() or ''">https://yourcompany.odoo.com</a></b> and <t t-out="object.account_online_link_id.name or ''">Belfius</t> <t t-if="not object.expiring_synchronization_due_day">expired.</t><t t-else="">expires in <t t-out="object.expiring_synchronization_due_day or ''">10</t> days.</t><br/>
|
||||||
|
<div style="margin: 16px 0px 16px 0px;">
|
||||||
|
<a t-attf-href="{{ website_url }}/renew_consent/{{ object.id }}?access_token={{object.access_token}}"
|
||||||
|
style="background-color: #4caf50; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;">
|
||||||
|
Renew Consent
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
Security Tip: Check that the domain name you are redirected to is: <b><a t-att-href='object.get_base_url()' t-out="object.get_base_url() or ''">https://yourcompany.odoo.com</a></b>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:center;">
|
||||||
|
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- POWERED BY -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="min-width: 590px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;">
|
||||||
|
<tr>
|
||||||
|
<td style="text-align: center; font-size: 13px;">
|
||||||
|
Powered by <a target="_blank" href="https://www.odoo.com?utm_source=db&utm_medium=auth" style="color: #875A7B;">ODEX</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; color: #454748; padding: 8px; border-collapse:separate;">
|
||||||
|
<tr>
|
||||||
|
<td style="text-align: center; font-size: 11px;">
|
||||||
|
PS: This is an automated email sent by ODEX Accounting to remind you before a bank sync consent expiration.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,9 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import account_bank_statement
|
||||||
|
from . import account_journal
|
||||||
|
from . import account_online
|
||||||
|
from . import company
|
||||||
|
from . import mail_activity_type
|
||||||
|
from . import partner
|
||||||
|
from . import bank_rec_widget
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
|
||||||
|
from odoo import api, fields, models, SUPERUSER_ID, tools, _
|
||||||
|
from odoo.tools import date_utils
|
||||||
|
from odoo.exceptions import UserError, ValidationError
|
||||||
|
|
||||||
|
STATEMENT_LINE_CREATION_BATCH_SIZE = 500 # When importing transactions, batch the process to commit after importing batch_size
|
||||||
|
|
||||||
|
|
||||||
|
class AccountBankStatementLine(models.Model):
|
||||||
|
_inherit = 'account.bank.statement.line'
|
||||||
|
|
||||||
|
online_transaction_identifier = fields.Char("Online Transaction Identifier", readonly=True)
|
||||||
|
online_partner_information = fields.Char(readonly=True)
|
||||||
|
online_account_id = fields.Many2one(comodel_name='account.online.account', readonly=True)
|
||||||
|
online_link_id = fields.Many2one(
|
||||||
|
comodel_name='account.online.link',
|
||||||
|
related='online_account_id.account_online_link_id',
|
||||||
|
store=True,
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
"""
|
||||||
|
Some transactions can be marked as "Zero Balancing",
|
||||||
|
which is a transaction used at the end of the day to summarize all the transactions
|
||||||
|
of the day. As we already manage the details of all the transactions, this one is not
|
||||||
|
useful and moreover create duplicates. To deal with that, we cancel the move and so
|
||||||
|
the bank statement line.
|
||||||
|
"""
|
||||||
|
# EXTEND account
|
||||||
|
bank_statement_lines = super().create(vals_list)
|
||||||
|
moves_to_cancel = self.env['account.move']
|
||||||
|
for bank_statement_line in bank_statement_lines:
|
||||||
|
transaction_details = json.loads(bank_statement_line.transaction_details) if bank_statement_line.transaction_details else {}
|
||||||
|
if not transaction_details.get('is_zero_balancing'):
|
||||||
|
continue
|
||||||
|
moves_to_cancel |= bank_statement_line.move_id
|
||||||
|
moves_to_cancel.button_cancel()
|
||||||
|
|
||||||
|
return bank_statement_lines
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _online_sync_bank_statement(self, transactions, online_account):
|
||||||
|
"""
|
||||||
|
build bank statement lines from a list of transaction and post messages is also post in the online_account of the journal.
|
||||||
|
:param transactions: A list of transactions that will be created.
|
||||||
|
The format is : [{
|
||||||
|
'id': online id, (unique ID for the transaction)
|
||||||
|
'date': transaction date, (The date of the transaction)
|
||||||
|
'name': transaction description, (The description)
|
||||||
|
'amount': transaction amount, (The amount of the transaction. Negative for debit, positive for credit)
|
||||||
|
}, ...]
|
||||||
|
:param online_account: The online account for this statement
|
||||||
|
Return: The number of imported transaction for the journal
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
lines_to_reconcile = self.env['account.bank.statement.line']
|
||||||
|
try:
|
||||||
|
for journal in online_account.journal_ids:
|
||||||
|
# Since the synchronization succeeded, set it as the bank_statements_source of the journal
|
||||||
|
journal.sudo().write({'bank_statements_source': 'online_sync'})
|
||||||
|
if not transactions:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sorted_transactions = sorted(transactions, key=lambda transaction: transaction['date'])
|
||||||
|
total = self.env.context.get('transactions_total') or sum([transaction['amount'] for transaction in transactions])
|
||||||
|
|
||||||
|
# For first synchronization, an opening line is created to fill the missing bank statement data
|
||||||
|
any_st_line = self.search_count([('journal_id', '=', journal.id)], limit=1)
|
||||||
|
journal_currency = journal.currency_id or journal.company_id.currency_id
|
||||||
|
# If there are neither statement and the ending balance != 0, we create an opening bank statement at the day of the oldest transaction.
|
||||||
|
# We set the sequence to >1 to ensure the computed internal_index will force its display before any other statement with the same date.
|
||||||
|
if not any_st_line and not journal_currency.is_zero(online_account.balance - total):
|
||||||
|
opening_st_line = self.with_context(skip_statement_line_cron_trigger=True).create({
|
||||||
|
'date': sorted_transactions[0]['date'],
|
||||||
|
'journal_id': journal.id,
|
||||||
|
'payment_ref': _("Opening statement: first synchronization"),
|
||||||
|
'amount': online_account.balance - total,
|
||||||
|
'sequence': 2,
|
||||||
|
})
|
||||||
|
lines_to_reconcile += opening_st_line
|
||||||
|
|
||||||
|
filtered_transactions = online_account._get_filtered_transactions(sorted_transactions)
|
||||||
|
|
||||||
|
do_commit = not (hasattr(threading.current_thread(), 'testing') and threading.current_thread().testing)
|
||||||
|
if filtered_transactions:
|
||||||
|
# split transactions import in batch and commit after each batch except in testing mode
|
||||||
|
for index in range(0, len(filtered_transactions), STATEMENT_LINE_CREATION_BATCH_SIZE):
|
||||||
|
lines_to_reconcile += self.with_user(SUPERUSER_ID).with_company(journal.company_id).with_context(skip_statement_line_cron_trigger=True).create(filtered_transactions[index:index + STATEMENT_LINE_CREATION_BATCH_SIZE])
|
||||||
|
if do_commit:
|
||||||
|
self.env.cr.commit()
|
||||||
|
# Set last sync date as the last transaction date
|
||||||
|
journal.account_online_account_id.sudo().write({'last_sync': filtered_transactions[-1]['date']})
|
||||||
|
|
||||||
|
if lines_to_reconcile:
|
||||||
|
# 'limit_time_real_cron' defaults to -1.
|
||||||
|
# Manual fallback applied for non-POSIX systems where this key is disabled (set to None).
|
||||||
|
cron_limit_time = tools.config['limit_time_real_cron'] or -1
|
||||||
|
limit_time = (cron_limit_time if cron_limit_time > 0 else 180) - (time.time() - start_time)
|
||||||
|
if limit_time > 0:
|
||||||
|
lines_to_reconcile._cron_try_auto_reconcile_statement_lines(limit_time=limit_time)
|
||||||
|
# Catch any configuration error that would prevent creating the entries, reset fetching_status flag and re-raise the error
|
||||||
|
# Otherwise flag is never reset and user is under the impression that we are still fetching transactions
|
||||||
|
except (UserError, ValidationError) as e:
|
||||||
|
self.env.cr.rollback()
|
||||||
|
online_account.account_online_link_id._log_information('error', subject=_("Error"), message=str(e))
|
||||||
|
self.env.cr.commit()
|
||||||
|
raise
|
||||||
|
return lines_to_reconcile
|
||||||
|
|
@ -0,0 +1,380 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from requests.exceptions import RequestException, Timeout
|
||||||
|
|
||||||
|
from odoo import api, fields, models, tools, _
|
||||||
|
from odoo.exceptions import UserError, ValidationError, RedirectWarning
|
||||||
|
from odoo.tools import SQL
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountJournal(models.Model):
|
||||||
|
_inherit = "account.journal"
|
||||||
|
|
||||||
|
def __get_bank_statements_available_sources(self):
|
||||||
|
rslt = super(AccountJournal, self).__get_bank_statements_available_sources()
|
||||||
|
rslt.append(("online_sync", _("Online Synchronization")))
|
||||||
|
return rslt
|
||||||
|
|
||||||
|
next_link_synchronization = fields.Datetime("Online Link Next synchronization", related='account_online_link_id.next_refresh')
|
||||||
|
expiring_synchronization_date = fields.Date(related='account_online_link_id.expiring_synchronization_date')
|
||||||
|
expiring_synchronization_due_day = fields.Integer(compute='_compute_expiring_synchronization_due_day')
|
||||||
|
account_online_account_id = fields.Many2one('account.online.account', copy=False, ondelete='set null')
|
||||||
|
account_online_link_id = fields.Many2one('account.online.link', related='account_online_account_id.account_online_link_id', readonly=True, store=True)
|
||||||
|
account_online_link_state = fields.Selection(related="account_online_link_id.state", readonly=True)
|
||||||
|
renewal_contact_email = fields.Char(
|
||||||
|
string='Connection Requests',
|
||||||
|
help='Comma separated list of email addresses to send consent renewal notifications 15, 3 and 1 days before expiry',
|
||||||
|
default=lambda self: self.env.user.email,
|
||||||
|
)
|
||||||
|
online_sync_fetching_status = fields.Selection(related="account_online_account_id.fetching_status", readonly=True)
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
# When changing the bank_statement_source, unlink the connection if there is any
|
||||||
|
if 'bank_statements_source' in vals and vals.get('bank_statements_source') != 'online_sync':
|
||||||
|
for journal in self:
|
||||||
|
if journal.bank_statements_source == 'online_sync':
|
||||||
|
# unlink current connection
|
||||||
|
vals['account_online_account_id'] = False
|
||||||
|
journal.account_online_link_id.has_unlinked_accounts = True
|
||||||
|
return super().write(vals)
|
||||||
|
|
||||||
|
@api.depends('expiring_synchronization_date')
|
||||||
|
def _compute_expiring_synchronization_due_day(self):
|
||||||
|
for record in self:
|
||||||
|
if record.expiring_synchronization_date:
|
||||||
|
due_day_delta = record.expiring_synchronization_date - fields.Date.context_today(record)
|
||||||
|
record.expiring_synchronization_due_day = due_day_delta.days
|
||||||
|
else:
|
||||||
|
record.expiring_synchronization_due_day = 0
|
||||||
|
|
||||||
|
def _fill_bank_cash_dashboard_data(self, dashboard_data):
|
||||||
|
super()._fill_bank_cash_dashboard_data(dashboard_data)
|
||||||
|
# Caching data to avoid one call per journal
|
||||||
|
self.browse(list(dashboard_data.keys())).fetch(['type', 'account_online_account_id'])
|
||||||
|
for journal_id, journal_data in dashboard_data.items():
|
||||||
|
journal = self.browse(journal_id)
|
||||||
|
journal_data['display_connect_bank_in_dashboard'] = journal.type in ('bank', 'credit') \
|
||||||
|
and not journal.account_online_account_id \
|
||||||
|
and journal.company_id.id == self.env.company.id
|
||||||
|
|
||||||
|
@api.constrains('account_online_account_id')
|
||||||
|
def _check_account_online_account_id(self):
|
||||||
|
for journal in self:
|
||||||
|
if len(journal.account_online_account_id.journal_ids) > 1:
|
||||||
|
raise ValidationError(_('You cannot have two journals associated with the same Online Account.'))
|
||||||
|
|
||||||
|
def _fetch_online_transactions(self):
|
||||||
|
for journal in self:
|
||||||
|
try:
|
||||||
|
journal.account_online_link_id._pop_connection_state_details(journal=journal)
|
||||||
|
journal.manual_sync()
|
||||||
|
# for cron jobs it is usually recommended committing after each iteration,
|
||||||
|
# so that a later error or job timeout doesn't discard previous work
|
||||||
|
self.env.cr.commit()
|
||||||
|
except (UserError, RedirectWarning):
|
||||||
|
# We need to rollback here otherwise the next iteration will still have the error when trying to commit
|
||||||
|
self.env.cr.rollback()
|
||||||
|
|
||||||
|
def fetch_online_sync_favorite_institutions(self):
|
||||||
|
self.ensure_one()
|
||||||
|
timeout = int(self.env['ir.config_parameter'].sudo().get_param('odex30_account_online_synchronization.request_timeout')) or 60
|
||||||
|
endpoint_url = self.env['account.online.link']._get_odoofin_url('/proxy/v1/get_dashboard_institutions')
|
||||||
|
params = {'country': self.sudo().company_id.account_fiscal_country_id.code, 'limit': 28}
|
||||||
|
try:
|
||||||
|
resp = requests.post(endpoint_url, json=params, timeout=timeout)
|
||||||
|
resp_dict = resp.json()['result']
|
||||||
|
for institution in resp_dict:
|
||||||
|
if institution['picture'].startswith('/'):
|
||||||
|
institution['picture'] = self.env['account.online.link']._get_odoofin_url(institution['picture'])
|
||||||
|
return resp_dict
|
||||||
|
except (Timeout, ConnectionError, RequestException, ValueError) as e:
|
||||||
|
_logger.warning(e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_fetch_waiting_online_transactions(self):
|
||||||
|
""" This method is only called when the user fetch transactions asynchronously.
|
||||||
|
We only fetch transactions on synchronizations that are in "waiting" status.
|
||||||
|
Once the synchronization is done, the status is changed for "done".
|
||||||
|
We have to that to avoid having too much logic in the same cron function to do
|
||||||
|
2 different things. This cron should only be used for asynchronous fetchs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 'limit_time_real_cron' and 'limit_time_real' default respectively to -1 and 120.
|
||||||
|
# Manual fallbacks applied for non-POSIX systems where this key is disabled (set to None).
|
||||||
|
limit_time = tools.config['limit_time_real_cron'] or -1
|
||||||
|
if limit_time <= 0:
|
||||||
|
limit_time = tools.config['limit_time_real'] or 120
|
||||||
|
journals = self.search([
|
||||||
|
'|',
|
||||||
|
('online_sync_fetching_status', 'in', ('planned', 'waiting')),
|
||||||
|
'&',
|
||||||
|
('online_sync_fetching_status', '=', 'processing'),
|
||||||
|
('account_online_link_id.last_refresh', '<', fields.Datetime.now() - relativedelta(seconds=limit_time)),
|
||||||
|
])
|
||||||
|
journals.with_context(cron=True)._fetch_online_transactions()
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_fetch_online_transactions(self):
|
||||||
|
""" This method is called by the cron (by default twice a day) to fetch (for all journals)
|
||||||
|
the new transactions.
|
||||||
|
"""
|
||||||
|
journals = self.search([('account_online_account_id', '!=', False)])
|
||||||
|
journals.with_context(cron=True)._fetch_online_transactions()
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_send_reminder_email(self):
|
||||||
|
for journal in self.search([('account_online_account_id', '!=', False)]):
|
||||||
|
if journal.expiring_synchronization_due_day in {1, 3, 15}:
|
||||||
|
journal.action_send_reminder()
|
||||||
|
|
||||||
|
def manual_sync(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.account_online_link_id:
|
||||||
|
account = self.account_online_account_id
|
||||||
|
return self.account_online_link_id._fetch_transactions(accounts=account)
|
||||||
|
|
||||||
|
def unlink(self):
|
||||||
|
"""
|
||||||
|
Override of the unlink method.
|
||||||
|
That's useful to unlink account.online.account too.
|
||||||
|
"""
|
||||||
|
if self.account_online_account_id:
|
||||||
|
self.account_online_account_id.unlink()
|
||||||
|
return super(AccountJournal, self).unlink()
|
||||||
|
|
||||||
|
def action_configure_bank_journal(self):
|
||||||
|
"""
|
||||||
|
Override the "action_configure_bank_journal" and change the flow for the
|
||||||
|
"Configure" button in dashboard.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
return self.env['account.online.link'].action_new_synchronization()
|
||||||
|
|
||||||
|
def action_open_account_online_link(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': self.account_online_link_id.name,
|
||||||
|
'res_model': 'account.online.link',
|
||||||
|
'target': 'main',
|
||||||
|
'view_mode': 'form',
|
||||||
|
'views': [[False, 'form']],
|
||||||
|
'res_id': self.account_online_link_id.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_extend_consent(self):
|
||||||
|
"""
|
||||||
|
Extend the consent of the user by redirecting him to update his credentials
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
return self.account_online_link_id._open_iframe(
|
||||||
|
mode='updateCredentials',
|
||||||
|
include_param={
|
||||||
|
'account_online_identifier': self.account_online_account_id.online_identifier,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_reconnect_online_account(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return self.account_online_link_id.action_reconnect_account()
|
||||||
|
|
||||||
|
def action_send_reminder(self):
|
||||||
|
self.ensure_one()
|
||||||
|
self._portal_ensure_token()
|
||||||
|
template = self.env.ref('odex30_account_online_synchronization.email_template_sync_reminder')
|
||||||
|
subtype = self.env.ref('odex30_account_online_synchronization.bank_sync_consent_renewal')
|
||||||
|
self.message_post_with_source(source_ref=template, subtype_id=subtype.id)
|
||||||
|
|
||||||
|
def action_open_missing_transaction_wizard(self):
|
||||||
|
""" This method allows to open the wizard to fetch the missing
|
||||||
|
transactions and the pending ones.
|
||||||
|
Depending on where the function is called, we'll receive
|
||||||
|
one journal or none of them.
|
||||||
|
If we receive more or less than one journal, we do not set
|
||||||
|
it on the wizard, the user should select it by himself.
|
||||||
|
|
||||||
|
:return: An action opening the wizard.
|
||||||
|
"""
|
||||||
|
journal_id = None
|
||||||
|
if len(self) == 1:
|
||||||
|
if not self.account_online_account_id or self.account_online_link_state != 'connected':
|
||||||
|
raise UserError(_("You can't find missing transactions for a journal that isn't connected."))
|
||||||
|
|
||||||
|
journal_id = self.id
|
||||||
|
|
||||||
|
wizard = self.env['account.missing.transaction.wizard'].create({'journal_id': journal_id})
|
||||||
|
return {
|
||||||
|
'name': _("Find Missing Transactions"),
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'account.missing.transaction.wizard',
|
||||||
|
'res_id': wizard.id,
|
||||||
|
'views': [(False, 'form')],
|
||||||
|
'target': 'new',
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_open_duplicate_transaction_wizard(self, from_date=None):
|
||||||
|
""" This method allows to open the wizard to find duplicate transactions.
|
||||||
|
:param from_date: date from with we must check for duplicates.
|
||||||
|
|
||||||
|
:return: An action opening the wizard.
|
||||||
|
"""
|
||||||
|
wizard = self.env['account.duplicate.transaction.wizard'].create({
|
||||||
|
'journal_id': self.id if len(self) == 1 else None,
|
||||||
|
**({'date': from_date} if from_date else {}),
|
||||||
|
})
|
||||||
|
return wizard._get_records_action(name=_("Find Duplicate Transactions"))
|
||||||
|
|
||||||
|
def _has_duplicate_transactions(self, date_from):
|
||||||
|
""" Has any transaction with
|
||||||
|
- same amount &
|
||||||
|
- same date &
|
||||||
|
- same account number
|
||||||
|
We do not check on online_transaction_identifier because this is called after the fetch
|
||||||
|
where transitions would already have been filtered on existing online_transaction_identifier.
|
||||||
|
|
||||||
|
:param from_date: date from with we must check for duplicates.
|
||||||
|
"""
|
||||||
|
self.env.cr.execute(SQL.join(SQL(''), [
|
||||||
|
self._get_duplicate_amount_date_account_transactions_query(date_from),
|
||||||
|
SQL('LIMIT 1'),
|
||||||
|
]))
|
||||||
|
return bool(self.env.cr.rowcount)
|
||||||
|
|
||||||
|
def _get_duplicate_transactions(self, date_from):
|
||||||
|
"""Find all transaction with
|
||||||
|
- same amount &
|
||||||
|
- same date &
|
||||||
|
- same account number
|
||||||
|
or
|
||||||
|
- same transaction id
|
||||||
|
|
||||||
|
:param from_date: date from with we must check for duplicates.
|
||||||
|
"""
|
||||||
|
query = SQL.join(SQL(''), [
|
||||||
|
self._get_duplicate_amount_date_account_transactions_query(date_from),
|
||||||
|
SQL('UNION'),
|
||||||
|
self._get_duplicate_online_transaction_identifier_transactions_query(date_from),
|
||||||
|
SQL('ORDER BY ids'),
|
||||||
|
])
|
||||||
|
return [res[0] for res in self.env.execute_query(query)]
|
||||||
|
|
||||||
|
def _get_duplicate_amount_date_account_transactions_query(self, date_from):
|
||||||
|
self.ensure_one()
|
||||||
|
return SQL('''
|
||||||
|
SELECT ARRAY_AGG(st_line.id ORDER BY st_line.id) AS ids
|
||||||
|
FROM account_bank_statement_line st_line
|
||||||
|
JOIN account_move move ON move.id = st_line.move_id
|
||||||
|
WHERE st_line.journal_id = %(journal_id)s AND move.date >= %(date_from)s
|
||||||
|
GROUP BY st_line.currency_id, st_line.amount, st_line.account_number, move.date
|
||||||
|
HAVING count(st_line.id) > 1
|
||||||
|
''',
|
||||||
|
journal_id=self.id,
|
||||||
|
date_from=date_from,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_duplicate_online_transaction_identifier_transactions_query(self, date_from):
|
||||||
|
return SQL('''
|
||||||
|
SELECT ARRAY_AGG(st_line.id ORDER BY st_line.id) AS ids
|
||||||
|
FROM account_bank_statement_line st_line
|
||||||
|
JOIN account_move move ON move.id = st_line.move_id
|
||||||
|
WHERE st_line.journal_id = %(journal_id)s AND
|
||||||
|
move.date >= %(prior_date)s AND
|
||||||
|
st_line.online_transaction_identifier IS NOT NULL
|
||||||
|
GROUP BY st_line.online_transaction_identifier
|
||||||
|
HAVING count(st_line.id) > 1 AND BOOL_OR(move.date >= %(date_from)s) -- at least one date is > date_from
|
||||||
|
''',
|
||||||
|
journal_id=self.id,
|
||||||
|
date_from=date_from,
|
||||||
|
prior_date=date_from - relativedelta(months=3), # allow 1 of duplicate statements to be older than "from" date
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_open_dashboard_asynchronous_action(self):
|
||||||
|
""" This method allows to open action asynchronously
|
||||||
|
during the fetching process.
|
||||||
|
When a user clicks on the Fetch Transactions button in
|
||||||
|
the dashboard, we fetch the transactions asynchronously
|
||||||
|
and save connection state details on the synchronization.
|
||||||
|
This action allows the user to open the action saved in
|
||||||
|
the connection state details.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if not self.account_online_account_id:
|
||||||
|
raise UserError(_("You can only execute this action for bank-synchronized journals."))
|
||||||
|
|
||||||
|
connection_state_details = self.account_online_link_id._pop_connection_state_details(journal=self)
|
||||||
|
if connection_state_details and connection_state_details.get('action'):
|
||||||
|
if connection_state_details.get('error_type') == 'redirect_warning':
|
||||||
|
self.env.cr.commit()
|
||||||
|
raise RedirectWarning(connection_state_details['error_message'], connection_state_details['action'], _('Report Issue'))
|
||||||
|
else:
|
||||||
|
return connection_state_details['action']
|
||||||
|
|
||||||
|
return {'type': 'ir.actions.client', 'tag': 'soft_reload'}
|
||||||
|
|
||||||
|
def _get_journal_dashboard_data_batched(self):
|
||||||
|
dashboard_data = super()._get_journal_dashboard_data_batched()
|
||||||
|
for journal in self.filtered(lambda j: j.type in ('bank', 'credit')):
|
||||||
|
if journal.account_online_account_id:
|
||||||
|
if journal.company_id.id not in self.env.companies.ids:
|
||||||
|
continue
|
||||||
|
connection_state_details = journal.account_online_link_id._get_connection_state_details(journal=journal)
|
||||||
|
if not connection_state_details and journal.account_online_account_id.fetching_status in ('waiting', 'processing'):
|
||||||
|
connection_state_details = {'status': 'fetching'}
|
||||||
|
dashboard_data[journal.id]['connection_state_details'] = connection_state_details
|
||||||
|
dashboard_data[journal.id]['show_sync_actions'] = journal.account_online_link_id.show_sync_actions
|
||||||
|
return dashboard_data
|
||||||
|
|
||||||
|
def get_related_connection_state_details(self):
|
||||||
|
""" This method allows JS widget to get the last connection state details
|
||||||
|
It's useful if the user wasn't on the dashboard when we send the message
|
||||||
|
by websocket that the asynchronous flow is finished.
|
||||||
|
In case we don't have a connection state details and if the fetching
|
||||||
|
status is set on "waiting" or "processing". We're returning that the sync
|
||||||
|
is currently fetching.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
connection_state_details = self.account_online_link_id._get_connection_state_details(journal=self)
|
||||||
|
if not connection_state_details and self.account_online_account_id.fetching_status in ('waiting', 'processing'):
|
||||||
|
connection_state_details = {'status': 'fetching'}
|
||||||
|
return connection_state_details
|
||||||
|
|
||||||
|
def _consume_connection_state_details(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.account_online_link_id and self.env.user.has_group('account.group_account_manager'):
|
||||||
|
# In case we have a bank synchronization connected to the journal
|
||||||
|
# we want to remove the last connection state because it means that we
|
||||||
|
# have "mark as read" this state, and we don't want to display it again to
|
||||||
|
# the user.
|
||||||
|
self.account_online_link_id._pop_connection_state_details(journal=self)
|
||||||
|
|
||||||
|
def open_action(self):
|
||||||
|
# Extends 'odex30_account_accountant'
|
||||||
|
if not self._context.get('action_name') and self.type == 'bank' and self.bank_statements_source == 'online_sync':
|
||||||
|
self._consume_connection_state_details()
|
||||||
|
return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
|
||||||
|
default_context={'search_default_journal_id': self.id},
|
||||||
|
)
|
||||||
|
return super().open_action()
|
||||||
|
|
||||||
|
def action_open_reconcile(self):
|
||||||
|
# Extends 'odex30_account_accountant'
|
||||||
|
self._consume_connection_state_details()
|
||||||
|
return super().action_open_reconcile()
|
||||||
|
|
||||||
|
def action_open_bank_transactions(self):
|
||||||
|
# Extends 'odex30_account_accountant'
|
||||||
|
self._consume_connection_state_details()
|
||||||
|
return super().action_open_bank_transactions()
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _toggle_asynchronous_fetching_cron(self):
|
||||||
|
cron = self.env.ref('odex30_account_online_synchronization.online_sync_cron_waiting_synchronization', raise_if_not_found=False)
|
||||||
|
if cron:
|
||||||
|
cron.sudo().toggle(model=self._name, domain=[('account_online_account_id', '!=', False)])
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,15 @@
|
||||||
|
from odoo import models
|
||||||
|
|
||||||
|
|
||||||
|
class BankRecWidget(models.Model):
|
||||||
|
_inherit = 'bank.rec.widget'
|
||||||
|
|
||||||
|
def _action_validate(self):
|
||||||
|
# EXTENDS odex30_account_accountant
|
||||||
|
super()._action_validate()
|
||||||
|
line = self.st_line_id
|
||||||
|
if line.partner_id and line.online_partner_information:
|
||||||
|
|
||||||
|
value_merchant = line.partner_id.online_partner_information or line.online_partner_information
|
||||||
|
value_merchant = value_merchant if value_merchant == line.online_partner_information else False
|
||||||
|
line.partner_id.online_partner_information = value_merchant
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResCompany(models.Model):
|
||||||
|
_inherit = "res.company"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def setting_init_bank_account_action(self):
|
||||||
|
|
||||||
|
return self.env['account.online.link'].action_new_synchronization()
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
|
||||||
|
class MailActivityType(models.Model):
|
||||||
|
_inherit = "mail.activity.type"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_model_info_by_xmlid(self):
|
||||||
|
info = super()._get_model_info_by_xmlid()
|
||||||
|
info['odex30_account_online_synchronization.bank_sync_activity_update_consent'] = {
|
||||||
|
'res_model': 'account.journal',
|
||||||
|
'unlink': False,
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import werkzeug.urls
|
||||||
|
|
||||||
|
|
||||||
|
class ODEXFinAuth(requests.auth.AuthBase):
|
||||||
|
|
||||||
|
def __init__(self, record=None):
|
||||||
|
self.access_token = record and record.access_token or False
|
||||||
|
self.refresh_token = record and record.refresh_token or False
|
||||||
|
self.client_id = record and record.client_id or False
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
# We don't sign request that still don't have a client_id/refresh_token
|
||||||
|
if not self.client_id or not self.refresh_token:
|
||||||
|
return request
|
||||||
|
# craft the message (timestamp|url path|client_id|access_token|query params|body content)
|
||||||
|
msg_timestamp = int(time.time())
|
||||||
|
parsed_url = werkzeug.urls.url_parse(request.path_url)
|
||||||
|
|
||||||
|
body = request.body
|
||||||
|
if isinstance(body, bytes):
|
||||||
|
body = body.decode('utf-8')
|
||||||
|
body = json.loads(body)
|
||||||
|
|
||||||
|
message = '%s|%s|%s|%s|%s|%s' % (
|
||||||
|
msg_timestamp, # timestamp
|
||||||
|
parsed_url.path, # url path
|
||||||
|
self.client_id,
|
||||||
|
self.access_token,
|
||||||
|
json.dumps(werkzeug.urls.url_decode(parsed_url.query), sort_keys=True), # url query params sorted by key
|
||||||
|
json.dumps(body, sort_keys=True)) # http request body
|
||||||
|
|
||||||
|
h = hmac.new(base64.b64decode(self.refresh_token), message.encode('utf-8'), digestmod=hashlib.sha256)
|
||||||
|
|
||||||
|
request.headers.update({
|
||||||
|
'odoofin-client-id': self.client_id,
|
||||||
|
'odoofin-access-token': self.access_token,
|
||||||
|
'odoofin-signature': base64.b64encode(h.digest()),
|
||||||
|
'odoofin-timestamp': msg_timestamp,
|
||||||
|
})
|
||||||
|
return request
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class ResPartner(models.Model):
|
||||||
|
_inherit = 'res.partner'
|
||||||
|
|
||||||
|
online_partner_information = fields.Char(readonly=True)
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
<record model="ir.rule" id="account_online_sync_link_rule">
|
||||||
|
<field name="name">Account online link company rule</field>
|
||||||
|
<field name="model_id" ref="model_account_online_link"/>
|
||||||
|
<field name="global" eval="True"/>
|
||||||
|
<field name="domain_force">[('company_id', 'parent_of', company_ids)]</field>
|
||||||
|
</record>
|
||||||
|
<record model="ir.rule" id="account_online_sync_account_rule">
|
||||||
|
<field name="name">Online account company rule</field>
|
||||||
|
<field name="model_id" ref="model_account_online_account"/>
|
||||||
|
<field name="global" eval="True"/>
|
||||||
|
<field name="domain_force">[('account_online_link_id.company_id','parent_of', company_ids)]</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue