[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
This commit is contained in:
Oliver 2024-08-08 20:01:56 +10:00 committed by GitHub
parent a5564090bb
commit 21f623eea8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 173 additions and 80 deletions

View File

@ -1,13 +1,17 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v234 - 2024-08-08 : https://github.com/inventree/InvenTree/pull/7829
- Fixes bug in the plugin metadata endpoint - Fixes bug in the plugin metadata endpoint

View File

@ -21,7 +21,6 @@ from rest_framework.serializers import ValidationError
from sql_util.utils import SubqueryCount from sql_util.utils import SubqueryCount
import order.models import order.models
import part.filters
import part.filters as part_filters import part.filters as part_filters
import part.models as part_models import part.models as part_models
import stock.models import stock.models
@ -1030,8 +1029,6 @@ class SalesOrderLineItemSerializer(
'pk', 'pk',
'allocated', 'allocated',
'allocations', 'allocations',
'available_stock',
'available_variant_stock',
'customer_detail', 'customer_detail',
'quantity', 'quantity',
'reference', 'reference',
@ -1046,6 +1043,11 @@ class SalesOrderLineItemSerializer(
'shipped', 'shipped',
'target_date', 'target_date',
'link', 'link',
# Annotated fields for part stocking information
'available_stock',
'available_variant_stock',
'building',
'on_order',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -1078,6 +1080,8 @@ class SalesOrderLineItemSerializer(
- "overdue" status (boolean field) - "overdue" status (boolean field)
- "available_quantity" - "available_quantity"
- "building"
- "on_order"
""" """
queryset = queryset.annotate( queryset = queryset.annotate(
overdue=Case( overdue=Case(
@ -1093,11 +1097,11 @@ class SalesOrderLineItemSerializer(
# Annotate each line with the available stock quantity # Annotate each line with the available stock quantity
# To do this, we need to look at the total stock and any allocations # To do this, we need to look at the total stock and any allocations
queryset = queryset.alias( queryset = queryset.alias(
total_stock=part.filters.annotate_total_stock(reference='part__'), total_stock=part_filters.annotate_total_stock(reference='part__'),
allocated_to_sales_orders=part.filters.annotate_sales_order_allocations( allocated_to_sales_orders=part_filters.annotate_sales_order_allocations(
reference='part__' reference='part__'
), ),
allocated_to_build_orders=part.filters.annotate_build_order_allocations( allocated_to_build_orders=part_filters.annotate_build_order_allocations(
reference='part__' reference='part__'
), ),
) )
@ -1112,19 +1116,19 @@ class SalesOrderLineItemSerializer(
) )
# Filter for "variant" stock: Variant stock items must be salable and active # 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__' reference='part__'
).filter(part__salable=True, part__active=True) ).filter(part__salable=True, part__active=True)
# Also add in available "variant" stock # Also add in available "variant" stock
queryset = queryset.alias( 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_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_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' 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 return queryset
customer_detail = CompanyBriefSerializer( customer_detail = CompanyBriefSerializer(
@ -1153,6 +1167,8 @@ class SalesOrderLineItemSerializer(
overdue = serializers.BooleanField(required=False, read_only=True) overdue = serializers.BooleanField(required=False, read_only=True)
available_stock = serializers.FloatField(read_only=True) available_stock = serializers.FloatField(read_only=True)
available_variant_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() quantity = InvenTreeDecimalField()

View File

@ -27,7 +27,7 @@ import company.models
import InvenTree.helpers import InvenTree.helpers
import InvenTree.serializers import InvenTree.serializers
import InvenTree.status import InvenTree.status
import part.filters import part.filters as part_filters
import part.helpers as part_helpers import part.helpers as part_helpers
import part.stocktake import part.stocktake
import part.tasks import part.tasks
@ -107,12 +107,12 @@ class CategorySerializer(
"""Annotate extra information to the queryset.""" """Annotate extra information to the queryset."""
# Annotate the number of 'parts' which exist in each category (including subcategories!) # Annotate the number of 'parts' which exist in each category (including subcategories!)
queryset = queryset.annotate( queryset = queryset.annotate(
part_count=part.filters.annotate_category_parts(), part_count=part_filters.annotate_category_parts(),
subcategories=part.filters.annotate_sub_categories(), subcategories=part_filters.annotate_sub_categories(),
) )
queryset = queryset.annotate( queryset = queryset.annotate(
parent_default_location=part.filters.annotate_default_location('parent__') parent_default_location=part_filters.annotate_default_location('parent__')
) )
return queryset return queryset
@ -164,7 +164,7 @@ class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
@staticmethod @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Annotate the queryset with the number of subcategories.""" """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() @register_importer()
@ -781,10 +781,10 @@ class PartSerializer(
queryset = queryset.annotate(stock_item_count=SubqueryCount('stock_items')) queryset = queryset.annotate(stock_item_count=SubqueryCount('stock_items'))
# Annotate with the total variant stock quantity # Annotate with the total variant stock quantity
variant_query = part.filters.variant_stock_query() variant_query = part_filters.variant_stock_query()
queryset = queryset.annotate( queryset = queryset.annotate(
variant_stock=part.filters.annotate_variant_quantity( variant_stock=part_filters.annotate_variant_quantity(
variant_query, reference='quantity' variant_query, reference='quantity'
) )
) )
@ -814,10 +814,10 @@ class PartSerializer(
# TODO: Note that BomItemSerializer and BuildLineSerializer have very similar code # TODO: Note that BomItemSerializer and BuildLineSerializer have very similar code
queryset = queryset.annotate( queryset = queryset.annotate(
ordering=part.filters.annotate_on_order_quantity(), ordering=part_filters.annotate_on_order_quantity(),
in_stock=part.filters.annotate_total_stock(), in_stock=part_filters.annotate_total_stock(),
allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(), allocated_to_sales_orders=part_filters.annotate_sales_order_allocations(),
allocated_to_build_orders=part.filters.annotate_build_order_allocations(), allocated_to_build_orders=part_filters.annotate_build_order_allocations(),
) )
# Annotate the queryset with the 'total_in_stock' quantity # Annotate the queryset with the 'total_in_stock' quantity
@ -829,7 +829,7 @@ class PartSerializer(
) )
queryset = queryset.annotate( queryset = queryset.annotate(
external_stock=part.filters.annotate_total_stock( external_stock=part_filters.annotate_total_stock(
filter=Q(location__external=True) filter=Q(location__external=True)
) )
) )
@ -847,12 +847,12 @@ class PartSerializer(
# Annotate with the total 'required for builds' quantity # Annotate with the total 'required for builds' quantity
queryset = queryset.annotate( queryset = queryset.annotate(
required_for_build_orders=part.filters.annotate_build_order_requirements(), required_for_build_orders=part_filters.annotate_build_order_requirements(),
required_for_sales_orders=part.filters.annotate_sales_order_requirements(), required_for_sales_orders=part_filters.annotate_sales_order_requirements(),
) )
queryset = queryset.annotate( queryset = queryset.annotate(
category_default_location=part.filters.annotate_default_location( category_default_location=part_filters.annotate_default_location(
'category__' 'category__'
) )
) )
@ -1684,30 +1684,23 @@ class BomItemSerializer(
# Annotate with the total "on order" amount for the sub-part # Annotate with the total "on order" amount for the sub-part
queryset = queryset.annotate( 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 # Annotate with the total "building" amount for the sub-part
queryset = queryset.annotate( queryset = queryset.annotate(
building=Coalesce( building=part_filters.annotate_in_production_quantity(ref)
SubquerySum(
'sub_part__builds__quantity',
filter=Q(status__in=BuildStatusGroups.ACTIVE_CODES),
),
Decimal(0),
output_field=models.DecimalField(),
)
) )
# Calculate "total stock" for the referenced sub_part # Calculate "total stock" for the referenced sub_part
# Calculate the "build_order_allocations" for the sub_part # Calculate the "build_order_allocations" for the sub_part
# Note that these fields are only aliased, not annotated # Note that these fields are only aliased, not annotated
queryset = queryset.alias( queryset = queryset.alias(
total_stock=part.filters.annotate_total_stock(reference=ref), total_stock=part_filters.annotate_total_stock(reference=ref),
allocated_to_sales_orders=part.filters.annotate_sales_order_allocations( allocated_to_sales_orders=part_filters.annotate_sales_order_allocations(
reference=ref reference=ref
), ),
allocated_to_build_orders=part.filters.annotate_build_order_allocations( allocated_to_build_orders=part_filters.annotate_build_order_allocations(
reference=ref reference=ref
), ),
) )
@ -1724,7 +1717,7 @@ class BomItemSerializer(
# Calculate 'external_stock' # Calculate 'external_stock'
queryset = queryset.annotate( queryset = queryset.annotate(
external_stock=part.filters.annotate_total_stock( external_stock=part_filters.annotate_total_stock(
reference=ref, filter=Q(location__external=True) reference=ref, filter=Q(location__external=True)
) )
) )
@ -1733,11 +1726,11 @@ class BomItemSerializer(
# Extract similar information for any 'substitute' parts # Extract similar information for any 'substitute' parts
queryset = queryset.alias( queryset = queryset.alias(
substitute_stock=part.filters.annotate_total_stock(reference=ref), substitute_stock=part_filters.annotate_total_stock(reference=ref),
substitute_build_allocations=part.filters.annotate_build_order_allocations( substitute_build_allocations=part_filters.annotate_build_order_allocations(
reference=ref reference=ref
), ),
substitute_sales_allocations=part.filters.annotate_sales_order_allocations( substitute_sales_allocations=part_filters.annotate_sales_order_allocations(
reference=ref reference=ref
), ),
) )
@ -1753,16 +1746,16 @@ class BomItemSerializer(
) )
# Annotate the queryset with 'available variant stock' information # 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( 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_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_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' variant_stock_query, reference='allocations__quantity'
), ),
) )

View File

@ -247,11 +247,7 @@ export default function BuildDetail() {
label: t`Line Items`, label: t`Line Items`,
icon: <IconListNumbers />, icon: <IconListNumbers />,
content: build?.pk ? ( content: build?.pk ? (
<BuildLineTable <BuildLineTable buildId={build.pk} />
params={{
build: id
}}
/>
) : ( ) : (
<Skeleton /> <Skeleton />
) )

View File

@ -543,7 +543,7 @@ export default function PartDetail() {
label: t`Variants`, label: t`Variants`,
icon: <IconVersions />, icon: <IconVersions />,
hidden: !part.is_template, hidden: !part.is_template,
content: <PartVariantTable partId={String(id)} /> content: <PartVariantTable part={part} />
}, },
{ {
name: 'allocations', name: 'allocations',

View File

@ -5,11 +5,14 @@ import {
IconShoppingCart, IconShoppingCart,
IconTool IconTool
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { ProgressBar } from '../../components/items/ProgressBar'; import { ProgressBar } from '../../components/items/ProgressBar';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; 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 { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
@ -19,7 +22,13 @@ import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard'; 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 table = useTable('buildline');
const user = useUserState(); const user = useUserState();
@ -213,6 +222,19 @@ export default function BuildLineTable({ params = {} }: { params?: any }) {
]; ];
}, []); }, []);
const buildOrderFields = useBuildOrderFields({ create: true });
const [initialData, setInitialData] = useState<any>({});
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( const rowActions = useCallback(
(record: any) => { (record: any) => {
let part = record.part_detail; let part = record.part_detail;
@ -243,8 +265,16 @@ export default function BuildLineTable({ params = {} }: { params?: any }) {
{ {
icon: <IconTool />, icon: <IconTool />,
title: t`Build Stock`, title: t`Build Stock`,
hidden: !part?.assembly, hidden: !part?.assembly || !user.hasAddRole(UserRoles.build),
color: 'blue' 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 ( return (
<InvenTreeTable <>
url={apiUrl(ApiEndpoints.build_line_list)} {newBuildOrder.modal}
tableState={table} <InvenTreeTable
columns={tableColumns} url={apiUrl(ApiEndpoints.build_line_list)}
props={{ tableState={table}
params: { columns={tableColumns}
...params, props={{
part_detail: true params: {
}, ...params,
tableFilters: tableFilters, build: buildId,
rowActions: rowActions, part_detail: true
modelType: ModelType.part, },
modelField: 'part_detail.pk', tableFilters: tableFilters,
enableDownload: true rowActions: rowActions,
}} modelType: ModelType.part,
/> modelField: 'part_detail.pk',
enableDownload: true
}}
/>
</>
); );
} }

View File

@ -303,20 +303,28 @@ function partTableFilters(): TableFilter[] {
* @param {Object} params - The query parameters to pass to the API * @param {Object} params - The query parameters to pass to the API
* @returns * @returns
*/ */
export function PartListTable({ props }: { props: InvenTreeTableProps }) { export function PartListTable({
props,
defaultPartData
}: {
props: InvenTreeTableProps;
defaultPartData?: any;
}) {
const tableColumns = useMemo(() => partTableColumns(), []); const tableColumns = useMemo(() => partTableColumns(), []);
const tableFilters = useMemo(() => partTableFilters(), []); const tableFilters = useMemo(() => partTableFilters(), []);
const table = useTable('part-list'); const table = useTable('part-list');
const user = useUserState(); const user = useUserState();
const initialPartData = useMemo(() => {
return defaultPartData ?? props.params ?? {};
}, [defaultPartData, props.params]);
const newPart = useCreateApiFormModal({ const newPart = useCreateApiFormModal({
url: ApiEndpoints.part_list, url: ApiEndpoints.part_list,
title: t`Add Part`, title: t`Add Part`,
fields: usePartFields({ create: true }), fields: usePartFields({ create: true }),
initialData: { initialData: initialPartData,
...(props.params ?? {})
},
follow: true, follow: true,
modelType: ModelType.part modelType: ModelType.part
}); });

View File

@ -7,7 +7,7 @@ import { PartListTable } from './PartTable';
/** /**
* Display variant parts for the specified parent part * Display variant parts for the specified parent part
*/ */
export function PartVariantTable({ partId }: { partId: string }) { export function PartVariantTable({ part }: { part: any }) {
const tableFilters: TableFilter[] = useMemo(() => { const tableFilters: TableFilter[] = useMemo(() => {
return [ return [
{ {
@ -39,9 +39,14 @@ export function PartVariantTable({ partId }: { partId: string }) {
enableDownload: false, enableDownload: false,
tableFilters: tableFilters, tableFilters: tableFilters,
params: { params: {
ancestor: partId ancestor: part.pk
} }
}} }}
defaultPartData={{
...part,
variant_of: part.pk,
is_template: false
}}
/> />
); );
} }

View File

@ -13,6 +13,7 @@ import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
import { useBuildOrderFields } from '../../forms/BuildForms';
import { useSalesOrderLineItemFields } from '../../forms/SalesOrderForms'; import { useSalesOrderLineItemFields } from '../../forms/SalesOrderForms';
import { import {
useCreateApiFormModal, useCreateApiFormModal,
@ -122,6 +123,22 @@ export default function SalesOrderLineItemTable({
extra.push(<Text size="sm">{t`Includes variant stock`}</Text>); extra.push(<Text size="sm">{t`Includes variant stock`}</Text>);
} }
if (record.building > 0) {
extra.push(
<Text size="sm">
{t`In production`}: {record.building}
</Text>
);
}
if (record.on_order > 0) {
extra.push(
<Text size="sm">
{t`On order`}: {record.on_order}
</Text>
);
}
return ( return (
<TableHoverCard <TableHoverCard
value={<Text color={color}>{text}</Text>} value={<Text color={color}>{text}</Text>}
@ -199,6 +216,17 @@ export default function SalesOrderLineItemTable({
table: table 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(() => { const tableActions = useMemo(() => {
return [ return [
<AddItemButton <AddItemButton
@ -235,7 +263,15 @@ export default function SalesOrderLineItemTable({
!record?.part_detail?.assembly, !record?.part_detail?.assembly,
title: t`Build stock`, title: t`Build stock`,
icon: <IconTools />, icon: <IconTools />,
color: 'blue' color: 'blue',
onClick: () => {
setInitialData({
part: record.part,
quantity: (record?.quantity ?? 1) - (record?.allocated ?? 0),
sales_order: orderId
});
newBuildOrder.open();
}
}, },
{ {
hidden: hidden:
@ -277,6 +313,7 @@ export default function SalesOrderLineItemTable({
{editLine.modal} {editLine.modal}
{deleteLine.modal} {deleteLine.modal}
{newLine.modal} {newLine.modal}
{newBuildOrder.modal}
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.sales_order_line_list)} url={apiUrl(ApiEndpoints.sales_order_line_list)}
tableState={table} tableState={table}

View File

@ -11,7 +11,7 @@ test('PUI - Pages - Build Order', async ({ page }) => {
await page.getByRole('tab', { name: 'Build', exact: true }).click(); await page.getByRole('tab', { name: 'Build', exact: true }).click();
// We have now loaded the "Build Order" table. Check for some expected texts // 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(); await page.getByText('Pending').first().waitFor();
// Load a particular build order // Load a particular build order