[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:
@@ -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'],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user