2
0
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:
Oliver
2025-08-19 17:03:19 +10:00
committed by GitHub
parent ce6ffdac18
commit 49cc5fb137
24 changed files with 1079 additions and 142 deletions

View File

@@ -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 }>) {

View File

@@ -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/',

View File

@@ -60,6 +60,10 @@ export function RenderPartCategory(
): ReactNode {
const { instance } = props;
if (!instance) {
return '';
}
const suffix: ReactNode = (
<Group gap='xs'>
<TableHoverCard

View File

@@ -22,6 +22,10 @@ export function RenderStockLocation(
): ReactNode {
const { instance } = props;
if (!instance) {
return '';
}
const suffix: ReactNode = (
<Group gap='xs'>
<TableHoverCard

View File

@@ -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
};
})
}
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');

View File

@@ -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();