473 lines
22 KiB
Python
473 lines
22 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from collections import defaultdict
|
|
|
|
from odoo import api, fields, models, tools, _
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools.float_utils import float_is_zero
|
|
|
|
|
|
SPLIT_METHOD = [
|
|
('equal', 'Equal'),
|
|
('by_quantity', 'By Quantity'),
|
|
('by_current_cost_price', 'By Current Cost'),
|
|
('by_weight', 'By Weight'),
|
|
('by_volume', 'By Volume'),
|
|
]
|
|
|
|
|
|
class StockLandedCost(models.Model):
|
|
_name = 'stock.landed.cost'
|
|
_description = 'Stock Landed Cost'
|
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
|
_order = 'date desc, id desc'
|
|
|
|
def _default_account_journal_id(self):
|
|
"""Take the journal configured in the company, else fallback on the stock journal."""
|
|
lc_journal = self.env['account.journal']
|
|
if self.env.company.lc_journal_id:
|
|
lc_journal = self.env.company.lc_journal_id
|
|
else:
|
|
lc_journal = self.env['ir.property']._get("property_stock_journal", "product.category")
|
|
return lc_journal
|
|
|
|
name = fields.Char(
|
|
'Name', default=lambda self: _('New'),
|
|
copy=False, readonly=True, tracking=True)
|
|
date = fields.Date(
|
|
'Date', default=fields.Date.context_today,
|
|
copy=False, required=True, tracking=True)
|
|
target_model = fields.Selection(
|
|
[('picking', 'Transfers')], string="Apply On",
|
|
required=True, default='picking',
|
|
copy=False)
|
|
picking_ids = fields.Many2many(
|
|
'stock.picking', string='Transfers',
|
|
copy=False)
|
|
cost_lines = fields.One2many(
|
|
'stock.landed.cost.lines', 'cost_id', 'Cost Lines',
|
|
copy=True)
|
|
valuation_adjustment_lines = fields.One2many(
|
|
'stock.valuation.adjustment.lines', 'cost_id', 'Valuation Adjustments',)
|
|
description = fields.Text(
|
|
'Item Description')
|
|
amount_total = fields.Monetary(
|
|
'Total', compute='_compute_total_amount',
|
|
store=True, tracking=True)
|
|
state = fields.Selection([
|
|
('draft', 'Draft'),
|
|
('done', 'Posted'),
|
|
('cancel', 'Cancelled')], 'State', default='draft',
|
|
copy=False, readonly=True, tracking=True)
|
|
account_move_id = fields.Many2one(
|
|
'account.move', 'Journal Entry',
|
|
index='btree_not_null',
|
|
copy=False, readonly=True)
|
|
account_journal_id = fields.Many2one(
|
|
'account.journal', 'Account Journal',
|
|
required=True, default=lambda self: self._default_account_journal_id())
|
|
company_id = fields.Many2one('res.company', string="Company",
|
|
related='account_journal_id.company_id')
|
|
stock_valuation_layer_ids = fields.One2many('stock.valuation.layer', 'stock_landed_cost_id')
|
|
vendor_bill_id = fields.Many2one(
|
|
'account.move', 'Vendor Bill', copy=False, domain=[('move_type', '=', 'in_invoice')])
|
|
currency_id = fields.Many2one('res.currency', related='company_id.currency_id')
|
|
|
|
@api.depends('cost_lines.price_unit')
|
|
def _compute_total_amount(self):
|
|
for cost in self:
|
|
cost.amount_total = sum(line.price_unit for line in cost.cost_lines)
|
|
|
|
@api.onchange('target_model')
|
|
def _onchange_target_model(self):
|
|
if self.target_model != 'picking':
|
|
self.picking_ids = False
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
for vals in vals_list:
|
|
if vals.get('name', _('New')) == _('New'):
|
|
vals['name'] = self.env['ir.sequence'].next_by_code('stock.landed.cost')
|
|
return super().create(vals_list)
|
|
|
|
def unlink(self):
|
|
self.button_cancel()
|
|
return super().unlink()
|
|
|
|
def _track_subtype(self, init_values):
|
|
if 'state' in init_values and self.state == 'done':
|
|
return self.env.ref('stock_landed_costs.mt_stock_landed_cost_open')
|
|
return super()._track_subtype(init_values)
|
|
|
|
def button_cancel(self):
|
|
if any(cost.state == 'done' for cost in self):
|
|
raise UserError(
|
|
_('Validated landed costs cannot be cancelled, but you could create negative landed costs to reverse them'))
|
|
return self.write({'state': 'cancel'})
|
|
|
|
def button_validate(self):
|
|
self._check_can_validate()
|
|
cost_without_adjusment_lines = self.filtered(lambda c: not c.valuation_adjustment_lines)
|
|
if cost_without_adjusment_lines:
|
|
cost_without_adjusment_lines.compute_landed_cost()
|
|
if not self._check_sum():
|
|
raise UserError(_('Cost and adjustments lines do not match. You should maybe recompute the landed costs.'))
|
|
|
|
for cost in self:
|
|
cost = cost.with_company(cost.company_id)
|
|
move = self.env['account.move']
|
|
move_vals = {
|
|
'journal_id': cost.account_journal_id.id,
|
|
'date': cost.date,
|
|
'ref': cost.name,
|
|
'line_ids': [],
|
|
'move_type': 'entry',
|
|
}
|
|
valuation_layer_ids = []
|
|
cost_to_add_byproduct = defaultdict(lambda: 0.0)
|
|
for line in cost.valuation_adjustment_lines.filtered(lambda line: line.move_id):
|
|
remaining_qty = sum(line.move_id.stock_valuation_layer_ids.mapped('remaining_qty'))
|
|
linked_layer = line.move_id.stock_valuation_layer_ids[:1]
|
|
|
|
# Prorate the value at what's still in stock
|
|
cost_to_add = (remaining_qty / line.move_id.quantity) * line.additional_landed_cost
|
|
if not cost.company_id.currency_id.is_zero(cost_to_add):
|
|
valuation_layer = self.env['stock.valuation.layer'].create({
|
|
'value': cost_to_add,
|
|
'unit_cost': 0,
|
|
'quantity': 0,
|
|
'remaining_qty': 0,
|
|
'stock_valuation_layer_id': linked_layer.id,
|
|
'description': cost.name,
|
|
'stock_move_id': line.move_id.id,
|
|
'product_id': line.move_id.product_id.id,
|
|
'stock_landed_cost_id': cost.id,
|
|
'company_id': cost.company_id.id,
|
|
})
|
|
linked_layer.remaining_value += cost_to_add
|
|
valuation_layer_ids.append(valuation_layer.id)
|
|
# Update the AVCO/FIFO
|
|
product = line.move_id.product_id
|
|
if product.cost_method in ['average', 'fifo']:
|
|
cost_to_add_byproduct[product] += cost_to_add
|
|
# Products with manual inventory valuation are ignored because they do not need to create journal entries.
|
|
if product.valuation != "real_time":
|
|
continue
|
|
# `remaining_qty` is negative if the move is out and delivered proudcts that were not
|
|
# in stock.
|
|
qty_out = 0
|
|
if line.move_id._is_in():
|
|
qty_out = line.move_id.quantity - remaining_qty
|
|
elif line.move_id._is_out():
|
|
qty_out = line.move_id.quantity
|
|
move_vals['line_ids'] += line._create_accounting_entries(move, qty_out)
|
|
|
|
# batch standard price computation avoid recompute quantity_svl at each iteration
|
|
products = self.env['product.product'].browse(p.id for p in cost_to_add_byproduct.keys()).with_company(cost.company_id)
|
|
for product in products: # iterate on recordset to prefetch efficiently quantity_svl
|
|
if not float_is_zero(product.quantity_svl, precision_rounding=product.uom_id.rounding):
|
|
product.sudo().with_context(disable_auto_svl=True).standard_price += cost_to_add_byproduct[product] / product.quantity_svl
|
|
|
|
move_vals['stock_valuation_layer_ids'] = [(6, None, valuation_layer_ids)]
|
|
# We will only create the accounting entry when there are defined lines (the lines will be those linked to products of real_time valuation category).
|
|
cost_vals = {'state': 'done'}
|
|
if move_vals.get("line_ids"):
|
|
move = move.create(move_vals)
|
|
cost_vals.update({'account_move_id': move.id})
|
|
cost.write(cost_vals)
|
|
if cost.account_move_id:
|
|
move._post()
|
|
cost.reconcile_landed_cost()
|
|
return True
|
|
|
|
def reconcile_landed_cost(self):
|
|
for cost in self:
|
|
if cost.vendor_bill_id and cost.vendor_bill_id.state == 'posted' and cost.company_id.anglo_saxon_accounting:
|
|
all_amls = cost.vendor_bill_id.line_ids | cost.account_move_id.line_ids
|
|
for product in cost.cost_lines.product_id:
|
|
accounts = product.product_tmpl_id.get_product_accounts()
|
|
input_account = accounts['stock_input']
|
|
all_amls.filtered(lambda aml: aml.account_id == input_account and not aml.reconciled).reconcile()
|
|
|
|
def get_valuation_lines(self):
|
|
self.ensure_one()
|
|
lines = []
|
|
|
|
for move in self._get_targeted_move_ids():
|
|
# it doesn't make sense to make a landed cost for a product that isn't set as being valuated in real time at real cost
|
|
if move.product_id.cost_method not in ('fifo', 'average') or move.state == 'cancel' or not move.product_qty:
|
|
continue
|
|
vals = {
|
|
'product_id': move.product_id.id,
|
|
'move_id': move.id,
|
|
'quantity': move.product_qty,
|
|
'former_cost': sum(move.stock_valuation_layer_ids.mapped('value')),
|
|
'weight': move.product_id.weight * move.product_qty,
|
|
'volume': move.product_id.volume * move.product_qty
|
|
}
|
|
lines.append(vals)
|
|
|
|
if not lines:
|
|
target_model_descriptions = dict(self._fields['target_model']._description_selection(self.env))
|
|
raise UserError(_("You cannot apply landed costs on the chosen %s(s). Landed costs can only be applied for products with FIFO or average costing method.", target_model_descriptions[self.target_model]))
|
|
return lines
|
|
|
|
def compute_landed_cost(self):
|
|
AdjustementLines = self.env['stock.valuation.adjustment.lines']
|
|
AdjustementLines.search([('cost_id', 'in', self.ids)]).unlink()
|
|
|
|
towrite_dict = {}
|
|
for cost in self.filtered(lambda cost: cost._get_targeted_move_ids()):
|
|
cost = cost.with_company(cost.company_id)
|
|
rounding = cost.currency_id.rounding
|
|
total_qty = 0.0
|
|
total_cost = 0.0
|
|
total_weight = 0.0
|
|
total_volume = 0.0
|
|
total_line = 0.0
|
|
all_val_line_values = cost.get_valuation_lines()
|
|
for val_line_values in all_val_line_values:
|
|
for cost_line in cost.cost_lines:
|
|
val_line_values.update({'cost_id': cost.id, 'cost_line_id': cost_line.id})
|
|
self.env['stock.valuation.adjustment.lines'].create(val_line_values)
|
|
total_qty += val_line_values.get('quantity', 0.0)
|
|
total_weight += val_line_values.get('weight', 0.0)
|
|
total_volume += val_line_values.get('volume', 0.0)
|
|
|
|
former_cost = val_line_values.get('former_cost', 0.0)
|
|
# round this because former_cost on the valuation lines is also rounded
|
|
total_cost += cost.currency_id.round(former_cost)
|
|
|
|
total_line += 1
|
|
|
|
for line in cost.cost_lines:
|
|
value_split = 0.0
|
|
for valuation in cost.valuation_adjustment_lines:
|
|
value = 0.0
|
|
if valuation.cost_line_id and valuation.cost_line_id.id == line.id:
|
|
if line.split_method == 'by_quantity' and total_qty:
|
|
per_unit = (line.price_unit / total_qty)
|
|
value = valuation.quantity * per_unit
|
|
elif line.split_method == 'by_weight' and total_weight:
|
|
per_unit = (line.price_unit / total_weight)
|
|
value = valuation.weight * per_unit
|
|
elif line.split_method == 'by_volume' and total_volume:
|
|
per_unit = (line.price_unit / total_volume)
|
|
value = valuation.volume * per_unit
|
|
elif line.split_method == 'equal':
|
|
value = (line.price_unit / total_line)
|
|
elif line.split_method == 'by_current_cost_price' and total_cost:
|
|
per_unit = (line.price_unit / total_cost)
|
|
value = valuation.former_cost * per_unit
|
|
else:
|
|
value = (line.price_unit / total_line)
|
|
|
|
if rounding:
|
|
value = tools.float_round(value, precision_rounding=rounding, rounding_method='UP')
|
|
fnc = min if line.price_unit > 0 else max
|
|
value = fnc(value, line.price_unit - value_split)
|
|
value_split += value
|
|
|
|
if valuation.id not in towrite_dict:
|
|
towrite_dict[valuation.id] = value
|
|
else:
|
|
towrite_dict[valuation.id] += value
|
|
for key, value in towrite_dict.items():
|
|
AdjustementLines.browse(key).write({'additional_landed_cost': value})
|
|
return True
|
|
|
|
def action_view_stock_valuation_layers(self):
|
|
self.ensure_one()
|
|
domain = [('id', 'in', self.stock_valuation_layer_ids.ids)]
|
|
action = self.env["ir.actions.actions"]._for_xml_id("stock_account.stock_valuation_layer_action")
|
|
return dict(action, domain=domain)
|
|
|
|
def _get_targeted_move_ids(self):
|
|
return self.picking_ids.move_ids
|
|
|
|
def _check_can_validate(self):
|
|
if any(cost.state != 'draft' for cost in self):
|
|
raise UserError(_('Only draft landed costs can be validated'))
|
|
for cost in self:
|
|
if not cost._get_targeted_move_ids():
|
|
target_model_descriptions = dict(self._fields['target_model']._description_selection(self.env))
|
|
raise UserError(_('Please define %s on which those additional costs should apply.', target_model_descriptions[cost.target_model]))
|
|
|
|
def _check_sum(self):
|
|
""" Check if each cost line its valuation lines sum to the correct amount
|
|
and if the overall total amount is correct also """
|
|
prec_digits = self.env.company.currency_id.decimal_places
|
|
for landed_cost in self:
|
|
total_amount = sum(landed_cost.valuation_adjustment_lines.mapped('additional_landed_cost'))
|
|
if not tools.float_is_zero(total_amount - landed_cost.amount_total, precision_digits=prec_digits):
|
|
return False
|
|
|
|
val_to_cost_lines = defaultdict(lambda: 0.0)
|
|
for val_line in landed_cost.valuation_adjustment_lines:
|
|
val_to_cost_lines[val_line.cost_line_id] += val_line.additional_landed_cost
|
|
if any(not tools.float_is_zero(cost_line.price_unit - val_amount, precision_digits=prec_digits)
|
|
for cost_line, val_amount in val_to_cost_lines.items()):
|
|
return False
|
|
return True
|
|
|
|
|
|
class StockLandedCostLine(models.Model):
|
|
_name = 'stock.landed.cost.lines'
|
|
_description = 'Stock Landed Cost Line'
|
|
|
|
name = fields.Char('Description')
|
|
cost_id = fields.Many2one(
|
|
'stock.landed.cost', 'Landed Cost',
|
|
required=True, ondelete='cascade')
|
|
product_id = fields.Many2one('product.product', 'Product', required=True)
|
|
price_unit = fields.Monetary('Cost', required=True)
|
|
split_method = fields.Selection(
|
|
SPLIT_METHOD,
|
|
string='Split Method',
|
|
required=True,
|
|
help="Equal: Cost will be equally divided.\n"
|
|
"By Quantity: Cost will be divided according to product's quantity.\n"
|
|
"By Current cost: Cost will be divided according to product's current cost.\n"
|
|
"By Weight: Cost will be divided depending on its weight.\n"
|
|
"By Volume: Cost will be divided depending on its volume.")
|
|
account_id = fields.Many2one('account.account', 'Account', domain=[('deprecated', '=', False)])
|
|
currency_id = fields.Many2one('res.currency', related='cost_id.currency_id')
|
|
|
|
@api.onchange('product_id')
|
|
def onchange_product_id(self):
|
|
self.name = self.product_id.name or ''
|
|
self.split_method = self.product_id.product_tmpl_id.split_method_landed_cost or self.split_method or 'equal'
|
|
self.price_unit = self.product_id.standard_price or 0.0
|
|
accounts_data = self.product_id.product_tmpl_id.get_product_accounts()
|
|
self.account_id = accounts_data['stock_input']
|
|
|
|
|
|
class AdjustmentLines(models.Model):
|
|
_name = 'stock.valuation.adjustment.lines'
|
|
_description = 'Valuation Adjustment Lines'
|
|
|
|
name = fields.Char(
|
|
'Description', compute='_compute_name', store=True)
|
|
cost_id = fields.Many2one(
|
|
'stock.landed.cost', 'Landed Cost',
|
|
ondelete='cascade', required=True)
|
|
cost_line_id = fields.Many2one(
|
|
'stock.landed.cost.lines', 'Cost Line', readonly=True)
|
|
move_id = fields.Many2one('stock.move', 'Stock Move', readonly=True)
|
|
product_id = fields.Many2one('product.product', 'Product', required=True)
|
|
quantity = fields.Float(
|
|
'Quantity', default=1.0,
|
|
digits=0, required=True)
|
|
weight = fields.Float(
|
|
'Weight', default=1.0,
|
|
digits='Stock Weight')
|
|
volume = fields.Float(
|
|
'Volume', default=1.0, digits='Volume')
|
|
former_cost = fields.Monetary(
|
|
'Original Value')
|
|
additional_landed_cost = fields.Monetary(
|
|
'Additional Landed Cost')
|
|
final_cost = fields.Monetary(
|
|
'New Value', compute='_compute_final_cost',
|
|
store=True)
|
|
currency_id = fields.Many2one('res.currency', related='cost_id.company_id.currency_id')
|
|
|
|
@api.depends('cost_line_id.name', 'product_id.code', 'product_id.name')
|
|
def _compute_name(self):
|
|
for line in self:
|
|
name = '%s - ' % (line.cost_line_id.name if line.cost_line_id else '')
|
|
line.name = name + (line.product_id.code or line.product_id.name or '')
|
|
|
|
@api.depends('former_cost', 'additional_landed_cost')
|
|
def _compute_final_cost(self):
|
|
for line in self:
|
|
line.final_cost = line.former_cost + line.additional_landed_cost
|
|
|
|
def _create_accounting_entries(self, move, qty_out):
|
|
# TDE CLEANME: product chosen for computation ?
|
|
cost_product = self.cost_line_id.product_id
|
|
if not cost_product:
|
|
return False
|
|
accounts = self.product_id.product_tmpl_id.get_product_accounts()
|
|
debit_account_id = accounts.get('stock_valuation') and accounts['stock_valuation'].id or False
|
|
# If the stock move is dropshipped move we need to get the cost account instead the stock valuation account
|
|
if self.move_id._is_dropshipped():
|
|
debit_account_id = accounts.get('expense') and accounts['expense'].id or False
|
|
already_out_account_id = accounts['stock_output'].id
|
|
credit_account_id = self.cost_line_id.account_id.id or cost_product.categ_id.property_stock_account_input_categ_id.id
|
|
|
|
if not credit_account_id:
|
|
raise UserError(_('Please configure Stock Expense Account for product: %s.', cost_product.name))
|
|
|
|
return self._create_account_move_line(move, credit_account_id, debit_account_id, qty_out, already_out_account_id)
|
|
|
|
def _create_account_move_line(self, move, credit_account_id, debit_account_id, qty_out, already_out_account_id):
|
|
"""
|
|
Generate the account.move.line values to track the landed cost.
|
|
Afterwards, for the goods that are already out of stock, we should create the out moves
|
|
"""
|
|
AccountMoveLine = []
|
|
|
|
base_line = {
|
|
'name': self.name,
|
|
'product_id': self.product_id.id,
|
|
'quantity': 0,
|
|
}
|
|
debit_line = dict(base_line, account_id=debit_account_id)
|
|
credit_line = dict(base_line, account_id=credit_account_id)
|
|
diff = self.additional_landed_cost
|
|
if diff > 0:
|
|
debit_line['debit'] = diff
|
|
credit_line['credit'] = diff
|
|
else:
|
|
# negative cost, reverse the entry
|
|
debit_line['credit'] = -diff
|
|
credit_line['debit'] = -diff
|
|
AccountMoveLine.append([0, 0, debit_line])
|
|
AccountMoveLine.append([0, 0, credit_line])
|
|
|
|
# Create account move lines for quants already out of stock
|
|
if qty_out > 0:
|
|
debit_line = dict(base_line,
|
|
name=(self.name + ": " + str(qty_out) + _(' already out')),
|
|
quantity=0,
|
|
account_id=already_out_account_id)
|
|
credit_line = dict(base_line,
|
|
name=(self.name + ": " + str(qty_out) + _(' already out')),
|
|
quantity=0,
|
|
account_id=debit_account_id)
|
|
diff = diff * qty_out / self.quantity
|
|
if diff > 0:
|
|
debit_line['debit'] = diff
|
|
credit_line['credit'] = diff
|
|
else:
|
|
# negative cost, reverse the entry
|
|
debit_line['credit'] = -diff
|
|
credit_line['debit'] = -diff
|
|
AccountMoveLine.append([0, 0, debit_line])
|
|
AccountMoveLine.append([0, 0, credit_line])
|
|
|
|
if self.env.company.anglo_saxon_accounting:
|
|
expense_account_id = self.product_id.product_tmpl_id.get_product_accounts()['expense'].id
|
|
debit_line = dict(base_line,
|
|
name=(self.name + ": " + str(qty_out) + _(' already out')),
|
|
quantity=0,
|
|
account_id=expense_account_id)
|
|
credit_line = dict(base_line,
|
|
name=(self.name + ": " + str(qty_out) + _(' already out')),
|
|
quantity=0,
|
|
account_id=already_out_account_id)
|
|
|
|
if diff > 0:
|
|
debit_line['debit'] = diff
|
|
credit_line['credit'] = diff
|
|
else:
|
|
# negative cost, reverse the entry
|
|
debit_line['credit'] = -diff
|
|
credit_line['debit'] = -diff
|
|
AccountMoveLine.append([0, 0, debit_line])
|
|
AccountMoveLine.append([0, 0, credit_line])
|
|
|
|
return AccountMoveLine
|