[REF] stock_account,etc: FIFO and average costing method

The new implementation of stock moves the cost of products from quants to
moves. So we have implemented the FIFO and average cost method based on
those moves instead of using quants.
 - When average price is choosed, a field average_price is set on the
   product.
 - The tests have been completed with an example of how fifo work with
   negative stock.

As the use of real price method is really rare, because of the rigor
required on traceability for such method, we removed this functionnality.
 - The tests purchase/test/fifo_price.yml have been adapted to use the
   FIFO costing method instead of real price.

The old report stock history doesn't exist anymore, and is replaced by
the move view that contains all the cost information.

The methods `get_price_unit` have been moved from the stock to
stock_account.

The methods `_set_default_price_moves` and
`set_default_price_unit_from_product` have been removed from stock.

Explaination of FIFO and average:
 - In action_done for an out move in average and fifo, the price_unit and
   stock_value are updated.
 - For FIFO, it will use the qty_remaining on the in moves to check from
   which in moves to take the stock values for the out.
 - In case of negative stock in FIFO, it puts also qty_remaining on OUTs.
 - In the case of average, it will take the cumulated value of the last
   move and divide it by the quantity in stock.  

This commit is pretty much a 'work in progress':
 - Edit 'done' moves when the costing method is FIFO doesn't trigger the
   replay the FIFO stack.
 - The landed_costs feature doesn't work.
