2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-15 03:23:07 +00:00

[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
This commit is contained in:
Oliver 2023-11-08 07:37:17 +11:00 committed by GitHub
parent 26b2e90fcf
commit 5d05137630
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 242 additions and 78 deletions

View File

@ -2,10 +2,14 @@
# InvenTree API version # 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.""" """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 = """
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 v148 -> 2023-11-06 : https://github.com/inventree/InvenTree/pull/5872
- Allow "quantity" to be specified when installing an item into another item - Allow "quantity" to be specified when installing an item into another item

View File

@ -1810,6 +1810,10 @@ class BomList(BomMixin, ListCreateDestroyAPIView):
'quantity', 'quantity',
'sub_part', 'sub_part',
'available_stock', 'available_stock',
'allow_variants',
'inherited',
'optional',
'consumable',
] ]
ordering_field_aliases = { ordering_field_aliases = {

View File

@ -1184,6 +1184,9 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
# Annotated field describing quantity on order # Annotated field describing quantity on order
'on_order', 'on_order',
# Annotated field describing quantity being built
'building',
] ]
def __init__(self, *args, **kwargs): 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) sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True)
on_order = serializers.FloatField(read_only=True) on_order = serializers.FloatField(read_only=True)
building = serializers.FloatField(read_only=True)
# Cached pricing fields # Cached pricing fields
pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(source='sub_part.pricing.overall_min', allow_null=True, read_only=True) 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', 'substitutes__part__stock_items',
) )
queryset = queryset.prefetch_related(
'sub_part__builds',
)
return queryset return queryset
@staticmethod @staticmethod
@ -1280,6 +1288,18 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
on_order=part.filters.annotate_on_order_quantity(ref), 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 "total stock" for the referenced sub_part
# Calculate the "build_order_allocations" for the sub_part # Calculate the "build_order_allocations" for the sub_part
# Note that these fields are only aliased, not annotated # Note that these fields are only aliased, not annotated

View File

@ -1,18 +1,15 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Badge } from '@mantine/core'; import { Badge } from '@mantine/core';
export function YesNoButton({ value }: { value: any }) { export function YesNoButton({ value }: { value: boolean }) {
const bool =
String(value).toLowerCase().trim() in ['true', '1', 't', 'y', 'yes'];
return ( return (
<Badge <Badge
color={bool ? 'green' : 'red'} color={value ? 'lime.5' : 'red.6'}
variant="filled" variant="filled"
radius="lg" radius="lg"
size="sm" size="sm"
> >
{bool ? t`Yes` : t`No`} {value ? t`Yes` : t`No`}
</Badge> </Badge>
); );
} }

View File

