From 59fa3bb4ffab6c869406fae74883af10eaf57faa Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 16 Oct 2024 20:19:11 +1100 Subject: [PATCH] Order table variants (#8295) * BuildOrderTable: Show variants - Allow filtering of build orders by part variant * Add "include_variants" filter for SalesOrder table - A bit tricker! * Add "include_variants" filter to PartPurchaseOrdersTable * Enable filtering ReturnOrder by "part" attribute * Add similiar functionality for SalesOrderAllocation * Add similar filter for BuildAllocation table * Add migration file --- .../InvenTree/InvenTree/api_version.py | 8 +- src/backend/InvenTree/build/api.py | 66 +++++++- .../build/migrations/0053_alter_build_part.py | 20 +++ src/backend/InvenTree/build/models.py | 2 - src/backend/InvenTree/order/api.py | 154 ++++++++++++++++-- .../src/pages/company/CompanyDetail.tsx | 6 +- src/frontend/src/pages/part/PartDetail.tsx | 13 +- .../tables/build/BuildAllocatedStockTable.tsx | 19 ++- .../src/tables/build/BuildOrderTable.tsx | 20 ++- .../tables/part/PartPurchaseOrdersTable.tsx | 6 + .../src/tables/sales/ReturnOrderTable.tsx | 28 +++- .../sales/SalesOrderAllocationTable.tsx | 19 ++- .../src/tables/sales/SalesOrderTable.tsx | 17 +- 13 files changed, 338 insertions(+), 40 deletions(-) create mode 100644 src/backend/InvenTree/build/migrations/0053_alter_build_part.py diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index ab2ab9d327..40e3951dbc 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,19 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 268 +INVENTREE_API_VERSION = 269 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v269 - 2024-10-16 : https://github.com/inventree/InvenTree/pull/8295 + - Adds "include_variants" filter to the BuildOrder API endpoint + - Adds "include_variants" filter to the SalesOrder API endpoint + - Adds "include_variants" filter to the PurchaseOrderLineItem API endpoint + - Adds "include_variants" filter to the ReturnOrder API endpoint + 268 - 2024-10-11 : https://github.com/inventree/InvenTree/pull/8274 - Adds "in_stock" attribute to the StockItem serializer diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 6e2ba21a72..d6ceebab77 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -35,7 +35,6 @@ class BuildFilter(rest_filters.FilterSet): model = Build fields = [ 'sales_order', - 'part', ] status = rest_filters.NumberFilter(label='Status') @@ -54,6 +53,39 @@ class BuildFilter(rest_filters.FilterSet): field_name='parent', ) + include_variants = rest_filters.BooleanFilter(label=_('Include Variants'), method='filter_include_variants') + + def filter_include_variants(self, queryset, name, value): + """Filter by whether or not to include variants of the selected part. + + Note: + - This filter does nothing by itself, and requires the 'part' filter to be set. + - Refer to the 'filter_part' method for more information. + """ + + return queryset + + part = rest_filters.ModelChoiceFilter( + queryset=part.models.Part.objects.all(), + field_name='part', + method='filter_part' + ) + + def filter_part(self, queryset, name, part): + """Filter by 'part' which is being built. + + Note: + - If "include_variants" is True, include all variants of the selected part. + - Otherwise, just filter by the selected part. + """ + + include_variants = str2bool(self.data.get('include_variants', False)) + + if include_variants: + return queryset.filter(part__in=part.get_descendants(include_self=True)) + else: + return queryset.filter(part=part) + ancestor = rest_filters.ModelChoiceFilter( queryset=Build.objects.all(), label=_('Ancestor Build'), @@ -581,13 +613,45 @@ class BuildItemFilter(rest_filters.FilterSet): 'install_into', ] + include_variants = rest_filters.BooleanFilter( + label=_('Include Variants'), method='filter_include_variants' + ) + + def filter_include_variants(self, queryset, name, value): + """Filter by whether or not to include variants of the selected part. + + Note: + - This filter does nothing by itself, and requires the 'part' filter to be set. + - Refer to the 'filter_part' method for more information. + """ + + return queryset + part = rest_filters.ModelChoiceFilter( queryset=part.models.Part.objects.all(), + label=_('Part'), + method='filter_part', field_name='stock_item__part', ) + def filter_part(self, queryset, name, part): + """Filter by 'part' which is being built. + + Note: + - If "include_variants" is True, include all variants of the selected part. + - Otherwise, just filter by the selected part. + """ + + include_variants = str2bool(self.data.get('include_variants', False)) + + if include_variants: + return queryset.filter(stock_item__part__in=part.get_descendants(include_self=True)) + else: + return queryset.filter(stock_item__part=part) + build = rest_filters.ModelChoiceFilter( queryset=build.models.Build.objects.all(), + label=_('Build Order'), field_name='build_line__build', ) diff --git a/src/backend/InvenTree/build/migrations/0053_alter_build_part.py b/src/backend/InvenTree/build/migrations/0053_alter_build_part.py new file mode 100644 index 0000000000..d457b9bab2 --- /dev/null +++ b/src/backend/InvenTree/build/migrations/0053_alter_build_part.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.16 on 2024-10-16 06:19 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0130_alter_parttesttemplate_part'), + ('build', '0052_build_status_custom_key_alter_build_status'), + ] + + operations = [ + migrations.AlterField( + model_name='build', + name='part', + field=models.ForeignKey(help_text='Select part to build', limit_choices_to={'assembly': True}, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='part.part', verbose_name='Part'), + ), + ] diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 2209c0ae27..e633c3c791 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -270,8 +270,6 @@ class Build( related_name='builds', limit_choices_to={ 'assembly': True, - 'active': True, - 'virtual': False, }, help_text=_('Select part to build'), ) diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 93e217d235..dec97d4d2d 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -390,12 +390,40 @@ class PurchaseOrderLineItemFilter(LineItemFilter): label=_('Supplier Part'), ) + include_variants = rest_filters.BooleanFilter( + label=_('Include Variants'), method='filter_include_variants' + ) + + def filter_include_variants(self, queryset, name, value): + """Filter by whether or not to include variants of the selected part. + + Note: + - This filter does nothing by itself, and requires the 'base_part' filter to be set. + - Refer to the 'filter_base_part' method for more information. + """ + return queryset + base_part = rest_filters.ModelChoiceFilter( queryset=Part.objects.filter(purchaseable=True), - field_name='part__part', + method='filter_base_part', label=_('Internal Part'), ) + def filter_base_part(self, queryset, name, base_part): + """Filter by the 'base_part' attribute. + + Note: + - If "include_variants" is True, include all variants of the selected part + - Otherwise, just filter by the selected part + """ + include_variants = str2bool(self.data.get('include_variants', False)) + + if include_variants: + parts = base_part.get_descendants(include_self=True) + return queryset.filter(part__part__in=parts) + else: + return queryset.filter(part__part=base_part) + pending = rest_filters.BooleanFilter( method='filter_pending', label=_('Order Pending') ) @@ -575,6 +603,47 @@ class SalesOrderFilter(OrderFilter): model = models.SalesOrder fields = ['customer'] + include_variants = rest_filters.BooleanFilter( + label=_('Include Variants'), method='filter_include_variants' + ) + + def filter_include_variants(self, queryset, name, value): + """Filter by whether or not to include variants of the selected part. + + Note: + - This filter does nothing by itself, and requires the 'part' filter to be set. + - Refer to the 'filter_part' method for more information. + """ + return queryset + + part = rest_filters.ModelChoiceFilter( + queryset=Part.objects.all(), field_name='part', method='filter_part' + ) + + def filter_part(self, queryset, name, part): + """Filter SalesOrder by selected 'part'. + + Note: + - If 'include_variants' is set to True, then all variants of the selected part will be included. + - Otherwise, just filter by the selected part. + """ + include_variants = str2bool(self.data.get('include_variants', False)) + + # Construct a queryset of parts to filter by + if include_variants: + parts = part.get_descendants(include_self=True) + else: + parts = Part.objects.filter(pk=part.pk) + + # Now that we have a queryset of parts, find all the matching sales orders + line_items = models.SalesOrderLineItem.objects.filter(part__in=parts) + + # Generate a list of ID values for the matching sales orders + sales_orders = line_items.values_list('order', flat=True).distinct() + + # Now we have a list of matching IDs, filter the queryset + return queryset.filter(pk__in=sales_orders) + class SalesOrderMixin: """Mixin class for SalesOrder endpoints.""" @@ -636,17 +705,6 @@ class SalesOrderList(SalesOrderMixin, DataExportViewMixin, ListCreateAPI): params = self.request.query_params - # Filter by "Part" - # Only return SalesOrder which have LineItem referencing the part - part = params.get('part', None) - - if part is not None: - try: - part = Part.objects.get(pk=part) - queryset = queryset.filter(id__in=[so.id for so in part.sales_orders()]) - except (Part.DoesNotExist, ValueError): - pass - # Filter by 'date range' min_date = params.get('min_date', None) max_date = params.get('max_date', None) @@ -903,10 +961,38 @@ class SalesOrderAllocationFilter(rest_filters.FilterSet): label=_('Order'), ) - part = rest_filters.ModelChoiceFilter( - queryset=Part.objects.all(), field_name='item__part', label=_('Part') + include_variants = rest_filters.BooleanFilter( + label=_('Include Variants'), method='filter_include_variants' ) + def filter_include_variants(self, queryset, name, value): + """Filter by whether or not to include variants of the selected part. + + Note: + - This filter does nothing by itself, and requires the 'part' filter to be set. + - Refer to the 'filter_part' method for more information. + """ + return queryset + + part = rest_filters.ModelChoiceFilter( + queryset=Part.objects.all(), method='filter_part', label=_('Part') + ) + + def filter_part(self, queryset, name, part): + """Filter by the 'part' attribute. + + Note: + - If "include_variants" is True, include all variants of the selected part + - Otherwise, just filter by the selected part + """ + include_variants = str2bool(self.data.get('include_variants', False)) + + if include_variants: + parts = part.get_descendants(include_self=True) + return queryset.filter(item__part__in=parts) + else: + return queryset.filter(item__part=part) + outstanding = rest_filters.BooleanFilter( label=_('Outstanding'), method='filter_outstanding' ) @@ -1072,6 +1158,46 @@ class ReturnOrderFilter(OrderFilter): model = models.ReturnOrder fields = ['customer'] + include_variants = rest_filters.BooleanFilter( + label=_('Include Variants'), method='filter_include_variants' + ) + + def filter_include_variants(self, queryset, name, value): + """Filter by whether or not to include variants of the selected part. + + Note: + - This filter does nothing by itself, and requires the 'part' filter to be set. + - Refer to the 'filter_part' method for more information. + """ + return queryset + + part = rest_filters.ModelChoiceFilter( + queryset=Part.objects.all(), field_name='part', method='filter_part' + ) + + def filter_part(self, queryset, name, part): + """Filter by selected 'part'. + + Note: + - If 'include_variants' is set to True, then all variants of the selected part will be included. + - Otherwise, just filter by the selected part. + """ + include_variants = str2bool(self.data.get('include_variants', False)) + + if include_variants: + parts = part.get_descendants(include_self=True) + else: + parts = Part.objects.filter(pk=part.pk) + + # Now that we have a queryset of parts, find all the matching return orders + line_items = models.ReturnOrderLineItem.objects.filter(item__part__in=parts) + + # Generate a list of ID values for the matching return orders + return_orders = line_items.values_list('order', flat=True).distinct() + + # Now we have a list of matching IDs, filter the queryset + return queryset.filter(pk__in=return_orders) + class ReturnOrderMixin: """Mixin class for ReturnOrder endpoints.""" diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx index fa46156aac..5d71d4d662 100644 --- a/src/frontend/src/pages/company/CompanyDetail.tsx +++ b/src/frontend/src/pages/company/CompanyDetail.tsx @@ -223,8 +223,10 @@ export default function CompanyDetail(props: Readonly) { label: t`Return Orders`, icon: , hidden: !company?.is_customer, - content: company.pk && ( - + content: company.pk ? ( + + ) : ( + ) }, { diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 05305667c8..cd6a1b30da 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -29,6 +29,7 @@ import { IconTestPipe, IconTools, IconTruckDelivery, + IconTruckReturn, IconVersions } from '@tabler/icons-react'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; @@ -43,7 +44,6 @@ import { DetailsField, DetailsTable } from '../../components/details/Details'; import DetailsBadge from '../../components/details/DetailsBadge'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; -import NotesEditor from '../../components/editors/NotesEditor'; import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField'; import { Thumbnail } from '../../components/images/Thumbnail'; import { @@ -98,6 +98,7 @@ import { PartVariantTable } from '../../tables/part/PartVariantTable'; import { RelatedPartTable } from '../../tables/part/RelatedPartTable'; import { ManufacturerPartTable } from '../../tables/purchasing/ManufacturerPartTable'; import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable'; +import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable'; import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable'; import { SalesOrderTable } from '../../tables/sales/SalesOrderTable'; import { StockItemTable } from '../../tables/stock/StockItemTable'; @@ -606,6 +607,7 @@ export default function PartDetail() { modelField="build" modelTarget={ModelType.build} showBuildInfo + showPartInfo allowEdit /> @@ -698,6 +700,13 @@ export default function PartDetail() { hidden: !part.salable, content: part.pk ? : }, + { + name: 'return_orders', + label: t`Return Orders`, + icon: , + hidden: !part.salable || !globalSettings.isSet('RETURNORDER_ENABLED'), + content: part.pk ? : + }, { name: 'stocktake', label: t`Stock History`, @@ -1093,7 +1102,7 @@ export default function PartDetail() { badges={badges} breadcrumbs={breadcrumbs} breadcrumbAction={() => { - setTreeOpen(true); + setTreeOpen(true); // Open the category tree }} editAction={editPart.open} editEnabled={user.hasChangeRole(UserRoles.part)} diff --git a/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx b/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx index c6057fa37d..5eccbf9b76 100644 --- a/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx +++ b/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx @@ -45,17 +45,30 @@ export default function BuildAllocatedStockTable({ modelField?: string; }>) { const user = useUserState(); - const table = useTable('buildallocatedstock'); + const table = useTable( + !!partId ? 'buildallocatedstock-part' : 'buildallocatedstock' + ); const tableFilters: TableFilter[] = useMemo(() => { - return [ + let filters: TableFilter[] = [ { name: 'tracked', label: t`Allocated to Output`, description: t`Show items allocated to a build output` } ]; - }, []); + + if (!!partId) { + filters.push({ + name: 'include_variants', + type: 'boolean', + label: t`Include Variants`, + description: t`Include orders for part variants` + }); + } + + return filters; + }, [partId]); const tableColumns: TableColumn[] = useMemo(() => { return [ diff --git a/src/frontend/src/tables/build/BuildOrderTable.tsx b/src/frontend/src/tables/build/BuildOrderTable.tsx index d09d476322..03004d5428 100644 --- a/src/frontend/src/tables/build/BuildOrderTable.tsx +++ b/src/frontend/src/tables/build/BuildOrderTable.tsx @@ -46,6 +46,8 @@ export function BuildOrderTable({ parentBuildId?: number; salesOrderId?: number; }>) { + const table = useTable(!!partId ? 'buildorder-part' : 'buildorder-index'); + const tableColumns = useMemo(() => { return [ ReferenceColumn({}), @@ -110,7 +112,7 @@ export function BuildOrderTable({ const ownerFilters = useOwnerFilters(); const tableFilters: TableFilter[] = useMemo(() => { - return [ + let filters: TableFilter[] = [ { name: 'active', type: 'boolean', @@ -147,12 +149,22 @@ export function BuildOrderTable({ choices: ownerFilters.choices } ]; - }, [parentBuildId, projectCodeFilters.choices, ownerFilters.choices]); + + // If we are filtering on a specific part, we can include the "include variants" filter + if (!!partId) { + filters.push({ + name: 'include_variants', + type: 'boolean', + label: t`Include Variants`, + description: t`Include orders for part variants` + }); + } + + return filters; + }, [partId, projectCodeFilters.choices, ownerFilters.choices]); const user = useUserState(); - const table = useTable('buildorder'); - const buildOrderFields = useBuildOrderFields({ create: true }); const newBuild = useCreateApiFormModal({ diff --git a/src/frontend/src/tables/part/PartPurchaseOrdersTable.tsx b/src/frontend/src/tables/part/PartPurchaseOrdersTable.tsx index 859927ece4..ed50428c52 100644 --- a/src/frontend/src/tables/part/PartPurchaseOrdersTable.tsx +++ b/src/frontend/src/tables/part/PartPurchaseOrdersTable.tsx @@ -123,6 +123,12 @@ export default function PartPurchaseOrdersTable({ label: t`Order Status`, description: t`Filter by order status`, choiceFunction: StatusFilterOptions(ModelType.purchaseorder) + }, + { + name: 'include_variants', + type: 'boolean', + label: t`Include Variants`, + description: t`Include orders for part variants` } ]; }, []); diff --git a/src/frontend/src/tables/sales/ReturnOrderTable.tsx b/src/frontend/src/tables/sales/ReturnOrderTable.tsx index 7e6b35e999..906f2569b7 100644 --- a/src/frontend/src/tables/sales/ReturnOrderTable.tsx +++ b/src/frontend/src/tables/sales/ReturnOrderTable.tsx @@ -35,15 +35,21 @@ import { } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; -export function ReturnOrderTable({ params }: Readonly<{ params?: any }>) { - const table = useTable('return-orders'); +export function ReturnOrderTable({ + partId, + customerId +}: Readonly<{ + partId?: number; + customerId?: number; +}>) { + const table = useTable(!!partId ? 'returnorders-part' : 'returnorders-index'); const user = useUserState(); const projectCodeFilters = useProjectCodeFilters(); const responsibleFilters = useOwnerFilters(); const tableFilters: TableFilter[] = useMemo(() => { - return [ + let filters: TableFilter[] = [ { name: 'status', label: t`Status`, @@ -69,7 +75,18 @@ export function ReturnOrderTable({ params }: Readonly<{ params?: any }>) { choices: responsibleFilters.choices } ]; - }, [projectCodeFilters.choices, responsibleFilters.choices]); + + if (!!partId) { + filters.push({ + name: 'include_variants', + type: 'boolean', + label: t`Include Variants`, + description: t`Include orders for part variants` + }); + } + + return filters; + }, [partId, projectCodeFilters.choices, responsibleFilters.choices]); const tableColumns = useMemo(() => { return [ @@ -143,7 +160,8 @@ export function ReturnOrderTable({ params }: Readonly<{ params?: any }>) { columns={tableColumns} props={{ params: { - ...params, + part: partId, + customer: customerId, customer_detail: true }, tableFilters: tableFilters, diff --git a/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx b/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx index 6ff418aef2..a2d38b8b7f 100644 --- a/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx @@ -47,17 +47,30 @@ export default function SalesOrderAllocationTable({ modelField?: string; }>) { const user = useUserState(); - const table = useTable('salesorderallocations'); + const table = useTable( + !!partId ? 'salesorderallocations-part' : 'salesorderallocations' + ); const tableFilters: TableFilter[] = useMemo(() => { - return [ + let filters: TableFilter[] = [ { name: 'outstanding', label: t`Outstanding`, description: t`Show outstanding allocations` } ]; - }, []); + + if (!!partId) { + filters.push({ + name: 'include_variants', + type: 'boolean', + label: t`Include Variants`, + description: t`Include orders for part variants` + }); + } + + return filters; + }, [partId]); const tableColumns: TableColumn[] = useMemo(() => { return [ diff --git a/src/frontend/src/tables/sales/SalesOrderTable.tsx b/src/frontend/src/tables/sales/SalesOrderTable.tsx index 96d9da6389..1f9b48c7da 100644 --- a/src/frontend/src/tables/sales/SalesOrderTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderTable.tsx @@ -43,14 +43,14 @@ export function SalesOrderTable({ partId?: number; customerId?: number; }>) { - const table = useTable('sales-order'); + const table = useTable(!!partId ? 'salesorder-part' : 'salesorder-index'); const user = useUserState(); const projectCodeFilters = useProjectCodeFilters(); const responsibleFilters = useOwnerFilters(); const tableFilters: TableFilter[] = useMemo(() => { - return [ + let filters: TableFilter[] = [ { name: 'status', label: t`Status`, @@ -76,7 +76,18 @@ export function SalesOrderTable({ choices: responsibleFilters.choices } ]; - }, [projectCodeFilters.choices, responsibleFilters.choices]); + + if (!!partId) { + filters.push({ + name: 'include_variants', + type: 'boolean', + label: t`Include Variants`, + description: t`Include orders for part variants` + }); + } + + return filters; + }, [partId, projectCodeFilters.choices, responsibleFilters.choices]); const salesOrderFields = useSalesOrderFields({});