mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
26b2e90fcf
commit
5d05137630
@ -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
|
||||
|
||||
|
@ -1810,6 +1810,10 @@ class BomList(BomMixin, ListCreateDestroyAPIView):
|
||||
'quantity',
|
||||
'sub_part',
|
||||
'available_stock',
|
||||
'allow_variants',
|
||||
'inherited',
|
||||
'optional',
|
||||
'consumable',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
},
|
||||
|
26
src/frontend/src/forms/BomForms.tsx
Normal file
26
src/frontend/src/forms/BomForms.tsx
Normal 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: {}
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue
Block a user