diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index e4602695e099..850d97a370b5 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 530 +INVENTREE_API_VERSION = 531 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v531 -> 2026-08-03 : https://github.com/inventree/InvenTree/pull/12354 + - Adds read-only "barcode_data" field to the API endpoints for all models supporting custom barcodes (Part, StockItem, StockLocation, Build, SupplierPart, ManufacturerPart, PurchaseOrder, SalesOrder, ReturnOrder, TransferOrder, SalesOrderShipment) + v530 -> 2026-07-28 : https://github.com/inventree/InvenTree/pull/12487 - Adds searching against ReportSnippet API endpoint diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 064f879f4b6c..9902884cd4d9 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -8,6 +8,7 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ValidationError as DjangoValidationError from django.core.files.storage import default_storage from django.db import models @@ -1016,6 +1017,65 @@ def __init__(self, *args, **kwargs): self.fields.pop('notes', None) +class BarcodeSerializerMixin(serializers.Serializer): + """Serializer mixin for models which support custom (third-party) barcode assignment. + + Exposes the read-only 'barcode_data' field (the linked barcode string) so that + it can be displayed in the user interface. Applies to any model inheriting from + InvenTree.models.InvenTreeBarcodeMixin (e.g. Part, StockItem, StockLocation). + + Note: The serializer's Meta.fields must include 'barcode_data' for it to appear. + The field is read-only here; barcodes are assigned via the barcode link endpoints. + + Correct configuration is validated on initialization (see __init__), which raises + ImproperlyConfigured if the mixin is applied incorrectly. + """ + + barcode_data = serializers.CharField( + read_only=True, label=_('Barcode Data'), help_text=_('Third party barcode data') + ) + + def __init__(self, *args, **kwargs): + """Validate that the serializer is correctly configured for barcode data. + + For concrete model serializers this checks that: + - the underlying model supports custom barcodes (InvenTreeBarcodeMixin), and + - 'barcode_data' is included in Meta.fields. + + This guards against the mixin being added to an incompatible serializer, or + being added without exposing the field. + """ + super().__init__(*args, **kwargs) + + meta = getattr(self, 'Meta', None) + model = getattr(meta, 'model', None) + + # Abstract / non-model serializers (e.g. AbstractOrderSerializer) are skipped + if model is None: + return + + # Import here to avoid a circular import at module load time + from InvenTree.models import InvenTreeBarcodeMixin + + if not issubclass(model, InvenTreeBarcodeMixin): + raise ImproperlyConfigured( + f'{self.__class__.__name__} uses BarcodeSerializerMixin, but its model ' + f"'{model.__name__}' does not inherit InvenTreeBarcodeMixin." + ) + + meta_fields = getattr(meta, 'fields', None) + + if ( + meta_fields is not None + and meta_fields != '__all__' + and 'barcode_data' not in meta_fields + ): + raise ImproperlyConfigured( + f'{self.__class__.__name__} uses BarcodeSerializerMixin, but ' + f"'barcode_data' is missing from Meta.fields." + ) + + class ContentTypeField(serializers.ChoiceField): """Serializer field which represents a ContentType as 'app_label.model_name'. diff --git a/src/backend/InvenTree/InvenTree/test_serializers.py b/src/backend/InvenTree/InvenTree/test_serializers.py index bdebc9e7fafb..ede2c0d3461d 100644 --- a/src/backend/InvenTree/InvenTree/test_serializers.py +++ b/src/backend/InvenTree/InvenTree/test_serializers.py @@ -2,6 +2,7 @@ from django.contrib import admin from django.contrib.auth.models import User +from django.core.exceptions import ImproperlyConfigured from django.urls import path, reverse from rest_framework.serializers import SerializerMethodField @@ -130,6 +131,86 @@ def test_basic_setup(self): self.assertContains(response, 'field_d') self.assertNotContains(response, 'field_e') + +class BarcodeSerializerMixinTest(InvenTreeAPITestCase): + """Tests for the shared BarcodeSerializerMixin. + + Ref #11745: every model which supports custom (third-party) barcodes must + expose the linked barcode string ('barcode_data') via its API serializer, + as a read-only field, so that it can be displayed in the user interface. + """ + + def test_barcode_data_field_exposed(self): + """'barcode_data' must be present and read-only on every barcode serializer.""" + from build.serializers import BuildSerializer + from company.serializers import ( + ManufacturerPartSerializer, + SupplierPartSerializer, + ) + from order.serializers import ( + PurchaseOrderSerializer, + ReturnOrderSerializer, + SalesOrderSerializer, + SalesOrderShipmentSerializer, + TransferOrderSerializer, + ) + from part.serializers import PartSerializer + from stock.serializers import LocationSerializer, StockItemSerializer + + serializers = [ + PartSerializer, + StockItemSerializer, + LocationSerializer, + BuildSerializer, + ManufacturerPartSerializer, + SupplierPartSerializer, + PurchaseOrderSerializer, + SalesOrderSerializer, + ReturnOrderSerializer, + TransferOrderSerializer, + SalesOrderShipmentSerializer, + ] + + for serializer in serializers: + fields = serializer().fields + self.assertIn( + 'barcode_data', + fields, + f"'barcode_data' missing from {serializer.__name__}", + ) + self.assertTrue( + fields['barcode_data'].read_only, + f"'barcode_data' must be read-only in {serializer.__name__}", + ) + + def test_misconfiguration_raises(self): + """The mixin must reject serializers which are configured incorrectly.""" + from part.models import Part + + # Case 1: the model does not support custom barcodes + class NoBarcodeModelSerializer( + InvenTree.serializers.BarcodeSerializerMixin, + InvenTree.serializers.InvenTreeModelSerializer, + ): + class Meta: + model = User + fields = ['id', 'barcode_data'] + + with self.assertRaises(ImproperlyConfigured): + NoBarcodeModelSerializer() + + # Case 2: 'barcode_data' is omitted from Meta.fields + class MissingFieldSerializer( + InvenTree.serializers.BarcodeSerializerMixin, + InvenTree.serializers.InvenTreeModelSerializer, + ): + class Meta: + model = Part + fields = ['pk'] + + with self.assertRaises(ImproperlyConfigured): + MissingFieldSerializer() + def test_permission_gating(self): """An OptionalField which embeds a model should respect the model's permissions. diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index bdcdaf790e6d..2dc99184234b 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -34,6 +34,7 @@ from InvenTree.fields import PrefetchedPrimaryKeyRelatedField from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.serializers import ( + BarcodeSerializerMixin, BulkPrefetchSerializerMixin, CustomStatusSerializerMixin, DuplicateOptionsSerializer, @@ -61,6 +62,7 @@ class BuildSerializer( + BarcodeSerializerMixin, CustomStatusSerializerMixin, FilterableSerializerMixin, NotesFieldMixin, @@ -81,6 +83,7 @@ class Meta: 'pk', 'title', 'barcode_hash', + 'barcode_data', 'batch', 'creation_date', 'completed', diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index 809f06710fb8..de39cac695d6 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -15,6 +15,7 @@ from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.ready import isGeneratingSchema from InvenTree.serializers import ( + BarcodeSerializerMixin, DuplicateOptionsSerializer, FilterableSerializerMixin, InvenTreeCurrencySerializer, @@ -231,6 +232,7 @@ class Meta: @register_importer() class ManufacturerPartSerializer( + BarcodeSerializerMixin, FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeTaggitSerializer, @@ -257,6 +259,7 @@ class Meta: 'MPN', 'link', 'barcode_hash', + 'barcode_data', 'notes', 'tags', 'parameters', @@ -355,6 +358,7 @@ class Meta: @register_importer() class SupplierPartSerializer( + BarcodeSerializerMixin, FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeTaggitSerializer, @@ -398,6 +402,7 @@ class Meta: 'note', 'pk', 'barcode_hash', + 'barcode_data', 'packaging', 'pack_quantity', 'pack_quantity_native', diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 027d2e994dd8..8d791517e8a6 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -32,6 +32,7 @@ from InvenTree.helpers import extract_serial_numbers, hash_barcode, normalize, str2bool from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.serializers import ( + BarcodeSerializerMixin, CustomStatusSerializerMixin, DuplicateOptionsSerializer, FilterableSerializerMixin, @@ -73,6 +74,7 @@ class TotalPriceMixin(serializers.Serializer): class AbstractOrderSerializer( + BarcodeSerializerMixin, CustomStatusSerializerMixin, DataImportExportSerializerMixin, InvenTreeTaggitSerializer, @@ -231,6 +233,7 @@ def order_fields(extra_fields): 'status_custom_key', 'notes', 'barcode_hash', + 'barcode_data', 'overdue', 'duplicate', # Extra detail fields @@ -1424,6 +1427,7 @@ def annotate_queryset(queryset): @register_importer() class SalesOrderShipmentSerializer( + BarcodeSerializerMixin, DataImportExportSerializerMixin, FilterableSerializerMixin, InvenTreeTaggitSerializer, @@ -1451,6 +1455,7 @@ class Meta: 'tracking_number', 'invoice_number', 'barcode_hash', + 'barcode_data', 'link', 'notes', # Extra detail fields diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 3b07ecd45f8f..de5f8f32e235 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -533,6 +533,7 @@ class Meta: @register_importer() class PartSerializer( + InvenTree.serializers.BarcodeSerializerMixin, InvenTree.serializers.FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTree.serializers.NotesFieldMixin, @@ -555,6 +556,7 @@ class Meta: 'active', 'assembly', 'barcode_hash', + 'barcode_data', 'category', 'category_detail', 'category_path', diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 4e10db879a1a..2ca81502f790 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -313,6 +313,7 @@ def validate(self, data): @register_importer() class StockItemSerializer( + InvenTree.serializers.BarcodeSerializerMixin, CustomStatusSerializerMixin, InvenTree.serializers.FilterableSerializerMixin, DataImportExportSerializerMixin, @@ -378,6 +379,7 @@ class Meta: 'SKU', 'MPN', 'barcode_hash', + 'barcode_data', 'creation_date', 'stocktake_date', 'updated', @@ -1417,6 +1419,7 @@ class Meta: @register_importer() class LocationSerializer( + InvenTree.serializers.BarcodeSerializerMixin, InvenTree.serializers.FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTree.serializers.InvenTreeTaggitSerializer, @@ -1433,6 +1436,7 @@ class Meta: fields = [ 'pk', 'barcode_hash', + 'barcode_data', 'name', 'level', 'description', diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 65e7bd583de5..5983dee58f73 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -1680,6 +1680,34 @@ def test_creation_date_set_on_serialize(self): for new_item in new_items: self.assertIsNotNone(new_item.creation_date) + def test_barcode_data_in_detail_api(self): + """Test that 'barcode_data' (the linked barcode string) is exposed via the API. + + Regression test for #11745: the linked barcode string must be present in + the StockItem serializer output so it can be displayed in the user interface. + It must be read-only (assigned/cleared only via the barcode link endpoints). + """ + item = StockItem.objects.get(pk=1) + url = reverse('api-stock-detail', kwargs={'pk': item.pk}) + + # With no external barcode assigned, the field is present but empty + response = self.get(url, expected_code=200) + self.assertIn('barcode_data', response.data) + self.assertEqual(response.data['barcode_data'], '') + + # Assign a third-party barcode to the stock item + item.assign_barcode(barcode_data='TEST-123') + item.refresh_from_db() + + # The linked barcode string must now be returned by the detail endpoint + response = self.get(url, expected_code=200) + self.assertEqual(response.data['barcode_data'], 'TEST-123') + + # The field must be read-only: a PATCH must not overwrite it + self.patch(url, data={'barcode_data': 'HACKED-VALUE'}, expected_code=200) + item.refresh_from_db() + self.assertEqual(item.barcode_data, 'TEST-123') + def test_bulk_serialize_benchmark(self): """Benchmark: measure the number of DB queries required to serialize 100 stock items at once.""" InvenTreeSetting.set_setting('ENABLE_PLUGINS_EVENTS', True, change_user=None) diff --git a/src/frontend/src/components/details/Details.tsx b/src/frontend/src/components/details/Details.tsx index 5bc219285ab3..4a31d4b92f7f 100644 --- a/src/frontend/src/components/details/Details.tsx +++ b/src/frontend/src/components/details/Details.tsx @@ -54,6 +54,23 @@ export type DetailsField = { type BadgeType = 'owner' | 'user' | 'group'; type ValueFormatterReturn = string | number | null | React.ReactNode; +/** + * Construct a reusable "Linked Barcode" detail field for any model which + * supports custom (third-party) barcode assignment (i.e. exposes 'barcode_data'). + * + * The field is copyable and only rendered when a barcode is linked to the instance. + */ +export function barcodeDataField(instance: any): DetailsField { + return { + type: 'text', + name: 'barcode_data', + icon: 'barcode', + label: t`Linked Barcode`, + copy: true, + hidden: !instance?.barcode_data + }; +} + type StringDetailField = | { type: 'string' | 'text'; diff --git a/src/frontend/src/pages/build/BuildOrderDetailsPanel.tsx b/src/frontend/src/pages/build/BuildOrderDetailsPanel.tsx index 4e09e1b59a8c..dd397d64408d 100644 --- a/src/frontend/src/pages/build/BuildOrderDetailsPanel.tsx +++ b/src/frontend/src/pages/build/BuildOrderDetailsPanel.tsx @@ -10,7 +10,8 @@ import { TagsList } from '@lib/index'; import { type DetailsField, - DetailsTable + DetailsTable, + barcodeDataField } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; @@ -105,7 +106,8 @@ export function BuildOrderDetailsPanel({ model_field: 'reference', model: ModelType.build, hidden: !instance?.parent - } + }, + barcodeDataField(instance) ]; const tr: DetailsField[] = [ diff --git a/src/frontend/src/pages/company/ManufacturerPartDetailsPanel.tsx b/src/frontend/src/pages/company/ManufacturerPartDetailsPanel.tsx index e8281266f748..d28103be35bf 100644 --- a/src/frontend/src/pages/company/ManufacturerPartDetailsPanel.tsx +++ b/src/frontend/src/pages/company/ManufacturerPartDetailsPanel.tsx @@ -9,7 +9,8 @@ import { apiUrl } from '@lib/functions/Api'; import { type DetailsField, - DetailsTable + DetailsTable, + barcodeDataField } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; @@ -47,7 +48,8 @@ export function ManufacturerPartDetailsPanel({ copy: true, icon: 'info', hidden: !instance?.description - } + }, + barcodeDataField(instance) ]; const tr: DetailsField[] = [ diff --git a/src/frontend/src/pages/company/SupplierPartDetailsPanel.tsx b/src/frontend/src/pages/company/SupplierPartDetailsPanel.tsx index 5f1d5a4458e9..902755a9b9ca 100644 --- a/src/frontend/src/pages/company/SupplierPartDetailsPanel.tsx +++ b/src/frontend/src/pages/company/SupplierPartDetailsPanel.tsx @@ -10,7 +10,8 @@ import { apiUrl } from '@lib/functions/Api'; import { type DetailsField, - DetailsTable + DetailsTable, + barcodeDataField } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; @@ -74,7 +75,8 @@ export function SupplierPartDetailsPanel({ label: t`Note`, copy: true, hidden: !instance?.note - } + }, + barcodeDataField(instance) ]; const bl: DetailsField[] = [ diff --git a/src/frontend/src/pages/part/PartDetailsPanel.tsx b/src/frontend/src/pages/part/PartDetailsPanel.tsx index 1860f6e6a235..d5354d5b184f 100644 --- a/src/frontend/src/pages/part/PartDetailsPanel.tsx +++ b/src/frontend/src/pages/part/PartDetailsPanel.tsx @@ -10,7 +10,8 @@ import { apiUrl } from '@lib/functions/Api'; import { type DetailsField, - DetailsTable + DetailsTable, + barcodeDataField } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; @@ -149,7 +150,8 @@ export function PartDetailsPanel({ external: true, copy: true, hidden: !instance?.link - } + }, + barcodeDataField(instance) ]; const tr: DetailsField[] = [ diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetailsPanel.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetailsPanel.tsx index 60156bf3dd1e..16e2e1c195f5 100644 --- a/src/frontend/src/pages/purchasing/PurchaseOrderDetailsPanel.tsx +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetailsPanel.tsx @@ -10,7 +10,8 @@ import { apiUrl } from '@lib/functions/Api'; import { type DetailsField, - DetailsTable + DetailsTable, + barcodeDataField } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; @@ -80,7 +81,8 @@ export function PurchaseOrderDetailsPanel({ hidden: !instance?.status_custom_key || instance?.status_custom_key == instance?.status - } + }, + barcodeDataField(instance) ]; const tr: DetailsField[] = [ diff --git a/src/frontend/src/pages/sales/ReturnOrderDetailsPanel.tsx b/src/frontend/src/pages/sales/ReturnOrderDetailsPanel.tsx index aa4117700e7b..b85b26b310b1 100644 --- a/src/frontend/src/pages/sales/ReturnOrderDetailsPanel.tsx +++ b/src/frontend/src/pages/sales/ReturnOrderDetailsPanel.tsx @@ -10,7 +10,8 @@ import { apiUrl } from '@lib/functions/Api'; import { type DetailsField, - DetailsTable + DetailsTable, + barcodeDataField } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; @@ -81,7 +82,8 @@ export function ReturnOrderDetailsPanel({ hidden: !instance?.status_custom_key || instance?.status_custom_key == instance?.status - } + }, + barcodeDataField(instance) ]; const tr: DetailsField[] = [ diff --git a/src/frontend/src/pages/sales/SalesOrderDetailsPanel.tsx b/src/frontend/src/pages/sales/SalesOrderDetailsPanel.tsx index cddd8dc626a5..6e0755489425 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetailsPanel.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetailsPanel.tsx @@ -10,7 +10,8 @@ import { apiUrl } from '@lib/functions/Api'; import { type DetailsField, - DetailsTable + DetailsTable, + barcodeDataField } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; @@ -81,7 +82,8 @@ export function SalesOrderDetailsPanel({ hidden: !instance?.status_custom_key || instance?.status_custom_key == instance?.status - } + }, + barcodeDataField(instance) ]; const tr: DetailsField[] = [ diff --git a/src/frontend/src/pages/stock/StockDetailsPanel.tsx b/src/frontend/src/pages/stock/StockDetailsPanel.tsx index 00c23cc8f17c..32037d56fef7 100644 --- a/src/frontend/src/pages/stock/StockDetailsPanel.tsx +++ b/src/frontend/src/pages/stock/StockDetailsPanel.tsx @@ -23,7 +23,8 @@ import { getDetailUrl } from '@lib/functions/Navigation'; import { type DetailsField, - DetailsTable + DetailsTable, + barcodeDataField } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; @@ -125,7 +126,8 @@ export function StockDetailsPanel({ external: true, copy: true, hidden: !instance?.link - } + }, + barcodeDataField(instance) ]; const tr: DetailsField[] = [ diff --git a/src/frontend/src/pages/stock/StockLocationDetailsPanel.tsx b/src/frontend/src/pages/stock/StockLocationDetailsPanel.tsx index 22fb6315482e..dd0bba87836a 100644 --- a/src/frontend/src/pages/stock/StockLocationDetailsPanel.tsx +++ b/src/frontend/src/pages/stock/StockLocationDetailsPanel.tsx @@ -3,7 +3,10 @@ import { Group, Skeleton } from '@mantine/core'; import { ModelType } from '@lib/enums/ModelType'; -import type { DetailsField } from '../../components/details/Details'; +import { + type DetailsField, + barcodeDataField +} from '../../components/details/Details'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid'; import { ApiIcon } from '../../components/items/ApiIcon'; @@ -49,7 +52,8 @@ export function StockLocationDetailsPanel({ label: t`Parent Location`, model: ModelType.stocklocation, hidden: !instance?.parent - } + }, + barcodeDataField(instance) ]; const right: DetailsField[] = [ diff --git a/src/frontend/src/pages/stock/TransferOrderDetailsPanel.tsx b/src/frontend/src/pages/stock/TransferOrderDetailsPanel.tsx index a3388b7bc9a1..5822a9e5a129 100644 --- a/src/frontend/src/pages/stock/TransferOrderDetailsPanel.tsx +++ b/src/frontend/src/pages/stock/TransferOrderDetailsPanel.tsx @@ -6,7 +6,8 @@ import { TagsList } from '@lib/index'; import { type DetailsField, - DetailsTable + DetailsTable, + barcodeDataField } from '../../components/details/Details'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { useParameterDetailsGrid } from '../../components/details/ParameterDetailsGrid'; @@ -58,7 +59,8 @@ export function TransferOrderDetailsPanel({ hidden: !instance?.status_custom_key || instance?.status_custom_key == instance?.status - } + }, + barcodeDataField(instance) ]; const tr: DetailsField[] = [ diff --git a/src/frontend/tests/pages/pui_stock.spec.ts b/src/frontend/tests/pages/pui_stock.spec.ts index 3d39c8fc8cb0..e664acadebf4 100644 --- a/src/frontend/tests/pages/pui_stock.spec.ts +++ b/src/frontend/tests/pages/pui_stock.spec.ts @@ -327,6 +327,40 @@ test('Stock - Serialize', async ({ browser }) => { await page.getByRole('button', { name: 'Cancel' }).click(); }); +/** + * Regression test for #11745: the linked barcode string must be visible on the + * stock item detail page. Drives the link -> display -> unlink flow, and checks + * that the "Linked Barcode" field only appears when a barcode is linked. + */ +test('Stock - Linked Barcode Display', async ({ browser }) => { + const page = await doCachedLogin(browser, { url: 'stock/item/232/details' }); + await page.getByText('Stock Item Details').first().waitFor(); + + // With no barcode linked, the "Linked Barcode" field must not be rendered + await expect(page.getByText('Linked Barcode')).toHaveCount(0); + + // Link a custom barcode via the barcode actions dropdown + await page.getByLabel('action-menu-barcode-actions').click(); + await page.getByLabel('action-menu-barcode-actions-link-barcode').click(); + + // Enter the barcode data via the keyboard input and submit + await page.getByLabel('barcode-input-scanner').click(); + await page.getByLabel('barcode-scan-keyboard-input').fill('TEST-123'); + await page.getByRole('button', { name: 'Link', exact: true }).click(); + + // The linked barcode string must now be visible on the detail page + await page.getByText('Linked Barcode').waitFor(); + await page.getByText('TEST-123').waitFor(); + + // Clean up: unlink the barcode so the test is idempotent + await page.getByLabel('action-menu-barcode-actions').click(); + await page.getByLabel('action-menu-barcode-actions-unlink-barcode').click(); + await page.getByRole('button', { name: 'Unlink Barcode' }).click(); + + // Once unlinked, the field must disappear again + await expect(page.getByText('Linked Barcode')).toHaveCount(0); +}); + /** * Test various 'actions' on the stock detail page */