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: