summaryrefslogtreecommitdiff
path: root/addons/stock_landed_costs/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/stock_landed_costs/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/stock_landed_costs/models')
-rw-r--r--addons/stock_landed_costs/models/__init__.py11
-rw-r--r--addons/stock_landed_costs/models/account_move.py75
-rw-r--r--addons/stock_landed_costs/models/product.py24
-rw-r--r--addons/stock_landed_costs/models/purchase.py9
-rw-r--r--addons/stock_landed_costs/models/res_company.py11
-rw-r--r--addons/stock_landed_costs/models/res_config_settings.py11
-rw-r--r--addons/stock_landed_costs/models/stock_landed_cost.py474
-rw-r--r--addons/stock_landed_costs/models/stock_valuation_layer.py13
8 files changed, 628 insertions, 0 deletions
diff --git a/addons/stock_landed_costs/models/__init__.py b/addons/stock_landed_costs/models/__init__.py
new file mode 100644
index 00000000..6ac642d8
--- /dev/null
+++ b/addons/stock_landed_costs/models/__init__.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import product
+from . import purchase
+from . import res_company
+from . import res_config_settings
+from . import stock_landed_cost
+from . import account_move
+from . import stock_valuation_layer
+
diff --git a/addons/stock_landed_costs/models/account_move.py b/addons/stock_landed_costs/models/account_move.py
new file mode 100644
index 00000000..217d4e0e
--- /dev/null
+++ b/addons/stock_landed_costs/models/account_move.py
@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class AccountMove(models.Model):
+ _inherit = 'account.move'
+
+ landed_costs_ids = fields.One2many('stock.landed.cost', 'vendor_bill_id', string='Landed Costs')
+ landed_costs_visible = fields.Boolean(compute='_compute_landed_costs_visible')
+
+ @api.depends('line_ids', 'line_ids.is_landed_costs_line')
+ def _compute_landed_costs_visible(self):
+ for account_move in self:
+ if account_move.landed_costs_ids:
+ account_move.landed_costs_visible = False
+ else:
+ account_move.landed_costs_visible = any(line.is_landed_costs_line for line in account_move.line_ids)
+
+ def button_create_landed_costs(self):
+ """Create a `stock.landed.cost` record associated to the account move of `self`, each
+ `stock.landed.costs` lines mirroring the current `account.move.line` of self.
+ """
+ self.ensure_one()
+ landed_costs_lines = self.line_ids.filtered(lambda line: line.is_landed_costs_line)
+
+ landed_costs = self.env['stock.landed.cost'].create({
+ 'vendor_bill_id': self.id,
+ 'cost_lines': [(0, 0, {
+ 'product_id': l.product_id.id,
+ 'name': l.product_id.name,
+ 'account_id': l.product_id.product_tmpl_id.get_product_accounts()['stock_input'].id,
+ 'price_unit': l.currency_id._convert(l.price_subtotal, l.company_currency_id, l.company_id, l.move_id.date),
+ 'split_method': l.product_id.split_method_landed_cost or 'equal',
+ }) for l in landed_costs_lines],
+ })
+ action = self.env["ir.actions.actions"]._for_xml_id("stock_landed_costs.action_stock_landed_cost")
+ return dict(action, view_mode='form', res_id=landed_costs.id, views=[(False, 'form')])
+
+ def action_view_landed_costs(self):
+ self.ensure_one()
+ action = self.env["ir.actions.actions"]._for_xml_id("stock_landed_costs.action_stock_landed_cost")
+ domain = [('id', 'in', self.landed_costs_ids.ids)]
+ context = dict(self.env.context, default_vendor_bill_id=self.id)
+ views = [(self.env.ref('stock_landed_costs.view_stock_landed_cost_tree2').id, 'tree'), (False, 'form'), (False, 'kanban')]
+ return dict(action, domain=domain, context=context, views=views)
+
+
+class AccountMoveLine(models.Model):
+ _inherit = 'account.move.line'
+
+ product_type = fields.Selection(related='product_id.type', readonly=True)
+ is_landed_costs_line = fields.Boolean()
+
+ @api.onchange('is_landed_costs_line')
+ def _onchange_is_landed_costs_line(self):
+ """Mark an invoice line as a landed cost line and adapt `self.account_id`. The default
+ value can be set according to `self.product_id.landed_cost_ok`."""
+ if self.product_id:
+ accounts = self.product_id.product_tmpl_id._get_product_accounts()
+ if self.product_type != 'service':
+ self.account_id = accounts['expense']
+ self.is_landed_costs_line = False
+ elif self.is_landed_costs_line and self.move_id.company_id.anglo_saxon_accounting:
+ self.account_id = accounts['stock_input']
+ else:
+ self.account_id = accounts['expense']
+
+ @api.onchange('product_id')
+ def _onchange_is_landed_costs_line_product(self):
+ if self.product_id.landed_cost_ok:
+ self.is_landed_costs_line = True
+ else:
+ self.is_landed_costs_line = False
diff --git a/addons/stock_landed_costs/models/product.py b/addons/stock_landed_costs/models/product.py
new file mode 100644
index 00000000..9b78e7e9
--- /dev/null
+++ b/addons/stock_landed_costs/models/product.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+from odoo.addons.stock_landed_costs.models.stock_landed_cost import SPLIT_METHOD
+from odoo.exceptions import UserError
+from odoo import _
+
+
+class ProductTemplate(models.Model):
+ _inherit = "product.template"
+
+ landed_cost_ok = fields.Boolean('Is a Landed Cost', help='Indicates whether the product is a landed cost.')
+ split_method_landed_cost = fields.Selection(SPLIT_METHOD, string="Default Split Method",
+ help="Default Split Method when used for Landed Cost")
+
+ def write(self, vals):
+ for product in self:
+ if (('type' in vals and vals['type'] != 'service') or ('landed_cost_ok' in vals and not vals['landed_cost_ok'])) and product.type == 'service' and product.landed_cost_ok:
+ if self.env['account.move.line'].search_count([('product_id', 'in', product.product_variant_ids.ids), ('is_landed_costs_line', '=', True)]):
+ raise UserError(_("You cannot change the product type or disable landed cost option because the product is used in an account move line."))
+ vals['landed_cost_ok'] = False
+
+ return super().write(vals)
diff --git a/addons/stock_landed_costs/models/purchase.py b/addons/stock_landed_costs/models/purchase.py
new file mode 100644
index 00000000..369dea03
--- /dev/null
+++ b/addons/stock_landed_costs/models/purchase.py
@@ -0,0 +1,9 @@
+from odoo import api,models
+
+class PurchaseOrderLine(models.Model):
+ _inherit = 'purchase.order.line'
+
+ def _prepare_account_move_line(self, move=False):
+ res = super()._prepare_account_move_line(move)
+ res.update({'is_landed_costs_line': self.product_id.landed_cost_ok})
+ return res
diff --git a/addons/stock_landed_costs/models/res_company.py b/addons/stock_landed_costs/models/res_company.py
new file mode 100644
index 00000000..fe2a178a
--- /dev/null
+++ b/addons/stock_landed_costs/models/res_company.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class ResCompany(models.Model):
+ _inherit = 'res.company'
+
+ lc_journal_id = fields.Many2one('account.journal')
+
diff --git a/addons/stock_landed_costs/models/res_config_settings.py b/addons/stock_landed_costs/models/res_config_settings.py
new file mode 100644
index 00000000..e40f4294
--- /dev/null
+++ b/addons/stock_landed_costs/models/res_config_settings.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ lc_journal_id = fields.Many2one('account.journal', string='Default Journal', related='company_id.lc_journal_id', readonly=False)
+
diff --git a/addons/stock_landed_costs/models/stock_landed_cost.py b/addons/stock_landed_costs/models/stock_landed_cost.py
new file mode 100644
index 00000000..b12861e6
--- /dev/null
+++ b/addons/stock_landed_costs/models/stock_landed_cost.py
@@ -0,0 +1,474 @@
+# -*- 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']
+
+ 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, states={'done': [('readonly', True)]}, tracking=True)
+ target_model = fields.Selection(
+ [('picking', 'Transfers')], string="Apply On",
+ required=True, default='picking',
+ copy=False, states={'done': [('readonly', True)]})
+ picking_ids = fields.Many2many(
+ 'stock.picking', string='Transfers',
+ copy=False, states={'done': [('readonly', True)]})
+ allowed_picking_ids = fields.Many2many('stock.picking', compute='_compute_allowed_picking_ids')
+ cost_lines = fields.One2many(
+ 'stock.landed.cost.lines', 'cost_id', 'Cost Lines',
+ copy=True, states={'done': [('readonly', True)]})
+ valuation_adjustment_lines = fields.One2many(
+ 'stock.valuation.adjustment.lines', 'cost_id', 'Valuation Adjustments',
+ states={'done': [('readonly', True)]})
+ description = fields.Text(
+ 'Item Description', states={'done': [('readonly', True)]})
+ 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',
+ copy=False, readonly=True)
+ account_journal_id = fields.Many2one(
+ 'account.journal', 'Account Journal',
+ required=True, states={'done': [('readonly', 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.depends('company_id')
+ def _compute_allowed_picking_ids(self):
+ valued_picking_ids_per_company = defaultdict(list)
+ if self.company_id:
+ self.env.cr.execute("""SELECT sm.picking_id, sm.company_id
+ FROM stock_move AS sm
+ INNER JOIN stock_valuation_layer AS svl ON svl.stock_move_id = sm.id
+ WHERE sm.picking_id IS NOT NULL AND sm.company_id IN %s
+ GROUP BY sm.picking_id, sm.company_id""", [tuple(self.company_id.ids)])
+ for res in self.env.cr.fetchall():
+ valued_picking_ids_per_company[res[1]].append(res[0])
+ for cost in self:
+ cost.allowed_picking_ids = valued_picking_ids_per_company[cost.company_id.id]
+
+ @api.onchange('target_model')
+ def _onchange_target_model(self):
+ if self.target_model != 'picking':
+ self.picking_ids = False
+
+ @api.model
+ def create(self, vals):
+ if vals.get('name', _('New')) == _('New'):
+ vals['name'] = self.env['ir.sequence'].next_by_code('stock.landed.cost')
+ return super().create(vals)
+
+ 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.product_qty) * 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
+ product = line.move_id.product_id
+ if product.cost_method == 'average':
+ cost_to_add_byproduct[product] += cost_to_add
+ # `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.product_qty - remaining_qty
+ elif line.move_id._is_out():
+ qty_out = line.move_id.product_qty
+ 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())
+ 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.with_company(cost.company_id).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)]
+ move = move.create(move_vals)
+ cost.write({'state': 'done', 'account_move_id': move.id})
+ move._post()
+
+ 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.full_reconcile_id).reconcile()
+
+ return True
+
+ 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.valuation != 'real_time' or move.product_id.cost_method not in ('fifo', 'average') or move.state == 'cancel':
+ 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 automated inventory valuation and 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()
+
+ digits = self.env['decimal.precision'].precision_get('Product Price')
+ towrite_dict = {}
+ for cost in self.filtered(lambda cost: cost._get_targeted_move_ids()):
+ 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 += tools.float_round(former_cost, precision_digits=digits) if digits else 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 digits:
+ value = tools.float_round(value, precision_digits=digits, 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_lines
+
+ 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
diff --git a/addons/stock_landed_costs/models/stock_valuation_layer.py b/addons/stock_landed_costs/models/stock_valuation_layer.py
new file mode 100644
index 00000000..56e2e82b
--- /dev/null
+++ b/addons/stock_landed_costs/models/stock_valuation_layer.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class StockValuationLayer(models.Model):
+ """Stock Valuation Layer"""
+
+ _inherit = 'stock.valuation.layer'
+
+ stock_landed_cost_id = fields.Many2one('stock.landed.cost', 'Landed Cost')
+