summaryrefslogtreecommitdiff
path: root/addons/stock_account/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/stock_account/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/stock_account/models')
-rw-r--r--addons/stock_account/models/__init__.py14
-rw-r--r--addons/stock_account/models/account_chart_template.py35
-rw-r--r--addons/stock_account/models/account_move.py236
-rw-r--r--addons/stock_account/models/product.py815
-rw-r--r--addons/stock_account/models/res_config_settings.py11
-rw-r--r--addons/stock_account/models/stock_inventory.py42
-rw-r--r--addons/stock_account/models/stock_location.py33
-rw-r--r--addons/stock_account/models/stock_move.py522
-rw-r--r--addons/stock_account/models/stock_move_line.py62
-rw-r--r--addons/stock_account/models/stock_picking.py21
-rw-r--r--addons/stock_account/models/stock_quant.py57
-rw-r--r--addons/stock_account/models/stock_valuation_layer.py38
12 files changed, 1886 insertions, 0 deletions
diff --git a/addons/stock_account/models/__init__.py b/addons/stock_account/models/__init__.py
new file mode 100644
index 00000000..77f0fb8d
--- /dev/null
+++ b/addons/stock_account/models/__init__.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import account_chart_template
+from . import account_move
+from . import product
+from . import stock_move
+from . import stock_inventory
+from . import stock_location
+from . import stock_move_line
+from . import stock_picking
+from . import stock_quant
+from . import stock_valuation_layer
+from . import res_config_settings
diff --git a/addons/stock_account/models/account_chart_template.py b/addons/stock_account/models/account_chart_template.py
new file mode 100644
index 00000000..8f6105d0
--- /dev/null
+++ b/addons/stock_account/models/account_chart_template.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models, _
+
+import logging
+_logger = logging.getLogger(__name__)
+
+
+class AccountChartTemplate(models.Model):
+ _inherit = "account.chart.template"
+
+ @api.model
+ def generate_journals(self, acc_template_ref, company, journals_dict=None):
+ journal_to_add = [{'name': _('Inventory Valuation'), 'type': 'general', 'code': 'STJ', 'favorite': False, 'sequence': 8}]
+ return super(AccountChartTemplate, self).generate_journals(acc_template_ref=acc_template_ref, company=company, journals_dict=journal_to_add)
+
+ def generate_properties(self, acc_template_ref, company, property_list=None):
+ res = super(AccountChartTemplate, self).generate_properties(acc_template_ref=acc_template_ref, company=company)
+ PropertyObj = self.env['ir.property'] # Property Stock Journal
+ value = self.env['account.journal'].search([('company_id', '=', company.id), ('code', '=', 'STJ'), ('type', '=', 'general')], limit=1)
+ if value:
+ PropertyObj._set_default("property_stock_journal", "product.category", value, company)
+
+ todo_list = [ # Property Stock Accounts
+ 'property_stock_account_input_categ_id',
+ 'property_stock_account_output_categ_id',
+ 'property_stock_valuation_account_id',
+ ]
+ for field in todo_list:
+ account = self[field]
+ value = acc_template_ref[account.id] if account else False
+ PropertyObj._set_default(field, "product.category", value, company)
+
+ return res
diff --git a/addons/stock_account/models/account_move.py b/addons/stock_account/models/account_move.py
new file mode 100644
index 00000000..244c910c
--- /dev/null
+++ b/addons/stock_account/models/account_move.py
@@ -0,0 +1,236 @@
+# -*- coding: utf-8 -*-
+
+from odoo import fields, models
+
+
+class AccountMove(models.Model):
+ _inherit = 'account.move'
+
+ stock_move_id = fields.Many2one('stock.move', string='Stock Move', index=True)
+ stock_valuation_layer_ids = fields.One2many('stock.valuation.layer', 'account_move_id', string='Stock Valuation Layer')
+
+ # -------------------------------------------------------------------------
+ # OVERRIDE METHODS
+ # -------------------------------------------------------------------------
+
+ def _get_lines_onchange_currency(self):
+ # OVERRIDE
+ return self.line_ids.filtered(lambda l: not l.is_anglo_saxon_line)
+
+ def _reverse_move_vals(self, default_values, cancel=True):
+ # OVERRIDE
+ # Don't keep anglo-saxon lines if not cancelling an existing invoice.
+ move_vals = super(AccountMove, self)._reverse_move_vals(default_values, cancel=cancel)
+ if not cancel:
+ move_vals['line_ids'] = [vals for vals in move_vals['line_ids'] if not vals[2]['is_anglo_saxon_line']]
+ return move_vals
+
+ def copy_data(self, default=None):
+ # OVERRIDE
+ # Don't keep anglo-saxon lines when copying a journal entry.
+ res = super().copy_data(default=default)
+
+ if not self._context.get('move_reverse_cancel'):
+ for copy_vals in res:
+ if 'line_ids' in copy_vals:
+ copy_vals['line_ids'] = [line_vals for line_vals in copy_vals['line_ids']
+ if line_vals[0] != 0 or not line_vals[2].get('is_anglo_saxon_line')]
+
+ return res
+
+ def _post(self, soft=True):
+ # OVERRIDE
+
+ # Don't change anything on moves used to cancel another ones.
+ if self._context.get('move_reverse_cancel'):
+ return super()._post(soft)
+
+ # Create additional COGS lines for customer invoices.
+ self.env['account.move.line'].create(self._stock_account_prepare_anglo_saxon_out_lines_vals())
+
+ # Post entries.
+ posted = super()._post(soft)
+
+ # Reconcile COGS lines in case of anglo-saxon accounting with perpetual valuation.
+ posted._stock_account_anglo_saxon_reconcile_valuation()
+ return posted
+
+ def button_draft(self):
+ res = super(AccountMove, self).button_draft()
+
+ # Unlink the COGS lines generated during the 'post' method.
+ self.mapped('line_ids').filtered(lambda line: line.is_anglo_saxon_line).unlink()
+ return res
+
+ def button_cancel(self):
+ # OVERRIDE
+ res = super(AccountMove, self).button_cancel()
+
+ # Unlink the COGS lines generated during the 'post' method.
+ # In most cases it shouldn't be necessary since they should be unlinked with 'button_draft'.
+ # However, since it can be called in RPC, better be safe.
+ self.mapped('line_ids').filtered(lambda line: line.is_anglo_saxon_line).unlink()
+ return res
+
+ # -------------------------------------------------------------------------
+ # COGS METHODS
+ # -------------------------------------------------------------------------
+
+ def _stock_account_prepare_anglo_saxon_out_lines_vals(self):
+ ''' Prepare values used to create the journal items (account.move.line) corresponding to the Cost of Good Sold
+ lines (COGS) for customer invoices.
+
+ Example:
+
+ Buy a product having a cost of 9 being a storable product and having a perpetual valuation in FIFO.
+ Sell this product at a price of 10. The customer invoice's journal entries looks like:
+
+ Account | Debit | Credit
+ ---------------------------------------------------------------
+ 200000 Product Sales | | 10.0
+ ---------------------------------------------------------------
+ 101200 Account Receivable | 10.0 |
+ ---------------------------------------------------------------
+
+ This method computes values used to make two additional journal items:
+
+ ---------------------------------------------------------------
+ 220000 Expenses | 9.0 |
+ ---------------------------------------------------------------
+ 101130 Stock Interim Account (Delivered) | | 9.0
+ ---------------------------------------------------------------
+
+ Note: COGS are only generated for customer invoices except refund made to cancel an invoice.
+
+ :return: A list of Python dictionary to be passed to env['account.move.line'].create.
+ '''
+ lines_vals_list = []
+ for move in self:
+ if not move.is_sale_document(include_receipts=True) or not move.company_id.anglo_saxon_accounting:
+ continue
+
+ for line in move.invoice_line_ids:
+
+ # Filter out lines being not eligible for COGS.
+ if line.product_id.type != 'product' or line.product_id.valuation != 'real_time':
+ continue
+
+ # Retrieve accounts needed to generate the COGS.
+ accounts = (
+ line.product_id.product_tmpl_id
+ .with_company(line.company_id)
+ .get_product_accounts(fiscal_pos=move.fiscal_position_id)
+ )
+ debit_interim_account = accounts['stock_output']
+ credit_expense_account = accounts['expense'] or self.journal_id.default_account_id
+ if not debit_interim_account or not credit_expense_account:
+ continue
+
+ # Compute accounting fields.
+ sign = -1 if move.move_type == 'out_refund' else 1
+ price_unit = line._stock_account_get_anglo_saxon_price_unit()
+ balance = sign * line.quantity * price_unit
+
+ # Add interim account line.
+ lines_vals_list.append({
+ 'name': line.name[:64],
+ 'move_id': move.id,
+ 'product_id': line.product_id.id,
+ 'product_uom_id': line.product_uom_id.id,
+ 'quantity': line.quantity,
+ 'price_unit': price_unit,
+ 'debit': balance < 0.0 and -balance or 0.0,
+ 'credit': balance > 0.0 and balance or 0.0,
+ 'account_id': debit_interim_account.id,
+ 'exclude_from_invoice_tab': True,
+ 'is_anglo_saxon_line': True,
+ })
+
+ # Add expense account line.
+ lines_vals_list.append({
+ 'name': line.name[:64],
+ 'move_id': move.id,
+ 'product_id': line.product_id.id,
+ 'product_uom_id': line.product_uom_id.id,
+ 'quantity': line.quantity,
+ 'price_unit': -price_unit,
+ 'debit': balance > 0.0 and balance or 0.0,
+ 'credit': balance < 0.0 and -balance or 0.0,
+ 'account_id': credit_expense_account.id,
+ 'analytic_account_id': line.analytic_account_id.id,
+ 'analytic_tag_ids': [(6, 0, line.analytic_tag_ids.ids)],
+ 'exclude_from_invoice_tab': True,
+ 'is_anglo_saxon_line': True,
+ })
+ return lines_vals_list
+
+ def _stock_account_get_last_step_stock_moves(self):
+ """ To be overridden for customer invoices and vendor bills in order to
+ return the stock moves related to the invoices in self.
+ """
+ return self.env['stock.move']
+
+ def _stock_account_anglo_saxon_reconcile_valuation(self, product=False):
+ """ Reconciles the entries made in the interim accounts in anglosaxon accounting,
+ reconciling stock valuation move lines with the invoice's.
+ """
+ for move in self:
+ if not move.is_invoice():
+ continue
+ if not move.company_id.anglo_saxon_accounting:
+ continue
+
+ stock_moves = move._stock_account_get_last_step_stock_moves()
+
+ if not stock_moves:
+ continue
+
+ products = product or move.mapped('invoice_line_ids.product_id')
+ for prod in products:
+ if prod.valuation != 'real_time':
+ continue
+
+ # We first get the invoices move lines (taking the invoice and the previous ones into account)...
+ product_accounts = prod.product_tmpl_id._get_product_accounts()
+ if move.is_sale_document():
+ product_interim_account = product_accounts['stock_output']
+ else:
+ product_interim_account = product_accounts['stock_input']
+
+ if product_interim_account.reconcile:
+ # Search for anglo-saxon lines linked to the product in the journal entry.
+ product_account_moves = move.line_ids.filtered(
+ lambda line: line.product_id == prod and line.account_id == product_interim_account and not line.reconciled)
+
+ # Search for anglo-saxon lines linked to the product in the stock moves.
+ product_stock_moves = stock_moves.filtered(lambda stock_move: stock_move.product_id == prod)
+ product_account_moves += product_stock_moves.mapped('account_move_ids.line_ids')\
+ .filtered(lambda line: line.account_id == product_interim_account and not line.reconciled)
+
+ # Reconcile.
+ product_account_moves.reconcile()
+
+
+class AccountMoveLine(models.Model):
+ _inherit = 'account.move.line'
+
+ is_anglo_saxon_line = fields.Boolean(help="Technical field used to retrieve the anglo-saxon lines.")
+
+ def _get_computed_account(self):
+ # OVERRIDE to use the stock input account by default on vendor bills when dealing
+ # with anglo-saxon accounting.
+ self.ensure_one()
+ if self.product_id.type == 'product' \
+ and self.move_id.company_id.anglo_saxon_accounting \
+ and self.move_id.is_purchase_document():
+ fiscal_position = self.move_id.fiscal_position_id
+ accounts = self.product_id.product_tmpl_id.get_product_accounts(fiscal_pos=fiscal_position)
+ if accounts['stock_input']:
+ return accounts['stock_input']
+ return super(AccountMoveLine, self)._get_computed_account()
+
+ def _stock_account_get_anglo_saxon_price_unit(self):
+ self.ensure_one()
+ if not self.product_id:
+ return self.price_unit
+ return self.product_id._stock_account_get_anglo_saxon_price_unit(uom=self.product_uom_id)
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
diff --git a/addons/stock_account/models/res_config_settings.py b/addons/stock_account/models/res_config_settings.py
new file mode 100644
index 00000000..0889e5e0
--- /dev/null
+++ b/addons/stock_account/models/res_config_settings.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ module_stock_landed_costs = fields.Boolean("Landed Costs",
+ help="Affect landed costs on reception operations and split them among products to update their cost price.")
diff --git a/addons/stock_account/models/stock_inventory.py b/addons/stock_account/models/stock_inventory.py
new file mode 100644
index 00000000..6dafe449
--- /dev/null
+++ b/addons/stock_account/models/stock_inventory.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class StockInventory(models.Model):
+ _inherit = "stock.inventory"
+
+ accounting_date = fields.Date(
+ 'Accounting Date',
+ help="Date at which the accounting entries will be created"
+ " in case of automated inventory valuation."
+ " If empty, the inventory date will be used.")
+ has_account_moves = fields.Boolean(compute='_compute_has_account_moves')
+
+ def _compute_has_account_moves(self):
+ for inventory in self:
+ if inventory.state == 'done' and inventory.move_ids:
+ account_move = self.env['account.move'].search_count([
+ ('stock_move_id.id', 'in', inventory.move_ids.ids)
+ ])
+ inventory.has_account_moves = account_move > 0
+ else:
+ inventory.has_account_moves = False
+
+ def action_get_account_moves(self):
+ self.ensure_one()
+ action_data = self.env['ir.actions.act_window']._for_xml_id('account.action_move_journal_line')
+ action_data['domain'] = [('stock_move_id.id', 'in', self.move_ids.ids)]
+ action_data['context'] = dict(self._context, create=False)
+ return action_data
+
+ def post_inventory(self):
+ res = True
+ acc_inventories = self.filtered(lambda inventory: inventory.accounting_date)
+ for inventory in acc_inventories:
+ res = super(StockInventory, inventory.with_context(force_period_date=inventory.accounting_date)).post_inventory()
+ other_inventories = self - acc_inventories
+ if other_inventories:
+ res = super(StockInventory, other_inventories).post_inventory()
+ return res
diff --git a/addons/stock_account/models/stock_location.py b/addons/stock_account/models/stock_location.py
new file mode 100644
index 00000000..731e31b3
--- /dev/null
+++ b/addons/stock_account/models/stock_location.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class StockLocation(models.Model):
+ _inherit = "stock.location"
+
+ valuation_in_account_id = fields.Many2one(
+ 'account.account', 'Stock Valuation Account (Incoming)',
+ domain=[('internal_type', '=', 'other'), ('deprecated', '=', False)],
+ help="Used for real-time inventory valuation. When set on a virtual location (non internal type), "
+ "this account will be used to hold the value of products being moved from an internal location "
+ "into this location, instead of the generic Stock Output Account set on the product. "
+ "This has no effect for internal locations.")
+ valuation_out_account_id = fields.Many2one(
+ 'account.account', 'Stock Valuation Account (Outgoing)',
+ domain=[('internal_type', '=', 'other'), ('deprecated', '=', False)],
+ help="Used for real-time inventory valuation. When set on a virtual location (non internal type), "
+ "this account will be used to hold the value of products being moved out of this location "
+ "and into an internal location, instead of the generic Stock Output Account set on the product. "
+ "This has no effect for internal locations.")
+
+ def _should_be_valued(self):
+ """ This method returns a boolean reflecting whether the products stored in `self` should
+ be considered when valuating the stock of a company.
+ """
+ self.ensure_one()
+ if self.usage == 'internal' or (self.usage == 'transit' and self.company_id):
+ return True
+ return False
+
diff --git a/addons/stock_account/models/stock_move.py b/addons/stock_account/models/stock_move.py
new file mode 100644
index 00000000..254a74bc
--- /dev/null
+++ b/addons/stock_account/models/stock_move.py
@@ -0,0 +1,522 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from collections import defaultdict
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+from odoo.tools import float_is_zero, OrderedSet
+
+import logging
+_logger = logging.getLogger(__name__)
+
+
+class StockMove(models.Model):
+ _inherit = "stock.move"
+
+ to_refund = fields.Boolean(string="Update quantities on SO/PO", copy=False,
+ help='Trigger a decrease of the delivered/received quantity in the associated Sale Order/Purchase Order')
+ account_move_ids = fields.One2many('account.move', 'stock_move_id')
+ stock_valuation_layer_ids = fields.One2many('stock.valuation.layer', 'stock_move_id')
+
+ def _filter_anglo_saxon_moves(self, product):
+ return self.filtered(lambda m: m.product_id.id == product.id)
+
+ def action_get_account_moves(self):
+ self.ensure_one()
+ action_data = self.env['ir.actions.act_window']._for_xml_id('account.action_move_journal_line')
+ action_data['domain'] = [('id', 'in', self.account_move_ids.ids)]
+ return action_data
+
+ def _get_price_unit(self):
+ """ Returns the unit price to value this stock move """
+ self.ensure_one()
+ price_unit = self.price_unit
+ precision = self.env['decimal.precision'].precision_get('Product Price')
+ # If the move is a return, use the original move's price unit.
+ if self.origin_returned_move_id and self.origin_returned_move_id.sudo().stock_valuation_layer_ids:
+ price_unit = self.origin_returned_move_id.sudo().stock_valuation_layer_ids[-1].unit_cost
+ return not float_is_zero(price_unit, precision) and price_unit or self.product_id.standard_price
+
+ @api.model
+ def _get_valued_types(self):
+ """Returns a list of `valued_type` as strings. During `action_done`, we'll call
+ `_is_[valued_type]'. If the result of this method is truthy, we'll consider the move to be
+ valued.
+
+ :returns: a list of `valued_type`
+ :rtype: list
+ """
+ return ['in', 'out', 'dropshipped', 'dropshipped_returned']
+
+ def _get_in_move_lines(self):
+ """ Returns the `stock.move.line` records of `self` considered as incoming. It is done thanks
+ to the `_should_be_valued` method of their source and destionation location as well as their
+ owner.
+
+ :returns: a subset of `self` containing the incoming records
+ :rtype: recordset
+ """
+ self.ensure_one()
+ res = OrderedSet()
+ for move_line in self.move_line_ids:
+ if move_line.owner_id and move_line.owner_id != move_line.company_id.partner_id:
+ continue
+ if not move_line.location_id._should_be_valued() and move_line.location_dest_id._should_be_valued():
+ res.add(move_line.id)
+ return self.env['stock.move.line'].browse(res)
+
+ def _is_in(self):
+ """Check if the move should be considered as entering the company so that the cost method
+ will be able to apply the correct logic.
+
+ :returns: True if the move is entering the company else False
+ :rtype: bool
+ """
+ self.ensure_one()
+ if self._get_in_move_lines():
+ return True
+ return False
+
+ def _get_out_move_lines(self):
+ """ Returns the `stock.move.line` records of `self` considered as outgoing. It is done thanks
+ to the `_should_be_valued` method of their source and destionation location as well as their
+ owner.
+
+ :returns: a subset of `self` containing the outgoing records
+ :rtype: recordset
+ """
+ res = self.env['stock.move.line']
+ for move_line in self.move_line_ids:
+ if move_line.owner_id and move_line.owner_id != move_line.company_id.partner_id:
+ continue
+ if move_line.location_id._should_be_valued() and not move_line.location_dest_id._should_be_valued():
+ res |= move_line
+ return res
+
+ def _is_out(self):
+ """Check if the move should be considered as leaving the company so that the cost method
+ will be able to apply the correct logic.
+
+ :returns: True if the move is leaving the company else False
+ :rtype: bool
+ """
+ self.ensure_one()
+ if self._get_out_move_lines():
+ return True
+ return False
+
+ def _is_dropshipped(self):
+ """Check if the move should be considered as a dropshipping move so that the cost method
+ will be able to apply the correct logic.
+
+ :returns: True if the move is a dropshipping one else False
+ :rtype: bool
+ """
+ self.ensure_one()
+ return self.location_id.usage == 'supplier' and self.location_dest_id.usage == 'customer'
+
+ def _is_dropshipped_returned(self):
+ """Check if the move should be considered as a returned dropshipping move so that the cost
+ method will be able to apply the correct logic.
+
+ :returns: True if the move is a returned dropshipping one else False
+ :rtype: bool
+ """
+ self.ensure_one()
+ return self.location_id.usage == 'customer' and self.location_dest_id.usage == 'supplier'
+
+ def _prepare_common_svl_vals(self):
+ """When a `stock.valuation.layer` is created from a `stock.move`, we can prepare a dict of
+ common vals.
+
+ :returns: the common values when creating a `stock.valuation.layer` from a `stock.move`
+ :rtype: dict
+ """
+ self.ensure_one()
+ return {
+ 'stock_move_id': self.id,
+ 'company_id': self.company_id.id,
+ 'product_id': self.product_id.id,
+ 'description': self.reference and '%s - %s' % (self.reference, self.product_id.name) or self.product_id.name,
+ }
+
+ def _create_in_svl(self, forced_quantity=None):
+ """Create a `stock.valuation.layer` from `self`.
+
+ :param forced_quantity: under some circunstances, the quantity to value is different than
+ the initial demand of the move (Default value = None)
+ """
+ svl_vals_list = []
+ for move in self:
+ move = move.with_company(move.company_id)
+ valued_move_lines = move._get_in_move_lines()
+ valued_quantity = 0
+ for valued_move_line in valued_move_lines:
+ valued_quantity += valued_move_line.product_uom_id._compute_quantity(valued_move_line.qty_done, move.product_id.uom_id)
+ unit_cost = abs(move._get_price_unit()) # May be negative (i.e. decrease an out move).
+ if move.product_id.cost_method == 'standard':
+ unit_cost = move.product_id.standard_price
+ svl_vals = move.product_id._prepare_in_svl_vals(forced_quantity or valued_quantity, unit_cost)
+ svl_vals.update(move._prepare_common_svl_vals())
+ if forced_quantity:
+ svl_vals['description'] = 'Correction of %s (modification of past move)' % move.picking_id.name or move.name
+ svl_vals_list.append(svl_vals)
+ return self.env['stock.valuation.layer'].sudo().create(svl_vals_list)
+
+ def _create_out_svl(self, forced_quantity=None):
+ """Create a `stock.valuation.layer` from `self`.
+
+ :param forced_quantity: under some circunstances, the quantity to value is different than
+ the initial demand of the move (Default value = None)
+ """
+ svl_vals_list = []
+ for move in self:
+ move = move.with_company(move.company_id)
+ valued_move_lines = move._get_out_move_lines()
+ valued_quantity = 0
+ for valued_move_line in valued_move_lines:
+ valued_quantity += valued_move_line.product_uom_id._compute_quantity(valued_move_line.qty_done, move.product_id.uom_id)
+ if float_is_zero(forced_quantity or valued_quantity, precision_rounding=move.product_id.uom_id.rounding):
+ continue
+ svl_vals = move.product_id._prepare_out_svl_vals(forced_quantity or valued_quantity, move.company_id)
+ svl_vals.update(move._prepare_common_svl_vals())
+ if forced_quantity:
+ svl_vals['description'] = 'Correction of %s (modification of past move)' % move.picking_id.name or move.name
+ svl_vals['description'] += svl_vals.pop('rounding_adjustment', '')
+ svl_vals_list.append(svl_vals)
+ return self.env['stock.valuation.layer'].sudo().create(svl_vals_list)
+
+ def _create_dropshipped_svl(self, forced_quantity=None):
+ """Create a `stock.valuation.layer` from `self`.
+
+ :param forced_quantity: under some circunstances, the quantity to value is different than
+ the initial demand of the move (Default value = None)
+ """
+ svl_vals_list = []
+ for move in self:
+ move = move.with_company(move.company_id)
+ valued_move_lines = move.move_line_ids
+ valued_quantity = 0
+ for valued_move_line in valued_move_lines:
+ valued_quantity += valued_move_line.product_uom_id._compute_quantity(valued_move_line.qty_done, move.product_id.uom_id)
+ quantity = forced_quantity or valued_quantity
+
+ unit_cost = move._get_price_unit()
+ if move.product_id.cost_method == 'standard':
+ unit_cost = move.product_id.standard_price
+
+ common_vals = dict(move._prepare_common_svl_vals(), remaining_qty=0)
+
+ # create the in
+ in_vals = {
+ 'unit_cost': unit_cost,
+ 'value': unit_cost * quantity,
+ 'quantity': quantity,
+ }
+ in_vals.update(common_vals)
+ svl_vals_list.append(in_vals)
+
+ # create the out
+ out_vals = {
+ 'unit_cost': unit_cost,
+ 'value': unit_cost * quantity * -1,
+ 'quantity': quantity * -1,
+ }
+ out_vals.update(common_vals)
+ svl_vals_list.append(out_vals)
+ return self.env['stock.valuation.layer'].sudo().create(svl_vals_list)
+
+ def _create_dropshipped_returned_svl(self, forced_quantity=None):
+ """Create a `stock.valuation.layer` from `self`.
+
+ :param forced_quantity: under some circunstances, the quantity to value is different than
+ the initial demand of the move (Default value = None)
+ """
+ return self._create_dropshipped_svl(forced_quantity=forced_quantity)
+
+ def _action_done(self, cancel_backorder=False):
+ # Init a dict that will group the moves by valuation type, according to `move._is_valued_type`.
+ valued_moves = {valued_type: self.env['stock.move'] for valued_type in self._get_valued_types()}
+ for move in self:
+ if float_is_zero(move.quantity_done, precision_rounding=move.product_uom.rounding):
+ continue
+ for valued_type in self._get_valued_types():
+ if getattr(move, '_is_%s' % valued_type)():
+ valued_moves[valued_type] |= move
+
+ # AVCO application
+ valued_moves['in'].product_price_update_before_done()
+
+ res = super(StockMove, self)._action_done(cancel_backorder=cancel_backorder)
+
+ # '_action_done' might have created an extra move to be valued
+ for move in res - self:
+ for valued_type in self._get_valued_types():
+ if getattr(move, '_is_%s' % valued_type)():
+ valued_moves[valued_type] |= move
+
+ stock_valuation_layers = self.env['stock.valuation.layer'].sudo()
+ # Create the valuation layers in batch by calling `moves._create_valued_type_svl`.
+ for valued_type in self._get_valued_types():
+ todo_valued_moves = valued_moves[valued_type]
+ if todo_valued_moves:
+ todo_valued_moves._sanity_check_for_valuation()
+ stock_valuation_layers |= getattr(todo_valued_moves, '_create_%s_svl' % valued_type)()
+
+
+ for svl in stock_valuation_layers:
+ if not svl.product_id.valuation == 'real_time':
+ continue
+ if svl.currency_id.is_zero(svl.value):
+ continue
+ svl.stock_move_id._account_entry_move(svl.quantity, svl.description, svl.id, svl.value)
+
+ stock_valuation_layers._check_company()
+
+ # For every in move, run the vacuum for the linked product.
+ products_to_vacuum = valued_moves['in'].mapped('product_id')
+ company = valued_moves['in'].mapped('company_id') and valued_moves['in'].mapped('company_id')[0] or self.env.company
+ for product_to_vacuum in products_to_vacuum:
+ product_to_vacuum._run_fifo_vacuum(company)
+
+ return res
+
+ def _sanity_check_for_valuation(self):
+ for move in self:
+ # Apply restrictions on the stock move to be able to make
+ # consistent accounting entries.
+ if move._is_in() and move._is_out():
+ raise UserError(_("The move lines are not in a consistent state: some are entering and other are leaving the company."))
+ company_src = move.mapped('move_line_ids.location_id.company_id')
+ company_dst = move.mapped('move_line_ids.location_dest_id.company_id')
+ try:
+ if company_src:
+ company_src.ensure_one()
+ if company_dst:
+ company_dst.ensure_one()
+ except ValueError:
+ raise UserError(_("The move lines are not in a consistent states: they do not share the same origin or destination company."))
+ if company_src and company_dst and company_src.id != company_dst.id:
+ raise UserError(_("The move lines are not in a consistent states: they are doing an intercompany in a single step while they should go through the intercompany transit location."))
+
+ def product_price_update_before_done(self, forced_qty=None):
+ tmpl_dict = defaultdict(lambda: 0.0)
+ # adapt standard price on incomming moves if the product cost_method is 'average'
+ std_price_update = {}
+ for move in self.filtered(lambda move: move._is_in() and move.with_company(move.company_id).product_id.cost_method == 'average'):
+ product_tot_qty_available = move.product_id.sudo().with_company(move.company_id).quantity_svl + tmpl_dict[move.product_id.id]
+ rounding = move.product_id.uom_id.rounding
+
+ valued_move_lines = move._get_in_move_lines()
+ qty_done = 0
+ for valued_move_line in valued_move_lines:
+ qty_done += valued_move_line.product_uom_id._compute_quantity(valued_move_line.qty_done, move.product_id.uom_id)
+
+ qty = forced_qty or qty_done
+ if float_is_zero(product_tot_qty_available, precision_rounding=rounding):
+ new_std_price = move._get_price_unit()
+ elif float_is_zero(product_tot_qty_available + move.product_qty, precision_rounding=rounding) or \
+ float_is_zero(product_tot_qty_available + qty, precision_rounding=rounding):
+ new_std_price = move._get_price_unit()
+ else:
+ # Get the standard price
+ amount_unit = std_price_update.get((move.company_id.id, move.product_id.id)) or move.product_id.with_company(move.company_id).standard_price
+ new_std_price = ((amount_unit * product_tot_qty_available) + (move._get_price_unit() * qty)) / (product_tot_qty_available + qty)
+
+ tmpl_dict[move.product_id.id] += qty_done
+ # Write the standard price, as SUPERUSER_ID because a warehouse manager may not have the right to write on products
+ move.product_id.with_company(move.company_id.id).with_context(disable_auto_svl=True).sudo().write({'standard_price': new_std_price})
+ std_price_update[move.company_id.id, move.product_id.id] = new_std_price
+
+ # adapt standard price on incomming moves if the product cost_method is 'fifo'
+ for move in self.filtered(lambda move:
+ move.with_company(move.company_id).product_id.cost_method == 'fifo'
+ and float_is_zero(move.product_id.sudo().quantity_svl, precision_rounding=move.product_id.uom_id.rounding)):
+ move.product_id.with_company(move.company_id.id).sudo().write({'standard_price': move._get_price_unit()})
+
+ def _get_accounting_data_for_valuation(self):
+ """ Return the accounts and journal to use to post Journal Entries for
+ the real-time valuation of the quant. """
+ self.ensure_one()
+ self = self.with_company(self.company_id)
+ accounts_data = self.product_id.product_tmpl_id.get_product_accounts()
+
+ acc_src = self._get_src_account(accounts_data)
+ acc_dest = self._get_dest_account(accounts_data)
+
+ acc_valuation = accounts_data.get('stock_valuation', False)
+ if acc_valuation:
+ acc_valuation = acc_valuation.id
+ if not accounts_data.get('stock_journal', False):
+ raise UserError(_('You don\'t have any stock journal defined on your product category, check if you have installed a chart of accounts.'))
+ if not acc_src:
+ raise UserError(_('Cannot find a stock input account for the product %s. You must define one on the product category, or on the location, before processing this operation.') % (self.product_id.display_name))
+ if not acc_dest:
+ raise UserError(_('Cannot find a stock output account for the product %s. You must define one on the product category, or on the location, before processing this operation.') % (self.product_id.display_name))
+ if not acc_valuation:
+ raise UserError(_('You don\'t have any stock valuation account defined on your product category. You must define one before processing this operation.'))
+ journal_id = accounts_data['stock_journal'].id
+ return journal_id, acc_src, acc_dest, acc_valuation
+
+ def _get_src_account(self, accounts_data):
+ return self.location_id.valuation_out_account_id.id or accounts_data['stock_input'].id
+
+ def _get_dest_account(self, accounts_data):
+ return self.location_dest_id.valuation_in_account_id.id or accounts_data['stock_output'].id
+
+ def _prepare_account_move_line(self, qty, cost, credit_account_id, debit_account_id, description):
+ """
+ Generate the account.move.line values to post to track the stock valuation difference due to the
+ processing of the given quant.
+ """
+ self.ensure_one()
+
+ # the standard_price of the product may be in another decimal precision, or not compatible with the coinage of
+ # the company currency... so we need to use round() before creating the accounting entries.
+ debit_value = self.company_id.currency_id.round(cost)
+ credit_value = debit_value
+
+ valuation_partner_id = self._get_partner_id_for_valuation_lines()
+ res = [(0, 0, line_vals) for line_vals in self._generate_valuation_lines_data(valuation_partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id, description).values()]
+
+ return res
+
+ def _generate_valuation_lines_data(self, partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id, description):
+ # This method returns a dictionary to provide an easy extension hook to modify the valuation lines (see purchase for an example)
+ self.ensure_one()
+ debit_line_vals = {
+ 'name': description,
+ 'product_id': self.product_id.id,
+ 'quantity': qty,
+ 'product_uom_id': self.product_id.uom_id.id,
+ 'ref': description,
+ 'partner_id': partner_id,
+ 'debit': debit_value if debit_value > 0 else 0,
+ 'credit': -debit_value if debit_value < 0 else 0,
+ 'account_id': debit_account_id,
+ }
+
+ credit_line_vals = {
+ 'name': description,
+ 'product_id': self.product_id.id,
+ 'quantity': qty,
+ 'product_uom_id': self.product_id.uom_id.id,
+ 'ref': description,
+ 'partner_id': partner_id,
+ 'credit': credit_value if credit_value > 0 else 0,
+ 'debit': -credit_value if credit_value < 0 else 0,
+ 'account_id': credit_account_id,
+ }
+
+ rslt = {'credit_line_vals': credit_line_vals, 'debit_line_vals': debit_line_vals}
+ if credit_value != debit_value:
+ # for supplier returns of product in average costing method, in anglo saxon mode
+ diff_amount = debit_value - credit_value
+ price_diff_account = self.product_id.property_account_creditor_price_difference
+
+ if not price_diff_account:
+ price_diff_account = self.product_id.categ_id.property_account_creditor_price_difference_categ
+ if not price_diff_account:
+ raise UserError(_('Configuration error. Please configure the price difference account on the product or its category to process this operation.'))
+
+ rslt['price_diff_line_vals'] = {
+ 'name': self.name,
+ 'product_id': self.product_id.id,
+ 'quantity': qty,
+ 'product_uom_id': self.product_id.uom_id.id,
+ 'ref': description,
+ 'partner_id': partner_id,
+ 'credit': diff_amount > 0 and diff_amount or 0,
+ 'debit': diff_amount < 0 and -diff_amount or 0,
+ 'account_id': price_diff_account.id,
+ }
+ return rslt
+
+ def _get_partner_id_for_valuation_lines(self):
+ return (self.picking_id.partner_id and self.env['res.partner']._find_accounting_partner(self.picking_id.partner_id).id) or False
+
+ def _prepare_move_split_vals(self, uom_qty):
+ vals = super(StockMove, self)._prepare_move_split_vals(uom_qty)
+ vals['to_refund'] = self.to_refund
+ return vals
+
+ def _create_account_move_line(self, credit_account_id, debit_account_id, journal_id, qty, description, svl_id, cost):
+ self.ensure_one()
+ AccountMove = self.env['account.move'].with_context(default_journal_id=journal_id)
+
+ move_lines = self._prepare_account_move_line(qty, cost, credit_account_id, debit_account_id, description)
+ if move_lines:
+ date = self._context.get('force_period_date', fields.Date.context_today(self))
+ new_account_move = AccountMove.sudo().create({
+ 'journal_id': journal_id,
+ 'line_ids': move_lines,
+ 'date': date,
+ 'ref': description,
+ 'stock_move_id': self.id,
+ 'stock_valuation_layer_ids': [(6, None, [svl_id])],
+ 'move_type': 'entry',
+ })
+ new_account_move._post()
+
+ def _account_entry_move(self, qty, description, svl_id, cost):
+ """ Accounting Valuation Entries """
+ self.ensure_one()
+ if self.product_id.type != 'product':
+ # no stock valuation for consumable products
+ return False
+ if self.restrict_partner_id:
+ # if the move isn't owned by the company, we don't make any valuation
+ return False
+
+ company_from = self._is_out() and self.mapped('move_line_ids.location_id.company_id') or False
+ company_to = self._is_in() and self.mapped('move_line_ids.location_dest_id.company_id') or False
+
+ journal_id, acc_src, acc_dest, acc_valuation = self._get_accounting_data_for_valuation()
+ # Create Journal Entry for products arriving in the company; in case of routes making the link between several
+ # warehouse of the same company, the transit location belongs to this company, so we don't need to create accounting entries
+ if self._is_in():
+ if self._is_returned(valued_type='in'):
+ self.with_company(company_to)._create_account_move_line(acc_dest, acc_valuation, journal_id, qty, description, svl_id, cost)
+ else:
+ self.with_company(company_to)._create_account_move_line(acc_src, acc_valuation, journal_id, qty, description, svl_id, cost)
+
+ # Create Journal Entry for products leaving the company
+ if self._is_out():
+ cost = -1 * cost
+ if self._is_returned(valued_type='out'):
+ self.with_company(company_from)._create_account_move_line(acc_valuation, acc_src, journal_id, qty, description, svl_id, cost)
+ else:
+ self.with_company(company_from)._create_account_move_line(acc_valuation, acc_dest, journal_id, qty, description, svl_id, cost)
+
+ if self.company_id.anglo_saxon_accounting:
+ # Creates an account entry from stock_input to stock_output on a dropship move. https://github.com/odoo/odoo/issues/12687
+ if self._is_dropshipped():
+ if cost > 0:
+ self.with_company(self.company_id)._create_account_move_line(acc_src, acc_valuation, journal_id, qty, description, svl_id, cost)
+ else:
+ cost = -1 * cost
+ self.with_company(self.company_id)._create_account_move_line(acc_valuation, acc_dest, journal_id, qty, description, svl_id, cost)
+ elif self._is_dropshipped_returned():
+ if cost > 0:
+ self.with_company(self.company_id)._create_account_move_line(acc_valuation, acc_src, journal_id, qty, description, svl_id, cost)
+ else:
+ cost = -1 * cost
+ self.with_company(self.company_id)._create_account_move_line(acc_dest, acc_valuation, journal_id, qty, description, svl_id, cost)
+
+ if self.company_id.anglo_saxon_accounting:
+ # Eventually reconcile together the invoice and valuation accounting entries on the stock interim accounts
+ self._get_related_invoices()._stock_account_anglo_saxon_reconcile_valuation(product=self.product_id)
+
+ def _get_related_invoices(self): # To be overridden in purchase and sale_stock
+ """ This method is overrided in both purchase and sale_stock modules to adapt
+ to the way they mix stock moves with invoices.
+ """
+ return self.env['account.move']
+
+ def _is_returned(self, valued_type):
+ self.ensure_one()
+ if valued_type == 'in':
+ return self.location_id and self.location_id.usage == 'customer' # goods returned from customer
+ if valued_type == 'out':
+ return self.location_dest_id and self.location_dest_id.usage == 'supplier' # goods returned to supplier
diff --git a/addons/stock_account/models/stock_move_line.py b/addons/stock_account/models/stock_move_line.py
new file mode 100644
index 00000000..4187f201
--- /dev/null
+++ b/addons/stock_account/models/stock_move_line.py
@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models
+from odoo.tools import float_is_zero
+
+
+class StockMoveLine(models.Model):
+ _inherit = 'stock.move.line'
+
+ # -------------------------------------------------------------------------
+ # CRUD
+ # -------------------------------------------------------------------------
+ @api.model_create_multi
+ def create(self, vals_list):
+ move_lines = super(StockMoveLine, self).create(vals_list)
+ for move_line in move_lines:
+ if move_line.state != 'done':
+ continue
+ move = move_line.move_id
+ rounding = move.product_id.uom_id.rounding
+ diff = move_line.qty_done
+ if float_is_zero(diff, precision_rounding=rounding):
+ continue
+ self._create_correction_svl(move, diff)
+ return move_lines
+
+ def write(self, vals):
+ if 'qty_done' in vals:
+ for move_line in self:
+ if move_line.state != 'done':
+ continue
+ move = move_line.move_id
+ rounding = move.product_id.uom_id.rounding
+ diff = vals['qty_done'] - move_line.qty_done
+ if float_is_zero(diff, precision_rounding=rounding):
+ continue
+ self._create_correction_svl(move, diff)
+ return super(StockMoveLine, self).write(vals)
+
+ # -------------------------------------------------------------------------
+ # SVL creation helpers
+ # -------------------------------------------------------------------------
+ @api.model
+ def _create_correction_svl(self, move, diff):
+ stock_valuation_layers = self.env['stock.valuation.layer']
+ if move._is_in() and diff > 0 or move._is_out() and diff < 0:
+ move.product_price_update_before_done(forced_qty=diff)
+ stock_valuation_layers |= move._create_in_svl(forced_quantity=abs(diff))
+ if move.product_id.cost_method in ('average', 'fifo'):
+ move.product_id._run_fifo_vacuum(move.company_id)
+ elif move._is_in() and diff < 0 or move._is_out() and diff > 0:
+ stock_valuation_layers |= move._create_out_svl(forced_quantity=abs(diff))
+ elif move._is_dropshipped() and diff > 0 or move._is_dropshipped_returned() and diff < 0:
+ stock_valuation_layers |= move._create_dropshipped_svl(forced_quantity=abs(diff))
+ elif move._is_dropshipped() and diff < 0 or move._is_dropshipped_returned() and diff > 0:
+ stock_valuation_layers |= move._create_dropshipped_returned_svl(forced_quantity=abs(diff))
+
+ for svl in stock_valuation_layers:
+ if not svl.product_id.valuation == 'real_time':
+ continue
+ svl.stock_move_id._account_entry_move(svl.quantity, svl.description, svl.id, svl.value)
diff --git a/addons/stock_account/models/stock_picking.py b/addons/stock_account/models/stock_picking.py
new file mode 100644
index 00000000..df5a3e07
--- /dev/null
+++ b/addons/stock_account/models/stock_picking.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from ast import literal_eval
+
+from odoo import models
+
+
+class StockPicking(models.Model):
+ _inherit = 'stock.picking'
+
+ def action_view_stock_valuation_layers(self):
+ self.ensure_one()
+ scraps = self.env['stock.scrap'].search([('picking_id', '=', self.id)])
+ domain = [('id', 'in', (self.move_lines + scraps.move_id).stock_valuation_layer_ids.ids)]
+ action = self.env["ir.actions.actions"]._for_xml_id("stock_account.stock_valuation_layer_action")
+ context = literal_eval(action['context'])
+ context.update(self.env.context)
+ context['no_at_date'] = True
+ return dict(action, domain=domain, context=context)
+
diff --git a/addons/stock_account/models/stock_quant.py b/addons/stock_account/models/stock_quant.py
new file mode 100644
index 00000000..c8724bf9
--- /dev/null
+++ b/addons/stock_account/models/stock_quant.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+from odoo.tools.float_utils import float_is_zero
+
+
+class StockQuant(models.Model):
+ _inherit = 'stock.quant'
+
+ value = fields.Monetary('Value', compute='_compute_value', groups='stock.group_stock_manager')
+ currency_id = fields.Many2one('res.currency', compute='_compute_value', groups='stock.group_stock_manager')
+
+ @api.depends('company_id', 'location_id', 'owner_id', 'product_id', 'quantity')
+ def _compute_value(self):
+ """ For standard and AVCO valuation, compute the current accounting
+ valuation of the quants by multiplying the quantity by
+ the standard price. Instead for FIFO, use the quantity times the
+ average cost (valuation layers are not manage by location so the
+ average cost is the same for all location and the valuation field is
+ a estimation more than a real value).
+ """
+ for quant in self:
+ quant.currency_id = quant.company_id.currency_id
+ # If the user didn't enter a location yet while enconding a quant.
+ if not quant.location_id:
+ quant.value = 0
+ return
+
+ if not quant.location_id._should_be_valued() or\
+ (quant.owner_id and quant.owner_id != quant.company_id.partner_id):
+ quant.value = 0
+ continue
+ if quant.product_id.cost_method == 'fifo':
+ quantity = quant.product_id.quantity_svl
+ if float_is_zero(quantity, precision_rounding=quant.product_id.uom_id.rounding):
+ quant.value = 0.0
+ continue
+ average_cost = quant.product_id.with_company(quant.company_id).value_svl / quantity
+ quant.value = quant.quantity * average_cost
+ else:
+ quant.value = quant.quantity * quant.product_id.with_company(quant.company_id).standard_price
+
+ @api.model
+ def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
+ """ This override is done in order for the grouped list view to display the total value of
+ the quants inside a location. This doesn't work out of the box because `value` is a computed
+ field.
+ """
+ if 'value' not in fields:
+ return super(StockQuant, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
+ res = super(StockQuant, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
+ for group in res:
+ if group.get('__domain'):
+ quants = self.search(group['__domain'])
+ group['value'] = sum(quant.value for quant in quants)
+ return res
diff --git a/addons/stock_account/models/stock_valuation_layer.py b/addons/stock_account/models/stock_valuation_layer.py
new file mode 100644
index 00000000..ffb96e4b
--- /dev/null
+++ b/addons/stock_account/models/stock_valuation_layer.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models, tools
+
+
+class StockValuationLayer(models.Model):
+ """Stock Valuation Layer"""
+
+ _name = 'stock.valuation.layer'
+ _description = 'Stock Valuation Layer'
+ _order = 'create_date, id'
+
+ _rec_name = 'product_id'
+
+ company_id = fields.Many2one('res.company', 'Company', readonly=True, required=True)
+ product_id = fields.Many2one('product.product', 'Product', readonly=True, required=True, check_company=True)
+ categ_id = fields.Many2one('product.category', related='product_id.categ_id')
+ product_tmpl_id = fields.Many2one('product.template', related='product_id.product_tmpl_id')
+ quantity = fields.Float('Quantity', digits=0, help='Quantity', readonly=True)
+ uom_id = fields.Many2one(related='product_id.uom_id', readonly=True, required=True)
+ currency_id = fields.Many2one('res.currency', 'Currency', related='company_id.currency_id', readonly=True, required=True)
+ unit_cost = fields.Monetary('Unit Value', readonly=True)
+ value = fields.Monetary('Total Value', readonly=True)
+ remaining_qty = fields.Float(digits=0, readonly=True)
+ remaining_value = fields.Monetary('Remaining Value', readonly=True)
+ description = fields.Char('Description', readonly=True)
+ stock_valuation_layer_id = fields.Many2one('stock.valuation.layer', 'Linked To', readonly=True, check_company=True)
+ stock_valuation_layer_ids = fields.One2many('stock.valuation.layer', 'stock_valuation_layer_id')
+ stock_move_id = fields.Many2one('stock.move', 'Stock Move', readonly=True, check_company=True, index=True)
+ account_move_id = fields.Many2one('account.move', 'Journal Entry', readonly=True, check_company=True)
+
+ def init(self):
+ tools.create_index(
+ self._cr, 'stock_valuation_layer_index',
+ self._table, ['product_id', 'remaining_qty', 'stock_move_id', 'company_id', 'create_date']
+ )
+