mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-12 14:28:55 +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:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -354,6 +354,7 @@ class SupplierPartSerializer(
|
||||
'on_order',
|
||||
'link',
|
||||
'active',
|
||||
'primary',
|
||||
'manufacturer_detail',
|
||||
'manufacturer_part',
|
||||
'manufacturer_part_detail',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,7 +25,6 @@ class PartAdmin(admin.ModelAdmin):
|
||||
'variant_of',
|
||||
'category',
|
||||
'default_location',
|
||||
'default_supplier',
|
||||
'bom_checked_by',
|
||||
'creation_user',
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -576,7 +576,6 @@ class PartSerializer(
|
||||
'default_expiry',
|
||||
'default_location',
|
||||
'default_location_detail',
|
||||
'default_supplier',
|
||||
'description',
|
||||
'full_name',
|
||||
'image',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -71,6 +71,7 @@ export function useSupplierPartFields({
|
||||
packaging: {
|
||||
icon: <IconPackage />
|
||||
},
|
||||
primary: {},
|
||||
active: {}
|
||||
};
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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`
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user