2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-02-25 16:17:58 +00:00

Supplier Part Updates (#11303)

* Add "primary" field to SupplierPart model

* Remove "default_supplier" field from the Part model

* Ensure only one SupplierPart can be "primary" for a given Part

* Update references to "default_supplier"

* Add 'primary' field to the SupplierPart API serializer

* update SupplierPart table

* Use bulk-update operations

* Bug fix for data migration

* Allow ordering by 'primary' field

* Tweak import message

* Edit 'primary' field in UI

* Fix checks in save() methods

* Better table updates

* Update CHANGELOG

* Bump API version

* Fix unit test

* Add unit test for API

* Playwright tests
This commit is contained in:
Oliver
2026-02-20 18:25:26 +11:00
committed by GitHub
parent 14d6d2354f
commit 3910cc5a50
21 changed files with 216 additions and 65 deletions

View File

@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Breaking Changes ### 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 ### 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. [#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.

View File

@@ -1,11 +1,15 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v455 -> 2026-02-19 : https://github.com/inventree/InvenTree/pull/11383
- Adds "exists_for_model_id" filter to ParameterTemplate API endpoint - Adds "exists_for_model_id" filter to ParameterTemplate API endpoint
- Adds "exists_for_related_model" filter to ParameterTemplate API endpoint - Adds "exists_for_related_model" filter to ParameterTemplate API endpoint

View File

@@ -249,6 +249,8 @@ class SupplierPartFilter(FilterSet):
active = rest_filters.BooleanFilter(label=_('Supplier Part is Active')) active = rest_filters.BooleanFilter(label=_('Supplier Part is Active'))
primary = rest_filters.BooleanFilter(label=_('Primary Supplier Part'))
# Filter by 'active' status of linked part # Filter by 'active' status of linked part
part_active = rest_filters.BooleanFilter( part_active = rest_filters.BooleanFilter(
field_name='part__active', label=_('Internal Part is Active') field_name='part__active', label=_('Internal Part is Active')
@@ -366,6 +368,7 @@ class SupplierPartList(
'supplier', 'supplier',
'manufacturer', 'manufacturer',
'active', 'active',
'primary',
'IPN', 'IPN',
'MPN', 'MPN',
'SKU', 'SKU',

View File

@@ -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",
),
),
]

View File

@@ -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,
),
]

View File

