diff --git a/addons/purchase/doc/average.rst b/addons/purchase/doc/average.rst new file mode 100644 index 00000000000..021567f0d82 --- /dev/null +++ b/addons/purchase/doc/average.rst @@ -0,0 +1,46 @@ +Average price + + +Normal case: +------------ + += When the product is purchased by purchase order and leaves towards a customer with a delivery order. We assume also +that accounting entries are generated in real-time. + +- When the products are received, the stock move gets the unit price and UoM of the purchase order in company currency. The standard price of the product is updated with +(qty available * standard price + incoming qty * purchase price) / (qty available + incoming qty) +- In the stock journal, the accounting items will be generated based on this (price on move is price of purchase order) +- When a delivery order is made, it is going out at cost price (= average price which was updated during incoming move) +- When generating outgoing accounting entries, this is the total amount based on this average cost price + + +Case of production: +------------------- +In case of produced goods, the incoming stock move of the finished product and accounting entries can not just invent a cost price like the sum of the cost price of the parts in the BoM, as cost methods tend to be a lot more complicated than this. +When the finished good is produced, we will put the cost price from the product. + +In the product form there is a link next to the cost price where the user can update it. This will also generate accounting entries as the stock will be valued differently. + + +Case of no purchase order +------------------------- +When no purchase order is given, the price on the stock move and generated entries is the cost price on the product + + +Returned Goods / Scrap / ... +---------------------------- +For returning goods to supplier, the price on the original purchase order is put on the stock and account moves. That way, this would have the same effect as cancelling the original in move. +Scrap is an outgoing move at cost price. + + +Negative stock +-------------- +If your stock is negative and you receive, the price of the product becomes the price on the purchase order. + +Extra +----- +- standard price, costing method and valuation (real_time or manual) are properties. This means it is possible to use the same product in different companies with different price, valuation and costing methods. + +- UoMs: On the stock move, the price is in units of the stock move, so this will get converted to the product UoM and reverse + +- Currency: On the stock move, the currency is the company currency diff --git a/addons/purchase/doc/fifolifo.rst b/addons/purchase/doc/fifolifo.rst new file mode 100644 index 00000000000..18cb9371b42 --- /dev/null +++ b/addons/purchase/doc/fifolifo.rst @@ -0,0 +1,50 @@ +FIFO/LIFO + +In order to activate FIFO/LIFO, the costing method in the product form should be fifo/lifo. This is only possible when cost methods are checked under Settings > Purchase + + + +Normal case: +------------ + += When product is purchased by purchase order and leaves towards a customer with a delivery order. We assume also +that accounting entries are generated in real-time. + +- When product is received, the stock move gets the unit price and UoM of the purchase order +- In the stock journal, the accounting items will be generated based on this. +- When a delivery order is made, the FIFO/LIFO algorithm is used to check which in moves correspond to this out move. A weighted average is calculated +based on the different in moves which would theoretically have gone out according to the FIFO/LIFO algorithm. This average becomes also the new cost price on the product. +Technically, these calculated matchings are saved in stock_move_matching which makes further FIFO/LIFO calculations easier. +- When generating accounting entries, the stock.move.matching table is used, to generate 1 account move line per matching. That way, one stock move will have one account +move with multiple account move lines with the amounts from the matchings. + + +Case of production: +------------------- +In case of produced goods, the incoming stock move of the finished product and accounting entries can not just invent a cost price like the sum of the cost price of the parts in the BoM, as costing methods tend to be a lot more complicated than this. +On the stock move, we will put the cost price of the product. + +Case of no purchase order +------------------------- +When no purchase order is given, the price on the stock move and generated entries is the standard price on the product. + + +Returned Goods / Scrap / ... +---------------------------- +Returned goods to supplier have the same calculations as a normal out, same with scrap. + + +Negative stocks +--------------- +When an out move makes the stock (quantity on hand) become negative, the cost price of the product is not updated and stock move matchings will only be created for the moves that can be matched. (until stock is zero) If the quantity on hand became negative and afterwards we get an incoming move, the system will try to match the previous outgoing move(s) as much as possible with the incoming move. These matches will generate also the necessary accounting entries. + + +Inter-company +------------- +cost price, costing method and valuation (real_time or manual_periodic) are properties (are different according to the company) => when you receive/ship goods, it depends on the company of the stock move as what costing method needs to be used. + +Not possible to create a stock move between two locations of different companies. You need a transit location in between. This new constraint removes the requirement for a currency or two prices on one stock move. + +UoMs: As quantities need to be matched between outgoing and ingoing stock moves which can have different UoMs, it will take all these conversions into account + +Currency: On the stock move, the currency is the company currency diff --git a/addons/purchase/test/fifo_price.yml b/addons/purchase/test/fifo_price.yml index ec07782652e..d6bb16cff8f 100644 --- a/addons/purchase/test/fifo_price.yml +++ b/addons/purchase/test/fifo_price.yml @@ -121,8 +121,7 @@ Check 2 stock move matchings were created - !python {model: stock.picking}: | - if len(self.browse(cr, uid, ref("outgoing_fifo_shipment")).move_lines[0].matching_ids_out) != 2: - print 'Should have created 2 matchings' + assert len(self.browse(cr, uid, ref("outgoing_fifo_shipment")).move_lines[0].matching_ids_out) == 2, "It should have created 2 " - Check product standard price changed to 65.0 - @@ -206,11 +205,7 @@ - !python {model: stock.partial.picking}: | pick_ids = self.pool.get('purchase.order').browse(cr, uid, ref("purchase_order_fifo_usd")).picking_ids - print pick_ids - print uid, context - print self.pool.get('res.users').browse(cr, uid, uid).company_id.id partial_id = self.create(cr, uid, {}, context={'active_model': 'stock.picking','active_ids': [pick_ids[0].id]}) - print partial_id self.do_partial(cr, uid, [partial_id]) - We create delivery order of 49.500 kg @@ -236,17 +231,7 @@ Check rounded price is 102 euro = (30 kg * ) - !python {model: product.product}: | - print round(self.browse(cr, uid, ref("product_fifo_icecream")).standard_price, 2) assert round(self.browse(cr, uid, ref("product_fifo_icecream")).standard_price) == 102 -- - Print price -- - !python {model: product.product}: | - print self.browse(cr, uid, ref("product_fifo_icecream")).standard_price - print self.browse(cr, uid, ref("product_fifo_icecream")).qty_available - list = self.pool.get("stock.move").search(cr, uid, [('product_id','=', ref("product_fifo_icecream"))]) - for move in self.pool.get("stock.move").browse(cr, uid, list): - print move.price_unit, move.product_qty, move.qty_remaining, move.type, move.date - Do a delivery of an extra 10 kg - @@ -276,16 +261,6 @@ - !python {model: product.product}: | assert round(self.browse(cr, uid, ref("product_fifo_icecream")).standard_price) == round(150.0 / 1.2834) -- - Print price -- - !python {model: product.product}: | - print self.browse(cr, uid, ref("product_fifo_icecream")).standard_price - print self.browse(cr, uid, ref("product_fifo_icecream")).qty_available - print self.browse(cr, uid, ref("product_fifo_icecream"), context={'force_company': 1}).qty_available - list = self.pool.get("stock.move").search(cr, uid, [('product_id','=', ref("product_fifo_icecream"))]) - for move in self.pool.get("stock.move").browse(cr, uid, list): - print move.price_unit, move.product_qty, move.qty_remaining, move.type, move.date - Let us create some outs to get negative stock. Create outpicking. We create delivery order of 200 kg, but will pick only 100 kg - @@ -326,16 +301,6 @@ line = self.browse(cr, uid, partial_id, context=context).move_ids[0] self.pool.get("stock.partial.picking.line").write(cr, uid, [line.id], {'quantity':50}) self.do_partial(cr, uid, [partial_id]) -- - Print price -- - !python {model: product.product}: | - print self.browse(cr, uid, ref("product_fifo_icecream")).standard_price - print self.browse(cr, uid, ref("product_fifo_icecream")).qty_available - print self.browse(cr, uid, ref("product_fifo_icecream"), context={'force_company': 1}).qty_available - list = self.pool.get("stock.move").search(cr, uid, [('product_id','=', ref("product_fifo_icecream"))]) - for move in self.pool.get("stock.move").browse(cr, uid, list): - print move.price_unit, move.product_qty, move.qty_remaining, move.type, move.date - Receive purchase order with 50 kg FIFO Ice Cream at 50 euro/kg - @@ -393,9 +358,4 @@ The price of the product should have changed back to 65.0 - !python {model: product.product}: | - print self.browse(cr, uid, ref("product_fifo_icecream")).standard_price - print self.browse(cr, uid, ref("product_fifo_icecream")).qty_available - print self.browse(cr, uid, ref("product_fifo_icecream"), context={'force_company': 1}).qty_available - list = self.pool.get("stock.move").search(cr, uid, [('product_id','=', ref("product_fifo_icecream"))], order='date') - for move in self.pool.get("stock.move").browse(cr, uid, list): - print move.price_unit, move.product_qty, move.qty_remaining, move.type, move.date \ No newline at end of file + assert self.browse(cr, uid, ref("product_fifo_icecream")).standard_price == 65.0 \ No newline at end of file diff --git a/addons/purchase/test/fifo_returns.yml b/addons/purchase/test/fifo_returns.yml index e3c7c78badc..37656b89c86 100644 --- a/addons/purchase/test/fifo_returns.yml +++ b/addons/purchase/test/fifo_returns.yml @@ -75,7 +75,6 @@ return_id = self.create(cr, uid, {}, context={'active_model':'stock.picking', 'active_id': pick_ids[0].id}) res = self.create_returns(cr, uid, [return_id], context={'active_id': pick_ids[0].id}) return_move_id = self.pool.get("stock.move").search(cr, uid, [('move_returned_from','=', pick_ids[0].move_lines[0].id)]) - movepick_obj = self.pool.get("stock.partial.move") movepick_id = movepick_obj.create(cr, uid, {}, context={'active_model': 'stock.move', 'active_ids': return_move_id}) movepick_obj.do_partial(cr, uid, [movepick_id]) @@ -83,15 +82,4 @@ Check the standard price of the product (fifo return icecream) changed to 70.0 - !python {model: product.product}: | - print self.browse(cr, uid, ref("product_fiforet_icecream")).standard_price - assert self.browse(cr, uid, ref("product_fiforet_icecream")).standard_price == 70.0, 'Standard price should have changed to 70.0!' -- - Print price -- - !python {model: product.product}: | - print self.browse(cr, uid, ref("product_fiforet_icecream")).standard_price - print self.browse(cr, uid, ref("product_fiforet_icecream")).qty_available - print self.browse(cr, uid, ref("product_fiforet_icecream"), context={'force_company': 1}).qty_available - list = self.pool.get("stock.move").search(cr, uid, [('product_id','=', ref("product_fiforet_icecream"))]) - for move in self.pool.get("stock.move").browse(cr, uid, list): - print move.price_unit, move.product_qty, move.qty_remaining, move.type, move.date \ No newline at end of file + assert self.browse(cr, uid, ref("product_fiforet_icecream")).standard_price == 70.0, 'Standard price should have changed to 70.0!' \ No newline at end of file diff --git a/addons/stock/product.py b/addons/stock/product.py index 3f1e6b365f4..69eb6dccabb 100644 --- a/addons/stock/product.py +++ b/addons/stock/product.py @@ -462,7 +462,6 @@ class product_product(osv.osv): 'track_outgoing': fields.boolean('Track Outgoing Lots', help="Forces to specify a Serial Number for all moves containing this product and going to a Customer Location"), 'location_id': fields.dummy(string='Location', relation='stock.location', type='many2one'), 'warehouse_id': fields.dummy(string='Warehouse', relation='stock.warehouse', type='many2one'), - #TODO: why first arg is empty? 'valuation':fields.property(type='selection', selection=[('manual_periodic', 'Periodical (manual)'), ('real_time','Real Time (automated)'),], string = 'Inventory Valuation', help="If real-time valuation is enabled for a product, the system will automatically write journal entries corresponding to stock moves." \ diff --git a/addons/stock/report/report_stock_move_view.xml b/addons/stock/report/report_stock_move_view.xml index bff58158ef3..8f621d3375d 100644 --- a/addons/stock/report/report_stock_move_view.xml +++ b/addons/stock/report/report_stock_move_view.xml @@ -277,7 +277,7 @@ {'contact_display': 'partner', 'search_default_real':1, 'search_default_location_type_internal':1,'group_by':[]} Stock Valuation Analysis allows you to easily check and analyse your company stock levels. The difference with the Inventory Analysis, is that - the stock is calculated based on the prices of the stock moves depending on the costing method of the product (FIFO/LIFO/Average/standard) and not just the standard price of the product. + the stock is calculated based on the prices of the stock moves depending on the cost method of the product (FIFO/LIFO/Average/standard) and not just the standard price of the product. Sort and group by selection criteria in order to better analyse and manage your company activities. diff --git a/addons/stock/stock.py b/addons/stock/stock.py index 49fbe0e8fc2..aef14b3abe0 100644 --- a/addons/stock/stock.py +++ b/addons/stock/stock.py @@ -2330,7 +2330,6 @@ class stock_move(osv.osv): else: account_moves += [(journal_id, self._create_account_move_line(cr, uid, move, matches, acc_src, acc_valuation, reference_amount, reference_currency_id, 'in', context=company_ctx))] if matches and move.product_id.cost_method in ('fifo', 'lifo'): - print "Generate accounting entries of negative matches" outs = {} match_obj = self.pool.get("stock.move.matching") for match in match_obj.browse(cr, uid, matches, context=context): @@ -2443,6 +2442,7 @@ class stock_move(osv.osv): 'name': move.name, 'product_id': move.product_id and move.product_id.id or False, 'quantity': item[0], + 'product_uom_id': move.product_uom.id, 'ref': move.picking_id and move.picking_id.name or False, 'date': time.strftime('%Y-%m-%d'), 'partner_id': partner_id, @@ -2453,32 +2453,13 @@ class stock_move(osv.osv): 'name': move.name, 'product_id': move.product_id and move.product_id.id or False, 'quantity': item[0], + 'product_uom_id': move.product_uom.id, 'ref': move.picking_id and move.picking_id.name or False, 'date': time.strftime('%Y-%m-%d'), 'partner_id': partner_id, 'credit': item[1], 'account_id': src_account_id, } - # if we are posting to accounts in a different currency, provide correct values in both currencies correctly - # when compatible with the optional secondary currency on the account. - # Financial Accounts only accept amounts in secondary currencies if there's no secondary currency on the account - # or if it's the same as that of the secondary amount being posted. - #TODO -> might need to be changed still for fifolifo - account_obj = self.pool.get('account.account') - src_acct, dest_acct = account_obj.browse(cr, uid, [src_account_id, dest_account_id], context=context) - src_main_currency_id = src_acct.currency_id and src_acct.currency_id.id or src_acct.company_id.currency_id.id - dest_main_currency_id = dest_acct.currency_id and dest_acct.currency_id.id or dest_acct.company_id.currency_id.id - cur_obj = self.pool.get('res.currency') - if reference_currency_id != src_main_currency_id: - # fix credit line: - credit_line_vals['credit'] = cur_obj.compute(cr, uid, reference_currency_id, src_main_currency_id, reference_amount, context=context) - if (not src_acct.currency_id) or src_acct.currency_id.id == reference_currency_id: - credit_line_vals.update(currency_id=reference_currency_id, amount_currency=-reference_amount) - if reference_currency_id != dest_main_currency_id: - # fix debit line: - debit_line_vals['debit'] = cur_obj.compute(cr, uid, reference_currency_id, dest_main_currency_id, reference_amount, context=context) - if (not dest_acct.currency_id) or dest_acct.currency_id.id == reference_currency_id: - debit_line_vals.update(currency_id=reference_currency_id, amount_currency=reference_amount) res += [(0, 0, debit_line_vals), (0, 0, credit_line_vals)] return res @@ -2689,25 +2670,25 @@ class stock_move(osv.osv): for move in self.browse(cr, uid, ids, context=context): # Initialize variables res[move.id] = [] - product_qty = move.product_qty - product_uom = move.product_uom.id + move_qty = move.product_qty + move_uom = move.product_uom.id company_id = move.company_id.id ctx = context.copy() user = self.pool.get('res.users').browse(cr, uid, uid, context=context) ctx['force_company'] = move.company_id.id product = product_obj.browse(cr, uid, move.product_id.id, context=ctx) cost_method = product.cost_method - product_uom_qty = uom_obj._compute_qty(cr, uid, product_uom, product_qty, product.uom_id.id, round=False) + product_uom_qty = uom_obj._compute_qty(cr, uid, move_uom, move_qty, product.uom_id.id, round=False) if not product.id in product_avail: - product_avail[product.id] = product.qty_available + product_avail[product.id] = product.qty_available # Check if out -> do stock move matchings and if fifo/lifo -> update price # only update the cost price on the product form on stock moves of type == 'out' because if a valuation has to be made without PO, # for inventories for example we want to use the last value used for an outgoing move if move.location_id.usage == 'internal' and move.location_dest_id.usage != 'internal': fifo = (cost_method != 'lifo') - tuples = product_obj.get_stock_matchings_fifolifo(cr, uid, [product.id], product_qty, fifo, - product_uom, move.company_id.currency_id.id, context=ctx) #TODO Would be better to use price_currency_id for migration? + tuples = product_obj.get_stock_matchings_fifolifo(cr, uid, [product.id], move_qty, fifo, + move_uom, move.company_id.currency_id.id, context=ctx) #TODO Would be better to use price_currency_id for migration? price_amount = 0.0 amount = 0.0 #Write stock matchings @@ -2725,12 +2706,11 @@ class stock_move(osv.osv): self.write(cr, uid, move.id, {'price_unit': price_amount / amount}, context=context) product_obj.write(cr, uid, product.id, {'standard_price': price_amount / product_uom_qty}, context=ctx) else: - raise osv.except_osv(_('Error'), "Something went wrong finding stock moves " + str(tuples) + str(self.search(cr, uid, [('company_id','=', company_id), ('qty_remaining', '>', 0), ('state', '=', 'done'), + raise osv.except_osv(_('Error'), _("Something went wrong finding stock moves ") + str(tuples) + str(self.search(cr, uid, [('company_id','=', company_id), ('qty_remaining', '>', 0), ('state', '=', 'done'), ('location_id.usage', '!=', 'internal'), ('location_dest_id.usage', '=', 'internal'), ('product_id', '=', product.id)], - order = 'date, id', context=context)) + str(product_qty) + str(product_uom) + str(move.company_id.currency_id.id)) + order = 'date, id', context=context)) + str(move_qty) + str(move_uom) + str(move.company_id.currency_id.id)) else: - new_price = uom_obj._compute_price(cr, uid, product.uom_id.id, product.standard_price, - product_uom) + new_price = uom_obj._compute_price(cr, uid, product.uom_id.id, product.standard_price, move_uom) self.write(cr, uid, move.id, {'price_unit': new_price}, context=ctx) #Adjust product_avail when not average and move returned from if (not move.move_returned_from or product.cost_method != 'average'): @@ -2739,16 +2719,16 @@ class stock_move(osv.osv): #Check if in => if price 0.0, take standard price / Update price when average price and price on move != standard price if move.location_id.usage != 'internal' and move.location_dest_id.usage == 'internal': if move.price_unit == 0.0: - new_price = uom_obj._compute_price(cr, uid, product.uom_id.id, product.standard_price, product_uom) + new_price = uom_obj._compute_price(cr, uid, product.uom_id.id, product.standard_price, move_uom) self.write(cr, uid, move.id, {'price_unit': new_price}, context=ctx) elif product.cost_method == 'average': if product_avail[product.id] >= 0.0: #TODO: Could put > instead amount_unit = product.standard_price - move_product_price = uom_obj._compute_price(cr, uid, product_uom, move.price_unit, product.uom_id.id) + move_product_price = uom_obj._compute_price(cr, uid, move_uom, move.price_unit, product.uom_id.id) new_std_price = ((amount_unit * product_avail[product.id])\ + (move_product_price * product_uom_qty))/(product_avail[product.id] + product_uom_qty) else: - new_std_price = move.price_unit + new_std_price = uom_obj._compute_price(cr, uid, move_uom, move.price_unit, product.uom_id.id) product_obj.write(cr, uid, [product.id], {'standard_price': new_std_price}, context=ctx) # Should create the stock move matchings for previous outs for the negative stock that can be matched with is in if product_avail[product.id] < 0.0: @@ -2765,7 +2745,6 @@ class stock_move(osv.osv): qty = out_qty_converted elif qty_to_go > 0.0: qty = qty_to_go - #revert_qty = uom_obj._compute_qty(cr, uid, move.product_uom.id, qty, out_mov.product_uom.id) revert_qty = (qty / out_qty_converted) * out_mov.qty_remaining matchvals = {'move_in_id': move.id, 'qty': revert_qty, 'move_out_id': out_mov.id} @@ -2785,7 +2764,7 @@ class stock_move(osv.osv): if amount >= out_mov.product_qty: product_obj.write(cr, uid, [product.id], {'standard_price': total_price / amount}, context=ctx) product_avail[product.id] += product_uom_qty - #The return of average products at average price could be made optional + #The return of average products at average price (could be made optional) if move.location_id.usage == 'internal' and move.location_dest_id.usage != 'internal' and cost_method == 'average' and move.move_returned_from: move_orig = move.move_returned_from new_price = uom_obj._compute_price(cr, uid, move_orig.product_uom, move_orig.price_unit, product.uom_id.id)