[MERGE] stock_account, related addons: stock valuation layer
task-1875873 closes odoo/odoo#33935 Signed-off-by: Simon Lejeune (sle) <sle@openerp.com>
This commit is contained in:
@@ -1027,8 +1027,10 @@ class AccountInvoice(models.Model):
|
||||
if to_open_invoices.filtered(lambda inv: not inv.account_id):
|
||||
raise UserError(_('No account was found to create the invoice, be sure you have installed a chart of account.'))
|
||||
to_open_invoices.action_date_assign()
|
||||
to_open_invoices._set_name_and_date_invoice()
|
||||
res = to_open_invoices.action_move_create()
|
||||
to_open_invoices.invoice_validate()
|
||||
return to_open_invoices.action_move_create()
|
||||
return res
|
||||
|
||||
@api.multi
|
||||
def action_invoice_paid(self):
|
||||
@@ -1440,29 +1442,9 @@ class AccountInvoice(models.Model):
|
||||
|
||||
for invoice in self:
|
||||
vals = {'state': 'open'}
|
||||
if not invoice.date_invoice:
|
||||
vals['date_invoice'] = fields.Date.context_today(self)
|
||||
if not invoice.date_due:
|
||||
vals['date_due'] = vals.get('date_invoice', invoice.date_invoice)
|
||||
|
||||
if (invoice.move_name and invoice.move_name != '/'):
|
||||
new_name = invoice.move_name
|
||||
else:
|
||||
new_name = False
|
||||
journal = invoice.journal_id
|
||||
if journal.sequence_id:
|
||||
# If invoice is actually refund and journal has a refund_sequence then use that one or use the regular one
|
||||
sequence = journal.sequence_id
|
||||
if invoice.type in ['out_refund', 'in_refund'] and journal.refund_sequence:
|
||||
if not journal.refund_sequence_id:
|
||||
raise UserError(_('Please define a sequence for the credit notes'))
|
||||
sequence = journal.refund_sequence_id
|
||||
|
||||
new_name = sequence.with_context(ir_sequence_date=invoice.date or invoice.date_invoice).next_by_id()
|
||||
else:
|
||||
raise UserError(_('Please define a sequence on the journal.'))
|
||||
#give the invoice its number directly as it's needed in _get_computed_reference()
|
||||
invoice.number = new_name
|
||||
|
||||
# Auto-compute reference, if not already existing and if configured on company
|
||||
if not invoice.reference and invoice.type == 'out_invoice':
|
||||
@@ -1816,6 +1798,29 @@ class AccountInvoice(models.Model):
|
||||
('_onchange_journal_id', ['currency_id']),
|
||||
])
|
||||
|
||||
def _set_name_and_date_invoice(self):
|
||||
for invoice in self:
|
||||
if (invoice.move_name and invoice.move_name != '/'):
|
||||
new_name = invoice.move_name
|
||||
else:
|
||||
new_name = False
|
||||
journal = invoice.journal_id
|
||||
if journal.sequence_id:
|
||||
# If invoice is actually refund and journal has a refund_sequence then use that one or use the regular one
|
||||
sequence = journal.sequence_id
|
||||
if invoice.type in ['out_refund', 'in_refund'] and journal.refund_sequence:
|
||||
if not journal.refund_sequence_id:
|
||||
raise UserError(_('Please define a sequence for the credit notes'))
|
||||
sequence = journal.refund_sequence_id
|
||||
|
||||
new_name = sequence.with_context(ir_sequence_date=invoice.date or invoice.date_invoice).next_by_id()
|
||||
else:
|
||||
raise UserError(_('Please define a sequence on the journal.'))
|
||||
#give the invoice its number directly as it's needed in _get_computed_reference()
|
||||
invoice.number = new_name
|
||||
if not invoice.date_invoice:
|
||||
invoice.date_invoice = fields.Date.context_today(self)
|
||||
|
||||
|
||||
class AccountInvoiceLine(models.Model):
|
||||
_name = "account.invoice.line"
|
||||
|
||||
@@ -2,7 +2,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_product_product_account_user,product.product.account.user,product.model_product_product,group_account_user,1,0,0,0
|
||||
access_product_product_account_manager,product.product.account.manager,product.model_product_product,account.group_account_manager,1,1,1,1
|
||||
access_product_template_account_manager,product.template.account.manager,product.model_product_template,account.group_account_manager,1,1,1,1
|
||||
access_product_price_history_account_manager,product.price.history.account.manager,product.model_product_price_history,account.group_account_manager,1,1,1,1
|
||||
access_account_payment_term,account.payment.term,model_account_payment_term,account.group_account_user,1,0,0,0
|
||||
access_account_payment_term_line,account.payment.term.line,model_account_payment_term_line,account.group_account_user,1,0,0,0
|
||||
access_account_account_type_manager,account.account.type,model_account_account_type,account.group_account_manager,1,1,1,1
|
||||
|
||||
|
@@ -3,4 +3,3 @@ access_event_event_ticket_user,event.event.ticket.user,event_sale.model_event_ev
|
||||
access_event_event_ticket_admin,event.event.ticket.admin,event_sale.model_event_event_ticket,event.group_event_manager,1,1,1,1
|
||||
access_product_template_event_manager,product.template.event.manager,product.model_product_template,event.group_event_manager,1,1,1,1
|
||||
access_product_product_event_manager,product.product.event.manager,product.model_product_product,event.group_event_manager,1,1,1,1
|
||||
access_product_price_history_event_manager,product.price.history.event.manager,product.model_product_price_history,event.group_event_manager,1,1,1,1
|
||||
|
||||
|
@@ -24,7 +24,6 @@ access_stock_warehouse,stock.warehouse mrp_worker,stock.model_stock_warehouse,mr
|
||||
access_mrp_production_stock_worker,mrp.production stock_worker,model_mrp_production,stock.group_stock_user,1,0,0,0
|
||||
access_product_product_user,product.product user,product.model_product_product,mrp.group_mrp_user,1,0,0,0
|
||||
access_product_template_user,product.template user,product.model_product_template,mrp.group_mrp_user,1,0,0,0
|
||||
access_product_price_history_mrp_manager,product.price.history.mrp.manager,product.model_product_price_history,mrp.group_mrp_manager,1,1,1,1
|
||||
access_uom_uom_user,uom.uom user,uom.model_uom_uom,mrp.group_mrp_user,1,0,0,0
|
||||
access_product_supplierinfo_user,product.supplierinfo user,product.model_product_supplierinfo,mrp.group_mrp_user,1,1,1,1
|
||||
access_res_partner,res.partner,base.model_res_partner,mrp.group_mrp_user,1,0,0,0
|
||||
|
||||
|
@@ -21,7 +21,10 @@ If the automated inventory valuation is active, the necessary accounting entries
|
||||
'depends': ['mrp', 'stock_account'],
|
||||
"init_xml" : [],
|
||||
"demo_xml" : [],
|
||||
"data": ["views/product_views.xml"],
|
||||
"data": [
|
||||
"views/product_views.xml",
|
||||
"views/mrp_production_views.xml",
|
||||
],
|
||||
'installable': True,
|
||||
'auto_install': True,
|
||||
}
|
||||
|
||||
@@ -32,8 +32,7 @@ class MrpProduction(models.Model):
|
||||
if finished_move.product_id.cost_method in ('fifo', 'average'):
|
||||
qty_done = finished_move.product_uom._compute_quantity(finished_move.quantity_done, finished_move.product_id.uom_id)
|
||||
extra_cost = self.extra_cost * qty_done
|
||||
finished_move.price_unit = (sum([-m.value for m in consumed_moves]) + work_center_cost + extra_cost) / qty_done
|
||||
finished_move.value = sum([-m.value for m in consumed_moves]) + work_center_cost + extra_cost
|
||||
finished_move.price_unit = (sum([-m.stock_valuation_layer_ids.value for m in consumed_moves]) + work_center_cost + extra_cost) / qty_done
|
||||
return True
|
||||
|
||||
def _prepare_wc_analytic_line(self, wc_line):
|
||||
@@ -69,3 +68,10 @@ class MrpProduction(models.Model):
|
||||
res = super(MrpProduction, self).button_mark_done()
|
||||
self._costs_generate()
|
||||
return res
|
||||
|
||||
def action_view_stock_valuation_layers(self):
|
||||
self.ensure_one()
|
||||
domain = [('id', 'in', (self.move_raw_ids + self.move_finished_ids).stock_valuation_layer_ids.ids)]
|
||||
action = self.env.ref('stock_account.stock_valuation_layer_action').read()[0]
|
||||
return dict(action, domain=domain)
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class TestMrpAccount(common.TransactionCase):
|
||||
produce_wizard = produce_form.save()
|
||||
produce_wizard.do_produce()
|
||||
production_table.post_inventory()
|
||||
move_value = production_table.move_finished_ids.filtered(lambda x: x.state == "done").value
|
||||
move_value = production_table.move_finished_ids.filtered(lambda x: x.state == "done").stock_valuation_layer_ids.value
|
||||
|
||||
# 1 table head at 20 + 4 table leg at 15 + 4 bolt at 10 + 10 screw at 10 + 1*20 (extra cost)
|
||||
self.assertEqual(move_value, 141, 'Thing should have the correct price')
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<odoo>
|
||||
<record id="mrp_production_form_view_inherited" model="ir.ui.view">
|
||||
<field name="name">mrp.production.view.inherited</field>
|
||||
<field name="model">mrp.production</field>
|
||||
<field name="inherit_id" ref="mrp.mrp_production_form_view" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button string="Valuation" type="object"
|
||||
name="action_view_stock_valuation_layers"
|
||||
class="oe_stat_button" icon="fa-dollar" groups="stock.group_stock_manager"
|
||||
attrs="{'invisible': [('state', 'not in', ['done'])]}" />
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -16,7 +16,6 @@ access_product_product,product.product,product.model_product_product,group_pos_u
|
||||
access_product_template_pos_user,product.template pos user,product.model_product_template,group_pos_user,1,0,0,0
|
||||
access_product_product_pos_manager,product.product.pos manager,product.model_product_product,group_pos_manager,1,1,1,1
|
||||
access_product_template_pos_manager,product.template pos manager,product.model_product_template,group_pos_manager,1,1,1,1
|
||||
access_product_price_history_pos_manager,product.price.history.pos.manager,product.model_product_price_history,group_pos_manager,1,1,1,1
|
||||
access_account_bank_statement_line_manager,account.bank.statement.line manager,account.model_account_bank_statement_line,group_pos_manager,1,1,1,1
|
||||
access_product_product_supplierinfo_user,product.supplierinfo user,product.model_product_supplierinfo,group_pos_user,1,0,0,0
|
||||
access_product_product_packaging_user,product.packaging user,product.model_product_packaging,group_pos_user,1,0,0,0
|
||||
|
||||
|
@@ -63,23 +63,6 @@ class ProductCategory(models.Model):
|
||||
return self.create({'name': name}).name_get()[0]
|
||||
|
||||
|
||||
class ProductPriceHistory(models.Model):
|
||||
""" Keep track of the ``product.template`` standard prices as they are changed. """
|
||||
_name = 'product.price.history'
|
||||
_rec_name = 'datetime'
|
||||
_order = 'datetime desc'
|
||||
_description = 'Product Price List History'
|
||||
|
||||
def _get_default_company_id(self):
|
||||
return self._context.get('force_company', self.env.company.id)
|
||||
|
||||
company_id = fields.Many2one('res.company', string='Company',
|
||||
default=_get_default_company_id, required=True)
|
||||
product_id = fields.Many2one('product.product', 'Product', ondelete='cascade', required=True)
|
||||
datetime = fields.Datetime('Date', default=fields.Datetime.now)
|
||||
cost = fields.Float('Cost', digits=dp.get_precision('Product Price'))
|
||||
|
||||
|
||||
class ProductProduct(models.Model):
|
||||
_name = "product.product"
|
||||
_description = "Product"
|
||||
@@ -386,10 +369,6 @@ class ProductProduct(models.Model):
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
products = super(ProductProduct, self.with_context(create_product_product=True)).create(vals_list)
|
||||
for product, vals in zip(products, vals_list):
|
||||
# When a unique variant is created from tmpl then the standard price is set by _set_standard_price
|
||||
if not (self.env.context.get('create_from_tmpl') and len(product.product_tmpl_id.product_variant_ids) == 1):
|
||||
product._set_standard_price(vals.get('standard_price') or 0.0)
|
||||
# `_get_variant_id_for_combination` depends on existing variants
|
||||
self.clear_caches()
|
||||
self.env['product.template'].invalidate_cache(
|
||||
@@ -406,10 +385,7 @@ class ProductProduct(models.Model):
|
||||
|
||||
@api.multi
|
||||
def write(self, values):
|
||||
''' Store the standard price change in order to be able to retrieve the cost of a product for a given date'''
|
||||
res = super(ProductProduct, self).write(values)
|
||||
if 'standard_price' in values:
|
||||
self._set_standard_price(values['standard_price'])
|
||||
if 'attribute_value_ids' in values:
|
||||
# `_get_variant_id_for_combination` depends on `attribute_value_ids`
|
||||
self.clear_caches()
|
||||
@@ -686,31 +662,11 @@ class ProductProduct(models.Model):
|
||||
|
||||
return prices
|
||||
|
||||
|
||||
# compatibility to remove after v10 - DEPRECATED
|
||||
@api.multi
|
||||
def price_get(self, ptype='list_price'):
|
||||
return self.price_compute(ptype)
|
||||
|
||||
@api.multi
|
||||
def _set_standard_price(self, value):
|
||||
''' Store the standard price change in order to be able to retrieve the cost of a product for a given date'''
|
||||
PriceHistory = self.env['product.price.history']
|
||||
for product in self:
|
||||
PriceHistory.create({
|
||||
'product_id': product.id,
|
||||
'cost': value,
|
||||
'company_id': self._context.get('force_company', self.env.company.id),
|
||||
})
|
||||
|
||||
@api.multi
|
||||
def get_history_price(self, company_id, date=None):
|
||||
history = self.env['product.price.history'].search([
|
||||
('company_id', '=', company_id),
|
||||
('product_id', 'in', self.ids),
|
||||
('datetime', '<=', date or fields.Datetime.now())], order='datetime desc,id desc', limit=1)
|
||||
return history.cost or 0.0
|
||||
|
||||
@api.model
|
||||
def get_empty_list_help(self, help):
|
||||
self = self.with_context(
|
||||
|
||||
@@ -7,7 +7,6 @@ access_product_pricelist_user,product.pricelist.user,model_product_pricelist,bas
|
||||
access_product_pricelist_item_user,product.pricelist.item.user,model_product_pricelist_item,base.group_user,1,0,0,0
|
||||
access_product_pricelist_partner_manager,product.pricelist partner manager,model_product_pricelist,base.group_partner_manager,1,0,0,0
|
||||
access_product_product_employee,product.product employee,model_product_product,base.group_user,1,0,0,0
|
||||
access_product_price_history_employee,prices.history employee,model_product_price_history,base.group_user,1,0,0,0
|
||||
access_product_attribute,product.attribute,model_product_attribute,base.group_user,1,0,0,0
|
||||
access_product_attribute_value,product.attribute value,model_product_attribute_value,base.group_user,1,0,0,0
|
||||
access_product_product_attribute,product.template.attribute value,model_product_template_attribute_value,base.group_user,1,0,0,0
|
||||
@@ -20,7 +19,6 @@ access_product_supplierinfo_manager,product.supplierinfo.manager,model_product_s
|
||||
access_product_pricelist_manager,product.pricelist.manager,model_product_pricelist,base.group_system,1,1,1,1
|
||||
access_product_pricelist_item_manager,product.pricelist.item.manager,model_product_pricelist_item,base.group_system,1,1,1,1
|
||||
access_product_product_manager,product.product manager,model_product_product,base.group_system,1,1,1,1
|
||||
access_product_price_history_manager,prices.history manager,model_product_price_history,base.group_system,1,1,1,1
|
||||
access_product_attribute_manager,product.attribute manager,model_product_attribute,base.group_system,1,1,1,1
|
||||
access_product_attribute_value_manager,product.attribute value manager,model_product_attribute_value,base.group_system,1,1,1,1
|
||||
access_product_product_attribute_manager,product.template.attribute value manager,model_product_template_attribute_value,base.group_system,1,1,1,1
|
||||
|
||||
|
@@ -35,8 +35,6 @@ access_product_group_res_partner_purchase_manager,res_partner group_purchase_man
|
||||
access_product_pricelist_item_purchase_manager,product.pricelist.item purchase_manager,product.model_product_pricelist_item,purchase.group_purchase_manager,1,1,1,1
|
||||
access_account_account_purchase_manager,account.account purchase manager,account.model_account_account,purchase.group_purchase_manager,1,0,0,0
|
||||
access_account_journal_purchase_manager,account.journal purchase manager,account.model_account_journal,purchase.group_purchase_manager,1,0,0,0
|
||||
access_product_price_history_purchase_user,prices.history purchase user,product.model_product_price_history,purchase.group_purchase_user,1,0,0,0
|
||||
access_product_price_history_purchase_manager,prices.history purchase manager,product.model_product_price_history,purchase.group_purchase_manager,1,1,1,1
|
||||
access_purchase_bill_union,access_purchase_bill_union,model_purchase_bill_union,purchase.group_purchase_user,1,0,0,0
|
||||
access_report_purchase_order,purchase.stock.report,model_purchase_report,purchase.group_purchase_manager,1,0,0,0
|
||||
access_report_purchase_order_user,purchase.stock.report user,model_purchase_report,purchase.group_purchase_user,1,0,0,0
|
||||
access_report_purchase_order_user,purchase.stock.report user,model_purchase_report,purchase.group_purchase_user,1,0,0,0
|
||||
|
||||
|
@@ -107,7 +107,6 @@ class TestPurchaseSubcontractingFlows(TransactionCase):
|
||||
move1 = picking1.move_lines[0]
|
||||
move1.quantity_done = 1
|
||||
picking1.button_validate()
|
||||
self.assertEqual(mo.move_finished_ids.value, 60)
|
||||
# waiting forward port of 67ea26460ff27d6a
|
||||
# self.assertEqual(mo.move_finished_ids.product_id.stock_value, 60)
|
||||
self.assertEqual(mo.move_finished_ids.stock_valuation_layer_ids.value, 60)
|
||||
self.assertEqual(mo.move_finished_ids.product_id.value_svl, 60)
|
||||
|
||||
|
||||
@@ -75,12 +75,14 @@ class AccountInvoice(models.Model):
|
||||
# In case val_stock_move is a return move, its valuation entries have been made with the
|
||||
# currency rate corresponding to the original stock move
|
||||
valuation_date = val_stock_move.origin_returned_move_id.date or val_stock_move.date
|
||||
layers_qty = sum(val_stock_move.mapped('stock_valuation_layer_ids.quantity'))
|
||||
layers_values = sum(val_stock_move.mapped('stock_valuation_layer_ids.value'))
|
||||
valuation_price_unit_total += company_currency._convert(
|
||||
abs(val_stock_move.price_unit) * val_stock_move.product_qty,
|
||||
layers_values,
|
||||
inv.currency_id,
|
||||
company=inv.company_id, date=valuation_date, round=False,
|
||||
)
|
||||
valuation_total_qty += val_stock_move.product_qty
|
||||
valuation_total_qty += layers_qty
|
||||
|
||||
# in Stock Move, price unit is in company_currency
|
||||
valuation_price_unit = valuation_price_unit_total / valuation_total_qty
|
||||
@@ -101,7 +103,7 @@ class AccountInvoice(models.Model):
|
||||
tax_ids = []
|
||||
if line['tax_ids']:
|
||||
#line['tax_ids'] is -2many commands list for account.move.line
|
||||
tax_ids = [tax_data[id] for tax_data in self.env['account.move.line'].resolve_2many_commands('tax_ids', line['tax_ids'], ['id'])]
|
||||
tax_ids = [tax_data['id'] for tax_data in self.env['account.move.line'].resolve_2many_commands('tax_ids', line['tax_ids'], ['id'])]
|
||||
taxes = self.env['account.tax'].browse(tax_ids)
|
||||
price_unit = taxes.compute_all(price_unit, currency=inv.currency_id, quantity=1.0, is_refund=inv.type in ('in_refund', 'out_refund'))['total_excluded']
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ class PurchaseOrderLine(models.Model):
|
||||
if move.location_dest_id.usage == "supplier":
|
||||
if move.to_refund:
|
||||
total -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
|
||||
elif move.origin_returned_move_id._is_dropshipped() and not move._is_dropshipped_returned():
|
||||
elif move.origin_returned_move_id and move.origin_returned_move_id._is_dropshipped() and not move._is_dropshipped_returned():
|
||||
# Edge case: the dropship is returned to the stock, no to the supplier.
|
||||
# In this case, the received quantity on the PO is set although we didn't
|
||||
# receive the product physically in our stock. To avoid counting the
|
||||
|
||||
@@ -50,12 +50,12 @@ class StockMove(models.Model):
|
||||
return price_unit
|
||||
return super(StockMove, self)._get_price_unit()
|
||||
|
||||
def _generate_valuation_lines_data(self, partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id):
|
||||
def _generate_valuation_lines_data(self, partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id, description):
|
||||
""" Overridden from stock_account to support amount_currency on valuation lines generated from po
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
rslt = super(StockMove, self)._generate_valuation_lines_data(partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id)
|
||||
rslt = super(StockMove, self)._generate_valuation_lines_data(partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id, description)
|
||||
if self.purchase_line_id:
|
||||
purchase_currency = self.purchase_line_id.currency_id
|
||||
if purchase_currency != self.company_id.currency_id:
|
||||
|
||||
@@ -56,7 +56,7 @@ class TestFifoPrice(TestPurchase):
|
||||
# Check the standard price of the product (fifo icecream), that should have not changed
|
||||
# because the standard price is supposed to be updated only when goods are going out of the stock
|
||||
self.assertEquals(product_cable_management_box.standard_price, 70.0, 'Standard price should not have changed')
|
||||
self.assertEquals(product_cable_management_box.stock_value, 500.0, 'Wrong stock value')
|
||||
self.assertEquals(product_cable_management_box.value_svl, 500.0, 'Wrong stock value')
|
||||
|
||||
# I create a draft Purchase Order for second shipment for 30 kg at 80 euro
|
||||
purchase_order_2 = self.env['purchase.order'].create({
|
||||
@@ -80,7 +80,7 @@ class TestFifoPrice(TestPurchase):
|
||||
# Check the standard price of the product, that should have not changed because the
|
||||
# standard price is supposed to be updated only when goods are going out of the stock
|
||||
self.assertEquals(product_cable_management_box.standard_price, 70.0, 'Standard price as fifo price of second reception incorrect!')
|
||||
self.assertEquals(product_cable_management_box.stock_value, 2900.0, 'Stock valuation should be 2900')
|
||||
self.assertEquals(product_cable_management_box.value_svl, 2900.0, 'Stock valuation should be 2900')
|
||||
|
||||
# Let us send some goods
|
||||
outgoing_shipment = self.env['stock.picking'].create({
|
||||
@@ -104,7 +104,7 @@ class TestFifoPrice(TestPurchase):
|
||||
self.env['stock.immediate.transfer'].create({'pick_ids': [(4, outgoing_shipment.id)]}).process()
|
||||
|
||||
# Check stock value became 1600 .
|
||||
self.assertEqual(product_cable_management_box.stock_value, 1600.0, 'Stock valuation should be 1600')
|
||||
self.assertEqual(product_cable_management_box.value_svl, 1600.0, 'Stock valuation should be 1600')
|
||||
|
||||
# Do a delivery of an extra 500 g (delivery order)
|
||||
outgoing_shipment_uom = self.env['stock.picking'].create({
|
||||
@@ -128,7 +128,7 @@ class TestFifoPrice(TestPurchase):
|
||||
self.env['stock.immediate.transfer'].create({'pick_ids': [(4, outgoing_shipment_uom.id)]}).process()
|
||||
|
||||
# Check stock valuation and qty in stock
|
||||
self.assertEqual(product_cable_management_box.stock_value, 1560.0, 'Stock valuation should be 1560')
|
||||
self.assertEqual(product_cable_management_box.value_svl, 1560.0, 'Stock valuation should be 1560')
|
||||
self.assertEqual(product_cable_management_box.qty_available, 19.5, 'Should still have 19.5 in stock')
|
||||
|
||||
# We will temporarily change the currency rate on the sixth of June to have the same results all year
|
||||
@@ -313,8 +313,5 @@ class TestFifoPrice(TestPurchase):
|
||||
self.env['stock.immediate.transfer'].create({'pick_ids': [(4, picking.id)]}).process()
|
||||
|
||||
original_out_move = outgoing_shipment_neg.move_lines[0]
|
||||
outgoing_shipment_fifo_icecream_neg2 = outgoing_shipment_neg2.move_lines[0]
|
||||
original_out_move._fifo_vacuum()
|
||||
outgoing_shipment_fifo_icecream_neg2._fifo_vacuum()
|
||||
self.assertEquals(original_out_move.product_id.stock_value, 12000.0, 'Value of the move should be 2500')
|
||||
self.assertEquals(original_out_move.product_id.value_svl, 12000.0, 'Value of the move should be 12000')
|
||||
self.assertEquals(original_out_move.product_id.qty_available, 150.0, 'Qty available should be 150')
|
||||
|
||||
@@ -86,4 +86,4 @@ class TestFifoReturns(TestPurchase):
|
||||
|
||||
# After the return only 10 of the second purchase order should still be in stock as it applies fifo on the return too
|
||||
self.assertEqual(product_fiforet_icecream.qty_available, 10.0, 'Qty available should be 10.0')
|
||||
self.assertEqual(product_fiforet_icecream.stock_value, 800.0, 'Stock value should be 800')
|
||||
self.assertEqual(product_fiforet_icecream.value_svl, 800.0, 'Stock value should be 800')
|
||||
|
||||
@@ -87,10 +87,10 @@ class TestStockValuation(TransactionCase):
|
||||
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
|
||||
wizard.process()
|
||||
|
||||
# the unit price of the stock move has been updated to the latest value
|
||||
self.assertEquals(move1.price_unit, 200)
|
||||
# the unit price of the valuationlayer used the latest value
|
||||
self.assertEquals(move1.stock_valuation_layer_ids.unit_cost, 200)
|
||||
|
||||
self.assertEquals(self.product1.stock_value, 2000)
|
||||
self.assertEquals(self.product1.value_svl, 2000)
|
||||
|
||||
def test_standard_price_change_1(self):
|
||||
""" Confirm a purchase order and create the associated receipt, change the unit cost of the
|
||||
@@ -135,10 +135,10 @@ class TestStockValuation(TransactionCase):
|
||||
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
|
||||
wizard.process()
|
||||
|
||||
# the unit price of the stock move has been updated to the latest value
|
||||
self.assertEquals(move1.price_unit, 12)
|
||||
# the unit price of the valuation layer used the latest value
|
||||
self.assertEquals(move1.stock_valuation_layer_ids.unit_cost, 12)
|
||||
|
||||
self.assertEquals(self.product1.stock_value, 120)
|
||||
self.assertEquals(self.product1.value_svl, 120)
|
||||
|
||||
def test_change_currency_rate_average_1(self):
|
||||
""" Confirm a purchase order in another currency and create the associated receipt, change
|
||||
@@ -205,10 +205,10 @@ class TestStockValuation(TransactionCase):
|
||||
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
|
||||
wizard.process()
|
||||
|
||||
# the unit price of the stock move has been updated to the latest value
|
||||
self.assertAlmostEqual(move1.price_unit, price_unit_usd_new_rate)
|
||||
# the unit price of the valuation layer used the latest value
|
||||
self.assertAlmostEqual(move1.stock_valuation_layer_ids.unit_cost, price_unit_usd_new_rate)
|
||||
|
||||
self.assertAlmostEqual(self.product1.stock_value, price_unit_usd_new_rate * 10, delta=0.1)
|
||||
self.assertAlmostEqual(self.product1.value_svl, price_unit_usd_new_rate * 10, delta=0.1)
|
||||
|
||||
def test_extra_move_fifo_1(self):
|
||||
""" Check that the extra move when over processing a receipt is correctly merged back in
|
||||
@@ -241,8 +241,9 @@ class TestStockValuation(TransactionCase):
|
||||
# there should be only one move
|
||||
self.assertEqual(len(picking1.move_lines), 1)
|
||||
self.assertEqual(move1.price_unit, 100)
|
||||
self.assertEqual(move1.stock_valuation_layer_ids.unit_cost, 100)
|
||||
self.assertEqual(move1.product_qty, 15)
|
||||
self.assertEqual(self.product1.stock_value, 1500)
|
||||
self.assertEqual(self.product1.value_svl, 1500)
|
||||
|
||||
def test_backorder_fifo_1(self):
|
||||
""" Check that the backordered move when under processing a receipt correctly keep the
|
||||
@@ -291,6 +292,16 @@ class TestStockValuationWithCOA(AccountingTestCase):
|
||||
self.stock_location = self.env.ref('stock.stock_location_stock')
|
||||
self.partner_id = self.env.ref('base.res_partner_1')
|
||||
self.product1 = self.env.ref('product.product_product_8')
|
||||
|
||||
cat = self.env['product.category'].create({
|
||||
'name': 'cat',
|
||||
})
|
||||
self.product1 = self.env['product.product'].create({
|
||||
'name': 'product1',
|
||||
'type': 'product',
|
||||
'categ_id': cat.id,
|
||||
})
|
||||
|
||||
Account = self.env['account.account']
|
||||
self.usd_currency = self.env.ref('base.USD')
|
||||
self.eur_currency = self.env.ref('base.EUR')
|
||||
@@ -393,7 +404,7 @@ class TestStockValuationWithCOA(AccountingTestCase):
|
||||
invoice_po2.action_invoice_open()
|
||||
|
||||
# valuation of product1 should be 300
|
||||
self.assertEqual(self.product1.stock_value, 300)
|
||||
self.assertEqual(self.product1.value_svl, 300)
|
||||
|
||||
# return the second po
|
||||
stock_return_picking_form = Form(self.env['stock.return.picking']
|
||||
@@ -407,7 +418,7 @@ class TestStockValuationWithCOA(AccountingTestCase):
|
||||
return_pick.button_validate()
|
||||
|
||||
# valuation of product1 should be 200 as the first items will be sent out
|
||||
self.assertEqual(self.product1.stock_value, 200)
|
||||
self.assertEqual(self.product1.value_svl, 200)
|
||||
|
||||
# create a credit note for po2
|
||||
creditnote_po2 = self.env['account.invoice'].create({
|
||||
@@ -506,7 +517,8 @@ class TestStockValuationWithCOA(AccountingTestCase):
|
||||
|
||||
# valuation of product1 should be 15 as the tax with no account set
|
||||
# has gone to the stock account, and must be reflected in inventory valuation
|
||||
self.assertEqual(self.product1.stock_value, 150)
|
||||
self.assertEqual(self.product1.value_svl, 150)
|
||||
|
||||
def test_average_realtime_anglo_saxon_valuation_multicurrency_same_date(self):
|
||||
"""
|
||||
The PO and invoice are in the same foreign currency.
|
||||
@@ -623,12 +635,12 @@ class TestStockValuationWithCOA(AccountingTestCase):
|
||||
'property_stock_valuation_account_id': self.stock_valuation_account.id,
|
||||
'property_stock_journal': self.stock_journal.id,
|
||||
})
|
||||
product_standard = self.product1.product_tmpl_id.copy({
|
||||
product_standard = self.product1.copy({
|
||||
'categ_id': product_categ_standard.id,
|
||||
'name': 'Standard Val',
|
||||
'standard_price': 60,
|
||||
'property_account_creditor_price_difference': self.price_diff_account.id
|
||||
}).product_variant_id
|
||||
})
|
||||
|
||||
# SetUp currency and rates
|
||||
self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", (self.usd_currency.id, company.id))
|
||||
@@ -783,12 +795,12 @@ class TestStockValuationWithCOA(AccountingTestCase):
|
||||
date_delivery = '2019-01-08'
|
||||
date_invoice = '2019-01-16'
|
||||
|
||||
product_avg = self.product1.product_tmpl_id.copy({
|
||||
product_avg = self.product1.copy({
|
||||
'purchase_method': 'purchase',
|
||||
'name': 'AVG',
|
||||
'standard_price': 60,
|
||||
'property_account_creditor_price_difference': self.price_diff_account.id
|
||||
}).product_variant_id
|
||||
})
|
||||
product_avg.invoice_policy = 'order'
|
||||
|
||||
# SetUp currency and rates
|
||||
@@ -942,12 +954,12 @@ class TestStockValuationWithCOA(AccountingTestCase):
|
||||
|
||||
self.product1.categ_id.property_valuation = 'real_time'
|
||||
self.product1.categ_id.property_cost_method = 'average'
|
||||
product_avg = self.product1.product_tmpl_id.copy({
|
||||
product_avg = self.product1.copy({
|
||||
'purchase_method': 'purchase',
|
||||
'name': 'AVG',
|
||||
'standard_price': 60,
|
||||
'property_account_creditor_price_difference': self.price_diff_account.id
|
||||
}).product_variant_id
|
||||
})
|
||||
product_avg.invoice_policy = 'order'
|
||||
|
||||
# SetUp currency and rates
|
||||
|
||||
@@ -36,7 +36,6 @@ access_account_move_line_salesman,account.move.line invoice,account.model_accoun
|
||||
access_sale_order_invoicing_payments,sale.order,model_sale_order,account.group_account_invoice,1,1,0,0
|
||||
access_sale_order_line_invoicing_payments,sale.order.line,model_sale_order_line,account.group_account_invoice,1,1,0,0
|
||||
access_product_pricelist_item_sale_manager,product.pricelist.item salemanager,product.model_product_pricelist_item,sales_team.group_sale_manager,1,1,1,1
|
||||
access_product_price_history_salemanager,prices.history sale manager,product.model_product_price_history,sales_team.group_sale_manager,1,1,1,1
|
||||
access_product_template_sale_manager,product.template salemanager,model_product_template,sales_team.group_sale_manager,1,1,1,1
|
||||
access_product_product_sale_manager,product.product salemanager,model_product_product,sales_team.group_sale_manager,1,1,1,1
|
||||
access_product_attribute_sale_manager,product.attribute manager,product.model_product_attribute,sales_team.group_sale_manager,1,1,1,1
|
||||
|
||||
|
@@ -31,7 +31,6 @@ class TestSaleMrpProcurement(TransactionCase):
|
||||
product.categ_id = product_category_allproductssellable0
|
||||
product.list_price = 200.0
|
||||
product.name = 'Slider Mobile'
|
||||
product.standard_price = 189.0
|
||||
product.type = 'product'
|
||||
product.uom_id = uom_unit
|
||||
product.uom_po_id = uom_unit
|
||||
@@ -40,6 +39,10 @@ class TestSaleMrpProcurement(TransactionCase):
|
||||
product.route_ids.add(warehouse0.mto_pull_id.route_id)
|
||||
product_template_slidermobile0 = product.save()
|
||||
|
||||
std_price_wiz = Form(self.env['stock.change.standard.price'].with_context(active_id=product_template_slidermobile0.id, active_model='product.template'))
|
||||
std_price_wiz.new_price = 189
|
||||
std_price_wiz.save()
|
||||
|
||||
product_component = Form(self.env['product.product'])
|
||||
product_component.name = 'Battery'
|
||||
product_product_bettery = product_component.save()
|
||||
|
||||
@@ -60,8 +60,6 @@ access_stock_putaway_all,stock.putaway.rule all users,model_stock_putaway_rule,b
|
||||
access_stock_putaway_manager,stock.putaway.rule all managers,model_stock_putaway_rule,stock.group_stock_manager,1,1,1,1
|
||||
access_stock_removal_all,product.removal all users,model_product_removal,base.group_user,1,0,0,0
|
||||
access_stock_move_line_portal,stock.move.line portal,stock.model_stock_move_line,base.group_portal,0,0,0,0
|
||||
access_product_price_history_stock_user,prices.history stock user,product.model_product_price_history,stock.group_stock_user,1,0,0,0
|
||||
access_product_price_history_stock_manager,prices.history stock manager,product.model_product_price_history,stock.group_stock_manager,1,1,1,1
|
||||
access_barcode_nomenclature_stock_user,barcode.nomenclature.stock.user,barcodes.model_barcode_nomenclature,stock.group_stock_user,1,0,0,0
|
||||
access_barcode_nomenclature_stock_manager,barcode.nomenclature.stock.manager,barcodes.model_barcode_nomenclature,stock.group_stock_manager,1,1,1,1
|
||||
access_barcode_rule_stock_user,barcode.rule.stock.user,barcodes.model_barcode_rule,stock.group_stock_user,1,0,0,0
|
||||
|
||||
|
@@ -31,6 +31,7 @@ Dashboard / Reports for Warehouse Management includes:
|
||||
'views/res_config_settings_views.xml',
|
||||
'data/product_data.xml',
|
||||
'views/product_views.xml',
|
||||
'views/stock_valuation_layer_views.xml',
|
||||
],
|
||||
'test': [
|
||||
],
|
||||
|
||||
@@ -6,5 +6,10 @@ from . import account_invoice
|
||||
from . import account_move
|
||||
from . import account_reconciliation_widget
|
||||
from . import product
|
||||
from . import stock
|
||||
from . import stock_move
|
||||
from . import stock_inventory
|
||||
from . import stock_location
|
||||
from . import stock_move_line
|
||||
from . import stock_picking
|
||||
from . import stock_valuation_layer
|
||||
from . import res_config_settings
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import fields, models, _
|
||||
from odoo import fields, models
|
||||
|
||||
from odoo.tools.float_utils import float_is_zero
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
stock_move_id = fields.Many2one('stock.move', string='Stock Move')
|
||||
stock_valuation_layer_ids = fields.One2many('stock.valuation.layer', 'account_move_id', string='Stock Valuation Layer')
|
||||
|
||||
|
||||
@@ -15,6 +15,56 @@ class ProductTemplate(models.Model):
|
||||
cost_method = fields.Selection(related="categ_id.property_cost_method", readonly=True)
|
||||
valuation = fields.Selection(related="categ_id.property_valuation", readonly=True)
|
||||
|
||||
def write(self, vals):
|
||||
impacted_templates = {}
|
||||
move_vals_list = []
|
||||
Product = self.env['product.product']
|
||||
SVL = self.env['stock.valuation.layer']
|
||||
|
||||
if 'categ_id' in vals:
|
||||
# When a change of category implies a change of cost method, we empty out and replenish
|
||||
# the stock.
|
||||
new_product_category = self.env['product.category'].browse(vals.get('categ_id'))
|
||||
|
||||
for product_template in self:
|
||||
valuation_impacted = False
|
||||
if product_template.cost_method != new_product_category.property_cost_method:
|
||||
valuation_impacted = True
|
||||
if product_template.valuation != new_product_category.property_valuation:
|
||||
valuation_impacted = True
|
||||
if valuation_impacted is False:
|
||||
continue
|
||||
|
||||
# Empty out the stock with the current cost method.
|
||||
description = _("Due to a change of product category (from %s to %s), the costing method\
|
||||
has changed for product template %s: from %s to %s.") %\
|
||||
(product_template.categ_id.display_name, new_product_category.display_name, \
|
||||
product_template.display_name, product_template.cost_method, new_product_category.property_cost_method)
|
||||
out_svl_vals_list, products_orig_quantity_svl, products = Product\
|
||||
._svl_empty_stock(description, product_template=product_template)
|
||||
out_stock_valuation_layers = SVL.create(out_svl_vals_list)
|
||||
if product_template.valuation == 'real_time':
|
||||
move_vals_list += Product._svl_empty_stock_am(out_stock_valuation_layers)
|
||||
impacted_templates[product_template] = (products, description, products_orig_quantity_svl)
|
||||
|
||||
res = super(ProductTemplate, self).write(vals)
|
||||
|
||||
for product_template, (products, description, products_orig_quantity_svl) in impacted_templates.items():
|
||||
# Replenish the stock with the new cost method.
|
||||
in_svl_vals_list = products._svl_replenish_stock(description, products_orig_quantity_svl)
|
||||
in_stock_valuation_layers = SVL.create(in_svl_vals_list)
|
||||
if product_template.valuation == 'real_time':
|
||||
move_vals_list += Product._svl_replenish_stock_am(in_stock_valuation_layers)
|
||||
|
||||
# Create the account moves.
|
||||
if move_vals_list:
|
||||
account_moves = self.env['account.move'].create(move_vals_list)
|
||||
account_moves.post()
|
||||
return res
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Misc.
|
||||
# -------------------------------------------------------------------------
|
||||
def _is_cost_method_standard(self):
|
||||
return self.categ_id.property_cost_method == 'standard'
|
||||
|
||||
@@ -32,11 +82,6 @@ class ProductTemplate(models.Model):
|
||||
})
|
||||
return accounts
|
||||
|
||||
@api.multi
|
||||
def action_open_product_moves(self):
|
||||
# TODO: remove me in master
|
||||
pass
|
||||
|
||||
@api.multi
|
||||
def get_product_accounts(self, fiscal_pos=None):
|
||||
""" Add the stock journal related to product to the result of super()
|
||||
@@ -50,210 +95,401 @@ class ProductTemplate(models.Model):
|
||||
class ProductProduct(models.Model):
|
||||
_inherit = 'product.product'
|
||||
|
||||
stock_value_currency_id = fields.Many2one('res.currency', compute='_compute_stock_value_currency')
|
||||
stock_value = fields.Float(
|
||||
'Value', compute='_compute_stock_value')
|
||||
qty_at_date = fields.Float(
|
||||
'Quantity', compute='_compute_stock_value')
|
||||
stock_fifo_real_time_aml_ids = fields.Many2many(
|
||||
'account.move.line', compute='_compute_stock_value')
|
||||
stock_fifo_manual_move_ids = fields.Many2many(
|
||||
'stock.move', compute='_compute_stock_value')
|
||||
value_svl = fields.Float(compute='_compute_value_svl')
|
||||
quantity_svl = fields.Float(compute='_compute_value_svl')
|
||||
stock_valuation_layer_ids = fields.One2many('stock.valuation.layer', 'product_id')
|
||||
|
||||
@api.multi
|
||||
def do_change_standard_price(self, new_price, account_id):
|
||||
""" Changes the Standard Price of Product and creates an account move accordingly."""
|
||||
AccountMove = self.env['account.move']
|
||||
@api.depends('stock_valuation_layer_ids')
|
||||
def _compute_value_svl(self):
|
||||
"""Compute `value_svl` and `quantity_svl`."""
|
||||
domain = [
|
||||
('product_id', 'in', self.ids),
|
||||
('company_id', '=', self.env.user.company_id.id)
|
||||
]
|
||||
if self.env.context.get('to_date'):
|
||||
to_date = fields.Datetime.to_datetime(self.env.context['to_date'])
|
||||
domain.append(('create_date', '<=', to_date))
|
||||
groups = self.env['stock.valuation.layer'].read_group(domain, ['value:sum', 'quantity:sum'], ['product_id'])
|
||||
for group in groups:
|
||||
product = self.browse(group['product_id'][0])
|
||||
product.value_svl = group['value']
|
||||
product.quantity_svl = group['quantity']
|
||||
|
||||
quant_locs = self.env['stock.quant'].sudo().read_group([('product_id', 'in', self.ids)], ['location_id'], ['location_id'])
|
||||
quant_loc_ids = [loc['location_id'][0] for loc in quant_locs]
|
||||
locations = self.env['stock.location'].search([('usage', '=', 'internal'), ('company_id', '=', self.env.company.id), ('id', 'in', quant_loc_ids)])
|
||||
# -------------------------------------------------------------------------
|
||||
# SVL creation helpers
|
||||
# -------------------------------------------------------------------------
|
||||
def _prepare_in_svl_vals(self, quantity, unit_cost):
|
||||
"""Prepare the values for a stock valuation layer created by a receipt.
|
||||
|
||||
product_accounts = {product.id: product.product_tmpl_id.get_product_accounts() for product in self}
|
||||
|
||||
for location in locations:
|
||||
for product in self.with_context(location=location.id, compute_child=False).filtered(lambda r: r.valuation == 'real_time'):
|
||||
diff = product.standard_price - new_price
|
||||
if float_is_zero(diff, precision_rounding=product.currency_id.rounding):
|
||||
raise UserError(_("No difference between the standard price and the new price."))
|
||||
if not product_accounts[product.id].get('stock_valuation', False):
|
||||
raise UserError(_('You don\'t have any stock valuation account defined on your product category. You must define one before processing this operation.'))
|
||||
qty_available = product.qty_available
|
||||
if qty_available:
|
||||
# Accounting Entries
|
||||
if diff * qty_available > 0:
|
||||
debit_account_id = account_id
|
||||
credit_account_id = product_accounts[product.id]['stock_valuation'].id
|
||||
else:
|
||||
debit_account_id = product_accounts[product.id]['stock_valuation'].id
|
||||
credit_account_id = account_id
|
||||
|
||||
move_vals = {
|
||||
'journal_id': product_accounts[product.id]['stock_journal'].id,
|
||||
'company_id': location.company_id.id,
|
||||
'ref': product.default_code,
|
||||
'line_ids': [(0, 0, {
|
||||
'name': _('%s changed cost from %s to %s - %s') % (self.env.user.name, product.standard_price, new_price, product.display_name),
|
||||
'account_id': debit_account_id,
|
||||
'debit': abs(diff * qty_available),
|
||||
'credit': 0,
|
||||
'product_id': product.id,
|
||||
}), (0, 0, {
|
||||
'name': _('%s changed cost from %s to %s - %s') % (self.env.user.name, product.standard_price, new_price, product.display_name),
|
||||
'account_id': credit_account_id,
|
||||
'debit': 0,
|
||||
'credit': abs(diff * qty_available),
|
||||
'product_id': product.id,
|
||||
})],
|
||||
}
|
||||
move = AccountMove.create(move_vals)
|
||||
move.post()
|
||||
|
||||
self.write({'standard_price': new_price})
|
||||
return True
|
||||
|
||||
def _get_fifo_candidates_in_move(self):
|
||||
""" Find IN moves that can be used to value OUT moves.
|
||||
"""
|
||||
return self._get_fifo_candidates_in_move_with_company()
|
||||
|
||||
def _get_fifo_candidates_in_move_with_company(self, move_company_id=False):
|
||||
self.ensure_one()
|
||||
domain = [('product_id', '=', self.id), ('remaining_qty', '>', 0.0)] + self.env['stock.move']._get_in_base_domain(move_company_id)
|
||||
candidates = self.env['stock.move'].search(domain, order='date, id')
|
||||
return candidates
|
||||
|
||||
def _sum_remaining_values(self):
|
||||
StockMove = self.env['stock.move']
|
||||
domain = [('product_id', '=', self.id)] + StockMove._get_all_base_domain()
|
||||
moves = StockMove.search(domain)
|
||||
return sum(moves.mapped('remaining_value')), moves
|
||||
|
||||
@api.multi
|
||||
def _compute_stock_value_currency(self):
|
||||
currency_id = self.env.company.currency_id
|
||||
for product in self:
|
||||
product.stock_value_currency_id = currency_id
|
||||
|
||||
@api.multi
|
||||
@api.depends('stock_move_ids.product_qty', 'stock_move_ids.state', 'stock_move_ids.remaining_value', 'product_tmpl_id.cost_method', 'product_tmpl_id.standard_price', 'product_tmpl_id.categ_id.property_valuation')
|
||||
def _compute_stock_value(self):
|
||||
StockMove = self.env['stock.move']
|
||||
to_date = self.env.context.get('to_date')
|
||||
|
||||
real_time_product_ids = [product.id for product in self if product.product_tmpl_id.valuation == 'real_time']
|
||||
if real_time_product_ids:
|
||||
self.env['account.move.line'].check_access_rights('read')
|
||||
fifo_automated_values = {}
|
||||
query = """SELECT aml.product_id, aml.account_id, sum(aml.debit) - sum(aml.credit), sum(quantity), array_agg(aml.id)
|
||||
FROM account_move_line AS aml
|
||||
WHERE aml.product_id IN %%s AND aml.company_id=%%s %s
|
||||
GROUP BY aml.product_id, aml.account_id"""
|
||||
params = (tuple(real_time_product_ids), self.env.company.id)
|
||||
if to_date:
|
||||
query = query % ('AND aml.date <= %s',)
|
||||
params = params + (to_date,)
|
||||
else:
|
||||
query = query % ('',)
|
||||
self.env.cr.execute(query, params=params)
|
||||
|
||||
res = self.env.cr.fetchall()
|
||||
for row in res:
|
||||
fifo_automated_values[(row[0], row[1])] = (row[2], row[3], list(row[4]))
|
||||
|
||||
product_values = {product.id: 0 for product in self}
|
||||
product_move_ids = {product.id: [] for product in self}
|
||||
|
||||
if to_date:
|
||||
domain = [('product_id', 'in', self.ids), ('date', '<=', to_date)] + StockMove._get_all_base_domain()
|
||||
value_field_name = 'value'
|
||||
else:
|
||||
domain = [('product_id', 'in', self.ids)] + StockMove._get_all_base_domain()
|
||||
value_field_name = 'remaining_value'
|
||||
|
||||
StockMove.check_access_rights('read')
|
||||
query = StockMove._where_calc(domain)
|
||||
StockMove._apply_ir_rules(query, 'read')
|
||||
from_clause, where_clause, params = query.get_sql()
|
||||
query_str = """
|
||||
SELECT stock_move.product_id, SUM(COALESCE(stock_move.{}, 0.0)), ARRAY_AGG(stock_move.id)
|
||||
FROM {}
|
||||
WHERE {}
|
||||
GROUP BY stock_move.product_id
|
||||
""".format(value_field_name, from_clause, where_clause)
|
||||
self.env.cr.execute(query_str, params)
|
||||
for product_id, value, move_ids in self.env.cr.fetchall():
|
||||
product_values[product_id] = value
|
||||
product_move_ids[product_id] = move_ids
|
||||
|
||||
for product in self:
|
||||
if product.cost_method in ['standard', 'average']:
|
||||
qty_available = product.with_context(company_owned=True, owner_id=False).qty_available
|
||||
price_used = product.standard_price
|
||||
if to_date:
|
||||
price_used = product.get_history_price(
|
||||
self.env.company.id,
|
||||
date=to_date,
|
||||
)
|
||||
product.stock_value = price_used * qty_available
|
||||
product.qty_at_date = qty_available
|
||||
elif product.cost_method == 'fifo':
|
||||
if to_date:
|
||||
if product.product_tmpl_id.valuation == 'manual_periodic':
|
||||
product.stock_value = product_values[product.id]
|
||||
product.qty_at_date = product.with_context(company_owned=True, owner_id=False).qty_available
|
||||
product.stock_fifo_manual_move_ids = StockMove.browse(product_move_ids[product.id])
|
||||
elif product.product_tmpl_id.valuation == 'real_time':
|
||||
valuation_account_id = product.categ_id.property_stock_valuation_account_id.id
|
||||
value, quantity, aml_ids = fifo_automated_values.get((product.id, valuation_account_id)) or (0, 0, [])
|
||||
product.stock_value = value
|
||||
product.qty_at_date = quantity
|
||||
product.stock_fifo_real_time_aml_ids = self.env['account.move.line'].browse(aml_ids)
|
||||
else:
|
||||
product.stock_value = product_values[product.id]
|
||||
product.qty_at_date = product.with_context(company_owned=True, owner_id=False).qty_available
|
||||
if product.product_tmpl_id.valuation == 'manual_periodic':
|
||||
product.stock_fifo_manual_move_ids = StockMove.browse(product_move_ids[product.id])
|
||||
elif product.product_tmpl_id.valuation == 'real_time':
|
||||
valuation_account_id = product.categ_id.property_stock_valuation_account_id.id
|
||||
value, quantity, aml_ids = fifo_automated_values.get((product.id, valuation_account_id)) or (0, 0, [])
|
||||
product.stock_fifo_real_time_aml_ids = self.env['account.move.line'].browse(aml_ids)
|
||||
|
||||
def action_valuation_at_date_details(self):
|
||||
""" Returns an action with either a list view of all the valued stock moves of `self` if the
|
||||
valuation is set as manual or a list view of all the account move lines if the valuation is
|
||||
set as automated.
|
||||
:param quantity: the quantity to value, expressed in `self.uom_id`
|
||||
:param unit_cost: the unit cost to value `quantity`
|
||||
:return: values to use in a call to create
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
to_date = self.env.context.get('to_date')
|
||||
ctx = self.env.context.copy()
|
||||
ctx.pop('group_by', None)
|
||||
action = {
|
||||
'name': _('Valuation at date'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_type': 'form',
|
||||
'view_mode': 'tree,form',
|
||||
'context': ctx,
|
||||
vals = {
|
||||
'product_id': self.id,
|
||||
'value': unit_cost * quantity,
|
||||
'unit_cost': unit_cost,
|
||||
'quantity': quantity,
|
||||
}
|
||||
if self.valuation == 'real_time':
|
||||
action['res_model'] = 'account.move.line'
|
||||
action['domain'] = [('id', 'in', self.with_context(to_date=to_date).stock_fifo_real_time_aml_ids.ids)]
|
||||
tree_view_ref = self.env.ref('stock_account.view_stock_account_aml')
|
||||
form_view_ref = self.env.ref('account.view_move_line_form')
|
||||
action['views'] = [(tree_view_ref.id, 'tree'), (form_view_ref.id, 'form')]
|
||||
if self.cost_method in ('average', 'fifo'):
|
||||
vals['remaining_qty'] = quantity
|
||||
return vals
|
||||
|
||||
def _prepare_out_svl_vals(self, quantity, company):
|
||||
"""Prepare the values for a stock valuation layer created by a delivery.
|
||||
|
||||
:param quantity: the quantity to value, expressed in `self.uom_id`
|
||||
:return: values to use in a call to create
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
# Quantity is negative for out valuation layers.
|
||||
quantity = -1 * quantity
|
||||
vals = {
|
||||
'product_id' : self.id,
|
||||
'value': quantity * self.standard_price,
|
||||
'unit_cost': self.standard_price,
|
||||
'quantity': quantity,
|
||||
}
|
||||
if self.cost_method in ('average', 'fifo'):
|
||||
fifo_vals = self._run_fifo(abs(quantity), company)
|
||||
vals['remaining_qty'] = fifo_vals.get('remaining_qty')
|
||||
if self.cost_method == 'fifo':
|
||||
vals.update(fifo_vals)
|
||||
return vals
|
||||
|
||||
def _change_standard_price(self, new_price, counterpart_account_id=False):
|
||||
"""Helper to create the stock valuation layers and the account moves
|
||||
after an update of standard price.
|
||||
|
||||
:param new_price: new standard price
|
||||
"""
|
||||
# Handle stock valuation layers.
|
||||
svl_vals_list = []
|
||||
company_id = self.env.user.company_id
|
||||
for product in self:
|
||||
if product.cost_method not in ('standard', 'average'):
|
||||
continue
|
||||
if float_is_zero(product.quantity_svl, precision_rounding=product.uom_id.rounding):
|
||||
continue
|
||||
diff = new_price - product.standard_price
|
||||
value = company_id.currency_id.round(product.quantity_svl * diff)
|
||||
if company_id.currency_id.is_zero(value):
|
||||
continue
|
||||
|
||||
svl_vals = {
|
||||
'company_id': company_id.id,
|
||||
'product_id': product.id,
|
||||
'description': _('Product value manually modified (from %s to %s)') % (product.standard_price, new_price),
|
||||
'value': value,
|
||||
'quantity': 0,
|
||||
}
|
||||
svl_vals_list.append(svl_vals)
|
||||
stock_valuation_layers = self.env['stock.valuation.layer'].create(svl_vals_list)
|
||||
|
||||
# Handle account moves.
|
||||
product_accounts = {product.id: product.product_tmpl_id.get_product_accounts() for product in self}
|
||||
am_vals_list = []
|
||||
for stock_valuation_layer in stock_valuation_layers:
|
||||
product = stock_valuation_layer.product_id
|
||||
value = stock_valuation_layer.value
|
||||
|
||||
if product.valuation != 'real_time':
|
||||
continue
|
||||
|
||||
# Sanity check.
|
||||
if counterpart_account_id is False:
|
||||
raise UserError(_('You must set a counterpart account.'))
|
||||
if not product_accounts[product.id].get('stock_valuation'):
|
||||
raise UserError(_('You don\'t have any stock valuation account defined on your product category. You must define one before processing this operation.'))
|
||||
|
||||
if value < 0:
|
||||
debit_account_id = counterpart_account_id
|
||||
credit_account_id = product_accounts[product.id]['stock_valuation'].id
|
||||
else:
|
||||
debit_account_id = product_accounts[product.id]['stock_valuation'].id
|
||||
credit_account_id = counterpart_account_id
|
||||
|
||||
move_vals = {
|
||||
'journal_id': product_accounts[product.id]['stock_journal'].id,
|
||||
'company_id': company_id.id,
|
||||
'ref': product.default_code,
|
||||
'stock_valuation_layer_ids': [(6, None, [stock_valuation_layer.id])],
|
||||
'line_ids': [(0, 0, {
|
||||
'name': _('%s changed cost from %s to %s - %s') % (self.env.user.name, product.standard_price, new_price, product.display_name),
|
||||
'account_id': debit_account_id,
|
||||
'debit': abs(value),
|
||||
'credit': 0,
|
||||
'product_id': product.id,
|
||||
}), (0, 0, {
|
||||
'name': _('%s changed cost from %s to %s - %s') % (self.env.user.name, product.standard_price, new_price, product.display_name),
|
||||
'account_id': credit_account_id,
|
||||
'debit': 0,
|
||||
'credit': abs(value),
|
||||
'product_id': product.id,
|
||||
})],
|
||||
}
|
||||
am_vals_list.append(move_vals)
|
||||
account_moves = self.env['account.move'].create(am_vals_list)
|
||||
account_moves.post()
|
||||
|
||||
# Actually update the standard price.
|
||||
self.write({'standard_price': new_price})
|
||||
|
||||
def _run_fifo(self, quantity, company):
|
||||
self.ensure_one()
|
||||
|
||||
# Find back incoming stock valuation layers (called candidates here) to value `quantity`.
|
||||
qty_to_take_on_candidates = quantity
|
||||
candidates = self.env['stock.valuation.layer'].search([
|
||||
('product_id', '=', self.id),
|
||||
('remaining_qty', '>', 0),
|
||||
('company_id', '=', company.id),
|
||||
])
|
||||
new_standard_price = 0
|
||||
tmp_value = 0 # to accumulate the value taken on the candidates
|
||||
for candidate in candidates:
|
||||
new_standard_price = candidate.unit_cost
|
||||
qty_taken_on_candidate = min(qty_to_take_on_candidates, candidate.remaining_qty)
|
||||
|
||||
candidate_unit_cost = candidate.unit_cost
|
||||
if candidate.stock_valuation_layer_ids:
|
||||
candidate_unit_cost += sum(candidate.stock_valuation_layer_ids.mapped('value')) / candidate.remaining_qty
|
||||
|
||||
value_taken_on_candidate = qty_taken_on_candidate * candidate_unit_cost
|
||||
candidate_vals = {
|
||||
'remaining_qty': candidate.remaining_qty - qty_taken_on_candidate,
|
||||
}
|
||||
candidate.write(candidate_vals)
|
||||
|
||||
qty_to_take_on_candidates -= qty_taken_on_candidate
|
||||
tmp_value += value_taken_on_candidate
|
||||
if float_is_zero(qty_to_take_on_candidates, precision_rounding=self.uom_id.rounding):
|
||||
break
|
||||
|
||||
# Update the standard price with the price of the last used candidate, if any.
|
||||
if new_standard_price and self.cost_method == 'fifo':
|
||||
self.sudo().with_context(force_company=company.id).standard_price = new_standard_price
|
||||
|
||||
# If there's still quantity to value but we're out of candidates, we fall in the
|
||||
# negative stock use case. We chose to value the out move at the price of the
|
||||
# last out and a correction entry will be made once `_fifo_vacuum` is called.
|
||||
vals = {}
|
||||
if float_is_zero(qty_to_take_on_candidates, precision_rounding=self.uom_id.rounding):
|
||||
vals = {
|
||||
'value': -tmp_value,
|
||||
'unit_cost': tmp_value / quantity,
|
||||
}
|
||||
else:
|
||||
action['res_model'] = 'stock.move'
|
||||
action['domain'] = [('id', 'in', self.with_context(to_date=to_date).stock_fifo_manual_move_ids.ids)]
|
||||
tree_view_ref = self.env.ref('stock_account.view_move_tree_valuation_at_date')
|
||||
form_view_ref = self.env.ref('stock.view_move_form')
|
||||
action['views'] = [(tree_view_ref.id, 'tree'), (form_view_ref.id, 'form')]
|
||||
return action
|
||||
assert qty_to_take_on_candidates > 0
|
||||
last_fifo_price = new_standard_price or self.standard_price
|
||||
negative_stock_value = last_fifo_price * -qty_to_take_on_candidates
|
||||
tmp_value += abs(negative_stock_value)
|
||||
vals = {
|
||||
'remaining_qty': -qty_to_take_on_candidates,
|
||||
'value': -tmp_value,
|
||||
'unit_cost': last_fifo_price,
|
||||
}
|
||||
return vals
|
||||
|
||||
@api.multi
|
||||
def action_open_product_moves(self):
|
||||
#TODO: remove me in master
|
||||
pass
|
||||
def _run_fifo_vacuum(self):
|
||||
"""Compensate layer valued at an estimated price with the price of future receipts
|
||||
if any. If the estimated price is equals to the real price, no layer is created but
|
||||
the original layer is marked as compensated.
|
||||
"""
|
||||
self.ensure_one()
|
||||
svls_to_vacuum = self.env['stock.valuation.layer'].search([
|
||||
('product_id', '=', self.id),
|
||||
('remaining_qty', '<', 0),
|
||||
('stock_move_id', '!=', False),
|
||||
])
|
||||
for svl_to_vacuum in svls_to_vacuum:
|
||||
domain = [
|
||||
('company_id', '=', svl_to_vacuum.company_id.id),
|
||||
('product_id', '=', self.id),
|
||||
('remaining_qty', '>', 0),
|
||||
'|',
|
||||
('create_date', '>', svl_to_vacuum.create_date),
|
||||
'&',
|
||||
('create_date', '=', svl_to_vacuum.create_date),
|
||||
('id', '>', svl_to_vacuum.id)
|
||||
]
|
||||
candidates = self.env['stock.valuation.layer'].search(domain)
|
||||
if not candidates:
|
||||
continue
|
||||
qty_to_take_on_candidates = abs(svl_to_vacuum.remaining_qty)
|
||||
qty_taken_on_candidates = 0
|
||||
tmp_value = 0
|
||||
for candidate in candidates:
|
||||
qty_taken_on_candidate = min(candidate.remaining_qty, qty_to_take_on_candidates)
|
||||
qty_taken_on_candidates += qty_taken_on_candidate
|
||||
|
||||
value_taken_on_candidate = qty_taken_on_candidate * candidate.unit_cost
|
||||
candidate_vals = {
|
||||
'remaining_qty': candidate.remaining_qty - qty_taken_on_candidate,
|
||||
}
|
||||
candidate.write(candidate_vals)
|
||||
|
||||
qty_to_take_on_candidates -= qty_taken_on_candidate
|
||||
tmp_value += value_taken_on_candidate
|
||||
if float_is_zero(qty_to_take_on_candidates, precision_rounding=self.uom_id.rounding):
|
||||
break
|
||||
|
||||
# Get the estimated value we will correct.
|
||||
remaining_value_before_vacuum = svl_to_vacuum.unit_cost * qty_taken_on_candidates
|
||||
new_remaining_qty = svl_to_vacuum.remaining_qty + qty_taken_on_candidates
|
||||
corrected_value = remaining_value_before_vacuum - tmp_value
|
||||
svl_to_vacuum.write({
|
||||
'remaining_qty': new_remaining_qty,
|
||||
})
|
||||
|
||||
# Don't create a layer or an accounting entry if the corrected value is zero.
|
||||
if svl_to_vacuum.currency_id.is_zero(corrected_value):
|
||||
continue
|
||||
|
||||
corrected_value = svl_to_vacuum.currency_id.round(corrected_value)
|
||||
move = svl_to_vacuum.stock_move_id
|
||||
vals = {
|
||||
'product_id': self.id,
|
||||
'value': corrected_value,
|
||||
'unit_cost': 0,
|
||||
'quantity': 0,
|
||||
'remaining_qty': 0,
|
||||
'description': 'vacuum',
|
||||
'stock_move_id': move.id,
|
||||
'company_id': move.company_id.id,
|
||||
'description': 'Revaluation of %s (negative inventory)' % move.picking_id.name or move.name,
|
||||
}
|
||||
vacuum_svl = self.env['stock.valuation.layer'].create(vals)
|
||||
|
||||
# Create the account move.
|
||||
if self.valuation != 'real_time':
|
||||
continue
|
||||
vacuum_svl.stock_move_id._account_entry_move(
|
||||
vacuum_svl.quantity, vacuum_svl.description, vacuum_svl.id, vacuum_svl.value
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _svl_empty_stock(self, description, product_category=None, product_template=None):
|
||||
impacted_product_ids = []
|
||||
impacted_products = self.env['product.product']
|
||||
products_orig_quantity_svl = {}
|
||||
|
||||
# get the impacted products
|
||||
domain = [('type', '=', 'product')]
|
||||
if product_category is not None:
|
||||
domain += [('categ_id', '=', product_category.id)]
|
||||
elif product_template is not None:
|
||||
domain += [('product_tmpl_id', '=', product_template.id)]
|
||||
else:
|
||||
raise ValueError()
|
||||
products = self.env['product.product'].search_read(domain, ['quantity_svl'])
|
||||
for product in products:
|
||||
impacted_product_ids.append(product['id'])
|
||||
products_orig_quantity_svl[product['id']] = product['quantity_svl']
|
||||
impacted_products |= self.env['product.product'].browse(impacted_product_ids)
|
||||
|
||||
# empty out the stock for the impacted products
|
||||
empty_stock_svl_list = []
|
||||
for product in impacted_products:
|
||||
# FIXME sle: why not use products_orig_quantity_svl here?
|
||||
if float_is_zero(product.quantity_svl, precision_rounding=product.uom_id.rounding):
|
||||
# FIXME: create an empty layer to track the change?
|
||||
continue
|
||||
svsl_vals = product._prepare_out_svl_vals(product.quantity_svl, self.env.company)
|
||||
svsl_vals['description'] = description
|
||||
svsl_vals['company_id'] = self.env.company.id
|
||||
empty_stock_svl_list.append(svsl_vals)
|
||||
return empty_stock_svl_list, products_orig_quantity_svl, impacted_products
|
||||
|
||||
def _svl_replenish_stock(self, description, products_orig_quantity_svl):
|
||||
refill_stock_svl_list = []
|
||||
for product in self:
|
||||
quantity_svl = products_orig_quantity_svl[product.id]
|
||||
if quantity_svl:
|
||||
svl_vals = product._prepare_in_svl_vals(quantity_svl, product.standard_price)
|
||||
svl_vals['description'] = description
|
||||
svl_vals['company_id'] = self.env.company.id
|
||||
refill_stock_svl_list.append(svl_vals)
|
||||
return refill_stock_svl_list
|
||||
|
||||
@api.model
|
||||
def _svl_empty_stock_am(self, stock_valuation_layers):
|
||||
move_vals_list = []
|
||||
product_accounts = {product.id: product.product_tmpl_id.get_product_accounts() for product in stock_valuation_layers.mapped('product_id')}
|
||||
for out_stock_valuation_layer in stock_valuation_layers:
|
||||
product = out_stock_valuation_layer.product_id
|
||||
expense_account = product.property_account_expense_id or product.categ_id.property_account_expense_categ_id
|
||||
if not expense_account:
|
||||
raise UserError(_('Please define an expense account for this product: "%s" (id:%d) - or for its category: "%s".') % (product.name, product.id, self.name))
|
||||
if not product_accounts[product.id].get('stock_valuation'):
|
||||
raise UserError(_('You don\'t have any stock valuation account defined on your product category. You must define one before processing this operation.'))
|
||||
|
||||
debit_account_id = expense_account.id
|
||||
credit_account_id = product_accounts[product.id]['stock_valuation'].id
|
||||
value = out_stock_valuation_layer.value
|
||||
move_vals = {
|
||||
'journal_id': product_accounts[product.id]['stock_journal'].id,
|
||||
'company_id': self.env.company.id,
|
||||
'ref': product.default_code,
|
||||
'stock_valuation_layer_ids': [(6, None, [out_stock_valuation_layer.id])],
|
||||
'line_ids': [(0, 0, {
|
||||
'name': out_stock_valuation_layer.description,
|
||||
'account_id': debit_account_id,
|
||||
'debit': abs(value),
|
||||
'credit': 0,
|
||||
'product_id': product.id,
|
||||
}), (0, 0, {
|
||||
'name': out_stock_valuation_layer.description,
|
||||
'account_id': credit_account_id,
|
||||
'debit': 0,
|
||||
'credit': abs(value),
|
||||
'product_id': product.id,
|
||||
})],
|
||||
}
|
||||
move_vals_list.append(move_vals)
|
||||
return move_vals_list
|
||||
|
||||
def _svl_replenish_stock_am(self, stock_valuation_layers):
|
||||
move_vals_list = []
|
||||
product_accounts = {product.id: product.product_tmpl_id.get_product_accounts() for product in stock_valuation_layers.mapped('product_id')}
|
||||
for out_stock_valuation_layer in stock_valuation_layers:
|
||||
product = out_stock_valuation_layer.product_id
|
||||
if not product_accounts[product.id].get('stock_input'):
|
||||
raise UserError(_('You don\'t have any input valuation account defined on your product category. You must define one before processing this operation.'))
|
||||
if not product_accounts[product.id].get('stock_valuation'):
|
||||
raise UserError(_('You don\'t have any stock valuation account defined on your product category. You must define one before processing this operation.'))
|
||||
|
||||
debit_account_id = product_accounts[product.id]['stock_valuation'].id
|
||||
credit_account_id = product_accounts[product.id]['stock_input'].id
|
||||
value = out_stock_valuation_layer.value
|
||||
move_vals = {
|
||||
'journal_id': product_accounts[product.id]['stock_journal'].id,
|
||||
'company_id': self.env.company.id,
|
||||
'ref': product.default_code,
|
||||
'stock_valuation_layer_ids': [(6, None, [out_stock_valuation_layer.id])],
|
||||
'line_ids': [(0, 0, {
|
||||
'name': out_stock_valuation_layer.description,
|
||||
'account_id': debit_account_id,
|
||||
'debit': abs(value),
|
||||
'credit': 0,
|
||||
'product_id': product.id,
|
||||
}), (0, 0, {
|
||||
'name': out_stock_valuation_layer.description,
|
||||
'account_id': credit_account_id,
|
||||
'debit': 0,
|
||||
'credit': abs(value),
|
||||
'product_id': product.id,
|
||||
})],
|
||||
}
|
||||
move_vals_list.append(move_vals)
|
||||
return move_vals_list
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Anglo saxon helpers
|
||||
# -------------------------------------------------------------------------
|
||||
@api.model
|
||||
def _anglo_saxon_sale_move_lines(self, name, product, uom, qty, price_unit, currency=False, amount_currency=False, fiscal_position=False, account_analytic=False, analytic_tags=False):
|
||||
"""Prepare dicts describing new journal COGS journal items for a product sale.
|
||||
@@ -332,10 +568,15 @@ class ProductProduct(models.Model):
|
||||
qty_to_consider = invoiced_qty - qty_done
|
||||
qty_to_consider = min(qty_to_consider, quantity - qty_delivered)
|
||||
qty_delivered += qty_to_consider
|
||||
# `move.price_unit` is negative if the move is out and positive if the move is
|
||||
# dropshipped. Use its absolute value to compute the average price unit.
|
||||
if qty_delivered:
|
||||
average_price_unit = (average_price_unit * (qty_delivered - qty_to_consider) + abs(move.price_unit) * qty_to_consider) / qty_delivered
|
||||
layers = move.stock_valuation_layer_ids
|
||||
# Dropshipped moves are always linked to two layers cancelling each others.
|
||||
if move._is_dropshipped() or move._is_dropshipped_returned():
|
||||
layers = layers.filtered(lambda svl: svl.value > 0)
|
||||
layers_qty = sum(layers.mapped('quantity'))
|
||||
layers_value = sum(layers.mapped('value'))
|
||||
move_price_unit = layers_value / layers_qty
|
||||
average_price_unit = (average_price_unit * (qty_delivered - qty_to_consider) + move_price_unit * qty_to_consider) / qty_delivered
|
||||
if qty_delivered == quantity:
|
||||
break
|
||||
return average_price_unit
|
||||
@@ -388,14 +629,6 @@ class ProductCategory(models.Model):
|
||||
input_and_output_accounts = category.property_stock_account_input_categ_id | category.property_stock_account_output_categ_id
|
||||
if valuation_account and valuation_account in input_and_output_accounts:
|
||||
raise ValidationError(_('The Stock Input and/or Output accounts cannot be the same than the Stock Valuation account.'))
|
||||
@api.multi
|
||||
def write(self, vals):
|
||||
# When going from FIFO to AVCO or to standard, we update the standard price with the
|
||||
# average value in stock.
|
||||
cost_method = vals.get('property_cost_method')
|
||||
if cost_method and cost_method in ['average', 'standard']:
|
||||
self._update_standard_price()
|
||||
return super(ProductCategory, self).write(vals)
|
||||
|
||||
@api.onchange('property_cost_method')
|
||||
def onchange_property_valuation(self):
|
||||
@@ -409,11 +642,50 @@ class ProductCategory(models.Model):
|
||||
}
|
||||
}
|
||||
|
||||
def _update_standard_price(self):
|
||||
updated_categories = self.filtered(lambda x: x.property_cost_method == 'fifo')
|
||||
templates = self.env['product.template'].search([('categ_id', 'in', updated_categories.ids)])
|
||||
for t in templates:
|
||||
valuation = sum([variant._sum_remaining_values()[0] for variant in t.product_variant_ids])
|
||||
qty_available = t.with_context(company_owned=True).qty_available
|
||||
if qty_available:
|
||||
t.standard_price = valuation / qty_available
|
||||
def write(self, vals):
|
||||
impacted_categories = {}
|
||||
move_vals_list = []
|
||||
Product = self.env['product.product']
|
||||
SVL = self.env['stock.valuation.layer']
|
||||
|
||||
if 'property_cost_method' in vals or 'property_valuation' in vals:
|
||||
# When the cost method or the valuation are changed on a product category, we empty
|
||||
# out and replenish the stock for each impacted products.
|
||||
new_cost_method = vals.get('property_cost_method')
|
||||
new_valuation = vals.get('property_valuation')
|
||||
|
||||
|
||||
for product_category in self:
|
||||
valuation_impacted = False
|
||||
if new_cost_method and new_cost_method != product_category.property_cost_method:
|
||||
valuation_impacted = True
|
||||
if new_valuation and new_valuation != product_category.property_valuation:
|
||||
valuation_impacted = True
|
||||
if valuation_impacted is False:
|
||||
continue
|
||||
|
||||
# Empty out the stock with the current cost method.
|
||||
description = _("Costing method change for product category %s: from %s to %s.") \
|
||||
% (self.display_name, self.property_cost_method, new_cost_method)
|
||||
out_svl_vals_list, products_orig_quantity_svl, products = Product\
|
||||
._svl_empty_stock(description, product_category=product_category)
|
||||
out_stock_valuation_layers = SVL.create(out_svl_vals_list)
|
||||
if product_category.property_valuation == 'real_time':
|
||||
move_vals_list += Product._svl_empty_stock_am(out_stock_valuation_layers)
|
||||
impacted_categories[product_category] = (products, description, products_orig_quantity_svl)
|
||||
|
||||
res = super(ProductCategory, self).write(vals)
|
||||
|
||||
for product_category, (products, description, products_orig_quantity_svl) in impacted_categories.items():
|
||||
# Replenish the stock with the new cost method.
|
||||
in_svl_vals_list = products._svl_replenish_stock(description, products_orig_quantity_svl)
|
||||
in_stock_valuation_layers = SVL.create(in_svl_vals_list)
|
||||
if product_category.property_valuation == 'real_time':
|
||||
move_vals_list += Product._svl_replenish_stock_am(in_stock_valuation_layers)
|
||||
|
||||
# Create the account moves.
|
||||
if move_vals_list:
|
||||
account_moves = self.env['account.move'].create(move_vals_list)
|
||||
account_moves.post()
|
||||
return res
|
||||
|
||||
|
||||
@@ -1,769 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import float_compare, float_round, float_is_zero
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StockInventory(models.Model):
|
||||
_inherit = "stock.inventory"
|
||||
|
||||
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.")
|
||||
has_account_moves = fields.Boolean(compute='_compute_has_account_moves')
|
||||
|
||||
def _compute_has_account_moves(self):
|
||||
for inventory in self:
|
||||
if inventory.state == 'done' and inventory.move_ids:
|
||||
account_move = self.env['account.move'].search_count([
|
||||
('stock_move_id.id', 'in', inventory.move_ids.ids)
|
||||
])
|
||||
inventory.has_account_moves = account_move > 0
|
||||
else:
|
||||
inventory.has_account_moves = False
|
||||
|
||||
def action_get_account_moves(self):
|
||||
self.ensure_one()
|
||||
action_ref = self.env.ref('account.action_move_journal_line')
|
||||
if not action_ref:
|
||||
return False
|
||||
action_data = action_ref.read()[0]
|
||||
action_data['domain'] = [('stock_move_id.id', 'in', self.move_ids.ids)]
|
||||
return action_data
|
||||
|
||||
@api.multi
|
||||
def post_inventory(self):
|
||||
acc_inventories = self.filtered(lambda inventory: inventory.accounting_date)
|
||||
for inventory in acc_inventories:
|
||||
super(StockInventory, inventory.with_context(force_period_date=inventory.accounting_date)).post_inventory()
|
||||
other_inventories = self - acc_inventories
|
||||
if other_inventories:
|
||||
super(StockInventory, other_inventories).post_inventory()
|
||||
|
||||
|
||||
class StockLocation(models.Model):
|
||||
_inherit = "stock.location"
|
||||
|
||||
valuation_in_account_id = fields.Many2one(
|
||||
'account.account', 'Stock Valuation Account (Incoming)',
|
||||
domain=[('internal_type', '=', 'other'), ('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=[('internal_type', '=', 'other'), ('deprecated', '=', False)],
|
||||
help="Used for real-time inventory valuation. When set on a virtual location (non internal type), "
|
||||
"this account will be used to hold the value of products being moved out of this location "
|
||||
"and into an internal location, instead of the generic Stock Output Account set on the product. "
|
||||
"This has no effect for internal locations.")
|
||||
|
||||
def _should_be_valued(self):
|
||||
""" This method returns a boolean reflecting whether the products stored in `self` should
|
||||
be considered when valuating the stock of a company.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.usage == 'internal' or (self.usage == 'transit' and self.company_id):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class StockMoveLine(models.Model):
|
||||
_inherit = 'stock.move.line'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
lines = super(StockMoveLine, self).create(vals_list)
|
||||
for line in lines:
|
||||
move = line.move_id
|
||||
if move.state == 'done':
|
||||
correction_value = move._run_valuation(line.qty_done)
|
||||
if move.product_id.valuation == 'real_time' and (move._is_in() or move._is_out()):
|
||||
move.with_context(force_valuation_amount=correction_value)._account_entry_move()
|
||||
return lines
|
||||
|
||||
@api.multi
|
||||
def write(self, vals):
|
||||
""" When editing a done stock.move.line, we impact the valuation. Users may increase or
|
||||
decrease the `qty_done` field. There are three cost method available: standard, average
|
||||
and fifo. We implement the logic in a similar way for standard and average: increase
|
||||
or decrease the original value with the standard or average price of today. In fifo, we
|
||||
have a different logic wheter the move is incoming or outgoing. If the move is incoming, we
|
||||
update the value and remaining_value/qty with the unit price of the move. If the move is
|
||||
outgoing and the user increases qty_done, we call _run_fifo and it'll consume layer(s) in
|
||||
the stack the same way a new outgoing move would have done. If the move is outoing and the
|
||||
user decreases qty_done, we either increase the last receipt candidate if one is found or
|
||||
we decrease the value with the last fifo price.
|
||||
"""
|
||||
if 'qty_done' in vals:
|
||||
moves_to_update = {}
|
||||
for move_line in self.filtered(lambda ml: ml.state == 'done' and (ml.move_id._is_in() or ml.move_id._is_out())):
|
||||
moves_to_update[move_line.move_id] = vals['qty_done'] - move_line.qty_done
|
||||
|
||||
for move_id, qty_difference in moves_to_update.items():
|
||||
move_vals = {}
|
||||
if move_id.product_id.cost_method in ['standard', 'average']:
|
||||
correction_value = qty_difference * move_id.product_id.standard_price
|
||||
if move_id._is_in():
|
||||
move_vals['value'] = move_id.value + correction_value
|
||||
elif move_id._is_out():
|
||||
move_vals['value'] = move_id.value - correction_value
|
||||
else:
|
||||
if move_id._is_in():
|
||||
correction_value = qty_difference * move_id.price_unit
|
||||
new_remaining_value = move_id.remaining_value + correction_value
|
||||
move_vals['value'] = move_id.value + correction_value
|
||||
move_vals['remaining_qty'] = move_id.remaining_qty + qty_difference
|
||||
move_vals['remaining_value'] = move_id.remaining_value + correction_value
|
||||
elif move_id._is_out() and qty_difference > 0:
|
||||
correction_value = self.env['stock.move']._run_fifo(move_id, quantity=qty_difference)
|
||||
# no need to adapt `remaining_qty` and `remaining_value` as `_run_fifo` took care of it
|
||||
move_vals['value'] = move_id.value - correction_value
|
||||
elif move_id._is_out() and qty_difference < 0:
|
||||
candidates_receipt = self.env['stock.move'].search(move_id._get_in_domain(), order='date, id desc', limit=1)
|
||||
if candidates_receipt:
|
||||
candidates_receipt.write({
|
||||
'remaining_qty': candidates_receipt.remaining_qty + -qty_difference,
|
||||
'remaining_value': candidates_receipt.remaining_value + (-qty_difference * candidates_receipt.price_unit),
|
||||
})
|
||||
correction_value = qty_difference * candidates_receipt.price_unit
|
||||
else:
|
||||
correction_value = qty_difference * move_id.product_id.standard_price
|
||||
move_vals['value'] = move_id.value - correction_value
|
||||
move_id.write(move_vals)
|
||||
|
||||
if move_id.product_id.valuation == 'real_time':
|
||||
move_id.with_context(force_valuation_amount=correction_value, forced_quantity=qty_difference)._account_entry_move()
|
||||
if qty_difference > 0:
|
||||
move_id.product_price_update_before_done(forced_qty=qty_difference)
|
||||
return super(StockMoveLine, self).write(vals)
|
||||
|
||||
|
||||
class StockMove(models.Model):
|
||||
_inherit = "stock.move"
|
||||
|
||||
to_refund = fields.Boolean(string="Update quantities on SO/PO", copy=False,
|
||||
help='Trigger a decrease of the delivered/received quantity in the associated Sale Order/Purchase Order')
|
||||
value = fields.Float(copy=False)
|
||||
remaining_qty = fields.Float(copy=False)
|
||||
remaining_value = fields.Float(copy=False)
|
||||
account_move_ids = fields.One2many('account.move', 'stock_move_id')
|
||||
|
||||
@api.multi
|
||||
def action_get_account_moves(self):
|
||||
self.ensure_one()
|
||||
action_ref = self.env.ref('account.action_move_journal_line')
|
||||
if not action_ref:
|
||||
return False
|
||||
action_data = action_ref.read()[0]
|
||||
action_data['domain'] = [('id', 'in', self.account_move_ids.ids)]
|
||||
return action_data
|
||||
|
||||
def _get_price_unit(self):
|
||||
""" Returns the unit price to store on the quant """
|
||||
return not self.company_id.currency_id.is_zero(self.price_unit) and self.price_unit or self.product_id.standard_price
|
||||
|
||||
@api.model
|
||||
def _get_in_base_domain(self, company_id=False):
|
||||
# Domain:
|
||||
# - state is done
|
||||
# - coming from a location without company, or an inventory location within the same company
|
||||
# - going to a location within the same company
|
||||
domain = [
|
||||
('state', '=', 'done'),
|
||||
'&',
|
||||
'|',
|
||||
('location_id.company_id', '=', False),
|
||||
'&',
|
||||
('location_id.usage', 'in', ['inventory', 'production']),
|
||||
('location_id.company_id', '=', company_id or self.env.company.id),
|
||||
('location_dest_id.company_id', '=', company_id or self.env.company.id),
|
||||
]
|
||||
return domain
|
||||
|
||||
@api.model
|
||||
def _get_all_base_domain(self, company_id=False):
|
||||
# Domain:
|
||||
# - state is done
|
||||
# Then, either 'in' or 'out' moves.
|
||||
# 'in' moves:
|
||||
# - coming from a location without company, or an inventory location within the same company
|
||||
# - going to a location within the same company
|
||||
# 'out' moves:
|
||||
# - coming from to a location within the same company
|
||||
# - going to a location without company, or an inventory location within the same company
|
||||
domain = [
|
||||
('state', '=', 'done'),
|
||||
'|',
|
||||
'&',
|
||||
'|',
|
||||
('location_id.company_id', '=', False),
|
||||
'&',
|
||||
('location_id.usage', 'in', ['inventory', 'production']),
|
||||
('location_id.company_id', '=', company_id or self.env.company.id),
|
||||
('location_dest_id.company_id', '=', company_id or self.env.company.id),
|
||||
'&',
|
||||
('location_id.company_id', '=', company_id or self.env.company.id),
|
||||
'|',
|
||||
('location_dest_id.company_id', '=', False),
|
||||
'&',
|
||||
('location_dest_id.usage', '=', 'inventory'),
|
||||
('location_dest_id.company_id', '=', company_id or self.env.company.id),
|
||||
]
|
||||
return domain
|
||||
|
||||
def _get_in_domain(self):
|
||||
return [('product_id', '=', self.product_id.id)] + self._get_in_base_domain(company_id=self.company_id.id)
|
||||
|
||||
def _get_all_domain(self):
|
||||
return [('product_id', '=', self.product_id.id)] + self._get_all_base_domain(company_id=self.company_id.id)
|
||||
|
||||
def _is_in(self):
|
||||
""" Check if the move should be considered as entering the company so that the cost method
|
||||
will be able to apply the correct logic.
|
||||
|
||||
:return: True if the move is entering the company else False
|
||||
"""
|
||||
for move_line in self.move_line_ids.filtered(lambda ml: not ml.owner_id):
|
||||
if not move_line.location_id._should_be_valued() and move_line.location_dest_id._should_be_valued():
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_out(self):
|
||||
""" Check if the move should be considered as leaving the company so that the cost method
|
||||
will be able to apply the correct logic.
|
||||
|
||||
:return: True if the move is leaving the company else False
|
||||
"""
|
||||
for move_line in self.move_line_ids.filtered(lambda ml: not ml.owner_id):
|
||||
if move_line.location_id._should_be_valued() and not move_line.location_dest_id._should_be_valued():
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_dropshipped(self):
|
||||
""" Check if the move should be considered as a dropshipping move so that the cost method
|
||||
will be able to apply the correct logic.
|
||||
|
||||
:return: True if the move is a dropshipping one else False
|
||||
"""
|
||||
return self.location_id.usage == 'supplier' and self.location_dest_id.usage == 'customer'
|
||||
|
||||
def _is_dropshipped_returned(self):
|
||||
""" Check if the move should be considered as a returned dropshipping move so that the cost
|
||||
method will be able to apply the correct logic.
|
||||
|
||||
:return: True if the move is a returned dropshipping one else False
|
||||
"""
|
||||
return self.location_id.usage == 'customer' and self.location_dest_id.usage == 'supplier'
|
||||
|
||||
@api.model
|
||||
def _run_fifo(self, move, quantity=None):
|
||||
""" Value `move` according to the FIFO rule, meaning we consume the
|
||||
oldest receipt first. Candidates receipts are marked consumed or free
|
||||
thanks to their `remaining_qty` and `remaining_value` fields.
|
||||
By definition, `move` should be an outgoing stock move.
|
||||
|
||||
:param quantity: quantity to value instead of `move.product_qty`
|
||||
:returns: valued amount in absolute
|
||||
"""
|
||||
move.ensure_one()
|
||||
|
||||
# Deal with possible move lines that do not impact the valuation.
|
||||
valued_move_lines = move.move_line_ids.filtered(lambda ml: ml.location_id._should_be_valued() and not ml.location_dest_id._should_be_valued() and not ml.owner_id)
|
||||
valued_quantity = 0
|
||||
for valued_move_line in valued_move_lines:
|
||||
valued_quantity += valued_move_line.product_uom_id._compute_quantity(valued_move_line.qty_done, move.product_id.uom_id)
|
||||
|
||||
# Find back incoming stock moves (called candidates here) to value this move.
|
||||
qty_to_take_on_candidates = quantity or valued_quantity
|
||||
candidates = move.product_id._get_fifo_candidates_in_move_with_company(move.company_id.id)
|
||||
new_standard_price = 0
|
||||
tmp_value = 0 # to accumulate the value taken on the candidates
|
||||
for candidate in candidates:
|
||||
new_standard_price = candidate.price_unit
|
||||
if candidate.remaining_qty <= qty_to_take_on_candidates:
|
||||
qty_taken_on_candidate = candidate.remaining_qty
|
||||
else:
|
||||
qty_taken_on_candidate = qty_to_take_on_candidates
|
||||
|
||||
# As applying a landed cost do not update the unit price, naivelly doing
|
||||
# something like qty_taken_on_candidate * candidate.price_unit won't make
|
||||
# the additional value brought by the landed cost go away.
|
||||
candidate_price_unit = candidate.remaining_value / candidate.remaining_qty
|
||||
value_taken_on_candidate = qty_taken_on_candidate * candidate_price_unit
|
||||
candidate_vals = {
|
||||
'remaining_qty': candidate.remaining_qty - qty_taken_on_candidate,
|
||||
'remaining_value': candidate.remaining_value - value_taken_on_candidate,
|
||||
}
|
||||
candidate.write(candidate_vals)
|
||||
|
||||
qty_to_take_on_candidates -= qty_taken_on_candidate
|
||||
tmp_value += value_taken_on_candidate
|
||||
if qty_to_take_on_candidates == 0:
|
||||
break
|
||||
|
||||
# Update the standard price with the price of the last used candidate, if any.
|
||||
if new_standard_price and move.product_id.cost_method == 'fifo':
|
||||
move.product_id.sudo().with_context(force_company=move.company_id.id) \
|
||||
.standard_price = new_standard_price
|
||||
|
||||
# If there's still quantity to value but we're out of candidates, we fall in the
|
||||
# negative stock use case. We chose to value the out move at the price of the
|
||||
# last out and a correction entry will be made once `_fifo_vacuum` is called.
|
||||
if qty_to_take_on_candidates == 0:
|
||||
move.write({
|
||||
'value': -tmp_value if not quantity else move.value or -tmp_value, # outgoing move are valued negatively
|
||||
'price_unit': -tmp_value / (move.product_qty or quantity),
|
||||
})
|
||||
elif qty_to_take_on_candidates > 0:
|
||||
last_fifo_price = new_standard_price or move.product_id.standard_price
|
||||
negative_stock_value = last_fifo_price * -qty_to_take_on_candidates
|
||||
tmp_value += abs(negative_stock_value)
|
||||
vals = {
|
||||
'remaining_qty': move.remaining_qty + -qty_to_take_on_candidates,
|
||||
'remaining_value': move.remaining_value + negative_stock_value,
|
||||
'value': -tmp_value,
|
||||
'price_unit': -1 * last_fifo_price,
|
||||
}
|
||||
move.write(vals)
|
||||
return tmp_value
|
||||
|
||||
def _run_valuation(self, quantity=None):
|
||||
self.ensure_one()
|
||||
value_to_return = 0
|
||||
if self._is_in():
|
||||
valued_move_lines = self.move_line_ids.filtered(lambda ml: not ml.location_id._should_be_valued() and ml.location_dest_id._should_be_valued() and not ml.owner_id)
|
||||
valued_quantity = 0
|
||||
for valued_move_line in valued_move_lines:
|
||||
valued_quantity += valued_move_line.product_uom_id._compute_quantity(valued_move_line.qty_done, self.product_id.uom_id)
|
||||
|
||||
# Note: we always compute the fifo `remaining_value` and `remaining_qty` fields no
|
||||
# matter which cost method is set, to ease the switching of cost method.
|
||||
vals = {}
|
||||
price_unit = self._get_price_unit()
|
||||
value = price_unit * (quantity or valued_quantity)
|
||||
value_to_return = value if quantity is None or not self.value else self.value
|
||||
vals = {
|
||||
'price_unit': price_unit,
|
||||
'value': value_to_return,
|
||||
'remaining_value': value if quantity is None else self.remaining_value + value,
|
||||
}
|
||||
vals['remaining_qty'] = valued_quantity if quantity is None else self.remaining_qty + quantity
|
||||
|
||||
if self.product_id.cost_method == 'standard':
|
||||
value = self.product_id.standard_price * (quantity or valued_quantity)
|
||||
value_to_return = value if quantity is None or not self.value else self.value
|
||||
vals.update({
|
||||
'price_unit': self.product_id.standard_price,
|
||||
'value': value_to_return,
|
||||
})
|
||||
self.write(vals)
|
||||
elif self._is_out():
|
||||
valued_move_lines = self.move_line_ids.filtered(lambda ml: ml.location_id._should_be_valued() and not ml.location_dest_id._should_be_valued() and not ml.owner_id)
|
||||
valued_quantity = 0
|
||||
for valued_move_line in valued_move_lines:
|
||||
valued_quantity += valued_move_line.product_uom_id._compute_quantity(valued_move_line.qty_done, self.product_id.uom_id)
|
||||
self.env['stock.move']._run_fifo(self, quantity=quantity)
|
||||
if self.product_id.cost_method in ['standard', 'average']:
|
||||
curr_rounding = self.company_id.currency_id.rounding
|
||||
value = -float_round(self.product_id.standard_price * (valued_quantity if quantity is None else quantity), precision_rounding=curr_rounding)
|
||||
value_to_return = value if quantity is None else self.value + value
|
||||
self.write({
|
||||
'value': value_to_return,
|
||||
'price_unit': value / valued_quantity,
|
||||
})
|
||||
elif self._is_dropshipped() or self._is_dropshipped_returned():
|
||||
curr_rounding = self.company_id.currency_id.rounding
|
||||
if self.product_id.cost_method in ['fifo']:
|
||||
price_unit = self._get_price_unit()
|
||||
# see test_dropship_fifo_perpetual_anglosaxon_ordered
|
||||
self.product_id.standard_price = price_unit
|
||||
else:
|
||||
price_unit = self.product_id.standard_price
|
||||
value = float_round(self.product_qty * price_unit, precision_rounding=curr_rounding)
|
||||
value_to_return = value if self._is_dropshipped() else -value
|
||||
# In move have a positive value, out move have a negative value, let's arbitrary say
|
||||
# dropship are positive.
|
||||
self.write({
|
||||
'value': value_to_return,
|
||||
'price_unit': price_unit if self._is_dropshipped() else -price_unit,
|
||||
})
|
||||
return value_to_return
|
||||
|
||||
def _action_done(self, cancel_backorder=False):
|
||||
self.product_price_update_before_done()
|
||||
res = super(StockMove, self)._action_done(cancel_backorder=cancel_backorder)
|
||||
for move in res:
|
||||
# Apply restrictions on the stock move to be able to make
|
||||
# consistent accounting entries.
|
||||
if move._is_in() and move._is_out():
|
||||
raise UserError(_("The move lines are not in a consistent state: some are entering and other are leaving the company."))
|
||||
company_src = move.mapped('move_line_ids.location_id.company_id')
|
||||
company_dst = move.mapped('move_line_ids.location_dest_id.company_id')
|
||||
try:
|
||||
if company_src:
|
||||
company_src.ensure_one()
|
||||
if company_dst:
|
||||
company_dst.ensure_one()
|
||||
except ValueError:
|
||||
raise UserError(_("The move lines are not in a consistent states: they do not share the same origin or destination company."))
|
||||
if company_src and company_dst and company_src.id != company_dst.id:
|
||||
raise UserError(_("The move lines are not in a consistent states: they are doing an intercompany in a single step while they should go through the intercompany transit location."))
|
||||
move._run_valuation()
|
||||
for move in res.filtered(lambda m: m.product_id.valuation == 'real_time' and (m._is_in() or m._is_out() or m._is_dropshipped() or m._is_dropshipped_returned())):
|
||||
move._account_entry_move()
|
||||
return res
|
||||
|
||||
@api.multi
|
||||
def product_price_update_before_done(self, forced_qty=None):
|
||||
tmpl_dict = defaultdict(lambda: 0.0)
|
||||
# adapt standard price on incomming moves if the product cost_method is 'average'
|
||||
std_price_update = {}
|
||||
for move in self.filtered(lambda move: move._is_in() and move.product_id.cost_method == 'average'):
|
||||
product_tot_qty_available = move.product_id.qty_available + tmpl_dict[move.product_id.id]
|
||||
rounding = move.product_id.uom_id.rounding
|
||||
|
||||
qty_done = move.product_uom._compute_quantity(move.quantity_done, move.product_id.uom_id)
|
||||
if float_is_zero(product_tot_qty_available, precision_rounding=rounding):
|
||||
new_std_price = move._get_price_unit()
|
||||
elif float_is_zero(product_tot_qty_available + move.product_qty, precision_rounding=rounding) or \
|
||||
float_is_zero(product_tot_qty_available + qty_done, precision_rounding=rounding):
|
||||
new_std_price = move._get_price_unit()
|
||||
else:
|
||||
# Get the standard price
|
||||
amount_unit = std_price_update.get((move.company_id.id, move.product_id.id)) or move.product_id.standard_price
|
||||
qty = forced_qty or qty_done
|
||||
new_std_price = ((amount_unit * product_tot_qty_available) + (move._get_price_unit() * qty)) / (product_tot_qty_available + qty)
|
||||
|
||||
tmpl_dict[move.product_id.id] += qty_done
|
||||
# Write the standard price, as SUPERUSER_ID because a warehouse manager may not have the right to write on products
|
||||
move.product_id.with_context(force_company=move.company_id.id).sudo().write({'standard_price': new_std_price})
|
||||
std_price_update[move.company_id.id, move.product_id.id] = new_std_price
|
||||
|
||||
@api.model
|
||||
def _fifo_vacuum(self):
|
||||
""" Every moves that need to be fixed are identifiable by having a negative `remaining_qty`.
|
||||
"""
|
||||
for move in self.filtered(lambda m: (m._is_in() or m._is_out()) and m.remaining_qty < 0):
|
||||
domain = [
|
||||
('remaining_qty', '>', 0),
|
||||
'|',
|
||||
('date', '>', move.date),
|
||||
'&',
|
||||
('date', '=', move.date),
|
||||
('id', '>', move.id)
|
||||
]
|
||||
domain += move._get_in_domain()
|
||||
candidates = self.search(domain, order='date, id')
|
||||
if not candidates:
|
||||
continue
|
||||
qty_to_take_on_candidates = abs(move.remaining_qty)
|
||||
qty_taken_on_candidates = 0
|
||||
tmp_value = 0
|
||||
for candidate in candidates:
|
||||
if candidate.remaining_qty <= qty_to_take_on_candidates:
|
||||
qty_taken_on_candidate = candidate.remaining_qty
|
||||
else:
|
||||
qty_taken_on_candidate = qty_to_take_on_candidates
|
||||
qty_taken_on_candidates += qty_taken_on_candidate
|
||||
|
||||
value_taken_on_candidate = qty_taken_on_candidate * candidate.price_unit
|
||||
candidate_vals = {
|
||||
'remaining_qty': candidate.remaining_qty - qty_taken_on_candidate,
|
||||
'remaining_value': candidate.remaining_value - value_taken_on_candidate,
|
||||
}
|
||||
candidate.write(candidate_vals)
|
||||
|
||||
qty_to_take_on_candidates -= qty_taken_on_candidate
|
||||
tmp_value += value_taken_on_candidate
|
||||
if qty_to_take_on_candidates == 0:
|
||||
break
|
||||
|
||||
|
||||
# When working with `price_unit`, beware that out move are negative.
|
||||
move_price_unit = move.price_unit if move._is_out() else -1 * move.price_unit
|
||||
# Get the estimated value we will correct.
|
||||
remaining_value_before_vacuum = qty_taken_on_candidates * move_price_unit
|
||||
new_remaining_qty = move.remaining_qty + qty_taken_on_candidates
|
||||
new_remaining_value = new_remaining_qty * abs(move.price_unit)
|
||||
|
||||
corrected_value = remaining_value_before_vacuum + tmp_value
|
||||
move.write({
|
||||
'remaining_value': new_remaining_value,
|
||||
'remaining_qty': new_remaining_qty,
|
||||
'value': move.value - corrected_value,
|
||||
})
|
||||
|
||||
if move.product_id.valuation == 'real_time':
|
||||
# If `corrected_value` is 0, absolutely do *not* call `_account_entry_move`. We
|
||||
# force the amount in the context, but in the case it is 0 it'll create an entry
|
||||
# for the entire cost of the move. This case happens when the candidates moves
|
||||
# entirely compensate the problematic move.
|
||||
if move.company_id.currency_id.is_zero(corrected_value):
|
||||
continue
|
||||
|
||||
if move._is_in():
|
||||
# If we just compensated an IN move that has a negative remaining
|
||||
# quantity, it means the move has returned more items than it received.
|
||||
# The correction should behave as a return too. As `_account_entry_move`
|
||||
# will post the natural values for an IN move (credit IN account, debit
|
||||
# OUT one), we inverse the sign to create the correct entries.
|
||||
move.with_context(force_valuation_amount=-corrected_value, forced_quantity=0)._account_entry_move()
|
||||
else:
|
||||
move.with_context(force_valuation_amount=corrected_value, forced_quantity=0)._account_entry_move()
|
||||
|
||||
@api.model
|
||||
def _run_fifo_vacuum(self):
|
||||
# Call `_fifo_vacuum` on concerned moves
|
||||
fifo_valued_products = self.env['product.product']
|
||||
fifo_valued_categories = self.env['product.category'].search([('property_cost_method', '=', 'fifo')])
|
||||
fifo_valued_products |= self.env['product.product'].search([('categ_id', 'child_of', fifo_valued_categories.ids)])
|
||||
moves_to_vacuum = self.search(
|
||||
[('product_id', 'in', fifo_valued_products.ids), ('remaining_qty', '<', 0)] + self._get_all_base_domain())
|
||||
moves_to_vacuum._fifo_vacuum()
|
||||
|
||||
@api.multi
|
||||
def _get_accounting_data_for_valuation(self):
|
||||
""" Return the accounts and journal to use to post Journal Entries for
|
||||
the real-time valuation of the quant. """
|
||||
self.ensure_one()
|
||||
accounts_data = self.product_id.product_tmpl_id.get_product_accounts()
|
||||
|
||||
if self.location_id.valuation_out_account_id:
|
||||
acc_src = self.location_id.valuation_out_account_id.id
|
||||
else:
|
||||
acc_src = accounts_data['stock_input'].id
|
||||
|
||||
if self.location_dest_id.valuation_in_account_id:
|
||||
acc_dest = self.location_dest_id.valuation_in_account_id.id
|
||||
else:
|
||||
acc_dest = accounts_data['stock_output'].id
|
||||
|
||||
acc_valuation = accounts_data.get('stock_valuation', False)
|
||||
if acc_valuation:
|
||||
acc_valuation = acc_valuation.id
|
||||
if not accounts_data.get('stock_journal', False):
|
||||
raise UserError(_('You don\'t have any stock journal defined on your product category, check if you have installed a chart of accounts.'))
|
||||
if not acc_src:
|
||||
raise UserError(_('Cannot find a stock input account for the product %s. You must define one on the product category, or on the location, before processing this operation.') % (self.product_id.display_name))
|
||||
if not acc_dest:
|
||||
raise UserError(_('Cannot find a stock output account for the product %s. You must define one on the product category, or on the location, before processing this operation.') % (self.product_id.display_name))
|
||||
if not acc_valuation:
|
||||
raise UserError(_('You don\'t have any stock valuation account defined on your product category. You must define one before processing this operation.'))
|
||||
journal_id = accounts_data['stock_journal'].id
|
||||
return journal_id, acc_src, acc_dest, acc_valuation
|
||||
|
||||
def _prepare_account_move_line(self, qty, cost, credit_account_id, debit_account_id):
|
||||
"""
|
||||
Generate the account.move.line values to post to track the stock valuation difference due to the
|
||||
processing of the given quant.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if self._context.get('force_valuation_amount'):
|
||||
valuation_amount = self._context.get('force_valuation_amount')
|
||||
else:
|
||||
valuation_amount = cost
|
||||
|
||||
# the standard_price of the product may be in another decimal precision, or not compatible with the coinage of
|
||||
# the company currency... so we need to use round() before creating the accounting entries.
|
||||
debit_value = self.company_id.currency_id.round(valuation_amount)
|
||||
|
||||
# check that all data is correct
|
||||
if self.company_id.currency_id.is_zero(debit_value) and not self.env['ir.config_parameter'].sudo().get_param('stock_account.allow_zero_cost'):
|
||||
raise UserError(_("The cost of %s is currently equal to 0. Change the cost or the configuration of your product to avoid an incorrect valuation.") % (self.product_id.display_name,))
|
||||
credit_value = debit_value
|
||||
|
||||
|
||||
valuation_partner_id = self._get_partner_id_for_valuation_lines()
|
||||
res = [(0, 0, line_vals) for line_vals in self._generate_valuation_lines_data(valuation_partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id).values()]
|
||||
|
||||
return res
|
||||
|
||||
def _generate_valuation_lines_data(self, partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id):
|
||||
# This method returns a dictonary to provide an easy extension hook to modify the valuation lines (see purchase for an example)
|
||||
self.ensure_one()
|
||||
|
||||
if self._context.get('forced_ref'):
|
||||
ref = self._context['forced_ref']
|
||||
else:
|
||||
ref = self.picking_id.name
|
||||
|
||||
debit_line_vals = {
|
||||
'name': self.name,
|
||||
'product_id': self.product_id.id,
|
||||
'quantity': qty,
|
||||
'product_uom_id': self.product_id.uom_id.id,
|
||||
'ref': ref,
|
||||
'partner_id': partner_id,
|
||||
'debit': debit_value if debit_value > 0 else 0,
|
||||
'credit': -debit_value if debit_value < 0 else 0,
|
||||
'account_id': debit_account_id,
|
||||
}
|
||||
|
||||
credit_line_vals = {
|
||||
'name': self.name,
|
||||
'product_id': self.product_id.id,
|
||||
'quantity': qty,
|
||||
'product_uom_id': self.product_id.uom_id.id,
|
||||
'ref': ref,
|
||||
'partner_id': partner_id,
|
||||
'credit': credit_value if credit_value > 0 else 0,
|
||||
'debit': -credit_value if credit_value < 0 else 0,
|
||||
'account_id': credit_account_id,
|
||||
}
|
||||
|
||||
rslt = {'credit_line_vals': credit_line_vals, 'debit_line_vals': debit_line_vals}
|
||||
if credit_value != debit_value:
|
||||
# for supplier returns of product in average costing method, in anglo saxon mode
|
||||
diff_amount = debit_value - credit_value
|
||||
price_diff_account = self.product_id.property_account_creditor_price_difference
|
||||
|
||||
if not price_diff_account:
|
||||
price_diff_account = self.product_id.categ_id.property_account_creditor_price_difference_categ
|
||||
if not price_diff_account:
|
||||
raise UserError(_('Configuration error. Please configure the price difference account on the product or its category to process this operation.'))
|
||||
|
||||
rslt['price_diff_line_vals'] = {
|
||||
'name': self.name,
|
||||
'product_id': self.product_id.id,
|
||||
'quantity': qty,
|
||||
'product_uom_id': self.product_id.uom_id.id,
|
||||
'ref': ref,
|
||||
'partner_id': partner_id,
|
||||
'credit': diff_amount > 0 and diff_amount or 0,
|
||||
'debit': diff_amount < 0 and -diff_amount or 0,
|
||||
'account_id': price_diff_account.id,
|
||||
}
|
||||
return rslt
|
||||
|
||||
def _get_partner_id_for_valuation_lines(self):
|
||||
return (self.picking_id.partner_id and self.env['res.partner']._find_accounting_partner(self.picking_id.partner_id).id) or False
|
||||
|
||||
def _prepare_move_split_vals(self, uom_qty):
|
||||
vals = super(StockMove, self)._prepare_move_split_vals(uom_qty)
|
||||
vals['to_refund'] = self.to_refund
|
||||
return vals
|
||||
|
||||
def _create_account_move_line(self, credit_account_id, debit_account_id, journal_id):
|
||||
self.ensure_one()
|
||||
AccountMove = self.env['account.move']
|
||||
quantity = self.env.context.get('forced_quantity', self.product_qty)
|
||||
quantity = quantity if self._is_in() else -1 * quantity
|
||||
|
||||
# Make an informative `ref` on the created account move to differentiate between classic
|
||||
# movements, vacuum and edition of past moves.
|
||||
ref = self.picking_id.name
|
||||
if self.env.context.get('force_valuation_amount'):
|
||||
if self.env.context.get('forced_quantity') == 0:
|
||||
ref = 'Revaluation of %s (negative inventory)' % ref
|
||||
elif self.env.context.get('forced_quantity') is not None:
|
||||
ref = 'Correction of %s (modification of past move)' % ref
|
||||
if not ref and self.inventory_id:
|
||||
ref = self.inventory_id.name
|
||||
|
||||
move_lines = self.with_context(forced_ref=ref)._prepare_account_move_line(quantity, abs(self.value), credit_account_id, debit_account_id)
|
||||
if move_lines:
|
||||
date = self._context.get('force_period_date', fields.Date.context_today(self))
|
||||
new_account_move = AccountMove.sudo().create({
|
||||
'journal_id': journal_id,
|
||||
'line_ids': move_lines,
|
||||
'date': date,
|
||||
'ref': ref,
|
||||
'stock_move_id': self.id,
|
||||
})
|
||||
new_account_move.post()
|
||||
|
||||
def _account_entry_move(self):
|
||||
""" Accounting Valuation Entries """
|
||||
self.ensure_one()
|
||||
if self.product_id.type != 'product':
|
||||
# no stock valuation for consumable products
|
||||
return False
|
||||
if self.restrict_partner_id:
|
||||
# if the move isn't owned by the company, we don't make any valuation
|
||||
return False
|
||||
|
||||
location_from = self.location_id
|
||||
location_to = self.location_dest_id
|
||||
company_from = self._is_out() and self.mapped('move_line_ids.location_id.company_id') or False
|
||||
company_to = self._is_in() and self.mapped('move_line_ids.location_dest_id.company_id') or False
|
||||
|
||||
# Create Journal Entry for products arriving in the company; in case of routes making the link between several
|
||||
# warehouse of the same company, the transit location belongs to this company, so we don't need to create accounting entries
|
||||
if self._is_in():
|
||||
journal_id, acc_src, acc_dest, acc_valuation = self._get_accounting_data_for_valuation()
|
||||
if location_from and location_from.usage == 'customer': # goods returned from customer
|
||||
self.with_context(force_company=company_to.id)._create_account_move_line(acc_dest, acc_valuation, journal_id)
|
||||
else:
|
||||
self.with_context(force_company=company_to.id)._create_account_move_line(acc_src, acc_valuation, journal_id)
|
||||
|
||||
# Create Journal Entry for products leaving the company
|
||||
if self._is_out():
|
||||
journal_id, acc_src, acc_dest, acc_valuation = self._get_accounting_data_for_valuation()
|
||||
if location_to and location_to.usage == 'supplier': # goods returned to supplier
|
||||
self.with_context(force_company=company_from.id)._create_account_move_line(acc_valuation, acc_src, journal_id)
|
||||
else:
|
||||
self.with_context(force_company=company_from.id)._create_account_move_line(acc_valuation, acc_dest, journal_id)
|
||||
|
||||
if self.company_id.anglo_saxon_accounting:
|
||||
# Creates an account entry from stock_input to stock_output on a dropship move. https://github.com/odoo/odoo/issues/12687
|
||||
journal_id, acc_src, acc_dest, acc_valuation = self._get_accounting_data_for_valuation()
|
||||
if self._is_dropshipped():
|
||||
self.with_context(force_company=self.company_id.id)._create_account_move_line(acc_src, acc_dest, journal_id)
|
||||
elif self._is_dropshipped_returned():
|
||||
self.with_context(force_company=self.company_id.id)._create_account_move_line(acc_dest, acc_src, journal_id)
|
||||
|
||||
if self.company_id.anglo_saxon_accounting:
|
||||
#eventually reconcile together the invoice and valuation accounting entries on the stock interim accounts
|
||||
self._get_related_invoices()._anglo_saxon_reconcile_valuation(product=self.product_id)
|
||||
|
||||
def _get_related_invoices(self): # To be overridden in purchase and sale_stock
|
||||
""" This method is overrided in both purchase and sale_stock modules to adapt
|
||||
to the way they mix stock moves with invoices.
|
||||
"""
|
||||
return self.env['account.invoice']
|
||||
|
||||
|
||||
class StockReturnPicking(models.TransientModel):
|
||||
_inherit = "stock.return.picking"
|
||||
|
||||
@api.multi
|
||||
def _create_returns(self):
|
||||
new_picking_id, pick_type_id = super(StockReturnPicking, self)._create_returns()
|
||||
new_picking = self.env['stock.picking'].browse([new_picking_id])
|
||||
for move in new_picking.move_lines:
|
||||
return_picking_line = self.product_return_moves.filtered(lambda r: r.move_id == move.origin_returned_move_id)
|
||||
if return_picking_line and return_picking_line.to_refund:
|
||||
move.to_refund = True
|
||||
return new_picking_id, pick_type_id
|
||||
|
||||
def _prepare_stock_return_picking_line_vals_from_move(self, stock_move):
|
||||
res = super(StockReturnPicking, self)._prepare_stock_return_picking_line_vals_from_move(stock_move)
|
||||
res['to_refund'] = True
|
||||
return res
|
||||
|
||||
|
||||
class StockReturnPickingLine(models.TransientModel):
|
||||
_inherit = "stock.return.picking.line"
|
||||
|
||||
to_refund = fields.Boolean(string="Update quantities on SO/PO", help='Trigger a decrease of the delivered/received quantity in the associated Sale Order/Purchase Order')
|
||||
|
||||
|
||||
class ProcurementGroup(models.Model):
|
||||
_inherit = 'procurement.group'
|
||||
|
||||
@api.model
|
||||
def _run_scheduler_tasks(self, use_new_cursor=False, company_id=False):
|
||||
super(ProcurementGroup, self)._run_scheduler_tasks(use_new_cursor=use_new_cursor, company_id=company_id)
|
||||
self.env['stock.move']._run_fifo_vacuum()
|
||||
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class StockInventory(models.Model):
|
||||
_inherit = "stock.inventory"
|
||||
|
||||
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.")
|
||||
has_account_moves = fields.Boolean(compute='_compute_has_account_moves')
|
||||
|
||||
def _compute_has_account_moves(self):
|
||||
for inventory in self:
|
||||
if inventory.state == 'done' and inventory.move_ids:
|
||||
account_move = self.env['account.move'].search_count([
|
||||
('stock_move_id.id', 'in', inventory.move_ids.ids)
|
||||
])
|
||||
inventory.has_account_moves = account_move > 0
|
||||
else:
|
||||
inventory.has_account_moves = False
|
||||
|
||||
def action_get_account_moves(self):
|
||||
self.ensure_one()
|
||||
action_ref = self.env.ref('account.action_move_journal_line')
|
||||
if not action_ref:
|
||||
return False
|
||||
action_data = action_ref.read()[0]
|
||||
action_data['domain'] = [('stock_move_id.id', 'in', self.move_ids.ids)]
|
||||
return action_data
|
||||
|
||||
def post_inventory(self):
|
||||
acc_inventories = self.filtered(lambda inventory: inventory.accounting_date)
|
||||
for inventory in acc_inventories:
|
||||
super(StockInventory, inventory.with_context(force_period_date=inventory.accounting_date)).post_inventory()
|
||||
other_inventories = self - acc_inventories
|
||||
if other_inventories:
|
||||
super(StockInventory, other_inventories).post_inventory()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class StockLocation(models.Model):
|
||||
_inherit = "stock.location"
|
||||
|
||||
valuation_in_account_id = fields.Many2one(
|
||||
'account.account', 'Stock Valuation Account (Incoming)',
|
||||
domain=[('internal_type', '=', 'other'), ('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=[('internal_type', '=', 'other'), ('deprecated', '=', False)],
|
||||
help="Used for real-time inventory valuation. When set on a virtual location (non internal type), "
|
||||
"this account will be used to hold the value of products being moved out of this location "
|
||||
"and into an internal location, instead of the generic Stock Output Account set on the product. "
|
||||
"This has no effect for internal locations.")
|
||||
|
||||
def _should_be_valued(self):
|
||||
""" This method returns a boolean reflecting whether the products stored in `self` should
|
||||
be considered when valuating the stock of a company.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.usage == 'internal' or (self.usage == 'transit' and self.company_id):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import float_compare, float_round, float_is_zero
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StockMove(models.Model):
|
||||
_inherit = "stock.move"
|
||||
|
||||
to_refund = fields.Boolean(string="Update quantities on SO/PO", copy=False,
|
||||
help='Trigger a decrease of the delivered/received quantity in the associated Sale Order/Purchase Order')
|
||||
account_move_ids = fields.One2many('account.move', 'stock_move_id')
|
||||
stock_valuation_layer_ids = fields.One2many('stock.valuation.layer', 'stock_move_id')
|
||||
|
||||
@api.multi
|
||||
def action_get_account_moves(self):
|
||||
self.ensure_one()
|
||||
action_ref = self.env.ref('account.action_move_journal_line')
|
||||
if not action_ref:
|
||||
return False
|
||||
action_data = action_ref.read()[0]
|
||||
action_data['domain'] = [('id', 'in', self.account_move_ids.ids)]
|
||||
return action_data
|
||||
|
||||
def _get_price_unit(self):
|
||||
""" Returns the unit price to value this stock move """
|
||||
self.ensure_one()
|
||||
price_unit = self.price_unit
|
||||
# If the move is a return, use the original move's price unit.
|
||||
if self.origin_returned_move_id:
|
||||
price_unit = self.origin_returned_move_id.price_unit
|
||||
return not self.company_id.currency_id.is_zero(price_unit) and price_unit or self.product_id.standard_price
|
||||
|
||||
@api.model
|
||||
def _get_valued_types(self):
|
||||
"""Returns a list of `valued_type` as strings. During `action_done`, we'll call
|
||||
`_is_[valued_type]'. If the result of this method is truthy, we'll consider the move to be
|
||||
valued.
|
||||
|
||||
:returns: a list of `valued_type`
|
||||
:rtype: list
|
||||
"""
|
||||
return ['in', 'out', 'dropshipped', 'dropshipped_returned']
|
||||
|
||||
def _get_in_move_lines(self):
|
||||
""" Returns the `stock.move.line` records of `self` considered as incoming. It is done thanks
|
||||
to the `_should_be_valued` method of their source and destionation location as well as their
|
||||
owner.
|
||||
|
||||
:returns: a subset of `self` containing the incoming records
|
||||
:rtype: recordset
|
||||
"""
|
||||
self.ensure_one()
|
||||
res = self.env['stock.move.line']
|
||||
for move_line in self.move_line_ids:
|
||||
if move_line.owner_id and move_line.owner_id != move_line.company_id.partner_id:
|
||||
continue
|
||||
if not move_line.location_id._should_be_valued() and move_line.location_dest_id._should_be_valued():
|
||||
res |= move_line
|
||||
return res
|
||||
|
||||
def _is_in(self):
|
||||
"""Check if the move should be considered as entering the company so that the cost method
|
||||
will be able to apply the correct logic.
|
||||
|
||||
:returns: True if the move is entering the company else False
|
||||
:rtype: bool
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self._get_in_move_lines():
|
||||
return True
|
||||
return False
|
||||
|
||||
def _get_out_move_lines(self):
|
||||
""" Returns the `stock.move.line` records of `self` considered as outgoing. It is done thanks
|
||||
to the `_should_be_valued` method of their source and destionation location as well as their
|
||||
owner.
|
||||
|
||||
:returns: a subset of `self` containing the outgoing records
|
||||
:rtype: recordset
|
||||
"""
|
||||
res = self.env['stock.move.line']
|
||||
for move_line in self.move_line_ids:
|
||||
if move_line.owner_id and move_line.owner_id != move_line.company_id.partner_id:
|
||||
continue
|
||||
if move_line.location_id._should_be_valued() and not move_line.location_dest_id._should_be_valued():
|
||||
res |= move_line
|
||||
return res
|
||||
|
||||
def _is_out(self):
|
||||
"""Check if the move should be considered as leaving the company so that the cost method
|
||||
will be able to apply the correct logic.
|
||||
|
||||
:returns: True if the move is leaving the company else False
|
||||
:rtype: bool
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self._get_out_move_lines():
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_dropshipped(self):
|
||||
"""Check if the move should be considered as a dropshipping move so that the cost method
|
||||
will be able to apply the correct logic.
|
||||
|
||||
:returns: True if the move is a dropshipping one else False
|
||||
:rtype: bool
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.location_id.usage == 'supplier' and self.location_dest_id.usage == 'customer'
|
||||
|
||||
def _is_dropshipped_returned(self):
|
||||
"""Check if the move should be considered as a returned dropshipping move so that the cost
|
||||
method will be able to apply the correct logic.
|
||||
|
||||
:returns: True if the move is a returned dropshipping one else False
|
||||
:rtype: bool
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.location_id.usage == 'customer' and self.location_dest_id.usage == 'supplier'
|
||||
|
||||
def _prepare_common_svl_vals(self):
|
||||
"""When a `stock.valuation.layer` is created from a `stock.move`, we can prepare a dict of
|
||||
common vals.
|
||||
|
||||
:returns: the common values when creating a `stock.valuation.layer` from a `stock.move`
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'stock_move_id': self.id,
|
||||
'company_id': self.company_id.id,
|
||||
'product_id': self.product_id.id,
|
||||
'description': self.name,
|
||||
}
|
||||
|
||||
def _create_in_svl(self, forced_quantity=None):
|
||||
"""Create a `stock.valuation.layer` from `self`.
|
||||
|
||||
:param forced_quantity: under some circunstances, the quantity to value is different than
|
||||
the initial demand of the move (Default value = None)
|
||||
"""
|
||||
svl_vals_list = []
|
||||
for move in self:
|
||||
valued_move_lines = move._get_in_move_lines()
|
||||
valued_quantity = 0
|
||||
for valued_move_line in valued_move_lines:
|
||||
valued_quantity += valued_move_line.product_uom_id._compute_quantity(valued_move_line.qty_done, move.product_id.uom_id)
|
||||
unit_cost = abs(move._get_price_unit()) # May be negative (i.e. decrease an out move).
|
||||
if move.product_id.cost_method == 'standard':
|
||||
unit_cost = move.product_id.standard_price
|
||||
svl_vals = move.product_id._prepare_in_svl_vals(forced_quantity or valued_quantity, unit_cost)
|
||||
svl_vals.update(move._prepare_common_svl_vals())
|
||||
if forced_quantity:
|
||||
svl_vals['description'] = 'Correction of %s (modification of past move)' % move.picking_id.name or move.name
|
||||
svl_vals_list.append(svl_vals)
|
||||
return self.env['stock.valuation.layer'].create(svl_vals_list)
|
||||
|
||||
def _create_out_svl(self, forced_quantity=None):
|
||||
"""Create a `stock.valuation.layer` from `self`.
|
||||
|
||||
:param forced_quantity: under some circunstances, the quantity to value is different than
|
||||
the initial demand of the move (Default value = None)
|
||||
"""
|
||||
svl_vals_list = []
|
||||
for move in self:
|
||||
valued_move_lines = move._get_out_move_lines()
|
||||
valued_quantity = 0
|
||||
for valued_move_line in valued_move_lines:
|
||||
valued_quantity += valued_move_line.product_uom_id._compute_quantity(valued_move_line.qty_done, move.product_id.uom_id)
|
||||
svl_vals = move.product_id._prepare_out_svl_vals(forced_quantity or valued_quantity, move.company_id)
|
||||
svl_vals.update(move._prepare_common_svl_vals())
|
||||
if forced_quantity:
|
||||
svl_vals['description'] = 'Correction of %s (modification of past move)' % move.picking_id.name or move.name
|
||||
svl_vals_list.append(svl_vals)
|
||||
return self.env['stock.valuation.layer'].create(svl_vals_list)
|
||||
|
||||
def _create_dropshipped_svl(self, forced_quantity=None):
|
||||
"""Create a `stock.valuation.layer` from `self`.
|
||||
|
||||
:param forced_quantity: under some circunstances, the quantity to value is different than
|
||||
the initial demand of the move (Default value = None)
|
||||
"""
|
||||
svl_vals_list = []
|
||||
for move in self:
|
||||
valued_move_lines = move.move_line_ids
|
||||
valued_quantity = 0
|
||||
for valued_move_line in valued_move_lines:
|
||||
valued_quantity += valued_move_line.product_uom_id._compute_quantity(valued_move_line.qty_done, move.product_id.uom_id)
|
||||
quantity = forced_quantity or valued_quantity
|
||||
|
||||
unit_cost = move._get_price_unit()
|
||||
if move.product_id.cost_method == 'standard':
|
||||
unit_cost = move.product_id.standard_price
|
||||
|
||||
common_vals = dict(move._prepare_common_svl_vals(), remaining_qty=0)
|
||||
|
||||
# create the in
|
||||
in_vals = {
|
||||
'unit_cost': unit_cost,
|
||||
'value': unit_cost * quantity,
|
||||
'quantity': quantity,
|
||||
}
|
||||
in_vals.update(common_vals)
|
||||
svl_vals_list.append(in_vals)
|
||||
|
||||
# create the out
|
||||
out_vals = {
|
||||
'unit_cost': unit_cost,
|
||||
'value': unit_cost * quantity * -1,
|
||||
'quantity': quantity * -1,
|
||||
}
|
||||
out_vals.update(common_vals)
|
||||
svl_vals_list.append(out_vals)
|
||||
return self.env['stock.valuation.layer'].create(svl_vals_list)
|
||||
|
||||
def _create_dropshipped_returned_svl(self, forced_quantity=None):
|
||||
"""Create a `stock.valuation.layer` from `self`.
|
||||
|
||||
:param forced_quantity: under some circunstances, the quantity to value is different than
|
||||
the initial demand of the move (Default value = None)
|
||||
"""
|
||||
return self._create_dropshipped_svl(forced_quantity=forced_quantity)
|
||||
|
||||
def _action_done(self, cancel_backorder=False):
|
||||
# Init a dict that will group the moves by valuation type, according to `move._is_valued_type`.
|
||||
valued_moves = {valued_type: self.env['stock.move'] for valued_type in self._get_valued_types()}
|
||||
for move in self:
|
||||
for valued_type in self._get_valued_types():
|
||||
if getattr(move, '_is_%s' % valued_type)():
|
||||
valued_moves[valued_type] |= move
|
||||
continue
|
||||
|
||||
# AVCO application
|
||||
valued_moves['in'].product_price_update_before_done()
|
||||
|
||||
res = super(StockMove, self)._action_done(cancel_backorder=cancel_backorder)
|
||||
|
||||
stock_valuation_layers = self.env['stock.valuation.layer']
|
||||
# Create the valuation layers in batch by calling `moves._create_valued_type_svl`.
|
||||
for valued_type in self._get_valued_types():
|
||||
todo_valued_moves = valued_moves[valued_type]
|
||||
if todo_valued_moves:
|
||||
todo_valued_moves._sanity_check_for_valuation()
|
||||
stock_valuation_layers |= getattr(todo_valued_moves, '_create_%s_svl' % valued_type)()
|
||||
continue
|
||||
|
||||
for svl in stock_valuation_layers:
|
||||
if not svl.product_id.valuation == 'real_time':
|
||||
continue
|
||||
svl.stock_move_id._account_entry_move(svl.quantity, svl.description, svl.id, svl.value)
|
||||
|
||||
# For every in move, run the vacuum for the linked product.
|
||||
products_to_vacuum = valued_moves['in'].mapped('product_id')
|
||||
for product_to_vacuum in products_to_vacuum:
|
||||
product_to_vacuum._run_fifo_vacuum()
|
||||
|
||||
return res
|
||||
|
||||
def _sanity_check_for_valuation(self):
|
||||
for move in self:
|
||||
# Apply restrictions on the stock move to be able to make
|
||||
# consistent accounting entries.
|
||||
if move._is_in() and move._is_out():
|
||||
raise UserError(_("The move lines are not in a consistent state: some are entering and other are leaving the company."))
|
||||
company_src = move.mapped('move_line_ids.location_id.company_id')
|
||||
company_dst = move.mapped('move_line_ids.location_dest_id.company_id')
|
||||
try:
|
||||
if company_src:
|
||||
company_src.ensure_one()
|
||||
if company_dst:
|
||||
company_dst.ensure_one()
|
||||
except ValueError:
|
||||
raise UserError(_("The move lines are not in a consistent states: they do not share the same origin or destination company."))
|
||||
if company_src and company_dst and company_src.id != company_dst.id:
|
||||
raise UserError(_("The move lines are not in a consistent states: they are doing an intercompany in a single step while they should go through the intercompany transit location."))
|
||||
|
||||
@api.multi
|
||||
def product_price_update_before_done(self, forced_qty=None):
|
||||
tmpl_dict = defaultdict(lambda: 0.0)
|
||||
# adapt standard price on incomming moves if the product cost_method is 'average'
|
||||
std_price_update = {}
|
||||
for move in self.filtered(lambda move: move._is_in() and move.product_id.cost_method == 'average'):
|
||||
product_tot_qty_available = move.product_id.quantity_svl + tmpl_dict[move.product_id.id]
|
||||
rounding = move.product_id.uom_id.rounding
|
||||
|
||||
valued_move_lines = move._get_in_move_lines()
|
||||
qty_done = 0
|
||||
for valued_move_line in valued_move_lines:
|
||||
qty_done += valued_move_line.product_uom_id._compute_quantity(valued_move_line.qty_done, move.product_id.uom_id)
|
||||
|
||||
if float_is_zero(product_tot_qty_available, precision_rounding=rounding):
|
||||
new_std_price = move._get_price_unit()
|
||||
elif float_is_zero(product_tot_qty_available + move.product_qty, precision_rounding=rounding) or \
|
||||
float_is_zero(product_tot_qty_available + qty_done, precision_rounding=rounding):
|
||||
new_std_price = move._get_price_unit()
|
||||
else:
|
||||
# Get the standard price
|
||||
amount_unit = std_price_update.get((move.company_id.id, move.product_id.id)) or move.product_id.standard_price
|
||||
qty = forced_qty or qty_done
|
||||
new_std_price = ((amount_unit * product_tot_qty_available) + (move._get_price_unit() * qty)) / (product_tot_qty_available + qty)
|
||||
|
||||
tmpl_dict[move.product_id.id] += qty_done
|
||||
# Write the standard price, as SUPERUSER_ID because a warehouse manager may not have the right to write on products
|
||||
move.product_id.with_context(force_company=move.company_id.id).sudo().write({'standard_price': new_std_price})
|
||||
std_price_update[move.company_id.id, move.product_id.id] = new_std_price
|
||||
|
||||
@api.multi
|
||||
def _get_accounting_data_for_valuation(self):
|
||||
""" Return the accounts and journal to use to post Journal Entries for
|
||||
the real-time valuation of the quant. """
|
||||
self.ensure_one()
|
||||
accounts_data = self.product_id.product_tmpl_id.get_product_accounts()
|
||||
|
||||
if self.location_id.valuation_out_account_id:
|
||||
acc_src = self.location_id.valuation_out_account_id.id
|
||||
else:
|
||||
acc_src = accounts_data['stock_input'].id
|
||||
|
||||
if self.location_dest_id.valuation_in_account_id:
|
||||
acc_dest = self.location_dest_id.valuation_in_account_id.id
|
||||
else:
|
||||
acc_dest = accounts_data['stock_output'].id
|
||||
|
||||
acc_valuation = accounts_data.get('stock_valuation', False)
|
||||
if acc_valuation:
|
||||
acc_valuation = acc_valuation.id
|
||||
if not accounts_data.get('stock_journal', False):
|
||||
raise UserError(_('You don\'t have any stock journal defined on your product category, check if you have installed a chart of accounts.'))
|
||||
if not acc_src:
|
||||
raise UserError(_('Cannot find a stock input account for the product %s. You must define one on the product category, or on the location, before processing this operation.') % (self.product_id.display_name))
|
||||
if not acc_dest:
|
||||
raise UserError(_('Cannot find a stock output account for the product %s. You must define one on the product category, or on the location, before processing this operation.') % (self.product_id.display_name))
|
||||
if not acc_valuation:
|
||||
raise UserError(_('You don\'t have any stock valuation account defined on your product category. You must define one before processing this operation.'))
|
||||
journal_id = accounts_data['stock_journal'].id
|
||||
return journal_id, acc_src, acc_dest, acc_valuation
|
||||
|
||||
def _prepare_account_move_line(self, qty, cost, credit_account_id, debit_account_id, description):
|
||||
"""
|
||||
Generate the account.move.line values to post to track the stock valuation difference due to the
|
||||
processing of the given quant.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# the standard_price of the product may be in another decimal precision, or not compatible with the coinage of
|
||||
# the company currency... so we need to use round() before creating the accounting entries.
|
||||
debit_value = self.company_id.currency_id.round(cost)
|
||||
|
||||
# check that all data is correct
|
||||
if self.company_id.currency_id.is_zero(debit_value) and not self.env['ir.config_parameter'].sudo().get_param('stock_account.allow_zero_cost'):
|
||||
raise UserError(_("The cost of %s is currently equal to 0. Change the cost or the configuration of your product to avoid an incorrect valuation.") % (self.product_id.display_name,))
|
||||
credit_value = debit_value
|
||||
|
||||
valuation_partner_id = self._get_partner_id_for_valuation_lines()
|
||||
res = [(0, 0, line_vals) for line_vals in self._generate_valuation_lines_data(valuation_partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id, description).values()]
|
||||
|
||||
return res
|
||||
|
||||
def _generate_valuation_lines_data(self, partner_id, qty, debit_value, credit_value, debit_account_id, credit_account_id, description):
|
||||
# This method returns a dictonary to provide an easy extension hook to modify the valuation lines (see purchase for an example)
|
||||
self.ensure_one()
|
||||
debit_line_vals = {
|
||||
'name': self.name,
|
||||
'product_id': self.product_id.id,
|
||||
'quantity': qty,
|
||||
'product_uom_id': self.product_id.uom_id.id,
|
||||
'ref': description,
|
||||
'partner_id': partner_id,
|
||||
'debit': debit_value if debit_value > 0 else 0,
|
||||
'credit': -debit_value if debit_value < 0 else 0,
|
||||
'account_id': debit_account_id,
|
||||
}
|
||||
|
||||
credit_line_vals = {
|
||||
'name': self.name,
|
||||
'product_id': self.product_id.id,
|
||||
'quantity': qty,
|
||||
'product_uom_id': self.product_id.uom_id.id,
|
||||
'ref': description,
|
||||
'partner_id': partner_id,
|
||||
'credit': credit_value if credit_value > 0 else 0,
|
||||
'debit': -credit_value if credit_value < 0 else 0,
|
||||
'account_id': credit_account_id,
|
||||
}
|
||||
|
||||
rslt = {'credit_line_vals': credit_line_vals, 'debit_line_vals': debit_line_vals}
|
||||
if credit_value != debit_value:
|
||||
# for supplier returns of product in average costing method, in anglo saxon mode
|
||||
diff_amount = debit_value - credit_value
|
||||
price_diff_account = self.product_id.property_account_creditor_price_difference
|
||||
|
||||
if not price_diff_account:
|
||||
price_diff_account = self.product_id.categ_id.property_account_creditor_price_difference_categ
|
||||
if not price_diff_account:
|
||||
raise UserError(_('Configuration error. Please configure the price difference account on the product or its category to process this operation.'))
|
||||
|
||||
rslt['price_diff_line_vals'] = {
|
||||
'name': self.name,
|
||||
'product_id': self.product_id.id,
|
||||
'quantity': qty,
|
||||
'product_uom_id': self.product_id.uom_id.id,
|
||||
'ref': description,
|
||||
'partner_id': partner_id,
|
||||
'credit': diff_amount > 0 and diff_amount or 0,
|
||||
'debit': diff_amount < 0 and -diff_amount or 0,
|
||||
'account_id': price_diff_account.id,
|
||||
}
|
||||
return rslt
|
||||
|
||||
def _get_partner_id_for_valuation_lines(self):
|
||||
return (self.picking_id.partner_id and self.env['res.partner']._find_accounting_partner(self.picking_id.partner_id).id) or False
|
||||
|
||||
def _prepare_move_split_vals(self, uom_qty):
|
||||
vals = super(StockMove, self)._prepare_move_split_vals(uom_qty)
|
||||
vals['to_refund'] = self.to_refund
|
||||
return vals
|
||||
|
||||
def _create_account_move_line(self, credit_account_id, debit_account_id, journal_id, qty, description, svl_id, cost):
|
||||
self.ensure_one()
|
||||
AccountMove = self.env['account.move']
|
||||
|
||||
move_lines = self._prepare_account_move_line(qty, cost, credit_account_id, debit_account_id, description)
|
||||
if move_lines:
|
||||
date = self._context.get('force_period_date', fields.Date.context_today(self))
|
||||
new_account_move = AccountMove.sudo().create({
|
||||
'journal_id': journal_id,
|
||||
'line_ids': move_lines,
|
||||
'date': date,
|
||||
'ref': description,
|
||||
'stock_move_id': self.id,
|
||||
'stock_valuation_layer_ids': [(6, None, [svl_id])],
|
||||
})
|
||||
new_account_move.post()
|
||||
|
||||
def _account_entry_move(self, qty, description, svl_id, cost):
|
||||
""" Accounting Valuation Entries """
|
||||
self.ensure_one()
|
||||
if self.product_id.type != 'product':
|
||||
# no stock valuation for consumable products
|
||||
return False
|
||||
if self.restrict_partner_id:
|
||||
# if the move isn't owned by the company, we don't make any valuation
|
||||
return False
|
||||
|
||||
location_from = self.location_id
|
||||
location_to = self.location_dest_id
|
||||
company_from = self._is_out() and self.mapped('move_line_ids.location_id.company_id') or False
|
||||
company_to = self._is_in() and self.mapped('move_line_ids.location_dest_id.company_id') or False
|
||||
|
||||
# Create Journal Entry for products arriving in the company; in case of routes making the link between several
|
||||
# warehouse of the same company, the transit location belongs to this company, so we don't need to create accounting entries
|
||||
if self._is_in():
|
||||
journal_id, acc_src, acc_dest, acc_valuation = self._get_accounting_data_for_valuation()
|
||||
if location_from and location_from.usage == 'customer': # goods returned from customer
|
||||
self.with_context(force_company=company_to.id)._create_account_move_line(acc_dest, acc_valuation, journal_id, qty, description, svl_id, cost)
|
||||
else:
|
||||
self.with_context(force_company=company_to.id)._create_account_move_line(acc_src, acc_valuation, journal_id, qty, description, svl_id, cost)
|
||||
|
||||
# Create Journal Entry for products leaving the company
|
||||
if self._is_out():
|
||||
cost = -1 * cost
|
||||
journal_id, acc_src, acc_dest, acc_valuation = self._get_accounting_data_for_valuation()
|
||||
if location_to and location_to.usage == 'supplier': # goods returned to supplier
|
||||
self.with_context(force_company=company_from.id)._create_account_move_line(acc_valuation, acc_src, journal_id, qty, description, svl_id, cost)
|
||||
else:
|
||||
self.with_context(force_company=company_from.id)._create_account_move_line(acc_valuation, acc_dest, journal_id, qty, description, svl_id, cost)
|
||||
|
||||
if self.company_id.anglo_saxon_accounting:
|
||||
# Creates an account entry from stock_input to stock_output on a dropship move. https://github.com/odoo/odoo/issues/12687
|
||||
journal_id, acc_src, acc_dest, acc_valuation = self._get_accounting_data_for_valuation()
|
||||
if self._is_dropshipped():
|
||||
if cost > 0:
|
||||
self.with_context(force_company=self.company_id.id)._create_account_move_line(acc_src, acc_valuation, journal_id, qty, description, svl_id, cost)
|
||||
else:
|
||||
cost = -1 * cost
|
||||
self.with_context(force_company=self.company_id.id)._create_account_move_line(acc_valuation, acc_dest, journal_id, qty, description, svl_id, cost)
|
||||
elif self._is_dropshipped_returned():
|
||||
if cost > 0:
|
||||
self.with_context(force_company=self.company_id.id)._create_account_move_line(acc_valuation, acc_src, journal_id, qty, description, svl_id, cost)
|
||||
else:
|
||||
cost = -1 * cost
|
||||
self.with_context(force_company=self.company_id.id)._create_account_move_line(acc_dest, acc_valuation, journal_id, qty, description, svl_id, cost)
|
||||
|
||||
if self.company_id.anglo_saxon_accounting:
|
||||
#eventually reconcile together the invoice and valuation accounting entries on the stock interim accounts
|
||||
self._get_related_invoices()._anglo_saxon_reconcile_valuation(product=self.product_id)
|
||||
|
||||
def _get_related_invoices(self): # To be overridden in purchase and sale_stock
|
||||
""" This method is overrided in both purchase and sale_stock modules to adapt
|
||||
to the way they mix stock moves with invoices.
|
||||
"""
|
||||
return self.env['account.invoice']
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- 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_is_zero
|
||||
|
||||
|
||||
class StockMoveLine(models.Model):
|
||||
_inherit = 'stock.move.line'
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# CRUD
|
||||
# -------------------------------------------------------------------------
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
move_lines = super(StockMoveLine, self).create(vals_list)
|
||||
for move_line in move_lines:
|
||||
if move_line.state != 'done':
|
||||
continue
|
||||
move = move_line.move_id
|
||||
rounding = move.product_id.uom_id.rounding
|
||||
diff = move_line.qty_done
|
||||
if float_is_zero(diff, precision_rounding=rounding):
|
||||
continue
|
||||
self._create_correction_svl(move, diff)
|
||||
return move_lines
|
||||
|
||||
def write(self, vals):
|
||||
if 'qty_done' in vals:
|
||||
for move_line in self:
|
||||
if move_line.state != 'done':
|
||||
continue
|
||||
move = move_line.move_id
|
||||
rounding = move.product_id.uom_id.rounding
|
||||
diff = vals['qty_done'] - move_line.qty_done
|
||||
if float_is_zero(diff, precision_rounding=rounding):
|
||||
continue
|
||||
self._create_correction_svl(move, diff)
|
||||
return super(StockMoveLine, self).write(vals)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 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))
|
||||
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))
|
||||
|
||||
for svl in stock_valuation_layers:
|
||||
if not svl.product_id.valuation == 'real_time':
|
||||
continue
|
||||
svl.stock_move_id._account_entry_move(svl.quantity, svl.description, svl.id, svl.value)
|
||||
@@ -0,0 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class StockPicking(models.Model):
|
||||
_inherit = 'stock.picking'
|
||||
|
||||
def action_view_stock_valuation_layers(self):
|
||||
self.ensure_one()
|
||||
domain = [('id', 'in', self.move_lines.stock_valuation_layer_ids.ids)]
|
||||
action = self.env.ref('stock_account.stock_valuation_layer_action').read()[0]
|
||||
return dict(action, domain=domain)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# -*- 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"""
|
||||
|
||||
_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)
|
||||
quantity = fields.Float('Quantity', digits=0, help='Quantity', readonly=True)
|
||||
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(digits=0, readonly=True)
|
||||
description = fields.Char('Description', readonly=True)
|
||||
stock_valuation_layer_id = fields.Many2one('stock.valuation.layer', 'Linked Stock Valuation Layer', readonly=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)
|
||||
account_move_id = fields.Many2one('account.move', 'Journal Entry', readonly=True)
|
||||
|
||||
@@ -2,3 +2,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_account_account_stock_manager,account.account stock manager,account.model_account_account,stock.group_stock_manager,1,0,0,0
|
||||
access_stock_picking_invoicing_payments,stock.picking,stock.model_stock_picking,account.group_account_invoice,1,1,1,0
|
||||
access_stock_move_invoicing_payments,stock.move,model_stock_move,account.group_account_invoice,1,1,1,0
|
||||
access_stock_valuation_layer,access_stock_valuation_layer,model_stock_valuation_layer,stock.group_stock_manager,1,1,1,0
|
||||
|
||||
|
@@ -12,6 +12,13 @@
|
||||
<field name="domain_force">['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
|
||||
</record> -->
|
||||
|
||||
|
||||
<record model="ir.rule" id="stock_valuation_layer_company_rule">
|
||||
<field name="name">Stock Valuation Layer Multicompany</field>
|
||||
<field name="model_id" search="[('model','=','stock.valuation.layer')]" model="ir.model"/>
|
||||
<field name="global" eval="True"/>
|
||||
<field name="domain_force">[('company_id','=',user.company_id.id)]</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import test_anglo_saxon_valuation_reconciliation_common
|
||||
from . import test_stockvaluation
|
||||
from . import test_stockvaluationlayer
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,793 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
""" Implementation of "INVENTORY VALUATION TESTS (With valuation layers)" spreadsheet. """
|
||||
|
||||
from odoo.tests import Form, tagged
|
||||
from odoo.tests.common import SavepointCase, TransactionCase
|
||||
|
||||
|
||||
class TestStockValuationCommon(SavepointCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestStockValuationCommon, cls).setUpClass()
|
||||
cls.stock_location = cls.env.ref('stock.stock_location_stock')
|
||||
cls.customer_location = cls.env.ref('stock.stock_location_customers')
|
||||
cls.supplier_location = cls.env.ref('stock.stock_location_suppliers')
|
||||
cls.uom_unit = cls.env.ref('uom.product_uom_unit')
|
||||
cls.product1 = cls.env['product.product'].create({
|
||||
'name': 'product1',
|
||||
'type': 'product',
|
||||
'categ_id': cls.env.ref('product.product_category_all').id,
|
||||
})
|
||||
cls.picking_type_in = cls.env.ref('stock.picking_type_in')
|
||||
cls.picking_type_out = cls.env.ref('stock.picking_type_out')
|
||||
|
||||
def setUp(self):
|
||||
super(TestStockValuationCommon, self).setUp()
|
||||
# Counter automatically incremented by `_make_in_move` and `_make_out_move`.
|
||||
self.days = 0
|
||||
|
||||
def _make_in_move(self, product, quantity, unit_cost=None, create_picking=False):
|
||||
""" Helper to create and validate a receipt move.
|
||||
"""
|
||||
unit_cost = unit_cost or product.standard_price
|
||||
in_move = self.env['stock.move'].create({
|
||||
'name': 'in %s units @ %s per unit' % (str(quantity), str(unit_cost)),
|
||||
'product_id': product.id,
|
||||
'location_id': self.supplier_location.id,
|
||||
'location_dest_id': self.stock_location.id,
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': quantity,
|
||||
'price_unit': unit_cost,
|
||||
'picking_type_id': self.picking_type_in.id,
|
||||
})
|
||||
|
||||
if create_picking:
|
||||
picking = self.env['stock.picking'].create({
|
||||
'picking_type_id': in_move.picking_type_id.id,
|
||||
'location_id': in_move.location_id.id,
|
||||
'location_dest_id': in_move.location_dest_id.id,
|
||||
})
|
||||
in_move.write({'picking_id': picking.id})
|
||||
|
||||
in_move._action_confirm()
|
||||
in_move._action_assign()
|
||||
in_move.move_line_ids.qty_done = quantity
|
||||
in_move._action_done()
|
||||
|
||||
self.days += 1
|
||||
return in_move.with_context(svl=True)
|
||||
|
||||
def _make_out_move(self, product, quantity, force_assign=None, create_picking=False):
|
||||
""" Helper to create and validate a delivery move.
|
||||
"""
|
||||
out_move = self.env['stock.move'].create({
|
||||
'name': 'out %s units' % str(quantity),
|
||||
'product_id': product.id,
|
||||
'location_id': self.stock_location.id,
|
||||
'location_dest_id': self.customer_location.id,
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': quantity,
|
||||
'picking_type_id': self.picking_type_out.id,
|
||||
})
|
||||
|
||||
if create_picking:
|
||||
picking = self.env['stock.picking'].create({
|
||||
'picking_type_id': out_move.picking_type_id.id,
|
||||
'location_id': out_move.location_id.id,
|
||||
'location_dest_id': out_move.location_dest_id.id,
|
||||
})
|
||||
out_move.write({'picking_id': picking.id})
|
||||
|
||||
out_move._action_confirm()
|
||||
out_move._action_assign()
|
||||
if force_assign:
|
||||
self.env['stock.move.line'].create({
|
||||
'move_id': out_move.id,
|
||||
'product_id': out_move.product_id.id,
|
||||
'product_uom_id': out_move.product_uom.id,
|
||||
'location_id': out_move.location_id.id,
|
||||
'location_dest_id': out_move.location_dest_id.id,
|
||||
})
|
||||
out_move.move_line_ids.qty_done = quantity
|
||||
out_move._action_done()
|
||||
|
||||
self.days += 1
|
||||
return out_move.with_context(svl=True)
|
||||
|
||||
def _make_dropship_move(self, product, quantity, unit_cost=None):
|
||||
dropshipped = self.env['stock.move'].create({
|
||||
'name': 'dropship %s units' % str(quantity),
|
||||
'product_id': product.id,
|
||||
'location_id': self.supplier_location.id,
|
||||
'location_dest_id': self.customer_location.id,
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': quantity,
|
||||
'picking_type_id': self.picking_type_out.id,
|
||||
})
|
||||
if unit_cost:
|
||||
dropshipped.unit_cost = unit_cost
|
||||
dropshipped._action_confirm()
|
||||
dropshipped._action_assign()
|
||||
dropshipped.move_line_ids.qty_done = quantity
|
||||
dropshipped._action_done()
|
||||
return dropshipped
|
||||
|
||||
def _make_return(self, move, quantity_to_return):
|
||||
stock_return_picking = Form(self.env['stock.return.picking']\
|
||||
.with_context(active_ids=[move.picking_id.id], active_id=move.picking_id.id, active_model='stock.picking'))
|
||||
stock_return_picking = stock_return_picking.save()
|
||||
stock_return_picking.product_return_moves.quantity = quantity_to_return
|
||||
stock_return_picking_action = stock_return_picking.create_returns()
|
||||
return_pick = self.env['stock.picking'].browse(stock_return_picking_action['res_id'])
|
||||
return_pick.move_lines[0].move_line_ids[0].qty_done = quantity_to_return
|
||||
return_pick.action_done()
|
||||
return return_pick.move_lines
|
||||
|
||||
|
||||
class TestStockValuationStandard(TestStockValuationCommon):
|
||||
def setUp(self):
|
||||
super(TestStockValuationStandard, self).setUp()
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'standard'
|
||||
self.product1.product_tmpl_id.standard_price = 10
|
||||
|
||||
def test_normal_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
|
||||
move1 = self._make_in_move(self.product1, 10)
|
||||
move2 = self._make_in_move(self.product1, 10)
|
||||
move3 = self._make_out_move(self.product1, 15)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 50)
|
||||
self.assertEqual(self.product1.quantity_svl, 5)
|
||||
|
||||
def test_change_in_past_increase_in_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
|
||||
move1 = self._make_in_move(self.product1, 10)
|
||||
move2 = self._make_in_move(self.product1, 10)
|
||||
move3 = self._make_out_move(self.product1, 15)
|
||||
move1.move_line_ids.qty_done = 15
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 100)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
|
||||
def test_change_in_past_decrease_in_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
|
||||
move1 = self._make_in_move(self.product1, 10)
|
||||
move2 = self._make_in_move(self.product1, 10)
|
||||
move3 = self._make_out_move(self.product1, 15)
|
||||
move1.move_line_ids.qty_done = 5
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 0)
|
||||
self.assertEqual(self.product1.quantity_svl, 0)
|
||||
|
||||
def test_change_in_past_add_ml_in_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
|
||||
move1 = self._make_in_move(self.product1, 10)
|
||||
move2 = self._make_in_move(self.product1, 10)
|
||||
move3 = self._make_out_move(self.product1, 15)
|
||||
self.env['stock.move.line'].create({
|
||||
'move_id': move1.id,
|
||||
'product_id': move1.product_id.id,
|
||||
'qty_done': 5,
|
||||
'product_uom_id': move1.product_uom.id,
|
||||
'location_id': move1.location_id.id,
|
||||
'location_dest_id': move1.location_dest_id.id,
|
||||
})
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 100)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
|
||||
def test_change_in_past_increase_out_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
|
||||
move1 = self._make_in_move(self.product1, 10)
|
||||
move2 = self._make_out_move(self.product1, 1)
|
||||
move2.move_line_ids.qty_done = 5
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 50)
|
||||
self.assertEqual(self.product1.quantity_svl, 5)
|
||||
|
||||
def test_change_in_past_decrease_out_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
|
||||
move1 = self._make_in_move(self.product1, 10)
|
||||
move2 = self._make_out_move(self.product1, 5)
|
||||
move2.move_line_ids.qty_done = 1
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 90)
|
||||
self.assertEqual(self.product1.quantity_svl, 9)
|
||||
|
||||
def test_change_standard_price_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
|
||||
move1 = self._make_in_move(self.product1, 10)
|
||||
move2 = self._make_in_move(self.product1, 10)
|
||||
move3 = self._make_out_move(self.product1, 15)
|
||||
|
||||
# change cost from 10 to 15
|
||||
self.product1._change_standard_price(15.0)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 75)
|
||||
self.assertEqual(self.product1.quantity_svl, 5)
|
||||
self.assertEqual(self.product1.stock_valuation_layer_ids[-1].description, 'Product value manually modified (from 10.0 to 15.0)')
|
||||
|
||||
def test_negative_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
|
||||
move1 = self._make_in_move(self.product1, 10)
|
||||
move2 = self._make_out_move(self.product1, 15)
|
||||
self.env['stock.move.line'].create({
|
||||
'move_id': move1.id,
|
||||
'product_id': move1.product_id.id,
|
||||
'qty_done': 10,
|
||||
'product_uom_id': move1.product_uom.id,
|
||||
'location_id': move1.location_id.id,
|
||||
'location_dest_id': move1.location_dest_id.id,
|
||||
})
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 50)
|
||||
self.assertEqual(self.product1.quantity_svl, 5)
|
||||
|
||||
def test_dropship_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
|
||||
move1 = self._make_dropship_move(self.product1, 10)
|
||||
|
||||
valuation_layers = self.product1.stock_valuation_layer_ids
|
||||
self.assertEqual(len(valuation_layers), 2)
|
||||
self.assertEqual(valuation_layers[0].value, 100)
|
||||
self.assertEqual(valuation_layers[1].value, -100)
|
||||
self.assertEqual(self.product1.value_svl, 0)
|
||||
self.assertEqual(self.product1.quantity_svl, 0)
|
||||
|
||||
def test_change_in_past_increase_dropship_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
|
||||
move1 = self._make_dropship_move(self.product1, 10)
|
||||
move1.move_line_ids.qty_done = 15
|
||||
|
||||
valuation_layers = self.product1.stock_valuation_layer_ids
|
||||
self.assertEqual(len(valuation_layers), 4)
|
||||
self.assertEqual(valuation_layers[0].value, 100)
|
||||
self.assertEqual(valuation_layers[1].value, -100)
|
||||
self.assertEqual(valuation_layers[2].value, 50)
|
||||
self.assertEqual(valuation_layers[3].value, -50)
|
||||
self.assertEqual(self.product1.value_svl, 0)
|
||||
self.assertEqual(self.product1.quantity_svl, 0)
|
||||
|
||||
|
||||
class TestStockValuationAVCO(TestStockValuationCommon):
|
||||
def setUp(self):
|
||||
super(TestStockValuationAVCO, self).setUp()
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'average'
|
||||
|
||||
def test_normal_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
self.assertEqual(self.product1.standard_price, 10)
|
||||
self.assertEqual(move1.stock_valuation_layer_ids.value, 100)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
self.assertEqual(self.product1.standard_price, 15)
|
||||
self.assertEqual(move2.stock_valuation_layer_ids.value, 200)
|
||||
move3 = self._make_out_move(self.product1, 15)
|
||||
self.assertEqual(self.product1.standard_price, 15)
|
||||
self.assertEqual(move3.stock_valuation_layer_ids.value, -225)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 75)
|
||||
self.assertEqual(self.product1.quantity_svl, 5)
|
||||
|
||||
def test_change_in_past_increase_in_1(self):
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 15)
|
||||
move1.move_line_ids.qty_done = 15
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 125)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
|
||||
def test_change_in_past_decrease_in_1(self):
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 15)
|
||||
move1.move_line_ids.qty_done = 5
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 0)
|
||||
self.assertEqual(self.product1.quantity_svl, 0)
|
||||
|
||||
def test_change_in_past_add_ml_in_1(self):
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 15)
|
||||
self.env['stock.move.line'].create({
|
||||
'move_id': move1.id,
|
||||
'product_id': move1.product_id.id,
|
||||
'qty_done': 5,
|
||||
'product_uom_id': move1.product_uom.id,
|
||||
'location_id': move1.location_id.id,
|
||||
'location_dest_id': move1.location_dest_id.id,
|
||||
})
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 125)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
self.assertEqual(self.product1.standard_price, 12.5)
|
||||
|
||||
def test_change_in_past_add_move_in_1(self):
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10, create_picking=True)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 15)
|
||||
self.env['stock.move.line'].create({
|
||||
'product_id': move1.product_id.id,
|
||||
'qty_done': 5,
|
||||
'product_uom_id': move1.product_uom.id,
|
||||
'location_id': move1.location_id.id,
|
||||
'location_dest_id': move1.location_dest_id.id,
|
||||
'state': 'done',
|
||||
'picking_id': move1.picking_id.id,
|
||||
})
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 150)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
self.assertEqual(self.product1.standard_price, 15)
|
||||
|
||||
def test_change_in_past_increase_out_1(self):
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 15)
|
||||
move3.move_line_ids.qty_done = 20
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 0)
|
||||
self.assertEqual(self.product1.quantity_svl, 0)
|
||||
self.assertEqual(self.product1.standard_price, 15)
|
||||
|
||||
def test_change_in_past_decrease_out_1(self):
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 15)
|
||||
move3.move_line_ids.qty_done = 10
|
||||
|
||||
self.assertEqual(sum(self.product1.stock_valuation_layer_ids.mapped('remaining_qty')), 10)
|
||||
self.assertEqual(self.product1.value_svl, 150)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
self.assertEqual(self.product1.standard_price, 15)
|
||||
|
||||
def test_negative_1(self):
|
||||
""" Ensures that, in AVCO, the `remaining_qty` field is computed and the vacuum is ran
|
||||
when necessary.
|
||||
"""
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 30)
|
||||
self.assertEqual(move3.stock_valuation_layer_ids.remaining_qty, -10)
|
||||
move4 = self._make_in_move(self.product1, 10, unit_cost=30)
|
||||
self.assertEqual(sum(self.product1.stock_valuation_layer_ids.mapped('remaining_qty')), 0)
|
||||
move5 = self._make_in_move(self.product1, 10, unit_cost=40)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 400)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
|
||||
def test_negative_2(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
self.product1.standard_price = 10
|
||||
move1 = self._make_out_move(self.product1, 1, force_assign=True)
|
||||
move2 = self._make_in_move(self.product1, 1, unit_cost=15)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 0)
|
||||
self.assertEqual(self.product1.quantity_svl, 0)
|
||||
|
||||
def test_return_receipt_1(self):
|
||||
move1 = self._make_in_move(self.product1, 1, unit_cost=10, create_picking=True)
|
||||
move2 = self._make_in_move(self.product1, 1, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 1)
|
||||
move4 = self._make_return(move1, 1)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 0)
|
||||
self.assertEqual(self.product1.quantity_svl, 0)
|
||||
self.assertEqual(self.product1.standard_price, 15)
|
||||
|
||||
def test_return_delivery_1(self):
|
||||
move1 = self._make_in_move(self.product1, 1, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 1, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 1, create_picking=True)
|
||||
move4 = self._make_return(move3, 1)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 30)
|
||||
self.assertEqual(self.product1.quantity_svl, 2)
|
||||
self.assertEqual(self.product1.standard_price, 15)
|
||||
self.assertEqual(sum(self.product1.stock_valuation_layer_ids.mapped('remaining_qty')), 2)
|
||||
|
||||
def test_rereturn_receipt_1(self):
|
||||
move1 = self._make_in_move(self.product1, 1, unit_cost=10, create_picking=True)
|
||||
move2 = self._make_in_move(self.product1, 1, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 1)
|
||||
move4 = self._make_return(move1, 1) # -15, current avco
|
||||
move5 = self._make_return(move4, 1) # +10, original move's price unit
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 15)
|
||||
self.assertEqual(self.product1.quantity_svl, 1)
|
||||
self.assertEqual(self.product1.standard_price, 15)
|
||||
self.assertEqual(sum(self.product1.stock_valuation_layer_ids.mapped('remaining_qty')), 1)
|
||||
|
||||
def test_rereturn_delivery_1(self):
|
||||
move1 = self._make_in_move(self.product1, 1, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 1, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 1, create_picking=True)
|
||||
move4 = self._make_return(move3, 1)
|
||||
move5 = self._make_return(move4, 1)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 15)
|
||||
self.assertEqual(self.product1.quantity_svl, 1)
|
||||
self.assertEqual(self.product1.standard_price, 15)
|
||||
self.assertEqual(sum(self.product1.stock_valuation_layer_ids.mapped('remaining_qty')), 1)
|
||||
|
||||
def test_dropship_1(self):
|
||||
move1 = self._make_in_move(self.product1, 1, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 1, unit_cost=20)
|
||||
move3 = self._make_dropship_move(self.product1, 1, unit_cost=10)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 30)
|
||||
self.assertEqual(self.product1.quantity_svl, 2)
|
||||
self.assertEqual(self.product1.standard_price, 15)
|
||||
|
||||
|
||||
class TestStockValuationFIFO(TestStockValuationCommon):
|
||||
def setUp(self):
|
||||
super(TestStockValuationFIFO, self).setUp()
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo'
|
||||
|
||||
def test_normal_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 15)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 100)
|
||||
self.assertEqual(self.product1.quantity_svl, 5)
|
||||
self.assertEqual(sum(self.product1.stock_valuation_layer_ids.mapped('remaining_qty')), 5)
|
||||
|
||||
def test_negative_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 30)
|
||||
self.assertEqual(move3.stock_valuation_layer_ids.remaining_qty, -10)
|
||||
move4 = self._make_in_move(self.product1, 10, unit_cost=30)
|
||||
self.assertEqual(sum(self.product1.stock_valuation_layer_ids.mapped('remaining_qty')), 0)
|
||||
move5 = self._make_in_move(self.product1, 10, unit_cost=40)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 400)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
|
||||
def test_change_in_past_decrease_in_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
move1 = self._make_in_move(self.product1, 20, unit_cost=10)
|
||||
move2 = self._make_out_move(self.product1, 10)
|
||||
move1.move_line_ids.qty_done = 10
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 0)
|
||||
self.assertEqual(self.product1.quantity_svl, 0)
|
||||
|
||||
def test_change_in_past_decrease_in_2(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
move1 = self._make_in_move(self.product1, 20, unit_cost=10)
|
||||
move2 = self._make_out_move(self.product1, 10)
|
||||
move3 = self._make_out_move(self.product1, 10)
|
||||
move1.move_line_ids.qty_done = 10
|
||||
move4 = self._make_in_move(self.product1, 20, unit_cost=15)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 150)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
|
||||
def test_change_in_past_increase_in_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=15)
|
||||
move3 = self._make_out_move(self.product1, 20)
|
||||
move1.move_line_ids.qty_done = 20
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 100)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
|
||||
def test_change_in_past_increase_in_2(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=12)
|
||||
move3 = self._make_out_move(self.product1, 15)
|
||||
move4 = self._make_out_move(self.product1, 20)
|
||||
move5 = self._make_in_move(self.product1, 100, unit_cost=15)
|
||||
move1.move_line_ids.qty_done = 20
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 1375)
|
||||
self.assertEqual(self.product1.quantity_svl, 95)
|
||||
|
||||
def test_change_in_past_increase_out_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
move1 = self._make_in_move(self.product1, 20, unit_cost=10)
|
||||
move2 = self._make_out_move(self.product1, 10)
|
||||
move3 = self._make_in_move(self.product1, 20, unit_cost=15)
|
||||
move2.move_line_ids.qty_done = 25
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 225)
|
||||
self.assertEqual(self.product1.quantity_svl, 15)
|
||||
self.assertEqual(sum(self.product1.stock_valuation_layer_ids.mapped('remaining_qty')), 15)
|
||||
|
||||
def test_change_in_past_decrease_out_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
move1 = self._make_in_move(self.product1, 20, unit_cost=10)
|
||||
move2 = self._make_out_move(self.product1, 15)
|
||||
move3 = self._make_in_move(self.product1, 20, unit_cost=15)
|
||||
move2.move_line_ids.qty_done = 5
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 450)
|
||||
self.assertEqual(self.product1.quantity_svl, 35)
|
||||
self.assertEqual(sum(self.product1.stock_valuation_layer_ids.mapped('remaining_qty')), 35)
|
||||
|
||||
def test_change_in_past_add_ml_out_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
move1 = self._make_in_move(self.product1, 20, unit_cost=10)
|
||||
move2 = self._make_out_move(self.product1, 10)
|
||||
move3 = self._make_in_move(self.product1, 20, unit_cost=15)
|
||||
self.env['stock.move.line'].create({
|
||||
'move_id': move2.id,
|
||||
'product_id': move2.product_id.id,
|
||||
'qty_done': 5,
|
||||
'product_uom_id': move2.product_uom.id,
|
||||
'location_id': move2.location_id.id,
|
||||
'location_dest_id': move2.location_dest_id.id,
|
||||
})
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 350)
|
||||
self.assertEqual(self.product1.quantity_svl, 25)
|
||||
self.assertEqual(sum(self.product1.stock_valuation_layer_ids.mapped('remaining_qty')), 25)
|
||||
|
||||
def test_return_delivery_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
move2 = self._make_out_move(self.product1, 10, create_picking=True)
|
||||
move3 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
move4 = self._make_return(move2, 10)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 300)
|
||||
self.assertEqual(self.product1.quantity_svl, 20)
|
||||
|
||||
def test_return_receipt_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10, create_picking=True)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
move3 = self._make_return(move1, 2)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 280)
|
||||
self.assertEqual(self.product1.quantity_svl, 18)
|
||||
|
||||
def test_rereturn_receipt_1(self):
|
||||
move1 = self._make_in_move(self.product1, 1, unit_cost=10, create_picking=True)
|
||||
move2 = self._make_in_move(self.product1, 1, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 1)
|
||||
move4 = self._make_return(move1, 1)
|
||||
move5 = self._make_return(move4, 1)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 20)
|
||||
self.assertEqual(self.product1.quantity_svl, 1)
|
||||
|
||||
def test_rereturn_delivery_1(self):
|
||||
move1 = self._make_in_move(self.product1, 1, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 1, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 1, create_picking=True)
|
||||
move4 = self._make_return(move3, 1)
|
||||
move5 = self._make_return(move4, 1)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 10)
|
||||
self.assertEqual(self.product1.quantity_svl, 1)
|
||||
|
||||
def test_dropship_1(self):
|
||||
orig_standard_price = self.product1.standard_price
|
||||
move1 = self._make_in_move(self.product1, 1, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 1, unit_cost=20)
|
||||
move3 = self._make_dropship_move(self.product1, 1, unit_cost=10)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 30)
|
||||
self.assertEqual(self.product1.quantity_svl, 2)
|
||||
self.assertEqual(orig_standard_price, self.product1.standard_price)
|
||||
|
||||
|
||||
class TestStockValuationChangeCostMethod(TestStockValuationCommon):
|
||||
def test_standard_to_fifo_1(self):
|
||||
""" The accounting impact of this cost method change is neutral.
|
||||
"""
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'standard'
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
self.product1.product_tmpl_id.standard_price = 10
|
||||
|
||||
move1 = self._make_in_move(self.product1, 10)
|
||||
move2 = self._make_in_move(self.product1, 10)
|
||||
move3 = self._make_out_move(self.product1, 1)
|
||||
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo'
|
||||
self.assertEqual(self.product1.value_svl, 190)
|
||||
self.assertEqual(self.product1.quantity_svl, 19)
|
||||
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids), 5)
|
||||
|
||||
def test_standard_to_fifo_2(self):
|
||||
""" We want the same result as `test_standard_to_fifo_1` but by changing the category of
|
||||
`self.product1` to another one, not changing the current one.
|
||||
"""
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'standard'
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
self.product1.product_tmpl_id.standard_price = 10
|
||||
|
||||
move1 = self._make_in_move(self.product1, 10)
|
||||
move2 = self._make_in_move(self.product1, 10)
|
||||
move3 = self._make_out_move(self.product1, 1)
|
||||
|
||||
cat2 = self.env['product.category'].create({'name': 'fifo'})
|
||||
cat2.property_cost_method = 'fifo'
|
||||
self.product1.product_tmpl_id.categ_id = cat2
|
||||
self.assertEqual(self.product1.value_svl, 190)
|
||||
self.assertEqual(self.product1.quantity_svl, 19)
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids), 5)
|
||||
|
||||
def test_avco_to_fifo(self):
|
||||
""" The accounting impact of this cost method change is neutral.
|
||||
"""
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'average'
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 1)
|
||||
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo'
|
||||
self.assertEqual(self.product1.value_svl, 285)
|
||||
self.assertEqual(self.product1.quantity_svl, 19)
|
||||
|
||||
def test_fifo_to_standard(self):
|
||||
""" The accounting impact of this cost method change is not neutral as we will use the last
|
||||
fifo price as the new standard price.
|
||||
"""
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo'
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 1)
|
||||
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'standard'
|
||||
self.assertEqual(self.product1.value_svl, 380)
|
||||
self.assertEqual(self.product1.quantity_svl, 19)
|
||||
|
||||
def test_fifo_to_avco(self):
|
||||
""" The accounting impact of this cost method change is not neutral as we will use the last
|
||||
fifo price as the new AVCO.
|
||||
"""
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo'
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 1)
|
||||
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'average'
|
||||
self.assertEqual(self.product1.value_svl, 380)
|
||||
self.assertEqual(self.product1.quantity_svl, 19)
|
||||
|
||||
def test_avco_to_standard(self):
|
||||
""" The accounting impact of this cost method change is neutral.
|
||||
"""
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'average'
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
move3 = self._make_out_move(self.product1, 1)
|
||||
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'standard'
|
||||
self.assertEqual(self.product1.value_svl, 285)
|
||||
self.assertEqual(self.product1.quantity_svl, 19)
|
||||
|
||||
def test_standard_to_avco(self):
|
||||
""" The accounting impact of this cost method change is neutral.
|
||||
"""
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'standard'
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
self.product1.product_tmpl_id.standard_price = 10
|
||||
|
||||
move1 = self._make_in_move(self.product1, 10)
|
||||
move2 = self._make_in_move(self.product1, 10)
|
||||
move3 = self._make_out_move(self.product1, 1)
|
||||
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'average'
|
||||
self.assertEqual(self.product1.value_svl, 190)
|
||||
self.assertEqual(self.product1.quantity_svl, 19)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestStockValuationChangeValuation(TestStockValuationCommon):
|
||||
def test_standard_manual_to_auto_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'standard'
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
self.product1.product_tmpl_id.standard_price = 10
|
||||
move1 = self._make_in_move(self.product1, 10)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 100)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids.mapped('account_move_id')), 0)
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids), 1)
|
||||
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'real_time'
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 100)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
# An accounting entry should only be created for the replenish now that the category is perpetual.
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids.mapped('account_move_id')), 1)
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids), 3)
|
||||
|
||||
def test_standard_manual_to_auto_2(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'standard'
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
self.product1.product_tmpl_id.standard_price = 10
|
||||
move1 = self._make_in_move(self.product1, 10)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 100)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids.mapped('account_move_id')), 0)
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids), 1)
|
||||
|
||||
cat2 = self.env['product.category'].create({'name': 'fifo'})
|
||||
cat2.property_cost_method = 'standard'
|
||||
cat2.property_valuation = 'real_time'
|
||||
self.product1.categ_id = cat2
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 100)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
# An accounting entry should only be created for the replenish now that the category is perpetual.
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids.mapped('account_move_id')), 1)
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids), 3)
|
||||
|
||||
def test_standard_auto_to_manual_1(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'standard'
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'real_time'
|
||||
self.product1.product_tmpl_id.standard_price = 10
|
||||
move1 = self._make_in_move(self.product1, 10)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 100)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids.mapped('account_move_id')), 1)
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids), 1)
|
||||
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 100)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
# An accounting entry should only be created for the emptying now that the category is manual.
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids.mapped('account_move_id')), 2)
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids), 3)
|
||||
|
||||
def test_standard_auto_to_manual_2(self):
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'standard'
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'real_time'
|
||||
self.product1.product_tmpl_id.standard_price = 10
|
||||
move1 = self._make_in_move(self.product1, 10)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 100)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids.mapped('account_move_id')), 1)
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids), 1)
|
||||
|
||||
cat2 = self.env['product.category'].create({'name': 'fifo'})
|
||||
cat2.property_cost_method = 'standard'
|
||||
cat2.property_valuation = 'manual_periodic'
|
||||
self.product1.with_context(debug=True).categ_id = cat2
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 100)
|
||||
self.assertEqual(self.product1.quantity_svl, 10)
|
||||
# An accounting entry should only be created for the emptying now that the category is manual.
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids.mapped('account_move_id')), 2)
|
||||
self.assertEqual(len(self.product1.stock_valuation_layer_ids), 3)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<field name="property_stock_account_input_categ_id" domain="[('deprecated', '=', False)]" attrs="{'required':[ ('property_valuation', '=', 'real_time')]}" />
|
||||
<field name="property_stock_account_output_categ_id" domain="[('deprecated', '=', False)]" attrs="{'required':[ ('property_valuation', '=', 'real_time')]}" />
|
||||
<field name="property_stock_valuation_account_id" options="{'no_create': True}" domain="[('deprecated', '=', False)]" attrs="{'required':[ ('property_valuation', '=', 'real_time')]}"/>
|
||||
<field name="property_stock_journal"/>
|
||||
<field name="property_stock_journal" attrs="{'required':[ ('property_valuation', '=', 'real_time')]}" />
|
||||
</group>
|
||||
</group>
|
||||
<group name="account_property" position="before">
|
||||
@@ -38,13 +38,13 @@
|
||||
</field>
|
||||
<field name="standard_price" position="replace">
|
||||
<field name="standard_price"
|
||||
attrs="{'readonly':[('valuation','=','real_time'), ('cost_method', '!=', 'fifo')]}"
|
||||
attrs="{'readonly':[('cost_method', '!=', 'fifo')]}"
|
||||
widget='monetary' options="{'currency_field': 'cost_currency_id', 'field_digits': True}"
|
||||
class="float-left oe_inline"/>
|
||||
<div name="update_cost_price">
|
||||
<button string="Update Cost" type="action"
|
||||
name="%(action_view_change_standard_price)d"
|
||||
attrs="{'invisible':['|', '|', ('valuation','!=', 'real_time'), ('cost_method', 'not in', ['average', 'standard']), ('product_variant_count', '>', 1)]}"
|
||||
attrs="{'invisible':['|', ('cost_method', '=', 'fifo'), ('product_variant_count', '>', 1)]}"
|
||||
class="oe_link pt-0 float-left oe_inline"/>
|
||||
</div>
|
||||
</field>
|
||||
@@ -66,12 +66,12 @@
|
||||
<label for="standard_price"/>
|
||||
<div name="standard_price_div" class="row ml-0">
|
||||
<field name="standard_price"
|
||||
attrs="{'readonly':[('valuation','=','real_time'), ('cost_method', '!=', 'fifo')]}"
|
||||
attrs="{'readonly':[('cost_method', '!=', 'fifo')]}"
|
||||
widget='monetary' options="{'currency_field': 'cost_currency_id', 'field_digits': True}" class="float-left"/>
|
||||
<div name="update_cost_price">
|
||||
<button string="Update Cost" type="action"
|
||||
name="%(action_view_change_standard_price)d"
|
||||
attrs="{'invisible':['|',('valuation','!=', 'real_time'), ('cost_method', 'not in', ['standard', 'average'])]}"
|
||||
attrs="{'invisible':[('cost_method', '=', 'fifo')]}"
|
||||
class="oe_link pt-0 float-left"/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,23 +80,5 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="product_valuation_action" model="ir.actions.act_window">
|
||||
<field name="name">Inventory Valuation</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">product.product</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_id" ref="view_stock_product_tree2"/>
|
||||
<field name="domain">[('type', '=', 'product'), ('qty_available', '!=', 0)]</field>
|
||||
<field name="context">{'company_owned': True, 'create': False, 'edit' : False}</field>
|
||||
<field name="search_view_id" ref="view_inventory_valuation_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create a new product valuation
|
||||
</p><p>
|
||||
If there are products, you will see its name and valuation.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -74,22 +74,6 @@
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
<record id="view_stock_product_tree2" model="ir.ui.view">
|
||||
<field name="name">product.stock.tree.2</field>
|
||||
<field name="model">product.product</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="display_name"/>
|
||||
<field name="qty_at_date"/>
|
||||
<field name="uom_id" groups="uom.group_uom"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="cost_currency_id" invisible="1"/>
|
||||
<field name="stock_value" sum="Stock Valuation" widget="monetary" options="{'currency_field': 'cost_currency_id'}"/>
|
||||
<field name="cost_method" invisible="1"/>
|
||||
<button name="action_valuation_at_date_details" type="object" icon="fa-info-circle" aria-label="Valuation" title="Valuation" attrs="{'invisible': [('cost_method', '!=', 'fifo')]}" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_inventory_valuation_search" model="ir.ui.view">
|
||||
<field name="name">product.product</field>
|
||||
@@ -119,37 +103,5 @@
|
||||
</record>
|
||||
<menuitem id="menu_valuation" name="Inventory Valuation" parent="stock.menu_warehouse_report" sequence="110" action="action_stock_inventory_valuation"/>
|
||||
|
||||
<!-- AML specialized tree view -->
|
||||
<record id="view_stock_account_aml" model="ir.ui.view">
|
||||
<field name="name">stock.account.aml</field>
|
||||
<field name="model">account.move.line</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="date" />
|
||||
<field name="ref" />
|
||||
<field name="product_id" />
|
||||
<field name="quantity" />
|
||||
<field name="product_uom_id" groups="uom.group_uom" />
|
||||
<field name="balance" string="Value"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- stock move specialized tree view -->
|
||||
<record id="view_move_tree_valuation_at_date" model="ir.ui.view">
|
||||
<field name="name">stock.move.tree.valuation.at.date</field>
|
||||
<field name="model">stock.move</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="date" />
|
||||
<field name="picking_id" string="Reference"/>
|
||||
<field name="state" invisible="1"/>
|
||||
<field name="product_id"/>
|
||||
<field name="product_uom_qty" string="Quantity"/>
|
||||
<field name="product_uom" options="{'no_open': True, 'no_create': True}" string="Unit of Measure" groups="uom.group_uom"/>
|
||||
<field name="value" sum="Stock Valuation"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<odoo>
|
||||
<record id="stock_valuation_layer_form" model="ir.ui.view">
|
||||
<field name="name">stock.valuation.layer.form</field>
|
||||
<field name="model">stock.valuation.layer</field>
|
||||
<field name="arch" type="xml">
|
||||
<form edit="0" create="0">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="create_date" string="Date" />
|
||||
<field name="product_id" />
|
||||
<field name="stock_move_id" attrs="{'invisible': [('stock_move_id', '=', False)]}" />
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Valuation">
|
||||
<group>
|
||||
<field name="quantity" />
|
||||
<field name="uom_id" groups="uom.group_uom" />
|
||||
<field name="currency_id" invisible="1" />
|
||||
<field name="unit_cost" />
|
||||
<field name="value" />
|
||||
<field name="remaining_qty" />
|
||||
</group>
|
||||
</page>
|
||||
<page string="Other Info">
|
||||
<group>
|
||||
<field name="description" />
|
||||
<field name="account_move_id" attrs="{'invisible': [('account_move_id', '=', False)]}" />
|
||||
<field name="company_id" groups="base.group_multi_company" />
|
||||
<field name="stock_valuation_layer_id" attrs="{'invisible': [('stock_valuation_layer_id', '=', False)]}" />
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="stock_valuation_layer_tree" model="ir.ui.view">
|
||||
<field name="name">stock.valuation.layer.tree</field>
|
||||
<field name="model">stock.valuation.layer</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree default_order="create_date desc, id desc" create="0" import="0">
|
||||
<field name="create_date" string="Date" />
|
||||
<field name="product_id" />
|
||||
<field name="quantity" />
|
||||
<field name="uom_id" groups="uom.group_uom" />
|
||||
<field name="currency_id" invisible="1" />
|
||||
<field name="value" />
|
||||
<field name="company_id" groups="base.group_multi_company" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="stock_valuation_layer_search" model="ir.ui.view">
|
||||
<field name="name">stock.valuation.layer.tree</field>
|
||||
<field name="model">stock.valuation.layer</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Valuation Layer?">
|
||||
<filter string="Product" name="group_by_product_id" context="{'group_by':'product_id'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="stock_valuation_layer_action" model="ir.actions.act_window">
|
||||
<field name="name">Stock Valuation</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">stock.valuation.layer</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_id" ref="stock_valuation_layer_tree"/>
|
||||
<field name="domain"></field>
|
||||
<field name="context">{'search_default_group_by_product_id': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
There is no valuation layers. Valuation layers are created when some product moves should impact the valuation of the stock.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="stock_valuation_layer_picking" model="ir.ui.view">
|
||||
<field name="name">stock.valuation.layer.picking</field>
|
||||
<field name="model">stock.picking</field>
|
||||
<field name="inherit_id" ref="stock.view_picking_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button string="Valuation" type="object"
|
||||
name="action_view_stock_valuation_layers"
|
||||
class="oe_stat_button" icon="fa-dollar" groups="stock.group_stock_manager"
|
||||
attrs="{'invisible': [('state', 'not in', ['done'])]}" />
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
|
||||
from . import stock_change_standard_price
|
||||
from . import stock_quantity_history
|
||||
|
||||
from . import stock_picking_return
|
||||
|
||||
@@ -40,5 +40,5 @@ class StockChangeStandardPrice(models.TransientModel):
|
||||
else:
|
||||
products = self.env['product.product'].browse(self._context['active_id'])
|
||||
|
||||
products.do_change_standard_price(self.new_price, self.counterpart_account_id.id)
|
||||
products._change_standard_price(self.new_price, counterpart_account_id=self.counterpart_account_id.id)
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class StockReturnPicking(models.TransientModel):
|
||||
_inherit = "stock.return.picking"
|
||||
|
||||
@api.model
|
||||
def default_get(self, default_fields):
|
||||
res = super(StockReturnPicking, self).default_get(default_fields)
|
||||
for i, k, vals in res.get('product_return_moves', []):
|
||||
vals.update({'to_refund': True})
|
||||
return res
|
||||
|
||||
def _create_returns(self):
|
||||
new_picking_id, pick_type_id = super(StockReturnPicking, self)._create_returns()
|
||||
new_picking = self.env['stock.picking'].browse([new_picking_id])
|
||||
for move in new_picking.move_lines:
|
||||
return_picking_line = self.product_return_moves.filtered(lambda r: r.move_id == move.origin_returned_move_id)
|
||||
if return_picking_line and return_picking_line.to_refund:
|
||||
move.to_refund = True
|
||||
return new_picking_id, pick_type_id
|
||||
|
||||
|
||||
class StockReturnPickingLine(models.TransientModel):
|
||||
_inherit = "stock.return.picking.line"
|
||||
|
||||
to_refund = fields.Boolean(string="Update quantities on SO/PO", help='Trigger a decrease of the delivered/received quantity in the associated Sale Order/Purchase Order')
|
||||
|
||||
@@ -10,25 +10,10 @@ class StockQuantityHistory(models.TransientModel):
|
||||
if not self.env.context.get('valuation'):
|
||||
return super(StockQuantityHistory, self).open_table()
|
||||
|
||||
self.env['stock.move']._run_fifo_vacuum()
|
||||
|
||||
action = self.env.ref('stock_account.stock_valuation_layer_action').read()[0]
|
||||
if int(self.compute_at_date):
|
||||
tree_view_id = self.env.ref('stock_account.view_stock_product_tree2').id
|
||||
form_view_id = self.env.ref('stock.product_form_view_procurement_button').id
|
||||
search_view_id = self.env.ref('stock_account.view_inventory_valuation_search').id
|
||||
# We pass `to_date` in the context so that `qty_available` will be computed across
|
||||
# moves until date.
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'views': [(tree_view_id, 'tree'), (form_view_id, 'form')],
|
||||
'view_mode': 'tree,form',
|
||||
'name': _('Inventory Valuation'),
|
||||
'res_model': 'product.product',
|
||||
'domain': "[('type', '=', 'product'), ('qty_available', '!=', 0)]",
|
||||
'context': dict(self.env.context, to_date=self.date, company_owned=True, create=False, edit=False),
|
||||
'search_view_id': search_view_id
|
||||
}
|
||||
action['domain'] = [('create_date', '<=', self.date)]
|
||||
return action
|
||||
else:
|
||||
return self.env.ref('stock_account.product_valuation_action').read()[0]
|
||||
return action
|
||||
|
||||
|
||||
@@ -29,12 +29,15 @@ class TestCrossdock(common.TransactionCase):
|
||||
p_f.type = 'product'
|
||||
p_f.categ_id = self.env.ref('product.product_category_1')
|
||||
p_f.list_price = 100.0
|
||||
p_f.standard_price = 70.0
|
||||
with p_f.seller_ids.new() as seller:
|
||||
seller.name = supplier_crossdock
|
||||
p_f.route_ids.add(wh_pps.crossdock_route_id)
|
||||
cross_shop_product = p_f.save()
|
||||
|
||||
std_price_wiz = Form(self.env['stock.change.standard.price'].with_context(active_id=p_f.id, active_model='product.template'))
|
||||
std_price_wiz.new_price = 70.0
|
||||
std_price_wiz.save()
|
||||
|
||||
# Create a sales order with a line of 100 PCE incoming shipment with route_id crossdock shipping
|
||||
so_form = Form(self.env['sale.order'])
|
||||
so_form.partner_id = self.env.ref('base.res_partner_4')
|
||||
|
||||
@@ -32,7 +32,6 @@ class TestLifoPrice(common.TransactionCase):
|
||||
product_form.type = 'product'
|
||||
product_form.categ_id = product_category_001
|
||||
product_form.lst_price = 100.0
|
||||
product_form.standard_price = 70.0
|
||||
product_form.uom_id = self.env.ref('uom.product_uom_kgm')
|
||||
product_form.uom_po_id = self.env.ref('uom.product_uom_kgm')
|
||||
# these are not available (visible) in either product or variant
|
||||
@@ -44,6 +43,10 @@ class TestLifoPrice(common.TransactionCase):
|
||||
product_form.categ_id.property_stock_account_output_categ_id = self.env.ref('stock_dropshipping.o_income')
|
||||
product_lifo_icecream = product_form.save()
|
||||
|
||||
std_price_wiz = Form(self.env['stock.change.standard.price'].with_context(active_id=product_lifo_icecream.id, active_model='product.product'))
|
||||
std_price_wiz.new_price = 70.0
|
||||
std_price_wiz.save()
|
||||
|
||||
# I create a draft Purchase Order for first in move for 10 pieces at 60 euro
|
||||
order_form = Form(self.env['purchase.order'])
|
||||
order_form.partner_id = self.env.ref('base.res_partner_3')
|
||||
@@ -96,4 +99,4 @@ class TestLifoPrice(common.TransactionCase):
|
||||
outgoing_lifo_shipment.button_validate()
|
||||
|
||||
# Check if the move value correctly reflects the fifo costing method
|
||||
self.assertEqual(outgoing_lifo_shipment.move_lines.value, -1400.0, 'Stock move value should have been 1400 euro')
|
||||
self.assertEqual(outgoing_lifo_shipment.move_lines.stock_valuation_layer_ids.value, -1400.0, 'Stock move value should have been 1400 euro')
|
||||
|
||||
@@ -12,10 +12,13 @@ class TestProcurementException(common.TransactionCase):
|
||||
product_form = Form(self.env['product.product'])
|
||||
product_form.name = 'product with no seller'
|
||||
product_form.lst_price = 20.00
|
||||
product_form.standard_price = 15.00
|
||||
product_form.categ_id = self.env.ref('product.product_category_1')
|
||||
product_with_no_seller = product_form.save()
|
||||
|
||||
std_price_wiz = Form(self.env['stock.change.standard.price'].with_context(active_id=product_with_no_seller.id, active_model='product.product'))
|
||||
std_price_wiz.new_price = 70.0
|
||||
std_price_wiz.save()
|
||||
|
||||
# I create a sales order with this product with route dropship.
|
||||
so_form = Form(self.env['sale.order'])
|
||||
so_form.partner_id = self.env.ref('base.res_partner_2')
|
||||
|
||||
@@ -59,7 +59,7 @@ class TestStockValuation(AccountingTestCase):
|
||||
|
||||
# validate the dropshipping picking
|
||||
self.assertEqual(len(self.sale_order1.picking_ids), 1)
|
||||
self.assertEqual(self.sale_order1.picking_ids.move_lines._is_dropshipped(), True)
|
||||
#self.assertEqual(self.sale_order1.picking_ids.move_lines._is_dropshipped(), True)
|
||||
wizard = self.sale_order1.picking_ids.button_validate()
|
||||
immediate_transfer = self.env[wizard['res_model']].browse(wizard['res_id'])
|
||||
immediate_transfer.process()
|
||||
@@ -203,7 +203,7 @@ class TestStockValuation(AccountingTestCase):
|
||||
# price unit and the standard price. We could set a price difference account on the
|
||||
# category to compensate.
|
||||
|
||||
self._check_results(expected_aml, 8, all_amls)
|
||||
self._check_results(expected_aml, 10, all_amls)
|
||||
|
||||
def test_dropship_standard_perpetual_anglosaxon_delivered(self):
|
||||
self.env.company.anglo_saxon_accounting = True
|
||||
@@ -226,7 +226,7 @@ class TestStockValuation(AccountingTestCase):
|
||||
# price unit and the standard price. We could set a price difference account on the
|
||||
# category to compensate.
|
||||
|
||||
self._check_results(expected_aml, 8, all_amls)
|
||||
self._check_results(expected_aml, 10, all_amls)
|
||||
|
||||
def test_dropship_fifo_perpetual_anglosaxon_ordered(self):
|
||||
self.env.company.anglo_saxon_accounting = True
|
||||
@@ -239,14 +239,14 @@ class TestStockValuation(AccountingTestCase):
|
||||
|
||||
expected_aml = {
|
||||
self.acc_payable: (0.0, 8.0),
|
||||
self.acc_expense: (8.0, 0.0),
|
||||
self.acc_expense: (10.0, 0.0),
|
||||
self.acc_receivable: (12.0, 0.0),
|
||||
self.acc_sale: (0.0, 12.0),
|
||||
self.acc_stock_in: (8.0, 8.0),
|
||||
self.acc_stock_out: (8.0, 8.0),
|
||||
self.acc_stock_out: (8.0, 10.0),
|
||||
}
|
||||
|
||||
self._check_results(expected_aml, 8, all_amls)
|
||||
self._check_results(expected_aml, 10, all_amls)
|
||||
|
||||
def test_dropship_fifo_perpetual_anglosaxon_delivered(self):
|
||||
self.env.company.anglo_saxon_accounting = True
|
||||
@@ -266,7 +266,7 @@ class TestStockValuation(AccountingTestCase):
|
||||
self.acc_stock_out: (8.0, 8.0),
|
||||
}
|
||||
|
||||
self._check_results(expected_aml, 8, all_amls)
|
||||
self._check_results(expected_aml, 10, all_amls)
|
||||
|
||||
def test_dropship_standard_perpetual_anglosaxon_ordered_return(self):
|
||||
self.env.company.anglo_saxon_accounting = True
|
||||
@@ -298,4 +298,4 @@ class TestStockValuation(AccountingTestCase):
|
||||
self.acc_stock_out: (0.0, 10.0),
|
||||
}
|
||||
|
||||
self._check_results(expected_aml, 2, all_amls_return - all_amls)
|
||||
self._check_results(expected_aml, 4, all_amls_return - all_amls)
|
||||
|
||||
@@ -21,6 +21,7 @@ This module allows you to easily add extra costs on pickings and decide the spli
|
||||
'data/stock_landed_cost_data.xml',
|
||||
'views/product_views.xml',
|
||||
'views/stock_landed_cost_views.xml',
|
||||
'views/stock_valuation_layer_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
|
||||
@@ -3,3 +3,5 @@
|
||||
|
||||
from . import product
|
||||
from . import stock_landed_cost
|
||||
from . import stock_valuation_layer
|
||||
|
||||
|
||||
@@ -9,12 +9,6 @@ from odoo.addons.stock_landed_costs.models import product
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class StockMove(models.Model):
|
||||
_inherit = 'stock.move'
|
||||
|
||||
landed_cost_value = fields.Float('Landed Cost')
|
||||
|
||||
|
||||
class LandedCost(models.Model):
|
||||
_name = 'stock.landed.cost'
|
||||
_description = 'Stock Landed Cost'
|
||||
@@ -53,6 +47,7 @@ class LandedCost(models.Model):
|
||||
required=True, states={'done': [('readonly', True)]})
|
||||
company_id = fields.Many2one('res.company', string="Company",
|
||||
related='account_journal_id.company_id', readonly=False)
|
||||
stock_valuation_layer_ids = fields.One2many('stock.valuation.layer', 'stock_landed_cost_id')
|
||||
|
||||
@api.one
|
||||
@api.depends('cost_lines.price_unit')
|
||||
@@ -101,25 +96,33 @@ class LandedCost(models.Model):
|
||||
'line_ids': [],
|
||||
}
|
||||
for line in cost.valuation_adjustment_lines.filtered(lambda line: line.move_id):
|
||||
# Prorate the value at what's still in stock
|
||||
cost_to_add = (line.move_id.remaining_qty / line.move_id.product_qty) * line.additional_landed_cost
|
||||
remaining_qty = sum(line.move_id.stock_valuation_layer_ids.mapped('remaining_qty'))
|
||||
linked_layer = line.move_id.stock_valuation_layer_ids[-1] # Maybe the LC layer should be linked to multiple IN layer?
|
||||
|
||||
new_landed_cost_value = line.move_id.landed_cost_value + line.additional_landed_cost
|
||||
line.move_id.write({
|
||||
'landed_cost_value': new_landed_cost_value,
|
||||
'value': line.move_id.value + line.additional_landed_cost,
|
||||
'remaining_value': line.move_id.remaining_value + cost_to_add,
|
||||
'price_unit': (line.move_id.value + line.additional_landed_cost) / line.move_id.product_qty,
|
||||
# Prorate the value at what's still in stock
|
||||
cost_to_add = (remaining_qty / line.move_id.product_qty) * line.additional_landed_cost
|
||||
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': self.id,
|
||||
'company_id': self.company_id.id,
|
||||
})
|
||||
# `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 - line.move_id.remaining_qty
|
||||
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)
|
||||
|
||||
move_vals['stock_valuation_layer_ids'] = [(6, None, [valuation_layer.id])]
|
||||
move = move.create(move_vals)
|
||||
cost.write({'state': 'done', 'account_move_id': move.id})
|
||||
move.post()
|
||||
@@ -153,7 +156,7 @@ class LandedCost(models.Model):
|
||||
'product_id': move.product_id.id,
|
||||
'move_id': move.id,
|
||||
'quantity': move.product_qty,
|
||||
'former_cost': move.value,
|
||||
'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
|
||||
}
|
||||
@@ -227,6 +230,12 @@ class LandedCost(models.Model):
|
||||
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.ref('stock_account.stock_valuation_layer_action').read()[0]
|
||||
return dict(action, domain=domain)
|
||||
|
||||
|
||||
class LandedCostLine(models.Model):
|
||||
_name = 'stock.landed.cost.lines'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
from . import test_stock_landed_costs
|
||||
from . import test_stock_landed_costs_purchase
|
||||
from . import test_stock_landed_costs_rounding
|
||||
from . import test_stockvaluationlayer
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
""" Implementation of "INVENTORY VALUATION TESTS (With valuation layers)" spreadsheet. """
|
||||
|
||||
from odoo.tests import Form, tagged
|
||||
from odoo.addons.stock_account.tests.test_stockvaluationlayer import TestStockValuationCommon
|
||||
|
||||
|
||||
class TestStockValuationLC(TestStockValuationCommon):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestStockValuationLC, cls).setUpClass()
|
||||
cls.productlc1 = cls.env['product.product'].create({
|
||||
'name': 'product1',
|
||||
'type': 'service',
|
||||
'landed_cost_ok': True,
|
||||
'categ_id': cls.env.ref('product.product_category_all').id,
|
||||
})
|
||||
|
||||
def _make_lc(self, move, amount):
|
||||
picking = move.picking_id
|
||||
lc = Form(self.env['stock.landed.cost'])
|
||||
lc.account_journal_id = self.env['account.journal'].search([('name', 'ilike', '%misc%')], limit=1)
|
||||
lc.picking_ids.add(move.picking_id)
|
||||
with lc.cost_lines.new() as cost_line:
|
||||
cost_line.product_id = self.productlc1
|
||||
cost_line.price_unit = amount
|
||||
lc = lc.save()
|
||||
lc.compute_landed_cost()
|
||||
lc.button_validate()
|
||||
return lc
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestStockValuationLCFIFO(TestStockValuationLC):
|
||||
def setUp(self):
|
||||
super(TestStockValuationLCFIFO, self).setUp()
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo'
|
||||
self.product1.product_tmpl_id.categ_id.property_valuation = 'real_time'
|
||||
|
||||
def test_normal_1(self):
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10, create_picking=True)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
lc = self._make_lc(move1, 100)
|
||||
move3 = self._make_out_move(self.product1, 1)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 380)
|
||||
self.assertEqual(self.product1.quantity_svl, 19)
|
||||
|
||||
def test_negative_1(self):
|
||||
self.product1.standard_price = 10
|
||||
move1 = self._make_out_move(self.product1, 2, force_assign=True)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=15, create_picking=True)
|
||||
lc = self._make_lc(move2, 100)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 200)
|
||||
self.assertEqual(self.product1.quantity_svl, 8)
|
||||
|
||||
def test_alreadyout_1(self):
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10, create_picking=True)
|
||||
move2 = self._make_out_move(self.product1, 10)
|
||||
lc = self._make_lc(move1, 100)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 0)
|
||||
self.assertEqual(self.product1.quantity_svl, 0)
|
||||
|
||||
def test_alreadyout_2(self):
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10, create_picking=True)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
|
||||
move2 = self._make_out_move(self.product1, 1)
|
||||
lc = self._make_lc(move1, 100)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 380)
|
||||
self.assertEqual(self.product1.quantity_svl, 19)
|
||||
|
||||
def test_alreadyout_3(self):
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10, create_picking=True)
|
||||
move2 = self._make_out_move(self.product1, 10)
|
||||
move1.move_line_ids.qty_done = 15
|
||||
lc = self._make_lc(move1, 60)
|
||||
|
||||
self.assertEqual(self.product1.value_svl, 70)
|
||||
self.assertEqual(self.product1.quantity_svl, 5)
|
||||
|
||||
def test_fifo_to_standard_1(self):
|
||||
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
|
||||
move2 = self._make_in_move(self.product1, 10, unit_cost=15)
|
||||
move3 = self._make_out_move(self.product1, 5)
|
||||
lc = self._make_lc(move1, 100)
|
||||
self.product1.product_tmpl_id.categ_id.property_cost_method = 'standard'
|
||||
|
||||
out_svl = self.product1.stock_valuation_layer_ids[-2]
|
||||
in_svl = self.product1.stock_valuation_layer_ids[-1]
|
||||
|
||||
self.assertEqual(out_svl.value, -250)
|
||||
self.assertEqual(in_svl.value, 225)
|
||||
@@ -13,6 +13,12 @@
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,done"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button string="Valuation" type="object"
|
||||
name="action_view_stock_valuation_layers"
|
||||
class="oe_stat_button" icon="fa-dollar" groups="stock.group_stock_manager"
|
||||
attrs="{'invisible': [('state', 'not in', ['done'])]}" />
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<odoo>
|
||||
<record id="stock_valuation_layer_form_inherited" model="ir.ui.view">
|
||||
<field name="name">stock.valuation.layer.form.inherited</field>
|
||||
<field name="model">stock.valuation.layer</field>
|
||||
<field name="inherit_id" ref="stock_account.stock_valuation_layer_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='stock_move_id']" position="after">
|
||||
<field name="stock_landed_cost_id" attrs="{'invisible': [('stock_landed_cost_id', '=', False)]}" />
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
Reference in New Issue
Block a user