mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
12b40542d0
@ -6,6 +6,7 @@ Provides extra global data to all templates.
|
|||||||
|
|
||||||
from InvenTree.status_codes import SalesOrderStatus, PurchaseOrderStatus
|
from InvenTree.status_codes import SalesOrderStatus, PurchaseOrderStatus
|
||||||
from InvenTree.status_codes import BuildStatus, StockStatus
|
from InvenTree.status_codes import BuildStatus, StockStatus
|
||||||
|
from InvenTree.status_codes import StockHistoryCode
|
||||||
|
|
||||||
import InvenTree.status
|
import InvenTree.status
|
||||||
|
|
||||||
@ -65,6 +66,7 @@ def status_codes(request):
|
|||||||
'PurchaseOrderStatus': PurchaseOrderStatus,
|
'PurchaseOrderStatus': PurchaseOrderStatus,
|
||||||
'BuildStatus': BuildStatus,
|
'BuildStatus': BuildStatus,
|
||||||
'StockStatus': StockStatus,
|
'StockStatus': StockStatus,
|
||||||
|
'StockHistoryCode': StockHistoryCode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -77,12 +77,20 @@ class AuthRequiredMiddleware(object):
|
|||||||
if request.path_info == reverse_lazy('logout'):
|
if request.path_info == reverse_lazy('logout'):
|
||||||
return HttpResponseRedirect(reverse_lazy('login'))
|
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
|
# 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
|
# Code to be executed for each request/response after
|
||||||
# the view is called.
|
# the view is called.
|
||||||
|
@ -507,7 +507,7 @@
|
|||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
padding-top: 3px;
|
padding-top: 3px;
|
||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
};
|
}
|
||||||
|
|
||||||
.panel-heading .badge {
|
.panel-heading .badge {
|
||||||
float: right;
|
float: right;
|
||||||
@ -568,7 +568,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.media {
|
.media {
|
||||||
//padding-top: 15px;
|
/* padding-top: 15px; */
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -594,8 +594,8 @@
|
|||||||
width: 160px;
|
width: 160px;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
//top: 0;
|
/* top: 0;
|
||||||
//left: 0;
|
left: 0; */
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
padding-right: 25px;
|
padding-right: 25px;
|
||||||
@ -826,7 +826,7 @@ input[type="submit"] {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
z-index: 5000;
|
z-index: 5000;
|
||||||
pointer-events: none; // Prevent this div from blocking links underneath
|
pointer-events: none; /* Prevent this div from blocking links underneath */
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert {
|
.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 {
|
input[type="date"].form-control, input[type="time"].form-control, input[type="datetime-local"].form-control, input[type="month"].form-control {
|
||||||
line-height: unset;
|
line-height: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clip-btn {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 0px 6px;
|
||||||
|
color: var(--label-grey);
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-btn:hover {
|
||||||
|
background: var(--label-grey);
|
||||||
|
}
|
||||||
|
7
InvenTree/InvenTree/static/script/clipboard.min.js
vendored
Normal file
7
InvenTree/InvenTree/static/script/clipboard.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -1,3 +1,14 @@
|
|||||||
|
function attachClipboard(selector) {
|
||||||
|
|
||||||
|
new ClipboardJS(selector, {
|
||||||
|
text: function(trigger) {
|
||||||
|
var content = trigger.parentElement.parentElement.textContent;
|
||||||
|
|
||||||
|
return content.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function inventreeDocReady() {
|
function inventreeDocReady() {
|
||||||
/* Run this function when the HTML document is loaded.
|
/* Run this function when the HTML document is loaded.
|
||||||
* This will be called for every page that extends "base.html"
|
* This will be called for every page that extends "base.html"
|
||||||
@ -48,6 +59,10 @@ function inventreeDocReady() {
|
|||||||
no_post: true,
|
no_post: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize clipboard-buttons
|
||||||
|
attachClipboard('.clip-btn');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFileTransfer(transfer) {
|
function isFileTransfer(transfer) {
|
||||||
|
@ -7,6 +7,8 @@ class StatusCode:
|
|||||||
This is used to map a set of integer values to text.
|
This is used to map a set of integer values to text.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
colors = {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def render(cls, key, large=False):
|
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):
|
class BuildStatus(StatusCode):
|
||||||
|
|
||||||
# Build status codes
|
# Build status codes
|
||||||
|
@ -22,7 +22,7 @@ from markdownx.models import MarkdownxField
|
|||||||
|
|
||||||
from mptt.models import MPTTModel, TreeForeignKey
|
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.helpers import increment, getSetting, normalize, MakeBarcode
|
||||||
from InvenTree.validators import validate_build_order_reference
|
from InvenTree.validators import validate_build_order_reference
|
||||||
from InvenTree.models import InvenTreeAttachment
|
from InvenTree.models import InvenTreeAttachment
|
||||||
@ -811,6 +811,7 @@ class Build(MPTTModel):
|
|||||||
# Select the location for the build output
|
# Select the location for the build output
|
||||||
location = kwargs.get('location', self.destination)
|
location = kwargs.get('location', self.destination)
|
||||||
status = kwargs.get('status', StockStatus.OK)
|
status = kwargs.get('status', StockStatus.OK)
|
||||||
|
notes = kwargs.get('notes', '')
|
||||||
|
|
||||||
# List the allocated BuildItem objects for the given output
|
# List the allocated BuildItem objects for the given output
|
||||||
allocated_items = output.items_to_install.all()
|
allocated_items = output.items_to_install.all()
|
||||||
@ -834,10 +835,13 @@ class Build(MPTTModel):
|
|||||||
|
|
||||||
output.save()
|
output.save()
|
||||||
|
|
||||||
output.addTransactionNote(
|
output.add_tracking_entry(
|
||||||
_('Completed build output'),
|
StockHistoryCode.BUILD_OUTPUT_COMPLETED,
|
||||||
user,
|
user,
|
||||||
system=True
|
notes=notes,
|
||||||
|
deltas={
|
||||||
|
'status': status,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Increase the completed quantity for this build
|
# Increase the completed quantity for this build
|
||||||
|
@ -28,7 +28,7 @@ from company.models import Company, SupplierPart
|
|||||||
|
|
||||||
from InvenTree.fields import RoundingDecimalField
|
from InvenTree.fields import RoundingDecimalField
|
||||||
from InvenTree.helpers import decimal2string, increment, getSetting
|
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
|
from InvenTree.models import InvenTreeAttachment
|
||||||
|
|
||||||
|
|
||||||
@ -336,10 +336,12 @@ class PurchaseOrder(Order):
|
|||||||
return self.pending_line_items().count() == 0
|
return self.pending_line_items().count() == 0
|
||||||
|
|
||||||
@transaction.atomic
|
@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
|
""" Receive a line item (or partial line item) against this PO
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
notes = kwargs.get('notes', '')
|
||||||
|
|
||||||
if not self.status == PurchaseOrderStatus.PLACED:
|
if not self.status == PurchaseOrderStatus.PLACED:
|
||||||
raise ValidationError({"status": _("Lines can only be received against an order marked as '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,
|
purchase_price=purchase_price,
|
||||||
)
|
)
|
||||||
|
|
||||||
stock.save()
|
stock.save(add_note=False)
|
||||||
|
|
||||||
text = _("Received items")
|
tracking_info = {
|
||||||
note = _('Received {n} items against order {name}').format(n=quantity, name=str(self))
|
'status': status,
|
||||||
|
'purchaseorder': self.pk,
|
||||||
|
}
|
||||||
|
|
||||||
# Add a new transaction note to the newly created stock item
|
stock.add_tracking_entry(
|
||||||
stock.addTransactionNote(text, user, note)
|
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
|
# Update the number of parts received against the particular line item
|
||||||
line.received += quantity
|
line.received += quantity
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
{% extends "modal_delete_form.html" %}
|
{% extends "modal_delete_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
{% block pre_form_content %}
|
{% 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 %}
|
{% endblock %}
|
@ -20,20 +20,20 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-font'></span></td>
|
<td><span class='fas fa-font'></span></td>
|
||||||
<td><strong>{% trans "Part name" %}</strong></td>
|
<td><strong>{% trans "Part name" %}</strong></td>
|
||||||
<td>{{ part.name }}</td>
|
<td>{{ part.name }}{% include "clip.html"%}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if part.IPN %}
|
{% if part.IPN %}
|
||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td><strong>{% trans "IPN" %}</strong></td>
|
<td><strong>{% trans "IPN" %}</strong></td>
|
||||||
<td>{{ part.IPN }}</td>
|
<td>{{ part.IPN }}{% include "clip.html"%}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.revision %}
|
{% if part.revision %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-code-branch'></span></td>
|
<td><span class='fas fa-code-branch'></span></td>
|
||||||
<td><strong>{% trans "Revision" %}</strong></td>
|
<td><strong>{% trans "Revision" %}</strong></td>
|
||||||
<td>{{ part.revision }}</td>
|
<td>{{ part.revision }}{% include "clip.html"%}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.trackable %}
|
{% if part.trackable %}
|
||||||
@ -42,7 +42,7 @@
|
|||||||
<td><strong>{% trans "Latest Serial Number" %}</strong></td>
|
<td><strong>{% trans "Latest Serial Number" %}</strong></td>
|
||||||
<td>
|
<td>
|
||||||
{% if part.getLatestSerialNumber %}
|
{% if part.getLatestSerialNumber %}
|
||||||
{{ part.getLatestSerialNumber }}
|
{{ part.getLatestSerialNumber }}{% include "clip.html"%}
|
||||||
{% else %}
|
{% else %}
|
||||||
<em>{% trans "No serial numbers recorded" %}</em>
|
<em>{% trans "No serial numbers recorded" %}</em>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -52,7 +52,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-info-circle'></span></td>
|
<td><span class='fas fa-info-circle'></span></td>
|
||||||
<td><strong>{% trans "Description" %}</strong></td>
|
<td><strong>{% trans "Description" %}</strong></td>
|
||||||
<td>{{ part.description }}</td>
|
<td>{{ part.description }}{% include "clip.html"%}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if part.variant_of %}
|
{% if part.variant_of %}
|
||||||
<tr>
|
<tr>
|
||||||
@ -96,7 +96,7 @@
|
|||||||
<td></td>
|
<td></td>
|
||||||
<td><strong>{% trans "Default Supplier" %}</strong></td>
|
<td><strong>{% trans "Default Supplier" %}</strong></td>
|
||||||
<td><a href="{% url 'supplier-part-detail' part.default_supplier.id %}">
|
<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>
|
</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -130,7 +130,7 @@ class StockAttachmentAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class StockTrackingAdmin(ImportExportModelAdmin):
|
class StockTrackingAdmin(ImportExportModelAdmin):
|
||||||
list_display = ('item', 'date', 'title')
|
list_display = ('item', 'date', 'label')
|
||||||
|
|
||||||
|
|
||||||
class StockItemTestResultAdmin(admin.ModelAdmin):
|
class StockItemTestResultAdmin(admin.ModelAdmin):
|
||||||
|
@ -21,8 +21,11 @@ from .models import StockItemTestResult
|
|||||||
from part.models import Part, PartCategory
|
from part.models import Part, PartCategory
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
|
|
||||||
from company.models import SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
from company.serializers import SupplierPartSerializer
|
from company.serializers import CompanySerializer, SupplierPartSerializer
|
||||||
|
|
||||||
|
from order.models import PurchaseOrder
|
||||||
|
from order.serializers import POSerializer
|
||||||
|
|
||||||
import common.settings
|
import common.settings
|
||||||
import common.models
|
import common.models
|
||||||
@ -97,6 +100,16 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
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):
|
class StockFilter(FilterSet):
|
||||||
""" FilterSet for advanced stock filtering.
|
""" FilterSet for advanced stock filtering.
|
||||||
@ -371,25 +384,26 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
we can pre-fill the location automatically.
|
we can pre-fill the location automatically.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
# TODO - Save the user who created this item
|
||||||
item = serializer.save()
|
item = serializer.save()
|
||||||
|
|
||||||
# A location was *not* specified - try to infer it
|
# A location was *not* specified - try to infer it
|
||||||
if 'location' not in request.data:
|
if 'location' not in request.data:
|
||||||
location = item.part.get_default_location()
|
item.location = item.part.get_default_location()
|
||||||
|
|
||||||
if location is not None:
|
|
||||||
item.location = location
|
|
||||||
item.save()
|
|
||||||
|
|
||||||
# An expiry date was *not* specified - try to infer it!
|
# An expiry date was *not* specified - try to infer it!
|
||||||
if 'expiry_date' not in request.data:
|
if 'expiry_date' not in request.data:
|
||||||
|
|
||||||
if item.part.default_expiry > 0:
|
if item.part.default_expiry > 0:
|
||||||
item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry)
|
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
|
# Return a response
|
||||||
headers = self.get_success_headers(serializer.data)
|
headers = self.get_success_headers(serializer.data)
|
||||||
@ -965,7 +979,7 @@ class StockItemTestResultList(generics.ListCreateAPIView):
|
|||||||
test_result.save()
|
test_result.save()
|
||||||
|
|
||||||
|
|
||||||
class StockTrackingList(generics.ListCreateAPIView):
|
class StockTrackingList(generics.ListAPIView):
|
||||||
""" API endpoint for list view of StockItemTracking objects.
|
""" API endpoint for list view of StockItemTracking objects.
|
||||||
|
|
||||||
StockItemTracking objects are read-only
|
StockItemTracking objects are read-only
|
||||||
@ -992,6 +1006,59 @@ class StockTrackingList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
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):
|
def create(self, request, *args, **kwargs):
|
||||||
""" Create a new StockItemTracking object
|
""" Create a new StockItemTracking object
|
||||||
|
|
||||||
|
@ -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):
|
class EditStockItemForm(HelperForm):
|
||||||
""" Form for editing a StockItem object.
|
""" Form for editing a StockItem object.
|
||||||
Note that not all fields can be edited here (even if they can be specified during creation.
|
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):
|
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:
|
class Meta:
|
||||||
model = StockItemTracking
|
model = StockItemTracking
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
'title',
|
|
||||||
'notes',
|
'notes',
|
||||||
'link',
|
|
||||||
]
|
]
|
||||||
|
28
InvenTree/stock/migrations/0060_auto_20210511_1713.py
Normal file
28
InvenTree/stock/migrations/0060_auto_20210511_1713.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
219
InvenTree/stock/migrations/0061_auto_20210511_0911.py
Normal file
219
InvenTree/stock/migrations/0061_auto_20210511_0911.py
Normal 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)
|
||||||
|
]
|
23
InvenTree/stock/migrations/0062_auto_20210511_2151.py
Normal file
23
InvenTree/stock/migrations/0062_auto_20210511_2151.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
29
InvenTree/stock/migrations/0063_auto_20210511_2343.py
Normal file
29
InvenTree/stock/migrations/0063_auto_20210511_2343.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
@ -34,7 +34,7 @@ import common.models
|
|||||||
import report.models
|
import report.models
|
||||||
import label.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.models import InvenTreeTree, InvenTreeAttachment
|
||||||
from InvenTree.fields import InvenTreeURLField
|
from InvenTree.fields import InvenTreeURLField
|
||||||
|
|
||||||
@ -183,29 +183,61 @@ class StockItem(MPTTModel):
|
|||||||
self.validate_unique()
|
self.validate_unique()
|
||||||
self.clean()
|
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)
|
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)
|
super(StockItem, self).save(*args, **kwargs)
|
||||||
|
|
||||||
if add_note:
|
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.add_tracking_entry(
|
||||||
self.addTransactionNote(
|
StockHistoryCode.CREATED,
|
||||||
_('Created stock item'),
|
|
||||||
user,
|
user,
|
||||||
note,
|
deltas=tracking_info,
|
||||||
system=True
|
notes=notes,
|
||||||
|
location=self.location,
|
||||||
|
quantity=float(self.quantity),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -610,31 +642,42 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
# TODO - Remove any stock item allocations from this stock item
|
# TODO - Remove any stock item allocations from this stock item
|
||||||
|
|
||||||
item.addTransactionNote(
|
item.add_tracking_entry(
|
||||||
_("Assigned to Customer"),
|
StockHistoryCode.SENT_TO_CUSTOMER,
|
||||||
user,
|
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 the reference to the stock item
|
||||||
return 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.
|
Return stock item from customer, back into the specified location.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.addTransactionNote(
|
notes = kwargs.get('notes', '')
|
||||||
_("Returned from customer {name}").format(name=self.customer.name),
|
|
||||||
|
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,
|
user,
|
||||||
notes=_("Returned to location {loc}").format(loc=location.name),
|
notes=notes,
|
||||||
system=True
|
deltas=tracking_info,
|
||||||
|
location=location
|
||||||
)
|
)
|
||||||
|
|
||||||
self.customer = None
|
self.customer = None
|
||||||
self.location = location
|
self.location = location
|
||||||
self.sales_order = None
|
|
||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
@ -788,18 +831,23 @@ class StockItem(MPTTModel):
|
|||||||
stock_item.save()
|
stock_item.save()
|
||||||
|
|
||||||
# Add a transaction note to the other item
|
# Add a transaction note to the other item
|
||||||
stock_item.addTransactionNote(
|
stock_item.add_tracking_entry(
|
||||||
_('Installed into stock item {pk}').format(str(self.pk)),
|
StockHistoryCode.INSTALLED_INTO_ASSEMBLY,
|
||||||
user,
|
user,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
url=self.get_absolute_url()
|
deltas={
|
||||||
|
'stockitem': self.pk,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add a transaction note to this item
|
# Add a transaction note to this item (the assembly)
|
||||||
self.addTransactionNote(
|
self.add_tracking_entry(
|
||||||
_('Installed stock item {pk}').format(str(stock_item.pk)),
|
StockHistoryCode.INSTALLED_CHILD_ITEM,
|
||||||
user, notes=notes,
|
user,
|
||||||
url=stock_item.get_absolute_url()
|
notes=notes,
|
||||||
|
deltas={
|
||||||
|
'stockitem': stock_item.pk,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
@ -820,11 +868,25 @@ class StockItem(MPTTModel):
|
|||||||
# TODO - Are there any other checks that need to be performed at this stage?
|
# TODO - Are there any other checks that need to be performed at this stage?
|
||||||
|
|
||||||
# Add a transaction note to the parent item
|
# Add a transaction note to the parent item
|
||||||
self.belongs_to.addTransactionNote(
|
self.belongs_to.add_tracking_entry(
|
||||||
_("Uninstalled stock item {pk}").format(pk=str(self.pk)),
|
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,
|
user,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
url=self.get_absolute_url(),
|
deltas=tracking_info,
|
||||||
|
location=location,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mark this stock item as *not* belonging to anyone
|
# Mark this stock item as *not* belonging to anyone
|
||||||
@ -833,19 +895,6 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
self.save()
|
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
|
@property
|
||||||
def children(self):
|
def children(self):
|
||||||
""" Return a list of the child items which have been split from this stock item """
|
""" 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):
|
def has_tracking_info(self):
|
||||||
return self.tracking_info_count > 0
|
return self.tracking_info_count > 0
|
||||||
|
|
||||||
def addTransactionNote(self, title, user, notes='', url='', system=True):
|
def add_tracking_entry(self, entry_type, user, deltas={}, notes='', **kwargs):
|
||||||
""" Generation a stock transaction note for this item.
|
"""
|
||||||
|
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,
|
item=self,
|
||||||
title=title,
|
tracking_type=entry_type,
|
||||||
user=user,
|
user=user,
|
||||||
quantity=self.quantity,
|
date=datetime.now(),
|
||||||
date=datetime.now().date(),
|
|
||||||
notes=notes,
|
notes=notes,
|
||||||
link=url,
|
deltas=deltas,
|
||||||
system=system
|
|
||||||
)
|
)
|
||||||
|
|
||||||
track.save()
|
entry.save()
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def serializeStock(self, quantity, serials, user, notes='', location=None):
|
def serializeStock(self, quantity, serials, user, notes='', location=None):
|
||||||
@ -930,7 +995,7 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
quantity: Number of items to serialize (integer)
|
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
|
user: User object associated with action
|
||||||
notes: Optional notes for tracking
|
notes: Optional notes for tracking
|
||||||
location: If specified, serialized items will be placed in the given location
|
location: If specified, serialized items will be placed in the given location
|
||||||
@ -982,7 +1047,7 @@ class StockItem(MPTTModel):
|
|||||||
new_item.location = location
|
new_item.location = location
|
||||||
|
|
||||||
# The item already has a transaction history, don't create a new note
|
# 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
|
# Copy entire transaction history
|
||||||
new_item.copyHistoryFrom(self)
|
new_item.copyHistoryFrom(self)
|
||||||
@ -991,10 +1056,18 @@ class StockItem(MPTTModel):
|
|||||||
new_item.copyTestResultsFrom(self)
|
new_item.copyTestResultsFrom(self)
|
||||||
|
|
||||||
# Create a new stock tracking item
|
# 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
|
# 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
|
@transaction.atomic
|
||||||
def copyHistoryFrom(self, other):
|
def copyHistoryFrom(self, other):
|
||||||
@ -1018,7 +1091,7 @@ class StockItem(MPTTModel):
|
|||||||
result.save()
|
result.save()
|
||||||
|
|
||||||
@transaction.atomic
|
@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.
|
""" Split this stock item into two items, in the same location.
|
||||||
Stock tracking notes for this StockItem will be duplicated,
|
Stock tracking notes for this StockItem will be duplicated,
|
||||||
and added to the new StockItem.
|
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.
|
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
|
# Do not split a serialized part
|
||||||
if self.serialized:
|
if self.serialized:
|
||||||
return self
|
return self
|
||||||
@ -1071,17 +1146,21 @@ class StockItem(MPTTModel):
|
|||||||
new_stock.copyTestResultsFrom(self)
|
new_stock.copyTestResultsFrom(self)
|
||||||
|
|
||||||
# Add a new tracking item for the new stock item
|
# Add a new tracking item for the new stock item
|
||||||
new_stock.addTransactionNote(
|
new_stock.add_tracking_entry(
|
||||||
_("Split from existing stock"),
|
StockHistoryCode.SPLIT_FROM_PARENT,
|
||||||
user,
|
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
|
# Remove the specified quantity from THIS stock item
|
||||||
self.take_stock(
|
self.take_stock(
|
||||||
quantity,
|
quantity,
|
||||||
user,
|
user,
|
||||||
f"{_('Split')} {quantity} {_('items into new stock item')}"
|
notes=notes
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return a copy of the "new" stock item
|
# Return a copy of the "new" stock item
|
||||||
@ -1131,18 +1210,17 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
return True
|
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.location = location
|
||||||
|
|
||||||
self.addTransactionNote(
|
tracking_info = {}
|
||||||
msg,
|
|
||||||
|
self.add_tracking_entry(
|
||||||
|
StockHistoryCode.STOCK_MOVE,
|
||||||
user,
|
user,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
system=True)
|
deltas=tracking_info,
|
||||||
|
location=location,
|
||||||
|
)
|
||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
@ -1202,13 +1280,13 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
if self.updateQuantity(count):
|
if self.updateQuantity(count):
|
||||||
|
|
||||||
text = _('Counted {n} items').format(n=helpers.normalize(count))
|
self.add_tracking_entry(
|
||||||
|
StockHistoryCode.STOCK_COUNT,
|
||||||
self.addTransactionNote(
|
|
||||||
text,
|
|
||||||
user,
|
user,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
system=True
|
deltas={
|
||||||
|
'quantity': float(self.quantity),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@ -1234,20 +1312,23 @@ class StockItem(MPTTModel):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
if self.updateQuantity(self.quantity + quantity):
|
if self.updateQuantity(self.quantity + quantity):
|
||||||
text = _('Added {n} items').format(n=helpers.normalize(quantity))
|
|
||||||
|
|
||||||
self.addTransactionNote(
|
self.add_tracking_entry(
|
||||||
text,
|
StockHistoryCode.STOCK_ADD,
|
||||||
user,
|
user,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
system=True
|
deltas={
|
||||||
|
'added': float(quantity),
|
||||||
|
'quantity': float(self.quantity),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def take_stock(self, quantity, user, notes=''):
|
def take_stock(self, quantity, user, notes=''):
|
||||||
""" Remove items from stock
|
"""
|
||||||
|
Remove items from stock
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Cannot remove items from a serialized part
|
# Cannot remove items from a serialized part
|
||||||
@ -1264,12 +1345,15 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
if self.updateQuantity(self.quantity - quantity):
|
if self.updateQuantity(self.quantity - quantity):
|
||||||
|
|
||||||
text = _('Removed {n1} items').format(n1=helpers.normalize(quantity))
|
self.add_tracking_entry(
|
||||||
|
StockHistoryCode.STOCK_REMOVE,
|
||||||
self.addTransactionNote(text,
|
user,
|
||||||
user,
|
notes=notes,
|
||||||
notes=notes,
|
deltas={
|
||||||
system=True)
|
'removed': float(quantity),
|
||||||
|
'quantity': float(self.quantity),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -1527,44 +1611,58 @@ class StockItemAttachment(InvenTreeAttachment):
|
|||||||
|
|
||||||
|
|
||||||
class StockItemTracking(models.Model):
|
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:
|
Attributes:
|
||||||
item: Link to StockItem
|
item: ForeignKey reference to a particular StockItem
|
||||||
date: Date that this tracking info was created
|
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)
|
notes: Associated notes (input by user)
|
||||||
link: Optional URL to external page
|
|
||||||
user: The user associated with this tracking info
|
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):
|
def get_absolute_url(self):
|
||||||
return '/stock/track/{pk}'.format(pk=self.id)
|
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,
|
def label(self):
|
||||||
related_name='tracking_info')
|
|
||||||
|
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)
|
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, null=True,
|
||||||
notes = models.CharField(blank=True, max_length=512, verbose_name=_('Notes'), help_text=_('Entry notes'))
|
max_length=512,
|
||||||
|
verbose_name=_('Notes'),
|
||||||
link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page for further information'))
|
help_text=_('Entry notes')
|
||||||
|
)
|
||||||
|
|
||||||
user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)
|
user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)
|
||||||
|
|
||||||
system = models.BooleanField(default=False)
|
deltas = models.JSONField(null=True, blank=True)
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
def rename_stock_item_test_result_attachment(instance, filename):
|
def rename_stock_item_test_result_attachment(instance, filename):
|
||||||
|
@ -349,32 +349,32 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
|
|||||||
if user_detail is not True:
|
if user_detail is not True:
|
||||||
self.fields.pop('user_detail')
|
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)
|
item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True)
|
||||||
|
|
||||||
user_detail = UserSerializerBrief(source='user', many=False, read_only=True)
|
user_detail = UserSerializerBrief(source='user', many=False, read_only=True)
|
||||||
|
|
||||||
|
deltas = serializers.JSONField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StockItemTracking
|
model = StockItemTracking
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
'url',
|
|
||||||
'item',
|
'item',
|
||||||
'item_detail',
|
'item_detail',
|
||||||
'date',
|
'date',
|
||||||
'title',
|
'deltas',
|
||||||
|
'label',
|
||||||
'notes',
|
'notes',
|
||||||
'link',
|
'tracking_type',
|
||||||
'quantity',
|
|
||||||
'user',
|
'user',
|
||||||
'user_detail',
|
'user_detail',
|
||||||
'system',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
'date',
|
'date',
|
||||||
'user',
|
'user',
|
||||||
'system',
|
'label',
|
||||||
'quantity',
|
'tracking_type',
|
||||||
]
|
]
|
||||||
|
@ -94,7 +94,13 @@
|
|||||||
{% if item.is_expired %}
|
{% if item.is_expired %}
|
||||||
<span class='label label-large label-large-red'>{% trans "Expired" %}</span>
|
<span class='label label-large label-large-red'>{% trans "Expired" %}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
{% if roles.stock.change %}
|
||||||
|
<a href='#' id='stock-edit-status'>
|
||||||
|
{% endif %}
|
||||||
{% stock_status_label item.status large=True %}
|
{% stock_status_label item.status large=True %}
|
||||||
|
{% if roles.stock.change %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% if item.is_stale %}
|
{% if item.is_stale %}
|
||||||
<span class='label label-large label-large-yellow'>{% trans "Stale" %}</span>
|
<span class='label label-large label-large-yellow'>{% trans "Stale" %}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -453,6 +459,7 @@ $("#print-label").click(function() {
|
|||||||
printStockItemLabels([{{ item.pk }}]);
|
printStockItemLabels([{{ item.pk }}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
{% if roles.stock.change %}
|
||||||
$("#stock-duplicate").click(function() {
|
$("#stock-duplicate").click(function() {
|
||||||
createNewStockItem({
|
createNewStockItem({
|
||||||
follow: true,
|
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() {
|
$("#show-qr-code").click(function() {
|
||||||
launchModalForm("{% url 'stock-item-qr' item.id %}",
|
launchModalForm("{% url 'stock-item-qr' item.id %}",
|
||||||
{
|
{
|
||||||
|
@ -5,6 +5,8 @@ from django.core.exceptions import ValidationError
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
from InvenTree.status_codes import StockHistoryCode
|
||||||
|
|
||||||
from .models import StockLocation, StockItem, StockItemTracking
|
from .models import StockLocation, StockItem, StockItemTracking
|
||||||
from .models import StockItemTestResult
|
from .models import StockItemTestResult
|
||||||
|
|
||||||
@ -217,7 +219,7 @@ class StockTest(TestCase):
|
|||||||
track = StockItemTracking.objects.filter(item=it).latest('id')
|
track = StockItemTracking.objects.filter(item=it).latest('id')
|
||||||
|
|
||||||
self.assertEqual(track.item, it)
|
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')
|
self.assertEqual(track.notes, 'Moved to the bathroom')
|
||||||
|
|
||||||
def test_self_move(self):
|
def test_self_move(self):
|
||||||
@ -284,8 +286,7 @@ class StockTest(TestCase):
|
|||||||
# Check that a tracking item was added
|
# Check that a tracking item was added
|
||||||
track = StockItemTracking.objects.filter(item=it).latest('id')
|
track = StockItemTracking.objects.filter(item=it).latest('id')
|
||||||
|
|
||||||
self.assertIn('Counted', track.title)
|
self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_COUNT)
|
||||||
self.assertIn('items', track.title)
|
|
||||||
self.assertIn('Counted items', track.notes)
|
self.assertIn('Counted items', track.notes)
|
||||||
|
|
||||||
n = it.tracking_info.count()
|
n = it.tracking_info.count()
|
||||||
@ -304,7 +305,7 @@ class StockTest(TestCase):
|
|||||||
# Check that a tracking item was added
|
# Check that a tracking item was added
|
||||||
track = StockItemTracking.objects.filter(item=it).latest('id')
|
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.assertIn('Added some items', track.notes)
|
||||||
|
|
||||||
self.assertFalse(it.add_stock(-10, None))
|
self.assertFalse(it.add_stock(-10, None))
|
||||||
@ -319,7 +320,7 @@ class StockTest(TestCase):
|
|||||||
# Check that a tracking item was added
|
# Check that a tracking item was added
|
||||||
track = StockItemTracking.objects.filter(item=it).latest('id')
|
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.assertIn('Removed some items', track.notes)
|
||||||
self.assertTrue(it.has_tracking_info)
|
self.assertTrue(it.has_tracking_info)
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ URL lookup for Stock app
|
|||||||
|
|
||||||
from django.conf.urls import url, include
|
from django.conf.urls import url, include
|
||||||
|
|
||||||
from . import views
|
from stock import views
|
||||||
|
|
||||||
location_urls = [
|
location_urls = [
|
||||||
|
|
||||||
@ -24,6 +24,7 @@ location_urls = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
stock_item_detail_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'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'),
|
||||||
url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'),
|
url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'),
|
||||||
url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'),
|
url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'),
|
||||||
|
@ -1212,6 +1212,27 @@ class StockAdjust(AjaxView, FormMixin):
|
|||||||
return _("Deleted {n} stock items").format(n=count)
|
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):
|
class StockItemEdit(AjaxUpdateView):
|
||||||
"""
|
"""
|
||||||
View for editing details of a single StockItem
|
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:
|
if not owner and not self.request.user.is_superuser:
|
||||||
form.add_error('owner', _('Owner is required (ownership control is enabled)'))
|
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):
|
class StockItemConvert(AjaxUpdateView):
|
||||||
"""
|
"""
|
||||||
|
@ -133,11 +133,14 @@
|
|||||||
<!-- boostrap-table-treegrid -->
|
<!-- boostrap-table-treegrid -->
|
||||||
<script type='text/javascript' src='{% static "bootstrap-table/extensions/treegrid/bootstrap-table-treegrid.js" %}'></script>
|
<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/main.js' %}"></script>
|
||||||
<script type="text/javascript" src="{% static 'fullcalendar/locales-all.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/select2/select2.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% static 'script/moment.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/inventree.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% static 'script/inventree/api.js' %}"></script>
|
<script type='text/javascript' src="{% static 'script/inventree/api.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
|
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
|
||||||
|
5
InvenTree/templates/clip.html
Normal file
5
InvenTree/templates/clip.html
Normal 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>
|
@ -976,42 +976,28 @@ function loadStockLocationTable(table, options) {
|
|||||||
|
|
||||||
function loadStockTrackingTable(table, options) {
|
function loadStockTrackingTable(table, options) {
|
||||||
|
|
||||||
var cols = [
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (m.isValid()) {
|
||||||
if (options.partColumn) {
|
var html = m.format('dddd MMMM Do YYYY'); // + '<br>' + m.format('h:mm a');
|
||||||
cols.push({
|
return html;
|
||||||
field: 'item',
|
|
||||||
title: '{% trans "Stock Item" %}',
|
|
||||||
sortable: true,
|
|
||||||
formatter: function(value, row, index, field) {
|
|
||||||
return renderLink(value.part_name, value.url);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
return '<i>{% trans "Invalid date" %}</i>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Stock transaction description
|
// Stock transaction description
|
||||||
cols.push({
|
cols.push({
|
||||||
field: 'title',
|
field: 'label',
|
||||||
title: '{% trans "Description" %}',
|
title: '{% trans "Description" %}',
|
||||||
formatter: function(value, row, index, field) {
|
formatter: function(value, row, index, field) {
|
||||||
var html = "<b>" + value + "</b>";
|
var html = "<b>" + value + "</b>";
|
||||||
@ -1020,20 +1006,139 @@ function loadStockTrackingTable(table, options) {
|
|||||||
html += "<br><i>" + row.notes + "</i>";
|
html += "<br><i>" + row.notes + "</i>";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.link) {
|
|
||||||
html += "<br><a href='" + row.link + "'>" + row.link + "</a>";
|
|
||||||
}
|
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stock transaction details
|
||||||
cols.push({
|
cols.push({
|
||||||
field: 'quantity',
|
field: 'deltas',
|
||||||
title: '{% trans "Quantity" %}',
|
title: '{% trans "Details" %}',
|
||||||
formatter: function(value, row, index, field) {
|
formatter: function(details, row, index, field) {
|
||||||
return parseFloat(value);
|
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({
|
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({
|
cols.push({
|
||||||
sortable: false,
|
sortable: false,
|
||||||
formatter: function(value, row, index, field) {
|
formatter: function(value, row, index, field) {
|
||||||
// Manually created entries can be edited or deleted
|
// 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 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>";
|
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({
|
table.inventreeTable({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
|
||||||
{% include "status_codes.html" with label='stock' options=StockStatus.list %}
|
{% 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='build' options=BuildStatus.list %}
|
||||||
{% include "status_codes.html" with label='purchaseOrder' options=PurchaseOrderStatus.list %}
|
{% include "status_codes.html" with label='purchaseOrder' options=PurchaseOrderStatus.list %}
|
||||||
{% include "status_codes.html" with label='salesOrder' options=SalesOrderStatus.list %}
|
{% include "status_codes.html" with label='salesOrder' options=SalesOrderStatus.list %}
|
||||||
|
@ -14,7 +14,7 @@ var {{ label }}Codes = {
|
|||||||
* Uses the values specified in "status_codes.py"
|
* Uses the values specified in "status_codes.py"
|
||||||
* This function is generated by the "status_codes.html" template
|
* This function is generated by the "status_codes.html" template
|
||||||
*/
|
*/
|
||||||
function {{ label }}StatusDisplay(key) {
|
function {{ label }}StatusDisplay(key, options={}) {
|
||||||
|
|
||||||
key = String(key);
|
key = String(key);
|
||||||
|
|
||||||
@ -31,5 +31,11 @@ function {{ label }}StatusDisplay(key) {
|
|||||||
label = '';
|
label = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<span class='label ${label}'>${value}</span>`;
|
var classes = `label ${label}`;
|
||||||
|
|
||||||
|
if (options.classes) {
|
||||||
|
classes += ' ' + options.classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<span class='${classes}'>${value}</span>`;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user