diff --git a/addons/repair/models/stock_lot.py b/addons/repair/models/stock_lot.py index 84606d71fc9..0d05c69ce0e 100644 --- a/addons/repair/models/stock_lot.py +++ b/addons/repair/models/stock_lot.py @@ -8,6 +8,7 @@ class StockLot(models.Model): repair_order_ids = fields.Many2many('repair.order', string="Repair Orders", compute="_compute_repair_order_ids") repair_order_count = fields.Integer('Repair order count', compute="_compute_repair_order_ids") + repaired_count = fields.Integer('Repaired count', compute='_compute_repaired_count') @api.depends('name') def _compute_repair_order_ids(self): @@ -18,6 +19,22 @@ class StockLot(models.Model): lot.repair_order_ids = repair_orders[lot.id] lot.repair_order_count = len(lot.repair_order_ids) + def _compute_repaired_count(self): + for lot in self: + lot.repaired_count = len(self.env['repair.order'].search([('lot_id', '=', self.id), ('state', '=', 'done')])) + + def action_lot_open_repairs(self): + action = self.env["ir.actions.actions"]._for_xml_id("repair.action_repair_order_tree") + action.update({ + 'domain': [('lot_id', '=', self.id)], + 'context': { + 'default_product_id': self.product_id.id, + 'default_lot_id': self.id, + 'default_company_id': self.company_id.id, + }, + }) + return action + def action_view_ro(self): self.ensure_one() diff --git a/addons/repair/views/stock_lot_views.xml b/addons/repair/views/stock_lot_views.xml index 8fa41f02c8e..d62bb8e5ce6 100644 --- a/addons/repair/views/stock_lot_views.xml +++ b/addons/repair/views/stock_lot_views.xml @@ -11,8 +11,17 @@
+ + diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index 2fde47840dd..1fa0177bdf4 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -499,12 +499,13 @@ class Product(models.Model): def action_open_product_lot(self): self.ensure_one() - action = self.env["ir.actions.actions"]._for_xml_id("stock.action_production_lot_form") + action = self.env["ir.actions.actions"]._for_xml_id("stock.action_product_production_lot_form") action['domain'] = [('product_id', '=', self.id)] action['context'] = { 'default_product_id': self.id, 'set_product_readonly': True, 'default_company_id': (self.company_id or self.env.company).id, + 'search_default_group_by_location': True, } return action @@ -944,11 +945,12 @@ class ProductTemplate(models.Model): def action_open_product_lot(self): self.ensure_one() - action = self.env["ir.actions.actions"]._for_xml_id("stock.action_production_lot_form") + action = self.env["ir.actions.actions"]._for_xml_id("stock.action_product_production_lot_form") action['domain'] = [('product_id.product_tmpl_id', '=', self.id)] action['context'] = { 'default_product_tmpl_id': self.id, 'default_company_id': (self.company_id or self.env.company).id, + 'search_default_group_by_location': True, } if self.product_variant_count == 1: action['context'].update({ diff --git a/addons/stock/models/stock_lot.py b/addons/stock/models/stock_lot.py index b2455d75542..95eee56c52c 100644 --- a/addons/stock/models/stock_lot.py +++ b/addons/stock/models/stock_lot.py @@ -34,6 +34,7 @@ class StockLot(models.Model): delivery_count = fields.Integer('Delivery order count', compute='_compute_delivery_ids') last_delivery_partner_id = fields.Many2one('res.partner', compute='_compute_delivery_ids') lot_properties = fields.Properties('Properties', definition='product_id.lot_properties_definition', copy=True) + location_id = fields.Many2one('stock.location', 'Location', compute='_compute_single_location', store=True, readonly=False, inverse='_set_single_location', domain="[('usage', '!=', 'view')]") @api.model def generate_lot_names(self, first_lot, count): @@ -126,6 +127,19 @@ class StockLot(models.Model): if lot.product_id.tracking == 'serial' and lot.delivery_count > 0: lot.last_delivery_partner_id = lot.delivery_ids.sorted(key=attrgetter('date_done'), reverse=True)[0].partner_id + @api.depends('quant_ids') + def _compute_single_location(self): + for lot in self: + location_id = lot.quant_ids.filtered(lambda q: q.quantity > 0).mapped('location_id') + lot.location_id = location_id if len(location_id) == 1 else False + + def _set_single_location(self): + quant_to_move = self.quant_ids.filtered(lambda q: q.quantity > 0) + if len(quant_to_move) == 1: + quant_to_move._move_quants(self.location_id, 'Lot/Serial Number Relocated') + elif len(quant_to_move) > 1: + raise UserError(_('You can only move a lot/serial to a new location if it exists in a single location.')) + @api.model_create_multi def create(self, vals_list): self._check_create() diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py index 04dc40794db..57785334c13 100644 --- a/addons/stock/models/stock_quant.py +++ b/addons/stock/models/stock_quant.py @@ -77,7 +77,6 @@ class StockQuant(models.Model): 'stock.lot', 'Lot/Serial Number', index=True, ondelete='restrict', check_company=True, domain=lambda self: self._domain_lot_id()) - lot_properties = fields.Properties(related='lot_id.lot_properties', definition='product_id.lot_properties_definition', readonly=True) sn_duplicated = fields.Boolean(string="Duplicated Serial Number", compute='_compute_sn_duplicated', help="If the same SN is in another Quant") package_id = fields.Many2one( 'stock.quant.package', 'Package', @@ -1237,6 +1236,18 @@ class StockQuant(models.Model): lot_id.name, source_location_id.display_name, ', '.join(sn_locations.mapped('display_name'))) return message, recommended_location + def _move_quants(self, location_dest_id=False, message=False): + """ Directly move a stock.quant to another location by creating a stock.move. """ + if not location_dest_id: + return + for quant in self: + move_vals = quant._get_inventory_move_values(quant.quantity, quant.location_id, location_dest_id) + move_vals.update({ + 'name': message or 'Quantity Relocated', + }) + moves = self.env['stock.move'].with_context(inventory_mode=False).create(move_vals) + moves._action_done() + class QuantPackage(models.Model): """ Packages containing quants and/or other packages """ diff --git a/addons/stock/tests/__init__.py b/addons/stock/tests/__init__.py index 62ff00ecdd7..0f46fe5042c 100644 --- a/addons/stock/tests/__init__.py +++ b/addons/stock/tests/__init__.py @@ -21,3 +21,4 @@ from . import test_report from . import test_report_stock_quantity from . import test_report_tours from . import test_stock_return_picking +from . import test_stock_lot diff --git a/addons/stock/tests/test_stock_lot.py b/addons/stock/tests/test_stock_lot.py new file mode 100644 index 00000000000..81b050f0bf6 --- /dev/null +++ b/addons/stock/tests/test_stock_lot.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.stock.tests.common import TestStockCommon +from odoo.tests.common import Form + +class TestLotSerial(TestStockCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.locationA = cls.env['stock.location'].create({ + 'name': 'Location A', + 'usage': 'internal', + }) + cls.locationB = cls.env['stock.location'].create({ + 'name': 'Location B', + 'usage': 'internal', + }) + cls.locationC = cls.env['stock.location'].create({ + 'name': 'Location C', + 'usage': 'internal', + }) + cls.productA.tracking = 'lot' + cls.lot_p_a = cls.LotObj.create({ + 'name': 'lot_product_a', + 'product_id': cls.productA.id, + 'company_id': cls.env.company.id, + }) + cls.StockQuantObj.create({ + 'product_id': cls.productA.id, + 'location_id': cls.locationA.id, + 'quantity': 10.0, + 'lot_id': cls.lot_p_a.id + }) + + cls.productB.tracking = 'serial' + cls.lot_p_b = cls.LotObj.create({ + 'name': 'lot_product_b', + 'product_id': cls.productB.id, + 'company_id': cls.env.company.id, + }) + cls.StockQuantObj.create({ + 'product_id': cls.productB.id, + 'location_id': cls.locationA.id, + 'quantity': 1.0, + 'lot_id': cls.lot_p_b.id + }) + + def test_single_location(self): + self.assertEqual(self.lot_p_a.location_id, self.locationA) + self.assertEqual(self.lot_p_b.location_id, self.locationA) + + # testing changing the location from the lot form + lot_b_form = Form(self.lot_p_b) + lot_b_form.location_id = self.locationB + lot_b_form.save() + self.assertEqual(self.lot_p_b.quant_ids.filtered(lambda q: q.quantity > 0).location_id, self.locationB) + + # testing changing the location from the quant + self.lot_p_b.quant_ids._move_quants(self.locationC, 'test_quant_move') + self.assertEqual(self.lot_p_b.location_id, self.locationC) + + # testing having the lot in multiple locations + self.StockQuantObj.create({ + 'product_id': self.productA.id, + 'location_id': self.locationC.id, + 'quantity': 10.0, + 'lot_id': self.lot_p_a.id + }) + self.assertEqual(self.lot_p_a.location_id.id, False) + + # testing having the lot back in a single location + self.lot_p_a.quant_ids.filtered(lambda q: q.location_id == self.locationA)._move_quants(self.locationC) + self.StockQuantObj.invalidate_model() + self.StockQuantObj._unlink_zero_quants() + self.assertEqual(self.lot_p_a.location_id, self.locationC) diff --git a/addons/stock/views/stock_lot_views.xml b/addons/stock/views/stock_lot_views.xml index 7261a9978ed..ccc4a84c875 100644 --- a/addons/stock/views/stock_lot_views.xml +++ b/addons/stock/views/stock_lot_views.xml @@ -48,6 +48,8 @@+ Add a lot/serial number +
+ Lots/Serial numbers help you tracking the path followed by your products. + From their traceability report you will see the full history of their use, as well as their composition. +
+