diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index a278b4e17c..c80c0e8523 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -22,7 +22,7 @@ from markdownx.models import MarkdownxField from mptt.models import MPTTModel, TreeForeignKey -from InvenTree.status_codes import BuildStatus, StockStatus +from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode from InvenTree.validators import validate_build_order_reference from InvenTree.models import InvenTreeAttachment @@ -811,6 +811,7 @@ class Build(MPTTModel): # Select the location for the build output location = kwargs.get('location', self.destination) status = kwargs.get('status', StockStatus.OK) + notes = kwargs.get('notes', '') # List the allocated BuildItem objects for the given output allocated_items = output.items_to_install.all() @@ -834,10 +835,13 @@ class Build(MPTTModel): output.save() - output.addTransactionNote( - _('Completed build output'), + output.add_tracking_entry( + StockHistoryCode.BUILD_OUTPUT_COMPLETED, user, - system=True + notes=notes, + deltas={ + 'status': status, + } ) # Increase the completed quantity for this build diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 534775ebaf..8572d0c634 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -28,7 +28,7 @@ from company.models import Company, SupplierPart from InvenTree.fields import RoundingDecimalField from InvenTree.helpers import decimal2string, increment, getSetting -from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus +from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus, StockHistoryCode from InvenTree.models import InvenTreeAttachment @@ -336,10 +336,12 @@ class PurchaseOrder(Order): return self.pending_line_items().count() == 0 @transaction.atomic - def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None): + def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None, **kwargs): """ Receive a line item (or partial line item) against this PO """ + notes = kwargs.get('notes', '') + if not self.status == PurchaseOrderStatus.PLACED: raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")}) @@ -369,8 +371,22 @@ class PurchaseOrder(Order): text = _("Received items") note = _('Received {n} items against order {name}').format(n=quantity, name=str(self)) - # Add a new transaction note to the newly created stock item - stock.addTransactionNote(text, user, note) + tracking_info = { + 'status': status, + 'purchaseorder': self.pk, + 'quantity': quantity, + } + + if location: + tracking_info['location'] = location.pk + + stock.add_tracking_entry( + StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER, + user, + notes=notes, + url=self.get_absolute_url(), + deltas=tracking_info + ) # Update the number of parts received against the particular line item line.received += quantity diff --git a/InvenTree/stock/migrations/0060_auto_20210511_1713.py b/InvenTree/stock/migrations/0060_auto_20210511_1713.py new file mode 100644 index 0000000000..752b070750 --- /dev/null +++ b/InvenTree/stock/migrations/0060_auto_20210511_1713.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2 on 2021-05-11 07:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0059_auto_20210404_2016'), + ] + + operations = [ + migrations.AddField( + model_name='stockitemtracking', + name='deltas', + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name='stockitemtracking', + name='tracking_type', + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name='stockitemtracking', + name='title', + field=models.CharField(blank=True, help_text='Tracking entry title', max_length=250, verbose_name='Title'), + ), + ] diff --git a/InvenTree/stock/migrations/0061_auto_20210511_0911.py b/InvenTree/stock/migrations/0061_auto_20210511_0911.py new file mode 100644 index 0000000000..087863c348 --- /dev/null +++ b/InvenTree/stock/migrations/0061_auto_20210511_0911.py @@ -0,0 +1,59 @@ +# Generated by Django 3.2 on 2021-05-10 23:11 + +from django.db import migrations + + +def update_history(apps, schema_editor): + """ + Update each existing StockItemTracking object, + convert the recorded "quantity" to a delta + """ + + StockItem = apps.get_model('stock', 'stockitem') + StockItemTracking = apps.get_model('stock', 'stockitemtracking') + + update_count = 0 + + for item in StockItem.objects.all(): + + history = StockItemTracking.objects.filter(item=item).order_by('date') + + if history.count() == 0: + continue + + quantity = history[0].quantity + + for entry in history: + + q = entry.quantity + + if not q == quantity: + + entry.deltas = { + 'quantity': float(q), + } + + entry.save() + + update_count += 1 + + quantity = q + + print(f"Updated {update_count} StockItemHistory entries") + + +def reverse_update(apps, schema_editor): + """ + """ + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0060_auto_20210511_1713'), + ] + + operations = [ + migrations.RunPython(update_history, reverse_code=reverse_update) + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 3aecfff2c2..408c598141 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -34,7 +34,7 @@ import common.models import report.models import label.models -from InvenTree.status_codes import StockStatus +from InvenTree.status_codes import StockStatus, StockHistoryCode from InvenTree.models import InvenTreeTree, InvenTreeAttachment from InvenTree.fields import InvenTreeURLField @@ -198,14 +198,18 @@ class StockItem(MPTTModel): if add_note: - note = _('Created new stock item for {part}').format(part=str(self.part)) + tracking_info = { + 'quantity': self.quantity, + 'status': self.status, + } - # This StockItem is being saved for the first time - self.addTransactionNote( - _('Created stock item'), + if self.location: + tracking_info['location'] = self.location.pk + + self.add_tracking_entry( + StockHistoryCode.CREATED, user, - note, - system=True + deltas=tracking_info ) @property @@ -610,31 +614,45 @@ class StockItem(MPTTModel): # TODO - Remove any stock item allocations from this stock item - item.addTransactionNote( - _("Assigned to Customer"), + item.add_tracking_entry( + StockHistoryCode.SENT_TO_CUSTOMER, user, - notes=_("Manually assigned to customer {name}").format(name=customer.name), - system=True + { + 'customer': customer.id, + 'customer_name': customer.name, + }, + notes=notes, ) # Return the reference to the stock item return item - def returnFromCustomer(self, location, user=None): + def returnFromCustomer(self, location, user=None, **kwargs): """ Return stock item from customer, back into the specified location. """ - self.addTransactionNote( - _("Returned from customer {name}").format(name=self.customer.name), + notes = kwargs.get('notes', '') + + tracking_info = {} + + if location: + tracking_info['location'] = location.id + tracking_info['location_name'] = location.name + + if self.customer: + tracking_info['customer'] = customer.id + tracking_info['customer_name'] = customer.name + + self.add_tracking_entry( + StockHistoryCode.RETURNED_FROM_CUSTOMER, user, - notes=_("Returned to location {loc}").format(loc=location.name), - system=True + notes=notes, + deltas=tracking_info ) self.customer = None self.location = location - self.sales_order = None self.save() @@ -788,18 +806,25 @@ class StockItem(MPTTModel): stock_item.save() # Add a transaction note to the other item - stock_item.addTransactionNote( - _('Installed into stock item {pk}').format(str(self.pk)), + stock_item.add_tracking_entry( + StockHistoryCode.INSTALLED_INTO_ASSEMBLY, user, notes=notes, - url=self.get_absolute_url() + url=self.get_absolute_url(), + deltas={ + 'assembly': self.pk, + } ) - # Add a transaction note to this item - self.addTransactionNote( - _('Installed stock item {pk}').format(str(stock_item.pk)), - user, notes=notes, - url=stock_item.get_absolute_url() + # Add a transaction note to this item (the assembly) + self.add_tracking_entry( + StockHistoryCode.INSTALLED_CHILD_ITEM, + user, + notes=notes, + url=stock_item.get_absolute_url(), + deltas={ + 'stockitem': stock_item.pk, + } ) @transaction.atomic @@ -820,32 +845,41 @@ class StockItem(MPTTModel): # TODO - Are there any other checks that need to be performed at this stage? # Add a transaction note to the parent item - self.belongs_to.addTransactionNote( - _("Uninstalled stock item {pk}").format(pk=str(self.pk)), + self.belongs_to.add_tracking_entry( + StockHistoryCode.REMOVED_CHILD_ITEM, user, + deltas={ + 'stockitem': self.pk, + }, notes=notes, url=self.get_absolute_url(), ) + tracking_info = { + 'assembly': self.belongs_to.pk + } + + if location: + tracking_info['location'] = location.pk + tracking_info['location_name'] = location.name + url = location.get_absolute_url() + else: + url = '' + + self.add_tracking_entry( + StockHistoryCode.REMOVED_FROM_ASSEMBLY, + user, + notes=notes, + url=url, + deltas=tracking_info + ) + # Mark this stock item as *not* belonging to anyone self.belongs_to = None self.location = location self.save() - if location: - url = location.get_absolute_url() - else: - url = '' - - # Add a transaction note! - self.addTransactionNote( - _('Uninstalled into location {loc}').formaT(loc=str(location)), - user, - notes=notes, - url=url - ) - @property def children(self): """ Return a list of the child items which have been split from this stock item """ @@ -901,24 +935,30 @@ class StockItem(MPTTModel): def has_tracking_info(self): return self.tracking_info_count > 0 - def addTransactionNote(self, title, user, notes='', url='', system=True): - """ Generation a stock transaction note for this item. + def add_tracking_entry(self, entry_type, user, deltas={}, notes='', url=''): + """ + Add a history tracking entry for this StockItem - Brief automated note detailing a movement or quantity change. + Args: + entry_type - Integer code describing the "type" of historical action (see StockHistoryCode) + user - The user performing this action + deltas - A map of the changes made to the model + notes - User notes associated with this tracking entry + url - Optional URL associated with this tracking entry """ - track = StockItemTracking.objects.create( + entry = StockItemTracking.objects.create( item=self, - title=title, + tracking_type=entry_type, user=user, - quantity=self.quantity, - date=datetime.now().date(), + date=datetime.now(), notes=notes, + deltas=deltas, link=url, - system=system + system=True ) - track.save() + entry.save() @transaction.atomic def serializeStock(self, quantity, serials, user, notes='', location=None): @@ -991,10 +1031,17 @@ class StockItem(MPTTModel): new_item.copyTestResultsFrom(self) # Create a new stock tracking item - new_item.addTransactionNote(_('Add serial number'), user, notes=notes) + new_item.add_tracking_entry( + StockHistoryCode.ASSIGNED_SERIAL, + user, + notes=notes, + deltas={ + 'serial': serial, + } + ) # Remove the equivalent number of items - self.take_stock(quantity, user, notes=_('Serialized {n} items').format(n=quantity)) + self.take_stock(quantity, user, notes=notes) @transaction.atomic def copyHistoryFrom(self, other): @@ -1018,7 +1065,7 @@ class StockItem(MPTTModel): result.save() @transaction.atomic - def splitStock(self, quantity, location, user): + def splitStock(self, quantity, location, user, **kwargs): """ Split this stock item into two items, in the same location. Stock tracking notes for this StockItem will be duplicated, and added to the new StockItem. @@ -1032,6 +1079,8 @@ class StockItem(MPTTModel): The new item will have a different StockItem ID, while this will remain the same. """ + notes = kwargs.get('notes', '') + # Do not split a serialized part if self.serialized: return self @@ -1071,17 +1120,20 @@ class StockItem(MPTTModel): new_stock.copyTestResultsFrom(self) # Add a new tracking item for the new stock item - new_stock.addTransactionNote( - _("Split from existing stock"), + new_stock.add_tracking_entry( + StockHistoryCode.SPLIT_FROM_PARENT, user, - _('Split {n} items').format(n=helpers.normalize(quantity)) + notes=notes, + deltas={ + 'stockitem': self.pk, + } ) # Remove the specified quantity from THIS stock item self.take_stock( quantity, user, - f"{_('Split')} {quantity} {_('items into new stock item')}" + notes=notes ) # Return a copy of the "new" stock item @@ -1138,11 +1190,21 @@ class StockItem(MPTTModel): self.location = location - self.addTransactionNote( - msg, + tracking_info = {} + + if location: + tracking_info['location'] = location.pk + url = location.get_absolute_url() + else: + url = '' + + self.add_tracking_entry( + StockHistoryCode.STOCK_MOVE, user, notes=notes, - system=True) + deltas=tracking_info, + url=url, + ) self.save() @@ -1202,13 +1264,13 @@ class StockItem(MPTTModel): if self.updateQuantity(count): - text = _('Counted {n} items').format(n=helpers.normalize(count)) - - self.addTransactionNote( - text, + self.add_tracking_entry( + StockHistoryCode.STOCK_COUNT, user, notes=notes, - system=True + deltas={ + 'quantity': self.quantity, + } ) return True @@ -1234,13 +1296,15 @@ class StockItem(MPTTModel): return False if self.updateQuantity(self.quantity + quantity): - text = _('Added {n} items').format(n=helpers.normalize(quantity)) - self.addTransactionNote( - text, + self.add_tracking_entry( + StockHistoryCode.STOCK_ADD, user, notes=notes, - system=True + deltas={ + 'added': quantity, + 'quantity': self.quantity + } ) return True @@ -1264,12 +1328,15 @@ class StockItem(MPTTModel): if self.updateQuantity(self.quantity - quantity): - text = _('Removed {n1} items').format(n1=helpers.normalize(quantity)) - - self.addTransactionNote(text, - user, - notes=notes, - system=True) + self.add_tracking_entry( + StockHistoryCode.STOCK_REMOVE, + user, + notes=notes, + deltas={ + 'removed': quantity, + 'quantity': self.quantity, + } + ) return True @@ -1527,30 +1594,57 @@ class StockItemAttachment(InvenTreeAttachment): class StockItemTracking(models.Model): - """ Stock tracking entry - breacrumb for keeping track of automated stock transactions + """ + Stock tracking entry - used for tracking history of a particular StockItem + + Note: 2021-05-11 + The legacy StockTrackingItem model contained very litle information about the "history" of the item. + In fact, only the "quantity" of the item was recorded at each interaction. + Also, the "title" was translated at time of generation, and thus was not really translateable. + The "new" system tracks all 'delta' changes to the model, + and tracks change "type" which can then later be translated + Attributes: - item: Link to StockItem + item: ForeignKey reference to a particular StockItem date: Date that this tracking info was created - title: Title of this tracking info (generated by system) + title: Title of this tracking info (legacy, no longer used!) + tracking_type: The type of tracking information notes: Associated notes (input by user) link: Optional URL to external page user: The user associated with this tracking info + deltas: The changes associated with this history item quantity: The StockItem quantity at this point in time """ def get_absolute_url(self): return '/stock/track/{pk}'.format(pk=self.id) - # return reverse('stock-tracking-detail', kwargs={'pk': self.id}) - item = models.ForeignKey(StockItem, on_delete=models.CASCADE, - related_name='tracking_info') + tracking_type = models.IntegerField( + default=StockHistoryCode.LEGACY, + ) + + item = models.ForeignKey( + StockItem, + on_delete=models.CASCADE, + related_name='tracking_info' + ) date = models.DateTimeField(auto_now_add=True, editable=False) - title = models.CharField(blank=False, max_length=250, verbose_name=_('Title'), help_text=_('Tracking entry title')) + title = models.CharField( + blank=True, + max_length=250, + verbose_name=_('Title'), + help_text=_('Tracking entry title') + ) - notes = models.CharField(blank=True, max_length=512, verbose_name=_('Notes'), help_text=_('Entry notes')) + notes = models.CharField( + blank=True, + max_length=512, + verbose_name=_('Notes'), + help_text=_('Entry notes') + ) link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page for further information')) @@ -1558,13 +1652,15 @@ class StockItemTracking(models.Model): system = models.BooleanField(default=False) - quantity = models.DecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, verbose_name=_('Quantity')) + deltas = models.JSONField(null=True, blank=True) - # TODO - # image = models.ImageField(upload_to=func, max_length=255, null=True, blank=True) - - # TODO - # file = models.FileField() + quantity = models.DecimalField( + max_digits=15, + decimal_places=5, + validators=[MinValueValidator(0)], + default=1, + verbose_name=_('Quantity') + ) def rename_stock_item_test_result_attachment(instance, filename):