19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:07 +01:00
parent ba20ce7443
commit 768b70e05e
2357 changed files with 1057103 additions and 712486 deletions

View file

@ -1,13 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_account
from . import account_chart_template
from . import account_move
from . import account_move_line
from . import analytic_account
from . import res_company
from . import product
from . import product_value
from . import stock_move
from . import stock_location
from . import stock_lot
from . import stock_move_line
from . import stock_picking
from . import stock_picking_type
from . import stock_quant
from . import stock_valuation_layer
from . import res_config_settings

View file

@ -0,0 +1,12 @@
from odoo import fields, models
class AccountAccount(models.Model):
_inherit = 'account.account'
account_stock_variation_id = fields.Many2one(
'account.account', string='Variation Account',
help="At closing, register the inventory variation of the period into a specific account")
account_stock_expense_id = fields.Many2one(
'account.account', string='Expense Account',
help="Counterpart used at closing for accounting adjustments to inventory valuation.")

View file

@ -1,37 +1,54 @@
# -*- 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__)
from odoo import models, _
from odoo.addons.account.models.chart_template import template
class AccountChartTemplate(models.Model):
class AccountChartTemplate(models.AbstractModel):
_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 _get_stock_account_res_company(self, template_code):
return {
company_id: filtered_vals
for company_id, vals in self._get_chart_template_model_data(template_code, 'res.company').items()
if (filtered_vals := {
fname: value
for fname, value in vals.items()
if fname in [
'account_stock_journal_id',
'account_stock_valuation_id',
'account_production_wip_account_id',
'account_production_wip_overhead_account_id',
]
})
}
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)
def _get_stock_account_account(self, template_code):
return {
xmlid: filtered_vals
for xmlid, vals in self._get_chart_template_model_data(template_code, 'account.account').items()
if (filtered_vals := {
fname: value
for fname, value in vals.items()
if fname in ['account_stock_expense_id', 'account_stock_variation_id']
})
}
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)
@template(model='account.journal')
def _get_stock_account_journal(self, template_code):
return {
'inventory_valuation': {
'name': _('Inventory Valuation'),
'code': 'STJ',
'type': 'general',
'sequence': 10,
'show_on_dashboard': False,
},
}
return res
@template()
def _get_stock_template_data(self, template_code):
return {
'stock_journal': 'inventory_valuation',
}

View file

