Added generic MPN barcode scanning (#13266)

* Added generic MPN barcode scanning

- Provides generic lookup against MPN
- Runs with lower priority (fallback only)
- Barcode plugins are now ordered by decreasing priority

* Apply suggestion from @matmair

* Bump internal priority

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
OliverandMatthias Mair authored and GitHub committed 2026-11-13 21:53:35 +11:00
1 parent 891553a9c7
commit 790112013b
12 files changed
+550 -5

No files matched your search

+2
View File
@@ -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.
@@ -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.
+1
View File
@@ -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 |
+13
View File
@@ -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.
+1
View File
@@ -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
@@ -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
@@ -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."""
@@ -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."""
@@ -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'),
@@ -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'),
}
@@ -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))
+1 -1
View File
@@ -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,