2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-01 11:10:54 +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
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

View File

@ -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):

View File

@ -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."""

View File

@ -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."""

View File

@ -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/',

View File

@ -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 <Skeleton />;
}
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}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={build} />
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<DetailsTable fields={tr} item={build} />
<DetailsTable fields={bl} item={build} />
<DetailsTable fields={br} item={build} />
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
</ItemDetailsGrid>
);
}, [build, instanceQuery]);
}, [
build,
instanceQuery,
partRequirements,
partRequirementsQuery.isFetching
]);
const buildPanels: PanelType[] = useMemo(() => {
return [

View File

@ -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 <Skeleton />;
@ -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)

View File

@ -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/' });