2
0
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:
Oliver 2024-09-27 08:29:40 +10:00 committed by GitHub
parent 23de1e038d
commit a5f2273e14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 143 additions and 13 deletions

View File

@ -1572,6 +1572,13 @@ class StockItem(
# Remove the equivalent number of items
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
def copyHistoryFrom(self, other):
"""Copy stock history from another StockItem."""
@ -1806,7 +1813,7 @@ class StockItem(
for tree_id in tree_ids:
StockItem.objects.partial_rebuild(tree_id=tree_id)
except Exception:
logger.warning('Rebuilding entire StockItem tree')
logger.warning('Rebuilding entire StockItem tree during merge_stock_items')
StockItem.objects.rebuild()
@transaction.atomic

View File

@ -925,6 +925,46 @@ class StockItemListTest(StockAPITestCase):
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):
"""Tests for custom stock item statuses."""

View File

@ -116,12 +116,14 @@ export enum ApiEndpoints {
manufacturer_part_list = 'company/part/manufacturer/',
manufacturer_part_parameter_list = 'company/part/manufacturer/parameter/',
// Stock API endpoints
stock_item_list = 'stock/',
stock_tracking_list = 'stock/track/',
// Stock location endpoints
stock_location_list = 'stock/location/',
stock_location_type_list = 'stock/location-type/',
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_transfer = 'stock/transfer/',
stock_remove = 'stock/remove/',
@ -131,9 +133,10 @@ export enum ApiEndpoints {
stock_merge = 'stock/merge/',
stock_assign = 'stock/assign/',
stock_status = 'stock/status/',
stock_install = 'stock/:id/install',
build_test_statistics = 'test-statistics/by-build/:id',
part_test_statistics = 'test-statistics/by-part/:id',
stock_install = 'stock/:id/install/',
stock_serialize = 'stock/:id/serialize/',
build_test_statistics = 'test-statistics/by-build/:id/',
part_test_statistics = 'test-statistics/by-part/:id/',
// Generator API endpoints
generate_batch_code = 'generate/batch-code/',

View File

@ -40,6 +40,7 @@ import {
useBatchCodeGenerator,
useSerialNumberGenerator
} from '../hooks/UseGenerator';
import { useSerialNumberPlaceholder } from '../hooks/UsePlaceholder';
import { apiUrl } from '../states/ApiState';
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({
stockItem,
value

View File

@ -90,7 +90,7 @@ export function useInstance<T = any>({
});
const refreshInstance = useCallback(function () {
instanceQuery.refetch();
return instanceQuery.refetch();
}, []);
return {

View File

@ -39,12 +39,14 @@ import { StatusRenderer } from '../../components/render/StatusRenderer';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { partCategoryFields } from '../../forms/PartForms';
import {
StockOperationProps,
useAddStockItem,
useCountStockItem,
useRemoveStockItem,
useStockFields,
useStockItemSerializeFields,
useTransferStockItem
} from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
@ -203,6 +205,16 @@ export default function StockDetail() {
model: ModelType.stockitem,
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',
name: 'consumed_by',
@ -481,8 +493,39 @@ export default function StockDetail() {
const removeStockItem = useRemoveStockItem(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} />,
<BarcodeActionDropdown
model={ModelType.stockitem}
@ -503,6 +546,7 @@ export default function StockDetail() {
{
name: t`Count`,
tooltip: t`Count stock`,
hidden: serialized,
icon: (
<InvenTreeIcon icon="stocktake" iconProps={{ color: 'blue' }} />
),
@ -513,6 +557,7 @@ export default function StockDetail() {
{
name: t`Add`,
tooltip: t`Add stock`,
hidden: serialized,
icon: <InvenTreeIcon icon="add" iconProps={{ color: 'green' }} />,
onClick: () => {
stockitem.pk && addStockItem.open();
@ -521,11 +566,21 @@ export default function StockDetail() {
{
name: t`Remove`,
tooltip: t`Remove stock`,
hidden: serialized,
icon: <InvenTreeIcon icon="remove" iconProps={{ color: 'red' }} />,
onClick: () => {
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`,
tooltip: t`Transfer stock`,
@ -555,9 +610,8 @@ export default function StockDetail() {
})
]}
/>
],
[id, stockitem, user]
);
];
}, [id, stockitem, user]);
const stockBadges: ReactNode[] = useMemo(() => {
let available = (stockitem?.quantity ?? 0) - (stockitem?.allocated ?? 0);
@ -642,6 +696,7 @@ export default function StockDetail() {
{addStockItem.modal}
{removeStockItem.modal}
{transferStockItem.modal}
{serializeStockItem.modal}
</Stack>
</InstanceDetail>
);