@ -1,20 +1,11 @@
# -*- coding: utf-8 -*-
from odoo import fields, models, api
from odoo.tools import float_compare, float_is_zero
from odoo import fields, models
from odoo.tools import 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
stock_move_ids = fields.One2many('stock.move', 'account_move_id', string='Stock Move')
# -------------------------------------------------------------------------
# OVERRIDE METHODS
@ -25,46 +16,44 @@ class AccountMove(models.Model):
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)
vals_list = 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 not self.env.context.get('move_reverse_cancel'):
for vals in vals_list:
if 'line_ids' in vals:
vals['line_ids'] = [line_vals for line_vals in vals['line_ids']
if line_vals[0] != 0 or line_vals[2].get('display_type') != 'cogs']
return res
return vals_list
def _post(self, soft=True):
# OVERRIDE
# Don't change anything on moves used to cancel another ones.
if self._context.get('move_reverse_cancel'):
if self.env.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())
self.env['account.move.line'].create(self._stock_account_prepare_realtime_out_lines_vals())
# Post entries.
posted = super()._post(soft)
res = 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
self.line_ids._get_stock_moves().filtered(lambda m: m.is_in or m.is_dropship)._set_value()
return res
def button_draft(self):
res = super(AccountMove, self).button_draft()
res = super().button_draft()
# Unlink the COGS lines generated during the 'post' method.
self.mapped('line_ids').filtered(lambda line: line.display_type == 'cogs').unlink()
with self.env.protecting(self.env['account.move']._get_protected_vals({}, self)):
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()
res = super().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'.
@ -76,7 +65,7 @@ class AccountMove(models.Model):
# COGS METHODS
# -------------------------------------------------------------------------
def _stock_account_prepare_anglo_saxon_out_lines_vals(self):
def _stock_account_prepare_realtime_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.
@ -95,9 +84,9 @@ class AccountMove(models.Model):
This method computes values used to make two additional journal items:
---------------------------------------------------------------
220000 Expenses | 9.0 |
500000 COGS (stock variation) | 9.0 |
---------------------------------------------------------------
101130 Stock Interim Account (Delivered) | | 9.0
110100 Stock Account | | 9.0
---------------------------------------------------------------
Note: COGS are only generated for customer invoices except refund made to cancel an invoice.
@ -105,40 +94,40 @@ class AccountMove(models.Model):
: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:
if not move.is_sale_document(include_receipts=True):
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():
if not line._eligible_for_stock_account() or line.product_id.valuation != 'real_time':
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']
stock_account = accounts['stock_valuation']
credit_expense_account = accounts['expense'] or move.journal_id.default_account_id
if not debit_interim_account or not credit_expense_account:
if not stock_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
price_unit = line.with_context(anglo_saxon_price_ctx)._get_cogs_value()
amount_currency = sign * line.product_uom_id._compute_quantity(line.quantity, line.product_id.uom_id) * 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],
'name': line.name[:64] if line.name else '',
'move_id': move.id,
'partner_id': move.commercial_partner_id.id,
'product_id': line.product_id.id,
@ -146,14 +135,15 @@ class AccountMove(models.Model):
'quantity': line.quantity,
'price_unit': price_unit,
'amount_currency': -amount_currency,
'account_id': debit_interim_account.id,
'account_id': stock_account.id,
'display_type': 'cogs',
'tax_ids': [],
'cogs_origin_id': line.id,
})
# Add expense account line.
lines_vals_list.append({
'name': line.name[:64],
'name': line.name[:64] if line.name else '',
'move_id': move.id,
'partner_id': move.commercial_partner_id.id,
'product_id': line.product_id.id,
@ -165,189 +155,18 @@ class AccountMove(models.Model):
'analytic_distribution': line.analytic_distribution,
'display_type': 'cogs',
'tax_ids': [],
'cogs_origin_id': line.id,
})
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 be overriden in modules overriding _get_cogs_value
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.
"""
def _get_related_stock_moves(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

View file

@ -0,0 +1,85 @@
from odoo import fields, models, api
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
cogs_origin_id = fields.Many2one( # technical field used to keep track in the originating line of the anglo-saxon lines
comodel_name="account.move.line",
copy=False,
index="btree_not_null",
)
def _compute_account_id(self):
super()._compute_account_id()
for line in self:
if not line.move_id.is_purchase_document():
continue
if not line._eligible_for_stock_account():
continue
fiscal_position = line.move_id.fiscal_position_id
accounts = line.with_company(line.company_id).product_id.product_tmpl_id.get_product_accounts(fiscal_pos=fiscal_position)
if line.product_id.valuation == 'real_time' and accounts['stock_valuation']:
line.account_id = accounts['stock_valuation']
@api.onchange('product_id')
def _inverse_product_id(self):
super(AccountMoveLine, self.filtered(lambda l: l.display_type != 'cogs'))._inverse_product_id()
def _eligible_for_stock_account(self):
self.ensure_one()
if not self.product_id.is_storable:
return False
moves = self._get_stock_moves()
return all(not m._is_dropshipped() for m in moves)
def _get_gross_unit_price(self):
if self.product_uom_id.is_zero(self.quantity):
return self.price_unit
if self.discount != 100:
if not any(t.price_include for t in self.tax_ids) and self.discount:
price_unit = self.price_unit * (1 - self.discount / 100)
else:
price_unit = self.price_subtotal / self.quantity
else:
price_unit = self.price_unit
return -price_unit if self.move_id.move_type == 'in_refund' else price_unit
def _get_cogs_value(self):
""" Get the COGS price unit in the product's default unit of measure.
"""
self.ensure_one()
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]
if original_line:
return original_line.price_unit
if not self.product_id or self.product_uom_id.is_zero(self.quantity):
return self.price_unit
cogs_qty = self._get_cogs_qty()
if moves := self._get_stock_moves().filtered(lambda m: m.state == 'done'):
price_unit = moves._get_cogs_price_unit(cogs_qty)
else:
if self.product_id.cost_method in ['standard', 'average']:
price_unit = self.product_id.standard_price
else:
price_unit = self.product_id._run_fifo(cogs_qty) / cogs_qty if cogs_qty else 0
return (price_unit * cogs_qty - self._get_posted_cogs_value()) / self.quantity
def _get_stock_moves(self):
return self.env['stock.move']
def _get_cogs_qty(self):
self.ensure_one()
return self.quantity
def _get_posted_cogs_value(self):
self.ensure_one()
return 0

View file

@ -0,0 +1,110 @@
from odoo import models
from odoo.tools import float_compare, float_is_zero, float_round
class AccountAnalyticPlan(models.Model):
_inherit = 'account.analytic.plan'
def _calculate_distribution_amount(self, amount, percentage, total_percentage, distribution_on_each_plan):
"""
Ensures that the total amount distributed across all lines always adds up to exactly `amount` per
plan. We try to correct for compounding rounding errors by assigning the exact outstanding amount when
we detect that a line will close out a plan's total percentage. However, since multiple plans can be
assigned to a line, with different prior distributions, there is the possible edge case that one line
closes out two (or more) tallies with different compounding errors. This means there is no one correct
amount that we can assign to a line that will correctly close out both all plans. This is described in
more detail in the commit message, under "concurrent closing line edge case".
"""
decimal_precision = self.env['decimal.precision'].precision_get('Percentage Analytic')
distributed_percentage, distributed_amount = distribution_on_each_plan.get(self, (0, 0))
allocated_percentage = distributed_percentage + percentage
if float_compare(allocated_percentage, total_percentage, precision_digits=decimal_precision) == 0:
calculated_amount = (amount * total_percentage / 100) - distributed_amount
else:
calculated_amount = amount * percentage / 100
distributed_amount += float_round(calculated_amount, precision_digits=decimal_precision)
distribution_on_each_plan[self] = (allocated_percentage, distributed_amount)
return calculated_amount
class AccountAnalyticAccount(models.Model):
_inherit = 'account.analytic.account'
def _perform_analytic_distribution(self, distribution, amount, unit_amount, lines, obj, additive=False):
"""
Redistributes the analytic lines to match the given distribution:
- For account_ids where lines already exist, the amount and unit_amount of these lines get updated,
lines where the updated amount becomes zero get unlinked.
- For account_ids where lines don't exist yet, the line values to create them are returned,
lines where the amount becomes zero are not included.
:param distribution: the desired distribution to match the analytic lines to
:param amount: the total amount to distribute over the analytic lines
:param unit_amount: the total unit amount (will not be distributed)
:param lines: the (current) analytic account lines that need to be matched to the new distribution
:param obj: the object on which _prepare_analytic_line_values(account_id, amount, unit_amount) will be
called to get the template for the values of new analytic line objects
:param additive: if True, the unit_amount and (distributed) amount get added to the existing lines
:returns: a list of dicts containing the values for new analytic lines that need to be created
:rtype: dict
"""
if not distribution:
lines.unlink()
return []
# Does this: {'15': 40, '14,16': 60} -> { account(15): 40, account(14,16): 60 }
distribution = {
self.env['account.analytic.account'].browse(map(int, ids.split(','))).exists(): percentage
for ids, percentage in distribution.items()
}
plans = self.env['account.analytic.plan']
plans = sum(plans._get_all_plans(), plans)
line_columns = [p._column_name() for p in plans]
lines_to_link = []
distribution_on_each_plan = {}
total_percentages = {}
for accounts, percentage in distribution.items():
for plan in accounts.root_plan_id:
total_percentages[plan] = total_percentages.get(plan, 0) + percentage
for existing_aal in lines:
# TODO: recommend something better for this line in review, please
accounts = sum(map(existing_aal.mapped, line_columns), self.env['account.analytic.account'])
if accounts in distribution:
# Update the existing AAL for this account
percentage = distribution[accounts]
new_amount = 0
new_unit_amount = unit_amount
for account in accounts:
plan = account.root_plan_id
new_amount = plan._calculate_distribution_amount(amount, percentage, total_percentages[plan], distribution_on_each_plan)
if additive:
new_amount += existing_aal.amount
new_unit_amount += existing_aal.unit_amount
currency = accounts[0].currency_id or obj.company_id.currency_id
if float_is_zero(new_amount, precision_rounding=currency.rounding):
existing_aal.unlink()
else:
existing_aal.amount = new_amount
existing_aal.unit_amount = new_unit_amount
# Prevent this distribution from being applied again
del distribution[accounts]
else:
# Delete the existing AAL if it is no longer present in the new distribution
existing_aal.unlink()
# Create new lines from remaining distributions
for accounts, percentage in distribution.items():
if not accounts:
continue
account_field_values = {}
for account in accounts:
new_amount = account.root_plan_id._calculate_distribution_amount(amount, percentage, total_percentages[plan], distribution_on_each_plan)
account_field_values[account.plan_id._column_name()] = account.id
currency = account.currency_id or obj.company_id.currency_id
if not float_is_zero(new_amount, precision_rounding=currency.rounding):
lines_to_link.append(obj._prepare_analytic_line_values(account_field_values, new_amount, unit_amount))
return lines_to_link

View file

@ -0,0 +1,96 @@
from odoo import _, api, fields, models
class ProductValue(models.Model):
""" This model represents the history of manual update of a value.
The potential update could be:
- Modification of the product standard price
- Modification of the lot standard price
- Modification of the move value
In case of modification of:
- standard price, value contains the new standard price (by unit).
- a move value: value contains the global value of the move.
"""
_name = 'product.value'
_description = 'Product Value'
product_id = fields.Many2one('product.product', string='Product')
lot_id = fields.Many2one('stock.lot', string='Lot')
move_id = fields.Many2one('stock.move', string='Move')
value = fields.Monetary(string='Value', currency_field='currency_id', required=True)
company_id = fields.Many2one(
'res.company', string='Company', compute='_compute_company_id',
store=True, required=True, precompute=True, readonly=False)
currency_id = fields.Many2one('res.currency', related='company_id.currency_id', string='Currency')
date = fields.Datetime(string='Date', default=fields.Datetime.now, required=True)
user_id = fields.Many2one('res.users', string='User', default=lambda self: self.env.user, required=True)
description = fields.Char(string='Description')
# User Display Fields
current_value = fields.Monetary(
string='Current Value', currency_field='currency_id',
related='move_id.value')
current_value_details = fields.Char(string='Current Value Details', compute="_compute_current_value_details")
current_value_description = fields.Text(string='Current Value Description', compute="_compute_value_description")
computed_value_description = fields.Text(string='Computed Value Description', compute="_compute_value_description")
@api.depends('move_id', 'lot_id', 'product_id')
def _compute_company_id(self):
for product_value in self:
if product_value.move_id:
product_value.company_id = product_value.move_id.company_id
elif product_value.lot_id:
product_value.company_id = product_value.lot_id.company_id
elif product_value.product_id:
product_value.company_id = product_value.product_id.company_id
else:
product_value.company_id = self.env.company
def _compute_current_value_details(self):
for product_value in self:
if not (product_value.move_id and product_value.move_id.quantity):
product_value.current_value_details = False
continue
move = product_value.move_id
quantity = move.quantity
uom = move.product_uom.name
price_unit = move.value / move.quantity
product_value.current_value_details = _("For %(quantity)s %(uom)s (%(price_unit)s per %(uom)s)",
quantity=quantity, uom=uom, price_unit=price_unit)
def _compute_value_description(self):
for product_value in self:
if not product_value.move_id:
product_value.current_value_description = False
product_value.computed_value_description = False
continue
product_value.current_value_description = product_value.move_id.value_justification
product_value.computed_value_description = product_value.move_id.value_computed_justification
@api.model_create_multi
def create(self, vals_list):
lot_ids = set()
product_ids = set()
move_ids = set()
for vals in vals_list:
if vals.get('move_id'):
move_ids.add(vals['move_id'])
elif vals.get('lot_id'):
lot_ids.add(vals['lot_id'])
else:
product_ids.add(vals['product_id'])
if lot_ids:
move_ids.update(self.env['stock.move.line'].search([('lot_id', 'in', lot_ids)]).move_id.ids)
products = self.env['product.product'].browse(product_ids)
if products:
moves_by_product = products._get_remaining_moves()
for qty_by_move in moves_by_product.values():
move_ids.update(self.env['stock.move'].concat(*qty_by_move.keys()).ids)
res = super().create(vals_list)
if move_ids:
self.env['stock.move'].browse(move_ids)._set_value()
return res

View file

@ -0,0 +1,360 @@
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from odoo import Command, _, api, fields, models
from odoo.fields import Domain
from odoo.exceptions import UserError
class ResCompany(models.Model):
_inherit = "res.company"
account_stock_journal_id = fields.Many2one('account.journal', string='Stock Journal', check_company=True)
account_stock_valuation_id = fields.Many2one('account.account', string='Stock Valuation Account', check_company=True)
account_production_wip_account_id = fields.Many2one('account.account', string='Production WIP Account', check_company=True)
account_production_wip_overhead_account_id = fields.Many2one('account.account', string='Production WIP Overhead Account', check_company=True)
inventory_period = fields.Selection(
string='Inventory Period',
selection=[
('manual', 'Manual'),
('daily', 'Daily'),
('monthly', 'Monthly'),
],
default='manual',
required=True)
inventory_valuation = fields.Selection(
string='Valuation',
selection=[
('periodic', 'Periodic (at closing)'),
('real_time', 'Perpetual (at invoicing)'),
],
default='periodic',
)
cost_method = fields.Selection(
string="Cost Method",
selection=[
('standard', "Standard Price"),
('fifo', "First In First Out (FIFO)"),
('average', "Average Cost (AVCO)"),
],
default='standard',
required=True,
)
def action_close_stock_valuation(self, at_date=None, auto_post=False):
self.ensure_one()
if at_date and isinstance(at_date, str):
at_date = fields.Date.from_string(at_date)
last_closing_date = self._get_last_closing_date()
if at_date and last_closing_date and at_date < fields.Date.to_date(last_closing_date):
raise UserError(self.env._('It exists closing entries after the selected date. Cancel them before generate an entry prior to them'))
aml_vals_list = self._action_close_stock_valuation(at_date=at_date)
if not aml_vals_list:
# No account moves to create, so nothing to display.
raise UserError(_("Everything is correctly closed"))
if not self.account_stock_journal_id:
raise UserError(self.env._("Please set the Journal for Inventory Valuation in the settings."))
if not self.account_stock_valuation_id:
raise UserError(self.env._("Please set the Valuation Account for Inventory Valuation in the settings."))
moves_vals = {
'journal_id': self.account_stock_journal_id.id,
'date': at_date or fields.Date.today(),
'ref': _('Stock Closing'),
'line_ids': [Command.create(aml_vals) for aml_vals in aml_vals_list],
}
account_move = self.env['account.move'].create(moves_vals)
self._save_closing_id(account_move.id)
if auto_post:
account_move._post()
return {
'type': 'ir.actions.act_window',
'name': _("Journal Items"),
'res_model': 'account.move',
'res_id': account_move.id,
'views': [(False, 'form')],
}
def stock_value(self, accounts_by_product=None, at_date=None):
self.ensure_one()
value_by_account: dict = defaultdict(float)
if not accounts_by_product:
accounts_by_product = self._get_accounts_by_product()
for product, accounts in accounts_by_product.items():
account = accounts['valuation']
product_value = product.with_context(to_date=at_date).total_value
value_by_account[account] += product_value
return value_by_account
def stock_accounting_value(self, accounts_by_product=None, at_date=None):
self.ensure_one()
if not accounts_by_product:
accounts_by_product = self._get_accounts_by_product()
account_data = defaultdict(float)
stock_valuation_accounts_ids = set()
for dummy, accounts in accounts_by_product.items():
stock_valuation_accounts_ids.add(accounts['valuation'].id)
stock_valuation_accounts = self.env['account.account'].browse(stock_valuation_accounts_ids)
domain = Domain([
('account_id', 'in', stock_valuation_accounts.ids),
('company_id', '=', self.id),
('parent_state', '=', 'posted'),
])
if at_date:
domain = domain & Domain([('date', '<=', at_date)])
amls_group = self.env['account.move.line']._read_group(domain, ['account_id'], ['balance:sum'])
for account, balance in amls_group:
account_data[account] += balance
return account_data
def _action_close_stock_valuation(self, at_date=None):
aml_vals_list = []
accounts_by_product = self._get_accounts_by_product()
vals_list = self._get_location_valuation_vals(at_date)
if vals_list:
# Needed directly since it will impact the accounting stock valuation.
aml_vals_list += vals_list
vals_list = self._get_stock_valuation_account_vals(accounts_by_product, at_date, aml_vals_list)
if vals_list:
aml_vals_list += vals_list
vals_list = self._get_continental_realtime_variation_vals(accounts_by_product, at_date, aml_vals_list)
if vals_list:
aml_vals_list += vals_list
return aml_vals_list
@api.model
def _cron_post_stock_valuation(self):
domain = Domain([('inventory_period', '=', 'daily'), ('inventory_valuation', '!=', 'real_time')])
if fields.Date.today() == fields.Date.today() + relativedelta(day=31):
domain = domain & Domain([('inventory_period', '=', 'monthly')])
companies = self.env['res.company'].search(domain)
for company in companies:
company.action_close_stock_valuation(auto_post=True)
def _get_accounts_by_product(self, products=None):
if not products:
products = self.env['product.product'].with_company(self).search([('is_storable', '=', True)])
accounts_by_product = {}
for product in products:
accounts = product._get_product_accounts()
accounts_by_product[product] = {
'valuation': accounts['stock_valuation'],
'variation': accounts['stock_variation'],
'expense': accounts['expense'],
}
return accounts_by_product
@api.model
def _get_extra_balance(self, vals_list=None):
extra_balance = defaultdict(float)
if not vals_list:
return extra_balance
for vals in vals_list:
extra_balance[vals['account_id']] += (vals['debit'] - vals['credit'])
return extra_balance
def _get_location_valuation_vals(self, at_date=None, location_domain=False):
location_domain = Domain.AND([
location_domain or [],
[('valuation_account_id', '!=', False)],
[('company_id', '=', self.id)],
])
amls_vals_list = []
valued_location = self.env['stock.location'].search(location_domain)
last_closing_date = self._get_last_closing_date()
moves_base_domain = Domain([
('product_id.is_storable', '=', True),
('product_id.valuation', '=', 'periodic')
])
if last_closing_date:
moves_base_domain &= Domain([('date', '>', last_closing_date)])
if at_date:
moves_base_domain &= Domain([('date', '<=', at_date)])
moves_in_domain = Domain([
('is_out', '=', True),
('company_id', '=', self.id),
('location_dest_id', 'in', valued_location.ids),
]) & moves_base_domain
moves_in_by_location = self.env['stock.move']._read_group(
moves_in_domain,
['location_dest_id', 'product_category_id'],
['value:sum'],
)
moves_out_domain = Domain([
('is_in', '=', True),
('company_id', '=', self.id),
('location_id', 'in', valued_location.ids),
]) & moves_base_domain
moves_out_by_location = self.env['stock.move']._read_group(
moves_out_domain,
['location_id', 'product_category_id'],
['value:sum'],
)
account_balance = defaultdict(float)
for location, category, value in moves_in_by_location:
stock_valuation_acc = category.property_stock_valuation_account_id or self.account_stock_valuation_id
account_balance[location.valuation_account_id, stock_valuation_acc] += value
for location, category, value in moves_out_by_location:
stock_valuation_acc = category.property_stock_valuation_account_id or self.account_stock_valuation_id
account_balance[location.valuation_account_id, stock_valuation_acc] -= value
for (location_account, stock_account), balance in account_balance.items():
if balance == 0:
continue
amls_vals = self._prepare_inventory_aml_vals(
location_account,
stock_account,
balance,
_('Closing: Location Reclassification - [%(account)s]', account=location_account.display_name),
)
amls_vals_list += amls_vals
return amls_vals_list
def _get_stock_valuation_account_vals(self, accounts_by_product, at_date=None, extra_aml_vals_list=None):
amls_vals_list = []
if not accounts_by_product:
return amls_vals_list
extra_balance = self._get_extra_balance(extra_aml_vals_list)
if 'inventory_data' in self.env.context:
inventory_data = self.env.context.get('inventory_data')
else:
inventory_data = self.stock_value(accounts_by_product, at_date)
accounting_data = self.stock_accounting_value(accounts_by_product, at_date)
accounts = inventory_data.keys() | accounting_data.keys()
for account in accounts:
account_variation = account.account_stock_variation_id
if not account_variation:
account_variation = self.expense_account_id
if not account_variation:
continue
balance = inventory_data.get(account, 0) - accounting_data.get(account, 0)
balance -= extra_balance.get(account.id, 0)
if self.currency_id.is_zero(balance):
continue
amls_vals = self._prepare_inventory_aml_vals(
account,
account_variation,
balance,
_('Closing: Stock Variation Global for company [%(company)s]', company=self.display_name),
)
amls_vals_list += amls_vals
return amls_vals_list
def _get_continental_realtime_variation_vals(self, accounts_by_product, at_date=None, extra_aml_vals_list=None):
""" In continental perpetual the inventory variation is never posted.
This method compute the variation for a period and post it.
"""
extra_balance = self._get_extra_balance(extra_aml_vals_list)
fiscal_year_date_from = self.compute_fiscalyear_dates(fields.Date.today())['date_from']
amls_vals_list = []
accounting_data_today = self.stock_accounting_value(accounts_by_product)
accounting_data_last_period = self.stock_accounting_value(accounts_by_product, at_date=fiscal_year_date_from)
accounts = accounting_data_today.keys() | accounting_data_last_period.keys()
for account in accounts:
variation_acc = account.account_stock_variation_id
expense_acc = account.account_stock_expense_id
if not variation_acc or not expense_acc:
continue
balance_today = accounting_data_today.get(account, 0) - extra_balance[account]
balance_last_period = accounting_data_last_period.get(account, 0)
balance_over_period = balance_today - balance_last_period
current_balance_domain = Domain([
('account_id', '=', variation_acc.id),
('company_id', '=', self.id),
('parent_state', '=', 'posted'),
])
if at_date:
current_balance_domain &= Domain([('date', '<=', at_date)])
existing_balance = sum(self.env['account.move.line'].search(current_balance_domain).mapped('balance'))
balance_over_period += existing_balance
if self.currency_id.is_zero(balance_over_period):
continue
amls_vals = self._prepare_inventory_aml_vals(
expense_acc,
variation_acc,
balance_over_period,
_('Closing: Stock Variation Over Period'),
)
amls_vals_list += amls_vals
return amls_vals_list
def _prepare_inventory_aml_vals(self, debit_acc, credit_acc, balance, ref, product_id=False):
if balance < 0:
temp = credit_acc
credit_acc = debit_acc
debit_acc = temp
balance = abs(balance)
return [{
'account_id': credit_acc.id,
'name': ref,
'debit': 0,
'credit': balance,
'product_id': product_id,
}, {
'account_id': debit_acc.id,
'name': ref,
'debit': balance,
'credit': 0,
'product_id': product_id,
}]
def _get_last_closing_date(self):
self.ensure_one()
key = f'{self.id}.stock_valuation_closing_ids'
closing_ids = self.env['ir.config_parameter'].sudo().get_param(key)
closing_ids = closing_ids.split(',') if closing_ids else []
closing = self.env['account.move']
while not closing and closing_ids:
closing_id = closing_ids.pop(-1)
closing_id = int(closing_id)
closing = self.env['account.move'].browse(closing_id).exists().filtered(lambda am: am.state == 'posted')
if not closing:
return False
am_state_field = self.env['ir.model.fields'].search([('model', '=', 'account.move'), ('name', '=', 'state')], limit=1)
state_tracking = closing.message_ids.sudo().tracking_value_ids.filtered(lambda t: t.field_id == am_state_field).sorted('id')
return state_tracking[-1:].create_date or fields.Datetime.to_datetime(closing.date)
def _save_closing_id(self, move_id):
self.ensure_one()
key = f'{self.id}.stock_valuation_closing_ids'
closing_ids = self.env['ir.config_parameter'].sudo().get_param(key)
ids = closing_ids.split(',') if closing_ids else []
ids.append(str(move_id))
if len(ids) > 10:
ids = ids[1:]
self.env['ir.config_parameter'].sudo().set_param(key, ','.join(ids))
def _set_category_defaults(self):
for company in self:
self.env['ir.default'].set('product.category', 'property_valuation', company.inventory_valuation, company_id=company.id)
self.env['ir.default'].set('product.category', 'property_cost_method', company.cost_method, company_id=company.id)
self.env['ir.default'].set('product.category', 'property_stock_journal', company.account_stock_journal_id.id, company_id=company.id)
self.env['ir.default'].set('product.category', 'property_stock_valuation_account_id', company.account_stock_valuation_id.id, company_id=company.id)

View file

@ -1,6 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
@ -10,4 +7,4 @@ class ResConfigSettings(models.TransientModel):
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')
implied_group='stock_account.group_lot_on_invoice')

View file

@ -2,31 +2,40 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo.fields import Domain
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.")
valuation_account_id = fields.Many2one(
'account.account', 'Stock Valuation Account',
domain=[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card'))],
help="Expense account used to re-qualify products removed from stock and sent to this location")
is_valued_internal = fields.Boolean('Is valued inside the company', compute="_compute_is_valued", search="_search_is_valued")
is_valued_external = fields.Boolean('Is valued outside the company', compute="_compute_is_valued")
def _search_is_valued(self, operator, value):
if operator not in ['=', '!=']:
raise NotImplementedError(self.env._("Invalid search operator or value"))
positive_operator = (operator == '=' and value) or (operator == '!=' and not value)
domain = Domain([('company_id', 'in', self.env.companies.ids), ('usage', 'in', ['internal', 'transit'])])
if positive_operator:
return domain
return ~domain
def _compute_is_valued(self):
for location in self:
if location._should_be_valued():
location.is_valued_internal = True
location.is_valued_external = False
else:
location.is_valued_internal = False
location.is_valued_external = True
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
return bool(self.company_id) and self.usage in ['internal', 'transit']

View file

@ -0,0 +1,107 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
class StockLot(models.Model):
_inherit = 'stock.lot'
lot_valuated = fields.Boolean(related='product_id.lot_valuated', readonly=True, store=False)
avg_cost = fields.Monetary(string="Average Cost", compute='_compute_value', compute_sudo=True, readonly=True, currency_field='company_currency_id')
total_value = fields.Monetary(string="Total Value", compute='_compute_value', compute_sudo=True, currency_field='company_currency_id')
company_currency_id = fields.Many2one('res.currency', 'Valuation Currency', compute='_compute_value', compute_sudo=True)
standard_price = fields.Float(
"Cost", company_dependent=True,
min_display_digits='Product Price', groups="base.group_user",
help="""Value of the lot (automatically computed in AVCO).
Used to value the product when the purchase cost is not known (e.g. inventory adjustment).
Used to compute margins on sale orders."""
)
@api.depends('product_id.lot_valuated', 'product_id.product_tmpl_id.lot_valuated', 'product_id.stock_move_ids.value', 'standard_price')
@api.depends_context('to_date', 'company', 'warehouse_id')
def _compute_value(self):
"""Compute totals of multiple svl related values"""
company_id = self.env.company
self.company_currency_id = company_id.currency_id
at_date = fields.Datetime.to_datetime(self.env.context.get('to_date'))
for lot in self:
if not lot.lot_valuated:
lot.total_value = 0.0
lot.avg_cost = 0.0
continue
valuated_product = lot.product_id.with_context(at_date=at_date, lot_id=lot.id)
qty_valued = lot.product_qty
qty_available = lot.with_context(warehouse_id=False).product_qty
if valuated_product.uom_id.is_zero(qty_valued):
lot.total_value = 0
elif valuated_product.cost_method == 'standard' or valuated_product.uom_id.is_zero(qty_available):
lot.total_value = lot.standard_price * qty_valued
elif valuated_product.cost_method == 'average':
lot.total_value = valuated_product.with_context(warehouse_id=False)._run_avco(at_date=at_date, lot=lot.with_context(warehouse_id=False))[1] * qty_valued / qty_available
else:
lot.total_value = valuated_product.with_context(warehouse_id=False)._run_fifo(qty_available, at_date=at_date, lot=lot.with_context(warehouse_id=False)) * qty_valued / qty_available
lot.avg_cost = lot.total_value / qty_valued if qty_valued else 0.0
# TODO: remove avg cost column in master and merge the two compute methods
@api.depends('product_id.lot_valuated')
@api.depends_context('to_date')
def _compute_avg_cost(self):
"""Compute totals of multiple svl related values"""
self.avg_cost = 0
@api.model_create_multi
def create(self, vals_list):
lots = super().create(vals_list)
for product, lots_by_product in lots.grouped('product_id').items():
if product.lot_valuated:
lots_by_product.filtered(lambda lot: not lot.standard_price).with_context(disable_auto_revaluation=True).write({
'standard_price': product.standard_price,
})
return lots
def write(self, vals):
old_price = False
if 'standard_price' in vals and not self.env.context.get('disable_auto_revaluation'):
old_price = {lot: lot.standard_price for lot in self}
res = super().write(vals)
if old_price:
self._change_standard_price(old_price)
return res
def _update_standard_price(self):
# TODO: Add extra value and extra quantity kwargs to avoid total recomputation
for lot in self:
lot = lot.with_context(disable_auto_revaluation=True)
if not lot.product_id.lot_valuated:
continue
if lot.product_id.cost_method == 'standard':
if not lot.standard_price:
lot.standard_price = lot.product_id.standard_price
continue
elif lot.product_id.cost_method == 'average':
lot.standard_price = lot.product_id._run_avco(lot=lot)[0]
else:
lot.standard_price = lot.product_id._run_fifo_batch(lot=lot)[0].get(lot.product_id.id, lot.standard_price)
def _change_standard_price(self, old_price):
"""Helper to create the stock valuation layers and the account moves
after an update of standard price.
:param new_price: new standard price
"""
product_values = []
for lot in self:
if lot.product_id.cost_method != 'average' or lot.standard_price == old_price:
continue
product = lot.product_id
product_values.append({
'product_id': product.id,
'lot_id': lot.id,
'value': lot.standard_price,
'company_id': product.company_id.id or self.env.company.id,
'date': fields.Datetime.now(),
'description': _('%(lot)s price update from %(old_price)s to %(new_price)s by %(user)s',
lot=lot.name, old_price=old_price, new_price=lot.standard_price, user=self.env.user.name)
})
self.env['product.value'].sudo().create(product_values)

View file

@ -1,74 +1,38 @@
# -*- 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
mls = super().create(vals_list)
mls._update_stock_move_value()
return mls
def write(self, vals):
analytic_move_to_recompute = set()
if 'qty_done' in vals or 'move_id' in vals:
if 'quantity' 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
move_id = vals.get('move_id', 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)
valuation_fields = ['quantity', 'location_id', 'location_dest_id', 'owner_id', 'quant_id', 'lot_id']
valuation_trigger = any(field in vals for field in valuation_fields)
qty_by_ml = {}
if valuation_trigger:
qty_by_ml = {ml: ml.quantity for ml in self if ml.move_id.is_in or ml.move_id.is_out}
res = super().write(vals)
if valuation_trigger and qty_by_ml:
self._update_stock_move_value(qty_by_ml)
if analytic_move_to_recompute:
self.env['stock.move'].browse(analytic_move_to_recompute)._account_analytic_entry_move()
self.env['stock.move'].browse(analytic_move_to_recompute).sudo()._create_analytic_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()
def unlink(self):
analytic_move_to_recompute = self.move_id
res = super().unlink()
analytic_move_to_recompute.sudo()._create_analytic_move()
return res
@api.model
def _should_exclude_for_valuation(self):
@ -79,3 +43,24 @@ class StockMoveLine(models.Model):
"""
self.ensure_one()
return self.owner_id and self.owner_id != self.company_id.partner_id
def _update_stock_move_value(self, old_qty_by_ml=None):
move_to_update_ids = set()
if not old_qty_by_ml:
old_qty_by_ml = {}
for move, mls in self.grouped('move_id').items():
if not (move.is_in or move.is_out):
continue
if move.is_in:
move_to_update_ids.add(move.id)
elif move.is_out:
delta = sum(
ml.quantity - old_qty_by_ml.get(ml, 0)
for ml in mls
if not ml._should_exclude_for_valuation()
)
if delta:
move._set_value(correction_quantity=delta)
if moves_to_update := self.env['stock.move'].browse(move_to_update_ids):
moves_to_update._set_value()