@ -5,12 +5,28 @@ import { t } from '@lingui/macro';
import { formatCurrency, renderDate } from '../../defaults/formatters'; import { formatCurrency, renderDate } from '../../defaults/formatters';
import { ProgressBar } from '../items/ProgressBar'; import { ProgressBar } from '../items/ProgressBar';
import { YesNoButton } from '../items/YesNoButton';
import { ModelType } from '../render/ModelType'; import { ModelType } from '../render/ModelType';
import { RenderOwner } from '../render/User'; import { RenderOwner } from '../render/User';
import { TableStatusRenderer } from '../renderers/StatusRenderer'; import { TableStatusRenderer } from '../renderers/StatusRenderer';
import { TableColumn } from './Column'; import { TableColumn } from './Column';
import { ProjectCodeHoverCard } from './TableHoverCard'; import { ProjectCodeHoverCard } from './TableHoverCard';
export function BooleanColumn({
accessor,
title
}: {
accessor: string;
title: string;
}): TableColumn {
return {
accessor: accessor,
title: title,
sortable: true,
render: (record: any) => <YesNoButton value={record[accessor]} />
};
}
export function DescriptionColumn(): TableColumn { export function DescriptionColumn(): TableColumn {
return { return {
accessor: 'description', accessor: 'description',

View File

@ -3,17 +3,36 @@ import { Text } from '@mantine/core';
import { ReactNode, useCallback, useMemo } from 'react'; import { ReactNode, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { bomItemFields } from '../../../forms/BomForms';
import { openDeleteApiForm, openEditApiForm } from '../../../functions/forms';
import { useTableRefresh } from '../../../hooks/TableRefresh'; import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState'; import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { useUserState } from '../../../states/UserState'; import { useUserState } from '../../../states/UserState';
import { ThumbnailHoverCard } from '../../images/Thumbnail'; import { Thumbnail } from '../../images/Thumbnail';
import { YesNoButton } from '../../items/YesNoButton'; import { YesNoButton } from '../../items/YesNoButton';
import { TableColumn } from '../Column'; import { TableColumn } from '../Column';
import { BooleanColumn } from '../ColumnRenderers';
import { TableFilter } from '../Filter'; import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions'; import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
import { TableHoverCard } from '../TableHoverCard'; 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({ export function BomTable({
partId, partId,
params = {} params = {}
@ -25,7 +44,7 @@ export function BomTable({
const user = useUserState(); const user = useUserState();
const { tableKey } = useTableRefresh('bom'); const { tableKey, refreshTable } = useTableRefresh('bom');
const tableColumns: TableColumn[] = useMemo(() => { const tableColumns: TableColumn[] = useMemo(() => {
return [ return [
@ -33,16 +52,28 @@ export function BomTable({
{ {
accessor: 'part', accessor: 'part',
title: t`Part`, title: t`Part`,
render: (row) => { switchable: false,
let part = row.sub_part_detail; 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 ( return (
part && ( part && (
<ThumbnailHoverCard <TableHoverCard
value={
<Thumbnail
src={part.thumbnail || part.image} src={part.thumbnail || part.image}
text={part.full_name}
alt={part.description} alt={part.description}
link="" text={part.full_name}
/>
}
extra={extra}
title={t`Part Information`}
/> />
) )
); );
@ -55,17 +86,20 @@ export function BomTable({
}, },
{ {
accessor: 'reference', accessor: 'reference',
title: t`Reference` title: t`Reference`
}, },
{ {
accessor: 'quantity', 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', accessor: 'substitutes',
title: t`Substitutes`, title: t`Substitutes`,
// TODO: Show hovercard with list of substitutes
render: (row) => { render: (row) => {
let substitutes = row.substitutes ?? []; let substitutes = row.substitutes ?? [];
@ -76,43 +110,24 @@ export function BomTable({
); );
} }
}, },
{ BooleanColumn({
accessor: 'optional', accessor: 'optional',
title: t`Optional`, title: t`Optional`
}),
sortable: true, BooleanColumn({
render: (row) => {
return <YesNoButton value={row.optional} />;
}
},
{
accessor: 'consumable', accessor: 'consumable',
title: t`Consumable`, title: t`Consumable`
}),
sortable: true, BooleanColumn({
render: (row) => {
return <YesNoButton value={row.consumable} />;
}
},
{
accessor: 'allow_variants', accessor: 'allow_variants',
title: t`Allow Variants`, title: t`Allow Variants`
}),
sortable: true, BooleanColumn({
render: (row) => {
return <YesNoButton value={row.allow_variants} />;
}
},
{
accessor: 'inherited', accessor: 'inherited',
title: t`Gets Inherited`, title: t`Gets Inherited`
// TODO: Custom renderer for this column
sortable: true, // TODO: See bom.js for existing implementation
render: (row) => { }),
// TODO: Update complexity here
return <YesNoButton value={row.inherited} />;
}
},
{ {
accessor: 'price_range', accessor: 'price_range',
title: t`Price Range`, title: t`Price Range`,
@ -123,6 +138,7 @@ export function BomTable({
let max_price = row.pricing_max || row.pricing_min; let max_price = row.pricing_max || row.pricing_min;
// TODO: Custom price range rendering component // TODO: Custom price range rendering component
// TODO: Footer component for price range
return `${min_price} - ${max_price}`; return `${min_price} - ${max_price}`;
} }
}, },
@ -130,26 +146,35 @@ export function BomTable({
accessor: 'available_stock', accessor: 'available_stock',
title: t`Available`, title: t`Available`,
render: (row) => { render: (record) => {
let extra: ReactNode[] = []; let extra: ReactNode[] = [];
let available_stock: number = row?.available_stock ?? 0; let available_stock: number = availableStockQuantity(record);
let substitute_stock: number = row?.substitute_stock ?? 0; let on_order: number = record?.on_order ?? 0;
let variant_stock: number = row?.variant_stock ?? 0; let building: number = record?.building ?? 0;
let on_order: number = row?.on_order ?? 0;
if (available_stock <= 0) { let text =
return <Text color="red" italic>{t`No stock`}</Text>; available_stock <= 0 ? (
} <Text color="red" italic>{t`No stock`}</Text>
) : (
available_stock
);
if (substitute_stock > 0) { if (record.available_substitute_stock > 0) {
extra.push( extra.push(
<Text key="substitute">{t`Includes substitute stock`}</Text> <Text key="substitute">
{t`Includes substitute stock`}:{' '}
{record.available_substitute_stock}
</Text>
); );
} }
if (variant_stock > 0) { if (record.allow_variants && record.available_variant_stock > 0) {
extra.push(<Text key="variant">{t`Includes variant stock`}</Text>); extra.push(
<Text key="variant">
{t`Includes variant stock`}: {record.available_variant_stock}
</Text>
);
} }
if (on_order > 0) { if (on_order > 0) {
@ -160,11 +185,19 @@ export function BomTable({
); );
} }
if (building > 0) {
extra.push(
<Text key="building">
{t`Building`}: {building}
</Text>
);
}
return ( return (
<TableHoverCard <TableHoverCard
value={available_stock} value={text}
extra={extra} extra={extra}
title={t`Available Stock`} title={t`Stock Information`}
/> />
); );
} }
@ -172,9 +205,19 @@ export function BomTable({
{ {
accessor: 'can_build', accessor: 'can_build',
title: t`Can Build`, title: t`Can Build`,
sortable: false, // TODO: Custom sorting via API
render: (record: any) => {
if (record.consumable) {
return <Text italic>{t`Consumable item`}</Text>;
}
sortable: true // TODO: Custom sorting via API let can_build = availableStockQuantity(record) / record.quantity;
// TODO: Reference bom.js for canBuildQuantity method can_build = Math.trunc(can_build);
return (
<Text color={can_build <= 0 ? 'red' : undefined}>{can_build}</Text>
);
}
}, },
{ {
accessor: 'note', accessor: 'note',
@ -185,27 +228,81 @@ export function BomTable({
}, [partId, params]); }, [partId, params]);
const tableFilters: TableFilter[] = useMemo(() => { const tableFilters: TableFilter[] = useMemo(() => {
return []; return [
{
name: 'consumable',
label: t`Consumable`,
type: 'boolean'
}
// TODO: More BOM table filters here
];
}, [partId, params]); }, [partId, params]);
const rowActions = useCallback( const rowActions = useCallback(
(record: any) => { (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: Check user permissions here,
// TODO: to determine which actions are allowed // TODO: to determine which actions are allowed
let actions: RowAction[] = []; let actions: RowAction[] = [];
if (!record.validated) { // TODO: Enable BomItem validation
actions.push({ actions.push({
title: t`Validate` title: t`Validate`,
hidden: record.validated || !user.checkUserRole('part', 'change')
});
// TODO: Enable editing of substitutes
actions.push({
title: t`Substitutes`,
color: 'blue',
hidden: !user.checkUserRole('part', 'change')
});
// 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
}); });
} }
})
);
// TODO: Action on edit // Action on delete
actions.push(RowEditAction({})); actions.push(
RowDeleteAction({
// TODO: Action on delete hidden: !user.checkUserRole('part', 'delete'),
actions.push(RowDeleteAction({})); onClick: () => {
openDeleteApiForm({
url: ApiPaths.bom_list,
pk: record.pk,
title: t`Delete Bom Item`,
successMessage: t`Bom item deleted`,
onFormSuccess: refreshTable,
preFormContent: (
<Text>{t`Are you sure you want to remove this BOM item?`}</Text>
)
});
}
})
);
return actions; return actions;
}, },

View File

@ -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: {}
};
}