Merge branch 'master' into optimize_test_statistic_queries

This commit is contained in:
Oliver
2024-08-02 10:59:44 +10:00
committed by GitHub
30 changed files with 817 additions and 120 deletions

View File

@ -1,13 +1,16 @@
# Runs on releases
name: Publish release notes
name: Publish release
on:
release:
types: [published]
permissions:
contents: read
jobs:
stable:
runs-on: ubuntu-latest
name: Write release to stable branch
permissions:
contents: write
pull-requests: write
@ -28,11 +31,13 @@ jobs:
branch: stable
force: true
publish-build:
build:
runs-on: ubuntu-latest
name: Build and attest frontend
permissions:
id-token: write
contents: write
pull-requests: write
attestations: write
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # pin@v4.1.7
- name: Environment Setup
@ -43,6 +48,11 @@ jobs:
run: cd src/frontend && yarn install
- name: Build frontend
run: cd src/frontend && npm run compile && npm run build
- name: Create SBOM for frontend
uses: anchore/sbom-action@v0
with:
artifact-name: frontend-build.spdx
path: src/frontend
- name: Write version file - SHA
run: cd src/backend/InvenTree/web/static/web/.vite && echo "$GITHUB_SHA" > sha.txt
- name: Write version file - TAG
@ -51,10 +61,25 @@ jobs:
run: |
cd src/backend/InvenTree/web/static/web
zip -r ../frontend-build.zip * .vite
- uses: svenstaro/upload-release-action@04733e069f2d7f7f0b4aebc4fbdbce8613b03ccd # pin@2.9.0
- name: Attest Build Provenance
id: attest
uses: actions/attest-build-provenance@v1
with:
subject-path: "${{ github.workspace }}/src/backend/InvenTree/web/static/frontend-build.zip"
- name: Upload frontend
uses: svenstaro/upload-release-action@04733e069f2d7f7f0b4aebc4fbdbce8613b03ccd # pin@2.9.0
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: src/backend/InvenTree/web/static/frontend-build.zip
asset_name: frontend-build.zip
tag: ${{ github.ref }}
overwrite: true
- name: Upload Attestation
uses: svenstaro/upload-release-action@04733e069f2d7f7f0b4aebc4fbdbce8613b03ccd # pin@2.9.0
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
asset_name: frontend-build.intoto.jsonl
file: ${{ steps.attest.outputs.bundle-path}}
tag: ${{ github.ref }}
overwrite: true

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

@ -87,4 +87,4 @@ known_django="django"
sections=["FUTURE","STDLIB","DJANGO","THIRDPARTY","FIRSTPARTY","LOCALFOLDER"]
[tool.codespell]
ignore-words-list = ["assertIn","SME"]
ignore-words-list = ["assertIn","SME","intoto"]

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

@ -296,15 +296,12 @@ def annotate_default_location(reference=''):
rght__gt=OuterRef(f'{reference}rght'),
level__lte=OuterRef(f'{reference}level'),
parent__isnull=False,
)
default_location__isnull=False,
).order_by('-level')
return Coalesce(
F(f'{reference}default_location'),
Subquery(
subquery.order_by('-level')
.filter(default_location__isnull=False)
.values('default_location')
),
Subquery(subquery.values('default_location')[:1]),
Value(None),
output_field=IntegerField(),
)

View File