@@ -373,22 +373,19 @@ class Address(InvenTree.models.InvenTreeModel):
Rules: Rules:
- If this address is marked as "primary", ensure that all other addresses for this company are marked as non-primary - If this address is marked as "primary", ensure that all other addresses for this company are marked as non-primary
""" """
others = list( others = Address.objects.filter(company=self.company).exclude(pk=self.pk)
Address.objects.filter(company=self.company).exclude(pk=self.pk).all()
)
# If this is the *only* address for this company, make it the primary one # 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 self.primary = True
super().save(*args, **kwargs) super().save(*args, **kwargs)
# Once this address is saved, check others # Once this address is saved, check others
if self.primary: if self.primary:
for addr in others: Address.objects.filter(company=self.company).exclude(pk=self.pk).filter(
if addr.primary: primary=True
addr.primary = False ).update(primary=False)
addr.save()
@staticmethod @staticmethod
def get_api_url(): def get_api_url():
@@ -612,6 +609,7 @@ class SupplierPart(
source_item: The sourcing item linked to this SupplierPart instance source_item: The sourcing item linked to this SupplierPart instance
supplier: Company that supplies this SupplierPart object supplier: Company that supplies this SupplierPart object
active: Boolean value, is this supplier part active 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) SKU: Stock keeping unit (supplier part number)
link: Link to external website for this supplier part link: Link to external website for this supplier part
description: Descriptive notes field description: Descriptive notes field
@@ -739,8 +737,21 @@ class SupplierPart(
self.clean() self.clean()
self.validate_unique() 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) 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 = models.ForeignKey(
'part.Part', 'part.Part',
on_delete=models.CASCADE, on_delete=models.CASCADE,
@@ -771,6 +782,12 @@ class SupplierPart(
help_text=_('Is this supplier part active?'), 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( manufacturer_part = models.ForeignKey(
ManufacturerPart, ManufacturerPart,
on_delete=models.CASCADE, on_delete=models.CASCADE,

View File

@@ -354,6 +354,7 @@ class SupplierPartSerializer(
'on_order', 'on_order',
'link', 'link',
'active', 'active',
'primary',
'manufacturer_detail', 'manufacturer_detail',
'manufacturer_part', 'manufacturer_part',
'manufacturer_part_detail', 'manufacturer_part_detail',

View File

@@ -712,6 +712,32 @@ class SupplierPartTest(InvenTreeAPITestCase):
for result in response.data: for result in response.data:
self.assertEqual(result['supplier'], company.pk) 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): def test_filterable_fields(self):
"""Test inclusion/exclusion of optional API fields.""" """Test inclusion/exclusion of optional API fields."""
fields = { fields = {

View File

@@ -14,9 +14,9 @@ from sql_util.utils import SubqueryCount, SubquerySum
import build.serializers import build.serializers
import common.filters import common.filters
import company.models as company_models
import order.models import order.models
import part.filters as part_filters import part.filters as part_filters
import part.models as part_models
import stock.models import stock.models
import stock.serializers import stock.serializers
from company.serializers import ( from company.serializers import (
@@ -587,7 +587,7 @@ class PurchaseOrderLineItemSerializer(
return queryset return queryset
part = serializers.PrimaryKeyRelatedField( part = serializers.PrimaryKeyRelatedField(
queryset=part_models.SupplierPart.objects.all(), queryset=company_models.SupplierPart.objects.all(),
many=False, many=False,
required=True, required=True,
allow_null=True, allow_null=True,

View File

@@ -25,7 +25,6 @@ class PartAdmin(admin.ModelAdmin):
'variant_of', 'variant_of',
'category', 'category',
'default_location', 'default_location',
'default_supplier',
'bom_checked_by', 'bom_checked_by',
'creation_user', 'creation_user',
] ]

View File

@@ -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",
),
]

View File

@@ -52,7 +52,6 @@ from build.status_codes import BuildStatusGroups
from common.currency import currency_code_default from common.currency import currency_code_default
from common.icons import validate_icon from common.icons import validate_icon
from common.settings import get_global_setting from common.settings import get_global_setting
from company.models import SupplierPart
from InvenTree import helpers, validators from InvenTree import helpers, validators
from InvenTree.exceptions import log_error from InvenTree.exceptions import log_error
from InvenTree.fields import InvenTreeURLField 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) link: Link to an external page with more information about this part (e.g. internal Wiki)
image: Image of this part image: Image of this part
default_location: Where the item is normally stored (may be null) 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 default_expiry: The default expiry duration for any StockItem instances of this part
minimum_stock: Minimum preferred quantity to keep in stock minimum_stock: Minimum preferred quantity to keep in stock
units: Units of measure for this part (default='pcs') units: Units of measure for this part (default='pcs')
@@ -1215,31 +1213,14 @@ class Part(
# Default case - no default category found # Default case - no default category found
return None return None
def get_default_supplier(self): @property
"""Get the default supplier part for this part (may be None). def default_supplier(self):
"""Return the default (primary) SupplierPart for this Part.
- If the part specifies a default_supplier, return that This function is included for backwards compatibility,
- If there is only one supplier part available, return that as the 'Part' model used to have a 'default_supplier' field which was a ForeignKey to SupplierPart.
- Else, return None
""" """
if self.default_supplier: return self.supplier_parts.filter(primary=True).first()
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',
)
default_expiry = models.PositiveIntegerField( default_expiry = models.PositiveIntegerField(
default=0, default=0,

View File

@@ -576,7 +576,6 @@ class PartSerializer(
'default_expiry', 'default_expiry',
'default_location', 'default_location',
'default_location_detail', 'default_location_detail',
'default_supplier',
'description', 'description',
'full_name', 'full_name',
'image', 'image',

View File

@@ -184,10 +184,10 @@ class ImportPart(APIView):
import_data, part=part, manufacturer_part=manufacturer_part 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: if not part.default_supplier:
part.default_supplier = supplier_part supplier_part.primary = True
part.save() supplier_part.save()
# get pricing # get pricing
pricing = supplier_plugin.get_pricing_data(import_data) pricing = supplier_plugin.get_pricing_data(import_data)

View File

@@ -109,8 +109,8 @@ class SupplierMixin(SettingsMixin, Generic[PartData]):
*, *,
part: part_models.Part, part: part_models.Part,
manufacturer_part: company.models.ManufacturerPart, manufacturer_part: company.models.ManufacturerPart,
) -> part_models.SupplierPart: ) -> company.models.SupplierPart:
"""Import a supplier part using the provided data. """Import a SupplierPart using the provided data.
This may include: This may include:
- Creating a new supplier part - Creating a new supplier part

View File

@@ -71,6 +71,7 @@ export function useSupplierPartFields({
packaging: { packaging: {
icon: <IconPackage /> icon: <IconPackage />
}, },
primary: {},
active: {} active: {}
}; };

View File

@@ -55,14 +55,6 @@ export function usePartFields({
structural: false structural: false
} }
}, },
default_supplier: {
hidden: !partId || !purchaseable,
filters: {
part: partId,
part_detail: true,
supplier_detail: true
}
},
default_expiry: {}, default_expiry: {},
minimum_stock: {}, minimum_stock: {},
responsible: { responsible: {

View File

@@ -692,16 +692,6 @@ export default function PartDetail() {
badge: 'owner', badge: 'owner',
hidden: !part.responsible 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', name: 'default_expiry',
label: t`Default Expiry`, label: t`Default Expiry`,

View File

@@ -99,6 +99,12 @@ export function SupplierPartTable({
title: t`MPN`, title: t`MPN`,
render: (record: any) => record?.manufacturer_part_detail?.MPN render: (record: any) => record?.manufacturer_part_detail?.MPN
}, },
BooleanColumn({
accessor: 'primary',
sortable: true,
switchable: true,
defaultVisible: false
}),
BooleanColumn({ BooleanColumn({
accessor: 'active', accessor: 'active',
title: t`Active`, title: t`Active`,
@@ -176,7 +182,9 @@ export function SupplierPartTable({
supplier: supplierId, supplier: supplierId,
manufacturer_part: manufacturerPartId manufacturer_part: manufacturerPartId
}, },
table: table, onFormSuccess: (response: any) => {
table.refreshTable();
},
successMessage: t`Supplier part created` successMessage: t`Supplier part created`
}); });
@@ -215,6 +223,11 @@ export function SupplierPartTable({
label: t`Active`, label: t`Active`,
description: t`Show active supplier parts` description: t`Show active supplier parts`
}, },
{
name: 'primary',
label: t`Primary`,
description: t`Show primary supplier parts`
},
{ {
name: 'part_active', name: 'part_active',
label: t`Active Part`, label: t`Active Part`,
@@ -243,7 +256,9 @@ export function SupplierPartTable({
pk: selectedSupplierPart?.pk, pk: selectedSupplierPart?.pk,
title: t`Edit Supplier Part`, title: t`Edit Supplier Part`,
fields: useMemo(() => editSupplierPartFields, [editSupplierPartFields]), fields: useMemo(() => editSupplierPartFields, [editSupplierPartFields]),
table: table onFormSuccess: (response: any) => {
table.refreshTable();
}
}); });
const duplicateSupplierPart = useCreateApiFormModal({ const duplicateSupplierPart = useCreateApiFormModal({
@@ -252,9 +267,12 @@ export function SupplierPartTable({
fields: useMemo(() => editSupplierPartFields, [editSupplierPartFields]), fields: useMemo(() => editSupplierPartFields, [editSupplierPartFields]),
initialData: { initialData: {
...selectedSupplierPart, ...selectedSupplierPart,
primary: false,
active: true active: true
}, },
table: table, onFormSuccess: (response: any) => {
table.refreshTable();
},
successMessage: t`Supplier part created` successMessage: t`Supplier part created`
}); });

View File

@@ -1,8 +1,10 @@
import { test } from '../baseFixtures.js'; import { test } from '../baseFixtures.js';
import { import {
clearTableFilters,
clickOnParamFilter, clickOnParamFilter,
loadTab, loadTab,
navigate, navigate,
setTableChoiceFilter,
showParametricView showParametricView
} from '../helpers.js'; } from '../helpers.js';
import { doCachedLogin } from '../login.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: 'Arrow Electronics' }).waitFor();
await page.getByRole('cell', { name: 'PCB assembly house' }).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();
});

View File

@@ -1142,7 +1142,7 @@ def import_records(
os.remove(datafile) os.remove(datafile)
os.remove(authfile) os.remove(authfile)
info('Data import completed') success('Data import completed')
@task( @task(