2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

[PUI] Part allocations (#8458)

* Add new backend filters for BuildLine API

* PUI: Better display of part allocations against build orders

* Add 'order_outstanding' filter to SalesOrderLineItem API

* Add new table showing outstanding SalesOrder allocations against a part

* Update playwright test

* Cleanup

* Bump API version

* Add more table columns

* Tweak UsedInTable

* Another table tweak

* Tweak playwright tests
This commit is contained in:
Oliver 2024-11-09 20:22:26 +11:00 committed by GitHub
parent ad39d3fd95
commit 255a5d083e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 457 additions and 81 deletions

View File

@ -1,13 +1,17 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 278
INVENTREE_API_VERSION = 279
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v279 - 2024-11-09 : https://github.com/inventree/InvenTree/pull/8458
- Adds "order_outstanding" and "part" filters to the BuildLine API endpoint
- Adds "order_outstanding" filter to the SalesOrderLineItem API endpoint
v278 - 2024-11-07 : https://github.com/inventree/InvenTree/pull/8445
- Updates to the SalesOrder API endpoints
- Add "shipment count" information to the SalesOrder API endpoints

View File

@ -357,6 +357,23 @@ class BuildLineFilter(rest_filters.FilterSet):
tracked = rest_filters.BooleanFilter(label=_('Tracked'), field_name='bom_item__sub_part__trackable')
testable = rest_filters.BooleanFilter(label=_('Testable'), field_name='bom_item__sub_part__testable')
part = rest_filters.ModelChoiceFilter(
queryset=part.models.Part.objects.all(),
label=_('Part'),
field_name='bom_item__sub_part',
)
order_outstanding = rest_filters.BooleanFilter(
label=_('Order Outstanding'),
method='filter_order_outstanding'
)
def filter_order_outstanding(self, queryset, name, value):
"""Filter by whether the associated BuildOrder is 'outstanding'."""
if str2bool(value):
return queryset.filter(build__status__in=BuildStatusGroups.ACTIVE_CODES)
return queryset.exclude(build__status__in=BuildStatusGroups.ACTIVE_CODES)
allocated = rest_filters.BooleanFilter(label=_('Allocated'), method='filter_allocated')
def filter_allocated(self, queryset, name, value):
@ -383,12 +400,28 @@ class BuildLineFilter(rest_filters.FilterSet):
return queryset.exclude(flt)
class BuildLineEndpoint:
"""Mixin class for BuildLine API endpoints."""
queryset = BuildLine.objects.all()
serializer_class = build.serializers.BuildLineSerializer
def get_serializer(self, *args, **kwargs):
"""Return the serializer instance for this endpoint."""
kwargs['context'] = self.get_serializer_context()
try:
params = self.request.query_params
kwargs['part_detail'] = str2bool(params.get('part_detail', True))
kwargs['build_detail'] = str2bool(params.get('build_detail', False))
except AttributeError:
pass
return self.serializer_class(*args, **kwargs)
def get_source_build(self) -> Build:
"""Return the source Build object for the BuildLine queryset.

View File

@ -1278,8 +1278,6 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'pk',
'build',
'bom_item',
'bom_item_detail',
'part_detail',
'quantity',
# Build detail fields
@ -1315,6 +1313,11 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
# Extra fields only for data export
'part_description',
'part_category_name',
# Extra detail (related field) serializers
'bom_item_detail',
'part_detail',
'build_detail',
]
read_only_fields = [
@ -1323,6 +1326,19 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'allocations',
]
def __init__(self, *args, **kwargs):
"""Determine which extra details fields should be included"""
part_detail = kwargs.pop('part_detail', True)
build_detail = kwargs.pop('build_detail', False)
super().__init__(*args, **kwargs)
if not part_detail:
self.fields.pop('part_detail', None)
if not build_detail:
self.fields.pop('build_detail', None)
# Build info fields
build_reference = serializers.CharField(source='build.reference', label=_('Build Reference'), read_only=True)
@ -1362,6 +1378,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
)
part_detail = part_serializers.PartBriefSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
build_detail = BuildSerializer(source='build', part_detail=False, many=False, read_only=True)
# Annotated (calculated) fields
@ -1404,9 +1421,13 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
"""
queryset = queryset.select_related(
'build',
'build__part',
'build__part__pricing_data',
'bom_item',
'bom_item__part',
'bom_item__part__pricing_data',
'bom_item__sub_part',
'bom_item__sub_part__pricing_data'
)
# Pre-fetch related fields

View File

@ -816,6 +816,17 @@ class SalesOrderLineItemFilter(LineItemFilter):
return queryset.exclude(order__status__in=SalesOrderStatusGroups.COMPLETE)
order_outstanding = rest_filters.BooleanFilter(
label=_('Order Outstanding'), method='filter_order_outstanding'
)
def filter_order_outstanding(self, queryset, name, value):
"""Filter by whether the order is 'outstanding' or not."""
if str2bool(value):
return queryset.filter(order__status__in=SalesOrderStatusGroups.OPEN)
return queryset.exclude(order__status__in=SalesOrderStatusGroups.OPEN)
class SalesOrderLineItemMixin:
"""Mixin class for SalesOrderLineItem endpoints."""

View File

@ -497,9 +497,9 @@ export function useAllocateStockToBuildForm({
lineItems,
onFormSuccess
}: {
buildId: number;
buildId?: number;
outputId?: number | null;
build: any;
build?: any;
lineItems: any[];
onFormSuccess: (response: any) => void;
}) {
@ -533,8 +533,8 @@ export function useAllocateStockToBuildForm({
}, [lineItems, sourceLocation]);
useEffect(() => {
setSourceLocation(build.take_from);
}, [build.take_from]);
setSourceLocation(build?.take_from);
}, [build?.take_from]);
const sourceLocationField: ApiFormFieldType = useMemo(() => {
return {
@ -545,7 +545,7 @@ export function useAllocateStockToBuildForm({
label: t`Source Location`,
description: t`Select the source location for the stock allocation`,
name: 'source_location',
value: build.take_from,
value: build?.take_from,
onValueChange: (value: any) => {
setSourceLocation(value);
}

View File

@ -2,11 +2,10 @@ import { t } from '@lingui/macro';
import { Accordion } from '@mantine/core';
import { StylishText } from '../../components/items/StylishText';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useUserState } from '../../states/UserState';
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable';
import PartBuildAllocationsTable from '../../tables/part/PartBuildAllocationsTable';
import PartSalesAllocationsTable from '../../tables/part/PartSalesAllocationsTable';
export default function PartAllocationPanel({ part }: { part: any }) {
const user = useUserState();
@ -23,14 +22,7 @@ export default function PartAllocationPanel({ part }: { part: any }) {
<StylishText size="lg">{t`Build Order Allocations`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<BuildAllocatedStockTable
partId={part.pk}
modelField="build"
modelTarget={ModelType.build}
showBuildInfo
showPartInfo
allowEdit
/>
<PartBuildAllocationsTable partId={part.pk} />
</Accordion.Panel>
</Accordion.Item>
)}
@ -40,12 +32,7 @@ export default function PartAllocationPanel({ part }: { part: any }) {
<StylishText size="lg">{t`Sales Order Allocations`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<SalesOrderAllocationTable
partId={part.pk}
modelField="order"
modelTarget={ModelType.salesorder}
showOrderInfo
/>
<PartSalesAllocationsTable partId={part.pk} />
</Accordion.Panel>
</Accordion.Item>
)}

View File

@ -163,10 +163,16 @@ export function LineItemsProgressColumn(): TableColumn {
export function ProjectCodeColumn(props: TableColumnProps): TableColumn {
return {
accessor: 'project_code',
ordering: 'project_code',
sortable: true,
render: (record: any) => (
<ProjectCodeHoverCard projectCode={record.project_code_detail} />
),
title: t`Project Code`,
render: (record: any) => {
let project_code = resolveItem(
record,
props.accessor ?? 'project_code_detail'
);
return <ProjectCodeHoverCard projectCode={project_code} />;
},
...props
};
}

View File

@ -0,0 +1,16 @@
import { ActionIcon } from '@mantine/core';
import { IconChevronDown, IconChevronRight } from '@tabler/icons-react';
export default function RowExpansionIcon({
enabled,
expanded
}: {
enabled: boolean;
expanded: boolean;
}) {
return (
<ActionIcon size="sm" variant="transparent" disabled={!enabled}>
{expanded ? <IconChevronDown /> : <IconChevronRight />}
</ActionIcon>
);
}

View File

@ -51,12 +51,13 @@ export function UsedInTable({
},
{
accessor: 'quantity',
switchable: false,
render: (record: any) => {
let quantity = formatDecimal(record.quantity);
let units = record.sub_part_detail?.units;
return (
<Group justify="space-between" grow>
<Group justify="space-between" grow wrap="nowrap">
<Text>{quantity}</Text>
{units && <Text size="xs">{units}</Text>}
</Group>

View File

@ -1,9 +1,7 @@
import { t } from '@lingui/macro';
import { ActionIcon, Alert, Group, Paper, Stack, Text } from '@mantine/core';
import { Alert, Group, Paper, Stack, Text } from '@mantine/core';
import {
IconArrowRight,
IconChevronDown,
IconChevronRight,
IconCircleMinus,
IconShoppingCart,
IconTool,
@ -43,6 +41,7 @@ import {
RowEditAction,
RowViewAction
} from '../RowActions';
import RowExpansionIcon from '../RowExpansionIcon';
import { TableHoverCard } from '../TableHoverCard';
/**
@ -53,16 +52,17 @@ import { TableHoverCard } from '../TableHoverCard';
*
* Note: We expect that the "lineItem" object contains an allocations[] list
*/
function BuildLineSubTable({
export function BuildLineSubTable({
lineItem,
onEditAllocation,
onDeleteAllocation
}: {
lineItem: any;
onEditAllocation: (pk: number) => void;
onDeleteAllocation: (pk: number) => void;
onEditAllocation?: (pk: number) => void;
onDeleteAllocation?: (pk: number) => void;
}) {
const user = useUserState();
const navigate = useNavigate();
const tableColumns: any[] = useMemo(() => {
return [
@ -100,16 +100,24 @@ function BuildLineSubTable({
title={t`Actions`}
index={record.pk}
actions={[
RowViewAction({
title: t`View Stock Item`,
modelType: ModelType.stockitem,
modelId: record.stock_item,
navigate: navigate
}),
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.build),
hidden:
!onEditAllocation || !user.hasChangeRole(UserRoles.build),
onClick: () => {
onEditAllocation(record.pk);
onEditAllocation?.(record.pk);
}
}),
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.build),
hidden:
!onDeleteAllocation || !user.hasDeleteRole(UserRoles.build),
onClick: () => {
onDeleteAllocation(record.pk);
onDeleteAllocation?.(record.pk);
}
})
]}
@ -131,7 +139,7 @@ function BuildLineSubTable({
pinLastColumn
idAccessor="pk"
columns={tableColumns}
records={lineItem.filteredAllocations}
records={lineItem.filteredAllocations ?? lineItem.allocations}
/>
</Stack>
</Paper>
@ -301,17 +309,10 @@ export default function BuildLineTable({
return (
<Group wrap="nowrap">
<ActionIcon
size="sm"
variant="transparent"
disabled={!hasAllocatedItems}
>
{table.isRowExpanded(record.pk) ? (
<IconChevronDown />
) : (
<IconChevronRight />
)}
</ActionIcon>
<RowExpansionIcon
enabled={hasAllocatedItems}
expanded={table.isRowExpanded(record.pk)}
/>
<PartColumn part={record.part_detail} />
</Group>
);

View File

@ -0,0 +1,130 @@
import { t } from '@lingui/macro';
import { Group, Text } from '@mantine/core';
import { DataTableRowExpansionProps } from 'mantine-datatable';
import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { ProgressBar } from '../../components/items/ProgressBar';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import {
DescriptionColumn,
ProjectCodeColumn,
StatusColumn
} from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowViewAction } from '../RowActions';
import RowExpansionIcon from '../RowExpansionIcon';
import { BuildLineSubTable } from '../build/BuildLineTable';
/**
* A "simplified" BuildOrderLineItem table showing all outstanding build order allocations for a given part.
*/
export default function PartBuildAllocationsTable({
partId
}: {
partId: number;
}) {
const user = useUserState();
const navigate = useNavigate();
const table = useTable('part-build-allocations');
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'build',
title: t`Build Order`,
sortable: true,
render: (record: any) => (
<Group wrap="nowrap" gap="xs">
<RowExpansionIcon
enabled={record.allocated > 0}
expanded={table.isRowExpanded(record.pk)}
/>
<Text>{record.build_detail?.reference}</Text>
</Group>
)
},
DescriptionColumn({
accessor: 'build_detail.title'
}),
ProjectCodeColumn({
accessor: 'build_detail.project_code_detail'
}),
StatusColumn({
accessor: 'build_detail.status',
model: ModelType.build,
title: t`Order Status`
}),
{
accessor: 'allocated',
sortable: true,
title: t`Required Stock`,
render: (record: any) => (
<ProgressBar
progressLabel
value={record.allocated}
maximum={record.quantity}
/>
)
}
];
}, [table.isRowExpanded]);
const rowActions = useCallback(
(record: any) => {
return [
RowViewAction({
title: t`View Build Order`,
modelType: ModelType.build,
modelId: record.build,
hidden: !user.hasViewRole(UserRoles.build),
navigate: navigate
})
];
},
[user]
);
// Control row expansion
const rowExpansion: DataTableRowExpansionProps<any> = useMemo(() => {
return {
allowMultiple: true,
expandable: ({ record }: { record: any }) => {
// Only items with allocated stock can be expanded
return table.isRowExpanded(record.pk) || record.allocated > 0;
},
content: ({ record }: { record: any }) => {
return <BuildLineSubTable lineItem={record} />;
}
};
}, [table.isRowExpanded]);
return (
<>
<InvenTreeTable
url={apiUrl(ApiEndpoints.build_line_list)}
tableState={table}
columns={tableColumns}
props={{
minHeight: 200,
params: {
part: partId,
consumable: false,
build_detail: true,
order_outstanding: true
},
enableColumnSwitching: false,
enableSearch: false,
rowActions: rowActions,
rowExpansion: rowExpansion
}}
/>
</>
);
}

View File

@ -0,0 +1,132 @@
import { t } from '@lingui/macro';
import { Group, Text } from '@mantine/core';
import { DataTableRowExpansionProps } from 'mantine-datatable';
import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { ProgressBar } from '../../components/items/ProgressBar';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import {
DescriptionColumn,
ProjectCodeColumn,
StatusColumn
} from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowViewAction } from '../RowActions';
import RowExpansionIcon from '../RowExpansionIcon';
import SalesOrderAllocationTable from '../sales/SalesOrderAllocationTable';
export default function PartSalesAllocationsTable({
partId
}: {
partId: number;
}) {
const user = useUserState();
const navigate = useNavigate();
const table = useTable('part-sales-allocations');
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'order',
title: t`Sales Order`,
render: (record: any) => (
<Group wrap="nowrap" gap="xs">
<RowExpansionIcon
enabled={record.allocated > 0}
expanded={table.isRowExpanded(record.pk)}
/>
<Text>{record.order_detail?.reference}</Text>
</Group>
)
},
DescriptionColumn({
accessor: 'order_detail.description'
}),
ProjectCodeColumn({
accessor: 'order_detail.project_code_detail'
}),
StatusColumn({
accessor: 'order_detail.status',
model: ModelType.salesorder,
title: t`Order Status`
}),
{
accessor: 'allocated',
title: t`Required Stock`,
render: (record: any) => (
<ProgressBar
progressLabel
value={record.allocated}
maximum={record.quantity}
/>
)
}
];
}, [table.isRowExpanded]);
const rowActions = useCallback(
(record: any) => {
return [
RowViewAction({
title: t`View Sales Order`,
modelType: ModelType.salesorder,
modelId: record.order,
hidden: !user.hasViewRole(UserRoles.sales_order),
navigate: navigate
})
];
},
[user]
);
// Control row expansion
const rowExpansion: DataTableRowExpansionProps<any> = useMemo(() => {
return {
allowMultiple: true,
expandable: ({ record }: { record: any }) => {
return table.isRowExpanded(record.pk) || record.allocated > 0;
},
content: ({ record }: { record: any }) => {
return (
<SalesOrderAllocationTable
showOrderInfo={false}
showPartInfo={false}
lineItemId={record.pk}
partId={record.part}
allowEdit
isSubTable
/>
);
}
};
}, [table.isRowExpanded]);
return (
<>
<InvenTreeTable
url={apiUrl(ApiEndpoints.sales_order_line_list)}
tableState={table}
columns={tableColumns}
props={{
minHeight: 200,
params: {
part: partId,
order_detail: true,
order_outstanding: true
},
enableSearch: false,
enableColumnSwitching: false,
rowExpansion: rowExpansion,
rowActions: rowActions
}}
/>
</>
);
}

View File

@ -1,9 +1,7 @@
import { t } from '@lingui/macro';
import { ActionIcon, Group, Text } from '@mantine/core';
import { Group, Text } from '@mantine/core';
import {
IconArrowRight,
IconChevronDown,
IconChevronRight,
IconHash,
IconShoppingCart,
IconSquareArrowRight,
@ -46,6 +44,7 @@ import {
RowEditAction,
RowViewAction
} from '../RowActions';
import RowExpansionIcon from '../RowExpansionIcon';
import { TableHoverCard } from '../TableHoverCard';
import SalesOrderAllocationTable from './SalesOrderAllocationTable';
@ -73,17 +72,10 @@ export default function SalesOrderLineItemTable({
render: (record: any) => {
return (
<Group wrap="nowrap">
<ActionIcon
size="sm"
variant="transparent"
disabled={!record.allocated}
>
{table.isRowExpanded(record.pk) ? (
<IconChevronDown />
) : (
<IconChevronRight />
)}
</ActionIcon>
<RowExpansionIcon
enabled={record.allocated}
expanded={table.isRowExpanded(record.pk)}
/>
<PartColumn part={record.part_detail} />
</Group>
);

View File

@ -212,7 +212,7 @@ test('Pages - Build Order - Allocation', async ({ page }) => {
{
name: 'Blue Widget',
ipn: 'widget.blue',
available: '45',
available: '39',
required: '5',
allocated: '5'
},

View File

@ -100,29 +100,71 @@ test('Parts - Allocations', async ({ page }) => {
await doQuickLogin(page);
// Let's look at the allocations for a single stock item
await page.goto(`${baseUrl}/stock/item/324/`);
// TODO: Un-comment these lines!
// await page.goto(`${baseUrl}/stock/item/324/`);
// await page.getByRole('tab', { name: 'Allocations' }).click();
// await page.getByRole('button', { name: 'Build Order Allocations' }).waitFor();
// await page.getByRole('cell', { name: 'Making some blue chairs' }).waitFor();
// await page.getByRole('cell', { name: 'Making tables for SO 0003' }).waitFor();
// Let's look at the allocations for an entire part
await page.goto(`${baseUrl}/part/74/details`);
// Check that the overall allocations are displayed correctly
await page.getByText('11 / 825').waitFor();
await page.getByText('6 / 110').waitFor();
// Navigate to the "Allocations" tab
await page.getByRole('tab', { name: 'Allocations' }).click();
await page.getByRole('button', { name: 'Build Order Allocations' }).waitFor();
await page.getByRole('cell', { name: 'Making some blue chairs' }).waitFor();
await page.getByRole('cell', { name: 'Making tables for SO 0003' }).waitFor();
await page.getByRole('button', { name: 'Sales Order Allocations' }).waitFor();
// Let's look at the allocations for the entire part
await page.getByRole('tab', { name: 'Details' }).click();
await page.getByRole('link', { name: 'Leg' }).click();
// Expected order reference values
await page.getByText('BO0001').waitFor();
await page.getByText('BO0016').waitFor();
await page.getByText('BO0019').waitFor();
await page.getByText('SO0008').waitFor();
await page.getByText('SO0025').waitFor();
await page.getByRole('tab', { name: 'Part Details' }).click();
await page.getByText('660 / 760').waitFor();
// Check "progress" bar of BO0001
const build_order_cell = await page.getByRole('cell', { name: 'BO0001' });
const build_order_row = await build_order_cell
.locator('xpath=ancestor::tr')
.first();
await build_order_row.getByText('11 / 75').waitFor();
await page.getByRole('tab', { name: 'Allocations' }).click();
// Expand allocations against BO0001
await build_order_cell.click();
await page.getByRole('cell', { name: '# 3', exact: true }).waitFor();
await page.getByRole('cell', { name: 'Room 101', exact: true }).waitFor();
await build_order_cell.click();
// Number of table records
await page.getByText('1 - 4 / 4').waitFor();
await page.getByRole('cell', { name: 'Making red square tables' }).waitFor();
// Check row options for BO0001
await build_order_row.getByLabel(/row-action-menu/).click();
await page.getByRole('menuitem', { name: 'View Build Order' }).waitFor();
await page.keyboard.press('Escape');
// Navigate through to the build order
await page.getByRole('cell', { name: 'BO0007' }).click();
await page.getByRole('tab', { name: 'Build Details' }).waitFor();
// Check "progress" bar of SO0025
const sales_order_cell = await page.getByRole('cell', { name: 'SO0025' });
const sales_order_row = await sales_order_cell
.locator('xpath=ancestor::tr')
.first();
await sales_order_row.getByText('3 / 10').waitFor();
// Expand allocations against SO0025
await sales_order_cell.click();
await page.getByRole('cell', { name: '161', exact: true });
await page.getByRole('cell', { name: '169', exact: true });
await page.getByRole('cell', { name: '170', exact: true });
await sales_order_cell.click();
// Check row options for SO0025
await sales_order_row.getByLabel(/row-action-menu/).click();
await page.getByRole('menuitem', { name: 'View Sales Order' }).waitFor();
await page.keyboard.press('Escape');
});
test('Parts - Pricing (Nothing, BOM)', async ({ page }) => {