mirror of
https://github.com/bringout/oca-ocb-accounting.git
synced 2026-04-25 18:02:00 +02:00
19.0 vanilla
This commit is contained in:
parent
ba20ce7443
commit
768b70e05e
2357 changed files with 1057103 additions and 712486 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue