diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b16f46a73..9c4d42818a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes +- [#11303](https://github.com/inventree/InvenTree/pull/11303) removes the `default_supplier` field from the `Part` model. Instead, the `SupplierPart` model now has a `primary` field which is used to indicate which supplier is the default for a given part. Any external client applications which made use of the old `default_supplier` field will need to be updated. + ### Added [#11222](https://github.com/inventree/InvenTree/pull/11222) adds support for data import using natural keys, allowing for easier association of related objects without needing to know their internal database IDs. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index edd864942c..ec23720717 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 455 +INVENTREE_API_VERSION = 456 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v456 -> 2026-02-20 : https://github.com/inventree/InvenTree/pull/11303 + - Adds "primary" field to the SupplierPart API + - Removes "default_supplier" field from the Part API + v455 -> 2026-02-19 : https://github.com/inventree/InvenTree/pull/11383 - Adds "exists_for_model_id" filter to ParameterTemplate API endpoint - Adds "exists_for_related_model" filter to ParameterTemplate API endpoint diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 6005a2eb99..8c1fe8db05 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -249,6 +249,8 @@ class SupplierPartFilter(FilterSet): active = rest_filters.BooleanFilter(label=_('Supplier Part is Active')) + primary = rest_filters.BooleanFilter(label=_('Primary Supplier Part')) + # Filter by 'active' status of linked part part_active = rest_filters.BooleanFilter( field_name='part__active', label=_('Internal Part is Active') @@ -366,6 +368,7 @@ class SupplierPartList( 'supplier', 'manufacturer', 'active', + 'primary', 'IPN', 'MPN', 'SKU', diff --git a/src/backend/InvenTree/company/migrations/0078_supplierpart_primary.py b/src/backend/InvenTree/company/migrations/0078_supplierpart_primary.py new file mode 100644 index 0000000000..0459589051 --- /dev/null +++ b/src/backend/InvenTree/company/migrations/0078_supplierpart_primary.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.11 on 2026-02-12 10:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0077_delete_manufacturerpartparameter"), + ] + + operations = [ + migrations.AddField( + model_name="supplierpart", + name="primary", + field=models.BooleanField( + default=False, + help_text="Is this the primary supplier part for the linked Part?", + verbose_name="Primary", + ), + ), + ] diff --git a/src/backend/InvenTree/company/migrations/0079_auto_20260212_1054.py b/src/backend/InvenTree/company/migrations/0079_auto_20260212_1054.py new file mode 100644 index 0000000000..a045edae4a --- /dev/null +++ b/src/backend/InvenTree/company/migrations/0079_auto_20260212_1054.py @@ -0,0 +1,55 @@ +# Generated by Django 5.2.11 on 2026-02-12 10:54 + +from django.db import migrations + + +def link_primary_supplier_part(apps, schema_editor): + """Mark 'primary' SupplierPart for each Part, if one exists.""" + + Part = apps.get_model("part", "Part") + SupplierPart = apps.get_model("company", "SupplierPart") + + # Find any part which links to a "default_supplier" + primary_supplier_ids = Part.objects.exclude( + default_supplier=None, + ).values_list("default_supplier_id", flat=True).distinct() + + if len(primary_supplier_ids) > 0: + # Mark the relevant SupplierPart objects as "primary" + SupplierPart.objects.filter( + pk__in=primary_supplier_ids + ).update(primary=True) + + print(f"Marked {len(primary_supplier_ids)} SupplierPart objects as primary") + + +def reverse_link_primary_supplier_part(apps, schema_editor): + """Add 'primary' SupplierPart for each Part.""" + + SupplierPart = apps.get_model("company", "SupplierPart") + + # Find any SupplierPart object marked as "primary" + primary_supplier_parts = SupplierPart.objects.filter(primary=True) + + if len(primary_supplier_parts) > 0: + # Unmark the relevant SupplierPart objects as "primary" + + for supplier_part in primary_supplier_parts: + supplier_part.part.default_supplier = supplier_part + supplier_part.part.save() + + print(f"Linked {len(primary_supplier_parts)} primary SupplierPart objects") + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0078_supplierpart_primary"), + ] + + operations = [ + migrations.RunPython( + code=link_primary_supplier_part, + reverse_code=reverse_link_primary_supplier_part, + ), + ] diff --git a/src/backend/InvenTree/company/models.py b/src/backend/InvenTree/company/models.py index f56b7c0912..90aeb23ae8 100644 --- a/src/backend/InvenTree/company/models.py +++ b/src/backend/InvenTree/company/models.py @@ -373,22 +373,19 @@ class Address(InvenTree.models.InvenTreeModel): Rules: - If this address is marked as "primary", ensure that all other addresses for this company are marked as non-primary """ - others = list( - Address.objects.filter(company=self.company).exclude(pk=self.pk).all() - ) + others = Address.objects.filter(company=self.company).exclude(pk=self.pk) # If this is the *only* address for this company, make it the primary one - if len(others) == 0: + if not others.exists(): self.primary = True super().save(*args, **kwargs) # Once this address is saved, check others if self.primary: - for addr in others: - if addr.primary: - addr.primary = False - addr.save() + Address.objects.filter(company=self.company).exclude(pk=self.pk).filter( + primary=True + ).update(primary=False) @staticmethod def get_api_url(): @@ -612,6 +609,7 @@ class SupplierPart( source_item: The sourcing item linked to this SupplierPart instance supplier: Company that supplies this SupplierPart object active: Boolean value, is this supplier part active + primary: Boolean value, is this the primary supplier part for the linked Part SKU: Stock keeping unit (supplier part number) link: Link to external website for this supplier part description: Descriptive notes field @@ -739,8 +737,21 @@ class SupplierPart( self.clean() self.validate_unique() + # Ensure that only one SupplierPart is marked as "primary" for a given Part + others = SupplierPart.objects.filter(part=self.part).exclude(pk=self.pk) + + # If this is the *only* SupplierPart for this Part, make it the primary one + if not others.exists(): + self.primary = True + super().save(*args, **kwargs) + # Once this SupplierPart is saved, check others + if self.primary: + SupplierPart.objects.filter(part=self.part).exclude(pk=self.pk).filter( + primary=True + ).update(primary=False) + part = models.ForeignKey( 'part.Part', on_delete=models.CASCADE, @@ -771,6 +782,12 @@ class SupplierPart( help_text=_('Is this supplier part active?'), ) + primary = models.BooleanField( + default=False, + verbose_name=_('Primary'), + help_text=_('Is this the primary supplier part for the linked Part?'), + ) + manufacturer_part = models.ForeignKey( ManufacturerPart, on_delete=models.CASCADE, diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index ef12834e38..eae59f1980 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -354,6 +354,7 @@ class SupplierPartSerializer( 'on_order', 'link', 'active', + 'primary', 'manufacturer_detail', 'manufacturer_part', 'manufacturer_part_detail', diff --git a/src/backend/InvenTree/company/test_api.py b/src/backend/InvenTree/company/test_api.py index 8ff721f123..6df854edd6 100644 --- a/src/backend/InvenTree/company/test_api.py +++ b/src/backend/InvenTree/company/test_api.py @@ -712,6 +712,32 @@ class SupplierPartTest(InvenTreeAPITestCase): for result in response.data: self.assertEqual(result['supplier'], company.pk) + def test_primary(self): + """Test for the 'primary' field in the SupplierPart model.""" + for sp in SupplierPart.objects.filter(part=1): + self.patch( + reverse('api-supplier-part-detail', kwargs={'pk': sp.pk}), + {'primary': True}, + expected_code=200, + ) + + # Only one supplier part should be primary for this part + self.assertEqual( + SupplierPart.objects.filter(part=1, primary=True).count(), 1 + ) + + # Filter via the API + response = self.get( + reverse('api-supplier-part-list'), + {'part': 1, 'primary': True}, + expected_code=200, + ) + + self.assertEqual(len(response.data), 1) + + self.assertEqual(SupplierPart.objects.filter(part=1).count(), 4) + self.assertEqual(SupplierPart.objects.filter(part=1, primary=False).count(), 3) + def test_filterable_fields(self): """Test inclusion/exclusion of optional API fields.""" fields = { diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 85b4b91d5f..c62ce69d90 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -14,9 +14,9 @@ from sql_util.utils import SubqueryCount, SubquerySum import build.serializers import common.filters +import company.models as company_models import order.models import part.filters as part_filters -import part.models as part_models import stock.models import stock.serializers from company.serializers import ( @@ -587,7 +587,7 @@ class PurchaseOrderLineItemSerializer( return queryset part = serializers.PrimaryKeyRelatedField( - queryset=part_models.SupplierPart.objects.all(), + queryset=company_models.SupplierPart.objects.all(), many=False, required=True, allow_null=True, diff --git a/src/backend/InvenTree/part/admin.py b/src/backend/InvenTree/part/admin.py index 377a0b1a4a..d1e0398952 100644 --- a/src/backend/InvenTree/part/admin.py +++ b/src/backend/InvenTree/part/admin.py @@ -25,7 +25,6 @@ class PartAdmin(admin.ModelAdmin): 'variant_of', 'category', 'default_location', - 'default_supplier', 'bom_checked_by', 'creation_user', ] diff --git a/src/backend/InvenTree/part/migrations/0147_remove_part_default_supplier.py b/src/backend/InvenTree/part/migrations/0147_remove_part_default_supplier.py new file mode 100644 index 0000000000..5b29121ced --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0147_remove_part_default_supplier.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-02-12 11:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0079_auto_20260212_1054"), + ("part", "0146_auto_20251203_1241"), + ] + + operations = [ + migrations.RemoveField( + model_name="part", + name="default_supplier", + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 98ff555ecd..a5fb4c4e22 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -52,7 +52,6 @@ from build.status_codes import BuildStatusGroups from common.currency import currency_code_default from common.icons import validate_icon from common.settings import get_global_setting -from company.models import SupplierPart from InvenTree import helpers, validators from InvenTree.exceptions import log_error from InvenTree.fields import InvenTreeURLField @@ -490,7 +489,6 @@ class Part( link: Link to an external page with more information about this part (e.g. internal Wiki) image: Image of this part default_location: Where the item is normally stored (may be null) - default_supplier: The default SupplierPart which should be used to procure and stock this part default_expiry: The default expiry duration for any StockItem instances of this part minimum_stock: Minimum preferred quantity to keep in stock units: Units of measure for this part (default='pcs') @@ -1215,31 +1213,14 @@ class Part( # Default case - no default category found return None - def get_default_supplier(self): - """Get the default supplier part for this part (may be None). + @property + def default_supplier(self): + """Return the default (primary) SupplierPart for this Part. - - If the part specifies a default_supplier, return that - - If there is only one supplier part available, return that - - Else, return None + This function is included for backwards compatibility, + as the 'Part' model used to have a 'default_supplier' field which was a ForeignKey to SupplierPart. """ - if self.default_supplier: - return self.default_supplier - - if self.supplier_count == 1: - return self.supplier_parts.first() - - # Default to None if there are multiple suppliers to choose from - return None - - default_supplier = models.ForeignKey( - SupplierPart, - on_delete=models.SET_NULL, - blank=True, - null=True, - verbose_name=_('Default Supplier'), - help_text=_('Default supplier part'), - related_name='default_parts', - ) + return self.supplier_parts.filter(primary=True).first() default_expiry = models.PositiveIntegerField( default=0, diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index a0a00933eb..bb27afe074 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -576,7 +576,6 @@ class PartSerializer( 'default_expiry', 'default_location', 'default_location_detail', - 'default_supplier', 'description', 'full_name', 'image', diff --git a/src/backend/InvenTree/plugin/base/supplier/api.py b/src/backend/InvenTree/plugin/base/supplier/api.py index 7dc6c6045b..3c288a238d 100644 --- a/src/backend/InvenTree/plugin/base/supplier/api.py +++ b/src/backend/InvenTree/plugin/base/supplier/api.py @@ -184,10 +184,10 @@ class ImportPart(APIView): import_data, part=part, manufacturer_part=manufacturer_part ) - # set default supplier if not set + # Set as primary supplier if not already set if not part.default_supplier: - part.default_supplier = supplier_part - part.save() + supplier_part.primary = True + supplier_part.save() # get pricing pricing = supplier_plugin.get_pricing_data(import_data) diff --git a/src/backend/InvenTree/plugin/base/supplier/mixins.py b/src/backend/InvenTree/plugin/base/supplier/mixins.py index 6542989a64..27c35d4d47 100644 --- a/src/backend/InvenTree/plugin/base/supplier/mixins.py +++ b/src/backend/InvenTree/plugin/base/supplier/mixins.py @@ -109,8 +109,8 @@ class SupplierMixin(SettingsMixin, Generic[PartData]): *, part: part_models.Part, manufacturer_part: company.models.ManufacturerPart, - ) -> part_models.SupplierPart: - """Import a supplier part using the provided data. + ) -> company.models.SupplierPart: + """Import a SupplierPart using the provided data. This may include: - Creating a new supplier part diff --git a/src/frontend/src/forms/CompanyForms.tsx b/src/frontend/src/forms/CompanyForms.tsx index 8336aa4fc8..de136355a4 100644 --- a/src/frontend/src/forms/CompanyForms.tsx +++ b/src/frontend/src/forms/CompanyForms.tsx @@ -71,6 +71,7 @@ export function useSupplierPartFields({ packaging: { icon: }, + primary: {}, active: {} }; diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 3177663aa9..2a54f9a10d 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -55,14 +55,6 @@ export function usePartFields({ structural: false } }, - default_supplier: { - hidden: !partId || !purchaseable, - filters: { - part: partId, - part_detail: true, - supplier_detail: true - } - }, default_expiry: {}, minimum_stock: {}, responsible: { diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 94b47236b0..22747010dd 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -692,16 +692,6 @@ export default function PartDetail() { badge: 'owner', hidden: !part.responsible }, - { - type: 'link', - name: 'default_supplier', - label: t`Default Supplier`, - model: ModelType.supplierpart, - model_formatter: (model: any) => { - return model.SKU; - }, - hidden: !part.default_supplier - }, { name: 'default_expiry', label: t`Default Expiry`, diff --git a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx index 26b7a9d040..ecdc42cf0b 100644 --- a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx +++ b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx @@ -99,6 +99,12 @@ export function SupplierPartTable({ title: t`MPN`, render: (record: any) => record?.manufacturer_part_detail?.MPN }, + BooleanColumn({ + accessor: 'primary', + sortable: true, + switchable: true, + defaultVisible: false + }), BooleanColumn({ accessor: 'active', title: t`Active`, @@ -176,7 +182,9 @@ export function SupplierPartTable({ supplier: supplierId, manufacturer_part: manufacturerPartId }, - table: table, + onFormSuccess: (response: any) => { + table.refreshTable(); + }, successMessage: t`Supplier part created` }); @@ -215,6 +223,11 @@ export function SupplierPartTable({ label: t`Active`, description: t`Show active supplier parts` }, + { + name: 'primary', + label: t`Primary`, + description: t`Show primary supplier parts` + }, { name: 'part_active', label: t`Active Part`, @@ -243,7 +256,9 @@ export function SupplierPartTable({ pk: selectedSupplierPart?.pk, title: t`Edit Supplier Part`, fields: useMemo(() => editSupplierPartFields, [editSupplierPartFields]), - table: table + onFormSuccess: (response: any) => { + table.refreshTable(); + } }); const duplicateSupplierPart = useCreateApiFormModal({ @@ -252,9 +267,12 @@ export function SupplierPartTable({ fields: useMemo(() => editSupplierPartFields, [editSupplierPartFields]), initialData: { ...selectedSupplierPart, + primary: false, active: true }, - table: table, + onFormSuccess: (response: any) => { + table.refreshTable(); + }, successMessage: t`Supplier part created` }); diff --git a/src/frontend/tests/pages/pui_company.spec.ts b/src/frontend/tests/pages/pui_company.spec.ts index d2e27cbb0e..0d5834f1aa 100644 --- a/src/frontend/tests/pages/pui_company.spec.ts +++ b/src/frontend/tests/pages/pui_company.spec.ts @@ -1,8 +1,10 @@ import { test } from '../baseFixtures.js'; import { + clearTableFilters, clickOnParamFilter, loadTab, navigate, + setTableChoiceFilter, showParametricView } from '../helpers.js'; import { doCachedLogin } from '../login.js'; @@ -63,3 +65,24 @@ test('Company - Parameters', async ({ browser }) => { await page.getByRole('cell', { name: 'Arrow Electronics' }).waitFor(); await page.getByRole('cell', { name: 'PCB assembly house' }).waitFor(); }); + +test('Company - Supplier Parts', async ({ browser }) => { + const page = await doCachedLogin(browser, { + username: 'steven', + password: 'wizardstaff', + url: 'purchasing/index/suppliers' + }); + + await loadTab(page, 'Supplier Parts'); + await clearTableFilters(page); + + await page.getByText('- 25 / 777').waitFor(); + + await setTableChoiceFilter(page, 'Primary', 'Yes'); + await page.getByText('- 25 / 318').waitFor(); + + await clearTableFilters(page); + + await setTableChoiceFilter(page, 'Primary', 'No'); + await page.getByText('- 25 / 459').waitFor(); +}); diff --git a/tasks.py b/tasks.py index e52ae21cb7..7e01bd1de4 100644 --- a/tasks.py +++ b/tasks.py @@ -1142,7 +1142,7 @@ def import_records( os.remove(datafile) os.remove(authfile) - info('Data import completed') + success('Data import completed') @task(