[PUI] Build test results (#7777)

* Skeleton for "test results" panel on build detail page

* Generate table columns based on test templates

* Fill out test result table in build panel

* Fix for form submission with files attached

- Better determination of "hasFiles"
- Ignore undefined values

* Add modal form to create a new test result

* Add button for creating a new test result

* Fix for build output table

* Add extra API filtering options to BuildLine API endpoint

* Improve table rendering

* Adjust form fields

* Account for multiple test results

* Add "location" column

* Docs updates

* playwright tests
This commit is contained in:
Oliver 2024-08-01 14:58:26 +10:00 committed by GitHub
parent 3cbfcc11cb
commit 97bef77d56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 357 additions and 34 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -103,25 +103,44 @@ For further information, refer to the [stock allocation documentation](./allocat
## Build Order Display
The detail view for a single build order provides multiple display tabs, as follows:
The detail view for a single build order provides multiple display panels, as follows:
### Build Details
The *Build Details* tab provides an overview of the Build Order:
The *Build Details* panel provides an overview of the Build Order:
{% with id="build_details", url="build/build_details.png", description="Details tab" %}
{% with id="build_details", url="build/build_panel_details.png", description="Build details panel" %}
{% include "img.html" %}
{% endwith %}
### Line Items
The *Line Items* tab provides an interface to allocate required stock (as specified by the BOM) to the build:
The *Line Items* panel displays all the line items (as defined by the [bill of materials](./bom.md)) required to complete the build order.
{% with id="build_allocate", url="build/build_allocate.png", description="Allocation tab" %}
{% with id="build_allocate", url="build/build_panel_line_items.png", description="Build line items panel" %}
{% include "img.html" %}
{% endwith %}
The allocation table (as shown above) shows the stock allocation progress for this build. In the example above, there are two BOM lines, which have been partially allocated.
The allocation table (as shown above) provides an interface to allocate required stock, and also shows the stock allocation progress for each line item in the build.
### Incomplete Outputs
The *Incomplete Outputs* panel shows the list of in-progress [build outputs](./output.md) (created stock items) associated with this build.
{% with id="build_outputs", url="build/build_outputs.png", description="Outputs tab" %}
{% include "img.html" %}
{% endwith %}
!!! info "Example: Build Outputs"
In the example image above, a single output (serial number 2) has been completed, while serial numbers 1 and 4 are still in progress.
- Build outputs can be created from this screen, by selecting the *Create New Output* button
- Outputs which are "in progress" can be completed or cancelled
- Completed outputs (which are simply *stock items*) can be viewed in the stock table at the bottom of the screen
### Completed Outputs
This panel displays all the completed build outputs (stock items) which have been created by this build order:
### Allocated Stock
@ -138,28 +157,29 @@ The *Consumed Stock* tab displays all stock items which have been *consumed* by
- [Tracked stock items](./allocate.md#tracked-stock) are consumed by specific build outputs
- [Untracked stock items](./allocate.md#untracked-stock) are consumed by the build order
### Build Outputs
The *Build Outputs* tab shows the [build outputs](./output.md) (created stock items) associated with this build.
As shown below, there are separate panels for *incomplete* and *completed* build outputs.
{% with id="build_outputs", url="build/build_outputs.png", description="Outputs tab" %}
{% include "img.html" %}
{% endwith %}
!!! info "Example: Build Outputs"
In the example image above, a single output (serial number 2) has been completed, while serial numbers 1 and 4 are still in progress.
- Build outputs can be created from this screen, by selecting the *Create New Output* button
- Outputs which are "in progress" can be completed or cancelled
- Completed outputs (which are simply *stock items*) can be viewed in the stock table at the bottom of the screen
### Child Builds
If there exist any build orders which are *children* of the selected build order, they are displayed in the *Child Builds* tab:
{% with id="build_childs", url="build/build_childs.png", description="Child builds tab" %}
{% with id="build_childs", url="build/build_childs.png", description="Child builds panel" %}
{% include "img.html" %}
{% endwith %}
### Test Results
For *trackable* parts, test results can be recorded against each build output. These results are displayed in the *Test Results* panel:
{% with id="build_test_results", url="build/build_panel_test_results.png", description="Test Results panel" %}
{% include "img.html" %}
{% endwith %}
This table provides a summary of the test results for each build output, and allows test results to be quickly added for each build output.
### Test Statistics
For *trackable* parts, this panel displays a summary of the test results for all build outputs:
{% with id="build_test_stats", url="build/build_panel_test_statistics.png", description="Test Statistics panel" %}
{% include "img.html" %}
{% endwith %}

View File

@ -54,6 +54,8 @@ The following basic options are available:
| --- | --- | --- | --- |
| INVENTREE_SITE_URL | site_url | Specify a fixed site URL | *Not specified* |
| INVENTREE_DEBUG | debug | Enable [debug mode](./intro.md#debug-mode) | True |
| INVENTREE_DEBUG_QUERYCOUNT | debug_querycount | Enable [query count logging](https://github.com/bradmontgomery/django-querycount) in the terminal | False |
| INVENTREE_DEBUG_SHELL | debug_shell | Enable [administrator shell](https://github.com/djk2/django-admin-shell) (only in debug mode) | False |
| INVENTREE_LOG_LEVEL | log_level | Set level of logging to terminal | WARNING |
| INVENTREE_DB_LOGGING | db_logging | Enable logging of database messages | False |
| INVENTREE_TIMEZONE | timezone | Server timezone | UTC |

View File

@ -2,6 +2,7 @@
import os
import subprocess
import textwrap
import requests
import yaml

View File

@ -359,6 +359,8 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
'unit_quantity',
'available_stock',
'trackable',
'allow_variants',
'inherited',
]
ordering_field_aliases = {
@ -368,6 +370,8 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
'consumable': 'bom_item__consumable',
'optional': 'bom_item__optional',
'trackable': 'bom_item__sub_part__trackable',
'allow_variants': 'bom_item__allow_variants',
'inherited': 'bom_item__inherited',
}
search_fields = [

View File

@ -122,11 +122,11 @@ function NameBadge({ pk, type }: { pk: string | number; type: BadgeType }) {
case 200:
return response.data;
default:
return undefined;
return {};
}
})
.catch(() => {
return undefined;
return {};
});
}
});

View File

@ -398,7 +398,7 @@ export function ApiForm({
let value: any = data[key];
let field_type = fields[key]?.field_type;
if (field_type == 'file upload') {
if (field_type == 'file upload' && !!value) {
hasFiles = true;
}
@ -413,7 +413,9 @@ export function ApiForm({
}
}
dataForm.append(key, value);
if (value != undefined) {
dataForm.append(key, value);
}
});
return api({

View File

@ -28,6 +28,7 @@ import {
useSerialNumberGenerator
} from '../hooks/UseGenerator';
import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
/**
* Construct a set of fields for creating / editing a StockItem instance
@ -932,6 +933,13 @@ export function useTestResultFields({
// Field type for the "value" input
const [fieldType, setFieldType] = useState<'string' | 'choice'>('string');
const settings = useGlobalSettingsState.getState();
const includeTestStation = useMemo(
() => settings.isSet('TEST_STATION_DATA'),
[settings]
);
return useMemo(() => {
return {
stock_item: {
@ -972,8 +980,15 @@ export function useTestResultFields({
},
attachment: {},
notes: {},
started_datetime: {},
finished_datetime: {}
started_datetime: {
hidden: !includeTestStation
},
finished_datetime: {
hidden: !includeTestStation
},
test_station: {
hidden: !includeTestStation
}
};
}, [choices, fieldType, partId, itemId]);
}, [choices, fieldType, partId, itemId, includeTestStation]);
}

View File

@ -1,6 +1,7 @@
import { t } from '@lingui/macro';
import { Grid, Skeleton, Stack } from '@mantine/core';
import {
IconChecklist,
IconClipboardCheck,
IconClipboardList,
IconDots,
@ -51,6 +52,7 @@ import { useUserState } from '../../states/UserState';
import BuildAllocatedStockTable from '../../tables/build/BuildAllocatedStockTable';
import BuildLineTable from '../../tables/build/BuildLineTable';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import BuildOrderTestTable from '../../tables/build/BuildOrderTestTable';
import BuildOutputTable from '../../tables/build/BuildOutputTable';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
@ -307,6 +309,17 @@ export default function BuildDetail() {
<Skeleton />
)
},
{
name: 'test-results',
label: t`Test Results`,
icon: <IconChecklist />,
hidden: !build.part_detail?.trackable,
content: build.pk ? (
<BuildOrderTestTable buildId={build.pk} partId={build.part} />
) : (
<Skeleton />
)
},
{
name: 'test-statistics',
label: t`Test Statistics`,

View File

@ -0,0 +1,256 @@
import { t } from '@lingui/macro';
import { ActionIcon, Badge, Group, Text, Tooltip } from '@mantine/core';
import { IconCirclePlus } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../App';
import { PassFailButton } from '../../components/buttons/YesNoButton';
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
import { RenderUser } from '../../components/render/User';
import { formatDate } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { useTestResultFields } from '../../forms/StockForms';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import { LocationColumn } from '../ColumnRenderers';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard';
/**
* A table which displays all "test results" for the outputs generated by a build order.
*/
export default function BuildOrderTestTable({
buildId,
partId
}: {
buildId: number;
partId: number;
}) {
const table = useTable('build-tests');
const user = useUserState();
// Fetch the test templates required for this build order
const { data: testTemplates } = useQuery({
queryKey: ['build-test-templates', partId, buildId],
queryFn: async () => {
if (!partId) {
return [];
}
return api
.get(apiUrl(ApiEndpoints.part_test_template_list), {
params: {
part: partId,
include_inherited: true,
enabled: true,
required: true
}
})
.then((res) => res.data)
.catch((err) => []);
}
});
// Reload the table data whenever the set of templates changes
useEffect(() => {
table.refreshTable();
}, [testTemplates]);
const [selectedOutput, setSelectedOutput] = useState<number>(0);
const [selectedTemplate, setSelectedTemplate] = useState<number>(0);
const testResultFields: ApiFormFieldSet = useTestResultFields({
partId: partId,
itemId: selectedOutput
});
const createTestResult = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.stock_test_result_list),
title: t`Add Test Result`,
fields: testResultFields,
initialData: {
template: selectedTemplate,
result: true
},
onFormSuccess: () => table.refreshTable(),
successMessage: t`Test result added`
});
// Generate a table column for each test template
const testColumns: TableColumn[] = useMemo(() => {
if (!testTemplates || testTemplates.length == 0) {
return [];
}
return testTemplates.map((template: any) => {
return {
accessor: `test_${template.pk}`,
title: template.test_name,
sortable: false,
switchable: true,
render: (record: any) => {
let tests = record.tests || [];
// Find the most recent test result (highest primary key)
let test = tests
.filter((test: any) => test.template == template.pk)
.sort((a: any, b: any) => b.pk - a.pk)
.shift();
// No test result recorded
if (!test || test.result === undefined) {
return (
<Group gap="xs" wrap="nowrap" justify="space-between">
<Badge color="lightblue" variant="filled">{t`No Result`}</Badge>
<Tooltip label={t`Add Test Result`}>
<ActionIcon
size="xs"
color="green"
variant="transparent"
onClick={() => {
setSelectedOutput(record.pk);
setSelectedTemplate(template.pk);
createTestResult.open();
}}
>
<IconCirclePlus />
</ActionIcon>
</Tooltip>
</Group>
);
}
let extra: ReactNode[] = [];
if (test.value) {
extra.push(
<Text key="value" size="sm">
{t`Value`}: {test.value}
</Text>
);
}
if (test.notes) {
extra.push(
<Text key="notes" size="sm">
{t`Notes`}: {test.notes}
</Text>
);
}
if (test.date) {
extra.push(
<Text key="date" size="sm">
{t`Date`}: {formatDate(test.date)}
</Text>
);
}
if (test.user_detail) {
extra.push(<RenderUser key="user" instance={test.user_detail} />);
}
return (
<TableHoverCard
value={<PassFailButton value={test.result} />}
title={template.test_name}
extra={extra}
/>
);
}
};
});
}, [testTemplates]);
const tableColumns: TableColumn[] = useMemo(() => {
// Fixed columns
let columns: TableColumn[] = [
{
accessor: 'stock',
title: t`Build Output`,
sortable: true,
switchable: false,
render: (record: any) => {
if (record.serial) {
return `# ${record.serial}`;
} else {
let extra: ReactNode[] = [];
if (record.batch) {
extra.push(
<Text key="batch" size="sm">
{t`Batch Code`}: {record.batch}
</Text>
);
}
return (
<TableHoverCard
value={
<Text>
{t`Quantity`}: {record.quantity}
</Text>
}
title={t`Build Output`}
extra={extra}
/>
);
}
}
},
LocationColumn({
accessor: 'location_detail'
})
];
return [...columns, ...testColumns];
}, [testColumns]);
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'is_building',
label: t`In Production`,
description: t`Show build outputs currently in production`
}
];
}, []);
const tableActions = useMemo(() => {
return [];
}, []);
const rowActions = useCallback(
(record: any) => {
return [];
},
[user]
);
return (
<>
{createTestResult.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.stock_item_list)}
tableState={table}
columns={tableColumns}
props={{
params: {
part_detail: true,
location_detail: true,
tests: true,
build: buildId
},
rowActions: rowActions,
tableFilters: tableFilters,
tableActions: tableActions
}}
/>
</>
);
}