View file

@ -1,9 +1,8 @@
# -*- 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
from odoo import api, models, fields
from odoo.exceptions import ValidationError
class StockPicking(models.Model):
@ -11,12 +10,25 @@ class StockPicking(models.Model):
country_code = fields.Char(related="company_id.account_fiscal_country_id.code")
def action_view_stock_valuation_layers(self):
@api.constrains("scheduled_date", "date_done")
def _check_backdate_allowed(self):
if self.env['ir.config_parameter'].sudo().get_param('stock_account.skip_lock_date_check'):
return
for picking in self:
if picking._is_date_in_lock_period():
raise ValidationError(self.env._("You cannot modify the scheduled date of operation %s because it falls within a locked fiscal period.", picking.display_name))
def _compute_is_date_editable(self):
super()._compute_is_date_editable()
for picking in self:
if picking.is_date_editable and picking.state in ['done', 'cancel'] and picking.ids:
picking.is_date_editable = not picking._is_date_in_lock_period()
def _is_date_in_lock_period(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)
lock = []
if self.state == "done":
lock += self.company_id._get_lock_date_violations(self.scheduled_date.date(), fiscalyear=True, sale=False, purchase=False, tax=False, hard=True)
if self.date_done:
lock += self.company_id._get_lock_date_violations(self.date_done.date(), fiscalyear=True, sale=False, purchase=False, tax=False, hard=True)
return bool(lock)

