From 21f623eea88bf1314e61c56b346ea51ab2f9176c Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 8 Aug 2024 20:01:56 +1000 Subject: [PATCH] [PUI] Sales order actions (#7837) * Create build order from sales order table * Allow creation of child build order from build page * Add production and purcahse order quantitres to sales order item serializer * Bump API version * Fix playwright test --- .../InvenTree/InvenTree/api_version.py | 6 +- src/backend/InvenTree/order/serializers.py | 36 ++++++--- src/backend/InvenTree/part/serializers.py | 63 +++++++--------- src/frontend/src/pages/build/BuildDetail.tsx | 6 +- src/frontend/src/pages/part/PartDetail.tsx | 2 +- .../src/tables/build/BuildLineTable.tsx | 74 ++++++++++++++----- src/frontend/src/tables/part/PartTable.tsx | 16 +++- .../src/tables/part/PartVariantTable.tsx | 9 ++- .../tables/sales/SalesOrderLineItemTable.tsx | 39 +++++++++- src/frontend/tests/pages/pui_build.spec.ts | 2 +- 10 files changed, 173 insertions(+), 80 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index f4ca3b8293..a0f0d02acd 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,17 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 234 +INVENTREE_API_VERSION = 235 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v235 - 2024-08-08 : https://github.com/inventree/InvenTree/pull/7837 + - Adds "on_order" quantity to SalesOrderLineItem serializer + - Adds "building" quantity to SalesOrderLineItem serializer + v234 - 2024-08-08 : https://github.com/inventree/InvenTree/pull/7829 - Fixes bug in the plugin metadata endpoint diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 79e7d9043e..f24b009e38 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -21,7 +21,6 @@ from rest_framework.serializers import ValidationError from sql_util.utils import SubqueryCount import order.models -import part.filters import part.filters as part_filters import part.models as part_models import stock.models @@ -1030,8 +1029,6 @@ class SalesOrderLineItemSerializer( 'pk', 'allocated', 'allocations', - 'available_stock', - 'available_variant_stock', 'customer_detail', 'quantity', 'reference', @@ -1046,6 +1043,11 @@ class SalesOrderLineItemSerializer( 'shipped', 'target_date', 'link', + # Annotated fields for part stocking information + 'available_stock', + 'available_variant_stock', + 'building', + 'on_order', ] def __init__(self, *args, **kwargs): @@ -1078,6 +1080,8 @@ class SalesOrderLineItemSerializer( - "overdue" status (boolean field) - "available_quantity" + - "building" + - "on_order" """ queryset = queryset.annotate( overdue=Case( @@ -1093,11 +1097,11 @@ class SalesOrderLineItemSerializer( # Annotate each line with the available stock quantity # To do this, we need to look at the total stock and any allocations queryset = queryset.alias( - total_stock=part.filters.annotate_total_stock(reference='part__'), - allocated_to_sales_orders=part.filters.annotate_sales_order_allocations( + total_stock=part_filters.annotate_total_stock(reference='part__'), + allocated_to_sales_orders=part_filters.annotate_sales_order_allocations( reference='part__' ), - allocated_to_build_orders=part.filters.annotate_build_order_allocations( + allocated_to_build_orders=part_filters.annotate_build_order_allocations( reference='part__' ), ) @@ -1112,19 +1116,19 @@ class SalesOrderLineItemSerializer( ) # Filter for "variant" stock: Variant stock items must be salable and active - variant_stock_query = part.filters.variant_stock_query( + variant_stock_query = part_filters.variant_stock_query( reference='part__' ).filter(part__salable=True, part__active=True) # Also add in available "variant" stock queryset = queryset.alias( - variant_stock_total=part.filters.annotate_variant_quantity( + variant_stock_total=part_filters.annotate_variant_quantity( variant_stock_query, reference='quantity' ), - variant_bo_allocations=part.filters.annotate_variant_quantity( + variant_bo_allocations=part_filters.annotate_variant_quantity( variant_stock_query, reference='sales_order_allocations__quantity' ), - variant_so_allocations=part.filters.annotate_variant_quantity( + variant_so_allocations=part_filters.annotate_variant_quantity( variant_stock_query, reference='allocations__quantity' ), ) @@ -1138,6 +1142,16 @@ class SalesOrderLineItemSerializer( ) ) + # Add information about the quantity of parts currently on order + queryset = queryset.annotate( + on_order=part_filters.annotate_on_order_quantity(reference='part__') + ) + + # Add information about the quantity of parts currently in production + queryset = queryset.annotate( + building=part_filters.annotate_in_production_quantity(reference='part__') + ) + return queryset customer_detail = CompanyBriefSerializer( @@ -1153,6 +1167,8 @@ class SalesOrderLineItemSerializer( overdue = serializers.BooleanField(required=False, read_only=True) available_stock = serializers.FloatField(read_only=True) available_variant_stock = serializers.FloatField(read_only=True) + on_order = serializers.FloatField(label=_('On Order'), read_only=True) + building = serializers.FloatField(label=_('In Production'), read_only=True) quantity = InvenTreeDecimalField() diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 0758a8ff8d..66307ea89d 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -27,7 +27,7 @@ import company.models import InvenTree.helpers import InvenTree.serializers import InvenTree.status -import part.filters +import part.filters as part_filters import part.helpers as part_helpers import part.stocktake import part.tasks @@ -107,12 +107,12 @@ class CategorySerializer( """Annotate extra information to the queryset.""" # Annotate the number of 'parts' which exist in each category (including subcategories!) queryset = queryset.annotate( - part_count=part.filters.annotate_category_parts(), - subcategories=part.filters.annotate_sub_categories(), + part_count=part_filters.annotate_category_parts(), + subcategories=part_filters.annotate_sub_categories(), ) queryset = queryset.annotate( - parent_default_location=part.filters.annotate_default_location('parent__') + parent_default_location=part_filters.annotate_default_location('parent__') ) return queryset @@ -164,7 +164,7 @@ class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer): @staticmethod def annotate_queryset(queryset): """Annotate the queryset with the number of subcategories.""" - return queryset.annotate(subcategories=part.filters.annotate_sub_categories()) + return queryset.annotate(subcategories=part_filters.annotate_sub_categories()) @register_importer() @@ -781,10 +781,10 @@ class PartSerializer( queryset = queryset.annotate(stock_item_count=SubqueryCount('stock_items')) # Annotate with the total variant stock quantity - variant_query = part.filters.variant_stock_query() + variant_query = part_filters.variant_stock_query() queryset = queryset.annotate( - variant_stock=part.filters.annotate_variant_quantity( + variant_stock=part_filters.annotate_variant_quantity( variant_query, reference='quantity' ) ) @@ -814,10 +814,10 @@ class PartSerializer( # TODO: Note that BomItemSerializer and BuildLineSerializer have very similar code queryset = queryset.annotate( - ordering=part.filters.annotate_on_order_quantity(), - in_stock=part.filters.annotate_total_stock(), - allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(), - allocated_to_build_orders=part.filters.annotate_build_order_allocations(), + ordering=part_filters.annotate_on_order_quantity(), + in_stock=part_filters.annotate_total_stock(), + allocated_to_sales_orders=part_filters.annotate_sales_order_allocations(), + allocated_to_build_orders=part_filters.annotate_build_order_allocations(), ) # Annotate the queryset with the 'total_in_stock' quantity @@ -829,7 +829,7 @@ class PartSerializer( ) queryset = queryset.annotate( - external_stock=part.filters.annotate_total_stock( + external_stock=part_filters.annotate_total_stock( filter=Q(location__external=True) ) ) @@ -847,12 +847,12 @@ class PartSerializer( # Annotate with the total 'required for builds' quantity queryset = queryset.annotate( - required_for_build_orders=part.filters.annotate_build_order_requirements(), - required_for_sales_orders=part.filters.annotate_sales_order_requirements(), + required_for_build_orders=part_filters.annotate_build_order_requirements(), + required_for_sales_orders=part_filters.annotate_sales_order_requirements(), ) queryset = queryset.annotate( - category_default_location=part.filters.annotate_default_location( + category_default_location=part_filters.annotate_default_location( 'category__' ) ) @@ -1684,30 +1684,23 @@ class BomItemSerializer( # Annotate with the total "on order" amount for the sub-part queryset = queryset.annotate( - on_order=part.filters.annotate_on_order_quantity(ref) + on_order=part_filters.annotate_on_order_quantity(ref) ) # Annotate with the total "building" amount for the sub-part queryset = queryset.annotate( - building=Coalesce( - SubquerySum( - 'sub_part__builds__quantity', - filter=Q(status__in=BuildStatusGroups.ACTIVE_CODES), - ), - Decimal(0), - output_field=models.DecimalField(), - ) + building=part_filters.annotate_in_production_quantity(ref) ) # Calculate "total stock" for the referenced sub_part # Calculate the "build_order_allocations" for the sub_part # Note that these fields are only aliased, not annotated queryset = queryset.alias( - total_stock=part.filters.annotate_total_stock(reference=ref), - allocated_to_sales_orders=part.filters.annotate_sales_order_allocations( + total_stock=part_filters.annotate_total_stock(reference=ref), + allocated_to_sales_orders=part_filters.annotate_sales_order_allocations( reference=ref ), - allocated_to_build_orders=part.filters.annotate_build_order_allocations( + allocated_to_build_orders=part_filters.annotate_build_order_allocations( reference=ref ), ) @@ -1724,7 +1717,7 @@ class BomItemSerializer( # Calculate 'external_stock' queryset = queryset.annotate( - external_stock=part.filters.annotate_total_stock( + external_stock=part_filters.annotate_total_stock( reference=ref, filter=Q(location__external=True) ) ) @@ -1733,11 +1726,11 @@ class BomItemSerializer( # Extract similar information for any 'substitute' parts queryset = queryset.alias( - substitute_stock=part.filters.annotate_total_stock(reference=ref), - substitute_build_allocations=part.filters.annotate_build_order_allocations( + substitute_stock=part_filters.annotate_total_stock(reference=ref), + substitute_build_allocations=part_filters.annotate_build_order_allocations( reference=ref ), - substitute_sales_allocations=part.filters.annotate_sales_order_allocations( + substitute_sales_allocations=part_filters.annotate_sales_order_allocations( reference=ref ), ) @@ -1753,16 +1746,16 @@ class BomItemSerializer( ) # Annotate the queryset with 'available variant stock' information - variant_stock_query = part.filters.variant_stock_query(reference='sub_part__') + variant_stock_query = part_filters.variant_stock_query(reference='sub_part__') queryset = queryset.alias( - variant_stock_total=part.filters.annotate_variant_quantity( + variant_stock_total=part_filters.annotate_variant_quantity( variant_stock_query, reference='quantity' ), - variant_bo_allocations=part.filters.annotate_variant_quantity( + variant_bo_allocations=part_filters.annotate_variant_quantity( variant_stock_query, reference='sales_order_allocations__quantity' ), - variant_so_allocations=part.filters.annotate_variant_quantity( + variant_so_allocations=part_filters.annotate_variant_quantity( variant_stock_query, reference='allocations__quantity' ), ) diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 1c6a9c9e3f..062fbf0789 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -247,11 +247,7 @@ export default function BuildDetail() { label: t`Line Items`, icon: , content: build?.pk ? ( - + ) : ( ) diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 70448e26d4..64cb40df98 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -543,7 +543,7 @@ export default function PartDetail() { label: t`Variants`, icon: , hidden: !part.is_template, - content: + content: }, { name: 'allocations', diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index 5d0b6c90ac..1940a95de2 100644 --- a/src/frontend/src/tables/build/BuildLineTable.tsx +++ b/src/frontend/src/tables/build/BuildLineTable.tsx @@ -5,11 +5,14 @@ import { IconShoppingCart, IconTool } from '@tabler/icons-react'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { ProgressBar } from '../../components/items/ProgressBar'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; +import { UserRoles } from '../../enums/Roles'; +import { useBuildOrderFields } from '../../forms/BuildForms'; +import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; @@ -19,7 +22,13 @@ import { TableFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; import { TableHoverCard } from '../TableHoverCard'; -export default function BuildLineTable({ params = {} }: { params?: any }) { +export default function BuildLineTable({ + buildId, + params = {} +}: { + buildId: number; + params?: any; +}) { const table = useTable('buildline'); const user = useUserState(); @@ -213,6 +222,19 @@ export default function BuildLineTable({ params = {} }: { params?: any }) { ]; }, []); + const buildOrderFields = useBuildOrderFields({ create: true }); + + const [initialData, setInitialData] = useState({}); + + const newBuildOrder = useCreateApiFormModal({ + url: ApiEndpoints.build_order_list, + title: t`Create Build Order`, + fields: buildOrderFields, + initialData: initialData, + follow: true, + modelType: ModelType.build + }); + const rowActions = useCallback( (record: any) => { let part = record.part_detail; @@ -243,8 +265,16 @@ export default function BuildLineTable({ params = {} }: { params?: any }) { { icon: , title: t`Build Stock`, - hidden: !part?.assembly, - color: 'blue' + hidden: !part?.assembly || !user.hasAddRole(UserRoles.build), + color: 'blue', + onClick: () => { + setInitialData({ + part: record.part, + parent: buildId, + quantity: record.quantity - record.allocated + }); + newBuildOrder.open(); + } } ]; }, @@ -252,21 +282,25 @@ export default function BuildLineTable({ params = {} }: { params?: any }) { ); return ( - + <> + {newBuildOrder.modal} + + ); } diff --git a/src/frontend/src/tables/part/PartTable.tsx b/src/frontend/src/tables/part/PartTable.tsx index b18cd436ec..49bbe87c6d 100644 --- a/src/frontend/src/tables/part/PartTable.tsx +++ b/src/frontend/src/tables/part/PartTable.tsx @@ -303,20 +303,28 @@ function partTableFilters(): TableFilter[] { * @param {Object} params - The query parameters to pass to the API * @returns */ -export function PartListTable({ props }: { props: InvenTreeTableProps }) { +export function PartListTable({ + props, + defaultPartData +}: { + props: InvenTreeTableProps; + defaultPartData?: any; +}) { const tableColumns = useMemo(() => partTableColumns(), []); const tableFilters = useMemo(() => partTableFilters(), []); const table = useTable('part-list'); const user = useUserState(); + const initialPartData = useMemo(() => { + return defaultPartData ?? props.params ?? {}; + }, [defaultPartData, props.params]); + const newPart = useCreateApiFormModal({ url: ApiEndpoints.part_list, title: t`Add Part`, fields: usePartFields({ create: true }), - initialData: { - ...(props.params ?? {}) - }, + initialData: initialPartData, follow: true, modelType: ModelType.part }); diff --git a/src/frontend/src/tables/part/PartVariantTable.tsx b/src/frontend/src/tables/part/PartVariantTable.tsx index a8c8df2251..72487005c4 100644 --- a/src/frontend/src/tables/part/PartVariantTable.tsx +++ b/src/frontend/src/tables/part/PartVariantTable.tsx @@ -7,7 +7,7 @@ import { PartListTable } from './PartTable'; /** * Display variant parts for the specified parent part */ -export function PartVariantTable({ partId }: { partId: string }) { +export function PartVariantTable({ part }: { part: any }) { const tableFilters: TableFilter[] = useMemo(() => { return [ { @@ -39,9 +39,14 @@ export function PartVariantTable({ partId }: { partId: string }) { enableDownload: false, tableFilters: tableFilters, params: { - ancestor: partId + ancestor: part.pk } }} + defaultPartData={{ + ...part, + variant_of: part.pk, + is_template: false + }} /> ); } diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx index 5beff3b52b..d4dad568e0 100644 --- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx @@ -13,6 +13,7 @@ import { formatCurrency } from '../../defaults/formatters'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; +import { useBuildOrderFields } from '../../forms/BuildForms'; import { useSalesOrderLineItemFields } from '../../forms/SalesOrderForms'; import { useCreateApiFormModal, @@ -122,6 +123,22 @@ export default function SalesOrderLineItemTable({ extra.push({t`Includes variant stock`}); } + if (record.building > 0) { + extra.push( + + {t`In production`}: {record.building} + + ); + } + + if (record.on_order > 0) { + extra.push( + + {t`On order`}: {record.on_order} + + ); + } + return ( {text}} @@ -199,6 +216,17 @@ export default function SalesOrderLineItemTable({ table: table }); + const buildOrderFields = useBuildOrderFields({ create: true }); + + const newBuildOrder = useCreateApiFormModal({ + url: ApiEndpoints.build_order_list, + title: t`Create Build Order`, + fields: buildOrderFields, + initialData: initialData, + follow: true, + modelType: ModelType.build + }); + const tableActions = useMemo(() => { return [ , - color: 'blue' + color: 'blue', + onClick: () => { + setInitialData({ + part: record.part, + quantity: (record?.quantity ?? 1) - (record?.allocated ?? 0), + sales_order: orderId + }); + newBuildOrder.open(); + } }, { hidden: @@ -277,6 +313,7 @@ export default function SalesOrderLineItemTable({ {editLine.modal} {deleteLine.modal} {newLine.modal} + {newBuildOrder.modal} { await page.getByRole('tab', { name: 'Build', exact: true }).click(); // We have now loaded the "Build Order" table. Check for some expected texts - await page.getByText('On Hold').waitFor(); + await page.getByText('On Hold').first().waitFor(); await page.getByText('Pending').first().waitFor(); // Load a particular build order