2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 03:26:45 +00:00

[UI] Barcode actions (#9538)

* Provide callback function for barcode scan dialog

* Adds hook for barcode scan dialog

* Fix callback processing

* Add function to extract API URL for a model instance

* Tweak page titles

* Extract instance data when scanning a barcode

* Scan item into location

* Scan in stock location

* Remove notYetImplemented func

* Revert "Remove notYetImplemented func"

This reverts commit a35408380e749c1d9054fd4fe7bcabec71863982.

* Add stock item action

* Add playwright tests
This commit is contained in:
Oliver 2025-04-21 08:42:54 +10:00 committed by GitHub
parent 7d87b8b896
commit 79dad13328
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 344 additions and 58 deletions

View File

@ -10,7 +10,7 @@ from django.db import models
from django.db.models import QuerySet
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.urls import reverse
from django.urls import resolve, reverse
from django.urls.exceptions import NoReverseMatch
from django.utils.translation import gettext_lazy as _
@ -958,7 +958,17 @@ class InvenTreeBarcodeMixin(models.Model):
if hasattr(self, 'get_api_url'):
api_url = self.get_api_url()
data['api_url'] = f'{api_url}{self.pk}/'
data['api_url'] = api_url = f'{api_url}{self.pk}/'
# Attempt to serialize the object too
try:
match = resolve(api_url)
view_class = match.func.view_class
serializer_class = view_class.serializer_class
serializer = serializer_class(self)
data['instance'] = serializer.data
except Exception:
pass
if hasattr(self, 'get_absolute_url'):
data['web_url'] = self.get_absolute_url()

View File

@ -1,13 +1,15 @@
import type { NavigateFunction } from 'react-router-dom';
import { ModelInformationDict } from '../enums/ModelInformation';
import type { ModelType } from '../enums/ModelType';
import { apiUrl } from './Api';
import { cancelEvent } from './Events';
export const getBaseUrl = (): string =>
(window as any).INVENTREE_SETTINGS?.base_url || 'web';
/**
* Returns the detail view URL for a given model type
* Returns the detail view URL for a given model type.
* This is the UI URL, not the API URL.
*/
export function getDetailUrl(
model: ModelType,
@ -35,6 +37,27 @@ export function getDetailUrl(
return '';
}
/**
* Returns the API detail URL for a given model type.
*/
export function getApiUrl(
model: ModelType,
pk: number | string
): string | undefined {
const modelInfo = ModelInformationDict[model];
if (pk === undefined || pk === null) {
return '';
}
if (!!pk && modelInfo && modelInfo.api_endpoint) {
return apiUrl(modelInfo.api_endpoint, pk);
}
console.error(`No API detail URL found for model ${model} <${pk}>`);
return undefined;
}
/*
* Navigate to a provided link.
* - If the link is to be opened externally, open it in a new tab.

View File

@ -5,6 +5,7 @@ import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import { t } from '@lingui/core/macro';
import { Box, Divider, Modal } from '@mantine/core';
import { hideNotification, showNotification } from '@mantine/notifications';
import { useCallback, useState } from 'react';
import { type NavigateFunction, useNavigate } from 'react-router-dom';
import { api } from '../../App';
@ -13,13 +14,29 @@ import { useUserState } from '../../states/UserState';
import { StylishText } from '../items/StylishText';
import { BarcodeInput } from './BarcodeInput';
export type BarcodeScanResult = {
success?: string;
error?: string;
};
// Callback function for handling a barcode scan
// This function should return true if the barcode was handled successfully
export type BarcodeScanCallback = (
barcode: string,
response: any
) => Promise<BarcodeScanResult>;
export default function BarcodeScanDialog({
title,
opened,
callback,
modelType,
onClose
}: Readonly<{
title?: string;
opened: boolean;
modelType?: ModelType;
callback?: BarcodeScanCallback;
onClose: () => void;
}>) {
const navigate = useNavigate();
@ -33,38 +50,43 @@ export default function BarcodeScanDialog({
>
<Divider />
<Box>
<ScanInputHandler navigate={navigate} onClose={onClose} />
<ScanInputHandler
navigate={navigate}
onClose={onClose}
modelType={modelType}
callback={callback}
/>
</Box>
</Modal>
);
}
export function ScanInputHandler({
callback,
modelType,
onClose,
navigate
}: Readonly<{ onClose: () => void; navigate: NavigateFunction }>) {
}: Readonly<{
callback?: BarcodeScanCallback;
onClose: () => void;
modelType?: ModelType;
navigate: NavigateFunction;
}>) {
const [error, setError] = useState<string>('');
const [processing, setProcessing] = useState<boolean>(false);
const user = useUserState();
const onScan = useCallback((barcode: string) => {
if (!barcode || barcode.length === 0) {
return;
}
setProcessing(true);
api
.post(apiUrl(ApiEndpoints.barcode), {
barcode: barcode
})
.then((response) => {
setError('');
const data = response.data ?? {};
const defaultScan = useCallback(
(data: any) => {
let match = false;
// Find the matching model type
for (const model_type of Object.keys(ModelInformationDict)) {
// If a specific model type is provided, check if it matches
if (modelType && model_type !== modelType) {
continue;
}
if (data[model_type]?.['pk']) {
if (user.hasViewPermission(model_type as ModelType)) {
const url = getDetailUrl(
@ -82,6 +104,61 @@ export function ScanInputHandler({
if (!match) {
setError(t`No matching item found`);
}
},
[navigate, onClose, user, modelType]
);
const onScan = useCallback(
(barcode: string) => {
if (!barcode || barcode.length === 0) {
return;
}
setProcessing(true);
setError('');
api
.post(apiUrl(ApiEndpoints.barcode), {
barcode: barcode
})
.then((response: any) => {
const data = response.data ?? {};
if (callback && data.success && response.status === 200) {
const instance = null;
// If the caller is expecting a specific model type, check if it matches
if (modelType) {
const pk: number = data[modelType]?.['pk'];
if (!pk) {
setError(t`Barcode does not match the expected model type`);
return;
}
}
callback(barcode, data)
.then((result: BarcodeScanResult) => {
if (result.success) {
hideNotification('barcode-scan');
showNotification({
id: 'barcode-scan',
title: t`Success`,
message: result.success,
color: 'green'
});
onClose();
} else {
setError(result.error ?? t`Failed to handle barcode`);
}
})
.finally(() => {
setProcessing(false);
});
} else {
// If no callback is provided, use the default scan function
defaultScan(data);
setProcessing(false);
}
})
.catch((error) => {
const _error = extractErrorMessage({
@ -95,7 +172,40 @@ export function ScanInputHandler({
.finally(() => {
setProcessing(false);
});
}, []);
},
[callback, defaultScan, modelType, onClose]
);
return <BarcodeInput onScan={onScan} error={error} processing={processing} />;
}
export function useBarcodeScanDialog({
title,
callback,
modelType
}: Readonly<{
title: string;
modelType?: ModelType;
callback: BarcodeScanCallback;
}>) {
const [opened, setOpened] = useState(false);
const open = useCallback(() => {
setOpened(true);
}, []);
const dialog = (
<BarcodeScanDialog
title={title}
opened={opened}
callback={callback}
modelType={modelType}
onClose={() => setOpened(false)}
/>
);
return {
open,
dialog
};
}

View File

@ -342,8 +342,8 @@ export default function CategoryDetail() {
selectedId={category?.pk}
/>
<PageDetail
title={t`Part Category`}
subtitle={category?.name}
title={category?.name ?? t`Part Category`}
subtitle={category?.description}
icon={category?.icon && <ApiIcon name={category?.icon} />}
breadcrumbs={breadcrumbs}
breadcrumbAction={() => {

View File

@ -2,11 +2,14 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { getDetailUrl } from '@lib/functions/Navigation';
import { apiUrl } from '@lib/index';
import { t } from '@lingui/core/macro';
import { Group, Skeleton, Stack, Text } from '@mantine/core';
import { IconInfoCircle, IconPackages, IconSitemap } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../../App';
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import {
@ -35,7 +38,6 @@ import {
useTransferStockItem
} from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
import { notYetImplemented } from '../../functions/notifications';
import {
useDeleteApiFormModal,
useEditApiFormModal
@ -272,6 +274,62 @@ export default function Stock() {
const transferStockItems = useTransferStockItem(stockItemActionProps);
const countStockItems = useCountStockItem(stockItemActionProps);
const scanInStockItem = useBarcodeScanDialog({
title: t`Scan Stock Item`,
modelType: ModelType.stockitem,
callback: async (barcode, response) => {
const item = response.stockitem.instance;
// Scan the stock item into the current location
return api
.post(apiUrl(ApiEndpoints.stock_transfer), {
location: location.pk,
items: [
{
pk: item.pk,
quantity: item.quantity
}
]
})
.then(() => {
return {
success: t`Scanned stock item into location`
};
})
.catch((error) => {
console.error('Error scanning stock item:', error);
return {
error: t`Error scanning stock item`
};
});
}
});
const scanInStockLocation = useBarcodeScanDialog({
title: t`Scan Stock Location`,
modelType: ModelType.stocklocation,
callback: async (barcode, response) => {
const pk = response.stocklocation.pk;
// Set the parent location
return api
.patch(apiUrl(ApiEndpoints.stock_location_list, pk), {
parent: location.pk
})
.then(() => {
return {
success: t`Scanned stock location into location`
};
})
.catch((error) => {
console.error('Error scanning stock location:', error);
return {
error: t`Error scanning stock location`
};
});
}
});
const locationActions = useMemo(
() => [
<AdminButton model={ModelType.stocklocation} id={location.pk} />,
@ -286,14 +344,14 @@ export default function Stock() {
{
name: 'Scan in stock items',
icon: <InvenTreeIcon icon='stock' />,
tooltip: 'Scan items',
onClick: notYetImplemented
tooltip: 'Scan item into this location',
onClick: scanInStockItem.open
},
{
name: 'Scan in container',
icon: <InvenTreeIcon icon='unallocated_stock' />,
tooltip: 'Scan container',
onClick: notYetImplemented
tooltip: 'Scan container into this location',
onClick: scanInStockLocation.open
}
]}
/>
@ -362,6 +420,8 @@ export default function Stock() {
<>
{editLocation.modal}
{deleteLocation.modal}
{scanInStockItem.dialog}
{scanInStockLocation.dialog}
<InstanceDetail
status={requestStatus}
loading={id ? instanceQuery.isFetching : false}
@ -377,8 +437,8 @@ export default function Stock() {
selectedId={location?.pk}
/>
<PageDetail
title={t`Stock Items`}
subtitle={location?.name}
title={location?.name ?? t`Stock Location`}
subtitle={location?.description}
icon={location?.icon && <ApiIcon name={location?.icon} />}
actions={locationActions}
editAction={editLocation.open}

View File

@ -33,6 +33,7 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
import { ActionButton } from '../../components/buttons/ActionButton';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
@ -749,6 +750,37 @@ export default function StockDetail() {
parts: stockitem.part_detail ? [stockitem.part_detail] : []
});
const scanIntoLocation = useBarcodeScanDialog({
title: t`Scan Into Location`,
modelType: ModelType.stocklocation,
callback: async (barcode, response) => {
const pk = response.stocklocation.pk;
return api
.post(apiUrl(ApiEndpoints.stock_transfer), {
location: pk,
items: [
{
pk: stockitem.pk,
quantity: stockitem.quantity
}
]
})
.then(() => {
refreshInstance();
return {
success: t`Scanned stock item into location`
};
})
.catch((error) => {
console.log('Error scanning stock item:', error);
return {
error: t`Error scanning stock item`
};
});
}
});
const stockActions = useMemo(() => {
// Can this stock item be transferred to a different location?
const canTransfer =
@ -775,6 +807,14 @@ export default function StockDetail() {
pk={stockitem.pk}
hash={stockitem?.barcode_hash}
perm={user.hasChangeRole(UserRoles.stock)}
actions={[
{
name: t`Scan into location`,
icon: <InvenTreeIcon icon='location' />,
tooltip: t`Scan this item into a location`,
onClick: scanIntoLocation.open
}
]}
/>,
<PrintingActions
modelType={ModelType.stockitem}
@ -974,6 +1014,7 @@ export default function StockDetail() {
return (
<>
{findBySerialNumber.modal}
{scanIntoLocation.dialog}
<InstanceDetail
requiredRole={UserRoles.stock}
status={requestStatus}

View File

@ -236,6 +236,17 @@ test('Stock - Stock Actions', async ({ browser }) => {
.waitFor();
await page.getByText('123').first().waitFor();
// Check barcode actions
await page.getByLabel('action-menu-barcode-actions').click();
await page
.getByLabel('action-menu-barcode-actions-scan-into-location')
.click();
await page
.getByPlaceholder('Enter barcode data')
.fill('{"stocklocation": 12}');
await page.getByRole('button', { name: 'Scan', exact: true }).click();
await page.getByText('Scanned stock item into location').waitFor();
// Add stock, and change status
await launchStockAction('add');
await page.getByLabel('number-field-quantity').fill('12');
@ -284,3 +295,34 @@ test('Stock - Tracking', async ({ browser }) => {
await page.getByRole('link', { name: 'Widget Assembly' }).waitFor();
await page.getByRole('cell', { name: 'Installed into assembly' }).waitFor();
});
test('Stock - Location', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'stock/location/12/' });
await loadTab(page, 'Default Parts');
await loadTab(page, 'Stock Items');
await loadTab(page, 'Stock Locations');
await loadTab(page, 'Location Details');
await page.getByLabel('action-menu-barcode-actions').click();
await page
.getByLabel('action-menu-barcode-actions-scan-in-stock-items')
.waitFor();
await page
.getByLabel('action-menu-barcode-actions-scan-in-container')
.click();
// Attempt to scan in the same location (should fail)
await page
.getByPlaceholder('Enter barcode data')
.fill('{"stocklocation": 12}');
await page.getByRole('button', { name: 'Scan', exact: true }).click();
await page.getByText('Error scanning stock location').waitFor();
// Attempt to scan bad data (no match)
await page
.getByPlaceholder('Enter barcode data')
.fill('{"stocklocation": 1234}');
await page.getByRole('button', { name: 'Scan', exact: true }).click();
await page.getByText('No match found for barcode data').waitFor();
});