From 62aef238f0c2baf29693817767630be38f946f13 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 18 Jun 2025 10:17:39 +1000 Subject: [PATCH] [UI] Add "can build" part info (#9798) * Add proper serializer to PartRequirements API endpoint * Add API endpoint * Display "can_build" quantity * Add simple playwright tests * Bump API version * Updated docs * Fix formatting * Consolidate field names - Match field names to the PartSerializer * Adjust frontend * Add "can_build" to BuildDetail page * Tweak BuildDetail * Hide until load * serializer fixes --- .../InvenTree/InvenTree/api_version.py | 8 +- src/backend/InvenTree/part/api.py | 29 +------ src/backend/InvenTree/part/serializers.py | 75 ++++++++++++++++++- src/backend/InvenTree/part/test_api.py | 24 ++++++ src/frontend/lib/enums/ApiEndpoints.tsx | 1 + src/frontend/src/pages/build/BuildDetail.tsx | 37 +++++++-- src/frontend/src/pages/part/PartDetail.tsx | 45 ++++++++--- src/frontend/tests/pages/pui_part.spec.ts | 20 +++++ 8 files changed, 190 insertions(+), 49 deletions(-) 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/' });