diff --git a/docs/docs/stock/expiry.md b/docs/docs/stock/expiry.md index 62aaa2730e..7433497d2b 100644 --- a/docs/docs/stock/expiry.md +++ b/docs/docs/stock/expiry.md @@ -55,6 +55,48 @@ The Part expiry time can be altered using the Part editing form. {{ image("stock/part_expiry.png", title="Edit part expiry") }} +## Stale Stock Notifications + +InvenTree can automatically notify users when stock items are approaching their expiry dates. This feature helps prevent stock from expiring unnoticed by providing advance warning. + +### Configuration + +The stale stock notification system uses the `STOCK_STALE_DAYS` global setting to determine when to send notifications. This setting specifies how many days before expiry (or after expiry) to consider stock items as "stale". + +For example, if `STOCK_STALE_DAYS` is set to 10: +- Stock items expiring within the next 10 days will trigger notifications +- Stock items that expired within the last 10 days will also trigger notifications + +### How It Works + +The system runs a daily background task that: + +1. **Checks for stale stock**: Identifies stock items with expiry dates within the configured threshold +2. **Groups by user**: Organizes stale items by users who are subscribed to notifications for the relevant parts +3. **Sends consolidated emails**: Each user receives a single email containing all their stale stock items + + + +### Prerequisites + +For stale stock notifications to work: + +1. **Stock expiry must be enabled**: The `STOCK_ENABLE_EXPIRY` setting must be enabled +2. **Stale days configured**: The `STOCK_STALE_DAYS` setting must be greater than 0 +3. **Email configuration**: [Email settings](../settings/email.md) must be properly configured +4. **User subscriptions**: Users must be subscribed to notifications for the relevant parts + + +### User Subscriptions + +Users will only receive notifications for parts they are subscribed to. To subscribe to part notifications: + +1. Navigate to the part detail page +2. Click the notification bell icon to subscribe +3. Users can also subscribe to entire part categories + +For more information on part subscriptions, see the [Part Notifications](../part/notification.md) documentation. + ## Sales and Build Orders By default, expired Stock Items cannot be added to neither a Sales Order nor a Build Order. This behavior can be adjusted using the *Sell Expired Stock* and *Build Expired Stock* settings: diff --git a/src/backend/InvenTree/part/tasks.py b/src/backend/InvenTree/part/tasks.py index 4e003a85b2..b4d2891d4a 100644 --- a/src/backend/InvenTree/part/tasks.py +++ b/src/backend/InvenTree/part/tasks.py @@ -15,6 +15,7 @@ import InvenTree.helpers_model import InvenTree.tasks import part.models as part_models import part.stocktake +import stock.models as stock_models from common.settings import get_global_setting from InvenTree.tasks import ( ScheduledTask, @@ -52,6 +53,82 @@ def notify_low_stock(part: part_models.Part): ) +@tracer.start_as_current_span('notify_stale_stock') +def notify_stale_stock(user, stale_items): + """Notify a user about all their stale stock items in one consolidated email. + + Rules: + - Triggered when stock items' expiry dates are within the configured STOCK_STALE_DAYS + - One notification is delivered per user containing all their stale stock items + + Arguments: + user: The user to notify + stale_items: List of stale stock items for this user + """ + if not stale_items: + return + + name = _('Stale stock notification') + item_count = len(stale_items) + + if item_count == 1: + message = _('You have 1 stock item approaching its expiry date') + else: + message = _(f'You have {item_count} stock items approaching their expiry dates') + + # Add absolute URLs and days until expiry for each stock item + stale_items_enhanced = [] + today = InvenTree.helpers.current_date() + + for stock_item in stale_items: + # Calculate days until expiry to print it clearly in table in email later + days_until_expiry = None + expiry_status = _('No expiry date') + + if stock_item.expiry_date: + days_diff = (stock_item.expiry_date - today).days + + if days_diff < 0: + days_until_expiry = days_diff # Keep negative value for template logic + expiry_status = _(f'Expired {abs(days_diff)} days ago') + elif days_diff == 0: + days_until_expiry = 0 + expiry_status = _('Expires today') + else: + days_until_expiry = days_diff + expiry_status = _(f'{days_until_expiry} days') + + item_data = { + 'stock_item': stock_item, + 'absolute_url': InvenTree.helpers_model.construct_absolute_url( + stock_item.get_absolute_url() + ), + 'days_until_expiry': days_until_expiry, + 'expiry_status': expiry_status, + } + stale_items_enhanced.append(item_data) + + context = { + 'stale_items': stale_items_enhanced, + 'item_count': item_count, + 'name': name, + 'message': message, + 'template': {'html': 'email/stale_stock_notification.html', 'subject': name}, + } + + # Use the first stock item as the trigger object for the notification system + trigger_object = stale_items[0] if stale_items else None + + if trigger_object: + common.notifications.trigger_notification( + trigger_object, + 'stock.notify_stale_stock', + targets=[user], + context=context, + check_recent=False, + ) + + @tracer.start_as_current_span('notify_low_stock_if_required') def notify_low_stock_if_required(part_id: int): """Check if the stock quantity has fallen below the minimum threshold of part. @@ -74,6 +151,76 @@ def notify_low_stock_if_required(part_id: int): InvenTree.tasks.offload_task(notify_low_stock, p, group='notification') +@tracer.start_as_current_span('check_stale_stock') +@scheduled_task(ScheduledTask.DAILY) +def check_stale_stock(): + """Check all stock items for stale stock. + + This function runs daily and checks if any stock items are approaching their expiry date + based on the STOCK_STALE_DAYS global setting. + + For any stale stock items found, notifications are sent to users who have subscribed + to notifications for the respective parts. Each user receives one consolidated email + containing all their stale stock items. + """ + # Check if stock expiry functionality is enabled + if not get_global_setting('STOCK_ENABLE_EXPIRY', False, cache=False): + logger.info('Stock expiry functionality is not enabled - exiting') + return + + # Check if STOCK_STALE_DAYS is configured + stale_days = int(get_global_setting('STOCK_STALE_DAYS', 0, cache=False)) + + if stale_days <= 0: + logger.info('Stock stale days is not configured or set to 0 - exiting') + return + + today = InvenTree.helpers.current_date() + stale_threshold = today + timedelta(days=stale_days) + + # Find stock items that are stale (expiry date within STOCK_STALE_DAYS) + stale_stock_items = stock_models.StockItem.objects.filter( + stock_models.StockItem.IN_STOCK_FILTER, # Only in-stock items + expiry_date__isnull=False, # Must have an expiry date + expiry_date__lt=stale_threshold, # Expiry date is within stale threshold + ).select_related('part', 'location') # Optimize queries + + if not stale_stock_items.exists(): + logger.info('No stale stock items found') + return + + logger.info('Found %s stale stock items', stale_stock_items.count()) + + # Group stale stock items by user subscriptions + user_stale_items: dict[stock_models.StockItem, list[stock_models.StockItem]] = {} + + for stock_item in stale_stock_items: + # Get all subscribers for this part + subscribers = stock_item.part.get_subscribers() + + for user in subscribers: + if user not in user_stale_items: + user_stale_items[user] = [] + user_stale_items[user].append(stock_item) + + # Send one consolidated notification per user + for user, items in user_stale_items.items(): + try: + InvenTree.tasks.offload_task( + notify_stale_stock, user, items, group='notification' + ) + except Exception as e: + logger.error( + 'Error scheduling stale stock notification for user %s: %s', + user.username, + str(e), + ) + + logger.info( + 'Scheduled stale stock notifications for %s users', len(user_stale_items) + ) + + @tracer.start_as_current_span('update_part_pricing') def update_part_pricing(pricing: part_models.PartPricing, counter: int = 0): """Update cached pricing data for the specified PartPricing instance. diff --git a/src/backend/InvenTree/part/test_notification_stale.py b/src/backend/InvenTree/part/test_notification_stale.py new file mode 100644 index 0000000000..2c88729a2e --- /dev/null +++ b/src/backend/InvenTree/part/test_notification_stale.py @@ -0,0 +1,272 @@ +"""Unit tests for Part stale stock notification functionality.""" + +from datetime import timedelta +from unittest.mock import patch + +from allauth.account.models import EmailAddress + +import part.models +import part.tasks +import stock.models +from common.models import NotificationEntry, NotificationMessage +from common.settings import set_global_setting +from InvenTree import helpers +from InvenTree.unit_test import InvenTreeTestCase, addUserPermission + + +class StaleStockNotificationTests(InvenTreeTestCase): + """Unit tests for stale stock notification functionality.""" + + fixtures = ['category', 'part', 'location', 'stock'] + + @classmethod + def setUpTestData(cls): + """Create test data as part of initialization.""" + super().setUpTestData() + + # Add email address for user + EmailAddress.objects.create(user=cls.user, email='test@testing.com') + + # Create test parts + cls.part1 = part.models.Part.objects.create( + name='Test Part 1', + description='A test part for stale stock testing', + component=True, + ) + + cls.part2 = part.models.Part.objects.create( + name='Test Part 2', description='Another test part', component=True + ) + + # Create test stock location + cls.location = stock.models.StockLocation.objects.first() + + def setUp(self): + """Setup routines.""" + super().setUp() + + # Enable stock expiry functionality + set_global_setting('STOCK_ENABLE_EXPIRY', True, self.user) + set_global_setting('STOCK_STALE_DAYS', 7, self.user) + + # Clear notifications + NotificationEntry.objects.all().delete() # type: ignore[attr-defined] + NotificationMessage.objects.all().delete() # type: ignore[attr-defined] + + def create_stock_items_with_expiry(self): + """Create stock items with various expiry dates for testing.""" + today = helpers.current_date() + + # Create stock items with different expiry scenarios + # Item 1: Expires tomorrow (stale) + self.stock_item_stale = stock.models.StockItem.objects.create( + part=self.part1, + location=self.location, + quantity=10, + expiry_date=today + timedelta(days=1), + ) + + # Item 2: Already expired + self.stock_item_expired = stock.models.StockItem.objects.create( + part=self.part1, + location=self.location, + quantity=5, + expiry_date=today - timedelta(days=1), + ) + + # Item 3: Expires in 2 weeks (not stale) + self.stock_item_future = stock.models.StockItem.objects.create( + part=self.part2, + location=self.location, + quantity=15, + expiry_date=today + timedelta(days=14), + ) + + # Item 4: No expiry date + self.stock_item_no_expiry = stock.models.StockItem.objects.create( + part=self.part2, location=self.location, quantity=20 + ) + + # Item 5: Out of stock but stale (should be ignored) + self.stock_item_out_of_stock = stock.models.StockItem.objects.create( + part=self.part1, + location=self.location, + quantity=0, + expiry_date=today + timedelta(days=1), + ) + + def test_notify_stale_stock_with_empty_list(self): + """Test notify_stale_stock with empty stale_items list.""" + # Should return early and do nothing + part.tasks.notify_stale_stock(self.user, []) + + # No notifications should be created + self.assertEqual(NotificationMessage.objects.count(), 0) # type: ignore[attr-defined] + + def test_notify_stale_stock_single_item(self): + """Test notify_stale_stock with a single stale item.""" + self.create_stock_items_with_expiry() + + # Mock the trigger_notification to verify it's called correctly + with patch('common.notifications.trigger_notification') as mock_trigger: + part.tasks.notify_stale_stock(self.user, [self.stock_item_stale]) + + # Check that trigger_notification was called + self.assertTrue(mock_trigger.called) + _args, kwargs = mock_trigger.call_args + + # Check trigger object and category + self.assertEqual(_args[0], self.stock_item_stale) + self.assertEqual(_args[1], 'stock.notify_stale_stock') + + # Check context data + context = kwargs['context'] + self.assertIn('1 stock item approaching', context['message']) + + def test_notify_stale_stock_multiple_items(self): + """Test notify_stale_stock with multiple stale items.""" + self.create_stock_items_with_expiry() + + # Mock the trigger_notification to verify it's called correctly + with patch('common.notifications.trigger_notification') as mock_trigger: + # Call notify_stale_stock with multiple items + stale_items = [self.stock_item_stale, self.stock_item_expired] + part.tasks.notify_stale_stock(self.user, stale_items) + + # Check that trigger_notification was called + self.assertTrue(mock_trigger.called) + _args, kwargs = mock_trigger.call_args + + # Check context data + context = kwargs['context'] + self.assertIn('2 stock items approaching', context['message']) + + def test_check_stale_stock_disabled_expiry(self): + """Test check_stale_stock when stock expiry is disabled.""" + # Disable stock expiry + set_global_setting('STOCK_ENABLE_EXPIRY', False, self.user) + + # Create stale stock items + self.create_stock_items_with_expiry() + + # Call check_stale_stock + with patch('part.tasks.logger') as mock_logger: + part.tasks.check_stale_stock() + mock_logger.info.assert_called_with( + 'Stock expiry functionality is not enabled - exiting' + ) + + def test_check_stale_stock_zero_stale_days(self): + """Test check_stale_stock when STOCK_STALE_DAYS is 0.""" + # Set stale days to 0 + set_global_setting('STOCK_STALE_DAYS', 0, self.user) + + # Create stale stock items + self.create_stock_items_with_expiry() + + # Call check_stale_stock + with patch('part.tasks.logger') as mock_logger: + part.tasks.check_stale_stock() + mock_logger.info.assert_called_with( + 'Stock stale days is not configured or set to 0 - exiting' + ) + + def test_check_stale_stock_no_stale_items(self): + """Test check_stale_stock when no stale items exist.""" + # Clear all existing stock items + stock.models.StockItem.objects.all().delete() + + # Create only future expiry items + today = helpers.current_date() + stock.models.StockItem.objects.create( + part=self.part1, + location=self.location, + quantity=10, + expiry_date=today + timedelta(days=30), + ) + + # Call check_stale_stock + with patch('part.tasks.logger') as mock_logger: + part.tasks.check_stale_stock() + mock_logger.info.assert_called_with('No stale stock items found') + + @patch('InvenTree.tasks.offload_task') + def test_check_stale_stock_with_stale_items(self, mock_offload): + """Test check_stale_stock when stale items exist.""" + # Clear existing stock items + stock.models.StockItem.objects.all().delete() + + self.create_stock_items_with_expiry() + + # Subscribe user to parts + addUserPermission(self.user, 'part', 'part', 'view') + self.user.is_active = True + self.user.save() + self.part1.set_starred(self.user, True) + + # Call check_stale_stock + with patch('part.tasks.logger') as mock_logger: + part.tasks.check_stale_stock() + + # Check that stale items were found (stale and expired items) + found_calls = [] + for call in mock_logger.info.call_args_list: + call_str = str(call) + if 'Found' in call_str and 'stale stock items' in call_str: + found_calls.append(call) + self.assertGreater(len(found_calls), 0) + + # Check that notifications were scheduled + scheduled_calls = [] + for call in mock_logger.info.call_args_list: + if 'Scheduled stale stock notifications' in str(call): + scheduled_calls.append(call) + self.assertGreater(len(scheduled_calls), 0) + + # Verify offload_task was called at least once + self.assertTrue(mock_offload.called) + + def test_check_stale_stock_filtering(self): + """Test that check_stale_stock properly filters stock items.""" + # Clear all existing stock items first + stock.models.StockItem.objects.all().delete() + + today = helpers.current_date() + + # Create various stock items + # Should be included: in stock + has expiry + within stale threshold + _included_item = stock.models.StockItem.objects.create( + part=self.part1, + location=self.location, + quantity=10, + expiry_date=today + timedelta(days=3), # Within 7-day threshold + ) + + # Should be excluded: no expiry date + stock.models.StockItem.objects.create( + part=self.part1, location=self.location, quantity=10 + ) + + # Should be excluded: out of stock + stock.models.StockItem.objects.create( + part=self.part1, + location=self.location, + quantity=0, + expiry_date=today + timedelta(days=3), + ) + + # Should be excluded: expiry too far in future + stock.models.StockItem.objects.create( + part=self.part1, + location=self.location, + quantity=10, + expiry_date=today + timedelta(days=20), + ) + + # Call check_stale_stock and verify stale items were found + with patch('InvenTree.tasks.offload_task') as _mock_offload: + with patch('part.tasks.logger') as mock_logger: + part.tasks.check_stale_stock() + + # Should find exactly 1 stale item + mock_logger.info.assert_any_call('Found %s stale stock items', 1) diff --git a/src/backend/InvenTree/templates/email/stale_stock_notification.html b/src/backend/InvenTree/templates/email/stale_stock_notification.html new file mode 100644 index 0000000000..ee0f89f8a5 --- /dev/null +++ b/src/backend/InvenTree/templates/email/stale_stock_notification.html @@ -0,0 +1,61 @@ +{% extends "email/email.html" %} + +{% load i18n %} +{% load inventree_extras %} + +{% block header %} + + +

