You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

501 lines
24 KiB
Python

import ast
from collections import defaultdict
from contextlib import contextmanager
from datetime import date, timedelta
from functools import lru_cache
from odoo import api, fields, models, Command, _
from odoo.exceptions import ValidationError, UserError
from odoo.tools import frozendict, formatLang, format_date, float_is_zero, float_compare
from odoo.tools.sql import create_index
from odoo.addons.web.controllers.utils import clean_action
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
# _inherit = "analytic.mixin"
# _description = "Journal Item"
# _order = "date desc, move_name desc, id"
# _check_company_auto = True
# _rec_names_search = ['name', 'move_id', 'product_id']
exclude_from_invoice_tab = fields.Boolean(
help="Technical field used to exclude some lines from the invoice_line_ids tab in the form view.")
recompute_tax_line = fields.Boolean(store=False, readonly=True,
help="Technical field used to know on which lines the taxes must be recomputed.")
analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account',
index=True, compute="_compute_analytic_account", store=True, readonly=False,copy=True)
analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags',
compute="_compute_analytic_account", store=True, readonly=False, copy=True)
@api.model
def _get_default_line_name(self, document, amount, currency, date, partner=None):
''' 新增Helper to construct a default label to set on journal items.
E.g. Vendor Reimbursement $ 1,555.00 - Azure Interior - 05/14/2020.
:param document: A string representing the type of the document.
:param amount: The document's amount.
:param currency: The document's currency.
:param date: The document's date.
:param partner: The optional partner.
:return: A string.
'''
values = ['%s %s' % (document, formatLang(self.env, amount, currency_obj=currency))]
if partner:
values.append(partner.display_name)
values.append(format_date(self.env, fields.Date.to_string(date)))
return ' - '.join(values)
@api.onchange('amount_currency', 'currency_id', 'debit', 'credit', 'tax_ids', 'account_id', 'price_unit')
def _onchange_mark_recompute_taxes(self):
''' Recompute the dynamic onchange based on taxes.
If the edited line is a tax line, don't recompute anything as the user must be able to
set a custom value.
'''
for line in self:
if not line.tax_repartition_line_id:
line.recompute_tax_line = True
@api.onchange('analytic_account_id', 'analytic_tag_ids')
def _onchange_mark_recompute_taxes_analytic(self):
''' Trigger tax recomputation only when some taxes with analytics
'''
for line in self:
if not line.tax_repartition_line_id and any(tax.analytic for tax in line.tax_ids):
line.recompute_tax_line = True
def _get_fields_onchange_balance(self, quantity=None, discount=None, amount_currency=None, move_type=None,
currency=None, taxes=None, price_subtotal=None, force_computation=False):
self.ensure_one()
return self._get_fields_onchange_balance_model(
quantity=quantity or self.quantity,
discount=discount or self.discount,
amount_currency=amount_currency or self.amount_currency,
move_type=move_type or self.move_id.move_type,
currency=currency or self.currency_id or self.move_id.currency_id,
taxes=taxes or self.tax_ids,
price_subtotal=price_subtotal or self.price_subtotal,
force_computation=force_computation,
)
@api.model
def _get_fields_onchange_balance_model(self, quantity, discount, amount_currency, move_type, currency, taxes,
price_subtotal, force_computation=False):
''' This method is used to recompute the values of 'quantity', 'discount', 'price_unit' due to a change made
in some accounting fields such as 'balance'.
This method is a bit complex as we need to handle some special cases.
For example, setting a positive balance with a 100% discount.
:param quantity: The current quantity.
:param discount: The current discount.
:param amount_currency: The new balance in line's currency.
:param move_type: The type of the move.
:param currency: The currency.
:param taxes: The applied taxes.
:param price_subtotal: The price_subtotal.
:return: A dictionary containing 'quantity', 'discount', 'price_unit'.
'''
if move_type in self.move_id.get_outbound_types():
sign = 1
elif move_type in self.move_id.get_inbound_types():
sign = -1
else:
sign = 1
amount_currency *= sign
# Avoid rounding issue when dealing with price included taxes. For example, when the price_unit is 2300.0 and
# a 5.5% price included tax is applied on it, a balance of 2300.0 / 1.055 = 2180.094 ~ 2180.09 is computed.
# However, when triggering the inverse, 2180.09 + (2180.09 * 0.055) = 2180.09 + 119.90 = 2299.99 is computed.
# To avoid that, set the price_subtotal at the balance if the difference between them looks like a rounding
# issue.
if not force_computation and currency.is_zero(amount_currency - price_subtotal):
return {}
taxes = taxes.flatten_taxes_hierarchy()
if taxes and any(tax.price_include for tax in taxes):
# Inverse taxes. E.g:
#
# Price Unit | Taxes | Originator Tax |Price Subtotal | Price Total
# -----------------------------------------------------------------------------------
# 110 | 10% incl, 5% | | 100 | 115
# 10 | | 10% incl | 10 | 10
# 5 | | 5% | 5 | 5
#
# When setting the balance to -200, the expected result is:
#
# Price Unit | Taxes | Originator Tax |Price Subtotal | Price Total
# -----------------------------------------------------------------------------------
# 220 | 10% incl, 5% | | 200 | 230
# 20 | | 10% incl | 20 | 20
# 10 | | 5% | 10 | 10
force_sign = -1 if move_type in ('out_invoice', 'in_refund', 'out_receipt') else 1
taxes_res = taxes._origin.with_context(force_sign=force_sign).compute_all(amount_currency,
currency=currency,
handle_price_include=False)
for tax_res in taxes_res['taxes']:
tax = self.env['account.tax'].browse(tax_res['id'])
if tax.price_include:
amount_currency += tax_res['amount']
discount_factor = 1 - (discount / 100.0)
if amount_currency and discount_factor:
# discount != 100%
vals = {
'quantity': quantity or 1.0,
'price_unit': amount_currency / discount_factor / (quantity or 1.0),
}
elif amount_currency and not discount_factor:
# discount == 100%
vals = {
'quantity': quantity or 1.0,
'discount': 0.0,
'price_unit': amount_currency / (quantity or 1.0),
}
elif not discount_factor:
# balance of line is 0, but discount == 100% so we display the normal unit_price
vals = {}
else:
# balance is 0, so unit price is 0 as well
vals = {'price_unit': 0.0}
return vals
def _get_computed_name(self):
self.ensure_one()
if not self.product_id:
return ''
if self.partner_id.lang:
product = self.product_id.with_context(lang=self.partner_id.lang)
else:
product = self.product_id
values = []
if product.partner_ref:
values.append(product.partner_ref)
if self.journal_id.type == 'sale':
if product.description_sale:
values.append(product.description_sale)
elif self.journal_id.type == 'purchase':
if product.description_purchase:
values.append(product.description_purchase)
return '\n'.join(values)
def _get_computed_account(self):
self.ensure_one()
self = self.with_company(self.move_id.journal_id.company_id)
if not self.product_id:
return
fiscal_position = self.move_id.fiscal_position_id
accounts = self.product_id.product_tmpl_id.get_product_accounts(fiscal_pos=fiscal_position)
if self.move_id.is_sale_document(include_receipts=True):
# Out invoice.
return accounts['income'] or self.account_id
elif self.move_id.is_purchase_document(include_receipts=True):
# In invoice.
return accounts['expense'] or self.account_id
def _get_computed_uom(self):
self.ensure_one()
if self.product_id:
return self.product_id.uom_id
return False
def _set_price_and_tax_after_fpos(self):
self.ensure_one()
# Manage the fiscal position after that and adapt the price_unit.
# E.g. mapping a price-included-tax to a price-excluded-tax must
# remove the tax amount from the price_unit.
# However, mapping a price-included tax to another price-included tax must preserve the balance but
# adapt the price_unit to the new tax.
# E.g. mapping a 10% price-included tax to a 20% price-included tax for a price_unit of 110 should preserve
# 100 as balance but set 120 as price_unit.
if self.tax_ids and self.move_id.fiscal_position_id:
price_subtotal = self._get_price_total_and_subtotal()['price_subtotal']
self.tax_ids = self.move_id.fiscal_position_id.map_tax(
self.tax_ids._origin,
partner=self.move_id.partner_id)
accounting_vals = self._get_fields_onchange_subtotal(
price_subtotal=price_subtotal,
currency=self.move_id.company_currency_id)
amount_currency = accounting_vals['amount_currency']
business_vals = self._get_fields_onchange_balance(amount_currency=amount_currency)
if 'price_unit' in business_vals:
self.price_unit = business_vals['price_unit']
@api.onchange('product_id')
def _onchange_product_id(self):
for line in self:
if not line.product_id or line.display_type in ('line_section', 'line_note'):
continue
line.name = line._get_computed_name()
line.account_id = line._get_computed_account()
line.tax_ids = line._get_computed_taxes()
line.product_uom_id = line._get_computed_uom()
line.price_unit = line._get_computed_price_unit()
# price_unit and taxes may need to be adapted following Fiscal Position
line._set_price_and_tax_after_fpos()
# Convert the unit price to the invoice's currency.
company = line.move_id.company_id
line.price_unit = company.currency_id._convert(line.price_unit, line.move_id.currency_id, company,
line.move_id.date, round=False)
@api.model
def _get_price_total_and_subtotal_model(self, price_unit, quantity, discount, currency, product, partner, taxes,
move_type):
''' This method is used to compute 'price_total' & 'price_subtotal'.
:param price_unit: The current price unit.
:param quantity: The current quantity.
:param discount: The current discount.
:param currency: The line's currency.
:param product: The line's product.
:param partner: The line's partner.
:param taxes: The applied taxes.
:param move_type: The type of the move.
:return: A dictionary containing 'price_subtotal' & 'price_total'.
'''
res = {}
# Compute 'price_subtotal'.
line_discount_price_unit = price_unit * (1 - (discount / 100.0))
subtotal = quantity * line_discount_price_unit
# Compute 'price_total'.
if taxes:
force_sign = -1 if move_type in ('out_invoice', 'in_refund', 'out_receipt') else 1
taxes_res = taxes._origin.with_context(force_sign=force_sign).compute_all(line_discount_price_unit,
quantity=quantity,
currency=currency,
product=product,
partner=partner,
is_refund=move_type in (
'out_refund',
'in_refund'))
res['price_subtotal'] = taxes_res['total_excluded']
res['price_total'] = taxes_res['total_included']
else:
res['price_total'] = res['price_subtotal'] = subtotal
# In case of multi currency, round before it's use for computing debit credit
if currency:
res = {k: currency.round(v) for k, v in res.items()}
return res
def _get_price_total_and_subtotal(self, price_unit=None, quantity=None, discount=None, currency=None,
product=None,
partner=None, taxes=None, move_type=None):
self.ensure_one()
return self._get_price_total_and_subtotal_model(
price_unit=price_unit or self.price_unit,
quantity=quantity or self.quantity,
discount=discount or self.discount,
currency=currency or self.currency_id,
product=product or self.product_id,
partner=partner or self.partner_id,
taxes=taxes or self.tax_ids,
move_type=move_type or self.move_id.move_type,
)
def _get_fields_onchange_subtotal(self, price_subtotal=None, move_type=None, currency=None, company=None,
date=None):
self.ensure_one()
return self._get_fields_onchange_subtotal_model(
price_subtotal=price_subtotal or self.price_subtotal,
move_type=move_type or self.move_id.move_type,
currency=currency or self.currency_id,
company=company or self.move_id.company_id,
date=date or self.move_id.date,
)
@api.model
def _get_fields_onchange_subtotal_model(self, price_subtotal, move_type, currency, company, date):
''' This method is used to recompute the values of 'amount_currency', 'debit', 'credit' due to a change made
in some business fields (affecting the 'price_subtotal' field).
:param price_subtotal: The untaxed amount.
:param move_type: The type of the move.
:param currency: The line's currency.
:param company: The move's company.
:param date: The move's date.
:return: A dictionary containing 'debit', 'credit', 'amount_currency'.
'''
if move_type in self.move_id.get_outbound_types():
sign = 1
elif move_type in self.move_id.get_inbound_types():
sign = -1
else:
sign = 1
amount_currency = price_subtotal * sign
balance = currency._convert(amount_currency, company.currency_id, company,
date or fields.Date.context_today(self))
return {
'amount_currency': amount_currency,
'currency_id': currency.id,
'debit': balance > 0.0 and balance or 0.0,
'credit': balance < 0.0 and -balance or 0.0,
}
def _get_computed_price_unit(self):
self.ensure_one()
if not self.product_id:
return self.price_unit
elif self.move_id.is_sale_document(include_receipts=True):
# Out invoice.
price_unit = self.product_id.lst_price
elif self.move_id.is_purchase_document(include_receipts=True):
# In invoice.
price_unit = self.product_id.standard_price
else:
return self.price_unit
if self.product_uom_id != self.product_id.uom_id:
price_unit = self.product_id.uom_id._compute_price(price_unit, self.product_uom_id)
return price_unit
@api.onchange('product_uom_id')
def _onchange_uom_id(self):
''' Recompute the 'price_unit' depending of the unit of measure. '''
price_unit = self._get_computed_price_unit()
# See '_onchange_product_id' for details.
taxes = self._get_computed_taxes()
if taxes and self.move_id.fiscal_position_id:
price_subtotal = self._get_price_total_and_subtotal(price_unit=price_unit, taxes=taxes)[
'price_subtotal']
accounting_vals = self._get_fields_onchange_subtotal(price_subtotal=price_subtotal,
currency=self.move_id.company_currency_id)
amount_currency = accounting_vals['amount_currency']
price_unit = self._get_fields_onchange_balance(amount_currency=amount_currency,
force_computation=True).get('price_unit', price_unit)
# Convert the unit price to the invoice's currency.
company = self.move_id.company_id
self.price_unit = company.currency_id._convert(price_unit, self.move_id.currency_id, company,
self.move_id.date, round=False)
@api.onchange('account_id')
def _onchange_account_id(self):
''' Recompute 'tax_ids' based on 'account_id'.
/!\ Don't remove existing taxes if there is no explicit taxes set on the account.
'''
if not self.display_type and (self.account_id.tax_ids or not self.tax_ids):
taxes = self._get_computed_taxes()
if taxes and self.move_id.fiscal_position_id:
taxes = self.move_id.fiscal_position_id.map_tax(taxes, partner=self.partner_id)
self.tax_ids = taxes
def _onchange_balance(self):
for line in self:
if line.currency_id == line.move_id.company_id.currency_id:
line.amount_currency = line.balance
else:
continue
if not line.move_id.is_invoice(include_receipts=True):
continue
line.update(line._get_fields_onchange_balance())
@api.onchange('debit')
def _onchange_debit(self):
if self.debit:
self.credit = 0.0
self._onchange_balance()
@api.onchange('credit')
def _onchange_credit(self):
if self.credit:
self.debit = 0.0
self._onchange_balance()
@api.onchange('amount_currency')
def _onchange_amount_currency(self):
for line in self:
company = line.move_id.company_id
balance = line.currency_id._convert(line.amount_currency, company.currency_id, company,
line.move_id.date)
line.debit = balance if balance > 0.0 else 0.0
line.credit = -balance if balance < 0.0 else 0.0
if not line.move_id.is_invoice(include_receipts=True):
continue
line.update(line._get_fields_onchange_balance())
line.update(line._get_price_total_and_subtotal())
@api.onchange('quantity', 'discount', 'price_unit', 'tax_ids')
def _onchange_price_subtotal(self):
for line in self:
if not line.move_id.is_invoice(include_receipts=True):
continue
line.update(line._get_price_total_and_subtotal())
line.update(line._get_fields_onchange_subtotal())
@api.onchange('currency_id')
def _onchange_currency(self):
for line in self:
company = line.move_id.company_id
if line.move_id.is_invoice(include_receipts=True):
line._onchange_price_subtotal()
elif not line.move_id.reversed_entry_id:
balance = line.currency_id._convert(line.amount_currency, company.currency_id, company,
line.move_id.date or fields.Date.context_today(line))
line.debit = balance if balance > 0.0 else 0.0
line.credit = -balance if balance < 0.0 else 0.0
def create_analytic_lines(self):
""" Create analytic items upon validation of an account.move.line having an analytic account or an analytic distribution.
"""
lines_to_create_analytic_entries = self.env['account.move.line']
analytic_line_vals = []
for obj_line in self:
for tag in obj_line.analytic_tag_ids.filtered('active_analytic_distribution'):
for distribution in tag.analytic_distribution_ids:
analytic_line_vals.append(obj_line._prepare_analytic_distribution_line(distribution))
if obj_line.analytic_account_id:
lines_to_create_analytic_entries |= obj_line
# create analytic entries in batch
if lines_to_create_analytic_entries:
analytic_line_vals += lines_to_create_analytic_entries._prepare_analytic_line()
self.env['account.analytic.line'].create(analytic_line_vals)
@api.depends('product_id', 'account_id', 'partner_id', 'date')
def _compute_analytic_account(self):
for record in self:
if not record.exclude_from_invoice_tab or not record.move_id.is_invoice(include_receipts=True):
rec = self.env['account.analytic.default'].account_get(
product_id=record.product_id.id,
partner_id=record.partner_id.commercial_partner_id.id or record.move_id.partner_id.commercial_partner_id.id,
account_id=record.account_id.id,
user_id=record.env.uid,
date=record.date,
company_id=record.move_id.company_id.id
)
if rec:
record.analytic_account_id = rec.analytic_id
record.analytic_tag_ids = rec.analytic_tag_ids
@api.model
def _get_suspense_moves_domain(self):
return [
('move_id.to_check', '=', True),
('full_reconcile_id', '=', False),
('statement_line_id', '!=', False),
]