diff --git a/CHANGELOG.md b/CHANGELOG.md index dcdaffa017..f1889def14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [#13266](https://github.com/inventree/InvenTree/pull/13266) Adds a "Manufacturer Barcode" plugin, which provides fallback barcode matching against manufacturer part numbers, and a `BARCODE_PRIORITY` attribute which controls the order in which barcode plugins are queried. + - [#13208](https://github.com/inventree/InvenTree/pull/13208) Shows the consuming build order in the stock item Child Items table, which includes consumed children by default for users without saved filters. - [#13166](https://github.com/inventree/InvenTree/pull/13166) Adds an "exclude_from_stock" attribute to the StockLocation model, allowing stock items in specified locations to be excluded from stock availability calculations. diff --git a/docs/docs/plugins/builtin/barcode_manufacturer.md b/docs/docs/plugins/builtin/barcode_manufacturer.md new file mode 100644 index 0000000000..b302f7f742 --- /dev/null +++ b/docs/docs/plugins/builtin/barcode_manufacturer.md @@ -0,0 +1,42 @@ +--- +title: Manufacturer Barcode Plugin +--- + +## Manufacturer Barcode Plugin + +The **Manufacturer Barcode Plugin** provides *fallback* barcode support for matching a scanned barcode against the manufacturer part number (MPN) of an existing [manufacturer part](../../purchasing/manufacturer.md). + +It is intended for barcodes which are not handled by any of the supplier-specific barcode plugins (e.g. [DigiKey](./barcode_digikey.md) or [Mouser](./barcode_mouser.md)), but which still contain a manufacturer part number. + +This plugin implements the [BarcodeMixin](../mixins/barcode.md) mixin to provide custom barcode support - see [Barcode Plugins](./barcode_index.md). + +### Activation + +This plugin is an *optional* plugin, and must be enabled in the InvenTree admin center. + +### Supported Formats + +The following barcode formats are supported: + +| Format | Example | Notes | +| --- | --- | --- | +| 2D barcode (e.g. DataMatrix) | `[)>␞06␝K123␝1PLM358␝Q10␞␄` | Fields separated by GS / RS characters (ISO/IEC 15434 / ECIA). The `1P` field is used if present, otherwise the `P` field. | +| Prefixed MPN | `1PLM358` or `PLM358` | A single field with a `1P` or `P` prefix, followed by the MPN. | +| Raw MPN | `LM358` | The barcode data is the MPN itself. | + +For a single prefixed field, both the MPN with the prefix removed *and* the raw barcode data are checked. For example, `PIC16F84A` matches a manufacturer part with MPN `PIC16F84A`. + +### Matching Rules + +- The MPN must match *exactly*. +- A match is only returned if exactly *one* manufacturer part matches. If the same MPN exists for multiple manufacturers, no match is returned. +- The user must have permission to view manufacturer parts. + +### Priority + +This plugin has a low [barcode priority](../mixins/barcode.md#barcode-priority), and is only used if no other barcode plugin is able to match the scanned barcode. A match from a supplier plugin (which may also identify the supplier part) is always preferred. + +!!! warning "Supplier Plugin Configuration" + If a supplier barcode plugin is enabled, but no supplier is configured for it (and none can be automatically determined), that plugin may match *any* standard 2D barcode by MPN alone. Ensure that each enabled supplier plugin has the correct supplier selected. + +This plugin does not support receiving items against a purchase order, as a manufacturer part number alone cannot identify the supplier. diff --git a/docs/docs/plugins/builtin/index.md b/docs/docs/plugins/builtin/index.md index c229f02ca7..a37739c8e8 100644 --- a/docs/docs/plugins/builtin/index.md +++ b/docs/docs/plugins/builtin/index.md @@ -17,6 +17,7 @@ The following builtin plugins are available in InvenTree: | Barcodes | [DigiKey](./barcode_digikey.md) | DigiKey barcode support | No | | Barcodes | [InvenTree Barcode](./inventree_barcode.md) | Internal barcode support | Yes | | Barcodes | [LCSC](./barcode_lcsc.md) | LCSC barcode support | No | +| Barcodes | [Manufacturer](./barcode_manufacturer.md) | Fallback barcode support for manufacturer part numbers | No | | Barcodes | [Mouser](./barcode_mouser.md) | Mouser barcode support | No | | Barcodes | [TME](./barcode_tme.md) | TME barcode support | No | | Data Export | [BOM Exporter](./bom_exporter.md) | Custom [exporter](../mixins/export.md) for BOM data | Yes | diff --git a/docs/docs/plugins/mixins/barcode.md b/docs/docs/plugins/mixins/barcode.md index 3985990141..8ff4a3eca9 100644 --- a/docs/docs/plugins/mixins/barcode.md +++ b/docs/docs/plugins/mixins/barcode.md @@ -24,6 +24,19 @@ POST { } ``` +### Barcode Priority + +When a barcode is scanned, the active barcode plugins are queried in order of their `BARCODE_PRIORITY` attribute (highest first), and the first plugin to return a successful match is used. Plugins with equal priority are queried in the order they are loaded. + +The default priority is `0`. A plugin which should only be used as a *fallback* (when no other plugin can match the barcode) should set a negative priority: + +```python +class MyFallbackBarcodePlugin(BarcodeMixin, InvenTreePlugin): + BARCODE_PRIORITY = -10 +``` + +A plugin which should take precedence over the builtin plugins can set a positive priority. Note that a higher priority plugin may prevent other plugins (including the builtin InvenTree barcode plugin) from matching a barcode. + ### Builtin Plugin The InvenTree server includes a builtin barcode plugin which can generate and decode the QR codes. This plugin is enabled by default. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index d89068ca31..2769beaf6f 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -255,6 +255,7 @@ nav: - LCSC Barcode Plugin: plugins/builtin/barcode_lcsc.md - Mouser Barcode Plugin: plugins/builtin/barcode_mouser.md - TME Barcode Plugin: plugins/builtin/barcode_tme.md + - Manufacturer Barcode Plugin: plugins/builtin/barcode_manufacturer.md - Event Plugins: - Auto Create Builds: plugins/builtin/auto_create_builds.md - Auto Issue: plugins/builtin/auto_issue.md diff --git a/src/backend/InvenTree/plugin/base/barcodes/api.py b/src/backend/InvenTree/plugin/base/barcodes/api.py index 64be3f512e..c429462965 100644 --- a/src/backend/InvenTree/plugin/base/barcodes/api.py +++ b/src/backend/InvenTree/plugin/base/barcodes/api.py @@ -31,6 +31,23 @@ from . import serializers as barcode_serializers logger = structlog.get_logger('inventree') +def get_barcode_plugins(mixin: str = PluginMixinEnum.BARCODE) -> list: + """Return the active barcode plugins, in the order they should be queried. + + Plugins are sorted by their BARCODE_PRIORITY attribute (highest first). + The sort is stable, so plugins with equal priority retain registry order. + """ + plugins = registry.with_mixin(mixin) + + def priority(plugin) -> int: + try: + return int(getattr(plugin, 'BARCODE_PRIORITY', 0)) + except (TypeError, ValueError): + return 0 + + return sorted(plugins, key=priority, reverse=True) + + class BarcodeView(CreateAPIView): """Custom view class for handling a barcode scan.""" @@ -143,9 +160,9 @@ class BarcodeView(CreateAPIView): def scan_barcode(self, barcode: str, request, **kwargs): """Perform a generic 'scan' of the provided barcode data. - Check each loaded plugin, and return the first valid match + Check each loaded plugin (in priority order), and return the first valid match """ - plugins = registry.with_mixin(PluginMixinEnum.BARCODE) + plugins = get_barcode_plugins(PluginMixinEnum.BARCODE) # Look for a barcode plugin which knows how to deal with this barcode plugin = None @@ -548,7 +565,7 @@ class BarcodePOReceive(BarcodeView): raise ValidationError(response) # Now, look just for "supplier-barcode" plugins - plugins = registry.with_mixin(PluginMixinEnum.SUPPLIER_BARCODE) + plugins = get_barcode_plugins(PluginMixinEnum.SUPPLIER_BARCODE) plugin_slug = None diff --git a/src/backend/InvenTree/plugin/base/barcodes/mixins.py b/src/backend/InvenTree/plugin/base/barcodes/mixins.py index 1d9510b8c5..1e3b84a61d 100644 --- a/src/backend/InvenTree/plugin/base/barcodes/mixins.py +++ b/src/backend/InvenTree/plugin/base/barcodes/mixins.py @@ -27,6 +27,14 @@ class BarcodeMixin: ACTION_NAME = '' + # Order in which barcode plugins are queried when scanning a barcode. + # Plugins with a higher priority are queried first, and the first plugin + # to return a successful match is used. + # Plugins with equal priority are queried in registry order. + # Use a negative value for "fallback" plugins which should only be used + # when no other plugin can match the barcode. + BARCODE_PRIORITY: int = 0 + class MixinMeta: """Meta options for this mixin.""" diff --git a/src/backend/InvenTree/plugin/base/barcodes/test_barcode.py b/src/backend/InvenTree/plugin/base/barcodes/test_barcode.py index 1f7678dade..e4fbb6a441 100644 --- a/src/backend/InvenTree/plugin/base/barcodes/test_barcode.py +++ b/src/backend/InvenTree/plugin/base/barcodes/test_barcode.py @@ -261,6 +261,48 @@ class BarcodeAPITest(InvenTreeAPITestCase): self.assertIn('object does not exist', str(response.data[k])) +class BarcodePluginPriorityTest(InvenTreeAPITestCase): + """Tests for the ordering of barcode plugins by BARCODE_PRIORITY.""" + + def test_sort_order(self): + """Plugins are sorted by priority (highest first), retaining registry order for ties.""" + from types import SimpleNamespace + from unittest import mock + + from plugin.base.barcodes.api import get_barcode_plugins + + plugins = [ + SimpleNamespace(slug='a', BARCODE_PRIORITY=0), + SimpleNamespace(slug='fallback', BARCODE_PRIORITY=-100), + SimpleNamespace(slug='b', BARCODE_PRIORITY=0), + SimpleNamespace(slug='high', BARCODE_PRIORITY=10), + SimpleNamespace(slug='missing'), # No attribute - defaults to 0 + SimpleNamespace(slug='invalid', BARCODE_PRIORITY='abc'), # Defaults to 0 + ] + + with mock.patch( + 'plugin.base.barcodes.api.registry.with_mixin', return_value=plugins + ): + slugs = [plg.slug for plg in get_barcode_plugins()] + + self.assertEqual(slugs, ['high', 'a', 'b', 'missing', 'invalid', 'fallback']) + + def test_registry_order(self): + """The fallback manufacturer barcode plugin is queried last.""" + from plugin import PluginMixinEnum, registry + from plugin.base.barcodes.api import get_barcode_plugins + + registry.set_plugin_states({'manufacturerbarcode': True}) + + slugs = [plg.slug for plg in get_barcode_plugins(PluginMixinEnum.BARCODE)] + + self.assertGreater(len(slugs), 1) + self.assertEqual(slugs[-1], 'manufacturerbarcode') + self.assertLess( + slugs.index('inventreebarcode'), slugs.index('manufacturerbarcode') + ) + + class BarcodeScanResultAPITest(InvenTreeAPITestCase): """Tests for the BarcodeScanResult list / detail API endpoints.""" diff --git a/src/backend/InvenTree/plugin/builtin/barcodes/inventree_barcode.py b/src/backend/InvenTree/plugin/builtin/barcodes/inventree_barcode.py index 52e31b894a..4027344db0 100644 --- a/src/backend/InvenTree/plugin/builtin/barcodes/inventree_barcode.py +++ b/src/backend/InvenTree/plugin/builtin/barcodes/inventree_barcode.py @@ -30,9 +30,13 @@ class InvenTreeInternalBarcodePlugin(SettingsMixin, BarcodeMixin, InvenTreePlugi NAME = 'InvenTreeBarcode' TITLE = _('InvenTree Barcodes') DESCRIPTION = _('Provides native support for barcodes') - VERSION = '2.2.0' + VERSION = '2.3.0' AUTHOR = _('InvenTree contributors') + # This plugin will by default take priority over other barcode plugins + # This is to ensure that an internal barcode should always scan first + BARCODE_PRIORITY = 100 + SETTINGS = { 'INTERNAL_BARCODE_FORMAT': { 'name': _('Internal Barcode Format'), diff --git a/src/backend/InvenTree/plugin/builtin/barcodes/manufacturer_barcode.py b/src/backend/InvenTree/plugin/builtin/barcodes/manufacturer_barcode.py new file mode 100644 index 0000000000..4c93a7f848 --- /dev/null +++ b/src/backend/InvenTree/plugin/builtin/barcodes/manufacturer_barcode.py @@ -0,0 +1,177 @@ +"""Fallback barcode plugin which matches barcodes against manufacturer part numbers. + +This plugin is intended to catch barcodes which are not handled by any +supplier-specific plugin (e.g. DigiKey / Mouser), but which still contain +a manufacturer part number (MPN). + +The following barcode formats are supported: + +- ISO/IEC 15434 / ECIA 2D barcodes (e.g. DataMatrix) with GS / RS field separators, + containing a '1P' (manufacturer part number) or 'P' data identifier field. +- A single field with a '1P' or 'P' prefix, followed by the MPN (e.g. '1PLM358' or 'PLM358'). +- The raw MPN string (e.g. 'LM358'). + +A match is only returned if exactly one ManufacturerPart matches the MPN exactly. + +This plugin has a low barcode priority, so it is only used when no other +barcode plugin is able to match the scanned barcode. +""" + +import re + +from django.utils.translation import gettext_lazy as _ + +import structlog + +from company.models import ManufacturerPart +from plugin import InvenTreePlugin +from plugin.mixins import BarcodeMixin +from users.permissions import check_user_permission + +logger = structlog.get_logger('inventree') + + +# ISO/IEC 15434 control characters +GS = '\x1d' # Group separator (field separator) +RS = '\x1e' # Record separator +EOT = '\x04' # End of transmission + +# A data identifier (DI) is an optional numeric prefix, followed by a single uppercase letter +DATA_IDENTIFIER_REGEX = re.compile(r'^(\d{0,3}[A-Z])') + +# Any ASCII control character (which should never appear in an MPN) +CONTROL_CHAR_REGEX = re.compile(r'[\x00-\x1f\x7f]') + +# Data identifiers which may contain a manufacturer part number, in order of preference +MPN_IDENTIFIERS = ['1P', 'P'] + + +class ManufacturerBarcodePlugin(BarcodeMixin, InvenTreePlugin): + """Fallback barcode plugin for matching manufacturer part numbers.""" + + NAME = 'ManufacturerBarcodePlugin' + SLUG = 'manufacturerbarcode' + TITLE = _('Manufacturer Part Barcodes') + DESCRIPTION = _( + 'Fallback barcode support for matching manufacturer part numbers (MPN)' + ) + VERSION = '1.0.0' + AUTHOR = _('InvenTree contributors') + + # Fallback plugin - query after all other barcode plugins + BARCODE_PRIORITY = -100 + + @staticmethod + def max_mpn_length() -> int: + """Return the maximum length of a ManufacturerPart MPN.""" + return ManufacturerPart._meta.get_field('MPN').max_length or 100 + + @staticmethod + def parse_data_identifier_fields(barcode_data: str) -> dict[str, str]: + """Split a GS / RS delimited barcode into a dict of {identifier: value}. + + Header fields (e.g. '[)>' and the '06' format code) do not match the + data identifier pattern, and are ignored. + If an identifier appears multiple times, the first value is used. + """ + fields = {} + + for field in re.split(f'[{GS}{RS}{EOT}]', barcode_data): + if match := DATA_IDENTIFIER_REGEX.match(field): + identifier = match.group(1) + fields.setdefault(identifier, field[match.end() :]) + + return fields + + @classmethod + def extract_mpn_candidates(cls, barcode_data: str) -> list[str]: + """Extract a list of candidate MPN values from the provided barcode data. + + Arguments: + barcode_data: The raw barcode data + + Returns: + A list of candidate MPN strings (may be empty) + """ + if not isinstance(barcode_data, str) or not barcode_data: + return [] + + candidates = [] + + if GS in barcode_data or RS in barcode_data: + # Structured (e.g. DataMatrix) barcode + # Only the MPN fields are considered - never the raw barcode data + fields = cls.parse_data_identifier_fields(barcode_data) + + for identifier in MPN_IDENTIFIERS: + if value := fields.get(identifier, '').strip(): + # Prefer '1P' over 'P' - a 'P' field is typically a + # customer or supplier part number when '1P' is present + candidates.append(value) + break + else: + data = barcode_data.strip() + + # Reject any other (unstructured) data containing control characters + if not data or CONTROL_CHAR_REGEX.search(data): + return [] + + # Single field with a '1P' or 'P' prefix + for identifier in MPN_IDENTIFIERS: + if data.startswith(identifier): + candidates.append(data[len(identifier) :].strip()) + break + + # The raw data may itself be the MPN (e.g. 'PIC16F84A') + candidates.append(data) + + max_length = cls.max_mpn_length() + + result = [] + + for candidate in candidates: + if ( + candidate + and len(candidate) <= max_length + and not CONTROL_CHAR_REGEX.search(candidate) + and candidate not in result + ): + result.append(candidate) + + return result + + def scan(self, barcode_data: str, user, **kwargs) -> dict | None: + """Attempt to match the barcode data against a single ManufacturerPart. + + Returns: + A dict containing the matched ManufacturerPart, or None if there is not exactly one match + """ + candidates = self.extract_mpn_candidates(barcode_data) + + if not candidates: + return None + + # Do not query (or reveal) manufacturer part data for users who cannot view it + if not check_user_permission(user, ManufacturerPart, 'view'): + return None + + # Exact match only - fetch at most two results to detect ambiguity + matches = list( + ManufacturerPart.objects.filter(MPN__in=candidates).order_by('pk')[:2] + ) + + if len(matches) != 1: + if len(matches) > 1: + logger.debug( + 'ManufacturerBarcodePlugin: multiple ManufacturerPart matches for barcode' + ) + return None + + manufacturer_part = matches[0] + + return { + ManufacturerPart.barcode_model_type(): manufacturer_part.format_matched_response( + user=user + ), + 'success': _('Found matching manufacturer part'), + } diff --git a/src/backend/InvenTree/plugin/builtin/barcodes/test_manufacturer_barcode.py b/src/backend/InvenTree/plugin/builtin/barcodes/test_manufacturer_barcode.py new file mode 100644 index 0000000000..50f1504109 --- /dev/null +++ b/src/backend/InvenTree/plugin/builtin/barcodes/test_manufacturer_barcode.py @@ -0,0 +1,238 @@ +"""Unit tests for the ManufacturerBarcodePlugin.""" + +from django.contrib.auth import get_user_model +from django.urls import reverse + +from company.models import Company, ManufacturerPart, SupplierPart +from InvenTree.unit_test import InvenTreeAPITestCase +from part.models import Part +from plugin import PluginMixinEnum, registry +from plugin.builtin.barcodes.manufacturer_barcode import ManufacturerBarcodePlugin + +PLUGIN_NAME = 'ManufacturerBarcodePlugin' +PLUGIN_SLUG = 'manufacturerbarcode' + + +def ecia(*fields: str) -> str: + """Construct an ECIA / ISO 15434 barcode from the provided fields.""" + return '[)>\x1e06\x1d' + '\x1d'.join(fields) + '\x1e\x04' + + +class ManufacturerBarcodeParseTests(InvenTreeAPITestCase): + """Tests for extracting MPN candidates from barcode data (no database lookups).""" + + def test_datamatrix(self): + """Test extraction from GS / RS delimited barcodes.""" + extract = ManufacturerBarcodePlugin.extract_mpn_candidates + + # 1P field + self.assertEqual(extract(ecia('K123', '1PLM358', 'Q10')), ['LM358']) + + # P field only + self.assertEqual(extract(ecia('K123', 'PLM358', 'Q10')), ['LM358']) + + # 1P is preferred over P, regardless of field order + self.assertEqual(extract(ecia('PSKU-123', '1PLM358')), ['LM358']) + + # 30P (supplier part number) and 11P are not MPN fields + self.assertEqual(extract(ecia('30PSKU-123', '11PXYZ')), []) + + # No header, GS separators only + self.assertEqual(extract('K123\x1d1PLM358\x1dQ10'), ['LM358']) + + # Old style Mouser header + self.assertEqual(extract('>[)>06\x1dK1\x1d1PLM358\x1dQ3'), ['LM358']) + + # Raw data is never used as a candidate for structured barcodes + self.assertEqual(extract(ecia('K123', 'Q10')), []) + + # Empty MPN field + self.assertEqual(extract(ecia('1P', 'Q10')), []) + + def test_simple(self): + """Test extraction from simple (single field) barcodes.""" + extract = ManufacturerBarcodePlugin.extract_mpn_candidates + + self.assertEqual(extract('1PLM358'), ['LM358', '1PLM358']) + self.assertEqual(extract('PLM358'), ['LM358', 'PLM358']) + self.assertEqual(extract(' LM358 \n'), ['LM358']) + self.assertEqual(extract('LM358'), ['LM358']) + + # Invalid data + self.assertEqual(extract(''), []) + self.assertEqual(extract(' '), []) + self.assertEqual(extract(None), []) + self.assertEqual(extract(123), []) + self.assertEqual(extract('LM\x00358'), []) + + # Exceeds the maximum MPN length + self.assertEqual(extract('X' * 1000), []) + + +class ManufacturerBarcodeTests(InvenTreeAPITestCase): + """Tests for scanning manufacturer part barcodes via the API.""" + + SCAN_URL = reverse('api-barcode-scan') + + roles = ['part.view', 'purchase_order.view', 'stock.view'] + + @classmethod + def setUpTestData(cls): + """Create manufacturer parts to match against.""" + super().setUpTestData() + + # Only the DigiKey supplier plugin is active for these tests + registry.set_plugin_states({ + PLUGIN_SLUG: True, + 'digikeyplugin': True, + 'mouserplugin': False, + 'lcscplugin': False, + 'tmeplugin': False, + }) + + # Note: The DigiKey plugin must have a supplier configured, + # otherwise it will match *any* ECIA barcode by MPN alone + cls.digikey = Company.objects.create(name='DigiKey', is_supplier=True) + registry.get_plugin('digikeyplugin').set_setting('SUPPLIER_ID', cls.digikey.pk) + + cls.part = Part.objects.create(name='Test Part', description='Test Part') + + cls.manufacturer_a = Company.objects.create( + name='Manufacturer A', is_manufacturer=True + ) + cls.manufacturer_b = Company.objects.create( + name='Manufacturer B', is_manufacturer=True + ) + + def create(manufacturer, mpn): + return ManufacturerPart.objects.create( + part=cls.part, manufacturer=manufacturer, MPN=mpn + ) + + # Unique MPN values + cls.lm358 = create(cls.manufacturer_a, 'LM358') + cls.pic = create(cls.manufacturer_a, 'PIC16F84A') + + # Same MPN from two different manufacturers + create(cls.manufacturer_a, 'NE555') + create(cls.manufacturer_b, 'NE555') + + # 'PX1' could be either 'P' + 'X1' or the raw MPN 'PX1' + create(cls.manufacturer_a, 'X1') + create(cls.manufacturer_a, 'PX1') + + def setUp(self): + """Clear any supplier cached by the DigiKey plugin in a previous test.""" + super().setUp() + registry.get_plugin('digikeyplugin').__dict__.pop('_supplier', None) + + def scan(self, barcode: str, expected_code: int = 200): + """Scan the provided barcode.""" + return self.post( + self.SCAN_URL, data={'barcode': barcode}, expected_code=expected_code + ).data + + def assertMatch(self, barcode: str, manufacturer_part: ManufacturerPart): + """Assert that the barcode matches the provided ManufacturerPart.""" + data = self.scan(barcode) + self.assertEqual(data['plugin'], PLUGIN_NAME) + self.assertEqual(data['manufacturerpart']['pk'], manufacturer_part.pk) + + def assertNoMatch(self, barcode: str): + """Assert that the barcode does not match.""" + data = self.scan(barcode, expected_code=400) + self.assertIn('error', data) + self.assertNotIn('manufacturerpart', data) + + def test_plugin_priority(self): + """The manufacturer barcode plugin has a lower priority than all other barcode plugins. + + Note: The actual query order is tested in plugin.base.barcodes.test_barcode, + and via the API in test_internal_barcode / test_supplier_plugin_preferred. + """ + plugin = registry.get_plugin(PLUGIN_SLUG) + others = [ + plg + for plg in registry.with_mixin(PluginMixinEnum.BARCODE) + if plg.slug != PLUGIN_SLUG + ] + + self.assertLess(plugin.BARCODE_PRIORITY, 0) + self.assertGreater(len(others), 0) + + for other in others: + self.assertGreater(other.BARCODE_PRIORITY, plugin.BARCODE_PRIORITY) + + def test_datamatrix(self): + """Test scanning of structured barcodes.""" + self.assertMatch(ecia('K123', '1PLM358', 'Q10'), self.lm358) + self.assertMatch(ecia('K123', 'PLM358', 'Q10'), self.lm358) + + # 1P takes precedence - an unmatched 1P does not fall back to P + self.assertNoMatch(ecia('PLM358', '1PUNKNOWN')) + + # No MPN fields + self.assertNoMatch(ecia('K123', 'Q10')) + + def test_simple(self): + """Test scanning of simple prefixed barcodes and raw MPN values.""" + for barcode in ['1PLM358', 'PLM358', 'LM358', ' LM358 ']: + self.assertMatch(barcode, self.lm358) + + # Raw MPN which happens to start with 'P' + self.assertMatch('PIC16F84A', self.pic) + + def test_exact_match(self): + """Partial matches are not accepted.""" + for barcode in ['LM35', 'LM3588', 'XLM358', ecia('1PLM35')]: + self.assertNoMatch(barcode) + + def test_ambiguous(self): + """Multiple matching ManufacturerPart instances must not return a match.""" + self.assertNoMatch('NE555') + self.assertNoMatch('1PNE555') + self.assertNoMatch(ecia('1PNE555')) + + # 'PX1' matches both 'X1' (prefix stripped) and 'PX1' (raw) + self.assertNoMatch('PX1') + + def test_internal_barcode(self): + """An internal barcode is matched by the internal plugin, not this plugin.""" + self.lm358.part.assign_barcode(barcode_data='LM358') + + data = self.scan('LM358') + self.assertEqual(data['plugin'], 'InvenTreeBarcode') + self.assertEqual(data['part']['pk'], self.part.pk) + + def test_supplier_plugin_preferred(self): + """A supplier barcode plugin match takes precedence over this plugin.""" + supplier = self.digikey + + barcode = ecia('PDK-LM358-ND', '1PLM358', 'Q10') + + # No supplier part - falls back to the manufacturer part + self.assertMatch(barcode, self.lm358) + + supplier_part = SupplierPart.objects.create( + part=self.part, + supplier=supplier, + SKU='DK-LM358-ND', + manufacturer_part=self.lm358, + ) + + data = self.scan(barcode) + self.assertEqual(data['plugin'], 'DigiKeyBarcodePlugin') + self.assertEqual(data['supplierpart']['pk'], supplier_part.pk) + + def test_permission(self): + """Users without permission to view manufacturer parts get no match.""" + user = get_user_model().objects.create_user( + username='no-perms', password='no-perms-password' + ) + + plugin = registry.get_plugin(PLUGIN_SLUG) + self.assertIsNone(plugin.scan('LM358', user=user)) + self.assertIsNone(plugin.scan('LM358', user=None)) + + # Sanity check - the API user can see the match + self.assertIsNotNone(plugin.scan('LM358', user=self.user)) diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index 1e67d732c0..69096a71ec 100644 --- a/src/backend/InvenTree/plugin/test_api.py +++ b/src/backend/InvenTree/plugin/test_api.py @@ -528,7 +528,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): # Filter by 'mixin' parameter mixin_results = { - PluginMixinEnum.BARCODE: 5, + PluginMixinEnum.BARCODE: 6, PluginMixinEnum.EXPORTER: 4, PluginMixinEnum.ICON_PACK: 1, PluginMixinEnum.MAIL: 1,