{{ message }}

+

{% trans "The following stock items are approaching their expiry dates:" %}

+ + +{% endblock header %} + +{% block body %} + + {% trans "Part" %} + {% trans "Location" %} + {% trans "Quantity" %} + {% trans "Batch" %} + {% trans "Serial Number" %} + {% trans "Expiry Date" %} + {% trans "Days Until Expiry" %} + +{% for item_data in stale_items %} + + + + {{ item_data.stock_item.part.full_name }} + + + {{ item_data.stock_item.location|default:"-" }} + {% decimal item_data.stock_item.quantity %} + {{ item_data.stock_item.batch|default:"-" }} + {{ item_data.stock_item.serial|default:"-" }} + {{ item_data.stock_item.expiry_date|date:"Y-m-d"|default:"-" }} + + {% if item_data.days_until_expiry is not None %} + {% if item_data.days_until_expiry < 0 %} + {{ item_data.expiry_status }} + {% elif item_data.days_until_expiry == 0 %} + {{ item_data.expiry_status }} + {% else %} + {{ item_data.expiry_status }} + {% endif %} + {% else %} + - + {% endif %} + + +{% endfor %} +{% endblock body %} + +{% block footer %} + + +

{% trans "You are receiving this email because you are subscribed to notifications for these parts" %}.

+

{% inventree_version shortstring=True %} - InvenTree Documentation

+ + +{% endblock footer %}