View file

@ -0,0 +1,9 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class StockPickingType(models.Model):
_inherit = 'stock.picking.type'
country_code = fields.Char(related='company_id.account_fiscal_country_id.code')

View file

@ -1,8 +1,6 @@
# -*- 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 import api, fields, models, SUPERUSER_ID, _
from odoo.tools.misc import groupby
@ -10,13 +8,32 @@ 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')
currency_id = fields.Many2one('res.currency', related='company_id.currency_id', 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")
cost_method = fields.Selection(
string="Cost Method",
selection=[
('standard', "Standard Price"),
('fifo', "First In First Out (FIFO)"),
('average', "Average Cost (AVCO)"),
],
compute='_compute_cost_method',
)
@api.depends_context('company')
@api.depends('product_categ_id.property_cost_method')
def _compute_cost_method(self):
for quant in self:
quant.cost_method = (
quant.product_categ_id.with_company(
quant.company_id
).property_cost_method
or (quant.company_id or self.env.company).cost_method
)
@api.model
def _should_exclude_for_valuation(self):
@ -29,52 +46,58 @@ class StockQuant(models.Model):
@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
"""
self.fetch(['company_id', 'location_id', 'owner_id', 'product_id', 'quantity', 'lot_id'])
self.value = 0
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
quant.product_id.uom_id.is_zero(quant.quantity):
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
if quant.product_id.lot_valuated:
quantity = quant.lot_id.with_company(quant.company_id).product_qty
value = quant.lot_id.with_company(quant.company_id).total_value
else:
quantity = quant.product_id.with_company(quant.company_id).qty_available
value = quant.product_id.with_company(quant.company_id).total_value
if quant.product_id.uom_id.is_zero(quantity):
continue
quant.value = quant.quantity * quant.product_id.with_company(quant.company_id).value_svl / quantity
quant.value = quant.quantity * value / 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 _read_group_select(self, aggregate_spec, query):
# flag value as aggregatable, and manually sum the values from the
# records in the group
if aggregate_spec in ('value:sum', 'value:sum_currency'):
return super()._read_group_select('id:recordset', query)
return super()._read_group_select(aggregate_spec, query)
def _apply_inventory(self):
def _read_group_postprocess_aggregate(self, aggregate_spec, raw_values):
if aggregate_spec in ('value:sum', 'value:sum_currency'):
column = super()._read_group_postprocess_aggregate('id:recordset', raw_values)
return (sum(records.mapped('value')) for records in column)
return super()._read_group_postprocess_aggregate(aggregate_spec, raw_values)
def _apply_inventory(self, date=None):
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()
super(StockQuant, inventories.with_context(force_period_date=accounting_date))._apply_inventory(date)
inventories.accounting_date = False
else:
super(StockQuant, inventories)._apply_inventory()
super(StockQuant, inventories)._apply_inventory(date)
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)
def _get_inventory_move_values(self, qty, location_id, location_dest_id, package_id=False, package_dest_id=False):
res_move = super()._get_inventory_move_values(qty, location_id, location_dest_id, package_id, package_dest_id)
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)
if self.product_uom_id.is_zero(qty):
name = _('Product Quantity Confirmed')
else:
name = _('Product Quantity Updated')
if self.env.uid and self.env.uid != SUPERUSER_ID:
name += f' ({self.env.user.display_name})'
res_move['inventory_name'] = name + _(' [Accounted on %s]', force_period_date)
return res_move
@api.model

View file

@ -1,186 +0,0 @@
# -*- 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