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), ]