diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index e7a9491079..d5d08f23e1 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,12 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 349
+INVENTREE_API_VERSION = 350
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
-
INVENTREE_API_TEXT = """
+v350 -> 2025-06-17 : https://github.com/inventree/InvenTree/pull/9798
+ - Adds "can_build" field to the part requirements API endpoint
+ - Remove "allocated" and "required" fields from the part requirements API endpoint
+ - Add detailed serializer to the part requirements API endpoint
+
v349 -> 2025-06-13 : https://github.com/inventree/InvenTree/pull/9574
- Remove the 'create_child_builds' flag from the BuildOrder creation API endpoint
diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py
index d86c3c17f4..a64bde2cdd 100644
--- a/src/backend/InvenTree/part/api.py
+++ b/src/backend/InvenTree/part/api.py
@@ -754,38 +754,13 @@ class PartRequirements(RetrieveAPI):
- Sales Orders
- Build Orders
- Total requirements
+ - How many of this part can be assembled with available stock
As this data is somewhat complex to calculate, is it not included in the default API
"""
queryset = Part.objects.all()
- serializer_class = EmptySerializer
-
- def retrieve(self, request, *args, **kwargs):
- """Construct a response detailing Part requirements."""
- part = self.get_object()
-
- data = {
- 'available_stock': part.available_stock,
- 'on_order': part.on_order,
- 'required_build_order_quantity': part.required_build_order_quantity(),
- 'allocated_build_order_quantity': part.build_order_allocation_count(),
- 'required_sales_order_quantity': part.required_sales_order_quantity(),
- 'allocated_sales_order_quantity': part.sales_order_allocation_count(
- pending=True
- ),
- }
-
- data['allocated'] = (
- data['allocated_build_order_quantity']
- + data['allocated_sales_order_quantity']
- )
- data['required'] = (
- data['required_build_order_quantity']
- + data['required_sales_order_quantity']
- )
-
- return Response(data)
+ serializer_class = part_serializers.PartRequirementsSerializer
class PartPricingDetail(RetrieveUpdateAPI):
diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py
index 875d2f64c3..3cb7051c8b 100644
--- a/src/backend/InvenTree/part/serializers.py
+++ b/src/backend/InvenTree/part/serializers.py
@@ -98,10 +98,6 @@ class CategorySerializer(
if not path_detail and not isGeneratingSchema():
self.fields.pop('path', None)
- def get_starred(self, category) -> bool:
- """Return True if the category is directly "starred" by the current user."""
- return category in self.context.get('starred_categories', [])
-
@staticmethod
def annotate_queryset(queryset):
"""Annotate extra information to the queryset."""
@@ -137,6 +133,10 @@ class CategorySerializer(
starred = serializers.SerializerMethodField()
+ def get_starred(self, category) -> bool:
+ """Return True if the category is directly "starred" by the current user."""
+ return category in self.context.get('starred_categories', [])
+
path = serializers.ListField(
child=serializers.DictField(),
source='get_path',
@@ -1223,6 +1223,73 @@ class PartSerializer(
return self.instance
+class PartRequirementsSerializer(InvenTree.serializers.InvenTreeModelSerializer):
+ """Serializer for Part requirements."""
+
+ class Meta:
+ """Metaclass options."""
+
+ model = Part
+ fields = [
+ 'total_stock',
+ 'unallocated_stock',
+ 'can_build',
+ 'ordering',
+ 'building',
+ 'scheduled_to_build',
+ 'required_for_build_orders',
+ 'allocated_to_build_orders',
+ 'required_for_sales_orders',
+ 'allocated_to_sales_orders',
+ ]
+
+ total_stock = serializers.FloatField(read_only=True, label=_('Total Stock'))
+
+ unallocated_stock = serializers.FloatField(
+ source='available_stock', read_only=True, label=_('Available Stock')
+ )
+
+ can_build = serializers.FloatField(read_only=True, label=_('Can Build'))
+
+ ordering = serializers.FloatField(
+ source='on_order', read_only=True, label=_('On Order')
+ )
+
+ building = serializers.FloatField(
+ read_only=True, label=_('In Production'), source='quantity_in_production'
+ )
+
+ scheduled_to_build = serializers.FloatField(
+ read_only=True, label=_('Scheduled to Build'), source='quantity_being_built'
+ )
+
+ required_for_build_orders = serializers.FloatField(
+ source='required_build_order_quantity',
+ read_only=True,
+ label=_('Required for Build Orders'),
+ )
+
+ allocated_to_build_orders = serializers.FloatField(
+ read_only=True,
+ label=_('Allocated to Build Orders'),
+ source='build_order_allocation_count',
+ )
+
+ required_for_sales_orders = serializers.FloatField(
+ source='required_sales_order_quantity',
+ read_only=True,
+ label=_('Required for Sales Orders'),
+ )
+
+ allocated_to_sales_orders = serializers.SerializerMethodField(
+ read_only=True, label=_('Allocated to Sales Orders')
+ )
+
+ def get_allocated_to_sales_orders(self, part) -> float:
+ """Return the allocated sales order quantity."""
+ return part.sales_order_allocation_count(pending=True)
+
+
class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the PartStocktake model."""
diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py
index e046cd9266..790bfd912b 100644
--- a/src/backend/InvenTree/part/test_api.py
+++ b/src/backend/InvenTree/part/test_api.py
@@ -1843,6 +1843,30 @@ class PartDetailTests(PartImageTestMixin, PartAPITestBase):
self.assertIn('category_path', response.data)
self.assertEqual(len(response.data['category_path']), 2)
+ def test_part_requirements(self):
+ """Unit test for the "PartRequirements" API endpoint."""
+ url = reverse('api-part-requirements', kwargs={'pk': Part.objects.first().pk})
+
+ # Get the requirements for part 1
+ response = self.get(url, expected_code=200)
+
+ # Check that the response contains the expected fields
+ expected_fields = [
+ 'total_stock',
+ 'unallocated_stock',
+ 'can_build',
+ 'ordering',
+ 'building',
+ 'scheduled_to_build',
+ 'required_for_build_orders',
+ 'allocated_to_build_orders',
+ 'required_for_sales_orders',
+ 'allocated_to_sales_orders',
+ ]
+
+ for field in expected_fields:
+ self.assertIn(field, response.data)
+
class PartListTests(PartAPITestBase):
"""Unit tests for the Part List API endpoint."""
diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx
index 96f08d4224..3dad40b5f0 100644
--- a/src/frontend/lib/enums/ApiEndpoints.tsx
+++ b/src/frontend/lib/enums/ApiEndpoints.tsx
@@ -111,6 +111,7 @@ export enum ApiEndpoints {
part_parameter_template_list = 'part/parameter/template/',
part_thumbs_list = 'part/thumbs/',
part_pricing = 'part/:id/pricing/',
+ part_requirements = 'part/:id/requirements/',
part_serial_numbers = 'part/:id/serial-numbers/',
part_scheduling = 'part/:id/scheduling/',
part_pricing_internal = 'part/internal-price/',
diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx
index 297c1a451c..d532ec679e 100644
--- a/src/frontend/src/pages/build/BuildDetail.tsx
+++ b/src/frontend/src/pages/build/BuildDetail.tsx
@@ -86,11 +86,24 @@ export default function BuildDetail() {
refetchOnMount: true
});
+ const { instance: partRequirements, instanceQuery: partRequirementsQuery } =
+ useInstance({
+ endpoint: ApiEndpoints.part_requirements,
+ pk: build?.part,
+ hasPrimaryKey: true,
+ defaultValue: {}
+ });
+
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return ;
}
+ const data = {
+ ...build,
+ can_build: partRequirements?.can_build ?? 0
+ };
+
const tl: DetailsField[] = [
{
type: 'link',
@@ -173,10 +186,17 @@ export default function BuildDetail() {
const tr: DetailsField[] = [
{
- type: 'text',
+ type: 'number',
name: 'quantity',
label: t`Build Quantity`
},
+ {
+ type: 'number',
+ name: 'can_build',
+ unit: build.part_detail?.units,
+ label: t`Can Build`,
+ hidden: partRequirementsQuery.isFetching
+ },
{
type: 'progressbar',
name: 'completed',
@@ -290,15 +310,20 @@ export default function BuildDetail() {
pk={build.part}
/>
-
+
-
-
-
+
+
+
);
- }, [build, instanceQuery]);
+ }, [
+ build,
+ instanceQuery,
+ partRequirements,
+ partRequirementsQuery.isFetching
+ ]);
const buildPanels: PanelType[] = useMemo(() => {
return [
diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx
index 0c9d583e31..8e4671f548 100644
--- a/src/frontend/src/pages/part/PartDetail.tsx
+++ b/src/frontend/src/pages/part/PartDetail.tsx
@@ -143,6 +143,14 @@ export default function PartDetail() {
refetchOnMount: true
});
+ const { instance: partRequirements, instanceQuery: partRequirementsQuery } =
+ useInstance({
+ endpoint: ApiEndpoints.part_requirements,
+ pk: id,
+ hasPrimaryKey: true,
+ refetchOnMount: true
+ });
+
const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) {
return ;
@@ -151,8 +159,23 @@ export default function PartDetail() {
const data = { ...part };
data.required =
- (data?.required_for_build_orders ?? 0) +
- (data?.required_for_sales_orders ?? 0);
+ (partRequirements?.required_for_build_orders ??
+ part?.required_for_build_orders ??
+ 0) +
+ (partRequirements?.required_for_sales_orders ??
+ part?.required_for_sales_orders ??
+ 0);
+
+ data.allocated =
+ (partRequirements?.allocated_to_build_orders ??
+ part?.allocated_to_build_orders ??
+ 0) +
+ (partRequirements?.allocated_to_sales_orders ??
+ part?.allocated_to_sales_orders ??
+ 0);
+
+ // Extract requirements data
+ data.can_build = partRequirements?.can_build ?? 0;
// Provide latest serial number info
if (!!serials.latest) {
@@ -315,13 +338,6 @@ export default function PartDetail() {
(part.required_for_sales_orders <= 0 &&
part.allocated_to_sales_orders <= 0)
},
- {
- type: 'number',
- name: 'can_build',
- unit: true,
- label: t`Can Build`,
- hidden: true // TODO: Expose "can_build" to the API
- },
{
type: 'progressbar',
name: 'building',
@@ -329,6 +345,13 @@ export default function PartDetail() {
progress: part.building,
total: part.scheduled_to_build,
hidden: !part.assembly || (!part.building && !part.scheduled_to_build)
+ },
+ {
+ type: 'number',
+ name: 'can_build',
+ unit: part.units,
+ label: t`Can Build`,
+ hidden: !part.assembly || partRequirementsQuery.isFetching
}
];
@@ -489,7 +512,9 @@ export default function PartDetail() {
id,
serials,
instanceQuery.isFetching,
- instanceQuery.data
+ instanceQuery.data,
+ partRequirementsQuery.isFetching,
+ partRequirements
]);
// Part data panels (recalculate when part data changes)
diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts
index a0891d1da6..f0232fb342 100644
--- a/src/frontend/tests/pages/pui_part.spec.ts
+++ b/src/frontend/tests/pages/pui_part.spec.ts
@@ -162,6 +162,26 @@ test('Parts - Locking', async ({ browser }) => {
await page.getByText('Part parameters cannot be').waitFor();
});
+test('Parts - Details', async ({ browser }) => {
+ const page = await doCachedLogin(browser, { url: 'part/113/details' });
+
+ // Check for expected values on this page
+ await page.getByText('Required for Orders').waitFor();
+ await page.getByText('Allocated to Sales Orders').waitFor();
+ await page.getByText('Can Build').waitFor();
+
+ await page.getByText('0 / 10').waitFor();
+ await page.getByText('4 / 49').waitFor();
+
+ // Badges
+ await page.getByText('Required: 10').waitFor();
+ await page.getByText('No Stock').waitFor();
+ await page.getByText('In Production: 4').waitFor();
+
+ await page.getByText('Creation Date').waitFor();
+ await page.getByText('2022-04-29').waitFor();
+});
+
test('Parts - Allocations', async ({ browser }) => {
// Let's look at the allocations for a single stock item
const page = await doCachedLogin(browser, { url: 'stock/item/324/' });