[MERGE] stock_account, related addons: stock valuation layer

task-1875873

closes odoo/odoo#33935

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