diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 5ffd555400..ecfd6f0314 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 195 +INVENTREE_API_VERSION = 196 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v196 - 2024-05-05 : https://github.com/inventree/InvenTree/pull/7160 + - Adds "location" field to BuildOutputComplete API endpoint + v195 - 2024-05-03 : https://github.com/inventree/InvenTree/pull/7153 - Fixes bug in BuildOrderCancel API endpoint diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index b04c26c83c..f4786e9fbf 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -109,6 +109,12 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo self.validate_reference_field(self.reference) self.reference_int = self.rebuild_reference_field(self.reference) + # On first save (i.e. creation), run some extra checks + if self.pk is None: + # Set the destination location (if not specified) + if not self.destination: + self.destination = self.part.get_default_location() + try: super().save(*args, **kwargs) except InvalidMove: @@ -682,10 +688,13 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo """ user = kwargs.get('user', None) batch = kwargs.get('batch', self.batch) - location = kwargs.get('location', self.destination) + location = kwargs.get('location', None) serials = kwargs.get('serials', None) auto_allocate = kwargs.get('auto_allocate', False) + if location is None: + location = self.destination or self.part.get_default_location() + """ Determine if we can create a single output (with quantity > 0), or multiple outputs (with quantity = 1) diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 8ce542cbf1..b790d9fc82 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -286,6 +286,13 @@ class BuildOutputCreateSerializer(serializers.Serializer): help_text=_('Enter serial numbers for build outputs'), ) + location = serializers.PrimaryKeyRelatedField( + queryset=StockLocation.objects.all(), + label=_('Location'), + help_text=_('Stock location for build output'), + required=False, allow_null=True + ) + def validate_serial_numbers(self, serial_numbers): """Clean the provided serial number string""" serial_numbers = serial_numbers.strip() @@ -310,6 +317,11 @@ class BuildOutputCreateSerializer(serializers.Serializer): quantity = data['quantity'] serial_numbers = data.get('serial_numbers', '') + if part.trackable and not serial_numbers: + raise ValidationError({ + 'serial_numbers': _('Serial numbers must be provided for trackable parts') + }) + if serial_numbers: try: @@ -346,19 +358,15 @@ class BuildOutputCreateSerializer(serializers.Serializer): """Generate the new build output(s)""" data = self.validated_data - quantity = data['quantity'] - batch_code = data.get('batch_code', '') - auto_allocate = data.get('auto_allocate', False) - build = self.get_build() - user = self.context['request'].user build.create_build_output( - quantity, + data['quantity'], serials=self.serials, - batch=batch_code, - auto_allocate=auto_allocate, - user=user, + batch=data.get('batch_code', ''), + location=data.get('location', None), + auto_allocate=data.get('auto_allocate', False), + user=self.context['request'].user, ) diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index ae6d57a3e0..1d26224586 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -87,7 +87,7 @@ export type ApiFormFieldType = { description?: string; preFieldContent?: JSX.Element; postFieldContent?: JSX.Element; - onValueChange?: (value: any) => void; + onValueChange?: (value: any, record?: any) => void; adjustFilters?: (value: ApiFormAdjustFilterType) => any; headers?: string[]; }; diff --git a/src/frontend/src/components/forms/fields/RelatedModelField.tsx b/src/frontend/src/components/forms/fields/RelatedModelField.tsx index d02bfcf321..d53e732611 100644 --- a/src/frontend/src/components/forms/fields/RelatedModelField.tsx +++ b/src/frontend/src/components/forms/fields/RelatedModelField.tsx @@ -187,7 +187,7 @@ export function RelatedModelField({ setPk(_pk); // Run custom callback for this field (if provided) - definition.onValueChange?.(_pk); + definition.onValueChange?.(_pk, value.data ?? {}); }, [field.onChange, definition] ); diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index d92d152f8e..e8e5ebc844 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -53,6 +53,10 @@ export enum ApiEndpoints { // Build API endpoints build_order_list = 'build/', build_order_cancel = 'build/:id/cancel/', + build_output_create = 'build/:id/create-output/', + build_output_complete = 'build/:id/complete/', + build_output_scrap = 'build/:id/scrap-outputs/', + build_output_delete = 'build/:id/delete-outputs/', build_order_attachment_list = 'build/attachment/', build_line_list = 'build/line/', @@ -64,6 +68,7 @@ export enum ApiEndpoints { part_parameter_template_list = 'part/parameter/template/', part_thumbs_list = 'part/thumbs/', part_pricing_get = 'part/:id/pricing/', + part_serial_numbers = 'part/:id/serial-numbers/', part_pricing_internal = 'part/internal-price/', part_pricing_sale = 'part/sale-price/', part_stocktake_list = 'part/stocktake/', diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index dd4523a6bc..b22dff4f74 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -1,3 +1,5 @@ +import { t } from '@lingui/macro'; +import { ActionIcon, Alert, Stack, Text } from '@mantine/core'; import { IconCalendar, IconLink, @@ -7,9 +9,18 @@ import { IconUser, IconUsersGroup } from '@tabler/icons-react'; -import { useMemo } from 'react'; +import { DataTable } from 'mantine-datatable'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { api } from '../App'; +import { ActionButton } from '../components/buttons/ActionButton'; import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; +import { ApiEndpoints } from '../enums/ApiEndpoints'; +import { ModelType } from '../enums/ModelType'; +import { InvenTreeIcon } from '../functions/icons'; +import { useCreateApiFormModal } from '../hooks/UseForm'; +import { apiUrl } from '../states/ApiState'; +import { PartColumn, StatusColumn } from '../tables/ColumnRenderers'; /** * Field set for BuildOrder forms @@ -19,6 +30,10 @@ export function useBuildOrderFields({ }: { create: boolean; }): ApiFormFieldSet { + const [destination, setDestination] = useState( + null + ); + return useMemo(() => { return { reference: {}, @@ -26,6 +41,14 @@ export function useBuildOrderFields({ filters: { assembly: true, virtual: false + }, + onValueChange(value: any, record?: any) { + // Adjust the destination location for the build order + if (record) { + setDestination( + record.default_location || record.category_default_location + ); + } } }, title: {}, @@ -51,7 +74,8 @@ export function useBuildOrderFields({ destination: { filters: { structural: false - } + }, + value: destination }, link: { icon: @@ -66,5 +90,337 @@ export function useBuildOrderFields({ } } }; - }, [create]); + }, [create, destination]); +} + +export function useBuildOrderOutputFields({ + build +}: { + build: any; +}): ApiFormFieldSet { + const trackable: boolean = useMemo(() => { + return build.part_detail?.trackable ?? false; + }, [build.part_detail]); + + const [location, setLocation] = useState(null); + + useEffect(() => { + setLocation(build.location || build.part_detail?.default_location || null); + }, [build.location, build.part_detail]); + + const [quantity, setQuantity] = useState(0); + + useEffect(() => { + let build_quantity = build.quantity ?? 0; + let build_complete = build.completed ?? 0; + + setQuantity(Math.max(0, build_quantity - build_complete)); + }, [build]); + + const [serialPlaceholder, setSerialPlaceholder] = useState(''); + + useEffect(() => { + if (trackable) { + api + .get(apiUrl(ApiEndpoints.part_serial_numbers, build.part_detail.pk)) + .then((response: any) => { + if (response.data?.next) { + setSerialPlaceholder( + t`Next serial number` + ' - ' + response.data.next + ); + } else if (response.data?.latest) { + setSerialPlaceholder( + t`Latest serial number` + ' - ' + response.data.latest + ); + } else { + setSerialPlaceholder(''); + } + }) + .catch(() => { + setSerialPlaceholder(''); + }); + } else { + setSerialPlaceholder(''); + } + }, [build, trackable]); + + return useMemo(() => { + return { + quantity: { + value: quantity, + onValueChange: (value: any) => { + setQuantity(value); + } + }, + serial_numbers: { + hidden: !trackable, + placeholder: serialPlaceholder + }, + batch_code: {}, + location: { + value: location, + onValueChange: (value: any) => { + setQuantity(value); + } + }, + auto_allocate: { + hidden: !trackable + } + }; + }, [quantity, serialPlaceholder, trackable]); +} + +/* + * Construct a table of build outputs, for displaying at the top of a form + */ +function buildOutputFormTable(outputs: any[], onRemove: (output: any) => void) { + return ( + PartColumn(record.part_detail) + }, + { + accessor: 'quantity', + title: t`Quantity`, + render: (record: any) => { + if (record.serial) { + return `# ${record.serial}`; + } else { + return record.quantity; + } + } + }, + StatusColumn({ model: ModelType.stockitem, sortable: false }), + { + accessor: 'actions', + title: '', + render: (record: any) => ( + } + color="red" + onClick={() => onRemove(record)} + disabled={outputs.length <= 1} + /> + ) + } + ]} + /> + ); +} + +export function useCompleteBuildOutputsForm({ + build, + outputs, + onFormSuccess +}: { + build: any; + outputs: any[]; + onFormSuccess: (response: any) => void; +}) { + const [selectedOutputs, setSelectedOutputs] = useState([]); + + const [location, setLocation] = useState(null); + + useEffect(() => { + setSelectedOutputs(outputs); + }, [outputs]); + + useEffect(() => { + if (location) { + return; + } + + setLocation( + build.destination || build.part_detail?.default_location || null + ); + }, [location, build.destination, build.part_detail]); + + // Remove a selected output from the list + const removeOutput = useCallback( + (output: any) => { + setSelectedOutputs( + selectedOutputs.filter((item) => item.pk != output.pk) + ); + }, + [selectedOutputs] + ); + + const preFormContent = useMemo(() => { + return buildOutputFormTable(selectedOutputs, removeOutput); + }, [selectedOutputs, removeOutput]); + + const buildOutputCompleteFields: ApiFormFieldSet = useMemo(() => { + return { + outputs: { + hidden: true, + value: selectedOutputs.map((output) => { + return { + output: output.pk + }; + }) + }, + status: {}, + location: { + filters: { + structural: false + }, + value: location, + onValueChange: (value) => { + setLocation(value); + } + }, + notes: {}, + accept_incomplete_allocation: {} + }; + }, [selectedOutputs, location]); + + return useCreateApiFormModal({ + url: apiUrl(ApiEndpoints.build_output_complete, build.pk), + method: 'POST', + title: t`Complete Build Outputs`, + fields: buildOutputCompleteFields, + onFormSuccess: onFormSuccess, + preFormContent: preFormContent, + successMessage: t`Build outputs have been completed` + }); +} + +export function useScrapBuildOutputsForm({ + build, + outputs, + onFormSuccess +}: { + build: any; + outputs: any[]; + onFormSuccess: (response: any) => void; +}) { + const [location, setLocation] = useState(null); + const [selectedOutputs, setSelectedOutputs] = useState([]); + + useEffect(() => { + setSelectedOutputs(outputs); + }, [outputs]); + + // Remove a selected output from the list + const removeOutput = useCallback( + (output: any) => { + setSelectedOutputs( + selectedOutputs.filter((item) => item.pk != output.pk) + ); + }, + [selectedOutputs] + ); + + useEffect(() => { + if (location) { + return; + } + + setLocation( + build.destination || build.part_detail?.default_location || null + ); + }, [location, build.destination, build.part_detail]); + + const preFormContent = useMemo(() => { + return buildOutputFormTable(selectedOutputs, removeOutput); + }, [selectedOutputs, removeOutput]); + + const buildOutputScrapFields: ApiFormFieldSet = useMemo(() => { + return { + outputs: { + hidden: true, + value: selectedOutputs.map((output) => { + return { + output: output.pk, + quantity: output.quantity + }; + }) + }, + location: { + value: location, + onValueChange: (value) => { + setLocation(value); + } + }, + notes: {}, + discard_allocations: {} + }; + }, [location, selectedOutputs]); + + return useCreateApiFormModal({ + url: apiUrl(ApiEndpoints.build_output_scrap, build.pk), + method: 'POST', + title: t`Scrap Build Outputs`, + fields: buildOutputScrapFields, + onFormSuccess: onFormSuccess, + preFormContent: preFormContent, + successMessage: t`Build outputs have been scrapped` + }); +} + +export function useCancelBuildOutputsForm({ + build, + outputs, + onFormSuccess +}: { + build: any; + outputs: any[]; + onFormSuccess: (response: any) => void; +}) { + const [selectedOutputs, setSelectedOutputs] = useState([]); + + useEffect(() => { + setSelectedOutputs(outputs); + }, [outputs]); + + // Remove a selected output from the list + const removeOutput = useCallback( + (output: any) => { + setSelectedOutputs( + selectedOutputs.filter((item) => item.pk != output.pk) + ); + }, + [selectedOutputs] + ); + + const preFormContent = useMemo(() => { + return ( + + + {t`Selected build outputs will be deleted`} + + {buildOutputFormTable(selectedOutputs, removeOutput)} + + ); + }, [selectedOutputs, removeOutput]); + + const buildOutputCancelFields: ApiFormFieldSet = useMemo(() => { + return { + outputs: { + hidden: true, + value: selectedOutputs.map((output) => { + return { + output: output.pk + }; + }) + } + }; + }, [selectedOutputs]); + + return useCreateApiFormModal({ + url: apiUrl(ApiEndpoints.build_output_delete, build.pk), + method: 'POST', + title: t`Cancel Build Outputs`, + fields: buildOutputCancelFields, + preFormContent: preFormContent, + onFormSuccess: onFormSuccess, + successMessage: t`Build outputs have been cancelled` + }); } diff --git a/src/frontend/src/hooks/UseTable.tsx b/src/frontend/src/hooks/UseTable.tsx index 19ed88aa56..a756715501 100644 --- a/src/frontend/src/hooks/UseTable.tsx +++ b/src/frontend/src/hooks/UseTable.tsx @@ -79,10 +79,9 @@ export function useTable(tableName: string): TableState { setSelectedRecords([]); }, []); - const hasSelectedRecords = useMemo( - () => selectedRecords.length > 0, - [selectedRecords] - ); + const hasSelectedRecords = useMemo(() => { + return selectedRecords.length > 0; + }, [selectedRecords]); // Total record count const [recordCount, setRecordCount] = useState(0); diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 3efea00a9f..5d5568e484 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -220,11 +220,7 @@ export default function BuildDetail() { name: 'incomplete-outputs', label: t`Incomplete Outputs`, icon: , - content: build.pk ? ( - - ) : ( - - ) + content: build.pk ? : // TODO: Hide if build is complete }, { @@ -233,6 +229,8 @@ export default function BuildDetail() { icon: , content: ( , content: ( ) { icon: , hidden: !company?.is_manufacturer && !company?.is_supplier, content: company?.pk && ( - + ) }, { @@ -222,7 +226,11 @@ export default function CompanyDetail(props: Readonly) { icon: , hidden: !company?.is_customer, content: company?.pk ? ( - + ) : ( ) diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 50be4a95f8..1bb983e0c2 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -486,6 +486,8 @@ export default function PartDetail() { icon: , content: part.pk && ( , content: ( , content: ( , hidden: (stockitem?.child_items ?? 0) == 0, content: stockitem?.pk ? ( - + ) : ( ) diff --git a/src/frontend/src/tables/ColumnRenderers.tsx b/src/frontend/src/tables/ColumnRenderers.tsx index 83cfdf0884..4b02224c9a 100644 --- a/src/frontend/src/tables/ColumnRenderers.tsx +++ b/src/frontend/src/tables/ColumnRenderers.tsx @@ -170,10 +170,18 @@ export function ProjectCodeColumn(): TableColumn { }; } -export function StatusColumn(model: ModelType) { +export function StatusColumn({ + model, + sortable, + accessor +}: { + model: ModelType; + sortable?: boolean; + accessor?: string; +}) { return { - accessor: 'status', - sortable: true, + accessor: accessor ?? 'status', + sortable: sortable ?? true, render: TableStatusRenderer(model) }; } diff --git a/src/frontend/src/tables/build/BuildOrderTable.tsx b/src/frontend/src/tables/build/BuildOrderTable.tsx index db78ec4814..5857761554 100644 --- a/src/frontend/src/tables/build/BuildOrderTable.tsx +++ b/src/frontend/src/tables/build/BuildOrderTable.tsx @@ -59,7 +59,7 @@ function buildOrderTableColumns(): TableColumn[] { /> ) }, - StatusColumn(ModelType.build), + StatusColumn({ model: ModelType.build }), ProjectCodeColumn(), { accessor: 'priority', diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx index 31ccdd8238..4e4b567f59 100644 --- a/src/frontend/src/tables/build/BuildOutputTable.tsx +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -2,7 +2,7 @@ import { t } from '@lingui/macro'; import { Group, Text } from '@mantine/core'; import { IconCircleCheck, IconCircleX } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { api } from '../../App'; import { ActionButton } from '../../components/buttons/ActionButton'; @@ -11,7 +11,14 @@ import { ProgressBar } from '../../components/items/ProgressBar'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; +import { + useBuildOrderOutputFields, + useCancelBuildOutputsForm, + useCompleteBuildOutputsForm, + useScrapBuildOutputsForm +} from '../../forms/BuildForms'; import { InvenTreeIcon } from '../../functions/icons'; +import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; @@ -26,19 +33,21 @@ type TestResultOverview = { result: boolean; }; -export default function BuildOutputTable({ - buildId, - partId -}: { - buildId: number; - partId: number; -}) { +export default function BuildOutputTable({ build }: { build: any }) { const user = useUserState(); const table = useTable('build-outputs'); + const buildId: number = useMemo(() => { + return build.pk ?? -1; + }, [build.pk]); + + const partId: number = useMemo(() => { + return build.part ?? -1; + }, [build.part]); + // Fetch the test templates associated with the partId const { data: testTemplates } = useQuery({ - queryKey: ['buildoutputtests', partId], + queryKey: ['buildoutputtests', build.part], queryFn: async () => { if (!partId) { return []; @@ -98,36 +107,82 @@ export default function BuildOutputTable({ [partId, testTemplates] ); + const buildOutputFields = useBuildOrderOutputFields({ build: build }); + + const addBuildOutput = useCreateApiFormModal({ + url: apiUrl(ApiEndpoints.build_output_create, buildId), + title: t`Add Build Output`, + fields: buildOutputFields, + onFormSuccess: () => { + table.refreshTable(); + } + }); + + const [selectedOutputs, setSelectedOutputs] = useState([]); + + const completeBuildOutputsForm = useCompleteBuildOutputsForm({ + build: build, + outputs: selectedOutputs, + onFormSuccess: () => { + table.refreshTable(); + } + }); + + const scrapBuildOutputsForm = useScrapBuildOutputsForm({ + build: build, + outputs: selectedOutputs, + onFormSuccess: () => { + table.refreshTable(); + } + }); + + const cancelBuildOutputsForm = useCancelBuildOutputsForm({ + build: build, + outputs: selectedOutputs, + onFormSuccess: () => { + table.refreshTable(); + } + }); + const tableActions = useMemo(() => { - // TODO: Button to create new build output - // TODO: Button to complete output(s) - // TODO: Button to cancel output(s) - // TODO: Button to scrap output(s) return [