Build order improvements (#6343)

* Auto-fill project code

When creating a new child build order, copy project code from parent build

* Auto-fill project code for sales orders

* Annotate "building" quantity to BuildLine serializer

- So we know how many units are in production

* Display building quantity in build line table

* Update API version info

* Skeleton for BuildLineTable

- No content yet (needs work)

* Refactor part hovercard

* Navigate to part

* Add actions for build line table

* Display more information for "available stock" column

* More updates

* Fix "building" filter

- Rename to "in_production"

* Add filters

* Remove unused imports
This commit is contained in:
Oliver 2024-01-29 10:56:34 +11:00 committed by GitHub
parent 1272b89839
commit f6ba180cc4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 334 additions and 49 deletions

View File

@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 163 INVENTREE_API_VERSION = 164
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v164 -> 2024-01-24 : https://github.com/inventree/InvenTree/pull/6343
- Adds "building" quantity to BuildLine API serializer
v163 -> 2024-01-22 : https://github.com/inventree/InvenTree/pull/6314 v163 -> 2024-01-22 : https://github.com/inventree/InvenTree/pull/6314
- Extends API endpoint to expose auth configuration information for signin pages - Extends API endpoint to expose auth configuration information for signin pages

View File

@ -1,5 +1,7 @@
"""JSON serializers for Build API.""" """JSON serializers for Build API."""
from decimal import Decimal
from django.db import transaction from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -7,18 +9,20 @@ from django.utils.translation import gettext_lazy as _
from django.db import models from django.db import models
from django.db.models import ExpressionWrapper, F, FloatField from django.db.models import ExpressionWrapper, F, FloatField
from django.db.models import Case, Sum, When, Value from django.db.models import Case, Sum, When, Value
from django.db.models import BooleanField from django.db.models import BooleanField, Q
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from rest_framework import serializers from rest_framework import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from sql_util.utils import SubquerySum
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import UserSerializer from InvenTree.serializers import UserSerializer
import InvenTree.helpers import InvenTree.helpers
from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.status_codes import StockStatus from InvenTree.status_codes import BuildStatusGroups, StockStatus
from stock.models import generate_batch_code, StockItem, StockLocation from stock.models import generate_batch_code, StockItem, StockLocation
from stock.serializers import StockItemSerializerBrief, LocationSerializer from stock.serializers import StockItemSerializerBrief, LocationSerializer
@ -1055,6 +1059,7 @@ class BuildLineSerializer(InvenTreeModelSerializer):
# Annotated fields # Annotated fields
'allocated', 'allocated',
'in_production',
'on_order', 'on_order',
'available_stock', 'available_stock',
'available_substitute_stock', 'available_substitute_stock',
@ -1078,6 +1083,7 @@ class BuildLineSerializer(InvenTreeModelSerializer):
# Annotated (calculated) fields # Annotated (calculated) fields
allocated = serializers.FloatField(read_only=True) allocated = serializers.FloatField(read_only=True)
on_order = serializers.FloatField(read_only=True) on_order = serializers.FloatField(read_only=True)
in_production = serializers.FloatField(read_only=True)
available_stock = serializers.FloatField(read_only=True) available_stock = serializers.FloatField(read_only=True)
available_substitute_stock = serializers.FloatField(read_only=True) available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True) available_variant_stock = serializers.FloatField(read_only=True)
@ -1090,6 +1096,7 @@ class BuildLineSerializer(InvenTreeModelSerializer):
- allocated: Total stock quantity allocated against this build line - allocated: Total stock quantity allocated against this build line
- available: Total stock available for allocation against this build line - available: Total stock available for allocation against this build line
- on_order: Total stock on order for this build line - on_order: Total stock on order for this build line
- in_production: Total stock currently in production for this build line
""" """
queryset = queryset.select_related( queryset = queryset.select_related(
'build', 'bom_item', 'build', 'bom_item',
@ -1126,6 +1133,11 @@ class BuildLineSerializer(InvenTreeModelSerializer):
ref = 'bom_item__sub_part__' ref = 'bom_item__sub_part__'
# Annotate the "in_production" quantity
queryset = queryset.annotate(
in_production=part.filters.annotate_in_production_quantity(reference=ref)
)
# Annotate the "on_order" quantity # Annotate the "on_order" quantity
# Difficulty: Medium # Difficulty: Medium
queryset = queryset.annotate( queryset = queryset.annotate(

View File

@ -373,7 +373,11 @@ onPanelLoad('allocate', function() {
loadBuildLineTable( loadBuildLineTable(
"#build-lines-table", "#build-lines-table",
{{ build.pk }}, {{ build.pk }},
{} {
{% if build.project_code %}
project_code: {{ build.project_code.pk }},
{% endif %}
}
); );
}); });

View File

@ -243,6 +243,9 @@
order: {{ order.pk }}, order: {{ order.pk }},
reference: '{{ order.reference }}', reference: '{{ order.reference }}',
status: {{ order.status }}, status: {{ order.status }},
{% if order.project_code %}
project_code: {{ order.project_code.pk }},
{% endif %}
open: {% js_bool order.is_open %}, open: {% js_bool order.is_open %},
{% if roles.sales_order.change %} {% if roles.sales_order.change %}
{% settings_value "SALESORDER_EDIT_COMPLETED_ORDERS" as allow_edit %} {% settings_value "SALESORDER_EDIT_COMPLETED_ORDERS" as allow_edit %}

View File

@ -47,6 +47,25 @@ from InvenTree.status_codes import (
) )
def annotate_in_production_quantity(reference=''):
"""Annotate the 'in production' quantity for each part in a queryset.
Sum the 'quantity' field for all stock items which are 'in production' for each part.
Arguments:
reference: Reference to the part from the current queryset (default = '')
"""
building_filter = Q(
is_building=True, build__status__in=BuildStatusGroups.ACTIVE_CODES
)
return Coalesce(
SubquerySum(f'{reference}stock_items__quantity', filter=building_filter),
Decimal(0),
output_field=DecimalField(),
)
def annotate_on_order_quantity(reference: str = ''): def annotate_on_order_quantity(reference: str = ''):
"""Annotate the 'on order' quantity for each part in a queryset. """Annotate the 'on order' quantity for each part in a queryset.

View File

@ -173,6 +173,11 @@ function newBuildOrder(options={}) {
fields.sales_order.value = options.sales_order; fields.sales_order.value = options.sales_order;
} }
// Specify a project code
if (options.project_code) {
fields.project_code.value = options.project_code;
}
if (options.data) { if (options.data) {
delete options.data.pk; delete options.data.pk;
} }
@ -2553,6 +2558,7 @@ function loadBuildLineTable(table, build_id, options={}) {
sortable: true, sortable: true,
formatter: function(value, row) { formatter: function(value, row) {
var url = `/part/${row.part_detail.pk}/?display=part-stock`; var url = `/part/${row.part_detail.pk}/?display=part-stock`;
// Calculate the "available" quantity // Calculate the "available" quantity
let available = row.available_stock + row.available_substitute_stock; let available = row.available_stock + row.available_substitute_stock;
@ -2603,6 +2609,10 @@ function loadBuildLineTable(table, build_id, options={}) {
icons += makeIconBadge('fa-shopping-cart', `{% trans "On Order" %}: ${formatDecimal(row.on_order)}`); icons += makeIconBadge('fa-shopping-cart', `{% trans "On Order" %}: ${formatDecimal(row.on_order)}`);
} }
if (row.in_production && row.in_production > 0) {
icons += makeIconBadge('fa-tools icon-blue', `{% trans "In Production" %}: ${formatDecimal(row.in_production)}`);
}
return renderLink(text, url) + icons; return renderLink(text, url) + icons;
} }
}, },
@ -2695,6 +2705,7 @@ function loadBuildLineTable(table, build_id, options={}) {
part: row.part_detail.pk, part: row.part_detail.pk,
parent: build_id, parent: build_id,
quantity: Math.max(row.quantity - row.allocated, 0), quantity: Math.max(row.quantity - row.allocated, 0),
...options,
}); });
}); });

