diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py
index 24e939b7e5..2e1b366b5c 100644
--- a/InvenTree/InvenTree/api_version.py
+++ b/InvenTree/InvenTree/api_version.py
@@ -2,10 +2,14 @@
# InvenTree API version
-INVENTREE_API_VERSION = 148
+INVENTREE_API_VERSION = 149
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v149 -> 2023-11-07 : https://github.com/inventree/InvenTree/pull/5876
+ - Add 'building' quantity to BomItem serializer
+ - Add extra ordering options for the BomItem list API
+
v148 -> 2023-11-06 : https://github.com/inventree/InvenTree/pull/5872
- Allow "quantity" to be specified when installing an item into another item
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 0072fdd3e2..64ac4d1f7a 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -1810,6 +1810,10 @@ class BomList(BomMixin, ListCreateDestroyAPIView):
'quantity',
'sub_part',
'available_stock',
+ 'allow_variants',
+ 'inherited',
+ 'optional',
+ 'consumable',
]
ordering_field_aliases = {
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 5740530960..e2897a3c14 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -1184,6 +1184,9 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
# Annotated field describing quantity on order
'on_order',
+
+ # Annotated field describing quantity being built
+ 'building',
]
def __init__(self, *args, **kwargs):
@@ -1228,6 +1231,7 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True)
on_order = serializers.FloatField(read_only=True)
+ building = serializers.FloatField(read_only=True)
# Cached pricing fields
pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(source='sub_part.pricing.overall_min', allow_null=True, read_only=True)
@@ -1259,6 +1263,10 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'substitutes__part__stock_items',
)
+ queryset = queryset.prefetch_related(
+ 'sub_part__builds',
+ )
+
return queryset
@staticmethod
@@ -1280,6 +1288,18 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
on_order=part.filters.annotate_on_order_quantity(ref),
)
+ # Annotate with the total "building" amount for the sub-part
+ queryset = queryset.annotate(
+ building=Coalesce(
+ SubquerySum(
+ 'sub_part__builds__quantity',
+ filter=Q(status__in=BuildStatusGroups.ACTIVE_CODES),
+ ),
+ Decimal(0),
+ output_field=models.DecimalField(),
+ )
+ )
+
# Calculate "total stock" for the referenced sub_part
# Calculate the "build_order_allocations" for the sub_part
# Note that these fields are only aliased, not annotated
diff --git a/src/frontend/src/components/items/YesNoButton.tsx b/src/frontend/src/components/items/YesNoButton.tsx
index c98721a21d..f175c5461e 100644
--- a/src/frontend/src/components/items/YesNoButton.tsx
+++ b/src/frontend/src/components/items/YesNoButton.tsx
@@ -1,18 +1,15 @@
import { t } from '@lingui/macro';
import { Badge } from '@mantine/core';
-export function YesNoButton({ value }: { value: any }) {
- const bool =
- String(value).toLowerCase().trim() in ['true', '1', 't', 'y', 'yes'];
-
+export function YesNoButton({ value }: { value: boolean }) {
return (
- {bool ? t`Yes` : t`No`}
+ {value ? t`Yes` : t`No`}
);
}
diff --git a/src/frontend/src/components/tables/ColumnRenderers.tsx b/src/frontend/src/components/tables/ColumnRenderers.tsx
index c8246434fa..e6c845942f 100644
--- a/src/frontend/src/components/tables/ColumnRenderers.tsx
+++ b/src/frontend/src/components/tables/ColumnRenderers.tsx
@@ -5,12 +5,28 @@ import { t } from '@lingui/macro';
import { formatCurrency, renderDate } from '../../defaults/formatters';
import { ProgressBar } from '../items/ProgressBar';
+import { YesNoButton } from '../items/YesNoButton';
import { ModelType } from '../render/ModelType';
import { RenderOwner } from '../render/User';
import { TableStatusRenderer } from '../renderers/StatusRenderer';
import { TableColumn } from './Column';
import { ProjectCodeHoverCard } from './TableHoverCard';
+export function BooleanColumn({
+ accessor,
+ title
+}: {
+ accessor: string;
+ title: string;
+}): TableColumn {
+ return {
+ accessor: accessor,
+ title: title,
+ sortable: true,
+ render: (record: any) =>
+ };
+}
+
export function DescriptionColumn(): TableColumn {
return {
accessor: 'description',
diff --git a/src/frontend/src/components/tables/bom/BomTable.tsx b/src/frontend/src/components/tables/bom/BomTable.tsx
index 44db315b10..29cebb4dcd 100644
--- a/src/frontend/src/components/tables/bom/BomTable.tsx
+++ b/src/frontend/src/components/tables/bom/BomTable.tsx
@@ -3,17 +3,36 @@ import { Text } from '@mantine/core';
import { ReactNode, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
+import { bomItemFields } from '../../../forms/BomForms';
+import { openDeleteApiForm, openEditApiForm } from '../../../functions/forms';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { useUserState } from '../../../states/UserState';
-import { ThumbnailHoverCard } from '../../images/Thumbnail';
+import { Thumbnail } from '../../images/Thumbnail';
import { YesNoButton } from '../../items/YesNoButton';
import { TableColumn } from '../Column';
+import { BooleanColumn } from '../ColumnRenderers';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
import { TableHoverCard } from '../TableHoverCard';
+// Calculate the total stock quantity available for a given BomItem
+function availableStockQuantity(record: any): number {
+ // Base availability
+ let available: number = record.available_stock;
+
+ // Add in available substitute stock
+ available += record?.available_substitute_stock ?? 0;
+
+ // Add in variant stock
+ if (record.allow_variants) {
+ available += record?.available_variant_stock ?? 0;
+ }
+
+ return available;
+}
+
export function BomTable({
partId,
params = {}
@@ -25,7 +44,7 @@ export function BomTable({
const user = useUserState();
- const { tableKey } = useTableRefresh('bom');
+ const { tableKey, refreshTable } = useTableRefresh('bom');
const tableColumns: TableColumn[] = useMemo(() => {
return [
@@ -33,16 +52,28 @@ export function BomTable({
{
accessor: 'part',
title: t`Part`,
- render: (row) => {
- let part = row.sub_part_detail;
+ switchable: false,
+ sortable: true,
+ render: (record) => {
+ let part = record.sub_part_detail;
+ let extra = [];
+
+ if (record.part != partId) {
+ extra.push(t`This BOM item is defined for a different parent`);
+ }
return (
part && (
-
+ }
+ extra={extra}
+ title={t`Part Information`}
/>
)
);
@@ -55,17 +86,20 @@ export function BomTable({
},
{
accessor: 'reference',
-
title: t`Reference`
},
{
accessor: 'quantity',
- title: t`Quantity`
+ title: t`Quantity`,
+ switchable: false,
+ sortable: true
+ // TODO: Custom quantity renderer
+ // TODO: see bom.js for existing implementation
},
{
accessor: 'substitutes',
title: t`Substitutes`,
-
+ // TODO: Show hovercard with list of substitutes
render: (row) => {
let substitutes = row.substitutes ?? [];
@@ -76,43 +110,24 @@ export function BomTable({
);
}
},
- {
+ BooleanColumn({
accessor: 'optional',
- title: t`Optional`,
-
- sortable: true,
- render: (row) => {
- return ;
- }
- },
- {
+ title: t`Optional`
+ }),
+ BooleanColumn({
accessor: 'consumable',
- title: t`Consumable`,
-
- sortable: true,
- render: (row) => {
- return ;
- }
- },
- {
+ title: t`Consumable`
+ }),
+ BooleanColumn({
accessor: 'allow_variants',
- title: t`Allow Variants`,
-
- sortable: true,
- render: (row) => {
- return ;
- }
- },
- {
+ title: t`Allow Variants`
+ }),
+ BooleanColumn({
accessor: 'inherited',
- title: t`Gets Inherited`,
-
- sortable: true,
- render: (row) => {
- // TODO: Update complexity here
- return ;
- }
- },
+ title: t`Gets Inherited`
+ // TODO: Custom renderer for this column
+ // TODO: See bom.js for existing implementation
+ }),
{
accessor: 'price_range',
title: t`Price Range`,
@@ -123,6 +138,7 @@ export function BomTable({
let max_price = row.pricing_max || row.pricing_min;
// TODO: Custom price range rendering component
+ // TODO: Footer component for price range
return `${min_price} - ${max_price}`;
}
},
@@ -130,26 +146,35 @@ export function BomTable({
accessor: 'available_stock',
title: t`Available`,
- render: (row) => {
+ render: (record) => {
let extra: ReactNode[] = [];
- let available_stock: number = row?.available_stock ?? 0;
- let substitute_stock: number = row?.substitute_stock ?? 0;
- let variant_stock: number = row?.variant_stock ?? 0;
- let on_order: number = row?.on_order ?? 0;
+ let available_stock: number = availableStockQuantity(record);
+ let on_order: number = record?.on_order ?? 0;
+ let building: number = record?.building ?? 0;
- if (available_stock <= 0) {
- return {t`No stock`};
- }
+ let text =
+ available_stock <= 0 ? (
+ {t`No stock`}
+ ) : (
+ available_stock
+ );
- if (substitute_stock > 0) {
+ if (record.available_substitute_stock > 0) {
extra.push(
- {t`Includes substitute stock`}
+
+ {t`Includes substitute stock`}:{' '}
+ {record.available_substitute_stock}
+
);
}
- if (variant_stock > 0) {
- extra.push({t`Includes variant stock`});
+ if (record.allow_variants && record.available_variant_stock > 0) {
+ extra.push(
+
+ {t`Includes variant stock`}: {record.available_variant_stock}
+
+ );
}
if (on_order > 0) {
@@ -160,11 +185,19 @@ export function BomTable({
);
}
+ if (building > 0) {
+ extra.push(
+
+ {t`Building`}: {building}
+
+ );
+ }
+
return (
);
}
@@ -172,9 +205,19 @@ export function BomTable({
{
accessor: 'can_build',
title: t`Can Build`,
+ sortable: false, // TODO: Custom sorting via API
+ render: (record: any) => {
+ if (record.consumable) {
+ return {t`Consumable item`};
+ }
- sortable: true // TODO: Custom sorting via API
- // TODO: Reference bom.js for canBuildQuantity method
+ let can_build = availableStockQuantity(record) / record.quantity;
+ can_build = Math.trunc(can_build);
+
+ return (
+ {can_build}
+ );
+ }
},
{
accessor: 'note',
@@ -185,27 +228,81 @@ export function BomTable({
}, [partId, params]);
const tableFilters: TableFilter[] = useMemo(() => {
- return [];
+ return [
+ {
+ name: 'consumable',
+ label: t`Consumable`,
+ type: 'boolean'
+ }
+ // TODO: More BOM table filters here
+ ];
}, [partId, params]);
const rowActions = useCallback(
(record: any) => {
+ // If this BOM item is defined for a *different* parent, then it cannot be edited
+ if (record.part && record.part != partId) {
+ return [
+ {
+ title: t`View BOM`,
+ onClick: () => navigate(`/part/${record.part}/`)
+ }
+ ];
+ }
+
// TODO: Check user permissions here,
// TODO: to determine which actions are allowed
let actions: RowAction[] = [];
- if (!record.validated) {
- actions.push({
- title: t`Validate`
- });
- }
+ // TODO: Enable BomItem validation
+ actions.push({
+ title: t`Validate`,
+ hidden: record.validated || !user.checkUserRole('part', 'change')
+ });
- // TODO: Action on edit
- actions.push(RowEditAction({}));
+ // TODO: Enable editing of substitutes
+ actions.push({
+ title: t`Substitutes`,
+ color: 'blue',
+ hidden: !user.checkUserRole('part', 'change')
+ });
- // TODO: Action on delete
- actions.push(RowDeleteAction({}));
+ // Action on edit
+ actions.push(
+ RowEditAction({
+ hidden: !user.checkUserRole('part', 'change'),
+ onClick: () => {
+ openEditApiForm({
+ url: ApiPaths.bom_list,
+ pk: record.pk,
+ title: t`Edit Bom Item`,
+ fields: bomItemFields(),
+ successMessage: t`Bom item updated`,
+ onFormSuccess: refreshTable
+ });
+ }
+ })
+ );
+
+ // Action on delete
+ actions.push(
+ RowDeleteAction({
+ hidden: !user.checkUserRole('part', 'delete'),
+ onClick: () => {
+ openDeleteApiForm({
+ url: ApiPaths.bom_list,
+ pk: record.pk,
+ title: t`Delete Bom Item`,
+ successMessage: t`Bom item deleted`,
+ onFormSuccess: refreshTable,
+ preFormContent: (
+ {t`Are you sure you want to remove this BOM item?`}
+ )
+ });
+ }
+ })
+ );
return actions;
},
diff --git a/src/frontend/src/forms/BomForms.tsx b/src/frontend/src/forms/BomForms.tsx
new file mode 100644
index 0000000000..5533cfcd76
--- /dev/null
+++ b/src/frontend/src/forms/BomForms.tsx
@@ -0,0 +1,26 @@
+import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
+
+/**
+ * Field set for BomItem form
+ */
+export function bomItemFields(): ApiFormFieldSet {
+ return {
+ part: {
+ hidden: true
+ },
+ sub_part: {
+ filters: {
+ component: true,
+ virtual: false
+ }
+ },
+ quantity: {},
+ reference: {},
+ overage: {},
+ note: {},
+ allow_variants: {},
+ inherited: {},
+ consumable: {},
+ optional: {}
+ };
+}