diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/stock_account/models/product.py | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/stock_account/models/product.py')
| -rw-r--r-- | addons/stock_account/models/product.py | 815 |
1 files changed, 815 insertions, 0 deletions
diff --git a/addons/stock_account/models/product.py b/addons/stock_account/models/product.py new file mode 100644 index 00000000..4c0b0b3c --- /dev/null +++ b/addons/stock_account/models/product.py @@ -0,0 +1,815 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.tools import float_is_zero, float_repr +from odoo.exceptions import ValidationError +from collections import defaultdict + + +class ProductTemplate(models.Model): + _name = 'product.template' + _inherit = 'product.template' + + cost_method = fields.Selection(related="categ_id.property_cost_method", readonly=True) + valuation = fields.Selection(related="categ_id.property_valuation", readonly=True) + + def write(self, vals): + impacted_templates = {} + move_vals_list = [] + Product = self.env['product.product'] + SVL = self.env['stock.valuation.layer'] + + if 'categ_id' in vals: + # When a change of category implies a change of cost method, we empty out and replenish + # the stock. + new_product_category = self.env['product.category'].browse(vals.get('categ_id')) + + for product_template in self: + product_template = product_template.with_company(product_template.company_id) + valuation_impacted = False + if product_template.cost_method != new_product_category.property_cost_method: + valuation_impacted = True + if product_template.valuation != new_product_category.property_valuation: + valuation_impacted = True + if valuation_impacted is False: + continue + + # Empty out the stock with the current cost method. + description = _("Due to a change of product category (from %s to %s), the costing method\ + has changed for product template %s: from %s to %s.") %\ + (product_template.categ_id.display_name, new_product_category.display_name, + product_template.display_name, product_template.cost_method, new_product_category.property_cost_method) + out_svl_vals_list, products_orig_quantity_svl, products = Product\ + ._svl_empty_stock(description, product_template=product_template) + out_stock_valuation_layers = SVL.create(out_svl_vals_list) + if product_template.valuation == 'real_time': + move_vals_list += Product._svl_empty_stock_am(out_stock_valuation_layers) + impacted_templates[product_template] = (products, description, products_orig_quantity_svl) + + res = super(ProductTemplate, self).write(vals) + + for product_template, (products, description, products_orig_quantity_svl) in impacted_templates.items(): + # Replenish the stock with the new cost method. + in_svl_vals_list = products._svl_replenish_stock(description, products_orig_quantity_svl) + in_stock_valuation_layers = SVL.create(in_svl_vals_list) + if product_template.valuation == 'real_time': + move_vals_list += Product._svl_replenish_stock_am(in_stock_valuation_layers) + + # Check access right + if move_vals_list and not self.env['stock.valuation.layer'].check_access_rights('read', raise_exception=False): + raise UserError(_("The action leads to the creation of a journal entry, for which you don't have the access rights.")) + # Create the account moves. + if move_vals_list: + account_moves = self.env['account.move'].sudo().create(move_vals_list) + account_moves._post() + return res + + # ------------------------------------------------------------------------- + # Misc. + # ------------------------------------------------------------------------- + def _get_product_accounts(self): + """ Add the stock accounts related to product to the result of super() + @return: dictionary which contains information regarding stock accounts and super (income+expense accounts) + """ + accounts = super(ProductTemplate, self)._get_product_accounts() + res = self._get_asset_accounts() + accounts.update({ + 'stock_input': res['stock_input'] or self.categ_id.property_stock_account_input_categ_id, + 'stock_output': res['stock_output'] or self.categ_id.property_stock_account_output_categ_id, + 'stock_valuation': self.categ_id.property_stock_valuation_account_id or False, + }) + return accounts + + def get_product_accounts(self, fiscal_pos=None): + """ Add the stock journal related to product to the result of super() + @return: dictionary which contains all needed information regarding stock accounts and journal and super (income+expense accounts) + """ + accounts = super(ProductTemplate, self).get_product_accounts(fiscal_pos=fiscal_pos) + accounts.update({'stock_journal': self.categ_id.property_stock_journal or False}) + return accounts + + +class ProductProduct(models.Model): + _inherit = 'product.product' + + value_svl = fields.Float(compute='_compute_value_svl', compute_sudo=True) + quantity_svl = fields.Float(compute='_compute_value_svl', compute_sudo=True) + stock_valuation_layer_ids = fields.One2many('stock.valuation.layer', 'product_id') + valuation = fields.Selection(related="categ_id.property_valuation", readonly=True) + cost_method = fields.Selection(related="categ_id.property_cost_method", readonly=True) + + def write(self, vals): + if 'standard_price' in vals and not self.env.context.get('disable_auto_svl'): + self.filtered(lambda p: p.cost_method != 'fifo')._change_standard_price(vals['standard_price']) + return super(ProductProduct, self).write(vals) + + @api.depends('stock_valuation_layer_ids') + @api.depends_context('to_date', 'company') + def _compute_value_svl(self): + """Compute `value_svl` and `quantity_svl`.""" + company_id = self.env.company.id + domain = [ + ('product_id', 'in', self.ids), + ('company_id', '=', company_id), + ] + if self.env.context.get('to_date'): + to_date = fields.Datetime.to_datetime(self.env.context['to_date']) + domain.append(('create_date', '<=', to_date)) + groups = self.env['stock.valuation.layer'].read_group(domain, ['value:sum', 'quantity:sum'], ['product_id']) + products = self.browse() + for group in groups: + product = self.browse(group['product_id'][0]) + product.value_svl = self.env.company.currency_id.round(group['value']) + product.quantity_svl = group['quantity'] + products |= product + remaining = (self - products) + remaining.value_svl = 0 + remaining.quantity_svl = 0 + + # ------------------------------------------------------------------------- + # Actions + # ------------------------------------------------------------------------- + def action_revaluation(self): + self.ensure_one() + ctx = dict(self._context, default_product_id=self.id, default_company_id=self.env.company.id) + return { + 'name': _("Product Revaluation"), + 'view_mode': 'form', + 'res_model': 'stock.valuation.layer.revaluation', + 'view_id': self.env.ref('stock_account.stock_valuation_layer_revaluation_form_view').id, + 'type': 'ir.actions.act_window', + 'context': ctx, + 'target': 'new' + } + + # ------------------------------------------------------------------------- + # SVL creation helpers + # ------------------------------------------------------------------------- + def _prepare_in_svl_vals(self, quantity, unit_cost): + """Prepare the values for a stock valuation layer created by a receipt. + + :param quantity: the quantity to value, expressed in `self.uom_id` + :param unit_cost: the unit cost to value `quantity` + :return: values to use in a call to create + :rtype: dict + """ + self.ensure_one() + vals = { + 'product_id': self.id, + 'value': unit_cost * quantity, + 'unit_cost': unit_cost, + 'quantity': quantity, + } + if self.cost_method in ('average', 'fifo'): + vals['remaining_qty'] = quantity + vals['remaining_value'] = vals['value'] + return vals + + def _prepare_out_svl_vals(self, quantity, company): + """Prepare the values for a stock valuation layer created by a delivery. + + :param quantity: the quantity to value, expressed in `self.uom_id` + :return: values to use in a call to create + :rtype: dict + """ + self.ensure_one() + # Quantity is negative for out valuation layers. + quantity = -1 * quantity + vals = { + 'product_id': self.id, + 'value': quantity * self.standard_price, + 'unit_cost': self.standard_price, + 'quantity': quantity, + } + if self.cost_method in ('average', 'fifo'): + fifo_vals = self._run_fifo(abs(quantity), company) + vals['remaining_qty'] = fifo_vals.get('remaining_qty') + # In case of AVCO, fix rounding issue of standard price when needed. + if self.cost_method == 'average': + currency = self.env.company.currency_id + rounding_error = currency.round(self.standard_price * self.quantity_svl - self.value_svl) + if rounding_error: + # If it is bigger than the (smallest number of the currency * quantity) / 2, + # then it isn't a rounding error but a stock valuation error, we shouldn't fix it under the hood ... + if abs(rounding_error) <= (abs(quantity) * currency.rounding) / 2: + vals['value'] += rounding_error + vals['rounding_adjustment'] = '\nRounding Adjustment: %s%s %s' % ( + '+' if rounding_error > 0 else '', + float_repr(rounding_error, precision_digits=currency.decimal_places), + currency.symbol + ) + if self.cost_method == 'fifo': + vals.update(fifo_vals) + return vals + + def _change_standard_price(self, new_price): + """Helper to create the stock valuation layers and the account moves + after an update of standard price. + + :param new_price: new standard price + """ + # Handle stock valuation layers. + + if self.filtered(lambda p: p.valuation == 'real_time') and not self.env['stock.valuation.layer'].check_access_rights('read', raise_exception=False): + raise UserError(_("You cannot update the cost of a product in automated valuation as it leads to the creation of a journal entry, for which you don't have the access rights.")) + + svl_vals_list = [] + company_id = self.env.company + for product in self: + if product.cost_method not in ('standard', 'average'): + continue + quantity_svl = product.sudo().quantity_svl + if float_is_zero(quantity_svl, precision_rounding=product.uom_id.rounding): + continue + diff = new_price - product.standard_price + value = company_id.currency_id.round(quantity_svl * diff) + if company_id.currency_id.is_zero(value): + continue + + svl_vals = { + 'company_id': company_id.id, + 'product_id': product.id, + 'description': _('Product value manually modified (from %s to %s)') % (product.standard_price, new_price), + 'value': value, + 'quantity': 0, + } + svl_vals_list.append(svl_vals) + stock_valuation_layers = self.env['stock.valuation.layer'].sudo().create(svl_vals_list) + + # Handle account moves. + product_accounts = {product.id: product.product_tmpl_id.get_product_accounts() for product in self} + am_vals_list = [] + for stock_valuation_layer in stock_valuation_layers: + product = stock_valuation_layer.product_id + value = stock_valuation_layer.value + + if product.type != 'product' or product.valuation != 'real_time': + continue + + # Sanity check. + if not product_accounts[product.id].get('expense'): + raise UserError(_('You must set a counterpart account on your product category.')) + if not product_accounts[product.id].get('stock_valuation'): + raise UserError(_('You don\'t have any stock valuation account defined on your product category. You must define one before processing this operation.')) + + if value < 0: + debit_account_id = product_accounts[product.id]['expense'].id + credit_account_id = product_accounts[product.id]['stock_valuation'].id + else: + debit_account_id = product_accounts[product.id]['stock_valuation'].id + credit_account_id = product_accounts[product.id]['expense'].id + + move_vals = { + 'journal_id': product_accounts[product.id]['stock_journal'].id, + 'company_id': company_id.id, + 'ref': product.default_code, + 'stock_valuation_layer_ids': [(6, None, [stock_valuation_layer.id])], + 'move_type': 'entry', + 'line_ids': [(0, 0, { + 'name': _( + '%(user)s changed cost from %(previous)s to %(new_price)s - %(product)s', + user=self.env.user.name, + previous=product.standard_price, + new_price=new_price, + product=product.display_name + ), + 'account_id': debit_account_id, + 'debit': abs(value), + 'credit': 0, + 'product_id': product.id, + }), (0, 0, { + 'name': _( + '%(user)s changed cost from %(previous)s to %(new_price)s - %(product)s', + user=self.env.user.name, + previous=product.standard_price, + new_price=new_price, + product=product.display_name + ), + 'account_id': credit_account_id, + 'debit': 0, + 'credit': abs(value), + 'product_id': product.id, + })], + } + am_vals_list.append(move_vals) + + account_moves = self.env['account.move'].sudo().create(am_vals_list) + if account_moves: + account_moves._post() + + def _run_fifo(self, quantity, company): + self.ensure_one() + + # Find back incoming stock valuation layers (called candidates here) to value `quantity`. + qty_to_take_on_candidates = quantity + candidates = self.env['stock.valuation.layer'].sudo().search([ + ('product_id', '=', self.id), + ('remaining_qty', '>', 0), + ('company_id', '=', company.id), + ]) + new_standard_price = 0 + tmp_value = 0 # to accumulate the value taken on the candidates + for candidate in candidates: + qty_taken_on_candidate = min(qty_to_take_on_candidates, candidate.remaining_qty) + + candidate_unit_cost = candidate.remaining_value / candidate.remaining_qty + new_standard_price = candidate_unit_cost + value_taken_on_candidate = qty_taken_on_candidate * candidate_unit_cost + value_taken_on_candidate = candidate.currency_id.round(value_taken_on_candidate) + new_remaining_value = candidate.remaining_value - value_taken_on_candidate + + candidate_vals = { + 'remaining_qty': candidate.remaining_qty - qty_taken_on_candidate, + 'remaining_value': new_remaining_value, + } + + candidate.write(candidate_vals) + + qty_to_take_on_candidates -= qty_taken_on_candidate + tmp_value += value_taken_on_candidate + + if float_is_zero(qty_to_take_on_candidates, precision_rounding=self.uom_id.rounding): + if float_is_zero(candidate.remaining_qty, precision_rounding=self.uom_id.rounding): + next_candidates = candidates.filtered(lambda svl: svl.remaining_qty > 0) + new_standard_price = next_candidates and next_candidates[0].unit_cost or new_standard_price + break + + # Update the standard price with the price of the last used candidate, if any. + if new_standard_price and self.cost_method == 'fifo': + self.sudo().with_company(company.id).with_context(disable_auto_svl=True).standard_price = new_standard_price + + # If there's still quantity to value but we're out of candidates, we fall in the + # negative stock use case. We chose to value the out move at the price of the + # last out and a correction entry will be made once `_fifo_vacuum` is called. + vals = {} + if float_is_zero(qty_to_take_on_candidates, precision_rounding=self.uom_id.rounding): + vals = { + 'value': -tmp_value, + 'unit_cost': tmp_value / quantity, + } + else: + assert qty_to_take_on_candidates > 0 + last_fifo_price = new_standard_price or self.standard_price + negative_stock_value = last_fifo_price * -qty_to_take_on_candidates + tmp_value += abs(negative_stock_value) + vals = { + 'remaining_qty': -qty_to_take_on_candidates, + 'value': -tmp_value, + 'unit_cost': last_fifo_price, + } + return vals + + def _run_fifo_vacuum(self, company=None): + """Compensate layer valued at an estimated price with the price of future receipts + if any. If the estimated price is equals to the real price, no layer is created but + the original layer is marked as compensated. + + :param company: recordset of `res.company` to limit the execution of the vacuum + """ + self.ensure_one() + if company is None: + company = self.env.company + svls_to_vacuum = self.env['stock.valuation.layer'].sudo().search([ + ('product_id', '=', self.id), + ('remaining_qty', '<', 0), + ('stock_move_id', '!=', False), + ('company_id', '=', company.id), + ], order='create_date, id') + if not svls_to_vacuum: + return + + domain = [ + ('company_id', '=', company.id), + ('product_id', '=', self.id), + ('remaining_qty', '>', 0), + ('create_date', '>=', svls_to_vacuum[0].create_date), + ] + all_candidates = self.env['stock.valuation.layer'].sudo().search(domain) + + for svl_to_vacuum in svls_to_vacuum: + # We don't use search to avoid executing _flush_search and to decrease interaction with DB + candidates = all_candidates.filtered( + lambda r: r.create_date > svl_to_vacuum.create_date + or r.create_date == svl_to_vacuum.create_date + and r.id > svl_to_vacuum.id + ) + if not candidates: + break + qty_to_take_on_candidates = abs(svl_to_vacuum.remaining_qty) + qty_taken_on_candidates = 0 + tmp_value = 0 + for candidate in candidates: + qty_taken_on_candidate = min(candidate.remaining_qty, qty_to_take_on_candidates) + qty_taken_on_candidates += qty_taken_on_candidate + + candidate_unit_cost = candidate.remaining_value / candidate.remaining_qty + value_taken_on_candidate = qty_taken_on_candidate * candidate_unit_cost + value_taken_on_candidate = candidate.currency_id.round(value_taken_on_candidate) + new_remaining_value = candidate.remaining_value - value_taken_on_candidate + + candidate_vals = { + 'remaining_qty': candidate.remaining_qty - qty_taken_on_candidate, + 'remaining_value': new_remaining_value + } + candidate.write(candidate_vals) + if not (candidate.remaining_qty > 0): + all_candidates -= candidate + + qty_to_take_on_candidates -= qty_taken_on_candidate + tmp_value += value_taken_on_candidate + if float_is_zero(qty_to_take_on_candidates, precision_rounding=self.uom_id.rounding): + break + + # Get the estimated value we will correct. + remaining_value_before_vacuum = svl_to_vacuum.unit_cost * qty_taken_on_candidates + new_remaining_qty = svl_to_vacuum.remaining_qty + qty_taken_on_candidates + corrected_value = remaining_value_before_vacuum - tmp_value + svl_to_vacuum.write({ + 'remaining_qty': new_remaining_qty, + }) + + # Don't create a layer or an accounting entry if the corrected value is zero. + if svl_to_vacuum.currency_id.is_zero(corrected_value): + continue + + corrected_value = svl_to_vacuum.currency_id.round(corrected_value) + move = svl_to_vacuum.stock_move_id + vals = { + 'product_id': self.id, + 'value': corrected_value, + 'unit_cost': 0, + 'quantity': 0, + 'remaining_qty': 0, + 'stock_move_id': move.id, + 'company_id': move.company_id.id, + 'description': 'Revaluation of %s (negative inventory)' % move.picking_id.name or move.name, + 'stock_valuation_layer_id': svl_to_vacuum.id, + } + vacuum_svl = self.env['stock.valuation.layer'].sudo().create(vals) + + # Create the account move. + if self.valuation != 'real_time': + continue + vacuum_svl.stock_move_id._account_entry_move( + vacuum_svl.quantity, vacuum_svl.description, vacuum_svl.id, vacuum_svl.value + ) + # Create the related expense entry + self._create_fifo_vacuum_anglo_saxon_expense_entry(vacuum_svl, svl_to_vacuum) + + # If some negative stock were fixed, we need to recompute the standard price. + product = self.with_company(company.id) + if product.cost_method == 'average' and not float_is_zero(product.quantity_svl, precision_rounding=self.uom_id.rounding): + product.sudo().with_context(disable_auto_svl=True).write({'standard_price': product.value_svl / product.quantity_svl}) + + + def _create_fifo_vacuum_anglo_saxon_expense_entry(self, vacuum_svl, svl_to_vacuum): + """ When product is delivered and invoiced while you don't have units in stock anymore, there are chances of that + product getting undervalued/overvalued. So, we should nevertheless take into account the fact that the product has + already been delivered and invoiced to the customer by posting the value difference in the expense account also. + Consider the below case where product is getting undervalued: + + You bought 8 units @ 10$ -> You have a stock valuation of 8 units, unit cost 10. + Then you deliver 10 units of the product. + You assumed the missing 2 should go out at a value of 10$ but you are not sure yet as it hasn't been bought in Odoo yet. + Afterwards, you buy missing 2 units of the same product at 12$ instead of expected 10$. + In case the product has been undervalued when delivered without stock, the vacuum entry is the following one (this entry already takes place): + + Account | Debit | Credit + =================================================== + Stock Valuation | 0.00 | 4.00 + Stock Interim (Delivered) | 4.00 | 0.00 + + So, on delivering product with different price, We should create additional journal items like: + Account | Debit | Credit + =================================================== + Stock Interim (Delivered) | 0.00 | 4.00 + Expenses Revaluation | 4.00 | 0.00 + """ + if not vacuum_svl.company_id.anglo_saxon_accounting or not svl_to_vacuum.stock_move_id._is_out(): + return False + AccountMove = self.env['account.move'].sudo() + account_move_lines = svl_to_vacuum.account_move_id.line_ids + # Find related customer invoice where product is delivered while you don't have units in stock anymore + reconciled_line_ids = list(set(account_move_lines._reconciled_lines()) - set(account_move_lines.ids)) + account_move = AccountMove.search([('line_ids','in', reconciled_line_ids)], limit=1) + # If delivered quantity is not invoiced then no need to create this entry + if not account_move: + return False + accounts = svl_to_vacuum.product_id.product_tmpl_id.get_product_accounts(fiscal_pos=account_move.fiscal_position_id) + if not accounts.get('stock_output') or not accounts.get('expense'): + return False + description = "Expenses %s" % (vacuum_svl.description) + move_lines = vacuum_svl.stock_move_id._prepare_account_move_line( + vacuum_svl.quantity, vacuum_svl.value * -1, + accounts['stock_output'].id, accounts['expense'].id, + description) + new_account_move = AccountMove.sudo().create({ + 'journal_id': accounts['stock_journal'].id, + 'line_ids': move_lines, + 'date': self._context.get('force_period_date', fields.Date.context_today(self)), + 'ref': description, + 'stock_move_id': vacuum_svl.stock_move_id.id, + 'move_type': 'entry', + }) + new_account_move._post() + to_reconcile_account_move_lines = vacuum_svl.account_move_id.line_ids.filtered(lambda l: not l.reconciled and l.account_id == accounts['stock_output'] and l.account_id.reconcile) + to_reconcile_account_move_lines += new_account_move.line_ids.filtered(lambda l: not l.reconciled and l.account_id == accounts['stock_output'] and l.account_id.reconcile) + return to_reconcile_account_move_lines.reconcile() + + @api.model + def _svl_empty_stock(self, description, product_category=None, product_template=None): + impacted_product_ids = [] + impacted_products = self.env['product.product'] + products_orig_quantity_svl = {} + + # get the impacted products + domain = [('type', '=', 'product')] + if product_category is not None: + domain += [('categ_id', '=', product_category.id)] + elif product_template is not None: + domain += [('product_tmpl_id', '=', product_template.id)] + else: + raise ValueError() + products = self.env['product.product'].search_read(domain, ['quantity_svl']) + for product in products: + impacted_product_ids.append(product['id']) + products_orig_quantity_svl[product['id']] = product['quantity_svl'] + impacted_products |= self.env['product.product'].browse(impacted_product_ids) + + # empty out the stock for the impacted products + empty_stock_svl_list = [] + for product in impacted_products: + # FIXME sle: why not use products_orig_quantity_svl here? + if float_is_zero(product.quantity_svl, precision_rounding=product.uom_id.rounding): + # FIXME: create an empty layer to track the change? + continue + svsl_vals = product._prepare_out_svl_vals(product.quantity_svl, self.env.company) + svsl_vals['description'] = description + svsl_vals.pop('rounding_adjustment', '') + svsl_vals['company_id'] = self.env.company.id + empty_stock_svl_list.append(svsl_vals) + return empty_stock_svl_list, products_orig_quantity_svl, impacted_products + + def _svl_replenish_stock(self, description, products_orig_quantity_svl): + refill_stock_svl_list = [] + for product in self: + quantity_svl = products_orig_quantity_svl[product.id] + if quantity_svl: + svl_vals = product._prepare_in_svl_vals(quantity_svl, product.standard_price) + svl_vals['description'] = description + svl_vals['company_id'] = self.env.company.id + refill_stock_svl_list.append(svl_vals) + return refill_stock_svl_list + + @api.model + def _svl_empty_stock_am(self, stock_valuation_layers): + move_vals_list = [] + product_accounts = {product.id: product.product_tmpl_id.get_product_accounts() for product in stock_valuation_layers.mapped('product_id')} + for out_stock_valuation_layer in stock_valuation_layers: + product = out_stock_valuation_layer.product_id + expense_account = product._get_product_accounts()['expense'] + if not expense_account: + raise UserError(_('Please define an expense account for this product: "%s" (id:%d) - or for its category: "%s".') % (product.name, product.id, self.name)) + if not product_accounts[product.id].get('stock_valuation'): + raise UserError(_('You don\'t have any stock valuation account defined on your product category. You must define one before processing this operation.')) + + debit_account_id = expense_account.id + credit_account_id = product_accounts[product.id]['stock_valuation'].id + value = out_stock_valuation_layer.value + move_vals = { + 'journal_id': product_accounts[product.id]['stock_journal'].id, + 'company_id': self.env.company.id, + 'ref': product.default_code, + 'stock_valuation_layer_ids': [(6, None, [out_stock_valuation_layer.id])], + 'line_ids': [(0, 0, { + 'name': out_stock_valuation_layer.description, + 'account_id': debit_account_id, + 'debit': abs(value), + 'credit': 0, + 'product_id': product.id, + }), (0, 0, { + 'name': out_stock_valuation_layer.description, + 'account_id': credit_account_id, + 'debit': 0, + 'credit': abs(value), + 'product_id': product.id, + })], + 'move_type': 'entry', + } + move_vals_list.append(move_vals) + return move_vals_list + + def _svl_replenish_stock_am(self, stock_valuation_layers): + move_vals_list = [] + product_accounts = {product.id: product.product_tmpl_id.get_product_accounts() for product in stock_valuation_layers.mapped('product_id')} + for out_stock_valuation_layer in stock_valuation_layers: + product = out_stock_valuation_layer.product_id + if not product_accounts[product.id].get('stock_input'): + raise UserError(_('You don\'t have any input valuation account defined on your product category. You must define one before processing this operation.')) + if not product_accounts[product.id].get('stock_valuation'): + raise UserError(_('You don\'t have any stock valuation account defined on your product category. You must define one before processing this operation.')) + + debit_account_id = product_accounts[product.id]['stock_valuation'].id + credit_account_id = product_accounts[product.id]['stock_input'].id + value = out_stock_valuation_layer.value + move_vals = { + 'journal_id': product_accounts[product.id]['stock_journal'].id, + 'company_id': self.env.company.id, + 'ref': product.default_code, + 'stock_valuation_layer_ids': [(6, None, [out_stock_valuation_layer.id])], + 'line_ids': [(0, 0, { + 'name': out_stock_valuation_layer.description, + 'account_id': debit_account_id, + 'debit': abs(value), + 'credit': 0, + 'product_id': product.id, + }), (0, 0, { + 'name': out_stock_valuation_layer.description, + 'account_id': credit_account_id, + 'debit': 0, + 'credit': abs(value), + 'product_id': product.id, + })], + 'move_type': 'entry', + } + move_vals_list.append(move_vals) + return move_vals_list + + # ------------------------------------------------------------------------- + # Anglo saxon helpers + # ------------------------------------------------------------------------- + def _stock_account_get_anglo_saxon_price_unit(self, uom=False): + price = self.standard_price + if not self or not uom or self.uom_id.id == uom.id: + return price or 0.0 + return self.uom_id._compute_price(price, uom) + + def _compute_average_price(self, qty_invoiced, qty_to_invoice, stock_moves): + """Go over the valuation layers of `stock_moves` to value `qty_to_invoice` while taking + care of ignoring `qty_invoiced`. If `qty_to_invoice` is greater than what's possible to + value with the valuation layers, use the product's standard price. + + :param qty_invoiced: quantity already invoiced + :param qty_to_invoice: quantity to invoice + :param stock_moves: recordset of `stock.move` + :returns: the anglo saxon price unit + :rtype: float + """ + self.ensure_one() + if not qty_to_invoice: + return 0.0 + + returned_quantities = defaultdict(float) + for move in stock_moves: + if move.origin_returned_move_id: + returned_quantities[move.origin_returned_move_id.id] += abs(sum(move.stock_valuation_layer_ids.mapped('quantity'))) + candidates = stock_moves\ + .sudo()\ + .filtered(lambda m: not (m.origin_returned_move_id and sum(m.stock_valuation_layer_ids.mapped('quantity')) >= 0))\ + .mapped('stock_valuation_layer_ids')\ + .sorted() + qty_to_take_on_candidates = qty_to_invoice + tmp_value = 0 # to accumulate the value taken on the candidates + for candidate in candidates: + candidate_quantity = abs(candidate.quantity) + if candidate.stock_move_id.id in returned_quantities: + candidate_quantity -= returned_quantities[candidate.stock_move_id.id] + if float_is_zero(candidate_quantity, precision_rounding=candidate.uom_id.rounding): + continue # correction entries + if not float_is_zero(qty_invoiced, precision_rounding=candidate.uom_id.rounding): + qty_ignored = min(qty_invoiced, candidate_quantity) + qty_invoiced -= qty_ignored + candidate_quantity -= qty_ignored + if float_is_zero(candidate_quantity, precision_rounding=candidate.uom_id.rounding): + continue + qty_taken_on_candidate = min(qty_to_take_on_candidates, candidate_quantity) + + qty_to_take_on_candidates -= qty_taken_on_candidate + tmp_value += qty_taken_on_candidate * \ + ((candidate.value + sum(candidate.stock_valuation_layer_ids.mapped('value'))) / candidate.quantity) + if float_is_zero(qty_to_take_on_candidates, precision_rounding=candidate.uom_id.rounding): + break + + # If there's still quantity to invoice but we're out of candidates, we chose the standard + # price to estimate the anglo saxon price unit. + if not float_is_zero(qty_to_take_on_candidates, precision_rounding=self.uom_id.rounding): + negative_stock_value = self.standard_price * qty_to_take_on_candidates + tmp_value += negative_stock_value + + return tmp_value / qty_to_invoice + + +class ProductCategory(models.Model): + _inherit = 'product.category' + + property_valuation = fields.Selection([ + ('manual_periodic', 'Manual'), + ('real_time', 'Automated')], string='Inventory Valuation', + company_dependent=True, copy=True, required=True, + help="""Manual: The accounting entries to value the inventory are not posted automatically. + Automated: An accounting entry is automatically created to value the inventory when a product enters or leaves the company. + """) + property_cost_method = fields.Selection([ + ('standard', 'Standard Price'), + ('fifo', 'First In First Out (FIFO)'), + ('average', 'Average Cost (AVCO)')], string="Costing Method", + company_dependent=True, copy=True, required=True, + help="""Standard Price: The products are valued at their standard cost defined on the product. + Average Cost (AVCO): The products are valued at weighted average cost. + First In First Out (FIFO): The products are valued supposing those that enter the company first will also leave it first. + """) + property_stock_journal = fields.Many2one( + 'account.journal', 'Stock Journal', company_dependent=True, + domain="[('company_id', '=', allowed_company_ids[0])]", check_company=True, + help="When doing automated inventory valuation, this is the Accounting Journal in which entries will be automatically posted when stock moves are processed.") + property_stock_account_input_categ_id = fields.Many2one( + 'account.account', 'Stock Input Account', company_dependent=True, + domain="[('company_id', '=', allowed_company_ids[0]), ('deprecated', '=', False)]", check_company=True, + help="""Counterpart journal items for all incoming stock moves will be posted in this account, unless there is a specific valuation account + set on the source location. This is the default value for all products in this category. It can also directly be set on each product.""") + property_stock_account_output_categ_id = fields.Many2one( + 'account.account', 'Stock Output Account', company_dependent=True, + domain="[('company_id', '=', allowed_company_ids[0]), ('deprecated', '=', False)]", check_company=True, + help="""When doing automated inventory valuation, counterpart journal items for all outgoing stock moves will be posted in this account, + unless there is a specific valuation account set on the destination location. This is the default value for all products in this category. + It can also directly be set on each product.""") + property_stock_valuation_account_id = fields.Many2one( + 'account.account', 'Stock Valuation Account', company_dependent=True, + domain="[('company_id', '=', allowed_company_ids[0]), ('deprecated', '=', False)]", check_company=True, + help="""When automated inventory valuation is enabled on a product, this account will hold the current value of the products.""",) + + @api.constrains('property_stock_valuation_account_id', 'property_stock_account_output_categ_id', 'property_stock_account_input_categ_id') + def _check_valuation_accouts(self): + # Prevent to set the valuation account as the input or output account. + for category in self: + valuation_account = category.property_stock_valuation_account_id + input_and_output_accounts = category.property_stock_account_input_categ_id | category.property_stock_account_output_categ_id + if valuation_account and valuation_account in input_and_output_accounts: + raise ValidationError(_('The Stock Input and/or Output accounts cannot be the same as the Stock Valuation account.')) + + @api.onchange('property_cost_method') + def onchange_property_valuation(self): + if not self._origin: + # don't display the warning when creating a product category + return + return { + 'warning': { + 'title': _("Warning"), + 'message': _("Changing your cost method is an important change that will impact your inventory valuation. Are you sure you want to make that change?"), + } + } + + def write(self, vals): + impacted_categories = {} + move_vals_list = [] + Product = self.env['product.product'] + SVL = self.env['stock.valuation.layer'] + + if 'property_cost_method' in vals or 'property_valuation' in vals: + # When the cost method or the valuation are changed on a product category, we empty + # out and replenish the stock for each impacted products. + new_cost_method = vals.get('property_cost_method') + new_valuation = vals.get('property_valuation') + + + for product_category in self: + valuation_impacted = False + if new_cost_method and new_cost_method != product_category.property_cost_method: + valuation_impacted = True + if new_valuation and new_valuation != product_category.property_valuation: + valuation_impacted = True + if valuation_impacted is False: + continue + + # Empty out the stock with the current cost method. + if new_cost_method: + description = _("Costing method change for product category %s: from %s to %s.") \ + % (product_category.display_name, product_category.property_cost_method, new_cost_method) + else: + description = _("Valuation method change for product category %s: from %s to %s.") \ + % (product_category.display_name, product_category.property_valuation, new_valuation) + out_svl_vals_list, products_orig_quantity_svl, products = Product\ + ._svl_empty_stock(description, product_category=product_category) + out_stock_valuation_layers = SVL.sudo().create(out_svl_vals_list) + if product_category.property_valuation == 'real_time': + move_vals_list += Product._svl_empty_stock_am(out_stock_valuation_layers) + impacted_categories[product_category] = (products, description, products_orig_quantity_svl) + + res = super(ProductCategory, self).write(vals) + + for product_category, (products, description, products_orig_quantity_svl) in impacted_categories.items(): + # Replenish the stock with the new cost method. + in_svl_vals_list = products._svl_replenish_stock(description, products_orig_quantity_svl) + in_stock_valuation_layers = SVL.sudo().create(in_svl_vals_list) + if product_category.property_valuation == 'real_time': + move_vals_list += Product._svl_replenish_stock_am(in_stock_valuation_layers) + + # Check access right + if move_vals_list and not self.env['stock.valuation.layer'].check_access_rights('read', raise_exception=False): + raise UserError(_("The action leads to the creation of a journal entry, for which you don't have the access rights.")) + # Create the account moves. + if move_vals_list: + account_moves = self.env['account.move'].sudo().create(move_vals_list) + account_moves._post() + return res |