@ -505,6 +505,82 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
self.assertEqual(item['parent'], parent)
self.assertEqual(item['subcategories'], subcategories)
def test_part_category_default_location(self):
"""Test default location propagation through location trees."""
"""Making a tree structure like this:
main
loc 2
sub1
sub2
loc 3
sub3
loc 4
sub4
sub5
Expected behaviour:
main parent loc: Out of test scope. Parent category data not controlled by the test
sub1 parent loc: loc 2
sub2 parent loc: loc 2
sub3 parent loc: loc 3
sub4 parent loc: loc 4
sub5 parent loc: loc 3
"""
main = PartCategory.objects.create(
name='main',
parent=PartCategory.objects.first(),
default_location=StockLocation.objects.get(id=2),
)
sub1 = PartCategory.objects.create(name='sub1', parent=main)
sub2 = PartCategory.objects.create(
name='sub2', parent=sub1, default_location=StockLocation.objects.get(id=3)
)
sub3 = PartCategory.objects.create(
name='sub3', parent=sub2, default_location=StockLocation.objects.get(id=4)
)
sub4 = PartCategory.objects.create(name='sub4', parent=sub3)
sub5 = PartCategory.objects.create(name='sub5', parent=sub2)
part = Part.objects.create(name='test', category=sub4)
PartCategory.objects.rebuild()
# This query will trigger an internal server error if annotation results are not limited to 1
url = reverse('api-part-list')
response = self.get(url, expected_code=200)
# sub1, expect main to be propagated
url = reverse('api-part-category-detail', kwargs={'pk': sub1.pk})
response = self.get(url, expected_code=200)
self.assertEqual(
response.data['parent_default_location'], main.default_location.pk
)
# sub2, expect main to be propagated
url = reverse('api-part-category-detail', kwargs={'pk': sub2.pk})
response = self.get(url, expected_code=200)
self.assertEqual(
response.data['parent_default_location'], main.default_location.pk
)
# sub3, expect sub2 to be propagated
url = reverse('api-part-category-detail', kwargs={'pk': sub3.pk})
response = self.get(url, expected_code=200)
self.assertEqual(
response.data['parent_default_location'], sub2.default_location.pk
)
# sub4, expect sub3 to be propagated
url = reverse('api-part-category-detail', kwargs={'pk': sub4.pk})
response = self.get(url, expected_code=200)
self.assertEqual(
response.data['parent_default_location'], sub3.default_location.pk
)
# sub5, expect sub2 to be propagated
url = reverse('api-part-category-detail', kwargs={'pk': sub5.pk})
response = self.get(url, expected_code=200)
self.assertEqual(
response.data['parent_default_location'], sub2.default_location.pk
)
class PartOptionsAPITest(InvenTreeAPITestCase):
"""Tests for the various OPTIONS endpoints in the /part/ API.

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

@ -50,49 +50,3 @@ export function Thumbnail({
</Group>
);
}
export function ThumbnailHoverCard({
src,
text,
link = '',
alt = t`Thumbnail`,
size = 20
}: {
src: string;
text: string;
link?: string;
alt?: string;
size?: number;
}) {
const card = useMemo(() => {
return (
<Group justify="left" gap={10} wrap="nowrap">
<Thumbnail src={src} alt={alt} size={size} />
<Text>{text}</Text>
</Group>
);
}, [src, text, alt, size]);
if (link) {
return (
<Anchor href={link} style={{ textDecoration: 'none' }}>
{card}
</Anchor>
);
}
return <div>{card}</div>;
}
export function PartHoverCard({ part }: { part: any }) {
return part ? (
<ThumbnailHoverCard
src={part.thumbnail || part.image}
text={part.full_name}
alt={part.description}
link=""
/>
) : (
<Skeleton />
);
}

View File

@ -47,6 +47,43 @@ export function useSalesOrderFields(): ApiFormFieldSet {
}, []);
}
export function useSalesOrderLineItemFields({
customerId,
orderId,
create
}: {
customerId?: number;
orderId?: number;
create?: boolean;
}): ApiFormFieldSet {
const fields = useMemo(() => {
return {
order: {
filters: {
customer_detail: true
},
disabled: true,
value: create ? orderId : undefined
},
part: {
filters: {
active: true,
salable: true
}
},
reference: {},
quantity: {},
sale_price: {},
sale_price_currency: {},
target_date: {},
notes: {},
link: {}
};
}, []);
return fields;
}
export function useReturnOrderFields(): ApiFormFieldSet {
return useMemo(() => {
return {

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

@ -47,6 +47,7 @@ import { useInstance } from '../../hooks/UseInstance';
import { useUserState } from '../../states/UserState';
import { BuildOrderTable } from '../../tables/build/BuildOrderTable';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import SalesOrderLineItemTable from '../../tables/sales/SalesOrderLineItemTable';
/**
* Detail page for a single SalesOrder
@ -249,7 +250,12 @@ export default function SalesOrderDetail() {
name: 'line-items',
label: t`Line Items`,
icon: <IconList />,
content: <PlaceholderPanel />
content: (
<SalesOrderLineItemTable
orderId={order.pk}
customerId={order.customer}
/>
)
},
{
name: 'pending-shipments',

View File

@ -281,7 +281,7 @@ export function InvenTreeTable<T = any>({
// If row actions are available, add a column for them
if (tableProps.rowActions) {
cols.push({
accessor: 'actions',
accessor: '--actions--',
title: ' ',
hidden: false,
switchable: false,
@ -540,19 +540,29 @@ export function InvenTreeTable<T = any>({
}
});
// Callback when a row is clicked
const handleRowClick = useCallback(
// Callback when a cell is clicked
const handleCellClick = useCallback(
({
event,
record,
index
index,
column,
columnIndex
}: {
event: React.MouseEvent;
record: any;
index: number;
column: any;
columnIndex: number;
}) => {
if (props.onRowClick) {
// If a custom row click handler is provided, use that
// Ignore any click on the 'actions' column
if (column.accessor == '--actions--') {
return;
}
if (props.onCellClick) {
props.onCellClick({ event, record, index, column, columnIndex });
} else if (props.onRowClick) {
props.onRowClick(record, index, event);
} else if (tableProps.modelType) {
const accessor = tableProps.modelField ?? 'pk';
@ -566,7 +576,7 @@ export function InvenTreeTable<T = any>({
}
}
},
[props.onRowClick]
[props.onRowClick, props.onCellClick]
);
return (
@ -705,8 +715,7 @@ export function InvenTreeTable<T = any>({
noRecordsText={missingRecordsText}
records={tableState.records}
columns={dataColumns}
onRowClick={handleRowClick}
onCellClick={tableProps.onCellClick}
onCellClick={handleCellClick}
defaultColumnProps={{
noWrap: true,
textAlign: 'left',

View File

@ -2,14 +2,13 @@ import { t } from '@lingui/macro';
import { Group, Text } from '@mantine/core';
import { useMemo } from 'react';
import { PartHoverCard } from '../../components/images/Thumbnail';
import { formatDecimal } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { TableColumn } from '../Column';
import { ReferenceColumn } from '../ColumnRenderers';
import { PartColumn, ReferenceColumn } from '../ColumnRenderers';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
@ -31,12 +30,14 @@ export function UsedInTable({
accessor: 'part',
switchable: false,
sortable: true,
render: (record: any) => <PartHoverCard part={record.part_detail} />
title: t`Assembly`,
render: (record: any) => PartColumn(record.part_detail)
},
{
accessor: 'sub_part',
sortable: true,
render: (record: any) => <PartHoverCard part={record.sub_part_detail} />
title: t`Component`,
render: (record: any) => PartColumn(record.sub_part_detail)
},
{
accessor: 'quantity',

View File

@ -7,7 +7,6 @@ import {
} from '@tabler/icons-react';
import { useCallback, useMemo } from 'react';
import { PartHoverCard } from '../../components/images/Thumbnail';
import { ProgressBar } from '../../components/items/ProgressBar';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
@ -15,7 +14,7 @@ import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import { BooleanColumn } from '../ColumnRenderers';
import { BooleanColumn, PartColumn } from '../ColumnRenderers';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard';
@ -131,7 +130,7 @@ export default function BuildLineTable({ params = {} }: { params?: any }) {
ordering: 'part',
sortable: true,
switchable: false,
render: (record: any) => <PartHoverCard part={record.part_detail} />
render: (record: any) => PartColumn(record.part_detail)
},
{
accessor: 'bom_item_detail.reference',

View File

@ -2,7 +2,6 @@ import { t } from '@lingui/macro';
import { useMemo } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { PartHoverCard } from '../../components/images/Thumbnail';
import { ProgressBar } from '../../components/items/ProgressBar';
import { RenderUser } from '../../components/render/User';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
@ -22,6 +21,7 @@ import { TableColumn } from '../Column';
import {
CreationDateColumn,
DateColumn,
PartColumn,
ProjectCodeColumn,
ReferenceColumn,
ResponsibleColumn,
@ -41,7 +41,7 @@ function buildOrderTableColumns(): TableColumn[] {
accessor: 'part',
sortable: true,
switchable: false,
render: (record: any) => <PartHoverCard part={record.part_detail} />
render: (record: any) => PartColumn(record.part_detail)
},
{
accessor: 'title',

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

@ -5,7 +5,6 @@ import { useCallback, useMemo, useState } from 'react';
import { ActionButton } from '../../components/buttons/ActionButton';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { Thumbnail } from '../../components/images/Thumbnail';
import ImporterDrawer from '../../components/importer/ImporterDrawer';
import { ProgressBar } from '../../components/items/ProgressBar';
import { RenderStockLocation } from '../../components/render/Stock';
@ -30,6 +29,7 @@ import {
CurrencyColumn,
LinkColumn,
NoteColumn,
PartColumn,
ReferenceColumn,
TargetDateColumn,
TotalPriceColumn
@ -124,14 +124,7 @@ export function PurchaseOrderLineItemTable({
title: t`Internal Part`,
sortable: true,
switchable: false,
render: (record: any) => {
return (
<Thumbnail
text={record?.part_detail?.name}
src={record?.part_detail?.thumbnail ?? record?.part_detail?.image}
/>
);
}
render: (record: any) => PartColumn(record.part_detail)
},
{
accessor: 'description',

View File

@ -0,0 +1,272 @@
import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import { IconSquareArrowRight } from '@tabler/icons-react';
import { ReactNode, useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ProgressBar } from '../../components/items/ProgressBar';
import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useSalesOrderLineItemFields } from '../../forms/SalesOrderForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import { DateColumn, LinkColumn, PartColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
import {
RowDeleteAction,
RowDuplicateAction,
RowEditAction
} from '../RowActions';
import { TableHoverCard } from '../TableHoverCard';
export default function SalesOrderLineItemTable({
orderId,
customerId
}: {
orderId: number;
customerId: number;
}) {
const user = useUserState();
const table = useTable('sales-order-line-item');
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'part',
sortable: true,
switchable: false,
render: (record: any) => PartColumn(record?.part_detail)
},
{
accessor: 'part_detail.IPN',
title: t`IPN`,
switchable: true
},
{
accessor: 'part_detail.description',
title: t`Description`,
sortable: false,
switchable: true
},
{
accessor: 'reference',
sortable: false,
switchable: true
},
{
accessor: 'quantity',
sortable: true
},
{
accessor: 'sale_price',
render: (record: any) =>
formatCurrency(record.sale_price, {
currency: record.sale_price_currency
})
},
{
accessor: 'total_price',
title: t`Total Price`,
render: (record: any) =>
formatCurrency(record.sale_price, {
currency: record.sale_price_currency,
multiplier: record.quantity
})
},
DateColumn({
accessor: 'target_date',
sortable: true,
title: t`Target Date`
}),
{
accessor: 'stock',
title: t`Available Stock`,
render: (record: any) => {
let part_stock = record?.available_stock ?? 0;
let variant_stock = record?.available_variant_stock ?? 0;
let available = part_stock + variant_stock;
let required = Math.max(
record.quantity - record.allocated - record.shipped,
0
);
let color: string | undefined = undefined;
let text: string = `${available}`;
let extra: ReactNode[] = [];
if (available <= 0) {
color = 'red';
text = t`No stock available`;
} else if (available < required) {
color = 'orange';
}
if (variant_stock > 0) {
extra.push(<Text size="sm">{t`Includes variant stock`}</Text>);
}
return (
<TableHoverCard
value={<Text color={color}>{text}</Text>}
extra={extra}
title={t`Stock Information`}
/>
);
}
},
{
accessor: 'allocated',
render: (record: any) => (
<ProgressBar
progressLabel={true}
value={record.allocated}
maximum={record.quantity}
/>
)
},
{
accessor: 'shipped',
render: (record: any) => (
<ProgressBar
progressLabel={true}
value={record.shipped}
maximum={record.quantity}
/>
)
},
{
accessor: 'notes'
},
LinkColumn({
accessor: 'link'
})
];
}, []);
const [selectedLine, setSelectedLine] = useState<number>(0);
const [initialData, setInitialData] = useState({});
const createLineFields = useSalesOrderLineItemFields({
orderId: orderId,
customerId: customerId,
create: true
});
const newLine = useCreateApiFormModal({
url: ApiEndpoints.sales_order_line_list,
title: t`Add Line Item`,
fields: createLineFields,
initialData: initialData,
table: table
});
const editLineFields = useSalesOrderLineItemFields({
orderId: orderId,
customerId: customerId,
create: false
});
const editLine = useEditApiFormModal({
url: ApiEndpoints.sales_order_line_list,
pk: selectedLine,
title: t`Edit Line Item`,
fields: editLineFields,
table: table
});
const deleteLine = useDeleteApiFormModal({
url: ApiEndpoints.sales_order_line_list,
pk: selectedLine,
title: t`Delete Line Item`,
table: table
});
const tableActions = useMemo(() => {
return [
<AddItemButton
tooltip={t`Add line item`}
onClick={() => {
setInitialData({
order: orderId
});
newLine.open();
}}
hidden={!user.hasAddRole(UserRoles.sales_order)}
/>
];
}, [user]);
const rowActions = useCallback(
(record: any) => {
const allocated = (record?.allocated ?? 0) > (record?.quantity ?? 0);
return [
{
hidden: allocated || !user.hasChangeRole(UserRoles.sales_order),
title: t`Allocate stock`,
icon: <IconSquareArrowRight />,
color: 'green'
},
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.sales_order),
onClick: () => {
setSelectedLine(record.pk);
editLine.open();
}
}),
RowDuplicateAction({
hidden: !user.hasAddRole(UserRoles.sales_order),
onClick: () => {
setInitialData(record);
newLine.open();
}
}),
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.sales_order),
onClick: () => {
setSelectedLine(record.pk);
deleteLine.open();
}
})
];
},
[user]
);
return (
<>
{editLine.modal}
{deleteLine.modal}
{newLine.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.sales_order_line_list)}
tableState={table}
columns={tableColumns}
props={{
enableSelection: true,
enableDownload: true,
params: {
order: orderId,
part_detail: true
},
rowActions: rowActions,
tableActions: tableActions,
modelType: ModelType.part,
modelField: 'part'
}}
/>
</>
);
}

View File

@ -11,6 +11,7 @@ import {
TemplateEditor
} from '../../components/editors/TemplateEditor';
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
import { AttachmentLink } from '../../components/items/AttachmentLink';
import { DetailDrawer } from '../../components/nav/DetailDrawer';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
@ -134,7 +135,11 @@ export function TemplateTable({
sortable: false,
switchable: true,
render: (record: any) => {
return record.template?.split('/')?.pop() ?? '-';
if (!record.template) {
return '-';
}
return <AttachmentLink attachment={record.template} />;
}
},
{

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