update messaging, add 'added' line in history
This commit is contained in:
parent
9f01acaa66
commit
47ed85e180
|
|
@ -788,10 +788,9 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||
'validator': bool,
|
||||
},
|
||||
'STOCK_MERGE_ON_TRANSFER': {
|
||||
'name': _('Merge stock with existing stock on transfer'),
|
||||
'name': _('Merge stock with existing stock on transfer by default'),
|
||||
'description': _(
|
||||
'Default: when transferring stock, merge into an existing lot at the '
|
||||
'destination if possible (uses the same rules as manual stock merge)'
|
||||
'Default state for merge stock on transfer behaviour. (Can be changed per transfer if desired)'
|
||||
),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
|
|
|
|||
|
|
@ -2251,9 +2251,12 @@ class StockItem(
|
|||
)
|
||||
return
|
||||
|
||||
merged_quantity = Decimal(0)
|
||||
|
||||
for other in other_items:
|
||||
tree_ids.add(other.tree_id)
|
||||
|
||||
merged_quantity += other.quantity
|
||||
self.quantity += other.quantity
|
||||
|
||||
if other.purchase_price:
|
||||
|
|
@ -2294,7 +2297,8 @@ class StockItem(
|
|||
notes=notes,
|
||||
deltas={
|
||||
'location': location.pk if location else None,
|
||||
'quantity': self.quantity,
|
||||
'quantity': float(self.quantity),
|
||||
'added': float(merged_quantity),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1635,7 +1635,7 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
|
|||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['pk', 'quantity', 'batch', 'status', 'packaging']
|
||||
fields = ['pk', 'quantity', 'batch', 'status', 'packaging', 'merge']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the serializer."""
|
||||
|
|
@ -1709,6 +1709,15 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
|
|||
help_text=_('Packaging this stock item is stored in'),
|
||||
)
|
||||
|
||||
merge = serializers.BooleanField(
|
||||
default=False,
|
||||
required=False,
|
||||
label=_('Merge into existing stock'),
|
||||
help_text=_(
|
||||
'Merge this item into existing stock at the destination if possible'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class StockAdjustmentSerializer(serializers.Serializer):
|
||||
"""Base class for managing stock adjustment actions via the API."""
|
||||
|
|
@ -1860,13 +1869,14 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
|||
items = data['items']
|
||||
notes = data.get('notes', '')
|
||||
location = data['location']
|
||||
merge = data.get('merge', False)
|
||||
default_merge = data.get('merge', False)
|
||||
|
||||
with transaction.atomic():
|
||||
for item in items:
|
||||
# Required fields
|
||||
stock_item = item['pk']
|
||||
quantity = item['quantity']
|
||||
merge = item.get('merge', default_merge)
|
||||
|
||||
# Optional fields
|
||||
kwargs = {}
|
||||
|
|
|
|||
|
|
@ -2181,9 +2181,8 @@ class StockTransferMergeTest(StockAPITestCase):
|
|||
self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [{'pk': incoming.pk, 'quantity': 50}],
|
||||
'items': [{'pk': incoming.pk, 'quantity': 50, 'merge': False}],
|
||||
'location': self.dest.pk,
|
||||
'merge': False,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
|
@ -2207,9 +2206,8 @@ class StockTransferMergeTest(StockAPITestCase):
|
|||
self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [{'pk': incoming.pk, 'quantity': 50}],
|
||||
'items': [{'pk': incoming.pk, 'quantity': 50, 'merge': True}],
|
||||
'location': self.dest.pk,
|
||||
'merge': True,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
|
@ -2222,6 +2220,39 @@ class StockTransferMergeTest(StockAPITestCase):
|
|||
self.assertEqual(existing.quantity, 150)
|
||||
self.assertFalse(StockItem.objects.filter(pk=incoming.pk).exists())
|
||||
|
||||
def test_transfer_mixed_merge_per_item(self):
|
||||
"""Each transfer line can merge or move independently."""
|
||||
existing = StockItem.objects.create(
|
||||
part=self.part, location=self.dest, quantity=100
|
||||
)
|
||||
merge_incoming = StockItem.objects.create(
|
||||
part=self.part, location=self.source_loc, quantity=30
|
||||
)
|
||||
separate_incoming = StockItem.objects.create(
|
||||
part=self.part, location=self.source_loc, quantity=20
|
||||
)
|
||||
|
||||
self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [
|
||||
{'pk': merge_incoming.pk, 'quantity': 30, 'merge': True},
|
||||
{'pk': separate_incoming.pk, 'quantity': 20, 'merge': False},
|
||||
],
|
||||
'location': self.dest.pk,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
StockItem.objects.filter(part=self.part, location=self.dest).count(), 2
|
||||
)
|
||||
|
||||
existing.refresh_from_db()
|
||||
self.assertEqual(existing.quantity, 130)
|
||||
self.assertFalse(StockItem.objects.filter(pk=merge_incoming.pk).exists())
|
||||
self.assertTrue(StockItem.objects.filter(pk=separate_incoming.pk).exists())
|
||||
|
||||
def test_transfer_merge_does_not_copy_source_tracking(self):
|
||||
"""Transfer merge keeps destination history and adds a single merge entry."""
|
||||
existing = StockItem.objects.create(
|
||||
|
|
@ -2240,9 +2271,8 @@ class StockTransferMergeTest(StockAPITestCase):
|
|||
self.post(
|
||||
self.url,
|
||||
{
|
||||
'items': [{'pk': incoming.pk, 'quantity': 50}],
|
||||
'items': [{'pk': incoming.pk, 'quantity': 50, 'merge': True}],
|
||||
'location': self.dest.pk,
|
||||
'merge': True,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
|
@ -2253,11 +2283,12 @@ class StockTransferMergeTest(StockAPITestCase):
|
|||
existing.tracking_info.filter(notes='Source tracking entry').exists()
|
||||
)
|
||||
self.assertEqual(existing.tracking_info.count(), tracking_count + 1)
|
||||
self.assertTrue(
|
||||
existing.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.MERGED_STOCK_ITEMS
|
||||
).exists()
|
||||
)
|
||||
merge_entry = existing.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.MERGED_STOCK_ITEMS
|
||||
).first()
|
||||
self.assertIsNotNone(merge_entry)
|
||||
self.assertEqual(merge_entry.deltas['added'], 50.0)
|
||||
self.assertEqual(merge_entry.deltas['quantity'], 150.0)
|
||||
|
||||
|
||||
class StockItemDeletionTest(StockAPITestCase):
|
||||
|
|
|
|||
|
|
@ -723,9 +723,7 @@ class StockTest(StockTestBase):
|
|||
s3 = StockItem.objects.create(part=part, quantity=30)
|
||||
|
||||
s2.add_tracking_entry(
|
||||
StockHistoryCode.STOCK_UPDATE,
|
||||
None,
|
||||
notes='Merged away tracking',
|
||||
StockHistoryCode.STOCK_UPDATE, None, notes='Merged away tracking'
|
||||
)
|
||||
|
||||
tracking_before = s1.tracking_info.count()
|
||||
|
|
@ -736,11 +734,16 @@ class StockTest(StockTestBase):
|
|||
s1.refresh_from_db()
|
||||
self.assertEqual(s1.quantity, 60)
|
||||
self.assertIsNone(s1.purchase_price)
|
||||
self.assertTrue(
|
||||
s1.tracking_info.filter(notes='Merged away tracking').exists()
|
||||
)
|
||||
self.assertTrue(s1.tracking_info.filter(notes='Merged away tracking').exists())
|
||||
self.assertGreater(s1.tracking_info.count(), tracking_before)
|
||||
|
||||
merge_entry = s1.tracking_info.filter(
|
||||
tracking_type=StockHistoryCode.MERGED_STOCK_ITEMS
|
||||
).first()
|
||||
self.assertIsNotNone(merge_entry)
|
||||
self.assertEqual(merge_entry.deltas['added'], 50.0)
|
||||
self.assertEqual(merge_entry.deltas['quantity'], 60.0)
|
||||
|
||||
part.stock_items.all().delete()
|
||||
|
||||
# Create some stock items with pricing information
|
||||
|
|
|
|||
|
|
@ -555,6 +555,7 @@ function StockOperationsRow({
|
|||
add = false,
|
||||
setMax = false,
|
||||
merge = false,
|
||||
transferMerge = false,
|
||||
record
|
||||
}: {
|
||||
props: TableFieldRowProps;
|
||||
|
|
@ -563,6 +564,7 @@ function StockOperationsRow({
|
|||
add?: boolean;
|
||||
setMax?: boolean;
|
||||
merge?: boolean;
|
||||
transferMerge?: boolean;
|
||||
record?: any;
|
||||
}) {
|
||||
const statusOptions: ApiFormFieldChoice[] = useMemo(() => {
|
||||
|
|
@ -712,6 +714,17 @@ function StockOperationsRow({
|
|||
variant={packagingOpen ? 'filled' : 'transparent'}
|
||||
/>
|
||||
)}
|
||||
{transferMerge && (
|
||||
<ActionButton
|
||||
size='sm'
|
||||
icon={<InvenTreeIcon icon='merge' />}
|
||||
tooltip={t`Merge into existing stock`}
|
||||
onClick={() =>
|
||||
callChangeFn(props.idx, 'merge', !props.item?.merge)
|
||||
}
|
||||
variant={props.item?.merge ? 'filled' : 'transparent'}
|
||||
/>
|
||||
)}
|
||||
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
|
||||
</Flex>
|
||||
</Table.Td>
|
||||
|
|
@ -759,9 +772,10 @@ type StockAdjustmentItem = {
|
|||
batch?: string;
|
||||
status?: number | '' | null;
|
||||
packaging?: string;
|
||||
merge?: boolean;
|
||||
};
|
||||
|
||||
function mapAdjustmentItems(items: any[]) {
|
||||
function mapAdjustmentItems(items: any[], mergeDefault?: boolean) {
|
||||
const mappedItems: StockAdjustmentItemWithRecord[] = items.map((elem) => {
|
||||
return {
|
||||
pk: elem.pk,
|
||||
|
|
@ -769,6 +783,7 @@ function mapAdjustmentItems(items: any[]) {
|
|||
batch: elem.batch || undefined,
|
||||
status: elem.status || undefined,
|
||||
packaging: elem.packaging || undefined,
|
||||
merge: elem.merge ?? mergeDefault ?? false,
|
||||
obj: elem
|
||||
};
|
||||
});
|
||||
|
|
@ -776,7 +791,10 @@ function mapAdjustmentItems(items: any[]) {
|
|||
return mappedItems;
|
||||
}
|
||||
|
||||
function stockTransferFields(items: any[]): ApiFormFieldSet {
|
||||
function stockTransferFields(
|
||||
items: any[],
|
||||
mergeDefault = false
|
||||
): ApiFormFieldSet {
|
||||
if (!items) {
|
||||
return {};
|
||||
}
|
||||
|
|
@ -789,7 +807,7 @@ function stockTransferFields(items: any[]): ApiFormFieldSet {
|
|||
const fields: ApiFormFieldSet = {
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: mapAdjustmentItems(items),
|
||||
value: mapAdjustmentItems(items, mergeDefault),
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record = records[row.item.pk];
|
||||
|
||||
|
|
@ -799,6 +817,7 @@ function stockTransferFields(items: any[]): ApiFormFieldSet {
|
|||
transfer
|
||||
changeStatus
|
||||
setMax
|
||||
transferMerge
|
||||
key={record.pk}
|
||||
record={record}
|
||||
/>
|
||||
|
|
@ -1329,12 +1348,11 @@ export function useTransferStockItem(props: StockOperationProps) {
|
|||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const fieldGenerator = useCallback(
|
||||
(items: any[]) => ({
|
||||
...stockTransferFields(items),
|
||||
merge: {
|
||||
default: globalSettings.isSet('STOCK_MERGE_ON_TRANSFER')
|
||||
}
|
||||
}),
|
||||
(items: any[]) =>
|
||||
stockTransferFields(
|
||||
items,
|
||||
globalSettings.isSet('STOCK_MERGE_ON_TRANSFER')
|
||||
),
|
||||
[globalSettings]
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue