From 5d05137630d480b714ab4109919e27822e4be18e Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 8 Nov 2023 07:37:17 +1100 Subject: [PATCH] [Platform] BOM Table (#5876) * Add generic BooleanColumn for tables * Edit BOM item * Add 'building' quantity to BomItemSerializer * Improve "available" column * Fix yesnobutton * Update 'available' and 'can_build' columns * Delete BOM item * Improve back-end ordering for BomItem list API * Table tweaks * Bump API version * Tweak API notes --- InvenTree/InvenTree/api_version.py | 6 +- InvenTree/part/api.py | 4 + InvenTree/part/serializers.py | 20 ++ .../src/components/items/YesNoButton.tsx | 9 +- .../src/components/tables/ColumnRenderers.tsx | 16 ++ .../src/components/tables/bom/BomTable.tsx | 239 ++++++++++++------ src/frontend/src/forms/BomForms.tsx | 26 ++ 7 files changed, 242 insertions(+), 78 deletions(-) create mode 100644 src/frontend/src/forms/BomForms.tsx 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: {} + }; +}