View File

@ -2174,7 +2174,8 @@ function loadSalesOrderLineItemTable(table, options={}) {
part: pk, part: pk,
sales_order: options.order, sales_order: options.order,
quantity: quantity, quantity: quantity,
success: reloadTable success: reloadTable,
...options
}); });
}); });

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Anchor } from '@mantine/core'; import { Anchor, Skeleton } from '@mantine/core';
import { Group } from '@mantine/core'; import { Group } from '@mantine/core';
import { Text } from '@mantine/core'; import { Text } from '@mantine/core';
import { ReactNode, useMemo } from 'react'; import { ReactNode, useMemo } from 'react';
@ -74,3 +74,16 @@ export function ThumbnailHoverCard({
return <div>{card}</div>; 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

@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom';
import { ApiPaths } from '../../../enums/ApiEndpoints'; import { ApiPaths } from '../../../enums/ApiEndpoints';
import { useTable } from '../../../hooks/UseTable'; import { useTable } from '../../../hooks/UseTable';
import { apiUrl } from '../../../states/ApiState'; import { apiUrl } from '../../../states/ApiState';
import { ThumbnailHoverCard } from '../../images/Thumbnail'; import { PartHoverCard } from '../../images/Thumbnail';
import { TableColumn } from '../Column'; import { TableColumn } from '../Column';
import { TableFilter } from '../Filter'; import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
@ -31,37 +31,13 @@ export function UsedInTable({
title: t`Assembled Part`, title: t`Assembled Part`,
switchable: false, switchable: false,
sortable: true, sortable: true,
render: (record: any) => { render: (record: any) => <PartHoverCard part={record.part_detail} />
let part = record.part_detail;
return (
part && (
<ThumbnailHoverCard
src={part.thumbnail || part.image}
text={part.full_name}
alt={part.description}
link=""
/>
)
);
}
}, },
{ {
accessor: 'sub_part', accessor: 'sub_part',
title: t`Required Part`, title: t`Required Part`,
sortable: true, sortable: true,
render: (record: any) => { render: (record: any) => <PartHoverCard part={record.sub_part_detail} />
let part = record.sub_part_detail;
return (
part && (
<ThumbnailHoverCard
src={part.thumbnail || part.image}
text={part.full_name}
alt={part.description}
link=""
/>
)
);
}
}, },
{ {
accessor: 'quantity', accessor: 'quantity',

View File

@ -0,0 +1,242 @@
import { t } from '@lingui/macro';
import { Group, Text } from '@mantine/core';
import {
IconArrowRight,
IconShoppingCart,
IconTool
} from '@tabler/icons-react';
import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { ApiPaths } from '../../../enums/ApiEndpoints';
import { useTable } from '../../../hooks/UseTable';
import { apiUrl } from '../../../states/ApiState';
import { useUserState } from '../../../states/UserState';
import { PartHoverCard } from '../../images/Thumbnail';
import { ProgressBar } from '../../items/ProgressBar';
import { TableColumn } from '../Column';
import { BooleanColumn } from '../ColumnRenderers';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard';
export default function BuildLineTable({ params = {} }: { params?: any }) {
const table = useTable('buildline');
const user = useUserState();
const navigate = useNavigate();
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'allocated',
label: t`Allocated`,
description: t`Show allocated lines`
},
{
name: 'available',
label: t`Available`,
description: t`Show lines with available stock`
},
{
name: 'consumable',
label: t`Consumable`,
description: t`Show consumable lines`
},
{
name: 'optional',
label: t`Optional`,
description: t`Show optional lines`
}
];
}, []);
const renderAvailableColumn = useCallback((record: any) => {
let bom_item = record?.bom_item_detail ?? {};
let extra: any[] = [];
let available = record?.available_stock;
// Account for substitute stock
if (record.available_substitute_stock > 0) {
available += record.available_substitute_stock;
extra.push(
<Text key="substitite" size="sm">
{t`Includes substitute stock`}
</Text>
);
}
// Account for variant stock
if (bom_item.allow_variants && record.available_variant_stock > 0) {
available += record.available_variant_stock;
extra.push(
<Text key="variant" size="sm">
{t`Includes variant stock`}
</Text>
);
}
// Account for in-production stock
if (record.in_production > 0) {
extra.push(
<Text key="production" size="sm">
{t`In production`}: {record.in_production}
</Text>
);
}
// Account for stock on order
if (record.on_order > 0) {
extra.push(
<Text key="on-order" size="sm">
{t`On order`}: {record.on_order}
</Text>
);
}
return (
<TableHoverCard
value={
available > 0 ? (
available
) : (
<Text color="red" italic>{t`No stock available`}</Text>
)
}
title={t`Available Stock`}
extra={extra}
/>
);
}, []);
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'bom_item',
title: t`Part`,
sortable: true,
switchable: false,
render: (record: any) => <PartHoverCard part={record.part_detail} />
},
{
accessor: 'reference',
title: t`Reference`,
render: (record: any) => record.bom_item_detail.reference
},
BooleanColumn({
accessor: 'bom_item_detail.consumable',
title: t`Consumable`
}),
BooleanColumn({
accessor: 'bom_item_detail.optional',
title: t`Optional`
}),
{
accessor: 'unit_quantity',
title: t`Unit Quantity`,
sortable: true,
render: (record: any) => {
return (
<Group position="apart">
<Text>{record.bom_item_detail?.quantity}</Text>
{record?.part_detail?.units && (
<Text size="xs">[{record.part_detail.units}]</Text>
)}
</Group>
);
}
},
{
accessor: 'quantity',
title: t`Required Quantity`,
sortable: true,
render: (record: any) => {
return (
<Group position="apart">
<Text>{record.quantity}</Text>
{record?.part_detail?.units && (
<Text size="xs">[{record.part_detail.units}]</Text>
)}
</Group>
);
}
},
{
accessor: 'available_stock',
title: t`Available`,
sortable: true,
switchable: false,
render: renderAvailableColumn
},
{
accessor: 'allocated',
title: t`Allocated`,
switchable: false,
render: (record: any) => {
return record?.bom_item_detail?.consumable ? (
<Text italic>{t`Consumable item`}</Text>
) : (
<ProgressBar
progressLabel={true}
value={record.allocated}
maximum={record.quantity}
/>
);
}
}
];
}, []);
const rowActions = useCallback(
(record: any) => {
let part = record.part_detail;
// Consumable items have no appropriate actions
if (record?.bom_item_detail?.consumable) {
return [];
}
return [
{
icon: <IconArrowRight />,
title: t`Allocate Stock`,
hidden: record.allocated >= record.quantity,
color: 'green'
},
{
icon: <IconShoppingCart />,
title: t`Order Stock`,
hidden: !part?.purchaseable,
color: 'blue'
},
{
icon: <IconTool />,
title: t`Build Stock`,
hidden: !part?.assembly,
color: 'blue'
}
];
},
[user]
);
return (
<InvenTreeTable
url={apiUrl(ApiPaths.build_line_list)}
tableState={table}
columns={tableColumns}
props={{
params: {
...params,
part_detail: true
},
tableFilters: tableFilters,
rowActions: rowActions,
onRowClick: (row: any) => {
if (row?.part_detail?.pk) {
navigate(`/part/${row.part_detail.pk}`);
}
}
}}
/>
);
}

