2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-01 19:20:55 +00:00

[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
This commit is contained in:
Oliver
2025-06-18 10:17:39 +10:00
committed by GitHub
parent fe4038205f
commit 62aef238f0
8 changed files with 190 additions and 49 deletions

View File

@ -1,12 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """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 = """
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 v349 -> 2025-06-13 : https://github.com/inventree/InvenTree/pull/9574
- Remove the 'create_child_builds' flag from the BuildOrder creation API endpoint - Remove the 'create_child_builds' flag from the BuildOrder creation API endpoint

View File

@ -754,38 +754,13 @@ class PartRequirements(RetrieveAPI):
- Sales Orders - Sales Orders
- Build Orders - Build Orders
- Total requirements - 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 As this data is somewhat complex to calculate, is it not included in the default API
""" """
queryset = Part.objects.all() queryset = Part.objects.all()
serializer_class = EmptySerializer serializer_class = part_serializers.PartRequirementsSerializer
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)
class PartPricingDetail(RetrieveUpdateAPI): class PartPricingDetail(RetrieveUpdateAPI):

View File

@ -98,10 +98,6 @@ class CategorySerializer(
if not path_detail and not isGeneratingSchema(): if not path_detail and not isGeneratingSchema():
self.fields.pop('path', None) 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 @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Annotate extra information to the queryset.""" """Annotate extra information to the queryset."""
@ -137,6 +133,10 @@ class CategorySerializer(
starred = serializers.SerializerMethodField() 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( path = serializers.ListField(
child=serializers.DictField(), child=serializers.DictField(),
source='get_path', source='get_path',
@ -1223,6 +1223,73 @@ class PartSerializer(
return self.instance 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): class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the PartStocktake model.""" """Serializer for the PartStocktake model."""

View File

@ -1843,6 +1843,30 @@ class PartDetailTests(PartImageTestMixin, PartAPITestBase):
self.assertIn('category_path', response.data) self.assertIn('category_path', response.data)
self.assertEqual(len(response.data['category_path']), 2) 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): class PartListTests(PartAPITestBase):
"""Unit tests for the Part List API endpoint.""" """Unit tests for the Part List API endpoint."""

View File

@ -111,6 +111,7 @@ export enum ApiEndpoints {
part_parameter_template_list = 'part/parameter/template/', part_parameter_template_list = 'part/parameter/template/',
part_thumbs_list = 'part/thumbs/', part_thumbs_list = 'part/thumbs/',
part_pricing = 'part/:id/pricing/', part_pricing = 'part/:id/pricing/',
part_requirements = 'part/:id/requirements/',
part_serial_numbers = 'part/:id/serial-numbers/', part_serial_numbers = 'part/:id/serial-numbers/',
part_scheduling = 'part/:id/scheduling/', part_scheduling = 'part/:id/scheduling/',
part_pricing_internal = 'part/internal-price/', part_pricing_internal = 'part/internal-price/',

View File

@ -86,11 +86,24 @@ export default function BuildDetail() {
refetchOnMount: true refetchOnMount: true
}); });
const { instance: partRequirements, instanceQuery: partRequirementsQuery } =
useInstance({
endpoint: ApiEndpoints.part_requirements,
pk: build?.part,
hasPrimaryKey: true,
defaultValue: {}
});
const detailsPanel = useMemo(() => { const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) { if (instanceQuery.isFetching) {
return <Skeleton />; return <Skeleton />;
} }
const data = {
...build,
can_build: partRequirements?.can_build ?? 0
};
const tl: DetailsField[] = [ const tl: DetailsField[] = [
{ {
type: 'link', type: 'link',
@ -173,10 +186,17 @@ export default function BuildDetail() {
const tr: DetailsField[] = [ const tr: DetailsField[] = [
{ {
type: 'text', type: 'number',
name: 'quantity', name: 'quantity',
label: t`Build Quantity` label: t`Build Quantity`
}, },
{
type: 'number',
name: 'can_build',
unit: build.part_detail?.units,
label: t`Can Build`,
hidden: partRequirementsQuery.isFetching
},
{ {
type: 'progressbar', type: 'progressbar',
name: 'completed', name: 'completed',
@ -290,15 +310,20 @@ export default function BuildDetail() {
pk={build.part} pk={build.part}
/> />
<Grid.Col span={{ base: 12, sm: 8 }}> <Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={build} /> <DetailsTable fields={tl} item={data} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<DetailsTable fields={tr} item={build} /> <DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={build} /> <DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={build} /> <DetailsTable fields={br} item={data} />
</ItemDetailsGrid> </ItemDetailsGrid>
); );
}, [build, instanceQuery]); }, [
build,
instanceQuery,
partRequirements,
partRequirementsQuery.isFetching
]);
const buildPanels: PanelType[] = useMemo(() => { const buildPanels: PanelType[] = useMemo(() => {
return [ return [

View File

@ -143,6 +143,14 @@ export default function PartDetail() {
refetchOnMount: true refetchOnMount: true
}); });
const { instance: partRequirements, instanceQuery: partRequirementsQuery } =
useInstance({
endpoint: ApiEndpoints.part_requirements,
pk: id,
hasPrimaryKey: true,
refetchOnMount: true
});
const detailsPanel = useMemo(() => { const detailsPanel = useMemo(() => {
if (instanceQuery.isFetching) { if (instanceQuery.isFetching) {
return <Skeleton />; return <Skeleton />;
@ -151,8 +159,23 @@ export default function PartDetail() {
const data = { ...part }; const data = { ...part };
data.required = data.required =
(data?.required_for_build_orders ?? 0) + (partRequirements?.required_for_build_orders ??
(data?.required_for_sales_orders ?? 0); 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 // Provide latest serial number info
if (!!serials.latest) { if (!!serials.latest) {
@ -315,13 +338,6 @@ export default function PartDetail() {
(part.required_for_sales_orders <= 0 && (part.required_for_sales_orders <= 0 &&
part.allocated_to_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', type: 'progressbar',
name: 'building', name: 'building',
@ -329,6 +345,13 @@ export default function PartDetail() {
progress: part.building, progress: part.building,
total: part.scheduled_to_build, total: part.scheduled_to_build,
hidden: !part.assembly || (!part.building && !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, id,
serials, serials,
instanceQuery.isFetching, instanceQuery.isFetching,
instanceQuery.data instanceQuery.data,
partRequirementsQuery.isFetching,
partRequirements
]); ]);
// Part data panels (recalculate when part data changes) // Part data panels (recalculate when part data changes)

View File

@ -162,6 +162,26 @@ test('Parts - Locking', async ({ browser }) => {
await page.getByText('Part parameters cannot be').waitFor(); 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 }) => { test('Parts - Allocations', async ({ browser }) => {
// Let's look at the allocations for a single stock item // Let's look at the allocations for a single stock item
const page = await doCachedLogin(browser, { url: 'stock/item/324/' }); const page = await doCachedLogin(browser, { url: 'stock/item/324/' });