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