diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 7688b6f027..1a869bdd51 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # InvenTree API version -INVENTREE_API_VERSION = 159 +INVENTREE_API_VERSION = 160 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v160 -> 2023-012-11 : https://github.com/inventree/InvenTree/pull/6072 + - Adds API endpoint for allocating stock items against a sales order via barcode scan + v159 -> 2023-12-08 : https://github.com/inventree/InvenTree/pull/6056 - Adds API endpoint for reloading plugin registry diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 1268faf5ac..d061d49520 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -1645,8 +1645,17 @@ class SalesOrderAllocation(models.Model): if self.quantity > self.item.quantity: errors['quantity'] = _('Allocation quantity cannot exceed stock quantity') - # TODO: The logic here needs improving. Do we need to subtract our own amount, or something? - if self.item.quantity - self.item.allocation_count() + self.quantity < self.quantity: + # Ensure that we do not 'over allocate' a stock item + build_allocation_count = self.item.build_allocation_count() + sales_allocation_count = self.item.sales_order_allocation_count( + exclude_allocations={ + "pk": self.pk, + } + ) + + total_allocation = build_allocation_count + sales_allocation_count + self.quantity + + if total_allocation > self.item.quantity: errors['quantity'] = _('Stock item is over-allocated') if self.quantity <= 0: diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py index 86eaaf37e0..3bbf33c46c 100644 --- a/InvenTree/order/test_sales_order.py +++ b/InvenTree/order/test_sales_order.py @@ -137,6 +137,37 @@ class SalesOrderTest(TestCase): quantity=25 if full else 20 ) + def test_over_allocate(self): + """Test that over allocation logic works""" + + SA = StockItem.objects.create(part=self.part, quantity=9) + + # First three allocations should succeed + for _i in range(3): + allocation = SalesOrderAllocation.objects.create( + line=self.line, + item=SA, + quantity=3, + shipment=self.shipment + ) + + # Editing an existing allocation with a larger quantity should fail + with self.assertRaises(ValidationError): + allocation.quantity = 4 + allocation.save() + allocation.clean() + + # Next allocation should fail + with self.assertRaises(ValidationError): + allocation = SalesOrderAllocation.objects.create( + line=self.line, + item=SA, + quantity=3, + shipment=self.shipment + ) + + allocation.clean() + def test_allocate_partial(self): """Partially allocate stock""" self.allocate_stock(False) diff --git a/InvenTree/plugin/base/barcodes/api.py b/InvenTree/plugin/base/barcodes/api.py index 6d89631604..e46253bd62 100644 --- a/InvenTree/plugin/base/barcodes/api.py +++ b/InvenTree/plugin/base/barcodes/api.py @@ -2,6 +2,7 @@ import logging +from django.db.models import F from django.urls import path, re_path from django.utils.translation import gettext_lazy as _ @@ -10,6 +11,8 @@ from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.generics import CreateAPIView from rest_framework.response import Response +import order.models +import stock.models from InvenTree.helpers import hash_barcode from plugin import registry from plugin.builtin.barcodes.inventree_barcode import \ @@ -272,6 +275,10 @@ class BarcodePOAllocate(BarcodeView): - A SupplierPart object """ + role_required = [ + 'purchase_order.add' + ] + serializer_class = barcode_serializers.BarcodePOAllocateSerializer def get_supplier_part(self, purchase_order, part=None, supplier_part=None, manufacturer_part=None): @@ -370,6 +377,10 @@ class BarcodePOReceive(BarcodeView): - location: The destination location for the received item (optional) """ + role_required = [ + 'purchase_order.add' + ] + serializer_class = barcode_serializers.BarcodePOReceiveSerializer def handle_barcode(self, barcode: str, request, **kwargs): @@ -385,19 +396,26 @@ class BarcodePOReceive(BarcodeView): # Look for a barcode plugin which knows how to deal with this barcode plugin = None - response = {} + + response = { + "barcode_data": barcode, + "barcode_hash": hash_barcode(barcode) + } internal_barcode_plugin = next(filter( lambda plugin: plugin.name == "InvenTreeBarcode", plugins )) - if internal_barcode_plugin.scan(barcode): - response["error"] = _("Item has already been received") - raise ValidationError(response) + if result := internal_barcode_plugin.scan(barcode): + if 'stockitem' in result: + response["error"] = _("Item has already been received") + raise ValidationError(response) # Now, look just for "supplier-barcode" plugins plugins = registry.with_mixin("supplier-barcode") + plugin_response = None + for current_plugin in plugins: result = current_plugin.scan_receive_item( @@ -413,17 +431,18 @@ class BarcodePOReceive(BarcodeView): if "error" in result: logger.info("%s.scan_receive_item(...) returned an error: %s", current_plugin.__class__.__name__, result["error"]) - if not response: + if not plugin_response: plugin = current_plugin - response = result + plugin_response = result else: plugin = current_plugin - response = result + plugin_response = result break - response["plugin"] = plugin.name if plugin else None - response["barcode_data"] = barcode - response["barcode_hash"] = hash_barcode(barcode) + response['plugin'] = plugin.name if plugin else None + + if plugin_response: + response = {**response, **plugin_response} # A plugin has not been found! if plugin is None: @@ -435,6 +454,158 @@ class BarcodePOReceive(BarcodeView): return Response(response) +class BarcodeSOAllocate(BarcodeView): + """Endpoint for allocating stock to a sales order, by scanning barcode. + + The scanned barcode should map to a StockItem object. + + Additional fields can be passed to the endpoint: + + - SalesOrder (Required) + - Line Item + - Shipment + - Quantity + """ + + role_required = [ + 'sales_order.add', + ] + + serializer_class = barcode_serializers.BarcodeSOAllocateSerializer + + def get_line_item(self, stock_item, **kwargs): + """Return the matching line item for the provided stock item""" + + # Extract sales order object (required field) + sales_order = kwargs['sales_order'] + + # Next, check if a line-item is provided (optional field) + if line_item := kwargs.get('line', None): + return line_item + + # If not provided, we need to find the correct line item + parts = stock_item.part.get_ancestors(include_self=True) + + # Find any matching line items for the stock item + lines = order.models.SalesOrderLineItem.objects.filter( + order=sales_order, + part__in=parts, + shipped__lte=F('quantity'), + ) + + if lines.count() > 1: + raise ValidationError({ + 'error': _('Multiple matching line items found'), + }) + + if lines.count() == 0: + raise ValidationError({ + 'error': _('No matching line item found'), + }) + + return lines.first() + + def get_shipment(self, **kwargs): + """Extract the shipment from the provided kwargs, or guess""" + + sales_order = kwargs['sales_order'] + + if shipment := kwargs.get('shipment', None): + if shipment.order != sales_order: + raise ValidationError({ + 'error': _('Shipment does not match sales order'), + }) + + return shipment + + shipments = order.models.SalesOrderShipment.objects.filter( + order=sales_order, + delivery_date=None + ) + + if shipments.count() == 1: + return shipments.first() + + # If shipment cannot be determined, return None + return None + + def handle_barcode(self, barcode: str, request, **kwargs): + """Handle barcode scan for sales order allocation.""" + + logger.debug("BarcodeSOAllocate: scanned barcode - '%s'", barcode) + + result = self.scan_barcode(barcode, request, **kwargs) + + if result['plugin'] is None: + result['error'] = _('No match found for barcode data') + raise ValidationError(result) + + # Check that the scanned barcode was a StockItem + if 'stockitem' not in result: + result['error'] = _('Barcode does not match an existing stock item') + raise ValidationError(result) + + try: + stock_item_id = result['stockitem'].get('pk', None) + stock_item = stock.models.StockItem.objects.get(pk=stock_item_id) + except (ValueError, stock.models.StockItem.DoesNotExist): + result['error'] = _('Barcode does not match an existing stock item') + raise ValidationError(result) + + # At this stage, we have a valid StockItem object + # Extract any other data from the kwargs + line_item = self.get_line_item(stock_item, **kwargs) + sales_order = kwargs['sales_order'] + shipment = self.get_shipment(**kwargs) + + if stock_item is not None and line_item is not None: + if stock_item.part != line_item.part: + result['error'] = _('Stock item does not match line item') + raise ValidationError(result) + + quantity = kwargs.get('quantity', None) + + # Override quantity for serialized items + if stock_item.serialized: + quantity = 1 + + if quantity is None: + quantity = line_item.quantity - line_item.shipped + quantity = min(quantity, stock_item.unallocated_quantity()) + + response = { + 'stock_item': stock_item.pk if stock_item else None, + 'part': stock_item.part.pk if stock_item else None, + 'sales_order': sales_order.pk if sales_order else None, + 'line_item': line_item.pk if line_item else None, + 'shipment': shipment.pk if shipment else None, + 'quantity': quantity + } + + if stock_item is not None and quantity is not None: + if stock_item.unallocated_quantity() < quantity: + response['error'] = _('Insufficient stock available') + raise ValidationError(response) + + # If we have sufficient information, we can allocate the stock item + if all((x is not None for x in [line_item, sales_order, shipment, quantity])): + order.models.SalesOrderAllocation.objects.create( + line=line_item, + shipment=shipment, + item=stock_item, + quantity=quantity, + ) + + response['success'] = _('Stock item allocated to sales order') + + return Response(response) + + response['error'] = _('Not enough information') + response['action_required'] = True + + raise ValidationError(response) + + barcode_api_urls = [ # Link a third-party barcode to an item (e.g. Part / StockItem / etc) path('link/', BarcodeAssign.as_view(), name='api-barcode-link'), @@ -448,6 +619,9 @@ barcode_api_urls = [ # Allocate parts to a purchase order by scanning their barcode path("po-allocate/", BarcodePOAllocate.as_view(), name="api-barcode-po-allocate"), + # Allocate stock to a sales order by scanning barcode + path("so-allocate/", BarcodeSOAllocate.as_view(), name="api-barcode-so-allocate"), + # Catch-all performs barcode 'scan' re_path(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'), ] diff --git a/InvenTree/plugin/base/barcodes/serializers.py b/InvenTree/plugin/base/barcodes/serializers.py index 584e3605ef..ef0b17d3d0 100644 --- a/InvenTree/plugin/base/barcodes/serializers.py +++ b/InvenTree/plugin/base/barcodes/serializers.py @@ -7,7 +7,7 @@ from rest_framework import serializers import order.models import stock.models -from InvenTree.status_codes import PurchaseOrderStatus +from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus from plugin.builtin.barcodes.inventree_barcode import \ InvenTreeInternalBarcodePlugin @@ -78,7 +78,7 @@ class BarcodePOAllocateSerializer(BarcodeSerializer): purchase_order = serializers.PrimaryKeyRelatedField( queryset=order.models.PurchaseOrder.objects.all(), required=True, - help_text=_('PurchaseOrder to allocate items against'), + help_text=_('Purchase Order to allocate items against'), ) def validate_purchase_order(self, order: order.models.PurchaseOrder): @@ -126,3 +126,49 @@ class BarcodePOReceiveSerializer(BarcodeSerializer): raise ValidationError(_("Cannot select a structural location")) return location + + +class BarcodeSOAllocateSerializer(BarcodeSerializer): + """Serializr for allocating stock items to a sales order + + The scanned barcode must map to a StockItem object + """ + + sales_order = serializers.PrimaryKeyRelatedField( + queryset=order.models.SalesOrder.objects.all(), + required=True, + help_text=_('Sales Order to allocate items against'), + ) + + def validate_sales_order(self, order: order.models.SalesOrder): + """Validate the provided order""" + + if order and order.status != SalesOrderStatus.PENDING.value: + raise ValidationError(_("Sales order is not pending")) + + return order + + line = serializers.PrimaryKeyRelatedField( + queryset=order.models.SalesOrderLineItem.objects.all(), + required=False, allow_null=True, + help_text=_('Sales order line item to allocate items against'), + ) + + shipment = serializers.PrimaryKeyRelatedField( + queryset=order.models.SalesOrderShipment.objects.all(), + required=False, allow_null=True, + help_text=_('Sales order shipment to allocate items against'), + ) + + def validate_shipment(self, shipment: order.models.SalesOrderShipment): + """Validate the provided shipment""" + + if shipment and shipment.is_delivered(): + raise ValidationError(_("Shipment has already been delivered")) + + return shipment + + quantity = serializers.IntegerField( + required=False, + help_text=_('Quantity to allocate'), + ) diff --git a/InvenTree/plugin/base/barcodes/test_barcode.py b/InvenTree/plugin/base/barcodes/test_barcode.py index 8223b40973..b828e12848 100644 --- a/InvenTree/plugin/base/barcodes/test_barcode.py +++ b/InvenTree/plugin/base/barcodes/test_barcode.py @@ -2,6 +2,8 @@ from django.urls import reverse +import company.models +import order.models from InvenTree.unit_test import InvenTreeAPITestCase from part.models import Part from stock.models import StockItem @@ -257,3 +259,175 @@ class BarcodeAPITest(InvenTreeAPITestCase): ) self.assertIn("object does not exist", str(response.data[k])) + + +class SOAllocateTest(InvenTreeAPITestCase): + """Unit tests for the barcode endpoint for allocating items to a sales order""" + + fixtures = [ + 'category', + 'company', + 'part', + 'location', + 'stock', + ] + + @classmethod + def setUpTestData(cls): + """Setup for all tests.""" + super().setUpTestData() + + # Assign required roles + cls.assignRole('sales_order.change') + cls.assignRole('sales_order.add') + + # Find a salable part + cls.part = Part.objects.filter(salable=True).first() + + # Make a stock item + cls.stock_item = StockItem.objects.create( + part=cls.part, + quantity=100 + ) + + cls.stock_item.assign_barcode(barcode_data='barcode') + + # Find a customer + cls.customer = company.models.Company.objects.filter( + is_customer=True + ).first() + + # Create a sales order + cls.sales_order = order.models.SalesOrder.objects.create( + customer=cls.customer + ) + + # Create a shipment + cls.shipment = order.models.SalesOrderShipment.objects.create( + order=cls.sales_order + ) + + # Create a line item + cls.line_item = order.models.SalesOrderLineItem.objects.create( + order=cls.sales_order, + part=cls.part, + quantity=10, + ) + + def setUp(self): + """Setup method for each test""" + super().setUp() + + def postBarcode(self, barcode, expected_code=None, **kwargs): + """Post barcode and return results.""" + + data = { + 'barcode': barcode, + **kwargs + } + + response = self.post( + reverse('api-barcode-so-allocate'), + data=data, + expected_code=expected_code, + ) + + return response.data + + def test_no_data(self): + """Test when no data is provided""" + + result = self.postBarcode('', expected_code=400) + + self.assertIn('This field may not be blank', str(result['barcode'])) + self.assertIn('This field is required', str(result['sales_order'])) + + def test_invalid_sales_order(self): + """Test when an invalid sales order is provided""" + + # Test with an invalid sales order ID + result = self.postBarcode( + '', + sales_order=999999999, + expected_code=400 + ) + + self.assertIn('object does not exist', str(result['sales_order'])) + + def test_invalid_barcode(self): + """Test when an invalid barcode is provided (does not match stock item)""" + + # Test with an invalid barcode + result = self.postBarcode( + '123456789', + sales_order=self.sales_order.pk, + expected_code=400 + ) + + self.assertIn('No match found for barcode', str(result['error'])) + + # Test with a barcode that matches a *different* stock item + item = StockItem.objects.exclude(pk=self.stock_item.pk).first() + item.assign_barcode(barcode_data='123456789') + + result = self.postBarcode( + '123456789', + sales_order=self.sales_order.pk, + expected_code=400 + ) + + self.assertIn('No matching line item found', str(result['error'])) + + # Test with barcode which points to a *part* instance + item.part.assign_barcode(barcode_data='abcde') + + result = self.postBarcode( + 'abcde', + sales_order=self.sales_order.pk, + expected_code=400 + ) + + self.assertIn('does not match an existing stock item', str(result['error'])) + + def test_submit(self): + """Test data submission""" + + # Create a shipment for a different order + other_order = order.models.SalesOrder.objects.create( + customer=self.customer + ) + + other_shipment = order.models.SalesOrderShipment.objects.create( + order=other_order + ) + + # Test with invalid shipment + response = self.postBarcode( + self.stock_item.format_barcode(), + sales_order=self.sales_order.pk, + shipment=other_shipment.pk, + expected_code=400 + ) + + self.assertIn('Shipment does not match sales order', str(response['error'])) + + # No stock has been allocated + self.assertEqual(self.line_item.allocated_quantity(), 0) + + # Test with minimum valid data - this should be enough information to allocate stock + response = self.postBarcode( + self.stock_item.format_barcode(), + sales_order=self.sales_order.pk, + expected_code=200 + ) + + # Check that the right data has been extracted + self.assertIn('Stock item allocated', str(response['success'])) + self.assertEqual(response['sales_order'], self.sales_order.pk) + self.assertEqual(response['line_item'], self.line_item.pk) + self.assertEqual(response['shipment'], self.shipment.pk) + self.assertEqual(response['quantity'], 10) + + self.line_item.refresh_from_db() + self.assertEqual(self.line_item.allocated_quantity(), 10) + self.assertTrue(self.line_item.is_fully_allocated()) diff --git a/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py b/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py index 014c66ed17..73f19d3fec 100644 --- a/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py +++ b/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py @@ -139,33 +139,45 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): part = Part.objects.create(name="Test Part", description="Test Part") supplier = Company.objects.create(name="Supplier", is_supplier=True) manufacturer = Company.objects.create( - name="Test Manufacturer", is_manufacturer=True) + name="Test Manufacturer", is_manufacturer=True + ) mouser = Company.objects.create(name="Mouser Test", is_supplier=True) mpart = ManufacturerPart.objects.create( - part=part, manufacturer=manufacturer, MPN="MC34063ADR") + part=part, manufacturer=manufacturer, MPN="MC34063ADR" + ) self.purchase_order1 = PurchaseOrder.objects.create( - supplier_reference="72991337", supplier=supplier) + supplier_reference="72991337", supplier=supplier + ) + supplier_parts1 = [ SupplierPart(SKU=f"1_{i}", part=part, supplier=supplier) for i in range(6) ] + supplier_parts1.insert( - 2, SupplierPart(SKU="296-LM358BIDDFRCT-ND", part=part, supplier=supplier)) + 2, SupplierPart(SKU="296-LM358BIDDFRCT-ND", part=part, supplier=supplier) + ) + for supplier_part in supplier_parts1: supplier_part.save() self.purchase_order1.add_line_item(supplier_part, 8) self.purchase_order2 = PurchaseOrder.objects.create( - reference="P0-1337", supplier=mouser) + reference="P0-1337", supplier=mouser + ) + self.purchase_order2.place_order() supplier_parts2 = [ SupplierPart(SKU=f"2_{i}", part=part, supplier=mouser) for i in range(6) ] - supplier_parts2.insert( - 3, SupplierPart(SKU="42", part=part, manufacturer_part=mpart, supplier=mouser)) + + supplier_parts2.insert(3, SupplierPart( + SKU="42", part=part, manufacturer_part=mpart, supplier=mouser + )) + for supplier_part in supplier_parts2: supplier_part.save() self.purchase_order2.add_line_item(supplier_part, 5) @@ -175,28 +187,23 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): url = reverse("api-barcode-po-receive") - result1 = self.post(url, data={"barcode": DIGIKEY_BARCODE}) - assert result1.status_code == 400 + result1 = self.post(url, data={"barcode": DIGIKEY_BARCODE}, expected_code=400) + assert result1.data["error"].startswith("No matching purchase order") self.purchase_order1.place_order() - result2 = self.post(url, data={"barcode": DIGIKEY_BARCODE}) - assert result2.status_code == 200 - assert "success" in result2.data + result2 = self.post(url, data={"barcode": DIGIKEY_BARCODE}, expected_code=200) + self.assertIn("success", result2.data) - result3 = self.post(url, data={"barcode": DIGIKEY_BARCODE}) - assert result3.status_code == 400 - assert result3.data["error"].startswith( - "Item has already been received") + result3 = self.post(url, data={"barcode": DIGIKEY_BARCODE}, expected_code=400) + self.assertEqual(result3.data['error'], "Item has already been received") - result4 = self.post(url, data={"barcode": DIGIKEY_BARCODE[:-1]}) - assert result4.status_code == 400 + result4 = self.post(url, data={"barcode": DIGIKEY_BARCODE[:-1]}, expected_code=400) assert result4.data["error"].startswith( "Failed to find pending line item for supplier part") - result5 = self.post(reverse("api-barcode-scan"), data={"barcode": DIGIKEY_BARCODE}) - assert result5.status_code == 200 + result5 = self.post(reverse("api-barcode-scan"), data={"barcode": DIGIKEY_BARCODE}, expected_code=200) stock_item = StockItem.objects.get(pk=result5.data["stockitem"]["pk"]) assert stock_item.supplier_part.SKU == "296-LM358BIDDFRCT-ND" assert stock_item.quantity == 10 diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 3fbb873dd0..8fbecd495c 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1106,7 +1106,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo return total - def get_sales_order_allocations(self, active=True): + def get_sales_order_allocations(self, active=True, **kwargs): """Return a queryset for SalesOrderAllocations against this StockItem, with optional filters. Arguments: @@ -1114,6 +1114,12 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo """ query = self.sales_order_allocations.all() + if filter_allocations := kwargs.get('filter_allocations', None): + query = query.filter(**filter_allocations) + + if exclude_allocations := kwargs.get('exclude_allocations', None): + query = query.exclude(**exclude_allocations) + if active is True: query = query.filter( line__order__status__in=SalesOrderStatusGroups.OPEN, @@ -1128,9 +1134,9 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo return query - def sales_order_allocation_count(self, active=True): + def sales_order_allocation_count(self, active=True, **kwargs): """Return the total quantity allocated to SalesOrders.""" - query = self.get_sales_order_allocations(active=active) + query = self.get_sales_order_allocations(active=active, **kwargs) query = query.aggregate(q=Coalesce(Sum('quantity'), Decimal(0))) total = query['q'] diff --git a/InvenTree/templates/js/translated/barcode.js b/InvenTree/templates/js/translated/barcode.js index 8d294ae9e5..e03d6f3950 100644 --- a/InvenTree/templates/js/translated/barcode.js +++ b/InvenTree/templates/js/translated/barcode.js @@ -419,6 +419,18 @@ function barcodeScanDialog(options={}) { let modal = options.modal || createNewModal(); let title = options.title || '{% trans "Scan Barcode" %}'; + const matching_models = [ + 'build', + 'manufacturerpart', + 'part', + 'purchaseorder', + 'returnorder', + 'salesorder', + 'supplierpart', + 'stockitem', + 'stocklocation' + ]; + barcodeDialog( title, { @@ -428,19 +440,24 @@ function barcodeScanDialog(options={}) { if (options.onScan) { options.onScan(response); } else { + // Find matching model + matching_models.forEach(function(model) { + if (model in response) { + let instance = response[model]; + let url = instance.web_url || instance.url; + if (url) { + window.location.href = url; + return; + } + } + }); - let url = response.url; - - if (url) { - $(modal).modal('hide'); - window.location.href = url; - } else { - showBarcodeMessage( - modal, - '{% trans "No URL in response" %}', - 'warning' - ); - } + // No match + showBarcodeMessage( + modal, + '{% trans "No URL in response" %}', + 'warning' + ); } } }, diff --git a/docs/docs/app/barcode.md b/docs/docs/app/barcode.md index 876bade3c1..98a6c60c36 100644 --- a/docs/docs/app/barcode.md +++ b/docs/docs/app/barcode.md @@ -112,3 +112,15 @@ From the [Purchase Order detail page](./po.md#purchase-order-detail) page, the f #### Scan Received Parts Receive incoming purchase order items against the selected purchase order. Scanning a *new* barcode which is associated with an item in an incoming purchase order will receive the item into stock. + +### Sales Order Actions + +The following barcode actions are available for [Sales Orders](./so.md): + +#### Add Line Item + +Add a new line item to the selected order by scanning a *Part* barcode + +#### Assign Stock + +Allocate stock items to the selected sales order by scanning a *StockItem* barcode diff --git a/docs/docs/app/po.md b/docs/docs/app/po.md index 30f8a5587c..8d7a8bdbb2 100644 --- a/docs/docs/app/po.md +++ b/docs/docs/app/po.md @@ -4,7 +4,7 @@ title: Purchase Orders ## Purchase Order List -The purchase order list display shows all current *outstanding* purchase orders. (Purchase orders which have been completed are not shown here). +The purchase order list display lists all purchase orders: {% with id="po_list", url="app/po_list.png", maxheight="240px", description="Purchase order list" %} {% include "img.html" %} @@ -14,7 +14,7 @@ Select an individual purchase order to display the detail view for that order. ### Filtering -Available purchase orders can be subsequently filtered using the search input at the top of the screen +Displayed purchase orders can be subsequently filtered using the search input at the top of the screen ## Purchase Order Detail diff --git a/docs/docs/app/so.md b/docs/docs/app/so.md new file mode 100644 index 0000000000..a259877eb6 --- /dev/null +++ b/docs/docs/app/so.md @@ -0,0 +1,37 @@ +--- +title: Sales Orders +--- + +## Sales Order List + +The sales order list display shows all sales orders: + +{% with id="so_list", url="app/so_list.png", maxheight="240px", description="Sales order list" %} +{% include "img.html" %} +{% endwith %} + +Select an individual sales order to display the detail view for that order. + +### Filtering + +Displayed sales orders can be subsequently filtered using the search input at the top of the screen + +## Sales Order Detail + +Select an individual order to show the detailed view for that order: + +{% with id="so_detail", url="app/so_detail.png", maxheight="240px", description="Sales order details" %} +{% include "img.html" %} +{% endwith %} + +### Edit Order Details + +From the detail view, select the *Edit* button in the top-right of the screen. This opens the sales order editing display. + +### Line Items + +View the line items associated with the selected order: + +{% with id="so_lines", url="app/so_lines.png", maxheight="240px", description="Sales order lines" %} +{% include "img.html" %} +{% endwith %} diff --git a/docs/docs/assets/images/app/so_detail.png b/docs/docs/assets/images/app/so_detail.png new file mode 100644 index 0000000000..68c93e26b9 Binary files /dev/null and b/docs/docs/assets/images/app/so_detail.png differ diff --git a/docs/docs/assets/images/app/so_lines.png b/docs/docs/assets/images/app/so_lines.png new file mode 100644 index 0000000000..1ae52ce559 Binary files /dev/null and b/docs/docs/assets/images/app/so_lines.png differ diff --git a/docs/docs/assets/images/app/so_list.png b/docs/docs/assets/images/app/so_list.png new file mode 100644 index 0000000000..58b703e803 Binary files /dev/null and b/docs/docs/assets/images/app/so_list.png differ diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 699b3f2df0..2afbf527ae 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -174,6 +174,7 @@ nav: - Parts: app/part.md - Stock: app/stock.md - Purchase Orders: app/po.md + - Sales Orders: app/so.md - Settings: app/settings.md - Privacy: app/privacy.md - Translation: app/translation.md