Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2021-05-12 11:09:54 +10:00
commit 12b40542d0
28 changed files with 1000 additions and 210 deletions

View File

@ -6,6 +6,7 @@ Provides extra global data to all templates.
from InvenTree.status_codes import SalesOrderStatus, PurchaseOrderStatus
from InvenTree.status_codes import BuildStatus, StockStatus
from InvenTree.status_codes import StockHistoryCode
import InvenTree.status
@ -65,6 +66,7 @@ def status_codes(request):
'PurchaseOrderStatus': PurchaseOrderStatus,
'BuildStatus': BuildStatus,
'StockStatus': StockStatus,
'StockHistoryCode': StockHistoryCode,
}

View File

@ -77,12 +77,20 @@ class AuthRequiredMiddleware(object):
if request.path_info == reverse_lazy('logout'):
return HttpResponseRedirect(reverse_lazy('login'))
login = reverse_lazy('login')
path = request.path_info
if not request.path_info == login and not request.path_info.startswith('/api/'):
# List of URL endpoints we *do not* want to redirect to
urls = [
reverse_lazy('login'),
reverse_lazy('logout'),
reverse_lazy('admin:login'),
reverse_lazy('admin:logout'),
]
if path not in urls and not path.startswith('/api/'):
# Save the 'next' parameter to pass through to the login view
return redirect('%s?next=%s' % (login, request.path))
return redirect('%s?next=%s' % (reverse_lazy('login'), request.path))
# Code to be executed for each request/response after
# the view is called.

View File

