mirror of
https://github.com/bringout/oca-ocb-accounting.git
synced 2026-04-25 15:02:06 +02:00
Initial commit: Accounting packages
This commit is contained in:
commit
4ef34c2317
2661 changed files with 1709616 additions and 0 deletions
|
|
@ -0,0 +1,13 @@
|
|||
# -*- 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_location
|
||||
from . import stock_move_line
|
||||
from . import stock_picking
|
||||
from . import stock_quant
|
||||
from . import stock_valuation_layer
|
||||
from . import res_config_settings
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,37 @@
|
|||
# -*- 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 = (journals_dict or []) + [{'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',
|
||||
]
|
||||
categ_values = {category.id: False for category in self.env['product.category'].search([])}
|
||||
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)
|
||||
PropertyObj._set_multi(field, "product.category", categ_values, True)
|
||||
|
||||
return res
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import fields, models, api
|
||||
from odoo.tools import float_compare, float_is_zero
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
stock_move_id = fields.Many2one('stock.move', string='Stock Move', index='btree_not_null')
|
||||
stock_valuation_layer_ids = fields.One2many('stock.valuation.layer', 'account_move_id', string='Stock Valuation Layer')
|
||||
|
||||
def _compute_show_reset_to_draft_button(self):
|
||||
super()._compute_show_reset_to_draft_button()
|
||||
for move in self:
|
||||
if move.sudo().line_ids.stock_valuation_layer_ids:
|
||||
move.show_reset_to_draft_button = False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# OVERRIDE METHODS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _get_lines_onchange_currency(self):
|
||||
# OVERRIDE
|
||||
return self.line_ids.filtered(lambda l: l.display_type != 'cogs')
|
||||
|
||||
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 line_vals[2].get('display_type') != 'cogs']
|
||||
|
||||
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.
|
||||
if not self.env.context.get('skip_cogs_reconciliation'):
|
||||
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.display_type == 'cogs').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.display_type == 'cogs').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 = []
|
||||
price_unit_prec = self.env['decimal.precision'].precision_get('Product Price')
|
||||
for move in self:
|
||||
# Make the loop multi-company safe when accessing models like product.product
|
||||
move = move.with_company(move.company_id)
|
||||
|
||||
if not move.is_sale_document(include_receipts=True) or not move.company_id.anglo_saxon_accounting:
|
||||
continue
|
||||
|
||||
anglo_saxon_price_ctx = move._get_anglo_saxon_price_ctx()
|
||||
|
||||
for line in move.invoice_line_ids:
|
||||
|
||||
# Filter out lines being not eligible for COGS.
|
||||
if not line._eligible_for_cogs():
|
||||
continue
|
||||
|
||||
# Retrieve accounts needed to generate the COGS.
|
||||
accounts = line.product_id.product_tmpl_id.get_product_accounts(fiscal_pos=move.fiscal_position_id)
|
||||
debit_interim_account = accounts['stock_output']
|
||||
credit_expense_account = accounts['expense'] or move.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.with_context(anglo_saxon_price_ctx)._stock_account_get_anglo_saxon_price_unit()
|
||||
amount_currency = sign * line.quantity * price_unit
|
||||
|
||||
if move.currency_id.is_zero(amount_currency) or float_is_zero(price_unit, precision_digits=price_unit_prec):
|
||||
continue
|
||||
|
||||
# Add interim account line.
|
||||
lines_vals_list.append({
|
||||
'name': line.name[:64],
|
||||
'move_id': move.id,
|
||||
'partner_id': move.commercial_partner_id.id,
|
||||
'product_id': line.product_id.id,
|
||||
'product_uom_id': line.product_uom_id.id,
|
||||
'quantity': line.quantity,
|
||||
'price_unit': price_unit,
|
||||
'amount_currency': -amount_currency,
|
||||
'account_id': debit_interim_account.id,
|
||||
'display_type': 'cogs',
|
||||
'tax_ids': [],
|
||||
})
|
||||
|
||||
# Add expense account line.
|
||||
lines_vals_list.append({
|
||||
'name': line.name[:64],
|
||||
'move_id': move.id,
|
||||
'partner_id': move.commercial_partner_id.id,
|
||||
'product_id': line.product_id.id,
|
||||
'product_uom_id': line.product_uom_id.id,
|
||||
'quantity': line.quantity,
|
||||
'price_unit': -price_unit,
|
||||
'amount_currency': amount_currency,
|
||||
'account_id': credit_expense_account.id,
|
||||
'analytic_distribution': line.analytic_distribution,
|
||||
'display_type': 'cogs',
|
||||
'tax_ids': [],
|
||||
})
|
||||
return lines_vals_list
|
||||
|
||||
def _get_anglo_saxon_price_ctx(self):
|
||||
""" To be overriden in modules overriding _stock_account_get_anglo_saxon_price_unit
|
||||
to optimize computations that only depend on account.move and not account.move.line
|
||||
"""
|
||||
return self.env.context
|
||||
|
||||
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()
|
||||
# In case we return a return, we have to provide the related AMLs so all can be reconciled
|
||||
stock_moves |= stock_moves.origin_returned_move_id
|
||||
|
||||
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._get_all_related_sm(prod)
|
||||
product_account_moves |= product_stock_moves._get_all_related_aml().filtered(
|
||||
lambda line: line.account_id == product_interim_account and not line.reconciled and line.move_id.state == "posted"
|
||||
)
|
||||
|
||||
correction_amls = product_account_moves.filtered(
|
||||
lambda aml: aml.move_id.sudo().stock_valuation_layer_ids.stock_valuation_layer_id or (aml.display_type == 'cogs' and not aml.quantity)
|
||||
)
|
||||
invoice_aml = product_account_moves.filtered(lambda aml: aml not in correction_amls and aml.move_id == move)
|
||||
stock_aml = product_account_moves - correction_amls - invoice_aml
|
||||
# Reconcile:
|
||||
# In case there is a move with correcting lines that has not been posted
|
||||
# (e.g., it's dated for some time in the future) we should defer any
|
||||
# reconciliation with exchange difference.
|
||||
if correction_amls or 'draft' in move.line_ids.sudo().stock_valuation_layer_ids.account_move_id.mapped('state'):
|
||||
if sum(correction_amls.mapped('balance')) > 0:
|
||||
product_account_moves.with_context(no_exchange_difference=True).reconcile()
|
||||
else:
|
||||
(invoice_aml | correction_amls).with_context(no_exchange_difference=True).reconcile()
|
||||
(invoice_aml.filtered(lambda aml: not aml.reconciled) | stock_aml).with_context(no_exchange_difference=True).reconcile()
|
||||
else:
|
||||
product_account_moves.reconcile()
|
||||
|
||||
def _get_invoiced_lot_values(self):
|
||||
return []
|
||||
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
_inherit = 'account.move.line'
|
||||
|
||||
stock_valuation_layer_ids = fields.One2many('stock.valuation.layer', 'account_move_line_id', string='Stock Valuation Layer')
|
||||
|
||||
def _compute_account_id(self):
|
||||
super()._compute_account_id()
|
||||
input_lines = self.filtered(lambda line: (
|
||||
line._can_use_stock_accounts()
|
||||
and line.move_id.company_id.anglo_saxon_accounting
|
||||
and line.move_id.is_purchase_document()
|
||||
))
|
||||
for line in input_lines:
|
||||
line = line.with_company(line.move_id.journal_id.company_id)
|
||||
fiscal_position = line.move_id.fiscal_position_id
|
||||
accounts = line.product_id.product_tmpl_id.get_product_accounts(fiscal_pos=fiscal_position)
|
||||
if accounts['stock_input']:
|
||||
line.account_id = accounts['stock_input']
|
||||
|
||||
def _eligible_for_cogs(self):
|
||||
self.ensure_one()
|
||||
return self.product_id.type == 'product' and self.product_id.valuation == 'real_time'
|
||||
|
||||
def _get_gross_unit_price(self):
|
||||
if float_is_zero(self.quantity, precision_rounding=self.product_uom_id.rounding):
|
||||
return self.price_unit
|
||||
|
||||
price_unit = self.price_subtotal / self.quantity
|
||||
return -price_unit if self.move_id.move_type == 'in_refund' else price_unit
|
||||
|
||||
def _get_stock_valuation_layers(self, move):
|
||||
valued_moves = self._get_valued_in_moves()
|
||||
if move.move_type == 'in_refund':
|
||||
valued_moves = valued_moves.filtered(lambda stock_move: stock_move._is_out())
|
||||
else:
|
||||
valued_moves = valued_moves.filtered(lambda stock_move: stock_move._is_in())
|
||||
return valued_moves.stock_valuation_layer_ids
|
||||
|
||||
def _get_valued_in_moves(self):
|
||||
return self.env['stock.move']
|
||||
|
||||
def _can_use_stock_accounts(self):
|
||||
return self.product_id.type == 'product' and self.product_id.categ_id.property_valuation == 'real_time'
|
||||
|
||||
def _stock_account_get_anglo_saxon_price_unit(self):
|
||||
self.ensure_one()
|
||||
if not self.product_id:
|
||||
return self.price_unit
|
||||
original_line = self.move_id.reversed_entry_id.line_ids.filtered(
|
||||
lambda l: l.display_type == 'cogs' and l.product_id == self.product_id and
|
||||
l.product_uom_id == self.product_uom_id and l.price_unit >= 0)
|
||||
original_line = original_line and original_line[0]
|
||||
return original_line.price_unit if original_line \
|
||||
else self.product_id.with_company(self.company_id)._stock_account_get_anglo_saxon_price_unit(uom=self.product_uom_id)
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _inverse_product_id(self):
|
||||
super(AccountMoveLine, self.filtered(lambda l: l.display_type != 'cogs'))._inverse_product_id()
|
||||
|
||||
def _deduce_anglo_saxon_unit_price(self, account_moves, stock_moves):
|
||||
self.ensure_one()
|
||||
|
||||
move_is_downpayment = self.env.context.get("move_is_downpayment")
|
||||
if move_is_downpayment is None:
|
||||
move_is_downpayment = self.move_id.invoice_line_ids.filtered(
|
||||
lambda line: any(line.sale_line_ids.mapped("is_downpayment"))
|
||||
)
|
||||
|
||||
is_line_reversing = False
|
||||
if self.move_id.move_type == 'out_refund' and not move_is_downpayment:
|
||||
is_line_reversing = True
|
||||
qty_to_invoice = self.product_uom_id._compute_quantity(self.quantity, self.product_id.uom_id)
|
||||
if self.move_id.move_type == 'out_refund' and move_is_downpayment:
|
||||
qty_to_invoice = -qty_to_invoice
|
||||
account_moves = account_moves.filtered(lambda m: m.state == 'posted' and bool(m.reversed_entry_id) == is_line_reversing)
|
||||
|
||||
posted_cogs = self.env['account.move.line'].search([
|
||||
('move_id', 'in', account_moves.ids),
|
||||
('display_type', '=', 'cogs'),
|
||||
('product_id', '=', self.product_id.id),
|
||||
('balance', '>', 0),
|
||||
])
|
||||
qty_invoiced = 0
|
||||
product_uom = self.product_id.uom_id
|
||||
for line in posted_cogs:
|
||||
if float_compare(line.quantity, 0, precision_rounding=product_uom.rounding) and line.move_id.move_type == 'out_refund' and any(line.move_id.invoice_line_ids.sale_line_ids.mapped('is_downpayment')):
|
||||
qty_invoiced += line.product_uom_id._compute_quantity(abs(line.quantity), line.product_id.uom_id)
|
||||
else:
|
||||
qty_invoiced += line.product_uom_id._compute_quantity(line.quantity, line.product_id.uom_id)
|
||||
value_invoiced = sum(posted_cogs.mapped('balance'))
|
||||
reversal_moves = self.env['account.move']._search([('reversed_entry_id', 'in', posted_cogs.move_id.ids)])
|
||||
reversal_cogs = self.env['account.move.line'].search([
|
||||
('move_id', 'in', reversal_moves),
|
||||
('display_type', '=', 'cogs'),
|
||||
('product_id', '=', self.product_id.id),
|
||||
('balance', '>', 0)
|
||||
])
|
||||
for line in reversal_cogs:
|
||||
if float_compare(line.quantity, 0, precision_rounding=product_uom.rounding) and line.move_id.move_type == 'out_refund' and any(line.move_id.invoice_line_ids.sale_line_ids.mapped('is_downpayment')):
|
||||
qty_invoiced -= line.product_uom_id._compute_quantity(abs(line.quantity), line.product_id.uom_id)
|
||||
else:
|
||||
qty_invoiced -= line.product_uom_id._compute_quantity(line.quantity, line.product_id.uom_id)
|
||||
value_invoiced -= sum(reversal_cogs.mapped('balance'))
|
||||
|
||||
product = self.product_id.with_company(self.company_id).with_context(value_invoiced=value_invoiced)
|
||||
average_price_unit = product._compute_average_price(qty_invoiced, qty_to_invoice, stock_moves, is_returned=is_line_reversing)
|
||||
price_unit = self.product_id.uom_id.with_company(self.company_id)._compute_price(average_price_unit, self.product_uom_id)
|
||||
|
||||
return price_unit
|
||||
|
|
@ -0,0 +1,982 @@
|
|||
# -*- 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 clean_context, float_is_zero, float_repr, float_round, float_compare
|
||||
from odoo.exceptions import ValidationError
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
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().with_context(clean_context(self._context)).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)
|
||||
avg_cost = fields.Monetary(string="Average Cost", compute='_compute_value_svl', compute_sudo=True, currency_field='company_currency_id')
|
||||
total_value = fields.Monetary(string="Total Value", compute='_compute_value_svl', compute_sudo=True, currency_field='company_currency_id')
|
||||
company_currency_id = fields.Many2one(
|
||||
'res.currency', 'Valuation Currency', compute='_compute_value_svl', compute_sudo=True,
|
||||
help="Technical field to correctly show the currently selected company's currency that corresponds "
|
||||
"to the totaled value of the product's valuation layers")
|
||||
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)
|
||||
|
||||
def _get_valuation_layer_group_domain(self):
|
||||
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))
|
||||
return domain
|
||||
|
||||
def _get_valuation_layer_group_fields(self):
|
||||
return ['value:sum', 'quantity:sum']
|
||||
|
||||
def _get_valuation_layer_groups(self):
|
||||
domain = self._get_valuation_layer_group_domain()
|
||||
group_fields = self._get_valuation_layer_group_fields()
|
||||
return self.env['stock.valuation.layer']._read_group(domain, group_fields, ['product_id'])
|
||||
|
||||
def _prepare_valuation_layer_field_values(self, valuation_layer_group):
|
||||
self.ensure_one()
|
||||
value_svl = self.env.company.currency_id.round(valuation_layer_group['value'])
|
||||
avg_cost = 0
|
||||
if not float_is_zero(valuation_layer_group['quantity'], precision_rounding=self.uom_id.rounding):
|
||||
avg_cost = value_svl / valuation_layer_group['quantity']
|
||||
return {
|
||||
"value_svl": value_svl,
|
||||
"quantity_svl": valuation_layer_group['quantity'],
|
||||
"avg_cost": avg_cost,
|
||||
"total_value": avg_cost * self.sudo(False).qty_available
|
||||
}
|
||||
|
||||
@api.depends('stock_valuation_layer_ids')
|
||||
@api.depends_context('to_date', 'company')
|
||||
def _compute_value_svl(self):
|
||||
"""Compute totals of multiple svl related values"""
|
||||
self.company_currency_id = self.env.company.currency_id
|
||||
valuation_layer_groups = self._get_valuation_layer_groups()
|
||||
products = self.browse()
|
||||
# Browse all products and compute products' quantities_dict in batch.
|
||||
self.env['product.product'].browse([group['product_id'][0] for group in valuation_layer_groups]).sudo(False).mapped('qty_available')
|
||||
for valuation_layer_group in valuation_layer_groups:
|
||||
product = self.browse(valuation_layer_group['product_id'][0])
|
||||
product.update(product._prepare_valuation_layer_field_values(valuation_layer_group))
|
||||
products |= product
|
||||
remaining = (self - products)
|
||||
remaining.value_svl = 0
|
||||
remaining.quantity_svl = 0
|
||||
remaining.avg_cost = 0
|
||||
remaining.total_value = 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()
|
||||
company_id = self.env.context.get('force_company', self.env.company.id)
|
||||
company = self.env['res.company'].browse(company_id)
|
||||
value = company.currency_id.round(unit_cost * quantity)
|
||||
return {
|
||||
'product_id': self.id,
|
||||
'value': value,
|
||||
'unit_cost': unit_cost,
|
||||
'quantity': quantity,
|
||||
'remaining_qty': quantity,
|
||||
'remaining_value': value,
|
||||
}
|
||||
|
||||
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()
|
||||
company_id = self.env.context.get('force_company', self.env.company.id)
|
||||
company = self.env['res.company'].browse(company_id)
|
||||
currency = company.currency_id
|
||||
# Quantity is negative for out valuation layers.
|
||||
quantity = -1 * quantity
|
||||
vals = {
|
||||
'product_id': self.id,
|
||||
'value': currency.round(quantity * self.standard_price),
|
||||
'unit_cost': self.standard_price,
|
||||
'quantity': quantity,
|
||||
}
|
||||
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.product_tmpl_id.cost_method == 'average' and not float_is_zero(self.quantity_svl, precision_rounding=self.uom_id.rounding):
|
||||
rounding_error = currency.round(
|
||||
(self.standard_price * self.quantity_svl - self.value_svl) * abs(quantity / self.quantity_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) <= max((abs(quantity) * currency.rounding) / 2, currency.rounding):
|
||||
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.product_tmpl_id.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
|
||||
price_unit_prec = self.env['decimal.precision'].precision_get('Product Price')
|
||||
rounded_new_price = float_round(new_price, precision_digits=price_unit_prec)
|
||||
for product in self:
|
||||
if product.cost_method not in ('standard', 'average'):
|
||||
continue
|
||||
quantity_svl = product.sudo().quantity_svl
|
||||
if float_compare(quantity_svl, 0.0, precision_rounding=product.uom_id.rounding) <= 0:
|
||||
continue
|
||||
value_svl = product.sudo().value_svl
|
||||
value = company_id.currency_id.round((rounded_new_price * quantity_svl) - value_svl)
|
||||
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, rounded_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,
|
||||
'quantity': 0,
|
||||
}), (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,
|
||||
'quantity': 0,
|
||||
})],
|
||||
}
|
||||
am_vals_list.append(move_vals)
|
||||
|
||||
account_moves = self.env['account.move'].sudo().with_context(clean_context(self._context)).create(am_vals_list)
|
||||
if account_moves:
|
||||
account_moves._post()
|
||||
|
||||
def _get_fifo_candidates_domain(self, company):
|
||||
return [
|
||||
("product_id", "=", self.id),
|
||||
("remaining_qty", ">", 0),
|
||||
("company_id", "=", company.id),
|
||||
]
|
||||
|
||||
def _get_fifo_candidates(self, company):
|
||||
candidates_domain = self._get_fifo_candidates_domain(company)
|
||||
return self.env["stock.valuation.layer"].sudo().search(candidates_domain)
|
||||
|
||||
def _get_qty_taken_on_candidate(self, qty_to_take_on_candidates, candidate):
|
||||
return min(qty_to_take_on_candidates, candidate.remaining_qty)
|
||||
|
||||
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._get_fifo_candidates(company)
|
||||
new_standard_price = 0
|
||||
tmp_value = 0 # to accumulate the value taken on the candidates
|
||||
for candidate in candidates:
|
||||
qty_taken_on_candidate = self._get_qty_taken_on_candidate(qty_to_take_on_candidates, candidate)
|
||||
|
||||
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
|
||||
"""
|
||||
if company is None:
|
||||
company = self.env.company
|
||||
ValuationLayer = self.env['stock.valuation.layer'].sudo()
|
||||
svls_to_vacuum_by_product = defaultdict(lambda: ValuationLayer)
|
||||
res = ValuationLayer.read_group([
|
||||
('product_id', 'in', self.ids),
|
||||
('remaining_qty', '<', 0),
|
||||
('stock_move_id', '!=', False),
|
||||
('company_id', '=', company.id),
|
||||
], ['ids:array_agg(id)', 'create_date:min'], ['product_id'], orderby='create_date, id')
|
||||
min_create_date = datetime.max
|
||||
for group in res:
|
||||
svls_to_vacuum_by_product[group['product_id'][0]] = ValuationLayer.browse(group['ids'])
|
||||
min_create_date = min(min_create_date, group['create_date'])
|
||||
all_candidates_by_product = defaultdict(lambda: ValuationLayer)
|
||||
res = ValuationLayer.read_group([
|
||||
('product_id', 'in', self.ids),
|
||||
('remaining_qty', '>', 0),
|
||||
('company_id', '=', company.id),
|
||||
('create_date', '>=', min_create_date),
|
||||
], ['ids:array_agg(id)'], ['product_id'], orderby='id')
|
||||
for group in res:
|
||||
all_candidates_by_product[group['product_id'][0]] = ValuationLayer.browse(group['ids'])
|
||||
|
||||
new_svl_vals_real_time = []
|
||||
new_svl_vals_manual = []
|
||||
real_time_svls_to_vacuum = ValuationLayer
|
||||
|
||||
for product in self:
|
||||
all_candidates = all_candidates_by_product[product.id]
|
||||
current_real_time_svls = ValuationLayer
|
||||
for svl_to_vacuum in svls_to_vacuum_by_product[product.id]:
|
||||
# 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=product.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
|
||||
new_svl_vals = new_svl_vals_real_time if product.valuation == 'real_time' else new_svl_vals_manual
|
||||
new_svl_vals.append({
|
||||
'product_id': product.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,
|
||||
})
|
||||
if product.valuation == 'real_time':
|
||||
current_real_time_svls |= svl_to_vacuum
|
||||
real_time_svls_to_vacuum |= current_real_time_svls
|
||||
ValuationLayer.create(new_svl_vals_manual)
|
||||
vacuum_svls = ValuationLayer.create(new_svl_vals_real_time)
|
||||
|
||||
# If some negative stock were fixed, we need to recompute the standard price.
|
||||
for product in self:
|
||||
product = product.with_company(company.id)
|
||||
# Only recompute if we fixed some negative stock
|
||||
if not svls_to_vacuum_by_product[product.id]:
|
||||
continue
|
||||
if product.cost_method == 'average' and not float_is_zero(product.quantity_svl,
|
||||
precision_rounding=product.uom_id.rounding):
|
||||
product.sudo().with_context(disable_auto_svl=True).write({'standard_price': product.value_svl / product.quantity_svl})
|
||||
|
||||
vacuum_svls._validate_accounting_entries()
|
||||
self._create_fifo_vacuum_anglo_saxon_expense_entries(zip(vacuum_svls, real_time_svls_to_vacuum))
|
||||
|
||||
@api.model
|
||||
def _create_fifo_vacuum_anglo_saxon_expense_entries(self, vacuum_pairs):
|
||||
""" Batch version of _create_fifo_vacuum_anglo_saxon_expense_entry
|
||||
"""
|
||||
AccountMove = self.env['account.move'].sudo()
|
||||
account_move_vals = []
|
||||
vacuum_pairs_to_reconcile = []
|
||||
svls_accounts = {}
|
||||
for vacuum_svl, svl_to_vacuum in vacuum_pairs:
|
||||
if not vacuum_svl.company_id.anglo_saxon_accounting or not svl_to_vacuum.stock_move_id._is_out():
|
||||
continue
|
||||
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:
|
||||
continue
|
||||
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'):
|
||||
continue
|
||||
svls_accounts[svl_to_vacuum.id] = accounts
|
||||
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,
|
||||
vacuum_svl.id, description)
|
||||
account_move_vals.append({
|
||||
'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',
|
||||
})
|
||||
vacuum_pairs_to_reconcile.append((vacuum_svl, svl_to_vacuum))
|
||||
new_account_moves = AccountMove.with_context(clean_context(self._context)).create(account_move_vals)
|
||||
new_account_moves._post()
|
||||
for new_account_move, (vacuum_svl, svl_to_vacuum) in zip(new_account_moves, vacuum_pairs_to_reconcile):
|
||||
account = svls_accounts[svl_to_vacuum.id]['stock_output']
|
||||
to_reconcile_account_move_lines = vacuum_svl.account_move_id.line_ids.filtered(lambda l: not l.reconciled and l.account_id == account 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 == account and l.account_id.reconcile)
|
||||
to_reconcile_account_move_lines.reconcile()
|
||||
|
||||
# TODO remove in master
|
||||
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,
|
||||
vacuum_svl.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
|
||||
if float_compare(product.quantity_svl, 0, precision_rounding=product.uom_id.rounding) > 0:
|
||||
svsl_vals = product._prepare_out_svl_vals(product.quantity_svl, self.env.company)
|
||||
else:
|
||||
svsl_vals = product._prepare_in_svl_vals(abs(product.quantity_svl), product.value_svl / product.quantity_svl)
|
||||
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:
|
||||
if float_compare(quantity_svl, 0, precision_rounding=product.uom_id.rounding) > 0:
|
||||
svl_vals = product._prepare_in_svl_vals(quantity_svl, product.standard_price)
|
||||
else:
|
||||
svl_vals = product._prepare_out_svl_vals(abs(quantity_svl), self.env.company)
|
||||
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
|
||||
stock_input_account = product_accounts[product.id].get('stock_input')
|
||||
if not stock_input_account:
|
||||
raise UserError(_('You don\'t have any stock input 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 = stock_input_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.'))
|
||||
if not product_accounts[product.id].get('stock_output'):
|
||||
raise UserError(
|
||||
_('You don\'t have any output valuation account defined on your product '
|
||||
'category. You must define one before processing this operation.')
|
||||
)
|
||||
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
if float_compare(out_stock_valuation_layer.quantity, 0, precision_digits=precision) == 1:
|
||||
debit_account_id = product_accounts[product.id]['stock_valuation'].id
|
||||
credit_account_id = product_accounts[product.id]['stock_input'].id
|
||||
else:
|
||||
debit_account_id = product_accounts[product.id]['stock_output'].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
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 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, is_returned=False):
|
||||
"""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`
|
||||
:param is_returned: if True, consider the incoming moves
|
||||
:returns: the anglo saxon price unit
|
||||
:rtype: float
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not qty_to_invoice:
|
||||
return 0
|
||||
|
||||
candidates = stock_moves\
|
||||
.sudo()\
|
||||
.filtered(lambda m: is_returned == bool(m.origin_returned_move_id and sum(m.stock_valuation_layer_ids.mapped('quantity')) >= 0))\
|
||||
.mapped('stock_valuation_layer_ids')
|
||||
|
||||
if self.env.context.get('candidates_prefetch_ids'):
|
||||
candidates = candidates.with_prefetch(self.env.context.get('candidates_prefetch_ids'))
|
||||
|
||||
if len(candidates) > 1:
|
||||
candidates = candidates.sorted(lambda svl: (svl.create_date, svl.id))
|
||||
|
||||
value_invoiced = self.env.context.get('value_invoiced', 0)
|
||||
if 'value_invoiced' in self.env.context:
|
||||
qty_valued, valuation = candidates._consume_all(qty_invoiced, value_invoiced, qty_to_invoice)
|
||||
else:
|
||||
qty_valued, valuation = candidates._consume_specific_qty(qty_invoiced, qty_to_invoice)
|
||||
|
||||
# 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.
|
||||
missing = qty_to_invoice - qty_valued
|
||||
for sml in stock_moves.move_line_ids:
|
||||
if not sml._should_exclude_for_valuation():
|
||||
continue
|
||||
missing -= sml.product_uom_id._compute_quantity(sml.qty_done, self.uom_id, rounding_method='HALF-UP')
|
||||
if float_compare(missing, 0, precision_rounding=self.uom_id.rounding) > 0:
|
||||
valuation += self.standard_price * missing
|
||||
|
||||
return valuation / 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_cost(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:
|
||||
property_stock_fields = ['property_stock_account_input_categ_id', 'property_stock_account_output_categ_id', 'property_stock_valuation_account_id']
|
||||
if 'property_valuation' in vals and vals['property_valuation'] == 'manual_periodic' and product_category.property_valuation != 'manual_periodic':
|
||||
for stock_property in property_stock_fields:
|
||||
vals[stock_property] = False
|
||||
elif 'property_valuation' in vals and vals['property_valuation'] == 'real_time' and product_category.property_valuation != 'real_time':
|
||||
company_id = self.env.company
|
||||
for stock_property in property_stock_fields:
|
||||
vals[stock_property] = vals.get(stock_property, False) or company_id[stock_property]
|
||||
elif product_category.property_valuation == 'manual_periodic':
|
||||
for stock_property in property_stock_fields:
|
||||
if stock_property in vals:
|
||||
vals.pop(stock_property)
|
||||
else:
|
||||
for stock_property in property_stock_fields:
|
||||
if stock_property in vals and vals[stock_property] is False:
|
||||
vals.pop(stock_property)
|
||||
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)
|
||||
for svl in out_stock_valuation_layers:
|
||||
svl.product_id.with_context(disable_auto_svl=True).standard_price = svl.unit_cost
|
||||
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().with_context(clean_context(self._context)).create(move_vals_list)
|
||||
account_moves._post()
|
||||
return res
|
||||
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if 'property_valuation' not in vals or vals['property_valuation'] == 'manual_periodic':
|
||||
vals['property_stock_account_input_categ_id'] = False
|
||||
vals['property_stock_account_output_categ_id'] = False
|
||||
vals['property_stock_valuation_account_id'] = False
|
||||
if 'property_valuation' in vals and vals['property_valuation'] == 'real_time':
|
||||
company_id = self.env.company
|
||||
vals['property_stock_account_input_categ_id'] = vals.get('property_stock_account_input_categ_id', False) or company_id.property_stock_account_input_categ_id
|
||||
vals['property_stock_account_output_categ_id'] = vals.get('property_stock_account_output_categ_id', False) or company_id.property_stock_account_output_categ_id
|
||||
vals['property_stock_valuation_account_id'] = vals.get('property_stock_valuation_account_id', False) or company_id.property_stock_valuation_account_id
|
||||
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.onchange('property_valuation')
|
||||
def onchange_property_valuation(self):
|
||||
# Remove or set the account stock properties if necessary
|
||||
if self.property_valuation == 'manual_periodic':
|
||||
self.property_stock_account_input_categ_id = False
|
||||
self.property_stock_account_output_categ_id = False
|
||||
self.property_stock_valuation_account_id = False
|
||||
if self.property_valuation == 'real_time':
|
||||
company_id = self.env.company
|
||||
self.property_stock_account_input_categ_id = company_id.property_stock_account_input_categ_id
|
||||
self.property_stock_account_output_categ_id = company_id.property_stock_account_output_categ_id
|
||||
self.property_stock_valuation_account_id = company_id.property_stock_valuation_account_id
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# -*- 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.")
|
||||
group_lot_on_invoice = fields.Boolean("Display Lots & Serial Numbers on Invoices",
|
||||
implied_group='stock_account.group_lot_on_invoice')
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# -*- 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=[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card')), ('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=[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card')), ('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
|
||||
|
|
@ -0,0 +1,649 @@
|
|||
# -*- 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_compare, 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')
|
||||
analytic_account_line_id = fields.Many2one(
|
||||
'account.analytic.line', copy=False, index='btree_not_null')
|
||||
|
||||
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 _action_cancel(self):
|
||||
self.analytic_account_line_id.unlink()
|
||||
return super()._action_cancel()
|
||||
|
||||
def _should_force_price_unit(self):
|
||||
self.ensure_one()
|
||||
return False
|
||||
|
||||
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:
|
||||
layers = self.origin_returned_move_id.sudo().stock_valuation_layer_ids
|
||||
# dropshipping create additional positive svl to make sure there is no impact on the stock valuation
|
||||
# We need to remove them from the computation of the price unit.
|
||||
if self.origin_returned_move_id._is_dropshipped() or self.origin_returned_move_id._is_dropshipped_returned():
|
||||
layers = layers.filtered(lambda l: float_compare(l.value, 0, precision_rounding=l.product_id.uom_id.rounding) <= 0)
|
||||
layers |= layers.stock_valuation_layer_ids
|
||||
quantity = sum(layers.mapped("quantity"))
|
||||
return sum(layers.mapped("value")) / quantity if not float_is_zero(quantity, precision_rounding=layers.uom_id.rounding) else 0
|
||||
return price_unit if not float_is_zero(price_unit, precision) or self._should_force_price_unit() else 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._should_exclude_for_valuation():
|
||||
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() and not self._is_dropshipped_returned():
|
||||
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._should_exclude_for_valuation():
|
||||
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() and not self._is_dropshipped():
|
||||
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 = self._get_in_svl_vals(forced_quantity)
|
||||
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, rounding_method="HALF-UP"
|
||||
)
|
||||
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, rounding_method="HALF-UP"
|
||||
)
|
||||
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 if it does not come from a valued location (eg subcontract -> customer)
|
||||
if not move.location_id._should_be_valued():
|
||||
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 if it does not go to a valued location (eg customer -> subcontract)
|
||||
if not move.location_dest_id._should_be_valued():
|
||||
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 deleted some exploded stock moves
|
||||
valued_moves = {value_type: moves.exists() for value_type, moves in valued_moves.items()}
|
||||
|
||||
# '_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)()
|
||||
|
||||
stock_valuation_layers._validate_accounting_entries()
|
||||
stock_valuation_layers._validate_analytic_accounting_entries()
|
||||
|
||||
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
|
||||
products_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, rounding_method="HALF-UP"
|
||||
)
|
||||
|
||||
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_in_svl_vals(self, forced_quantity):
|
||||
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, rounding_method="HALF-UP"
|
||||
)
|
||||
unit_cost = move.product_id.standard_price
|
||||
if move.product_id.cost_method != 'standard':
|
||||
unit_cost = abs(move._get_price_unit()) # May be negative (i.e. decrease an out move).
|
||||
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 svl_vals_list
|
||||
|
||||
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):
|
||||
if not self.location_dest_id.usage in ('production', 'inventory'):
|
||||
return accounts_data['stock_output'].id
|
||||
else:
|
||||
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, svl_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, svl_id, description).values()]
|
||||
|
||||
return res
|
||||
|
||||
def _prepare_analytic_lines(self):
|
||||
self.ensure_one()
|
||||
if not self._get_analytic_account():
|
||||
return False
|
||||
|
||||
if self.state in ['cancel', 'draft']:
|
||||
return False
|
||||
|
||||
amount, unit_amount = 0, 0
|
||||
if self.state != 'done':
|
||||
unit_amount = self.product_uom._compute_quantity(
|
||||
self.quantity_done, self.product_id.uom_id)
|
||||
# Falsy in FIFO but since it's an estimation we don't require exact correct cost. Otherwise
|
||||
# we would have to recompute all the analytic estimation at each out.
|
||||
amount = - unit_amount * self.product_id.standard_price
|
||||
elif self.product_id.valuation == 'real_time' and not self._ignore_automatic_valuation():
|
||||
accounts_data = self.product_id.product_tmpl_id.get_product_accounts()
|
||||
account_valuation = accounts_data.get('stock_valuation', False)
|
||||
analytic_line_vals = self.stock_valuation_layer_ids.account_move_id.line_ids.filtered(
|
||||
lambda l: l.account_id == account_valuation)._prepare_analytic_lines()
|
||||
amount = - sum(vals['amount'] for vals in analytic_line_vals)
|
||||
unit_amount = - sum(vals['unit_amount'] for vals in analytic_line_vals)
|
||||
elif sum(self.stock_valuation_layer_ids.mapped('quantity')):
|
||||
amount = sum(self.stock_valuation_layer_ids.mapped('value'))
|
||||
unit_amount = - sum(self.stock_valuation_layer_ids.mapped('quantity'))
|
||||
if self.analytic_account_line_id:
|
||||
if amount == 0 and unit_amount == 0:
|
||||
self.analytic_account_line_id.unlink()
|
||||
return False
|
||||
self.analytic_account_line_id.unit_amount = unit_amount
|
||||
self.analytic_account_line_id.amount = amount
|
||||
return False
|
||||
elif amount:
|
||||
return self._generate_analytic_lines_data(
|
||||
unit_amount, amount)
|
||||
|
||||
def _ignore_automatic_valuation(self):
|
||||
return False
|
||||
|
||||
def _generate_analytic_lines_data(self, unit_amount, amount):
|
||||
self.ensure_one()
|
||||
account_id = self._get_analytic_account()
|
||||
return {
|
||||
'name': self.name,
|
||||
'amount': amount,
|
||||
'account_id': account_id.id,
|
||||
'unit_amount': unit_amount,
|
||||
'product_id': self.product_id.id,
|
||||
'product_uom_id': self.product_id.uom_id.id,
|
||||
'company_id': self.company_id.id,
|
||||
'ref': self._description,
|
||||
'category': 'other',
|
||||
}
|
||||
|
||||
def _generate_valuation_lines_data(self, partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id, svl_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()
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
svl = self.env['stock.valuation.layer'].browse(svl_id)
|
||||
if svl.account_move_line_id.analytic_distribution:
|
||||
line_vals['analytic_distribution'] = svl.account_move_line_id.analytic_distribution
|
||||
|
||||
rslt = {
|
||||
'credit_line_vals': {
|
||||
**line_vals,
|
||||
'balance': -credit_value,
|
||||
'account_id': credit_account_id,
|
||||
},
|
||||
'debit_line_vals': {
|
||||
**line_vals,
|
||||
'balance': debit_value,
|
||||
'account_id': debit_account_id,
|
||||
},
|
||||
}
|
||||
|
||||
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.env.context.get('price_diff_account')
|
||||
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,
|
||||
'balance': -diff_amount,
|
||||
'ref': description,
|
||||
'partner_id': partner_id,
|
||||
'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 _prepare_account_move_vals(self, credit_account_id, debit_account_id, journal_id, qty, description, svl_id, cost):
|
||||
self.ensure_one()
|
||||
valuation_partner_id = self._get_partner_id_for_valuation_lines()
|
||||
move_ids = self._prepare_account_move_line(qty, cost, credit_account_id, debit_account_id, svl_id, description)
|
||||
svl = self.env['stock.valuation.layer'].browse(svl_id)
|
||||
if self.env.context.get('force_period_date'):
|
||||
date = self.env.context.get('force_period_date')
|
||||
elif svl.account_move_line_id:
|
||||
date = svl.account_move_line_id.date
|
||||
else:
|
||||
date = fields.Date.context_today(self)
|
||||
return {
|
||||
'journal_id': journal_id,
|
||||
'line_ids': move_ids,
|
||||
'partner_id': valuation_partner_id,
|
||||
'date': date,
|
||||
'ref': description,
|
||||
'stock_move_id': self.id,
|
||||
'stock_valuation_layer_ids': [(6, None, [svl_id])],
|
||||
'move_type': 'entry',
|
||||
'is_storno': self.env.context.get('is_returned') and self.env.company.account_storno,
|
||||
}
|
||||
|
||||
def _account_analytic_entry_move(self):
|
||||
analytic_lines_vals = []
|
||||
moves_to_link = []
|
||||
for move in self:
|
||||
analytic_line_vals = move._prepare_analytic_lines()
|
||||
if not analytic_line_vals:
|
||||
continue
|
||||
moves_to_link.append(move.id)
|
||||
analytic_lines_vals.append(analytic_line_vals)
|
||||
analytic_lines = self.env['account.analytic.line'].sudo().create(analytic_lines_vals)
|
||||
for move_id, analytic_line in zip(moves_to_link, analytic_lines):
|
||||
self.env['stock.move'].browse(
|
||||
move_id).analytic_account_line_id = analytic_line
|
||||
|
||||
def _should_exclude_for_valuation(self):
|
||||
"""Determines if this move should be excluded from valuation based on its partner.
|
||||
:return: True if the move's restrict_partner_id is different from the company's partner (indicating
|
||||
it should be excluded from valuation), False otherwise.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.restrict_partner_id and self.restrict_partner_id != self.company_id.partner_id
|
||||
|
||||
def _account_entry_move(self, qty, description, svl_id, cost):
|
||||
""" Accounting Valuation Entries """
|
||||
self.ensure_one()
|
||||
am_vals = []
|
||||
if self.product_id.type != 'product':
|
||||
# no stock valuation for consumable products
|
||||
return am_vals
|
||||
if self._should_exclude_for_valuation():
|
||||
return am_vals
|
||||
|
||||
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'):
|
||||
am_vals.append(self.with_company(company_to).with_context(is_returned=True)._prepare_account_move_vals(acc_dest, acc_valuation, journal_id, qty, description, svl_id, cost))
|
||||
else:
|
||||
am_vals.append(self.with_company(company_to)._prepare_account_move_vals(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'):
|
||||
am_vals.append(self.with_company(company_from).with_context(is_returned=True)._prepare_account_move_vals(acc_valuation, acc_src, journal_id, qty, description, svl_id, cost))
|
||||
else:
|
||||
am_vals.append(self.with_company(company_from)._prepare_account_move_vals(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
|
||||
anglosaxon_am_vals = self._prepare_anglosaxon_account_move_vals(acc_src, acc_dest, acc_valuation, journal_id, qty, description, svl_id, cost)
|
||||
if anglosaxon_am_vals:
|
||||
am_vals.append(anglosaxon_am_vals)
|
||||
|
||||
return am_vals
|
||||
|
||||
def _prepare_anglosaxon_account_move_vals(self, acc_src, acc_dest, acc_valuation, journal_id, qty, description, svl_id, cost):
|
||||
anglosaxon_am_vals = {}
|
||||
if self._is_dropshipped():
|
||||
if cost > 0:
|
||||
anglosaxon_am_vals = self.with_company(self.company_id)._prepare_account_move_vals(acc_src, acc_valuation, journal_id, qty, description, svl_id, cost)
|
||||
else:
|
||||
cost = -1 * cost
|
||||
anglosaxon_am_vals = self.with_company(self.company_id)._prepare_account_move_vals(acc_valuation, acc_dest, journal_id, qty, description, svl_id, cost)
|
||||
elif self._is_dropshipped_returned():
|
||||
if cost > 0 and self.location_dest_id._should_be_valued():
|
||||
anglosaxon_am_vals = self.with_company(self.company_id).with_context(is_returned=True)._prepare_account_move_vals(acc_valuation, acc_src, journal_id, qty, description, svl_id, cost)
|
||||
elif cost > 0:
|
||||
anglosaxon_am_vals = self.with_company(self.company_id).with_context(is_returned=True)._prepare_account_move_vals(acc_dest, acc_valuation, journal_id, qty, description, svl_id, cost)
|
||||
else:
|
||||
cost = -1 * cost
|
||||
anglosaxon_am_vals = self.with_company(self.company_id).with_context(is_returned=True)._prepare_account_move_vals(acc_valuation, acc_src, journal_id, qty, description, svl_id, cost)
|
||||
return anglosaxon_am_vals
|
||||
|
||||
def _get_analytic_account(self):
|
||||
return False
|
||||
|
||||
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
|
||||
|
||||
def _get_all_related_aml(self):
|
||||
return self.account_move_ids.line_ids
|
||||
|
||||
def _get_all_related_sm(self, product):
|
||||
return self.filtered(lambda m: m.product_id == product)
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
# -*- 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_compare, float_is_zero
|
||||
|
||||
|
||||
class StockMoveLine(models.Model):
|
||||
_inherit = 'stock.move.line'
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# CRUD
|
||||
# -------------------------------------------------------------------------
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
analytic_move_to_recompute = set()
|
||||
move_lines = super(StockMoveLine, self).create(vals_list)
|
||||
for move_line in move_lines:
|
||||
move = move_line.move_id
|
||||
analytic_move_to_recompute.add(move.id)
|
||||
if move_line.state != 'done':
|
||||
continue
|
||||
product_uom = move_line.product_id.uom_id
|
||||
diff = move_line.product_uom_id._compute_quantity(move_line.qty_done, product_uom)
|
||||
if float_is_zero(diff, precision_rounding=product_uom.rounding):
|
||||
continue
|
||||
self._create_correction_svl(move, diff)
|
||||
if analytic_move_to_recompute:
|
||||
self.env['stock.move'].browse(
|
||||
analytic_move_to_recompute)._account_analytic_entry_move()
|
||||
return move_lines
|
||||
|
||||
def write(self, vals):
|
||||
analytic_move_to_recompute = set()
|
||||
if 'qty_done' in vals or 'move_id' in vals:
|
||||
for move_line in self:
|
||||
move_id = vals.get('move_id') if vals.get('move_id') else move_line.move_id.id
|
||||
analytic_move_to_recompute.add(move_id)
|
||||
if 'qty_done' in vals:
|
||||
for move_line in self:
|
||||
if move_line.state != 'done':
|
||||
continue
|
||||
product_uom = move_line.product_id.uom_id
|
||||
diff = move_line.product_uom_id._compute_quantity(vals['qty_done'] - move_line.qty_done, product_uom, rounding_method='HALF-UP')
|
||||
if float_is_zero(diff, precision_rounding=product_uom.rounding):
|
||||
continue
|
||||
self._create_correction_svl(move_line.move_id, diff)
|
||||
res = super(StockMoveLine, self).write(vals)
|
||||
if analytic_move_to_recompute:
|
||||
self.env['stock.move'].browse(analytic_move_to_recompute)._account_analytic_entry_move()
|
||||
return res
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 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))
|
||||
|
||||
stock_valuation_layers._validate_accounting_entries()
|
||||
|
||||
@api.model
|
||||
def _should_exclude_for_valuation(self):
|
||||
"""
|
||||
Determines if this move line should be excluded from valuation based on its ownership.
|
||||
:return: True if the move line's owner is different from the company's partner (indicating
|
||||
it should be excluded from valuation), False otherwise.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.owner_id and self.owner_id != self.company_id.partner_id
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from ast import literal_eval
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class StockPicking(models.Model):
|
||||
_inherit = 'stock.picking'
|
||||
|
||||
country_code = fields.Char(related="company_id.account_fiscal_country_id.code")
|
||||
|
||||
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_ids + 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)
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
# -*- 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
|
||||
from odoo.tools.misc import groupby
|
||||
|
||||
|
||||
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')
|
||||
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.")
|
||||
cost_method = fields.Selection(related="product_categ_id.property_cost_method")
|
||||
|
||||
@api.model
|
||||
def _should_exclude_for_valuation(self):
|
||||
"""
|
||||
Determines if a quant should be excluded from valuation based on its ownership.
|
||||
:return: True if the quant should be excluded from valuation, False otherwise.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.owner_id and self.owner_id != self.company_id.partner_id
|
||||
|
||||
@api.depends('company_id', 'location_id', 'owner_id', 'product_id', 'quantity')
|
||||
def _compute_value(self):
|
||||
""" (Product.value_svl / Product.quantity_svl) * quant.quantity, i.e. average unit cost * on hand qty
|
||||
"""
|
||||
for quant in self:
|
||||
quant.currency_id = quant.company_id.currency_id
|
||||
if not quant.location_id or not quant.product_id or\
|
||||
not quant.location_id._should_be_valued() or\
|
||||
quant._should_exclude_for_valuation() or\
|
||||
float_is_zero(quant.quantity, precision_rounding=quant.product_id.uom_id.rounding):
|
||||
quant.value = 0
|
||||
continue
|
||||
quantity = quant.product_id.with_company(quant.company_id).quantity_svl
|
||||
if float_is_zero(quantity, precision_rounding=quant.product_id.uom_id.rounding):
|
||||
quant.value = 0.0
|
||||
continue
|
||||
quant.value = quant.quantity * quant.product_id.with_company(quant.company_id).value_svl / quantity
|
||||
|
||||
@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
|
||||
|
||||
def _apply_inventory(self):
|
||||
for accounting_date, inventory_ids in groupby(self, key=lambda q: q.accounting_date):
|
||||
inventories = self.env['stock.quant'].concat(*inventory_ids)
|
||||
if accounting_date:
|
||||
super(StockQuant, inventories.with_context(force_period_date=accounting_date))._apply_inventory()
|
||||
inventories.accounting_date = False
|
||||
else:
|
||||
super(StockQuant, inventories)._apply_inventory()
|
||||
|
||||
def _get_inventory_move_values(self, qty, location_id, location_dest_id, out=False):
|
||||
res_move = super()._get_inventory_move_values(qty, location_id, location_dest_id, out)
|
||||
if not self.env.context.get('inventory_name'):
|
||||
force_period_date = self.env.context.get('force_period_date', False)
|
||||
if force_period_date:
|
||||
res_move['name'] += _(' [Accounted on %s]', force_period_date)
|
||||
return res_move
|
||||
|
||||
@api.model
|
||||
def _get_inventory_fields_write(self):
|
||||
""" Returns a list of fields user can edit when editing a quant in `inventory_mode`."""
|
||||
res = super()._get_inventory_fields_write()
|
||||
res += ['accounting_date']
|
||||
return res
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, tools
|
||||
from odoo.tools import float_compare, float_is_zero
|
||||
|
||||
from itertools import chain
|
||||
from odoo.tools import groupby
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
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, auto_join=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', readonly=True, digits='Product Unit of Measure')
|
||||
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(readonly=True, digits='Product Unit of Measure')
|
||||
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, index=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, index="btree_not_null")
|
||||
account_move_line_id = fields.Many2one('account.move.line', 'Invoice Line', readonly=True, check_company=True, index="btree_not_null")
|
||||
reference = fields.Char(related='stock_move_id.reference')
|
||||
price_diff_value = fields.Float('Invoice value correction with invoice currency')
|
||||
|
||||
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']
|
||||
)
|
||||
tools.create_index(
|
||||
self._cr, 'stock_valuation_company_product_index',
|
||||
self._table, ['product_id', 'company_id', 'id', 'value', 'quantity']
|
||||
)
|
||||
|
||||
def _validate_accounting_entries(self):
|
||||
am_vals = []
|
||||
aml_to_reconcile = defaultdict(set)
|
||||
for svl in self:
|
||||
if not svl.with_company(svl.company_id).product_id.valuation == 'real_time':
|
||||
continue
|
||||
if svl.currency_id.is_zero(svl.value):
|
||||
continue
|
||||
move = svl.stock_move_id
|
||||
if not move:
|
||||
move = svl.stock_valuation_layer_id.stock_move_id
|
||||
am_vals += move.with_company(svl.company_id)._account_entry_move(svl.quantity, svl.description, svl.id, svl.value)
|
||||
if am_vals:
|
||||
account_moves = self.env['account.move'].sudo().create(am_vals)
|
||||
account_moves._post()
|
||||
products_svl = groupby(self, lambda svl: (svl.product_id, svl.company_id.anglo_saxon_accounting))
|
||||
for (product, anglo_saxon_accounting), svls in products_svl:
|
||||
svls = self.browse(svl.id for svl in svls)
|
||||
moves = svls.stock_move_id
|
||||
if anglo_saxon_accounting:
|
||||
moves._get_related_invoices()._stock_account_anglo_saxon_reconcile_valuation(product=product)
|
||||
moves = (moves | moves.origin_returned_move_id).with_prefetch(chain(moves._prefetch_ids, moves.origin_returned_move_id._prefetch_ids))
|
||||
for aml in moves._get_all_related_aml():
|
||||
if aml.reconciled or aml.move_id.state != "posted" or not aml.account_id.reconcile:
|
||||
continue
|
||||
aml_to_reconcile[(product, aml.account_id)].add(aml.id)
|
||||
for aml_ids in aml_to_reconcile.values():
|
||||
self.env['account.move.line'].browse(aml_ids).reconcile()
|
||||
|
||||
def _validate_analytic_accounting_entries(self):
|
||||
for svl in self:
|
||||
svl.stock_move_id._account_analytic_entry_move()
|
||||
|
||||
@api.model
|
||||
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
|
||||
if 'unit_cost' in fields:
|
||||
fields.remove('unit_cost')
|
||||
return super().read_group(domain, fields, groupby, offset, limit, orderby, lazy)
|
||||
|
||||
def action_open_layer(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'res_model': self._name,
|
||||
'type': 'ir.actions.act_window',
|
||||
'views': [[False, "form"]],
|
||||
'res_id': self.id,
|
||||
}
|
||||
|
||||
def action_open_reference(self):
|
||||
self.ensure_one()
|
||||
if self.stock_move_id:
|
||||
action = self.stock_move_id.action_open_reference()
|
||||
if action['res_model'] != 'stock.move':
|
||||
return action
|
||||
return {
|
||||
'res_model': self._name,
|
||||
'type': 'ir.actions.act_window',
|
||||
'views': [[False, "form"]],
|
||||
'res_id': self.id,
|
||||
}
|
||||
|
||||
def _consume_specific_qty(self, qty_valued, qty_to_value):
|
||||
"""
|
||||
Iterate on the SVL to first skip the qty already valued. Then, keep
|
||||
iterating to consume `qty_to_value` and stop
|
||||
The method returns the valued quantity and its valuation
|
||||
"""
|
||||
if not self:
|
||||
return 0, 0
|
||||
|
||||
qty_to_take_on_candidates = qty_to_value
|
||||
tmp_value = 0 # to accumulate the value taken on the candidates
|
||||
for candidate in self:
|
||||
rounding = candidate.product_id.uom_id.rounding
|
||||
if float_is_zero(candidate.quantity, precision_rounding=rounding):
|
||||
continue
|
||||
candidate_quantity = abs(candidate.quantity)
|
||||
returned_qty = sum([sm.product_uom._compute_quantity(sm.quantity_done, self.uom_id)
|
||||
for sm in candidate.stock_move_id.returned_move_ids if sm.state == 'done'])
|
||||
candidate_quantity -= returned_qty
|
||||
if float_is_zero(candidate_quantity, precision_rounding=rounding):
|
||||
continue
|
||||
if not float_is_zero(qty_valued, precision_rounding=rounding):
|
||||
qty_ignored = min(qty_valued, candidate_quantity)
|
||||
qty_valued -= qty_ignored
|
||||
candidate_quantity -= qty_ignored
|
||||
if float_is_zero(candidate_quantity, precision_rounding=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=rounding):
|
||||
break
|
||||
|
||||
return qty_to_value - qty_to_take_on_candidates, tmp_value
|
||||
|
||||
def _consume_all(self, qty_valued, valued, qty_to_value):
|
||||
"""
|
||||
The method consumes all svl to get the total qty/value. Then it deducts
|
||||
the already consumed qty/value. Finally, it tries to consume the `qty_to_value`
|
||||
The method returns the valued quantity and its valuation
|
||||
"""
|
||||
if not self:
|
||||
return 0, 0
|
||||
|
||||
min_rounding = 1.0
|
||||
qty_total = -qty_valued
|
||||
value_total = -valued
|
||||
new_valued_qty = 0
|
||||
new_valuation = 0
|
||||
|
||||
for svl in self:
|
||||
rounding = svl.product_id.uom_id.rounding
|
||||
min_rounding = min(min_rounding, rounding)
|
||||
if float_is_zero(svl.quantity, precision_rounding=rounding):
|
||||
continue
|
||||
relevant_qty = abs(svl.quantity)
|
||||
returned_qty = sum([sm.product_uom._compute_quantity(sm.quantity_done, self.uom_id)
|
||||
for sm in svl.stock_move_id.returned_move_ids if sm.state == 'done'])
|
||||
relevant_qty -= returned_qty
|
||||
if float_is_zero(relevant_qty, precision_rounding=rounding):
|
||||
continue
|
||||
qty_total += relevant_qty
|
||||
value_total += relevant_qty * ((svl.value + sum(svl.stock_valuation_layer_ids.mapped('value'))) / svl.quantity)
|
||||
|
||||
if float_compare(qty_total, 0, precision_rounding=min_rounding) > 0:
|
||||
unit_cost = value_total / qty_total
|
||||
new_valued_qty = min(qty_total, qty_to_value)
|
||||
new_valuation = unit_cost * new_valued_qty
|
||||
|
||||
return new_valued_qty, new_valuation
|
||||
|
||||
def _should_impact_price_unit_receipt_value(self):
|
||||
self.ensure_one()
|
||||
return True
|
||||
Loading…
Add table
Add a link
Reference in a new issue