[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 = 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

View File

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

View File

@ -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

View File

@ -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 (
<Badge
color={bool ? 'green' : 'red'}
color={value ? 'lime.5' : 'red.6'}
variant="filled"
radius="lg"
size="sm"
>
{bool ? t`Yes` : t`No`}
{value ? t`Yes` : t`No`}
</Badge>
);
}

View File

@ -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) => <YesNoButton value={record[accessor]} />
};
}
export function DescriptionColumn(): TableColumn {
return {
accessor: 'description',

View File

@ -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 && (
<ThumbnailHoverCard
src={part.thumbnail || part.image}
text={part.full_name}
alt={part.description}
link=""
<TableHoverCard
value={
<Thumbnail
src={part.thumbnail || part.image}
alt={part.description}
text={part.full_name}
/>
}
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 <YesNoButton value={row.optional} />;
}
},
{
title: t`Optional`
}),
BooleanColumn({
accessor: 'consumable',
title: t`Consumable`,
sortable: true,
render: (row) => {
return <YesNoButton value={row.consumable} />;
}
},
{
title: t`Consumable`
}),
BooleanColumn({
accessor: 'allow_variants',
title: t`Allow Variants`,
sortable: true,
render: (row) => {
return <YesNoButton value={row.allow_variants} />;
}
},
{
title: t`Allow Variants`
}),
BooleanColumn({
accessor: 'inherited',
title: t`Gets Inherited`,
sortable: true,
render: (row) => {
// TODO: Update complexity here
return <YesNoButton value={row.inherited} />;
}
},
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 <Text color="red" italic>{t`No stock`}</Text>;
}
let 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(
<Text key="substitute">{t`Includes substitute stock`}</Text>
<Text key="substitute">
{t`Includes substitute stock`}:{' '}
{record.available_substitute_stock}
</Text>
);
}
if (variant_stock > 0) {
extra.push(<Text key="variant">{t`Includes variant stock`}</Text>);
if (record.allow_variants && record.available_variant_stock > 0) {
extra.push(
<Text key="variant">
{t`Includes variant stock`}: {record.available_variant_stock}
</Text>
);
}
if (on_order > 0) {
@ -160,11 +185,19 @@ export function BomTable({
);
}
if (building > 0) {
extra.push(
<Text key="building">
{t`Building`}: {building}
</Text>
);
}
return (
<TableHoverCard
value={available_stock}
value={text}
extra={extra}
title={t`Available Stock`}
title={t`Stock Information`}
/>
);
}
@ -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 <Text italic>{t`Consumable item`}</Text>;
}
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 (
<Text color={can_build <= 0 ? 'red' : undefined}>{can_build}</Text>
);
}
},
{
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: (
<Text>{t`Are you sure you want to remove this BOM item?`}</Text>
)
});
}
})
);
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: {}
};
}