mirror of
https://github.com/bringout/oca-ocb-accounting.git
synced 2026-04-22 22:21:59 +02:00
Initial commit: Accounting packages
This commit is contained in:
commit
4ef34c2317
2661 changed files with 1709616 additions and 0 deletions
39
odoo-bringout-oca-ocb-account/account/models/__init__.py
Normal file
39
odoo-bringout-oca-ocb-account/account/models/__init__.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import sequence_mixin
|
||||
from . import partner
|
||||
from . import res_partner_bank
|
||||
from . import account_account_tag
|
||||
from . import account_account
|
||||
from . import account_journal
|
||||
from . import account_tax
|
||||
from . import account_reconcile_model
|
||||
from . import account_payment_term
|
||||
from . import account_move
|
||||
from . import account_move_line
|
||||
from . import account_move_line_tax_details
|
||||
from . import account_partial_reconcile
|
||||
from . import account_full_reconcile
|
||||
from . import account_payment
|
||||
from . import account_payment_method
|
||||
from . import account_bank_statement
|
||||
from . import account_bank_statement_line
|
||||
from . import chart_template
|
||||
from . import account_analytic_account
|
||||
from . import account_analytic_distribution_model
|
||||
from . import account_analytic_plan
|
||||
from . import account_analytic_line
|
||||
from . import account_journal_dashboard
|
||||
from . import product
|
||||
from . import company
|
||||
from . import res_config_settings
|
||||
from . import account_cash_rounding
|
||||
from . import account_incoterms
|
||||
from . import decimal_precision
|
||||
from . import digest
|
||||
from . import res_users
|
||||
from . import ir_actions_report
|
||||
from . import ir_attachment
|
||||
from . import res_currency
|
||||
from . import mail_thread
|
||||
from . import account_report
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
974
odoo-bringout-oca-ocb-account/account/models/account_account.py
Normal file
974
odoo-bringout-oca-ocb-account/account/models/account_account.py
Normal file
|
|
@ -0,0 +1,974 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import api, fields, models, _, tools
|
||||
from odoo.osv import expression
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from bisect import bisect_left
|
||||
from collections import defaultdict
|
||||
import re
|
||||
|
||||
ACCOUNT_REGEX = re.compile(r'(?:(\S*\d+\S*))?(.*)')
|
||||
ACCOUNT_CODE_REGEX = re.compile(r'^[A-Za-z0-9.]+$')
|
||||
|
||||
class AccountAccount(models.Model):
|
||||
_name = "account.account"
|
||||
_inherit = ['mail.thread']
|
||||
_description = "Account"
|
||||
_order = "is_off_balance, code, company_id"
|
||||
_check_company_auto = True
|
||||
|
||||
@api.constrains('account_type', 'reconcile')
|
||||
def _check_reconcile(self):
|
||||
for account in self:
|
||||
if account.account_type in ('asset_receivable', 'liability_payable') and not account.reconcile:
|
||||
raise ValidationError(_('You cannot have a receivable/payable account that is not reconcilable. (account code: %s)', account.code))
|
||||
|
||||
@api.constrains('account_type')
|
||||
def _check_account_type_unique_current_year_earning(self):
|
||||
result = self._read_group(
|
||||
domain=[('account_type', '=', 'equity_unaffected')],
|
||||
fields=['company_id', 'ids:array_agg(id)'],
|
||||
groupby=['company_id'],
|
||||
)
|
||||
for res in result:
|
||||
if res.get('company_id_count', 0) >= 2:
|
||||
account_unaffected_earnings = self.browse(res['ids'])
|
||||
raise ValidationError(_('You cannot have more than one account with "Current Year Earnings" as type. (accounts: %s)', [a.code for a in account_unaffected_earnings]))
|
||||
|
||||
name = fields.Char(string="Account Name", required=True, index='trigram', tracking=True)
|
||||
currency_id = fields.Many2one('res.currency', string='Account Currency', tracking=True,
|
||||
help="Forces all journal items in this account to have a specific currency (i.e. bank journals). If no currency is set, entries can use any currency.")
|
||||
company_currency_id = fields.Many2one(related='company_id.currency_id')
|
||||
code = fields.Char(size=64, required=True, tracking=True, unaccent=False)
|
||||
deprecated = fields.Boolean(default=False, tracking=True)
|
||||
used = fields.Boolean(compute='_compute_used', search='_search_used')
|
||||
account_type = fields.Selection(
|
||||
selection=[
|
||||
("asset_receivable", "Receivable"),
|
||||
("asset_cash", "Bank and Cash"),
|
||||
("asset_current", "Current Assets"),
|
||||
("asset_non_current", "Non-current Assets"),
|
||||
("asset_prepayments", "Prepayments"),
|
||||
("asset_fixed", "Fixed Assets"),
|
||||
("liability_payable", "Payable"),
|
||||
("liability_credit_card", "Credit Card"),
|
||||
("liability_current", "Current Liabilities"),
|
||||
("liability_non_current", "Non-current Liabilities"),
|
||||
("equity", "Equity"),
|
||||
("equity_unaffected", "Current Year Earnings"),
|
||||
("income", "Income"),
|
||||
("income_other", "Other Income"),
|
||||
("expense", "Expenses"),
|
||||
("expense_depreciation", "Depreciation"),
|
||||
("expense_direct_cost", "Cost of Revenue"),
|
||||
("off_balance", "Off-Balance Sheet"),
|
||||
],
|
||||
string="Type", tracking=True,
|
||||
required=True,
|
||||
compute='_compute_account_type', store=True, readonly=False, precompute=True, index=True,
|
||||
help="Account Type is used for information purpose, to generate country-specific legal reports, and set the rules to close a fiscal year and generate opening entries."
|
||||
)
|
||||
include_initial_balance = fields.Boolean(string="Bring Accounts Balance Forward",
|
||||
help="Used in reports to know if we should consider journal items from the beginning of time instead of from the fiscal year only. Account types that should be reset to zero at each new fiscal year (like expenses, revenue..) should not have this option set.",
|
||||
compute="_compute_include_initial_balance",
|
||||
store=True)
|
||||
internal_group = fields.Selection(
|
||||
selection=[
|
||||
('equity', 'Equity'),
|
||||
('asset', 'Asset'),
|
||||
('liability', 'Liability'),
|
||||
('income', 'Income'),
|
||||
('expense', 'Expense'),
|
||||
('off_balance', 'Off Balance'),
|
||||
],
|
||||
string="Internal Group", readonly=True, compute="_compute_internal_group", store=True
|
||||
)
|
||||
#has_unreconciled_entries = fields.Boolean(compute='_compute_has_unreconciled_entries',
|
||||
# help="The account has at least one unreconciled debit and credit since last time the invoices & payments matching was performed.")
|
||||
reconcile = fields.Boolean(string='Allow Reconciliation', tracking=True,
|
||||
compute='_compute_reconcile', store=True, readonly=False,
|
||||
help="Check this box if this account allows invoices & payments matching of journal items.")
|
||||
tax_ids = fields.Many2many('account.tax', 'account_account_tax_default_rel',
|
||||
'account_id', 'tax_id', string='Default Taxes',
|
||||
check_company=True,
|
||||
context={'append_type_to_tax_name': True})
|
||||
note = fields.Text('Internal Notes', tracking=True)
|
||||
company_id = fields.Many2one('res.company', string='Company', required=True, readonly=True,
|
||||
default=lambda self: self.env.company)
|
||||
tag_ids = fields.Many2many('account.account.tag', 'account_account_account_tag', string='Tags', help="Optional tags you may want to assign for custom reporting", ondelete='restrict')
|
||||
group_id = fields.Many2one('account.group', compute='_compute_account_group', store=True, readonly=True,
|
||||
help="Account prefixes can determine account groups.")
|
||||
root_id = fields.Many2one('account.root', compute='_compute_account_root', store=True)
|
||||
allowed_journal_ids = fields.Many2many('account.journal', string="Allowed Journals", help="Define in which journals this account can be used. If empty, can be used in all journals.")
|
||||
opening_debit = fields.Monetary(string="Opening Debit", compute='_compute_opening_debit_credit', inverse='_set_opening_debit', currency_field='company_currency_id')
|
||||
opening_credit = fields.Monetary(string="Opening Credit", compute='_compute_opening_debit_credit', inverse='_set_opening_credit', currency_field='company_currency_id')
|
||||
opening_balance = fields.Monetary(string="Opening Balance", compute='_compute_opening_debit_credit', inverse='_set_opening_balance', currency_field='company_currency_id')
|
||||
|
||||
is_off_balance = fields.Boolean(compute='_compute_is_off_balance', default=False, store=True, readonly=True)
|
||||
|
||||
current_balance = fields.Float(compute='_compute_current_balance')
|
||||
related_taxes_amount = fields.Integer(compute='_compute_related_taxes_amount')
|
||||
|
||||
_sql_constraints = [
|
||||
('code_company_uniq', 'unique (code,company_id)', 'The code of the account must be unique per company !')
|
||||
]
|
||||
|
||||
non_trade = fields.Boolean(default=False,
|
||||
help="If set, this account will belong to Non Trade Receivable/Payable in reports and filters.\n"
|
||||
"If not, this account will belong to Trade Receivable/Payable in reports and filters.")
|
||||
|
||||
@api.constrains('reconcile', 'internal_group', 'tax_ids')
|
||||
def _constrains_reconcile(self):
|
||||
for record in self:
|
||||
if record.internal_group == 'off_balance':
|
||||
if record.reconcile:
|
||||
raise UserError(_('An Off-Balance account can not be reconcilable'))
|
||||
if record.tax_ids:
|
||||
raise UserError(_('An Off-Balance account can not have taxes'))
|
||||
|
||||
@api.constrains('allowed_journal_ids')
|
||||
def _constrains_allowed_journal_ids(self):
|
||||
self.env['account.move.line'].flush_model(['account_id', 'journal_id'])
|
||||
self.flush_recordset(['allowed_journal_ids'])
|
||||
self._cr.execute("""
|
||||
SELECT aml.id
|
||||
FROM account_move_line aml
|
||||
WHERE aml.account_id in %s
|
||||
AND EXISTS (SELECT 1 FROM account_account_account_journal_rel WHERE account_account_id = aml.account_id)
|
||||
AND NOT EXISTS (SELECT 1 FROM account_account_account_journal_rel WHERE account_account_id = aml.account_id AND account_journal_id = aml.journal_id)
|
||||
""", [tuple(self.ids)])
|
||||
ids = self._cr.fetchall()
|
||||
if ids:
|
||||
raise ValidationError(_('Some journal items already exist with this account but in other journals than the allowed ones.'))
|
||||
|
||||
@api.constrains('currency_id')
|
||||
def _check_journal_consistency(self):
|
||||
''' Ensure the currency set on the journal is the same as the currency set on the
|
||||
linked accounts.
|
||||
'''
|
||||
if not self:
|
||||
return
|
||||
|
||||
self.env['account.account'].flush_model(['currency_id'])
|
||||
self.env['account.journal'].flush_model([
|
||||
'currency_id',
|
||||
'default_account_id',
|
||||
'suspense_account_id',
|
||||
])
|
||||
self.env['account.payment.method'].flush_model(['payment_type'])
|
||||
self.env['account.payment.method.line'].flush_model(['payment_method_id', 'payment_account_id'])
|
||||
|
||||
self._cr.execute('''
|
||||
SELECT
|
||||
account.id,
|
||||
journal.id
|
||||
FROM account_journal journal
|
||||
JOIN res_company company ON company.id = journal.company_id
|
||||
JOIN account_account account ON account.id = journal.default_account_id
|
||||
WHERE journal.currency_id IS NOT NULL
|
||||
AND journal.currency_id != company.currency_id
|
||||
AND account.currency_id != journal.currency_id
|
||||
AND account.id IN %(accounts)s
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
account.id,
|
||||
journal.id
|
||||
FROM account_journal journal
|
||||
JOIN res_company company ON company.id = journal.company_id
|
||||
JOIN account_payment_method_line apml ON apml.journal_id = journal.id
|
||||
JOIN account_payment_method apm on apm.id = apml.payment_method_id
|
||||
JOIN account_account account ON account.id = COALESCE(apml.payment_account_id, company.account_journal_payment_debit_account_id)
|
||||
WHERE journal.currency_id IS NOT NULL
|
||||
AND journal.currency_id != company.currency_id
|
||||
AND account.currency_id != journal.currency_id
|
||||
AND apm.payment_type = 'inbound'
|
||||
AND account.id IN %(accounts)s
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
account.id,
|
||||
journal.id
|
||||
FROM account_journal journal
|
||||
JOIN res_company company ON company.id = journal.company_id
|
||||
JOIN account_payment_method_line apml ON apml.journal_id = journal.id
|
||||
JOIN account_payment_method apm on apm.id = apml.payment_method_id
|
||||
JOIN account_account account ON account.id = COALESCE(apml.payment_account_id, company.account_journal_payment_credit_account_id)
|
||||
WHERE journal.currency_id IS NOT NULL
|
||||
AND journal.currency_id != company.currency_id
|
||||
AND account.currency_id != journal.currency_id
|
||||
AND apm.payment_type = 'outbound'
|
||||
AND account.id IN %(accounts)s
|
||||
''', {
|
||||
'accounts': tuple(self.ids)
|
||||
})
|
||||
res = self._cr.fetchone()
|
||||
if res:
|
||||
account = self.env['account.account'].browse(res[0])
|
||||
journal = self.env['account.journal'].browse(res[1])
|
||||
raise ValidationError(_(
|
||||
"The foreign currency set on the journal '%(journal)s' and the account '%(account)s' must be the same.",
|
||||
journal=journal.display_name,
|
||||
account=account.display_name
|
||||
))
|
||||
|
||||
@api.constrains('company_id')
|
||||
def _check_company_consistency(self):
|
||||
if not self:
|
||||
return
|
||||
|
||||
self.env['account.move.line'].flush_model(['account_id', 'company_id'])
|
||||
self.flush_recordset(['company_id'])
|
||||
self._cr.execute('''
|
||||
SELECT line.id
|
||||
FROM account_move_line line
|
||||
JOIN account_account account ON account.id = line.account_id
|
||||
WHERE line.account_id IN %s
|
||||
AND line.company_id != account.company_id
|
||||
''', [tuple(self.ids)])
|
||||
if self._cr.fetchone():
|
||||
raise UserError(_("You can't change the company of your account since there are some journal items linked to it."))
|
||||
|
||||
@api.constrains('account_type')
|
||||
def _check_account_type_sales_purchase_journal(self):
|
||||
if not self:
|
||||
return
|
||||
|
||||
self.env['account.account'].flush_model(['account_type'])
|
||||
self.env['account.journal'].flush_model(['type', 'default_account_id'])
|
||||
self._cr.execute('''
|
||||
SELECT account.id
|
||||
FROM account_account account
|
||||
JOIN account_journal journal ON journal.default_account_id = account.id
|
||||
WHERE account.id IN %s
|
||||
AND account.account_type IN ('asset_receivable', 'liability_payable')
|
||||
AND journal.type IN ('sale', 'purchase')
|
||||
LIMIT 1;
|
||||
''', [tuple(self.ids)])
|
||||
|
||||
if self._cr.fetchone():
|
||||
raise ValidationError(_("The account is already in use in a 'sale' or 'purchase' journal. This means that the account's type couldn't be 'receivable' or 'payable'."))
|
||||
|
||||
@api.constrains('reconcile')
|
||||
def _check_used_as_journal_default_debit_credit_account(self):
|
||||
accounts = self.filtered(lambda a: not a.reconcile)
|
||||
if not accounts:
|
||||
return
|
||||
|
||||
self.flush_recordset(['reconcile'])
|
||||
self.env['account.journal'].flush_model(['company_id', 'default_account_id'])
|
||||
self.env['res.company'].flush_model(['account_journal_payment_credit_account_id', 'account_journal_payment_debit_account_id'])
|
||||
self.env['account.payment.method.line'].flush_model(['journal_id', 'payment_account_id'])
|
||||
|
||||
self._cr.execute('''
|
||||
SELECT journal.id
|
||||
FROM account_journal journal
|
||||
JOIN res_company company on journal.company_id = company.id
|
||||
LEFT JOIN account_payment_method_line apml ON journal.id = apml.journal_id
|
||||
WHERE (
|
||||
company.account_journal_payment_credit_account_id IN %(accounts)s
|
||||
AND company.account_journal_payment_credit_account_id != journal.default_account_id
|
||||
) OR (
|
||||
company.account_journal_payment_debit_account_id in %(accounts)s
|
||||
AND company.account_journal_payment_debit_account_id != journal.default_account_id
|
||||
) OR (
|
||||
apml.payment_account_id IN %(accounts)s
|
||||
AND apml.payment_account_id != journal.default_account_id
|
||||
)
|
||||
''', {
|
||||
'accounts': tuple(accounts.ids),
|
||||
})
|
||||
|
||||
rows = self._cr.fetchall()
|
||||
if rows:
|
||||
journals = self.env['account.journal'].browse([r[0] for r in rows])
|
||||
raise ValidationError(_(
|
||||
"This account is configured in %(journal_names)s journal(s) (ids %(journal_ids)s) as payment debit or credit account. This means that this account's type should be reconcilable.",
|
||||
journal_names=journals.mapped('display_name'),
|
||||
journal_ids=journals.ids
|
||||
))
|
||||
|
||||
@api.constrains('code')
|
||||
def _check_account_code(self):
|
||||
for account in self:
|
||||
if not re.match(ACCOUNT_CODE_REGEX, account.code):
|
||||
raise ValidationError(_(
|
||||
"The account code can only contain alphanumeric characters and dots."
|
||||
))
|
||||
|
||||
@api.constrains('account_type')
|
||||
def _check_account_is_bank_journal_bank_account(self):
|
||||
self.env['account.account'].flush_model(['account_type'])
|
||||
self.env['account.journal'].flush_model(['type', 'default_account_id'])
|
||||
self._cr.execute('''
|
||||
SELECT journal.id
|
||||
FROM account_journal journal
|
||||
JOIN account_account account ON journal.default_account_id = account.id
|
||||
WHERE account.account_type IN ('asset_receivable', 'liability_payable')
|
||||
AND account.id IN %s
|
||||
LIMIT 1;
|
||||
''', [tuple(self.ids)])
|
||||
|
||||
if self._cr.fetchone():
|
||||
raise ValidationError(_("You cannot change the type of an account set as Bank Account on a journal to Receivable or Payable."))
|
||||
|
||||
@api.depends('code')
|
||||
def _compute_account_root(self):
|
||||
# this computes the first 2 digits of the account.
|
||||
# This field should have been a char, but the aim is to use it in a side panel view with hierarchy, and it's only supported by many2one fields so far.
|
||||
# So instead, we make it a many2one to a psql view with what we need as records.
|
||||
for record in self:
|
||||
record.root_id = (ord(record.code[0]) * 1000 + ord(record.code[1:2] or '\x00')) if record.code else False
|
||||
|
||||
@api.depends('code')
|
||||
def _compute_account_group(self):
|
||||
if self.ids:
|
||||
self.env['account.group']._adapt_accounts_for_account_groups(self)
|
||||
else:
|
||||
self.group_id = False
|
||||
|
||||
def _search_used(self, operator, value):
|
||||
if operator not in ['=', '!='] or not isinstance(value, bool):
|
||||
raise UserError(_('Operation not supported'))
|
||||
if operator != '=':
|
||||
value = not value
|
||||
self._cr.execute("""
|
||||
SELECT id FROM account_account account
|
||||
WHERE EXISTS (SELECT 1 FROM account_move_line aml WHERE aml.account_id = account.id LIMIT 1)
|
||||
""")
|
||||
return [('id', 'in' if value else 'not in', [r[0] for r in self._cr.fetchall()])]
|
||||
|
||||
def _compute_used(self):
|
||||
ids = set(self._search_used('=', True)[0][2])
|
||||
for record in self:
|
||||
record.used = record.id in ids
|
||||
|
||||
@api.model
|
||||
def _search_new_account_code(self, company, digits, prefix):
|
||||
for num in range(1, 10000):
|
||||
new_code = str(prefix.ljust(digits - 1, '0')) + str(num)
|
||||
rec = self.search([('code', '=', new_code), ('company_id', '=', company.id)], limit=1)
|
||||
if not rec:
|
||||
return new_code
|
||||
raise UserError(_('Cannot generate an unused account code.'))
|
||||
|
||||
def _compute_current_balance(self):
|
||||
balances = {
|
||||
read['account_id'][0]: read['balance']
|
||||
for read in self.env['account.move.line']._read_group(
|
||||
domain=[('account_id', 'in', self.ids), ('parent_state', '=', 'posted')],
|
||||
fields=['balance', 'account_id'],
|
||||
groupby=['account_id'],
|
||||
)
|
||||
}
|
||||
for record in self:
|
||||
record.current_balance = balances.get(record.id, 0)
|
||||
|
||||
def _compute_related_taxes_amount(self):
|
||||
for record in self:
|
||||
record.related_taxes_amount = self.env['account.tax'].search_count([
|
||||
'|',
|
||||
('invoice_repartition_line_ids.account_id', '=', record.id),
|
||||
('refund_repartition_line_ids.account_id', '=', record.id),
|
||||
])
|
||||
|
||||
def _compute_opening_debit_credit(self):
|
||||
self.opening_debit = 0
|
||||
self.opening_credit = 0
|
||||
self.opening_balance = 0
|
||||
if not self.ids:
|
||||
return
|
||||
self.env.cr.execute("""
|
||||
SELECT line.account_id,
|
||||
SUM(line.balance) AS balance,
|
||||
SUM(line.debit) AS debit,
|
||||
SUM(line.credit) AS credit
|
||||
FROM account_move_line line
|
||||
JOIN res_company comp ON comp.id = line.company_id
|
||||
WHERE line.move_id = comp.account_opening_move_id
|
||||
AND line.account_id IN %s
|
||||
GROUP BY line.account_id
|
||||
""", [tuple(self.ids)])
|
||||
result = {r['account_id']: r for r in self.env.cr.dictfetchall()}
|
||||
for record in self:
|
||||
res = result.get(record.id) or {'debit': 0, 'credit': 0, 'balance': 0}
|
||||
record.opening_debit = res['debit']
|
||||
record.opening_credit = res['credit']
|
||||
record.opening_balance = res['balance']
|
||||
|
||||
@api.depends('code')
|
||||
def _compute_account_type(self):
|
||||
""" Compute the account type based on the account code.
|
||||
Search for the closest parent account code and sets the account type according to the parent.
|
||||
If there is no parent (e.g. the account code is lower than any other existing account code),
|
||||
the account type will be set to 'asset_current'.
|
||||
"""
|
||||
accounts_to_process = self.filtered(lambda r: r.code and not r.account_type)
|
||||
all_accounts = self.search_read(
|
||||
domain=[('company_id', 'in', accounts_to_process.company_id.ids)],
|
||||
fields=['code', 'account_type', 'company_id'],
|
||||
order='code',
|
||||
)
|
||||
accounts_with_codes = defaultdict(dict)
|
||||
# We want to group accounts by company to only search for account codes of the current company
|
||||
for account in all_accounts:
|
||||
accounts_with_codes[account['company_id'][0]][account['code']] = account['account_type']
|
||||
for account in accounts_to_process:
|
||||
codes_list = list(accounts_with_codes[account.company_id.id].keys())
|
||||
closest_index = bisect_left(codes_list, account.code) - 1
|
||||
account.account_type = accounts_with_codes[account.company_id.id][codes_list[closest_index]] if closest_index != -1 else 'asset_current'
|
||||
|
||||
@api.depends('internal_group')
|
||||
def _compute_is_off_balance(self):
|
||||
for account in self:
|
||||
account.is_off_balance = account.internal_group == "off_balance"
|
||||
|
||||
@api.depends('account_type')
|
||||
def _compute_include_initial_balance(self):
|
||||
for account in self:
|
||||
account.include_initial_balance = account.account_type not in ('income', 'income_other', 'expense', 'expense_depreciation', 'expense_direct_cost', 'off_balance')
|
||||
|
||||
@api.depends('account_type')
|
||||
def _compute_internal_group(self):
|
||||
for account in self:
|
||||
if account.account_type:
|
||||
account.internal_group = 'off_balance' if account.account_type == 'off_balance' else account.account_type.split('_')[0]
|
||||
|
||||
@api.depends('account_type')
|
||||
def _compute_reconcile(self):
|
||||
for account in self:
|
||||
if account.internal_group in ('income', 'expense', 'equity'):
|
||||
account.reconcile = False
|
||||
elif account.account_type in ('asset_receivable', 'liability_payable'):
|
||||
account.reconcile = True
|
||||
elif account.account_type in ('asset_cash', 'liability_credit_card', 'off_balance'):
|
||||
account.reconcile = False
|
||||
# For other asset/liability accounts, don't do any change to account.reconcile.
|
||||
|
||||
def _set_opening_debit(self):
|
||||
for record in self:
|
||||
record._set_opening_debit_credit(record.opening_debit, 'debit')
|
||||
|
||||
def _set_opening_credit(self):
|
||||
for record in self:
|
||||
record._set_opening_debit_credit(record.opening_credit, 'credit')
|
||||
|
||||
def _set_opening_balance(self):
|
||||
# Tracking of the balances to be used after the import to populate the opening move in batch.
|
||||
for account in self:
|
||||
balance = account.opening_balance
|
||||
account._set_opening_debit_credit(abs(balance) if balance > 0.0 else 0.0, 'debit')
|
||||
account._set_opening_debit_credit(abs(balance) if balance < 0.0 else 0.0, 'credit')
|
||||
|
||||
def _set_opening_debit_credit(self, amount, field):
|
||||
""" Generic function called by both opening_debit and opening_credit's
|
||||
inverse function. 'Amount' parameter is the value to be set, and field
|
||||
either 'debit' or 'credit', depending on which one of these two fields
|
||||
got assigned.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if 'import_account_opening_balance' not in self._cr.precommit.data:
|
||||
data = self._cr.precommit.data['import_account_opening_balance'] = {}
|
||||
self._cr.precommit.add(self._load_precommit_update_opening_move)
|
||||
else:
|
||||
data = self._cr.precommit.data['import_account_opening_balance']
|
||||
data.setdefault(self.id, [None, None])
|
||||
index = 0 if field == 'debit' else 1
|
||||
data[self.id][index] = amount
|
||||
|
||||
@api.model
|
||||
def default_get(self, default_fields):
|
||||
"""If we're creating a new account through a many2one, there are chances that we typed the account code
|
||||
instead of its name. In that case, switch both fields values.
|
||||
"""
|
||||
if 'name' not in default_fields and 'code' not in default_fields:
|
||||
return super().default_get(default_fields)
|
||||
default_name = self._context.get('default_name')
|
||||
default_code = self._context.get('default_code')
|
||||
if default_name and not default_code:
|
||||
try:
|
||||
default_code = int(default_name)
|
||||
except ValueError:
|
||||
pass
|
||||
if default_code:
|
||||
default_name = False
|
||||
contextual_self = self.with_context(default_name=default_name, default_code=default_code)
|
||||
return super(AccountAccount, contextual_self).default_get(default_fields)
|
||||
|
||||
@api.model
|
||||
def _get_most_frequent_accounts_for_partner(self, company_id, partner_id, move_type, filter_never_user_accounts=False, limit=None, journal_id=None):
|
||||
"""
|
||||
Returns the accounts ordered from most frequent to least frequent for a given partner
|
||||
and filtered according to the move type
|
||||
:param company_id: the company id
|
||||
:param partner_id: the partner id for which we want to retrieve the most frequent accounts
|
||||
:param move_type: the type of the move to know which type of accounts to retrieve
|
||||
:param filter_never_user_accounts: True if we should filter out accounts never used for the partner
|
||||
:param limit: the maximum number of accounts to retrieve
|
||||
:param journal_id: only return accounts allowed on this journal id
|
||||
:returns: List of account ids, ordered by frequency (from most to least frequent)
|
||||
"""
|
||||
join = "INNER JOIN" if filter_never_user_accounts else "LEFT JOIN"
|
||||
limit = f"LIMIT {limit:d}" if limit else ""
|
||||
where_internal_group = ""
|
||||
if move_type in self.env['account.move'].get_inbound_types(include_receipts=True):
|
||||
where_internal_group = "AND account.internal_group = 'income'"
|
||||
elif move_type in self.env['account.move'].get_outbound_types(include_receipts=True):
|
||||
where_internal_group = "AND account.internal_group = 'expense'"
|
||||
params = [partner_id, company_id]
|
||||
where_allowed_journal = ""
|
||||
if journal_id:
|
||||
allowed_accounts = self.env['account.account'].search(['|', ('allowed_journal_ids', '=', journal_id), ('allowed_journal_ids', '=', False)])
|
||||
where_allowed_journal = "AND aml.account_id in %s"
|
||||
params.append(tuple(allowed_accounts.ids))
|
||||
self._cr.execute(f"""
|
||||
SELECT account.id
|
||||
FROM account_account account
|
||||
{join} account_move_line aml
|
||||
ON aml.account_id = account.id
|
||||
AND aml.partner_id = %s
|
||||
AND account.company_id = aml.company_id
|
||||
AND aml.date >= now() - interval '2 years'
|
||||
WHERE account.company_id = %s
|
||||
AND account.deprecated = FALSE
|
||||
{where_internal_group}
|
||||
{where_allowed_journal}
|
||||
GROUP BY account.id
|
||||
ORDER BY COUNT(aml.id) DESC, account.code
|
||||
{limit}
|
||||
""", params)
|
||||
return [r[0] for r in self._cr.fetchall()]
|
||||
|
||||
@api.model
|
||||
def _get_most_frequent_account_for_partner(self, company_id, partner_id, move_type=None, journal_id=None):
|
||||
most_frequent_account = self._get_most_frequent_accounts_for_partner(company_id, partner_id, move_type, filter_never_user_accounts=True, limit=1, journal_id=journal_id)
|
||||
return most_frequent_account[0] if most_frequent_account else False
|
||||
|
||||
@api.model
|
||||
def _order_accounts_by_frequency_for_partner(self, company_id, partner_id, move_type=None):
|
||||
return self._get_most_frequent_accounts_for_partner(company_id, partner_id, move_type)
|
||||
|
||||
@api.model
|
||||
def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
|
||||
if not name and self._context.get('partner_id') and self._context.get('move_type'):
|
||||
return self._order_accounts_by_frequency_for_partner(
|
||||
self.env.company.id, self._context.get('partner_id'), self._context.get('move_type'))
|
||||
args = args or []
|
||||
domain = []
|
||||
if name:
|
||||
if operator in ('=', '!='):
|
||||
domain = ['|', ('code', '=', name.split(' ')[0]), ('name', operator, name)]
|
||||
else:
|
||||
domain = ['|', ('code', '=ilike', name.split(' ')[0] + '%'), ('name', operator, name)]
|
||||
if operator in expression.NEGATIVE_TERM_OPERATORS:
|
||||
domain = ['&', '!'] + domain[1:]
|
||||
return self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
|
||||
|
||||
@api.onchange('account_type')
|
||||
def _onchange_account_type(self):
|
||||
if self.internal_group == 'off_balance':
|
||||
self.tax_ids = False
|
||||
|
||||
def _split_code_name(self, code_name):
|
||||
# We only want to split the name on the first word if there is a digit in it
|
||||
code, name = ACCOUNT_REGEX.match(code_name or '').groups()
|
||||
return code, name.strip()
|
||||
|
||||
@api.onchange('name')
|
||||
def _onchange_name(self):
|
||||
code, name = self._split_code_name(self.name)
|
||||
if code and not self.code:
|
||||
self.name = name
|
||||
self.code = code
|
||||
|
||||
def name_get(self):
|
||||
result = []
|
||||
for account in self:
|
||||
name = account.code + ' ' + account.name
|
||||
result.append((account.id, name))
|
||||
return result
|
||||
|
||||
@api.returns('self', lambda value: value.id)
|
||||
def copy(self, default=None):
|
||||
default = dict(default or {})
|
||||
if default.get('code', False):
|
||||
return super(AccountAccount, self).copy(default)
|
||||
try:
|
||||
default['code'] = (str(int(self.code) + 10) or '').zfill(len(self.code))
|
||||
default.setdefault('name', _("%s (copy)") % (self.name or ''))
|
||||
while self.env['account.account'].search([('code', '=', default['code']),
|
||||
('company_id', '=', default.get('company_id', False) or self.company_id.id)], limit=1):
|
||||
default['code'] = (str(int(default['code']) + 10) or '')
|
||||
default['name'] = _("%s (copy)") % (self.name or '')
|
||||
except ValueError:
|
||||
default['code'] = _("%s.copy") % (self.code or '')
|
||||
default['name'] = self.name
|
||||
return super(AccountAccount, self).copy(default)
|
||||
|
||||
@api.model
|
||||
def _load_precommit_update_opening_move(self):
|
||||
""" precommit callback to recompute the opening move according the opening balances that changed.
|
||||
This is particularly useful when importing a csv containing the 'opening_balance' column.
|
||||
In that case, we don't want to use the inverse method set on field since it will be
|
||||
called for each account separately. That would be quite costly in terms of performances.
|
||||
Instead, the opening balances are collected and this method is called once at the end
|
||||
to update the opening move accordingly.
|
||||
"""
|
||||
data = self._cr.precommit.data.pop('import_account_opening_balance', {})
|
||||
accounts = self.browse(data.keys())
|
||||
|
||||
accounts_per_company = defaultdict(lambda: self.env['account.account'])
|
||||
for account in accounts:
|
||||
accounts_per_company[account.company_id] |= account
|
||||
|
||||
for company, company_accounts in accounts_per_company.items():
|
||||
company._update_opening_move({account: data[account.id] for account in company_accounts})
|
||||
|
||||
self.env.flush_all()
|
||||
|
||||
def _toggle_reconcile_to_true(self):
|
||||
'''Toggle the `reconcile´ boolean from False -> True
|
||||
|
||||
Note that: lines with debit = credit = amount_currency = 0 are set to `reconciled´ = True
|
||||
'''
|
||||
if not self.ids:
|
||||
return None
|
||||
query = """
|
||||
UPDATE account_move_line SET
|
||||
reconciled = CASE WHEN debit = 0 AND credit = 0 AND amount_currency = 0
|
||||
THEN true ELSE false END,
|
||||
amount_residual = (debit-credit),
|
||||
amount_residual_currency = amount_currency
|
||||
WHERE full_reconcile_id IS NULL and account_id IN %s
|
||||
"""
|
||||
self.env.cr.execute(query, [tuple(self.ids)])
|
||||
|
||||
def _toggle_reconcile_to_false(self):
|
||||
'''Toggle the `reconcile´ boolean from True -> False
|
||||
|
||||
Note that it is disallowed if some lines are partially reconciled.
|
||||
'''
|
||||
if not self.ids:
|
||||
return None
|
||||
partial_lines_count = self.env['account.move.line'].search_count([
|
||||
('account_id', 'in', self.ids),
|
||||
('full_reconcile_id', '=', False),
|
||||
('|'),
|
||||
('matched_debit_ids', '!=', False),
|
||||
('matched_credit_ids', '!=', False),
|
||||
])
|
||||
if partial_lines_count > 0:
|
||||
raise UserError(_('You cannot switch an account to prevent the reconciliation '
|
||||
'if some partial reconciliations are still pending.'))
|
||||
query = """
|
||||
UPDATE account_move_line
|
||||
SET amount_residual = 0, amount_residual_currency = 0
|
||||
WHERE full_reconcile_id IS NULL AND account_id IN %s
|
||||
"""
|
||||
self.env.cr.execute(query, [tuple(self.ids)])
|
||||
|
||||
@api.model
|
||||
def name_create(self, name):
|
||||
""" Split the account name into account code and account name in import.
|
||||
When importing a file with accounts, the account code and name may be both entered in the name column.
|
||||
In this case, the name will be split into code and name.
|
||||
"""
|
||||
if 'import_file' in self.env.context:
|
||||
code, name = self._split_code_name(name)
|
||||
return self.create({'code': code, 'name': name}).name_get()[0]
|
||||
raise ValidationError(_("Please create new accounts from the Chart of Accounts menu."))
|
||||
|
||||
def write(self, vals):
|
||||
# Do not allow changing the company_id when account_move_line already exist
|
||||
if vals.get('company_id', False):
|
||||
move_lines = self.env['account.move.line'].search([('account_id', 'in', self.ids)], limit=1)
|
||||
for account in self:
|
||||
if (account.company_id.id != vals['company_id']) and move_lines:
|
||||
raise UserError(_('You cannot change the owner company of an account that already contains journal items.'))
|
||||
if 'reconcile' in vals:
|
||||
if vals['reconcile']:
|
||||
self.filtered(lambda r: not r.reconcile)._toggle_reconcile_to_true()
|
||||
else:
|
||||
self.filtered(lambda r: r.reconcile)._toggle_reconcile_to_false()
|
||||
|
||||
if vals.get('currency_id'):
|
||||
for account in self:
|
||||
if self.env['account.move.line'].search_count([('account_id', '=', account.id), ('currency_id', 'not in', (False, vals['currency_id']))]):
|
||||
raise UserError(_('You cannot set a currency on this account as it already has some journal entries having a different foreign currency.'))
|
||||
|
||||
if vals.get('deprecated') and self.env["account.tax.repartition.line"].search_count([('account_id', 'in', self.ids)], limit=1):
|
||||
raise UserError(_("You cannot deprecate an account that is used in a tax distribution."))
|
||||
|
||||
return super(AccountAccount, self).write(vals)
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_contains_journal_items(self):
|
||||
if self.env['account.move.line'].search([('account_id', 'in', self.ids)], limit=1):
|
||||
raise UserError(_('You cannot perform this action on an account that contains journal items.'))
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_account_set_on_customer(self):
|
||||
#Checking whether the account is set as a property to any Partner or not
|
||||
values = ['account.account,%s' % (account_id,) for account_id in self.ids]
|
||||
partner_prop_acc = self.env['ir.property'].sudo().search([('value_reference', 'in', values)], limit=1)
|
||||
if partner_prop_acc:
|
||||
account_name = partner_prop_acc.get_by_record().display_name
|
||||
raise UserError(
|
||||
_('You cannot remove/deactivate the account %s which is set on a customer or vendor.', account_name)
|
||||
)
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_linked_to_fiscal_position(self):
|
||||
if self.env['account.fiscal.position.account'].search(['|', ('account_src_id', 'in', self.ids), ('account_dest_id', 'in', self.ids)], limit=1):
|
||||
raise UserError(_('You cannot remove/deactivate the accounts "%s" which are set on the account mapping of a fiscal position.', ', '.join(f"{a.code} - {a.name}" for a in self)))
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_linked_to_tax_repartition_line(self):
|
||||
if self.env['account.tax.repartition.line'].search([('account_id', 'in', self.ids)], limit=1):
|
||||
raise UserError(_('You cannot remove/deactivate the accounts "%s" which are set on a tax repartition line.', ', '.join(f"{a.code} - {a.name}" for a in self)))
|
||||
|
||||
def action_read_account(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': self.display_name,
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_type': 'form',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'account.account',
|
||||
'res_id': self.id,
|
||||
}
|
||||
|
||||
def action_duplicate_accounts(self):
|
||||
for account in self.browse(self.env.context['active_ids']):
|
||||
account.copy()
|
||||
|
||||
def action_open_related_taxes(self):
|
||||
related_taxes_ids = self.env['account.tax'].search([
|
||||
'|',
|
||||
('invoice_repartition_line_ids.account_id', '=', self.id),
|
||||
('refund_repartition_line_ids.account_id', '=', self.id),
|
||||
]).ids
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Taxes'),
|
||||
'res_model': 'account.tax',
|
||||
'view_type': 'list',
|
||||
'view_mode': 'list',
|
||||
'views': [[False, 'list'], [False, 'form']],
|
||||
'domain': [('id', 'in', related_taxes_ids)],
|
||||
}
|
||||
|
||||
@api.model
|
||||
def get_import_templates(self):
|
||||
return [{
|
||||
'label': _('Import Template for Chart of Accounts'),
|
||||
'template': '/account/static/xls/coa_import_template.xlsx'
|
||||
}]
|
||||
|
||||
def _merge_method(self, destination, source):
|
||||
raise UserError(_("You cannot merge accounts."))
|
||||
|
||||
|
||||
class AccountGroup(models.Model):
|
||||
_name = "account.group"
|
||||
_description = 'Account Group'
|
||||
_parent_store = True
|
||||
_order = 'code_prefix_start'
|
||||
|
||||
parent_id = fields.Many2one('account.group', index=True, ondelete='cascade', readonly=True)
|
||||
parent_path = fields.Char(index=True, unaccent=False)
|
||||
name = fields.Char(required=True)
|
||||
code_prefix_start = fields.Char()
|
||||
code_prefix_end = fields.Char()
|
||||
company_id = fields.Many2one('res.company', required=True, readonly=True, default=lambda self: self.env.company)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'check_length_prefix',
|
||||
'CHECK(char_length(COALESCE(code_prefix_start, \'\')) = char_length(COALESCE(code_prefix_end, \'\')))',
|
||||
'The length of the starting and the ending code prefix must be the same'
|
||||
),
|
||||
]
|
||||
|
||||
@api.onchange('code_prefix_start')
|
||||
def _onchange_code_prefix_start(self):
|
||||
if not self.code_prefix_end or self.code_prefix_end < self.code_prefix_start:
|
||||
self.code_prefix_end = self.code_prefix_start
|
||||
|
||||
@api.onchange('code_prefix_end')
|
||||
def _onchange_code_prefix_end(self):
|
||||
if not self.code_prefix_start or self.code_prefix_start > self.code_prefix_end:
|
||||
self.code_prefix_start = self.code_prefix_end
|
||||
|
||||
def name_get(self):
|
||||
result = []
|
||||
for group in self:
|
||||
prefix = group.code_prefix_start and str(group.code_prefix_start)
|
||||
if prefix and group.code_prefix_end != group.code_prefix_start:
|
||||
prefix += '-' + str(group.code_prefix_end)
|
||||
name = (prefix and (prefix + ' ') or '') + group.name
|
||||
result.append((group.id, name))
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
|
||||
args = args or []
|
||||
if operator == 'ilike' and not (name or '').strip():
|
||||
domain = []
|
||||
else:
|
||||
criteria_operator = ['|'] if operator not in expression.NEGATIVE_TERM_OPERATORS else ['&', '!']
|
||||
domain = criteria_operator + [('code_prefix_start', '=ilike', name + '%'), ('name', operator, name)]
|
||||
return self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
|
||||
|
||||
@api.constrains('code_prefix_start', 'code_prefix_end')
|
||||
def _constraint_prefix_overlap(self):
|
||||
self.flush_model()
|
||||
query = """
|
||||
SELECT other.id FROM account_group this
|
||||
JOIN account_group other
|
||||
ON char_length(other.code_prefix_start) = char_length(this.code_prefix_start)
|
||||
AND other.id != this.id
|
||||
AND other.company_id = this.company_id
|
||||
AND (
|
||||
other.code_prefix_start <= this.code_prefix_start AND this.code_prefix_start <= other.code_prefix_end
|
||||
OR
|
||||
other.code_prefix_start >= this.code_prefix_start AND this.code_prefix_end >= other.code_prefix_start
|
||||
)
|
||||
WHERE this.id IN %(ids)s
|
||||
"""
|
||||
self.env.cr.execute(query, {'ids': tuple(self.ids)})
|
||||
res = self.env.cr.fetchall()
|
||||
if res:
|
||||
raise ValidationError(_('Account Groups with the same granularity can\'t overlap'))
|
||||
|
||||
@api.constrains('parent_id')
|
||||
def _check_parent_not_circular(self):
|
||||
if not self._check_recursion():
|
||||
raise ValidationError(_("You cannot create recursive groups."))
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if 'code_prefix_start' in vals and not vals.get('code_prefix_end'):
|
||||
vals['code_prefix_end'] = vals['code_prefix_start']
|
||||
res_ids = super(AccountGroup, self).create(vals_list)
|
||||
res_ids._adapt_accounts_for_account_groups()
|
||||
res_ids._adapt_parent_account_group()
|
||||
return res_ids
|
||||
|
||||
def write(self, vals):
|
||||
res = super(AccountGroup, self).write(vals)
|
||||
if 'code_prefix_start' in vals or 'code_prefix_end' in vals:
|
||||
self._adapt_accounts_for_account_groups()
|
||||
self._adapt_parent_account_group()
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
for record in self:
|
||||
account_ids = self.env['account.account'].search([('group_id', '=', record.id)])
|
||||
account_ids.write({'group_id': record.parent_id.id})
|
||||
|
||||
children_ids = self.env['account.group'].search([('parent_id', '=', record.id)])
|
||||
children_ids.write({'parent_id': record.parent_id.id})
|
||||
super(AccountGroup, self).unlink()
|
||||
|
||||
def _adapt_accounts_for_account_groups(self, account_ids=None):
|
||||
"""Ensure consistency between accounts and account groups.
|
||||
|
||||
Find and set the most specific group matching the code of the account.
|
||||
The most specific is the one with the longest prefixes and with the starting
|
||||
prefix being smaller than the account code and the ending prefix being greater.
|
||||
"""
|
||||
company_ids = account_ids.company_id.ids if account_ids else self.company_id.ids
|
||||
account_ids = account_ids.ids if account_ids else []
|
||||
if not company_ids and not account_ids:
|
||||
return
|
||||
self.flush_model()
|
||||
self.env['account.account'].flush_model()
|
||||
|
||||
account_where_clause = ''
|
||||
where_params = [tuple(company_ids)]
|
||||
if account_ids:
|
||||
account_where_clause = 'AND account.id IN %s'
|
||||
where_params.append(tuple(account_ids))
|
||||
|
||||
self._cr.execute(f'''
|
||||
WITH candidates_account_groups AS (
|
||||
SELECT
|
||||
account.id AS account_id,
|
||||
ARRAY_AGG(agroup.id ORDER BY char_length(agroup.code_prefix_start) DESC, agroup.id) AS group_ids
|
||||
FROM account_account account
|
||||
LEFT JOIN account_group agroup
|
||||
ON agroup.code_prefix_start <= LEFT(account.code, char_length(agroup.code_prefix_start))
|
||||
AND agroup.code_prefix_end >= LEFT(account.code, char_length(agroup.code_prefix_end))
|
||||
AND agroup.company_id = account.company_id
|
||||
WHERE account.company_id IN %s {account_where_clause}
|
||||
GROUP BY account.id
|
||||
)
|
||||
UPDATE account_account
|
||||
SET group_id = rel.group_ids[1]
|
||||
FROM candidates_account_groups rel
|
||||
WHERE account_account.id = rel.account_id
|
||||
''', where_params)
|
||||
self.env['account.account'].invalidate_model(['group_id'])
|
||||
|
||||
def _adapt_parent_account_group(self):
|
||||
"""Ensure consistency of the hierarchy of account groups.
|
||||
|
||||
Find and set the most specific parent for each group.
|
||||
The most specific is the one with the longest prefixes and with the starting
|
||||
prefix being smaller than the child prefixes and the ending prefix being greater.
|
||||
"""
|
||||
if not self:
|
||||
return
|
||||
self.flush_model()
|
||||
query = """
|
||||
WITH relation AS (
|
||||
SELECT DISTINCT FIRST_VALUE(parent.id) OVER (PARTITION BY child.id ORDER BY child.id, char_length(parent.code_prefix_start) DESC) AS parent_id,
|
||||
child.id AS child_id
|
||||
FROM account_group parent
|
||||
JOIN account_group child
|
||||
ON char_length(parent.code_prefix_start) < char_length(child.code_prefix_start)
|
||||
AND parent.code_prefix_start <= LEFT(child.code_prefix_start, char_length(parent.code_prefix_start))
|
||||
AND parent.code_prefix_end >= LEFT(child.code_prefix_end, char_length(parent.code_prefix_end))
|
||||
AND parent.id != child.id
|
||||
AND parent.company_id = child.company_id
|
||||
WHERE child.company_id IN %(company_ids)s
|
||||
)
|
||||
UPDATE account_group child
|
||||
SET parent_id = relation.parent_id
|
||||
FROM relation
|
||||
WHERE child.id = relation.child_id;
|
||||
"""
|
||||
self.env.cr.execute(query, {'company_ids': tuple(self.company_id.ids)})
|
||||
self.invalidate_model(['parent_id'])
|
||||
self.search([('company_id', 'in', self.company_id.ids)])._parent_store_update()
|
||||
|
||||
|
||||
class AccountRoot(models.Model):
|
||||
_name = 'account.root'
|
||||
_description = 'Account codes first 2 digits'
|
||||
_auto = False
|
||||
|
||||
name = fields.Char()
|
||||
parent_id = fields.Many2one('account.root')
|
||||
company_id = fields.Many2one('res.company')
|
||||
|
||||
def init(self):
|
||||
tools.drop_view_if_exists(self.env.cr, self._table)
|
||||
self.env.cr.execute('''
|
||||
CREATE OR REPLACE VIEW %s AS (
|
||||
SELECT DISTINCT ASCII(code) * 1000 + ASCII(SUBSTRING(code,2,1)) AS id,
|
||||
LEFT(code,2) AS name,
|
||||
ASCII(code) AS parent_id,
|
||||
company_id
|
||||
FROM account_account WHERE code IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT DISTINCT ASCII(code) AS id,
|
||||
LEFT(code,1) AS name,
|
||||
NULL::int AS parent_id,
|
||||
company_id
|
||||
FROM account_account WHERE code IS NOT NULL
|
||||
)''' % (self._table,)
|
||||
)
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import api, fields, models, _
|
||||
from odoo import osv
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AccountAccountTag(models.Model):
|
||||
_name = 'account.account.tag'
|
||||
_description = 'Account Tag'
|
||||
|
||||
name = fields.Char('Tag Name', required=True)
|
||||
applicability = fields.Selection([('accounts', 'Accounts'), ('taxes', 'Taxes'), ('products', 'Products')], required=True, default='accounts')
|
||||
color = fields.Integer('Color Index')
|
||||
active = fields.Boolean(default=True, help="Set active to false to hide the Account Tag without removing it.")
|
||||
tax_negate = fields.Boolean(string="Negate Tax Balance", help="Check this box to negate the absolute value of the balance of the lines associated with this tag in tax report computation.")
|
||||
country_id = fields.Many2one(string="Country", comodel_name='res.country', help="Country for which this tag is available, when applied on taxes.")
|
||||
|
||||
def name_get(self):
|
||||
if not self.env.company.multi_vat_foreign_country_ids:
|
||||
return super().name_get()
|
||||
|
||||
res = []
|
||||
for tag in self:
|
||||
name = tag.name
|
||||
if tag.applicability == "taxes" and tag.country_id and tag.country_id != self.env.company.account_fiscal_country_id:
|
||||
name = _("%s (%s)", tag.name, tag.country_id.code)
|
||||
res.append((tag.id, name,))
|
||||
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _get_tax_tags(self, tag_name, country_id):
|
||||
""" Returns all the tax tags corresponding to the tag name given in parameter
|
||||
in the specified country.
|
||||
"""
|
||||
domain = self._get_tax_tags_domain(tag_name, country_id)
|
||||
original_lang = self._context.get('lang', 'en_US')
|
||||
rslt_tags = self.env['account.account.tag'].with_context(active_test=False, lang='en_US').search(domain)
|
||||
return rslt_tags.with_context(lang=original_lang) # Restore original language, in case the name of the tags needs to be shown/modified
|
||||
|
||||
@api.model
|
||||
def _get_tax_tags_domain(self, tag_name, country_id, sign=None):
|
||||
""" Returns a domain to search for all the tax tags corresponding to the tag name given in parameter
|
||||
in the specified country.
|
||||
"""
|
||||
escaped_tag_name = tag_name.replace('\\', '\\\\').replace('%', r'\%').replace('_', r'\_')
|
||||
return [
|
||||
('name', '=like', (sign or '_') + escaped_tag_name),
|
||||
('country_id', '=', country_id),
|
||||
('applicability', '=', 'taxes')
|
||||
]
|
||||
|
||||
def _get_related_tax_report_expressions(self):
|
||||
if not self:
|
||||
return self.env['account.report.expression']
|
||||
|
||||
or_domains = []
|
||||
for record in self:
|
||||
expr_domain = [
|
||||
'&',
|
||||
('report_line_id.report_id.country_id', '=', record.country_id.id),
|
||||
('formula', '=', record.name[1:]),
|
||||
]
|
||||
or_domains.append(expr_domain)
|
||||
|
||||
domain = osv.expression.AND([[('engine', '=', 'tax_tags')], osv.expression.OR(or_domains)])
|
||||
return self.env['account.report.expression'].search(domain)
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_master_tags(self):
|
||||
master_xmlids = [
|
||||
"account_tag_operating",
|
||||
"account_tag_financing",
|
||||
"account_tag_investing",
|
||||
]
|
||||
for master_xmlid in master_xmlids:
|
||||
master_tag = self.env.ref(f"account.{master_xmlid}", raise_if_not_found=False)
|
||||
if master_tag and master_tag in self:
|
||||
raise UserError(_("You cannot delete this account tag (%s), it is used on the chart of account definition.", master_tag.name))
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class AccountAnalyticAccount(models.Model):
|
||||
_inherit = 'account.analytic.account'
|
||||
|
||||
invoice_count = fields.Integer(
|
||||
"Invoice Count",
|
||||
compute='_compute_invoice_count',
|
||||
)
|
||||
vendor_bill_count = fields.Integer(
|
||||
"Vendor Bill Count",
|
||||
compute='_compute_vendor_bill_count',
|
||||
)
|
||||
|
||||
debit = fields.Monetary(groups='account.group_account_readonly')
|
||||
credit = fields.Monetary(groups='account.group_account_readonly')
|
||||
|
||||
@api.depends('line_ids')
|
||||
def _compute_invoice_count(self):
|
||||
sale_types = self.env['account.move'].get_sale_types(include_receipts=True)
|
||||
|
||||
query = self.env['account.move.line']._search([
|
||||
('parent_state', '=', 'posted'),
|
||||
('move_id.move_type', 'in', sale_types),
|
||||
])
|
||||
query.add_where(
|
||||
'account_move_line.analytic_distribution ?| %s',
|
||||
[[str(account_id) for account_id in self.ids]],
|
||||
)
|
||||
|
||||
query.order = None
|
||||
query_string, query_param = query.select(
|
||||
'jsonb_object_keys(account_move_line.analytic_distribution) as account_id',
|
||||
'COUNT(DISTINCT(account_move_line.move_id)) as move_count',
|
||||
)
|
||||
query_string = f"{query_string} GROUP BY jsonb_object_keys(account_move_line.analytic_distribution)"
|
||||
|
||||
self._cr.execute(query_string, query_param)
|
||||
data = {int(record.get('account_id')): record.get('move_count') for record in self._cr.dictfetchall()}
|
||||
for account in self:
|
||||
account.invoice_count = data.get(account.id, 0)
|
||||
|
||||
@api.depends('line_ids')
|
||||
def _compute_vendor_bill_count(self):
|
||||
purchase_types = self.env['account.move'].get_purchase_types(include_receipts=True)
|
||||
|
||||
query = self.env['account.move.line']._search([
|
||||
('parent_state', '=', 'posted'),
|
||||
('move_id.move_type', 'in', purchase_types),
|
||||
])
|
||||
query.add_where(
|
||||
'account_move_line.analytic_distribution ?| %s',
|
||||
[[str(account_id) for account_id in self.ids]],
|
||||
)
|
||||
|
||||
query.order = None
|
||||
query_string, query_param = query.select(
|
||||
'jsonb_object_keys(account_move_line.analytic_distribution) as account_id',
|
||||
'COUNT(DISTINCT(account_move_line.move_id)) as move_count',
|
||||
)
|
||||
query_string = f"{query_string} GROUP BY jsonb_object_keys(account_move_line.analytic_distribution)"
|
||||
|
||||
self._cr.execute(query_string, query_param)
|
||||
data = {int(record.get('account_id')): record.get('move_count') for record in self._cr.dictfetchall()}
|
||||
for account in self:
|
||||
account.vendor_bill_count = data.get(account.id, 0)
|
||||
|
||||
def action_view_invoice(self):
|
||||
self.ensure_one()
|
||||
query = self.env['account.move.line']._search([('move_id.move_type', 'in', self.env['account.move'].get_sale_types())])
|
||||
query.order = None
|
||||
query.add_where('analytic_distribution ? %s', [str(self.id)])
|
||||
query_string, query_param = query.select('DISTINCT account_move_line.move_id')
|
||||
self._cr.execute(query_string, query_param)
|
||||
move_ids = [line.get('move_id') for line in self._cr.dictfetchall()]
|
||||
result = {
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "account.move",
|
||||
"domain": [('id', 'in', move_ids)],
|
||||
"context": {"create": False, 'default_move_type': 'out_invoice'},
|
||||
"name": _("Customer Invoices"),
|
||||
'view_mode': 'tree,form',
|
||||
}
|
||||
return result
|
||||
|
||||
def action_view_vendor_bill(self):
|
||||
self.ensure_one()
|
||||
query = self.env['account.move.line']._search([('move_id.move_type', 'in', self.env['account.move'].get_purchase_types())])
|
||||
query.order = None
|
||||
query.add_where('analytic_distribution ? %s', [str(self.id)])
|
||||
query_string, query_param = query.select('DISTINCT account_move_line.move_id')
|
||||
self._cr.execute(query_string, query_param)
|
||||
move_ids = [line.get('move_id') for line in self._cr.dictfetchall()]
|
||||
result = {
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "account.move",
|
||||
"domain": [('id', 'in', move_ids)],
|
||||
"context": {"create": False, 'default_move_type': 'in_invoice'},
|
||||
"name": _("Vendor Bills"),
|
||||
'view_mode': 'tree,form',
|
||||
}
|
||||
return result
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountAnalyticDistributionModel(models.Model):
|
||||
_inherit = 'account.analytic.distribution.model'
|
||||
|
||||
account_prefix = fields.Char(
|
||||
string='Accounts Prefix',
|
||||
help="Prefix that defines which accounts from the financial accounting this model should apply on.",
|
||||
)
|
||||
product_id = fields.Many2one(
|
||||
'product.product',
|
||||
string='Product',
|
||||
ondelete='cascade',
|
||||
help="Select a product for which the analytic distribution will be used (e.g. create new customer invoice or Sales order if we select this product, it will automatically take this as an analytic account)",
|
||||
)
|
||||
product_categ_id = fields.Many2one(
|
||||
'product.category',
|
||||
string='Product Category',
|
||||
ondelete='cascade',
|
||||
help="Select a product category which will use analytic account specified in analytic default (e.g. create new customer invoice or Sales order if we select this product, it will automatically take this as an analytic account)",
|
||||
)
|
||||
|
||||
def _create_domain(self, fname, value):
|
||||
if not fname == 'account_prefix':
|
||||
return super()._create_domain(fname, value)
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
class AccountAnalyticLine(models.Model):
|
||||
_inherit = 'account.analytic.line'
|
||||
_description = 'Analytic Line'
|
||||
|
||||
product_id = fields.Many2one(
|
||||
'product.product',
|
||||
string='Product',
|
||||
check_company=True,
|
||||
)
|
||||
general_account_id = fields.Many2one(
|
||||
'account.account',
|
||||
string='Financial Account',
|
||||
ondelete='restrict',
|
||||
domain="[('deprecated', '=', False), ('company_id', '=', company_id)]",
|
||||
compute='_compute_general_account_id', store=True, readonly=False
|
||||
)
|
||||
journal_id = fields.Many2one(
|
||||
'account.journal',
|
||||
string='Financial Journal',
|
||||
check_company=True,
|
||||
readonly=True,
|
||||
related='move_line_id.journal_id',
|
||||
store=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
readonly=False,
|
||||
compute="_compute_partner_id",
|
||||
store=True,
|
||||
)
|
||||
move_line_id = fields.Many2one(
|
||||
'account.move.line',
|
||||
string='Journal Item',
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
check_company=True,
|
||||
)
|
||||
code = fields.Char(size=8)
|
||||
ref = fields.Char(string='Ref.')
|
||||
category = fields.Selection(selection_add=[('invoice', 'Customer Invoice'), ('vendor_bill', 'Vendor Bill')])
|
||||
|
||||
@api.depends('move_line_id')
|
||||
def _compute_general_account_id(self):
|
||||
for line in self:
|
||||
line.general_account_id = line.move_line_id.account_id
|
||||
|
||||
@api.constrains('move_line_id', 'general_account_id')
|
||||
def _check_general_account_id(self):
|
||||
for line in self:
|
||||
if line.move_line_id and line.general_account_id != line.move_line_id.account_id:
|
||||
raise ValidationError(_('The journal item is not linked to the correct financial account'))
|
||||
|
||||
@api.depends('move_line_id')
|
||||
def _compute_partner_id(self):
|
||||
for line in self:
|
||||
line.partner_id = line.move_line_id.partner_id or line.partner_id
|
||||
|
||||
@api.onchange('product_id', 'product_uom_id', 'unit_amount', 'currency_id')
|
||||
def on_change_unit_amount(self):
|
||||
if not self.product_id:
|
||||
return {}
|
||||
|
||||
prod_accounts = self.product_id.product_tmpl_id.with_company(self.company_id)._get_product_accounts()
|
||||
unit = self.product_uom_id
|
||||
account = prod_accounts['expense']
|
||||
if not unit or self.product_id.uom_po_id.category_id.id != unit.category_id.id:
|
||||
unit = self.product_id.uom_po_id
|
||||
|
||||
# Compute based on pricetype
|
||||
amount_unit = self.product_id.price_compute('standard_price', uom=unit)[self.product_id.id]
|
||||
amount = amount_unit * self.unit_amount or 0.0
|
||||
result = (self.currency_id.round(amount) if self.currency_id else round(amount, 2)) * -1
|
||||
self.amount = result
|
||||
self.general_account_id = account
|
||||
self.product_uom_id = unit
|
||||
|
||||
@api.model
|
||||
def view_header_get(self, view_id, view_type):
|
||||
if self.env.context.get('account_id'):
|
||||
return _(
|
||||
"Entries: %(account)s",
|
||||
account=self.env['account.analytic.account'].browse(self.env.context['account_id']).name
|
||||
)
|
||||
return super().view_header_get(view_id, view_type)
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountAnalyticApplicability(models.Model):
|
||||
_inherit = 'account.analytic.applicability'
|
||||
_description = "Analytic Plan's Applicabilities"
|
||||
|
||||
business_domain = fields.Selection(
|
||||
selection_add=[
|
||||
('invoice', 'Invoice'),
|
||||
('bill', 'Vendor Bill'),
|
||||
],
|
||||
ondelete={
|
||||
'invoice': 'cascade',
|
||||
'bill': 'cascade',
|
||||
},
|
||||
)
|
||||
account_prefix = fields.Char(
|
||||
string='Financial Accounts Prefix',
|
||||
help="Prefix that defines which accounts from the financial accounting this applicability should apply on.",
|
||||
)
|
||||
product_categ_id = fields.Many2one(
|
||||
'product.category',
|
||||
string='Product Category'
|
||||
)
|
||||
|
||||
def _get_score(self, **kwargs):
|
||||
score = super(AccountAnalyticApplicability, self)._get_score(**kwargs)
|
||||
if score == -1:
|
||||
return -1
|
||||
product = self.env['product.product'].browse(kwargs.get('product', None))
|
||||
account = self.env['account.account'].browse(kwargs.get('account', None))
|
||||
if self.account_prefix:
|
||||
if account and account.code.startswith(self.account_prefix):
|
||||
score += 1
|
||||
else:
|
||||
return -1
|
||||
if self.product_categ_id:
|
||||
if product and product.categ_id == self.product_categ_id:
|
||||
score += 1
|
||||
else:
|
||||
return -1
|
||||
return score
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from contextlib import contextmanager
|
||||
|
||||
from odoo import api, fields, models, _, Command
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.misc import formatLang
|
||||
|
||||
class AccountBankStatement(models.Model):
|
||||
_name = "account.bank.statement"
|
||||
_description = "Bank Statement"
|
||||
_order = "first_line_index desc"
|
||||
_check_company_auto = True
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
compute='_compute_name', store=True, readonly=False,
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# Used to hold the reference of the external mean that created this statement (name of imported file,
|
||||
# reference of online synchronization...)
|
||||
reference = fields.Char(
|
||||
string='External Reference',
|
||||
copy=False,
|
||||
)
|
||||
|
||||
date = fields.Date(
|
||||
compute='_compute_date_index', store=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# The internal index of the first line of a statement, it is used for sorting the statements
|
||||
# The date field cannot be used as there might be more than one statement in one day.
|
||||
# keeping this order is important because the validity of the statements are based on their order
|
||||
first_line_index = fields.Char(
|
||||
comodel_name='account.bank.statement.line',
|
||||
compute='_compute_date_index', store=True, index=True,
|
||||
)
|
||||
|
||||
balance_start = fields.Monetary(
|
||||
string='Starting Balance',
|
||||
compute='_compute_balance_start', store=True, readonly=False,
|
||||
)
|
||||
|
||||
# Balance end is calculated based on the statement line amounts and real starting balance.
|
||||
balance_end = fields.Monetary(
|
||||
string='Computed Balance',
|
||||
compute='_compute_balance_end', store=True,
|
||||
)
|
||||
|
||||
balance_end_real = fields.Monetary(
|
||||
string='Ending Balance',
|
||||
compute='_compute_balance_end_real', store=True, readonly=False,
|
||||
)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
comodel_name='res.company',
|
||||
related='journal_id.company_id', store=True,
|
||||
)
|
||||
|
||||
currency_id = fields.Many2one(
|
||||
comodel_name='res.currency',
|
||||
compute='_compute_currency_id',
|
||||
)
|
||||
|
||||
journal_id = fields.Many2one(
|
||||
comodel_name='account.journal',
|
||||
compute='_compute_journal_id', store=True,
|
||||
check_company=True,
|
||||
)
|
||||
|
||||
line_ids = fields.One2many(
|
||||
comodel_name='account.bank.statement.line',
|
||||
inverse_name='statement_id',
|
||||
string='Statement lines',
|
||||
required=True,
|
||||
)
|
||||
|
||||
# A statement assumed to be complete when the sum of encoded lines is equal to the difference between start and
|
||||
# end balances.
|
||||
is_complete = fields.Boolean(
|
||||
compute='_compute_is_complete', store=True,
|
||||
)
|
||||
|
||||
# A statement is considered valid when the starting balance matches the ending balance of the previous statement.
|
||||
# The lines without statements are neglected because, either the user is using statements regularly, so they can
|
||||
# assume every line without statement is problematic, or they don't use them regularly, in that case statements are
|
||||
# working as checkpoints only and their validity is not important.
|
||||
# The first statement of a journal is always considered valid. The validity of the statement is based on other
|
||||
# statements, so one can say this is external integrity check were as is_complete is the internal integrity.
|
||||
is_valid = fields.Boolean(
|
||||
compute='_compute_is_valid',
|
||||
search='_search_is_valid',
|
||||
)
|
||||
|
||||
problem_description = fields.Text(
|
||||
compute='_compute_problem_description',
|
||||
)
|
||||
|
||||
attachment_ids = fields.Many2many(
|
||||
comodel_name='ir.attachment',
|
||||
string="Attachments",
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# COMPUTE METHODS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@api.depends('create_date')
|
||||
def _compute_name(self):
|
||||
for stmt in self:
|
||||
stmt.name = _("%s Statement %s", stmt.journal_id.code, stmt.date)
|
||||
|
||||
@api.depends('line_ids.internal_index', 'line_ids.state')
|
||||
def _compute_date_index(self):
|
||||
for stmt in self:
|
||||
# When we create lines manually from the form view, they don't have any `internal_index` set yet.
|
||||
sorted_lines = stmt.line_ids.filtered("internal_index").sorted('internal_index')
|
||||
stmt.first_line_index = sorted_lines[:1].internal_index
|
||||
stmt.date = sorted_lines.filtered(lambda l: l.state == 'posted')[-1:].date
|
||||
|
||||
@api.depends('create_date')
|
||||
def _compute_balance_start(self):
|
||||
for stmt in self.sorted(lambda x: x.first_line_index or '0'):
|
||||
journal_id = stmt.journal_id.id or stmt.line_ids.journal_id.id
|
||||
previous_line_with_statement = self.env['account.bank.statement.line'].search([
|
||||
('internal_index', '<', stmt.first_line_index),
|
||||
('journal_id', '=', journal_id),
|
||||
('state', '=', 'posted'),
|
||||
('statement_id', '!=', False),
|
||||
], limit=1)
|
||||
balance_start = previous_line_with_statement.statement_id.balance_end_real
|
||||
|
||||
lines_in_between_domain = [
|
||||
('internal_index', '<', stmt.first_line_index),
|
||||
('journal_id', '=', journal_id),
|
||||
('state', '=', 'posted'),
|
||||
]
|
||||
if previous_line_with_statement:
|
||||
lines_in_between_domain.append(('internal_index', '>', previous_line_with_statement.internal_index))
|
||||
# remove lines from previous statement (when multi-editing a line already in another statement)
|
||||
previous_st_lines = previous_line_with_statement.statement_id.line_ids
|
||||
lines_in_common = previous_st_lines.filtered(lambda l: l.id in stmt.line_ids._origin.ids)
|
||||
balance_start -= sum(lines_in_common.mapped('amount'))
|
||||
|
||||
lines_in_between = self.env['account.bank.statement.line'].search(lines_in_between_domain)
|
||||
balance_start += sum(lines_in_between.mapped('amount'))
|
||||
|
||||
stmt.balance_start = balance_start
|
||||
|
||||
@api.depends('balance_start', 'line_ids.amount', 'line_ids.state')
|
||||
def _compute_balance_end(self):
|
||||
for stmt in self:
|
||||
lines = stmt.line_ids.filtered(lambda x: x.state == 'posted')
|
||||
stmt.balance_end = stmt.balance_start + sum(lines.mapped('amount'))
|
||||
|
||||
@api.depends('balance_start')
|
||||
def _compute_balance_end_real(self):
|
||||
for stmt in self:
|
||||
stmt.balance_end_real = stmt.balance_end
|
||||
|
||||
@api.depends('journal_id')
|
||||
def _compute_currency_id(self):
|
||||
for statement in self:
|
||||
statement.currency_id = statement.journal_id.currency_id or statement.company_id.currency_id
|
||||
|
||||
@api.depends('line_ids.journal_id')
|
||||
def _compute_journal_id(self):
|
||||
for statement in self:
|
||||
statement.journal_id = statement.line_ids.journal_id
|
||||
|
||||
@api.depends('balance_end', 'balance_end_real', 'line_ids.amount', 'line_ids.state')
|
||||
def _compute_is_complete(self):
|
||||
for stmt in self:
|
||||
stmt.is_complete = stmt.line_ids.filtered(lambda l: l.state == 'posted') and stmt.currency_id.compare_amounts(
|
||||
stmt.balance_end, stmt.balance_end_real) == 0
|
||||
|
||||
@api.depends('balance_end', 'balance_end_real')
|
||||
def _compute_is_valid(self):
|
||||
# we extract the invalid statements, the statements with no lines and the first statement are not in the query
|
||||
# because they don't have a previous statement, so they are excluded from the join, and we consider them valid.
|
||||
# if we have extracted the valid ones, we would have to mark above-mentioned statements valid manually
|
||||
# For new statements, a sql query can't be used
|
||||
if len(self) == 1:
|
||||
self.is_valid = self._get_statement_validity()
|
||||
else:
|
||||
invalids = self.filtered(lambda s: s.id in self._get_invalid_statement_ids())
|
||||
invalids.is_valid = False
|
||||
(self - invalids).is_valid = True
|
||||
|
||||
@api.depends('is_valid', 'is_complete')
|
||||
def _compute_problem_description(self):
|
||||
for stmt in self:
|
||||
description = None
|
||||
if not stmt.is_valid:
|
||||
description = _("The starting balance doesn't match the ending balance of the previous statement, or an earlier statement is missing.")
|
||||
elif not stmt.is_complete:
|
||||
description = _("The running balance (%s) doesn't match the specified ending balance.", formatLang(self.env, stmt.balance_end, currency_obj=stmt.currency_id))
|
||||
stmt.problem_description = description
|
||||
|
||||
def _search_is_valid(self, operator, value):
|
||||
if operator not in ('=', '!=', '<>'):
|
||||
raise UserError(_('Operation not supported'))
|
||||
invalid_ids = self._get_invalid_statement_ids(all_statements=True)
|
||||
if operator in ('!=', '<>') and value or operator == '=' and not value:
|
||||
return [('id', 'in', invalid_ids)]
|
||||
return [('id', 'not in', invalid_ids)]
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# BUSINESS METHODS
|
||||
# -------------------------------------------------------------------------
|
||||
def _get_statement_validity(self):
|
||||
""" Compares the balance_start to the previous statements balance_end_real """
|
||||
self.ensure_one()
|
||||
previous = self.env['account.bank.statement'].search(
|
||||
[
|
||||
('first_line_index', '<', self.first_line_index),
|
||||
('journal_id', '=', self.journal_id.id),
|
||||
],
|
||||
limit=1,
|
||||
order='first_line_index DESC',
|
||||
)
|
||||
return not previous or self.currency_id.compare_amounts(self.balance_start, previous.balance_end_real) == 0
|
||||
|
||||
def _get_invalid_statement_ids(self, all_statements=None):
|
||||
""" Returns the statements that are invalid for _compute and _search methods."""
|
||||
|
||||
self.env['account.bank.statement.line'].flush_model(['statement_id', 'internal_index'])
|
||||
self.env['account.bank.statement'].flush_model(['balance_start', 'balance_end_real', 'first_line_index'])
|
||||
|
||||
self.env.cr.execute(f"""
|
||||
SELECT st.id
|
||||
FROM account_bank_statement st
|
||||
LEFT JOIN res_company co ON st.company_id = co.id
|
||||
LEFT JOIN account_journal j ON st.journal_id = j.id
|
||||
LEFT JOIN res_currency currency ON COALESCE(j.currency_id, co.currency_id) = currency.id,
|
||||
LATERAL (
|
||||
SELECT balance_end_real
|
||||
FROM account_bank_statement st_lookup
|
||||
WHERE st_lookup.first_line_index < st.first_line_index
|
||||
AND st_lookup.journal_id = st.journal_id
|
||||
ORDER BY st_lookup.first_line_index desc
|
||||
LIMIT 1
|
||||
) prev
|
||||
WHERE ROUND(prev.balance_end_real, currency.decimal_places) != ROUND(st.balance_start, currency.decimal_places)
|
||||
{"" if all_statements else "AND st.id IN %(ids)s"}
|
||||
""", {
|
||||
'ids': tuple(self.ids)
|
||||
})
|
||||
res = self.env.cr.fetchall()
|
||||
return [r[0] for r in res]
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# LOW-LEVEL METHODS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
# EXTENDS base
|
||||
defaults = super().default_get(fields_list)
|
||||
|
||||
if 'line_ids' not in fields_list:
|
||||
return defaults
|
||||
|
||||
active_ids = self._context.get('active_ids')
|
||||
context_split_line_id = self._context.get('split_line_id')
|
||||
context_st_line_id = self._context.get('st_line_id')
|
||||
lines = None
|
||||
# creating statements with split button
|
||||
if context_split_line_id:
|
||||
current_st_line = self.env['account.bank.statement.line'].browse(context_split_line_id)
|
||||
line_before = self.env['account.bank.statement.line'].search(
|
||||
domain=[
|
||||
('internal_index', '<', current_st_line.internal_index),
|
||||
('journal_id', '=', current_st_line.journal_id.id),
|
||||
('statement_id', '!=', current_st_line.statement_id.id),
|
||||
('statement_id', '!=', False),
|
||||
],
|
||||
order='internal_index desc',
|
||||
limit=1,
|
||||
)
|
||||
lines = self.env['account.bank.statement.line'].search(
|
||||
domain=[
|
||||
('internal_index', '<=', current_st_line.internal_index),
|
||||
('internal_index', '>', line_before.internal_index or ''),
|
||||
('journal_id', '=', current_st_line.journal_id.id),
|
||||
],
|
||||
order='internal_index desc',
|
||||
)
|
||||
# single line edit
|
||||
elif context_st_line_id and len(active_ids) <= 1:
|
||||
lines = self.env['account.bank.statement.line'].browse(context_st_line_id)
|
||||
# multi edit
|
||||
elif context_st_line_id and len(active_ids) > 1:
|
||||
lines = self.env['account.bank.statement.line'].browse(active_ids).sorted()
|
||||
if len(lines.journal_id) > 1:
|
||||
raise UserError(_("A statement should only contain lines from the same journal."))
|
||||
# Check that the selected lines are contiguous (there might be canceled lines between the indexes and these should be ignored from the check)
|
||||
indexes = lines.mapped('internal_index')
|
||||
lines_between = self.env['account.bank.statement.line'].search([
|
||||
('internal_index', '>=', min(indexes)),
|
||||
('internal_index', '<=', max(indexes)),
|
||||
('journal_id', '=', lines.journal_id.id),
|
||||
])
|
||||
canceled_lines = lines_between.filtered(lambda l: l.state == 'cancel')
|
||||
if len(lines) != len(lines_between - canceled_lines):
|
||||
raise UserError(_("Unable to create a statement due to missing transactions. You may want to reorder the transactions before proceeding."))
|
||||
lines |= canceled_lines
|
||||
|
||||
if lines:
|
||||
defaults['line_ids'] = [Command.set(lines.ids)]
|
||||
|
||||
return defaults
|
||||
|
||||
@contextmanager
|
||||
def _check_attachments(self, container, values_list):
|
||||
attachments_to_fix_list = []
|
||||
for values in values_list:
|
||||
attachment_ids = set()
|
||||
for orm_command in values.get('attachment_ids', []):
|
||||
if orm_command[0] == Command.LINK:
|
||||
attachment_ids.add(orm_command[1])
|
||||
elif orm_command[0] == Command.SET:
|
||||
for attachment_id in orm_command[2]:
|
||||
attachment_ids.add(attachment_id)
|
||||
|
||||
attachments = self.env['ir.attachment'].browse(list(attachment_ids))
|
||||
attachments_to_fix_list.append(attachments)
|
||||
|
||||
yield
|
||||
|
||||
for stmt, attachments in zip(container['records'], attachments_to_fix_list):
|
||||
attachments.write({'res_id': stmt.id, 'res_model': stmt._name})
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
container = {'records': self.env['account.bank.statement']}
|
||||
with self._check_attachments(container, vals_list):
|
||||
container['records'] = stmts = super().create(vals_list)
|
||||
return stmts
|
||||
|
||||
def write(self, values):
|
||||
if len(self) != 1 and 'attachment_ids' in values:
|
||||
values.pop('attachment_ids')
|
||||
|
||||
container = {'records': self}
|
||||
with self._check_attachments(container, [values]):
|
||||
result = super().write(values)
|
||||
return result
|
||||
|
|
@ -0,0 +1,852 @@
|
|||
from odoo import api, Command, fields, models, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.tools import html2plaintext
|
||||
from odoo.tools.misc import str2bool
|
||||
|
||||
from odoo.addons.base.models.res_bank import sanitize_account_number
|
||||
|
||||
from xmlrpc.client import MAXINT
|
||||
from itertools import product
|
||||
|
||||
|
||||
class AccountBankStatementLine(models.Model):
|
||||
_name = "account.bank.statement.line"
|
||||
_inherits = {'account.move': 'move_id'}
|
||||
_description = "Bank Statement Line"
|
||||
_order = "internal_index desc"
|
||||
_check_company_auto = True
|
||||
|
||||
# FIXME: Field having the same name in both tables are confusing (partner_id). We don't change it because:
|
||||
# - It's a mess to track/fix.
|
||||
# - Some fields here could be simplified when the onchanges will be gone in account.move.
|
||||
# Should be improved in the future.
|
||||
# - there should be a better way for syncing account_moves with bank transactions, payments, invoices, etc.
|
||||
|
||||
# == Business fields ==
|
||||
def default_get(self, fields_list):
|
||||
defaults = super().default_get(fields_list)
|
||||
# copy the date and statement from the latest transaction of the same journal to help the user
|
||||
# to enter the next transaction, they do not have to enter the date and the statement every time until the
|
||||
# statement is completed. It is only possible if we know the journal that is used, so it can only be done
|
||||
# in a view in which the journal is already set and so is single journal view.
|
||||
if 'journal_id' in defaults and 'date' in fields_list:
|
||||
last_line = self.search([
|
||||
('journal_id', '=', defaults.get('journal_id')),
|
||||
('state', '=', 'posted'),
|
||||
], limit=1)
|
||||
statement = last_line.statement_id
|
||||
if statement:
|
||||
defaults.setdefault('date', statement.date)
|
||||
elif last_line:
|
||||
defaults.setdefault('date', last_line.date)
|
||||
|
||||
return defaults
|
||||
|
||||
move_id = fields.Many2one(
|
||||
comodel_name='account.move',
|
||||
auto_join=True,
|
||||
string='Journal Entry', required=True, readonly=True, ondelete='cascade',
|
||||
index=True,
|
||||
check_company=True)
|
||||
statement_id = fields.Many2one(
|
||||
comodel_name='account.bank.statement',
|
||||
string='Statement',
|
||||
)
|
||||
|
||||
# Payments generated during the reconciliation of this bank statement lines.
|
||||
payment_ids = fields.Many2many(
|
||||
comodel_name='account.payment',
|
||||
relation='account_payment_account_bank_statement_line_rel',
|
||||
string='Auto-generated Payments',
|
||||
)
|
||||
|
||||
# This sequence is working reversed because the default order is reversed, more info in compute_internal_index
|
||||
sequence = fields.Integer(default=1)
|
||||
partner_id = fields.Many2one(
|
||||
comodel_name='res.partner',
|
||||
string='Partner', ondelete='restrict',
|
||||
domain="['|', ('parent_id','=', False), ('is_company','=',True)]",
|
||||
check_company=True)
|
||||
|
||||
# Technical field used to store the bank account number before its creation, upon the line's processing
|
||||
account_number = fields.Char(string='Bank Account Number')
|
||||
|
||||
# This field is used to record the third party name when importing bank statement in electronic format,
|
||||
# when the partner doesn't exist yet in the database (or cannot be found).
|
||||
partner_name = fields.Char()
|
||||
|
||||
# Transaction type is used in electronic format, when the type of transaction is available in the imported file.
|
||||
transaction_type = fields.Char()
|
||||
payment_ref = fields.Char(string='Label')
|
||||
currency_id = fields.Many2one(
|
||||
comodel_name='res.currency',
|
||||
string='Journal Currency',
|
||||
compute='_compute_currency_id', store=True,
|
||||
)
|
||||
amount = fields.Monetary()
|
||||
|
||||
# Note the values of this field does not necessarily correspond to the cumulated balance in the account move line.
|
||||
# here these values correspond to occurrence order (the reality) and they should match the bank report but in
|
||||
# the move lines, it corresponds to the recognition order. Also, the statements act as checkpoints on this field
|
||||
running_balance = fields.Monetary(
|
||||
compute='_compute_running_balance'
|
||||
)
|
||||
foreign_currency_id = fields.Many2one(
|
||||
comodel_name='res.currency',
|
||||
string="Foreign Currency",
|
||||
help="The optional other currency if it is a multi-currency entry.",
|
||||
)
|
||||
amount_currency = fields.Monetary(
|
||||
compute='_compute_amount_currency', store=True, readonly=False,
|
||||
string="Amount in Currency",
|
||||
currency_field='foreign_currency_id',
|
||||
help="The amount expressed in an optional other currency if it is a multi-currency entry.",
|
||||
)
|
||||
|
||||
# == Technical fields ==
|
||||
# The amount left to be reconciled on this statement line (signed according to its move lines' balance),
|
||||
# expressed in its currency. This is a technical field use to speed up the application of reconciliation models.
|
||||
amount_residual = fields.Float(
|
||||
string="Residual Amount",
|
||||
compute="_compute_is_reconciled",
|
||||
store=True,
|
||||
)
|
||||
country_code = fields.Char(
|
||||
related='company_id.account_fiscal_country_id.code'
|
||||
)
|
||||
|
||||
# Technical field used to store the internal reference of the statement line for fast indexing and easier comparing
|
||||
# of statement lines. It holds the combination of the date, sequence and id of each line. Without this field,
|
||||
# the search/sorting lines would be very slow. The date field is related and stored in the account.move model,
|
||||
# so it is not possible to have an index on it (unless we use a sql view which is too complicated).
|
||||
# Using this prevents us having a compound index, and extensive `where` clauses.
|
||||
# Without this finding lines before current line (which we need e.g. for calculating the running balance)
|
||||
# would need a query like this:
|
||||
# date < current date OR (date = current date AND sequence > current date) or (
|
||||
# date = current date AND sequence = current sequence AND id < current id)
|
||||
# which needs to be repeated all over the code.
|
||||
# This would be simply "internal index < current internal index" using this field.
|
||||
internal_index = fields.Char(
|
||||
string='Internal Reference',
|
||||
compute='_compute_internal_index', store=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Technical field indicating if the statement line is already reconciled.
|
||||
is_reconciled = fields.Boolean(
|
||||
string='Is Reconciled',
|
||||
compute='_compute_is_reconciled', store=True,
|
||||
)
|
||||
statement_complete = fields.Boolean(
|
||||
related='statement_id.is_complete',
|
||||
)
|
||||
statement_valid = fields.Boolean(
|
||||
related='statement_id.is_valid',
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# COMPUTE METHODS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@api.depends('foreign_currency_id', 'date', 'amount', 'company_id')
|
||||
def _compute_amount_currency(self):
|
||||
for st_line in self:
|
||||
if not st_line.foreign_currency_id:
|
||||
st_line.amount_currency = False
|
||||
elif st_line.date and not st_line.amount_currency:
|
||||
# only convert if it hasn't been set already
|
||||
st_line.amount_currency = st_line.currency_id._convert(
|
||||
from_amount=st_line.amount,
|
||||
to_currency=st_line.foreign_currency_id,
|
||||
company=st_line.company_id,
|
||||
date=st_line.date,
|
||||
)
|
||||
|
||||
@api.depends('journal_id.currency_id')
|
||||
def _compute_currency_id(self):
|
||||
for st_line in self:
|
||||
st_line.currency_id = st_line.journal_id.currency_id or st_line.company_id.currency_id
|
||||
|
||||
def _compute_running_balance(self):
|
||||
# It looks back to find the latest statement and uses its balance_start as an anchor point for calculation, so
|
||||
# that the running balance is always relative to the latest statement. In this way we do not need to calculate
|
||||
# the running balance for all statement lines every time.
|
||||
# If there are statements inside the computed range, their balance_start has priority over calculated balance.
|
||||
# we have to compute running balance for draft lines because they are visible and also
|
||||
# the user can split on that lines, but their balance should be the same as previous posted line
|
||||
# we do the same for the canceled lines, in order to keep using them as anchor points
|
||||
|
||||
self.statement_id.flush_model(['balance_start', 'first_line_index'])
|
||||
self.flush_model(['internal_index', 'date', 'journal_id', 'statement_id', 'amount', 'state'])
|
||||
record_by_id = {x.id: x for x in self}
|
||||
|
||||
for journal in self.journal_id:
|
||||
journal_lines_indexes = self.filtered(lambda line: line.journal_id == journal)\
|
||||
.sorted('internal_index')\
|
||||
.mapped('internal_index')
|
||||
min_index, max_index = journal_lines_indexes[0], journal_lines_indexes[-1]
|
||||
|
||||
# Find the oldest index for each journal.
|
||||
self._cr.execute(
|
||||
"""
|
||||
SELECT first_line_index, COALESCE(balance_start, 0.0)
|
||||
FROM account_bank_statement
|
||||
WHERE
|
||||
first_line_index < %s
|
||||
AND journal_id = %s
|
||||
ORDER BY first_line_index DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
[min_index, journal.id],
|
||||
)
|
||||
current_running_balance = 0.0
|
||||
extra_clause = ''
|
||||
extra_params = []
|
||||
row = self._cr.fetchone()
|
||||
if row:
|
||||
starting_index, current_running_balance = row
|
||||
extra_clause = "AND st_line.internal_index >= %s"
|
||||
extra_params.append(starting_index)
|
||||
|
||||
self._cr.execute(
|
||||
f"""
|
||||
SELECT
|
||||
st_line.id,
|
||||
st_line.amount,
|
||||
st.first_line_index = st_line.internal_index AS is_anchor,
|
||||
COALESCE(st.balance_start, 0.0),
|
||||
move.state
|
||||
FROM account_bank_statement_line st_line
|
||||
JOIN account_move move ON move.id = st_line.move_id
|
||||
LEFT JOIN account_bank_statement st ON st.id = st_line.statement_id
|
||||
WHERE
|
||||
st_line.internal_index <= %s
|
||||
AND move.journal_id = %s
|
||||
{extra_clause}
|
||||
ORDER BY st_line.internal_index
|
||||
""",
|
||||
[max_index, journal.id] + extra_params,
|
||||
)
|
||||
pending_items = self
|
||||
for st_line_id, amount, is_anchor, balance_start, state in self._cr.fetchall():
|
||||
if is_anchor:
|
||||
current_running_balance = balance_start
|
||||
if state == 'posted':
|
||||
current_running_balance += amount
|
||||
if record_by_id.get(st_line_id):
|
||||
record_by_id[st_line_id].running_balance = current_running_balance
|
||||
pending_items -= record_by_id[st_line_id]
|
||||
# Lines manually deleted from the form view still require to have a value set here, as the field is computed and non-stored.
|
||||
for item in pending_items:
|
||||
item.running_balance = item.running_balance
|
||||
|
||||
@api.depends('date', 'sequence')
|
||||
def _compute_internal_index(self):
|
||||
"""
|
||||
Internal index is a field that holds the combination of the date, compliment of sequence and id of each line.
|
||||
Using this prevents us having a compound index, and extensive where clauses.
|
||||
Without this finding lines before current line (which we need for calculating the running balance)
|
||||
would need a query like this:
|
||||
date < current date OR (date = current date AND sequence > current date) or (
|
||||
date = current date AND sequence = current sequence AND id < current id)
|
||||
which needs to be repeated all over the code.
|
||||
This would be simply "internal index < current internal index" using this field.
|
||||
Also, we would need a compound index of date + sequence + id
|
||||
on the table which is not possible because date is not in this table (it is in the account move table)
|
||||
unless we use a sql view which is more complicated.
|
||||
"""
|
||||
# ensure we are using correct value for reversing sequence in the index (2147483647)
|
||||
# NOTE: assert self._fields['sequence'].column_type[1] == 'int4'
|
||||
# if for any reason it changes (how unlikely), we need to update this code
|
||||
|
||||
for st_line in self.filtered(lambda line: line._origin.id):
|
||||
st_line.internal_index = f'{st_line.date.strftime("%Y%m%d")}' \
|
||||
f'{MAXINT - st_line.sequence:0>10}' \
|
||||
f'{st_line._origin.id:0>10}'
|
||||
|
||||
@api.depends('journal_id', 'currency_id', 'amount', 'foreign_currency_id', 'amount_currency',
|
||||
'move_id.to_check',
|
||||
'move_id.line_ids.account_id', 'move_id.line_ids.amount_currency',
|
||||
'move_id.line_ids.amount_residual_currency', 'move_id.line_ids.currency_id',
|
||||
'move_id.line_ids.matched_debit_ids', 'move_id.line_ids.matched_credit_ids')
|
||||
def _compute_is_reconciled(self):
|
||||
""" Compute the field indicating if the statement lines are already reconciled with something.
|
||||
This field is used for display purpose (e.g. display the 'cancel' button on the statement lines).
|
||||
Also computes the residual amount of the statement line.
|
||||
"""
|
||||
for st_line in self:
|
||||
_liquidity_lines, suspense_lines, _other_lines = st_line._seek_for_lines()
|
||||
|
||||
# Compute residual amount
|
||||
if st_line.to_check:
|
||||
st_line.amount_residual = -st_line.amount_currency if st_line.foreign_currency_id else -st_line.amount
|
||||
elif suspense_lines.account_id.reconcile:
|
||||
st_line.amount_residual = sum(suspense_lines.mapped('amount_residual_currency'))
|
||||
else:
|
||||
st_line.amount_residual = sum(suspense_lines.mapped('amount_currency'))
|
||||
|
||||
# Compute is_reconciled
|
||||
if not st_line.id:
|
||||
# New record: The journal items are not yet there.
|
||||
st_line.is_reconciled = False
|
||||
elif suspense_lines:
|
||||
# In case of the statement line comes from an older version, it could have a residual amount of zero.
|
||||
st_line.is_reconciled = suspense_lines.currency_id.is_zero(st_line.amount_residual)
|
||||
elif st_line.currency_id.is_zero(st_line.amount):
|
||||
st_line.is_reconciled = True
|
||||
else:
|
||||
# The journal entry seems reconciled.
|
||||
st_line.is_reconciled = True
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# CONSTRAINT METHODS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@api.constrains('amount', 'amount_currency', 'currency_id', 'foreign_currency_id', 'journal_id')
|
||||
def _check_amounts_currencies(self):
|
||||
""" Ensure the consistency the specified amounts and the currencies. """
|
||||
|
||||
for st_line in self:
|
||||
if st_line.foreign_currency_id == st_line.currency_id:
|
||||
raise ValidationError(_("The foreign currency must be different than the journal one: %s",
|
||||
st_line.currency_id.name))
|
||||
if not st_line.foreign_currency_id and st_line.amount_currency:
|
||||
raise ValidationError(_("You can't provide an amount in foreign currency without "
|
||||
"specifying a foreign currency."))
|
||||
if not st_line.amount_currency and st_line.foreign_currency_id:
|
||||
raise ValidationError(_("You can't provide a foreign currency without specifying an amount in "
|
||||
"'Amount in Currency' field."))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# LOW-LEVEL METHODS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def new(self, values=None, origin=None, ref=None):
|
||||
st_line = super().new(values, origin, ref)
|
||||
if not st_line.journal_id: # might not be computed because declared by inheritance
|
||||
st_line.move_id._compute_journal_id()
|
||||
return st_line
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
# OVERRIDE
|
||||
counterpart_account_ids = []
|
||||
|
||||
for vals in vals_list:
|
||||
if 'statement_id' in vals and 'journal_id' not in vals:
|
||||
statement = self.env['account.bank.statement'].browse(vals['statement_id'])
|
||||
# Ensure the journal is the same as the statement one.
|
||||
# journal_id is a required field in the view, so it should be always available if the user
|
||||
# is creating the record, however, if a sync/import modules tries to add a line to an existing
|
||||
# statement they can omit the journal field because it can be obtained from the statement
|
||||
if statement.journal_id:
|
||||
vals['journal_id'] = statement.journal_id.id
|
||||
|
||||
# Avoid having the same foreign_currency_id as currency_id.
|
||||
if vals.get('journal_id') and vals.get('foreign_currency_id'):
|
||||
journal = self.env['account.journal'].browse(vals['journal_id'])
|
||||
journal_currency = journal.currency_id or journal.company_id.currency_id
|
||||
if vals['foreign_currency_id'] == journal_currency.id:
|
||||
vals['foreign_currency_id'] = None
|
||||
vals['amount_currency'] = 0.0
|
||||
|
||||
# Force the move_type to avoid inconsistency with residual 'default_move_type' inside the context.
|
||||
vals['move_type'] = 'entry'
|
||||
|
||||
# Hack to force different account instead of the suspense account.
|
||||
counterpart_account_ids.append(vals.pop('counterpart_account_id', None))
|
||||
|
||||
#Set the amount to 0 if it's not specified.
|
||||
if 'amount' not in vals:
|
||||
vals['amount'] = 0
|
||||
|
||||
st_lines = super().create(vals_list)
|
||||
|
||||
for i, st_line in enumerate(st_lines):
|
||||
counterpart_account_id = counterpart_account_ids[i]
|
||||
|
||||
to_write = {'statement_line_id': st_line.id, 'narration': st_line.narration}
|
||||
if 'line_ids' not in vals_list[i]:
|
||||
to_write['line_ids'] = [(0, 0, line_vals) for line_vals in st_line._prepare_move_line_default_vals(
|
||||
counterpart_account_id=counterpart_account_id)]
|
||||
|
||||
st_line.move_id.write(to_write)
|
||||
|
||||
# Otherwise field narration will be recomputed silently (at next flush) when writing on partner_id
|
||||
self.env.remove_to_compute(st_line.move_id._fields['narration'], st_line.move_id)
|
||||
|
||||
# No need for the user to manage their status (from 'Draft' to 'Posted')
|
||||
st_lines.move_id.action_post()
|
||||
return st_lines
|
||||
|
||||
def write(self, vals):
|
||||
# OVERRIDE
|
||||
|
||||
res = super().write(vals)
|
||||
self._synchronize_to_moves(set(vals.keys()))
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
# OVERRIDE to unlink the inherited account.move (move_id field) as well.
|
||||
moves = self.with_context(force_delete=True).mapped('move_id')
|
||||
res = super().unlink()
|
||||
moves.unlink()
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
|
||||
# Add latest running_balance in the read_group
|
||||
result = super(AccountBankStatementLine, self).read_group(
|
||||
domain, fields, groupby, offset=offset,
|
||||
limit=limit, orderby=orderby, lazy=lazy)
|
||||
show_running_balance = False
|
||||
# We loop over the content of groupby because the groupby date is in the form of "date:granularity"
|
||||
for el in groupby:
|
||||
if (el == 'statement_id' or el == 'journal_id' or el.startswith('date')) and 'running_balance' in fields:
|
||||
show_running_balance = True
|
||||
break
|
||||
if show_running_balance:
|
||||
for group_line in result:
|
||||
group_line['running_balance'] = self.search(group_line.get('__domain'), limit=1).running_balance or 0.0
|
||||
return result
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# ACTION METHODS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def action_undo_reconciliation(self):
|
||||
""" Undo the reconciliation made on the statement line and reset their journal items
|
||||
to their original states.
|
||||
"""
|
||||
self.line_ids.remove_move_reconcile()
|
||||
self.payment_ids.unlink()
|
||||
|
||||
for st_line in self:
|
||||
st_line.with_context(force_delete=True).write({
|
||||
'to_check': False,
|
||||
'line_ids': [Command.clear()] + [
|
||||
Command.create(line_vals) for line_vals in st_line._prepare_move_line_default_vals()],
|
||||
})
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# HELPERS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _find_or_create_bank_account(self):
|
||||
self.ensure_one()
|
||||
|
||||
# There is a sql constraint on res.partner.bank ensuring an unique pair <partner, account number>.
|
||||
# Since it's not dependent of the company, we need to search on others company too to avoid the creation
|
||||
# of an extra res.partner.bank raising an error coming from this constraint.
|
||||
# However, at the end, we need to filter out the results to not trigger the check_company when trying to
|
||||
# assign a res.partner.bank owned by another company.
|
||||
bank_account = self.env['res.partner.bank'].sudo().with_context(active_test=False).search([
|
||||
('acc_number', '=', self.account_number),
|
||||
('partner_id', '=', self.partner_id.id),
|
||||
])
|
||||
if not bank_account and not str2bool(
|
||||
self.env['ir.config_parameter'].sudo().get_param("account.skip_create_bank_account_on_reconcile")
|
||||
):
|
||||
bank_account = self.env['res.partner.bank'].create({
|
||||
'acc_number': self.account_number,
|
||||
'partner_id': self.partner_id.id,
|
||||
'journal_id': None,
|
||||
})
|
||||
return bank_account.filtered(lambda x: x.company_id.id in (False, self.company_id.id))
|
||||
|
||||
def _get_amounts_with_currencies(self):
|
||||
"""
|
||||
Returns the line amount in company, journal and foreign currencies
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
company_currency = self.journal_id.company_id.currency_id
|
||||
journal_currency = self.journal_id.currency_id or company_currency
|
||||
foreign_currency = self.foreign_currency_id or journal_currency or company_currency
|
||||
|
||||
journal_amount = self.amount
|
||||
if foreign_currency == journal_currency:
|
||||
transaction_amount = journal_amount
|
||||
else:
|
||||
transaction_amount = self.amount_currency
|
||||
if journal_currency == company_currency:
|
||||
company_amount = journal_amount
|
||||
elif foreign_currency == company_currency:
|
||||
company_amount = transaction_amount
|
||||
else:
|
||||
company_amount = journal_currency._convert(journal_amount, company_currency,
|
||||
self.journal_id.company_id, self.date)
|
||||
return company_amount, company_currency, journal_amount, journal_currency, transaction_amount, foreign_currency
|
||||
|
||||
def _get_default_amls_matching_domain(self):
|
||||
return [
|
||||
# Base domain.
|
||||
('display_type', 'not in', ('line_section', 'line_note')),
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', self.company_id.id),
|
||||
# Reconciliation domain.
|
||||
('reconciled', '=', False),
|
||||
('account_id.reconcile', '=', True),
|
||||
# Special domain for payments.
|
||||
'|',
|
||||
('account_id.account_type', 'not in', ('asset_receivable', 'liability_payable')),
|
||||
('payment_id', '=', False),
|
||||
# Special domain for statement lines.
|
||||
('statement_line_id', '!=', self.id),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _get_default_journal(self):
|
||||
journal_type = self.env.context.get('journal_type', 'bank')
|
||||
return self.env['account.journal'].search([
|
||||
('type', '=', journal_type),
|
||||
('company_id', '=', self.env.company.id)
|
||||
], limit=1)
|
||||
|
||||
def _get_st_line_strings_for_matching(self, allowed_fields=None):
|
||||
""" Collect the strings that could be used on the statement line to perform some matching.
|
||||
|
||||
:param allowed_fields: A explicit list of fields to consider.
|
||||
:return: A list of strings.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
st_line_text_values = []
|
||||
if not allowed_fields or 'payment_ref' in allowed_fields:
|
||||
if self.payment_ref:
|
||||
st_line_text_values.append(self.payment_ref)
|
||||
if not allowed_fields or 'narration' in allowed_fields:
|
||||
value = html2plaintext(self.narration or "")
|
||||
if value:
|
||||
st_line_text_values.append(value)
|
||||
if not allowed_fields or 'ref' in allowed_fields:
|
||||
if self.ref:
|
||||
st_line_text_values.append(self.ref)
|
||||
return st_line_text_values
|
||||
|
||||
def _get_accounting_amounts_and_currencies(self):
|
||||
""" Retrieve the transaction amount, journal amount and the company amount with their corresponding currencies
|
||||
from the journal entry linked to the statement line.
|
||||
All returned amounts will be positive for an inbound transaction, negative for an outbound one.
|
||||
|
||||
:return: (
|
||||
transaction_amount, transaction_currency,
|
||||
journal_amount, journal_currency,
|
||||
company_amount, company_currency,
|
||||
)
|
||||
"""
|
||||
self.ensure_one()
|
||||
liquidity_line, suspense_line, other_lines = self._seek_for_lines()
|
||||
if suspense_line and not other_lines:
|
||||
transaction_amount = -suspense_line.amount_currency
|
||||
transaction_currency = suspense_line.currency_id
|
||||
else:
|
||||
# In case of to_check or partial reconciliation, we can't trust the suspense line.
|
||||
transaction_amount = self.amount_currency if self.foreign_currency_id else self.amount
|
||||
transaction_currency = self.foreign_currency_id or liquidity_line.currency_id
|
||||
return (
|
||||
transaction_amount,
|
||||
transaction_currency,
|
||||
sum(liquidity_line.mapped('amount_currency')),
|
||||
liquidity_line.currency_id,
|
||||
sum(liquidity_line.mapped('balance')),
|
||||
liquidity_line.company_currency_id,
|
||||
)
|
||||
|
||||
def _prepare_counterpart_amounts_using_st_line_rate(self, currency, balance, amount_currency):
|
||||
""" Convert the amounts passed as parameters to the statement line currency using the rates provided by the
|
||||
bank. The computed amounts are the one that could be set on the statement line as a counterpart journal item
|
||||
to fully paid the provided amounts as parameters.
|
||||
|
||||
:param currency: The currency in which is expressed 'amount_currency'.
|
||||
:param balance: The amount expressed in company currency. Only needed when the currency passed as
|
||||
parameter is neither the statement line's foreign currency, neither the journal's
|
||||
currency.
|
||||
:param amount_currency: The amount expressed in the 'currency' passed as parameter.
|
||||
:return: A python dictionary containing:
|
||||
* balance: The amount to consider expressed in company's currency.
|
||||
* amount_currency: The amount to consider expressed in statement line's foreign currency.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
transaction_amount, transaction_currency, journal_amount, journal_currency, company_amount, company_currency \
|
||||
= self._get_accounting_amounts_and_currencies()
|
||||
|
||||
rate_journal2foreign_curr = journal_amount and abs(transaction_amount) / abs(journal_amount)
|
||||
rate_comp2journal_curr = company_amount and abs(journal_amount) / abs(company_amount)
|
||||
|
||||
if currency == transaction_currency:
|
||||
trans_amount_currency = amount_currency
|
||||
if rate_journal2foreign_curr:
|
||||
journ_amount_currency = journal_currency.round(trans_amount_currency / rate_journal2foreign_curr)
|
||||
else:
|
||||
journ_amount_currency = 0.0
|
||||
if rate_comp2journal_curr:
|
||||
new_balance = company_currency.round(journ_amount_currency / rate_comp2journal_curr)
|
||||
else:
|
||||
new_balance = 0.0
|
||||
elif currency == journal_currency:
|
||||
trans_amount_currency = transaction_currency.round(amount_currency * rate_journal2foreign_curr)
|
||||
if rate_comp2journal_curr:
|
||||
new_balance = company_currency.round(amount_currency / rate_comp2journal_curr)
|
||||
else:
|
||||
new_balance = 0.0
|
||||
else:
|
||||
journ_amount_currency = journal_currency.round(balance * rate_comp2journal_curr)
|
||||
trans_amount_currency = transaction_currency.round(journ_amount_currency * rate_journal2foreign_curr)
|
||||
new_balance = balance
|
||||
|
||||
return {
|
||||
'amount_currency': trans_amount_currency,
|
||||
'balance': new_balance,
|
||||
}
|
||||
|
||||
def _prepare_move_line_default_vals(self, counterpart_account_id=None):
|
||||
""" Prepare the dictionary to create the default account.move.lines for the current account.bank.statement.line
|
||||
record.
|
||||
:return: A list of python dictionary to be passed to the account.move.line's 'create' method.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if not counterpart_account_id:
|
||||
counterpart_account_id = self.journal_id.suspense_account_id.id
|
||||
|
||||
if not counterpart_account_id:
|
||||
raise UserError(_(
|
||||
"You can't create a new statement line without a suspense account set on the %s journal.",
|
||||
self.journal_id.display_name,
|
||||
))
|
||||
|
||||
company_amount, _company_currency, journal_amount, journal_currency, transaction_amount, foreign_currency \
|
||||
= self._get_amounts_with_currencies()
|
||||
|
||||
liquidity_line_vals = {
|
||||
'name': self.payment_ref,
|
||||
'move_id': self.move_id.id,
|
||||
'partner_id': self.partner_id.id,
|
||||
'account_id': self.journal_id.default_account_id.id,
|
||||
'currency_id': journal_currency.id,
|
||||
'amount_currency': journal_amount,
|
||||
'debit': company_amount > 0 and company_amount or 0.0,
|
||||
'credit': company_amount < 0 and -company_amount or 0.0,
|
||||
}
|
||||
|
||||
# Create the counterpart line values.
|
||||
counterpart_line_vals = {
|
||||
'name': self.payment_ref,
|
||||
'account_id': counterpart_account_id,
|
||||
'move_id': self.move_id.id,
|
||||
'partner_id': self.partner_id.id,
|
||||
'currency_id': foreign_currency.id,
|
||||
'amount_currency': -transaction_amount,
|
||||
'debit': -company_amount if company_amount < 0.0 else 0.0,
|
||||
'credit': company_amount if company_amount > 0.0 else 0.0,
|
||||
}
|
||||
return [liquidity_line_vals, counterpart_line_vals]
|
||||
|
||||
def _retrieve_partner(self):
|
||||
self.ensure_one()
|
||||
|
||||
# Retrieve the partner from the statement line.
|
||||
if self.partner_id:
|
||||
return self.partner_id
|
||||
|
||||
# Retrieve the partner from the bank account.
|
||||
if self.account_number:
|
||||
account_number_nums = sanitize_account_number(self.account_number)
|
||||
if account_number_nums:
|
||||
domain = [('sanitized_acc_number', 'ilike', account_number_nums)]
|
||||
for extra_domain in ([('company_id', '=', self.company_id.id)], [('company_id', '=', False)]):
|
||||
bank_accounts = self.env['res.partner.bank'].search(extra_domain + domain)
|
||||
if len(bank_accounts.partner_id) == 1:
|
||||
return bank_accounts.partner_id
|
||||
|
||||
# Retrieve the partner from the partner name.
|
||||
if self.partner_name:
|
||||
domains = product(
|
||||
[
|
||||
('name', '=ilike', self.partner_name),
|
||||
('name', 'ilike', self.partner_name),
|
||||
],
|
||||
[
|
||||
('company_id', '=', self.company_id.id),
|
||||
('company_id', '=', False),
|
||||
],
|
||||
)
|
||||
for domain in domains:
|
||||
partner = self.env['res.partner'].search(list(domain) + [('parent_id', '=', False)], limit=2)
|
||||
# Return the partner if there is only one with this name
|
||||
if len(partner) == 1:
|
||||
return partner
|
||||
|
||||
# Retrieve the partner from the 'reconcile models'.
|
||||
rec_models = self.env['account.reconcile.model'].search([
|
||||
('rule_type', '!=', 'writeoff_button'),
|
||||
('company_id', '=', self.company_id.id),
|
||||
])
|
||||
for rec_model in rec_models:
|
||||
partner = rec_model._get_partner_from_mapping(self)
|
||||
if partner and rec_model._is_applicable_for(self, partner):
|
||||
return partner
|
||||
|
||||
return self.env['res.partner']
|
||||
|
||||
def _seek_for_lines(self):
|
||||
""" Helper used to dispatch the journal items between:
|
||||
- The lines using the liquidity account.
|
||||
- The lines using the transfer account.
|
||||
- The lines being not in one of the two previous categories.
|
||||
:return: (liquidity_lines, suspense_lines, other_lines)
|
||||
"""
|
||||
liquidity_lines = self.env['account.move.line']
|
||||
suspense_lines = self.env['account.move.line']
|
||||
other_lines = self.env['account.move.line']
|
||||
|
||||
for line in self.move_id.line_ids:
|
||||
if line.account_id == self.journal_id.default_account_id:
|
||||
liquidity_lines += line
|
||||
elif line.account_id == self.journal_id.suspense_account_id:
|
||||
suspense_lines += line
|
||||
else:
|
||||
other_lines += line
|
||||
if not liquidity_lines:
|
||||
liquidity_lines = self.move_id.line_ids.filtered(lambda l: l.account_id.account_type in ('asset_cash', 'liability_credit_card'))
|
||||
other_lines -= liquidity_lines
|
||||
return liquidity_lines, suspense_lines, other_lines
|
||||
|
||||
# SYNCHRONIZATION account.bank.statement.line <-> account.move
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _synchronize_from_moves(self, changed_fields):
|
||||
""" Update the account.bank.statement.line regarding its related account.move.
|
||||
Also, check both models are still consistent.
|
||||
:param changed_fields: A set containing all modified fields on account.move.
|
||||
"""
|
||||
if self._context.get('skip_account_move_synchronization'):
|
||||
return
|
||||
|
||||
for st_line in self.with_context(skip_account_move_synchronization=True):
|
||||
move = st_line.move_id
|
||||
move_vals_to_write = {}
|
||||
st_line_vals_to_write = {}
|
||||
|
||||
if 'line_ids' in changed_fields:
|
||||
liquidity_lines, suspense_lines, _other_lines = st_line._seek_for_lines()
|
||||
company_currency = st_line.journal_id.company_id.currency_id
|
||||
journal_currency = st_line.journal_id.currency_id if st_line.journal_id.currency_id != company_currency\
|
||||
else False
|
||||
|
||||
if len(liquidity_lines) != 1:
|
||||
raise UserError(_(
|
||||
"The journal entry %s reached an invalid state regarding its related statement line.\n"
|
||||
"To be consistent, the journal entry must always have exactly one journal item involving the "
|
||||
"bank/cash account."
|
||||
) % st_line.move_id.display_name)
|
||||
|
||||
st_line_vals_to_write.update({
|
||||
'payment_ref': liquidity_lines.name,
|
||||
'partner_id': liquidity_lines.partner_id.id,
|
||||
})
|
||||
|
||||
# Update 'amount' according to the liquidity line.
|
||||
|
||||
if journal_currency:
|
||||
st_line_vals_to_write.update({
|
||||
'amount': liquidity_lines.amount_currency,
|
||||
})
|
||||
else:
|
||||
st_line_vals_to_write.update({
|
||||
'amount': liquidity_lines.balance,
|
||||
})
|
||||
|
||||
if len(suspense_lines) > 1:
|
||||
raise UserError(_(
|
||||
"%s reached an invalid state regarding its related statement line.\n"
|
||||
"To be consistent, the journal entry must always have exactly one suspense line.", st_line.move_id.display_name
|
||||
))
|
||||
elif len(suspense_lines) == 1:
|
||||
if journal_currency and suspense_lines.currency_id == journal_currency:
|
||||
|
||||
# The suspense line is expressed in the journal's currency meaning the foreign currency
|
||||
# set on the statement line is no longer needed.
|
||||
|
||||
st_line_vals_to_write.update({
|
||||
'amount_currency': 0.0,
|
||||
'foreign_currency_id': False,
|
||||
})
|
||||
|
||||
elif not journal_currency and suspense_lines.currency_id == company_currency:
|
||||
|
||||
# Don't set a specific foreign currency on the statement line.
|
||||
|
||||
st_line_vals_to_write.update({
|
||||
'amount_currency': 0.0,
|
||||
'foreign_currency_id': False,
|
||||
})
|
||||
|
||||
else:
|
||||
|
||||
# Update the statement line regarding the foreign currency of the suspense line.
|
||||
|
||||
st_line_vals_to_write.update({
|
||||
'amount_currency': -suspense_lines.amount_currency,
|
||||
'foreign_currency_id': suspense_lines.currency_id.id,
|
||||
})
|
||||
|
||||
move_vals_to_write.update({
|
||||
'partner_id': liquidity_lines.partner_id.id,
|
||||
'currency_id': (st_line.foreign_currency_id or journal_currency or company_currency).id,
|
||||
})
|
||||
|
||||
move.write(move._cleanup_write_orm_values(move, move_vals_to_write))
|
||||
st_line.write(move._cleanup_write_orm_values(st_line, st_line_vals_to_write))
|
||||
|
||||
def _synchronize_to_moves(self, changed_fields):
|
||||
""" Update the account.move regarding the modified account.bank.statement.line.
|
||||
:param changed_fields: A list containing all modified fields on account.bank.statement.line.
|
||||
"""
|
||||
if self._context.get('skip_account_move_synchronization'):
|
||||
return
|
||||
|
||||
if not any(field_name in changed_fields for field_name in (
|
||||
'payment_ref', 'amount', 'amount_currency',
|
||||
'foreign_currency_id', 'currency_id', 'partner_id',
|
||||
)):
|
||||
return
|
||||
|
||||
for st_line in self.with_context(skip_account_move_synchronization=True):
|
||||
liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines()
|
||||
journal = st_line.journal_id
|
||||
company_currency = journal.company_id.currency_id
|
||||
journal_currency = journal.currency_id if journal.currency_id != company_currency else False
|
||||
|
||||
line_vals_list = st_line._prepare_move_line_default_vals()
|
||||
line_ids_commands = [(1, liquidity_lines.id, line_vals_list[0])]
|
||||
|
||||
if suspense_lines:
|
||||
line_ids_commands.append((1, suspense_lines.id, line_vals_list[1]))
|
||||
else:
|
||||
line_ids_commands.append((0, 0, line_vals_list[1]))
|
||||
|
||||
for line in other_lines:
|
||||
line_ids_commands.append((2, line.id))
|
||||
|
||||
st_line_vals = {
|
||||
'currency_id': (st_line.foreign_currency_id or journal_currency or company_currency).id,
|
||||
'line_ids': line_ids_commands,
|
||||
}
|
||||
if st_line.move_id.journal_id != journal:
|
||||
st_line_vals['journal_id'] = journal.id
|
||||
if st_line.move_id.partner_id != st_line.partner_id:
|
||||
st_line_vals['partner_id'] = st_line.partner_id.id
|
||||
st_line.move_id.write(st_line_vals)
|
||||
|
||||
|
||||
# For optimization purpose, creating the reverse relation of m2o in _inherits saves
|
||||
# a lot of SQL queries
|
||||
class AccountMove(models.Model):
|
||||
_name = "account.move"
|
||||
_inherit = ['account.move']
|
||||
|
||||
statement_line_ids = fields.One2many('account.bank.statement.line', 'move_id', string='Statements')
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.tools import float_round
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class AccountCashRounding(models.Model):
|
||||
"""
|
||||
In some countries, we need to be able to make appear on an invoice a rounding line, appearing there only because the
|
||||
smallest coinage has been removed from the circulation. For example, in Switzerland invoices have to be rounded to
|
||||
0.05 CHF because coins of 0.01 CHF and 0.02 CHF aren't used anymore.
|
||||
see https://en.wikipedia.org/wiki/Cash_rounding for more details.
|
||||
"""
|
||||
_name = 'account.cash.rounding'
|
||||
_description = 'Account Cash Rounding'
|
||||
|
||||
name = fields.Char(string='Name', translate=True, required=True)
|
||||
rounding = fields.Float(string='Rounding Precision', required=True, default=0.01,
|
||||
help='Represent the non-zero value smallest coinage (for example, 0.05).')
|
||||
strategy = fields.Selection([('biggest_tax', 'Modify tax amount'), ('add_invoice_line', 'Add a rounding line')],
|
||||
string='Rounding Strategy', default='add_invoice_line', required=True,
|
||||
help='Specify which way will be used to round the invoice amount to the rounding precision')
|
||||
profit_account_id = fields.Many2one('account.account', string='Profit Account', company_dependent=True, domain="[('deprecated', '=', False), ('company_id', '=', current_company_id)]")
|
||||
loss_account_id = fields.Many2one('account.account', string='Loss Account', company_dependent=True, domain="[('deprecated', '=', False), ('company_id', '=', current_company_id)]")
|
||||
rounding_method = fields.Selection(string='Rounding Method', required=True,
|
||||
selection=[('UP', 'Up'), ('DOWN', 'Down'), ('HALF-UP', 'Nearest')],
|
||||
default='HALF-UP', help='The tie-breaking rule used for float rounding operations')
|
||||
company_id = fields.Many2one('res.company', related='profit_account_id.company_id')
|
||||
|
||||
@api.constrains('rounding')
|
||||
def validate_rounding(self):
|
||||
for record in self:
|
||||
if record.rounding <= 0:
|
||||
raise ValidationError(_("Please set a strictly positive rounding value."))
|
||||
|
||||
def round(self, amount):
|
||||
"""Compute the rounding on the amount passed as parameter.
|
||||
|
||||
:param amount: the amount to round
|
||||
:return: the rounded amount depending the rounding value and the rounding method
|
||||
"""
|
||||
return float_round(amount, precision_rounding=self.rounding, rounding_method=self.rounding_method)
|
||||
|
||||
def compute_difference(self, currency, amount):
|
||||
"""Compute the difference between the base_amount and the amount after rounding.
|
||||
For example, base_amount=23.91, after rounding=24.00, the result will be 0.09.
|
||||
|
||||
:param currency: The currency.
|
||||
:param amount: The amount
|
||||
:return: round(difference)
|
||||
"""
|
||||
amount = currency.round(amount)
|
||||
difference = self.round(amount) - amount
|
||||
return currency.round(difference)
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class AccountFullReconcile(models.Model):
|
||||
_name = "account.full.reconcile"
|
||||
_description = "Full Reconcile"
|
||||
|
||||
name = fields.Char(string='Number', required=True, copy=False, default=lambda self: self.env['ir.sequence'].next_by_code('account.reconcile'))
|
||||
partial_reconcile_ids = fields.One2many('account.partial.reconcile', 'full_reconcile_id', string='Reconciliation Parts')
|
||||
reconciled_line_ids = fields.One2many('account.move.line', 'full_reconcile_id', string='Matched Journal Items')
|
||||
exchange_move_id = fields.Many2one('account.move', index="btree_not_null")
|
||||
|
||||
def unlink(self):
|
||||
""" When removing a full reconciliation, we need to revert the eventual journal entries we created to book the
|
||||
fluctuation of the foreign currency's exchange rate.
|
||||
We need also to reconcile together the origin currency difference line and its reversal in order to completely
|
||||
cancel the currency difference entry on the partner account (otherwise it will still appear on the aged balance
|
||||
for example).
|
||||
"""
|
||||
# Avoid cyclic unlink calls when removing partials.
|
||||
if not self:
|
||||
return True
|
||||
|
||||
moves_to_reverse = self.exchange_move_id
|
||||
|
||||
res = super().unlink()
|
||||
|
||||
# Reverse all exchange moves at once.
|
||||
if moves_to_reverse:
|
||||
default_values_list = [{
|
||||
'date': move._get_accounting_date(move.date, move._affect_tax_report()),
|
||||
'ref': _('Reversal of: %s') % move.name,
|
||||
} for move in moves_to_reverse]
|
||||
moves_to_reverse._reverse_moves(default_values_list, cancel=True)
|
||||
|
||||
return res
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountIncoterms(models.Model):
|
||||
_name = 'account.incoterms'
|
||||
_description = 'Incoterms'
|
||||
|
||||
name = fields.Char(
|
||||
'Name', required=True, translate=True,
|
||||
help="Incoterms are series of sales terms. They are used to divide transaction costs and responsibilities between buyer and seller and reflect state-of-the-art transportation practices.")
|
||||
code = fields.Char(
|
||||
'Code', size=3, required=True,
|
||||
help="Incoterm Standard Code")
|
||||
active = fields.Boolean(
|
||||
'Active', default=True,
|
||||
help="By unchecking the active field, you may hide an INCOTERM you will not use.")
|
||||
1055
odoo-bringout-oca-ocb-account/account/models/account_journal.py
Normal file
1055
odoo-bringout-oca-ocb-account/account/models/account_journal.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,795 @@
|
|||
import ast
|
||||
from babel.dates import format_datetime, format_date
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import random
|
||||
|
||||
from odoo import models, api, _, fields
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.osv import expression
|
||||
from odoo.release import version
|
||||
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF
|
||||
from odoo.tools.misc import formatLang, format_date as odoo_format_date, get_lang
|
||||
|
||||
|
||||
def group_by_journal(vals_list):
|
||||
res = defaultdict(list)
|
||||
for vals in vals_list:
|
||||
res[vals['journal_id']].append(vals)
|
||||
return res
|
||||
|
||||
|
||||
class account_journal(models.Model):
|
||||
_inherit = "account.journal"
|
||||
|
||||
kanban_dashboard = fields.Text(compute='_kanban_dashboard')
|
||||
kanban_dashboard_graph = fields.Text(compute='_kanban_dashboard_graph')
|
||||
json_activity_data = fields.Text(compute='_get_json_activity_data')
|
||||
show_on_dashboard = fields.Boolean(string='Show journal on dashboard', help="Whether this journal should be displayed on the dashboard or not", default=True)
|
||||
color = fields.Integer("Color Index", default=0)
|
||||
current_statement_balance = fields.Monetary(compute='_compute_current_statement_balance') # technical field used to avoid computing the value multiple times
|
||||
has_statement_lines = fields.Boolean(compute='_compute_current_statement_balance') # technical field used to avoid computing the value multiple times
|
||||
entries_count = fields.Integer(compute='_compute_entries_count')
|
||||
has_sequence_holes = fields.Boolean(compute='_compute_has_sequence_holes')
|
||||
|
||||
def _compute_current_statement_balance(self):
|
||||
query_result = self._get_journal_dashboard_bank_running_balance()
|
||||
for journal in self:
|
||||
journal.has_statement_lines, journal.current_statement_balance = query_result.get(journal.id)
|
||||
|
||||
def _kanban_dashboard(self):
|
||||
dashboard_data = self._get_journal_dashboard_data_batched()
|
||||
for journal in self:
|
||||
journal.kanban_dashboard = json.dumps(dashboard_data[journal.id])
|
||||
|
||||
@api.depends('current_statement_balance')
|
||||
def _kanban_dashboard_graph(self):
|
||||
bank_cash_journals = self.filtered(lambda journal: journal.type in ('bank', 'cash'))
|
||||
bank_cash_graph_datas = bank_cash_journals._get_bank_cash_graph_data()
|
||||
for journal in bank_cash_journals:
|
||||
journal.kanban_dashboard_graph = json.dumps(bank_cash_graph_datas[journal.id])
|
||||
|
||||
sale_purchase_journals = self.filtered(lambda journal: journal.type in ('sale', 'purchase'))
|
||||
sale_purchase_graph_datas = sale_purchase_journals._get_sale_purchase_graph_data()
|
||||
for journal in sale_purchase_journals:
|
||||
journal.kanban_dashboard_graph = json.dumps(sale_purchase_graph_datas[journal.id])
|
||||
|
||||
(self - bank_cash_journals - sale_purchase_journals).kanban_dashboard_graph = False
|
||||
|
||||
def _get_json_activity_data(self):
|
||||
today = fields.Date.context_today(self)
|
||||
activities = defaultdict(list)
|
||||
# search activity on move on the journal
|
||||
lang = self.env.user.lang or get_lang(self.env).code
|
||||
sql_query = """
|
||||
SELECT activity.id,
|
||||
activity.res_id,
|
||||
activity.res_model,
|
||||
activity.summary,
|
||||
CASE WHEN activity.date_deadline < %(today)s THEN 'late' ELSE 'future' END as status,
|
||||
COALESCE(act_type.name->> %(lang)s, act_type.name->>'en_US') as act_type_name,
|
||||
act_type.category as activity_category,
|
||||
activity.date_deadline,
|
||||
move.date,
|
||||
move.ref,
|
||||
move.journal_id
|
||||
FROM account_move move
|
||||
JOIN mail_activity activity ON activity.res_id = move.id AND activity.res_model = 'account.move'
|
||||
LEFT JOIN mail_activity_type act_type ON activity.activity_type_id = act_type.id
|
||||
WHERE move.journal_id = ANY(%(ids)s)
|
||||
"""
|
||||
self.env.cr.execute(sql_query, {'ids': self.ids, 'today': today, 'lang': lang})
|
||||
for activity in self.env.cr.dictfetchall():
|
||||
act = {
|
||||
'id': activity['id'],
|
||||
'res_id': activity['res_id'],
|
||||
'res_model': activity['res_model'],
|
||||
'status': activity['status'],
|
||||
'name': activity['summary'] or activity['act_type_name'],
|
||||
'activity_category': activity['activity_category'],
|
||||
'date': odoo_format_date(self.env, activity['date_deadline'])
|
||||
}
|
||||
if activity['activity_category'] == 'tax_report' and activity['res_model'] == 'account.move':
|
||||
act['name'] = activity['ref']
|
||||
|
||||
activities[activity['journal_id']].append(act)
|
||||
for journal in self:
|
||||
journal.json_activity_data = json.dumps({'activities': activities[journal.id]})
|
||||
|
||||
def _query_has_sequence_holes(self):
|
||||
self.env['res.company'].flush_model(['fiscalyear_lock_date'])
|
||||
self.env['account.move'].flush_model(['journal_id', 'date', 'sequence_prefix', 'sequence_number', 'state'])
|
||||
self.env.cr.execute("""
|
||||
SELECT move.journal_id,
|
||||
move.sequence_prefix
|
||||
FROM account_move move
|
||||
JOIN res_company company ON company.id = move.company_id
|
||||
WHERE move.journal_id = ANY(%(journal_ids)s)
|
||||
AND (move.state = 'posted' OR (move.state = 'draft' AND move.name != '/'))
|
||||
AND (company.fiscalyear_lock_date IS NULL OR move.date > company.fiscalyear_lock_date)
|
||||
GROUP BY move.journal_id, move.sequence_prefix
|
||||
HAVING COUNT(*) != MAX(move.sequence_number) - MIN(move.sequence_number) + 1
|
||||
""", {
|
||||
'journal_ids': self.ids,
|
||||
})
|
||||
return self.env.cr.fetchall()
|
||||
|
||||
def _compute_has_sequence_holes(self):
|
||||
has_sequence_holes = set(journal_id for journal_id, _prefix in self._query_has_sequence_holes())
|
||||
for journal in self:
|
||||
journal.has_sequence_holes = journal.id in has_sequence_holes
|
||||
|
||||
def _compute_entries_count(self):
|
||||
res = {
|
||||
r['journal_id'][0]: r['journal_id_count']
|
||||
for r in self.env['account.move']._read_group(
|
||||
domain=[('journal_id', 'in', self.ids)],
|
||||
fields=['journal_id'],
|
||||
groupby=['journal_id'],
|
||||
)
|
||||
}
|
||||
for journal in self:
|
||||
journal.entries_count = res.get(journal.id, 0)
|
||||
|
||||
def _graph_title_and_key(self):
|
||||
if self.type in ['sale', 'purchase']:
|
||||
return ['', _('Residual amount')]
|
||||
elif self.type == 'cash':
|
||||
return ['', _('Cash: Balance')]
|
||||
elif self.type == 'bank':
|
||||
return ['', _('Bank: Balance')]
|
||||
|
||||
# TODO remove in master
|
||||
def get_line_graph_datas(self):
|
||||
self.ensure_one()
|
||||
return self._get_bank_cash_graph_data()[self.id]
|
||||
|
||||
def _get_bank_cash_graph_data(self):
|
||||
"""Computes the data used to display the graph for bank and cash journals in the accounting dashboard"""
|
||||
def build_graph_data(date, amount, currency):
|
||||
#display date in locale format
|
||||
name = format_date(date, 'd LLLL Y', locale=locale)
|
||||
short_name = format_date(date, 'd MMM', locale=locale)
|
||||
return {'x': short_name, 'y': currency.round(amount), 'name': name}
|
||||
|
||||
today = datetime.today()
|
||||
last_month = today + timedelta(days=-30)
|
||||
locale = get_lang(self.env).code
|
||||
|
||||
query = """
|
||||
SELECT move.journal_id,
|
||||
move.date,
|
||||
SUM(st_line.amount) AS amount
|
||||
FROM account_bank_statement_line st_line
|
||||
JOIN account_move move ON move.id = st_line.move_id
|
||||
WHERE move.journal_id = ANY(%s)
|
||||
AND move.date > %s
|
||||
GROUP BY move.date, move.journal_id
|
||||
ORDER BY move.date DESC
|
||||
"""
|
||||
self.env.cr.execute(query, (self.ids, last_month))
|
||||
query_result = group_by_journal(self.env.cr.dictfetchall())
|
||||
|
||||
result = {}
|
||||
for journal in self:
|
||||
graph_title, graph_key = journal._graph_title_and_key()
|
||||
currency = journal.currency_id or journal.company_id.currency_id
|
||||
journal_result = query_result[journal.id]
|
||||
|
||||
color = '#875A7B' if 'e' in version else '#7c7bad'
|
||||
is_sample_data = not journal.has_statement_lines
|
||||
|
||||
data = []
|
||||
if is_sample_data:
|
||||
for i in range(30, 0, -5):
|
||||
current_date = today + timedelta(days=-i)
|
||||
data.append(build_graph_data(current_date, random.randint(-5, 15), currency))
|
||||
graph_key = _('Sample data')
|
||||
else:
|
||||
last_balance = journal.current_statement_balance
|
||||
# Make sure the last point in the graph is at least today or a future date
|
||||
if not journal_result or journal_result[0]['date'] < today.date():
|
||||
data.append(build_graph_data(today, last_balance, currency))
|
||||
date = today
|
||||
amount = last_balance
|
||||
#then we subtract the total amount of bank statement lines per day to get the previous points
|
||||
#(graph is drawn backward)
|
||||
for val in journal_result:
|
||||
date = val['date']
|
||||
data[:0] = [build_graph_data(date, amount, currency)]
|
||||
amount -= val['amount']
|
||||
|
||||
# make sure the graph starts 1 month ago
|
||||
if date.strftime(DF) != last_month.strftime(DF):
|
||||
data[:0] = [build_graph_data(last_month, amount, currency)]
|
||||
|
||||
result[journal.id] = [{'values': data, 'title': graph_title, 'key': graph_key, 'area': True, 'color': color, 'is_sample_data': is_sample_data}]
|
||||
return result
|
||||
|
||||
# TODO remove in master
|
||||
def get_bar_graph_datas(self):
|
||||
self.ensure_one()
|
||||
return self._get_sale_purchase_graph_data()[self.id]
|
||||
|
||||
def _get_sale_purchase_graph_data(self):
|
||||
today = fields.Date.today()
|
||||
day_of_week = int(format_datetime(today, 'e', locale=get_lang(self.env).code))
|
||||
first_day_of_week = today + timedelta(days=-day_of_week+1)
|
||||
format_month = lambda d: format_date(d, 'MMM', locale=get_lang(self.env).code)
|
||||
|
||||
self.env.cr.execute("""
|
||||
SELECT move.journal_id,
|
||||
COALESCE(SUM(move.amount_residual_signed) FILTER (WHERE invoice_date_due < %(start_week1)s), 0) AS total_before,
|
||||
COALESCE(SUM(move.amount_residual_signed) FILTER (WHERE invoice_date_due >= %(start_week1)s AND invoice_date_due < %(start_week2)s), 0) AS total_week1,
|
||||
COALESCE(SUM(move.amount_residual_signed) FILTER (WHERE invoice_date_due >= %(start_week2)s AND invoice_date_due < %(start_week3)s), 0) AS total_week2,
|
||||
COALESCE(SUM(move.amount_residual_signed) FILTER (WHERE invoice_date_due >= %(start_week3)s AND invoice_date_due < %(start_week4)s), 0) AS total_week3,
|
||||
COALESCE(SUM(move.amount_residual_signed) FILTER (WHERE invoice_date_due >= %(start_week4)s AND invoice_date_due < %(start_week5)s), 0) AS total_week4,
|
||||
COALESCE(SUM(move.amount_residual_signed) FILTER (WHERE invoice_date_due >= %(start_week5)s), 0) AS total_after
|
||||
FROM account_move move
|
||||
WHERE move.journal_id = ANY(%(journal_ids)s)
|
||||
AND move.state = 'posted'
|
||||
AND move.payment_state in ('not_paid', 'partial')
|
||||
AND move.move_type IN %(invoice_types)s
|
||||
GROUP BY move.journal_id
|
||||
""", {
|
||||
'invoice_types': tuple(self.env['account.move'].get_invoice_types(True)),
|
||||
'journal_ids': self.ids,
|
||||
'start_week1': first_day_of_week + timedelta(days=-7),
|
||||
'start_week2': first_day_of_week + timedelta(days=0),
|
||||
'start_week3': first_day_of_week + timedelta(days=7),
|
||||
'start_week4': first_day_of_week + timedelta(days=14),
|
||||
'start_week5': first_day_of_week + timedelta(days=21),
|
||||
})
|
||||
query_results = {r['journal_id']: r for r in self.env.cr.dictfetchall()}
|
||||
result = {}
|
||||
for journal in self:
|
||||
currency = journal.currency_id or journal.company_id.currency_id
|
||||
graph_title, graph_key = journal._graph_title_and_key()
|
||||
sign = 1 if journal.type == 'sale' else -1
|
||||
journal_data = query_results.get(journal.id)
|
||||
data = []
|
||||
data.append({'label': _('Due'), 'type': 'past'})
|
||||
for i in range(-1, 3):
|
||||
if i == 0:
|
||||
label = _('This Week')
|
||||
else:
|
||||
start_week = first_day_of_week + timedelta(days=i*7)
|
||||
end_week = start_week + timedelta(days=6)
|
||||
if start_week.month == end_week.month:
|
||||
label = f"{start_week.day} - {end_week.day} {format_month(end_week)}"
|
||||
else:
|
||||
label = f"{start_week.day} {format_month(start_week)} - {end_week.day} {format_month(end_week)}"
|
||||
data.append({'label': label, 'type': 'past' if i < 0 else 'future'})
|
||||
data.append({'label': _('Not Due'), 'type': 'future'})
|
||||
|
||||
is_sample_data = not journal_data
|
||||
if not is_sample_data:
|
||||
data[0]['value'] = currency.round(sign * journal_data['total_before'])
|
||||
data[1]['value'] = currency.round(sign * journal_data['total_week1'])
|
||||
data[2]['value'] = currency.round(sign * journal_data['total_week2'])
|
||||
data[3]['value'] = currency.round(sign * journal_data['total_week3'])
|
||||
data[4]['value'] = currency.round(sign * journal_data['total_week4'])
|
||||
data[5]['value'] = currency.round(sign * journal_data['total_after'])
|
||||
else:
|
||||
for index in range(6):
|
||||
data[index]['type'] = 'o_sample_data'
|
||||
# we use unrealistic values for the sample data
|
||||
data[index]['value'] = random.randint(0, 20)
|
||||
graph_key = _('Sample data')
|
||||
|
||||
result[journal.id] = [{'values': data, 'title': graph_title, 'key': graph_key, 'is_sample_data': is_sample_data}]
|
||||
return result
|
||||
|
||||
# TODO remove in master
|
||||
def get_journal_dashboard_datas(self):
|
||||
return self._get_journal_dashboard_data_batched()[self.id]
|
||||
|
||||
def _get_journal_dashboard_data_batched(self):
|
||||
self.env['account.move'].flush_model()
|
||||
self.env['account.move.line'].flush_model()
|
||||
dashboard_data = {} # container that will be filled by functions below
|
||||
for journal in self:
|
||||
dashboard_data[journal.id] = {
|
||||
'currency_id': journal.currency_id.id or journal.company_id.currency_id.id,
|
||||
'company_count': len(self.env.companies),
|
||||
}
|
||||
self._fill_bank_cash_dashboard_data(dashboard_data)
|
||||
self._fill_sale_purchase_dashboard_data(dashboard_data)
|
||||
self._fill_general_dashboard_data(dashboard_data)
|
||||
return dashboard_data
|
||||
|
||||
def _fill_dashboard_data_count(self, dashboard_data, model, name, domain):
|
||||
"""Populate the dashboard data with the result of a count.
|
||||
|
||||
:param dashboard_data: a mapping between a journal ids and the data needed to display their
|
||||
dashboard kanban card.
|
||||
:type dashboard_data: dict[int, dict]
|
||||
:param model: the model on which to perform the count
|
||||
:type model: str
|
||||
:param name: the name of the variable to inject in the dashboard's data
|
||||
:type name: str
|
||||
:param domain: the domain of records to count
|
||||
:type domain: list[tuple]
|
||||
"""
|
||||
res = {
|
||||
r['journal_id'][0]: r['journal_id_count']
|
||||
for r in self.env[model]._read_group(
|
||||
domain=[('journal_id', 'in', self.ids)] + domain,
|
||||
fields=['journal_id'],
|
||||
groupby=['journal_id'],
|
||||
)
|
||||
}
|
||||
for journal in self:
|
||||
dashboard_data[journal.id][name] = res.get(journal.id, 0)
|
||||
|
||||
def _fill_bank_cash_dashboard_data(self, dashboard_data):
|
||||
"""Populate all bank and cash journal's data dict with relevant information for the kanban card."""
|
||||
bank_cash_journals = self.filtered(lambda journal: journal.type in ('bank', 'cash'))
|
||||
if not bank_cash_journals:
|
||||
return
|
||||
|
||||
# Number to reconcile
|
||||
self._cr.execute("""
|
||||
SELECT st_line_move.journal_id,
|
||||
COUNT(st_line.id)
|
||||
FROM account_bank_statement_line st_line
|
||||
JOIN account_move st_line_move ON st_line_move.id = st_line.move_id
|
||||
WHERE st_line_move.journal_id IN %s
|
||||
AND NOT st_line.is_reconciled
|
||||
AND st_line_move.to_check IS NOT TRUE
|
||||
AND st_line_move.state = 'posted'
|
||||
GROUP BY st_line_move.journal_id
|
||||
""", [tuple(bank_cash_journals.ids)])
|
||||
number_to_reconcile = {
|
||||
journal_id: count
|
||||
for journal_id, count in self.env.cr.fetchall()
|
||||
}
|
||||
|
||||
# Last statement
|
||||
self.env.cr.execute("""
|
||||
SELECT journal.id, statement.id
|
||||
FROM account_journal journal
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id
|
||||
FROM account_bank_statement
|
||||
WHERE journal_id = journal.id
|
||||
ORDER BY first_line_index DESC
|
||||
LIMIT 1
|
||||
) statement ON TRUE
|
||||
WHERE journal.id = ANY(%s)
|
||||
""", [self.ids])
|
||||
last_statements = {journal_id: statement_id for journal_id, statement_id in self.env.cr.fetchall()}
|
||||
self.env['account.bank.statement'].browse(i for i in last_statements.values() if i).mapped('balance_end_real') # prefetch
|
||||
|
||||
outstanding_pay_account_balances = bank_cash_journals._get_journal_dashboard_outstanding_payments()
|
||||
|
||||
# To check
|
||||
to_check = {
|
||||
res['journal_id'][0]: (res['amount'], res['journal_id_count'])
|
||||
for res in self.env['account.bank.statement.line'].read_group(
|
||||
domain=[
|
||||
('journal_id', 'in', bank_cash_journals.ids),
|
||||
('move_id.to_check', '=', True),
|
||||
('move_id.state', '=', 'posted'),
|
||||
],
|
||||
fields=['amount'],
|
||||
groupby=['journal_id'],
|
||||
)
|
||||
}
|
||||
|
||||
for journal in bank_cash_journals:
|
||||
last_statement = self.env['account.bank.statement'].browse(last_statements.get(journal.id))
|
||||
currency = journal.currency_id or journal.company_id.currency_id
|
||||
has_outstanding, outstanding_pay_account_balance = outstanding_pay_account_balances[journal.id]
|
||||
to_check_balance, number_to_check = to_check.get(journal.id, (0, 0))
|
||||
|
||||
dashboard_data[journal.id].update({
|
||||
'number_to_check': number_to_check,
|
||||
'to_check_balance': currency.format(to_check_balance),
|
||||
'number_to_reconcile': number_to_reconcile.get(journal.id, 0),
|
||||
'account_balance': currency.format(journal.current_statement_balance),
|
||||
'has_at_least_one_statement': bool(last_statement),
|
||||
'nb_lines_bank_account_balance': bool(journal.has_statement_lines),
|
||||
'outstanding_pay_account_balance': currency.format(outstanding_pay_account_balance),
|
||||
'nb_lines_outstanding_pay_account_balance': has_outstanding,
|
||||
'last_balance': currency.format(last_statement.balance_end_real),
|
||||
'bank_statements_source': journal.bank_statements_source,
|
||||
'is_sample_data': journal.has_statement_lines,
|
||||
})
|
||||
|
||||
def _fill_sale_purchase_dashboard_data(self, dashboard_data):
|
||||
"""Populate all sale and purchase journal's data dict with relevant information for the kanban card."""
|
||||
sale_purchase_journals = self.filtered(lambda journal: journal.type in ('sale', 'purchase'))
|
||||
if not sale_purchase_journals:
|
||||
return
|
||||
field_list = [
|
||||
"account_move.journal_id",
|
||||
"(CASE WHEN account_move.move_type IN ('out_refund', 'in_refund') THEN -1 ELSE 1 END) * account_move.amount_residual AS amount_total",
|
||||
"(CASE WHEN account_move.move_type IN ('in_invoice', 'in_refund', 'in_receipt') THEN -1 ELSE 1 END) * account_move.amount_residual_signed AS amount_total_company",
|
||||
"account_move.currency_id AS currency",
|
||||
"account_move.move_type",
|
||||
"account_move.invoice_date",
|
||||
"account_move.company_id",
|
||||
]
|
||||
query, params = sale_purchase_journals._get_open_bills_to_pay_query().select(*field_list)
|
||||
self.env.cr.execute(query, params)
|
||||
query_results_to_pay = group_by_journal(self.env.cr.dictfetchall())
|
||||
|
||||
query, params = sale_purchase_journals._get_draft_bills_query().select(*field_list)
|
||||
self.env.cr.execute(query, params)
|
||||
query_results_drafts = group_by_journal(self.env.cr.dictfetchall())
|
||||
|
||||
query, params = sale_purchase_journals._get_late_bills_query().select(*field_list)
|
||||
self.env.cr.execute(query, params)
|
||||
late_query_results = group_by_journal(self.env.cr.dictfetchall())
|
||||
|
||||
to_check_vals = {
|
||||
vals['journal_id'][0]: vals
|
||||
for vals in self.env['account.move'].read_group(
|
||||
domain=[('journal_id', 'in', sale_purchase_journals.ids), ('to_check', '=', True)],
|
||||
fields=['amount_total_signed'],
|
||||
groupby='journal_id',
|
||||
)
|
||||
}
|
||||
|
||||
curr_cache = {}
|
||||
sale_purchase_journals._fill_dashboard_data_count(dashboard_data, 'account.move', 'entries_count', [])
|
||||
for journal in sale_purchase_journals:
|
||||
currency = journal.currency_id or journal.company_id.currency_id
|
||||
(number_waiting, sum_waiting) = self._count_results_and_sum_amounts(query_results_to_pay[journal.id], currency, curr_cache=curr_cache)
|
||||
(number_draft, sum_draft) = self._count_results_and_sum_amounts(query_results_drafts[journal.id], currency, curr_cache=curr_cache)
|
||||
(number_late, sum_late) = self._count_results_and_sum_amounts(late_query_results[journal.id], currency, curr_cache=curr_cache)
|
||||
to_check = to_check_vals.get(journal.id, {})
|
||||
dashboard_data[journal.id].update({
|
||||
'number_to_check': to_check.get('journal_id_count', 0),
|
||||
'to_check_balance': currency.format(to_check.get('amount_total_signed', 0)),
|
||||
'title': _('Bills to pay') if journal.type == 'purchase' else _('Invoices owed to you'),
|
||||
'number_draft': number_draft,
|
||||
'number_waiting': number_waiting,
|
||||
'number_late': number_late,
|
||||
'sum_draft': currency.format(sum_draft),
|
||||
'sum_waiting': currency.format(sum_waiting),
|
||||
'sum_late': currency.format(sum_late),
|
||||
'has_sequence_holes': journal.has_sequence_holes,
|
||||
'is_sample_data': dashboard_data[journal.id]['entries_count'],
|
||||
})
|
||||
|
||||
def _fill_general_dashboard_data(self, dashboard_data):
|
||||
"""Populate all miscelaneous journal's data dict with relevant information for the kanban card."""
|
||||
general_journals = self.filtered(lambda journal: journal.type == 'general')
|
||||
if not general_journals:
|
||||
return
|
||||
to_check_vals = {
|
||||
vals['journal_id'][0]: vals
|
||||
for vals in self.env['account.move'].read_group(
|
||||
domain=[('journal_id', 'in', general_journals.ids), ('to_check', '=', True)],
|
||||
fields=['amount_total_signed'],
|
||||
groupby='journal_id',
|
||||
lazy=False,
|
||||
)
|
||||
}
|
||||
for journal in general_journals:
|
||||
currency = journal.currency_id or journal.company_id.currency_id
|
||||
vals = to_check_vals.get(journal.id, {})
|
||||
dashboard_data[journal.id].update({
|
||||
'number_to_check': vals.get('__count', 0),
|
||||
'to_check_balance': currency.format(vals.get('amount_total_signed', 0)),
|
||||
})
|
||||
|
||||
def _get_open_bills_to_pay_query(self):
|
||||
return self.env['account.move']._where_calc([
|
||||
('journal_id', 'in', self.ids),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('move_type', 'in', self.env['account.move'].get_invoice_types(include_receipts=True)),
|
||||
])
|
||||
|
||||
def _get_draft_bills_query(self):
|
||||
return self.env['account.move']._where_calc([
|
||||
('journal_id', 'in', self.ids),
|
||||
('state', '=', 'draft'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('move_type', 'in', self.env['account.move'].get_invoice_types(include_receipts=True)),
|
||||
])
|
||||
|
||||
def _get_late_bills_query(self):
|
||||
return self.env['account.move']._where_calc([
|
||||
('journal_id', 'in', self.ids),
|
||||
('invoice_date_due', '<', fields.Date.context_today(self)),
|
||||
('state', '=', 'posted'),
|
||||
('payment_state', 'in', ('not_paid', 'partial')),
|
||||
('move_type', 'in', self.env['account.move'].get_invoice_types(include_receipts=True)),
|
||||
])
|
||||
|
||||
def _count_results_and_sum_amounts(self, results_dict, target_currency, curr_cache=None):
|
||||
""" Loops on a query result to count the total number of invoices and sum
|
||||
their amount_total field (expressed in the given target currency).
|
||||
amount_total must be signed !
|
||||
"""
|
||||
# Create a cache with currency rates to avoid unnecessary SQL requests. Do not copy
|
||||
# curr_cache on purpose, so the dictionary is modified and can be re-used for subsequent
|
||||
# calls of the method.
|
||||
curr_cache = {} if curr_cache is None else curr_cache
|
||||
total_amount = 0
|
||||
for result in results_dict:
|
||||
document_currency = self.env['res.currency'].browse(result.get('currency'))
|
||||
company = self.env['res.company'].browse(result.get('company_id')) or self.env.company
|
||||
date = result.get('invoice_date') or fields.Date.context_today(self)
|
||||
|
||||
if document_currency == target_currency:
|
||||
total_amount += result.get('amount_total') or 0
|
||||
elif company.currency_id == target_currency:
|
||||
total_amount += result.get('amount_total_company') or 0
|
||||
else:
|
||||
key = (document_currency, target_currency, company, date)
|
||||
if key not in curr_cache:
|
||||
curr_cache[key] = self.env['res.currency']._get_conversion_rate(*key)
|
||||
total_amount += (result.get('amount_total') or 0) * curr_cache[key]
|
||||
return (len(results_dict), target_currency.round(total_amount))
|
||||
|
||||
def _get_journal_dashboard_bank_running_balance(self):
|
||||
# In order to not recompute everything from the start, we take the last
|
||||
# bank statement and only sum starting from there.
|
||||
self._cr.execute("""
|
||||
SELECT journal.id AS journal_id,
|
||||
statement.id AS statement_id,
|
||||
COALESCE(statement.balance_end_real, 0) AS balance_end_real,
|
||||
without_statement.amount AS unlinked_amount,
|
||||
without_statement.count AS unlinked_count
|
||||
FROM account_journal journal
|
||||
LEFT JOIN LATERAL ( -- select latest statement based on the date
|
||||
SELECT id,
|
||||
first_line_index,
|
||||
balance_end_real
|
||||
FROM account_bank_statement
|
||||
WHERE journal_id = journal.id
|
||||
ORDER BY date DESC, id DESC
|
||||
LIMIT 1
|
||||
) statement ON TRUE
|
||||
LEFT JOIN LATERAL ( -- sum all the lines not linked to a statement with a higher index than the last line of the statement
|
||||
SELECT COALESCE(SUM(stl.amount), 0.0) AS amount,
|
||||
COUNT(*)
|
||||
FROM account_bank_statement_line stl
|
||||
JOIN account_move move ON move.id = stl.move_id
|
||||
WHERE stl.statement_id IS NULL
|
||||
AND move.state != 'cancel'
|
||||
AND move.journal_id = journal.id
|
||||
AND stl.internal_index >= COALESCE(statement.first_line_index, '')
|
||||
LIMIT 1
|
||||
) without_statement ON TRUE
|
||||
WHERE journal.id = ANY(%s)
|
||||
""", [(self.ids)])
|
||||
query_res = {res['journal_id']: res for res in self.env.cr.dictfetchall()}
|
||||
result = {}
|
||||
for journal in self:
|
||||
journal_vals = query_res[journal.id]
|
||||
result[journal.id] = (
|
||||
bool(journal_vals['statement_id'] or journal_vals['unlinked_count']),
|
||||
journal_vals['balance_end_real'] + journal_vals['unlinked_amount'],
|
||||
)
|
||||
return result
|
||||
|
||||
def _get_journal_dashboard_outstanding_payments(self):
|
||||
self.env.cr.execute("""
|
||||
SELECT move.journal_id AS journal_id,
|
||||
move.company_id AS company_id,
|
||||
move.currency_id AS currency,
|
||||
SUM(CASE
|
||||
WHEN payment.payment_type = 'outbound' THEN -payment.amount
|
||||
ELSE payment.amount
|
||||
END) AS amount_total,
|
||||
SUM(amount_company_currency_signed) AS amount_total_company
|
||||
FROM account_payment payment
|
||||
JOIN account_move move ON move.payment_id = payment.id
|
||||
WHERE payment.is_matched IS NOT TRUE
|
||||
AND move.state = 'posted'
|
||||
AND move.journal_id = ANY(%s)
|
||||
GROUP BY move.company_id, move.journal_id, move.currency_id
|
||||
""", [self.ids])
|
||||
query_result = group_by_journal(self.env.cr.dictfetchall())
|
||||
result = {}
|
||||
curr_cache = {}
|
||||
for journal in self:
|
||||
currency = journal.currency_id or journal.company_id.currency_id
|
||||
result[journal.id] = self._count_results_and_sum_amounts(query_result[journal.id], currency, curr_cache)
|
||||
return result
|
||||
|
||||
def _get_move_action_context(self):
|
||||
ctx = self._context.copy()
|
||||
ctx['default_journal_id'] = self.id
|
||||
if self.type == 'sale':
|
||||
ctx['default_move_type'] = 'out_refund' if ctx.get('refund') else 'out_invoice'
|
||||
elif self.type == 'purchase':
|
||||
ctx['default_move_type'] = 'in_refund' if ctx.get('refund') else 'in_invoice'
|
||||
else:
|
||||
ctx['default_move_type'] = 'entry'
|
||||
ctx['view_no_maturity'] = True
|
||||
return ctx
|
||||
|
||||
def action_create_new(self):
|
||||
return {
|
||||
'name': _('Create invoice/bill'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'account.move',
|
||||
'view_id': self.env.ref('account.view_move_form').id,
|
||||
'context': self._get_move_action_context(),
|
||||
}
|
||||
|
||||
def create_cash_statement(self):
|
||||
raise UserError(_('Please install Accounting for this feature'))
|
||||
|
||||
def action_create_vendor_bill(self):
|
||||
""" This function is called by the "Import" button of Vendor Bills,
|
||||
visible on dashboard if no bill has been created yet.
|
||||
"""
|
||||
self.env.company.sudo().set_onboarding_step_done('account_setup_bill_state')
|
||||
|
||||
new_wizard = self.env['account.tour.upload.bill'].create({})
|
||||
view_id = self.env.ref('account.account_tour_upload_bill').id
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Import your first bill'),
|
||||
'view_mode': 'form',
|
||||
'res_model': 'account.tour.upload.bill',
|
||||
'target': 'new',
|
||||
'res_id': new_wizard.id,
|
||||
'views': [[view_id, 'form']],
|
||||
}
|
||||
|
||||
def to_check_ids(self):
|
||||
self.ensure_one()
|
||||
return self.env['account.bank.statement.line'].search([
|
||||
('journal_id', '=', self.id),
|
||||
('move_id.to_check', '=', True),
|
||||
('move_id.state', '=', 'posted'),
|
||||
])
|
||||
|
||||
def _select_action_to_open(self):
|
||||
self.ensure_one()
|
||||
if self._context.get('action_name'):
|
||||
return self._context.get('action_name')
|
||||
elif self.type == 'bank':
|
||||
return 'action_bank_statement_tree'
|
||||
elif self.type == 'cash':
|
||||
return 'action_view_bank_statement_tree'
|
||||
elif self.type == 'sale':
|
||||
return 'action_move_out_invoice_type'
|
||||
elif self.type == 'purchase':
|
||||
return 'action_move_in_invoice_type'
|
||||
else:
|
||||
return 'action_move_journal_line'
|
||||
|
||||
def open_action(self):
|
||||
"""return action based on type for related journals"""
|
||||
self.ensure_one()
|
||||
action_name = self._select_action_to_open()
|
||||
|
||||
# Set 'account.' prefix if missing.
|
||||
if not action_name.startswith("account."):
|
||||
action_name = 'account.%s' % action_name
|
||||
|
||||
action = self.env["ir.actions.act_window"]._for_xml_id(action_name)
|
||||
|
||||
context = self._context.copy()
|
||||
if 'context' in action and isinstance(action['context'], str):
|
||||
context.update(ast.literal_eval(action['context']))
|
||||
else:
|
||||
context.update(action.get('context', {}))
|
||||
action['context'] = context
|
||||
action['context'].update({
|
||||
'default_journal_id': self.id,
|
||||
})
|
||||
domain_type_field = action['res_model'] == 'account.move.line' and 'move_id.move_type' or 'move_type' # The model can be either account.move or account.move.line
|
||||
|
||||
# Override the domain only if the action was not explicitly specified in order to keep the
|
||||
# original action domain.
|
||||
if action.get('domain') and isinstance(action['domain'], str):
|
||||
action['domain'] = ast.literal_eval(action['domain'] or '[]')
|
||||
if not self._context.get('action_name'):
|
||||
if self.type == 'sale':
|
||||
action['domain'] = [(domain_type_field, 'in', ('out_invoice', 'out_refund', 'out_receipt'))]
|
||||
elif self.type == 'purchase':
|
||||
action['domain'] = [(domain_type_field, 'in', ('in_invoice', 'in_refund', 'in_receipt', 'entry'))]
|
||||
|
||||
action['domain'] = (action['domain'] or []) + [('journal_id', '=', self.id)]
|
||||
return action
|
||||
|
||||
def open_spend_money(self):
|
||||
return self.open_payments_action('outbound')
|
||||
|
||||
def open_collect_money(self):
|
||||
return self.open_payments_action('inbound')
|
||||
|
||||
def open_transfer_money(self):
|
||||
return self.open_payments_action('transfer')
|
||||
|
||||
def open_payments_action(self, payment_type, mode='tree'):
|
||||
if payment_type == 'outbound':
|
||||
action_ref = 'account.action_account_payments_payable'
|
||||
elif payment_type == 'transfer':
|
||||
action_ref = 'account.action_account_payments_transfer'
|
||||
else:
|
||||
action_ref = 'account.action_account_payments'
|
||||
action = self.env['ir.actions.act_window']._for_xml_id(action_ref)
|
||||
action['context'] = dict(ast.literal_eval(action.get('context')), default_journal_id=self.id, search_default_journal_id=self.id)
|
||||
if payment_type == 'transfer':
|
||||
action['context'].update({
|
||||
'default_partner_id': self.company_id.partner_id.id,
|
||||
'default_is_internal_transfer': True,
|
||||
})
|
||||
if mode == 'form':
|
||||
action['views'] = [[False, 'form']]
|
||||
return action
|
||||
|
||||
def open_action_with_context(self):
|
||||
action_name = self.env.context.get('action_name', False)
|
||||
if not action_name:
|
||||
return False
|
||||
ctx = dict(self.env.context, default_journal_id=self.id)
|
||||
if ctx.get('search_default_journal', False):
|
||||
ctx.update(search_default_journal_id=self.id)
|
||||
ctx['search_default_journal'] = False # otherwise it will do a useless groupby in bank statements
|
||||
ctx.pop('group_by', None)
|
||||
action = self.env['ir.actions.act_window']._for_xml_id(f"account.{action_name}")
|
||||
action['context'] = ctx
|
||||
if ctx.get('use_domain', False):
|
||||
action['domain'] = isinstance(ctx['use_domain'], list) and ctx['use_domain'] or ['|', ('journal_id', '=', self.id), ('journal_id', '=', False)]
|
||||
action['name'] = _(
|
||||
"%(action)s for journal %(journal)s",
|
||||
action=action["name"],
|
||||
journal=self.name,
|
||||
)
|
||||
return action
|
||||
|
||||
def show_sequence_holes(self):
|
||||
has_sequence_holes = self._query_has_sequence_holes()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _("Journal Entries"),
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'list,form',
|
||||
'domain': expression.OR(
|
||||
[('journal_id', '=', journal_id), ('sequence_prefix', '=', prefix)]
|
||||
for journal_id, prefix in has_sequence_holes
|
||||
),
|
||||
'context': {
|
||||
**self._get_move_action_context(),
|
||||
'search_default_group_by_sequence_prefix': 1,
|
||||
'expand': 1,
|
||||
}
|
||||
}
|
||||
|
||||
def create_bank_statement(self):
|
||||
"""return action to create a bank statements. This button should be called only on journals with type =='bank'"""
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("account.action_bank_statement_tree")
|
||||
action.update({
|
||||
'views': [[False, 'form']],
|
||||
'context': "{'default_journal_id': " + str(self.id) + "}",
|
||||
})
|
||||
return action
|
||||
|
||||
def create_customer_payment(self):
|
||||
"""return action to create a customer payment"""
|
||||
return self.open_payments_action('inbound', mode='form')
|
||||
|
||||
def create_supplier_payment(self):
|
||||
"""return action to create a supplier payment"""
|
||||
return self.open_payments_action('outbound', mode='form')
|
||||
|
||||
def create_internal_transfer(self):
|
||||
"""return action to create a internal transfer"""
|
||||
return self.open_payments_action('transfer', mode='form')
|
||||
|
||||
#####################
|
||||
# Setup Steps Stuff #
|
||||
#####################
|
||||
def mark_bank_setup_as_done_action(self):
|
||||
""" Marks the 'bank setup' step as done in the setup bar and in the company."""
|
||||
self.company_id.sudo().set_onboarding_step_done('account_setup_bank_data_state')
|
||||
|
||||
def unmark_bank_setup_as_done_action(self):
|
||||
""" Marks the 'bank setup' step as not done in the setup bar and in the company."""
|
||||
self.company_id.account_setup_bank_data_state = 'not_done'
|
||||
4537
odoo-bringout-oca-ocb-account/account/models/account_move.py
Normal file
4537
odoo-bringout-oca-ocb-account/account/models/account_move.py
Normal file
File diff suppressed because it is too large
Load diff
2887
odoo-bringout-oca-ocb-account/account/models/account_move_line.py
Normal file
2887
odoo-bringout-oca-ocb-account/account/models/account_move_line.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,509 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
_inherit = 'account.move.line'
|
||||
|
||||
@api.model
|
||||
def _get_query_tax_details_from_domain(self, domain, fallback=True):
|
||||
""" Create the tax details sub-query based on the orm domain passed as parameter.
|
||||
|
||||
:param domain: An orm domain on account.move.line.
|
||||
:param fallback: Fallback on an approximated mapping if the mapping failed.
|
||||
:return: A tuple <query, params>.
|
||||
"""
|
||||
self.env['account.move.line'].check_access_rights('read')
|
||||
|
||||
query = self.env['account.move.line']._where_calc(domain)
|
||||
|
||||
# Wrap the query with 'company_id IN (...)' to avoid bypassing company access rights.
|
||||
self.env['account.move.line']._apply_ir_rules(query)
|
||||
|
||||
tables, where_clause, where_params = query.get_sql()
|
||||
return self._get_query_tax_details(tables, where_clause, where_params, fallback=fallback)
|
||||
|
||||
@api.model
|
||||
def _get_extra_query_base_tax_line_mapping(self):
|
||||
#TO OVERRIDE
|
||||
return ''
|
||||
|
||||
@api.model
|
||||
def _get_query_tax_details(self, tables, where_clause, where_params, fallback=True):
|
||||
""" Create the tax details sub-query based on the orm domain passed as parameter.
|
||||
|
||||
:param tables: The 'tables' query to inject after the FROM.
|
||||
:param where_clause: The 'where_clause' query computed based on an orm domain.
|
||||
:param where_params: The params to fill the 'where_clause' query.
|
||||
:param fallback: Fallback on an approximated mapping if the mapping failed.
|
||||
:return: A tuple <query, params>.
|
||||
"""
|
||||
group_taxes = self.env['account.tax'].search([('amount_type', '=', 'group')])
|
||||
|
||||
group_taxes_query_list = []
|
||||
group_taxes_params = []
|
||||
for group_tax in group_taxes:
|
||||
children_taxes = group_tax.children_tax_ids
|
||||
if not children_taxes:
|
||||
continue
|
||||
|
||||
children_taxes_in_query = ','.join('%s' for dummy in children_taxes)
|
||||
group_taxes_query_list.append(f'WHEN tax.id = %s THEN ARRAY[{children_taxes_in_query}]')
|
||||
group_taxes_params.append(group_tax.id)
|
||||
group_taxes_params.extend(children_taxes.ids)
|
||||
|
||||
if group_taxes_query_list:
|
||||
group_taxes_query = f'''UNNEST(CASE {' '.join(group_taxes_query_list)} ELSE ARRAY[tax.id] END)'''
|
||||
else:
|
||||
group_taxes_query = 'tax.id'
|
||||
|
||||
if fallback:
|
||||
fallback_query = f'''
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
account_move_line.id AS tax_line_id,
|
||||
base_line.id AS base_line_id,
|
||||
base_line.id AS src_line_id,
|
||||
base_line.balance AS base_amount,
|
||||
base_line.amount_currency AS base_amount_currency
|
||||
FROM {tables}
|
||||
LEFT JOIN base_tax_line_mapping ON
|
||||
base_tax_line_mapping.tax_line_id = account_move_line.id
|
||||
JOIN account_move_line_account_tax_rel tax_rel ON
|
||||
tax_rel.account_tax_id = COALESCE(account_move_line.group_tax_id, account_move_line.tax_line_id)
|
||||
JOIN account_move_line base_line ON
|
||||
base_line.id = tax_rel.account_move_line_id
|
||||
AND base_line.tax_repartition_line_id IS NULL
|
||||
AND base_line.move_id = account_move_line.move_id
|
||||
AND base_line.currency_id = account_move_line.currency_id
|
||||
WHERE base_tax_line_mapping.tax_line_id IS NULL
|
||||
AND {where_clause}
|
||||
'''
|
||||
fallback_params = where_params
|
||||
else:
|
||||
fallback_query = ''
|
||||
fallback_params = []
|
||||
|
||||
extra_query_base_tax_line_mapping = self._get_extra_query_base_tax_line_mapping()
|
||||
|
||||
return f'''
|
||||
/*
|
||||
As example to explain the different parts of the query, we'll consider a move with the following lines:
|
||||
Name Tax_line_id Tax_ids Debit Credit Base lines
|
||||
---------------------------------------------------------------------------------------------------
|
||||
base_line_1 10_affect_base, 20 1000
|
||||
base_line_2 10_affect_base, 5 2000
|
||||
base_line_3 10_affect_base, 5 3000
|
||||
tax_line_1 10_affect_base 20 100 base_line_1
|
||||
tax_line_2 20 220 base_line_1
|
||||
tax_line_3 10_affect_base 5 500 base_line_2/3
|
||||
tax_line_4 5 275 base_line_2/3
|
||||
*/
|
||||
|
||||
WITH affecting_base_tax_ids AS (
|
||||
|
||||
/*
|
||||
This CTE builds a reference table based on the tax_ids field, with the following changes:
|
||||
- flatten the group of taxes
|
||||
- exclude the taxes having 'is_base_affected' set to False.
|
||||
Those allow to match only base_line_1 when finding the base lines of tax_line_1, as we need to find
|
||||
base lines having a 'affecting_base_tax_ids' ending with [10_affect_base, 20], not only containing
|
||||
'10_affect_base'. Otherwise, base_line_2/3 would also be matched.
|
||||
In our example, as all the taxes are set to be affected by previous ones affecting the base, the
|
||||
result is similar to the table 'account_move_line_account_tax_rel':
|
||||
Id Tax_ids
|
||||
-------------------------------------------
|
||||
base_line_1 [10_affect_base, 20]
|
||||
base_line_2 [10_affect_base, 5]
|
||||
base_line_3 [10_affect_base, 5]
|
||||
*/
|
||||
|
||||
SELECT
|
||||
sub.line_id AS id,
|
||||
ARRAY_AGG(sub.tax_id ORDER BY sub.sequence, sub.tax_id) AS tax_ids
|
||||
FROM (
|
||||
SELECT
|
||||
tax_rel.account_move_line_id AS line_id,
|
||||
{group_taxes_query} AS tax_id,
|
||||
tax.sequence
|
||||
FROM {tables}
|
||||
JOIN account_move_line_account_tax_rel tax_rel ON account_move_line.id = tax_rel.account_move_line_id
|
||||
JOIN account_tax tax ON tax.id = tax_rel.account_tax_id
|
||||
WHERE tax.is_base_affected
|
||||
AND {where_clause}
|
||||
) AS sub
|
||||
GROUP BY sub.line_id
|
||||
),
|
||||
|
||||
base_tax_line_mapping AS (
|
||||
|
||||
/*
|
||||
Create the mapping of each tax lines with their corresponding base lines.
|
||||
|
||||
In the example, it will give the following values:
|
||||
base_line_id tax_line_id base_amount
|
||||
-------------------------------------------
|
||||
base_line_1 tax_line_1 1000
|
||||
base_line_1 tax_line_2 1000
|
||||
base_line_2 tax_line_3 2000
|
||||
base_line_2 tax_line_4 2000
|
||||
base_line_3 tax_line_3 3000
|
||||
base_line_3 tax_line_4 3000
|
||||
*/
|
||||
|
||||
SELECT
|
||||
account_move_line.id AS tax_line_id,
|
||||
base_line.id AS base_line_id,
|
||||
base_line.balance AS base_amount,
|
||||
base_line.amount_currency AS base_amount_currency
|
||||
|
||||
FROM {tables}
|
||||
JOIN account_tax_repartition_line tax_rep ON
|
||||
tax_rep.id = account_move_line.tax_repartition_line_id
|
||||
JOIN account_tax tax ON
|
||||
tax.id = account_move_line.tax_line_id
|
||||
JOIN account_move_line_account_tax_rel tax_rel ON
|
||||
tax_rel.account_tax_id = COALESCE(account_move_line.group_tax_id, account_move_line.tax_line_id)
|
||||
JOIN account_move move ON
|
||||
move.id = account_move_line.move_id
|
||||
JOIN account_move_line base_line ON
|
||||
base_line.id = tax_rel.account_move_line_id
|
||||
AND base_line.tax_repartition_line_id IS NULL
|
||||
AND base_line.move_id = account_move_line.move_id
|
||||
AND (
|
||||
move.move_type != 'entry'
|
||||
OR
|
||||
sign(account_move_line.balance) = sign(base_line.balance * tax.amount * tax_rep.factor_percent)
|
||||
)
|
||||
AND COALESCE(base_line.partner_id, 0) = COALESCE(account_move_line.partner_id, 0)
|
||||
AND base_line.currency_id = account_move_line.currency_id
|
||||
AND (
|
||||
COALESCE(tax_rep.account_id, base_line.account_id) = account_move_line.account_id
|
||||
OR (tax.tax_exigibility = 'on_payment' AND tax.cash_basis_transition_account_id IS NOT NULL)
|
||||
)
|
||||
AND (
|
||||
NOT tax.analytic
|
||||
OR (base_line.analytic_distribution IS NULL AND account_move_line.analytic_distribution IS NULL)
|
||||
OR base_line.analytic_distribution = account_move_line.analytic_distribution
|
||||
)
|
||||
{extra_query_base_tax_line_mapping}
|
||||
JOIN res_currency curr ON
|
||||
curr.id = account_move_line.currency_id
|
||||
JOIN res_currency comp_curr ON
|
||||
comp_curr.id = account_move_line.company_currency_id
|
||||
LEFT JOIN affecting_base_tax_ids tax_line_tax_ids ON tax_line_tax_ids.id = account_move_line.id
|
||||
JOIN affecting_base_tax_ids base_line_tax_ids ON base_line_tax_ids.id = base_line.id
|
||||
WHERE account_move_line.tax_repartition_line_id IS NOT NULL
|
||||
AND {where_clause}
|
||||
AND (
|
||||
-- keeping only the rows from affecting_base_tax_lines that end with the same taxes applied (see comment in affecting_base_tax_ids)
|
||||
NOT tax.include_base_amount
|
||||
OR base_line_tax_ids.tax_ids[ARRAY_LENGTH(base_line_tax_ids.tax_ids, 1) - COALESCE(ARRAY_LENGTH(tax_line_tax_ids.tax_ids, 1), 0):ARRAY_LENGTH(base_line_tax_ids.tax_ids, 1)]
|
||||
= ARRAY[account_move_line.tax_line_id] || COALESCE(tax_line_tax_ids.tax_ids, ARRAY[]::INTEGER[])
|
||||
)
|
||||
),
|
||||
|
||||
|
||||
tax_amount_affecting_base_to_dispatch AS (
|
||||
|
||||
/*
|
||||
Computes the total amount to dispatch in case of tax lines affecting the base of subsequent taxes.
|
||||
Such tax lines are an additional base amount for others lines, that will be truly dispatch in next
|
||||
CTE.
|
||||
|
||||
In the example:
|
||||
- tax_line_1 is an additional base of 100.0 from base_line_1 for tax_line_2.
|
||||
- tax_line_3 is an additional base of 2/5 * 500.0 = 200.0 from base_line_2 for tax_line_4.
|
||||
- tax_line_3 is an additional base of 3/5 * 500.0 = 300.0 from base_line_3 for tax_line_4.
|
||||
|
||||
src_line_id base_line_id tax_line_id total_base_amount
|
||||
-------------------------------------------------------------
|
||||
tax_line_1 base_line_1 tax_line_2 1000
|
||||
tax_line_3 base_line_2 tax_line_4 5000
|
||||
tax_line_3 base_line_3 tax_line_4 5000
|
||||
*/
|
||||
|
||||
SELECT
|
||||
tax_line.id AS tax_line_id,
|
||||
base_line.id AS base_line_id,
|
||||
account_move_line.id AS src_line_id,
|
||||
|
||||
tax_line.company_id,
|
||||
comp_curr.id AS company_currency_id,
|
||||
comp_curr.decimal_places AS comp_curr_prec,
|
||||
curr.id AS currency_id,
|
||||
curr.decimal_places AS curr_prec,
|
||||
|
||||
tax_line.tax_line_id AS tax_id,
|
||||
|
||||
base_line.balance AS base_amount,
|
||||
SUM(
|
||||
CASE WHEN tax.amount_type = 'fixed'
|
||||
THEN CASE WHEN base_line.balance < 0 THEN -1 ELSE 1 END * ABS(COALESCE(base_line.quantity, 1.0))
|
||||
ELSE base_line.balance
|
||||
END
|
||||
) OVER (PARTITION BY tax_line.id, account_move_line.id ORDER BY tax_line.tax_line_id, base_line.id) AS cumulated_base_amount,
|
||||
SUM(
|
||||
CASE WHEN tax.amount_type = 'fixed'
|
||||
THEN CASE WHEN base_line.balance < 0 THEN -1 ELSE 1 END * ABS(COALESCE(base_line.quantity, 1.0))
|
||||
ELSE base_line.balance
|
||||
END
|
||||
) OVER (PARTITION BY tax_line.id, account_move_line.id) AS total_base_amount,
|
||||
account_move_line.balance AS total_tax_amount,
|
||||
|
||||
base_line.amount_currency AS base_amount_currency,
|
||||
SUM(
|
||||
CASE WHEN tax.amount_type = 'fixed'
|
||||
THEN CASE WHEN base_line.amount_currency < 0 THEN -1 ELSE 1 END * ABS(COALESCE(base_line.quantity, 1.0))
|
||||
ELSE base_line.amount_currency
|
||||
END
|
||||
) OVER (PARTITION BY tax_line.id, account_move_line.id ORDER BY tax_line.tax_line_id, base_line.id) AS cumulated_base_amount_currency,
|
||||
SUM(
|
||||
CASE WHEN tax.amount_type = 'fixed'
|
||||
THEN CASE WHEN base_line.amount_currency < 0 THEN -1 ELSE 1 END * ABS(COALESCE(base_line.quantity, 1.0))
|
||||
ELSE base_line.amount_currency
|
||||
END
|
||||
) OVER (PARTITION BY tax_line.id, account_move_line.id) AS total_base_amount_currency,
|
||||
account_move_line.amount_currency AS total_tax_amount_currency
|
||||
|
||||
FROM {tables}
|
||||
JOIN account_tax tax_include_base_amount ON
|
||||
tax_include_base_amount.include_base_amount
|
||||
AND tax_include_base_amount.id = account_move_line.tax_line_id
|
||||
JOIN base_tax_line_mapping base_tax_line_mapping ON
|
||||
base_tax_line_mapping.tax_line_id = account_move_line.id
|
||||
JOIN account_move_line_account_tax_rel tax_rel ON
|
||||
tax_rel.account_move_line_id = base_tax_line_mapping.tax_line_id
|
||||
JOIN account_tax tax ON
|
||||
tax.id = tax_rel.account_tax_id
|
||||
JOIN base_tax_line_mapping tax_line_matching ON
|
||||
tax_line_matching.base_line_id = base_tax_line_mapping.base_line_id
|
||||
JOIN account_move_line tax_line ON
|
||||
tax_line.id = tax_line_matching.tax_line_id
|
||||
AND tax_line.tax_line_id = tax_rel.account_tax_id
|
||||
JOIN res_currency curr ON
|
||||
curr.id = tax_line.currency_id
|
||||
JOIN res_currency comp_curr ON
|
||||
comp_curr.id = tax_line.company_currency_id
|
||||
JOIN account_move_line base_line ON
|
||||
base_line.id = base_tax_line_mapping.base_line_id
|
||||
WHERE {where_clause}
|
||||
),
|
||||
|
||||
|
||||
base_tax_matching_base_amounts AS (
|
||||
|
||||
/*
|
||||
Build here the full mapping tax lines <=> base lines containing the final base amounts.
|
||||
This is done in a 3-parts union.
|
||||
|
||||
Note: src_line_id is used only to build a unique ID.
|
||||
*/
|
||||
|
||||
/*
|
||||
PART 1: raw mapping computed in base_tax_line_mapping.
|
||||
*/
|
||||
|
||||
SELECT
|
||||
tax_line_id,
|
||||
base_line_id,
|
||||
base_line_id AS src_line_id,
|
||||
base_amount,
|
||||
base_amount_currency
|
||||
FROM base_tax_line_mapping
|
||||
|
||||
UNION ALL
|
||||
|
||||
/*
|
||||
PART 2: Dispatch the tax amount of tax lines affecting the base of subsequent ones, using
|
||||
tax_amount_affecting_base_to_dispatch.
|
||||
|
||||
This will effectively add the following rows:
|
||||
base_line_id tax_line_id src_line_id base_amount
|
||||
-------------------------------------------------------------
|
||||
base_line_1 tax_line_2 tax_line_1 100
|
||||
base_line_2 tax_line_4 tax_line_3 200
|
||||
base_line_3 tax_line_4 tax_line_3 300
|
||||
*/
|
||||
|
||||
SELECT
|
||||
sub.tax_line_id,
|
||||
sub.base_line_id,
|
||||
sub.src_line_id,
|
||||
|
||||
ROUND(
|
||||
COALESCE(SIGN(sub.cumulated_base_amount) * sub.total_tax_amount * ABS(sub.cumulated_base_amount) / NULLIF(sub.total_base_amount, 0.0), 0.0),
|
||||
sub.comp_curr_prec
|
||||
)
|
||||
- LAG(ROUND(
|
||||
COALESCE(SIGN(sub.cumulated_base_amount) * sub.total_tax_amount * ABS(sub.cumulated_base_amount) / NULLIF(sub.total_base_amount, 0.0), 0.0),
|
||||
sub.comp_curr_prec
|
||||
), 1, 0.0)
|
||||
OVER (
|
||||
PARTITION BY sub.tax_line_id, sub.src_line_id ORDER BY sub.tax_id, sub.base_line_id
|
||||
) AS base_amount,
|
||||
|
||||
ROUND(
|
||||
COALESCE(SIGN(sub.cumulated_base_amount_currency) * sub.total_tax_amount_currency * ABS(sub.cumulated_base_amount_currency) / NULLIF(sub.total_base_amount_currency, 0.0), 0.0),
|
||||
sub.curr_prec
|
||||
)
|
||||
- LAG(ROUND(
|
||||
COALESCE(SIGN(sub.cumulated_base_amount_currency) * sub.total_tax_amount_currency * ABS(sub.cumulated_base_amount_currency) / NULLIF(sub.total_base_amount_currency, 0.0), 0.0),
|
||||
sub.curr_prec
|
||||
), 1, 0.0)
|
||||
OVER (
|
||||
PARTITION BY sub.tax_line_id, sub.src_line_id ORDER BY sub.tax_id, sub.base_line_id
|
||||
) AS base_amount_currency
|
||||
FROM tax_amount_affecting_base_to_dispatch sub
|
||||
JOIN account_move_line tax_line ON
|
||||
tax_line.id = sub.tax_line_id
|
||||
|
||||
/*
|
||||
PART 3: In case of the matching failed because the configuration changed or some journal entries
|
||||
have been imported, construct a simple mapping as a fallback. This mapping is super naive and only
|
||||
build based on the 'tax_ids' and 'tax_line_id' fields, nothing else. Hence, the mapping will not be
|
||||
exact but will give an acceptable approximation.
|
||||
|
||||
Skipped if the 'fallback' method parameter is False.
|
||||
*/
|
||||
{fallback_query}
|
||||
),
|
||||
|
||||
|
||||
base_tax_matching_all_amounts AS (
|
||||
|
||||
/*
|
||||
Complete base_tax_matching_base_amounts with the tax amounts (prorata):
|
||||
base_line_id tax_line_id src_line_id base_amount tax_amount
|
||||
--------------------------------------------------------------------------
|
||||
base_line_1 tax_line_1 base_line_1 1000 100
|
||||
base_line_1 tax_line_2 base_line_1 1000 (1000 / 1100) * 220 = 200
|
||||
base_line_1 tax_line_2 tax_line_1 100 (100 / 1100) * 220 = 20
|
||||
base_line_2 tax_line_3 base_line_2 2000 (2000 / 5000) * 500 = 200
|
||||
base_line_2 tax_line_4 base_line_2 2000 (2000 / 5500) * 275 = 100
|
||||
base_line_2 tax_line_4 tax_line_3 200 (200 / 5500) * 275 = 10
|
||||
base_line_3 tax_line_3 base_line_3 3000 (3000 / 5000) * 500 = 300
|
||||
base_line_3 tax_line_4 base_line_3 3000 (3000 / 5500) * 275 = 150
|
||||
base_line_3 tax_line_4 tax_line_3 300 (300 / 5500) * 275 = 15
|
||||
*/
|
||||
|
||||
SELECT
|
||||
sub.tax_line_id,
|
||||
sub.base_line_id,
|
||||
sub.src_line_id,
|
||||
|
||||
tax_line.tax_line_id AS tax_id,
|
||||
tax_line.group_tax_id,
|
||||
tax_line.tax_repartition_line_id,
|
||||
|
||||
tax_line.company_id,
|
||||
tax_line.display_type AS display_type,
|
||||
comp_curr.id AS company_currency_id,
|
||||
comp_curr.decimal_places AS comp_curr_prec,
|
||||
curr.id AS currency_id,
|
||||
curr.decimal_places AS curr_prec,
|
||||
(
|
||||
tax.tax_exigibility != 'on_payment'
|
||||
OR tax_move.tax_cash_basis_rec_id IS NOT NULL
|
||||
OR tax_move.always_tax_exigible
|
||||
) AS tax_exigible,
|
||||
base_line.account_id AS base_account_id,
|
||||
|
||||
sub.base_amount,
|
||||
SUM(
|
||||
CASE WHEN tax.amount_type = 'fixed'
|
||||
THEN CASE WHEN base_line.balance < 0 THEN -1 ELSE 1 END * ABS(COALESCE(base_line.quantity, 1.0))
|
||||
ELSE sub.base_amount
|
||||
END
|
||||
) OVER (PARTITION BY tax_line.id ORDER BY tax_line.tax_line_id, sub.base_line_id, sub.src_line_id) AS cumulated_base_amount,
|
||||
SUM(
|
||||
CASE WHEN tax.amount_type = 'fixed'
|
||||
THEN CASE WHEN base_line.balance < 0 THEN -1 ELSE 1 END * ABS(COALESCE(base_line.quantity, 1.0))
|
||||
ELSE sub.base_amount
|
||||
END
|
||||
) OVER (PARTITION BY tax_line.id) AS total_base_amount,
|
||||
tax_line.balance AS total_tax_amount,
|
||||
|
||||
sub.base_amount_currency,
|
||||
SUM(
|
||||
CASE WHEN tax.amount_type = 'fixed'
|
||||
THEN CASE WHEN base_line.amount_currency < 0 THEN -1 ELSE 1 END * ABS(COALESCE(base_line.quantity, 1.0))
|
||||
ELSE sub.base_amount_currency
|
||||
END
|
||||
) OVER (PARTITION BY tax_line.id ORDER BY tax_line.tax_line_id, sub.base_line_id, sub.src_line_id) AS cumulated_base_amount_currency,
|
||||
SUM(
|
||||
CASE WHEN tax.amount_type = 'fixed'
|
||||
THEN CASE WHEN base_line.amount_currency < 0 THEN -1 ELSE 1 END * ABS(COALESCE(base_line.quantity, 1.0))
|
||||
ELSE sub.base_amount_currency
|
||||
END
|
||||
) OVER (PARTITION BY tax_line.id) AS total_base_amount_currency,
|
||||
tax_line.amount_currency AS total_tax_amount_currency
|
||||
|
||||
FROM base_tax_matching_base_amounts sub
|
||||
JOIN account_move_line tax_line ON
|
||||
tax_line.id = sub.tax_line_id
|
||||
JOIN account_move tax_move ON
|
||||
tax_move.id = tax_line.move_id
|
||||
JOIN account_move_line base_line ON
|
||||
base_line.id = sub.base_line_id
|
||||
JOIN account_tax tax ON
|
||||
tax.id = tax_line.tax_line_id
|
||||
JOIN res_currency curr ON
|
||||
curr.id = tax_line.currency_id
|
||||
JOIN res_currency comp_curr ON
|
||||
comp_curr.id = tax_line.company_currency_id
|
||||
|
||||
)
|
||||
|
||||
|
||||
/* Final select that makes sure to deal with rounding errors, using LAG to dispatch the last cents. */
|
||||
|
||||
SELECT
|
||||
sub.tax_line_id || '-' || sub.base_line_id || '-' || sub.src_line_id AS id,
|
||||
|
||||
sub.base_line_id,
|
||||
sub.tax_line_id,
|
||||
sub.display_type,
|
||||
sub.src_line_id,
|
||||
|
||||
sub.tax_id,
|
||||
sub.group_tax_id,
|
||||
sub.tax_exigible,
|
||||
sub.base_account_id,
|
||||
sub.tax_repartition_line_id,
|
||||
|
||||
sub.base_amount,
|
||||
COALESCE(
|
||||
ROUND(
|
||||
COALESCE(SIGN(sub.cumulated_base_amount) * sub.total_tax_amount * ABS(sub.cumulated_base_amount) / NULLIF(sub.total_base_amount, 0.0), 0.0),
|
||||
sub.comp_curr_prec
|
||||
)
|
||||
- LAG(ROUND(
|
||||
COALESCE(SIGN(sub.cumulated_base_amount) * sub.total_tax_amount * ABS(sub.cumulated_base_amount) / NULLIF(sub.total_base_amount, 0.0), 0.0),
|
||||
sub.comp_curr_prec
|
||||
), 1, 0.0)
|
||||
OVER (
|
||||
PARTITION BY sub.tax_line_id ORDER BY sub.tax_id, sub.base_line_id
|
||||
),
|
||||
0.0
|
||||
) AS tax_amount,
|
||||
|
||||
sub.base_amount_currency,
|
||||
COALESCE(
|
||||
ROUND(
|
||||
COALESCE(SIGN(sub.cumulated_base_amount_currency) * sub.total_tax_amount_currency * ABS(sub.cumulated_base_amount_currency) / NULLIF(sub.total_base_amount_currency, 0.0), 0.0),
|
||||
sub.curr_prec
|
||||
)
|
||||
- LAG(ROUND(
|
||||
COALESCE(SIGN(sub.cumulated_base_amount_currency) * sub.total_tax_amount_currency * ABS(sub.cumulated_base_amount_currency) / NULLIF(sub.total_base_amount_currency, 0.0), 0.0),
|
||||
sub.curr_prec
|
||||
), 1, 0.0)
|
||||
OVER (
|
||||
PARTITION BY sub.tax_line_id ORDER BY sub.tax_id, sub.base_line_id
|
||||
),
|
||||
0.0
|
||||
) AS tax_amount_currency
|
||||
FROM base_tax_matching_all_amounts sub
|
||||
''', group_taxes_params + where_params + where_params + where_params + fallback_params
|
||||
|
|
@ -0,0 +1,553 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import api, fields, models, _, Command
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.tools import frozendict
|
||||
|
||||
from datetime import date
|
||||
|
||||
|
||||
class AccountPartialReconcile(models.Model):
|
||||
_name = "account.partial.reconcile"
|
||||
_description = "Partial Reconcile"
|
||||
_rec_name = "id"
|
||||
|
||||
# ==== Reconciliation fields ====
|
||||
debit_move_id = fields.Many2one(
|
||||
comodel_name='account.move.line',
|
||||
index=True, required=True)
|
||||
credit_move_id = fields.Many2one(
|
||||
comodel_name='account.move.line',
|
||||
index=True, required=True)
|
||||
full_reconcile_id = fields.Many2one(
|
||||
comodel_name='account.full.reconcile',
|
||||
string="Full Reconcile", copy=False, index='btree_not_null')
|
||||
exchange_move_id = fields.Many2one(comodel_name='account.move', index='btree_not_null')
|
||||
|
||||
# ==== Currency fields ====
|
||||
company_currency_id = fields.Many2one(
|
||||
comodel_name='res.currency',
|
||||
string="Company Currency",
|
||||
related='company_id.currency_id',
|
||||
help="Utility field to express amount currency")
|
||||
debit_currency_id = fields.Many2one(
|
||||
comodel_name='res.currency',
|
||||
store=True,
|
||||
related='debit_move_id.currency_id', precompute=True,
|
||||
string="Currency of the debit journal item.")
|
||||
credit_currency_id = fields.Many2one(
|
||||
comodel_name='res.currency',
|
||||
store=True,
|
||||
related='credit_move_id.currency_id', precompute=True,
|
||||
string="Currency of the credit journal item.")
|
||||
|
||||
# ==== Amount fields ====
|
||||
amount = fields.Monetary(
|
||||
currency_field='company_currency_id',
|
||||
help="Always positive amount concerned by this matching expressed in the company currency.")
|
||||
debit_amount_currency = fields.Monetary(
|
||||
currency_field='debit_currency_id',
|
||||
help="Always positive amount concerned by this matching expressed in the debit line foreign currency.")
|
||||
credit_amount_currency = fields.Monetary(
|
||||
currency_field='credit_currency_id',
|
||||
help="Always positive amount concerned by this matching expressed in the credit line foreign currency.")
|
||||
|
||||
# ==== Other fields ====
|
||||
company_id = fields.Many2one(
|
||||
comodel_name='res.company',
|
||||
string="Company", store=True, readonly=False,
|
||||
related='debit_move_id.company_id')
|
||||
max_date = fields.Date(
|
||||
string="Max Date of Matched Lines", store=True,
|
||||
compute='_compute_max_date')
|
||||
# used to determine at which date this reconciliation needs to be shown on the aged receivable/payable reports
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# CONSTRAINT METHODS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@api.constrains('debit_currency_id', 'credit_currency_id')
|
||||
def _check_required_computed_currencies(self):
|
||||
bad_partials = self.filtered(lambda partial: not partial.debit_currency_id or not partial.credit_currency_id)
|
||||
if bad_partials:
|
||||
raise ValidationError(_("Missing foreign currencies on partials having ids: %s", bad_partials.ids))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# COMPUTE METHODS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@api.depends('debit_move_id.date', 'credit_move_id.date')
|
||||
def _compute_max_date(self):
|
||||
for partial in self:
|
||||
partial.max_date = max(
|
||||
partial.debit_move_id.date,
|
||||
partial.credit_move_id.date
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# LOW-LEVEL METHODS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def unlink(self):
|
||||
# OVERRIDE to unlink full reconcile linked to the current partials
|
||||
# and reverse the tax cash basis journal entries.
|
||||
|
||||
# Avoid cyclic unlink calls when removing the partials that could remove some full reconcile
|
||||
# and then, loop again and again.
|
||||
if not self:
|
||||
return True
|
||||
|
||||
# Retrieve the matching number to unlink.
|
||||
full_to_unlink = self.full_reconcile_id
|
||||
|
||||
# Retrieve the CABA entries to reverse.
|
||||
moves_to_reverse = self.env['account.move'].search([('tax_cash_basis_rec_id', 'in', self.ids)])
|
||||
# Same for the exchange difference entries.
|
||||
moves_to_reverse += self.exchange_move_id
|
||||
|
||||
# Unlink partials before doing anything else to avoid 'Record has already been deleted' due to the recursion.
|
||||
res = super().unlink()
|
||||
|
||||
# Remove the matching numbers before reversing the moves to avoid trying to remove the full twice.
|
||||
full_to_unlink.unlink()
|
||||
|
||||
# Reverse CABA entries.
|
||||
if moves_to_reverse:
|
||||
default_values_list = [{
|
||||
'date': move._get_accounting_date(move.date, move._affect_tax_report()),
|
||||
'ref': _('Reversal of: %s') % move.name,
|
||||
} for move in moves_to_reverse]
|
||||
moves_to_reverse._reverse_moves(default_values_list, cancel=True)
|
||||
|
||||
return res
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# RECONCILIATION METHODS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _collect_tax_cash_basis_values(self):
|
||||
''' Collect all information needed to create the tax cash basis journal entries on the current partials.
|
||||
:return: A dictionary mapping each move_id to the result of 'account_move._collect_tax_cash_basis_values'.
|
||||
Also, add the 'partials' keys being a list of dictionary, one for each partial to process:
|
||||
* partial: The account.partial.reconcile record.
|
||||
* percentage: The reconciled percentage represented by the partial.
|
||||
* payment_rate: The applied rate of this partial.
|
||||
'''
|
||||
tax_cash_basis_values_per_move = {}
|
||||
|
||||
if not self:
|
||||
return {}
|
||||
|
||||
for partial in self:
|
||||
for move in {partial.debit_move_id.move_id, partial.credit_move_id.move_id}:
|
||||
|
||||
# Collect data about cash basis.
|
||||
if move.id in tax_cash_basis_values_per_move:
|
||||
move_values = tax_cash_basis_values_per_move[move.id]
|
||||
else:
|
||||
move_values = move._collect_tax_cash_basis_values()
|
||||
|
||||
# Nothing to process on the move.
|
||||
if not move_values:
|
||||
continue
|
||||
|
||||
# Check the cash basis configuration only when at least one cash basis tax entry need to be created.
|
||||
journal = partial.company_id.tax_cash_basis_journal_id
|
||||
|
||||
if not journal:
|
||||
raise UserError(_("There is no tax cash basis journal defined for the '%s' company.\n"
|
||||
"Configure it in Accounting/Configuration/Settings") % partial.company_id.display_name)
|
||||
|
||||
partial_amount = 0.0
|
||||
partial_amount_currency = 0.0
|
||||
rate_amount = 0.0
|
||||
rate_amount_currency = 0.0
|
||||
|
||||
if partial.debit_move_id.move_id == move:
|
||||
partial_amount += partial.amount
|
||||
partial_amount_currency += partial.debit_amount_currency
|
||||
rate_amount -= partial.credit_move_id.balance
|
||||
rate_amount_currency -= partial.credit_move_id.amount_currency
|
||||
source_line = partial.debit_move_id
|
||||
counterpart_line = partial.credit_move_id
|
||||
|
||||
if partial.credit_move_id.move_id == move:
|
||||
partial_amount += partial.amount
|
||||
partial_amount_currency += partial.credit_amount_currency
|
||||
rate_amount += partial.debit_move_id.balance
|
||||
rate_amount_currency += partial.debit_move_id.amount_currency
|
||||
source_line = partial.credit_move_id
|
||||
counterpart_line = partial.debit_move_id
|
||||
|
||||
if partial.debit_move_id.move_id.is_invoice(include_receipts=True) and partial.credit_move_id.move_id.is_invoice(include_receipts=True):
|
||||
# Will match when reconciling a refund with an invoice.
|
||||
# In this case, we want to use the rate of each businness document to compute its cash basis entry,
|
||||
# not the rate of what it's reconciled with.
|
||||
rate_amount = source_line.balance
|
||||
rate_amount_currency = source_line.amount_currency
|
||||
payment_date = move.date
|
||||
else:
|
||||
payment_date = counterpart_line.date
|
||||
|
||||
if move_values['currency'] == move.company_id.currency_id:
|
||||
# Ignore the exchange difference.
|
||||
if move.company_currency_id.is_zero(partial_amount):
|
||||
continue
|
||||
|
||||
# Percentage made on company's currency.
|
||||
percentage = partial_amount / move_values['total_balance']
|
||||
else:
|
||||
# Ignore the exchange difference.
|
||||
if move.currency_id.is_zero(partial_amount_currency):
|
||||
continue
|
||||
|
||||
# Percentage made on foreign currency.
|
||||
percentage = partial_amount_currency / move_values['total_amount_currency']
|
||||
|
||||
if source_line.currency_id != counterpart_line.currency_id:
|
||||
# When the invoice and the payment are not sharing the same foreign currency, the rate is computed
|
||||
# on-the-fly using the payment date.
|
||||
payment_rate = self.env['res.currency']._get_conversion_rate(
|
||||
counterpart_line.company_currency_id,
|
||||
source_line.currency_id,
|
||||
counterpart_line.company_id,
|
||||
payment_date,
|
||||
)
|
||||
elif rate_amount:
|
||||
payment_rate = rate_amount_currency / rate_amount
|
||||
else:
|
||||
payment_rate = 0.0
|
||||
|
||||
tax_cash_basis_values_per_move[move.id] = move_values
|
||||
|
||||
partial_vals = {
|
||||
'partial': partial,
|
||||
'percentage': percentage,
|
||||
'payment_rate': payment_rate,
|
||||
}
|
||||
|
||||
# Add partials.
|
||||
move_values.setdefault('partials', [])
|
||||
move_values['partials'].append(partial_vals)
|
||||
|
||||
# Clean-up moves having nothing to process.
|
||||
return {k: v for k, v in tax_cash_basis_values_per_move.items() if v}
|
||||
|
||||
@api.model
|
||||
def _prepare_cash_basis_base_line_vals(self, base_line, balance, amount_currency):
|
||||
''' Prepare the values to be used to create the cash basis journal items for the tax base line
|
||||
passed as parameter.
|
||||
|
||||
:param base_line: An account.move.line being the base of some taxes.
|
||||
:param balance: The balance to consider for this line.
|
||||
:param amount_currency: The balance in foreign currency to consider for this line.
|
||||
:return: A python dictionary that could be passed to the create method of
|
||||
account.move.line.
|
||||
'''
|
||||
account = base_line.company_id.account_cash_basis_base_account_id or base_line.account_id
|
||||
tax_ids = base_line.tax_ids.flatten_taxes_hierarchy().filtered(lambda x: x.tax_exigibility == 'on_payment')
|
||||
is_refund = base_line.is_refund
|
||||
tax_tags = tax_ids.get_tax_tags(is_refund, 'base')
|
||||
product_tags = base_line.tax_tag_ids.filtered(lambda x: x.applicability == 'products')
|
||||
all_tags = tax_tags + product_tags
|
||||
|
||||
return {
|
||||
'name': base_line.move_id.name,
|
||||
'debit': balance if balance > 0.0 else 0.0,
|
||||
'credit': -balance if balance < 0.0 else 0.0,
|
||||
'amount_currency': amount_currency,
|
||||
'currency_id': base_line.currency_id.id,
|
||||
'partner_id': base_line.partner_id.id,
|
||||
'account_id': account.id,
|
||||
'tax_ids': [Command.set(tax_ids.ids)],
|
||||
'tax_tag_ids': [Command.set(all_tags.ids)],
|
||||
'analytic_distribution': base_line.analytic_distribution,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _prepare_cash_basis_counterpart_base_line_vals(self, cb_base_line_vals):
|
||||
''' Prepare the move line used as a counterpart of the line created by
|
||||
_prepare_cash_basis_base_line_vals.
|
||||
|
||||
:param cb_base_line_vals: The line returned by _prepare_cash_basis_base_line_vals.
|
||||
:return: A python dictionary that could be passed to the create method of
|
||||
account.move.line.
|
||||
'''
|
||||
return {
|
||||
'name': cb_base_line_vals['name'],
|
||||
'debit': cb_base_line_vals['credit'],
|
||||
'credit': cb_base_line_vals['debit'],
|
||||
'account_id': cb_base_line_vals['account_id'],
|
||||
'amount_currency': -cb_base_line_vals['amount_currency'],
|
||||
'currency_id': cb_base_line_vals['currency_id'],
|
||||
'partner_id': cb_base_line_vals['partner_id'],
|
||||
'analytic_distribution': cb_base_line_vals['analytic_distribution'],
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _prepare_cash_basis_tax_line_vals(self, tax_line, balance, amount_currency):
|
||||
''' Prepare the move line corresponding to a tax in the cash basis entry.
|
||||
|
||||
:param tax_line: An account.move.line record being a tax line.
|
||||
:param balance: The balance to consider for this line.
|
||||
:param amount_currency: The balance in foreign currency to consider for this line.
|
||||
:return: A python dictionary that could be passed to the create method of
|
||||
account.move.line.
|
||||
'''
|
||||
tax_ids = tax_line.tax_ids.filtered(lambda x: x.tax_exigibility == 'on_payment')
|
||||
base_tags = tax_ids.get_tax_tags(tax_line.tax_repartition_line_id.refund_tax_id, 'base')
|
||||
product_tags = tax_line.tax_tag_ids.filtered(lambda x: x.applicability == 'products')
|
||||
all_tags = base_tags + tax_line.tax_repartition_line_id.tag_ids + product_tags
|
||||
|
||||
return {
|
||||
'name': tax_line.name,
|
||||
'debit': balance if balance > 0.0 else 0.0,
|
||||
'credit': -balance if balance < 0.0 else 0.0,
|
||||
'tax_base_amount': tax_line.tax_base_amount,
|
||||
'tax_repartition_line_id': tax_line.tax_repartition_line_id.id,
|
||||
'tax_ids': [Command.set(tax_ids.ids)],
|
||||
'tax_tag_ids': [Command.set(all_tags.ids)],
|
||||
'account_id': tax_line.tax_repartition_line_id.account_id.id or tax_line.company_id.account_cash_basis_base_account_id.id or tax_line.account_id.id,
|
||||
'amount_currency': amount_currency,
|
||||
'currency_id': tax_line.currency_id.id,
|
||||
'partner_id': tax_line.partner_id.id,
|
||||
'analytic_distribution': tax_line.analytic_distribution,
|
||||
# No need to set tax_tag_invert as on the base line; it will be computed from the repartition line
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _prepare_cash_basis_counterpart_tax_line_vals(self, tax_line, cb_tax_line_vals):
|
||||
''' Prepare the move line used as a counterpart of the line created by
|
||||
_prepare_cash_basis_tax_line_vals.
|
||||
|
||||
:param tax_line: An account.move.line record being a tax line.
|
||||
:param cb_tax_line_vals: The result of _prepare_cash_basis_counterpart_tax_line_vals.
|
||||
:return: A python dictionary that could be passed to the create method of
|
||||
account.move.line.
|
||||
'''
|
||||
return {
|
||||
'name': cb_tax_line_vals['name'],
|
||||
'debit': cb_tax_line_vals['credit'],
|
||||
'credit': cb_tax_line_vals['debit'],
|
||||
'account_id': tax_line.account_id.id,
|
||||
'amount_currency': -cb_tax_line_vals['amount_currency'],
|
||||
'currency_id': cb_tax_line_vals['currency_id'],
|
||||
'partner_id': cb_tax_line_vals['partner_id'],
|
||||
'analytic_distribution': cb_tax_line_vals['analytic_distribution'],
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_cash_basis_base_line_grouping_key_from_vals(self, base_line_vals):
|
||||
''' Get the grouping key of a cash basis base line that hasn't yet been created.
|
||||
:param base_line_vals: The values to create a new account.move.line record.
|
||||
:return: The grouping key as a tuple.
|
||||
'''
|
||||
tax_ids = base_line_vals['tax_ids'][0][2] # Decode [(6, 0, [...])] command
|
||||
base_taxes = self.env['account.tax'].browse(tax_ids)
|
||||
return (
|
||||
base_line_vals['currency_id'],
|
||||
base_line_vals['partner_id'],
|
||||
base_line_vals['account_id'],
|
||||
tuple(base_taxes.filtered(lambda x: x.tax_exigibility == 'on_payment').ids),
|
||||
frozendict(base_line_vals['analytic_distribution'] or {}),
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _get_cash_basis_base_line_grouping_key_from_record(self, base_line, account=None):
|
||||
''' Get the grouping key of a journal item being a base line.
|
||||
:param base_line: An account.move.line record.
|
||||
:param account: Optional account to shadow the current base_line one.
|
||||
:return: The grouping key as a tuple.
|
||||
'''
|
||||
return (
|
||||
base_line.currency_id.id,
|
||||
base_line.partner_id.id,
|
||||
(account or base_line.account_id).id,
|
||||
tuple(base_line.tax_ids.flatten_taxes_hierarchy().filtered(lambda x: x.tax_exigibility == 'on_payment').ids),
|
||||
frozendict(base_line.analytic_distribution or {}),
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _get_cash_basis_tax_line_grouping_key_from_vals(self, tax_line_vals):
|
||||
''' Get the grouping key of a cash basis tax line that hasn't yet been created.
|
||||
:param tax_line_vals: The values to create a new account.move.line record.
|
||||
:return: The grouping key as a tuple.
|
||||
'''
|
||||
tax_ids = tax_line_vals['tax_ids'][0][2] # Decode [(6, 0, [...])] command
|
||||
base_taxes = self.env['account.tax'].browse(tax_ids)
|
||||
return (
|
||||
tax_line_vals['currency_id'],
|
||||
tax_line_vals['partner_id'],
|
||||
tax_line_vals['account_id'],
|
||||
tuple(base_taxes.filtered(lambda x: x.tax_exigibility == 'on_payment').ids),
|
||||
tax_line_vals['tax_repartition_line_id'],
|
||||
frozendict(tax_line_vals['analytic_distribution'] or {}),
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _get_cash_basis_tax_line_grouping_key_from_record(self, tax_line, account=None):
|
||||
''' Get the grouping key of a journal item being a tax line.
|
||||
:param tax_line: An account.move.line record.
|
||||
:param account: Optional account to shadow the current tax_line one.
|
||||
:return: The grouping key as a tuple.
|
||||
'''
|
||||
return (
|
||||
tax_line.currency_id.id,
|
||||
tax_line.partner_id.id,
|
||||
(account or tax_line.account_id).id,
|
||||
tuple(tax_line.tax_ids.filtered(lambda x: x.tax_exigibility == 'on_payment').ids),
|
||||
tax_line.tax_repartition_line_id.id,
|
||||
frozendict(tax_line.analytic_distribution or {}),
|
||||
)
|
||||
|
||||
def _create_tax_cash_basis_moves(self):
|
||||
''' Create the tax cash basis journal entries.
|
||||
:return: The newly created journal entries.
|
||||
'''
|
||||
tax_cash_basis_values_per_move = self._collect_tax_cash_basis_values()
|
||||
today = fields.Date.context_today(self)
|
||||
|
||||
moves_to_create = []
|
||||
to_reconcile_after = []
|
||||
for move_values in tax_cash_basis_values_per_move.values():
|
||||
move = move_values['move']
|
||||
pending_cash_basis_lines = []
|
||||
|
||||
for partial_values in move_values['partials']:
|
||||
partial = partial_values['partial']
|
||||
|
||||
# Init the journal entry.
|
||||
lock_date = move.company_id._get_user_fiscal_lock_date()
|
||||
move_date = partial.max_date if partial.max_date > (lock_date or date.min) else today
|
||||
move_vals = {
|
||||
'move_type': 'entry',
|
||||
'date': move_date,
|
||||
'ref': move.name,
|
||||
'journal_id': partial.company_id.tax_cash_basis_journal_id.id,
|
||||
'line_ids': [],
|
||||
'tax_cash_basis_rec_id': partial.id,
|
||||
'tax_cash_basis_origin_move_id': move.id,
|
||||
'fiscal_position_id': move.fiscal_position_id.id,
|
||||
}
|
||||
|
||||
# Tracking of lines grouped all together.
|
||||
# Used to reduce the number of generated lines and to avoid rounding issues.
|
||||
partial_lines_to_create = {}
|
||||
|
||||
for caba_treatment, line in move_values['to_process_lines']:
|
||||
|
||||
# ==========================================================================
|
||||
# Compute the balance of the current line on the cash basis entry.
|
||||
# This balance is a percentage representing the part of the journal entry
|
||||
# that is actually paid by the current partial.
|
||||
# ==========================================================================
|
||||
|
||||
# Percentage expressed in the foreign currency.
|
||||
amount_currency = line.currency_id.round(line.amount_currency * partial_values['percentage'])
|
||||
balance = partial_values['payment_rate'] and amount_currency / partial_values['payment_rate'] or 0.0
|
||||
|
||||
# ==========================================================================
|
||||
# Prepare the mirror cash basis journal item of the current line.
|
||||
# Group them all together as much as possible to reduce the number of
|
||||
# generated journal items.
|
||||
# Also track the computed balance in order to avoid rounding issues when
|
||||
# the journal entry will be fully paid. At that case, we expect the exact
|
||||
# amount of each line has been covered by the cash basis journal entries
|
||||
# and well reported in the Tax Report.
|
||||
# ==========================================================================
|
||||
|
||||
if caba_treatment == 'tax':
|
||||
# Tax line.
|
||||
|
||||
cb_line_vals = self._prepare_cash_basis_tax_line_vals(line, balance, amount_currency)
|
||||
grouping_key = self._get_cash_basis_tax_line_grouping_key_from_vals(cb_line_vals)
|
||||
elif caba_treatment == 'base':
|
||||
# Base line.
|
||||
|
||||
cb_line_vals = self._prepare_cash_basis_base_line_vals(line, balance, amount_currency)
|
||||
grouping_key = self._get_cash_basis_base_line_grouping_key_from_vals(cb_line_vals)
|
||||
|
||||
if grouping_key in partial_lines_to_create:
|
||||
aggregated_vals = partial_lines_to_create[grouping_key]['vals']
|
||||
|
||||
debit = aggregated_vals['debit'] + cb_line_vals['debit']
|
||||
credit = aggregated_vals['credit'] + cb_line_vals['credit']
|
||||
balance = debit - credit
|
||||
|
||||
aggregated_vals.update({
|
||||
'debit': balance if balance > 0 else 0,
|
||||
'credit': -balance if balance < 0 else 0,
|
||||
'amount_currency': aggregated_vals['amount_currency'] + cb_line_vals['amount_currency'],
|
||||
})
|
||||
|
||||
if caba_treatment == 'tax':
|
||||
aggregated_vals.update({
|
||||
'tax_base_amount': aggregated_vals['tax_base_amount'] + cb_line_vals['tax_base_amount'],
|
||||
})
|
||||
partial_lines_to_create[grouping_key]['tax_line'] += line
|
||||
else:
|
||||
partial_lines_to_create[grouping_key] = {
|
||||
'vals': cb_line_vals,
|
||||
}
|
||||
if caba_treatment == 'tax':
|
||||
partial_lines_to_create[grouping_key].update({
|
||||
'tax_line': line,
|
||||
})
|
||||
|
||||
# ==========================================================================
|
||||
# Create the counterpart journal items.
|
||||
# ==========================================================================
|
||||
|
||||
# To be able to retrieve the correct matching between the tax lines to reconcile
|
||||
# later, the lines will be created using a specific sequence.
|
||||
sequence = 0
|
||||
|
||||
for grouping_key, aggregated_vals in partial_lines_to_create.items():
|
||||
line_vals = aggregated_vals['vals']
|
||||
line_vals['sequence'] = sequence
|
||||
|
||||
pending_cash_basis_lines.append((grouping_key, line_vals['amount_currency']))
|
||||
|
||||
if 'tax_repartition_line_id' in line_vals:
|
||||
# Tax line.
|
||||
|
||||
tax_line = aggregated_vals['tax_line']
|
||||
counterpart_line_vals = self._prepare_cash_basis_counterpart_tax_line_vals(tax_line, line_vals)
|
||||
counterpart_line_vals['sequence'] = sequence + 1
|
||||
|
||||
if tax_line.account_id.reconcile:
|
||||
move_index = len(moves_to_create)
|
||||
to_reconcile_after.append((tax_line, move_index, counterpart_line_vals['sequence']))
|
||||
|
||||
else:
|
||||
# Base line.
|
||||
|
||||
counterpart_line_vals = self._prepare_cash_basis_counterpart_base_line_vals(line_vals)
|
||||
counterpart_line_vals['sequence'] = sequence + 1
|
||||
|
||||
sequence += 2
|
||||
|
||||
move_vals['line_ids'] += [(0, 0, counterpart_line_vals), (0, 0, line_vals)]
|
||||
|
||||
moves_to_create.append(move_vals)
|
||||
|
||||
moves = self.env['account.move'].create(moves_to_create)
|
||||
moves._post(soft=False)
|
||||
|
||||
# Reconcile the tax lines being on a reconcile tax basis transfer account.
|
||||
for lines, move_index, sequence in to_reconcile_after:
|
||||
|
||||
# In expenses, all move lines are created manually without any grouping on tax lines.
|
||||
# In that case, 'lines' could be already reconciled.
|
||||
lines = lines.filtered(lambda x: not x.reconciled)
|
||||
if not lines:
|
||||
continue
|
||||
|
||||
counterpart_line = moves[move_index].line_ids.filtered(lambda line: line.sequence == sequence)
|
||||
|
||||
# When dealing with tiny amounts, the line could have a zero amount and then, be already reconciled.
|
||||
if counterpart_line.reconciled:
|
||||
continue
|
||||
|
||||
(lines + counterpart_line).reconcile()
|
||||
|
||||
return moves
|
||||
1059
odoo-bringout-oca-ocb-account/account/models/account_payment.py
Normal file
1059
odoo-bringout-oca-ocb-account/account/models/account_payment.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,170 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.osv import expression
|
||||
|
||||
|
||||
class AccountPaymentMethod(models.Model):
|
||||
_name = "account.payment.method"
|
||||
_description = "Payment Methods"
|
||||
|
||||
name = fields.Char(required=True, translate=True)
|
||||
code = fields.Char(required=True) # For internal identification
|
||||
payment_type = fields.Selection(selection=[('inbound', 'Inbound'), ('outbound', 'Outbound')], required=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('name_code_unique', 'unique (code, payment_type)', 'The combination code/payment type already exists!'),
|
||||
]
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
payment_methods = super().create(vals_list)
|
||||
methods_info = self._get_payment_method_information()
|
||||
for method in payment_methods:
|
||||
information = methods_info.get(method.code, {})
|
||||
|
||||
if information.get('mode') == 'multi':
|
||||
method_domain = method._get_payment_method_domain(method.code)
|
||||
|
||||
journals = self.env['account.journal'].search(method_domain)
|
||||
|
||||
self.env['account.payment.method.line'].create([{
|
||||
'name': method.name,
|
||||
'payment_method_id': method.id,
|
||||
'journal_id': journal.id
|
||||
} for journal in journals])
|
||||
return payment_methods
|
||||
|
||||
@api.model
|
||||
def _get_payment_method_domain(self, code):
|
||||
"""
|
||||
:return: The domain specyfying which journal can accomodate this payment method.
|
||||
"""
|
||||
if not code:
|
||||
return []
|
||||
information = self._get_payment_method_information().get(code)
|
||||
|
||||
currency_ids = information.get('currency_ids')
|
||||
country_id = information.get('country_id')
|
||||
default_domain = [('type', 'in', ('bank', 'cash'))]
|
||||
domains = [information.get('domain', default_domain)]
|
||||
|
||||
if currency_ids:
|
||||
domains += [expression.OR([
|
||||
[('currency_id', '=', False), ('company_id.currency_id', 'in', currency_ids)],
|
||||
[('currency_id', 'in', currency_ids)]],
|
||||
)]
|
||||
|
||||
if country_id:
|
||||
domains += [[('company_id.account_fiscal_country_id', '=', country_id)]]
|
||||
|
||||
return expression.AND(domains)
|
||||
|
||||
@api.model
|
||||
def _get_payment_method_information(self):
|
||||
"""
|
||||
Contains details about how to initialize a payment method with the code x.
|
||||
The contained info are:
|
||||
mode: Either unique if we only want one of them at a single time (payment providers for example)
|
||||
or multi if we want the method on each journal fitting the domain.
|
||||
domain: The domain defining the eligible journals.
|
||||
currency_id: The id of the currency necessary on the journal (or company) for it to be eligible.
|
||||
country_id: The id of the country needed on the company for it to be eligible.
|
||||
hidden: If set to true, the method will not be automatically added to the journal,
|
||||
and will not be selectable by the user.
|
||||
"""
|
||||
return {
|
||||
'manual': {'mode': 'multi', 'domain': [('type', 'in', ('bank', 'cash'))]},
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_sdd_payment_method_code(self):
|
||||
"""
|
||||
TO OVERRIDE
|
||||
This hook will be used to return the list of sdd payment method codes
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
class AccountPaymentMethodLine(models.Model):
|
||||
_name = "account.payment.method.line"
|
||||
_description = "Payment Methods"
|
||||
_order = 'sequence, id'
|
||||
|
||||
# == Business fields ==
|
||||
name = fields.Char(compute='_compute_name', readonly=False, store=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
payment_method_id = fields.Many2one(
|
||||
string='Payment Method',
|
||||
comodel_name='account.payment.method',
|
||||
domain="[('payment_type', '=?', payment_type), ('id', 'in', available_payment_method_ids)]",
|
||||
required=True,
|
||||
ondelete='cascade'
|
||||
)
|
||||
payment_account_id = fields.Many2one(
|
||||
comodel_name='account.account',
|
||||
check_company=True,
|
||||
copy=False,
|
||||
ondelete='restrict',
|
||||
domain="[('deprecated', '=', False), "
|
||||
"('company_id', '=', company_id), "
|
||||
"('account_type', 'not in', ('asset_receivable', 'liability_payable')), "
|
||||
"'|', ('account_type', 'in', ('asset_current', 'liability_current')), ('id', '=', parent.default_account_id)]"
|
||||
)
|
||||
journal_id = fields.Many2one(comodel_name='account.journal', ondelete="cascade")
|
||||
|
||||
# == Display purpose fields ==
|
||||
code = fields.Char(related='payment_method_id.code')
|
||||
payment_type = fields.Selection(related='payment_method_id.payment_type')
|
||||
company_id = fields.Many2one(related='journal_id.company_id')
|
||||
available_payment_method_ids = fields.Many2many(related='journal_id.available_payment_method_ids')
|
||||
|
||||
@api.depends('payment_method_id.name')
|
||||
def _compute_name(self):
|
||||
for method in self:
|
||||
if not method.name:
|
||||
method.name = method.payment_method_id.name
|
||||
|
||||
@api.constrains('name')
|
||||
def _ensure_unique_name_for_journal(self):
|
||||
self.journal_id._check_payment_method_line_ids_multiplicity()
|
||||
|
||||
def unlink(self):
|
||||
"""
|
||||
Payment method lines which are used in a payment should not be deleted from the database,
|
||||
only the link betweend them and the journals must be broken.
|
||||
"""
|
||||
unused_payment_method_lines = self
|
||||
for line in self:
|
||||
payment_count = self.env['account.payment'].sudo().search_count([('payment_method_line_id', '=', line.id)])
|
||||
if payment_count > 0:
|
||||
unused_payment_method_lines -= line
|
||||
|
||||
(self - unused_payment_method_lines).write({'journal_id': False})
|
||||
|
||||
return super(AccountPaymentMethodLine, unused_payment_method_lines).unlink()
|
||||
|
||||
@api.model
|
||||
def _auto_toggle_account_to_reconcile(self, account_id):
|
||||
""" Automatically toggle the account to reconcile if allowed.
|
||||
|
||||
:param account_id: The id of an account.account.
|
||||
"""
|
||||
account = self.env['account.account'].browse(account_id)
|
||||
if not account.reconcile and account.account_type not in ('asset_cash', 'liability_credit_card') and account.internal_group != 'off_balance':
|
||||
account.reconcile = True
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
# OVERRIDE
|
||||
for vals in vals_list:
|
||||
if vals.get('payment_account_id'):
|
||||
self._auto_toggle_account_to_reconcile(vals['payment_account_id'])
|
||||
return super().create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
# OVERRIDE
|
||||
if vals.get('payment_account_id'):
|
||||
self._auto_toggle_account_to_reconcile(vals['payment_account_id'])
|
||||
return super().write(vals)
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _, Command
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.tools import format_date, formatLang, frozendict
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
|
||||
class AccountPaymentTerm(models.Model):
|
||||
_name = "account.payment.term"
|
||||
_description = "Payment Terms"
|
||||
_order = "sequence, id"
|
||||
|
||||
def _default_line_ids(self):
|
||||
return [Command.create({'value': 'balance', 'value_amount': 0.0, 'days': 0, 'end_month': False})]
|
||||
|
||||
def _default_example_amount(self):
|
||||
return self._context.get('example_amount') or 100 # Force default value if the context is set to False
|
||||
|
||||
def _default_example_date(self):
|
||||
return self._context.get('example_date') or fields.Date.today()
|
||||
|
||||
name = fields.Char(string='Payment Terms', translate=True, required=True)
|
||||
active = fields.Boolean(default=True, help="If the active field is set to False, it will allow you to hide the payment terms without removing it.")
|
||||
note = fields.Html(string='Description on the Invoice', translate=True)
|
||||
line_ids = fields.One2many('account.payment.term.line', 'payment_id', string='Terms', copy=True, default=_default_line_ids)
|
||||
company_id = fields.Many2one('res.company', string='Company')
|
||||
sequence = fields.Integer(required=True, default=10)
|
||||
display_on_invoice = fields.Boolean(string='Display terms on invoice', help="If set, the payment deadlines and respective due amounts will be detailed on invoices.")
|
||||
example_amount = fields.Float(default=_default_example_amount, store=False)
|
||||
example_date = fields.Date(string='Date example', default=_default_example_date, store=False)
|
||||
example_invalid = fields.Boolean(compute='_compute_example_invalid')
|
||||
example_preview = fields.Html(compute='_compute_example_preview')
|
||||
|
||||
@api.depends('line_ids')
|
||||
def _compute_example_invalid(self):
|
||||
for payment_term in self:
|
||||
payment_term.example_invalid = len(payment_term.line_ids.filtered(lambda l: l.value == 'balance')) != 1
|
||||
|
||||
@api.depends('example_amount', 'example_date', 'line_ids.value', 'line_ids.value_amount',
|
||||
'line_ids.months', 'line_ids.days', 'line_ids.end_month', 'line_ids.days_after')
|
||||
def _compute_example_preview(self):
|
||||
for record in self:
|
||||
example_preview = ""
|
||||
if not record.example_invalid:
|
||||
currency = self.env.company.currency_id
|
||||
terms = record._compute_terms(
|
||||
date_ref=record.example_date,
|
||||
currency=currency,
|
||||
company=self.env.company,
|
||||
tax_amount=0,
|
||||
tax_amount_currency=0,
|
||||
untaxed_amount=record.example_amount,
|
||||
untaxed_amount_currency=record.example_amount,
|
||||
sign=1)
|
||||
for i, info_by_dates in enumerate(record._get_amount_by_date(terms, currency).values()):
|
||||
date = info_by_dates['date']
|
||||
discount_date = info_by_dates['discount_date']
|
||||
amount = info_by_dates['amount']
|
||||
discount_amount = info_by_dates['discounted_amount'] or 0.0
|
||||
example_preview += "<div style='margin-left: 20px;'>"
|
||||
example_preview += _(
|
||||
"<b>%(count)s#</b> Installment of <b>%(amount)s</b> on <b style='color: #704A66;'>%(date)s</b>",
|
||||
count=i+1,
|
||||
amount=formatLang(self.env, amount, monetary=True, currency_obj=currency),
|
||||
date=date,
|
||||
)
|
||||
if discount_date:
|
||||
example_preview += _(
|
||||
" (<b>%(amount)s</b> if paid before <b>%(date)s</b>)",
|
||||
amount=formatLang(self.env, discount_amount, monetary=True, currency_obj=currency),
|
||||
date=format_date(self.env, terms[i].get('discount_date')),
|
||||
)
|
||||
example_preview += "</div>"
|
||||
|
||||
record.example_preview = example_preview
|
||||
|
||||
@api.model
|
||||
def _get_amount_by_date(self, terms, currency):
|
||||
"""
|
||||
Returns a dictionary with the amount for each date of the payment term
|
||||
(grouped by date, discounted percentage and discount last date,
|
||||
sorted by date and ignoring null amounts).
|
||||
"""
|
||||
terms = sorted(terms, key=lambda t: t.get('date'))
|
||||
amount_by_date = {}
|
||||
for term in terms:
|
||||
key = frozendict({
|
||||
'date': term['date'],
|
||||
'discount_date': term['discount_date'],
|
||||
'discount_percentage': term['discount_percentage'],
|
||||
})
|
||||
results = amount_by_date.setdefault(key, {
|
||||
'date': format_date(self.env, term['date']),
|
||||
'amount': 0.0,
|
||||
'discounted_amount': 0.0,
|
||||
'discount_date': format_date(self.env, term['discount_date']),
|
||||
})
|
||||
results['amount'] += term['foreign_amount']
|
||||
results['discounted_amount'] += term['discount_amount_currency']
|
||||
return amount_by_date
|
||||
|
||||
@api.constrains('line_ids')
|
||||
def _check_lines(self):
|
||||
for terms in self:
|
||||
if len(terms.line_ids.filtered(lambda r: r.value == 'balance')) != 1:
|
||||
raise ValidationError(_('The Payment Term must have one Balance line.'))
|
||||
if terms.line_ids.filtered(lambda r: r.value == 'fixed' and r.discount_percentage):
|
||||
raise ValidationError(_("You can't mix fixed amount with early payment percentage"))
|
||||
|
||||
def _compute_terms(self, date_ref, currency, company, tax_amount, tax_amount_currency, sign, untaxed_amount, untaxed_amount_currency, cash_rounding=None):
|
||||
"""Get the distribution of this payment term.
|
||||
:param date_ref: The move date to take into account
|
||||
:param currency: the move's currency
|
||||
:param company: the company issuing the move
|
||||
:param tax_amount: the signed tax amount for the move
|
||||
:param tax_amount_currency: the signed tax amount for the move in the move's currency
|
||||
:param untaxed_amount: the signed untaxed amount for the move
|
||||
:param untaxed_amount_currency: the signed untaxed amount for the move in the move's currency
|
||||
:param sign: the sign of the move
|
||||
:param cash_rounding: the cash rounding that should be applied (or None).
|
||||
We assume that the input total in move currency (tax_amount_currency + untaxed_amount_currency) is already cash rounded.
|
||||
The cash rounding does not change the totals: Consider the sum of all the computed payment term amounts in move / company currency.
|
||||
It is the same as the input total in move / company currency.
|
||||
:return (list<tuple<datetime.date,tuple<float,float>>>): the amount in the company's currency and
|
||||
the document's currency, respectively for each required payment date
|
||||
"""
|
||||
self.ensure_one()
|
||||
company_currency = company.currency_id
|
||||
tax_amount_left = tax_amount
|
||||
tax_amount_currency_left = tax_amount_currency
|
||||
untaxed_amount_left = untaxed_amount
|
||||
untaxed_amount_currency_left = untaxed_amount_currency
|
||||
total_amount = tax_amount + untaxed_amount
|
||||
total_amount_currency = tax_amount_currency + untaxed_amount_currency
|
||||
foreign_rounding_amount = 0
|
||||
company_rounding_amount = 0
|
||||
result = []
|
||||
|
||||
for line in self.line_ids.sorted(lambda line: line.value == 'balance'):
|
||||
term_vals = {
|
||||
'date': line._get_due_date(date_ref),
|
||||
'has_discount': line.discount_percentage,
|
||||
'discount_date': None,
|
||||
'discount_amount_currency': 0.0,
|
||||
'discount_balance': 0.0,
|
||||
'discount_percentage': line.discount_percentage,
|
||||
}
|
||||
|
||||
if line.value == 'fixed':
|
||||
term_vals['company_amount'] = sign * company_currency.round(line.value_amount)
|
||||
term_vals['foreign_amount'] = sign * currency.round(line.value_amount)
|
||||
company_proportion = tax_amount/untaxed_amount if untaxed_amount else 1
|
||||
foreign_proportion = tax_amount_currency/untaxed_amount_currency if untaxed_amount_currency else 1
|
||||
line_tax_amount = company_currency.round(line.value_amount * company_proportion) * sign
|
||||
line_tax_amount_currency = currency.round(line.value_amount * foreign_proportion) * sign
|
||||
line_untaxed_amount = term_vals['company_amount'] - line_tax_amount
|
||||
line_untaxed_amount_currency = term_vals['foreign_amount'] - line_tax_amount_currency
|
||||
elif line.value == 'percent':
|
||||
term_vals['company_amount'] = company_currency.round(total_amount * (line.value_amount / 100.0))
|
||||
term_vals['foreign_amount'] = currency.round(total_amount_currency * (line.value_amount / 100.0))
|
||||
line_tax_amount = company_currency.round(tax_amount * (line.value_amount / 100.0))
|
||||
line_tax_amount_currency = currency.round(tax_amount_currency * (line.value_amount / 100.0))
|
||||
line_untaxed_amount = term_vals['company_amount'] - line_tax_amount
|
||||
line_untaxed_amount_currency = term_vals['foreign_amount'] - line_tax_amount_currency
|
||||
else:
|
||||
line_tax_amount = line_tax_amount_currency = line_untaxed_amount = line_untaxed_amount_currency = 0.0
|
||||
|
||||
# The following values do not account for any potential cash rounding
|
||||
tax_amount_left -= line_tax_amount
|
||||
tax_amount_currency_left -= line_tax_amount_currency
|
||||
untaxed_amount_left -= line_untaxed_amount
|
||||
untaxed_amount_currency_left -= line_untaxed_amount_currency
|
||||
|
||||
if cash_rounding and line.value in ['fixed', 'percent']:
|
||||
cash_rounding_difference_currency = cash_rounding.compute_difference(currency, term_vals['foreign_amount'])
|
||||
if not currency.is_zero(cash_rounding_difference_currency):
|
||||
rate = abs(term_vals['foreign_amount'] / term_vals['company_amount']) if term_vals['company_amount'] else 1.0
|
||||
|
||||
foreign_rounding_amount += cash_rounding_difference_currency
|
||||
term_vals['foreign_amount'] += cash_rounding_difference_currency
|
||||
|
||||
company_amount = company_currency.round(term_vals['foreign_amount'] / rate)
|
||||
cash_rounding_difference = company_amount - term_vals['company_amount']
|
||||
if not currency.is_zero(cash_rounding_difference):
|
||||
company_rounding_amount += cash_rounding_difference
|
||||
term_vals['company_amount'] = company_amount
|
||||
|
||||
if line.value == 'balance':
|
||||
# The `*_amount_left` variables do not account for cash rounding.
|
||||
# Here we remove the total amount added by the cash rounding from the amount left.
|
||||
# This way the totals in company and move currency remain unchanged (compared to the input).
|
||||
# We assume the foreign total (`tax_amount_currency + untaxed_amount_currency`) is cash rounded.
|
||||
# The following right side is the same as subtracting all the (cash rounded) foreign payment term amounts from the foreign total.
|
||||
# Thus it is the remaining foreign amount and also cash rounded.
|
||||
term_vals['foreign_amount'] = tax_amount_currency_left + untaxed_amount_currency_left - foreign_rounding_amount
|
||||
term_vals['company_amount'] = tax_amount_left + untaxed_amount_left - company_rounding_amount
|
||||
|
||||
line_tax_amount = tax_amount_left
|
||||
line_tax_amount_currency = tax_amount_currency_left
|
||||
line_untaxed_amount = untaxed_amount_left
|
||||
line_untaxed_amount_currency = untaxed_amount_currency_left
|
||||
|
||||
if line.discount_percentage:
|
||||
if company.early_pay_discount_computation in ('excluded', 'mixed'):
|
||||
term_vals['discount_balance'] = company_currency.round(term_vals['company_amount'] - line_untaxed_amount * line.discount_percentage / 100.0)
|
||||
term_vals['discount_amount_currency'] = currency.round(term_vals['foreign_amount'] - line_untaxed_amount_currency * line.discount_percentage / 100.0)
|
||||
else:
|
||||
term_vals['discount_balance'] = company_currency.round(term_vals['company_amount'] * (1 - (line.discount_percentage / 100.0)))
|
||||
term_vals['discount_amount_currency'] = currency.round(term_vals['foreign_amount'] * (1 - (line.discount_percentage / 100.0)))
|
||||
term_vals['discount_date'] = date_ref + relativedelta(days=line.discount_days)
|
||||
|
||||
if cash_rounding and line.discount_percentage:
|
||||
cash_rounding_difference_currency = cash_rounding.compute_difference(currency, term_vals['discount_amount_currency'])
|
||||
if not currency.is_zero(cash_rounding_difference_currency):
|
||||
rate = abs(term_vals['discount_amount_currency'] / term_vals['discount_balance']) if term_vals['discount_balance'] else 1.0
|
||||
term_vals['discount_amount_currency'] += cash_rounding_difference_currency
|
||||
term_vals['discount_balance'] = company_currency.round(term_vals['discount_amount_currency'] / rate)
|
||||
|
||||
result.append(term_vals)
|
||||
return result
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_referenced_terms(self):
|
||||
if self.env['account.move'].search([('invoice_payment_term_id', 'in', self.ids)]):
|
||||
raise UserError(_('You can not delete payment terms as other records still reference it. However, you can archive it.'))
|
||||
|
||||
def unlink(self):
|
||||
for terms in self:
|
||||
self.env['ir.property'].sudo().search(
|
||||
[('value_reference', 'in', ['account.payment.term,%s'%payment_term.id for payment_term in terms])]
|
||||
).unlink()
|
||||
return super(AccountPaymentTerm, self).unlink()
|
||||
|
||||
def copy(self, default=None):
|
||||
default = dict(default or {})
|
||||
default['name'] = _('%s (copy)', self.name)
|
||||
return super().copy(default)
|
||||
|
||||
|
||||
class AccountPaymentTermLine(models.Model):
|
||||
_name = "account.payment.term.line"
|
||||
_description = "Payment Terms Line"
|
||||
_order = "id"
|
||||
|
||||
value = fields.Selection([
|
||||
('balance', 'Balance'),
|
||||
('percent', 'Percent'),
|
||||
('fixed', 'Fixed Amount')
|
||||
], string='Type', required=True, default='percent',
|
||||
help="Select here the kind of valuation related to this payment terms line.")
|
||||
value_amount = fields.Float(string='Value', digits='Payment Terms', help="For percent enter a ratio between 0-100.")
|
||||
months = fields.Integer(string='Months', required=True, default=0)
|
||||
days = fields.Integer(string='Days', required=True, default=0)
|
||||
end_month = fields.Boolean(string='End of month', help="Switch to end of the month after having added months or days")
|
||||
days_after = fields.Integer(string='Days after End of month', help="Days to add after the end of the month")
|
||||
discount_percentage = fields.Float(string='Discount %', help='Early Payment Discount granted for this line')
|
||||
discount_days = fields.Integer(string='Discount Days', help='Number of days before the early payment proposition expires')
|
||||
payment_id = fields.Many2one('account.payment.term', string='Payment Terms', required=True, index=True, ondelete='cascade')
|
||||
|
||||
def _get_due_date(self, date_ref):
|
||||
self.ensure_one()
|
||||
due_date = fields.Date.from_string(date_ref) or fields.Date.today()
|
||||
due_date += relativedelta(months=self.months)
|
||||
due_date += relativedelta(days=self.days)
|
||||
if self.end_month:
|
||||
due_date += relativedelta(day=31)
|
||||
due_date += relativedelta(days=self.days_after)
|
||||
return due_date
|
||||
|
||||
@api.constrains('value', 'value_amount', 'discount_percentage')
|
||||
def _check_percent(self):
|
||||
for term_line in self:
|
||||
if term_line.value == 'percent' and (term_line.value_amount < 0.0 or term_line.value_amount > 100.0):
|
||||
raise ValidationError(_('Percentages on the Payment Terms lines must be between 0 and 100.'))
|
||||
if term_line.discount_percentage and (term_line.discount_percentage < 0.0 or term_line.discount_percentage > 100.0):
|
||||
raise ValidationError(_('Discount percentages on the Payment Terms lines must be between 0 and 100.'))
|
||||
|
||||
@api.constrains('discount_days')
|
||||
def _check_positive(self):
|
||||
for term_line in self:
|
||||
if term_line.discount_days < 0:
|
||||
raise ValidationError(_('The discount days of the Payment Terms lines must be positive.'))
|
||||
File diff suppressed because it is too large
Load diff
785
odoo-bringout-oca-ocb-account/account/models/account_report.py
Normal file
785
odoo-bringout-oca-ocb-account/account/models/account_report.py
Normal file
|
|
@ -0,0 +1,785 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import ast
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import models, fields, api, _, osv, Command
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
|
||||
FIGURE_TYPE_SELECTION_VALUES = [
|
||||
('monetary', "Monetary"),
|
||||
('percentage', "Percentage"),
|
||||
('integer', "Integer"),
|
||||
('float', "Float"),
|
||||
('date', "Date"),
|
||||
('datetime', "Datetime"),
|
||||
('none', "No Formatting"),
|
||||
]
|
||||
|
||||
DOMAIN_REGEX = re.compile(r'(-?sum)\((.*)\)')
|
||||
|
||||
class AccountReport(models.Model):
|
||||
_name = "account.report"
|
||||
_description = "Accounting Report"
|
||||
|
||||
# CORE ==========================================================================================================================================
|
||||
|
||||
name = fields.Char(string="Name", required=True, translate=True)
|
||||
line_ids = fields.One2many(string="Lines", comodel_name='account.report.line', inverse_name='report_id')
|
||||
column_ids = fields.One2many(string="Columns", comodel_name='account.report.column', inverse_name='report_id')
|
||||
root_report_id = fields.Many2one(string="Root Report", comodel_name='account.report', help="The report this report is a variant of.")
|
||||
variant_report_ids = fields.One2many(string="Variants", comodel_name='account.report', inverse_name='root_report_id')
|
||||
chart_template_id = fields.Many2one(string="Chart of Accounts", comodel_name='account.chart.template')
|
||||
country_id = fields.Many2one(string="Country", comodel_name='res.country')
|
||||
only_tax_exigible = fields.Boolean(
|
||||
string="Only Tax Exigible Lines",
|
||||
compute=lambda x: x._compute_report_option_filter('only_tax_exigible'), readonly=False, store=True, depends=['root_report_id'],
|
||||
)
|
||||
availability_condition = fields.Selection(
|
||||
string="Availability",
|
||||
selection=[('country', "Country Matches"), ('coa', "Chart of Accounts Matches"), ('always', "Always")],
|
||||
compute='_compute_default_availability_condition', readonly=False, store=True,
|
||||
)
|
||||
load_more_limit = fields.Integer(string="Load More Limit")
|
||||
search_bar = fields.Boolean(string="Search Bar")
|
||||
|
||||
default_opening_date_filter = fields.Selection(
|
||||
string="Default Opening",
|
||||
selection=[
|
||||
('this_year', "This Year"),
|
||||
('this_quarter', "This Quarter"),
|
||||
('this_month', "This Month"),
|
||||
('today', "Today"),
|
||||
('last_month', "Last Month"),
|
||||
('last_quarter', "Last Quarter"),
|
||||
('last_year', "Last Year"),
|
||||
],
|
||||
compute=lambda x: x._compute_report_option_filter('default_opening_date_filter', 'last_month'),
|
||||
readonly=False, store=True, depends=['root_report_id'],
|
||||
)
|
||||
|
||||
# FILTERS =======================================================================================================================================
|
||||
# Those fields control the display of menus on the report
|
||||
|
||||
filter_multi_company = fields.Selection(
|
||||
string="Multi-Company",
|
||||
selection=[('disabled', "Disabled"), ('selector', "Use Company Selector"), ('tax_units', "Use Tax Units")],
|
||||
compute=lambda x: x._compute_report_option_filter('filter_multi_company', 'disabled'), readonly=False, store=True, depends=['root_report_id'],
|
||||
)
|
||||
filter_date_range = fields.Boolean(
|
||||
string="Date Range",
|
||||
compute=lambda x: x._compute_report_option_filter('filter_date_range', True), readonly=False, store=True, depends=['root_report_id'],
|
||||
)
|
||||
filter_show_draft = fields.Boolean(
|
||||
string="Draft Entries",
|
||||
compute=lambda x: x._compute_report_option_filter('filter_show_draft', True), readonly=False, store=True, depends=['root_report_id'],
|
||||
)
|
||||
filter_unreconciled = fields.Boolean(
|
||||
string="Unreconciled Entries",
|
||||
compute=lambda x: x._compute_report_option_filter('filter_unreconciled', False), readonly=False, store=True, depends=['root_report_id'],
|
||||
)
|
||||
filter_unfold_all = fields.Boolean(
|
||||
string="Unfold All",
|
||||
compute=lambda x: x._compute_report_option_filter('filter_unfold_all'), readonly=False, store=True, depends=['root_report_id'],
|
||||
)
|
||||
filter_period_comparison = fields.Boolean(
|
||||
string="Period Comparison",
|
||||
compute=lambda x: x._compute_report_option_filter('filter_period_comparison', True), readonly=False, store=True, depends=['root_report_id'],
|
||||
)
|
||||
filter_growth_comparison = fields.Boolean(
|
||||
string="Growth Comparison",
|
||||
compute=lambda x: x._compute_report_option_filter('filter_growth_comparison', True), readonly=False, store=True, depends=['root_report_id'],
|
||||
)
|
||||
filter_journals = fields.Boolean(
|
||||
string="Journals",
|
||||
compute=lambda x: x._compute_report_option_filter('filter_journals'), readonly=False, store=True, depends=['root_report_id'],
|
||||
)
|
||||
filter_analytic = fields.Boolean(
|
||||
string="Analytic Filter",
|
||||
compute=lambda x: x._compute_report_option_filter('filter_analytic'), readonly=False, store=True, depends=['root_report_id'],
|
||||
)
|
||||
filter_hierarchy = fields.Selection(
|
||||
string="Account Groups",
|
||||
selection=[('by_default', "Enabled by Default"), ('optional', "Optional"), ('never', "Never")],
|
||||
compute=lambda x: x._compute_report_option_filter('filter_hierarchy', 'optional'), readonly=False, store=True, depends=['root_report_id'],
|
||||
)
|
||||
filter_account_type = fields.Boolean(
|
||||
string="Account Types",
|
||||
compute=lambda x: x._compute_report_option_filter('filter_account_type'), readonly=False, store=True, depends=['root_report_id'],
|
||||
)
|
||||
filter_partner = fields.Boolean(
|
||||
string="Partners",
|
||||
compute=lambda x: x._compute_report_option_filter('filter_partner'), readonly=False, store=True, depends=['root_report_id'],
|
||||
)
|
||||
filter_fiscal_position = fields.Boolean(
|
||||
string="Filter Multivat",
|
||||
compute=lambda x: x._compute_report_option_filter('filter_fiscal_position'), readonly=False, store=True, depends=['root_report_id'],
|
||||
)
|
||||
|
||||
def _compute_report_option_filter(self, field_name, default_value=False):
|
||||
# We don't depend on the different filter fields on the root report, as we don't want a manual change on it to be reflected on all the reports
|
||||
# using it as their root (would create confusion). The root report filters are only used as some kind of default values.
|
||||
for report in self:
|
||||
if report.root_report_id:
|
||||
report[field_name] = report.root_report_id[field_name]
|
||||
else:
|
||||
report[field_name] = default_value
|
||||
|
||||
@api.depends('root_report_id', 'country_id')
|
||||
def _compute_default_availability_condition(self):
|
||||
for report in self:
|
||||
if report.root_report_id and report.country_id:
|
||||
report.availability_condition = 'country'
|
||||
elif not report.availability_condition:
|
||||
report.availability_condition = 'always'
|
||||
|
||||
@api.constrains('root_report_id')
|
||||
def _validate_root_report_id(self):
|
||||
for report in self:
|
||||
if report.root_report_id.root_report_id:
|
||||
raise ValidationError(_("Only a report without a root report of its own can be selected as root report."))
|
||||
|
||||
@api.constrains('line_ids')
|
||||
def _validate_parent_sequence(self):
|
||||
previous_lines = self.env['account.report.line']
|
||||
for line in self.line_ids:
|
||||
if line.parent_id and line.parent_id not in previous_lines:
|
||||
raise ValidationError(
|
||||
_('Line "%s" defines line "%s" as its parent, but appears before it in the report. '
|
||||
'The parent must always come first.', line.name, line.parent_id.name))
|
||||
previous_lines |= line
|
||||
|
||||
@api.constrains('availability_condition', 'country_id')
|
||||
def _validate_availability_condition(self):
|
||||
for record in self:
|
||||
if record.availability_condition == 'country' and not record.country_id:
|
||||
raise ValidationError(_("The Availability is set to 'Country Matches' but the field Country is not set."))
|
||||
|
||||
@api.onchange('availability_condition')
|
||||
def _onchange_availability_condition(self):
|
||||
if self.availability_condition != 'country':
|
||||
self.country_id = None
|
||||
|
||||
def write(self, vals):
|
||||
# Overridden so that changing the country of a report also creates new tax tags if necessary, or updates the country
|
||||
# of existing tags, if they aren't shared with another report.
|
||||
if 'country_id' in vals:
|
||||
impacted_reports = self.filtered(lambda x: x.country_id.id != vals['country_id'])
|
||||
tax_tags_expressions = impacted_reports.line_ids.expression_ids.filtered(lambda x: x.engine == 'tax_tags')
|
||||
|
||||
for expression in tax_tags_expressions:
|
||||
tax_tags = self.env['account.account.tag']._get_tax_tags(expression.formula, expression.report_line_id.report_id.country_id.id)
|
||||
tag_reports = tax_tags._get_related_tax_report_expressions().report_line_id.report_id
|
||||
|
||||
if all(report in self for report in tag_reports):
|
||||
# Only reports in self are using these tags; let's change their country
|
||||
tax_tags.write({'country_id': vals['country_id']})
|
||||
else:
|
||||
# Another report uses these tags as well; let's keep them and create new tags in the target country
|
||||
# if they don't exist yet.
|
||||
existing_tax_tags = self.env['account.account.tag']._get_tax_tags(expression.formula, vals['country_id'])
|
||||
if not existing_tax_tags:
|
||||
tag_vals = self.env['account.report.expression']._get_tags_create_vals(expression.formula, vals['country_id'])
|
||||
self.env['account.account.tag'].create(tag_vals)
|
||||
|
||||
return super().write(vals)
|
||||
|
||||
def copy(self, default=None):
|
||||
'''Copy the whole financial report hierarchy by duplicating each line recursively.
|
||||
|
||||
:param default: Default values.
|
||||
:return: The copied account.report record.
|
||||
'''
|
||||
self.ensure_one()
|
||||
if default is None:
|
||||
default = {}
|
||||
default['name'] = self._get_copied_name()
|
||||
copied_report = super().copy(default=default)
|
||||
code_mapping = {}
|
||||
for line in self.line_ids.filtered(lambda x: not x.parent_id):
|
||||
line._copy_hierarchy(copied_report, code_mapping=code_mapping)
|
||||
|
||||
# Replace line codes by their copy in aggregation formulas
|
||||
for expression in copied_report.line_ids.expression_ids:
|
||||
if expression.engine == 'aggregation':
|
||||
copied_formula = f" {expression.formula} " # Add spaces so that the lookahead/lookbehind of the regex can work (we can't do a | in those)
|
||||
for old_code, new_code in code_mapping.items():
|
||||
copied_formula = re.sub(f"(?<=\\W){old_code}(?=\\W)", new_code, copied_formula)
|
||||
expression.formula = copied_formula.strip() # Remove the spaces introduced for lookahead/lookbehind
|
||||
|
||||
for column in self.column_ids:
|
||||
column.copy({'report_id': copied_report.id})
|
||||
|
||||
return copied_report
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_if_no_variant(self):
|
||||
if self.variant_report_ids:
|
||||
raise UserError(_("You can't delete a report that has variants."))
|
||||
|
||||
def _get_copied_name(self):
|
||||
'''Return a copied name of the account.report record by adding the suffix (copy) at the end
|
||||
until the name is unique.
|
||||
|
||||
:return: an unique name for the copied account.report
|
||||
'''
|
||||
self.ensure_one()
|
||||
name = self.name + ' ' + _('(copy)')
|
||||
while self.search_count([('name', '=', name)]) > 0:
|
||||
name += ' ' + _('(copy)')
|
||||
return name
|
||||
|
||||
@api.depends('name', 'country_id')
|
||||
def name_get(self):
|
||||
result = []
|
||||
for report in self:
|
||||
result.append((report.id, report.name + (f' ({report.country_id.code})' if report.country_id else '')))
|
||||
return result
|
||||
|
||||
|
||||
class AccountReportLine(models.Model):
|
||||
_name = "account.report.line"
|
||||
_description = "Accounting Report Line"
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string="Name", translate=True, required=True)
|
||||
expression_ids = fields.One2many(string="Expressions", comodel_name='account.report.expression', inverse_name='report_line_id')
|
||||
report_id = fields.Many2one(
|
||||
string="Parent Report",
|
||||
comodel_name='account.report',
|
||||
compute='_compute_report_id',
|
||||
store=True,
|
||||
readonly=False,
|
||||
required=True,
|
||||
recursive=True,
|
||||
precompute=True,
|
||||
ondelete='cascade'
|
||||
)
|
||||
hierarchy_level = fields.Integer(
|
||||
string="Level",
|
||||
compute='_compute_hierarchy_level',
|
||||
store=True,
|
||||
readonly=False,
|
||||
recursive=True,
|
||||
required=True,
|
||||
precompute=True,
|
||||
)
|
||||
parent_id = fields.Many2one(string="Parent Line", comodel_name='account.report.line', ondelete='set null')
|
||||
children_ids = fields.One2many(string="Child Lines", comodel_name='account.report.line', inverse_name='parent_id')
|
||||
groupby = fields.Char(string="Group By", help="Comma-separated list of fields from account.move.line (Journal Item). When set, this line will generate sublines grouped by those keys.")
|
||||
sequence = fields.Integer(string="Sequence")
|
||||
code = fields.Char(string="Code", help="Unique identifier for this line.")
|
||||
foldable = fields.Boolean(string="Foldable", help="By default, we always unfold the lines that can be. If this is checked, the line won't be unfolded by default, and a folding button will be displayed.")
|
||||
print_on_new_page = fields.Boolean('Print On New Page', help='When checked this line and everything after it will be printed on a new page.')
|
||||
action_id = fields.Many2one(string="Action", comodel_name='ir.actions.actions', help="Setting this field will turn the line into a link, executing the action when clicked.")
|
||||
hide_if_zero = fields.Boolean(string="Hide if Zero", help="This line and its children will be hidden when all of their columns are 0.")
|
||||
domain_formula = fields.Char(string="Domain Formula Shortcut", help="Internal field to shorten expression_ids creation for the domain engine", inverse='_inverse_domain_formula', store=False)
|
||||
account_codes_formula = fields.Char(string="Account Codes Formula Shortcut", help="Internal field to shorten expression_ids creation for the account_codes engine", inverse='_inverse_account_codes_formula', store=False)
|
||||
aggregation_formula = fields.Char(string="Aggregation Formula Shortcut", help="Internal field to shorten expression_ids creation for the aggregation engine", inverse='_inverse_aggregation_formula', store=False)
|
||||
tax_tags_formula = fields.Char(string="Tax Tags Formula Shortcut", help="Internal field to shorten expression_ids creation for the tax_tags engine", inverse='_inverse_aggregation_tax_formula', store=False)
|
||||
|
||||
_sql_constraints = [
|
||||
('code_uniq', 'unique (code)', "A report line with the same code already exists."),
|
||||
]
|
||||
|
||||
@api.depends('parent_id.hierarchy_level')
|
||||
def _compute_hierarchy_level(self):
|
||||
for report_line in self:
|
||||
if report_line.parent_id:
|
||||
report_line.hierarchy_level = report_line.parent_id.hierarchy_level + 2
|
||||
else:
|
||||
report_line.hierarchy_level = 1
|
||||
|
||||
@api.depends('parent_id.report_id')
|
||||
def _compute_report_id(self):
|
||||
for report_line in self:
|
||||
if report_line.parent_id:
|
||||
report_line.report_id = report_line.parent_id.report_id
|
||||
|
||||
@api.constrains('parent_id')
|
||||
def _validate_groupby_no_child(self):
|
||||
for report_line in self:
|
||||
if report_line.parent_id.groupby:
|
||||
raise ValidationError(_("A line cannot have both children and a groupby value (line '%s').", report_line.parent_id.name))
|
||||
|
||||
@api.constrains('expression_ids', 'groupby')
|
||||
def _validate_formula(self):
|
||||
for expression in self.expression_ids:
|
||||
if expression.engine == 'aggregation' and expression.report_line_id.groupby:
|
||||
raise ValidationError(_(
|
||||
"Groupby feature isn't supported by aggregation engine. Please remove the groupby value on '%s'",
|
||||
expression.report_line_id.display_name,
|
||||
))
|
||||
|
||||
@api.constrains('parent_id')
|
||||
def _check_parent_line(self):
|
||||
for line in self.filtered(lambda x: x.parent_id == x):
|
||||
raise ValidationError(_('Line "%s" defines itself as its parent.', line.name))
|
||||
|
||||
def _copy_hierarchy(self, copied_report, parent=None, code_mapping=None):
|
||||
''' Copy the whole hierarchy from this line by copying each line children recursively and adapting the
|
||||
formulas with the new copied codes.
|
||||
|
||||
:param copied_report: The copy of the report.
|
||||
:param parent: The parent line in the hierarchy (a copy of the original parent line).
|
||||
:param code_mapping: A dictionary keeping track of mapping old_code -> new_code
|
||||
'''
|
||||
self.ensure_one()
|
||||
|
||||
copied_line = self.copy({
|
||||
'report_id': copied_report.id,
|
||||
'parent_id': parent and parent.id,
|
||||
'code': self._get_copied_code(),
|
||||
})
|
||||
|
||||
# Keep track of old_code -> new_code in a mutable dict
|
||||
if code_mapping is None:
|
||||
code_mapping = {}
|
||||
if self.code:
|
||||
code_mapping[self.code] = copied_line.code
|
||||
|
||||
# Copy children
|
||||
for line in self.children_ids:
|
||||
line._copy_hierarchy(copied_report, parent=copied_line, code_mapping=code_mapping)
|
||||
|
||||
# Update aggregation expressions, so that they use the copied lines
|
||||
for expression in self.expression_ids:
|
||||
copy_defaults = {'report_line_id': copied_line.id}
|
||||
expression.copy(copy_defaults)
|
||||
|
||||
def _get_copied_code(self):
|
||||
'''Look for an unique copied code.
|
||||
|
||||
:return: an unique code for the copied account.report.line
|
||||
'''
|
||||
self.ensure_one()
|
||||
if not self.code:
|
||||
return False
|
||||
code = self.code + '_COPY'
|
||||
while self.search_count([('code', '=', code)]) > 0:
|
||||
code += '_COPY'
|
||||
return code
|
||||
|
||||
def _inverse_domain_formula(self):
|
||||
self._create_report_expression(engine='domain')
|
||||
|
||||
def _inverse_aggregation_formula(self):
|
||||
self._create_report_expression(engine='aggregation')
|
||||
|
||||
def _inverse_aggregation_tax_formula(self):
|
||||
self._create_report_expression(engine='tax_tags')
|
||||
|
||||
def _inverse_account_codes_formula(self):
|
||||
self._create_report_expression(engine='account_codes')
|
||||
|
||||
def _create_report_expression(self, engine):
|
||||
# create account.report.expression for each report line based on the formula provided to each
|
||||
# engine-related field. This makes xmls a bit shorter
|
||||
vals_list = []
|
||||
xml_ids = self.expression_ids.filtered(lambda exp: exp.label == 'balance').get_external_id()
|
||||
for report_line in self:
|
||||
if engine == 'domain' and report_line.domain_formula:
|
||||
subformula, formula = DOMAIN_REGEX.match(report_line.domain_formula or '').groups()
|
||||
# Resolve the calls to ref(), to mimic the fact those formulas are normally given with an eval="..." in XML
|
||||
formula = re.sub(r'''\bref\((?P<quote>['"])(?P<xmlid>.+?)(?P=quote)\)''', lambda m: str(self.env.ref(m['xmlid']).id), formula)
|
||||
elif engine == 'account_codes' and report_line.account_codes_formula:
|
||||
subformula, formula = None, report_line.account_codes_formula
|
||||
elif engine == 'aggregation' and report_line.aggregation_formula:
|
||||
subformula, formula = None, report_line.aggregation_formula
|
||||
elif engine == 'tax_tags' and report_line.tax_tags_formula:
|
||||
subformula, formula = None, report_line.tax_tags_formula
|
||||
else:
|
||||
# If we want to replace a formula shortcut with a full-syntax expression, we need to make the formula field falsy
|
||||
# We can't simply remove it from the xml because it won't be updated
|
||||
# If the formula field is falsy, we need to remove the expression that it generated
|
||||
report_line.expression_ids.filtered(lambda exp: exp.engine == engine and exp.label == 'balance' and not xml_ids.get(exp.id)).unlink()
|
||||
continue
|
||||
|
||||
vals = {
|
||||
'report_line_id': report_line.id,
|
||||
'label': 'balance',
|
||||
'engine': engine,
|
||||
'formula': formula.lstrip(' \t\n'), # Avoid IndentationError in evals
|
||||
'subformula': subformula
|
||||
}
|
||||
if report_line.expression_ids:
|
||||
# expressions already exists, update the first expression with the right engine
|
||||
# since syntactic sugar aren't meant to be used with multiple expressions
|
||||
for expression in report_line.expression_ids:
|
||||
if expression.label == 'balance':
|
||||
# If we had a 'balance' expression coming from the xml and are using a formula shortcut on top of it,
|
||||
# we expect the shortcut to replace the original expression. The full declaration should also
|
||||
# be removed from the data file, leading to the ORM deleting it automatically.
|
||||
if xml_ids.get(expression.id):
|
||||
expression.unlink()
|
||||
vals_list.append(vals)
|
||||
else:
|
||||
expression.write(vals)
|
||||
break
|
||||
else:
|
||||
# else prepare batch creation
|
||||
vals_list.append(vals)
|
||||
|
||||
if vals_list:
|
||||
self.env['account.report.expression'].create(vals_list)
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_child_expressions(self):
|
||||
"""
|
||||
We explicitly unlink child expressions.
|
||||
This is necessary even if there is an ondelete='cascade' on it, because
|
||||
the @api.ondelete method _unlink_archive_used_tags is not automatically
|
||||
called if the parent model is deleted.
|
||||
"""
|
||||
self.expression_ids.unlink()
|
||||
|
||||
|
||||
class AccountReportExpression(models.Model):
|
||||
_name = "account.report.expression"
|
||||
_description = "Accounting Report Expression"
|
||||
_rec_name = 'report_line_name'
|
||||
|
||||
report_line_id = fields.Many2one(string="Report Line", comodel_name='account.report.line', required=True, ondelete='cascade')
|
||||
report_line_name = fields.Char(string="Report Line Name", related="report_line_id.name")
|
||||
label = fields.Char(string="Label", required=True)
|
||||
engine = fields.Selection(
|
||||
string="Computation Engine",
|
||||
selection=[
|
||||
('domain', "Odoo Domain"),
|
||||
('tax_tags', "Tax Tags"),
|
||||
('aggregation', "Aggregate Other Formulas"),
|
||||
('account_codes', "Prefix of Account Codes"),
|
||||
('external', "External Value"),
|
||||
('custom', "Custom Python Function"),
|
||||
],
|
||||
required=True
|
||||
)
|
||||
formula = fields.Char(string="Formula", required=True)
|
||||
subformula = fields.Char(string="Subformula")
|
||||
date_scope = fields.Selection(
|
||||
string="Date Scope",
|
||||
selection=[
|
||||
('from_beginning', 'From the very start'),
|
||||
('from_fiscalyear', 'From the start of the fiscal year'),
|
||||
('to_beginning_of_fiscalyear', 'At the beginning of the fiscal year'),
|
||||
('to_beginning_of_period', 'At the beginning of the period'),
|
||||
('normal', 'According to each type of account'),
|
||||
('strict_range', 'Strictly on the given dates'),
|
||||
('previous_tax_period', "From previous tax period")
|
||||
],
|
||||
required=True,
|
||||
default='strict_range',
|
||||
)
|
||||
figure_type = fields.Selection(string="Figure Type", selection=FIGURE_TYPE_SELECTION_VALUES)
|
||||
green_on_positive = fields.Boolean(string="Is Growth Good when Positive", default=True)
|
||||
blank_if_zero = fields.Boolean(string="Blank if Zero", help="When checked, 0 values will not show when displaying this expression's value.")
|
||||
auditable = fields.Boolean(string="Auditable", store=True, readonly=False, compute='_compute_auditable')
|
||||
|
||||
# Carryover fields
|
||||
carryover_target = fields.Char(
|
||||
string="Carry Over To",
|
||||
help="Formula in the form line_code.expression_label. This allows setting the target of the carryover for this expression "
|
||||
"(on a _carryover_*-labeled expression), in case it is different from the parent line. 'custom' is also allowed as value"
|
||||
" in case the carryover destination requires more complex logic."
|
||||
)
|
||||
|
||||
@api.constrains('formula')
|
||||
def _check_domain_formula(self):
|
||||
for expression in self.filtered(lambda expr: expr.engine == 'domain'):
|
||||
try:
|
||||
domain = ast.literal_eval(expression.formula)
|
||||
self.env['account.move.line']._where_calc(domain)
|
||||
except:
|
||||
raise UserError(_("Invalid domain for expression '%s' of line '%s': %s",
|
||||
expression.label, expression.report_line_name, expression.formula))
|
||||
|
||||
@api.depends('engine')
|
||||
def _compute_auditable(self):
|
||||
auditable_engines = self._get_auditable_engines()
|
||||
for expression in self:
|
||||
expression.auditable = expression.engine in auditable_engines
|
||||
|
||||
def _get_auditable_engines(self):
|
||||
return {'tax_tags', 'domain', 'account_codes', 'external', 'aggregation'}
|
||||
|
||||
def _strip_formula(self, vals):
|
||||
if 'formula' in vals and isinstance(vals['formula'], str):
|
||||
vals['formula'] = re.sub(r'\s+', ' ', vals['formula'].strip())
|
||||
|
||||
def _create_tax_tags(self, tag_name, country):
|
||||
existing_tags = self.env['account.account.tag']._get_tax_tags(tag_name, country.id)
|
||||
if len(existing_tags) < 2:
|
||||
# We can have only one tag in case we archived it and deleted its unused complement sign
|
||||
tag_vals = self._get_tags_create_vals(tag_name, country.id, existing_tag=existing_tags)
|
||||
self.env['account.account.tag'].create(tag_vals)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
# Overridden so that we create the corresponding account.account.tag objects when instantiating an expression
|
||||
# with engine 'tax_tags'.
|
||||
for vals in vals_list:
|
||||
self._strip_formula(vals)
|
||||
|
||||
result = super().create(vals_list)
|
||||
|
||||
for expression in result:
|
||||
tag_name = expression.formula if expression.engine == 'tax_tags' else None
|
||||
if tag_name:
|
||||
country = expression.report_line_id.report_id.country_id
|
||||
self._create_tax_tags(tag_name, country)
|
||||
|
||||
return result
|
||||
|
||||
def write(self, vals):
|
||||
|
||||
self._strip_formula(vals)
|
||||
|
||||
tax_tags_expressions = self.filtered(lambda x: x.engine == 'tax_tags')
|
||||
|
||||
if vals.get('engine') == 'tax_tags':
|
||||
# We already generate the tags for the expressions receiving a new engine
|
||||
tags_create_vals = []
|
||||
for expression_with_new_engine in self - tax_tags_expressions:
|
||||
tag_name = vals.get('formula') or expression_with_new_engine.formula
|
||||
country = expression_with_new_engine.report_line_id.report_id.country_id
|
||||
if not self.env['account.account.tag']._get_tax_tags(tag_name, country.id):
|
||||
tags_create_vals += self.env['account.report.expression']._get_tags_create_vals(
|
||||
tag_name,
|
||||
country.id,
|
||||
)
|
||||
|
||||
self.env['account.account.tag'].create(tags_create_vals)
|
||||
|
||||
if 'formula' not in vals:
|
||||
return super().write(vals)
|
||||
|
||||
former_formulas_by_country = defaultdict(lambda: [])
|
||||
for expr in tax_tags_expressions:
|
||||
former_formulas_by_country[expr.report_line_id.report_id.country_id].append(expr.formula)
|
||||
|
||||
result = super().write(vals)
|
||||
|
||||
for country, former_formulas_list in former_formulas_by_country.items():
|
||||
for former_formula in former_formulas_list:
|
||||
new_tax_tags = self.env['account.account.tag']._get_tax_tags(vals['formula'], country.id)
|
||||
|
||||
if not new_tax_tags:
|
||||
# If new tags already exist, nothing to do ; else, we must create them or update existing tags.
|
||||
former_tax_tags = self.env['account.account.tag']._get_tax_tags(former_formula, country.id)
|
||||
|
||||
if former_tax_tags and all(tag_expr in self for tag_expr in former_tax_tags._get_related_tax_report_expressions()):
|
||||
# If we're changing the formula of all the expressions using that tag, rename the tag
|
||||
positive_tags, negative_tags = former_tax_tags.sorted(lambda x: x.tax_negate)
|
||||
if self.pool['account.tax'].name.translate:
|
||||
positive_tags._update_field_translations('name', {'en_US': f"+{vals['formula']}"})
|
||||
negative_tags._update_field_translations('name', {'en_US': f"-{vals['formula']}"})
|
||||
else:
|
||||
positive_tags.name = f"+{vals['formula']}"
|
||||
negative_tags.name = f"-{vals['formula']}"
|
||||
else:
|
||||
# Else, create a new tag. Its the compute functions will make sure it is properly linked to the expressions
|
||||
tag_vals = self.env['account.report.expression']._get_tags_create_vals(vals['formula'], country.id)
|
||||
self.env['account.account.tag'].create(tag_vals)
|
||||
|
||||
return result
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_archive_used_tags(self):
|
||||
"""
|
||||
Manages unlink or archive of tax_tags when account.report.expression are deleted.
|
||||
If a tag is still in use on amls, we archive it.
|
||||
"""
|
||||
expressions_tags = self._get_matching_tags()
|
||||
tags_to_archive = self.env['account.account.tag']
|
||||
tags_to_unlink = self.env['account.account.tag']
|
||||
for tag in expressions_tags:
|
||||
other_expression_using_tag = self.env['account.report.expression'].sudo().search([
|
||||
('engine', '=', 'tax_tags'),
|
||||
('formula', '=', tag.with_context(lang='en_US').name[1:]), # we escape the +/- sign
|
||||
('report_line_id.report_id.country_id.id', '=', tag.country_id.id),
|
||||
('id', 'not in', self.ids),
|
||||
], limit=1)
|
||||
if not other_expression_using_tag:
|
||||
aml_using_tag = self.env['account.move.line'].sudo().search([('tax_tag_ids', 'in', tag.id)], limit=1)
|
||||
if aml_using_tag:
|
||||
tags_to_archive += tag
|
||||
else:
|
||||
tags_to_unlink += tag
|
||||
|
||||
if tags_to_archive or tags_to_unlink:
|
||||
rep_lines_with_tag = self.env['account.tax.repartition.line'].sudo().search([('tag_ids', 'in', (tags_to_archive + tags_to_unlink).ids)])
|
||||
rep_lines_with_tag.write({'tag_ids': [Command.unlink(tag.id) for tag in tags_to_archive + tags_to_unlink]})
|
||||
tags_to_archive.active = False
|
||||
tags_to_unlink.unlink()
|
||||
|
||||
def name_get(self):
|
||||
return [(expr.id, f'{expr.report_line_name} [{expr.label}]') for expr in self]
|
||||
|
||||
def _expand_aggregations(self):
|
||||
"""Return self and its full aggregation expression dependency"""
|
||||
result = self
|
||||
|
||||
to_expand = self.filtered(lambda x: x.engine == 'aggregation')
|
||||
while to_expand:
|
||||
domains = []
|
||||
sub_expressions = self.env['account.report.expression']
|
||||
|
||||
for candidate_expr in to_expand:
|
||||
if candidate_expr.formula == 'sum_children':
|
||||
sub_expressions |= candidate_expr.report_line_id.children_ids.expression_ids.filtered(lambda e: e.label == candidate_expr.label)
|
||||
else:
|
||||
labels_by_code = candidate_expr._get_aggregation_terms_details()
|
||||
|
||||
cross_report_domain = []
|
||||
if candidate_expr.subformula != 'cross_report':
|
||||
cross_report_domain = [('report_line_id.report_id', '=', candidate_expr.report_line_id.report_id.id)]
|
||||
|
||||
for line_code, expr_labels in labels_by_code.items():
|
||||
dependency_domain = [('report_line_id.code', '=', line_code), ('label', 'in', tuple(expr_labels))] + cross_report_domain
|
||||
domains.append(dependency_domain)
|
||||
|
||||
if domains:
|
||||
sub_expressions |= self.env['account.report.expression'].search(osv.expression.OR(domains))
|
||||
|
||||
to_expand = sub_expressions.filtered(lambda x: x.engine == 'aggregation' and x not in result)
|
||||
result |= sub_expressions
|
||||
|
||||
return result
|
||||
|
||||
def _get_aggregation_terms_details(self):
|
||||
""" Computes the details of each aggregation expression in self, and returns them in the form of a single dict aggregating all the results.
|
||||
|
||||
Example of aggregation details:
|
||||
formula 'A.balance + B.balance + A.other'
|
||||
will return: {'A': {'balance', 'other'}, 'B': {'balance'}}
|
||||
"""
|
||||
totals_by_code = defaultdict(set)
|
||||
for expression in self:
|
||||
if expression.engine != 'aggregation':
|
||||
raise UserError(_("Cannot get aggregation details from a line not using 'aggregation' engine"))
|
||||
|
||||
expression_terms = re.split('[-+/*]', re.sub(r'[\s()]', '', expression.formula))
|
||||
for term in expression_terms:
|
||||
if term and not re.match(r'^([0-9]*[.])?[0-9]*$', term): # term might be empty if the formula contains a negative term
|
||||
line_code, total_name = term.split('.')
|
||||
totals_by_code[line_code].add(total_name)
|
||||
|
||||
if expression.subformula:
|
||||
if_other_expr_match = re.match(r'if_other_expr_(above|below)\((?P<line_code>.+)[.](?P<expr_label>.+),.+\)', expression.subformula)
|
||||
if if_other_expr_match:
|
||||
totals_by_code[if_other_expr_match['line_code']].add(if_other_expr_match['expr_label'])
|
||||
|
||||
return totals_by_code
|
||||
|
||||
def _get_matching_tags(self, sign=None):
|
||||
""" Returns all the signed account.account.tags records whose name matches any of the formulas of the tax_tags expressions contained in self.
|
||||
"""
|
||||
tag_expressions = self.filtered(lambda x: x.engine == 'tax_tags')
|
||||
if not tag_expressions:
|
||||
return self.env['account.account.tag']
|
||||
|
||||
or_domains = []
|
||||
for tag_expression in tag_expressions:
|
||||
country = tag_expression.report_line_id.report_id.country_id
|
||||
or_domains.append(self.env['account.account.tag']._get_tax_tags_domain(tag_expression.formula, country.id, sign))
|
||||
|
||||
return self.env['account.account.tag'].with_context(active_test=False, lang='en_US').search(osv.expression.OR(or_domains))
|
||||
|
||||
@api.model
|
||||
def _get_tags_create_vals(self, tag_name, country_id, existing_tag=None):
|
||||
"""
|
||||
We create the plus and minus tags with tag_name.
|
||||
In case there is an existing_tag (which can happen if we deleted its unused complement sign)
|
||||
we only recreate the missing sign.
|
||||
"""
|
||||
minus_tag_vals = {
|
||||
'name': '-' + tag_name,
|
||||
'applicability': 'taxes',
|
||||
'tax_negate': True,
|
||||
'country_id': country_id,
|
||||
}
|
||||
plus_tag_vals = {
|
||||
'name': '+' + tag_name,
|
||||
'applicability': 'taxes',
|
||||
'tax_negate': False,
|
||||
'country_id': country_id,
|
||||
}
|
||||
res = []
|
||||
if not existing_tag or not existing_tag.tax_negate:
|
||||
res.append(minus_tag_vals)
|
||||
if not existing_tag or existing_tag.tax_negate:
|
||||
res.append(plus_tag_vals)
|
||||
return res
|
||||
|
||||
def _get_carryover_target_expression(self, options):
|
||||
self.ensure_one()
|
||||
|
||||
if self.carryover_target:
|
||||
line_code, expr_label = self.carryover_target.split('.')
|
||||
return self.env['account.report.expression'].search([
|
||||
('report_line_id.code', '=', line_code),
|
||||
('label', '=', expr_label),
|
||||
('report_line_id.report_id', '=', self.report_line_id.report_id.id),
|
||||
])
|
||||
|
||||
main_expr_label = re.sub("^_carryover_", '', self.label)
|
||||
target_label = '_applied_carryover_%s' % main_expr_label
|
||||
auto_chosen_target = self.report_line_id.expression_ids.filtered(lambda x: x.label == target_label)
|
||||
|
||||
if not auto_chosen_target:
|
||||
raise UserError(_("Could not determine carryover target automatically for expression %s.", self.label))
|
||||
|
||||
return auto_chosen_target
|
||||
|
||||
|
||||
class AccountReportColumn(models.Model):
|
||||
_name = "account.report.column"
|
||||
_description = "Accounting Report Column"
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string="Name", translate=True, required=True)
|
||||
expression_label = fields.Char(string="Expression Label", required=True)
|
||||
sequence = fields.Integer(string="Sequence")
|
||||
report_id = fields.Many2one(string="Report", comodel_name='account.report')
|
||||
sortable = fields.Boolean(string="Sortable")
|
||||
figure_type = fields.Selection(string="Figure Type", selection=FIGURE_TYPE_SELECTION_VALUES, default="monetary", required=True)
|
||||
blank_if_zero = fields.Boolean(string="Blank if Zero", default=True, help="When checked, 0 values will not show in this column.")
|
||||
custom_audit_action_id = fields.Many2one(string="Custom Audit Action", comodel_name="ir.actions.act_window")
|
||||
|
||||
|
||||
class AccountReportExternalValue(models.Model):
|
||||
_name = "account.report.external.value"
|
||||
_description = 'Accounting Report External Value'
|
||||
_check_company_auto = True
|
||||
_order = 'date, id'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
value = fields.Float(required=True)
|
||||
date = fields.Date(required=True)
|
||||
|
||||
target_report_expression_id = fields.Many2one(string="Target Expression", comodel_name="account.report.expression", required=True, ondelete="cascade")
|
||||
target_report_line_id = fields.Many2one(string="Target Line", related="target_report_expression_id.report_line_id")
|
||||
target_report_expression_label = fields.Char(string="Target Expression Label", related="target_report_expression_id.label")
|
||||
report_country_id = fields.Many2one(string="Country", related='target_report_line_id.report_id.country_id')
|
||||
|
||||
company_id = fields.Many2one(string='Company', comodel_name='res.company', required=True, default=lambda self: self.env.company)
|
||||
|
||||
foreign_vat_fiscal_position_id = fields.Many2one(
|
||||
string="Fiscal position",
|
||||
comodel_name='account.fiscal.position',
|
||||
domain="[('company_id', '=', company_id), ('country_id', '=', report_country_id), ('foreign_vat', '!=', False)]",
|
||||
check_company=True,
|
||||
help="The foreign fiscal position for which this external value is made.",
|
||||
)
|
||||
|
||||
# Carryover fields
|
||||
carryover_origin_expression_label = fields.Char(string="Origin Expression Label")
|
||||
carryover_origin_report_line_id = fields.Many2one(string="Origin Line", comodel_name='account.report.line')
|
||||
|
||||
@api.constrains('foreign_vat_fiscal_position_id', 'target_report_expression_id')
|
||||
def _check_fiscal_position(self):
|
||||
for record in self:
|
||||
if record.foreign_vat_fiscal_position_id and record.foreign_vat_fiscal_position_id.country_id != record.report_country_id:
|
||||
raise ValidationError(_("The country set on the foreign VAT fiscal position must match the one set on the report."))
|
||||
1438
odoo-bringout-oca-ocb-account/account/models/account_tax.py
Normal file
1438
odoo-bringout-oca-ocb-account/account/models/account_tax.py
Normal file
File diff suppressed because it is too large
Load diff
1763
odoo-bringout-oca-ocb-account/account/models/chart_template.py
Normal file
1763
odoo-bringout-oca-ocb-account/account/models/chart_template.py
Normal file
File diff suppressed because it is too large
Load diff
860
odoo-bringout-oca-ocb-account/account/models/company.py
Normal file
860
odoo-bringout-oca-ocb-account/account/models/company.py
Normal file
|
|
@ -0,0 +1,860 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta, datetime, date
|
||||
import calendar
|
||||
|
||||
from odoo import fields, models, api, _, Command
|
||||
from odoo.exceptions import ValidationError, UserError, RedirectWarning
|
||||
from odoo.tools import date_utils
|
||||
from odoo.tools.mail import is_html_empty
|
||||
from odoo.tools.misc import format_date
|
||||
from odoo.tools.float_utils import float_round, float_is_zero
|
||||
from odoo.addons.account.models.account_move import MAX_HASH_VERSION
|
||||
|
||||
|
||||
MONTH_SELECTION = [
|
||||
('1', 'January'),
|
||||
('2', 'February'),
|
||||
('3', 'March'),
|
||||
('4', 'April'),
|
||||
('5', 'May'),
|
||||
('6', 'June'),
|
||||
('7', 'July'),
|
||||
('8', 'August'),
|
||||
('9', 'September'),
|
||||
('10', 'October'),
|
||||
('11', 'November'),
|
||||
('12', 'December'),
|
||||
]
|
||||
|
||||
ONBOARDING_STEP_STATES = [
|
||||
('not_done', "Not done"),
|
||||
('just_done', "Just done"),
|
||||
('done', "Done"),
|
||||
]
|
||||
DASHBOARD_ONBOARDING_STATES = ONBOARDING_STEP_STATES + [('closed', 'Closed')]
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_name = "res.company"
|
||||
_inherit = ["res.company", "mail.thread"]
|
||||
|
||||
#TODO check all the options/fields are in the views (settings + company form view)
|
||||
fiscalyear_last_day = fields.Integer(default=31, required=True)
|
||||
fiscalyear_last_month = fields.Selection(MONTH_SELECTION, default='12', required=True)
|
||||
period_lock_date = fields.Date(
|
||||
string="Journals Entries Lock Date",
|
||||
tracking=True,
|
||||
help="Only users with the 'Adviser' role can edit accounts prior to and inclusive of this"
|
||||
" date. Use it for period locking inside an open fiscal year, for example.")
|
||||
fiscalyear_lock_date = fields.Date(
|
||||
string="All Users Lock Date",
|
||||
tracking=True,
|
||||
help="No users, including Advisers, can edit accounts prior to and inclusive of this date."
|
||||
" Use it for fiscal year locking for example.")
|
||||
tax_lock_date = fields.Date(
|
||||
string="Tax Return Lock Date",
|
||||
tracking=True,
|
||||
help="No users can edit journal entries related to a tax prior and inclusive of this date.")
|
||||
transfer_account_id = fields.Many2one('account.account',
|
||||
domain="[('reconcile', '=', True), ('account_type', '=', 'asset_current'), ('deprecated', '=', False)]", string="Inter-Banks Transfer Account", help="Intermediary account used when moving money from a liqity account to another")
|
||||
expects_chart_of_accounts = fields.Boolean(string='Expects a Chart of Accounts', default=True)
|
||||
chart_template_id = fields.Many2one('account.chart.template', help='The chart template for the company (if any)')
|
||||
bank_account_code_prefix = fields.Char(string='Prefix of the bank accounts')
|
||||
cash_account_code_prefix = fields.Char(string='Prefix of the cash accounts')
|
||||
default_cash_difference_income_account_id = fields.Many2one('account.account', string="Cash Difference Income Account")
|
||||
default_cash_difference_expense_account_id = fields.Many2one('account.account', string="Cash Difference Expense Account")
|
||||
account_journal_suspense_account_id = fields.Many2one('account.account', string='Journal Suspense Account')
|
||||
account_journal_payment_debit_account_id = fields.Many2one('account.account', string='Journal Outstanding Receipts Account')
|
||||
account_journal_payment_credit_account_id = fields.Many2one('account.account', string='Journal Outstanding Payments Account')
|
||||
account_journal_early_pay_discount_gain_account_id = fields.Many2one(comodel_name='account.account', string='Cash Discount Write-Off Gain Account')
|
||||
account_journal_early_pay_discount_loss_account_id = fields.Many2one(comodel_name='account.account', string='Cash Discount Write-Off Loss Account')
|
||||
early_pay_discount_computation = fields.Selection([
|
||||
('included', 'On early payment'),
|
||||
('excluded', 'Never'),
|
||||
('mixed', 'Always (upon invoice)')
|
||||
], string='Cash Discount Tax Reduction', readonly=False, store=True, compute='_compute_early_pay_discount_computation')
|
||||
transfer_account_code_prefix = fields.Char(string='Prefix of the transfer accounts')
|
||||
account_sale_tax_id = fields.Many2one('account.tax', string="Default Sale Tax")
|
||||
account_purchase_tax_id = fields.Many2one('account.tax', string="Default Purchase Tax")
|
||||
tax_calculation_rounding_method = fields.Selection([
|
||||
('round_per_line', 'Round per Line'),
|
||||
('round_globally', 'Round Globally'),
|
||||
], default='round_per_line', string='Tax Calculation Rounding Method')
|
||||
currency_exchange_journal_id = fields.Many2one('account.journal', string="Exchange Gain or Loss Journal", domain=[('type', '=', 'general')])
|
||||
income_currency_exchange_account_id = fields.Many2one(
|
||||
comodel_name='account.account',
|
||||
string="Gain Exchange Rate Account",
|
||||
domain="[('deprecated', '=', False), ('company_id', '=', id), \
|
||||
('internal_group', '=', 'income')]")
|
||||
expense_currency_exchange_account_id = fields.Many2one(
|
||||
comodel_name='account.account',
|
||||
string="Loss Exchange Rate Account",
|
||||
domain="[('deprecated', '=', False), ('company_id', '=', id), \
|
||||
('account_type', '=', 'expense')]")
|
||||
anglo_saxon_accounting = fields.Boolean(string="Use anglo-saxon accounting")
|
||||
property_stock_account_input_categ_id = fields.Many2one('account.account', string="Input Account for Stock Valuation")
|
||||
property_stock_account_output_categ_id = fields.Many2one('account.account', string="Output Account for Stock Valuation")
|
||||
property_stock_valuation_account_id = fields.Many2one('account.account', string="Account Template for Stock Valuation")
|
||||
bank_journal_ids = fields.One2many('account.journal', 'company_id', domain=[('type', '=', 'bank')], string='Bank Journals')
|
||||
incoterm_id = fields.Many2one('account.incoterms', string='Default incoterm',
|
||||
help='International Commercial Terms are a series of predefined commercial terms used in international transactions.')
|
||||
|
||||
qr_code = fields.Boolean(string='Display QR-code on invoices')
|
||||
|
||||
invoice_is_email = fields.Boolean('Email by default', default=True)
|
||||
invoice_is_print = fields.Boolean('Print by default', default=True)
|
||||
account_use_credit_limit = fields.Boolean(
|
||||
string='Sales Credit Limit', help='Enable the use of credit limit on partners.')
|
||||
|
||||
#Fields of the setup step for opening move
|
||||
account_opening_move_id = fields.Many2one(string='Opening Journal Entry', comodel_name='account.move', help="The journal entry containing the initial balance of all this company's accounts.")
|
||||
account_opening_journal_id = fields.Many2one(string='Opening Journal', comodel_name='account.journal', related='account_opening_move_id.journal_id', help="Journal where the opening entry of this company's accounting has been posted.", readonly=False)
|
||||
account_opening_date = fields.Date(string='Opening Entry', default=lambda self: fields.Date.context_today(self).replace(month=1, day=1), required=True, help="That is the date of the opening entry.")
|
||||
|
||||
# Fields marking the completion of a setup step
|
||||
account_setup_bank_data_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding bank data step", default='not_done')
|
||||
account_setup_fy_data_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding fiscal year step", default='not_done')
|
||||
account_setup_coa_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding charts of account step", default='not_done')
|
||||
account_setup_taxes_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding Taxes step", default='not_done')
|
||||
account_onboarding_invoice_layout_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding invoice layout step", default='not_done')
|
||||
account_onboarding_create_invoice_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding create invoice step", compute='_compute_account_onboarding_create_invoice_state')
|
||||
#this field must be there to ensure that the create_invoice_state stay complete and because we can't use a dependencies on account move
|
||||
account_onboarding_create_invoice_state_flag = fields.Boolean(default=False, store=True)
|
||||
account_onboarding_sale_tax_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding sale tax step", default='not_done')
|
||||
|
||||
# account dashboard onboarding
|
||||
account_invoice_onboarding_state = fields.Selection(DASHBOARD_ONBOARDING_STATES, string="State of the account invoice onboarding panel", default='not_done')
|
||||
account_dashboard_onboarding_state = fields.Selection(DASHBOARD_ONBOARDING_STATES, string="State of the account dashboard onboarding panel", default='not_done')
|
||||
invoice_terms = fields.Html(string='Default Terms and Conditions', translate=True)
|
||||
terms_type = fields.Selection([('plain', 'Add a Note'), ('html', 'Add a link to a Web Page')],
|
||||
string='Terms & Conditions format', default='plain')
|
||||
invoice_terms_html = fields.Html(string='Default Terms and Conditions as a Web page', translate=True,
|
||||
sanitize_attributes=False,
|
||||
compute='_compute_invoice_terms_html', store=True, readonly=False)
|
||||
|
||||
account_setup_bill_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding bill step", default='not_done')
|
||||
|
||||
# Needed in the Point of Sale
|
||||
account_default_pos_receivable_account_id = fields.Many2one('account.account', string="Default PoS Receivable Account")
|
||||
|
||||
# Accrual Accounting
|
||||
expense_accrual_account_id = fields.Many2one('account.account',
|
||||
help="Account used to move the period of an expense",
|
||||
domain="[('internal_group', '=', 'liability'), ('account_type', 'not in', ('asset_receivable', 'liability_payable')), ('company_id', '=', id)]")
|
||||
revenue_accrual_account_id = fields.Many2one('account.account',
|
||||
help="Account used to move the period of a revenue",
|
||||
domain="[('internal_group', '=', 'asset'), ('account_type', 'not in', ('asset_receivable', 'liability_payable')), ('company_id', '=', id)]")
|
||||
automatic_entry_default_journal_id = fields.Many2one('account.journal', help="Journal used by default for moving the period of an entry", domain="[('type', '=', 'general')]")
|
||||
|
||||
# Technical field to hide country specific fields in company form view
|
||||
country_code = fields.Char(related='country_id.code', depends=['country_id'])
|
||||
|
||||
# Taxes
|
||||
account_fiscal_country_id = fields.Many2one(
|
||||
string="Fiscal Country",
|
||||
comodel_name='res.country',
|
||||
compute='compute_account_tax_fiscal_country',
|
||||
store=True,
|
||||
readonly=False,
|
||||
help="The country to use the tax reports from for this company")
|
||||
|
||||
account_enabled_tax_country_ids = fields.Many2many(
|
||||
string="l10n-used countries",
|
||||
comodel_name='res.country',
|
||||
compute='_compute_account_enabled_tax_country_ids',
|
||||
help="Technical field containing the countries for which this company is using tax-related features"
|
||||
"(hence the ones for which l10n modules need to show tax-related fields).")
|
||||
|
||||
# Cash basis taxes
|
||||
tax_exigibility = fields.Boolean(string='Use Cash Basis')
|
||||
tax_cash_basis_journal_id = fields.Many2one(
|
||||
comodel_name='account.journal',
|
||||
string="Cash Basis Journal")
|
||||
account_cash_basis_base_account_id = fields.Many2one(
|
||||
comodel_name='account.account',
|
||||
domain=[('deprecated', '=', False)],
|
||||
string="Base Tax Received Account",
|
||||
help="Account that will be set on lines created in cash basis journal entry and used to keep track of the "
|
||||
"tax base amount.")
|
||||
|
||||
# Storno Accounting
|
||||
account_storno = fields.Boolean(string="Storno accounting", readonly=False)
|
||||
|
||||
# Multivat
|
||||
fiscal_position_ids = fields.One2many(comodel_name="account.fiscal.position", inverse_name="company_id")
|
||||
multi_vat_foreign_country_ids = fields.Many2many(
|
||||
string="Foreign VAT countries",
|
||||
help="Countries for which the company has a VAT number",
|
||||
comodel_name='res.country',
|
||||
compute='_compute_multi_vat_foreign_country',
|
||||
)
|
||||
|
||||
# Fiduciary mode
|
||||
quick_edit_mode = fields.Selection(
|
||||
selection=[
|
||||
('out_invoices', 'Customer Invoices'),
|
||||
('in_invoices', 'Vendor Bills'),
|
||||
('out_and_in_invoices', 'Customer Invoices and Vendor Bills')],
|
||||
string="Quick encoding")
|
||||
|
||||
@api.constrains('account_opening_move_id', 'fiscalyear_last_day', 'fiscalyear_last_month')
|
||||
def _check_fiscalyear_last_day(self):
|
||||
# if the user explicitly chooses the 29th of February we allow it:
|
||||
# there is no "fiscalyear_last_year" so we do not know his intentions.
|
||||
for rec in self:
|
||||
if rec.fiscalyear_last_day == 29 and rec.fiscalyear_last_month == '2':
|
||||
continue
|
||||
|
||||
if rec.account_opening_date:
|
||||
year = rec.account_opening_date.year
|
||||
else:
|
||||
year = datetime.now().year
|
||||
|
||||
max_day = calendar.monthrange(year, int(rec.fiscalyear_last_month))[1]
|
||||
if rec.fiscalyear_last_day > max_day:
|
||||
raise ValidationError(_("Invalid fiscal year last day"))
|
||||
|
||||
@api.depends('fiscal_position_ids.foreign_vat')
|
||||
def _compute_multi_vat_foreign_country(self):
|
||||
company_to_foreign_vat_country = {
|
||||
val['company_id'][0]: val['country_ids']
|
||||
for val in self.env['account.fiscal.position'].read_group(
|
||||
domain=[('company_id', 'in', self.ids), ('foreign_vat', '!=', False)],
|
||||
fields=['country_ids:array_agg(country_id)'],
|
||||
groupby='company_id',
|
||||
)
|
||||
}
|
||||
for company in self:
|
||||
company.multi_vat_foreign_country_ids = self.env['res.country'].browse(company_to_foreign_vat_country.get(company.id))
|
||||
|
||||
@api.depends('country_id')
|
||||
def compute_account_tax_fiscal_country(self):
|
||||
for record in self:
|
||||
if not record.account_fiscal_country_id:
|
||||
record.account_fiscal_country_id = record.country_id
|
||||
|
||||
@api.depends('account_fiscal_country_id')
|
||||
def _compute_account_enabled_tax_country_ids(self):
|
||||
for record in self:
|
||||
foreign_vat_fpos = self.env['account.fiscal.position'].search([('company_id', '=', record.id), ('foreign_vat', '!=', False)])
|
||||
record.account_enabled_tax_country_ids = foreign_vat_fpos.country_id + record.account_fiscal_country_id
|
||||
|
||||
@api.depends('account_onboarding_create_invoice_state_flag')
|
||||
def _compute_account_onboarding_create_invoice_state(self):
|
||||
for record in self:
|
||||
if record.account_onboarding_create_invoice_state_flag:
|
||||
record.account_onboarding_create_invoice_state = 'done'
|
||||
elif self.env['account.move'].search([('company_id', '=', record.id), ('move_type', '=', 'out_invoice')], limit=1):
|
||||
record.account_onboarding_create_invoice_state = 'just_done'
|
||||
record.account_onboarding_create_invoice_state_flag = True
|
||||
else:
|
||||
record.account_onboarding_create_invoice_state = 'not_done'
|
||||
|
||||
@api.depends('terms_type')
|
||||
def _compute_invoice_terms_html(self):
|
||||
for company in self.filtered(lambda company: is_html_empty(company.invoice_terms_html) and company.terms_type == 'html'):
|
||||
html = self.env['ir.qweb']._render('account.account_default_terms_and_conditions',
|
||||
{'company_name': company.name, 'company_country': company.country_id.name},
|
||||
raise_if_not_found=False)
|
||||
if html:
|
||||
company.invoice_terms_html = html
|
||||
|
||||
def get_and_update_account_invoice_onboarding_state(self):
|
||||
""" This method is called on the controller rendering method and ensures that the animations
|
||||
are displayed only one time. """
|
||||
return self._get_and_update_onboarding_state(
|
||||
'account_invoice_onboarding_state',
|
||||
self.get_account_invoice_onboarding_steps_states_names()
|
||||
)
|
||||
|
||||
# YTI FIXME: Define only one method that returns {'account': [], 'sale': [], ...}
|
||||
def get_account_invoice_onboarding_steps_states_names(self):
|
||||
""" Necessary to add/edit steps from other modules (payment provider in this case). """
|
||||
return [
|
||||
'base_onboarding_company_state',
|
||||
'account_onboarding_invoice_layout_state',
|
||||
'account_onboarding_create_invoice_state',
|
||||
]
|
||||
|
||||
def get_and_update_account_dashboard_onboarding_state(self):
|
||||
""" This method is called on the controller rendering method and ensures that the animations
|
||||
are displayed only one time. """
|
||||
return self._get_and_update_onboarding_state(
|
||||
'account_dashboard_onboarding_state',
|
||||
self.get_account_dashboard_onboarding_steps_states_names()
|
||||
)
|
||||
|
||||
def get_account_dashboard_onboarding_steps_states_names(self):
|
||||
""" Necessary to add/edit steps from other modules (account_winbooks_import in this case). """
|
||||
return [
|
||||
'account_setup_bank_data_state',
|
||||
'account_setup_fy_data_state',
|
||||
'account_setup_coa_state',
|
||||
'account_setup_taxes_state',
|
||||
]
|
||||
|
||||
def get_new_account_code(self, current_code, old_prefix, new_prefix):
|
||||
digits = len(current_code)
|
||||
return new_prefix + current_code.replace(old_prefix, '', 1).lstrip('0').rjust(digits-len(new_prefix), '0')
|
||||
|
||||
def reflect_code_prefix_change(self, old_code, new_code):
|
||||
accounts = self.env['account.account'].search([('code', 'like', old_code), ('account_type', 'in', ('asset_cash', 'liability_credit_card')),
|
||||
('company_id', '=', self.id)], order='code asc')
|
||||
for account in accounts:
|
||||
if account.code.startswith(old_code):
|
||||
account.write({'code': self.get_new_account_code(account.code, old_code, new_code)})
|
||||
|
||||
def _get_fiscalyear_lock_statement_lines_redirect_action(self, unreconciled_statement_lines):
|
||||
""" Get the action redirecting to the statement lines that are not already reconciled when setting a fiscal
|
||||
year lock date.
|
||||
|
||||
:param unreconciled_statement_lines: The statement lines.
|
||||
:return: A dictionary representing a window action.
|
||||
"""
|
||||
|
||||
action = {
|
||||
'name': _("Unreconciled Transactions"),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'account.bank.statement.line',
|
||||
'context': {'create': False},
|
||||
}
|
||||
if len(unreconciled_statement_lines) == 1:
|
||||
action.update({
|
||||
'view_mode': 'form',
|
||||
'res_id': unreconciled_statement_lines.id,
|
||||
})
|
||||
else:
|
||||
action.update({
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('id', 'in', unreconciled_statement_lines.ids)],
|
||||
})
|
||||
return action
|
||||
|
||||
def _validate_fiscalyear_lock(self, values):
|
||||
if values.get('fiscalyear_lock_date'):
|
||||
|
||||
draft_entries = self.env['account.move'].search([
|
||||
('company_id', 'in', self.ids),
|
||||
('state', '=', 'draft'),
|
||||
('date', '<=', values['fiscalyear_lock_date'])])
|
||||
if draft_entries:
|
||||
error_msg = _('There are still unposted entries in the period you want to lock. You should either post or delete them.')
|
||||
action_error = {
|
||||
'view_mode': 'tree',
|
||||
'name': _('Unposted Entries'),
|
||||
'res_model': 'account.move',
|
||||
'type': 'ir.actions.act_window',
|
||||
'domain': [('id', 'in', draft_entries.ids)],
|
||||
'search_view_id': [self.env.ref('account.view_account_move_filter').id, 'search'],
|
||||
'views': [[self.env.ref('account.view_move_tree').id, 'list'], [self.env.ref('account.view_move_form').id, 'form']],
|
||||
}
|
||||
raise RedirectWarning(error_msg, action_error, _('Show unposted entries'))
|
||||
|
||||
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
|
||||
('company_id', 'in', self.ids),
|
||||
('is_reconciled', '=', False),
|
||||
('date', '<=', values['fiscalyear_lock_date']),
|
||||
('move_id.state', 'in', ('draft', 'posted')),
|
||||
])
|
||||
if unreconciled_statement_lines:
|
||||
error_msg = _("There are still unreconciled bank statement lines in the period you want to lock."
|
||||
"You should either reconcile or delete them.")
|
||||
action_error = self._get_fiscalyear_lock_statement_lines_redirect_action(unreconciled_statement_lines)
|
||||
raise RedirectWarning(error_msg, action_error, _('Show Unreconciled Bank Statement Line'))
|
||||
|
||||
def _get_user_fiscal_lock_date(self):
|
||||
"""Get the fiscal lock date for this company depending on the user"""
|
||||
if not self:
|
||||
return date.min
|
||||
self.ensure_one()
|
||||
lock_date = max(self.period_lock_date or date.min, self.fiscalyear_lock_date or date.min)
|
||||
if self.user_has_groups('account.group_account_manager'):
|
||||
lock_date = self.fiscalyear_lock_date or date.min
|
||||
return lock_date
|
||||
|
||||
def write(self, values):
|
||||
#restrict the closing of FY if there are still unposted entries
|
||||
self._validate_fiscalyear_lock(values)
|
||||
|
||||
# Reflect the change on accounts
|
||||
for company in self:
|
||||
if values.get('bank_account_code_prefix'):
|
||||
new_bank_code = values.get('bank_account_code_prefix') or company.bank_account_code_prefix
|
||||
company.reflect_code_prefix_change(company.bank_account_code_prefix, new_bank_code)
|
||||
|
||||
if values.get('cash_account_code_prefix'):
|
||||
new_cash_code = values.get('cash_account_code_prefix') or company.cash_account_code_prefix
|
||||
company.reflect_code_prefix_change(company.cash_account_code_prefix, new_cash_code)
|
||||
|
||||
#forbid the change of currency_id if there are already some accounting entries existing
|
||||
if 'currency_id' in values and values['currency_id'] != company.currency_id.id:
|
||||
if self.env['account.move.line'].sudo().search([('company_id', '=', company.id)]):
|
||||
raise UserError(_('You cannot change the currency of the company since some journal items already exist'))
|
||||
|
||||
return super(ResCompany, self).write(values)
|
||||
|
||||
@api.model
|
||||
def setting_init_bank_account_action(self):
|
||||
""" Called by the 'Bank Accounts' button of the setup bar."""
|
||||
view_id = self.env.ref('account.setup_bank_account_wizard').id
|
||||
return {'type': 'ir.actions.act_window',
|
||||
'name': _('Create a Bank Account'),
|
||||
'res_model': 'account.setup.bank.manual.config',
|
||||
'target': 'new',
|
||||
'view_mode': 'form',
|
||||
'views': [[view_id, 'form']],
|
||||
}
|
||||
|
||||
@api.model
|
||||
def setting_init_fiscal_year_action(self):
|
||||
""" Called by the 'Fiscal Year Opening' button of the setup bar."""
|
||||
company = self.env.company
|
||||
new_wizard = self.env['account.financial.year.op'].create({'company_id': company.id})
|
||||
view_id = self.env.ref('account.setup_financial_year_opening_form').id
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Accounting Periods'),
|
||||
'view_mode': 'form',
|
||||
'res_model': 'account.financial.year.op',
|
||||
'target': 'new',
|
||||
'res_id': new_wizard.id,
|
||||
'views': [[view_id, 'form']],
|
||||
}
|
||||
|
||||
@api.model
|
||||
def setting_chart_of_accounts_action(self):
|
||||
""" Called by the 'Chart of Accounts' button of the setup bar."""
|
||||
company = self.env.company
|
||||
company.sudo().set_onboarding_step_done('account_setup_coa_state')
|
||||
|
||||
# If an opening move has already been posted, we open the tree view showing all the accounts
|
||||
if company.opening_move_posted():
|
||||
return 'account.action_account_form'
|
||||
|
||||
# Then, we open will open a custom tree view allowing to edit opening balances of the account
|
||||
view_id = self.env.ref('account.init_accounts_tree').id
|
||||
# Hide the current year earnings account as it is automatically computed
|
||||
domain = [('account_type', '!=', 'equity_unaffected'), ('company_id', '=', company.id)]
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Chart of Accounts'),
|
||||
'res_model': 'account.account',
|
||||
'view_mode': 'tree',
|
||||
'limit': 99999999,
|
||||
'search_view_id': [self.env.ref('account.view_account_search').id],
|
||||
'views': [[view_id, 'list']],
|
||||
'domain': domain,
|
||||
}
|
||||
|
||||
def _get_default_opening_move_values(self):
|
||||
""" Get the default values to create the opening move.
|
||||
|
||||
:return: A dictionary to be passed to account.move.create.
|
||||
"""
|
||||
self.ensure_one()
|
||||
default_journal = self.env['account.journal'].search(
|
||||
[
|
||||
('type', '=', 'general'),
|
||||
('company_id', '=', self.id),
|
||||
],
|
||||
limit=1,
|
||||
)
|
||||
|
||||
if not default_journal:
|
||||
raise UserError(_("Please install a chart of accounts or create a miscellaneous journal before proceeding."))
|
||||
|
||||
return {
|
||||
'ref': _('Opening Journal Entry'),
|
||||
'company_id': self.id,
|
||||
'journal_id': default_journal.id,
|
||||
'date': self.account_opening_date - timedelta(days=1),
|
||||
}
|
||||
|
||||
def create_op_move_if_non_existant(self):
|
||||
""" Creates an empty opening move in 'draft' state for the current company
|
||||
if there wasn't already one defined. For this, the function needs at least
|
||||
one journal of type 'general' to exist (required by account.move).
|
||||
"""
|
||||
# TO BE REMOVED IN MASTER
|
||||
self.ensure_one()
|
||||
if not self.account_opening_move_id:
|
||||
self.account_opening_move_id = self.env['account.move'].create(self._get_default_opening_move_values())
|
||||
|
||||
def opening_move_posted(self):
|
||||
""" Returns true if this company has an opening account move and this move is posted."""
|
||||
return bool(self.account_opening_move_id) and self.account_opening_move_id.state == 'posted'
|
||||
|
||||
def get_unaffected_earnings_account(self):
|
||||
""" Returns the unaffected earnings account for this company, creating one
|
||||
if none has yet been defined.
|
||||
"""
|
||||
unaffected_earnings_type = "equity_unaffected"
|
||||
account = self.env['account.account'].search([('company_id', '=', self.id),
|
||||
('account_type', '=', unaffected_earnings_type)])
|
||||
if account:
|
||||
return account[0]
|
||||
# Do not assume '999999' doesn't exist since the user might have created such an account
|
||||
# manually.
|
||||
code = 999999
|
||||
while self.env['account.account'].search([('code', '=', str(code)), ('company_id', '=', self.id)]):
|
||||
code -= 1
|
||||
return self.env['account.account'].create({
|
||||
'code': str(code),
|
||||
'name': _('Undistributed Profits/Losses'),
|
||||
'account_type': unaffected_earnings_type,
|
||||
'company_id': self.id,
|
||||
})
|
||||
|
||||
def get_opening_move_differences(self, opening_move_lines):
|
||||
# TO BE REMOVED IN MASTER
|
||||
currency = self.currency_id
|
||||
balancing_move_line = opening_move_lines.filtered(lambda x: x.account_id == self.get_unaffected_earnings_account())
|
||||
|
||||
debits_sum = credits_sum = 0.0
|
||||
for line in opening_move_lines:
|
||||
if line != balancing_move_line:
|
||||
#skip the autobalancing move line
|
||||
debits_sum += line.debit
|
||||
credits_sum += line.credit
|
||||
|
||||
difference = abs(debits_sum - credits_sum)
|
||||
debit_diff = (debits_sum > credits_sum) and float_round(difference, precision_rounding=currency.rounding) or 0.0
|
||||
credit_diff = (debits_sum < credits_sum) and float_round(difference, precision_rounding=currency.rounding) or 0.0
|
||||
return debit_diff, credit_diff
|
||||
|
||||
def _auto_balance_opening_move(self):
|
||||
""" Checks the opening_move of this company. If it has not been posted yet
|
||||
and is unbalanced, balances it with a automatic account.move.line in the
|
||||
current year earnings account.
|
||||
"""
|
||||
# TO BE REMOVED IN MASTER
|
||||
if self.account_opening_move_id and self.account_opening_move_id.state == 'draft':
|
||||
balancing_account = self.get_unaffected_earnings_account()
|
||||
currency = self.currency_id
|
||||
|
||||
balancing_move_line = self.account_opening_move_id.line_ids.filtered(lambda x: x.account_id == balancing_account)
|
||||
# There could be multiple lines if we imported the balance from unaffected earnings account too
|
||||
if len(balancing_move_line) > 1:
|
||||
self.with_context(check_move_validity=False).account_opening_move_id.line_ids -= balancing_move_line[1:]
|
||||
balancing_move_line = balancing_move_line[0]
|
||||
|
||||
debit_diff, credit_diff = self.get_opening_move_differences(self.account_opening_move_id.line_ids)
|
||||
|
||||
if float_is_zero(debit_diff + credit_diff, precision_rounding=currency.rounding):
|
||||
if balancing_move_line:
|
||||
# zero difference and existing line : delete the line
|
||||
self.account_opening_move_id.line_ids -= balancing_move_line
|
||||
else:
|
||||
if balancing_move_line:
|
||||
# Non-zero difference and existing line : edit the line
|
||||
balancing_move_line.write({'debit': credit_diff, 'credit': debit_diff})
|
||||
else:
|
||||
# Non-zero difference and no existing line : create a new line
|
||||
self.env['account.move.line'].create({
|
||||
'name': _('Automatic Balancing Line'),
|
||||
'move_id': self.account_opening_move_id.id,
|
||||
'account_id': balancing_account.id,
|
||||
'debit': credit_diff,
|
||||
'credit': debit_diff,
|
||||
})
|
||||
|
||||
def _update_opening_move(self, to_update):
|
||||
""" Create or update the opening move for the accounts passed as parameter.
|
||||
|
||||
:param to_update: A dictionary mapping each account with a tuple (debit, credit).
|
||||
A separated opening line is created for both fields. A None value on debit/credit means the corresponding
|
||||
line will not be updated.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Don't allow to modify the opening move if not in draft.
|
||||
opening_move = self.account_opening_move_id
|
||||
if opening_move and opening_move.state != 'draft':
|
||||
raise UserError(_(
|
||||
'You cannot import the "openning_balance" if the opening move (%s) is already posted. \
|
||||
If you are absolutely sure you want to modify the opening balance of your accounts, reset the move to draft.',
|
||||
self.account_opening_move_id.name,
|
||||
))
|
||||
|
||||
move_values = {'line_ids': []}
|
||||
if opening_move:
|
||||
conversion_date = opening_move.date
|
||||
else:
|
||||
move_values.update(self._get_default_opening_move_values())
|
||||
conversion_date = move_values['date']
|
||||
|
||||
# Multi-currency management.
|
||||
cache_conversion_rate_per_currency = {}
|
||||
|
||||
def get_conversion_rate(account_currency):
|
||||
if account_currency in cache_conversion_rate_per_currency:
|
||||
return cache_conversion_rate_per_currency[account_currency]
|
||||
|
||||
rate = cache_conversion_rate_per_currency[account_currency] = self.env['res.currency']._get_conversion_rate(
|
||||
from_currency=self.currency_id,
|
||||
to_currency=account_currency,
|
||||
company=self,
|
||||
date=conversion_date,
|
||||
)
|
||||
return rate
|
||||
|
||||
# Decode the existing opening move.
|
||||
corresponding_lines_per_account = defaultdict(lambda: self.env['account.move.line'])
|
||||
for line in opening_move.line_ids:
|
||||
side = 'debit' if line.balance > 0.0 or line.amount_currency > 0.0 else 'credit'
|
||||
account = line.account_id
|
||||
corresponding_lines_per_account[(account, side)] |= line
|
||||
|
||||
line_ids = []
|
||||
open_balance = 0.0
|
||||
for account, amounts in to_update.items():
|
||||
for i, side, sign in ((0, 'debit', 1), (1, 'credit', -1)):
|
||||
amount = amounts[i]
|
||||
if amount is None:
|
||||
continue
|
||||
|
||||
currency = account.currency_id or self.currency_id
|
||||
if currency == self.currency_id:
|
||||
balance = amount_currency = sign * amount
|
||||
else:
|
||||
balance = sign * amount
|
||||
rate = get_conversion_rate(currency)
|
||||
amount_currency = currency.round(balance * rate)
|
||||
|
||||
corresponding_lines = corresponding_lines_per_account[(account, side)]
|
||||
|
||||
if self.currency_id.is_zero(balance):
|
||||
for line in corresponding_lines:
|
||||
open_balance -= line.balance
|
||||
line_ids.append(Command.delete(line.id))
|
||||
elif corresponding_lines:
|
||||
# Update the existing lines.
|
||||
corresponding_line = corresponding_lines[0]
|
||||
open_balance -= corresponding_line.balance
|
||||
open_balance += balance
|
||||
line_ids.append(Command.update(corresponding_line.id, {
|
||||
'balance': balance,
|
||||
'amount_currency': amount_currency,
|
||||
'currency_id': currency.id,
|
||||
}))
|
||||
|
||||
# If more than one line on this account, remove the others.
|
||||
for line in corresponding_lines[1:]:
|
||||
open_balance -= line.balance
|
||||
line_ids.append(Command.delete(line.id))
|
||||
else:
|
||||
# Create a new line.
|
||||
open_balance += balance
|
||||
line_ids.append(Command.create({
|
||||
'name': _("Opening balance"),
|
||||
'balance': balance,
|
||||
'amount_currency': amount_currency,
|
||||
'currency_id': currency.id,
|
||||
'account_id': account.id,
|
||||
}))
|
||||
|
||||
# Auto-balance.
|
||||
balancing_account = self.get_unaffected_earnings_account()
|
||||
balancing_move_lines = opening_move.line_ids.filtered(lambda x: x.account_id == balancing_account)
|
||||
for i, line in enumerate(balancing_move_lines):
|
||||
open_balance -= line.balance
|
||||
if i > 0:
|
||||
line_ids.append(Command.delete(line.id))
|
||||
|
||||
balancing_move_line = balancing_move_lines[:1]
|
||||
if balancing_move_line and self.currency_id.is_zero(open_balance):
|
||||
line_ids.append(Command.delete(balancing_move_line.id))
|
||||
elif balancing_move_lines:
|
||||
line_ids.append(Command.update(balancing_move_line.id, {
|
||||
'balance': -open_balance,
|
||||
'amount_currency': -open_balance,
|
||||
}))
|
||||
else:
|
||||
line_ids.append(Command.create({
|
||||
'name': _("Automatic Balancing Line"),
|
||||
'account_id': balancing_account.id,
|
||||
'balance': -open_balance,
|
||||
'amount_currency': -open_balance,
|
||||
}))
|
||||
|
||||
# Nothing to do.
|
||||
if not line_ids:
|
||||
return
|
||||
|
||||
move_values['line_ids'] = line_ids
|
||||
if opening_move:
|
||||
opening_move.write(move_values)
|
||||
else:
|
||||
self.account_opening_move_id = self.env['account.move'].create(move_values)
|
||||
|
||||
@api.model
|
||||
def action_close_account_invoice_onboarding(self):
|
||||
""" Mark the invoice onboarding panel as closed. """
|
||||
self.env.company.account_invoice_onboarding_state = 'closed'
|
||||
|
||||
@api.model
|
||||
def action_close_account_dashboard_onboarding(self):
|
||||
""" Mark the dashboard onboarding panel as closed. """
|
||||
self.env.company.account_dashboard_onboarding_state = 'closed'
|
||||
|
||||
@api.model
|
||||
def action_open_account_onboarding_sale_tax(self):
|
||||
""" Onboarding step for the invoice layout. """
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("account.action_open_account_onboarding_sale_tax")
|
||||
action['res_id'] = self.env.company.id
|
||||
return action
|
||||
|
||||
@api.model
|
||||
def action_open_account_onboarding_create_invoice(self):
|
||||
return self.env["ir.actions.actions"]._for_xml_id("account.action_open_account_onboarding_create_invoice")
|
||||
|
||||
@api.model
|
||||
def action_open_taxes_onboarding(self):
|
||||
""" Called by the 'Taxes' button of the setup bar."""
|
||||
|
||||
company = self.env.company
|
||||
company.sudo().set_onboarding_step_done('account_setup_taxes_state')
|
||||
view_id_list = self.env.ref('account.view_onboarding_tax_tree').id
|
||||
view_id_form = self.env.ref('account.view_tax_form').id
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Taxes'),
|
||||
'res_model': 'account.tax',
|
||||
'target': 'current',
|
||||
'views': [[view_id_list, 'list'], [view_id_form, 'form']],
|
||||
'context': {'search_default_sale': True, 'search_default_purchase': True, 'active_test': False},
|
||||
}
|
||||
|
||||
def action_save_onboarding_invoice_layout(self):
|
||||
""" Set the onboarding step as done """
|
||||
if bool(self.external_report_layout_id):
|
||||
self.set_onboarding_step_done('account_onboarding_invoice_layout_state')
|
||||
|
||||
def action_save_onboarding_sale_tax(self):
|
||||
""" Set the onboarding step as done """
|
||||
self.set_onboarding_step_done('account_onboarding_sale_tax_state')
|
||||
|
||||
def get_chart_of_accounts_or_fail(self):
|
||||
account = self.env['account.account'].search([('company_id', '=', self.id)], limit=1)
|
||||
if len(account) == 0:
|
||||
action = self.env.ref('account.action_account_config')
|
||||
msg = _(
|
||||
"We cannot find a chart of accounts for this company, you should configure it. \n"
|
||||
"Please go to Account Configuration and select or install a fiscal localization.")
|
||||
raise RedirectWarning(msg, action.id, _("Go to the configuration panel"))
|
||||
return account
|
||||
|
||||
@api.model
|
||||
def _action_check_hash_integrity(self):
|
||||
return self.env.ref('account.action_report_account_hash_integrity').report_action(self.id)
|
||||
|
||||
def _check_hash_integrity(self):
|
||||
"""Checks that all posted moves have still the same data as when they were posted
|
||||
and raises an error with the result.
|
||||
"""
|
||||
if not self.env.user.has_group('account.group_account_user'):
|
||||
raise UserError(_('Please contact your accountant to print the Hash integrity result.'))
|
||||
|
||||
def build_move_info(move):
|
||||
return(move.name, move.inalterable_hash, fields.Date.to_string(move.date))
|
||||
|
||||
journals = self.env['account.journal'].search([('company_id', '=', self.id)])
|
||||
results_by_journal = {
|
||||
'results': [],
|
||||
'printing_date': format_date(self.env, fields.Date.to_string(fields.Date.context_today(self)))
|
||||
}
|
||||
|
||||
for journal in journals:
|
||||
rslt = {
|
||||
'journal_name': journal.name,
|
||||
'journal_code': journal.code,
|
||||
'restricted_by_hash_table': journal.restrict_mode_hash_table and 'V' or 'X',
|
||||
'msg_cover': '',
|
||||
'first_hash': 'None',
|
||||
'first_move_name': 'None',
|
||||
'first_move_date': 'None',
|
||||
'last_hash': 'None',
|
||||
'last_move_name': 'None',
|
||||
'last_move_date': 'None',
|
||||
}
|
||||
if not journal.restrict_mode_hash_table:
|
||||
rslt.update({'msg_cover': _('This journal is not in strict mode.')})
|
||||
results_by_journal['results'].append(rslt)
|
||||
continue
|
||||
|
||||
# We need the `sudo()` to ensure that all the moves are searched, no matter the user's access rights.
|
||||
# This is required in order to generate consistent hashs.
|
||||
# It is not an issue, since the data is only used to compute a hash and not to return the actual values.
|
||||
all_moves_count = self.env['account.move'].sudo().search_count([('state', '=', 'posted'), ('journal_id', '=', journal.id)])
|
||||
moves = self.env['account.move'].sudo().search([('state', '=', 'posted'), ('journal_id', '=', journal.id),
|
||||
('secure_sequence_number', '!=', 0)], order="secure_sequence_number ASC")
|
||||
if not moves:
|
||||
rslt.update({
|
||||
'msg_cover': _('There isn\'t any journal entry flagged for data inalterability yet for this journal.'),
|
||||
})
|
||||
results_by_journal['results'].append(rslt)
|
||||
continue
|
||||
|
||||
previous_hash = u''
|
||||
start_move_info = []
|
||||
hash_corrupted = False
|
||||
current_hash_version = 1
|
||||
for move in moves:
|
||||
computed_hash = move.with_context(hash_version=current_hash_version)._compute_hash(previous_hash=previous_hash)
|
||||
while move.inalterable_hash != computed_hash and current_hash_version < MAX_HASH_VERSION:
|
||||
current_hash_version += 1
|
||||
computed_hash = move.with_context(hash_version=current_hash_version)._compute_hash(previous_hash=previous_hash)
|
||||
if move.inalterable_hash != computed_hash:
|
||||
rslt.update({'msg_cover': _('Corrupted data on journal entry with id %s.', move.id)})
|
||||
results_by_journal['results'].append(rslt)
|
||||
hash_corrupted = True
|
||||
break
|
||||
if not previous_hash:
|
||||
#save the date and sequence number of the first move hashed
|
||||
start_move_info = build_move_info(move)
|
||||
previous_hash = move.inalterable_hash
|
||||
end_move_info = build_move_info(move)
|
||||
|
||||
if hash_corrupted:
|
||||
continue
|
||||
|
||||
rslt.update({
|
||||
'first_move_name': start_move_info[0],
|
||||
'first_hash': start_move_info[1],
|
||||
'first_move_date': format_date(self.env, start_move_info[2]),
|
||||
'last_move_name': end_move_info[0],
|
||||
'last_hash': end_move_info[1],
|
||||
'last_move_date': format_date(self.env, end_move_info[2]),
|
||||
})
|
||||
if len(moves) == all_moves_count:
|
||||
rslt.update({'msg_cover': _('All entries are hashed.')})
|
||||
else:
|
||||
rslt.update({'msg_cover': _('Entries are hashed from %s (%s)') % (start_move_info[0], format_date(self.env, start_move_info[2]))})
|
||||
results_by_journal['results'].append(rslt)
|
||||
|
||||
return results_by_journal
|
||||
|
||||
def compute_fiscalyear_dates(self, current_date):
|
||||
"""
|
||||
Returns the dates of the fiscal year containing the provided date for this company.
|
||||
:return: A dictionary containing:
|
||||
* date_from
|
||||
* date_to
|
||||
"""
|
||||
self.ensure_one()
|
||||
date_from, date_to = date_utils.get_fiscal_year(current_date, day=self.fiscalyear_last_day, month=int(self.fiscalyear_last_month))
|
||||
return {'date_from': date_from, 'date_to': date_to}
|
||||
|
||||
@api.depends('country_code')
|
||||
def _compute_early_pay_discount_computation(self):
|
||||
for company in self:
|
||||
if company.country_code == 'BE':
|
||||
company.early_pay_discount_computation = 'mixed'
|
||||
elif company.country_code == 'NL':
|
||||
company.early_pay_discount_computation = 'excluded'
|
||||
else:
|
||||
company.early_pay_discount_computation = 'included'
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
from odoo import models
|
||||
|
||||
class DecimalPrecision(models.Model):
|
||||
_inherit = 'decimal.precision'
|
||||
|
||||
def precision_get(self, application):
|
||||
stackmap = self.env.cr.cache.get('account_disable_recursion_stack', {})
|
||||
if application == 'Discount' and stackmap.get('ignore_discount_precision'):
|
||||
return 100
|
||||
return super().precision_get(application)
|
||||
34
odoo-bringout-oca-ocb-account/account/models/digest.py
Normal file
34
odoo-bringout-oca-ocb-account/account/models/digest.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models, _
|
||||
from odoo.exceptions import AccessError
|
||||
|
||||
|
||||
class Digest(models.Model):
|
||||
_inherit = 'digest.digest'
|
||||
|
||||
kpi_account_total_revenue = fields.Boolean('Revenue')
|
||||
kpi_account_total_revenue_value = fields.Monetary(compute='_compute_kpi_account_total_revenue_value')
|
||||
|
||||
def _compute_kpi_account_total_revenue_value(self):
|
||||
if not self.env.user.has_group('account.group_account_invoice'):
|
||||
raise AccessError(_("Do not have access, skip this data for user's digest email"))
|
||||
for record in self:
|
||||
start, end, company = record._get_kpi_compute_parameters()
|
||||
self._cr.execute('''
|
||||
SELECT -SUM(line.balance)
|
||||
FROM account_move_line line
|
||||
JOIN account_move move ON move.id = line.move_id
|
||||
JOIN account_account account ON account.id = line.account_id
|
||||
WHERE line.company_id = %s AND line.date > %s::DATE AND line.date <= %s::DATE
|
||||
AND account.internal_group = 'income'
|
||||
AND move.state = 'posted'
|
||||
''', [company.id, start, end])
|
||||
query_res = self._cr.fetchone()
|
||||
record.kpi_account_total_revenue_value = query_res and query_res[0] or 0.0
|
||||
|
||||
def _compute_kpis_actions(self, company, user):
|
||||
res = super(Digest, self)._compute_kpis_actions(company, user)
|
||||
res['kpi_account_total_revenue'] = 'account.action_move_out_invoice_type&menu_id=%s' % self.env.ref('account.menu_finance').id
|
||||
return res
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from collections import OrderedDict
|
||||
from zlib import error as zlib_error
|
||||
try:
|
||||
from PyPDF2.errors import PdfReadError
|
||||
except ImportError:
|
||||
from PyPDF2.utils import PdfReadError
|
||||
|
||||
try:
|
||||
from PyPDF2.errors import DependencyError
|
||||
except ImportError:
|
||||
DependencyError = NotImplementedError
|
||||
|
||||
from odoo import api, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import pdf
|
||||
|
||||
|
||||
class IrActionsReport(models.Model):
|
||||
_inherit = 'ir.actions.report'
|
||||
|
||||
def _render_qweb_pdf_prepare_streams(self, report_ref, data, res_ids=None):
|
||||
# Custom behavior for 'account.report_original_vendor_bill'.
|
||||
if self._get_report(report_ref).report_name != 'account.report_original_vendor_bill':
|
||||
return super()._render_qweb_pdf_prepare_streams(report_ref, data, res_ids=res_ids)
|
||||
|
||||
invoices = self.env['account.move'].browse(res_ids)
|
||||
original_attachments = invoices.message_main_attachment_id
|
||||
if not original_attachments:
|
||||
raise UserError(_("No original purchase document could be found for any of the selected purchase documents."))
|
||||
|
||||
collected_streams = OrderedDict()
|
||||
for invoice in invoices:
|
||||
attachment = invoice.message_main_attachment_id
|
||||
if attachment:
|
||||
stream = pdf.to_pdf_stream(attachment)
|
||||
if stream:
|
||||
record = self.env[attachment.res_model].browse(attachment.res_id)
|
||||
try:
|
||||
stream = pdf.add_banner(stream, record.name, logo=True)
|
||||
except (ValueError, PdfReadError, TypeError, zlib_error, NotImplementedError, DependencyError, ArithmeticError):
|
||||
record._message_log(body=_(
|
||||
"There was an error when trying to add the banner to the original PDF.\n"
|
||||
"Please make sure the source file is valid."
|
||||
))
|
||||
collected_streams[invoice.id] = {
|
||||
'stream': stream,
|
||||
'attachment': attachment,
|
||||
}
|
||||
return collected_streams
|
||||
|
||||
def _is_invoice_report(self, report_ref):
|
||||
return self._get_report(report_ref).report_name in ('account.report_invoice_with_payments', 'account.report_invoice')
|
||||
|
||||
def _render_qweb_pdf(self, report_ref, res_ids=None, data=None):
|
||||
# Check for reports only available for invoices.
|
||||
# + append context data with the display_name_in_footer parameter
|
||||
if self._is_invoice_report(report_ref):
|
||||
invoices = self.env['account.move'].browse(res_ids)
|
||||
if self.env['ir.config_parameter'].sudo().get_param('account.display_name_in_footer'):
|
||||
data = data and dict(data) or {}
|
||||
data.update({'display_name_in_footer': True})
|
||||
if any(x.move_type == 'entry' for x in invoices):
|
||||
raise UserError(_("Only invoices could be printed."))
|
||||
|
||||
return super()._render_qweb_pdf(report_ref, res_ids=res_ids, data=data)
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_master_tags(self):
|
||||
master_xmlids = [
|
||||
"account_invoices",
|
||||
"action_account_original_vendor_bill"
|
||||
"account_invoices_without_payment",
|
||||
"action_report_journal",
|
||||
"action_report_payment_receipt",
|
||||
"action_report_account_statement",
|
||||
"action_report_account_hash_integrity",
|
||||
]
|
||||
for master_xmlid in master_xmlids:
|
||||
master_report = self.env.ref(f"account.{master_xmlid}", raise_if_not_found=False)
|
||||
if master_report and master_report in self:
|
||||
raise UserError(_("You cannot delete this report (%s), it is used by the accounting PDF generation engine.", master_report.name))
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, api
|
||||
|
||||
class IrAttachment(models.Model):
|
||||
_inherit = 'ir.attachment'
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# XSD validation
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def action_download_xsd_files(self):
|
||||
# To be extended by localisations, where they can download their necessary XSD files
|
||||
# Note: they should always return super().action_download_xsd_files()
|
||||
return
|
||||
34
odoo-bringout-oca-ocb-account/account/models/mail_thread.py
Normal file
34
odoo-bringout-oca-ocb-account/account/models/mail_thread.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
class MailThread(models.AbstractModel):
|
||||
_inherit = 'mail.thread'
|
||||
|
||||
def _message_post_process_attachments(self, attachments, attachment_ids, message_values):
|
||||
""" This method extension ensures that, when using the "Send & Print" feature, if the user
|
||||
adds an attachment, the latter will be linked to the record.
|
||||
|
||||
# Task-2792146: will move to model-based method
|
||||
"""
|
||||
record = self.env.context.get('attached_to')
|
||||
# link mail.compose.message attachments to attached_to
|
||||
if record and record._name in ('account.move', 'account.payment'):
|
||||
message_values['model'] = record._name
|
||||
message_values['res_id'] = record.id
|
||||
res = super()._message_post_process_attachments(attachments, attachment_ids, message_values)
|
||||
# link account.invoice.send attachments to attached_to
|
||||
model = message_values['model']
|
||||
res_id = message_values['res_id']
|
||||
att_ids = [att[1] for att in res.get('attachment_ids') or []]
|
||||
if att_ids and model == 'account.move':
|
||||
filtered_attachment_ids = self.env['ir.attachment'].sudo().browse(att_ids).filtered(
|
||||
lambda a: a.res_model in ('account.invoice.send',) and a.create_uid.id == self._uid)
|
||||
if filtered_attachment_ids:
|
||||
filtered_attachment_ids.write({'res_model': model, 'res_id': res_id})
|
||||
elif model == 'account.payment':
|
||||
attachments_to_link = self.env['ir.attachment'].sudo().browse(att_ids).filtered(
|
||||
lambda a: a.res_model in ('mail.message',) and a.create_uid.id == self._uid)
|
||||
attachments_to_link.write({'res_model': model, 'res_id': res_id})
|
||||
return res
|
||||
744
odoo-bringout-oca-ocb-account/account/models/partner.py
Normal file
744
odoo-bringout-oca-ocb-account/account/models/partner.py
Normal file
|
|
@ -0,0 +1,744 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import time
|
||||
import logging
|
||||
|
||||
from psycopg2 import sql, DatabaseError
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.osv import expression
|
||||
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, mute_logger
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class AccountFiscalPosition(models.Model):
|
||||
_name = 'account.fiscal.position'
|
||||
_description = 'Fiscal Position'
|
||||
_order = 'sequence'
|
||||
|
||||
sequence = fields.Integer()
|
||||
name = fields.Char(string='Fiscal Position', required=True)
|
||||
active = fields.Boolean(default=True,
|
||||
help="By unchecking the active field, you may hide a fiscal position without deleting it.")
|
||||
company_id = fields.Many2one(
|
||||
comodel_name='res.company',
|
||||
string='Company', required=True, readonly=True,
|
||||
default=lambda self: self.env.company)
|
||||
account_ids = fields.One2many('account.fiscal.position.account', 'position_id', string='Account Mapping', copy=True)
|
||||
tax_ids = fields.One2many('account.fiscal.position.tax', 'position_id', string='Tax Mapping', copy=True)
|
||||
note = fields.Html('Notes', translate=True, help="Legal mentions that have to be printed on the invoices.")
|
||||
auto_apply = fields.Boolean(string='Detect Automatically', help="Apply automatically this fiscal position.")
|
||||
vat_required = fields.Boolean(string='VAT required', help="Apply only if partner has a VAT number.")
|
||||
company_country_id = fields.Many2one(string="Company Country", related='company_id.account_fiscal_country_id')
|
||||
country_id = fields.Many2one('res.country', string='Country',
|
||||
help="Apply only if delivery country matches.")
|
||||
country_group_id = fields.Many2one('res.country.group', string='Country Group',
|
||||
help="Apply only if delivery country matches the group.")
|
||||
state_ids = fields.Many2many('res.country.state', string='Federal States')
|
||||
zip_from = fields.Char(string='Zip Range From')
|
||||
zip_to = fields.Char(string='Zip Range To')
|
||||
# To be used in hiding the 'Federal States' field('attrs' in view side) when selected 'Country' has 0 states.
|
||||
states_count = fields.Integer(compute='_compute_states_count')
|
||||
foreign_vat = fields.Char(string="Foreign Tax ID", help="The tax ID of your company in the region mapped by this fiscal position.")
|
||||
|
||||
# Technical field used to display a banner on top of foreign vat fiscal positions,
|
||||
# in order to ease the instantiation of foreign taxes when possible.
|
||||
foreign_vat_header_mode = fields.Selection(
|
||||
selection=[('templates_found', "Templates Found"), ('no_template', "No Template")],
|
||||
compute='_compute_foreign_vat_header_mode')
|
||||
|
||||
def _compute_states_count(self):
|
||||
for position in self:
|
||||
position.states_count = len(position.country_id.state_ids)
|
||||
|
||||
@api.depends('foreign_vat', 'country_id')
|
||||
def _compute_foreign_vat_header_mode(self):
|
||||
for record in self:
|
||||
if not record.foreign_vat or not record.country_id:
|
||||
record.foreign_vat_header_mode = None
|
||||
continue
|
||||
|
||||
if self.env['account.tax'].search([('country_id', '=', record.country_id.id)], limit=1):
|
||||
record.foreign_vat_header_mode = None
|
||||
elif self.env['account.tax.template'].search([('chart_template_id.country_id', '=', record.country_id.id)], limit=1):
|
||||
record.foreign_vat_header_mode = 'templates_found'
|
||||
else:
|
||||
record.foreign_vat_header_mode = 'no_template'
|
||||
|
||||
@api.constrains('zip_from', 'zip_to')
|
||||
def _check_zip(self):
|
||||
for position in self:
|
||||
if bool(position.zip_from) != bool(position.zip_to) or position.zip_from > position.zip_to:
|
||||
raise ValidationError(_('Invalid "Zip Range", You have to configure both "From" and "To" values for the zip range and "To" should be greater than "From".'))
|
||||
|
||||
@api.constrains('country_id', 'country_group_id', 'state_ids', 'foreign_vat')
|
||||
def _validate_foreign_vat_country(self):
|
||||
for record in self:
|
||||
if record.foreign_vat:
|
||||
if record.country_id == record.company_id.account_fiscal_country_id:
|
||||
if record.foreign_vat == record.company_id.vat:
|
||||
raise ValidationError(_("You cannot create a fiscal position within your fiscal country with the same VAT number as the main one set on your company."))
|
||||
|
||||
if not record.state_ids:
|
||||
if record.company_id.account_fiscal_country_id.state_ids:
|
||||
raise ValidationError(_("You cannot create a fiscal position with a foreign VAT within your fiscal country without assigning it a state."))
|
||||
else:
|
||||
raise ValidationError(_("You cannot create a fiscal position with a foreign VAT within your fiscal country."))
|
||||
if record.country_group_id and record.country_id:
|
||||
if record.country_id not in record.country_group_id.country_ids:
|
||||
raise ValidationError(_("You cannot create a fiscal position with a country outside of the selected country group."))
|
||||
|
||||
similar_fpos_domain = [
|
||||
('foreign_vat', '!=', False),
|
||||
('company_id', '=', record.company_id.id),
|
||||
('id', '!=', record.id),
|
||||
]
|
||||
|
||||
if record.country_group_id:
|
||||
foreign_vat_country = self.country_group_id.country_ids.filtered(lambda c: c.code == record.foreign_vat[:2].upper())
|
||||
if not foreign_vat_country:
|
||||
raise ValidationError(_("The country code of the foreign VAT number does not match any country in the group."))
|
||||
similar_fpos_domain += [('country_group_id', '=', record.country_group_id.id), ('country_id', '=', foreign_vat_country.id)]
|
||||
elif record.country_id:
|
||||
similar_fpos_domain += [('country_id', '=', record.country_id.id), ('country_group_id', '=', False)]
|
||||
|
||||
if record.state_ids:
|
||||
similar_fpos_domain.append(('state_ids', 'in', record.state_ids.ids))
|
||||
else:
|
||||
similar_fpos_domain.append(('state_ids', '=', False))
|
||||
|
||||
similar_fpos_count = self.env['account.fiscal.position'].search_count(similar_fpos_domain)
|
||||
if similar_fpos_count:
|
||||
raise ValidationError(_("A fiscal position with a foreign VAT already exists in this region."))
|
||||
|
||||
def map_tax(self, taxes):
|
||||
if not self:
|
||||
return taxes
|
||||
result = self.env['account.tax']
|
||||
for tax in taxes:
|
||||
taxes_correspondance = self.tax_ids.filtered(lambda t: t.tax_src_id == tax._origin and (not t.tax_dest_id or t.tax_dest_active))
|
||||
result |= taxes_correspondance.tax_dest_id if taxes_correspondance else tax
|
||||
return result
|
||||
|
||||
def map_account(self, account):
|
||||
for pos in self.account_ids:
|
||||
if pos.account_src_id == account:
|
||||
return pos.account_dest_id
|
||||
return account
|
||||
|
||||
def map_accounts(self, accounts):
|
||||
""" Receive a dictionary having accounts in values and try to replace those accounts accordingly to the fiscal position.
|
||||
"""
|
||||
ref_dict = {}
|
||||
for line in self.account_ids:
|
||||
ref_dict[line.account_src_id] = line.account_dest_id
|
||||
for key, acc in accounts.items():
|
||||
if acc in ref_dict:
|
||||
accounts[key] = ref_dict[acc]
|
||||
return accounts
|
||||
|
||||
@api.onchange('country_id')
|
||||
def _onchange_country_id(self):
|
||||
if self.country_id:
|
||||
self.zip_from = self.zip_to = False
|
||||
self.state_ids = [(5,)]
|
||||
self.states_count = len(self.country_id.state_ids)
|
||||
|
||||
@api.onchange('country_group_id')
|
||||
def _onchange_country_group_id(self):
|
||||
if self.country_group_id:
|
||||
self.zip_from = self.zip_to = False
|
||||
self.state_ids = [(5,)]
|
||||
|
||||
@api.model
|
||||
def _convert_zip_values(self, zip_from='', zip_to=''):
|
||||
if zip_from and zip_to:
|
||||
max_length = max(len(zip_from), len(zip_to))
|
||||
if zip_from.isdigit():
|
||||
zip_from = zip_from.rjust(max_length, '0')
|
||||
if zip_to.isdigit():
|
||||
zip_to = zip_to.rjust(max_length, '0')
|
||||
return zip_from, zip_to
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
zip_from = vals.get('zip_from')
|
||||
zip_to = vals.get('zip_to')
|
||||
if zip_from and zip_to:
|
||||
vals['zip_from'], vals['zip_to'] = self._convert_zip_values(zip_from, zip_to)
|
||||
return super().create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
zip_from = vals.get('zip_from')
|
||||
zip_to = vals.get('zip_to')
|
||||
if zip_from or zip_to:
|
||||
for rec in self:
|
||||
vals['zip_from'], vals['zip_to'] = self._convert_zip_values(zip_from or rec.zip_from, zip_to or rec.zip_to)
|
||||
return super(AccountFiscalPosition, self).write(vals)
|
||||
|
||||
@api.model
|
||||
def _get_fpos_by_region(self, country_id=False, state_id=False, zipcode=False, vat_required=False):
|
||||
if not country_id:
|
||||
return False
|
||||
base_domain = self._prepare_fpos_base_domain(vat_required)
|
||||
null_state_dom = state_domain = [('state_ids', '=', False)]
|
||||
null_zip_dom = zip_domain = [('zip_from', '=', False), ('zip_to', '=', False)]
|
||||
null_country_dom = [('country_id', '=', False), ('country_group_id', '=', False)]
|
||||
|
||||
if zipcode:
|
||||
zip_domain = [('zip_from', '<=', zipcode), ('zip_to', '>=', zipcode)]
|
||||
|
||||
if state_id:
|
||||
state_domain = [('state_ids', '=', state_id)]
|
||||
|
||||
domain_country = base_domain + [('country_id', '=', country_id)]
|
||||
domain_group = base_domain + [('country_group_id.country_ids', '=', country_id)]
|
||||
|
||||
# Build domain to search records with exact matching criteria
|
||||
fpos = self.search(domain_country + state_domain + zip_domain, limit=1)
|
||||
# return records that fit the most the criteria, and fallback on less specific fiscal positions if any can be found
|
||||
if not fpos and state_id:
|
||||
fpos = self.search(domain_country + null_state_dom + zip_domain, limit=1)
|
||||
if not fpos and zipcode:
|
||||
fpos = self.search(domain_country + state_domain + null_zip_dom, limit=1)
|
||||
if not fpos and state_id and zipcode:
|
||||
fpos = self.search(domain_country + null_state_dom + null_zip_dom, limit=1)
|
||||
|
||||
# fallback: country group with no state/zip range
|
||||
if not fpos:
|
||||
fpos = self.search(domain_group + null_state_dom + null_zip_dom, limit=1)
|
||||
|
||||
if not fpos:
|
||||
# Fallback on catchall (no country, no group)
|
||||
fpos = self.search(base_domain + null_country_dom, limit=1)
|
||||
return fpos
|
||||
|
||||
def _prepare_fpos_base_domain(self, vat_required):
|
||||
return [
|
||||
('auto_apply', '=', True),
|
||||
('vat_required', '=', vat_required),
|
||||
('company_id', 'in', [self.env.company.id, False]),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _get_fiscal_position(self, partner, delivery=None):
|
||||
"""
|
||||
:return: fiscal position found (recordset)
|
||||
:rtype: :class:`account.fiscal.position`
|
||||
"""
|
||||
if not partner:
|
||||
return self.env['account.fiscal.position']
|
||||
|
||||
company = self.env.company
|
||||
intra_eu = vat_exclusion = False
|
||||
if company.vat and partner.vat:
|
||||
eu_country_codes = set(self.env.ref('base.europe').country_ids.mapped('code'))
|
||||
intra_eu = company.vat[:2] in eu_country_codes and partner.vat[:2] in eu_country_codes
|
||||
vat_exclusion = company.vat[:2] == partner.vat[:2]
|
||||
|
||||
# If company and partner have the same vat prefix (and are both within the EU), use invoicing
|
||||
if not delivery or (intra_eu and vat_exclusion):
|
||||
delivery = partner
|
||||
|
||||
# partner manually set fiscal position always win
|
||||
manual_fiscal_position = (
|
||||
delivery.with_company(company).property_account_position_id
|
||||
or partner.with_company(company).property_account_position_id
|
||||
)
|
||||
if manual_fiscal_position:
|
||||
return manual_fiscal_position
|
||||
|
||||
# First search only matching VAT positions
|
||||
vat_required = bool(partner.vat)
|
||||
fp = self._get_fpos_by_region(delivery.country_id.id, delivery.state_id.id, delivery.zip, vat_required)
|
||||
|
||||
# Then if VAT required found no match, try positions that do not require it
|
||||
if not fp and vat_required:
|
||||
fp = self._get_fpos_by_region(delivery.country_id.id, delivery.state_id.id, delivery.zip, False)
|
||||
|
||||
return fp or self.env['account.fiscal.position']
|
||||
|
||||
def action_create_foreign_taxes(self):
|
||||
self.ensure_one()
|
||||
self.env['account.tax.template']._try_instantiating_foreign_taxes(self.country_id, self.company_id)
|
||||
|
||||
|
||||
class AccountFiscalPositionTax(models.Model):
|
||||
_name = 'account.fiscal.position.tax'
|
||||
_description = 'Tax Mapping of Fiscal Position'
|
||||
_rec_name = 'position_id'
|
||||
_check_company_auto = True
|
||||
|
||||
position_id = fields.Many2one('account.fiscal.position', string='Fiscal Position',
|
||||
required=True, ondelete='cascade')
|
||||
company_id = fields.Many2one('res.company', string='Company', related='position_id.company_id', store=True)
|
||||
tax_src_id = fields.Many2one('account.tax', string='Tax on Product', required=True, check_company=True)
|
||||
tax_dest_id = fields.Many2one('account.tax', string='Tax to Apply', check_company=True)
|
||||
tax_dest_active = fields.Boolean(related="tax_dest_id.active")
|
||||
|
||||
_sql_constraints = [
|
||||
('tax_src_dest_uniq',
|
||||
'unique (position_id,tax_src_id,tax_dest_id)',
|
||||
'A tax fiscal position could be defined only one time on same taxes.')
|
||||
]
|
||||
|
||||
|
||||
class AccountFiscalPositionAccount(models.Model):
|
||||
_name = 'account.fiscal.position.account'
|
||||
_description = 'Accounts Mapping of Fiscal Position'
|
||||
_rec_name = 'position_id'
|
||||
_check_company_auto = True
|
||||
|
||||
position_id = fields.Many2one('account.fiscal.position', string='Fiscal Position',
|
||||
required=True, ondelete='cascade')
|
||||
company_id = fields.Many2one('res.company', string='Company', related='position_id.company_id', store=True)
|
||||
account_src_id = fields.Many2one('account.account', string='Account on Product',
|
||||
check_company=True, required=True,
|
||||
domain="[('deprecated', '=', False), ('company_id', '=', company_id)]")
|
||||
account_dest_id = fields.Many2one('account.account', string='Account to Use Instead',
|
||||
check_company=True, required=True,
|
||||
domain="[('deprecated', '=', False), ('company_id', '=', company_id)]")
|
||||
|
||||
_sql_constraints = [
|
||||
('account_src_dest_uniq',
|
||||
'unique (position_id,account_src_id,account_dest_id)',
|
||||
'An account fiscal position could be defined only one time on same accounts.')
|
||||
]
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_name = 'res.partner'
|
||||
_inherit = 'res.partner'
|
||||
|
||||
@property
|
||||
def _order(self):
|
||||
res = super()._order
|
||||
partner_search_mode = self.env.context.get('res_partner_search_mode')
|
||||
if partner_search_mode not in ('customer', 'supplier'):
|
||||
return res
|
||||
order_by_field = f"{partner_search_mode}_rank DESC"
|
||||
return '%s, %s' % (order_by_field, res) if res else order_by_field
|
||||
|
||||
@api.depends_context('company')
|
||||
def _credit_debit_get(self):
|
||||
if not self.ids:
|
||||
self.debit = False
|
||||
self.credit = False
|
||||
return
|
||||
tables, where_clause, where_params = self.env['account.move.line']._where_calc([
|
||||
('parent_state', '=', 'posted'),
|
||||
('company_id', '=', self.env.company.id)
|
||||
]).get_sql()
|
||||
|
||||
where_params = [tuple(self.ids)] + where_params
|
||||
if where_clause:
|
||||
where_clause = 'AND ' + where_clause
|
||||
self._cr.execute("""SELECT account_move_line.partner_id, a.account_type, SUM(account_move_line.amount_residual)
|
||||
FROM """ + tables + """
|
||||
LEFT JOIN account_account a ON (account_move_line.account_id=a.id)
|
||||
WHERE a.account_type IN ('asset_receivable','liability_payable')
|
||||
AND account_move_line.partner_id IN %s
|
||||
AND account_move_line.reconciled IS NOT TRUE
|
||||
""" + where_clause + """
|
||||
GROUP BY account_move_line.partner_id, a.account_type
|
||||
""", where_params)
|
||||
treated = self.browse()
|
||||
for pid, type, val in self._cr.fetchall():
|
||||
partner = self.browse(pid)
|
||||
if type == 'asset_receivable':
|
||||
partner.credit = val
|
||||
if partner not in treated:
|
||||
partner.debit = False
|
||||
treated |= partner
|
||||
elif type == 'liability_payable':
|
||||
partner.debit = -val
|
||||
if partner not in treated:
|
||||
partner.credit = False
|
||||
treated |= partner
|
||||
remaining = (self - treated)
|
||||
remaining.debit = False
|
||||
remaining.credit = False
|
||||
|
||||
def _asset_difference_search(self, account_type, operator, operand):
|
||||
if operator not in ('<', '=', '>', '>=', '<='):
|
||||
return []
|
||||
if not isinstance(operand, (float, int)):
|
||||
return []
|
||||
sign = 1
|
||||
if account_type == 'liability_payable':
|
||||
sign = -1
|
||||
res = self._cr.execute('''
|
||||
SELECT partner.id
|
||||
FROM res_partner partner
|
||||
LEFT JOIN account_move_line aml ON aml.partner_id = partner.id
|
||||
JOIN account_move move ON move.id = aml.move_id
|
||||
RIGHT JOIN account_account acc ON aml.account_id = acc.id
|
||||
WHERE acc.account_type = %s
|
||||
AND NOT acc.deprecated AND acc.company_id = %s
|
||||
AND move.state = 'posted'
|
||||
GROUP BY partner.id
|
||||
HAVING %s * COALESCE(SUM(aml.amount_residual), 0) ''' + operator + ''' %s''', (account_type, self.env.company.id, sign, operand))
|
||||
res = self._cr.fetchall()
|
||||
if not res:
|
||||
return [('id', '=', '0')]
|
||||
return [('id', 'in', [r[0] for r in res])]
|
||||
|
||||
@api.model
|
||||
def _credit_search(self, operator, operand):
|
||||
return self._asset_difference_search('asset_receivable', operator, operand)
|
||||
|
||||
@api.model
|
||||
def _debit_search(self, operator, operand):
|
||||
return self._asset_difference_search('liability_payable', operator, operand)
|
||||
|
||||
def _invoice_total(self):
|
||||
self.total_invoiced = 0
|
||||
if not self.ids:
|
||||
return True
|
||||
|
||||
all_partners_and_children = {}
|
||||
all_partner_ids = []
|
||||
for partner in self.filtered('id'):
|
||||
# price_total is in the company currency
|
||||
all_partners_and_children[partner] = set(self.with_context(active_test=False).search([('id', 'child_of', partner.id)]).ids)
|
||||
all_partner_ids += all_partners_and_children[partner]
|
||||
|
||||
domain = [
|
||||
('partner_id', 'in', all_partner_ids),
|
||||
('state', 'not in', ['draft', 'cancel']),
|
||||
('move_type', 'in', ('out_invoice', 'out_refund')),
|
||||
]
|
||||
price_totals = self.env['account.invoice.report'].read_group(domain, ['price_subtotal'], ['partner_id'])
|
||||
for partner, child_ids in all_partners_and_children.items():
|
||||
partner.total_invoiced = sum(price['price_subtotal'] for price in price_totals if price['partner_id'][0] in child_ids)
|
||||
|
||||
def _compute_journal_item_count(self):
|
||||
AccountMoveLine = self.env['account.move.line']
|
||||
for partner in self:
|
||||
partner.journal_item_count = AccountMoveLine.search_count([('partner_id', '=', partner.id)])
|
||||
|
||||
def _compute_has_unreconciled_entries(self):
|
||||
for partner in self:
|
||||
# Avoid useless work if has_unreconciled_entries is not relevant for this partner
|
||||
if not partner.active or not partner.is_company and partner.parent_id:
|
||||
partner.has_unreconciled_entries = False
|
||||
continue
|
||||
self.env.cr.execute(
|
||||
""" SELECT 1 FROM(
|
||||
SELECT
|
||||
p.last_time_entries_checked AS last_time_entries_checked,
|
||||
MAX(l.write_date) AS max_date
|
||||
FROM
|
||||
account_move_line l
|
||||
RIGHT JOIN account_account a ON (a.id = l.account_id)
|
||||
RIGHT JOIN res_partner p ON (l.partner_id = p.id)
|
||||
WHERE
|
||||
p.id = %s
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM account_move_line l
|
||||
WHERE l.account_id = a.id
|
||||
AND l.partner_id = p.id
|
||||
AND l.amount_residual > 0
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM account_move_line l
|
||||
WHERE l.account_id = a.id
|
||||
AND l.partner_id = p.id
|
||||
AND l.amount_residual < 0
|
||||
)
|
||||
GROUP BY p.last_time_entries_checked
|
||||
) as s
|
||||
WHERE (last_time_entries_checked IS NULL OR max_date > last_time_entries_checked)
|
||||
""", (partner.id,))
|
||||
partner.has_unreconciled_entries = self.env.cr.rowcount == 1
|
||||
|
||||
def mark_as_reconciled(self):
|
||||
self.env['account.partial.reconcile'].check_access_rights('write')
|
||||
return self.sudo().write({'last_time_entries_checked': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)})
|
||||
|
||||
def _get_company_currency(self):
|
||||
for partner in self:
|
||||
if partner.company_id:
|
||||
partner.currency_id = partner.sudo().company_id.currency_id
|
||||
else:
|
||||
partner.currency_id = self.env.company.currency_id
|
||||
|
||||
credit = fields.Monetary(compute='_credit_debit_get', search=_credit_search,
|
||||
string='Total Receivable', help="Total amount this customer owes you.",
|
||||
groups='account.group_account_invoice,account.group_account_readonly')
|
||||
credit_limit = fields.Float(
|
||||
string='Credit Limit', help='Credit limit specific to this partner.',
|
||||
groups='account.group_account_invoice,account.group_account_readonly',
|
||||
company_dependent=True, copy=False, readonly=False)
|
||||
use_partner_credit_limit = fields.Boolean(
|
||||
string='Partner Limit', groups='account.group_account_invoice,account.group_account_readonly',
|
||||
compute='_compute_use_partner_credit_limit', inverse='_inverse_use_partner_credit_limit',
|
||||
help='Set a value greater than 0.0 to activate a credit limit check')
|
||||
show_credit_limit = fields.Boolean(
|
||||
default=lambda self: self.env.company.account_use_credit_limit,
|
||||
compute='_compute_show_credit_limit', groups='account.group_account_invoice,account.group_account_readonly')
|
||||
debit = fields.Monetary(
|
||||
compute='_credit_debit_get', search=_debit_search, string='Total Payable',
|
||||
help="Total amount you have to pay to this vendor.",
|
||||
groups='account.group_account_invoice,account.group_account_readonly')
|
||||
debit_limit = fields.Monetary('Payable Limit')
|
||||
total_invoiced = fields.Monetary(compute='_invoice_total', string="Total Invoiced",
|
||||
groups='account.group_account_invoice,account.group_account_readonly')
|
||||
currency_id = fields.Many2one('res.currency', compute='_get_company_currency', readonly=True,
|
||||
string="Currency") # currency of amount currency
|
||||
journal_item_count = fields.Integer(compute='_compute_journal_item_count', string="Journal Items")
|
||||
property_account_payable_id = fields.Many2one('account.account', company_dependent=True,
|
||||
string="Account Payable",
|
||||
domain="[('account_type', '=', 'liability_payable'), ('deprecated', '=', False), ('company_id', '=', current_company_id)]",
|
||||
help="This account will be used instead of the default one as the payable account for the current partner",
|
||||
required=True)
|
||||
property_account_receivable_id = fields.Many2one('account.account', company_dependent=True,
|
||||
string="Account Receivable",
|
||||
domain="[('account_type', '=', 'asset_receivable'), ('deprecated', '=', False), ('company_id', '=', current_company_id)]",
|
||||
help="This account will be used instead of the default one as the receivable account for the current partner",
|
||||
required=True)
|
||||
property_account_position_id = fields.Many2one('account.fiscal.position', company_dependent=True,
|
||||
string="Fiscal Position",
|
||||
domain="[('company_id', '=', current_company_id)]",
|
||||
help="The fiscal position determines the taxes/accounts used for this contact.")
|
||||
property_payment_term_id = fields.Many2one('account.payment.term', company_dependent=True,
|
||||
string='Customer Payment Terms',
|
||||
domain="[('company_id', 'in', [current_company_id, False])]",
|
||||
help="This payment term will be used instead of the default one for sales orders and customer invoices")
|
||||
property_supplier_payment_term_id = fields.Many2one('account.payment.term', company_dependent=True,
|
||||
string='Vendor Payment Terms',
|
||||
domain="[('company_id', 'in', [current_company_id, False])]",
|
||||
help="This payment term will be used instead of the default one for purchase orders and vendor bills")
|
||||
ref_company_ids = fields.One2many('res.company', 'partner_id',
|
||||
string='Companies that refers to partner')
|
||||
supplier_invoice_count = fields.Integer(compute='_compute_supplier_invoice_count', string='# Vendor Bills')
|
||||
has_unreconciled_entries = fields.Boolean(compute='_compute_has_unreconciled_entries',
|
||||
help="The partner has at least one unreconciled debit and credit since last time the invoices & payments matching was performed.")
|
||||
last_time_entries_checked = fields.Datetime(
|
||||
string='Latest Invoices & Payments Matching Date', readonly=True, copy=False,
|
||||
help='Last time the invoices & payments matching was performed for this partner. '
|
||||
'It is set either if there\'s not at least an unreconciled debit and an unreconciled credit '
|
||||
'or if you click the "Done" button.')
|
||||
invoice_ids = fields.One2many('account.move', 'partner_id', string='Invoices', readonly=True, copy=False)
|
||||
contract_ids = fields.One2many('account.analytic.account', 'partner_id', string='Partner Contracts', readonly=True)
|
||||
bank_account_count = fields.Integer(compute='_compute_bank_count', string="Bank")
|
||||
trust = fields.Selection([('good', 'Good Debtor'), ('normal', 'Normal Debtor'), ('bad', 'Bad Debtor')], string='Degree of trust you have in this debtor', default='normal', company_dependent=True)
|
||||
invoice_warn = fields.Selection(WARNING_MESSAGE, 'Invoice', help=WARNING_HELP, default="no-message")
|
||||
invoice_warn_msg = fields.Text('Message for Invoice')
|
||||
# Computed fields to order the partners as suppliers/customers according to the
|
||||
# amount of their generated incoming/outgoing account moves
|
||||
supplier_rank = fields.Integer(default=0, copy=False)
|
||||
customer_rank = fields.Integer(default=0, copy=False)
|
||||
|
||||
# Technical field holding the amount partners that share the same account number as any set on this partner.
|
||||
duplicated_bank_account_partners_count = fields.Integer(
|
||||
compute='_compute_duplicated_bank_account_partners_count',
|
||||
)
|
||||
# DEPRECATED, DO NOT USE, TO BE REMOVED IN MASTER
|
||||
is_coa_installed = fields.Boolean(store=False, default=lambda partner: bool(partner.env.company.chart_template_id))
|
||||
|
||||
def _compute_bank_count(self):
|
||||
bank_data = self.env['res.partner.bank']._read_group([('partner_id', 'in', self.ids)], ['partner_id'], ['partner_id'])
|
||||
mapped_data = dict([(bank['partner_id'][0], bank['partner_id_count']) for bank in bank_data])
|
||||
for partner in self:
|
||||
partner.bank_account_count = mapped_data.get(partner.id, 0)
|
||||
|
||||
def _compute_supplier_invoice_count(self):
|
||||
# retrieve all children partners
|
||||
all_partners = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
|
||||
|
||||
supplier_invoice_groups = self.env['account.move']._read_group(
|
||||
domain=[('partner_id', 'in', all_partners.ids),
|
||||
('move_type', 'in', ('in_invoice', 'in_refund'))],
|
||||
fields=['partner_id'], groupby=['partner_id']
|
||||
)
|
||||
partners = self.browse()
|
||||
for group in supplier_invoice_groups:
|
||||
partner = self.browse(group['partner_id'][0])
|
||||
while partner:
|
||||
if partner in self:
|
||||
partner.supplier_invoice_count += group['partner_id_count']
|
||||
partners |= partner
|
||||
partner = partner.parent_id
|
||||
(self - partners).supplier_invoice_count = 0
|
||||
|
||||
def _get_duplicated_bank_accounts(self):
|
||||
self.ensure_one()
|
||||
if not self.bank_ids:
|
||||
return self.env['res.partner.bank']
|
||||
domains = []
|
||||
for bank in self.bank_ids:
|
||||
domains.append([('acc_number', '=', bank.acc_number), ('bank_id', '=', bank.bank_id.id)])
|
||||
domain = expression.OR(domains)
|
||||
if self.company_id:
|
||||
domain = expression.AND([domain, [('company_id', 'in', (False, self.company_id.id))]])
|
||||
domain = expression.AND([domain, [('partner_id', '!=', self._origin.id)]])
|
||||
return self.env['res.partner.bank'].search(domain)
|
||||
|
||||
@api.depends('bank_ids')
|
||||
def _compute_duplicated_bank_account_partners_count(self):
|
||||
for partner in self:
|
||||
partner.duplicated_bank_account_partners_count = len(partner._get_duplicated_bank_accounts())
|
||||
|
||||
@api.depends_context('company')
|
||||
def _compute_use_partner_credit_limit(self):
|
||||
for partner in self:
|
||||
company_limit = self.env['ir.property']._get('credit_limit', 'res.partner')
|
||||
partner.use_partner_credit_limit = partner.credit_limit != company_limit
|
||||
|
||||
def _inverse_use_partner_credit_limit(self):
|
||||
for partner in self:
|
||||
if not partner.use_partner_credit_limit:
|
||||
partner.credit_limit = self.env['ir.property']._get('credit_limit', 'res.partner')
|
||||
|
||||
@api.depends_context('company')
|
||||
def _compute_show_credit_limit(self):
|
||||
for partner in self:
|
||||
partner.show_credit_limit = self.env.company.account_use_credit_limit
|
||||
|
||||
def _find_accounting_partner(self, partner):
|
||||
''' Find the partner for which the accounting entries will be created '''
|
||||
return partner.commercial_partner_id
|
||||
|
||||
@api.model
|
||||
def _commercial_fields(self):
|
||||
return super(ResPartner, self)._commercial_fields() + \
|
||||
['debit_limit', 'property_account_payable_id', 'property_account_receivable_id', 'property_account_position_id',
|
||||
'property_payment_term_id', 'property_supplier_payment_term_id', 'last_time_entries_checked', 'credit_limit']
|
||||
|
||||
def action_view_partner_invoices(self):
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_out_invoice_type")
|
||||
all_child = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
|
||||
action['domain'] = [
|
||||
('move_type', 'in', ('out_invoice', 'out_refund')),
|
||||
('partner_id', 'in', all_child.ids)
|
||||
]
|
||||
action['context'] = {'default_move_type': 'out_invoice', 'move_type': 'out_invoice', 'journal_type': 'sale', 'search_default_unpaid': 1}
|
||||
return action
|
||||
|
||||
def action_view_partner_with_same_bank(self):
|
||||
self.ensure_one()
|
||||
bank_partners = self._get_duplicated_bank_accounts()
|
||||
# Open a list view or form view of the partner(s) with the same bank accounts
|
||||
if self.duplicated_bank_account_partners_count == 1:
|
||||
action_vals = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'res.partner',
|
||||
'view_mode': 'form',
|
||||
'res_id': bank_partners.partner_id.id,
|
||||
'views': [(False, 'form')],
|
||||
}
|
||||
else:
|
||||
action_vals = {
|
||||
'name': _("Partners"),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'res.partner',
|
||||
'view_mode': 'tree,form',
|
||||
'views': [(False, 'list'), (False, 'form')],
|
||||
'domain': [('id', 'in', bank_partners.partner_id.ids)],
|
||||
}
|
||||
|
||||
return action_vals
|
||||
|
||||
def can_edit_vat(self):
|
||||
''' Can't edit `vat` if there is (non draft) issued invoices. '''
|
||||
can_edit_vat = super(ResPartner, self).can_edit_vat()
|
||||
if not can_edit_vat:
|
||||
return can_edit_vat
|
||||
has_invoice = self.env['account.move'].sudo().search([
|
||||
('move_type', 'in', ['out_invoice', 'out_refund']),
|
||||
('partner_id', 'child_of', self.commercial_partner_id.id),
|
||||
('state', '=', 'posted')
|
||||
], limit=1)
|
||||
return can_edit_vat and not (bool(has_invoice))
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
search_partner_mode = self.env.context.get('res_partner_search_mode')
|
||||
is_customer = search_partner_mode == 'customer'
|
||||
is_supplier = search_partner_mode == 'supplier'
|
||||
if search_partner_mode:
|
||||
for vals in vals_list:
|
||||
if is_customer and 'customer_rank' not in vals:
|
||||
vals['customer_rank'] = 1
|
||||
elif is_supplier and 'supplier_rank' not in vals:
|
||||
vals['supplier_rank'] = 1
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_if_partner_in_account_move(self):
|
||||
"""
|
||||
Prevent the deletion of a partner "Individual", child of a company if:
|
||||
- partner in 'account.move'
|
||||
- state: all states (draft and posted)
|
||||
"""
|
||||
moves = self.sudo().env['account.move'].search_count([
|
||||
('partner_id', 'in', self.ids),
|
||||
('state', 'in', ['draft', 'posted']),
|
||||
])
|
||||
if moves:
|
||||
raise UserError(_("The partner cannot be deleted because it is used in Accounting"))
|
||||
|
||||
def _increase_rank(self, field, n=1):
|
||||
if self.ids and field in ['customer_rank', 'supplier_rank']:
|
||||
try:
|
||||
with self.env.cr.savepoint(flush=False), mute_logger('odoo.sql_db'):
|
||||
query = sql.SQL("""
|
||||
SELECT {field} FROM res_partner WHERE ID IN %(partner_ids)s FOR NO KEY UPDATE NOWAIT;
|
||||
UPDATE res_partner SET {field} = {field} + %(n)s
|
||||
WHERE id IN %(partner_ids)s
|
||||
""").format(field=sql.Identifier(field))
|
||||
self.env.cr.execute(query, {'partner_ids': tuple(self.ids), 'n': n})
|
||||
self.invalidate_recordset([field])
|
||||
self.modified([field])
|
||||
except DatabaseError as e:
|
||||
# 55P03 LockNotAvailable
|
||||
# 40001 SerializationFailure
|
||||
if e.pgcode not in ('55P03', '40001'):
|
||||
raise e
|
||||
_logger.debug('Another transaction already locked partner rows. Cannot update partner ranks.')
|
||||
|
||||
@api.model
|
||||
def get_partner_localisation_fields_required_to_invoice(self, country_id):
|
||||
""" Returns the list of fields that needs to be filled when creating an invoice for the selected country.
|
||||
This is required for some flows that would allow a user to request an invoice from the portal.
|
||||
Using these, we can get their information and dynamically create form inputs based for the fields required legally for the company country_id.
|
||||
The returned fields must be of type ir.model.fields in order to handle translations
|
||||
|
||||
:param country_id: The country for which we want the fields.
|
||||
:return: an array of ir.model.fields for which the user should provide values.
|
||||
"""
|
||||
return []
|
||||
|
||||
def _merge_method(self, destination, source):
|
||||
"""
|
||||
Prevent merging partners that are linked to already hashed journal items.
|
||||
"""
|
||||
if self.env['account.move.line'].sudo().search([('move_id.inalterable_hash', '!=', False), ('partner_id', 'in', source.ids)], limit=1):
|
||||
raise UserError(_('Partners that are used in hashed entries cannot be merged.'))
|
||||
return super()._merge_method(destination, source)
|
||||
|
||||
def _run_vat_test(self, vat_number, default_country, partner_is_company=True):
|
||||
""" Checks a VAT number syntactically to ensure its validity upon saving.
|
||||
A first check is made by using the first two characters of the VAT as
|
||||
the country code. If it fails, a second one is made using default_country instead.
|
||||
|
||||
:param vat_number: a string with the VAT number to check.
|
||||
:param default_country: a res.country object
|
||||
:param partner_is_company: True if the partner is a company, else False.
|
||||
.. deprecated:: 16.0
|
||||
Will be removed in 16.2
|
||||
|
||||
:return: The country code (in lower case) of the country the VAT number
|
||||
was validated for, if it was validated. False if it could not be validated
|
||||
against the provided or guessed country. None if no country was available
|
||||
for the check, and no conclusion could be made with certainty.
|
||||
"""
|
||||
return default_country.code.lower()
|
||||
221
odoo-bringout-oca-ocb-account/account/models/product.py
Normal file
221
odoo-bringout-oca-ocb-account/account/models/product.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tools import format_amount
|
||||
|
||||
ACCOUNT_DOMAIN = "['&', '&', '&', ('deprecated', '=', False), ('account_type', 'not in', ('asset_receivable','liability_payable','asset_cash','liability_credit_card')), ('company_id', '=', current_company_id), ('is_off_balance', '=', False)]"
|
||||
|
||||
class ProductCategory(models.Model):
|
||||
_inherit = "product.category"
|
||||
|
||||
property_account_income_categ_id = fields.Many2one('account.account', company_dependent=True,
|
||||
string="Income Account",
|
||||
domain=ACCOUNT_DOMAIN,
|
||||
help="This account will be used when validating a customer invoice.")
|
||||
property_account_expense_categ_id = fields.Many2one('account.account', company_dependent=True,
|
||||
string="Expense Account",
|
||||
domain=ACCOUNT_DOMAIN,
|
||||
help="The expense is accounted for when a vendor bill is validated, except in anglo-saxon accounting with perpetual inventory valuation in which case the expense (Cost of Goods Sold account) is recognized at the customer invoice validation.")
|
||||
|
||||
#----------------------------------------------------------
|
||||
# Products
|
||||
#----------------------------------------------------------
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = "product.template"
|
||||
|
||||
taxes_id = fields.Many2many('account.tax', 'product_taxes_rel', 'prod_id', 'tax_id', help="Default taxes used when selling the product.", string='Customer Taxes',
|
||||
domain=[('type_tax_use', '=', 'sale')], default=lambda self: self.env.company.account_sale_tax_id)
|
||||
tax_string = fields.Char(compute='_compute_tax_string')
|
||||
supplier_taxes_id = fields.Many2many('account.tax', 'product_supplier_taxes_rel', 'prod_id', 'tax_id', string='Vendor Taxes', help='Default taxes used when buying the product.',
|
||||
domain=[('type_tax_use', '=', 'purchase')], default=lambda self: self.env.company.account_purchase_tax_id)
|
||||
property_account_income_id = fields.Many2one('account.account', company_dependent=True,
|
||||
string="Income Account",
|
||||
domain=ACCOUNT_DOMAIN,
|
||||
help="Keep this field empty to use the default value from the product category.")
|
||||
property_account_expense_id = fields.Many2one('account.account', company_dependent=True,
|
||||
string="Expense Account",
|
||||
domain=ACCOUNT_DOMAIN,
|
||||
help="Keep this field empty to use the default value from the product category. If anglo-saxon accounting with automated valuation method is configured, the expense account on the product category will be used.")
|
||||
account_tag_ids = fields.Many2many(
|
||||
string="Account Tags",
|
||||
comodel_name='account.account.tag',
|
||||
domain="[('applicability', '=', 'products')]",
|
||||
help="Tags to be set on the base and tax journal items created for this product.")
|
||||
|
||||
def _get_product_accounts(self):
|
||||
return {
|
||||
'income': self.property_account_income_id or self.categ_id.property_account_income_categ_id,
|
||||
'expense': self.property_account_expense_id or self.categ_id.property_account_expense_categ_id
|
||||
}
|
||||
|
||||
def _get_asset_accounts(self):
|
||||
res = {}
|
||||
res['stock_input'] = False
|
||||
res['stock_output'] = False
|
||||
return res
|
||||
|
||||
def get_product_accounts(self, fiscal_pos=None):
|
||||
accounts = self._get_product_accounts()
|
||||
if not fiscal_pos:
|
||||
fiscal_pos = self.env['account.fiscal.position']
|
||||
return fiscal_pos.map_accounts(accounts)
|
||||
|
||||
@api.depends('taxes_id', 'list_price')
|
||||
@api.depends_context('company')
|
||||
def _compute_tax_string(self):
|
||||
for record in self:
|
||||
record.tax_string = record._construct_tax_string(record.list_price)
|
||||
|
||||
def _construct_tax_string(self, price):
|
||||
currency = self.currency_id
|
||||
res = self.taxes_id.filtered(lambda t: t.company_id == self.env.company).compute_all(
|
||||
price, product=self, partner=self.env['res.partner']
|
||||
)
|
||||
joined = []
|
||||
included = res['total_included']
|
||||
if currency.compare_amounts(included, price):
|
||||
joined.append(_('%s Incl. Taxes', format_amount(self.env, included, currency)))
|
||||
excluded = res['total_excluded']
|
||||
if currency.compare_amounts(excluded, price):
|
||||
joined.append(_('%s Excl. Taxes', format_amount(self.env, excluded, currency)))
|
||||
if joined:
|
||||
tax_string = f"(= {', '.join(joined)})"
|
||||
else:
|
||||
tax_string = " "
|
||||
return tax_string
|
||||
|
||||
@api.constrains('uom_id')
|
||||
def _check_uom_not_in_invoice(self):
|
||||
self.env['product.template'].flush_model(['uom_id'])
|
||||
self._cr.execute("""
|
||||
SELECT prod_template.id
|
||||
FROM account_move_line line
|
||||
JOIN product_product prod_variant ON line.product_id = prod_variant.id
|
||||
JOIN product_template prod_template ON prod_variant.product_tmpl_id = prod_template.id
|
||||
JOIN uom_uom template_uom ON prod_template.uom_id = template_uom.id
|
||||
JOIN uom_category template_uom_cat ON template_uom.category_id = template_uom_cat.id
|
||||
JOIN uom_uom line_uom ON line.product_uom_id = line_uom.id
|
||||
JOIN uom_category line_uom_cat ON line_uom.category_id = line_uom_cat.id
|
||||
WHERE prod_template.id IN %s
|
||||
AND line.parent_state = 'posted'
|
||||
AND template_uom_cat.id != line_uom_cat.id
|
||||
LIMIT 1
|
||||
""", [tuple(self.ids)])
|
||||
if self._cr.fetchall():
|
||||
raise ValidationError(_(
|
||||
"This product is already being used in posted Journal Entries.\n"
|
||||
"If you want to change its Unit of Measure, please archive this product and create a new one."
|
||||
))
|
||||
|
||||
|
||||
class ProductProduct(models.Model):
|
||||
_inherit = "product.product"
|
||||
|
||||
tax_string = fields.Char(compute='_compute_tax_string')
|
||||
|
||||
def _get_product_accounts(self):
|
||||
return self.product_tmpl_id._get_product_accounts()
|
||||
|
||||
def _get_tax_included_unit_price(self, company, currency, document_date, document_type,
|
||||
is_refund_document=False, product_uom=None, product_currency=None,
|
||||
product_price_unit=None, product_taxes=None, fiscal_position=None
|
||||
):
|
||||
""" Helper to get the price unit from different models.
|
||||
This is needed to compute the same unit price in different models (sale order, account move, etc.) with same parameters.
|
||||
"""
|
||||
|
||||
product = self
|
||||
|
||||
assert document_type
|
||||
|
||||
if product_uom is None:
|
||||
product_uom = product.uom_id
|
||||
if not product_currency:
|
||||
if document_type == 'sale':
|
||||
product_currency = product.currency_id
|
||||
elif document_type == 'purchase':
|
||||
product_currency = company.currency_id
|
||||
if product_price_unit is None:
|
||||
if document_type == 'sale':
|
||||
product_price_unit = product.with_company(company).lst_price
|
||||
elif document_type == 'purchase':
|
||||
product_price_unit = product.with_company(company).standard_price
|
||||
else:
|
||||
return 0.0
|
||||
if product_taxes is None:
|
||||
if document_type == 'sale':
|
||||
product_taxes = product.taxes_id.filtered(lambda x: x.company_id == company)
|
||||
elif document_type == 'purchase':
|
||||
product_taxes = product.supplier_taxes_id.filtered(lambda x: x.company_id == company)
|
||||
# Apply unit of measure.
|
||||
if product_uom and product.uom_id != product_uom:
|
||||
product_price_unit = product.uom_id._compute_price(product_price_unit, product_uom)
|
||||
|
||||
# Apply fiscal position.
|
||||
if product_taxes and fiscal_position:
|
||||
product_price_unit = self._get_tax_included_unit_price_from_price(
|
||||
product_price_unit,
|
||||
currency,
|
||||
product_taxes,
|
||||
fiscal_position=fiscal_position,
|
||||
is_refund_document=is_refund_document,
|
||||
)
|
||||
|
||||
# Apply currency rate.
|
||||
if currency != product_currency:
|
||||
product_price_unit = product_currency._convert(product_price_unit, currency, company, document_date, round=False)
|
||||
|
||||
return product_price_unit
|
||||
|
||||
@api.model # the product is optional for `compute_all`
|
||||
def _get_tax_included_unit_price_from_price(
|
||||
self, product_price_unit, currency, product_taxes,
|
||||
fiscal_position=None,
|
||||
product_taxes_after_fp=None,
|
||||
is_refund_document=False,
|
||||
):
|
||||
if not product_taxes:
|
||||
return product_price_unit
|
||||
|
||||
if product_taxes_after_fp is None:
|
||||
if not fiscal_position:
|
||||
return product_price_unit
|
||||
|
||||
product_taxes_after_fp = fiscal_position.map_tax(product_taxes)
|
||||
|
||||
flattened_taxes_after_fp = product_taxes_after_fp._origin.flatten_taxes_hierarchy()
|
||||
flattened_taxes_before_fp = product_taxes._origin.flatten_taxes_hierarchy()
|
||||
taxes_before_included = all(tax.price_include for tax in flattened_taxes_before_fp)
|
||||
|
||||
if set(product_taxes.ids) != set(product_taxes_after_fp.ids) and taxes_before_included:
|
||||
taxes_res = flattened_taxes_before_fp.with_context(round=False, round_base=False).compute_all(
|
||||
product_price_unit,
|
||||
quantity=1.0,
|
||||
currency=currency,
|
||||
product=self,
|
||||
is_refund=is_refund_document,
|
||||
)
|
||||
product_price_unit = taxes_res['total_excluded']
|
||||
|
||||
if any(tax.price_include for tax in flattened_taxes_after_fp):
|
||||
taxes_res = flattened_taxes_after_fp.with_context(round=False, round_base=False).compute_all(
|
||||
product_price_unit,
|
||||
quantity=1.0,
|
||||
currency=currency,
|
||||
product=self,
|
||||
is_refund=is_refund_document,
|
||||
handle_price_include=False,
|
||||
)
|
||||
for tax_res in taxes_res['taxes']:
|
||||
tax = self.env['account.tax'].browse(tax_res['id'])
|
||||
if tax.price_include:
|
||||
product_price_unit += tax_res['amount']
|
||||
|
||||
return product_price_unit
|
||||
|
||||
@api.depends('lst_price', 'product_tmpl_id', 'taxes_id')
|
||||
@api.depends_context('company')
|
||||
def _compute_tax_string(self):
|
||||
for record in self:
|
||||
record.tax_string = record.product_tmpl_id._construct_tax_string(record.lst_price)
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
has_accounting_entries = fields.Boolean(compute='_compute_has_chart_of_accounts')
|
||||
currency_id = fields.Many2one('res.currency', related="company_id.currency_id", required=True, readonly=False,
|
||||
string='Currency', help="Main currency of the company.")
|
||||
currency_exchange_journal_id = fields.Many2one(
|
||||
comodel_name='account.journal',
|
||||
related='company_id.currency_exchange_journal_id', readonly=False,
|
||||
string="Currency Exchange Journal",
|
||||
domain="[('company_id', '=', company_id), ('type', '=', 'general')]",
|
||||
help='The accounting journal where automatic exchange differences will be registered')
|
||||
income_currency_exchange_account_id = fields.Many2one(
|
||||
comodel_name="account.account",
|
||||
related="company_id.income_currency_exchange_account_id",
|
||||
string="Gain Account",
|
||||
readonly=False,
|
||||
domain="[('deprecated', '=', False), ('company_id', '=', company_id),\
|
||||
('internal_group', '=', 'income')]")
|
||||
expense_currency_exchange_account_id = fields.Many2one(
|
||||
comodel_name="account.account",
|
||||
related="company_id.expense_currency_exchange_account_id",
|
||||
string="Loss Account",
|
||||
readonly=False,
|
||||
domain="[('deprecated', '=', False), ('company_id', '=', company_id),\
|
||||
('account_type', '=', 'expense')]")
|
||||
has_chart_of_accounts = fields.Boolean(compute='_compute_has_chart_of_accounts', string='Company has a chart of accounts')
|
||||
chart_template_id = fields.Many2one('account.chart.template', string='Template', default=lambda self: self.env.company.chart_template_id,
|
||||
domain="[('visible','=', True)]")
|
||||
sale_tax_id = fields.Many2one('account.tax', string="Default Sale Tax", related='company_id.account_sale_tax_id', readonly=False)
|
||||
purchase_tax_id = fields.Many2one('account.tax', string="Default Purchase Tax", related='company_id.account_purchase_tax_id', readonly=False)
|
||||
tax_calculation_rounding_method = fields.Selection(
|
||||
related='company_id.tax_calculation_rounding_method', string='Tax calculation rounding method', readonly=False)
|
||||
account_journal_suspense_account_id = fields.Many2one(
|
||||
comodel_name='account.account',
|
||||
string='Bank Suspense Account',
|
||||
readonly=False,
|
||||
related='company_id.account_journal_suspense_account_id',
|
||||
domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('account_type', 'in', ('asset_current', 'liability_current'))]",
|
||||
help='Bank Transactions are posted immediately after import or synchronization. '
|
||||
'Their counterparty is the bank suspense account.\n'
|
||||
'Reconciliation replaces the latter by the definitive account(s).')
|
||||
account_journal_payment_debit_account_id = fields.Many2one(
|
||||
comodel_name='account.account',
|
||||
string='Outstanding Receipts Account',
|
||||
readonly=False,
|
||||
related='company_id.account_journal_payment_debit_account_id',
|
||||
domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('account_type', '=', 'asset_current')]",
|
||||
help='Incoming payments are posted on an Outstanding Receipts Account. '
|
||||
'In the bank reconciliation widget, they appear as blue lines.\n'
|
||||
'Bank transactions are then reconciled on the Outstanding Receipts Accounts rather than the Receivable '
|
||||
'Account.')
|
||||
account_journal_payment_credit_account_id = fields.Many2one(
|
||||
comodel_name='account.account',
|
||||
string='Outstanding Payments Account',
|
||||
readonly=False,
|
||||
related='company_id.account_journal_payment_credit_account_id',
|
||||
domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('account_type', '=', 'asset_current')]",
|
||||
help='Outgoing Payments are posted on an Outstanding Payments Account. '
|
||||
'In the bank reconciliation widget, they appear as blue lines.\n'
|
||||
'Bank transactions are then reconciled on the Outstanding Payments Account rather the Payable Account.')
|
||||
transfer_account_id = fields.Many2one('account.account', string="Internal Transfer Account",
|
||||
related='company_id.transfer_account_id', readonly=False,
|
||||
domain=[
|
||||
('reconcile', '=', True),
|
||||
('account_type', '=', 'asset_current'),
|
||||
('deprecated', '=', False),
|
||||
],
|
||||
help="Intermediary account used when moving from a liquidity account to another.")
|
||||
module_account_accountant = fields.Boolean(string='Accounting')
|
||||
group_warning_account = fields.Boolean(string="Warnings in Invoices", implied_group='account.group_warning_account')
|
||||
group_cash_rounding = fields.Boolean(string="Cash Rounding", implied_group='account.group_cash_rounding')
|
||||
# group_show_line_subtotals_tax_excluded and group_show_line_subtotals_tax_included are opposite,
|
||||
# so we can assume exactly one of them will be set, and not the other.
|
||||
# We need both of them to coexist so we can take advantage of automatic group assignation.
|
||||
group_show_line_subtotals_tax_excluded = fields.Boolean(
|
||||
"Show line subtotals without taxes (B2B)",
|
||||
implied_group='account.group_show_line_subtotals_tax_excluded',
|
||||
group='base.group_portal,base.group_user,base.group_public',
|
||||
compute='_compute_group_show_line_subtotals', store=True, readonly=False)
|
||||
group_show_line_subtotals_tax_included = fields.Boolean(
|
||||
"Show line subtotals with taxes (B2C)",
|
||||
implied_group='account.group_show_line_subtotals_tax_included',
|
||||
group='base.group_portal,base.group_user,base.group_public',
|
||||
compute='_compute_group_show_line_subtotals', store=True, readonly=False)
|
||||
group_show_sale_receipts = fields.Boolean(string='Sale Receipt',
|
||||
implied_group='account.group_sale_receipts')
|
||||
group_show_purchase_receipts = fields.Boolean(string='Purchase Receipt',
|
||||
implied_group='account.group_purchase_receipts')
|
||||
show_line_subtotals_tax_selection = fields.Selection([
|
||||
('tax_excluded', 'Tax Excluded'),
|
||||
('tax_included', 'Tax Included')], string="Line Subtotals Tax Display",
|
||||
required=True, default='tax_excluded',
|
||||
config_parameter='account.show_line_subtotals_tax_selection')
|
||||
module_account_budget = fields.Boolean(string='Budget Management')
|
||||
module_account_payment = fields.Boolean(string='Invoice Online Payment')
|
||||
module_account_reports = fields.Boolean("Dynamic Reports")
|
||||
module_account_check_printing = fields.Boolean("Allow check printing and deposits")
|
||||
module_account_batch_payment = fields.Boolean(string='Use batch payments',
|
||||
help='This allows you grouping payments into a single batch and eases the reconciliation process.\n'
|
||||
'-This installs the account_batch_payment module.')
|
||||
module_account_sepa = fields.Boolean(string='SEPA Credit Transfer (SCT)')
|
||||
module_account_sepa_direct_debit = fields.Boolean(string='Use SEPA Direct Debit')
|
||||
module_account_bank_statement_import_qif = fields.Boolean("Import .qif files")
|
||||
module_account_bank_statement_import_ofx = fields.Boolean("Import in .ofx format")
|
||||
module_account_bank_statement_import_csv = fields.Boolean("Import in .csv format")
|
||||
module_account_bank_statement_import_camt = fields.Boolean("Import in CAMT.053 format")
|
||||
module_currency_rate_live = fields.Boolean(string="Automatic Currency Rates")
|
||||
module_account_intrastat = fields.Boolean(string='Intrastat')
|
||||
module_product_margin = fields.Boolean(string="Allow Product Margin")
|
||||
module_l10n_eu_oss = fields.Boolean(string="EU Intra-community Distance Selling")
|
||||
module_account_taxcloud = fields.Boolean(string="Account TaxCloud")
|
||||
module_account_invoice_extract = fields.Boolean(string="Document Digitization")
|
||||
module_snailmail_account = fields.Boolean(string="Snailmail")
|
||||
tax_exigibility = fields.Boolean(string='Cash Basis', related='company_id.tax_exigibility', readonly=False)
|
||||
tax_cash_basis_journal_id = fields.Many2one('account.journal', related='company_id.tax_cash_basis_journal_id', string="Tax Cash Basis Journal", readonly=False)
|
||||
account_cash_basis_base_account_id = fields.Many2one(
|
||||
comodel_name='account.account',
|
||||
string="Base Tax Received Account",
|
||||
readonly=False,
|
||||
related='company_id.account_cash_basis_base_account_id',
|
||||
domain=[('deprecated', '=', False)])
|
||||
account_fiscal_country_id = fields.Many2one(string="Fiscal Country Code", related="company_id.account_fiscal_country_id", readonly=False, store=False)
|
||||
|
||||
qr_code = fields.Boolean(string='Display SEPA QR-code', related='company_id.qr_code', readonly=False)
|
||||
invoice_is_print = fields.Boolean(string='Print', related='company_id.invoice_is_print', readonly=False)
|
||||
invoice_is_email = fields.Boolean(string='Send Email', related='company_id.invoice_is_email', readonly=False)
|
||||
incoterm_id = fields.Many2one('account.incoterms', string='Default incoterm', related='company_id.incoterm_id', help='International Commercial Terms are a series of predefined commercial terms used in international transactions.', readonly=False)
|
||||
invoice_terms = fields.Html(related='company_id.invoice_terms', string="Terms & Conditions", readonly=False)
|
||||
invoice_terms_html = fields.Html(related='company_id.invoice_terms_html', string="Terms & Conditions as a Web page",
|
||||
readonly=False)
|
||||
terms_type = fields.Selection(
|
||||
related='company_id.terms_type', readonly=False)
|
||||
preview_ready = fields.Boolean(string="Display preview button", compute='_compute_terms_preview')
|
||||
|
||||
use_invoice_terms = fields.Boolean(
|
||||
string='Default Terms & Conditions',
|
||||
config_parameter='account.use_invoice_terms')
|
||||
account_use_credit_limit = fields.Boolean(
|
||||
string="Sales Credit Limit", related="company_id.account_use_credit_limit", readonly=False,
|
||||
help="Enable the use of credit limit on partners.")
|
||||
account_default_credit_limit = fields.Monetary(
|
||||
string="Default Credit Limit", readonly=False,
|
||||
help='This is the default credit limit that will be used on partners that do not have a specific limit on them.',
|
||||
compute="_compute_account_default_credit_limit", inverse="_inverse_account_default_credit_limit")
|
||||
|
||||
# Technical field to hide country specific fields from accounting configuration
|
||||
country_code = fields.Char(related='company_id.account_fiscal_country_id.code', readonly=True)
|
||||
|
||||
# Storno Accounting
|
||||
account_storno = fields.Boolean(string="Storno accounting", readonly=False, related='company_id.account_storno')
|
||||
|
||||
# Allows for the use of a different delivery address
|
||||
group_sale_delivery_address = fields.Boolean("Customer Addresses", implied_group='account.group_delivery_invoice_address')
|
||||
|
||||
# Quick encoding (fiduciary mode)
|
||||
quick_edit_mode = fields.Selection(string="Quick encoding", readonly=False, related='company_id.quick_edit_mode')
|
||||
|
||||
early_pay_discount_computation = fields.Selection(related='company_id.early_pay_discount_computation', string='Tax setting', readonly=False)
|
||||
account_journal_early_pay_discount_loss_account_id = fields.Many2one(
|
||||
comodel_name='account.account',
|
||||
string='Cash Discount Loss account',
|
||||
help='Account for the difference amount after the expense discount has been granted',
|
||||
readonly=False,
|
||||
related='company_id.account_journal_early_pay_discount_loss_account_id',
|
||||
domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('account_type', 'in', ('expense', 'income', 'income_other'))]",
|
||||
)
|
||||
account_journal_early_pay_discount_gain_account_id = fields.Many2one(
|
||||
comodel_name='account.account',
|
||||
string='Cash Discount Gain account',
|
||||
help='Account for the difference amount after the income discount has been granted',
|
||||
readonly=False,
|
||||
related='company_id.account_journal_early_pay_discount_gain_account_id',
|
||||
domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('account_type', 'in', ('income', 'income_other', 'expense'))]",
|
||||
)
|
||||
|
||||
def set_values(self):
|
||||
super().set_values()
|
||||
# install a chart of accounts for the given company (if required)
|
||||
if self.env.company == self.company_id \
|
||||
and self.chart_template_id \
|
||||
and self.chart_template_id != self.company_id.chart_template_id:
|
||||
self.chart_template_id._load(self.env.company)
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_account_default_credit_limit(self):
|
||||
for setting in self:
|
||||
setting.account_default_credit_limit = self.env['ir.property']._get('credit_limit', 'res.partner')
|
||||
|
||||
def _inverse_account_default_credit_limit(self):
|
||||
for setting in self:
|
||||
self.env['ir.property']._set_default(
|
||||
'credit_limit',
|
||||
'res.partner',
|
||||
setting.account_default_credit_limit,
|
||||
setting.company_id.id
|
||||
)
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_has_chart_of_accounts(self):
|
||||
self.has_chart_of_accounts = bool(self.company_id.chart_template_id)
|
||||
self.has_accounting_entries = self.env['account.chart.template'].existing_accounting(self.company_id)
|
||||
|
||||
@api.depends('show_line_subtotals_tax_selection')
|
||||
def _compute_group_show_line_subtotals(self):
|
||||
for wizard in self:
|
||||
wizard.group_show_line_subtotals_tax_included = wizard.show_line_subtotals_tax_selection == "tax_included"
|
||||
wizard.group_show_line_subtotals_tax_excluded = wizard.show_line_subtotals_tax_selection == "tax_excluded"
|
||||
|
||||
@api.onchange('group_analytic_accounting')
|
||||
def onchange_analytic_accounting(self):
|
||||
if self.group_analytic_accounting:
|
||||
self.module_account_accountant = True
|
||||
|
||||
@api.onchange('module_account_budget')
|
||||
def onchange_module_account_budget(self):
|
||||
if self.module_account_budget:
|
||||
self.group_analytic_accounting = True
|
||||
|
||||
@api.onchange('tax_exigibility')
|
||||
def _onchange_tax_exigibility(self):
|
||||
res = {}
|
||||
tax = self.env['account.tax'].search([
|
||||
('company_id', '=', self.env.company.id), ('tax_exigibility', '=', 'on_payment')
|
||||
], limit=1)
|
||||
if not self.tax_exigibility and tax:
|
||||
self.tax_exigibility = True
|
||||
res['warning'] = {
|
||||
'title': _('Error!'),
|
||||
'message': _('You cannot disable this setting because some of your taxes are cash basis. '
|
||||
'Modify your taxes first before disabling this setting.')
|
||||
}
|
||||
return res
|
||||
|
||||
@api.depends('terms_type')
|
||||
def _compute_terms_preview(self):
|
||||
for setting in self:
|
||||
# We display the preview button only if the terms_type is html in the setting but also on the company
|
||||
# to avoid landing on an error page (see terms.py controller)
|
||||
setting.preview_ready = self.env.company.terms_type == 'html' and setting.terms_type == 'html'
|
||||
|
||||
def action_update_terms(self):
|
||||
self.ensure_one()
|
||||
if hasattr(self, 'website_id') and self.env.user.has_group('website.group_website_designer'):
|
||||
return self.env["website"].get_client_action('/terms', True)
|
||||
return {
|
||||
'name': _('Update Terms & Conditions'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'res.company',
|
||||
'view_id': self.env.ref("account.res_company_view_form_terms", False).id,
|
||||
'target': 'new',
|
||||
'res_id': self.company_id.id,
|
||||
}
|
||||
65
odoo-bringout-oca-ocb-account/account/models/res_currency.py
Normal file
65
odoo-bringout-oca-ocb-account/account/models/res_currency.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models, fields, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class ResCurrency(models.Model):
|
||||
_inherit = 'res.currency'
|
||||
|
||||
display_rounding_warning = fields.Boolean(string="Display Rounding Warning", compute='_compute_display_rounding_warning',
|
||||
help="The warning informs a rounding factor change might be dangerous on res.currency's form view.")
|
||||
|
||||
|
||||
@api.depends('rounding')
|
||||
def _compute_display_rounding_warning(self):
|
||||
for record in self:
|
||||
record.display_rounding_warning = record.id \
|
||||
and record._origin.rounding != record.rounding \
|
||||
and record._origin._has_accounting_entries()
|
||||
|
||||
def write(self, vals):
|
||||
if 'rounding' in vals:
|
||||
rounding_val = vals['rounding']
|
||||
for record in self:
|
||||
if (rounding_val > record.rounding or rounding_val == 0) and record._has_accounting_entries():
|
||||
raise UserError(_("You cannot reduce the number of decimal places of a currency which has already been used to make accounting entries."))
|
||||
|
||||
return super(ResCurrency, self).write(vals)
|
||||
|
||||
def _has_accounting_entries(self):
|
||||
""" Returns True iff this currency has been used to generate (hence, round)
|
||||
some move lines (either as their foreign currency, or as the main currency).
|
||||
"""
|
||||
self.ensure_one()
|
||||
return bool(self.env['account.move.line'].sudo().search_count(['|', ('currency_id', '=', self.id), ('company_currency_id', '=', self.id)]))
|
||||
|
||||
@api.model
|
||||
def _get_query_currency_table(self, options):
|
||||
''' Construct the currency table as a mapping company -> rate to convert the amount to the user's company
|
||||
currency in a multi-company/multi-currency environment.
|
||||
The currency_table is a small postgresql table construct with VALUES.
|
||||
:param options: The report options.
|
||||
:return: The query representing the currency table.
|
||||
'''
|
||||
|
||||
user_company = self.env.company
|
||||
user_currency = user_company.currency_id
|
||||
if options.get('multi_company', False):
|
||||
companies = self.env.companies
|
||||
conversion_date = options['date']['date_to']
|
||||
currency_rates = companies.mapped('currency_id')._get_rates(user_company, conversion_date)
|
||||
else:
|
||||
companies = user_company
|
||||
currency_rates = {user_currency.id: 1.0}
|
||||
|
||||
conversion_rates = []
|
||||
for company in companies:
|
||||
conversion_rates.extend((
|
||||
company.id,
|
||||
currency_rates[user_company.currency_id.id] / currency_rates[company.currency_id.id],
|
||||
user_currency.decimal_places,
|
||||
))
|
||||
query = '(VALUES %s) AS currency_table(company_id, rate, precision)' % ','.join('(%s, %s, %s)' for i in companies)
|
||||
return self.env.cr.mogrify(query, conversion_rates).decode(self.env.cr.connection.encoding)
|
||||
224
odoo-bringout-oca-ocb-account/account/models/res_partner_bank.py
Normal file
224
odoo-bringout-oca-ocb-account/account/models/res_partner_bank.py
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import base64
|
||||
from collections import defaultdict
|
||||
|
||||
import werkzeug
|
||||
import werkzeug.exceptions
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.tools.image import image_data_uri
|
||||
|
||||
|
||||
class ResPartnerBank(models.Model):
|
||||
_name = 'res.partner.bank'
|
||||
_inherit = ['res.partner.bank', 'mail.thread', 'mail.activity.mixin']
|
||||
|
||||
journal_id = fields.One2many(
|
||||
'account.journal', 'bank_account_id', domain=[('type', '=', 'bank')], string='Account Journal', readonly=True,
|
||||
help="The accounting journal corresponding to this bank account.")
|
||||
|
||||
# Add tracking to the base fields
|
||||
bank_id = fields.Many2one(tracking=True)
|
||||
active = fields.Boolean(tracking=True)
|
||||
acc_number = fields.Char(tracking=True)
|
||||
acc_holder_name = fields.Char(tracking=True)
|
||||
partner_id = fields.Many2one(tracking=True)
|
||||
allow_out_payment = fields.Boolean(tracking=True)
|
||||
currency_id = fields.Many2one(tracking=True)
|
||||
|
||||
@api.constrains('journal_id')
|
||||
def _check_journal_id(self):
|
||||
for bank in self:
|
||||
if len(bank.journal_id) > 1:
|
||||
raise ValidationError(_('A bank account can belong to only one journal.'))
|
||||
|
||||
def _build_qr_code_vals(self, amount, free_communication, structured_communication, currency, debtor_partner, qr_method=None, silent_errors=True):
|
||||
""" Returns the QR-code vals needed to generate the QR-code report link to pay this account with the given parameters,
|
||||
or None if no QR-code could be generated.
|
||||
|
||||
:param amount: The amount to be paid
|
||||
:param free_communication: Free communication to add to the payment when generating one with the QR-code
|
||||
:param structured_communication: Structured communication to add to the payment when generating one with the QR-code
|
||||
:param currency: The currency in which amount is expressed
|
||||
:param debtor_partner: The partner to which this QR-code is aimed (so the one who will have to pay)
|
||||
:param qr_method: The QR generation method to be used to make the QR-code. If None, the first one giving a result will be used.
|
||||
:param silent_errors: If true, forbids errors to be raised if some tested QR-code format can't be generated because of incorrect data.
|
||||
"""
|
||||
if not self:
|
||||
return None
|
||||
|
||||
self.ensure_one()
|
||||
|
||||
if not currency:
|
||||
raise UserError(_("Currency must always be provided in order to generate a QR-code"))
|
||||
|
||||
available_qr_methods = self.get_available_qr_methods_in_sequence()
|
||||
candidate_methods = qr_method and [(qr_method, dict(available_qr_methods)[qr_method])] or available_qr_methods
|
||||
for candidate_method, candidate_name in candidate_methods:
|
||||
if self._eligible_for_qr_code(candidate_method, debtor_partner, currency, not silent_errors):
|
||||
error_message = self._check_for_qr_code_errors(candidate_method, amount, currency, debtor_partner, free_communication, structured_communication)
|
||||
|
||||
if not error_message:
|
||||
return {
|
||||
'qr_method': candidate_method,
|
||||
'amount': amount,
|
||||
'currency': currency,
|
||||
'debtor_partner': debtor_partner,
|
||||
'free_communication': free_communication,
|
||||
'structured_communication': structured_communication,
|
||||
}
|
||||
|
||||
elif not silent_errors:
|
||||
error_header = _("The following error prevented '%s' QR-code to be generated though it was detected as eligible: ", candidate_name)
|
||||
raise UserError(error_header + error_message)
|
||||
|
||||
return None
|
||||
|
||||
def build_qr_code_url(self, amount, free_communication, structured_communication, currency, debtor_partner, qr_method=None, silent_errors=True):
|
||||
vals = self._build_qr_code_vals(amount, free_communication, structured_communication, currency, debtor_partner, qr_method, silent_errors)
|
||||
if vals:
|
||||
return self._get_qr_code_url(**vals)
|
||||
return None
|
||||
|
||||
def build_qr_code_base64(self, amount, free_communication, structured_communication, currency, debtor_partner, qr_method=None, silent_errors=True):
|
||||
vals = self._build_qr_code_vals(amount, free_communication, structured_communication, currency, debtor_partner, qr_method, silent_errors)
|
||||
if vals:
|
||||
return self._get_qr_code_base64(**vals)
|
||||
return None
|
||||
|
||||
def _get_qr_vals(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
|
||||
return None
|
||||
|
||||
def _get_qr_code_generation_params(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
|
||||
raise NotImplementedError()
|
||||
|
||||
def _get_qr_code_url(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
|
||||
""" Hook for extension, to support the different QR generation methods.
|
||||
This function uses the provided qr_method to try generation a QR-code for
|
||||
the given data. It it succeeds, it returns the report URL to make this
|
||||
QR-code; else None.
|
||||
|
||||
:param qr_method: The QR generation method to be used to make the QR-code.
|
||||
:param amount: The amount to be paid
|
||||
:param currency: The currency in which amount is expressed
|
||||
:param debtor_partner: The partner to which this QR-code is aimed (so the one who will have to pay)
|
||||
:param free_communication: Free communication to add to the payment when generating one with the QR-code
|
||||
:param structured_communication: Structured communication to add to the payment when generating one with the QR-code
|
||||
"""
|
||||
params = self._get_qr_code_generation_params(qr_method, amount, currency, debtor_partner, free_communication, structured_communication)
|
||||
return '/report/barcode/?' + werkzeug.urls.url_encode(params) if params else None
|
||||
|
||||
def _get_qr_code_base64(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
|
||||
""" Hook for extension, to support the different QR generation methods.
|
||||
This function uses the provided qr_method to try generation a QR-code for
|
||||
the given data. It it succeeds, it returns QR code in base64 url; else None.
|
||||
|
||||
:param qr_method: The QR generation method to be used to make the QR-code.
|
||||
:param amount: The amount to be paid
|
||||
:param currency: The currency in which amount is expressed
|
||||
:param debtor_partner: The partner to which this QR-code is aimed (so the one who will have to pay)
|
||||
:param free_communication: Free communication to add to the payment when generating one with the QR-code
|
||||
:param structured_communication: Structured communication to add to the payment when generating one with the QR-code
|
||||
"""
|
||||
params = self._get_qr_code_generation_params(qr_method, amount, currency, debtor_partner, free_communication, structured_communication)
|
||||
if params:
|
||||
try:
|
||||
barcode = self.env['ir.actions.report'].barcode(**params)
|
||||
except (ValueError, AttributeError):
|
||||
raise werkzeug.exceptions.HTTPException(description='Cannot convert into barcode.')
|
||||
return image_data_uri(base64.b64encode(barcode))
|
||||
return None
|
||||
|
||||
@api.model
|
||||
def _get_available_qr_methods(self):
|
||||
""" Returns the QR-code generation methods that are available on this db,
|
||||
in the form of a list of (code, name, sequence) elements, where
|
||||
'code' is a unique string identifier, 'name' the name to display
|
||||
to the user to designate the method, and 'sequence' is a positive integer
|
||||
indicating the order in which those mehtods need to be checked, to avoid
|
||||
shadowing between them (lower sequence means more prioritary).
|
||||
"""
|
||||
return []
|
||||
|
||||
@api.model
|
||||
def get_available_qr_methods_in_sequence(self):
|
||||
""" Same as _get_available_qr_methods but without returning the sequence,
|
||||
and using it directly to order the returned list.
|
||||
"""
|
||||
all_available = self._get_available_qr_methods()
|
||||
all_available.sort(key=lambda x: x[2])
|
||||
return [(code, name) for (code, name, sequence) in all_available]
|
||||
|
||||
def _eligible_for_qr_code(self, qr_method, debtor_partner, currency, raises_error=True):
|
||||
""" Tells whether or not the criteria to apply QR-generation
|
||||
method qr_method are met for a payment on this account, in the
|
||||
given currency, by debtor_partner. This does not impeach generation errors,
|
||||
it only checks that this type of QR-code *should be* possible to generate.
|
||||
Consistency of the required field needs then to be checked by _check_for_qr_code_errors().
|
||||
"""
|
||||
return False
|
||||
|
||||
def _check_for_qr_code_errors(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
|
||||
""" Checks the data before generating a QR-code for the specified qr_method
|
||||
(this method must have been checked for eligbility by _eligible_for_qr_code() first).
|
||||
|
||||
Returns None if no error was found, or a string describing the first error encountered
|
||||
so that it can be reported to the user.
|
||||
"""
|
||||
return None
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
# EXTENDS base res.partner.bank
|
||||
res = super().create(vals_list)
|
||||
for account in res:
|
||||
msg = _("Bank Account %s created", account._get_html_link(title=f"#{account.id}"))
|
||||
account.partner_id._message_log(body=msg)
|
||||
return res
|
||||
|
||||
def write(self, vals):
|
||||
# EXTENDS base res.partner.bank
|
||||
# Track and log changes to partner_id, heavily inspired from account_move
|
||||
account_initial_values = defaultdict(dict)
|
||||
# Get all tracked fields (without related fields because these fields must be managed on their own model)
|
||||
tracking_fields = []
|
||||
for field_name in vals:
|
||||
field = self._fields[field_name]
|
||||
if not (hasattr(field, 'related') and field.related) and hasattr(field, 'tracking') and field.tracking:
|
||||
tracking_fields.append(field_name)
|
||||
fields_definition = self.env['res.partner.bank'].fields_get(tracking_fields)
|
||||
|
||||
# Get initial values for each account
|
||||
for account in self:
|
||||
for field in tracking_fields:
|
||||
# Group initial values by partner_id
|
||||
account_initial_values[account][field] = account[field]
|
||||
|
||||
res = super().write(vals)
|
||||
|
||||
# Log changes to move lines on each move
|
||||
for account, initial_values in account_initial_values.items():
|
||||
tracking_value_ids = account._mail_track(fields_definition, initial_values)[1]
|
||||
if tracking_value_ids:
|
||||
msg = _("Bank Account %s updated", account._get_html_link(title=f"#{account.id}"))
|
||||
account.partner_id._message_log(body=msg, tracking_value_ids=tracking_value_ids)
|
||||
if 'partner_id' in initial_values: # notify previous partner as well
|
||||
initial_values['partner_id']._message_log(body=msg, tracking_value_ids=tracking_value_ids)
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
# EXTENDS base res.partner.bank
|
||||
for account in self:
|
||||
msg = _("Bank Account %s with number %s deleted", account._get_html_link(title=f"#{account.id}"), account.acc_number)
|
||||
account.partner_id._message_log(body=msg)
|
||||
return super().unlink()
|
||||
|
||||
def default_get(self, fields_list):
|
||||
if 'acc_number' not in fields_list:
|
||||
return super().default_get(fields_list)
|
||||
|
||||
# When create & edit, `name` could be used to pass (in the context) the
|
||||
# value input by the user. However, we want to set the default value of
|
||||
# `acc_number` variable instead.
|
||||
default_acc_number = self._context.get('default_acc_number', False) or self._context.get('default_name', False)
|
||||
return super(ResPartnerBank, self.with_context(default_acc_number=default_acc_number)).default_get(fields_list)
|
||||
43
odoo-bringout-oca-ocb-account/account/models/res_users.py
Normal file
43
odoo-bringout-oca-ocb-account/account/models/res_users.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class Users(models.Model):
|
||||
_inherit = "res.users"
|
||||
|
||||
@api.constrains('groups_id')
|
||||
def _check_one_user_type(self):
|
||||
super(Users, self)._check_one_user_type()
|
||||
|
||||
g1 = self.env.ref('account.group_show_line_subtotals_tax_included', False)
|
||||
g2 = self.env.ref('account.group_show_line_subtotals_tax_excluded', False)
|
||||
|
||||
if not g1 or not g2:
|
||||
# A user cannot be in a non-existant group
|
||||
return
|
||||
|
||||
for user in self:
|
||||
if user._has_multiple_groups([g1.id, g2.id]):
|
||||
raise ValidationError(_("A user cannot have both Tax B2B and Tax B2C.\n"
|
||||
"You should go in General Settings, and choose to display Product Prices\n"
|
||||
"either in 'Tax-Included' or in 'Tax-Excluded' mode\n"
|
||||
"(or switch twice the mode if you are already in the desired one)."))
|
||||
|
||||
|
||||
class GroupsView(models.Model):
|
||||
_inherit = 'res.groups'
|
||||
|
||||
@api.model
|
||||
def get_application_groups(self, domain):
|
||||
# Overridden in order to remove 'Show Full Accounting Features' and
|
||||
# 'Show Full Accounting Features - Readonly' in the 'res.users' form view to prevent confusion
|
||||
group_account_user = self.env.ref('account.group_account_user', raise_if_not_found=False)
|
||||
if group_account_user and group_account_user.category_id.xml_id == 'base.module_category_hidden':
|
||||
domain += [('id', '!=', group_account_user.id)]
|
||||
group_account_readonly = self.env.ref('account.group_account_readonly', raise_if_not_found=False)
|
||||
if group_account_readonly and group_account_readonly.category_id.xml_id == 'base.module_category_hidden':
|
||||
domain += [('id', '!=', group_account_readonly.id)]
|
||||
return super().get_application_groups(domain)
|
||||
342
odoo-bringout-oca-ocb-account/account/models/sequence_mixin.py
Normal file
342
odoo-bringout-oca-ocb-account/account/models/sequence_mixin.py
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from datetime import date
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tools.misc import format_date
|
||||
from odoo.tools import frozendict, date_utils
|
||||
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from psycopg2 import sql
|
||||
|
||||
|
||||
class SequenceMixin(models.AbstractModel):
|
||||
"""Mechanism used to have an editable sequence number.
|
||||
|
||||
Be careful of how you use this regarding the prefixes. More info in the
|
||||
docstring of _get_last_sequence.
|
||||
"""
|
||||
|
||||
_name = 'sequence.mixin'
|
||||
_description = "Automatic sequence"
|
||||
|
||||
_sequence_field = "name"
|
||||
_sequence_date_field = "date"
|
||||
_sequence_index = False
|
||||
_sequence_year_range_regex = r'^(?:(?P<prefix1>.*?)(?P<year>((?<=\D)|(?<=^))((19|20|21)\d{2}|(\d{2}(?=\D))))(?P<prefix2>\D)(?P<year_end>((?<=\D)|(?<=^))((19|20|21)\d{2}|(\d{2}(?=\D))))(?P<prefix3>\D+?))?(?P<seq>\d*)(?P<suffix>\D*?)$'
|
||||
_sequence_monthly_regex = r'^(?P<prefix1>.*?)(?P<year>((?<=\D)|(?<=^))((19|20|21)\d{2}|(\d{2}(?=\D))))(?P<prefix2>\D*?)(?P<month>(0[1-9]|1[0-2]))(?P<prefix3>\D+?)(?P<seq>\d*)(?P<suffix>\D*?)$'
|
||||
_sequence_yearly_regex = r'^(?P<prefix1>.*?)(?P<year>((?<=\D)|(?<=^))((19|20|21)?\d{2}))(?P<prefix2>\D+?)(?P<seq>\d*)(?P<suffix>\D*?)$'
|
||||
_sequence_fixed_regex = r'^(?P<prefix1>.*?)(?P<seq>\d{0,9})(?P<suffix>\D*?)$'
|
||||
|
||||
sequence_prefix = fields.Char(compute='_compute_split_sequence', store=True)
|
||||
sequence_number = fields.Integer(compute='_compute_split_sequence', store=True)
|
||||
|
||||
def init(self):
|
||||
# Add an index to optimise the query searching for the highest sequence number
|
||||
if not self._abstract and self._sequence_index:
|
||||
index_name = self._table + '_sequence_index'
|
||||
self.env.cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', (index_name,))
|
||||
if not self.env.cr.fetchone():
|
||||
self.env.cr.execute(sql.SQL("""
|
||||
CREATE INDEX {index_name} ON {table} ({sequence_index}, sequence_prefix desc, sequence_number desc, {field});
|
||||
CREATE INDEX {index2_name} ON {table} ({sequence_index}, id desc, sequence_prefix);
|
||||
""").format(
|
||||
sequence_index=sql.Identifier(self._sequence_index),
|
||||
index_name=sql.Identifier(index_name),
|
||||
index2_name=sql.Identifier(index_name + "2"),
|
||||
table=sql.Identifier(self._table),
|
||||
field=sql.Identifier(self._sequence_field),
|
||||
))
|
||||
|
||||
def _get_sequence_date_range(self, reset):
|
||||
ref_date = fields.Date.to_date(self[self._sequence_date_field])
|
||||
if reset in ('year', 'year_range'):
|
||||
return (date(ref_date.year, 1, 1), date(ref_date.year, 12, 31))
|
||||
if reset == 'month':
|
||||
return date_utils.get_month(ref_date)
|
||||
if reset == 'never':
|
||||
return (date(1, 1, 1), date(9999, 1, 1))
|
||||
raise NotImplementedError(reset)
|
||||
|
||||
def _must_check_constrains_date_sequence(self):
|
||||
return True
|
||||
|
||||
def _year_match(self, format_value, date):
|
||||
return format_value == self._truncate_year_to_length(date.year, len(str(format_value)))
|
||||
|
||||
def _truncate_year_to_length(self, year, length):
|
||||
return year % (10 ** length)
|
||||
|
||||
def _sequence_matches_date(self):
|
||||
self.ensure_one()
|
||||
date = fields.Date.to_date(self[self._sequence_date_field])
|
||||
sequence = self[self._sequence_field]
|
||||
|
||||
if not sequence or not date:
|
||||
return True
|
||||
|
||||
format_values = self._get_sequence_format_param(sequence)[1]
|
||||
sequence_number_reset = self._deduce_sequence_number_reset(sequence)
|
||||
year_start, year_end = self._get_sequence_date_range(sequence_number_reset)
|
||||
year_match = (
|
||||
(not format_values["year"] or self._year_match(format_values["year"], year_start))
|
||||
and (not format_values["year_end"] or self._year_match(format_values["year_end"], year_end))
|
||||
)
|
||||
month_match = not format_values['month'] or format_values['month'] == date.month
|
||||
return year_match and month_match
|
||||
|
||||
@api.constrains(lambda self: (self._sequence_field, self._sequence_date_field))
|
||||
def _constrains_date_sequence(self):
|
||||
# Make it possible to bypass the constraint to allow edition of already messed up documents.
|
||||
# /!\ Do not use this to completely disable the constraint as it will make this mixin unreliable.
|
||||
constraint_date = fields.Date.to_date(self.env['ir.config_parameter'].sudo().get_param(
|
||||
'sequence.mixin.constraint_start_date',
|
||||
'1970-01-01'
|
||||
))
|
||||
for record in self:
|
||||
if not record._must_check_constrains_date_sequence():
|
||||
continue
|
||||
date = fields.Date.to_date(record[record._sequence_date_field])
|
||||
sequence = record[record._sequence_field]
|
||||
if (
|
||||
sequence
|
||||
and date
|
||||
and date > constraint_date
|
||||
and not record._sequence_matches_date()
|
||||
):
|
||||
raise ValidationError(_(
|
||||
"The %(date_field)s (%(date)s) doesn't match the sequence number of the related %(model)s (%(sequence)s)\n"
|
||||
"You will need to clear the %(model)s's %(sequence_field)s to proceed.\n"
|
||||
"In doing so, you might want to resequence your entries in order to maintain a continuous date-based sequence.",
|
||||
date=format_date(self.env, date),
|
||||
sequence=sequence,
|
||||
date_field=record._fields[record._sequence_date_field]._description_string(self.env),
|
||||
sequence_field=record._fields[record._sequence_field]._description_string(self.env),
|
||||
model=self.env['ir.model']._get(record._name).display_name,
|
||||
))
|
||||
|
||||
@api.depends(lambda self: [self._sequence_field])
|
||||
def _compute_split_sequence(self):
|
||||
for record in self:
|
||||
sequence = record[record._sequence_field] or ''
|
||||
regex = re.sub(r"\?P<\w+>", "?:", record._sequence_fixed_regex.replace(r"?P<seq>", "")) # make the seq the only matching group
|
||||
matching = re.match(regex, sequence)
|
||||
record.sequence_prefix = sequence[:matching.start(1)]
|
||||
record.sequence_number = int(matching.group(1) or 0)
|
||||
|
||||
@api.model
|
||||
def _deduce_sequence_number_reset(self, name):
|
||||
"""Detect if the used sequence resets yearly, montly or never.
|
||||
|
||||
:param name: the sequence that is used as a reference to detect the resetting
|
||||
periodicity. Typically, it is the last before the one you want to give a
|
||||
sequence.
|
||||
"""
|
||||
for regex, ret_val, requirements in [
|
||||
(self._sequence_monthly_regex, 'month', ['seq', 'month', 'year']),
|
||||
(self._sequence_year_range_regex, 'year_range', ['seq', 'year', 'year_end']),
|
||||
(self._sequence_yearly_regex, 'year', ['seq', 'year']),
|
||||
(self._sequence_fixed_regex, 'never', ['seq']),
|
||||
]:
|
||||
match = re.match(regex, name or '')
|
||||
if match:
|
||||
groupdict = match.groupdict()
|
||||
if (
|
||||
groupdict.get('year_end') and groupdict.get('year')
|
||||
and (
|
||||
len(groupdict['year']) < len(groupdict['year_end'])
|
||||
or self._truncate_year_to_length((int(groupdict['year']) + 1), len(groupdict['year_end'])) != int(groupdict['year_end'])
|
||||
)
|
||||
):
|
||||
# year and year_end are not compatible for range (the difference is not 1)
|
||||
continue
|
||||
if all(groupdict.get(req) is not None for req in requirements):
|
||||
return ret_val
|
||||
raise ValidationError(_(
|
||||
'The sequence regex should at least contain the seq grouping keys. For instance:\n'
|
||||
r'^(?P<prefix1>.*?)(?P<seq>\d*)(?P<suffix>\D*?)$'
|
||||
))
|
||||
|
||||
def _get_last_sequence_domain(self, relaxed=False):
|
||||
"""Get the sql domain to retreive the previous sequence number.
|
||||
|
||||
This function should be overriden by models inheriting from this mixin.
|
||||
|
||||
:param relaxed: see _get_last_sequence.
|
||||
|
||||
:returns: tuple(where_string, where_params): with
|
||||
where_string: the entire SQL WHERE clause as a string.
|
||||
where_params: a dictionary containing the parameters to substitute
|
||||
at the execution of the query.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return "", {}
|
||||
|
||||
def _get_starting_sequence(self):
|
||||
"""Get a default sequence number.
|
||||
|
||||
This function should be overriden by models heriting from this mixin
|
||||
This number will be incremented so you probably want to start the sequence at 0.
|
||||
|
||||
:return: string to use as the default sequence to increment
|
||||
"""
|
||||
self.ensure_one()
|
||||
return "00000000"
|
||||
|
||||
def _get_last_sequence(self, relaxed=False, with_prefix=None, lock=True):
|
||||
"""Retrieve the previous sequence.
|
||||
|
||||
This is done by taking the number with the greatest alphabetical value within
|
||||
the domain of _get_last_sequence_domain. This means that the prefix has a
|
||||
huge importance.
|
||||
For instance, if you have INV/2019/0001 and INV/2019/0002, when you rename the
|
||||
last one to FACT/2019/0001, one might expect the next number to be
|
||||
FACT/2019/0002 but it will be INV/2019/0002 (again) because INV > FACT.
|
||||
Therefore, changing the prefix might not be convenient during a period, and
|
||||
would only work when the numbering makes a new start (domain returns by
|
||||
_get_last_sequence_domain is [], i.e: a new year).
|
||||
|
||||
:param field_name: the field that contains the sequence.
|
||||
:param relaxed: this should be set to True when a previous request didn't find
|
||||
something without. This allows to find a pattern from a previous period, and
|
||||
try to adapt it for the new period.
|
||||
:param with_prefix: The sequence prefix to restrict the search on, if any.
|
||||
|
||||
:return: the string of the previous sequence or None if there wasn't any.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self._sequence_field not in self._fields or not self._fields[self._sequence_field].store:
|
||||
raise ValidationError(_('%s is not a stored field', self._sequence_field))
|
||||
where_string, param = self._get_last_sequence_domain(relaxed)
|
||||
if self._origin.id:
|
||||
where_string += " AND id != %(id)s "
|
||||
param['id'] = self._origin.id
|
||||
if with_prefix is not None:
|
||||
where_string += " AND sequence_prefix = %(with_prefix)s "
|
||||
param['with_prefix'] = with_prefix
|
||||
|
||||
query = f"""
|
||||
SELECT {{field}} FROM {self._table}
|
||||
{where_string}
|
||||
AND sequence_prefix = (SELECT sequence_prefix FROM {self._table} {where_string} ORDER BY id DESC LIMIT 1)
|
||||
ORDER BY sequence_number DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
if lock:
|
||||
query = f"""
|
||||
UPDATE {self._table} SET write_date = write_date WHERE id = (
|
||||
{query.format(field='id')}
|
||||
)
|
||||
RETURNING {self._sequence_field};
|
||||
"""
|
||||
else:
|
||||
query = query.format(field=self._sequence_field)
|
||||
|
||||
self.flush_model([self._sequence_field, 'sequence_number', 'sequence_prefix'])
|
||||
self.env.cr.execute(query, param)
|
||||
return (self.env.cr.fetchone() or [None])[0]
|
||||
|
||||
def _get_sequence_format_param(self, previous):
|
||||
"""Get the python format and format values for the sequence.
|
||||
|
||||
:param previous: the sequence we want to extract the format from
|
||||
:return tuple(format, format_values):
|
||||
format is the format string on which we should call .format()
|
||||
format_values is the dict of values to format the `format` string
|
||||
``format.format(**format_values)`` should be equal to ``previous``
|
||||
"""
|
||||
sequence_number_reset = self._deduce_sequence_number_reset(previous)
|
||||
regex = self._sequence_fixed_regex
|
||||
if sequence_number_reset == 'year':
|
||||
regex = self._sequence_yearly_regex
|
||||
elif sequence_number_reset == 'year_range':
|
||||
regex = self._sequence_year_range_regex
|
||||
elif sequence_number_reset == 'month':
|
||||
regex = self._sequence_monthly_regex
|
||||
format_values = re.match(regex, previous).groupdict()
|
||||
format_values['seq_length'] = len(format_values['seq'])
|
||||
format_values['year_length'] = len(format_values.get('year') or '')
|
||||
format_values['year_end_length'] = len(format_values.get('year_end') or '')
|
||||
if not format_values.get('seq') and 'prefix1' in format_values and 'suffix' in format_values:
|
||||
# if we don't have a seq, consider we only have a prefix and not a suffix
|
||||
format_values['prefix1'] = format_values['suffix']
|
||||
format_values['suffix'] = ''
|
||||
for field in ('seq', 'year', 'month', 'year_end'):
|
||||
format_values[field] = int(format_values.get(field) or 0)
|
||||
|
||||
placeholders = re.findall(r'\b(prefix\d|seq|suffix\d?|year|year_end|month)\b', regex)
|
||||
format = ''.join(
|
||||
"{seq:0{seq_length}d}" if s == 'seq' else
|
||||
"{month:02d}" if s == 'month' else
|
||||
"{year:0{year_length}d}" if s == 'year' else
|
||||
"{year_end:0{year_end_length}d}" if s == 'year_end' else
|
||||
"{%s}" % s
|
||||
for s in placeholders
|
||||
)
|
||||
return format, format_values
|
||||
|
||||
def _set_next_sequence(self):
|
||||
"""Set the next sequence.
|
||||
|
||||
This method ensures that the field is set both in the ORM and in the database.
|
||||
This is necessary because we use a database query to get the previous sequence,
|
||||
and we need that query to always be executed on the latest data.
|
||||
|
||||
:param field_name: the field that contains the sequence.
|
||||
"""
|
||||
self.ensure_one()
|
||||
last_sequence = self._get_last_sequence()
|
||||
new = not last_sequence
|
||||
if new:
|
||||
last_sequence = self._get_last_sequence(relaxed=True) or self._get_starting_sequence()
|
||||
|
||||
format, format_values = self._get_sequence_format_param(last_sequence)
|
||||
sequence_number_reset = self._deduce_sequence_number_reset(last_sequence)
|
||||
if new:
|
||||
date_start, date_end = self._get_sequence_date_range(sequence_number_reset)
|
||||
format_values['seq'] = 0
|
||||
format_values['year'] = self._truncate_year_to_length(date_start.year, format_values['year_length'])
|
||||
format_values['year_end'] = self._truncate_year_to_length(date_end.year, format_values['year_end_length'])
|
||||
format_values['month'] = date_start.month
|
||||
format_values['seq'] = format_values['seq'] + 1
|
||||
self[self._sequence_field] = format.format(**format_values)
|
||||
self._compute_split_sequence()
|
||||
|
||||
def _is_last_from_seq_chain(self):
|
||||
"""Tells whether or not this element is the last one of the sequence chain.
|
||||
|
||||
:return: True if it is the last element of the chain.
|
||||
"""
|
||||
last_sequence = self._get_last_sequence(with_prefix=self.sequence_prefix)
|
||||
if not last_sequence:
|
||||
return True
|
||||
seq_format, seq_format_values = self._get_sequence_format_param(last_sequence)
|
||||
seq_format_values['seq'] += 1
|
||||
return seq_format.format(**seq_format_values) == self.name
|
||||
|
||||
def _is_end_of_seq_chain(self):
|
||||
"""Tells whether or not these elements are the last ones of the sequence chain.
|
||||
|
||||
:return: True if self are the last elements of the chain.
|
||||
"""
|
||||
batched = defaultdict(lambda: {'last_rec': self.browse(), 'seq_list': []})
|
||||
for record in self.filtered(lambda x: x[x._sequence_field]):
|
||||
seq_format, format_values = record._get_sequence_format_param(record[record._sequence_field])
|
||||
seq = format_values.pop('seq')
|
||||
batch = batched[(seq_format, frozendict(format_values))]
|
||||
batch['seq_list'].append(seq)
|
||||
if batch['last_rec'].sequence_number <= record.sequence_number:
|
||||
batch['last_rec'] = record
|
||||
|
||||
for values in batched.values():
|
||||
# The sequences we are deleting are not sequential
|
||||
seq_list = values['seq_list']
|
||||
if max(seq_list) - min(seq_list) != len(seq_list) - 1:
|
||||
return False
|
||||
|
||||
# last_rec must have the highest number in the database
|
||||
record = values['last_rec']
|
||||
if not record._is_last_from_seq_chain():
|
||||
return False
|
||||
return True
|
||||
Loading…
Add table
Add a link
Reference in a new issue