mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
1272b89839
commit
f6ba180cc4
@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# 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."""
|
||||
|
||||
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
|
||||
- Extends API endpoint to expose auth configuration information for signin pages
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
"""JSON serializers for Build API."""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
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.models import ExpressionWrapper, F, FloatField
|
||||
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 rest_framework import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from sql_util.utils import SubquerySum
|
||||
|
||||
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
|
||||
from InvenTree.serializers import UserSerializer
|
||||
|
||||
import InvenTree.helpers
|
||||
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.serializers import StockItemSerializerBrief, LocationSerializer
|
||||
@ -1055,6 +1059,7 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
|
||||
# Annotated fields
|
||||
'allocated',
|
||||
'in_production',
|
||||
'on_order',
|
||||
'available_stock',
|
||||
'available_substitute_stock',
|
||||
@ -1078,6 +1083,7 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
# Annotated (calculated) fields
|
||||
allocated = 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_substitute_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
|
||||
- available: Total stock available for allocation against 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(
|
||||
'build', 'bom_item',
|
||||
@ -1126,6 +1133,11 @@ class BuildLineSerializer(InvenTreeModelSerializer):
|
||||
|
||||
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
|
||||
# Difficulty: Medium
|
||||
queryset = queryset.annotate(
|
||||
|
@ -373,7 +373,11 @@ onPanelLoad('allocate', function() {
|
||||
loadBuildLineTable(
|
||||
"#build-lines-table",
|
||||
{{ build.pk }},
|
||||
{}
|
||||
{
|
||||
{% if build.project_code %}
|
||||
project_code: {{ build.project_code.pk }},
|
||||
{% endif %}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -243,6 +243,9 @@
|
||||
order: {{ order.pk }},
|
||||
reference: '{{ order.reference }}',
|
||||
status: {{ order.status }},
|
||||
{% if order.project_code %}
|
||||
project_code: {{ order.project_code.pk }},
|
||||
{% endif %}
|
||||
open: {% js_bool order.is_open %},
|
||||
{% if roles.sales_order.change %}
|
||||
{% settings_value "SALESORDER_EDIT_COMPLETED_ORDERS" as allow_edit %}
|
||||
|
@ -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 = ''):
|
||||
"""Annotate the 'on order' quantity for each part in a queryset.
|
||||
|
||||
|
@ -173,6 +173,11 @@ function newBuildOrder(options={}) {
|
||||
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) {
|
||||
delete options.data.pk;
|
||||
}
|
||||
@ -2553,6 +2558,7 @@ function loadBuildLineTable(table, build_id, options={}) {
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
var url = `/part/${row.part_detail.pk}/?display=part-stock`;
|
||||
|
||||
// Calculate the "available" quantity
|
||||
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)}`);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
@ -2695,6 +2705,7 @@ function loadBuildLineTable(table, build_id, options={}) {
|
||||
part: row.part_detail.pk,
|
||||
parent: build_id,
|
||||
quantity: Math.max(row.quantity - row.allocated, 0),
|
||||
...options,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -2174,7 +2174,8 @@ function loadSalesOrderLineItemTable(table, options={}) {
|
||||
part: pk,
|
||||
sales_order: options.order,
|
||||
quantity: quantity,
|
||||
success: reloadTable
|
||||
success: reloadTable,
|
||||
...options
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Anchor } from '@mantine/core';
|
||||
import { Anchor, Skeleton } from '@mantine/core';
|
||||
import { Group } from '@mantine/core';
|
||||
import { Text } from '@mantine/core';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
@ -74,3 +74,16 @@ export function ThumbnailHoverCard({
|
||||
|
||||
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 />
|
||||
);
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { ApiPaths } from '../../../enums/ApiEndpoints';
|
||||
import { useTable } from '../../../hooks/UseTable';
|
||||
import { apiUrl } from '../../../states/ApiState';
|
||||
import { ThumbnailHoverCard } from '../../images/Thumbnail';
|
||||
import { PartHoverCard } from '../../images/Thumbnail';
|
||||
import { TableColumn } from '../Column';
|
||||
import { TableFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
@ -31,37 +31,13 @@ export function UsedInTable({
|
||||
title: t`Assembled Part`,
|
||||
switchable: false,
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
let part = record.part_detail;
|
||||
return (
|
||||
part && (
|
||||
<ThumbnailHoverCard
|
||||
src={part.thumbnail || part.image}
|
||||
text={part.full_name}
|
||||
alt={part.description}
|
||||
link=""
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
render: (record: any) => <PartHoverCard part={record.part_detail} />
|
||||
},
|
||||
{
|
||||
accessor: 'sub_part',
|
||||
title: t`Required Part`,
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
let part = record.sub_part_detail;
|
||||
return (
|
||||
part && (
|
||||
<ThumbnailHoverCard
|
||||
src={part.thumbnail || part.image}
|
||||
text={part.full_name}
|
||||
alt={part.description}
|
||||
link=""
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
render: (record: any) => <PartHoverCard part={record.sub_part_detail} />
|
||||
},
|
||||
{
|
||||
accessor: 'quantity',
|
||||
|
242
src/frontend/src/components/tables/build/BuildLineTable.tsx
Normal file
242
src/frontend/src/components/tables/build/BuildLineTable.tsx
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -7,7 +7,7 @@ import { ApiPaths } from '../../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../../enums/ModelType';
|
||||
import { useTable } from '../../../hooks/UseTable';
|
||||
import { apiUrl } from '../../../states/ApiState';
|
||||
import { ThumbnailHoverCard } from '../../images/Thumbnail';
|
||||
import { PartHoverCard } from '../../images/Thumbnail';
|
||||
import { ProgressBar } from '../../items/ProgressBar';
|
||||
import { RenderUser } from '../../render/User';
|
||||
import { TableColumn } from '../Column';
|
||||
@ -37,19 +37,7 @@ function buildOrderTableColumns(): TableColumn[] {
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
title: t`Part`,
|
||||
render: (record: any) => {
|
||||
let part = record.part_detail;
|
||||
return (
|
||||
part && (
|
||||
<ThumbnailHoverCard
|
||||
src={part.thumbnail || part.image}
|
||||
text={part.full_name}
|
||||
alt={part.description}
|
||||
link=""
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
render: (record: any) => <PartHoverCard part={record.part_detail} />
|
||||
},
|
||||
{
|
||||
accessor: 'title',
|
||||
|
@ -47,6 +47,7 @@ export enum ApiPaths {
|
||||
// Build order URLs
|
||||
build_order_list = 'api-build-list',
|
||||
build_order_attachment_list = 'api-build-attachment-list',
|
||||
build_line_list = 'api-build-line-list',
|
||||
|
||||
// BOM URLs
|
||||
bom_list = 'api-bom-list',
|
||||
|
@ -29,6 +29,7 @@ import {
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { StatusRenderer } from '../../components/render/StatusRenderer';
|
||||
import BuildLineTable from '../../components/tables/build/BuildLineTable';
|
||||
import { BuildOrderTable } from '../../components/tables/build/BuildOrderTable';
|
||||
import { AttachmentTable } from '../../components/tables/general/AttachmentTable';
|
||||
import { StockItemTable } from '../../components/tables/stock/StockItemTable';
|
||||
@ -104,8 +105,17 @@ export default function BuildDetail() {
|
||||
{
|
||||
name: 'allocate-stock',
|
||||
label: t`Allocate Stock`,
|
||||
icon: <IconListCheck />
|
||||
// TODO: Hide if build is complete
|
||||
icon: <IconListCheck />,
|
||||
content: build?.pk ? (
|
||||
<BuildLineTable
|
||||
params={{
|
||||
build: id,
|
||||
tracked: false
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'incomplete-outputs',
|
||||
|
@ -151,6 +151,8 @@ export function apiEndpoint(path: ApiPaths): string {
|
||||
return 'build/';
|
||||
case ApiPaths.build_order_attachment_list:
|
||||
return 'build/attachment/';
|
||||
case ApiPaths.build_line_list:
|
||||
return 'build/line/';
|
||||
case ApiPaths.bom_list:
|
||||
return 'bom/';
|
||||
case ApiPaths.part_list:
|
||||
|
Loading…
Reference in New Issue
Block a user