19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

View file

@ -8,3 +8,4 @@ from . import stock_move_line
from . import stock_move
from . import stock_picking
from . import stock_quant
from . import stock_rule

View file

@ -1,17 +1,35 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
from odoo import fields, models
class Product(models.Model):
class ProductProduct(models.Model):
_inherit = "product.product"
def action_open_quants(self):
# Override to hide the `removal_date` column if not needed.
if not any(product.use_expiration_date for product in self):
self = self.with_context(hide_removal_date=True)
return super().action_open_quants()
def _compute_quantities_dict(self, lot_id, owner_id, package_id, from_date=False, to_date=False):
return super(ProductProduct, self.with_context(with_expiration=datetime.date.today()))._compute_quantities_dict(lot_id, owner_id, package_id, from_date, to_date)
free_qty = fields.Float(help="Available quantity (computed as Quantity On Hand "
"- reserved quantity - quantity to remove)\n"
"In a context with a single Stock Location, this includes "
"goods stored in this location, or any of its children.\n"
"In a context with a single Warehouse, this includes "
"goods stored in the Stock Location of this Warehouse, or any "
"of its children.\n"
"Otherwise, this includes goods stored in any Stock Location "
"with 'internal' type.")
virtual_available = fields.Float(help="Forecast quantity (computed as Quantity On Hand "
"- Outgoing + Incoming - Quantity to Remove)\n"
"In a context with a single Stock Location, this includes "
"goods stored in this location, or any of its children.\n"
"In a context with a single Warehouse, this includes "
"goods stored in the Stock Location of this Warehouse, or any "
"of its children.\n"
"Otherwise, this includes goods stored in any Stock Location "
"with 'internal' type.")
class ProductTemplate(models.Model):
@ -29,7 +47,8 @@ class ProductTemplate(models.Model):
' deteriorating, without being dangerous yet. It will be computed on the lot/serial number.')
removal_time = fields.Integer(string='Removal Date',
help='Number of days before the Expiration Date after which the goods'
' should be removed from the stock. It will be computed on the lot/serial number.')
' should be removed from the stock and not be counted in the Fresh On Hand Stock anymore.'
'It will be computed on the lot/serial number.')
alert_time = fields.Integer(string='Alert Date',
help='Number of days before the Expiration Date after which an alert should be'
' raised on the lot/serial number. It will be computed on the lot/serial number.')

View file

@ -15,12 +15,29 @@ class StockLot(models.Model):
use_date = fields.Datetime(string='Best before Date', compute='_compute_dates', store=True, readonly=False,
help='This is the date on which the goods with this Serial Number start deteriorating, without being dangerous yet.')
removal_date = fields.Datetime(string='Removal Date', compute='_compute_dates', store=True, readonly=False,
help='This is the date on which the goods with this Serial Number should be removed from the stock. This date will be used in FEFO removal strategy.')
help='This is the date on which the goods with this Serial Number should be removed from the stock and not be counted in the Fresh On Hand Stock anymore. This date will be used in FEFO removal strategy.')
alert_date = fields.Datetime(string='Alert Date', compute='_compute_dates', store=True, readonly=False,
help='Date to determine the expired lots and serial numbers using the filter "Expiration Alerts".')
product_expiry_alert = fields.Boolean(compute='_compute_product_expiry_alert', help="The Expiration Date has been reached.")
product_expiry_reminded = fields.Boolean(string="Expiry has been reminded")
@api.depends('use_expiration_date', 'expiration_date', 'alert_date')
@api.depends_context('formatted_display_name')
def _compute_display_name(self):
lots_to_process_ids = []
for lot in self:
if lot.env.context.get('formatted_display_name') and lot.use_expiration_date and lot.expiration_date:
name = f"{lot.name}"
if fields.Datetime.now() >= lot.expiration_date:
name += self.env._("\t--Expired--")
elif lot.alert_date and fields.Datetime.now() >= lot.alert_date:
name += self.env._("\t--Expire on %(date)s--", date=fields.Datetime.to_string(lot.expiration_date))
lot.display_name = name
else:
lots_to_process_ids.append(lot.id)
if lots_to_process_ids:
super(StockLot, self.env['stock.lot'].browse(lots_to_process_ids))._compute_display_name()
@api.depends('expiration_date')
def _compute_product_expiry_alert(self):
current_date = fields.Datetime.now()
@ -80,21 +97,11 @@ class StockLot(models.Model):
for lot in alert_lots:
lot.activity_schedule(
'product_expiry.mail_activity_type_alert_date_reached',
'mail.mail_activity_data_todo',
user_id=lot.product_id.with_company(lot.company_id).responsible_id.id or lot.product_id.responsible_id.id or SUPERUSER_ID,
note=_("The alert date has been reached for this lot/serial number")
note=_("The alert date has been reached for this lot/serial number"),
summary=_("Alert Date Reached"),
)
alert_lots.write({
'product_expiry_reminded': True
})
class ProcurementGroup(models.Model):
_inherit = 'procurement.group'
@api.model
def _run_scheduler_tasks(self, use_new_cursor=False, company_id=False):
super(ProcurementGroup, self)._run_scheduler_tasks(use_new_cursor=use_new_cursor, company_id=company_id)
self.env['stock.lot']._alert_date_exceeded()
if use_new_cursor:
self.env.cr.commit()

View file

@ -15,6 +15,12 @@ class ResConfigSettings(models.TransientModel):
if not self.group_lot_on_delivery_slip:
self.group_expiry_date_on_delivery_slip = False
@api.onchange('group_stock_production_lot')
def _onchange_group_stock_production_lot(self):
super()._onchange_group_stock_production_lot()
if self.group_stock_production_lot:
self.module_product_expiry = True
@api.onchange('module_product_expiry')
def _onchange_module_product_expiry(self):
if not self.module_product_expiry:

View file

@ -2,8 +2,11 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
import dateutil.parser as dparser
from re import findall as re_findall
from odoo import fields, models
from odoo import api, fields, models
from odoo.tools import get_lang
class StockMove(models.Model):
@ -12,12 +15,81 @@ class StockMove(models.Model):
use_expiration_date = fields.Boolean(
string='Use Expiration Date', related='product_id.use_expiration_date')
def _generate_serial_move_line_commands(self, lot_names, origin_move_line=None):
@api.model
def action_generate_lot_line_vals(self, context_data, mode, first_lot, count, lot_text):
vals_list = super().action_generate_lot_line_vals(context_data, mode, first_lot, count, lot_text)
product = self.env['product.product'].browse(context_data.get('default_product_id'))
picking = self.env['stock.picking'].browse(context_data.get('default_picking_id'))
if product.use_expiration_date:
from_date = picking.scheduled_date or fields.Datetime.today()
expiration_date = from_date + datetime.timedelta(days=product.expiration_time)
for vals in vals_list:
vals['expiration_date'] = vals.get('expiration_date') or expiration_date
return vals_list
def _generate_serial_move_line_commands(self, field_data, location_dest_id=False, origin_move_line=None):
"""Override to add a default `expiration_date` into the move lines values."""
move_lines_commands = super()._generate_serial_move_line_commands(lot_names, origin_move_line=origin_move_line)
move_lines_commands = super()._generate_serial_move_line_commands(field_data, location_dest_id, origin_move_line)
if self.product_id.use_expiration_date:
date = fields.Datetime.today() + datetime.timedelta(days=self.product_id.expiration_time)
for move_line_command in move_lines_commands:
move_line_vals = move_line_command[2]
move_line_vals['expiration_date'] = date
if 'expiration_date' not in move_line_vals:
move_line_vals['expiration_date'] = date
return move_lines_commands
def _convert_string_into_field_data(self, string, options):
res = super()._convert_string_into_field_data(string, options)
if not res:
try:
datetime = dparser.parse(string, **options)
if self and not self.use_expiration_date:
# The datetime was correctly parsed but this move's product doesn't use expiration date.
return "ignore"
return {'expiration_date': datetime}
except ValueError:
pass
return res
def _get_formating_options(self, strings):
options = super()._get_formating_options(strings)
separators = "-/ "
date_regex = f'[^{separators}]+'
for string in strings:
# Searches for a date.
date_data = re_findall(date_regex, string)
if len(date_data) < 2: # Not enough data.
continue
value_1, value_2 = date_data[:2]
if re_findall('[a-zA-Z]', value_1):
# Assumes the first value is the mounth (written in letters). Don't add any option
# as mounth as the first date's value is the default behavior for `dateutil.parse`.
break
# Try to guess if the first data is the day or the year.
if int(value_1) > 31:
options['yearfirst'] = True
break
elif int(value_1) > 12 and (re_findall('[a-zA-Z]', value_2) or int(value_2) <= 12):
options['dayfirst'] = True
break
else: # Too ambiguous, gets the option from the user's lang's date setting.
user_lang_format = get_lang(self.env).date_format
if re_findall('^%[mbB]', user_lang_format): # First parameter is for month.
return options
elif re_findall('^%[djaA]', user_lang_format): # First parameter is for day.
options['dayfirst'] = True
break
elif re_findall('^%[yY]', user_lang_format): # First parameter is for year.
options['yearfirst'] = True
break
return options
def _update_reserved_quantity(self, need, location_id, lot_id=None, package_id=None, owner_id=None, strict=True):
if self.product_id.use_expiration_date:
return super(StockMove, self.with_context(with_expiration=self.date))._update_reserved_quantity(need, location_id, lot_id, package_id, owner_id, strict)
return super()._update_reserved_quantity(need, location_id, lot_id, package_id, owner_id, strict)
def _get_available_quantity(self, location_id, lot_id=None, package_id=None, owner_id=None, strict=False, allow_negative=False):
if self.product_id.use_expiration_date:
return super(StockMove, self.with_context(with_expiration=self.date))._get_available_quantity(location_id, lot_id, package_id, owner_id, strict, allow_negative)
return super()._get_available_quantity(location_id, lot_id, package_id, owner_id, strict, allow_negative)

