2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-24 18:07:38 +00:00

Supplier Mixin (#9761)

* commit initial draft for supplier import

* complete import wizard

* allow importing only mp and sp

* improved sample supplier plugin

* add docs

* add tests

* bump api version

* fix schema docu

* fix issues from code review

* commit unstaged changes

* fix test

* refactor part parameter bulk creation

* try to fix test

* fix tests

* fix test for mysql

* fix test

* support multiple suppliers by a single plugin

* hide import button if there is no supplier import plugin

* make form submitable via enter

* add pui test

* try to prevent race condition

* refactor api calls in pui tests

* try to fix tests again?

* fix tests

* trigger: ci

* update changelog

* fix api_version

* fix style

* Update CHANGELOG.md

Co-authored-by: Matthias Mair <code@mjmair.com>

* add user docs

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Lukas Wolf
2025-10-17 22:13:03 +02:00
committed by GitHub
parent d534f67c62
commit de270a5fe7
41 changed files with 2298 additions and 119 deletions

View File

@@ -220,6 +220,9 @@ export enum ApiEndpoints {
// Special plugin endpoints
plugin_locate_item = 'locate/',
plugin_supplier_list = 'supplier/list/',
plugin_supplier_search = 'supplier/search/',
plugin_supplier_import = 'supplier/import/',
// Machine API endpoints
machine_types_list = 'machine/types/',

View File

@@ -115,6 +115,8 @@ export function OptionsApiForm({
if (!_props.fields) return _props;
_props.fields = { ..._props.fields };
for (const [k, v] of Object.entries(_props.fields)) {
_props.fields[k] = constructField({
field: v,

View File

@@ -0,0 +1,811 @@
import { ApiEndpoints, ModelType, apiUrl } from '@lib/index';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
ActionIcon,
Badge,
Box,
Button,
Center,
Checkbox,
Divider,
Group,
Loader,
Paper,
ScrollAreaAutosize,
Select,
Stack,
Text,
TextInput,
Title,
Tooltip
} from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconArrowDown, IconPlus } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import {
type FormEventHandler,
type ReactNode,
useCallback,
useEffect,
useMemo,
useState
} from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../App';
import { usePartFields } from '../../forms/PartForms';
import { InvenTreeIcon } from '../../functions/icons';
import { useEditApiFormModal } from '../../hooks/UseForm';
import useWizard from '../../hooks/UseWizard';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import { StandaloneField } from '../forms/StandaloneField';
import { RenderRemoteInstance } from '../render/Instance';
type SearchResult = {
id: string;
sku: string;
name: string;
exact: boolean;
description?: string;
price?: string;
link?: string;
image_url?: string;
existing_part_id?: number;
};
type ImportResult = {
manufacturer_part_id: number;
supplier_part_id: number;
part_id: number;
pricing: { [priceBreak: number]: [number, string] };
part_detail: any;
parameters: {
name: string;
value: string;
parameter_template: number | null;
on_category: boolean;
}[];
};
const SearchResult = ({
searchResult,
partId,
rightSection
}: {
searchResult: SearchResult;
partId?: number;
rightSection?: ReactNode;
}) => {
return (
<Paper key={searchResult.id} withBorder p='md' shadow='xs'>
<Group justify='space-between' align='flex-start' gap='xs'>
{searchResult.image_url && (
<img
src={searchResult.image_url}
alt={searchResult.name}
style={{ maxHeight: '50px' }}
/>
)}
<Stack gap={0} flex={1}>
<a href={searchResult.link} target='_blank' rel='noopener noreferrer'>
<Text size='lg' w={500}>
{searchResult.name} ({searchResult.sku})
</Text>
</a>
<Text size='sm'>{searchResult.description}</Text>
</Stack>
<Group gap='xs'>
{searchResult.price && (
<Text size='sm' c='primary'>
{searchResult.price}
</Text>
)}
{searchResult.exact && (
<Badge size='sm' color='green'>
<Trans>Exact Match</Trans>
</Badge>
)}
{searchResult.existing_part_id &&
partId &&
searchResult.existing_part_id === partId && (
<Badge size='sm' color='orange'>
<Trans>Current part</Trans>
</Badge>
)}
{searchResult.existing_part_id && (
<Link to={`/part/${searchResult.existing_part_id}`}>
<Badge size='sm' color='blue'>
<Trans>Already Imported</Trans>
</Badge>
</Link>
)}
{rightSection}
</Group>
</Group>
</Paper>
);
};
const SearchStep = ({
selectSupplierPart,
partId
}: {
selectSupplierPart: (props: {
plugin: string;
supplier: string;
searchResult: SearchResult;
}) => void;
partId?: number;
}) => {
const [searchValue, setSearchValue] = useState('');
const [supplier, setSupplier] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const supplierQuery = useQuery<
{ plugin_slug: string; supplier_slug: string; supplier_name: string }[]
>({
queryKey: ['supplier-import-list'],
queryFn: () =>
api
.get(apiUrl(ApiEndpoints.plugin_supplier_list))
.then((response) => response.data ?? []),
enabled: true
});
const handleSearch = useCallback<FormEventHandler<HTMLFormElement>>(
async (e) => {
e.preventDefault();
if (!searchValue || !supplier) return;
setIsLoading(true);
const [plugin_slug, supplier_slug] = JSON.parse(supplier);
const res = await api.get(apiUrl(ApiEndpoints.plugin_supplier_search), {
params: {
plugin: plugin_slug,
supplier: supplier_slug,
term: searchValue
}
});
setSearchResults(res.data ?? []);
setIsLoading(false);
},
[supplier, searchValue]
);
useEffect(() => {
if (
supplier === '' &&
supplierQuery.data &&
supplierQuery.data.length > 0
) {
setSupplier(
JSON.stringify([
supplierQuery.data[0].plugin_slug,
supplierQuery.data[0].supplier_slug
])
);
}
}, [supplierQuery.data]);
return (
<Stack>
<form onSubmit={handleSearch}>
<Group align='flex-end'>
<TextInput
aria-label='textbox-search-for-part'
flex={1}
placeholder='Search for a part'
label={t`Search...`}
value={searchValue}
onChange={(event) => setSearchValue(event.currentTarget.value)}
/>
<Select
label={t`Supplier`}
value={supplier}
onChange={(value) => setSupplier(value ?? '')}
data={
supplierQuery.data?.map((supplier) => ({
value: JSON.stringify([
supplier.plugin_slug,
supplier.supplier_slug
]),
label: supplier.supplier_name
})) || []
}
searchable
disabled={supplierQuery.isLoading || supplierQuery.isError}
placeholder={
supplierQuery.isLoading
? t`Loading...`
: supplierQuery.isError
? t`Error fetching suppliers`
: t`Select supplier`
}
/>
<Button disabled={!searchValue || !supplier} type='submit'>
<Trans>Search</Trans>
</Button>
</Group>
</form>
{isLoading && (
<Center>
<Loader />
</Center>
)}
{!isLoading && (
<Text size='sm' c='dimmed'>
<Trans>Found {searchResults.length} results</Trans>
</Text>
)}
<ScrollAreaAutosize style={{ maxHeight: '49vh' }}>
<Stack gap='xs'>
{searchResults.map((res) => (
<SearchResult
key={res.id}
searchResult={res}
partId={partId}
rightSection={
!res.existing_part_id && (
<Tooltip label={t`Import this part`}>
<ActionIcon
aria-label={`action-button-import-part-${res.id}`}
onClick={() => {
const [plugin_slug, supplier_slug] =
JSON.parse(supplier);
selectSupplierPart({
plugin: plugin_slug,
supplier: supplier_slug,
searchResult: res
});
}}
>
<IconArrowDown size={18} />
</ActionIcon>
</Tooltip>
)
}
/>
))}
</Stack>
</ScrollAreaAutosize>
</Stack>
);
};
const CategoryStep = ({
categoryId,
importPart,
isImporting
}: {
isImporting: boolean;
categoryId?: number;
importPart: (categoryId: number) => void;
}) => {
const [category, setCategory] = useState<number | undefined>(categoryId);
return (
<Stack>
<StandaloneField
fieldDefinition={{
field_type: 'related field',
api_url: apiUrl(ApiEndpoints.category_list),
description: '',
label: t`Select category`,
model: ModelType.partcategory,
filters: { structural: false },
value: category,
onValueChange: (value) => setCategory(value)
}}
/>
<Text>
<Trans>
Are you sure you want to import this part into the selected category
now?
</Trans>
</Text>
<Group justify='flex-end'>
<Button
aria-label='action-button-import-part-now'
disabled={!category || isImporting}
onClick={() => importPart(category!)}
loading={isImporting}
>
<Trans>Import Now</Trans>
</Button>
</Group>
</Stack>
);
};
type ParametersType = (ImportResult['parameters'][number] & { use: boolean })[];
const ParametersStep = ({
importResult,
isImporting,
skipStep,
importParameters,
parameterErrors
}: {
importResult: ImportResult;
isImporting: boolean;
skipStep: () => void;
importParameters: (parameters: ParametersType) => Promise<void>;
parameterErrors: { template?: string; data?: string }[] | null;
}) => {
const [parameters, setParameters] = useState<ParametersType>(() =>
importResult.parameters.map((p) => ({
...p,
use: p.parameter_template !== null
}))
);
const [categoryCount, otherCount] = useMemo(() => {
const c = parameters.filter((x) => x.on_category && x.use).length;
const o = parameters.filter((x) => !x.on_category && x.use).length;
return [c, o];
}, [parameters]);
const parametersFromCategory = useMemo(
() => parameters.filter((x) => x.on_category).length,
[parameters]
);
const setParameter = useCallback(
(i: number, key: string) => (e: unknown) =>
setParameters((p) => p.map((p, j) => (i === j ? { ...p, [key]: e } : p))),
[]
);
return (
<Stack>
<Text size='sm'>
<Trans>
Select and edit the parameters you want to add to this part.
</Trans>
</Text>
{parametersFromCategory > 0 && (
<Title order={5}>
<Trans>Default category parameters</Trans>
<Badge ml='xs'>{categoryCount}</Badge>
</Title>
)}
<Stack gap='xs'>
{parameters.map((p, i) => (
<Stack key={i}>
{p.on_category === false &&
parameters[i - 1]?.on_category === true && (
<>
<Divider />
<Title order={5}>
<Trans>Other parameters</Trans>
<Badge ml='xs'>{otherCount}</Badge>
</Title>
</>
)}
<Group align='center' gap='xs'>
<Checkbox
checked={p.use}
onChange={(e) =>
setParameter(i, 'use')(e.currentTarget.checked)
}
/>
{!p.on_category && (
<Tooltip label={p.name}>
<Text
w='160px'
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{p.name}
</Text>
</Tooltip>
)}
<Box flex={1}>
<StandaloneField
hideLabels
fieldDefinition={{
field_type: 'related field',
model: ModelType.partparametertemplate,
api_url: apiUrl(ApiEndpoints.part_parameter_template_list),
disabled: p.on_category,
value: p.parameter_template,
onValueChange: (v) => {
if (!p.parameter_template) setParameter(i, 'use')(true);
setParameter(i, 'parameter_template')(v);
},
error: parameterErrors?.[i]?.template
}}
/>
</Box>
<TextInput
flex={1}
value={p.value}
onChange={(e) =>
setParameter(i, 'value')(e.currentTarget.value)
}
error={parameterErrors?.[i]?.data}
/>
</Group>
</Stack>
))}
<Tooltip label={t`Add a new parameter`}>
<ActionIcon
onClick={() => {
setParameters((p) => [
...p,
{
name: '',
value: '',
parameter_template: null,
on_category: false,
use: true
}
]);
}}
>
<IconPlus size={18} />
</ActionIcon>
</Tooltip>
</Stack>
<Group justify='flex-end'>
<Button onClick={skipStep}>
<Trans>Skip</Trans>
</Button>
<Button
aria-label='action-button-import-create-parameters'
disabled={isImporting || parameters.filter((p) => p.use).length === 0}
loading={isImporting}
onClick={() => importParameters(parameters)}
>
<Trans>Create Parameters</Trans>
</Button>
</Group>
</Stack>
);
};
const StockStep = ({
importResult,
nextStep
}: {
importResult: ImportResult;
nextStep: () => void;
}) => {
return (
<Stack>
<Text size='sm'>
<Trans>Create initial stock for the imported part.</Trans>
</Text>
<StockItemTable
tableName='initial-stock-creation'
allowAdd
showPricing
showLocation
params={{
part: importResult.part_id,
supplier_part: importResult.supplier_part_id,
pricing: importResult.pricing,
openNewStockItem: false
}}
/>
<Group justify='flex-end'>
<Button onClick={nextStep} aria-label='action-button-import-stock-next'>
<Trans>Next</Trans>
</Button>
</Group>
</Stack>
);
};
export default function ImportPartWizard({
categoryId,
partId
}: {
categoryId?: number;
partId?: number;
}) {
const [supplierPart, setSupplierPart] = useState<{
plugin: string;
supplier: string;
searchResult: SearchResult;
}>();
const [importResult, setImportResult] = useState<ImportResult>();
const [isImporting, setIsImporting] = useState(false);
const [parameterErrors, setParameterErrors] = useState<
{ template?: string; data?: string }[] | null
>(null);
const partFields = usePartFields({ create: false });
const editPart = useEditApiFormModal({
url: ApiEndpoints.part_list,
pk: importResult?.part_id,
title: t`Edit Part`,
fields: partFields
});
const importPart = useCallback(
async ({
categoryId,
partId
}: { categoryId?: number; partId?: number }) => {
setIsImporting(true);
try {
const importResult = await api.post(
apiUrl(ApiEndpoints.plugin_supplier_import),
{
category_id: categoryId,
part_import_id: supplierPart?.searchResult.id,
plugin: supplierPart?.plugin,
supplier: supplierPart?.supplier,
part_id: partId
},
{
timeout: 30000 // 30 seconds
}
);
setImportResult(importResult.data);
showNotification({
title: t`Success`,
message: t`Part imported successfully!`,
color: 'green'
});
wizard.nextStep();
setIsImporting(false);
} catch (err: any) {
showNotification({
title: t`Error`,
message:
t`Failed to import part: ` +
(err?.response?.data?.detail || err.message),
color: 'red'
});
setIsImporting(false);
}
},
[supplierPart]
);
// Render the select wizard step
const renderStep = useCallback(
(step: number) => {
return (
<Stack gap='xs'>
{editPart.modal}
{step > 0 && supplierPart && (
<SearchResult
searchResult={supplierPart?.searchResult}
partId={partId}
rightSection={
importResult && (
<Group gap='xs'>
<Link to={`/part/${importResult.part_id}`} target='_blank'>
<InvenTreeIcon icon='part' />
</Link>
<ActionIcon
onClick={() => {
editPart.open();
}}
>
<InvenTreeIcon icon='edit' />
</ActionIcon>
</Group>
)
}
/>
)}
{step === 0 && (
<SearchStep
selectSupplierPart={(sp) => {
setSupplierPart(sp);
wizard.nextStep();
}}
partId={partId}
/>
)}
{!partId && step === 1 && (
<CategoryStep
isImporting={isImporting}
categoryId={categoryId}
importPart={(categoryId) => {
importPart({ categoryId });
}}
/>
)}
{!!partId && step === 1 && (
<Stack>
<RenderRemoteInstance model={ModelType.part} pk={partId} />
<Text>
<Trans>
Are you sure, you want to import the supplier and manufacturer
part into this part?
</Trans>
</Text>
<Group justify='flex-end'>
<Button
disabled={isImporting}
onClick={() => {
importPart({ partId });
}}
loading={isImporting}
>
<Trans>Import</Trans>
</Button>
</Group>
</Stack>
)}
{!partId && step === 2 && (
<ParametersStep
importResult={importResult!}
isImporting={isImporting}
parameterErrors={parameterErrors}
importParameters={async (parameters) => {
setIsImporting(true);
setParameterErrors(null);
const useParameters = parameters
.map((x, i) => ({ ...x, i }))
.filter((p) => p.use);
const map = useParameters.reduce(
(acc, p, i) => {
acc[p.i] = i;
return acc;
},
{} as Record<number, number>
);
const createParameters = useParameters.map((p) => ({
part: importResult!.part_id,
template: p.parameter_template,
data: p.value
}));
try {
await api.post(
apiUrl(ApiEndpoints.part_parameter_list),
createParameters
);
showNotification({
title: t`Success`,
message: t`Parameters created successfully!`,
color: 'green'
});
wizard.nextStep();
setIsImporting(false);
} catch (err: any) {
if (
err?.response?.status === 400 &&
Array.isArray(err.response.data)
) {
const errors = err.response.data.map(
(e: Record<string, string[]>) => {
const err: { data?: string; template?: string } = {};
if (e.data) err.data = e.data.join(',');
if (e.template) err.template = e.template.join(',');
return err;
}
);
setParameterErrors(
parameters.map((_, i) =>
map[i] !== undefined && errors[map[i]]
? errors[map[i]]
: {}
)
);
}
showNotification({
title: t`Error`,
message: t`Failed to create parameters, please fix the errors and try again`,
color: 'red'
});
setIsImporting(false);
}
}}
skipStep={() => wizard.nextStep()}
/>
)}
{step === (!partId ? 3 : 2) && (
<StockStep
importResult={importResult!}
nextStep={() => wizard.nextStep()}
/>
)}
{step === (!partId ? 4 : 3) && (
<Stack>
<Text size='sm'>
<Trans>
Part imported successfully from supplier{' '}
{supplierPart?.supplier}.
</Trans>
</Text>
<Group justify='flex-end'>
<Button
component={Link}
to={`/part/${importResult?.part_id}`}
variant='light'
aria-label='action-button-import-open-part'
>
<Trans>Open Part</Trans>
</Button>
<Button
component={Link}
to={`/purchasing/supplier-part/${importResult?.supplier_part_id}`}
variant='light'
>
<Trans>Open Supplier Part</Trans>
</Button>
<Button
component={Link}
to={`/purchasing/manufacturer-part/${importResult?.manufacturer_part_id}`}
variant='light'
>
<Trans>Open Manufacturer Part</Trans>
</Button>
<Button
onClick={() => wizard.closeWizard()}
aria-label='action-button-import-close'
>
<Trans>Close</Trans>
</Button>
</Group>
</Stack>
)}
</Stack>
);
},
[
partId,
categoryId,
supplierPart,
importResult,
isImporting,
parameterErrors,
importPart,
editPart.modal
]
);
const onClose = useCallback(() => {
setSupplierPart(undefined);
setImportResult(undefined);
setIsImporting(false);
setParameterErrors(null);
wizard.setStep(0);
}, []);
// Create the wizard manager
const wizard = useWizard({
title: t`Import Part`,
steps: [
t`Search Supplier Part`,
// if partId is provided, a inventree part already exists, just import the mp/sp
...(!partId ? [t`Category`, t`Parameters`] : [t`Confirm import`]),
t`Stock`,
t`Done`
],
onClose,
renderStep: renderStep,
disableManualStepChange: true
});
return wizard;
}

View File

@@ -5,7 +5,6 @@ import {
Divider,
Drawer,
Group,
Paper,
Space,
Stack,
Stepper,
@@ -26,11 +25,13 @@ import { StylishText } from '../items/StylishText';
function WizardProgressStepper({
currentStep,
steps,
onSelectStep
onSelectStep,
disableManualStepChange = false
}: {
currentStep: number;
steps: string[];
onSelectStep: (step: number) => void;
disableManualStepChange?: boolean;
}) {
if (!steps || steps.length == 0) {
return null;
@@ -54,23 +55,32 @@ function WizardProgressStepper({
return (
<Card p='xs' withBorder>
<Group justify='space-between' gap='xs' wrap='nowrap'>
<Tooltip
label={steps[currentStep - 1]}
position='top'
disabled={!canStepBackward}
>
<ActionIcon
variant='transparent'
onClick={() => onSelectStep(currentStep - 1)}
<Group
justify={disableManualStepChange ? 'center' : 'space-between'}
gap='xs'
wrap='nowrap'
>
{!disableManualStepChange && (
<Tooltip
label={steps[currentStep - 1]}
position='top'
disabled={!canStepBackward}
>
<IconArrowLeft />
</ActionIcon>
</Tooltip>
<ActionIcon
variant='transparent'
onClick={() => onSelectStep(currentStep - 1)}
disabled={!canStepBackward}
>
<IconArrowLeft />
</ActionIcon>
</Tooltip>
)}
<Stepper
active={currentStep}
onStepClick={(stepIndex: number) => onSelectStep(stepIndex)}
onStepClick={(stepIndex: number) => {
if (disableManualStepChange) return;
onSelectStep(stepIndex);
}}
iconSize={20}
size='xs'
>
@@ -84,19 +94,21 @@ function WizardProgressStepper({
))}
</Stepper>
{canStepForward ? (
<Tooltip
label={steps[currentStep + 1]}
position='top'
disabled={!canStepForward}
>
<ActionIcon
variant='transparent'
onClick={() => onSelectStep(currentStep + 1)}
!disableManualStepChange && (
<Tooltip
label={steps[currentStep + 1]}
position='top'
disabled={!canStepForward}
>
<IconArrowRight />
</ActionIcon>
</Tooltip>
<ActionIcon
variant='transparent'
onClick={() => onSelectStep(currentStep + 1)}
disabled={!canStepForward || disableManualStepChange}
>
<IconArrowRight />
</ActionIcon>
</Tooltip>
)
) : (
<Tooltip label={t`Complete`} position='top'>
<ActionIcon color='green' variant='transparent'>
@@ -120,7 +132,8 @@ export default function WizardDrawer({
opened,
onClose,
onNextStep,
onPreviousStep
onPreviousStep,
disableManualStepChange
}: {
title: string;
currentStep: number;
@@ -130,6 +143,7 @@ export default function WizardDrawer({
onClose: () => void;
onNextStep?: () => void;
onPreviousStep?: () => void;
disableManualStepChange?: boolean;
}) {
const titleBlock: ReactNode = useMemo(() => {
return (
@@ -145,7 +159,9 @@ export default function WizardDrawer({
<WizardProgressStepper
currentStep={currentStep}
steps={steps}
disableManualStepChange={disableManualStepChange}
onSelectStep={(step: number) => {
if (disableManualStepChange) return;
if (step < currentStep) {
onPreviousStep?.();
} else {
@@ -179,10 +195,7 @@ export default function WizardDrawer({
opened={opened}
onClose={onClose}
>
<Boundary label='wizard-drawer'>
<Paper p='md'>{}</Paper>
{children}
</Boundary>
<Boundary label='wizard-drawer'>{children}</Boundary>
</Drawer>
);
}

View File

@@ -67,22 +67,33 @@ import { StatusFilterOptions } from '../tables/Filter';
export function useStockFields({
partId,
stockItem,
modalId,
create = false
create = false,
supplierPartId,
pricing,
modalId
}: {
partId?: number;
stockItem?: any;
modalId: string;
create: boolean;
supplierPartId?: number;
pricing?: { [priceBreak: number]: [number, string] };
}): ApiFormFieldSet {
const globalSettings = useGlobalSettingsState();
// Keep track of the "part" instance
const [partInstance, setPartInstance] = useState<any>({});
const [supplierPart, setSupplierPart] = useState<number | null>(null);
const [supplierPart, setSupplierPart] = useState<number | null>(
supplierPartId ?? null
);
const [expiryDate, setExpiryDate] = useState<string | null>(null);
const [quantity, setQuantity] = useState<number | null>(null);
const [purchasePrice, setPurchasePrice] = useState<number | null>(null);
const [purchasePriceCurrency, setPurchasePriceCurrency] = useState<
string | null
>(null);
const batchGenerator = useBatchCodeGenerator({
modalId: modalId,
@@ -98,11 +109,30 @@ export function useStockFields({
}
});
// Update pricing when quantity changes
useEffect(() => {
if (quantity === null || quantity === undefined || !pricing) return;
// Find the highest price break that is less than or equal to the quantity
const priceBreak = Object.entries(pricing)
.sort(([a], [b]) => Number.parseInt(b) - Number.parseInt(a))
.find(([br]) => quantity >= Number.parseInt(br));
if (priceBreak) {
setPurchasePrice(priceBreak[1][0]);
setPurchasePriceCurrency(priceBreak[1][1]);
}
}, [pricing, quantity]);
useEffect(() => {
if (supplierPartId && !supplierPart) setSupplierPart(supplierPartId);
}, [partInstance, supplierPart, supplierPartId]);
return useMemo(() => {
const fields: ApiFormFieldSet = {
part: {
value: partId || partInstance?.pk,
disabled: !create,
value: partInstance.pk,
disabled: !create || !!partId,
filters: {
virtual: false,
active: create ? true : undefined
@@ -135,6 +165,7 @@ export function useStockFields({
},
supplier_part: {
hidden: partInstance?.purchaseable == false,
disabled: !!supplierPartId,
value: supplierPart,
onValueChange: (value) => {
setSupplierPart(value);
@@ -171,6 +202,7 @@ export function useStockFields({
description: t`Enter initial quantity for this stock item`,
onValueChange: (value) => {
batchGenerator.update({ quantity: value });
setQuantity(value);
}
},
serial_numbers: {
@@ -210,10 +242,18 @@ export function useStockFields({
}
},
purchase_price: {
icon: <IconCurrencyDollar />
icon: <IconCurrencyDollar />,
value: purchasePrice,
onValueChange: (value) => {
setPurchasePrice(value);
}
},
purchase_price_currency: {
icon: <IconCoins />
icon: <IconCoins />,
value: purchasePriceCurrency,
onValueChange: (value) => {
setPurchasePriceCurrency(value);
}
},
packaging: {
icon: <IconPackage />
@@ -240,6 +280,10 @@ export function useStockFields({
partId,
globalSettings,
supplierPart,
create,
supplierPartId,
purchasePrice,
purchasePriceCurrency,
serialGenerator.result,
batchGenerator.result,
create

View File

@@ -12,6 +12,8 @@ import WizardDrawer from '../components/wizards/WizardDrawer';
export interface WizardProps {
title: string;
steps: string[];
disableManualStepChange?: boolean;
onClose?: () => void;
renderStep: (step: number) => ReactNode;
canStepForward?: (step: number) => boolean;
canStepBackward?: (step: number) => boolean;
@@ -30,6 +32,7 @@ export interface WizardState {
nextStep: () => void;
previousStep: () => void;
wizard: ReactNode;
setStep: (step: number) => void;
}
/**
@@ -65,32 +68,44 @@ export default function useWizard(props: WizardProps): WizardState {
// Close the wizard
const closeWizard = useCallback(() => {
props.onClose?.();
setOpened(false);
}, []);
// Progress the wizard to the next step
const nextStep = useCallback(() => {
if (props.canStepForward && !props.canStepForward(currentStep)) {
return;
}
if (props.steps && currentStep < props.steps.length - 1) {
setCurrentStep(currentStep + 1);
clearError();
}
}, [currentStep, props.canStepForward]);
setCurrentStep((c) => {
if (props.canStepForward && !props.canStepForward(c)) {
return c;
}
const newStep = Math.min(c + 1, props.steps.length - 1);
if (newStep !== c) clearError();
return newStep;
});
}, [props.canStepForward]);
// Go back to the previous step
const previousStep = useCallback(() => {
if (props.canStepBackward && !props.canStepBackward(currentStep)) {
return;
}
setCurrentStep((c) => {
if (props.canStepBackward && !props.canStepBackward(c)) {
return c;
}
const newStep = Math.max(c - 1, 0);
if (newStep !== c) clearError();
return newStep;
});
}, [props.canStepBackward]);
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
const setStep = useCallback(
(step: number) => {
if (step < 0 || step >= props.steps.length) {
return;
}
setCurrentStep(step);
clearError();
}
}, [currentStep, props.canStepBackward]);
},
[props.steps.length]
);
// Render the wizard contents for the current step
const contents = useMemo(() => {
@@ -109,8 +124,10 @@ export default function useWizard(props: WizardProps): WizardState {
closeWizard,
nextStep,
previousStep,
setStep,
wizard: (
<WizardDrawer
disableManualStepChange={props.disableManualStepChange}
title={props.title}
currentStep={currentStep}
steps={props.steps}

View File

@@ -1,8 +1,8 @@
import { t } from '@lingui/core/macro';
import { Group, Text } from '@mantine/core';
import { IconShoppingCart } from '@tabler/icons-react';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import { ActionButton } from '@lib/components/ActionButton';
import { AddItemButton } from '@lib/components/AddItemButton';
import {
type RowAction,
@@ -17,7 +17,9 @@ import type { TableFilter } from '@lib/types/Filters';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables';
import type { InvenTreeTableProps } from '@lib/types/Tables';
import { IconPackageImport, IconShoppingCart } from '@tabler/icons-react';
import { ActionDropdown } from '../../components/items/ActionDropdown';
import ImportPartWizard from '../../components/wizards/ImportPartWizard';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
import { usePartFields } from '../../forms/PartForms';
@@ -27,6 +29,7 @@ import {
useCreateApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { usePluginsWithMixin } from '../../hooks/UsePlugins';
import { useTable } from '../../hooks/UseTable';
import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
@@ -424,6 +427,11 @@ export function PartListTable({
const orderPartsWizard = OrderPartsWizard({ parts: table.selectedRecords });
const supplierPlugins = usePluginsWithMixin('supplier');
const importPartWizard = ImportPartWizard({
categoryId: initialPartData.category
});
const rowActions = useCallback(
(record: any): RowAction[] => {
const can_edit = user.hasChangePermission(ModelType.part);
@@ -482,9 +490,19 @@ export function PartListTable({
hidden={!user.hasAddRole(UserRoles.part)}
tooltip={t`Add Part`}
onClick={() => newPart.open()}
/>,
<ActionButton
key='import-part'
hidden={
supplierPlugins.length === 0 || !user.hasAddRole(UserRoles.part)
}
tooltip={t`Import Part`}
color='green'
icon={<IconPackageImport />}
onClick={() => importPartWizard.openWizard()}
/>
];
}, [user, table.hasSelectedRecords]);
}, [user, table.hasSelectedRecords, supplierPlugins]);
return (
<>
@@ -493,6 +511,7 @@ export function PartListTable({
{editPart.modal}
{setCategory.modal}
{orderPartsWizard.wizard}
{importPartWizard.wizard}
<InvenTreeTable
url={apiUrl(ApiEndpoints.part_list)}
tableState={table}

View File

@@ -2,6 +2,7 @@ import { t } from '@lingui/core/macro';
import { Text } from '@mantine/core';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import { ActionButton } from '@lib/components/ActionButton';
import { AddItemButton } from '@lib/components/AddItemButton';
import {
type RowAction,
@@ -14,12 +15,15 @@ import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables';
import { IconPackageImport } from '@tabler/icons-react';
import ImportPartWizard from '../../components/wizards/ImportPartWizard';
import { useSupplierPartFields } from '../../forms/CompanyForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { usePluginsWithMixin } from '../../hooks/UsePlugins';
import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
import {
@@ -161,6 +165,11 @@ export function SupplierPartTable({
successMessage: t`Supplier part created`
});
const supplierPlugins = usePluginsWithMixin('supplier');
const importPartWizard = ImportPartWizard({
partId: params?.part
});
const tableActions = useMemo(() => {
return [
<AddItemButton
@@ -168,9 +177,21 @@ export function SupplierPartTable({
tooltip={t`Add supplier part`}
onClick={() => addSupplierPart.open()}
hidden={!user.hasAddRole(UserRoles.purchase_order)}
/>,
<ActionButton
key='import-part'
icon={<IconPackageImport />}
color='green'
tooltip={t`Import supplier part`}
onClick={() => importPartWizard.openWizard()}
hidden={
supplierPlugins.length === 0 ||
!user.hasAddRole(UserRoles.part) ||
!params?.part
}
/>
];
}, [user]);
}, [user, supplierPlugins]);
const tableFilters: TableFilter[] = useMemo(() => {
return [
@@ -244,6 +265,7 @@ export function SupplierPartTable({
{addSupplierPart.modal}
{editSupplierPart.modal}
{deleteSupplierPart.modal}
{importPartWizard.wizard}
<InvenTreeTable
url={apiUrl(ApiEndpoints.supplier_part_list)}
tableState={table}

View File

@@ -515,6 +515,8 @@ export function StockItemTable({
const newStockItemFields = useStockFields({
create: true,
partId: params.part,
supplierPartId: params.supplier_part,
pricing: params.pricing,
modalId: 'add-stock-item'
});
@@ -527,7 +529,7 @@ export function StockItemTable({
part: params.part,
location: params.location
},
follow: true,
follow: params.openNewStockItem ?? true,
table: table,
onFormSuccess: (response: any) => {
// Returns a list that may contain multiple serialized stock items

10
src/frontend/tests/api.ts Normal file
View File

@@ -0,0 +1,10 @@
import { request } from '@playwright/test';
import { adminuser, apiUrl } from './defaults';
export const createApi = () =>
request.newContext({
baseURL: apiUrl,
extraHTTPHeaders: {
Authorization: `Basic ${btoa(`${adminuser.username}:${adminuser.password}`)}`
}
});

View File

@@ -1,3 +1,6 @@
import { expect } from '@playwright/test';
import { createApi } from './api';
/**
* Open the filter drawer for the currently visible table
* @param page - The page object
@@ -130,3 +133,20 @@ export const globalSearch = async (page, query) => {
await page.getByPlaceholder('Enter search text').fill(query);
await page.waitForTimeout(300);
};
export const deletePart = async (name: string) => {
const api = await createApi();
const parts = await api
.get('part/', {
params: { search: name }
})
.then((res) => res.json());
const existingPart = parts.find((p: any) => p.name === name);
if (existingPart) {
await api.patch(`part/${existingPart.pk}/`, {
data: { active: false }
});
const res = await api.delete(`part/${existingPart.pk}/`);
expect(res.status()).toBe(204);
}
};

View File

@@ -39,10 +39,9 @@ test('Dashboard - Basic', async ({ browser }) => {
await page.getByLabel('dashboard-accept-layout').click();
});
test('Dashboard - Plugins', async ({ browser, request }) => {
test('Dashboard - Plugins', async ({ browser }) => {
// Ensure that the "SampleUI" plugin is enabled
await setPluginState({
request,
plugin: 'sampleui',
state: true
});

View File

@@ -2,12 +2,14 @@ import { test } from '../baseFixtures';
import {
clearTableFilters,
clickOnRowMenu,
deletePart,
getRowFromCell,
loadTab,
navigate,
setTableChoiceFilter
} from '../helpers';
import { doCachedLogin } from '../login';
import { setPluginState, setSettingState } from '../settings';
/**
* CHeck each panel tab for the "Parts" page
@@ -645,3 +647,62 @@ test('Parts - Duplicate', async ({ browser }) => {
await page.getByText('Copy Parameters', { exact: true }).waitFor();
await page.getByText('Copy Tests', { exact: true }).waitFor();
});
test('Parts - Import supplier part', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'part/category/1/parts'
});
// Ensure that the sample supplier plugin is enabled
await setPluginState({
plugin: 'samplesupplier',
state: true
});
await setSettingState({
setting: 'SUPPLIER',
value: 3,
type: 'plugin',
plugin: 'samplesupplier'
});
// cleanup old imported part if it exists
await deletePart('BOLT-Steel-M5-5');
await deletePart('BOLT-M5-5');
await page.reload();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
await page.getByRole('button', { name: 'action-button-import-part' }).click();
await page
.getByRole('textbox', { name: 'textbox-search-for-part' })
.fill('M5');
await page.waitForTimeout(250);
await page
.getByRole('textbox', { name: 'textbox-search-for-part' })
.press('Enter');
await page.getByText('Bolt M5x5mm Steel').waitFor();
await page
.getByRole('button', { name: 'action-button-import-part-BOLT-Steel-M5-5' })
.click();
await page.waitForTimeout(250);
await page
.getByRole('button', { name: 'action-button-import-part-now' })
.click();
await page
.getByRole('button', { name: 'action-button-import-create-parameters' })
.dispatchEvent('click');
await page
.getByRole('button', { name: 'action-button-import-stock-next' })
.dispatchEvent('click');
await page
.getByRole('button', { name: 'action-button-import-close' })
.dispatchEvent('click');
// cleanup imported part if it exists
await deletePart('BOLT-Steel-M5-5');
await deletePart('BOLT-M5-5');
});

View File

@@ -18,7 +18,7 @@ test('Machines - Admin Panel', async ({ browser }) => {
await page.getByText('There are no machine registry errors').waitFor();
});
test('Machines - Activation', async ({ browser, request }) => {
test('Machines - Activation', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree',
@@ -27,7 +27,6 @@ test('Machines - Activation', async ({ browser, request }) => {
// Ensure that the sample machine plugin is enabled
await setPluginState({
request,
plugin: 'sample-printer-machine-plugin',
state: true
});

View File

@@ -10,7 +10,7 @@ import { doCachedLogin } from './login';
* Test the "admin" account
* - This is a superuser account, so should have *all* permissions available
*/
test('Permissions - Admin', async ({ browser, request }) => {
test('Permissions - Admin', async ({ browser }) => {
// Login, and start on the "admin" page
const page = await doCachedLogin(browser, {
username: 'admin',
@@ -57,7 +57,7 @@ test('Permissions - Admin', async ({ browser, request }) => {
* Test the "reader" account
* - This account is read-only, but should be able to access *most* pages
*/
test('Permissions - Reader', async ({ browser, request }) => {
test('Permissions - Reader', async ({ browser }) => {
// Login, and start on the "admin" page
const page = await doCachedLogin(browser, {
username: 'reader',

View File

@@ -11,7 +11,7 @@ import { doCachedLogin } from './login.js';
import { setPluginState, setSettingState } from './settings.js';
// Unit test for plugin settings
test('Plugins - Settings', async ({ browser, request }) => {
test('Plugins - Settings', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree'
@@ -19,7 +19,6 @@ test('Plugins - Settings', async ({ browser, request }) => {
// Ensure that the SampleIntegration plugin is enabled
await setPluginState({
request,
plugin: 'sample',
state: true
});
@@ -63,12 +62,11 @@ test('Plugins - Settings', async ({ browser, request }) => {
await page.getByText('Mouser Electronics').click();
});
test('Plugins - User Settings', async ({ browser, request }) => {
test('Plugins - User Settings', async ({ browser }) => {
const page = await doCachedLogin(browser);
// Ensure that the SampleIntegration plugin is enabled
await setPluginState({
request,
plugin: 'sample',
state: true
});
@@ -149,7 +147,7 @@ test('Plugins - Functionality', async ({ browser }) => {
.waitFor();
});
test('Plugins - Panels', async ({ browser, request }) => {
test('Plugins - Panels', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree'
@@ -157,14 +155,12 @@ test('Plugins - Panels', async ({ browser, request }) => {
// Ensure that UI plugins are enabled
await setSettingState({
request,
setting: 'ENABLE_PLUGINS_INTERFACE',
value: true
});
// Ensure that the SampleUI plugin is enabled
await setPluginState({
request,
plugin: 'sampleui',
state: true
});
@@ -192,7 +188,6 @@ test('Plugins - Panels', async ({ browser, request }) => {
// Disable the plugin, and ensure it is no longer visible
await setPluginState({
request,
plugin: 'sampleui',
state: false
});
@@ -201,7 +196,7 @@ test('Plugins - Panels', async ({ browser, request }) => {
/**
* Unit test for custom admin integration for plugins
*/
test('Plugins - Custom Admin', async ({ browser, request }) => {
test('Plugins - Custom Admin', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree'
@@ -209,7 +204,6 @@ test('Plugins - Custom Admin', async ({ browser, request }) => {
// Ensure that the SampleUI plugin is enabled
await setPluginState({
request,
plugin: 'sampleui',
state: true
});
@@ -235,7 +229,7 @@ test('Plugins - Custom Admin', async ({ browser, request }) => {
await page.getByText('hello: world').waitFor();
});
test('Plugins - Locate Item', async ({ browser, request }) => {
test('Plugins - Locate Item', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree'
@@ -243,7 +237,6 @@ test('Plugins - Locate Item', async ({ browser, request }) => {
// Ensure that the sample location plugin is enabled
await setPluginState({
request,
plugin: 'samplelocate',
state: true
});

View File

@@ -77,7 +77,7 @@ test('Printing - Report Printing', async ({ browser }) => {
await page.context().close();
});
test('Printing - Report Editing', async ({ browser, request }) => {
test('Printing - Report Editing', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree'
@@ -85,7 +85,6 @@ test('Printing - Report Editing', async ({ browser, request }) => {
// activate the sample plugin for this test
await setPluginState({
request,
plugin: 'sampleui',
state: true
});
@@ -140,7 +139,6 @@ test('Printing - Report Editing', async ({ browser, request }) => {
// deactivate the sample plugin again after the test
await setPluginState({
request,
plugin: 'sampleui',
state: false
});

View File

@@ -1,5 +1,5 @@
import { createApi } from './api.js';
import { expect, test } from './baseFixtures.js';
import { apiUrl } from './defaults.js';
import { getRowFromCell, loadTab, navigate } from './helpers.js';
import { doCachedLogin } from './login.js';
import { setPluginState, setSettingState } from './settings.js';
@@ -134,7 +134,7 @@ test('Settings - User', async ({ browser }) => {
.waitFor();
});
test('Settings - Global', async ({ browser, request }) => {
test('Settings - Global', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'steven',
password: 'wizardstaff',
@@ -144,7 +144,6 @@ test('Settings - Global', async ({ browser, request }) => {
// Ensure the "slack" notification plugin is enabled
// This is to ensure it is visible in the "notification" settings tab
await setPluginState({
request,
plugin: 'inventree-slack-notification',
state: true
});
@@ -312,7 +311,7 @@ test('Settings - Admin', async ({ browser }) => {
await page.getByRole('button', { name: 'Submit' }).click();
});
test('Settings - Admin - Barcode History', async ({ browser, request }) => {
test('Settings - Admin - Barcode History', async ({ browser }) => {
// Login with admin credentials
const page = await doCachedLogin(browser, {
username: 'admin',
@@ -321,25 +320,21 @@ test('Settings - Admin - Barcode History', async ({ browser, request }) => {
// Ensure that the "save scans" setting is enabled
await setSettingState({
request: request,
setting: 'BARCODE_STORE_RESULTS',
value: true
});
// Scan some barcodes (via API calls)
const barcodes = ['ABC1234', 'XYZ5678', 'QRS9012'];
const api = await createApi();
for (let i = 0; i < barcodes.length; i++) {
const barcode = barcodes[i];
const url = new URL('barcode/', apiUrl).toString();
await request.post(url, {
await api.post('barcode/', {
data: {
barcode: barcode
},
timeout: 5000,
headers: {
Authorization: `Basic ${btoa('admin:inventree')}`
}
timeout: 5000
});
}

View File

@@ -1,54 +1,48 @@
import { expect } from 'playwright/test';
import { apiUrl } from './defaults';
import { createApi } from './api';
/*
* Set the value of a global setting in the database
*/
export const setSettingState = async ({
request,
setting,
value
value,
type = 'global',
plugin
}: {
request: any;
setting: string;
value: any;
type?: 'global' | 'plugin';
plugin?: string;
}) => {
const url = new URL(`settings/global/${setting}/`, apiUrl).toString();
const response = await request.patch(url, {
const api = await createApi();
const url =
type === 'global'
? `settings/global/${setting}/`
: `plugins/${plugin}/settings/${setting}/`;
const response = await api.patch(url, {
data: {
value: value
},
headers: {
// Basic username: password authorization
Authorization: `Basic ${btoa('admin:inventree')}`
}
});
expect(await response.status()).toBe(200);
expect(response.status()).toBe(200);
};
export const setPluginState = async ({
request,
plugin,
state
}: {
request: any;
plugin: string;
state: boolean;
}) => {
const url = new URL(`plugins/${plugin}/activate/`, apiUrl).toString();
const response = await request.patch(url, {
const api = await createApi();
const response = await api.patch(`plugins/${plugin}/activate/`, {
data: {
active: state
},
headers: {
// Basic username: password authorization
Authorization: `Basic ${btoa('admin:inventree')}`
}
});
expect(await response.status()).toBe(200);
expect(response.status()).toBe(200);
};