mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 19:46:46 +00:00
[PUI] Serialize Stock (#8192)
* Add PUI form to serialize existing stock item * Remove debug statement * Ensure that stock item trees are rebuilt correctly after serialization - No idea how this has not been detected previously * Add unit test to ensure child_items annotation works as expected * Add link to parent item in stock detail page * Refactor to use new placeholder hook
This commit is contained in:
parent
23de1e038d
commit
a5f2273e14
@ -1572,6 +1572,13 @@ class StockItem(
|
|||||||
# Remove the equivalent number of items
|
# Remove the equivalent number of items
|
||||||
self.take_stock(quantity, user, notes=notes)
|
self.take_stock(quantity, user, notes=notes)
|
||||||
|
|
||||||
|
# Rebuild the stock tree
|
||||||
|
try:
|
||||||
|
StockItem.objects.partial_rebuild(tree_id=self.tree_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning('Failed to rebuild stock tree during serializeStock')
|
||||||
|
StockItem.objects.rebuild()
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def copyHistoryFrom(self, other):
|
def copyHistoryFrom(self, other):
|
||||||
"""Copy stock history from another StockItem."""
|
"""Copy stock history from another StockItem."""
|
||||||
@ -1806,7 +1813,7 @@ class StockItem(
|
|||||||
for tree_id in tree_ids:
|
for tree_id in tree_ids:
|
||||||
StockItem.objects.partial_rebuild(tree_id=tree_id)
|
StockItem.objects.partial_rebuild(tree_id=tree_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning('Rebuilding entire StockItem tree')
|
logger.warning('Rebuilding entire StockItem tree during merge_stock_items')
|
||||||
StockItem.objects.rebuild()
|
StockItem.objects.rebuild()
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
|
@ -925,6 +925,46 @@ class StockItemListTest(StockAPITestCase):
|
|||||||
self.list_url, {'location_detail': True, 'tests': True}, max_query_count=35
|
self.list_url, {'location_detail': True, 'tests': True}, max_query_count=35
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_child_items(self):
|
||||||
|
"""Test that the 'child_items' annotation works as expected."""
|
||||||
|
# Create a trackable part
|
||||||
|
my_part = Part.objects.create(
|
||||||
|
name='Test Part', description='Test Part Description', trackable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create an initial stock item
|
||||||
|
parent_item = StockItem.objects.create(
|
||||||
|
part=my_part, quantity=10, location=StockLocation.objects.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Serialize this stock item
|
||||||
|
parent_item.serializeStock(
|
||||||
|
5, [1, 2, 3, 4, 5], user=self.user, notes='Some notes'
|
||||||
|
)
|
||||||
|
|
||||||
|
parent_item.refresh_from_db()
|
||||||
|
|
||||||
|
# Check that the parent item has 5 child items
|
||||||
|
self.assertEqual(parent_item.get_descendants(include_self=False).count(), 5)
|
||||||
|
self.assertEqual(my_part.stock_items.count(), 6)
|
||||||
|
|
||||||
|
# Fetch stock list via API
|
||||||
|
response = self.get(reverse('api-stock-list'), {'part': my_part.pk})
|
||||||
|
|
||||||
|
self.assertEqual(len(response.data), 6)
|
||||||
|
|
||||||
|
# Fetch stock detail
|
||||||
|
response = self.get(reverse('api-stock-detail', kwargs={'pk': parent_item.pk}))
|
||||||
|
|
||||||
|
self.assertEqual(response.data['child_items'], 5)
|
||||||
|
|
||||||
|
for child in parent_item.get_children():
|
||||||
|
response = self.get(reverse('api-stock-detail', kwargs={'pk': child.pk}))
|
||||||
|
|
||||||
|
self.assertEqual(response.data['parent'], parent_item.pk)
|
||||||
|
self.assertEqual(response.data['quantity'], 1)
|
||||||
|
self.assertEqual(response.data['child_items'], 0)
|
||||||
|
|
||||||
|
|
||||||
class CustomStockItemStatusTest(StockAPITestCase):
|
class CustomStockItemStatusTest(StockAPITestCase):
|
||||||
"""Tests for custom stock item statuses."""
|
"""Tests for custom stock item statuses."""
|
||||||
|
@ -116,12 +116,14 @@ export enum ApiEndpoints {
|
|||||||
manufacturer_part_list = 'company/part/manufacturer/',
|
manufacturer_part_list = 'company/part/manufacturer/',
|
||||||
manufacturer_part_parameter_list = 'company/part/manufacturer/parameter/',
|
manufacturer_part_parameter_list = 'company/part/manufacturer/parameter/',
|
||||||
|
|
||||||
// Stock API endpoints
|
// Stock location endpoints
|
||||||
stock_item_list = 'stock/',
|
|
||||||
stock_tracking_list = 'stock/track/',
|
|
||||||
stock_location_list = 'stock/location/',
|
stock_location_list = 'stock/location/',
|
||||||
stock_location_type_list = 'stock/location-type/',
|
stock_location_type_list = 'stock/location-type/',
|
||||||
stock_location_tree = 'stock/location/tree/',
|
stock_location_tree = 'stock/location/tree/',
|
||||||
|
|
||||||
|
// Stock item API endpoints
|
||||||
|
stock_item_list = 'stock/',
|
||||||
|
stock_tracking_list = 'stock/track/',
|
||||||
stock_test_result_list = 'stock/test/',
|
stock_test_result_list = 'stock/test/',
|
||||||
stock_transfer = 'stock/transfer/',
|
stock_transfer = 'stock/transfer/',
|
||||||
stock_remove = 'stock/remove/',
|
stock_remove = 'stock/remove/',
|
||||||
@ -131,9 +133,10 @@ export enum ApiEndpoints {
|
|||||||
stock_merge = 'stock/merge/',
|
stock_merge = 'stock/merge/',
|
||||||
stock_assign = 'stock/assign/',
|
stock_assign = 'stock/assign/',
|
||||||
stock_status = 'stock/status/',
|
stock_status = 'stock/status/',
|
||||||
stock_install = 'stock/:id/install',
|
stock_install = 'stock/:id/install/',
|
||||||
build_test_statistics = 'test-statistics/by-build/:id',
|
stock_serialize = 'stock/:id/serialize/',
|
||||||
part_test_statistics = 'test-statistics/by-part/:id',
|
build_test_statistics = 'test-statistics/by-build/:id/',
|
||||||
|
part_test_statistics = 'test-statistics/by-part/:id/',
|
||||||
|
|
||||||
// Generator API endpoints
|
// Generator API endpoints
|
||||||
generate_batch_code = 'generate/batch-code/',
|
generate_batch_code = 'generate/batch-code/',
|
||||||
|
@ -40,6 +40,7 @@ import {
|
|||||||
useBatchCodeGenerator,
|
useBatchCodeGenerator,
|
||||||
useSerialNumberGenerator
|
useSerialNumberGenerator
|
||||||
} from '../hooks/UseGenerator';
|
} from '../hooks/UseGenerator';
|
||||||
|
import { useSerialNumberPlaceholder } from '../hooks/UsePlaceholder';
|
||||||
import { apiUrl } from '../states/ApiState';
|
import { apiUrl } from '../states/ApiState';
|
||||||
import { useGlobalSettingsState } from '../states/SettingsState';
|
import { useGlobalSettingsState } from '../states/SettingsState';
|
||||||
|
|
||||||
@ -214,6 +215,30 @@ export function useCreateStockItem() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useStockItemSerializeFields({
|
||||||
|
partId,
|
||||||
|
trackable
|
||||||
|
}: {
|
||||||
|
partId: number;
|
||||||
|
trackable: boolean;
|
||||||
|
}) {
|
||||||
|
const snPlaceholder = useSerialNumberPlaceholder({
|
||||||
|
partId: partId,
|
||||||
|
key: 'stock-item-serialize',
|
||||||
|
enabled: trackable
|
||||||
|
});
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return {
|
||||||
|
quantity: {},
|
||||||
|
serial_numbers: {
|
||||||
|
placeholder: snPlaceholder
|
||||||
|
},
|
||||||
|
destination: {}
|
||||||
|
};
|
||||||
|
}, [snPlaceholder]);
|
||||||
|
}
|
||||||
|
|
||||||
function StockItemDefaultMove({
|
function StockItemDefaultMove({
|
||||||
stockItem,
|
stockItem,
|
||||||
value
|
value
|
||||||
|
@ -90,7 +90,7 @@ export function useInstance<T = any>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const refreshInstance = useCallback(function () {
|
const refreshInstance = useCallback(function () {
|
||||||
instanceQuery.refetch();
|
return instanceQuery.refetch();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -39,12 +39,14 @@ import { StatusRenderer } from '../../components/render/StatusRenderer';
|
|||||||
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 { partCategoryFields } from '../../forms/PartForms';
|
||||||
import {
|
import {
|
||||||
StockOperationProps,
|
StockOperationProps,
|
||||||
useAddStockItem,
|
useAddStockItem,
|
||||||
useCountStockItem,
|
useCountStockItem,
|
||||||
useRemoveStockItem,
|
useRemoveStockItem,
|
||||||
useStockFields,
|
useStockFields,
|
||||||
|
useStockItemSerializeFields,
|
||||||
useTransferStockItem
|
useTransferStockItem
|
||||||
} from '../../forms/StockForms';
|
} from '../../forms/StockForms';
|
||||||
import { InvenTreeIcon } from '../../functions/icons';
|
import { InvenTreeIcon } from '../../functions/icons';
|
||||||
@ -203,6 +205,16 @@ export default function StockDetail() {
|
|||||||
model: ModelType.stockitem,
|
model: ModelType.stockitem,
|
||||||
hidden: !stockitem.belongs_to
|
hidden: !stockitem.belongs_to
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
name: 'parent',
|
||||||
|
label: t`Parent Item`,
|
||||||
|
model: ModelType.stockitem,
|
||||||
|
hidden: !stockitem.parent,
|
||||||
|
model_formatter: (model: any) => {
|
||||||
|
return t`Parent stock item`;
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'link',
|
type: 'link',
|
||||||
name: 'consumed_by',
|
name: 'consumed_by',
|
||||||
@ -481,8 +493,39 @@ export default function StockDetail() {
|
|||||||
const removeStockItem = useRemoveStockItem(stockActionProps);
|
const removeStockItem = useRemoveStockItem(stockActionProps);
|
||||||
const transferStockItem = useTransferStockItem(stockActionProps);
|
const transferStockItem = useTransferStockItem(stockActionProps);
|
||||||
|
|
||||||
const stockActions = useMemo(
|
const serializeStockFields = useStockItemSerializeFields({
|
||||||
() => [
|
partId: stockitem.part,
|
||||||
|
trackable: stockitem.part_detail?.trackable
|
||||||
|
});
|
||||||
|
|
||||||
|
const serializeStockItem = useCreateApiFormModal({
|
||||||
|
url: ApiEndpoints.stock_serialize,
|
||||||
|
pk: stockitem.pk,
|
||||||
|
title: t`Serialize Stock Item`,
|
||||||
|
fields: serializeStockFields,
|
||||||
|
initialData: {
|
||||||
|
quantity: stockitem.quantity,
|
||||||
|
destination: stockitem.location ?? stockitem.part_detail?.default_location
|
||||||
|
},
|
||||||
|
onFormSuccess: () => {
|
||||||
|
const partId = stockitem.part;
|
||||||
|
refreshInstance().catch(() => {
|
||||||
|
// Part may have been deleted - redirect to the part detail page
|
||||||
|
navigate(getDetailUrl(ModelType.part, partId));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
successMessage: t`Stock item serialized`
|
||||||
|
});
|
||||||
|
|
||||||
|
const stockActions = useMemo(() => {
|
||||||
|
const serial = stockitem.serial;
|
||||||
|
const serialized =
|
||||||
|
serial != null &&
|
||||||
|
serial != undefined &&
|
||||||
|
serial != '' &&
|
||||||
|
stockitem.quantity == 1;
|
||||||
|
|
||||||
|
return [
|
||||||
<AdminButton model={ModelType.stockitem} pk={stockitem.pk} />,
|
<AdminButton model={ModelType.stockitem} pk={stockitem.pk} />,
|
||||||
<BarcodeActionDropdown
|
<BarcodeActionDropdown
|
||||||
model={ModelType.stockitem}
|
model={ModelType.stockitem}
|
||||||
@ -503,6 +546,7 @@ export default function StockDetail() {
|
|||||||
{
|
{
|
||||||
name: t`Count`,
|
name: t`Count`,
|
||||||
tooltip: t`Count stock`,
|
tooltip: t`Count stock`,
|
||||||
|
hidden: serialized,
|
||||||
icon: (
|
icon: (
|
||||||
<InvenTreeIcon icon="stocktake" iconProps={{ color: 'blue' }} />
|
<InvenTreeIcon icon="stocktake" iconProps={{ color: 'blue' }} />
|
||||||
),
|
),
|
||||||
@ -513,6 +557,7 @@ export default function StockDetail() {
|
|||||||
{
|
{
|
||||||
name: t`Add`,
|
name: t`Add`,
|
||||||
tooltip: t`Add stock`,
|
tooltip: t`Add stock`,
|
||||||
|
hidden: serialized,
|
||||||
icon: <InvenTreeIcon icon="add" iconProps={{ color: 'green' }} />,
|
icon: <InvenTreeIcon icon="add" iconProps={{ color: 'green' }} />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
stockitem.pk && addStockItem.open();
|
stockitem.pk && addStockItem.open();
|
||||||
@ -521,11 +566,21 @@ export default function StockDetail() {
|
|||||||
{
|
{
|
||||||
name: t`Remove`,
|
name: t`Remove`,
|
||||||
tooltip: t`Remove stock`,
|
tooltip: t`Remove stock`,
|
||||||
|
hidden: serialized,
|
||||||
icon: <InvenTreeIcon icon="remove" iconProps={{ color: 'red' }} />,
|
icon: <InvenTreeIcon icon="remove" iconProps={{ color: 'red' }} />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
stockitem.pk && removeStockItem.open();
|
stockitem.pk && removeStockItem.open();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: t`Serialize`,
|
||||||
|
tooltip: t`Serialize stock`,
|
||||||
|
hidden: serialized || stockitem?.part_detail?.trackable != true,
|
||||||
|
icon: <InvenTreeIcon icon="serial" iconProps={{ color: 'blue' }} />,
|
||||||
|
onClick: () => {
|
||||||
|
serializeStockItem.open();
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: t`Transfer`,
|
name: t`Transfer`,
|
||||||
tooltip: t`Transfer stock`,
|
tooltip: t`Transfer stock`,
|
||||||
@ -555,9 +610,8 @@ export default function StockDetail() {
|
|||||||
})
|
})
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
],
|
];
|
||||||
[id, stockitem, user]
|
}, [id, stockitem, user]);
|
||||||
);
|
|
||||||
|
|
||||||
const stockBadges: ReactNode[] = useMemo(() => {
|
const stockBadges: ReactNode[] = useMemo(() => {
|
||||||
let available = (stockitem?.quantity ?? 0) - (stockitem?.allocated ?? 0);
|
let available = (stockitem?.quantity ?? 0) - (stockitem?.allocated ?? 0);
|
||||||
@ -642,6 +696,7 @@ export default function StockDetail() {
|
|||||||
{addStockItem.modal}
|
{addStockItem.modal}
|
||||||
{removeStockItem.modal}
|
{removeStockItem.modal}
|
||||||
{transferStockItem.modal}
|
{transferStockItem.modal}
|
||||||
|
{serializeStockItem.modal}
|
||||||
</Stack>
|
</Stack>
|
||||||
</InstanceDetail>
|
</InstanceDetail>
|
||||||
);
|
);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user