mirror of
https://github.com/inventree/InvenTree.git
synced 2026-11-14 12:32:44 +00:00
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:
1 parent
891553a9c7
commit
790112013b
12 files changed
+550
-5
No files matched your search
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user