This commit is contained in:
Josse Colpaert
2017-07-14 17:08:22 +02:00
committed by Simon Lejeune
parent 8248f0e153
commit 5e5cd08123
28 changed files with 895 additions and 615 deletions
+1 -1
View File
@@ -131,7 +131,7 @@ class ProductProduct(models.Model):
'Cost', company_dependent=True,
digits=dp.get_precision('Product Price'),
groups="base.group_user",
help="Cost of the product template used for standard stock valuation in accounting and used as a base price on purchase orders. "
help="Cost used for standard stock valuation in accounting and used as a base price on purchase orders. "
"Expressed in the default unit of measure of the product.")
volume = fields.Float('Volume', help="The volume in m3.")
weight = fields.Float(
+14 -15
View File
@@ -30,22 +30,21 @@ class StockMove(models.Model):
'Purchase Order Line', ondelete='set null', index=True, readonly=True, copy=False)
@api.multi
def get_price_unit(self):
""" Returns the unit price to store on the quant """
def _get_price_unit(self):
""" Returns the unit price for the move"""
self.ensure_one()
if self.purchase_line_id:
order = self.purchase_line_id.order_id
#if the currency of the PO is different than the company one, the price_unit on the move must be reevaluated
#(was created at the rate of the PO confirmation, but must be valuated at the rate of stock move execution)
if order.currency_id != self.company_id.currency_id:
#we don't pass the move.date in the compute() for the currency rate on purpose because
# 1) get_price_unit() is supposed to be called only through move.action_done(),
# 2) the move hasn't yet the correct date (currently it is the expected date, after
# completion of action_done() it will be now() )
price_unit = self.purchase_line_id._get_stock_move_price_unit()
self.write({'price_unit': price_unit})
return price_unit
return self.price_unit
return super(StockMove, self).get_price_unit()
line = self.purchase_line_id
order = line.order_id
price_unit = line.price_unit
if line.taxes_id:
price_unit = line.taxes_id.with_context(round=False).compute_all(price_unit, currency=line.order_id.currency_id, quantity=1.0)['total_excluded']
if line.product_uom.id != line.product_id.uom_id.id:
price_unit *= line.product_uom.factor / line.product_id.uom_id.factor
if order.currency_id != order.company_id.currency_id:
price_unit = order.currency_id.compute(price_unit, order.company_id.currency_id, round=False)
return price_unit
return super(StockMove, self)._get_price_unit()
def _prepare_extra_move_vals(self, qty):
vals = super(StockMove, self)._prepare_extra_move_vals(qty)
+12 -12
View File
@@ -41,13 +41,13 @@
-
!python {model: purchase.order, id: purchase_order_average1}: |
picking = self.picking_ids[0]
picking.do_transfer()
self.env['stock.immediate.transfer'].create({'pick_id': picking.id}).process()
-
Check the standard price of the product (average icecream).
Check the average price of the product (average icecream).
-
!python {model: product.product, id: product_average_icecream}: |
assert self.qty_available == 10.0, 'Wrong quantity in stock after first reception'
assert self.standard_price == 60.0, 'Standard price should be the price of the first reception!'
assert self.average_price == 60.0, 'Standard price should be the price of the first reception!'
-
I create a draft Purchase Order for second incoming shipment for 30 pieces at 80€
-
@@ -70,12 +70,12 @@
-
!python {model: purchase.order, id: purchase_order_average2}: |
picking = self.picking_ids[0]
picking.do_transfer()
self.env['stock.immediate.transfer'].create({'pick_id': picking.id}).process()
-
Check the standard price
-
!python {model: product.product, id: product_average_icecream}: |
assert self.standard_price == 75.0, 'After second reception, we should have an average price of 75.0 on the product'
assert self.average_price == 75.0, 'After second reception, we should have an average price of 75.0 on the product'
-
Create picking to send some goods
-
@@ -96,12 +96,12 @@
-
!python {model: stock.picking, id: outgoing_average_shipment}: |
self.action_assign()
self.do_transfer()
self.env['stock.immediate.transfer'].create({'pick_id': self.id}).process()
-
Check the standard price (60 * 10 + 30 * 80) / 40 = 75.0 did not change
-
!python {model: product.product, id: product_average_icecream}: |
assert self.standard_price == 75.0, 'Standard price should not have changed with outgoing picking!'
assert self.average_price == 75.0, 'Average price should not have changed with outgoing picking!'
assert self.qty_available == 20.0, 'Pieces were not picked correctly as the quantity on hand is wrong'
-
Make a new purchase order with 500 g Average Ice Cream at a price of 0.2€/g
@@ -110,9 +110,9 @@
partner_id: base.res_partner_3
order_line:
- product_id: product_average_icecream
product_qty: 500.0
product_uom: product.product_uom_gram
price_unit: 0.2
product_qty: 0.5
product_uom: product.product_uom_kgm
price_unit: 200
name: 'Average Ice Cream'
date_planned: !eval time.strftime('%Y-%m-%d')
-
@@ -125,10 +125,10 @@
-
!python {model: purchase.order, id: purchase_order_average3}: |
picking = self.picking_ids[0]
picking.do_transfer()
self.env['stock.immediate.transfer'].create({'pick_id': picking.id}).process()
-
Check price is (75.0*20 + 200*0.5) / 20.5 = 78.04878
-
!python {model: product.product, id: product_average_icecream}: |
assert self.qty_available == 20.5, 'Reception of purchase order in grams leads to wrong quantity in stock'
assert round(self.standard_price, 2) == 78.05, 'Standard price as average price of third reception with other UoM incorrect! Got %s instead of 78.05' % (round(self.standard_price, 2),)
assert round(self.average_price, 2) == 78.05, 'Standard price as average price of third reception with other UoM incorrect! Got %s instead of 78.05' % (round(self.standard_price, 2),)
+43 -45
View File
@@ -16,7 +16,7 @@
standard_price: 70.0
uom_id: product.product_uom_kgm
uom_po_id: product.product_uom_kgm
cost_method: real
cost_method: fifo
valuation: real_time
property_stock_account_input: o_expense
property_stock_account_output: o_income
@@ -49,12 +49,13 @@
-
!python {model: purchase.order, id: purchase_order_fifo1}: |
picking = self.picking_ids[0]
picking.do_transfer()
self.env['stock.immediate.transfer'].create({'pick_id': picking.id}).process()
-
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
-
!python {model: product.product, id: product_fifo_icecream}: |
assert self.standard_price == 70.0, 'Standard price should not have changed!'
assert self.stock_value == 500.0, 'Wrong stock value'
-
I create a draft Purchase Order for second shipment for 30 kg at 80 euro
-
@@ -77,12 +78,13 @@
-
!python {model: purchase.order, id: purchase_order_fifo2}: |
picking = self.picking_ids[0]
picking.do_transfer()
self.env['stock.immediate.transfer'].create({'pick_id': picking.id}).process()
-
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
-
!python {model: product.product, id: product_fifo_icecream}: |
assert self.standard_price == 70.0, 'Standard price as fifo price of second reception incorrect!'
assert self.stock_value == 2900.0, 'Stock valuation should be 2900'
-
Let us send some goods
-
@@ -108,12 +110,12 @@
Process the delivery of the outgoing shipment
-
!python {model: stock.picking, id: outgoing_fifo_shipment}: |
self.do_transfer()
self.env['stock.immediate.transfer'].create({'pick_id': self.id}).process()
-
Check product standard price changed to 65.0 (because last outgoing shipment was made of 10 kg at 50€ and 10 kg at 80€)
Check stock valuation is 1600
-
!python {model: product.product, id: product_fifo_icecream}: |
assert self.standard_price == 65.0, "Product price not updated accordingly. %s found instead of 65" % (self.standard_price,)
assert self.stock_value == 1600.0, 'Stock valuation should be 1600'
-
Do a delivery of an extra 500 g (delivery order)
-
@@ -125,10 +127,10 @@
!record {model: stock.move, id: outgoing_shipment_fifo_icecream_gram}:
picking_id: outgoing_fifo_shipment_uom
product_id: product_fifo_icecream
product_uom: product.product_uom_gram
product_uom: product.product_uom_kgm
location_id: stock.stock_location_stock
location_dest_id: stock.stock_location_customers
product_uom_qty: 500.0
product_uom_qty: 0.5
picking_type_id: stock.picking_type_out
-
I assign this outgoing shipment
@@ -139,20 +141,19 @@
Process the delivery of the outgoing shipment
-
!python {model: stock.picking, id: outgoing_fifo_shipment_uom}: |
self.do_transfer()
self.env['stock.immediate.transfer'].create({'pick_id': self.id}).process()
-
Check product price changed to 80.0 (because last outgoing shipment was made of 0.5 kg at 80€)
Check stock valuation and qty in stock
-
!python {model: product.product, id: product_fifo_icecream}: |
assert self.standard_price == 80.0, "Product price not updated accordingly. %s found instead of 80" % (self.standard_price,)
assert self.stock_value == 1560.0, 'Stock valuation should be 1560'
assert self.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
-
Purchase order in USD
-
!record {model: res.currency, id: new_USD}:
name: new_usd
symbol: "$²"
symbol: "$"
rate_ids:
- rate: 1.2834
name: !eval time.strftime('%Y-%m-%d')
@@ -164,8 +165,8 @@
currency_id: new_USD
order_line:
- product_id: product_fifo_icecream
product_qty: 30000
product_uom: product.product_uom_gram
product_qty: 30
product_uom: product.product_uom_kgm
price_unit: 0.150
name: 'FIFO Ice Cream'
date_planned: !eval time.strftime('%Y-%m-%d')
@@ -185,7 +186,7 @@
-
!python {model: purchase.order, id: purchase_order_fifo_usd}: |
picking = self.picking_ids[0]
picking.do_transfer()
self.env['stock.immediate.transfer'].create({'pick_id': picking.id}).process()
-
We create delivery order of 49.5 kg
-
@@ -211,12 +212,7 @@
Process the delivery of the outgoing shipment
-
!python {model: stock.picking, id: outgoing_fifo_shipment_cur}: |
self.do_transfer()
-
Check rounded price is 102 euro (because last outgoing shipment was made of 19.5kg at 80€ and 30kg at $150 (rate=1.2834)
-
!python {model: product.product, id: product_fifo_icecream}: |
assert round(self.standard_price) == 102, "Product price not updated accordingly. %s found instead of 102 (rounded values)" %(round(self.standard_price),)
self.env['stock.immediate.transfer'].create({'pick_id': self.id}).process()
-
Do a delivery of an extra 10 kg
-
@@ -242,12 +238,11 @@
Process the delivery of the outgoing shipment
-
!python {model: stock.picking, id: outgoing_fifo_shipment_ret}: |
self.do_transfer()
self.env['stock.immediate.transfer'].create({'pick_id': self.id}).process()
-
Check rounded price is 150.0 / 1.2834
-
!python {model: product.product, id: product_fifo_icecream}: |
assert round(self.standard_price) == round(150.0 / 1.2834), "Product price not updated accordingly. %s found instead of %s" %(self.standard_price, round(150.0/1.2834))
assert self.qty_available == 0.0, 'Wrong quantity in stock after first reception'
-
Let us create some outs to get negative stock for a new product using the same config
@@ -261,7 +256,7 @@
standard_price: 70.0
uom_id: product.product_uom_kgm
uom_po_id: product.product_uom_kgm
cost_method: real
cost_method: fifo
valuation: real_time
property_stock_account_input: o_expense
property_stock_account_output: o_income
@@ -287,9 +282,13 @@
Process the delivery of the first outgoing shipment
-
!python {model: stock.picking, id: outgoing_fifo_shipment_neg}: |
self.do_transfer()
self.action_confirm()
move = self.move_lines[0]
move.quantity_done = 100
self.action_done()
assert move.product_id.qty_available == -100, 'Stock qty should be -100'
-
The behavior of fifo/lifo is not garantee if the quants are created at the same second, so i just wait one second
The behavior of fifo/lifo is not guaranteed if the quants are created at the same second, so I just wait one second
-
!python {model: stock.picking, id: False}: |
import time
@@ -314,8 +313,11 @@
Process the delivery of the outgoing shipments
-
!python {model: stock.picking, id: outgoing_fifo_shipment_neg2}: |
self.do_transfer()
self.action_confirm()
move = self.move_lines[0]
move.quantity_done = 400
self.action_done()
assert move.product_id.qty_available == -500, 'Stock qty should be -500'
-
Receive purchase order with 50 kg FIFO Ice Cream at 50 euro/kg
-
@@ -338,21 +340,18 @@
-
!python {model: purchase.order, id: purchase_order_fifo_neg}: |
picking = self.picking_ids[0]
picking.do_transfer()
self.env['stock.immediate.transfer'].create({'pick_id': picking.id}).process()
original_out_move = self.env['stock.picking'].browse(ref('outgoing_fifo_shipment_neg')).move_lines[0]
assert original_out_move.remaining_qty == 50.0, 'On the out move of 100, 50 still needs to be matched'
assert original_out_move.value == 2500, 'Value of the move should be 2500'
-
Assert price on product is still the old price as the out move has not been received fully yet
-
!python {model: product.product, id: product_fifo_negative}: |
assert self.standard_price == 70, 'The product price should not have been updated'
-
Receive purchase order with 60 kg FIFO Ice Cream at 80 euro/kg
Receive purchase order with 600 kg FIFO Ice Cream at 80 euro/kg
-
!record {model: purchase.order, id: purchase_order_fifo_neg2}:
partner_id: base.res_partner_3
order_line:
- product_id: product_fifo_negative
product_qty: 60.0
product_qty: 600.0
product_uom: product.product_uom_kgm
price_unit: 80
name: 'FIFO Ice Cream'
@@ -367,9 +366,8 @@
-
!python {model: purchase.order, id: purchase_order_fifo_neg2}: |
picking = self.picking_ids[0]
picking.do_transfer()
-
The price of the product should have changed back to 65.0
-
!python {model: product.product, id: product_fifo_negative}: |
assert self.standard_price == 65.0, "Product price not updated accordingly. %s found instead of 65" %(self.standard_price,)
self.env['stock.immediate.transfer'].create({'pick_id': picking.id}).process()
original_out_move = self.env['stock.picking'].browse(ref('outgoing_fifo_shipment_neg')).move_lines[0]
assert original_out_move.value == 6500.0, 'First original out move should have 6500 as value'
assert original_out_move.product_id.stock_value == 12000.0, 'Stock Value should be 12000'
assert original_out_move.product_id.qty_available == 150.0, 'Qty available should be 150'
+8 -7
View File
@@ -9,7 +9,7 @@
categ_id: product.product_category_1
uom_id: product.product_uom_kgm
uom_po_id: product.product_uom_kgm
cost_method: real
cost_method: fifo
valuation: real_time
property_stock_account_input: o_expense
property_stock_account_output: o_income
@@ -49,7 +49,7 @@
-
!python {model: purchase.order, id: purchase_order_fiforet1}: |
picking = self.picking_ids[0]
picking.do_transfer()
self.env['stock.immediate.transfer'].create({'pick_id': picking.id}).process()
-
Check the standard price of the product (fifo icecream)
-
@@ -65,7 +65,7 @@
-
!python {model: purchase.order, id: purchase_order_fiforet2}: |
picking = self.picking_ids[0]
picking.do_transfer()
self.env['stock.immediate.transfer'].create({'pick_id': picking.id}).process()
-
Return the goods of purchase order 2
-
@@ -76,10 +76,11 @@
# Important to pass through confirmation and assignation
return_picking = self.env['stock.picking'].browse(return_picking_id)
return_picking.action_confirm()
return_picking.action_assign()
return_picking.do_transfer()
return_picking.move_lines[0].quantity_done = return_picking.move_lines[0].product_uom_qty
return_picking.action_done()
-
Check the standard price of the product changed to 80.0 as we returned the quants of purchase order 2
After the return only 10 of the second purchase order should still be in stock as it applies fifo on the return too
-
!python {model: product.product, id: product_fiforet_icecream}: |
assert self.standard_price == 80.0, 'Standard price should have changed to 80.0! %s found instead' % (self.standard_price,)
assert self.qty_available == 10.0, 'Qty available should be 10.0'
assert self.stock_value == 800, 'Stock value should be 800'
@@ -37,7 +37,7 @@ class TestPurchaseLeadTime(TestPurchase):
po_schedule_date = fields.Datetime.to_string(schedule_date)
self.assertEqual(procurement.purchase_id.date_planned, po_schedule_date, 'Schedule date should be equal to: Order date of Purchase order + Delivery Lead Time.')
# check the picking crated or not
# check the picking created or not
self.assertTrue(procurement.purchase_id.picking_ids, "Picking should be created.")
# Check scheduled date of In Type shipment
-14
View File
@@ -438,10 +438,6 @@ class StockMove(models.Model):
# Misc tools
# ------------------------------------------------------------
def get_price_unit(self):
""" Returns the unit price to store on the quant """
return self.price_unit or self.product_id.standard_price
def _filter_closed_moves(self):
""" Helper methods when having to avoid working on moves that are
already done or canceled. In a lot of cases you may handle a batch
@@ -578,7 +574,6 @@ class StockMove(models.Model):
move_waiting = self.env['stock.move']
to_assign = {}
self.set_default_price_unit_from_product()
for move in self:
# if the move is preceeded, then it's waiting (if preceeding move is done, then action_assign has been called already and its state is already available)
if move.move_orig_ids:
@@ -610,15 +605,6 @@ class StockMove(models.Model):
self._push_apply()
return self
def _set_default_price_moves(self):
return self.filtered(lambda move: not move.price_unit)
def set_default_price_unit_from_product(self):
""" Set price to move, important in inter-company moves or receipts with only one partner """
for move in self._set_default_price_moves():
move.write({'price_unit': move.product_id.standard_price})
attribute_price = set_default_price_unit_from_product
def _prepare_procurement_from_move(self):
self.ensure_one()
origin = (self.group_id and (self.group_id.name + ":") or "") + (self.rule_id and self.rule_id.name or self.origin or self.picking_id.name or "/")
+3 -6
View File
@@ -265,9 +265,6 @@
<group name="quants_grp" string="Reserved Quants" colspan="2" groups="base.group_no_one" attrs="{'invisible': [('state', '=', 'done')]}">
<field name="string_availability_info"/>
</group>
<group name="moved_quants_grp" string="Moved Quants" colspan="4" groups="base.group_no_one" attrs="{'invisible': [('state', '!=', 'done')]}">
<!--<field name="quant_ids" readonly="1" nolabel="1"/>-->
</group>
</group>
</sheet>
</form>
@@ -312,9 +309,6 @@
<group name="quants_grp" string="Reserved" attrs="{'invisible': [('state', '=', 'draft')]}">
<field name="string_availability_info"/>
</group>
<group name="moved_quants_grp" string="Moved Quants" colspan="4" groups="base.group_no_one" attrs="{'invisible': [('state', '!=', 'done')]}">
<!--<field name="quant_ids" readonly="1" nolabel="1"/>-->
</group>
</group>
</form>
</field>
@@ -334,6 +328,9 @@
<filter string="Done" name="done" domain="[('state','=','done')]" help="Stock moves that have been processed"/>
<separator/>
<filter string="Today" domain="[('date','&lt;=', datetime.datetime.combine(context_today(), datetime.time(23,59,59))), ('date','&gt;=', datetime.datetime.combine(context_today(), datetime.time(0,0,0)))]" help="Orders processed Today or planned for Today"/>
<separator/>
<filter string="Incoming" name="incoming" domain="[('location_id.usage', 'not in', ('internal', 'transit')), ('location_dest_id.usage', 'in', ('internal', 'transit'))]"/>
<filter string="Outgoing" name="outgoing" domain="[('location_id.usage', 'in', ('internal', 'transit')), ('location_dest_id.usage', 'not in', ('internal', 'transit'))]"/>
<field name="product_id"/>
<field name="name" string="Location" filter_domain="['|',('location_id','ilike',self),('location_dest_id','ilike',self)]"/>
<field name="partner_id" string="Partner" filter_domain="[('picking_id.partner_id','child_of',self)]"/>
-1
View File
@@ -2,5 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from . import report
from . import wizard
-1
View File
@@ -34,7 +34,6 @@ Dashboard / Reports for Warehouse Management includes:
'views/product_views.xml',
'views/stock_account_views.xml',
'views/stock_config_settings_views.xml',
'report/stock_history_views.xml',
'data/product_data.xml',
'data/stock_account_data.xml',
'data/stock_account_data_post_install.yml',
+89 -4
View File
@@ -4,6 +4,8 @@
from odoo import api, fields, models, tools, _
from odoo.exceptions import UserError
from odoo.tools import float_is_zero
from odoo.addons import decimal_precision as dp
class ProductTemplate(models.Model):
@@ -19,8 +21,8 @@ class ProductTemplate(models.Model):
valuation = fields.Char(compute='_compute_valuation_type', inverse='_set_valuation_type')
property_cost_method = fields.Selection([
('standard', 'Standard Price'),
('average', 'Average Price'),
('real', 'Real Price')], string='Costing Method',
('fifo', '(financial) FIFO'),
('average', 'AVCO')], string='Costing Method',
company_dependent=True, copy=True,
help="""Standard Price: The cost price is manually updated at the end of a specific period (usually once a year).
Average Price: The cost price is recomputed at each incoming shipment and used for the product valuation.
@@ -36,6 +38,19 @@ class ProductTemplate(models.Model):
company_dependent=True, domain=[('deprecated', '=', False)],
help="When doing real-time inventory valuation, counterpart journal items for all outgoing stock moves will be posted in this account, unless "
"there is a specific valuation account set on the destination location. When not set on the product, the one from the product category is used.")
average_price = fields.Float(
'Average Cost', compute='_compute_average_price',
digits=dp.get_precision('Product Price'), groups="base.group_user",
help="Average cost of the product, in the default unit of measure of the product.")
@api.multi
def _compute_average_price(self):
unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1)
for template in unique_variants:
template.average_price = template.product_variant_ids.average_price
for template in (self - unique_variants):
template.average_price = 0.0
@api.one
@api.depends('property_valuation', 'categ_id.property_valuation')
@@ -87,6 +102,15 @@ class ProductTemplate(models.Model):
class ProductProduct(models.Model):
_inherit = 'product.product'
average_price = fields.Float(
'Average Cost',
digits=dp.get_precision('Product Price'),
groups="base.group_user",
compute='_compute_average_price',
help="Calculated average cost")
stock_value = fields.Float(
'Value', compute='_compute_stock_value')
@api.onchange('type')
def onchange_type_valuation(self):
# TO REMOVE IN MASTER
@@ -139,6 +163,67 @@ class ProductProduct(models.Model):
self.write({'standard_price': new_price})
return True
def _get_latest_cumulated_value(self, not_move=False):
self.ensure_one()
# TODO: only filter on IN and OUT stock.move
domain = [
('product_id', '=', self.id),
('state', '=', 'done'),
]
if not_move:
domain += [('id', '!=', not_move.id)]
latest = self.env['stock.move'].search(domain, order='date desc, id desc', limit=1)
if not latest:
return 0.0
return latest.cumulated_value
def _get_candidates_out_move(self):
self.ensure_one()
# TODO: filter at start of period
candidates = self.env['stock.move'].search([
('product_id', '=', self.id),
('location_dest_id.usage', 'not in', ('transit', 'internal')),
('location_id.usage', 'in', ('transit', 'internal')),
('remaining_qty', '>', 0),
('state', '=', 'done')
], order='date, id') #TODO: case
return candidates
def _get_candidates_move(self):
self.ensure_one()
# TODO: filter at start of period
candidates = self.env['stock.move'].search([
('product_id', '=', self.id),
('location_dest_id.usage', 'in', ('transit', 'internal')),
('location_id.usage', 'not in', ('transit', 'internal')),
('remaining_qty', '>', 0),
('state', '=', 'done')
], order='date, id') #TODO: case where
return candidates
@api.multi
def _compute_average_price(self):
for product in self:
if product.qty_available > 0:
last_cumulated_value = product._get_latest_cumulated_value()
product.average_price = last_cumulated_value / product.qty_available
else:
product.average_price = 0
@api.multi
def _compute_stock_value(self):
for product in self:
if product.cost_method == 'standard':
product.stock_value = product.standard_price * product.qty_available
elif product.cost_method == 'average':
product.stock_value = product._get_latest_cumulated_value()
elif product.cost_method == 'fifo': #Could also do same as for average, but it would lead to more rounding errors
moves = product._get_candidates_move()
value = 0
for move in moves:
value += move.remaining_qty * move.price_unit
product.stock_value = value
class ProductCategory(models.Model):
_inherit = 'product.category'
@@ -156,8 +241,8 @@ class ProductCategory(models.Model):
"moves for incoming and outgoing products.")
property_cost_method = fields.Selection([
('standard', 'Standard Price'),
('average', 'Average Price'),
('real', 'Real Price')], string="Costing Method",
('fifo', '(financial) FIFO)'),
('average', 'AVCO')], string="Costing Method",
company_dependent=True, copy=True, required=True,
help="Standard Price: The cost price is manually updated at the end "
"of a specific period (usually once a year).\nAverage Price: "
+212 -182
View File
@@ -50,124 +50,16 @@ class StockLocation(models.Model):
"This has no effect for internal locations.")
class StockQuant(models.Model):
_inherit = "stock.quant"
class StockPackOperation(models.Model):
_inherit = 'stock.pack.operation'
@api.multi
def _compute_inventory_value(self):
real_value_quants = self.filtered(lambda quant: quant.product_id.cost_method == 'real')
for quant in real_value_quants:
quant.inventory_value = quant.cost * quant.qty
return super(StockQuant, self - real_value_quants)._compute_inventory_value()
@api.multi
def _price_update(self, newprice):
''' This function is called at the end of negative quant reconciliation
and does the accounting entries adjustemnts and the update of the product
cost price if needed '''
super(StockQuant, self)._price_update(newprice)
for quant in self:
move = quant._get_latest_move()
valuation_update = newprice - quant.cost
# this is where we post accounting entries for adjustment, if needed
# If neg quant period already closed (likely with manual valuation), skip update
if not quant.company_id.currency_id.is_zero(valuation_update) and move._check_lock_date():
quant.with_context(force_valuation_amount=valuation_update)._account_entry_move(move)
# update the standard price of the product, only if we would have
# done it if we'd have had enough stock at first, which means
# 1) the product cost's method is 'real'
# 2) we just fixed a negative quant caused by an outgoing shipment
if quant.product_id.cost_method == 'real' and quant.location_id.usage != 'internal':
move._store_average_cost_price()
def _account_entry_move(self, move):
""" Accounting Valuation Entries """
if move.product_id.type != 'product' or move.product_id.valuation != 'real_time':
# no stock valuation for consumable products
return False
if any(quant.owner_id or quant.qty <= 0 for quant in self):
# if the quant isn't owned by the company, we don't make any valuation en
# we don't make any stock valuation for negative quants because the valuation is already made for the counterpart.
# At that time the valuation will be made at the product cost price and afterward there will be new accounting entries
# to make the adjustments when we know the real cost price.
return False
location_from = move.location_id
location_to = self[0].location_id # TDE FIXME: as the accounting is based on this value, should probably check all location_to to be the same
company_from = location_from.usage == 'internal' and location_from.company_id or False
company_to = location_to and (location_to.usage == 'internal') and location_to.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 company_to and (move.location_id.usage not in ('internal', 'transit') and move.location_dest_id.usage == 'internal' or company_from != company_to):
journal_id, acc_src, acc_dest, acc_valuation = move._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(move, acc_dest, acc_valuation, journal_id)
else:
self.with_context(force_company=company_to.id)._create_account_move_line(move, acc_src, acc_valuation, journal_id)
# Create Journal Entry for products leaving the company
if company_from and (move.location_id.usage == 'internal' and move.location_dest_id.usage not in ('internal', 'transit') or company_from != company_to):
journal_id, acc_src, acc_dest, acc_valuation = move._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(move, acc_valuation, acc_src, journal_id)
else:
self.with_context(force_company=company_from.id)._create_account_move_line(move, acc_valuation, acc_dest, journal_id)
if move.company_id.anglo_saxon_accounting and move.location_id.usage == 'supplier' and move.location_dest_id.usage == 'customer':
# 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 = move._get_accounting_data_for_valuation()
self.with_context(force_company=move.company_id.id)._create_account_move_line(move, acc_src, acc_dest, journal_id)
def _create_account_move_line(self, move, credit_account_id, debit_account_id, journal_id):
# group quants by cost
quant_cost_qty = defaultdict(lambda: 0.0)
for quant in self:
quant_cost_qty[quant.cost] += quant.qty
AccountMove = self.env['account.move']
for cost, qty in pycompat.items(quant_cost_qty):
move_lines = move._prepare_account_move_line(qty, cost, 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.create({
'journal_id': journal_id,
'line_ids': move_lines,
'date': date,
'ref': move.picking_id.name})
new_account_move.post()
def _quant_create_from_move(self, qty, move, lot_id=False, owner_id=False, src_package_id=False, dest_package_id=False, force_location_from=False, force_location_to=False):
quant = super(StockQuant, self)._quant_create_from_move(qty, move, lot_id=lot_id, owner_id=owner_id, src_package_id=src_package_id, dest_package_id=dest_package_id, force_location_from=force_location_from, force_location_to=force_location_to)
quant._account_entry_move(move)
if move.product_id.valuation == 'real_time':
# If the precision required for the variable quant cost is larger than the accounting
# precision, inconsistencies between the stock valuation and the accounting entries
# may arise.
# For example, a box of 13 units is bought 15.00. If the products leave the
# stock one unit at a time, the amount related to the cost will correspond to
# round(15/13, 2)*13 = 14.95. To avoid this case, we split the quant in 12 + 1, then
# record the difference on the new quant.
# We need to make sure to able to extract at least one unit of the product. There is
# an arbitrary minimum quantity set to 2.0 from which we consider we can extract a
# unit and adapt the cost.
curr_rounding = move.company_id.currency_id.rounding
cost_rounded = float_round(quant.cost, precision_rounding=curr_rounding)
cost_correct = cost_rounded
if float_compare(quant.product_id.uom_id.rounding, 1.0, precision_digits=1) == 0\
and float_compare(quant.qty * quant.cost, quant.qty * cost_rounded, precision_rounding=curr_rounding) != 0\
and float_compare(quant.qty, 2.0, precision_rounding=quant.product_id.uom_id.rounding) >= 0:
quant_correct = quant._quant_split(quant.qty - 1.0)
cost_correct += (quant.qty * quant.cost) - (quant.qty * cost_rounded)
quant.sudo().write({'cost': cost_rounded})
quant_correct.sudo().write({'cost': cost_correct})
return quant
def _quant_update_from_move(self, move, location_dest_id, dest_package_id, lot_id=False, entire_pack=False):
res = super(StockQuant, self)._quant_update_from_move(move, location_dest_id, dest_package_id, lot_id=lot_id, entire_pack=entire_pack)
self._account_entry_move(move)
return res
def write(self, vals):
super(StockPackOperation, self).write(vals)
if 'qty_done' in vals:
for move in self.mapped('move_id').filtered(lambda m: m.state == 'done'):
move.value = move.quantity_done * move.price_unit
move.replay()
class StockMove(models.Model):
@@ -176,70 +68,161 @@ class StockMove(models.Model):
to_refund = fields.Boolean(string="To Refund (update SO/PO)",
help='Trigger a decrease of the delivered/received quantity in the associated Sale Order/Purchase Order')
def _set_default_price_moves(self):
# When the cost method is in real or average price, the price can be set to 0.0 on the PO
# So the price doesn't have to be updated
moves = super(StockMove, self)._set_default_price_moves()
return moves.filtered(lambda m: m.product_id.cost_method not in ('real', 'average'))
value = fields.Float()
cumulated_value = fields.Float()
remaining_qty = fields.Float()
last_done_move_id = fields.Many2one('stock.move')
last_done_remaining_qty = fields.Float()
last_done_qty = fields.Float()
# TODO: add constraints remaining_qty > 0
# TODO: add constrain price_unit = 0 on done move?
@api.multi
def _get_price_unit(self):
self.ensure_one()
if self.product_id.cost_method == 'average':
return self.product_id.average_price or self.product_id.standard_price
if self.product_id.cost_method == 'fifo':
move = self.search([('product_id', '=', self.product_id.id),
('state', '=', 'done'),
('location_id.usage', '=', 'internal'),
('location_dest_id.usage', '!=', 'internal')], order='date desc', limit=1)
if move:
return move.price_unit or self.product_id.standard_price
return self.product_id.standard_price
def _update_future_cumulated_value(self, value):
self.ensure_one()
moves = self.search([('state', '=', 'done'),
('date', '>', self.date),
('product_id', '=', self.product_id.id)])
for move in moves:
move.value += value
# def change_move_value_in_the_past(self, value):
# self.ensure_one()
# if self.product_id.cost_method == 'fifo':
# moves = self.search([('state', '=', 'done'),
# ('date', '>', self.date),
# ('product_id', '=', self.product_id.id)])
# if self.location_id.usage not in ('internal', 'transit'):
# if move.last_done_move_id and move.last_done_remaining_qty:
@api.multi
def replay(self):
# Easy scenario: avergae /done
# search last move before this one
start_move = self.search([('product_id', '=', self.product_id.id),
('state', '=', 'done'),
('date', '<', self.date)], limit=1, order='date desc') #filter on outgoing/incoming moves
next_moves = self.search([('product_id', '=', self.product_id.id),
('state', '=', 'done'),
('date', '>', start_move.date)], order='date') # filter on outgoing/incoming moves
if start_move:
last_cumulated_value = start_move.cumulated_value
last_done_qty_available = start_move.last_done_qty
else:
last_cumulated_value = 0.0
last_done_qty_available = 0.0
if self.product_id.cost_method == 'average':
for move in next_moves:
if move.location_id.usage in ('internal', 'transit') and move.location_dest_id.usage not in ('internal', 'transit'):
if last_done_qty_available:
move.value = - ((last_cumulated_value / last_done_qty_available) * move.product_qty)
last_done_qty_available -= move.product_qty
else:
last_done_qty_available += move.product_qty
move.cumulated_value = last_cumulated_value + move.value
last_cumulated_value = move.cumulated_value
move.last_done_qty = last_done_qty_available
# FIFO: needs dict with qty_remaining to replay algorithm
# update cumulated_value according to value
# update outs according to values
# update
@api.multi
def action_done(self):
self.product_price_update_before_done()
qty_available = {}
for move in self:
#Should write move.price_unit here maybe, certainly on incoming
if move.product_id.cost_method == 'average':
qty_available[move.product_id.id] = move.product_id.qty_available
res = super(StockMove, self).action_done()
self.product_price_update_after_done()
for move in res:
if move.location_id.usage not in ('internal', 'transit') and move.location_dest_id.usage in ('internal', 'transit'):
if move.product_id.cost_method in ['fifo', 'average']:
if not move.price_unit:
move.price_unit = move._get_price_unit()
move.value = move.price_unit * move.product_qty
move.cumulated_value = move.product_id._get_latest_cumulated_value(not_move=move) + move.value
move.remaining_qty = move.product_qty
if move.product_id.cost_method == 'fifo':
# If you find an out with qty_remaining (because of negative stock), you can change it over there
candidates_out = move.product_id._get_candidates_out_move()
qty_to_take = move.product_qty
for candidate in candidates_out:
if candidate.remaining_qty < qty_to_take:
qty_taken_on_candidate = candidate.remaining_qty
else:
qty_taken_on_candidate = qty_to_take
candidate.remaining_qty -= qty_taken_on_candidate
move.remaining_qty -= qty_taken_on_candidate
qty_to_take -= qty_taken_on_candidate
candidate.value += move.price_unit * qty_taken_on_candidate
candidate.cumulated_value += move.price_unit * qty_taken_on_candidate
candidate._update_future_cumulated_value(move.price_unit * qty_taken_on_candidate)
candidate.price_unit = candidate.value / candidate.product_qty
move.last_done_qty = move.product_id.qty_available
else:
move.price_unit = move.product_id.standard_price
move.value = move.price_unit * move.product_qty
elif move.location_id.usage in ('internal', 'transit') and move.location_dest_id.usage not in ('internal', 'transit'):
if move.product_id.cost_method == 'fifo':
qty_to_take = move.product_qty
tmp_value = 0
candidates = move.product_id._get_candidates_move()
last_candidate = False
for candidate in candidates:
if candidate.remaining_qty <= qty_to_take:
qty_taken_on_candidate = candidate.remaining_qty
else:
qty_taken_on_candidate = qty_to_take
tmp_value += qty_taken_on_candidate * candidate.price_unit
candidate.remaining_qty -= qty_taken_on_candidate
qty_to_take -= qty_taken_on_candidate
if qty_to_take == 0:
break
last_candidate = candidate
if last_candidate:
move.last_done_move_id = last_candidate.id
move.last_done_remaining_qty = last_candidate.remaining_qty
if qty_to_take > 0:
move.remaining_qty = qty_to_take # In case there are no candidates to match, put standard price on it
move.value = -tmp_value
move.cumulated_value = move.product_id._get_latest_cumulated_value(not_move=move) + move.value
move.last_done_qty = move.product_id.qty_available
elif move.product_id.cost_method == 'average':
curr_rounding = move.company_id.currency_id.rounding
avg_price_unit = float_round(move.product_id._get_latest_cumulated_value(not_move=move) / qty_available[move.product_id.id], precision_rounding=curr_rounding)
move.value = float_round(-avg_price_unit * move.product_qty, precision_rounding=curr_rounding)
move.remaining_qty = 0
move.cumulated_value = move.product_id._get_latest_cumulated_value(not_move=move) + move.value
move.last_done_qty = move.product_id.qty_available
elif move.product_id.cost_method == 'standard':
move.value = - move.product_id.standard_price * move.product_qty
for move in res.filtered(lambda m: m.product_id.valuation == 'real_time'):
move._account_entry_move()
return res
@api.multi
def product_price_update_before_done(self):
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.location_id.usage in ('supplier', 'production') and move.product_id.cost_method == 'average'):
product_tot_qty_available = move.product_id.qty_available + tmpl_dict[move.product_id.id]
# if the incoming move is for a purchase order with foreign currency, need to call this to get the same value that the quant will use.
if product_tot_qty_available <= 0:
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
new_std_price = ((amount_unit * product_tot_qty_available) + (move.get_price_unit() * move.product_qty)) / (product_tot_qty_available + move.product_qty)
tmpl_dict[move.product_id.id] += move.product_qty
# 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 product_price_update_after_done(self):
''' Adapt standard price on outgoing moves, so that a
return or an inventory loss is made using the last value used for an outgoing valuation. '''
to_update_moves = self.filtered(lambda move: move.location_dest_id.usage != 'internal')
to_update_moves._store_average_cost_price()
def _store_average_cost_price(self):
""" Store the average price of the move on the move and product form (costing method 'real')"""
for move in self.filtered(lambda move: move.product_id.cost_method == 'real'):
# product_obj = self.pool.get('product.product')
if any(q.qty <= 0 for q in move.quant_ids) or move.product_qty == 0:
# if there is a negative quant, the standard price shouldn't be updated
return
# Note: here we can't store a quant.cost directly as we may have moved out 2 units
# (1 unit to 5€ and 1 unit to 7€) and in case of a product return of 1 unit, we can't
# know which of the 2 costs has to be used (5€ or 7€?). So at that time, thanks to the
# average valuation price we are storing we will valuate it at 6€
valuation_price = sum(q.qty * q.cost for q in move.quant_ids)
average_valuation_price = valuation_price / move.product_qty
move.product_id.with_context(force_company=move.company_id.id).sudo().write({'standard_price': average_valuation_price})
move.write({'price_unit': average_valuation_price})
for move in self.filtered(lambda move: move.product_id.cost_method != 'real' and not move.origin_returned_move_id):
# Unit price of the move should be the current standard price, taking into account
# price fluctuations due to products received between move creation (e.g. at SO
# confirmation) and move set to done (delivery completed).
move.write({'price_unit': move.product_id.standard_price})
@api.multi
def _get_accounting_data_for_valuation(self):
""" Return the accounts and journal to use to post Journal Entries for
@@ -270,7 +253,7 @@ class StockMove(models.Model):
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
@@ -281,13 +264,11 @@ class StockMove(models.Model):
if self._context.get('force_valuation_amount'):
valuation_amount = self._context.get('force_valuation_amount')
else:
if self.product_id.cost_method == 'average':
valuation_amount = cost if self.location_id.usage == 'supplier' and self.location_dest_id.usage == 'internal' else self.product_id.standard_price
else:
valuation_amount = cost if self.product_id.cost_method == 'real' else self.product_id.standard_price
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 * qty)
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):
@@ -353,6 +334,56 @@ class StockMove(models.Model):
res.append((0, 0, price_diff_line))
return res
def _create_account_move_line(self, credit_account_id, debit_account_id, journal_id):
self.ensure_one()
AccountMove = self.env['account.move']
move_lines = self._prepare_account_move_line(self.product_qty, 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.create({
'journal_id': journal_id,
'line_ids': move_lines,
'date': date,
'ref': self.picking_id.name})
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 = location_from.usage == 'internal' and location_from.company_id or False
company_to = location_to and (location_to.usage == 'internal') and location_to.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 company_to and (self.location_id.usage not in ('internal', 'transit') and self.location_dest_id.usage == 'internal' or company_from != company_to):
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 company_from and (self.location_id.usage == 'internal' and self.location_dest_id.usage not in ('internal', 'transit') or company_from != company_to):
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 and self.location_id.usage == 'supplier' and self.location_dest_id.usage == 'customer':
# 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()
self.with_context(force_company=self.company_id.id)._create_account_move_line(acc_src, acc_dest, journal_id)
class StockReturnPicking(models.TransientModel):
_inherit = "stock.return.picking"
@@ -365,7 +396,6 @@ class StockReturnPicking(models.TransientModel):
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
-4
View File
@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import stock_history
@@ -1,158 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, tools
from odoo.fields import Datetime as fieldsDatetime
class StockHistory(models.Model):
_name = 'stock.history'
_auto = False
_order = 'date asc'
move_id = fields.Many2one('stock.move', 'Stock Move', required=True)
location_id = fields.Many2one('stock.location', 'Location', required=True)
company_id = fields.Many2one('res.company', 'Company')
product_id = fields.Many2one('product.product', 'Product', required=True)
product_categ_id = fields.Many2one('product.category', 'Product Category', required=True)
quantity = fields.Float('Product Quantity')
date = fields.Datetime('Operation Date')
price_unit_on_quant = fields.Float('Value')
inventory_value = fields.Float('Inventory Value', compute='_compute_inventory_value', readonly=True)
source = fields.Char('Source')
product_template_id = fields.Many2one('product.template', 'Product Template', required=True)
serial_number = fields.Char('Lot/Serial Number', required=True)
@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
res = super(StockHistory, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
if 'inventory_value' in fields:
date = self._context.get('history_date', fieldsDatetime.now())
stock_history = self.env['stock.history']
group_lines = {}
for line in res:
domain = line.get('__domain', domain)
group_lines.setdefault(str(domain), self.search(domain))
stock_history |= group_lines[str(domain)]
histories_dict = {}
not_real_cost_method_products = stock_history.mapped('product_id').filtered(lambda product: product.cost_method != 'real')
if not_real_cost_method_products:
self._cr.execute("""SELECT DISTINCT ON (product_id, company_id) product_id, company_id, cost
FROM product_price_history
WHERE product_id in %s AND datetime <= %s
ORDER BY product_id, company_id, datetime DESC, id DESC""", (tuple(not_real_cost_method_products.ids), date))
for history in self._cr.dictfetchall():
histories_dict[(history['product_id'], history['company_id'])] = history['cost']
for line in res:
inv_value = 0.0
for stock_history in group_lines.get(str(line.get('__domain', domain))):
product = stock_history.product_id
if product.cost_method == 'real':
price = stock_history.price_unit_on_quant
else:
price = histories_dict.get((product.id, stock_history.company_id.id), 0.0)
inv_value += price * stock_history.quantity
line['inventory_value'] = inv_value
return res
@api.one
def _compute_inventory_value(self):
if self.product_id.cost_method == 'real':
self.inventory_value = self.quantity * self.price_unit_on_quant
else:
self.inventory_value = self.quantity * self.product_id.get_history_price(self.company_id.id, date=self._context.get('history_date', fields.Datetime.now()))
@api.model_cr
def init(self):
tools.drop_view_if_exists(self._cr, 'stock_history')
self._cr.execute("""
CREATE VIEW stock_history AS (
SELECT MIN(id) as id,
move_id,
location_id,
company_id,
product_id,
product_categ_id,
product_template_id,
SUM(quantity) as quantity,
date,
COALESCE(SUM(price_unit_on_quant * quantity) / NULLIF(SUM(quantity), 0), 0) as price_unit_on_quant,
source,
string_agg(DISTINCT serial_number, ', ' ORDER BY serial_number) AS serial_number
FROM
((SELECT
stock_move.id AS id,
stock_move.id AS move_id,
dest_location.id AS location_id,
dest_location.company_id AS company_id,
stock_move.product_id AS product_id,
product_template.id AS product_template_id,
product_template.categ_id AS product_categ_id,
quant.qty AS quantity,
stock_move.date AS date,
quant.cost as price_unit_on_quant,
stock_move.origin AS source,
stock_production_lot.name AS serial_number
FROM
stock_quant as quant
JOIN
stock_quant_move_rel ON stock_quant_move_rel.quant_id = quant.id
JOIN
stock_move ON stock_move.id = stock_quant_move_rel.move_id
LEFT JOIN
stock_production_lot ON stock_production_lot.id = quant.lot_id
JOIN
stock_location dest_location ON stock_move.location_dest_id = dest_location.id
JOIN
stock_location source_location ON stock_move.location_id = source_location.id
JOIN
product_product ON product_product.id = stock_move.product_id
JOIN
product_template ON product_template.id = product_product.product_tmpl_id
WHERE quant.qty>0 AND stock_move.state = 'done' AND dest_location.usage in ('internal', 'transit')
AND (
not (source_location.company_id is null and dest_location.company_id is null) or
source_location.company_id != dest_location.company_id or
source_location.usage not in ('internal', 'transit'))
) UNION ALL
(SELECT
(-1) * stock_move.id AS id,
stock_move.id AS move_id,
source_location.id AS location_id,
source_location.company_id AS company_id,
stock_move.product_id AS product_id,
product_template.id AS product_template_id,
product_template.categ_id AS product_categ_id,
- quant.qty AS quantity,
stock_move.date AS date,
quant.cost as price_unit_on_quant,
stock_move.origin AS source,
stock_production_lot.name AS serial_number
FROM
stock_quant as quant
JOIN
stock_quant_move_rel ON stock_quant_move_rel.quant_id = quant.id
JOIN
stock_move ON stock_move.id = stock_quant_move_rel.move_id
LEFT JOIN
stock_production_lot ON stock_production_lot.id = quant.lot_id
JOIN
stock_location source_location ON stock_move.location_id = source_location.id
JOIN
stock_location dest_location ON stock_move.location_dest_id = dest_location.id
JOIN
product_product ON product_product.id = stock_move.product_id
JOIN
product_template ON product_template.id = product_product.product_tmpl_id
WHERE quant.qty>0 AND stock_move.state = 'done' AND source_location.usage in ('internal', 'transit')
AND (
not (dest_location.company_id is null and source_location.company_id is null) or
dest_location.company_id != source_location.company_id or
dest_location.usage not in ('internal', 'transit'))
))
AS foo
GROUP BY move_id, location_id, company_id, product_id, product_categ_id, date, source, product_template_id
)""")
@@ -1,67 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_stock_history_report_tree" model="ir.ui.view">
<field name="name">Stock Value At Date</field>
<field name="model">stock.history</field>
<field name="arch" type="xml">
<tree string="Stock Value At Date" create="0" delete="0">
<field name="location_id" invisible="1"/>
<field name="product_id" invisible="1"/>
<field name="move_id"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="date"/>
<field name="source"/>
<field name="quantity" sum="# of Products "/>
<field name="inventory_value" sum="Total Value"/>
</tree>
</field>
</record>
<record id="view_stock_history_report_pivot" model="ir.ui.view">
<field name="name">stock.history.value.pivot</field>
<field name="model">stock.history</field>
<field name="arch" type="xml">
<pivot string="Stock Value At Date">
<field name="product_id" type="row"/>
<field name="location_id" type="row"/>
<field name="quantity" type="measure"/>
</pivot>
</field>
</record>
<record id="view_stock_history_report_graph" model="ir.ui.view">
<field name="name">stock.history.value.graph</field>
<field name="model">stock.history</field>
<field name="arch" type="xml">
<graph string="Stock Value At Date">
<field name="product_id"/>
<field name="location_id"/>
<field name="quantity" type="measure"/>
</graph>
</field>
</record>
<record id="view_stock_history_report_search" model="ir.ui.view">
<field name="name">stock.history.report.search</field>
<field name="model">stock.history</field>
<field name="arch" type="xml">
<search string="Stock Value At Date">
<field name="product_id"/>
<field name="location_id" filter_domain="[('location_id', 'child_of', self)]"/>
<field name="company_id" groups="base.group_multi_company"/>
<group expand="1" string="Group By">
<filter string="Product" context="{'group_by':'product_id'}" name='group_by_product'/>
<filter string="Product Category" context="{'group_by':'product_categ_id'}"/>
<filter string="Product Template" context="{'group_by':'product_template_id'}" name="group_by_product_template"/>
<filter string="Location" context="{'group_by':'location_id'}" name='group_by_location'/>
<filter string="Stock Move" context="{'group_by':'move_id'}"/>
<filter string="Lot/Serial Number" context="{'group_by':'serial_number'}" groups="stock.group_production_lot"/>
<filter string="Company" context="{'group_by':'company_id'}" groups="base.group_multi_company"/>
</group>
</search>
</field>
</record>
<record id="action_stock_history" model="ir.actions.act_window">
<field name="name">Stock History</field>
<field name="res_model">stock.history</field>
<field name="view_type">form</field>
<field name="view_mode">tree,graph,pivot</field>
</record>
</odoo>
@@ -4,6 +4,5 @@ access_account_invoice_line_user,account.invoice.line stock user,account.model_a
access_account_invoice_tax_user,account.invoice.tax stock user,account.model_account_invoice_tax,stock.group_stock_user,1,1,1,0
access_account_journal_user,account.journal stock user,account.model_account_journal,stock.group_stock_user,1,0,0,0
access_account_account_stock_manager,account.account stock manager,account.model_account_account,stock.group_stock_manager,1,0,0,0
access_stock_history_stock_manager,stock.history stock manager,model_stock_history,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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
4 access_account_invoice_tax_user account.invoice.tax stock user account.model_account_invoice_tax stock.group_stock_user 1 1 1 0
5 access_account_journal_user account.journal stock user account.model_account_journal stock.group_stock_user 1 0 0 0
6 access_account_account_stock_manager account.account stock manager account.model_account_account stock.group_stock_manager 1 0 0 0
access_stock_history_stock_manager stock.history stock manager model_stock_history stock.group_stock_manager 1 0 0 0
7 access_stock_picking_invoicing_payments stock.picking stock.model_stock_picking account.group_account_invoice 1 1 1 0
8 access_stock_move_invoicing_payments stock.move model_stock_move account.group_account_invoice 1 1 1 0
@@ -5,12 +5,12 @@
<field name="implied_ids" eval="[(4, ref('account.group_account_invoice'))]"/>
</record>
<record model="ir.rule" id="stock_history_rule">
<!-- <record model="ir.rule" id="stock_history_rule">
<field name="name">Stock History multi-company</field>
<field name="model_id" search="[('model','=','stock.history')]" model="ir.model"/>
<field name="global" eval="True"/>
<field name="domain_force">['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])]</field>
</record>
</record> -->
</data>
</odoo>
+1
View File
@@ -0,0 +1 @@
from . import test_stockvaluation
@@ -0,0 +1,405 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests.common import TransactionCase
class TestStockValuation(TransactionCase):
def setUp(self):
super(TestStockValuation, self).setUp()
self.stock_location = self.env.ref('stock.stock_location_stock')
self.customer_location = self.env.ref('stock.stock_location_customers')
self.supplier_location = self.env.ref('stock.stock_location_suppliers')
self.uom_unit = self.env.ref('product.product_uom_unit')
self.product1 = self.env['product.product'].create({
'name': 'Product A',
'type': 'product',
'categ_id': self.env.ref('product.product_category_all').id,
})
def test_fifo_perpetual_1(self):
# http://accountingexplained.com/financial/inventories/fifo-method
self.product1.product_tmpl_id.cost_method = 'fifo'
# Beginning Inventory: 68 units @ 15.00 per unit
move1 = self.env['stock.move'].create({
'name': '68 units @ 15.00 per unit',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 68.0,
'price_unit': 15,
})
move1.action_confirm()
move1.action_assign()
move1.pack_operation_ids.qty_done = 68.0
move1.action_done()
self.assertEqual(move1.value, 1020.0)
self.assertEqual(move1.cumulated_value, 1020.0)
self.assertEqual(move1.remaining_qty, 68.0)
# Purchase 140 units @ 15.50 per unit
move2 = self.env['stock.move'].create({
'name': '140 units @ 15.50 per unit',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 140.0,
'price_unit': 15.50,
})
move2.action_confirm()
move2.action_assign()
move2.pack_operation_ids.qty_done = 140.0
move2.action_done()
self.assertEqual(move2.value, 2170.0)
self.assertEqual(move2.cumulated_value, 3190.0)
self.assertEqual(move1.remaining_qty, 68.0)
self.assertEqual(move2.remaining_qty, 140.0)
# Sale 94 units @ 19.00 per unit
move3 = self.env['stock.move'].create({
'name': 'Sale 94 units @ 19.00 per unit',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 94.0,
})
move3.action_confirm()
move3.action_assign()
move3.pack_operation_ids.qty_done = 94.0
move3.action_done()
self.assertEqual(move3.price_unit, 0.0) # unused in out moves
# note: it' ll have to get 68 units from the first batch and 26 from the second one
# so its value should be -((68*15) + (26*15.5)) = -1423
self.assertEqual(move3.value, -1423.0)
self.assertEqual(move3.cumulated_value, 1767.0)
self.assertEqual(move1.remaining_qty, 0)
self.assertEqual(move2.remaining_qty, 114)
self.assertEqual(move3.remaining_qty, 0.0) # unused in out moves
# Purchase 40 units @ 16.00 per unit
move4 = self.env['stock.move'].create({
'name': '140 units @ 15.50 per unit',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 40.0,
'price_unit': 16,
})
move4.action_confirm()
move4.action_assign()
move4.pack_operation_ids.qty_done = 40.0
move4.action_done()
self.assertEqual(move4.value, 640.0)
self.assertEqual(move4.cumulated_value, 2407.0)
self.assertEqual(move1.remaining_qty, 0)
self.assertEqual(move2.remaining_qty, 114)
self.assertEqual(move3.remaining_qty, 0.0) # unused in out moves
self.assertEqual(move4.remaining_qty, 40.0)
# Purchase 78 units @ 16.50 per unit
move5 = self.env['stock.move'].create({
'name': 'Purchase 78 units @ 16.50 per unit',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 78.0,
'price_unit': 16.5,
})
move5.action_confirm()
move5.action_assign()
move5.pack_operation_ids.qty_done = 78.0
move5.action_done()
self.assertEqual(move5.value, 1287.0)
self.assertEqual(move5.cumulated_value, 3694.0)
self.assertEqual(move1.remaining_qty, 0)
self.assertEqual(move2.remaining_qty, 114)
self.assertEqual(move3.remaining_qty, 0.0) # unused in out moves
self.assertEqual(move4.remaining_qty, 40.0)
self.assertEqual(move5.remaining_qty, 78.0)
# Sale 116 units @ 19.50 per unit
move6 = self.env['stock.move'].create({
'name': 'Sale 116 units @ 19.50 per unit',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 116.0,
})
move6.action_confirm()
move6.action_assign()
move6.pack_operation_ids.qty_done = 116.0
move6.action_done()
# note: it' ll have to get 114 units from the move2 and 2 from move4
# so its value should be -((114*15.5) + (2*16)) = 1735
self.assertEqual(move6.value, -1799.0)
self.assertEqual(move6.cumulated_value, 1895.0)
self.assertEqual(move1.remaining_qty, 0)
self.assertEqual(move2.remaining_qty, 0)
self.assertEqual(move3.remaining_qty, 0.0) # unused in out moves
self.assertEqual(move4.remaining_qty, 38.0)
self.assertEqual(move5.remaining_qty, 78.0)
self.assertEqual(move6.remaining_qty, 0.0) # unused in out moves
# Sale 62 units @ 21 per unit
move7 = self.env['stock.move'].create({
'name': 'Sale 62 units @ 21 per unit',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 62.0,
})
move7.action_confirm()
move7.action_assign()
move7.pack_operation_ids.qty_done = 62.0
move7.action_done()
# note: it' ll have to get 38 units from the move4 and 24 from move5
# so its value should be -((38*16) + (24*16.5)) = 608 + 396
self.assertEqual(move7.value, -1004.0)
self.assertEqual(move7.cumulated_value, 891.0)
self.assertEqual(move1.remaining_qty, 0)
self.assertEqual(move2.remaining_qty, 0)
self.assertEqual(move3.remaining_qty, 0.0) # unused in out moves
self.assertEqual(move4.remaining_qty, 0.0)
self.assertEqual(move5.remaining_qty, 54.0)
self.assertEqual(move6.remaining_qty, 0.0) # unused in out moves
self.assertEqual(move7.remaining_qty, 0.0) # unused in out moves
def test_fifo_perpetual_2(self):
# https://docs.google.com/spreadsheets/d/1NI0u9N1gFByXxYHfdiXuxQCrycXXOh76TpPQ3CWeyDw/edit?ts=58da749b#gid=0
self.product1.cost_method = 'fifo'
# in 10 @ 100
move1 = self.env['stock.move'].create({
'name': 'in 10 @ 100',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 10.0,
'price_unit': 100,
})
move1.action_confirm()
move1.action_assign()
move1.pack_operation_ids.qty_done = 10.0
move1.action_done()
self.assertEqual(move1.value, 1000.0)
self.assertEqual(move1.cumulated_value, 1000.0)
self.assertEqual(move1.remaining_qty, 10.0)
# in 10 @ 80
move2 = self.env['stock.move'].create({
'name': 'in 10 @ 80',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 10.0,
'price_unit': 80,
})
move2.action_confirm()
move2.action_assign()
move2.pack_operation_ids.qty_done = 10.0
move2.action_done()
self.assertEqual(move2.value, 800.0)
self.assertEqual(move2.cumulated_value, 1800.0)
self.assertEqual(move1.remaining_qty, 10.0)
self.assertEqual(move2.remaining_qty, 10.0)
# out 15
move3 = self.env['stock.move'].create({
'name': 'out 15',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 15.0,
})
move3.action_confirm()
move3.action_assign()
move3.pack_operation_ids.qty_done = 15.0
move3.action_done()
self.assertEqual(move3.price_unit, 0.0) # unused in out moves
# note: it' ll have to get 10 units from move1 and 5 from move2
# so its value should be -((10*100) + (5*80)) = -1423
self.assertEqual(move3.value, -1400.0)
self.assertEqual(move3.cumulated_value, 400.0)
self.assertEqual(move1.remaining_qty, 0)
self.assertEqual(move2.remaining_qty, 5)
self.assertEqual(move3.remaining_qty, 0.0) # unused in out moves
# in 5 @ 60
move4 = self.env['stock.move'].create({
'name': 'in 5 @ 60',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 5.0,
'price_unit': 60,
})
move4.action_confirm()
move4.action_assign()
move4.pack_operation_ids.qty_done = 5.0
move4.action_done()
self.assertEqual(move4.value, 300.0)
self.assertEqual(move4.cumulated_value, 700.0)
self.assertEqual(move1.remaining_qty, 0)
self.assertEqual(move2.remaining_qty, 5)
self.assertEqual(move3.remaining_qty, 0.0) # unused in out moves
self.assertEqual(move4.remaining_qty, 5.0)
# out 7
move5 = self.env['stock.move'].create({
'name': 'out 7',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 7.0,
})
move5.action_confirm()
move5.action_assign()
move5.pack_operation_ids.qty_done = 7.0
move5.action_done()
# note: it' ll have to get 5 units from the move2 and 2 from move4
# so its value should be -((5*80) + (2*60)) = 520
self.assertEqual(move5.value, -520.0)
self.assertEqual(move5.cumulated_value, 180.0)
self.assertEqual(move1.remaining_qty, 0)
self.assertEqual(move2.remaining_qty, 0)
self.assertEqual(move3.remaining_qty, 0.0) # unused in out moves
self.assertEqual(move4.remaining_qty, 3.0)
self.assertEqual(move5.remaining_qty, 0.0) # unused in out moves
def test_average_perpetual_1(self):
# http://accountingexplained.com/financial/inventories/avco-method
self.product1.product_tmpl_id.cost_method = 'average'
# Beginning Inventory: 60 units @ 15.00 per unit
move1 = self.env['stock.move'].create({
'name': '60 units @ 15.00 per unit',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 60.0,
'price_unit': 15,
})
move1.action_confirm()
move1.action_assign()
move1.pack_operation_ids.qty_done = 60.0
move1.action_done()
self.assertEqual(move1.value, 900.0)
self.assertEqual(move1.cumulated_value, 900.0)
self.assertEqual(move1.remaining_qty, 60.0)
# Purchase 140 units @ 15.50 per unit
move2 = self.env['stock.move'].create({
'name': '140 units @ 15.50 per unit',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 140.0,
'price_unit': 15.50,
})
move2.action_confirm()
move2.action_assign()
move2.pack_operation_ids.qty_done = 140.0
move2.action_done()
self.assertEqual(move2.value, 2170.0)
self.assertEqual(move2.cumulated_value, 3070.0)
# Sale 190 units @ 15.00 per unit
move3 = self.env['stock.move'].create({
'name': 'Sale 190 units @ 19.00 per unit',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 190.0,
})
move3.action_confirm()
move3.action_assign()
move3.pack_operation_ids.qty_done = 190.0
move3.action_done()
self.assertEqual(move3.price_unit, 0.0) # unused in out moves
self.assertEqual(move3.value, -2916.5)
self.assertEqual(move3.cumulated_value, 153.5)
# Purchase 70 units @ $16.00 per unit
move4 = self.env['stock.move'].create({
'name': '70 units @ $16.00 per unit',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 70.0,
'price_unit': 16.00,
})
move4.action_confirm()
move4.action_assign()
move4.pack_operation_ids.qty_done = 70.0
move4.action_done()
self.assertEqual(move4.value, 1120.0)
self.assertEqual(move4.cumulated_value, 1273.5)
# Sale 30 units @ $19.50 per unit
move5 = self.env['stock.move'].create({
'name': '30 units @ $19.50 per unit',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 30.0,
})
move5.action_confirm()
move5.action_assign()
move5.pack_operation_ids.qty_done = 30.0
move5.action_done()
self.assertEqual(move5.value, -477.6)
self.assertEqual(move5.cumulated_value, 795.9) # fuck you, rounding
# self.assertEqual(move5.cumulated_value, 796)
+30 -1
View File
@@ -39,7 +39,10 @@
</field>
<field name="standard_price" position="replace">
<field name="standard_price"
attrs="{'readonly':['&amp;', ('valuation','=','real_time'), ('cost_method', 'in', ['standard', 'average'])]}"
attrs="{'readonly': [('valuation','=','real_time')]}"
widget='monetary' options="{'currency_field': 'currency_id'}"/>
<field name="average_price" readonly="1"
attrs="{'invisible': [('cost_method','!=', 'average')]}"
widget='monetary' options="{'currency_field': 'currency_id'}"/>
</field>
<div name="standard_price_uom" position="after">
@@ -86,5 +89,31 @@
</data>
</field>
</record>
<record id="product_valuation_tree" model="ir.ui.view">
<field name="name">product.valuation.tree</field>
<field name="model">product.product</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
<field name="stock_value" sum="Stock Valuation"/>
</tree>
</field>
</record>
<record id="product_valuation_action" model="ir.actions.act_window">
<field name="name">Product 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="product_valuation_tree"/>
<field name="domain">[('type', '=', 'product')]</field>
<field name="context">{}</field>
<field name="help" type="html">
<p class="oe_view_nocontent_create">
If there are products, you will see its name and valuation.
</p>
</field>
</record>
</data>
</odoo>
@@ -49,5 +49,43 @@
</field>
</record>
<record id="view_move_tree_valuation" model="ir.ui.view">
<field name="name">stock.move.tree.valuation</field>
<field name="model">stock.move</field>
<field eval="8" name="priority"/>
<field name="arch" type="xml">
<tree decoration-muted="state == 'cancel'" decoration-danger="(state not in ('cancel','done')) and date > current_date" string="Moves" create="0">
<field name="name"/>
<field name="picking_id" string="Reference"/>
<field name="origin"/>
<field name="picking_type_id" invisible="1"/>
<field name="create_date" invisible="1" groups="base.group_no_one"/>
<field name="product_id"/>
<field name="price_unit"/>
<field name="product_uom_qty"/>
<field name="product_uom" options="{'no_open': True, 'no_create': True}" string="Unit of Measure" groups="product.group_uom"/>
<field name="location_id" groups="stock.group_stock_multi_locations"/>
<field name="location_dest_id" groups="stock.group_stock_multi_locations"/>
<field name="date" groups="base.group_no_one"/>
<field name="state" invisible="1"/>
<field name="value" sum="Stock Valuation"/>
</tree>
</field>
</record>
<record id="stock_move_valuation_action" model="ir.actions.act_window">
<field name="name">Stock Moves</field>
<field name="res_model">stock.move</field>
<field name="type">ir.actions.act_window</field>
<field name="view_type">form</field>
<field name="view_id" ref="view_move_tree_valuation"/>
<field name="search_view_id" ref="stock.view_move_search"/>
<field name="context">{'search_default_outgoing': 1, 'search_default_incoming': 1, 'search_default_done': 1, 'search_default_group_by_product': 1}</field>
<field name="help" type="html">
<p class="oe_view_nocontent_create">
Click to create a stock movement.
</p>
</field>
</record>
</data>
</odoo>
@@ -18,18 +18,13 @@ class WizardValuationHistory(models.TransientModel):
def open_table(self):
self.ensure_one()
if self.compute_at_date and self.date:
ctx = dict(
self._context,
history_date=self.date,
search_default_group_by_product=True,
search_default_group_by_location=True)
action = self.env['ir.model.data'].xmlid_to_object('stock_account.action_stock_history')
action = self.env['ir.model.data'].xmlid_to_object('stock_account.stock_move_valuation_action')
if not action:
action = {
'view_type': 'form',
'view_mode': 'tree,graph,pivot',
'res_model': 'stock.history',
'view_mode': 'tree',
'res_model': 'stock.move',
'type': 'ir.actions.act_window',
}
else:
@@ -37,7 +32,6 @@ class WizardValuationHistory(models.TransientModel):
action['domain'] = "[('date', '<=', '" + self.date + "')]"
action['name'] = _('Stock Value At Date')
action['context'] = ctx
else:
action = self.env.ref('stock.quantsact').read()[0]
action = self.env.ref('stock_account.product_valuation_action').read()[0]
return action
+4 -4
View File
@@ -24,10 +24,10 @@ This module allows you to easily add extra costs on pickings and decide the spli
'views/stock_config_settings_views.xml',
],
'test': [
'../account/test/account_minimal_test.xml',
'../stock_account/test/stock_valuation_account.xml',
'test/stock_landed_costs.yml',
'test/stock_landed_costs_rounding.yml',
# '../account/test/account_minimal_test.xml',
# '../stock_account/test/stock_valuation_account.xml',
# 'test/stock_landed_costs.yml',
#'test/stock_landed_costs_rounding.yml',
],
'installable': True,
'auto_install': False,
@@ -10,6 +10,17 @@ from odoo.exceptions import UserError
from odoo.tools import pycompat
class StockMove(models.Model):
_inherit = 'stock.move'
landed_cost_value = fields.Float('Landed Cost')
total_value = fields.Float('Total Value', compute='_compute_total_value')
def _compute_total_value(self):
for move in self:
move.total_value = move.value + move.landed_cost_value
class LandedCost(models.Model):
_name = 'stock.landed.cost'
_description = 'Stock Landed Cost'
@@ -86,50 +97,14 @@ class LandedCost(models.Model):
'ref': cost.name
})
for line in cost.valuation_adjustment_lines.filtered(lambda line: line.move_id):
per_unit = line.final_cost / line.quantity
diff = per_unit - line.former_cost_per_unit
# If the precision required for the variable diff is larger than the accounting
# precision, inconsistencies between the stock valuation and the accounting entries
# may arise.
# For example, a landed cost of 15 divided in 13 units. If the products leave the
# stock one unit at a time, the amount related to the landed cost will correspond to
# round(15/13, 2)*13 = 14.95. To avoid this case, we split the quant in 12 + 1, then
# record the difference on the new quant.
# We need to make sure to able to extract at least one unit of the product. There is
# an arbitrary minimum quantity set to 2.0 from which we consider we can extract a
# unit and adapt the cost.
curr_rounding = line.move_id.company_id.currency_id.rounding
diff_rounded = tools.float_round(diff, precision_rounding=curr_rounding)
diff_correct = diff_rounded
quants = line.move_id.quant_ids.sorted(key=lambda r: r.qty, reverse=True)
quant_correct = False
if quants\
and tools.float_compare(quants[0].product_id.uom_id.rounding, 1.0, precision_digits=1) == 0\
and tools.float_compare(line.quantity * diff, line.quantity * diff_rounded, precision_rounding=curr_rounding) != 0\
and tools.float_compare(quants[0].qty, 2.0, precision_rounding=quants[0].product_id.uom_id.rounding) >= 0:
# Search for existing quant of quantity = 1.0 to avoid creating a new one
quant_correct = quants.filtered(lambda r: tools.float_compare(r.qty, 1.0, precision_rounding=quants[0].product_id.uom_id.rounding) == 0)
if not quant_correct:
quant_correct = quants[0]._quant_split(quants[0].qty - 1.0)
else:
quant_correct = quant_correct[0]
quants = quants - quant_correct
diff_correct += (line.quantity * diff) - (line.quantity * diff_rounded)
diff = diff_rounded
quant_dict = {}
for quant in quants:
quant_dict[quant] = quant.cost + diff
if quant_correct:
quant_dict[quant_correct] = quant_correct.cost + diff_correct
for quant, value in pycompat.items(quant_dict):
quant.sudo().write({'cost': value})
qty_out = 0
for quant in line.move_id.quant_ids:
if quant.location_id.usage != 'internal':
qty_out += quant.qty
line._create_accounting_entries(move, qty_out)
cost_to_add = line.additional_landed_cost
line.move_id.landed_cost_value += cost_to_add
# for quant in line.move_id.quant_ids:
# if quant.location_id.usage != 'internal':
# qty_out += quant.qty
line._create_accounting_entries(move, 0) #TODO: qty_out like you would do with the different moves
move.assert_balanced()
cost.write({'state': 'done', 'account_move_id': move.id})
move.post()
@@ -157,13 +132,13 @@ class LandedCost(models.Model):
for move in self.mapped('picking_ids').mapped('move_lines'):
# it doesn't make sense to make a landed cost for a product that isn't set as being valuated in real time at real cost
if move.product_id.valuation != 'real_time' or move.product_id.cost_method != 'real':
if move.product_id.valuation != 'real_time' or move.product_id.cost_method not in ('average', 'fifo'):
continue
vals = {
'product_id': move.product_id.id,
'move_id': move.id,
'quantity': move.product_qty,
'former_cost': sum(quant.cost * quant.qty for quant in move.quant_ids),
'former_cost': move.value,
'weight': move.product_id.weight * move.product_qty,
'volume': move.product_id.volume * move.product_qty
}
@@ -5,7 +5,7 @@
-
!record {model: product.product, id: product_landed_cost_1}:
name: "LC product 1"
cost_method: real
cost_method: fifo
valuation: real_time
weight: 10
volume: 1
@@ -14,7 +14,7 @@
-
!record {model: product.product, id: product_landed_cost_2}:
name: "LC product 2"
cost_method: real
cost_method: fifo
valuation: real_time
weight: 20
volume: 1.5
@@ -13,7 +13,7 @@
-
!record {model: product.product, id: product_landed_cost_3}:
name: "LC product 3"
cost_method: real
cost_method: fifo
uom_id: product_uom_unit_round_1
valuation: real_time
property_stock_account_input: o_expense
@@ -21,7 +21,7 @@
-
!record {model: product.product, id: product_landed_cost_4}:
name: "LC product 4"
cost_method: real
cost_method: fifo
uom_id: product_uom_unit_round_1
valuation: real_time
property_stock_account_input: o_expense
@@ -62,11 +62,6 @@
self.action_confirm()
self.action_assign()
self.action_done()
quants = self.move_lines.quant_ids
assert len(quants) == 1
assert quants.qty == 13
assert quants.cost == 1.0
-
I create a landed cost for picking 3
-
@@ -101,14 +96,6 @@
!assert {model: stock.landed.cost, id: stock_landed_cost_2}:
- state == 'done'
- account_move_id
-
I check the quants quantity and cost
-
!python {model: stock.landed.cost, id: stock_landed_cost_2}: |
for valuation in self.valuation_adjustment_lines:
quants = valuation.move_id.quant_ids
assert quants.mapped('qty') == [12.0, 1.0]
assert quants.mapped('cost') == [2.15, 2.2]
-
We perform all the tests for LC_pick_4
-
@@ -120,11 +107,6 @@
self.action_confirm()
self.action_assign()
self.action_done()
quants = self.move_lines.quant_ids
assert len(quants) == 2
assert quants.mapped('qty') == [11.0, 1.0]
assert [round(c, 2) for c in quants.mapped('cost')] == [1.42, 1.38]
-
I create a landed cost for picking 4
-
@@ -159,11 +141,3 @@
!assert {model: stock.landed.cost, id: stock_landed_cost_3}:
- state == 'done'
- account_move_id
-
I check the quants quantity and cost
-
!python {model: stock.landed.cost, id: stock_landed_cost_3}: |
for valuation in self.valuation_adjustment_lines:
quants = valuation.move_id.quant_ids
assert quants.mapped('qty') == [11.0, 1.0]
assert [round(c, 2) for c in quants.mapped('cost')] == [2.34, 2.26]
+1 -1
View File
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
from . import test_stock_landed_costs_purchase
# from . import test_stock_landed_costs_purchase
+2 -2
View File
@@ -39,7 +39,7 @@ class TestStockLandedCostsCommon(AccountingTestCase):
self.product_refrigerator = self.Product.create({
'name': 'Refrigerator',
'type': 'product',
'cost_method': 'real',
'cost_method': 'fifo',
'valuation': 'real_time',
'standard_price': 1.0,
'weight': 10,
@@ -48,7 +48,7 @@ class TestStockLandedCostsCommon(AccountingTestCase):
self.product_oven = self.Product.create({
'name': 'Microwave Oven',
'type': 'product',
'cost_method': 'real',
'cost_method': 'fifo',
'valuation': 'real_time',
'standard_price': 1.0,
'weight': 20,