diff --git a/docs/docs/app/barcode.md b/docs/docs/app/barcode.md index 2645287f76..21eebb315f 100644 --- a/docs/docs/app/barcode.md +++ b/docs/docs/app/barcode.md @@ -67,6 +67,8 @@ Transfer the currently selected stock location into another location. Scanning a Receive incoming purchase order items into the selected location. Scanning a *new* barcode which is associated with an item in an incoming purchase order will receive the item into the selected location. +*Note: Both purchase order number and supplier SKU's are required to be found on the barcode for this function to find the associated line item. Missing one will lead to an error.* + #### Scan Items Into Location the *Scan Items Into Location* action allows you to scan items into the selected location. Scanning a valid barcode associated with a stock item (already in the database) will result in that item being transferred to the selected location. @@ -105,6 +107,8 @@ From the [Purchase Order detail page](./po.md#purchase-order-detail) page, the f 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. +*Note: supplier SKU's are required to be found on the barcode for this function to find the associated line item.* + ### Sales Order Actions The following barcode actions are available for [Sales Orders](./so.md): diff --git a/src/backend/InvenTree/plugin/base/barcodes/api.py b/src/backend/InvenTree/plugin/base/barcodes/api.py index c67a9bed2b..f561319f6e 100644 --- a/src/backend/InvenTree/plugin/base/barcodes/api.py +++ b/src/backend/InvenTree/plugin/base/barcodes/api.py @@ -533,10 +533,23 @@ class BarcodePOReceive(BarcodeView): # Now, look just for "supplier-barcode" plugins plugins = registry.with_mixin(PluginMixinEnum.SUPPLIER_BARCODE) + plugin_slug = None + plugin_response = None + plugin_error = None + + no_supplier_plugin_error = [] + + supplier_purchase_order = None + + plugin_supplier = None + + supplier_part = None + for current_plugin in plugins: try: + # Will either Output Debugresponse if No_Match is True or return the regular response if No_Match is False result = current_plugin.scan_receive_item( barcode, request.user, @@ -546,12 +559,58 @@ class BarcodePOReceive(BarcodeView): line_item=line_item, auto_allocate=auto_allocate, ) + except Exception: log_error('BarcodePOReceive.handle_barcode', plugin=current_plugin.slug) continue - if result is None: - continue + no_match = result.get('no_match', True) + + # No_Match Determines if it found a exact match for all the required fields from scan_recieve_item + if no_match is True: + supplier_found = False + + try: + plugin_slug = current_plugin.slug + supplier_purchase_order = result.get('PO') + plugin_supplier = result.get('supplier') + supplier_part = result.get('supplier_part') + except KeyError as e: + log_error( + f'BarcodePOReceive.handle_barcode debugresponse: KeyError {e}' + ) + continue + + # Supplier does not have associated Supplier ID + if plugin_supplier is None: + no_supplier_plugin_error.append(plugin_slug) + continue + + # No Purchase Order or Supplier Part Found + if supplier_purchase_order is None and supplier_part is None: + continue + + # Purchase Order exists and is found but Supplier part does not exist + if supplier_purchase_order != None and supplier_part is None: + # Supplier was Found + supplier_found = True + plugin_error = _('Purchase order Found\rNo supplier Part Match') + + # Supplier Part is Found but Purchase Order does not exist + elif supplier_purchase_order is None and supplier_part != None: + # Supplier was Found + supplier_found = True + plugin_error = _('Supplier Part Found\rNo Purchase Order Match') + + # Supplier for PO or Supplier part in barcode was found + if supplier_found is True: + # Adds info on for what was found in the barcode + response['supplier_matches'] = { + 'purchase_order': supplier_purchase_order, + 'no_match': no_match, + 'supplier': plugin_supplier, + 'supplier_part': supplier_part, + } if 'error' in result: logger.info( @@ -569,13 +628,20 @@ class BarcodePOReceive(BarcodeView): response['plugin'] = plugin.name if plugin else None - if plugin_response: + # If there is a plugin response, and there is a match (no_match = false), combine the dictionaries + if plugin_response and plugin_response.get('no_match') is False: response = {**response, **plugin_response} + elif no_supplier_plugin_error: + response['no_supplier_plugin_error'] = no_supplier_plugin_error # A plugin has not been found! if plugin is None: response['error'] = _('No plugin match for supplier barcode') + # A plugin was found, with a Error + elif plugin_error: + response['error'] = plugin_error + self.log_scan(request, response, 'success' in response) if 'error' in response: diff --git a/src/backend/InvenTree/plugin/base/barcodes/mixins.py b/src/backend/InvenTree/plugin/base/barcodes/mixins.py index c744cc04f2..fe9d2e684b 100644 --- a/src/backend/InvenTree/plugin/base/barcodes/mixins.py +++ b/src/backend/InvenTree/plugin/base/barcodes/mixins.py @@ -320,7 +320,7 @@ class SupplierBarcodeMixin(BarcodeMixin): location=None, auto_allocate: bool = True, **kwargs, - ) -> dict | None: + ) -> dict: """Attempt to receive an item against a PurchaseOrder via barcode scanning. Arguments: @@ -344,32 +344,50 @@ class SupplierBarcodeMixin(BarcodeMixin): # Extract supplier information supplier = supplier or self.get_supplier(cache=True) - if not supplier: + """Construct Debug Response + This is returned if a perfect match is not found with the info provided from the barcode + + Response Info: + 'supplier': get supplier ID + 'PO': Represented for "Purchase Order", find PO number to supplier + 'supplier_part': find supplier part info to supplier + 'no_match': Boolean, did we find a perfect match with info given? False is Yes, True is No + """ + debug_response = {} + + if supplier is None: # No supplier information available - return None + debug_response['supplier'] = None + else: + debug_response['supplier'] = supplier.name # Extract purchase order information purchase_order = purchase_order or self.get_purchase_order() - if not purchase_order or purchase_order.supplier != supplier: + if purchase_order is None or purchase_order.supplier != supplier: # Purchase order does not match supplier - return None + debug_response['PO'] = None + else: + debug_response['PO'] = purchase_order.reference supplier_part = self.get_supplier_part() - if not supplier_part: + if supplier_part is None: # No supplier part information available - return None + debug_response['supplier_part'] = None + else: + debug_response['supplier_part'] = str(supplier_part.part) # Attempt to find matching line item - if not line_item: + if not line_item and purchase_order != None: line_items = purchase_order.lines.filter(part=supplier_part) if line_items.count() == 1: line_item = line_items.first() - if not line_item: - # No line item information available - return None + # If Purchase Order or Supplier Part does not exist, throw debug response + if debug_response['PO'] is None or debug_response['supplier_part'] is None: + debug_response['no_match'] = True + return debug_response if line_item.part != supplier_part: return {'error': _('Supplier part does not match line item')} @@ -406,7 +424,8 @@ class SupplierBarcodeMixin(BarcodeMixin): 'supplier_part': supplier_part.pk, 'purchase_order': purchase_order.pk, 'location': location.pk if location else None, - } + }, + 'no_match': False, } if action_required: diff --git a/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py b/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py index 5ae4a6dce7..d194102581 100644 --- a/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py +++ b/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py @@ -428,6 +428,95 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): # Quantity should be pre-filled with the remaining quantity self.assertEqual(5, response.data['lineitem']['quantity']) + def test_receive_includes_no_match_false(self): + """Successful receive merges no_match=False from the plugin response.""" + self.purchase_order1.place_order() + url = reverse('api-barcode-po-receive') + + result = self.post(url, data={'barcode': DIGIKEY_BARCODE}, expected_code=200) + + self.assertIn('success', result.data) + self.assertIn('no_match', result.data) + self.assertFalse(result.data['no_match']) + + def test_partial_match_po_found_no_supplier_part(self): + """Barcode references a known PO but the SKU has no matching supplier part.""" + url = reverse('api-barcode-po-receive') + + # '1K72991337' matches purchase_order1 via supplier_reference; SKU is unknown + result = self.post( + url, data={'barcode': DIGIKEY_BARCODE_PO_NO_PART}, expected_code=400 + ) + + self.assertIn('error', result.data) + self.assertIn('Purchase order Found', result.data['error']) + self.assertIn('No supplier Part Match', result.data['error']) + + self.assertIn('supplier_matches', result.data) + matches = result.data['supplier_matches'] + self.assertIsNotNone(matches['purchase_order']) + # DRF's ValidationError converts None leaves to ErrorDetail('None') + self.assertEqual(str(matches['supplier_part']), 'None') + self.assertIsNotNone(matches['supplier']) + self.assertTrue(matches['no_match']) + + def test_partial_match_supplier_part_found_no_po(self): + """Barcode references a known supplier part but the PO reference has no match.""" + url = reverse('api-barcode-po-receive') + + # 'P296-LM358BIDDFRCT-ND' matches the existing supplier part; PO ref is unknown + result = self.post( + url, data={'barcode': DIGIKEY_BARCODE_PART_NO_PO}, expected_code=400 + ) + + self.assertIn('error', result.data) + self.assertIn('Supplier Part Found', result.data['error']) + self.assertIn('No Purchase Order Match', result.data['error']) + + self.assertIn('supplier_matches', result.data) + matches = result.data['supplier_matches'] + # DRF's ValidationError converts None leaves to ErrorDetail('None') + self.assertEqual(str(matches['purchase_order']), 'None') + self.assertIsNotNone(matches['supplier_part']) + self.assertIsNotNone(matches['supplier']) + self.assertTrue(matches['no_match']) + + def test_no_supplier_plugin_error(self): + """Plugins that cannot resolve a supplier are listed in no_supplier_plugin_error.""" + url = reverse('api-barcode-po-receive') + + digikey_plugin = registry.get_plugin('digikeyplugin') + original_supplier_id = digikey_plugin.get_setting('SUPPLIER_ID') + + # Use a non-existent PK so get_supplier() returns None + digikey_plugin.set_setting('SUPPLIER_ID', 99999) + if hasattr(digikey_plugin, '_supplier'): + del digikey_plugin._supplier + + try: + result = self.post( + url, data={'barcode': DIGIKEY_BARCODE}, expected_code=400 + ) + + self.assertIn('no_supplier_plugin_error', result.data) + self.assertIn('digikeyplugin', result.data['no_supplier_plugin_error']) + finally: + digikey_plugin.set_setting('SUPPLIER_ID', original_supplier_id) + if hasattr(digikey_plugin, '_supplier'): + del digikey_plugin._supplier + + def test_no_supplier_matches_when_both_missing(self): + """When neither PO nor supplier part can be resolved, no supplier_matches is set.""" + url = reverse('api-barcode-po-receive') + + # Completely unknown barcode — no PO, no part, no supplier + result = self.post( + url, data={'barcode': 'COMPLETELY-UNKNOWN-BARCODE-XYZ'}, expected_code=400 + ) + + self.assertIn('error', result.data) + self.assertNotIn('supplier_matches', result.data) + DIGIKEY_BARCODE = ( '[)>\x1e06\x1dP296-LM358BIDDFRCT-ND\x1d1PLM358BIDDFR\x1dK\x1d1K72991337\x1d' @@ -476,3 +565,17 @@ TME_QRCODE = ( ) TME_DATAMATRIX_CODE = 'PWBP-302 1PMPNWBP-302 Q1 K19361337/1' + +# DigiKey barcode: '1K72991337' matches purchase_order1 via supplier_reference, +# but 'PNONEXISTENT-SKU' has no matching supplier part in the test database. +DIGIKEY_BARCODE_PO_NO_PART = ( + '[)>\x1e06\x1dPNONEXISTENT-SKU-XXXX\x1d1PNONEXISTENT-MPN\x1dK\x1d1K72991337\x1d' + '10K85781337\x1d11K1\x1d4LPH\x1dQ10\x1d11ZPICK' +) + +# DigiKey barcode: 'P296-LM358BIDDFRCT-ND' matches the existing supplier part, +# but '1KBADORDER-XXXXX' matches no purchase order in the test database. +DIGIKEY_BARCODE_PART_NO_PO = ( + '[)>\x1e06\x1dP296-LM358BIDDFRCT-ND\x1d1PLM358BIDDFR\x1dK\x1d1KBADORDER-XXXXX\x1d' + '10K85781337\x1d11K1\x1d4LPH\x1dQ10\x1d11ZPICK' +)