mirror of
https://github.com/inventree/InvenTree.git
synced 2025-09-13 14:11:37 +00:00
Build order consume (#8191)
* Adds "consumed" field to BuildLine model * Expose new field to serializer * Add "consumed" column to BuildLineTable * Boolean column tweaks * Increase consumed count when completing allocation * Add comment * Update migration * Add serializer for consuming build items * Improve build-line sub-table * Refactor BuildItem.complete_allocation method - Allow optional quantity to be specified - Adjust the allocated quantity when consuming * Perform consumption * Add "BuildConsume" API endpoint * Implement frontend form * Fixes for serializer * Enhance front-end form * Fix rendering of BuildLineTable * Further improve rendering * Bump API version * Update API description * Add option to consume by specifying a list of BuildLine objects * Add form to consume stock via BuildLine reference * Fix api_version * Fix backup colors * Fix typo * Fix migrations * Fix build forms * Forms fixes * Fix formatting * Fixes for BuildLineTable * Account for consumed stock in requirements calculation * Reduce API requirements for BuildLineTable * Docs updates * Updated playwright testing * Update src/frontend/src/forms/BuildForms.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/frontend/src/tables/build/BuildLineTable.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add unit test for filters * Add functional tests * Tweak query count * Increase max query time for testing * adjust unit test again * Prevent consumption of "tracked" items * Adjust playwright tests * Fix table * Fix rendering --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,24 +1,31 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Badge, Skeleton } from '@mantine/core';
|
||||
import { Badge, type MantineColor, Skeleton } from '@mantine/core';
|
||||
|
||||
import { isTrue } from '../functions/Conversion';
|
||||
|
||||
export function PassFailButton({
|
||||
value,
|
||||
passText,
|
||||
failText
|
||||
failText,
|
||||
passColor,
|
||||
failColor
|
||||
}: Readonly<{
|
||||
value: any;
|
||||
passText?: string;
|
||||
failText?: string;
|
||||
passColor?: MantineColor;
|
||||
failColor?: MantineColor;
|
||||
}>) {
|
||||
const v = isTrue(value);
|
||||
const pass = passText ?? t`Pass`;
|
||||
const fail = failText ?? t`Fail`;
|
||||
|
||||
const pColor = passColor ?? 'green';
|
||||
const fColor = failColor ?? 'red';
|
||||
|
||||
return (
|
||||
<Badge
|
||||
color={v ? 'green' : 'red'}
|
||||
color={v ? pColor : fColor}
|
||||
variant='filled'
|
||||
radius='lg'
|
||||
size='sm'
|
||||
@@ -30,7 +37,14 @@ export function PassFailButton({
|
||||
}
|
||||
|
||||
export function YesNoButton({ value }: Readonly<{ value: any }>) {
|
||||
return <PassFailButton value={value} passText={t`Yes`} failText={t`No`} />;
|
||||
return (
|
||||
<PassFailButton
|
||||
value={value}
|
||||
passText={t`Yes`}
|
||||
failText={t`No`}
|
||||
failColor={'orange.6'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function YesNoUndefinedButton({ value }: Readonly<{ value?: boolean }>) {
|
||||
|
@@ -96,6 +96,7 @@ export enum ApiEndpoints {
|
||||
build_output_delete = 'build/:id/delete-outputs/',
|
||||
build_order_auto_allocate = 'build/:id/auto-allocate/',
|
||||
build_order_allocate = 'build/:id/allocate/',
|
||||
build_order_consume = 'build/:id/consume/',
|
||||
build_order_deallocate = 'build/:id/unallocate/',
|
||||
|
||||
build_line_list = 'build/line/',
|
||||
|
@@ -60,6 +60,10 @@ export function RenderPartCategory(
|
||||
): ReactNode {
|
||||
const { instance } = props;
|
||||
|
||||
if (!instance) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const suffix: ReactNode = (
|
||||
<Group gap='xs'>
|
||||
<TableHoverCard
|
||||
|
@@ -22,6 +22,10 @@ export function RenderStockLocation(
|
||||
): ReactNode {
|
||||
const { instance } = props;
|
||||
|
||||
if (!instance) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const suffix: ReactNode = (
|
||||
<Group gap='xs'>
|
||||
<TableHoverCard
|
||||
|
@@ -1,7 +1,8 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Alert, Divider, List, Stack, Table } from '@mantine/core';
|
||||
import { Alert, Divider, Group, List, Stack, Table, Text } from '@mantine/core';
|
||||
import {
|
||||
IconCalendar,
|
||||
IconCircleCheck,
|
||||
IconInfoCircle,
|
||||
IconLink,
|
||||
IconList,
|
||||
@@ -25,7 +26,10 @@ import {
|
||||
type TableFieldRowProps
|
||||
} from '../components/forms/fields/TableField';
|
||||
import { StatusRenderer } from '../components/render/StatusRenderer';
|
||||
import { RenderStockItem } from '../components/render/Stock';
|
||||
import {
|
||||
RenderStockItem,
|
||||
RenderStockLocation
|
||||
} from '../components/render/Stock';
|
||||
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||
import {
|
||||
useBatchCodeGenerator,
|
||||
@@ -542,7 +546,7 @@ function BuildAllocateLineRow({
|
||||
<Table.Td>
|
||||
<ProgressBar
|
||||
value={record.allocatedQuantity}
|
||||
maximum={record.requiredQuantity}
|
||||
maximum={record.requiredQuantity - record.consumed}
|
||||
progressLabel
|
||||
/>
|
||||
</Table.Td>
|
||||
@@ -670,15 +674,220 @@ export function useAllocateStockToBuildForm({
|
||||
successMessage: t`Stock items allocated`,
|
||||
onFormSuccess: onFormSuccess,
|
||||
initialData: {
|
||||
items: lineItems.map((item) => {
|
||||
return {
|
||||
build_line: item.pk,
|
||||
stock_item: undefined,
|
||||
quantity: Math.max(0, item.requiredQuantity - item.allocatedQuantity),
|
||||
output: outputId
|
||||
};
|
||||
})
|
||||
items: lineItems
|
||||
.filter((item) => {
|
||||
return item.requiredQuantity > item.allocatedQuantity + item.consumed;
|
||||
})
|
||||
.map((item) => {
|
||||
return {
|
||||
build_line: item.pk,
|
||||
stock_item: undefined,
|
||||
quantity: Math.max(
|
||||
0,
|
||||
item.requiredQuantity - item.allocatedQuantity - item.consumed
|
||||
),
|
||||
output: outputId
|
||||
};
|
||||
})
|
||||
},
|
||||
size: '80%'
|
||||
});
|
||||
}
|
||||
|
||||
function BuildConsumeItemRow({
|
||||
props,
|
||||
record
|
||||
}: {
|
||||
props: TableFieldRowProps;
|
||||
record: any;
|
||||
}) {
|
||||
return (
|
||||
<Table.Tr key={`table-row-${record.pk}`}>
|
||||
<Table.Td>
|
||||
<PartColumn part={record.part_detail} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RenderStockItem instance={record.stock_item_detail} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{record.location_detail && (
|
||||
<RenderStockLocation instance={record.location_detail} />
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{record.quantity}</Table.Td>
|
||||
<Table.Td>
|
||||
<StandaloneField
|
||||
fieldName='quantity'
|
||||
fieldDefinition={{
|
||||
field_type: 'number',
|
||||
required: true,
|
||||
value: props.item.quantity,
|
||||
onValueChange: (value: any) => {
|
||||
props.changeFn(props.idx, 'quantity', value);
|
||||
}
|
||||
}}
|
||||
error={props.rowErrors?.quantity?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic form for consuming stock against multiple BuildItem records
|
||||
*/
|
||||
export function useConsumeBuildItemsForm({
|
||||
buildId,
|
||||
allocatedItems,
|
||||
onFormSuccess
|
||||
}: {
|
||||
buildId: number;
|
||||
allocatedItems: any[];
|
||||
onFormSuccess: (response: any) => void;
|
||||
}) {
|
||||
const consumeFields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: [],
|
||||
headers: [
|
||||
{ title: t`Part` },
|
||||
{ title: t`Stock Item` },
|
||||
{ title: t`Location` },
|
||||
{ title: t`Allocated` },
|
||||
{ title: t`Quantity` }
|
||||
],
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record = allocatedItems.find(
|
||||
(item) => item.pk == row.item.build_item
|
||||
);
|
||||
|
||||
return (
|
||||
<BuildConsumeItemRow key={row.idx} props={row} record={record} />
|
||||
);
|
||||
}
|
||||
},
|
||||
notes: {}
|
||||
};
|
||||
}, [allocatedItems]);
|
||||
|
||||
return useCreateApiFormModal({
|
||||
url: ApiEndpoints.build_order_consume,
|
||||
pk: buildId,
|
||||
title: t`Consume Stock`,
|
||||
successMessage: t`Stock items consumed`,
|
||||
onFormSuccess: onFormSuccess,
|
||||
size: '80%',
|
||||
fields: consumeFields,
|
||||
initialData: {
|
||||
items: allocatedItems.map((item) => {
|
||||
return {
|
||||
build_item: item.pk,
|
||||
quantity: item.quantity
|
||||
};
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function BuildConsumeLineRow({
|
||||
props,
|
||||
record
|
||||
}: {
|
||||
props: TableFieldRowProps;
|
||||
record: any;
|
||||
}) {
|
||||
const allocated: number = record.allocatedQuantity ?? record.allocated;
|
||||
const required: number = record.requiredQuantity ?? record.required;
|
||||
const remaining: number = Math.max(0, required - record.consumed);
|
||||
|
||||
return (
|
||||
<Table.Tr key={`table-row-${record.pk}`}>
|
||||
<Table.Td>
|
||||
<PartColumn part={record.part_detail} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{remaining <= 0 ? (
|
||||
<Group gap='xs'>
|
||||
<IconCircleCheck size={16} color='green' />
|
||||
<Text size='sm' style={{ fontStyle: 'italic' }}>
|
||||
{t`Fully consumed`}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<ProgressBar value={allocated} maximum={remaining} progressLabel />
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ProgressBar
|
||||
value={record.consumed}
|
||||
maximum={record.quantity}
|
||||
progressLabel
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic form for consuming stock against multiple BuildLine records
|
||||
*/
|
||||
export function useConsumeBuildLinesForm({
|
||||
buildId,
|
||||
buildLines,
|
||||
onFormSuccess
|
||||
}: {
|
||||
buildId: number;
|
||||
buildLines: any[];
|
||||
onFormSuccess: (response: any) => void;
|
||||
}) {
|
||||
const filteredLines = useMemo(() => {
|
||||
return buildLines.filter((line) => !line.part_detail?.trackable);
|
||||
}, [buildLines]);
|
||||
|
||||
const consumeFields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
lines: {
|
||||
field_type: 'table',
|
||||
value: [],
|
||||
headers: [
|
||||
{ title: t`Part` },
|
||||
{ title: t`Allocated` },
|
||||
{ title: t`Consumed` }
|
||||
],
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record = filteredLines.find(
|
||||
(item) => item.pk == row.item.build_line
|
||||
);
|
||||
|
||||
return (
|
||||
<BuildConsumeLineRow key={row.idx} props={row} record={record} />
|
||||
);
|
||||
}
|
||||
},
|
||||
notes: {}
|
||||
};
|
||||
}, [filteredLines]);
|
||||
|
||||
return useCreateApiFormModal({
|
||||
url: ApiEndpoints.build_order_consume,
|
||||
pk: buildId,
|
||||
title: t`Consume Stock`,
|
||||
successMessage: t`Stock items consumed`,
|
||||
onFormSuccess: onFormSuccess,
|
||||
fields: consumeFields,
|
||||
initialData: {
|
||||
lines: filteredLines.map((item) => {
|
||||
return {
|
||||
build_line: item.pk
|
||||
};
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@@ -46,6 +46,7 @@ import {
|
||||
import { Thumbnail } from '../components/images/Thumbnail';
|
||||
import { StylishText } from '../components/items/StylishText';
|
||||
import { StatusRenderer } from '../components/render/StatusRenderer';
|
||||
import { RenderStockLocation } from '../components/render/Stock';
|
||||
import { InvenTreeIcon } from '../functions/icons';
|
||||
import {
|
||||
useApiFormModal,
|
||||
@@ -576,7 +577,7 @@ function StockOperationsRow({
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{record.location ? record.location_detail?.pathstring : '-'}
|
||||
<RenderStockLocation instance={record.location_detail} />
|
||||
</Table.Td>
|
||||
<Table.Td>{record.batch ? record.batch : '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
|
@@ -2,7 +2,7 @@
|
||||
* Common rendering functions for table column data.
|
||||
*/
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Anchor, Group, Skeleton, Text, Tooltip } from '@mantine/core';
|
||||
import { Anchor, Center, Group, Skeleton, Text, Tooltip } from '@mantine/core';
|
||||
import {
|
||||
IconBell,
|
||||
IconExclamationCircle,
|
||||
@@ -210,7 +210,9 @@ export function BooleanColumn(props: TableColumn): TableColumn {
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
render: (record: any) => (
|
||||
<YesNoButton value={resolveItem(record, props.accessor ?? '')} />
|
||||
<Center>
|
||||
<YesNoButton value={resolveItem(record, props.accessor ?? '')} />
|
||||
</Center>
|
||||
),
|
||||
...props
|
||||
};
|
||||
|
@@ -10,8 +10,11 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { ActionButton } from '@lib/index';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { IconCircleDashedCheck } from '@tabler/icons-react';
|
||||
import { useConsumeBuildItemsForm } from '../../forms/BuildForms';
|
||||
import type { StockOperationProps } from '../../forms/StockForms';
|
||||
import {
|
||||
useDeleteApiFormModal,
|
||||
@@ -160,10 +163,10 @@ export default function BuildAllocatedStockTable({
|
||||
];
|
||||
}, []);
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<number>(0);
|
||||
const [selectedItemId, setSelectedItemId] = useState<number>(0);
|
||||
|
||||
const editItem = useEditApiFormModal({
|
||||
pk: selectedItem,
|
||||
pk: selectedItemId,
|
||||
url: ApiEndpoints.build_item_list,
|
||||
title: t`Edit Stock Allocation`,
|
||||
fields: {
|
||||
@@ -176,12 +179,23 @@ export default function BuildAllocatedStockTable({
|
||||
});
|
||||
|
||||
const deleteItem = useDeleteApiFormModal({
|
||||
pk: selectedItem,
|
||||
pk: selectedItemId,
|
||||
url: ApiEndpoints.build_item_list,
|
||||
title: t`Delete Stock Allocation`,
|
||||
table: table
|
||||
});
|
||||
|
||||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
||||
|
||||
const consumeStock = useConsumeBuildItemsForm({
|
||||
buildId: buildId ?? 0,
|
||||
allocatedItems: selectedItems,
|
||||
onFormSuccess: () => {
|
||||
table.clearSelectedRecords();
|
||||
table.refreshTable();
|
||||
}
|
||||
});
|
||||
|
||||
const stockOperationProps: StockOperationProps = useMemo(() => {
|
||||
// Extract stock items from the selected records
|
||||
// Note that the table is actually a list of BuildItem instances,
|
||||
@@ -216,17 +230,28 @@ export default function BuildAllocatedStockTable({
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
return [
|
||||
{
|
||||
color: 'green',
|
||||
icon: <IconCircleDashedCheck />,
|
||||
title: t`Consume`,
|
||||
tooltip: t`Consume Stock`,
|
||||
hidden: !user.hasChangeRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
setSelectedItems([record]);
|
||||
consumeStock.open();
|
||||
}
|
||||
},
|
||||
RowEditAction({
|
||||
hidden: !user.hasChangeRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
setSelectedItem(record.pk);
|
||||
setSelectedItemId(record.pk);
|
||||
editItem.open();
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden: !user.hasDeleteRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
setSelectedItem(record.pk);
|
||||
setSelectedItemId(record.pk);
|
||||
deleteItem.open();
|
||||
}
|
||||
})
|
||||
@@ -236,13 +261,28 @@ export default function BuildAllocatedStockTable({
|
||||
);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [stockAdjustActions.dropdown];
|
||||
}, [stockAdjustActions.dropdown]);
|
||||
return [
|
||||
stockAdjustActions.dropdown,
|
||||
<ActionButton
|
||||
key='consume-stock'
|
||||
icon={<IconCircleDashedCheck />}
|
||||
tooltip={t`Consume Stock`}
|
||||
hidden={!user.hasChangeRole(UserRoles.build)}
|
||||
disabled={table.selectedRecords.length == 0}
|
||||
color='green'
|
||||
onClick={() => {
|
||||
setSelectedItems(table.selectedRecords);
|
||||
consumeStock.open();
|
||||
}}
|
||||
/>
|
||||
];
|
||||
}, [user, table.selectedRecords, stockAdjustActions.dropdown]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editItem.modal}
|
||||
{deleteItem.modal}
|
||||
{consumeStock.modal}
|
||||
{stockAdjustActions.modals.map((modal) => modal.modal)}
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
|
@@ -1,21 +1,21 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Alert, Group, Paper, Stack, Text } from '@mantine/core';
|
||||
import { Alert, Group, Paper, Text } from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconCircleCheck,
|
||||
IconCircleDashedCheck,
|
||||
IconCircleMinus,
|
||||
IconShoppingCart,
|
||||
IconTool,
|
||||
IconWand
|
||||
} from '@tabler/icons-react';
|
||||
import { DataTable, type DataTableRowExpansionProps } from 'mantine-datatable';
|
||||
import type { DataTableRowExpansionProps } from 'mantine-datatable';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ActionButton } from '@lib/components/ActionButton';
|
||||
import { ProgressBar } from '@lib/components/ProgressBar';
|
||||
import {
|
||||
type RowAction,
|
||||
RowActions,
|
||||
RowDeleteAction,
|
||||
RowEditAction,
|
||||
RowViewAction
|
||||
@@ -26,11 +26,12 @@ import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { formatDecimal } from '@lib/functions/Formatting';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import type { RowAction, TableColumn } from '@lib/types/Tables';
|
||||
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
||||
import {
|
||||
useAllocateStockToBuildForm,
|
||||
useBuildOrderFields
|
||||
useBuildOrderFields,
|
||||
useConsumeBuildLinesForm
|
||||
} from '../../forms/BuildForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
@@ -70,6 +71,7 @@ export function BuildLineSubTable({
|
||||
}>) {
|
||||
const user = useUserState();
|
||||
const navigate = useNavigate();
|
||||
const table = useTable('buildline-subtable');
|
||||
|
||||
const tableColumns: any[] = useMemo(() => {
|
||||
return [
|
||||
@@ -96,59 +98,52 @@ export function BuildLineSubTable({
|
||||
},
|
||||
LocationColumn({
|
||||
accessor: 'location_detail'
|
||||
}),
|
||||
{
|
||||
accessor: '---actions---',
|
||||
title: ' ',
|
||||
width: 50,
|
||||
render: (record: any) => {
|
||||
return (
|
||||
<RowActions
|
||||
title={t`Actions`}
|
||||
index={record.pk}
|
||||
actions={[
|
||||
RowViewAction({
|
||||
title: t`View Stock Item`,
|
||||
modelType: ModelType.stockitem,
|
||||
modelId: record.stock_item,
|
||||
navigate: navigate
|
||||
}),
|
||||
RowEditAction({
|
||||
hidden:
|
||||
!onEditAllocation || !user.hasChangeRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
onEditAllocation?.(record.pk);
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden:
|
||||
!onDeleteAllocation || !user.hasDeleteRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
onDeleteAllocation?.(record.pk);
|
||||
}
|
||||
})
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
}, [user, onEditAllocation, onDeleteAllocation]);
|
||||
}, []);
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
return [
|
||||
RowViewAction({
|
||||
title: t`View Stock Item`,
|
||||
modelType: ModelType.stockitem,
|
||||
modelId: record.stock_item,
|
||||
navigate: navigate
|
||||
}),
|
||||
RowEditAction({
|
||||
hidden: !onEditAllocation || !user.hasChangeRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
onEditAllocation?.(record.pk);
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden: !onDeleteAllocation || !user.hasDeleteRole(UserRoles.build),
|
||||
onClick: () => {
|
||||
onDeleteAllocation?.(record.pk);
|
||||
}
|
||||
})
|
||||
];
|
||||
},
|
||||
[user, onEditAllocation, onDeleteAllocation]
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper p='md'>
|
||||
<Stack gap='xs'>
|
||||
<DataTable
|
||||
minHeight={50}
|
||||
withTableBorder
|
||||
withColumnBorders
|
||||
striped
|
||||
pinLastColumn
|
||||
idAccessor='pk'
|
||||
columns={tableColumns}
|
||||
records={lineItem.filteredAllocations ?? lineItem.allocations}
|
||||
/>
|
||||
</Stack>
|
||||
<Paper p='xs'>
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
tableData={lineItem.filteredAllocations ?? lineItem.allocations}
|
||||
props={{
|
||||
minHeight: 200,
|
||||
enableSearch: false,
|
||||
enableRefresh: false,
|
||||
enableColumnSwitching: false,
|
||||
enableFilters: false,
|
||||
rowActions: rowActions,
|
||||
noRecordsText: ''
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -186,7 +181,12 @@ export default function BuildLineTable({
|
||||
{
|
||||
name: 'allocated',
|
||||
label: t`Allocated`,
|
||||
description: t`Show allocated lines`
|
||||
description: t`Show fully allocated lines`
|
||||
},
|
||||
{
|
||||
name: 'consumed',
|
||||
label: t`Consumed`,
|
||||
description: t`Show fully consumed lines`
|
||||
},
|
||||
{
|
||||
name: 'available',
|
||||
@@ -471,13 +471,56 @@ export default function BuildLineTable({
|
||||
switchable: false,
|
||||
sortable: true,
|
||||
hidden: !isActive,
|
||||
render: (record: any) => {
|
||||
if (record?.bom_item_detail?.consumable) {
|
||||
return (
|
||||
<Text
|
||||
size='sm'
|
||||
style={{ fontStyle: 'italic' }}
|
||||
>{t`Consumable item`}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
let required = Math.max(0, record.quantity - record.consumed);
|
||||
|
||||
if (output?.pk) {
|
||||
// If an output is specified, we show the allocated quantity for that output
|
||||
required = record.bom_item_detail?.quantity;
|
||||
}
|
||||
|
||||
if (required <= 0) {
|
||||
return (
|
||||
<Group gap='xs' wrap='nowrap'>
|
||||
<IconCircleCheck size={16} color='green' />
|
||||
<Text size='sm' style={{ fontStyle: 'italic' }}>
|
||||
{record.consumed >= record.quantity
|
||||
? t`Fully consumed`
|
||||
: t`Fully allocated`}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProgressBar
|
||||
progressLabel={true}
|
||||
value={record.allocatedQuantity}
|
||||
maximum={required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'consumed',
|
||||
sortable: true,
|
||||
hidden: !!output?.pk,
|
||||
render: (record: any) => {
|
||||
return record?.bom_item_detail?.consumable ? (
|
||||
<Text style={{ fontStyle: 'italic' }}>{t`Consumable item`}</Text>
|
||||
) : (
|
||||
<ProgressBar
|
||||
progressLabel={true}
|
||||
value={record.allocatedQuantity}
|
||||
value={record.consumed}
|
||||
maximum={record.requiredQuantity}
|
||||
/>
|
||||
);
|
||||
@@ -544,6 +587,7 @@ export default function BuildLineTable({
|
||||
buildId: build.pk,
|
||||
lineItems: selectedRows,
|
||||
onFormSuccess: () => {
|
||||
table.clearSelectedRecords();
|
||||
table.refreshTable();
|
||||
}
|
||||
});
|
||||
@@ -574,7 +618,10 @@ export default function BuildLineTable({
|
||||
</Alert>
|
||||
),
|
||||
successMessage: t`Stock has been deallocated`,
|
||||
table: table
|
||||
onFormSuccess: () => {
|
||||
table.clearSelectedRecords();
|
||||
table.refreshTable();
|
||||
}
|
||||
});
|
||||
|
||||
const [selectedAllocation, setSelectedAllocation] = useState<number>(0);
|
||||
@@ -605,6 +652,15 @@ export default function BuildLineTable({
|
||||
parts: partsToOrder
|
||||
});
|
||||
|
||||
const consumeLines = useConsumeBuildLinesForm({
|
||||
buildId: build.pk,
|
||||
buildLines: selectedRows,
|
||||
onFormSuccess: () => {
|
||||
table.clearSelectedRecords();
|
||||
table.refreshTable();
|
||||
}
|
||||
});
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any): RowAction[] => {
|
||||
const part = record.part_detail ?? {};
|
||||
@@ -613,11 +669,24 @@ export default function BuildLineTable({
|
||||
|
||||
const hasOutput = !!output?.pk;
|
||||
|
||||
const required = Math.max(
|
||||
0,
|
||||
record.quantity - record.consumed - record.allocated
|
||||
);
|
||||
|
||||
// Can consume
|
||||
const canConsume =
|
||||
in_production &&
|
||||
!consumable &&
|
||||
record.allocated > 0 &&
|
||||
user.hasChangeRole(UserRoles.build);
|
||||
|
||||
// Can allocate
|
||||
const canAllocate =
|
||||
in_production &&
|
||||
!consumable &&
|
||||
user.hasChangeRole(UserRoles.build) &&
|
||||
required > 0 &&
|
||||
record.trackable == hasOutput;
|
||||
|
||||
// Can de-allocate
|
||||
@@ -647,6 +716,16 @@ export default function BuildLineTable({
|
||||
allocateStock.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: <IconCircleDashedCheck />,
|
||||
title: t`Consume Stock`,
|
||||
color: 'green',
|
||||
hidden: !canConsume || hasOutput,
|
||||
onClick: () => {
|
||||
setSelectedRows([record]);
|
||||
consumeLines.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: <IconCircleMinus />,
|
||||
title: t`Deallocate Stock`,
|
||||
@@ -758,6 +837,18 @@ export default function BuildLineTable({
|
||||
setSelectedLine(null);
|
||||
deallocateStock.open();
|
||||
}}
|
||||
/>,
|
||||
<ActionButton
|
||||
key='consume-stock'
|
||||
icon={<IconCircleDashedCheck />}
|
||||
tooltip={t`Consume Stock`}
|
||||
hidden={!visible || hasOutput}
|
||||
disabled={!table.hasSelectedRecords}
|
||||
color='green'
|
||||
onClick={() => {
|
||||
setSelectedRows(table.selectedRecords);
|
||||
consumeLines.open();
|
||||
}}
|
||||
/>
|
||||
];
|
||||
}, [
|
||||
@@ -843,6 +934,7 @@ export default function BuildLineTable({
|
||||
{deallocateStock.modal}
|
||||
{editAllocation.modal}
|
||||
{deleteAllocation.modal}
|
||||
{consumeLines.modal}
|
||||
{orderPartsWizard.wizard}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.build_line_list)}
|
||||
@@ -852,6 +944,7 @@ export default function BuildLineTable({
|
||||
params: {
|
||||
...params,
|
||||
build: build.pk,
|
||||
assembly_detail: false,
|
||||
part_detail: true
|
||||
},
|
||||
tableActions: tableActions,
|
||||
|
@@ -95,20 +95,24 @@ function OutputAllocationDrawer({
|
||||
position='bottom'
|
||||
size='lg'
|
||||
title={
|
||||
<Group p='md' wrap='nowrap' justify='space-apart'>
|
||||
<Group p='xs' wrap='nowrap' justify='space-apart'>
|
||||
<StylishText size='lg'>{t`Build Output Stock Allocation`}</StylishText>
|
||||
<Space h='lg' />
|
||||
<PartColumn part={build.part_detail} />
|
||||
{output?.serial && (
|
||||
<Text size='sm'>
|
||||
{t`Serial Number`}: {output.serial}
|
||||
</Text>
|
||||
)}
|
||||
{output?.batch && (
|
||||
<Text size='sm'>
|
||||
{t`Batch Code`}: {output.batch}
|
||||
</Text>
|
||||
)}
|
||||
<Paper withBorder p='sm'>
|
||||
<Group gap='xs'>
|
||||
<PartColumn part={build.part_detail} />
|
||||
{output?.serial && (
|
||||
<Text size='sm'>
|
||||
{t`Serial Number`}: {output.serial}
|
||||
</Text>
|
||||
)}
|
||||
{output?.batch && (
|
||||
<Text size='sm'>
|
||||
{t`Batch Code`}: {output.batch}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
<Space h='lg' />
|
||||
</Group>
|
||||
}
|
||||
|
@@ -12,6 +12,7 @@ import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { TableFilter } from '@lib/types/Filters';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { IconCircleCheck } from '@tabler/icons-react';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
@@ -92,13 +93,30 @@ export default function PartBuildAllocationsTable({
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
title: t`Required Stock`,
|
||||
render: (record: any) => (
|
||||
<ProgressBar
|
||||
progressLabel
|
||||
value={record.allocated}
|
||||
maximum={record.quantity}
|
||||
/>
|
||||
)
|
||||
render: (record: any) => {
|
||||
const required = Math.max(0, record.quantity - record.consumed);
|
||||
|
||||
if (required <= 0) {
|
||||
return (
|
||||
<Group gap='xs' wrap='nowrap'>
|
||||
<IconCircleCheck size={14} color='green' />
|
||||
<Text size='sm' style={{ fontStyle: 'italic' }}>
|
||||
{record.consumed >= record.quantity
|
||||
? t`Fully consumed`
|
||||
: t`Fully allocated`}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProgressBar
|
||||
progressLabel
|
||||
value={record.allocated}
|
||||
maximum={required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}, [table.isRowExpanded]);
|
||||
@@ -142,11 +160,13 @@ export default function PartBuildAllocationsTable({
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
minHeight: 200,
|
||||
minHeight: 300,
|
||||
params: {
|
||||
part: partId,
|
||||
consumable: false,
|
||||
part_detail: true,
|
||||
part_detail: false,
|
||||
bom_item_detail: false,
|
||||
project_code_detail: true,
|
||||
assembly_detail: true,
|
||||
build_detail: true,
|
||||
order_outstanding: true
|
||||
|
@@ -350,6 +350,59 @@ test('Build Order - Allocation', async ({ browser }) => {
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
// Test partial stock consumption against build order
|
||||
test('Build Order - Consume Stock', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'manufacturing/build-order/24/line-items'
|
||||
});
|
||||
|
||||
// Check for expected progress values
|
||||
await page.getByText('2 / 2', { exact: true }).waitFor();
|
||||
await page.getByText('8 / 10', { exact: true }).waitFor();
|
||||
await page.getByText('5 / 35', { exact: true }).waitFor();
|
||||
await page.getByText('5 / 40', { exact: true }).waitFor();
|
||||
|
||||
// Open the "Allocate Stock" dialog
|
||||
await page.getByRole('checkbox', { name: 'Select all records' }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-allocate-stock' })
|
||||
.click();
|
||||
await page
|
||||
.getByLabel('Allocate Stock')
|
||||
.getByText('5 / 35', { exact: true })
|
||||
.waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Open the "Consume Stock" dialog
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-consume-stock' })
|
||||
.click();
|
||||
await page.getByLabel('Consume Stock').getByText('2 / 2').waitFor();
|
||||
await page.getByLabel('Consume Stock').getByText('8 / 10').waitFor();
|
||||
await page.getByLabel('Consume Stock').getByText('5 / 35').waitFor();
|
||||
await page.getByLabel('Consume Stock').getByText('5 / 40').waitFor();
|
||||
await page
|
||||
.getByRole('textbox', { name: 'text-field-notes' })
|
||||
.fill('some notes here...');
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Try with a different build order
|
||||
await navigate(page, 'manufacturing/build-order/26/line-items');
|
||||
await page.getByRole('checkbox', { name: 'Select all records' }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-consume-stock' })
|
||||
.click();
|
||||
|
||||
await page.getByLabel('Consume Stock').getByText('306 / 1,900').waitFor();
|
||||
await page
|
||||
.getByLabel('Consume Stock')
|
||||
.getByText('Fully consumed')
|
||||
.first()
|
||||
.waitFor();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
test('Build Order - Tracked Outputs', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'manufacturing/build-order/10/incomplete-outputs'
|
||||
|
@@ -249,7 +249,13 @@ test('Parts - Requirements', async ({ browser }) => {
|
||||
await page.getByText('5 / 100').waitFor(); // Allocated to sales orders
|
||||
await page.getByText('10 / 125').waitFor(); // In production
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
// Also check requirements for part with open build orders which have been partially consumed
|
||||
await navigate(page, 'part/105/details');
|
||||
|
||||
await page.getByText('Required: 2').waitFor();
|
||||
await page.getByText('Available: 32').waitFor();
|
||||
await page.getByText('In Stock: 34').waitFor();
|
||||
await page.getByText('2 / 2').waitFor(); // Allocated to build orders
|
||||
});
|
||||
|
||||
test('Parts - Allocations', async ({ browser }) => {
|
||||
@@ -377,7 +383,6 @@ test('Parts - Pricing (Supplier)', async ({ browser }) => {
|
||||
|
||||
// Supplier Pricing
|
||||
await page.getByRole('button', { name: 'Supplier Pricing' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole('button', { name: 'SKU Not sorted' }).waitFor();
|
||||
|
||||
// Supplier Pricing - linkjumping
|
||||
|
@@ -323,6 +323,7 @@ test('Stock - Return Items', async ({ browser }) => {
|
||||
name: 'action-menu-stock-operations-return-stock'
|
||||
})
|
||||
.click();
|
||||
|
||||
await page.getByText('#128').waitFor();
|
||||
await page.getByText('Merge into existing stock').waitFor();
|
||||
await page.getByRole('textbox', { name: 'number-field-quantity' }).fill('0');
|
||||
|
@@ -52,8 +52,6 @@ test('Plugins - Settings', async ({ browser, request }) => {
|
||||
.fill(originalValue == '999' ? '1000' : '999');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Change it back
|
||||
await page.getByLabel('edit-setting-NUMERICAL_SETTING').click();
|
||||
await page.getByLabel('number-field-value').fill(originalValue);
|
||||
@@ -164,8 +162,6 @@ test('Plugins - Panels', async ({ browser, request }) => {
|
||||
value: true
|
||||
});
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Ensure that the SampleUI plugin is enabled
|
||||
await setPluginState({
|
||||
request,
|
||||
@@ -173,8 +169,6 @@ test('Plugins - Panels', async ({ browser, request }) => {
|
||||
state: true
|
||||
});
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Navigate to the "part" page
|
||||
await navigate(page, 'part/69/');
|
||||
|
||||
@@ -186,20 +180,14 @@ test('Plugins - Panels', async ({ browser, request }) => {
|
||||
|
||||
// Check out each of the plugin panels
|
||||
await loadTab(page, 'Broken Panel');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.getByText('Error occurred while loading plugin content').waitFor();
|
||||
|
||||
await loadTab(page, 'Dynamic Panel');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.getByText('Instance ID: 69');
|
||||
await page
|
||||
.getByText('This panel has been dynamically rendered by the plugin system')
|
||||
.waitFor();
|
||||
|
||||
await loadTab(page, 'Part Panel');
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByText('This content has been rendered by a custom plugin');
|
||||
|
||||
// Disable the plugin, and ensure it is no longer visible
|
||||
@@ -260,8 +248,6 @@ test('Plugins - Locate Item', async ({ browser, request }) => {
|
||||
state: true
|
||||
});
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Navigate to the "stock item" page
|
||||
await navigate(page, 'stock/item/287/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
@@ -273,7 +259,6 @@ test('Plugins - Locate Item', async ({ browser, request }) => {
|
||||
|
||||
// Show the location
|
||||
await page.getByLabel('breadcrumb-1-factory').click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.getByLabel('action-button-locate-item').click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
Reference in New Issue
Block a user