View File

@ -47,9 +47,9 @@ export default function BuildOutputTable({ build }: { build: any }) {
// Fetch the test templates associated with the partId
const { data: testTemplates } = useQuery({
queryKey: ['buildoutputtests', build.part],
queryKey: ['buildoutputtests', partId],
queryFn: async () => {
if (!partId) {
if (!partId || partId < 0) {
return [];
}
@ -322,7 +322,7 @@ export default function BuildOutputTable({ build }: { build: any }) {
}
}
];
}, [buildId, partId]);
}, [buildId, partId, testTemplates]);
return (
<>

View File

@ -24,6 +24,16 @@ test('PUI - Pages - Build Order', async ({ page }) => {
.getByRole('cell', { name: 'R38, R39, R40, R41, R42, R43' })
.waitFor();
// Check "test results"
await page.getByRole('tab', { name: 'Test Results' }).click();
await page.getByText('Quantity: 25').waitFor();
await page.getByText('Continuity Checks').waitFor();
await page
.getByRole('row', { name: 'Quantity: 16 No location set' })
.getByRole('button')
.hover();
await page.getByText('Add Test Result').waitFor();
// Click through to the "parent" build
await page.getByRole('tab', { name: 'Build Details' }).click();
await page.getByRole('link', { name: 'BO0010' }).click();