mirror of
https://github.com/OCA/manufacture.git
synced 2025-01-28 16:37:15 +02:00
Merge pull request #113 from cyrilgdn/mrp_bom_dismantling
[ADD] new module mrp_bom_dismantling
This commit is contained in:
59
mrp_bom_dismantling/README.rst
Normal file
59
mrp_bom_dismantling/README.rst
Normal file
@@ -0,0 +1,59 @@
|
||||
.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
|
||||
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
|
||||
:alt: License: AGPL-3
|
||||
|
||||
===============
|
||||
BOM Dismantling
|
||||
===============
|
||||
|
||||
This module adds the ability to create a dismantling BOM by reversing a BOM.
|
||||
|
||||
|
||||
Usage
|
||||
=====
|
||||
|
||||
|
||||
* On BOM form view, click on "Create dismantling BOM" button and it will reverse your BOM.
|
||||
* In Manufacturing -> Products, there is a new menu "Dismantling".
|
||||
* In dismantling tree view, you can search by dismantled product.
|
||||
* On BOM form view, there is a new button "Create Manufacturing Order".
|
||||
|
||||
.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas
|
||||
:alt: Try me on Runbot
|
||||
:target: https://runbot.odoo-community.org/runbot/129/9.0
|
||||
|
||||
|
||||
Bug Tracker
|
||||
===========
|
||||
|
||||
Bugs are tracked on `GitHub Issues
|
||||
<https://github.com/OCA/manufacture/issues>`_. In case of trouble, please
|
||||
check there if your issue has already been reported. If you spotted it first,
|
||||
help us smashing it by providing a detailed and welcomed `feedback
|
||||
<https://github.com/OCA/
|
||||
manufacture/issues/new?body=module:%20
|
||||
mrp_bom_dismantling%0Aversion:%20
|
||||
9.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
|
||||
|
||||
Credits
|
||||
=======
|
||||
|
||||
Contributors
|
||||
------------
|
||||
|
||||
* Camptocamp - Cyril Gaudin <cyril.gaudin@camptocamp.com>
|
||||
|
||||
Maintainer
|
||||
----------
|
||||
|
||||
.. image:: https://odoo-community.org/logo.png
|
||||
:alt: Odoo Community Association
|
||||
:target: https://odoo-community.org
|
||||
|
||||
This module is maintained by the OCA.
|
||||
|
||||
OCA, or the Odoo Community Association, is a nonprofit organization whose
|
||||
mission is to support the collaborative development of Odoo features and
|
||||
promote its widespread use.
|
||||
|
||||
To contribute to this module, please visit https://odoo-community.org.
|
||||
6
mrp_bom_dismantling/__init__.py
Normal file
6
mrp_bom_dismantling/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# © 2016 Cyril Gaudin (Camptocamp)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import models
|
||||
from . import wizards
|
||||
23
mrp_bom_dismantling/__openerp__.py
Normal file
23
mrp_bom_dismantling/__openerp__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# © 2016 Cyril Gaudin (Camptocamp)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
{
|
||||
"name": "BOM Dismantling",
|
||||
"summary": "Ability to create a dismantling BOM by reversing a BOM.",
|
||||
"version": "9.0.1.0.0",
|
||||
"category": "Manufacturing",
|
||||
"website": "http://www.camptocamp.com/",
|
||||
"author": "Camptocamp, Odoo Community Association (OCA)",
|
||||
"license": "AGPL-3",
|
||||
"application": False,
|
||||
"installable": True,
|
||||
"depends": [
|
||||
'mrp_byproduct',
|
||||
"stock_available_mrp",
|
||||
],
|
||||
"data": [
|
||||
"views/mrp_bom.xml",
|
||||
"views/product_template.xml",
|
||||
"wizards/mrp_product_produce.xml",
|
||||
],
|
||||
}
|
||||
60
mrp_bom_dismantling/i18n/de.po
Normal file
60
mrp_bom_dismantling/i18n/de.po
Normal file
@@ -0,0 +1,60 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * mrp_bom_dismantling
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 9.0c\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2016-04-04 11:23+0200\n"
|
||||
"PO-Revision-Date: 2016-04-06 15:55+0200\n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: Poedit 1.8.4\n"
|
||||
"Last-Translator: \n"
|
||||
"Language: de\n"
|
||||
|
||||
#. module: mrp_bom_dismantling
|
||||
#: model:ir.model,name:mrp_bom_dismantling.model_mrp_bom
|
||||
msgid "Bill of Material"
|
||||
msgstr "Stücklisten"
|
||||
|
||||
#. module: mrp_bom_dismantling
|
||||
#: model:ir.ui.view,arch_db:mrp_bom_dismantling.mrp_bom_form_view
|
||||
msgid "Create Manufacturing Order"
|
||||
msgstr "Fertigungsauftrag erstellen"
|
||||
|
||||
#. module: mrp_bom_dismantling
|
||||
#: model:ir.ui.view,arch_db:mrp_bom_dismantling.mrp_bom_form_view
|
||||
msgid "Create dismantling BoM"
|
||||
msgstr "Stücklisten umrüsten erstellen"
|
||||
|
||||
#. module: mrp_bom_dismantling
|
||||
#: model:ir.model.fields,field_description:mrp_bom_dismantling.field_mrp_bom_dismantled_product_id
|
||||
msgid "Dismantled product"
|
||||
msgstr "Produkt zu umrüsten"
|
||||
|
||||
#. module: mrp_bom_dismantling
|
||||
#: model:ir.actions.act_window,name:mrp_bom_dismantling.mrp_bom_dismantling_form_action
|
||||
#: model:ir.model.fields,field_description:mrp_bom_dismantling.field_mrp_bom_dismantling
|
||||
#: model:ir.ui.menu,name:mrp_bom_dismantling.menu_mrp_bom_dismantling
|
||||
msgid "Dismantling"
|
||||
msgstr "Umrüsten"
|
||||
|
||||
#. module: mrp_bom_dismantling
|
||||
#: sql_constraint:mrp.bom:0
|
||||
msgid "Dismantling BoM should have a dismantled product."
|
||||
msgstr ""
|
||||
|
||||
#. module: mrp_bom_dismantling
|
||||
#: model:ir.model,name:mrp_bom_dismantling.model_product_product
|
||||
msgid "Product"
|
||||
msgstr "Produkt"
|
||||
|
||||
#. module: mrp_bom_dismantling
|
||||
#: model:ir.model,name:mrp_bom_dismantling.model_product_template
|
||||
msgid "Product Template"
|
||||
msgstr "Produktvorlage"
|
||||
59
mrp_bom_dismantling/i18n/mrp_bom_dismantling.pot
Normal file
59
mrp_bom_dismantling/i18n/mrp_bom_dismantling.pot
Normal file
@@ -0,0 +1,59 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * mrp_bom_dismantling
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 9.0c\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2016-04-04 09:17+0000\n"
|
||||
"PO-Revision-Date: 2016-04-04 09:17+0000\n"
|
||||
"Last-Translator: <>\n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: mrp_bom_dismantling
|
||||
#: model:ir.model,name:mrp_bom_dismantling.model_mrp_bom
|
||||
msgid "Bill of Material"
|
||||
msgstr ""
|
||||
|
||||
#. module: mrp_bom_dismantling
|
||||
#: model:ir.ui.view,arch_db:mrp_bom_dismantling.mrp_bom_form_view
|
||||
msgid "Create Manufacturing Order"
|
||||
msgstr ""
|
||||
|
||||
#. module: mrp_bom_dismantling
|
||||
#: model:ir.ui.view,arch_db:mrp_bom_dismantling.mrp_bom_form_view
|
||||
msgid "Create dismantling BoM"
|
||||
msgstr ""
|
||||
|
||||
#. module: mrp_bom_dismantling
|
||||
#: model:ir.model.fields,field_description:mrp_bom_dismantling.field_mrp_bom_dismantled_product_id
|
||||
msgid "Dismantled product"
|
||||
msgstr ""
|
||||
|
||||
#. module: mrp_bom_dismantling
|
||||
#: model:ir.actions.act_window,name:mrp_bom_dismantling.mrp_bom_dismantling_form_action
|
||||
#: model:ir.model.fields,field_description:mrp_bom_dismantling.field_mrp_bom_dismantling
|
||||
#: model:ir.ui.menu,name:mrp_bom_dismantling.menu_mrp_bom_dismantling
|
||||
msgid "Dismantling"
|
||||
msgstr ""
|
||||
|
||||
#. module: mrp_bom_dismantling
|
||||
#: sql_constraint:mrp.bom:0
|
||||
msgid "Dismantling BoM should have a dismantled product."
|
||||
msgstr ""
|
||||
|
||||
#. module: mrp_bom_dismantling
|
||||
#: model:ir.model,name:mrp_bom_dismantling.model_product_product
|
||||
msgid "Product"
|
||||
msgstr ""
|
||||
|
||||
#. module: mrp_bom_dismantling
|
||||
#: model:ir.model,name:mrp_bom_dismantling.model_product_template
|
||||
msgid "Product Template"
|
||||
msgstr ""
|
||||
|
||||
8
mrp_bom_dismantling/models/__init__.py
Normal file
8
mrp_bom_dismantling/models/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# © 2016 Cyril Gaudin (Camptocamp)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import mrp_bom
|
||||
from . import product_product
|
||||
from . import product_template
|
||||
from . import stock_move
|
||||
139
mrp_bom_dismantling/models/mrp_bom.py
Normal file
139
mrp_bom_dismantling/models/mrp_bom.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# © 2016 Cyril Gaudin (Camptocamp)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from openerp import _, api, exceptions, fields, models
|
||||
|
||||
|
||||
class MrpBom(models.Model):
|
||||
_inherit = 'mrp.bom'
|
||||
|
||||
dismantling = fields.Boolean(string='Dismantling', default=False)
|
||||
dismantled_product_id = fields.Many2one(
|
||||
comodel_name='product.product',
|
||||
string='Dismantled product'
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('bom_dismantled_product_id',
|
||||
'CHECK(dismantled_product_id is not null = dismantling)',
|
||||
"Dismantling BoM should have a dismantled product."),
|
||||
]
|
||||
|
||||
@api.multi
|
||||
def create_mrp_production(self):
|
||||
""" Create a manufacturing order from this BoM
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
product = self._get_bom_product()
|
||||
|
||||
production = self.env['mrp.production'].create({
|
||||
'bom_id': self.id,
|
||||
'product_id': product.id,
|
||||
'product_qty': self.product_qty,
|
||||
'product_uom': self.product_uom.id,
|
||||
})
|
||||
|
||||
return self._get_form_view('mrp.production', production)
|
||||
|
||||
@api.multi
|
||||
def create_dismantling_bom(self):
|
||||
""" Create a dismantling BoM based on this BoM
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
self._check_bom_validity(check_dismantling=True)
|
||||
|
||||
product = self._get_bom_product()
|
||||
components = self._get_components_tuples()
|
||||
|
||||
# Create the BoM on first component (sorted by Id)
|
||||
first_component, first_component_needs = components.pop(0)
|
||||
dismantling_bom = self.create({
|
||||
'product_tmpl_id': first_component.product_tmpl_id.id,
|
||||
'product_id': first_component.id,
|
||||
'dismantling': True,
|
||||
'dismantled_product_id': product.id,
|
||||
'product_qty': first_component_needs,
|
||||
})
|
||||
|
||||
# Create BoM line for self.product_tmpl_id
|
||||
self.env['mrp.bom.line'].create({
|
||||
'bom_id': dismantling_bom.id,
|
||||
'product_id': product.id,
|
||||
'product_qty': self.product_qty,
|
||||
'product_uom': self.product_uom.id,
|
||||
})
|
||||
|
||||
# Add others component as By-products
|
||||
subproduct_model = self.env['mrp.subproduct']
|
||||
for component, needs in components:
|
||||
subproduct_model.create({
|
||||
'bom_id': dismantling_bom.id,
|
||||
'product_id': component.id,
|
||||
'product_qty': needs,
|
||||
'product_uom': self.env.ref('product.product_uom_unit').id,
|
||||
})
|
||||
|
||||
return self._get_form_view('mrp.bom', dismantling_bom)
|
||||
|
||||
def _get_form_view(self, model_name, entity):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_type': 'form',
|
||||
'view_mode': 'form',
|
||||
'res_model': model_name,
|
||||
'target': 'current',
|
||||
'res_id': entity.id,
|
||||
'context': self.env.context
|
||||
}
|
||||
|
||||
def _check_bom_validity(self, check_dismantling=False):
|
||||
""" Ensure this BoM can be use for creating a dismantling BoM
|
||||
or a manufacturing order.
|
||||
|
||||
:type check_dismantling: bool
|
||||
:raise exceptions.UserError: If this BoM is not valid.
|
||||
"""
|
||||
warning = None
|
||||
if check_dismantling and self.dismantling:
|
||||
warning = 'This BoM is already a dismantling Bom.'
|
||||
|
||||
if not len(self.bom_line_ids):
|
||||
warning = 'This BoM does not have components.'
|
||||
|
||||
if not self.product_id \
|
||||
and len(self.product_tmpl_id.product_variant_ids) > 1:
|
||||
warning = 'This product has several variants: ' \
|
||||
'you need to specify one.'
|
||||
|
||||
if warning:
|
||||
raise exceptions.UserError(_(warning))
|
||||
|
||||
def _get_components_tuples(self):
|
||||
""" Return this BoM components and their needed qties
|
||||
sorted by component id.
|
||||
|
||||
The result is like [(component_1, 1), (component_2, 5), ...]
|
||||
|
||||
:rtype: list of tuple
|
||||
"""
|
||||
components = self.product_id._get_components_needs(
|
||||
product=self.product_id, bom=self
|
||||
)
|
||||
components = sorted(components.items(), key=lambda t: t[0].id)
|
||||
return components
|
||||
|
||||
def _get_bom_product(self):
|
||||
""" Get the product of this BoM.
|
||||
|
||||
If BoM does not have product_id, return first template variant.
|
||||
|
||||
:rtype: product_product
|
||||
"""
|
||||
if not self.product_id:
|
||||
product = self.product_tmpl_id.product_variant_ids[0]
|
||||
else:
|
||||
product = self.product_id
|
||||
return product
|
||||
19
mrp_bom_dismantling/models/product_product.py
Normal file
19
mrp_bom_dismantling/models/product_product.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# © 2016 Cyril Gaudin (Camptocamp)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from openerp import models
|
||||
|
||||
|
||||
class ProductProduct(models.Model):
|
||||
_inherit = 'product.product'
|
||||
|
||||
def action_view_bom(self, cr, uid, ids, context=None):
|
||||
""" Override parent method to add a domain which filter out
|
||||
dismantling BoM
|
||||
"""
|
||||
result = super(ProductProduct, self).action_view_bom(
|
||||
cr, uid, ids, context
|
||||
)
|
||||
result['domain'] = [('dismantling', '=', False)]
|
||||
return result
|
||||
22
mrp_bom_dismantling/models/product_template.py
Normal file
22
mrp_bom_dismantling/models/product_template.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# © 2016 Cyril Gaudin (Camptocamp)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from openerp import api, fields, models
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = 'product.template'
|
||||
|
||||
bom_count = fields.Integer(compute='_bom_count',
|
||||
string='# Bill of Material')
|
||||
|
||||
@api.multi
|
||||
def _bom_count(self):
|
||||
""" Override parent method to filter out dismantling bom.
|
||||
"""
|
||||
for template in self:
|
||||
template.bom_count = self.env['mrp.bom'].search_count([
|
||||
('product_tmpl_id', '=', template.id),
|
||||
('dismantling', '=', False),
|
||||
])
|
||||
26
mrp_bom_dismantling/models/stock_move.py
Normal file
26
mrp_bom_dismantling/models/stock_move.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# © 2016 Cyril Gaudin (Camptocamp)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from openerp import api, models
|
||||
|
||||
|
||||
class StockMove(models.Model):
|
||||
_inherit = 'stock.move'
|
||||
|
||||
@api.multi
|
||||
def action_consume(self, product_qty, location_id=False,
|
||||
restrict_lot_id=False, restrict_partner_id=False,
|
||||
consumed_for=False):
|
||||
""" Override restrict_lot_id if user define one for this move's
|
||||
product in wizard.
|
||||
"""
|
||||
# If user define a lot_id for this move's product we override
|
||||
restrict_lot_id = self.env.context.get('mapping_move_lot', {}).pop(
|
||||
self.id, restrict_lot_id
|
||||
)
|
||||
|
||||
return super(StockMove, self).action_consume(
|
||||
product_qty, location_id, restrict_lot_id,
|
||||
restrict_partner_id, consumed_for
|
||||
)
|
||||
9
mrp_bom_dismantling/tests/__init__.py
Normal file
9
mrp_bom_dismantling/tests/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# © 2016 Cyril Gaudin (Camptocamp)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
|
||||
from . import test_bom
|
||||
from . import test_product
|
||||
from . import test_product_produce
|
||||
from . import test_template
|
||||
249
mrp_bom_dismantling/tests/test_bom.py
Normal file
249
mrp_bom_dismantling/tests/test_bom.py
Normal file
@@ -0,0 +1,249 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# © 2016 Cyril Gaudin (Camptocamp)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from openerp import exceptions
|
||||
from openerp.tests import TransactionCase
|
||||
|
||||
|
||||
class TestBom(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestBom, self).setUp()
|
||||
|
||||
self.product_model = self.env['product.product']
|
||||
self.bom_model = self.env['mrp.bom']
|
||||
self.bom_line_model = self.env['mrp.bom.line']
|
||||
self.mrp_production_model = self.env['mrp.production']
|
||||
|
||||
self.unit_uom = self.browse_ref('product.product_uom_unit')
|
||||
self.dozen_uom = self.browse_ref('product.product_uom_dozen')
|
||||
|
||||
def check_result_and_load_entity(self, model_name, result):
|
||||
entity_id = result.pop('res_id')
|
||||
self.assertEqual({
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_type': 'form',
|
||||
'view_mode': 'form',
|
||||
'res_model': model_name,
|
||||
'target': 'current',
|
||||
'context': self.env.context
|
||||
}, result)
|
||||
|
||||
return self.env[model_name].browse(entity_id)
|
||||
|
||||
def create_bom(self, product, qty=1, uom=None,
|
||||
phantom=False, components=None):
|
||||
bom = self.bom_model.create({
|
||||
'product_tmpl_id': product.product_tmpl_id.id,
|
||||
'product_id': product.id,
|
||||
'product_qty': qty,
|
||||
'product_uom': self.unit_uom.id if uom is None else uom.id,
|
||||
'type': 'phantom' if phantom else 'normal',
|
||||
})
|
||||
|
||||
if components:
|
||||
for component in components:
|
||||
self.create_bom_line(bom, component)
|
||||
return bom
|
||||
|
||||
def create_bom_line(self, bom, component, qty=1, uom=None):
|
||||
self.bom_line_model.create({
|
||||
'bom_id': bom.id,
|
||||
'product_id': component.id,
|
||||
'product_qty': qty,
|
||||
'product_uom': self.unit_uom.id if uom is None else uom.id,
|
||||
})
|
||||
|
||||
def test_dismantling_no_components(self):
|
||||
p1 = self.product_model.create({'name': 'Test P1'})
|
||||
p2 = self.product_model.create({'name': 'Test P1'})
|
||||
|
||||
p1_bom = self.create_bom(p1)
|
||||
|
||||
with self.assertRaises(exceptions.UserError):
|
||||
p1_bom.create_dismantling_bom()
|
||||
|
||||
self.create_bom_line(p1_bom, p2)
|
||||
p1_bom.create_dismantling_bom()
|
||||
|
||||
def test_dismantling_on_dismantling_bom(self):
|
||||
p1 = self.product_model.create({'name': 'Test P1'})
|
||||
p2 = self.product_model.create({'name': 'Test P1'})
|
||||
|
||||
p1_bom = self.create_bom(p1, components=[p2])
|
||||
p1_bom.write({
|
||||
'dismantling': True,
|
||||
'dismantled_product_id': p2.id
|
||||
})
|
||||
|
||||
with self.assertRaises(exceptions.UserError):
|
||||
p1_bom.create_dismantling_bom()
|
||||
|
||||
def test_dismantling_bom_no_product_id__multiple_vaiants(self):
|
||||
p1 = self.product_model.create({'name': 'Test P1'})
|
||||
p1_var = self.product_model.create({
|
||||
'product_tmpl_id': p1.product_tmpl_id.id,
|
||||
})
|
||||
p2 = self.product_model.create({'name': 'Test P2'})
|
||||
|
||||
# P1 BoM: Need one P2
|
||||
p1_bom = self.bom_model.create({
|
||||
'product_tmpl_id': p1.product_tmpl_id.id,
|
||||
'product_id': None,
|
||||
})
|
||||
self.create_bom_line(p1_bom, p2)
|
||||
|
||||
# No variant specified (and template has multiple variants)
|
||||
with self.assertRaises(exceptions.UserError):
|
||||
p1_bom.create_dismantling_bom()
|
||||
|
||||
# Variant specified
|
||||
p1_bom.product_id = p1_var
|
||||
result = p1_bom.create_dismantling_bom()
|
||||
self.check_result_and_load_entity('mrp.bom', result)
|
||||
|
||||
def test_dismantling_bom_no_product_id(self):
|
||||
# Same tests but BoM only have a product_tmpl_id, no product_id
|
||||
# (Seems to be the standard case)
|
||||
|
||||
p1 = self.product_model.create({'name': 'Test P1'})
|
||||
p2 = self.product_model.create({'name': 'Test P2'})
|
||||
|
||||
# P1 BoM: Need one P2
|
||||
p1_bom = self.bom_model.create({
|
||||
'product_tmpl_id': p1.product_tmpl_id.id,
|
||||
'product_id': None,
|
||||
})
|
||||
self.create_bom_line(p1_bom, p2)
|
||||
|
||||
result = p1_bom.create_dismantling_bom()
|
||||
|
||||
dmtl_bom = self.check_result_and_load_entity('mrp.bom', result)
|
||||
self.assertEqual(p2.id, dmtl_bom.product_id.id)
|
||||
self.assertEqual(True, dmtl_bom.dismantling)
|
||||
self.assertEqual(p1.id, dmtl_bom.dismantled_product_id.id)
|
||||
self.assertEqual(p2.product_tmpl_id.id, dmtl_bom.product_tmpl_id.id)
|
||||
|
||||
# Consume p1
|
||||
self.assertEqual(1, len(dmtl_bom.bom_line_ids))
|
||||
self.assertEqual(p1.id, dmtl_bom.bom_line_ids[0].product_id.id)
|
||||
|
||||
def test_dismantling_simple_case(self):
|
||||
p1 = self.product_model.create({'name': 'Test P1'})
|
||||
p2 = self.product_model.create({'name': 'Test P2'})
|
||||
p3 = self.product_model.create({'name': 'Test P3'})
|
||||
|
||||
# P1 BoM: Need one P2 and one P3
|
||||
p1_bom = self.create_bom(p1, components=[p2, p3])
|
||||
|
||||
result = p1_bom.create_dismantling_bom()
|
||||
|
||||
dmtl_bom = self.check_result_and_load_entity('mrp.bom', result)
|
||||
self.assertEqual(p2.id, dmtl_bom.product_id.id)
|
||||
self.assertEqual(p2.product_tmpl_id.id, dmtl_bom.product_tmpl_id.id)
|
||||
self.assertEqual(True, dmtl_bom.dismantling)
|
||||
self.assertEqual(p1.id, dmtl_bom.dismantled_product_id.id)
|
||||
|
||||
# Consume p1
|
||||
self.assertEqual(1, len(dmtl_bom.bom_line_ids))
|
||||
self.assertEqual(p1.id, dmtl_bom.bom_line_ids[0].product_id.id)
|
||||
|
||||
# P3 in by-products
|
||||
self.assertEqual(1, len(dmtl_bom.sub_products))
|
||||
self.assertEqual(p3.id, dmtl_bom.sub_products[0].product_id.id)
|
||||
|
||||
def test_phantom_bom(self):
|
||||
p1 = self.product_model.create({'name': 'Test P1'})
|
||||
p2 = self.product_model.create({'name': 'Test P2'})
|
||||
sub_p2 = self.product_model.create({'name': 'Test sub P2'})
|
||||
p3 = self.product_model.create({'name': 'Test P3'})
|
||||
sub_p3 = self.product_model.create({'name': 'Test sub P3'})
|
||||
|
||||
# P1 BoM: Need one P2 and one P3
|
||||
# P2 has a phantom BoM which need one sub P2
|
||||
# P3 has a normal Bom which need one sub p3
|
||||
p1_bom = self.create_bom(p1, components=[p2, p3])
|
||||
self.create_bom(p2, phantom=True, components=[sub_p2])
|
||||
self.create_bom(p3, components=[sub_p3])
|
||||
|
||||
result = p1_bom.create_dismantling_bom()
|
||||
|
||||
dmtl_bom = self.check_result_and_load_entity('mrp.bom', result)
|
||||
self.assertEqual(sub_p2.id, dmtl_bom.product_id.id)
|
||||
self.assertEqual(sub_p2.product_tmpl_id.id,
|
||||
dmtl_bom.product_tmpl_id.id)
|
||||
self.assertEqual(True, dmtl_bom.dismantling)
|
||||
self.assertEqual(p1.id, dmtl_bom.dismantled_product_id.id)
|
||||
|
||||
# Consume p1self.assertEqual(1, len(dmtl_bom.bom_line_ids))
|
||||
self.assertEqual(p1.id, dmtl_bom.bom_line_ids[0].product_id.id)
|
||||
|
||||
# Sub P3 in by-products
|
||||
self.assertEqual(1, len(dmtl_bom.sub_products))
|
||||
self.assertEqual(p3.id, dmtl_bom.sub_products[0].product_id.id)
|
||||
|
||||
def test_multi_unit_components(self):
|
||||
p1 = self.product_model.create({'name': 'Test P1'})
|
||||
p2 = self.product_model.create({'name': 'Test P2'})
|
||||
p3 = self.product_model.create({'name': 'Test P3'})
|
||||
p4 = self.product_model.create({'name': 'Test P4'})
|
||||
|
||||
# P1 BoM (produced 1 dozen): Needs 2 P2, 4 P3 and 2 dozen P4
|
||||
# P2 has a phantom BoM which need one Dozen of P3
|
||||
#
|
||||
# => Dismantling BoM:
|
||||
# Product: P3 (produced 28 unit)
|
||||
# Component: 1 dozen P1
|
||||
|
||||
p1_bom = self.create_bom(p1, qty=1, uom=self.dozen_uom)
|
||||
self.create_bom_line(p1_bom, p2, qty=2)
|
||||
self.create_bom_line(p1_bom, p3, qty=4)
|
||||
self.create_bom_line(p1_bom, p4, qty=2, uom=self.dozen_uom)
|
||||
|
||||
p2_bom = self.create_bom(p2, phantom=True)
|
||||
self.create_bom_line(p2_bom, p3, qty=1, uom=self.dozen_uom)
|
||||
|
||||
result = p1_bom.create_dismantling_bom()
|
||||
|
||||
dmtl_bom = self.check_result_and_load_entity('mrp.bom', result)
|
||||
self.assertEqual(p3.id, dmtl_bom.product_id.id)
|
||||
self.assertEqual(28, dmtl_bom.product_qty)
|
||||
self.assertEqual(self.unit_uom, dmtl_bom.product_uom)
|
||||
self.assertEqual(True, dmtl_bom.dismantling)
|
||||
self.assertEqual(p1.id, dmtl_bom.dismantled_product_id.id)
|
||||
|
||||
# Consume 1 dozen p1
|
||||
self.assertEqual(1, len(dmtl_bom.bom_line_ids))
|
||||
|
||||
dmtl_bom_line = dmtl_bom.bom_line_ids[0]
|
||||
self.assertEqual(p1.id, dmtl_bom_line.product_id.id)
|
||||
self.assertEqual(1, dmtl_bom_line.product_qty)
|
||||
self.assertEqual(self.dozen_uom, dmtl_bom_line.product_uom)
|
||||
|
||||
# Byproducts
|
||||
self.assertEqual(1, len(dmtl_bom.sub_products))
|
||||
|
||||
dmtl_sub_product = dmtl_bom.sub_products[0]
|
||||
self.assertEqual(p4.id, dmtl_sub_product.product_id.id)
|
||||
self.assertEqual(24, dmtl_sub_product.product_qty)
|
||||
self.assertEqual(self.unit_uom, dmtl_sub_product.product_uom)
|
||||
|
||||
def test_create_mrp_production(self):
|
||||
p1 = self.product_model.create({'name': 'Test P1'})
|
||||
p2 = self.product_model.create({'name': 'Test P2'})
|
||||
bom = self.create_bom(p1, qty=2, uom=self.dozen_uom, components=[p2])
|
||||
|
||||
self.assertEqual(
|
||||
0,
|
||||
self.mrp_production_model.search_count([('bom_id', '=', bom.id)])
|
||||
)
|
||||
|
||||
result = bom.create_mrp_production()
|
||||
mrp_prod = self.check_result_and_load_entity('mrp.production', result)
|
||||
|
||||
self.assertEqual(bom, mrp_prod.bom_id)
|
||||
self.assertEqual(p1, mrp_prod.product_id)
|
||||
self.assertEqual(2, mrp_prod.product_qty)
|
||||
self.assertEqual(2, mrp_prod.product_qty)
|
||||
self.assertEqual(self.dozen_uom, mrp_prod.product_uom)
|
||||
14
mrp_bom_dismantling/tests/test_product.py
Normal file
14
mrp_bom_dismantling/tests/test_product.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# © 2016 Cyril Gaudin (Camptocamp)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from openerp.tests import TransactionCase
|
||||
|
||||
|
||||
class TestProduct(TransactionCase):
|
||||
|
||||
def test_action_view_bom(self):
|
||||
# Covering test
|
||||
p1 = self.env['product.product'].create({'name': 'Test P1'})
|
||||
result = p1.action_view_bom()
|
||||
self.assertIn(('dismantling', '=', False), result['domain'])
|
||||
96
mrp_bom_dismantling/tests/test_product_produce.py
Normal file
96
mrp_bom_dismantling/tests/test_product_produce.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# -*- coding: utf8 -*-
|
||||
|
||||
from openerp.tests import TransactionCase
|
||||
|
||||
|
||||
class TestProductProduce(TransactionCase):
|
||||
|
||||
def test_produced_products_lots(self):
|
||||
produce_model = self.env['mrp.product.produce']
|
||||
product_model = self.env['product.product']
|
||||
lot_model = self.env['stock.production.lot']
|
||||
quant_model = self.env['stock.quant']
|
||||
|
||||
unit_uom = self.browse_ref('product.product_uom_unit')
|
||||
|
||||
wh_main = self.browse_ref('stock.warehouse0')
|
||||
|
||||
# Create simple bom with by products
|
||||
p1 = product_model.create({'name': 'Test P1'})
|
||||
p2 = product_model.create({'name': 'Test P2'})
|
||||
p3 = product_model.create({'name': 'Test P3'})
|
||||
|
||||
# We have 1 P2 in stock
|
||||
inventory = self.env['stock.inventory'].create({
|
||||
'name': 'P2 inventory',
|
||||
'location_id': wh_main.lot_stock_id.id,
|
||||
'filter': 'partial'
|
||||
})
|
||||
inventory.prepare_inventory()
|
||||
|
||||
self.env['stock.inventory.line'].create({
|
||||
'inventory_id': inventory.id,
|
||||
'product_id': p2.id,
|
||||
'location_id': wh_main.lot_stock_id.id,
|
||||
'product_qty': 1
|
||||
})
|
||||
inventory.action_done()
|
||||
|
||||
# P1 need P2 and generates one byproduct P3
|
||||
bom = self.env['mrp.bom'].create({
|
||||
'product_tmpl_id': p1.product_tmpl_id.id,
|
||||
'product_id': p1.id,
|
||||
'product_qty': 1,
|
||||
'product_uom': unit_uom.id,
|
||||
})
|
||||
|
||||
self.env['mrp.bom.line'].create({
|
||||
'bom_id': bom.id,
|
||||
'product_id': p2.id,
|
||||
'product_qty': 1,
|
||||
'product_uom': unit_uom.id,
|
||||
})
|
||||
|
||||
self.env['mrp.subproduct'].create({
|
||||
'bom_id': bom.id,
|
||||
'product_id': p3.id,
|
||||
'product_qty': 1,
|
||||
'product_uom': unit_uom.id,
|
||||
})
|
||||
|
||||
# Create MRP Order
|
||||
mrp_order_id = bom.create_mrp_production()['res_id']
|
||||
mrp_order = self.env['mrp.production'].browse(mrp_order_id)
|
||||
mrp_order.action_confirm()
|
||||
mrp_order.action_assign()
|
||||
|
||||
# Wizard simulation
|
||||
wizard = produce_model.with_context(active_id=mrp_order_id).create({})
|
||||
wizard.on_change_product_id()
|
||||
self.assertEqual(2, len(wizard.move_lot_ids))
|
||||
self.assertEqual([p1, p3], [x.product_id for x in wizard.move_lot_ids])
|
||||
|
||||
lot_p1 = lot_model.create({'name': 'LOT_01', 'product_id': p1.id})
|
||||
wizard.move_lot_ids[0].lot_id = lot_p1
|
||||
|
||||
lot_p3 = lot_model.create({'name': 'LOT_03', 'product_id': p3.id})
|
||||
wizard.move_lot_ids[1].lot_id = lot_p3
|
||||
|
||||
wizard.do_produce()
|
||||
|
||||
# Check created move in mrp.production
|
||||
mrp_order.refresh()
|
||||
self.assertEqual(lot_p1,
|
||||
mrp_order.move_created_ids2[0].restrict_lot_id)
|
||||
self.assertEqual(lot_p3,
|
||||
mrp_order.move_created_ids2[1].restrict_lot_id)
|
||||
|
||||
# Check stock.quants
|
||||
p1_quants = quant_model.search([('product_id', '=', p1.id)])
|
||||
self.assertEqual(1, len(p1_quants))
|
||||
self.assertEqual(lot_p1, p1_quants.lot_id)
|
||||
self.assertEqual(1, p1_quants.qty)
|
||||
|
||||
p3_quants = quant_model.search([('product_id', '=', p3.id)])
|
||||
self.assertEqual(1, len(p3_quants))
|
||||
self.assertEqual(1, p3_quants.qty)
|
||||
42
mrp_bom_dismantling/tests/test_template.py
Normal file
42
mrp_bom_dismantling/tests/test_template.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# © 2016 Cyril Gaudin (Camptocamp)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from openerp.tests import TransactionCase
|
||||
|
||||
|
||||
class TestTemplate(TransactionCase):
|
||||
|
||||
def test_bom_count(self):
|
||||
tmpl_model = self.env['product.template']
|
||||
|
||||
tmpl1 = tmpl_model.create({'name': 'Template 1'})
|
||||
self.assertEqual(0, tmpl1.bom_count)
|
||||
|
||||
# Create a BoM for this template
|
||||
bom_model = self.env['mrp.bom']
|
||||
bom_model.create({'product_tmpl_id': tmpl1.id})
|
||||
|
||||
self.assertEqual(1, tmpl1.bom_count)
|
||||
|
||||
# Create a dismantling BoM
|
||||
other_product = self.env['product.product'].create({
|
||||
'name': 'Other product'
|
||||
})
|
||||
|
||||
bom_model.create({
|
||||
'product_tmpl_id': tmpl1.id,
|
||||
'dismantling': True,
|
||||
'dismantled_product_id': other_product.id
|
||||
})
|
||||
|
||||
self.assertEqual(1, tmpl1.bom_count)
|
||||
|
||||
# Check count on another template
|
||||
tmpl2 = tmpl_model.create({'name': 'Template 2'})
|
||||
self.assertEqual(0, tmpl2
|
||||
|
||||
.bom_count)
|
||||
|
||||
# And on dismantled product
|
||||
self.assertEqual(0, other_product.product_tmpl_id.bom_count)
|
||||
70
mrp_bom_dismantling/views/mrp_bom.xml
Normal file
70
mrp_bom_dismantling/views/mrp_bom.xml
Normal file
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<openerp>
|
||||
<data>
|
||||
<!-- Menu and action-->
|
||||
<record id="mrp_bom_dismantling_form_action" model="ir.actions.act_window">
|
||||
<field name="name">Dismantling</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">mrp.bom</field>
|
||||
<field name="domain">[('dismantling', '=', True)]</field>
|
||||
<field name="context">{'default_dismantling': True}</field>
|
||||
<field name="view_type">form</field>
|
||||
</record>
|
||||
<menuitem id="menu_mrp_bom_dismantling"
|
||||
action="mrp_bom_dismantling_form_action"
|
||||
parent="mrp.menu_mrp_bom"
|
||||
sequence="100"/>
|
||||
|
||||
<record id="mrp.mrp_bom_form_action" model="ir.actions.act_window">
|
||||
<field name="domain">[('dismantling', '=', False)]</field>
|
||||
</record>
|
||||
|
||||
<!-- Form view -->
|
||||
<record id="mrp_bom_form_view" model="ir.ui.view">
|
||||
<field name="name">mrp_bom_form</field>
|
||||
<field name="model">mrp.bom</field>
|
||||
<field name="inherit_id" ref="mrp.mrp_bom_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="/form/sheet" position="before">
|
||||
<header>
|
||||
<!-- Create Manufacturing Order -->
|
||||
<button name="create_mrp_production" type="object" string="Create Manufacturing Order" />
|
||||
|
||||
<!-- Create Dismantling BoM -->
|
||||
<button name="create_dismantling_bom" type="object" string="Create dismantling BoM"
|
||||
attrs="{'invisible': [('dismantling', '=', True)]}"/>
|
||||
</header>
|
||||
</xpath>
|
||||
<field name="product_tmpl_id" position="before">
|
||||
<field name="dismantling" invisible="True"/>
|
||||
<field name="dismantled_product_id"
|
||||
attrs="{'invisible': [('dismantling', '=', False)], 'required': [('dismantling', '=', True)]}"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Tree view -->
|
||||
<record id="mrp_bom_tree_view" model="ir.ui.view">
|
||||
<field name="name">mrp_bom_tree</field>
|
||||
<field name="model">mrp.bom</field>
|
||||
<field name="inherit_id" ref="mrp.mrp_bom_tree_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="product_tmpl_id" position="before">
|
||||
<field name="dismantled_product_id" invisible="not context.get('default_dismantling')"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Searches -->
|
||||
<record id="view_mrp_bom_filter" model="ir.ui.view">
|
||||
<field name="name">mrp.bom.select</field>
|
||||
<field name="model">mrp.bom</field>
|
||||
<field name="inherit_id" ref="mrp.view_mrp_bom_filter"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="code" position="before">
|
||||
<field name="dismantled_product_id" invisible="not context.get('default_dismantling')"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</openerp>
|
||||
9
mrp_bom_dismantling/views/product_template.xml
Normal file
9
mrp_bom_dismantling/views/product_template.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<openerp>
|
||||
<data>
|
||||
<!-- Bill of Materials action override -->
|
||||
<record id="mrp.template_open_bom" model="ir.actions.act_window">
|
||||
<field name="domain">[('dismantling', '=', False)]</field>
|
||||
</record>
|
||||
</data>
|
||||
</openerp>
|
||||
5
mrp_bom_dismantling/wizards/__init__.py
Normal file
5
mrp_bom_dismantling/wizards/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# © 2016 Cyril Gaudin (Camptocamp)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import mrp_product_produce
|
||||
51
mrp_bom_dismantling/wizards/mrp_product_produce.py
Normal file
51
mrp_bom_dismantling/wizards/mrp_product_produce.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# © 2016 Cyril Gaudin (Camptocamp)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from openerp import api, fields, models
|
||||
|
||||
|
||||
class MrpByProductLine(models.TransientModel):
|
||||
_name = "mrp.product.produced.line"
|
||||
|
||||
produce_id = fields.Many2one('mrp.product.produce', required=True,
|
||||
string="Produce")
|
||||
move_id = fields.Many2one('stock.move', required=True)
|
||||
product_id = fields.Many2one('product.product',
|
||||
related='move_id.product_id')
|
||||
lot_id = fields.Many2one('stock.production.lot', string='Lot')
|
||||
|
||||
|
||||
class MrpProductProduce(models.TransientModel):
|
||||
_inherit = "mrp.product.produce"
|
||||
move_lot_ids = fields.One2many(
|
||||
'mrp.product.produced.line',
|
||||
inverse_name='produce_id',
|
||||
)
|
||||
|
||||
@api.onchange("product_id")
|
||||
def on_change_product_id(self):
|
||||
""" Listen to product_id changes just for filling byproducts_lot_ids.
|
||||
"""
|
||||
if not self.move_lot_ids:
|
||||
mrp_prod = self.env["mrp.production"].browse(
|
||||
self.env.context['active_id']
|
||||
)
|
||||
|
||||
self.move_lot_ids = [
|
||||
(0, None, {'move_id': move})
|
||||
for move in mrp_prod.move_created_ids
|
||||
]
|
||||
|
||||
@api.multi
|
||||
def do_produce(self):
|
||||
""" Stock produced products lot_id and call parent do_produce
|
||||
"""
|
||||
mapping_move_lot = {}
|
||||
for move_lot in self.move_lot_ids:
|
||||
if move_lot.lot_id:
|
||||
mapping_move_lot[move_lot.move_id.id] = move_lot.lot_id.id
|
||||
|
||||
super(MrpProductProduce, self.with_context(
|
||||
mapping_move_lot=mapping_move_lot
|
||||
)).do_produce()
|
||||
30
mrp_bom_dismantling/wizards/mrp_product_produce.xml
Normal file
30
mrp_bom_dismantling/wizards/mrp_product_produce.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Manage sub products lot_id -->
|
||||
<record id="view_mrp_product_produce_wizard" model="ir.ui.view">
|
||||
<field name="name">MRP Product Produce</field>
|
||||
<field name="model">mrp.product.produce</field>
|
||||
<field name="inherit_id" ref="mrp.view_mrp_product_produce_wizard"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- Hide lot_id field -->
|
||||
<field name="lot_id" position="attributes">
|
||||
<attribute name="invisible">True</attribute>
|
||||
</field>
|
||||
|
||||
<!-- Add produced products list for managing lot_id -->
|
||||
<xpath expr="//group" position="after">
|
||||
<group string="Products to produce lots" groups="stock.group_production_lot">
|
||||
<field name="move_lot_ids" nolabel="1">
|
||||
<tree editable="bottom" create="false" delete="false">
|
||||
<field name="move_id" invisible="True"/>
|
||||
<field name="product_id" readonly="True"/>
|
||||
<field name="lot_id" domain="[('product_id', '=', product_id)]"
|
||||
context="{'default_product_id':product_id}"/>
|
||||
</tree>
|
||||
</field>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,3 +1,4 @@
|
||||
# List the OCA project dependencies, one per line
|
||||
# Add a repository url and branch if you need a forked version
|
||||
product-attribute
|
||||
product-attribute
|
||||
stock-logistics-warehouse
|
||||
|
||||
Reference in New Issue
Block a user