View File

@ -7,7 +7,7 @@ import { ApiPaths } from '../../../enums/ApiEndpoints';
import { ModelType } from '../../../enums/ModelType'; import { ModelType } from '../../../enums/ModelType';
import { useTable } from '../../../hooks/UseTable'; import { useTable } from '../../../hooks/UseTable';
import { apiUrl } from '../../../states/ApiState'; import { apiUrl } from '../../../states/ApiState';
import { ThumbnailHoverCard } from '../../images/Thumbnail'; import { PartHoverCard } from '../../images/Thumbnail';
import { ProgressBar } from '../../items/ProgressBar'; import { ProgressBar } from '../../items/ProgressBar';
import { RenderUser } from '../../render/User'; import { RenderUser } from '../../render/User';
import { TableColumn } from '../Column'; import { TableColumn } from '../Column';
@ -37,19 +37,7 @@ function buildOrderTableColumns(): TableColumn[] {
sortable: true, sortable: true,
switchable: false, switchable: false,
title: t`Part`, title: t`Part`,
render: (record: any) => { render: (record: any) => <PartHoverCard part={record.part_detail} />
let part = record.part_detail;
return (
part && (
<ThumbnailHoverCard
src={part.thumbnail || part.image}
text={part.full_name}
alt={part.description}
link=""
/>
)
);
}
}, },
{ {
accessor: 'title', accessor: 'title',

View File

@ -47,6 +47,7 @@ export enum ApiPaths {
// Build order URLs // Build order URLs
build_order_list = 'api-build-list', build_order_list = 'api-build-list',
build_order_attachment_list = 'api-build-attachment-list', build_order_attachment_list = 'api-build-attachment-list',
build_line_list = 'api-build-line-list',
// BOM URLs // BOM URLs
bom_list = 'api-bom-list', bom_list = 'api-bom-list',

View File

@ -29,6 +29,7 @@ import {
import { PageDetail } from '../../components/nav/PageDetail'; import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StatusRenderer } from '../../components/render/StatusRenderer'; import { StatusRenderer } from '../../components/render/StatusRenderer';
import BuildLineTable from '../../components/tables/build/BuildLineTable';
import { BuildOrderTable } from '../../components/tables/build/BuildOrderTable'; import { BuildOrderTable } from '../../components/tables/build/BuildOrderTable';
import { AttachmentTable } from '../../components/tables/general/AttachmentTable'; import { AttachmentTable } from '../../components/tables/general/AttachmentTable';
import { StockItemTable } from '../../components/tables/stock/StockItemTable'; import { StockItemTable } from '../../components/tables/stock/StockItemTable';
@ -104,8 +105,17 @@ export default function BuildDetail() {
{ {
name: 'allocate-stock', name: 'allocate-stock',
label: t`Allocate Stock`, label: t`Allocate Stock`,
icon: <IconListCheck /> icon: <IconListCheck />,
// TODO: Hide if build is complete content: build?.pk ? (
<BuildLineTable
params={{
build: id,
tracked: false
}}
/>
) : (
<Skeleton />
)
}, },
{ {
name: 'incomplete-outputs', name: 'incomplete-outputs',

View File

@ -151,6 +151,8 @@ export function apiEndpoint(path: ApiPaths): string {
return 'build/'; return 'build/';
case ApiPaths.build_order_attachment_list: case ApiPaths.build_order_attachment_list:
return 'build/attachment/'; return 'build/attachment/';
case ApiPaths.build_line_list:
return 'build/line/';
case ApiPaths.bom_list: case ApiPaths.bom_list:
return 'bom/'; return 'bom/';
case ApiPaths.part_list: case ApiPaths.part_list: