[IMP] product_expiry,stock: expiration_date at receipt

User can now define the lot's field `expiration_date` when he/she
receipts a tracked product using the expiration dates.

Task #1938656
This commit is contained in:
svs-odoo
2019-10-25 13:50:36 +00:00
parent 69957aec9b
commit decef03acf
8 changed files with 200 additions and 31 deletions
+1
View File
@@ -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'],
}
+2
View File
@@ -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
+22 -8
View File
@@ -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'
@@ -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')
@@ -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)
@@ -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)
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_stock_move_operations_expiry" model="ir.ui.view">
<field name="name">stock.move.operations.inherit.form</field>
<field name="model">stock.move</field>
<field name="inherit_id" ref="stock.view_stock_move_operations"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='product_id']" position="after" >
<field name="picking_code" invisible="1"/>
<field name="use_expiration_date" invisible="1"/>
</xpath>
</field>
</record>
<record id="view_stock_move_line_operation_tree_expiry" model="ir.ui.view">
<field name="name">stock.move.line.inherit.tree</field>
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock.view_stock_move_line_operation_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='lot_name']" position="after" >
<field name="expiration_date_readonly" invisible="1"/>
<field name="expiration_date" force_save="1" attrs="{
'column_invisible': ['|', ('parent.use_expiration_date', '!=', True), ('parent.picking_code', '!=', 'incoming')],
'readonly': [('expiration_date_readonly', '=', True)],
}"/>
</xpath>
</field>
</record>
<record id="view_stock_move_line_detailed_operation_tree_expiry" model="ir.ui.view">
<field name="name">stock.move.line.operations.inherit.tree</field>
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock.view_stock_move_line_detailed_operation_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='lot_name']" position="after">
<field name="expiration_date_readonly" invisible="1"/>
<field name="expiration_date" force_save="1" attrs="{
'column_invisible': [('parent.picking_type_code', '!=', 'incoming')],
'readonly': [('expiration_date_readonly', '=', True)],
}"/>
</xpath>
</field>
</record>
</odoo>
+12 -5
View File
@@ -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