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

8 months ago
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),
]