View file

@ -14,6 +14,7 @@ class StockMoveLine(models.Model):
string='Expiration Date', compute='_compute_expiration_date', store=True,
help='This is the date on which the goods with this Serial Number may'
' become dangerous and must not be consumed.')
removal_date = fields.Datetime(string='Removal Date', compute='_compute_removal_date', readonly=False, store=True)
is_expired = fields.Boolean(related='lot_id.product_expiry_alert')
use_expiration_date = fields.Boolean(
string='Use Expiration Date', related='product_id.use_expiration_date')
@ -24,8 +25,10 @@ class StockMoveLine(models.Model):
and 'product_id.use_expiration_date' are new fields introduced in this module,
there is no need for an UPDATE statement here.
"""
if not column_exists(self._cr, "stock_move_line", "expiration_date"):
create_column(self._cr, "stock_move_line", "expiration_date", "timestamp")
if not column_exists(self.env.cr, "stock_move_line", "expiration_date"):
create_column(self.env.cr, "stock_move_line", "expiration_date", "timestamp")
if not column_exists(self.env.cr, "stock_move_line", "removal_date"):
create_column(self.env.cr, "stock_move_line", "removal_date", "timestamp")
return super()._auto_init()
@api.depends('product_id', 'lot_id.expiration_date', 'picking_id.scheduled_date')
@ -41,37 +44,19 @@ class StockMoveLine(models.Model):
else:
move_line.expiration_date = False
@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
@api.depends('product_id', 'expiration_date', 'lot_id.removal_date')
def _compute_removal_date(self):
for move_line in self:
if move_line.lot_id.removal_date:
move_line.removal_date = move_line.lot_id.removal_date
elif move_line.picking_type_use_create_lots:
if move_line.product_id.use_expiration_date and move_line.expiration_date:
move_line.removal_date = move_line.expiration_date - datetime.timedelta(days=move_line.product_id.removal_time)
else:
move_line.removal_date = False
@api.onchange('product_id', 'product_uom_id', 'picking_id')
def _onchange_product_id(self):
res = super()._onchange_product_id()
if self.picking_type_use_create_lots:
if self.product_id.use_expiration_date:
from_date = self.picking_id.scheduled_date or fields.Datetime.today()
self.expiration_date = from_date + datetime.timedelta(days=self.product_id.expiration_time)
else:
self.expiration_date = False
return res
def _assign_production_lot(self, lot):
super()._assign_production_lot(lot)
self.lot_id._update_date_values(self[0].expiration_date)
def _get_value_production_lot(self):
res = super()._get_value_production_lot()
def _prepare_new_lot_vals(self):
vals = super()._prepare_new_lot_vals()
if self.expiration_date:
res.update({
'expiration_date': self.expiration_date,
'use_date': self.expiration_date - datetime.timedelta(days=self.product_id.use_time),
'removal_date': self.expiration_date - datetime.timedelta(days=self.product_id.removal_time),
'alert_date': self.expiration_date - datetime.timedelta(days=self.product_id.alert_time),
})
return res
vals['expiration_date'] = self.expiration_date
return vals

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
from odoo import models, _
@ -18,11 +19,11 @@ class StockPicking(models.Model):
return res
def _check_expired_lots(self):
expired_pickings = self.move_line_ids.filtered(lambda ml: ml.lot_id.product_expiry_alert).picking_id
expired_pickings = self.move_line_ids.filtered(lambda ml: ml.lot_id.product_expiry_alert or (ml.removal_date and ml.removal_date <= datetime.datetime.now())).picking_id
return expired_pickings
def _action_generate_expired_wizard(self):
expired_lot_ids = self.move_line_ids.filtered(lambda ml: ml.lot_id.product_expiry_alert).lot_id.ids
expired_lot_ids = self.move_line_ids.filtered(lambda ml: ml.lot_id.product_expiry_alert or (ml.removal_date and ml.removal_date <= datetime.datetime.now())).lot_id.ids
view_id = self.env.ref('product_expiry.confirm_expiry_view').id
context = dict(self.env.context)

View file

@ -7,27 +7,36 @@ from odoo import api, fields, models
class StockQuant(models.Model):
_inherit = 'stock.quant'
removal_date = fields.Datetime(related='lot_id.removal_date', store=True, readonly=False)
use_expiration_date = fields.Boolean(related='product_id.use_expiration_date', readonly=True)
expiration_date = fields.Datetime(related='lot_id.expiration_date', store=True)
removal_date = fields.Datetime(related='lot_id.removal_date', store=True)
use_expiration_date = fields.Boolean(related='product_id.use_expiration_date')
available_quantity = fields.Float(help="On hand quantity which hasn't been reserved on a transfer and is still fresh, in the default unit of measure of the product")
@api.model
def _get_inventory_fields_create(self):
""" Returns a list of fields user can edit when he want to create a quant in `inventory_mode`.
"""
res = super()._get_inventory_fields_create()
res += ['removal_date']
return res
@api.model
def _get_inventory_fields_write(self):
""" Returns a list of fields user can edit when he want to edit a quant in `inventory_mode`.
"""
res = super()._get_inventory_fields_write()
res += ['removal_date']
return res
def _get_gs1_barcode(self, gs1_quantity_rules_ai_by_uom=False):
barcode = super()._get_gs1_barcode(gs1_quantity_rules_ai_by_uom)
if self.use_expiration_date:
if self.lot_id.expiration_date:
barcode = '17' + self.lot_id.expiration_date.strftime('%y%m%d') + barcode
if self.lot_id.use_date:
barcode = '15' + self.lot_id.use_date.strftime('%y%m%d') + barcode
return barcode
@api.model
def _get_removal_strategy_order(self, removal_strategy):
if removal_strategy == 'fefo':
return 'removal_date, in_date, id'
return super(StockQuant, self)._get_removal_strategy_order(removal_strategy)
return super()._get_removal_strategy_order(removal_strategy)
@api.depends('removal_date')
def _compute_available_quantity(self):
super()._compute_available_quantity()
current_date = fields.Datetime.now()
for quant in self:
if quant.use_expiration_date and quant.removal_date and quant.removal_date <= current_date:
quant.available_quantity = 0
def _set_view_context(self):
self_with_context = self
if self.env.context.get('default_product_id') and self.env['product.product'].browse(self.env.context.get('default_product_id')).use_expiration_date:
self_with_context = self.with_context(show_removal_date=True)
return super(StockQuant, self_with_context)._set_view_context()

View file

@ -0,0 +1,17 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class StockRule(models.Model):
_inherit = 'stock.rule'
@api.model
def _run_scheduler_tasks(self, use_new_cursor=False, company_id=False):
super()._run_scheduler_tasks(use_new_cursor=use_new_cursor, company_id=company_id)
self.env['stock.lot']._alert_date_exceeded()
if use_new_cursor:
self.env['ir.cron']._commit_progress(1)
@api.model
def _get_scheduler_tasks_to_do(self):
return super()._get_scheduler_tasks_to_do() + 1