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/stock_move.py | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/stock_account/models/stock_move.py')
| -rw-r--r-- | addons/stock_account/models/stock_move.py | 522 |
1 files changed, 522 insertions, 0 deletions
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 |
