diff --git a/addons/product_expiry/__manifest__.py b/addons/product_expiry/__manifest__.py index 000f3d5b2a5..4f5d16cac54 100644 --- a/addons/product_expiry/__manifest__.py +++ b/addons/product_expiry/__manifest__.py @@ -19,6 +19,7 @@ Also implements the removal strategy First Expiry First Out (FEFO) widely used, """, 'data': ['views/production_lot_views.xml', 'views/product_template_views.xml', + 'views/stock_move_views.xml', 'views/stock_quant_views.xml', 'data/product_expiry_data.xml'], } diff --git a/addons/product_expiry/models/__init__.py b/addons/product_expiry/models/__init__.py index 47544a5eeb1..a32a1a3e386 100644 --- a/addons/product_expiry/models/__init__.py +++ b/addons/product_expiry/models/__init__.py @@ -3,4 +3,6 @@ from . import production_lot from . import product_product +from . import stock_move_line +from . import stock_move from . import stock_quant diff --git a/addons/product_expiry/models/production_lot.py b/addons/product_expiry/models/production_lot.py index 79e8ec7ec13..9d05232fcb4 100644 --- a/addons/product_expiry/models/production_lot.py +++ b/addons/product_expiry/models/production_lot.py @@ -59,18 +59,12 @@ class StockProductionLot(models.Model): def _onchange_expiration_date(self): if not self._origin or not (self.expiration_date and self._origin.expiration_date): return - vals = {} time_delta = self.expiration_date - self._origin.expiration_date # As we compare expiration_date with _origin.expiration_date, we need to - # use _origin valuesto keep a stability in the values. + # use `_get_date_values` with _origin to keep a stability in the values. # Otherwise it will recompute from the updated values if the user calls # this onchange multiple times without save between each onchange. - if self.use_date and self._origin.use_date: - vals['use_date'] = self._origin.use_date + time_delta - if self.removal_date and self._origin.removal_date: - vals['removal_date'] = self._origin.removal_date + time_delta - if self.alert_date and self._origin.alert_date: - vals['alert_date'] = self._origin.alert_date + time_delta + vals = self._origin._get_date_values(time_delta) self.update(vals) @api.onchange('product_id') @@ -106,6 +100,26 @@ class StockProductionLot(models.Model): 'product_expiry_reminded': True }) + def _update_date_values(self, new_date): + if new_date: + time_delta = new_date - self.expiration_date + vals = self._get_date_values(time_delta) + vals['expiration_date'] = new_date + self.write(vals) + + def _get_date_values(self, time_delta): + ''' Return a dict with different date values updated depending of the + time_delta. Used in the onchange of `expiration_date` and when user + defines a date at the receipt. ''' + vals = {} + if self.use_date: + vals['use_date'] = self.use_date + time_delta + if self.removal_date: + vals['removal_date'] = self.removal_date + time_delta + if self.alert_date: + vals['alert_date'] = self.alert_date + time_delta + return vals + class ProcurementGroup(models.Model): _inherit = 'procurement.group' diff --git a/addons/product_expiry/models/stock_move.py b/addons/product_expiry/models/stock_move.py new file mode 100644 index 00000000000..3334ed416ea --- /dev/null +++ b/addons/product_expiry/models/stock_move.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class StockMove(models.Model): + _inherit = "stock.move" + use_expiration_date = fields.Boolean( + string='Use Expiration Date', related='product_id.use_expiration_date') diff --git a/addons/product_expiry/models/stock_move_line.py b/addons/product_expiry/models/stock_move_line.py new file mode 100644 index 00000000000..af78f86f248 --- /dev/null +++ b/addons/product_expiry/models/stock_move_line.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import datetime + +from odoo import api, fields, models + + +class StockMoveLine(models.Model): + _inherit = "stock.move.line" + + expiration_date_readonly = fields.Boolean(compute='_compute_expiration_date_readonly') + expiration_date = fields.Datetime(string='Expiration Date', + help='This is the date on which the goods with this Serial Number may' + ' become dangerous and must not be consumed.') + + @api.depends('picking_id', 'product_id') + def _compute_expiration_date_readonly(self): + if not self.picking_id.picking_type_id.use_existing_lots and self.product_id.use_expiration_date: + self.expiration_date_readonly = False + else: + self.expiration_date_readonly = True + + @api.onchange('product_id', 'product_uom_id') + def onchange_product_id(self): + res = super(StockMoveLine, self).onchange_product_id() + if self.picking_type_use_create_lots: + if self.product_id.use_expiration_date: + self.expiration_date = fields.Datetime.today() + datetime.timedelta(days=self.product_id.expiration_time) + else: + self.expiration_date = False + return res + + @api.onchange('lot_id') + def _onchange_lot_id(self): + if not self.picking_type_use_existing_lots or not self.product_id.use_expiration_date: + return + if self.lot_id: + self.expiration_date = self.lot_id.expiration_date + else: + self.expiration_date = False + + def _create_and_assign_production_lot(self): + super(StockMoveLine, self)._create_and_assign_production_lot() + self.lot_id._update_date_values(self.expiration_date) diff --git a/addons/product_expiry/tests/test_stock_production_lot.py b/addons/product_expiry/tests/test_stock_production_lot.py index b0c5442f815..256ab1f17eb 100644 --- a/addons/product_expiry/tests/test_stock_production_lot.py +++ b/addons/product_expiry/tests/test_stock_production_lot.py @@ -10,6 +10,21 @@ from odoo.tests.common import Form class TestStockProductionLot(TestStockCommon): + @classmethod + def setUpClass(cls): + super(TestStockProductionLot, cls).setUpClass() + # Creates a tracked product with expiration dates. + cls.apple_product = cls.ProductObj.create({ + 'name': 'Apple', + 'type': 'product', + 'tracking': 'lot', + 'use_expiration_date': True, + 'expiration_time': 10, + 'use_time': 5, + 'removal_time': 8, + 'alert_time': 4, + }) + def test_00_stock_production_lot(self): """ Test Scheduled Task on lot with an alert_date in the past creates an activity """ @@ -107,7 +122,6 @@ class TestStockProductionLot(TestStockCommon): ]) self.assertEqual(activity_count, 0, "As there is already an activity marked as done, there shouldn't be any related activity created for this lot") - def test_01_stock_production_lot(self): """ Test Scheduled Task on lot with an alert_date in future does not create an activity """ @@ -164,7 +178,6 @@ class TestStockProductionLot(TestStockCommon): ]) self.assertEqual(activity_count, 0, "An activity has been created while it shouldn't") - def test_02_stock_production_lot(self): """ Test Scheduled Task on lot without an alert_date does not create an activity """ @@ -215,38 +228,27 @@ class TestStockProductionLot(TestStockCommon): def test_03_onchange_expiration_date(self): """ Updates the `expiration_date` of the lot production and checks other date fields are updated as well. """ - # Creates a tracked product with expiration dates. - apple_product = self.ProductObj.create({ - 'name': 'Apple', - 'type': 'product', - 'tracking': 'lot', - 'use_expiration_date': True, - 'expiration_time': 10, - 'use_time': 5, - 'removal_time': 8, - 'alert_time': 4, - }) # Keeps track of the current datetime and set a delta for the compares. today_date = datetime.today() time_gap = timedelta(seconds=10) # Creates a new lot number and saves it... lot_form = Form(self.LotObj) lot_form.name = 'Apple Box #1' - lot_form.product_id = apple_product + lot_form.product_id = self.apple_product lot_form.company_id = self.env.company apple_lot = lot_form.save() # ...then checks date fields have the expected values. self.assertAlmostEqual( - today_date + timedelta(days=apple_product.expiration_time), + today_date + timedelta(days=self.apple_product.expiration_time), apple_lot.expiration_date, delta=time_gap) self.assertAlmostEqual( - today_date + timedelta(days=apple_product.use_time), + today_date + timedelta(days=self.apple_product.use_time), apple_lot.use_date, delta=time_gap) self.assertAlmostEqual( - today_date + timedelta(days=apple_product.removal_time), + today_date + timedelta(days=self.apple_product.removal_time), apple_lot.removal_date, delta=time_gap) self.assertAlmostEqual( - today_date + timedelta(days=apple_product.alert_time), + today_date + timedelta(days=self.apple_product.alert_time), apple_lot.alert_date, delta=time_gap) difference = timedelta(days=20) @@ -267,3 +269,47 @@ class TestStockProductionLot(TestStockCommon): apple_lot.removal_date, old_removal_date + difference, delta=time_gap) self.assertAlmostEqual( apple_lot.alert_date, old_alert_date + difference, delta=time_gap) + + def test_04_expiration_date_on_receipt(self): + """ Test we can set an expiration date on receipt and all expiration + date will be correctly set. """ + partner = self.env['res.partner'].create({ + 'name': 'Apple\'s Joe', + 'company_id': self.env.ref('base.main_company').id, + }) + expiration_date = datetime.today() + timedelta(days=30) + time_gap = timedelta(seconds=10) + + # Receives a tracked production using expiration date. + picking_form = Form(self.env['stock.picking']) + picking_form.partner_id = partner + picking_form.picking_type_id = self.env.ref('stock.picking_type_in') + with picking_form.move_ids_without_package.new() as move: + move.product_id = self.apple_product + move.product_uom_qty = 4 + receipt = picking_form.save() + receipt.action_confirm() + + # Defines a date during the receipt. + move = receipt.move_ids_without_package[0] + line = move.move_line_ids[0] + self.assertEqual(move.use_expiration_date, True) + line.lot_name = 'Apple Box #2' + line.expiration_date = expiration_date + line.qty_done = 4 + + receipt._action_done() + # Get back the lot created when the picking was done... + apple_lot = self.env['stock.production.lot'].search( + [('product_id', '=', self.apple_product.id)], + limit=1, + ) + # ... and checks all date fields are correctly set. + self.assertAlmostEqual( + apple_lot.expiration_date, expiration_date, delta=time_gap) + self.assertAlmostEqual( + apple_lot.use_date, expiration_date - timedelta(days=5), delta=time_gap) + self.assertAlmostEqual( + apple_lot.removal_date, expiration_date - timedelta(days=2), delta=time_gap) + self.assertAlmostEqual( + apple_lot.alert_date, expiration_date - timedelta(days=6), delta=time_gap) diff --git a/addons/product_expiry/views/stock_move_views.xml b/addons/product_expiry/views/stock_move_views.xml new file mode 100644 index 00000000000..79fe265aaad --- /dev/null +++ b/addons/product_expiry/views/stock_move_views.xml @@ -0,0 +1,44 @@ + + + + stock.move.operations.inherit.form + stock.move + + + + + + + + + + + stock.move.line.inherit.tree + stock.move.line + + + + + + + + + + + stock.move.line.operations.inherit.tree + stock.move.line + + + + + + + + + diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index b22cfc41c6b..f37f6f9c284 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -416,11 +416,7 @@ class StockMoveLine(models.Model): # If a picking type is linked, we may have to create a production lot on # the fly before assigning it to the move line if the user checked both # `use_create_lots` and `use_existing_lots`. - if ml.lot_name and not ml.lot_id: - lot = self.env['stock.production.lot'].create( - {'name': ml.lot_name, 'product_id': ml.product_id.id, 'company_id': ml.move_id.company_id.id} - ) - ml.write({'lot_id': lot.id}) + ml._create_and_assign_production_lot() elif not picking_type_id.use_create_lots and not picking_type_id.use_existing_lots: # If the user disabled both `use_create_lots` and `use_existing_lots` # checkboxes on the picking type, he's allowed to enter tracked @@ -476,6 +472,17 @@ class StockMoveLine(models.Model): 'date': fields.Datetime.now(), }) + def _create_and_assign_production_lot(self): + """ Creates and assign a new production lot of the move line.""" + self.ensure_one() + if self.lot_name and not self.lot_id: + lot = self.env['stock.production.lot'].create({ + 'company_id': self.move_id.company_id.id, + 'name': self.lot_name, + 'product_id': self.product_id.id, + }) + self.write({'lot_id': lot.id}) + def _reservation_is_updatable(self, quantity, reserved_quant): self.ensure_one() if (self.product_id.tracking != 'serial' and