mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-29 20:16:44 +00:00
Merge pull request #2468 from SchrodingersGat/merge-stock-items
Merge stock items
This commit is contained in:
commit
09777c5764
@ -252,6 +252,9 @@ class StockHistoryCode(StatusCode):
|
|||||||
SPLIT_FROM_PARENT = 40
|
SPLIT_FROM_PARENT = 40
|
||||||
SPLIT_CHILD_ITEM = 42
|
SPLIT_CHILD_ITEM = 42
|
||||||
|
|
||||||
|
# Stock merging operations
|
||||||
|
MERGED_STOCK_ITEMS = 45
|
||||||
|
|
||||||
# Build order codes
|
# Build order codes
|
||||||
BUILD_OUTPUT_CREATED = 50
|
BUILD_OUTPUT_CREATED = 50
|
||||||
BUILD_OUTPUT_COMPLETED = 55
|
BUILD_OUTPUT_COMPLETED = 55
|
||||||
@ -288,6 +291,8 @@ class StockHistoryCode(StatusCode):
|
|||||||
SPLIT_FROM_PARENT: _('Split from parent item'),
|
SPLIT_FROM_PARENT: _('Split from parent item'),
|
||||||
SPLIT_CHILD_ITEM: _('Split child item'),
|
SPLIT_CHILD_ITEM: _('Split child item'),
|
||||||
|
|
||||||
|
MERGED_STOCK_ITEMS: _('Merged stock items'),
|
||||||
|
|
||||||
SENT_TO_CUSTOMER: _('Sent to customer'),
|
SENT_TO_CUSTOMER: _('Sent to customer'),
|
||||||
RETURNED_FROM_CUSTOMER: _('Returned from customer'),
|
RETURNED_FROM_CUSTOMER: _('Returned from customer'),
|
||||||
|
|
||||||
|
@ -12,10 +12,14 @@ import common.models
|
|||||||
INVENTREE_SW_VERSION = "0.6.0 dev"
|
INVENTREE_SW_VERSION = "0.6.0 dev"
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 21
|
INVENTREE_API_VERSION = 22
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||||
|
|
||||||
|
v22 -> 2021-12-20
|
||||||
|
- Adds API endpoint to "merge" multiple stock items
|
||||||
|
|
||||||
v21 -> 2021-12-04
|
v21 -> 2021-12-04
|
||||||
- Adds support for multiple "Shipments" against a SalesOrder
|
- Adds support for multiple "Shipments" against a SalesOrder
|
||||||
- Refactors process for stock allocation against a SalesOrder
|
- Refactors process for stock allocation against a SalesOrder
|
||||||
|
@ -628,28 +628,30 @@ class SalesOrder(Order):
|
|||||||
Throws a ValidationError if cannot be completed.
|
Throws a ValidationError if cannot be completed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Order without line items cannot be completed
|
try:
|
||||||
if self.lines.count() == 0:
|
|
||||||
if raise_error:
|
# Order without line items cannot be completed
|
||||||
|
if self.lines.count() == 0:
|
||||||
raise ValidationError(_('Order cannot be completed as no parts have been assigned'))
|
raise ValidationError(_('Order cannot be completed as no parts have been assigned'))
|
||||||
|
|
||||||
# Only a PENDING order can be marked as SHIPPED
|
# Only a PENDING order can be marked as SHIPPED
|
||||||
elif self.status != SalesOrderStatus.PENDING:
|
elif self.status != SalesOrderStatus.PENDING:
|
||||||
if raise_error:
|
|
||||||
raise ValidationError(_('Only a pending order can be marked as complete'))
|
raise ValidationError(_('Only a pending order can be marked as complete'))
|
||||||
|
|
||||||
elif self.pending_shipment_count > 0:
|
elif self.pending_shipment_count > 0:
|
||||||
if raise_error:
|
|
||||||
raise ValidationError(_("Order cannot be completed as there are incomplete shipments"))
|
raise ValidationError(_("Order cannot be completed as there are incomplete shipments"))
|
||||||
|
|
||||||
elif self.pending_line_count > 0:
|
elif self.pending_line_count > 0:
|
||||||
if raise_error:
|
|
||||||
raise ValidationError(_("Order cannot be completed as there are incomplete line items"))
|
raise ValidationError(_("Order cannot be completed as there are incomplete line items"))
|
||||||
|
|
||||||
else:
|
except ValidationError as e:
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
if raise_error:
|
||||||
|
raise e
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def complete_order(self, user):
|
def complete_order(self, user):
|
||||||
"""
|
"""
|
||||||
|
@ -4,6 +4,7 @@ This script calculates translation coverage for various languages
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def calculate_coverage(filename):
|
def calculate_coverage(filename):
|
||||||
@ -42,7 +43,7 @@ if __name__ == '__main__':
|
|||||||
locales = {}
|
locales = {}
|
||||||
locales_perc = {}
|
locales_perc = {}
|
||||||
|
|
||||||
print("InvenTree translation coverage:")
|
verbose = '-v' in sys.argv
|
||||||
|
|
||||||
for locale in os.listdir(LC_DIR):
|
for locale in os.listdir(LC_DIR):
|
||||||
path = os.path.join(LC_DIR, locale)
|
path = os.path.join(LC_DIR, locale)
|
||||||
@ -53,7 +54,10 @@ if __name__ == '__main__':
|
|||||||
if os.path.exists(locale_file) and os.path.isfile(locale_file):
|
if os.path.exists(locale_file) and os.path.isfile(locale_file):
|
||||||
locales[locale] = locale_file
|
locales[locale] = locale_file
|
||||||
|
|
||||||
print("-" * 16)
|
if verbose:
|
||||||
|
print("-" * 16)
|
||||||
|
|
||||||
|
percentages = []
|
||||||
|
|
||||||
for locale in locales.keys():
|
for locale in locales.keys():
|
||||||
locale_file = locales[locale]
|
locale_file = locales[locale]
|
||||||
@ -66,11 +70,23 @@ if __name__ == '__main__':
|
|||||||
else:
|
else:
|
||||||
percentage = 0
|
percentage = 0
|
||||||
|
|
||||||
print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |")
|
if verbose:
|
||||||
|
print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |")
|
||||||
|
|
||||||
locales_perc[locale] = percentage
|
locales_perc[locale] = percentage
|
||||||
|
|
||||||
print("-" * 16)
|
percentages.append(percentage)
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print("-" * 16)
|
||||||
|
|
||||||
# write locale stats
|
# write locale stats
|
||||||
with open(STAT_FILE, 'w') as target:
|
with open(STAT_FILE, 'w') as target:
|
||||||
json.dump(locales_perc, target)
|
json.dump(locales_perc, target)
|
||||||
|
|
||||||
|
if len(percentages) > 0:
|
||||||
|
avg = int(sum(percentages) / len(percentages))
|
||||||
|
else:
|
||||||
|
avg = 0
|
||||||
|
|
||||||
|
print(f"InvenTree translation coverage: {avg}%")
|
||||||
|
@ -180,6 +180,20 @@ class StockAssign(generics.CreateAPIView):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class StockMerge(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for merging multiple stock items
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = StockItem.objects.none()
|
||||||
|
serializer_class = StockSerializers.StockMergeSerializer
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
ctx = super().get_serializer_context()
|
||||||
|
ctx['request'] = self.request
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class StockLocationList(generics.ListCreateAPIView):
|
class StockLocationList(generics.ListCreateAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint for list view of StockLocation objects:
|
API endpoint for list view of StockLocation objects:
|
||||||
@ -1213,6 +1227,7 @@ stock_api_urls = [
|
|||||||
url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'),
|
url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'),
|
||||||
url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'),
|
url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'),
|
||||||
url(r'^assign/', StockAssign.as_view(), name='api-stock-assign'),
|
url(r'^assign/', StockAssign.as_view(), name='api-stock-assign'),
|
||||||
|
url(r'^merge/', StockMerge.as_view(), name='api-stock-merge'),
|
||||||
|
|
||||||
# StockItemAttachment API endpoints
|
# StockItemAttachment API endpoints
|
||||||
url(r'^attachment/', include([
|
url(r'^attachment/', include([
|
||||||
|
@ -114,19 +114,6 @@
|
|||||||
lft: 0
|
lft: 0
|
||||||
rght: 0
|
rght: 0
|
||||||
|
|
||||||
- model: stock.stockitem
|
|
||||||
pk: 501
|
|
||||||
fields:
|
|
||||||
part: 10001
|
|
||||||
location: 7
|
|
||||||
batch: "AAA"
|
|
||||||
quantity: 1
|
|
||||||
serial: 1
|
|
||||||
level: 0
|
|
||||||
tree_id: 0
|
|
||||||
lft: 0
|
|
||||||
rght: 0
|
|
||||||
|
|
||||||
- model: stock.stockitem
|
- model: stock.stockitem
|
||||||
pk: 501
|
pk: 501
|
||||||
fields:
|
fields:
|
||||||
|
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 3.2.10 on 2021-12-20 21:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stock', '0072_remove_stockitem_scheduled_for_deletion'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='stockitem',
|
||||||
|
name='belongs_to',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Is this item installed in another item?', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='installed_parts', to='stock.stockitem', verbose_name='Installed In'),
|
||||||
|
),
|
||||||
|
]
|
@ -455,6 +455,7 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field"))
|
uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field"))
|
||||||
|
|
||||||
|
# Note: When a StockItem is deleted, a pre_delete signal handles the parent/child relationship
|
||||||
parent = TreeForeignKey(
|
parent = TreeForeignKey(
|
||||||
'self',
|
'self',
|
||||||
verbose_name=_('Parent Stock Item'),
|
verbose_name=_('Parent Stock Item'),
|
||||||
@ -477,6 +478,7 @@ class StockItem(MPTTModel):
|
|||||||
help_text=_('Select a matching supplier part for this stock item')
|
help_text=_('Select a matching supplier part for this stock item')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Note: When a StockLocation is deleted, stock items are updated via a signal
|
||||||
location = TreeForeignKey(
|
location = TreeForeignKey(
|
||||||
StockLocation, on_delete=models.DO_NOTHING,
|
StockLocation, on_delete=models.DO_NOTHING,
|
||||||
verbose_name=_('Stock Location'),
|
verbose_name=_('Stock Location'),
|
||||||
@ -492,10 +494,11 @@ class StockItem(MPTTModel):
|
|||||||
help_text=_('Packaging this stock item is stored in')
|
help_text=_('Packaging this stock item is stored in')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# When deleting a stock item with installed items, those installed items are also installed
|
||||||
belongs_to = models.ForeignKey(
|
belongs_to = models.ForeignKey(
|
||||||
'self',
|
'self',
|
||||||
verbose_name=_('Installed In'),
|
verbose_name=_('Installed In'),
|
||||||
on_delete=models.DO_NOTHING,
|
on_delete=models.CASCADE,
|
||||||
related_name='installed_parts', blank=True, null=True,
|
related_name='installed_parts', blank=True, null=True,
|
||||||
help_text=_('Is this item installed in another item?')
|
help_text=_('Is this item installed in another item?')
|
||||||
)
|
)
|
||||||
@ -800,14 +803,14 @@ class StockItem(MPTTModel):
|
|||||||
def can_delete(self):
|
def can_delete(self):
|
||||||
""" Can this stock item be deleted? It can NOT be deleted under the following circumstances:
|
""" Can this stock item be deleted? It can NOT be deleted under the following circumstances:
|
||||||
|
|
||||||
- Has child StockItems
|
- Has installed stock items
|
||||||
- Has a serial number and is tracked
|
- Has a serial number and is tracked
|
||||||
- Is installed inside another StockItem
|
- Is installed inside another StockItem
|
||||||
- It has been assigned to a SalesOrder
|
- It has been assigned to a SalesOrder
|
||||||
- It has been assigned to a BuildOrder
|
- It has been assigned to a BuildOrder
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.child_count > 0:
|
if self.installed_item_count() > 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if self.part.trackable and self.serial is not None:
|
if self.part.trackable and self.serial is not None:
|
||||||
@ -853,20 +856,13 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
return installed
|
return installed
|
||||||
|
|
||||||
def installedItemCount(self):
|
def installed_item_count(self):
|
||||||
"""
|
"""
|
||||||
Return the number of stock items installed inside this one.
|
Return the number of stock items installed inside this one.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self.installed_parts.count()
|
return self.installed_parts.count()
|
||||||
|
|
||||||
def hasInstalledItems(self):
|
|
||||||
"""
|
|
||||||
Returns true if this stock item has other stock items installed in it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.installedItemCount() > 0
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def installStockItem(self, other_item, quantity, user, notes):
|
def installStockItem(self, other_item, quantity, user, notes):
|
||||||
"""
|
"""
|
||||||
@ -1153,6 +1149,124 @@ class StockItem(MPTTModel):
|
|||||||
result.stock_item = self
|
result.stock_item = self
|
||||||
result.save()
|
result.save()
|
||||||
|
|
||||||
|
def can_merge(self, other=None, raise_error=False, **kwargs):
|
||||||
|
"""
|
||||||
|
Check if this stock item can be merged into another stock item
|
||||||
|
"""
|
||||||
|
|
||||||
|
allow_mismatched_suppliers = kwargs.get('allow_mismatched_suppliers', False)
|
||||||
|
|
||||||
|
allow_mismatched_status = kwargs.get('allow_mismatched_status', False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Generic checks (do not rely on the 'other' part)
|
||||||
|
if self.sales_order:
|
||||||
|
raise ValidationError(_('Stock item has been assigned to a sales order'))
|
||||||
|
|
||||||
|
if self.belongs_to:
|
||||||
|
raise ValidationError(_('Stock item is installed in another item'))
|
||||||
|
|
||||||
|
if self.installed_item_count() > 0:
|
||||||
|
raise ValidationError(_('Stock item contains other items'))
|
||||||
|
|
||||||
|
if self.customer:
|
||||||
|
raise ValidationError(_('Stock item has been assigned to a customer'))
|
||||||
|
|
||||||
|
if self.is_building:
|
||||||
|
raise ValidationError(_('Stock item is currently in production'))
|
||||||
|
|
||||||
|
if self.serialized:
|
||||||
|
raise ValidationError(_("Serialized stock cannot be merged"))
|
||||||
|
|
||||||
|
if other:
|
||||||
|
# Specific checks (rely on the 'other' part)
|
||||||
|
|
||||||
|
# Prevent stock item being merged with itself
|
||||||
|
if self == other:
|
||||||
|
raise ValidationError(_('Duplicate stock items'))
|
||||||
|
|
||||||
|
# Base part must match
|
||||||
|
if self.part != other.part:
|
||||||
|
raise ValidationError(_("Stock items must refer to the same part"))
|
||||||
|
|
||||||
|
# Check if supplier part references match
|
||||||
|
if self.supplier_part != other.supplier_part and not allow_mismatched_suppliers:
|
||||||
|
raise ValidationError(_("Stock items must refer to the same supplier part"))
|
||||||
|
|
||||||
|
# Check if stock status codes match
|
||||||
|
if self.status != other.status and not allow_mismatched_status:
|
||||||
|
raise ValidationError(_("Stock status codes must match"))
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
if raise_error:
|
||||||
|
raise e
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def merge_stock_items(self, other_items, raise_error=False, **kwargs):
|
||||||
|
"""
|
||||||
|
Merge another stock item into this one; the two become one!
|
||||||
|
|
||||||
|
*This* stock item subsumes the other, which is essentially deleted:
|
||||||
|
|
||||||
|
- The quantity of this StockItem is increased
|
||||||
|
- Tracking history for the *other* item is deleted
|
||||||
|
- Any allocations (build order, sales order) are moved to this StockItem
|
||||||
|
"""
|
||||||
|
|
||||||
|
if len(other_items) == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
user = kwargs.get('user', None)
|
||||||
|
location = kwargs.get('location', None)
|
||||||
|
notes = kwargs.get('notes', None)
|
||||||
|
|
||||||
|
parent_id = self.parent.pk if self.parent else None
|
||||||
|
|
||||||
|
for other in other_items:
|
||||||
|
# If the stock item cannot be merged, return
|
||||||
|
if not self.can_merge(other, raise_error=raise_error, **kwargs):
|
||||||
|
return
|
||||||
|
|
||||||
|
for other in other_items:
|
||||||
|
|
||||||
|
self.quantity += other.quantity
|
||||||
|
|
||||||
|
# Any "build order allocations" for the other item must be assigned to this one
|
||||||
|
for allocation in other.allocations.all():
|
||||||
|
|
||||||
|
allocation.stock_item = self
|
||||||
|
allocation.save()
|
||||||
|
|
||||||
|
# Any "sales order allocations" for the other item must be assigned to this one
|
||||||
|
for allocation in other.sales_order_allocations.all():
|
||||||
|
|
||||||
|
allocation.stock_item = self()
|
||||||
|
allocation.save()
|
||||||
|
|
||||||
|
# Prevent atomicity issues when we are merging our own "parent" part in
|
||||||
|
if parent_id and parent_id == other.pk:
|
||||||
|
self.parent = None
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
other.delete()
|
||||||
|
|
||||||
|
self.add_tracking_entry(
|
||||||
|
StockHistoryCode.MERGED_STOCK_ITEMS,
|
||||||
|
user,
|
||||||
|
quantity=self.quantity,
|
||||||
|
notes=notes,
|
||||||
|
deltas={
|
||||||
|
'location': location.pk,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.location = location
|
||||||
|
self.save()
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def splitStock(self, quantity, location, user, **kwargs):
|
def splitStock(self, quantity, location, user, **kwargs):
|
||||||
""" Split this stock item into two items, in the same location.
|
""" Split this stock item into two items, in the same location.
|
||||||
@ -1648,7 +1762,8 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
@receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log')
|
@receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log')
|
||||||
def before_delete_stock_item(sender, instance, using, **kwargs):
|
def before_delete_stock_item(sender, instance, using, **kwargs):
|
||||||
""" Receives pre_delete signal from StockItem object.
|
"""
|
||||||
|
Receives pre_delete signal from StockItem object.
|
||||||
|
|
||||||
Before a StockItem is deleted, ensure that each child object is updated,
|
Before a StockItem is deleted, ensure that each child object is updated,
|
||||||
to point to the new parent item.
|
to point to the new parent item.
|
||||||
|
@ -674,6 +674,149 @@ class StockAssignmentSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StockMergeItemSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for a single StockItem within the StockMergeSerializer class.
|
||||||
|
|
||||||
|
Here, the individual StockItem is being checked for merge compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'item',
|
||||||
|
]
|
||||||
|
|
||||||
|
item = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=StockItem.objects.all(),
|
||||||
|
many=False,
|
||||||
|
allow_null=False,
|
||||||
|
required=True,
|
||||||
|
label=_('Stock Item'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_item(self, item):
|
||||||
|
|
||||||
|
# Check that the stock item is able to be merged
|
||||||
|
item.can_merge(raise_error=True)
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
class StockMergeSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for merging two (or more) stock items together
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'items',
|
||||||
|
'location',
|
||||||
|
'notes',
|
||||||
|
'allow_mismatched_suppliers',
|
||||||
|
'allow_mismatched_status',
|
||||||
|
]
|
||||||
|
|
||||||
|
items = StockMergeItemSerializer(
|
||||||
|
many=True,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
location = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=StockLocation.objects.all(),
|
||||||
|
many=False,
|
||||||
|
required=True,
|
||||||
|
allow_null=False,
|
||||||
|
label=_('Location'),
|
||||||
|
help_text=_('Destination stock location'),
|
||||||
|
)
|
||||||
|
|
||||||
|
notes = serializers.CharField(
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
label=_('Notes'),
|
||||||
|
help_text=_('Stock merging notes'),
|
||||||
|
)
|
||||||
|
|
||||||
|
allow_mismatched_suppliers = serializers.BooleanField(
|
||||||
|
required=False,
|
||||||
|
label=_('Allow mismatched suppliers'),
|
||||||
|
help_text=_('Allow stock items with different supplier parts to be merged'),
|
||||||
|
)
|
||||||
|
|
||||||
|
allow_mismatched_status = serializers.BooleanField(
|
||||||
|
required=False,
|
||||||
|
label=_('Allow mismatched status'),
|
||||||
|
help_text=_('Allow stock items with different status codes to be merged'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
|
||||||
|
data = super().validate(data)
|
||||||
|
|
||||||
|
items = data['items']
|
||||||
|
|
||||||
|
if len(items) < 2:
|
||||||
|
raise ValidationError(_('At least two stock items must be provided'))
|
||||||
|
|
||||||
|
unique_items = set()
|
||||||
|
|
||||||
|
# The "base item" is the first item
|
||||||
|
base_item = items[0]['item']
|
||||||
|
|
||||||
|
data['base_item'] = base_item
|
||||||
|
|
||||||
|
# Ensure stock items are unique!
|
||||||
|
for element in items:
|
||||||
|
item = element['item']
|
||||||
|
|
||||||
|
if item.pk in unique_items:
|
||||||
|
raise ValidationError(_('Duplicate stock items'))
|
||||||
|
|
||||||
|
unique_items.add(item.pk)
|
||||||
|
|
||||||
|
# Checks from here refer to the "base_item"
|
||||||
|
if item == base_item:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check that this item can be merged with the base_item
|
||||||
|
item.can_merge(
|
||||||
|
raise_error=True,
|
||||||
|
other=base_item,
|
||||||
|
allow_mismatched_suppliers=data.get('allow_mismatched_suppliers', False),
|
||||||
|
allow_mismatched_status=data.get('allow_mismatched_status', False),
|
||||||
|
)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
"""
|
||||||
|
Actually perform the stock merging action.
|
||||||
|
At this point we are confident that the merge can take place
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
base_item = data['base_item']
|
||||||
|
items = data['items'][1:]
|
||||||
|
|
||||||
|
request = self.context['request']
|
||||||
|
user = getattr(request, 'user', None)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
|
||||||
|
for item in data['items'][1:]:
|
||||||
|
items.append(item['item'])
|
||||||
|
|
||||||
|
base_item.merge_stock_items(
|
||||||
|
items,
|
||||||
|
allow_mismatched_suppliers=data.get('allow_mismatched_suppliers', False),
|
||||||
|
allow_mismatched_status=data.get('allow_mismatched_status', False),
|
||||||
|
user=user,
|
||||||
|
location=data['location'],
|
||||||
|
notes=data.get('notes', None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StockAdjustmentItemSerializer(serializers.Serializer):
|
class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
Serializer for a single StockItem within a stock adjument request.
|
Serializer for a single StockItem within a stock adjument request.
|
||||||
@ -837,7 +980,7 @@ class StockTransferSerializer(StockAdjustmentSerializer):
|
|||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
|
|
||||||
super().validate(data)
|
data = super().validate(data)
|
||||||
|
|
||||||
# TODO: Any specific validation of location field?
|
# TODO: Any specific validation of location field?
|
||||||
|
|
||||||
|
@ -274,14 +274,6 @@
|
|||||||
<div class='alert alert-block alert-warning'>
|
<div class='alert alert-block alert-warning'>
|
||||||
{% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %}
|
{% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %}
|
||||||
</div>
|
</div>
|
||||||
{% elif item.child_count > 0 %}
|
|
||||||
<div class='alert alert-block alert-warning'>
|
|
||||||
{% trans "This stock item cannot be deleted as it has child items" %}
|
|
||||||
</div>
|
|
||||||
{% elif item.delete_on_deplete and item.can_delete %}
|
|
||||||
<div class='alert alert-block alert-warning'>
|
|
||||||
{% trans "This stock item will be automatically deleted when all stock is depleted." %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -842,3 +842,189 @@ class StockAssignTest(StockAPITestCase):
|
|||||||
|
|
||||||
# 5 stock items should now have been assigned to this customer
|
# 5 stock items should now have been assigned to this customer
|
||||||
self.assertEqual(customer.assigned_stock.count(), 5)
|
self.assertEqual(customer.assigned_stock.count(), 5)
|
||||||
|
|
||||||
|
|
||||||
|
class StockMergeTest(StockAPITestCase):
|
||||||
|
"""
|
||||||
|
Unit tests for merging stock items via the API
|
||||||
|
"""
|
||||||
|
|
||||||
|
URL = reverse('api-stock-merge')
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.part = part.models.Part.objects.get(pk=25)
|
||||||
|
self.loc = StockLocation.objects.get(pk=1)
|
||||||
|
self.sp_1 = company.models.SupplierPart.objects.get(pk=100)
|
||||||
|
self.sp_2 = company.models.SupplierPart.objects.get(pk=101)
|
||||||
|
|
||||||
|
self.item_1 = StockItem.objects.create(
|
||||||
|
part=self.part,
|
||||||
|
supplier_part=self.sp_1,
|
||||||
|
quantity=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.item_2 = StockItem.objects.create(
|
||||||
|
part=self.part,
|
||||||
|
supplier_part=self.sp_2,
|
||||||
|
quantity=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.item_3 = StockItem.objects.create(
|
||||||
|
part=self.part,
|
||||||
|
supplier_part=self.sp_2,
|
||||||
|
quantity=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_data(self):
|
||||||
|
"""
|
||||||
|
Test responses which are missing required data
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Post completely empty
|
||||||
|
|
||||||
|
data = self.post(
|
||||||
|
self.URL,
|
||||||
|
{},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('This field is required', str(data['items']))
|
||||||
|
self.assertIn('This field is required', str(data['location']))
|
||||||
|
|
||||||
|
# Post with a location and empty items list
|
||||||
|
data = self.post(
|
||||||
|
self.URL,
|
||||||
|
{
|
||||||
|
'items': [],
|
||||||
|
'location': 1,
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('At least two stock items', str(data))
|
||||||
|
|
||||||
|
def test_invalid_data(self):
|
||||||
|
"""
|
||||||
|
Test responses which have invalid data
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Serialized stock items should be rejected
|
||||||
|
data = self.post(
|
||||||
|
self.URL,
|
||||||
|
{
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'item': 501,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item': 502,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'location': 1,
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('Serialized stock cannot be merged', str(data))
|
||||||
|
|
||||||
|
# Prevent item duplication
|
||||||
|
|
||||||
|
data = self.post(
|
||||||
|
self.URL,
|
||||||
|
{
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'item': 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item': 11,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'location': 1,
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('Duplicate stock items', str(data))
|
||||||
|
|
||||||
|
# Check for mismatching stock items
|
||||||
|
data = self.post(
|
||||||
|
self.URL,
|
||||||
|
{
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'item': 1234,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item': 11,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'location': 1,
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('Stock items must refer to the same part', str(data))
|
||||||
|
|
||||||
|
# Check for mismatching supplier parts
|
||||||
|
payload = {
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'item': self.item_1.pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item': self.item_2.pk,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'location': 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
data = self.post(
|
||||||
|
self.URL,
|
||||||
|
payload,
|
||||||
|
expected_code=400,
|
||||||
|
).data
|
||||||
|
|
||||||
|
self.assertIn('Stock items must refer to the same supplier part', str(data))
|
||||||
|
|
||||||
|
def test_valid_merge(self):
|
||||||
|
"""
|
||||||
|
Test valid merging of stock items
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Check initial conditions
|
||||||
|
n = StockItem.objects.filter(part=self.part).count()
|
||||||
|
self.assertEqual(self.item_1.quantity, 100)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'item': self.item_1.pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item': self.item_2.pk,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'item': self.item_3.pk,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'location': 1,
|
||||||
|
'allow_mismatched_suppliers': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.post(
|
||||||
|
self.URL,
|
||||||
|
payload,
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.item_1.refresh_from_db()
|
||||||
|
|
||||||
|
# Stock quantity should have been increased!
|
||||||
|
self.assertEqual(self.item_1.quantity, 250)
|
||||||
|
|
||||||
|
# Total number of stock items has been reduced!
|
||||||
|
self.assertEqual(StockItem.objects.filter(part=self.part).count(), n - 2)
|
||||||
|
@ -52,6 +52,7 @@
|
|||||||
loadStockTestResultsTable,
|
loadStockTestResultsTable,
|
||||||
loadStockTrackingTable,
|
loadStockTrackingTable,
|
||||||
loadTableFilters,
|
loadTableFilters,
|
||||||
|
mergeStockItems,
|
||||||
removeStockRow,
|
removeStockRow,
|
||||||
serializeStockItem,
|
serializeStockItem,
|
||||||
stockItemFields,
|
stockItemFields,
|
||||||
@ -595,17 +596,17 @@ function assignStockToCustomer(items, options={}) {
|
|||||||
buttons += '</div>';
|
buttons += '</div>';
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<tr id='stock_item_${pk}' class='stock-item'row'>
|
<tr id='stock_item_${pk}' class='stock-item-row'>
|
||||||
<td id='part_${pk}'>${thumbnail} ${part.full_name}</td>
|
<td id='part_${pk}'>${thumbnail} ${part.full_name}</td>
|
||||||
<td id='stock_${pk}'>
|
<td id='stock_${pk}'>
|
||||||
<div id='div_id_items_item_${pk}'>
|
<div id='div_id_items_item_${pk}'>
|
||||||
${quantity}
|
${quantity}
|
||||||
<div id='errors-items_item_${pk}'></div>
|
<div id='errors-items_item_${pk}'></div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td id='location_${pk}'>${location}</td>
|
<td id='location_${pk}'>${location}</td>
|
||||||
<td id='buttons_${pk}'>${buttons}</td>
|
<td id='buttons_${pk}'>${buttons}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -615,13 +616,13 @@ function assignStockToCustomer(items, options={}) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
preFormContent: html,
|
preFormContent: html,
|
||||||
fields: {
|
fields: {
|
||||||
'customer': {
|
customer: {
|
||||||
value: options.customer,
|
value: options.customer,
|
||||||
filters: {
|
filters: {
|
||||||
is_customer: true,
|
is_customer: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'notes': {},
|
notes: {},
|
||||||
},
|
},
|
||||||
confirm: true,
|
confirm: true,
|
||||||
confirmMessage: '{% trans "Confirm stock assignment" %}',
|
confirmMessage: '{% trans "Confirm stock assignment" %}',
|
||||||
@ -694,6 +695,184 @@ function assignStockToCustomer(items, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge multiple stock items together
|
||||||
|
*/
|
||||||
|
function mergeStockItems(items, options={}) {
|
||||||
|
|
||||||
|
// Generate HTML content for the form
|
||||||
|
var html = `
|
||||||
|
<div class='alert alert-block alert-danger'>
|
||||||
|
<h5>{% trans "Warning: Merge operation cannot be reversed" %}</h5>
|
||||||
|
<strong>{% trans "Some information will be lost when merging stock items" %}:</strong>
|
||||||
|
<ul>
|
||||||
|
<li>{% trans "Stock transaction history will be deleted for merged items" %}</li>
|
||||||
|
<li>{% trans "Supplier part information will be deleted for merged items" %}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<table class='table table-striped table-condensed' id='stock-merge-table'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Part" %}</th>
|
||||||
|
<th>{% trans "Stock Item" %}</th>
|
||||||
|
<th>{% trans "Location" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Keep track of how many "locations" there are
|
||||||
|
var locations = [];
|
||||||
|
|
||||||
|
for (var idx = 0; idx < items.length; idx++) {
|
||||||
|
var item = items[idx];
|
||||||
|
|
||||||
|
var pk = item.pk;
|
||||||
|
|
||||||
|
if (item.location && !locations.includes(item.location)) {
|
||||||
|
locations.push(item.location);
|
||||||
|
}
|
||||||
|
|
||||||
|
var part = item.part_detail;
|
||||||
|
var location = locationDetail(item, false);
|
||||||
|
|
||||||
|
var thumbnail = thumbnailImage(part.thumbnail || part.image);
|
||||||
|
|
||||||
|
var quantity = '';
|
||||||
|
|
||||||
|
if (item.serial && item.quantity == 1) {
|
||||||
|
quantity = `{% trans "Serial" %}: ${item.serial}`;
|
||||||
|
} else {
|
||||||
|
quantity = `{% trans "Quantity" %}: ${item.quantity}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
quantity += stockStatusDisplay(item.status, {classes: 'float-right'});
|
||||||
|
|
||||||
|
var buttons = `<div class='btn-group' role='group'>`;
|
||||||
|
|
||||||
|
buttons += makeIconButton(
|
||||||
|
'fa-times icon-red',
|
||||||
|
'button-stock-item-remove',
|
||||||
|
pk,
|
||||||
|
'{% trans "Remove row" %}',
|
||||||
|
);
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr id='stock_item_${pk}' class='stock-item-row'>
|
||||||
|
<td id='part_${pk}'>${thumbnail} ${part.full_name}</td>
|
||||||
|
<td id='stock_${pk}'>
|
||||||
|
<div id='div_id_items_item_${pk}'>
|
||||||
|
${quantity}
|
||||||
|
<div id='errors-items_item_${pk}'></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td id='location_${pk}'>${location}</td>
|
||||||
|
<td id='buttons_${pk}'>${buttons}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
|
||||||
|
var location = locations.length == 1 ? locations[0] : null;
|
||||||
|
|
||||||
|
constructForm('{% url "api-stock-merge" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
preFormContent: html,
|
||||||
|
fields: {
|
||||||
|
location: {
|
||||||
|
value: location,
|
||||||
|
icon: 'fa-sitemap',
|
||||||
|
},
|
||||||
|
notes: {},
|
||||||
|
allow_mismatched_suppliers: {},
|
||||||
|
allow_mismatched_status: {},
|
||||||
|
},
|
||||||
|
confirm: true,
|
||||||
|
confirmMessage: '{% trans "Confirm stock item merge" %}',
|
||||||
|
title: '{% trans "Merge Stock Items" %}',
|
||||||
|
afterRender: function(fields, opts) {
|
||||||
|
// Add button callbacks to remove rows
|
||||||
|
$(opts.modal).find('.button-stock-item-remove').click(function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
$(opts.modal).find(`#stock_item_${pk}`).remove();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmit: function(fields, opts) {
|
||||||
|
|
||||||
|
// Extract data elements from the form
|
||||||
|
var data = {
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
var item_pk_values = [];
|
||||||
|
|
||||||
|
items.forEach(function(item) {
|
||||||
|
var pk = item.pk;
|
||||||
|
|
||||||
|
// Does the row still exist in the form?
|
||||||
|
var row = $(opts.modal).find(`#stock_item_${pk}`);
|
||||||
|
|
||||||
|
if (row.exists()) {
|
||||||
|
item_pk_values.push(pk);
|
||||||
|
|
||||||
|
data.items.push({
|
||||||
|
item: pk,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var extra_fields = [
|
||||||
|
'location',
|
||||||
|
'notes',
|
||||||
|
'allow_mismatched_suppliers',
|
||||||
|
'allow_mismatched_status',
|
||||||
|
];
|
||||||
|
|
||||||
|
extra_fields.forEach(function(field) {
|
||||||
|
data[field] = getFormFieldValue(field, fields[field], opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
opts.nested = {
|
||||||
|
'items': item_pk_values
|
||||||
|
};
|
||||||
|
|
||||||
|
// Submit the form data
|
||||||
|
inventreePut(
|
||||||
|
'{% url "api-stock-merge" %}',
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
success: function(response) {
|
||||||
|
$(opts.modal).modal('hide');
|
||||||
|
|
||||||
|
if (options.success) {
|
||||||
|
options.success(response);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(xhr) {
|
||||||
|
switch (xhr.status) {
|
||||||
|
case 400:
|
||||||
|
handleFormErrors(xhr.responseJSON, fields, opts);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$(opts.modal).modal('hide');
|
||||||
|
showApiError(xhr, opts.url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform stock adjustments
|
* Perform stock adjustments
|
||||||
*/
|
*/
|
||||||
@ -1875,6 +2054,20 @@ function loadStockTable(table, options) {
|
|||||||
stockAdjustment('move');
|
stockAdjustment('move');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#multi-item-merge').click(function() {
|
||||||
|
var items = $(table).bootstrapTable('getSelections');
|
||||||
|
|
||||||
|
mergeStockItems(items, {
|
||||||
|
success: function(response) {
|
||||||
|
$(table).bootstrapTable('refresh');
|
||||||
|
|
||||||
|
showMessage('{% trans "Merged stock items" %}', {
|
||||||
|
style: 'success',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
$('#multi-item-assign').click(function() {
|
$('#multi-item-assign').click(function() {
|
||||||
|
|
||||||
var items = $(table).bootstrapTable('getSelections');
|
var items = $(table).bootstrapTable('getSelections');
|
||||||
|
@ -49,6 +49,7 @@
|
|||||||
<li><a class='dropdown-item' href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'><span class='fas fa-minus-circle'></span> {% trans "Remove stock" %}</a></li>
|
<li><a class='dropdown-item' href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'><span class='fas fa-minus-circle'></span> {% trans "Remove stock" %}</a></li>
|
||||||
<li><a class='dropdown-item' href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li>
|
<li><a class='dropdown-item' href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li>
|
||||||
<li><a class='dropdown-item' href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li>
|
<li><a class='dropdown-item' href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li>
|
||||||
|
<li><a class='dropdown-item' href='#' id='multi-item-merge' title='{% trans "Merge selected stock items" %}'><span class='fas fa-object-group'></span> {% trans "Merge stock" %}</a></li>
|
||||||
<li><a class='dropdown-item' href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li>
|
<li><a class='dropdown-item' href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li>
|
||||||
<li><a class='dropdown-item' href='#' id='multi-item-assign' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li>
|
<li><a class='dropdown-item' href='#' id='multi-item-assign' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li>
|
||||||
<li><a class='dropdown-item' href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li>
|
<li><a class='dropdown-item' href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user