diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py
index dae9004125..50068a827e 100644
--- a/InvenTree/InvenTree/api_version.py
+++ b/InvenTree/InvenTree/api_version.py
@@ -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
diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py
index 337c00452d..19686f58cd 100644
--- a/InvenTree/build/serializers.py
+++ b/InvenTree/build/serializers.py
@@ -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(
diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html
index ffde5c2878..19438e70f5 100644
--- a/InvenTree/build/templates/build/detail.html
+++ b/InvenTree/build/templates/build/detail.html
@@ -373,7 +373,11 @@ onPanelLoad('allocate', function() {
loadBuildLineTable(
"#build-lines-table",
{{ build.pk }},
- {}
+ {
+ {% if build.project_code %}
+ project_code: {{ build.project_code.pk }},
+ {% endif %}
+ }
);
});
diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html
index 24cb4e4b78..4d9fac48f0 100644
--- a/InvenTree/order/templates/order/sales_order_detail.html
+++ b/InvenTree/order/templates/order/sales_order_detail.html
@@ -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 %}
diff --git a/InvenTree/part/filters.py b/InvenTree/part/filters.py
index aa0dfd46b7..9eb22624e9 100644
--- a/InvenTree/part/filters.py
+++ b/InvenTree/part/filters.py
@@ -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.
diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index b0caeef764..d49175d6d3 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -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,
});
});
diff --git a/InvenTree/templates/js/translated/sales_order.js b/InvenTree/templates/js/translated/sales_order.js
index 3f586e72f0..7f8ece3137 100644
--- a/InvenTree/templates/js/translated/sales_order.js
+++ b/InvenTree/templates/js/translated/sales_order.js
@@ -2174,7 +2174,8 @@ function loadSalesOrderLineItemTable(table, options={}) {
part: pk,
sales_order: options.order,
quantity: quantity,
- success: reloadTable
+ success: reloadTable,
+ ...options
});
});
diff --git a/src/frontend/src/components/images/Thumbnail.tsx b/src/frontend/src/components/images/Thumbnail.tsx
index 80a4f7a9f7..2a53494529 100644
--- a/src/frontend/src/components/images/Thumbnail.tsx
+++ b/src/frontend/src/components/images/Thumbnail.tsx
@@ -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
{card}
;
}
+
+export function PartHoverCard({ part }: { part: any }) {
+ return part ? (
+
+ ) : (
+
+ );
+}
diff --git a/src/frontend/src/components/tables/bom/UsedInTable.tsx b/src/frontend/src/components/tables/bom/UsedInTable.tsx
index 6d2bc496ac..15dcded36f 100644
--- a/src/frontend/src/components/tables/bom/UsedInTable.tsx
+++ b/src/frontend/src/components/tables/bom/UsedInTable.tsx
@@ -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 && (
-
- )
- );
- }
+ render: (record: any) =>
},
{
accessor: 'sub_part',
title: t`Required Part`,
sortable: true,
- render: (record: any) => {
- let part = record.sub_part_detail;
- return (
- part && (
-
- )
- );
- }
+ render: (record: any) =>
},
{
accessor: 'quantity',
diff --git a/src/frontend/src/components/tables/build/BuildLineTable.tsx b/src/frontend/src/components/tables/build/BuildLineTable.tsx
new file mode 100644
index 0000000000..9fdcffb9db
--- /dev/null
+++ b/src/frontend/src/components/tables/build/BuildLineTable.tsx
@@ -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(
+
+ {t`Includes substitute stock`}
+
+ );
+ }
+
+ // Account for variant stock
+ if (bom_item.allow_variants && record.available_variant_stock > 0) {
+ available += record.available_variant_stock;
+ extra.push(
+
+ {t`Includes variant stock`}
+
+ );
+ }
+
+ // Account for in-production stock
+ if (record.in_production > 0) {
+ extra.push(
+
+ {t`In production`}: {record.in_production}
+
+ );
+ }
+
+ // Account for stock on order
+ if (record.on_order > 0) {
+ extra.push(
+
+ {t`On order`}: {record.on_order}
+
+ );
+ }
+
+ return (
+ 0 ? (
+ available
+ ) : (
+ {t`No stock available`}
+ )
+ }
+ title={t`Available Stock`}
+ extra={extra}
+ />
+ );
+ }, []);
+
+ const tableColumns: TableColumn[] = useMemo(() => {
+ return [
+ {
+ accessor: 'bom_item',
+ title: t`Part`,
+ sortable: true,
+ switchable: false,
+ render: (record: any) =>
+ },
+ {
+ 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 (
+
+ {record.bom_item_detail?.quantity}
+ {record?.part_detail?.units && (
+ [{record.part_detail.units}]
+ )}
+
+ );
+ }
+ },
+ {
+ accessor: 'quantity',
+ title: t`Required Quantity`,
+ sortable: true,
+ render: (record: any) => {
+ return (
+
+ {record.quantity}
+ {record?.part_detail?.units && (
+ [{record.part_detail.units}]
+ )}
+
+ );
+ }
+ },
+ {
+ 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 ? (
+ {t`Consumable item`}
+ ) : (
+
+ );
+ }
+ }
+ ];
+ }, []);
+
+ 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: ,
+ title: t`Allocate Stock`,
+ hidden: record.allocated >= record.quantity,
+ color: 'green'
+ },
+ {
+ icon: ,
+ title: t`Order Stock`,
+ hidden: !part?.purchaseable,
+ color: 'blue'
+ },
+ {
+ icon: ,
+ title: t`Build Stock`,
+ hidden: !part?.assembly,
+ color: 'blue'
+ }
+ ];
+ },
+ [user]
+ );
+
+ return (
+ {
+ if (row?.part_detail?.pk) {
+ navigate(`/part/${row.part_detail.pk}`);
+ }
+ }
+ }}
+ />
+ );
+}
diff --git a/src/frontend/src/components/tables/build/BuildOrderTable.tsx b/src/frontend/src/components/tables/build/BuildOrderTable.tsx
index 0b9c885266..2d9e5fb1df 100644
--- a/src/frontend/src/components/tables/build/BuildOrderTable.tsx
+++ b/src/frontend/src/components/tables/build/BuildOrderTable.tsx
@@ -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 && (
-
- )
- );
- }
+ render: (record: any) =>
},
{
accessor: 'title',
diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx
index 0dc5b5368c..f804ff289c 100644
--- a/src/frontend/src/enums/ApiEndpoints.tsx
+++ b/src/frontend/src/enums/ApiEndpoints.tsx
@@ -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',
diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx
index bb8aaf62bf..d117a075cf 100644
--- a/src/frontend/src/pages/build/BuildDetail.tsx
+++ b/src/frontend/src/pages/build/BuildDetail.tsx
@@ -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:
- // TODO: Hide if build is complete
+ icon: ,
+ content: build?.pk ? (
+
+ ) : (
+
+ )
},
{
name: 'incomplete-outputs',
diff --git a/src/frontend/src/states/ApiState.tsx b/src/frontend/src/states/ApiState.tsx
index 6c17f8c98f..34f0bd4e35 100644
--- a/src/frontend/src/states/ApiState.tsx
+++ b/src/frontend/src/states/ApiState.tsx
@@ -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: