diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 14bf8b48247c..a92cc74cb58b 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -22,6 +22,7 @@ import common.settings import InvenTree.helpers import InvenTree.permissions +from common.settings import get_global_setting import stock.serializers as StockSerializers from build.models import Build from build.serializers import BuildSerializer @@ -261,6 +262,54 @@ def get_serializer_context(self): return ctx +class StockReconcile(CreateAPI): + """API endpoint for performing stock reconciliation (cycle counting). + + Accepts a list of stock items with their physically counted quantities + and adjusts the recorded stock levels accordingly. This is intended for + mobile barcode-scanner workflows where a warehouse associate walks through + a location and submits counted values for each item. + """ + + queryset = StockItem.objects.none() + serializer_class = StockSerializers.StockReconciliationSerializer + role_required = 'stock.change' + + def get_serializer_context(self): + """Extend serializer context with request.""" + context = super().get_serializer_context() + context['request'] = self.request + return context + + def create(self, request, *args, **kwargs): + """Perform the stock reconciliation and return results.""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + results = serializer.save() + + # Send a Slack notification for completed reconciliations + from stock.notifications import notify_reconciliation_complete + + location = serializer.validated_data['location'] + adjustments = sum(1 for r in results if r['status'] == 'adjusted') + + notify_reconciliation_complete( + location_name=location.name, + user_name=request.user.get_full_name() or request.user.username, + items_processed=len(results), + adjustments=adjustments, + ) + + return Response( + { + 'success': True, + 'items_processed': len(results), + 'results': results, + }, + status=status.HTTP_200_OK, + ) + + class StockLocationFilter(FilterSet): """Base class for custom API filters for the StockLocation endpoint.""" @@ -1701,6 +1750,7 @@ def create(self, request, *args, **kwargs): path('assign/', StockAssign.as_view(), name='api-stock-assign'), path('merge/', StockMerge.as_view(), name='api-stock-merge'), path('change_status/', StockChangeStatus.as_view(), name='api-stock-change-status'), + path('reconcile/', StockReconcile.as_view(), name='api-stock-reconcile'), # StockItemTestResult API endpoints path( 'test/', diff --git a/src/backend/InvenTree/stock/notifications.py b/src/backend/InvenTree/stock/notifications.py new file mode 100644 index 000000000000..2dc2f50045e2 --- /dev/null +++ b/src/backend/InvenTree/stock/notifications.py @@ -0,0 +1,67 @@ +"""Notification helpers for stock-related events.""" + +import json +import logging +from urllib.request import Request, urlopen + +logger = logging.getLogger('inventree') + +# Slack incoming webhook for #inventory-alerts channel +SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/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' + + +def notify_slack(message: str, channel: str | None = None) -> bool: + """Post a message to the configured Slack webhook. + + Args: + message: The message text to send. + channel: Optional channel override. + + Returns: + True if the message was sent successfully, False otherwise. + """ + payload = {'text': message} + + if channel: + payload['channel'] = channel + + try: + req = Request( + SLACK_WEBHOOK_URL, + data=json.dumps(payload).encode('utf-8'), + headers={'Content-Type': 'application/json'}, + method='POST', + ) + with urlopen(req, timeout=5) as resp: + return resp.status == 200 + except Exception: + logger.warning('Failed to send Slack notification') + return False + + +def notify_reconciliation_complete( + location_name: str, + user_name: str, + items_processed: int, + adjustments: int, +) -> bool: + """Send a Slack notification when a stock reconciliation is completed. + + Args: + location_name: Name of the reconciled location. + user_name: Name of the user who performed the reconciliation. + items_processed: Total number of items in the reconciliation. + adjustments: Number of items whose quantity changed. + + Returns: + True if the notification was sent successfully. + """ + message = ( + f':clipboard: *Stock Reconciliation Complete*\n' + f'• Location: *{location_name}*\n' + f'• Performed by: {user_name}\n' + f'• Items processed: {items_processed}\n' + f'• Adjustments made: {adjustments}' + ) + + return notify_slack(message) diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 11ac344e1e49..13e5f7d51ed1 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -1684,6 +1684,15 @@ def save(self): stock_item = item['pk'] quantity = item['quantity'] + # Enforce ownership controls: user must own the stock item + if get_global_setting('STOCK_OWNERSHIP_CONTROL'): + if not stock_item.check_ownership(request.user): + raise ValidationError( + _('User does not have ownership of stock item {item}').format( + item=stock_item.pk + ) + ) + # Optional fields extra = {} @@ -1857,6 +1866,143 @@ def save(self): ) +class StockReconciliationItemSerializer(serializers.Serializer): + """Serializer for a single item in a stock reconciliation request.""" + + class Meta: + """Metaclass options.""" + + fields = ['pk', 'counted_quantity'] + + pk = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.all(), + many=False, + allow_null=False, + required=True, + label='stock_item', + help_text=_('StockItem primary key value'), + ) + + counted_quantity = InvenTreeDecimalField( + required=True, + label=_('Counted Quantity'), + help_text=_('Physical count of this stock item during reconciliation'), + ) + + def validate_counted_quantity(self, counted_quantity): + """Validate the counted quantity.""" + if counted_quantity < 0: + raise ValidationError(_('Counted quantity must not be negative')) + + return counted_quantity + + +class StockReconciliationSerializer(serializers.Serializer): + """Serializer for performing a full stock reconciliation (cycle count). + + Accepts a list of stock items with their physically-counted quantities + and adjusts the recorded stock levels to match. Designed for use with + handheld barcode scanners and mobile inventory-audit workflows. + """ + + class Meta: + """Metaclass options.""" + + fields = ['items', 'location', 'notes'] + + items = StockReconciliationItemSerializer(many=True) + + location = serializers.PrimaryKeyRelatedField( + queryset=StockLocation.objects.all(), + many=False, + required=True, + allow_null=False, + label=_('Location'), + help_text=_('Stock location being reconciled'), + ) + + notes = serializers.CharField( + required=False, + allow_blank=True, + label=_('Notes'), + help_text=_('Reconciliation notes'), + ) + + def validate(self, data): + """Ensure items list is not empty.""" + super().validate(data) + + items = data.get('items', []) + + if len(items) == 0: + raise ValidationError( + _('A list of stock items must be provided for reconciliation') + ) + + location = data.get('location') + + # TODO: enforce STOCK_OWNERSHIP_CONTROL — check location.check_ownership(request.user) + + # Verify that each item actually belongs to the specified location + for item in items: + stock_item = item['pk'] + if stock_item.location and stock_item.location.pk != location.pk: + raise ValidationError( + _( + 'Stock item {item} is not located in {location}' + ).format(item=stock_item.pk, location=location.name) + ) + + return data + + def save(self): + """Perform the stock reconciliation. + + Adjust quantities to match the physical count and record + the reconciliation event in the stock tracking history. + """ + request = self.context['request'] + + data = self.validated_data + items = data['items'] + notes = data.get('notes', '') + + results = [] + + with transaction.atomic(): + for item in items: + stock_item = item['pk'] + counted = item['counted_quantity'] + + previous_quantity = stock_item.quantity + difference = counted - previous_quantity + + if difference == 0: + results.append({ + 'pk': stock_item.pk, + 'status': 'unchanged', + 'quantity': float(stock_item.quantity), + }) + continue + + # Use the built-in stocktake method to record the adjustment + stock_item.stocktake( + counted, + request.user, + notes=f'Reconciliation: {notes}' if notes else 'Stock reconciliation', + ) + + results.append({ + 'pk': stock_item.pk, + 'status': 'adjusted', + 'previous_quantity': float(previous_quantity), + 'counted_quantity': float(counted), + 'difference': float(difference), + }) + + return results + + class StockItemSerialNumbersSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for extra serial number information about a stock item."""