@ -507,7 +507,7 @@
padding-right: 6px;
padding-top: 3px;
padding-bottom: 2px;
};
}
.panel-heading .badge {
float: right;
@ -568,7 +568,7 @@
}
.media {
//padding-top: 15px;
/* padding-top: 15px; */
overflow: visible;
}
@ -594,8 +594,8 @@
width: 160px;
position: fixed;
z-index: 1;
//top: 0;
//left: 0;
/* top: 0;
left: 0; */
overflow-x: hidden;
padding-top: 20px;
padding-right: 25px;
@ -826,7 +826,7 @@ input[type="submit"] {
width: 100%;
padding: 20px;
z-index: 5000;
pointer-events: none; // Prevent this div from blocking links underneath
pointer-events: none; /* Prevent this div from blocking links underneath */
}
.alert {
@ -936,4 +936,15 @@ input[type="submit"] {
input[type="date"].form-control, input[type="time"].form-control, input[type="datetime-local"].form-control, input[type="month"].form-control {
line-height: unset;
}
}
.clip-btn {
font-size: 10px;
padding: 0px 6px;
color: var(--label-grey);
background: none;
}
.clip-btn:hover {
background: var(--label-grey);
}

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,14 @@
function attachClipboard(selector) {
new ClipboardJS(selector, {
text: function(trigger) {
var content = trigger.parentElement.parentElement.textContent;
return content.trim();
}
});
}
function inventreeDocReady() {
/* Run this function when the HTML document is loaded.
* This will be called for every page that extends "base.html"
@ -48,6 +59,10 @@ function inventreeDocReady() {
no_post: true,
});
});
// Initialize clipboard-buttons
attachClipboard('.clip-btn');
}
function isFileTransfer(transfer) {

View File

@ -7,6 +7,8 @@ class StatusCode:
This is used to map a set of integer values to text.
"""
colors = {}
@classmethod
def render(cls, key, large=False):
"""
@ -224,6 +226,82 @@ class StockStatus(StatusCode):
]
class StockHistoryCode(StatusCode):
LEGACY = 0
CREATED = 1
# Manual editing operations
EDITED = 5
ASSIGNED_SERIAL = 6
# Manual stock operations
STOCK_COUNT = 10
STOCK_ADD = 11
STOCK_REMOVE = 12
# Location operations
STOCK_MOVE = 20
# Installation operations
INSTALLED_INTO_ASSEMBLY = 30
REMOVED_FROM_ASSEMBLY = 31
INSTALLED_CHILD_ITEM = 35
REMOVED_CHILD_ITEM = 36
# Stock splitting operations
SPLIT_FROM_PARENT = 40
SPLIT_CHILD_ITEM = 42
# Build order codes
BUILD_OUTPUT_CREATED = 50
BUILD_OUTPUT_COMPLETED = 55
# Sales order codes
# Purchase order codes
RECEIVED_AGAINST_PURCHASE_ORDER = 70
# Customer actions
SENT_TO_CUSTOMER = 100
RETURNED_FROM_CUSTOMER = 105
options = {
LEGACY: _('Legacy stock tracking entry'),
CREATED: _('Stock item created'),
EDITED: _('Edited stock item'),
ASSIGNED_SERIAL: _('Assigned serial number'),
STOCK_COUNT: _('Stock counted'),
STOCK_ADD: _('Stock manually added'),
STOCK_REMOVE: _('Stock manually removed'),
STOCK_MOVE: _('Location changed'),
INSTALLED_INTO_ASSEMBLY: _('Installed into assembly'),
REMOVED_FROM_ASSEMBLY: _('Removed from assembly'),
INSTALLED_CHILD_ITEM: _('Installed component item'),
REMOVED_CHILD_ITEM: _('Removed component item'),
SPLIT_FROM_PARENT: _('Split from parent item'),
SPLIT_CHILD_ITEM: _('Split child item'),
SENT_TO_CUSTOMER: _('Sent to customer'),
RETURNED_FROM_CUSTOMER: _('Returned from customer'),
BUILD_OUTPUT_CREATED: _('Build order output created'),
BUILD_OUTPUT_COMPLETED: _('Build order output completed'),
RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against purchase order')
}
class BuildStatus(StatusCode):
# Build status codes

View File

@ -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

View File

@ -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'")})
@ -364,13 +366,22 @@ class PurchaseOrder(Order):
purchase_price=purchase_price,
)
stock.save()
stock.save(add_note=False)
text = _("Received items")
note = _('Received {n} items against order {name}').format(n=quantity, name=str(self))
tracking_info = {
'status': status,
'purchaseorder': self.pk,
}
# Add a new transaction note to the newly created stock item
stock.addTransactionNote(text, user, note)
stock.add_tracking_entry(
StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
user,
notes=notes,
deltas=tracking_info,
location=location,
purchaseorder=self,
quantity=quantity
)
# Update the number of parts received against the particular line item
line.received += quantity

View File

@ -1,5 +1,6 @@
{% extends "modal_delete_form.html" %}
{% load i18n %}
{% block pre_form_content %}
Are you sure you wish to delete this line item?
{% trans "Are you sure you wish to delete this line item?" %}
{% endblock %}

View File

@ -20,20 +20,20 @@
<tr>
<td><span class='fas fa-font'></span></td>
<td><strong>{% trans "Part name" %}</strong></td>
<td>{{ part.name }}</td>
<td>{{ part.name }}{% include "clip.html"%}</td>
</tr>
{% if part.IPN %}
<tr>
<td></td>
<td><strong>{% trans "IPN" %}</strong></td>
<td>{{ part.IPN }}</td>
<td>{{ part.IPN }}{% include "clip.html"%}</td>
</tr>
{% endif %}
{% if part.revision %}
<tr>
<td><span class='fas fa-code-branch'></span></td>
<td><strong>{% trans "Revision" %}</strong></td>
<td>{{ part.revision }}</td>
<td>{{ part.revision }}{% include "clip.html"%}</td>
</tr>
{% endif %}
{% if part.trackable %}
@ -42,7 +42,7 @@
<td><strong>{% trans "Latest Serial Number" %}</strong></td>
<td>
{% if part.getLatestSerialNumber %}
{{ part.getLatestSerialNumber }}
{{ part.getLatestSerialNumber }}{% include "clip.html"%}
{% else %}
<em>{% trans "No serial numbers recorded" %}</em>
{% endif %}
@ -52,7 +52,7 @@
<tr>
<td><span class='fas fa-info-circle'></span></td>
<td><strong>{% trans "Description" %}</strong></td>
<td>{{ part.description }}</td>
<td>{{ part.description }}{% include "clip.html"%}</td>
</tr>
{% if part.variant_of %}
<tr>
@ -96,7 +96,7 @@
<td></td>
<td><strong>{% trans "Default Supplier" %}</strong></td>
<td><a href="{% url 'supplier-part-detail' part.default_supplier.id %}">
{{ part.default_supplier.supplier.name }} | {{ part.default_supplier.SKU }}
{{ part.default_supplier.supplier.name }} | {{ part.default_supplier.SKU }}{% include "clip.html"%}
</a></td>
</tr>
{% endif %}

View File

@ -130,7 +130,7 @@ class StockAttachmentAdmin(admin.ModelAdmin):
class StockTrackingAdmin(ImportExportModelAdmin):
list_display = ('item', 'date', 'title')
list_display = ('item', 'date', 'label')
class StockItemTestResultAdmin(admin.ModelAdmin):

View File

@ -21,8 +21,11 @@ from .models import StockItemTestResult
from part.models import Part, PartCategory
from part.serializers import PartBriefSerializer
from company.models import SupplierPart
from company.serializers import SupplierPartSerializer
from company.models import Company, SupplierPart
from company.serializers import CompanySerializer, SupplierPartSerializer
from order.models import PurchaseOrder
from order.serializers import POSerializer
import common.settings
import common.models
@ -97,6 +100,16 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
return self.serializer_class(*args, **kwargs)
def update(self, request, *args, **kwargs):
"""
Record the user who updated the item
"""
# TODO: Record the user!
# user = request.user
return super().update(request, *args, **kwargs)
class StockFilter(FilterSet):
""" FilterSet for advanced stock filtering.
@ -371,25 +384,26 @@ class StockList(generics.ListCreateAPIView):
we can pre-fill the location automatically.
"""
user = request.user
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# TODO - Save the user who created this item
item = serializer.save()
# A location was *not* specified - try to infer it
if 'location' not in request.data:
location = item.part.get_default_location()
if location is not None:
item.location = location
item.save()
item.location = item.part.get_default_location()
# An expiry date was *not* specified - try to infer it!
if 'expiry_date' not in request.data:
if item.part.default_expiry > 0:
item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry)
item.save()
# Finally, save the item
item.save(user=user)
# Return a response
headers = self.get_success_headers(serializer.data)
@ -965,7 +979,7 @@ class StockItemTestResultList(generics.ListCreateAPIView):
test_result.save()
class StockTrackingList(generics.ListCreateAPIView):
class StockTrackingList(generics.ListAPIView):
""" API endpoint for list view of StockItemTracking objects.
StockItemTracking objects are read-only
@ -992,6 +1006,59 @@ class StockTrackingList(generics.ListCreateAPIView):
return self.serializer_class(*args, **kwargs)
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
serializer = self.get_serializer(queryset, many=True)
data = serializer.data
# Attempt to add extra context information to the historical data
for item in data:
deltas = item['deltas']
# Add location detail
if 'location' in deltas:
try:
location = StockLocation.objects.get(pk=deltas['location'])
serializer = LocationSerializer(location)
deltas['location_detail'] = serializer.data
except:
pass
# Add stockitem detail
if 'stockitem' in deltas:
try:
stockitem = StockItem.objects.get(pk=deltas['stockitem'])
serializer = StockItemSerializer(stockitem)
deltas['stockitem_detail'] = serializer.data
except:
pass
# Add customer detail
if 'customer' in deltas:
try:
customer = Company.objects.get(pk=deltas['customer'])
serializer = CompanySerializer(customer)
deltas['customer_detail'] = serializer.data
except:
pass
# Add purchaseorder detail
if 'purchaseorder' in deltas:
try:
order = PurchaseOrder.objects.get(pk=deltas['purchaseorder'])
serializer = POSerializer(order)
deltas['purchaseorder_detail'] = serializer.data
except:
pass
if request.is_ajax():
return JsonResponse(data, safe=False)
else:
return Response(data)
def create(self, request, *args, **kwargs):
""" Create a new StockItemTracking object

View File

@ -393,6 +393,18 @@ class AdjustStockForm(forms.ModelForm):
]
class EditStockItemStatusForm(HelperForm):
"""
Simple form for editing StockItem status field
"""
class Meta:
model = StockItem
fields = [
'status',
]
class EditStockItemForm(HelperForm):
""" Form for editing a StockItem object.
Note that not all fields can be edited here (even if they can be specified during creation.
@ -425,14 +437,15 @@ class EditStockItemForm(HelperForm):
class TrackingEntryForm(HelperForm):
""" Form for creating / editing a StockItemTracking object.
"""
Form for creating / editing a StockItemTracking object.
Note: 2021-05-11 - This form is not currently used - should delete?
"""
class Meta:
model = StockItemTracking
fields = [
'title',
'notes',
'link',
]

View File

@ -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'),
),
]

View File

@ -0,0 +1,219 @@
# Generated by Django 3.2 on 2021-05-10 23:11
import re
from django.db import migrations
from InvenTree.status_codes import StockHistoryCode
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')
StockLocation = apps.get_model('stock', 'stocklocation')
update_count = 0
locations = StockLocation.objects.all()
for location in locations:
# Pre-calculate pathstring
# Note we cannot use the 'pathstring' function here as we don't have access to model functions!
path = [location.name]
loc = location
while loc.parent:
loc = loc.parent
path = [loc.name] + path
location._path = '/'.join(path)
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 idx, entry in enumerate(history):
deltas = {}
updated = False
q = entry.quantity
if idx == 0 or not q == quantity:
try:
deltas['quantity']: float(q)
updated = True
except:
print(f"WARNING: Error converting quantity '{q}'")
quantity = q
# Try to "guess" the "type" of tracking entry, based on the title
title = entry.title.lower()
tracking_type = None
if 'completed build' in title:
tracking_type = StockHistoryCode.BUILD_OUTPUT_COMPLETED
elif 'removed' in title and 'item' in title:
if entry.notes.lower().startswith('split '):
tracking_type = StockHistoryCode.SPLIT_CHILD_ITEM
else:
tracking_type = StockHistoryCode.STOCK_REMOVE
# Extract the number of removed items
result = re.search("^removed ([\d\.]+) items", title)
if result:
removed = result.groups()[0]
try:
deltas['removed'] = float(removed)
# Ensure that 'quantity' is stored too in this case
deltas['quantity'] = float(q)
except:
print(f"WARNING: Error converting removed quantity '{removed}'")
else:
print(f"Could not decode '{title}'")
elif 'split from existing' in title:
tracking_type = StockHistoryCode.SPLIT_FROM_PARENT
deltas['quantity'] = float(q)
elif 'moved to' in title:
tracking_type = StockHistoryCode.STOCK_MOVE
result = re.search('^Moved to (.*)( - )*(.*) \(from.*$', entry.title)
if result:
# Legacy tracking entries recorded the location in multiple ways, because.. why not?
text = result.groups()[0]
matches = set()
for location in locations:
# Direct match for pathstring
if text == location._path:
matches.add(location)
# Direct match for name
if text == location.name:
matches.add(location)
# Match for "name - description"
compare = f"{location.name} - {location.description}"
if text == compare:
matches.add(location)
# Match for "pathstring - description"
compare = f"{location._path} - {location.description}"
if text == compare:
matches.add(location)
if len(matches) == 1:
location = list(matches)[0]
deltas['location'] = location.pk
else:
print(f"No location match: '{text}'")
break
elif 'created stock item' in title:
tracking_type = StockHistoryCode.CREATED
elif 'add serial number' in title:
tracking_type = StockHistoryCode.ASSIGNED_SERIAL
elif 'returned from customer' in title:
tracking_type = StockHistoryCode.RETURNED_FROM_CUSTOMER
elif 'counted' in title:
tracking_type = StockHistoryCode.STOCK_COUNT
elif 'added' in title:
tracking_type = StockHistoryCode.STOCK_ADD
# Extract the number of added items
result = re.search("^added ([\d\.]+) items", title)
if result:
added = result.groups()[0]
try:
deltas['added'] = float(added)
# Ensure that 'quantity' is stored too in this case
deltas['quantity'] = float(q)
except:
print(f"WARNING: Error converting added quantity '{added}'")
else:
print(f"Could not decode '{title}'")
elif 'assigned to customer' in title:
tracking_type = StockHistoryCode.SENT_TO_CUSTOMER
elif 'installed into stock item' in title:
tracking_type = StockHistoryCode.INSTALLED_INTO_ASSEMBLY
elif 'uninstalled into location' in title:
tracking_type = StockHistoryCode.REMOVED_FROM_ASSEMBLY
elif 'installed stock item' in title:
tracking_type = StockHistoryCode.INSTALLED_CHILD_ITEM
elif 'received items' in title:
tracking_type = StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER
if tracking_type is not None:
entry.tracking_type = tracking_type
updated = True
if updated:
entry.deltas = deltas
entry.save()
update_count += 1
print(f"\n==========================\nUpdated {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)
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2 on 2021-05-11 11:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0061_auto_20210511_0911'),
]
operations = [
migrations.AlterField(
model_name='stockitemtracking',
name='notes',
field=models.CharField(blank=True, help_text='Entry notes', max_length=512, null=True, verbose_name='Notes'),
),
migrations.AlterField(
model_name='stockitemtracking',
name='title',
field=models.CharField(blank=True, help_text='Tracking entry title', max_length=250, null=True, verbose_name='Title'),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 3.2 on 2021-05-11 13:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('stock', '0062_auto_20210511_2151'),
]
operations = [
migrations.RemoveField(
model_name='stockitemtracking',
name='link',
),
migrations.RemoveField(
model_name='stockitemtracking',
name='quantity',
),
migrations.RemoveField(
model_name='stockitemtracking',
name='system',
),
migrations.RemoveField(
model_name='stockitemtracking',
name='title',
),
]

View File

@ -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
@ -183,29 +183,61 @@ class StockItem(MPTTModel):
self.validate_unique()
self.clean()
if not self.pk:
# StockItem has not yet been saved
add_note = True
else:
# StockItem has already been saved
add_note = False
user = kwargs.pop('user', None)
add_note = add_note and kwargs.pop('note', True)
# If 'add_note = False' specified, then no tracking note will be added for item creation
add_note = kwargs.pop('add_note', True)
notes = kwargs.pop('notes', '')
if not self.pk:
# StockItem has not yet been saved
add_note = add_note and True
else:
# StockItem has already been saved
# Check if "interesting" fields have been changed
# (we wish to record these as historical records)
try:
old = StockItem.objects.get(pk=self.pk)
deltas = {}
# Status changed?
if not old.status == self.status:
deltas['status'] = self.status
# TODO - Other interesting changes we are interested in...
if add_note and len(deltas) > 0:
self.add_tracking_entry(
StockHistoryCode.EDITED,
user,
deltas=deltas,
notes=notes,
)
except (ValueError, StockItem.DoesNotExist):
pass
add_note = False
super(StockItem, self).save(*args, **kwargs)
if add_note:
note = _('Created new stock item for {part}').format(part=str(self.part))
tracking_info = {
'status': self.status,
}
# This StockItem is being saved for the first time
self.addTransactionNote(
_('Created stock item'),
self.add_tracking_entry(
StockHistoryCode.CREATED,
user,
note,
system=True
deltas=tracking_info,
notes=notes,
location=self.location,
quantity=float(self.quantity),
)
@property
@ -610,31 +642,42 @@ 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 self.customer:
tracking_info['customer'] = self.customer.id
tracking_info['customer_name'] = self.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,
location=location
)
self.customer = None
self.location = location
self.sales_order = None
self.save()
@ -788,18 +831,23 @@ 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()
deltas={
'stockitem': 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,
deltas={
'stockitem': stock_item.pk,
}
)
@transaction.atomic
@ -820,11 +868,25 @@ 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,
)
tracking_info = {
'stockitem': self.belongs_to.pk
}
self.add_tracking_entry(
StockHistoryCode.REMOVED_FROM_ASSEMBLY,
user,
notes=notes,
url=self.get_absolute_url(),
deltas=tracking_info,
location=location,
)
# Mark this stock item as *not* belonging to anyone
@ -833,19 +895,6 @@ class StockItem(MPTTModel):
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 +950,40 @@ 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='', **kwargs):
"""
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(
# Has a location been specified?
location = kwargs.get('location', None)
if location:
deltas['location'] = location.id
# Quantity specified?
quantity = kwargs.get('quantity', None)
if quantity:
deltas['quantity'] = float(quantity)
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,
link=url,
system=system
deltas=deltas,
)
track.save()
entry.save()
@transaction.atomic
def serializeStock(self, quantity, serials, user, notes='', location=None):
@ -930,7 +995,7 @@ class StockItem(MPTTModel):
Args:
quantity: Number of items to serialize (integer)
serials: List of serial numbers (list<int>)
serials: List of serial numbers
user: User object associated with action
notes: Optional notes for tracking
location: If specified, serialized items will be placed in the given location
@ -982,7 +1047,7 @@ class StockItem(MPTTModel):
new_item.location = location
# The item already has a transaction history, don't create a new note
new_item.save(user=user, note=False)
new_item.save(user=user, notes=notes)
# Copy entire transaction history
new_item.copyHistoryFrom(self)
@ -991,10 +1056,18 @@ 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,
},
location=location
)
# 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 +1091,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 +1105,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 +1146,21 @@ 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,
},
location=location,
)
# 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
@ -1131,18 +1210,17 @@ class StockItem(MPTTModel):
return True
if self.location:
msg = _("Moved to {loc_new} (from {loc_old})").format(loc_new=str(location), loc_old=str(self.location))
else:
msg = _('Moved to {loc_new}').format(loc_new=str(location))
self.location = location
self.addTransactionNote(
msg,
tracking_info = {}
self.add_tracking_entry(
StockHistoryCode.STOCK_MOVE,
user,
notes=notes,
system=True)
deltas=tracking_info,
location=location,
)
self.save()
@ -1202,13 +1280,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': float(self.quantity),
}
)
return True
@ -1234,20 +1312,23 @@ 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': float(quantity),
'quantity': float(self.quantity),
}
)
return True
@transaction.atomic
def take_stock(self, quantity, user, notes=''):
""" Remove items from stock
"""
Remove items from stock
"""
# Cannot remove items from a serialized part
@ -1264,12 +1345,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': float(quantity),
'quantity': float(self.quantity),
}
)
return True
@ -1527,44 +1611,58 @@ 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)
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
quantity: The StockItem quantity at this point in time
deltas: The changes associated with this history item
"""
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')
def label(self):
if self.tracking_type in StockHistoryCode.keys():
return StockHistoryCode.label(self.tracking_type)
else:
return self.title
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'))
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'))
notes = models.CharField(
blank=True, null=True,
max_length=512,
verbose_name=_('Notes'),
help_text=_('Entry notes')
)
user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)
system = models.BooleanField(default=False)
quantity = models.DecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, verbose_name=_('Quantity'))
# TODO
# image = models.ImageField(upload_to=func, max_length=255, null=True, blank=True)
# TODO
# file = models.FileField()
deltas = models.JSONField(null=True, blank=True)
def rename_stock_item_test_result_attachment(instance, filename):

View File

@ -349,32 +349,32 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
if user_detail is not True:
self.fields.pop('user_detail')
url = serializers.CharField(source='get_absolute_url', read_only=True)
label = serializers.CharField(read_only=True)
item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True)
user_detail = UserSerializerBrief(source='user', many=False, read_only=True)
deltas = serializers.JSONField(read_only=True)
class Meta:
model = StockItemTracking
fields = [
'pk',
'url',
'item',
'item_detail',
'date',
'title',
'deltas',
'label',
'notes',
'link',
'quantity',
'tracking_type',
'user',
'user_detail',
'system',
]
read_only_fields = [
'date',
'user',
'system',
'quantity',
'label',
'tracking_type',
]

View File

@ -94,7 +94,13 @@
{% if item.is_expired %}
<span class='label label-large label-large-red'>{% trans "Expired" %}</span>
{% else %}
{% if roles.stock.change %}
<a href='#' id='stock-edit-status'>
{% endif %}
{% stock_status_label item.status large=True %}
{% if roles.stock.change %}
</a>
{% endif %}
{% if item.is_stale %}
<span class='label label-large label-large-yellow'>{% trans "Stale" %}</span>
{% endif %}
@ -453,6 +459,7 @@ $("#print-label").click(function() {
printStockItemLabels([{{ item.pk }}]);
});
{% if roles.stock.change %}
$("#stock-duplicate").click(function() {
createNewStockItem({
follow: true,
@ -472,6 +479,18 @@ $("#stock-edit").click(function () {
);
});
$('#stock-edit-status').click(function () {
launchModalForm(
"{% url 'stock-item-edit-status' item.id %}",
{
reload: true,
submit_text: '{% trans "Save" %}',
}
);
});
{% endif %}
$("#show-qr-code").click(function() {
launchModalForm("{% url 'stock-item-qr' item.id %}",
{

View File

@ -5,6 +5,8 @@ from django.core.exceptions import ValidationError
import datetime
from InvenTree.status_codes import StockHistoryCode
from .models import StockLocation, StockItem, StockItemTracking
from .models import StockItemTestResult
@ -217,7 +219,7 @@ class StockTest(TestCase):
track = StockItemTracking.objects.filter(item=it).latest('id')
self.assertEqual(track.item, it)
self.assertIn('Moved to', track.title)
self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_MOVE)
self.assertEqual(track.notes, 'Moved to the bathroom')
def test_self_move(self):
@ -284,8 +286,7 @@ class StockTest(TestCase):
# Check that a tracking item was added
track = StockItemTracking.objects.filter(item=it).latest('id')
self.assertIn('Counted', track.title)
self.assertIn('items', track.title)
self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_COUNT)
self.assertIn('Counted items', track.notes)
n = it.tracking_info.count()
@ -304,7 +305,7 @@ class StockTest(TestCase):
# Check that a tracking item was added
track = StockItemTracking.objects.filter(item=it).latest('id')
self.assertIn('Added', track.title)
self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_ADD)
self.assertIn('Added some items', track.notes)
self.assertFalse(it.add_stock(-10, None))
@ -319,7 +320,7 @@ class StockTest(TestCase):
# Check that a tracking item was added
track = StockItemTracking.objects.filter(item=it).latest('id')
self.assertIn('Removed', track.title)
self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_REMOVE)
self.assertIn('Removed some items', track.notes)
self.assertTrue(it.has_tracking_info)

View File

@ -4,7 +4,7 @@ URL lookup for Stock app
from django.conf.urls import url, include
from . import views
from stock import views
location_urls = [
@ -24,6 +24,7 @@ location_urls = [
]
stock_item_detail_urls = [
url(r'^edit_status/', views.StockItemEditStatus.as_view(), name='stock-item-edit-status'),
url(r'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'),
url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'),
url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'),

View File

@ -1212,6 +1212,27 @@ class StockAdjust(AjaxView, FormMixin):
return _("Deleted {n} stock items").format(n=count)
class StockItemEditStatus(AjaxUpdateView):
"""
View for editing stock item status field
"""
model = StockItem
form_class = StockForms.EditStockItemStatusForm
ajax_form_title = _('Edit Stock Item Status')
def save(self, object, form, **kwargs):
"""
Override the save method, to track the user who updated the model
"""
item = form.save(commit=False)
item.save(user=self.request.user)
return item
class StockItemEdit(AjaxUpdateView):
"""
View for editing details of a single StockItem
@ -1321,6 +1342,17 @@ class StockItemEdit(AjaxUpdateView):
if not owner and not self.request.user.is_superuser:
form.add_error('owner', _('Owner is required (ownership control is enabled)'))
def save(self, object, form, **kwargs):
"""
Override the save method, to track the user who updated the model
"""
item = form.save(commit=False)
item.save(user=self.request.user)
return item
class StockItemConvert(AjaxUpdateView):
"""

View File

@ -133,11 +133,14 @@
<!-- boostrap-table-treegrid -->
<script type='text/javascript' src='{% static "bootstrap-table/extensions/treegrid/bootstrap-table-treegrid.js" %}'></script>
<!-- 3rd party general js -->
<script type="text/javascript" src="{% static 'fullcalendar/main.js' %}"></script>
<script type="text/javascript" src="{% static 'fullcalendar/locales-all.js' %}"></script>
<script type="text/javascript" src="{% static 'script/select2/select2.js' %}"></script>
<script type='text/javascript' src="{% static 'script/moment.js' %}"></script>
<script type='text/javascript' src="{% static 'script/clipboard.min.js' %}"></script>
<!-- general InvenTree -->
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/api.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>

View File

@ -0,0 +1,5 @@
{% load i18n %}
<span class="float-right">
<button class="btn clip-btn" type="button" data-toggle='tooltip' title='{% trans "copy to clipboard" %}'><i class="fas fa-copy"></i></button>
</span>

View File

@ -976,42 +976,28 @@ function loadStockLocationTable(table, options) {
function loadStockTrackingTable(table, options) {
var cols = [
{
field: 'pk',
visible: false,
},
{
field: 'date',
title: '{% trans "Date" %}',
sortable: true,
formatter: function(value, row, index, field) {
var m = moment(value);
if (m.isValid()) {
var html = m.format('dddd MMMM Do YYYY'); // + '<br>' + m.format('h:mm a');
return html;
}
var cols = [];
return 'N/A';
}
},
];
// Date
cols.push({
field: 'date',
title: '{% trans "Date" %}',
sortable: true,
formatter: function(value, row, index, field) {
var m = moment(value);
// If enabled, provide a link to the referenced StockItem
if (options.partColumn) {
cols.push({
field: 'item',
title: '{% trans "Stock Item" %}',
sortable: true,
formatter: function(value, row, index, field) {
return renderLink(value.part_name, value.url);
if (m.isValid()) {
var html = m.format('dddd MMMM Do YYYY'); // + '<br>' + m.format('h:mm a');
return html;
}
});
}
return '<i>{% trans "Invalid date" %}</i>';
}
});
// Stock transaction description
cols.push({
field: 'title',
field: 'label',
title: '{% trans "Description" %}',
formatter: function(value, row, index, field) {
var html = "<b>" + value + "</b>";
@ -1020,20 +1006,139 @@ function loadStockTrackingTable(table, options) {
html += "<br><i>" + row.notes + "</i>";
}
if (row.link) {
html += "<br><a href='" + row.link + "'>" + row.link + "</a>";
}
return html;
}
});
// Stock transaction details
cols.push({
field: 'quantity',
title: '{% trans "Quantity" %}',
formatter: function(value, row, index, field) {
return parseFloat(value);
},
field: 'deltas',
title: '{% trans "Details" %}',
formatter: function(details, row, index, field) {
var html = `<table class='table table-condensed' id='tracking-table-${row.pk}'>`;
// Location information
if (details.location) {
html += `<tr><th>{% trans "Location" %}</th>`;
html += '<td>';
if (details.location_detail) {
// A valid location is provided
html += renderLink(
details.location_detail.pathstring,
details.location_detail.url,
);
} else {
// An invalid location (may have been deleted?)
html += `<i>{% trans "Location no longer exists" %}</i>`;
}
html += '</td></tr>';
}
// Purchase Order Information
if (details.purchaseorder) {
html += `<tr><th>{% trans "Purchase Order" %}</td>`;
html += '<td>';
if (details.purchaseorder_detail) {
html += renderLink(
details.purchaseorder_detail.reference,
`/order/purchase-order/${details.purchaseorder}/`
);
} else {
html += `<i>{% trans "Purchase order no longer exists" %}</i>`;
}
html += '</td></tr>';
}
// Customer information
if (details.customer) {
html += `<tr><th>{% trans "Customer" %}</td>`;
html += '<td>';
if (details.customer_detail) {
html += renderLink(
details.customer_detail.name,
details.customer_detail.url
);
} else {
html += `<i>{% trans "Customer no longer exists" %}</i>`;
}
html += '</td></tr>';
}
// Stockitem information
if (details.stockitem) {
html += '<tr><th>{% trans "Stock Item" %}</td>';
html += '<td>';
if (details.stockitem_detail) {
html += renderLink(
details.stockitem,
`/stock/item/${details.stockitem}/`
);
} else {
html += `<i>{% trans "Stock item no longer exists" %}</i>`;
}
html += '</td></tr>';
}
// Status information
if (details.status) {
html += `<tr><th>{% trans "Status" %}</td>`;
html += '<td>';
html += stockStatusDisplay(
details.status,
{
classes: 'float-right',
}
);
html += '</td></tr>';
}
// Quantity information
if (details.added) {
html += '<tr><th>{% trans "Added" %}</th>';
html += `<td>${details.added}</td>`;
html += '</tr>';
}
if (details.removed) {
html += '<tr><th>{% trans "Removed" %}</th>';
html += `<td>${details.removed}</td>`;
html += '</tr>';
}
if (details.quantity) {
html += '<tr><th>{% trans "Quantity" %}</th>';
html += `<td>${details.quantity}</td>`;
html += '</tr>';
}
html += '</table>';
return html;
}
});
cols.push({
@ -1052,11 +1157,13 @@ function loadStockTrackingTable(table, options) {
}
});
/*
// 2021-05-11 - Ability to edit or delete StockItemTracking entries is now removed
cols.push({
sortable: false,
formatter: function(value, row, index, field) {
// Manually created entries can be edited or deleted
if (!row.system) {
if (false && !row.system) {
var bEdit = "<button title='{% trans 'Edit tracking entry' %}' class='btn btn-entry-edit btn-default btn-glyph' type='button' url='/stock/track/" + row.pk + "/edit/'><span class='fas fa-edit'/></button>";
var bDel = "<button title='{% trans 'Delete tracking entry' %}' class='btn btn-entry-delete btn-default btn-glyph' type='button' url='/stock/track/" + row.pk + "/delete/'><span class='fas fa-trash-alt icon-red'/></button>";
@ -1066,6 +1173,7 @@ function loadStockTrackingTable(table, options) {
}
}
});
*/
table.inventreeTable({
method: 'get',

View File

@ -3,6 +3,7 @@
{% load inventree_extras %}
{% include "status_codes.html" with label='stock' options=StockStatus.list %}
{% include "status_codes.html" with label='stockHistory' options=StockHistoryCode.list %}
{% include "status_codes.html" with label='build' options=BuildStatus.list %}
{% include "status_codes.html" with label='purchaseOrder' options=PurchaseOrderStatus.list %}
{% include "status_codes.html" with label='salesOrder' options=SalesOrderStatus.list %}

View File

@ -14,7 +14,7 @@ var {{ label }}Codes = {
* Uses the values specified in "status_codes.py"
* This function is generated by the "status_codes.html" template
*/
function {{ label }}StatusDisplay(key) {
function {{ label }}StatusDisplay(key, options={}) {
key = String(key);
@ -31,5 +31,11 @@ function {{ label }}StatusDisplay(key) {
label = '';
}
return `<span class='label ${label}'>${value}</span>`;
var classes = `label ${label}`;
if (options.classes) {
classes += ' ' + options.classes;
}
return `<span class='${classes}'>${value}</span>`;
}