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:
@ -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
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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."""
|
||||||
|
|
||||||
|
@ -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."""
|
||||||
|
@ -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/',
|
||||||
|
@ -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 [
|
||||||
|
@ -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)
|
||||||
|
@ -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/' });
|
||||||
|
Reference in New Issue
Block a user