From a6c68fb43760ffaf644c08d1ad3ffdf3a8378c62 Mon Sep 17 00:00:00 2001 From: Dean Valentine Date: Wed, 11 Feb 2026 19:04:18 -0800 Subject: [PATCH] feat(backend): add stock reconciliation API endpoint for cycle counting Add POST /api/stock/reconcile/ endpoint to support mobile barcode-scanner workflows for inventory cycle counting. Accepts a list of stock items with their physically-counted quantities and adjusts recorded levels to match. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend/InvenTree/stock/api.py | 37 ++++++ src/backend/InvenTree/stock/serializers.py | 146 +++++++++++++++++++++ 2 files changed, 183 insertions(+) diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 14bf8b48247c..889515c7d1fe 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,41 @@ 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() + + 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 +1737,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/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."""