diff --git a/src/backend/InvenTree/plugin/base/barcodes/mixins.py b/src/backend/InvenTree/plugin/base/barcodes/mixins.py index 9189e6f3ba..298cbaeb39 100644 --- a/src/backend/InvenTree/plugin/base/barcodes/mixins.py +++ b/src/backend/InvenTree/plugin/base/barcodes/mixins.py @@ -389,11 +389,17 @@ class SupplierBarcodeMixin(BarcodeMixin): debug_response['no_match'] = True return debug_response + if not line_item or not line_item.part: + return {'error': _('No matching line item found'), 'no_match': False} + if line_item.part != supplier_part: - return {'error': _('Supplier part does not match line item')} + return { + 'error': _('Supplier part does not match line item'), + 'no_match': False, + } if line_item.is_completed(): - return {'error': _('Line item is already completed')} + return {'error': _('Line item is already completed'), 'no_match': False} # Extract location information for the line item location = ( 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 d04993bef6..b8dea2cd17 100644 --- a/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py +++ b/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py @@ -526,6 +526,75 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): self.assertIn('error', result.data) self.assertNotIn('supplier_matches', result.data) + def test_no_matching_line_item_in_po(self): + """Supplier part matches globally, but is not a line item on the resolved PO. + + Regression test: previously line_item stayed None in this case and + scan_receive_item raised an unhandled AttributeError, which was swallowed + by the calling code and surfaced as a misleading 'No plugin match' error. + """ + digikey_supplier = Company.objects.get(name='Supplier') + part = Part.objects.get(name='Test Part') + + # Supplier part matches the barcode SKU, but is not attached to purchase_order1 + SupplierPart.objects.create( + SKU='ORPHAN-SKU-NOT-ON-PO', part=part, supplier=digikey_supplier + ) + + url = reverse('api-barcode-po-receive') + result = self.post( + url, data={'barcode': DIGIKEY_BARCODE_PART_NOT_ON_PO}, expected_code=400 + ) + + self.assertEqual(result.data['error'], 'No matching line item found') + + def test_multiple_matching_line_items_in_po(self): + """Same supplier part appears on two line items of the same PO. + + line_item auto-detection requires a *unique* match; with two line items + referencing the same supplier part, it should fail cleanly instead of crashing. + """ + supplier_part = SupplierPart.objects.get(SKU='296-LM358BIDDFRCT-ND') + + # Add a second, separate line item for the same supplier part + self.purchase_order1.add_line_item( + supplier_part, 4, group=False, destination=self.loc_2 + ) + + url = reverse('api-barcode-po-receive') + result = self.post(url, data={'barcode': DIGIKEY_BARCODE}, expected_code=400) + + self.assertEqual(result.data['error'], 'No matching line item found') + + def test_explicit_line_item_missing_supplier_part(self): + """Explicitly-provided line item whose supplier part has been deleted. + + SupplierPart.part uses on_delete=SET_NULL, so a line item can end up with + part=None if its supplier part is deleted after the line item was created. + """ + digikey_supplier = Company.objects.get(name='Supplier') + part = Part.objects.get(name='Test Part') + + orphan_supplier_part = SupplierPart.objects.create( + SKU='TO-BE-DELETED', part=part, supplier=digikey_supplier + ) + line_item = self.purchase_order1.add_line_item( + orphan_supplier_part, 2, destination=self.loc_2 + ) + + orphan_supplier_part.delete() + line_item.refresh_from_db() + self.assertIsNone(line_item.part) + + url = reverse('api-barcode-po-receive') + result = self.post( + url, + data={'barcode': DIGIKEY_BARCODE, 'line_item': line_item.pk}, + expected_code=400, + ) + + self.assertEqual(result.data['error'], 'No matching line item found') + DIGIKEY_BARCODE = ( '[)>\x1e06\x1dP296-LM358BIDDFRCT-ND\x1d1PLM358BIDDFR\x1dK\x1d1K72991337\x1d' @@ -588,3 +657,11 @@ DIGIKEY_BARCODE_PART_NO_PO = ( '[)>\x1e06\x1dP296-LM358BIDDFRCT-ND\x1d1PLM358BIDDFR\x1dK\x1d1KBADORDER-XXXXX\x1d' '10K85781337\x1d11K1\x1d4LPH\x1dQ10\x1d11ZPICK' ) + +# DigiKey barcode: '1K72991337' matches purchase_order1 via supplier_reference, +# and 'PORPHAN-SKU-NOT-ON-PO' matches a real supplier part in the test database, +# but that supplier part is not attached to any line item of purchase_order1. +DIGIKEY_BARCODE_PART_NOT_ON_PO = ( + '[)>\x1e06\x1dPORPHAN-SKU-NOT-ON-PO\x1d1PORPHAN-MPN\x1dK\x1d1K72991337\x1d' + '10K85781337\x1d11K1\x1d4LPH\x1dQ10\x1d11ZPICK' +)