2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-19 13:35:40 +00:00

Merge branch 'inventree:master' into matmair/issue2279

This commit is contained in:
Matthias Mair
2021-12-29 23:54:54 +01:00
committed by GitHub
63 changed files with 20963 additions and 16113 deletions

View File

@ -14,6 +14,10 @@
--bs-body-color: #68686a;
}
main {
overflow-x: clip;
}
.login-screen {
background: url(/static/img/paper_splash.jpg) no-repeat center fixed;
background-size: cover;

View File

@ -252,6 +252,9 @@ class StockHistoryCode(StatusCode):
SPLIT_FROM_PARENT = 40
SPLIT_CHILD_ITEM = 42
# Stock merging operations
MERGED_STOCK_ITEMS = 45
# Build order codes
BUILD_OUTPUT_CREATED = 50
BUILD_OUTPUT_COMPLETED = 55
@ -288,6 +291,8 @@ class StockHistoryCode(StatusCode):
SPLIT_FROM_PARENT: _('Split from parent item'),
SPLIT_CHILD_ITEM: _('Split child item'),
MERGED_STOCK_ITEMS: _('Merged stock items'),
SENT_TO_CUSTOMER: _('Sent to customer'),
RETURNED_FROM_CUSTOMER: _('Returned from customer'),

View File

@ -12,10 +12,14 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev"
# InvenTree API version
INVENTREE_API_VERSION = 21
INVENTREE_API_VERSION = 22
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v22 -> 2021-12-20
- Adds API endpoint to "merge" multiple stock items
v21 -> 2021-12-04
- Adds support for multiple "Shipments" against a SalesOrder
- Refactors process for stock allocation against a SalesOrder

View File

@ -45,8 +45,8 @@
{% endif %}
</ul>
</div>
{% include "filter_list.html" with id="supplier-part" %}
</div>
{% include "filter_list.html" with id="supplier-part" %}
</div>
</div>
{% endif %}
@ -92,8 +92,8 @@
{% endif %}
</ul>
</div>
{% include "filter_list.html" with id="manufacturer-part" %}
</div>
{% include "filter_list.html" with id="supplier-part" %}
</div>
</div>
{% endif %}
@ -169,10 +169,10 @@
</div>
<div class='panel-content'>
<div id='assigned-stock-button-toolbar'>
{% include "filter_list.html" with id="stock" %}
{% include "filter_list.html" with id="customerstock" %}
</div>
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#bassigned-stock-utton-toolbar'></table>
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table>
</div>
</div>
@ -225,6 +225,7 @@
},
url: "{% url 'api-stock-list' %}",
filterKey: "customerstock",
filterTarget: '#filter-list-customerstock',
});
{% if company.is_customer %}

View File

@ -25,6 +25,7 @@
<div class='panel-content'>
<div id='button-toolbar'>
{% include "filter_list.html" with id='company' %}
</div>
<table class='table table-striped table-condensed' id='company-table' data-toolbar='#button-toolbar'>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -628,28 +628,30 @@ class SalesOrder(Order):
Throws a ValidationError if cannot be completed.
"""
# Order without line items cannot be completed
if self.lines.count() == 0:
if raise_error:
try:
# Order without line items cannot be completed
if self.lines.count() == 0:
raise ValidationError(_('Order cannot be completed as no parts have been assigned'))
# Only a PENDING order can be marked as SHIPPED
elif self.status != SalesOrderStatus.PENDING:
if raise_error:
# Only a PENDING order can be marked as SHIPPED
elif self.status != SalesOrderStatus.PENDING:
raise ValidationError(_('Only a pending order can be marked as complete'))
elif self.pending_shipment_count > 0:
if raise_error:
elif self.pending_shipment_count > 0:
raise ValidationError(_("Order cannot be completed as there are incomplete shipments"))
elif self.pending_line_count > 0:
if raise_error:
elif self.pending_line_count > 0:
raise ValidationError(_("Order cannot be completed as there are incomplete line items"))
else:
return True
except ValidationError as e:
return False
if raise_error:
raise e
else:
return False
return True
def complete_order(self, user):
"""

View File

@ -454,6 +454,76 @@ class PartSerialNumberDetail(generics.RetrieveAPIView):
return Response(data)
class PartCopyBOM(generics.CreateAPIView):
"""
API endpoint for duplicating a BOM
"""
queryset = Part.objects.all()
serializer_class = part_serializers.PartCopyBOMSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
try:
ctx['part'] = Part.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
return ctx
class PartValidateBOM(generics.RetrieveUpdateAPIView):
"""
API endpoint for 'validating' the BOM for a given Part
"""
class BOMValidateSerializer(serializers.ModelSerializer):
class Meta:
model = Part
fields = [
'checksum',
'valid',
]
checksum = serializers.CharField(
read_only=True,
source='bom_checksum',
)
valid = serializers.BooleanField(
write_only=True,
default=False,
label=_('Valid'),
help_text=_('Validate entire Bill of Materials'),
)
def validate_valid(self, valid):
if not valid:
raise ValidationError(_('This option must be selected'))
queryset = Part.objects.all()
serializer_class = BOMValidateSerializer
def update(self, request, *args, **kwargs):
part = self.get_object()
partial = kwargs.pop('partial', False)
serializer = self.get_serializer(part, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
part.validate_bom(request.user)
return Response({
'checksum': part.bom_checksum,
})
class PartDetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of a single Part object """
@ -1585,6 +1655,12 @@ part_api_urls = [
# Endpoint for extra serial number information
url(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'),
# Endpoint for duplicating a BOM for the specific Part
url(r'^bom-copy/', PartCopyBOM.as_view(), name='api-part-bom-copy'),
# Endpoint for validating a BOM for the specific Part
url(r'^bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'),
# Part detail endpoint
url(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
])),

View File

@ -55,54 +55,6 @@ class PartImageDownloadForm(HelperForm):
]
class BomDuplicateForm(HelperForm):
"""
Simple confirmation form for BOM duplication.
Select which parent to select from.
"""
parent = PartModelChoiceField(
label=_('Parent Part'),
help_text=_('Select parent part to copy BOM from'),
queryset=Part.objects.filter(is_template=True),
)
clear = forms.BooleanField(
required=False, initial=True,
help_text=_('Clear existing BOM items')
)
confirm = forms.BooleanField(
required=False, initial=False,
label=_('Confirm'),
help_text=_('Confirm BOM duplication')
)
class Meta:
model = Part
fields = [
'parent',
'clear',
'confirm',
]
class BomValidateForm(HelperForm):
""" Simple confirmation form for BOM validation.
User is presented with a single checkbox input,
to confirm that the BOM for this part is valid
"""
validate = forms.BooleanField(required=False, initial=False, label=_('validate'), help_text=_('Confirm that the BOM is correct'))
class Meta:
model = Part
fields = [
'validate'
]
class BomMatchItemForm(MatchItemForm):
""" Override MatchItemForm fields """

View File

@ -481,7 +481,7 @@ class Part(MPTTModel):
def __str__(self):
return f"{self.full_name} - {self.description}"
def checkAddToBOM(self, parent):
def check_add_to_bom(self, parent, raise_error=False, recursive=True):
"""
Check if this Part can be added to the BOM of another part.
@ -491,33 +491,44 @@ class Part(MPTTModel):
b) The parent part is used in the BOM for *this* part
c) The parent part is used in the BOM for any child parts under this one
Failing this check raises a ValidationError!
"""
if parent is None:
return
result = True
if self.pk == parent.pk:
raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)").format(
p1=str(self),
p2=str(parent)
)})
bom_items = self.get_bom_items()
# Ensure that the parent part does not appear under any child BOM item!
for item in bom_items.all():
# Check for simple match
if item.sub_part == parent:
try:
if self.pk == parent.pk:
raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)").format(
p1=str(parent),
p2=str(self)
p1=str(self),
p2=str(parent)
)})
# And recursively check too
item.sub_part.checkAddToBOM(parent)
bom_items = self.get_bom_items()
# Ensure that the parent part does not appear under any child BOM item!
for item in bom_items.all():
# Check for simple match
if item.sub_part == parent:
raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)").format(
p1=str(parent),
p2=str(self)
)})
# And recursively check too
if recursive:
result = result and item.sub_part.check_add_to_bom(
parent,
recursive=True,
raise_error=raise_error
)
except ValidationError as e:
if raise_error:
raise e
else:
return False
return result
def checkIfSerialNumberExists(self, sn, exclude_self=False):
"""
@ -1816,23 +1827,45 @@ class Part(MPTTModel):
clear - Remove existing BOM items first (default=True)
"""
# Ignore if the other part is actually this part?
if other == self:
return
if clear:
# Remove existing BOM items
# Note: Inherited BOM items are *not* deleted!
self.bom_items.all().delete()
# List of "ancestor" parts above this one
my_ancestors = self.get_ancestors(include_self=False)
raise_error = not kwargs.get('skip_invalid', True)
include_inherited = kwargs.get('include_inherited', False)
# Copy existing BOM items from another part
# Note: Inherited BOM Items will *not* be duplicated!!
for bom_item in other.get_bom_items(include_inherited=False).all():
for bom_item in other.get_bom_items(include_inherited=include_inherited).all():
# If this part already has a BomItem pointing to the same sub-part,
# delete that BomItem from this part first!
try:
existing = BomItem.objects.get(part=self, sub_part=bom_item.sub_part)
existing.delete()
except (BomItem.DoesNotExist):
pass
# Ignore invalid BomItem objects
if not bom_item.part or not bom_item.sub_part:
continue
# Ignore ancestor parts which are inherited
if bom_item.part in my_ancestors and bom_item.inherited:
continue
# Skip if already exists
if BomItem.objects.filter(part=self, sub_part=bom_item.sub_part).exists():
continue
# Skip (or throw error) if BomItem is not valid
if not bom_item.sub_part.check_add_to_bom(self, raise_error=raise_error):
continue
# Construct a new BOM item
bom_item.part = self
bom_item.pk = None
@ -2677,7 +2710,7 @@ class BomItem(models.Model):
try:
# Check for circular BOM references
if self.sub_part:
self.sub_part.checkAddToBOM(self.part)
self.sub_part.check_add_to_bom(self.part, raise_error=True)
# If the sub_part is 'trackable' then the 'quantity' field must be an integer
if self.sub_part.trackable:

View File

@ -9,6 +9,7 @@ from django.urls import reverse_lazy
from django.db import models
from django.db.models import Q
from django.db.models.functions import Coalesce
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum
@ -636,3 +637,65 @@ class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
'parameter_template',
'default_value',
]
class PartCopyBOMSerializer(serializers.Serializer):
"""
Serializer for copying a BOM from another part
"""
class Meta:
fields = [
'part',
'remove_existing',
]
part = serializers.PrimaryKeyRelatedField(
queryset=Part.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Part'),
help_text=_('Select part to copy BOM from'),
)
def validate_part(self, part):
"""
Check that a 'valid' part was selected
"""
return part
remove_existing = serializers.BooleanField(
label=_('Remove Existing Data'),
help_text=_('Remove existing BOM items before copying'),
default=True,
)
include_inherited = serializers.BooleanField(
label=_('Include Inherited'),
help_text=_('Include BOM items which are inherited from templated parts'),
default=False,
)
skip_invalid = serializers.BooleanField(
label=_('Skip Invalid Rows'),
help_text=_('Enable this option to skip invalid rows'),
default=False,
)
def save(self):
"""
Actually duplicate the BOM
"""
base_part = self.context['part']
data = self.validated_data
base_part.copy_bom_from(
data['part'],
clear=data.get('remove_existing', True),
skip_invalid=data.get('skip_invalid', False),
include_inherited=data.get('include_inherited', False),
)

View File

@ -1,17 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<p>
{% trans "Select parent part to copy BOM from" %}
</p>
{% if part.has_bom %}
<div class='alert alert-block alert-danger'>
<strong>{% trans "Warning" %}</strong><br>
{% trans "This part already has a Bill of Materials" %}<br>
</div>
{% endif %}
{% endblock %}

View File

@ -1,12 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
{% blocktrans with part.full_name as part %}Confirm that the Bill of Materials (BOM) is valid for:<br><em>{{ part }}</em>{% endblocktrans %}
<div class='alert alert-warning alert-block'>
{% trans 'This will validate each line in the BOM.' %}
</div>
{% endblock %}

View File

@ -181,6 +181,9 @@
<div class='panel-content'>
<div id='param-button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="parameters" %}
</div>
</div>
</div>
<table id='parameter-table' class='table table-condensed table-striped' data-toolbar="#param-button-toolbar"></table>
@ -217,7 +220,7 @@
</div>
</div>
<div class='panel-content'>
<div id='related-button-bar'>
<div id='related-button-toolbar'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="related" %}
</div>
@ -344,6 +347,7 @@
<li><a class='dropdown-item' href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
</ul>
</div>
{% include "filter_list.html" with id="supplier-part" %}
</div>
</div>
@ -371,6 +375,7 @@
<ul class="dropdown-menu">
<li><a class='dropdown-item' href='#' id='manufacturer-part-delete' title='{% trans "Delete manufacturer parts" %}'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete" %}</a></li>
</ul>
{% include "filter_list.html" with id="manufacturer-part" %}
</div>
</div>
</div>
@ -580,14 +585,12 @@
});
$('#bom-duplicate').click(function() {
launchModalForm(
"{% url 'duplicate-bom' part.id %}",
{
success: function() {
$('#bom-table').bootstrapTable('refresh');
}
duplicateBom({{ part.pk }}, {
success: function(response) {
$('#bom-table').bootstrapTable('refresh');
}
);
});
});
$("#bom-item-new").click(function () {
@ -611,12 +614,10 @@
});
$("#validate-bom").click(function() {
launchModalForm(
"{% url 'bom-validate' part.id %}",
{
reload: true,
}
);
validateBom({{ part.id }}, {
reload: true
});
});
$("#download-bom").click(function () {

View File

@ -35,12 +35,10 @@ part_detail_urls = [
url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'),
url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
url(r'^bom-duplicate/?', views.BomDuplicate.as_view(), name='duplicate-bom'),
url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'),

View File

@ -694,100 +694,6 @@ class PartImageSelect(AjaxUpdateView):
return self.renderJsonResponse(request, form, data)
class BomDuplicate(AjaxUpdateView):
"""
View for duplicating BOM from a parent item.
"""
model = Part
context_object_name = 'part'
ajax_form_title = _('Duplicate BOM')
ajax_template_name = 'part/bom_duplicate.html'
form_class = part_forms.BomDuplicateForm
def get_form(self):
form = super().get_form()
# Limit choices to parents of the current part
parents = self.get_object().get_ancestors()
form.fields['parent'].queryset = parents
return form
def get_initial(self):
initials = super().get_initial()
parents = self.get_object().get_ancestors()
if parents.count() == 1:
initials['parent'] = parents[0]
return initials
def validate(self, part, form):
confirm = str2bool(form.cleaned_data.get('confirm', False))
if not confirm:
form.add_error('confirm', _('Confirm duplication of BOM from parent'))
def save(self, part, form):
"""
Duplicate BOM from the specified parent
"""
parent = form.cleaned_data.get('parent', None)
clear = str2bool(form.cleaned_data.get('clear', True))
if parent:
part.copy_bom_from(parent, clear=clear)
class BomValidate(AjaxUpdateView):
"""
Modal form view for validating a part BOM
"""
model = Part
ajax_form_title = _("Validate BOM")
ajax_template_name = 'part/bom_validate.html'
context_object_name = 'part'
form_class = part_forms.BomValidateForm
def get_context(self):
return {
'part': self.get_object(),
}
def get(self, request, *args, **kwargs):
form = self.get_form()
return self.renderJsonResponse(request, form, context=self.get_context())
def validate(self, part, form, **kwargs):
confirm = str2bool(form.cleaned_data.get('validate', False))
if not confirm:
form.add_error('validate', _('Confirm that the BOM is valid'))
def save(self, part, form, **kwargs):
"""
Mark the BOM as validated
"""
part.validate_bom(self.request.user)
def get_data(self):
return {
'success': _('Validated Bill of Materials')
}
class BomUpload(InvenTreeRoleMixin, FileManagementFormView):
""" View for uploading a BOM file, and handling BOM data importing.

View File

@ -4,6 +4,7 @@ This script calculates translation coverage for various languages
import os
import json
import sys
def calculate_coverage(filename):
@ -42,7 +43,7 @@ if __name__ == '__main__':
locales = {}
locales_perc = {}
print("InvenTree translation coverage:")
verbose = '-v' in sys.argv
for locale in os.listdir(LC_DIR):
path = os.path.join(LC_DIR, locale)
@ -53,7 +54,10 @@ if __name__ == '__main__':
if os.path.exists(locale_file) and os.path.isfile(locale_file):
locales[locale] = locale_file
print("-" * 16)
if verbose:
print("-" * 16)
percentages = []
for locale in locales.keys():
locale_file = locales[locale]
@ -66,11 +70,23 @@ if __name__ == '__main__':
else:
percentage = 0
print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |")
if verbose:
print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |")
locales_perc[locale] = percentage
print("-" * 16)
percentages.append(percentage)
if verbose:
print("-" * 16)
# write locale stats
with open(STAT_FILE, 'w') as target:
json.dump(locales_perc, target)
if len(percentages) > 0:
avg = int(sum(percentages) / len(percentages))
else:
avg = 0
print(f"InvenTree translation coverage: {avg}%")

View File

@ -180,6 +180,20 @@ class StockAssign(generics.CreateAPIView):
return ctx
class StockMerge(generics.CreateAPIView):
"""
API endpoint for merging multiple stock items
"""
queryset = StockItem.objects.none()
serializer_class = StockSerializers.StockMergeSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['request'] = self.request
return ctx
class StockLocationList(generics.ListCreateAPIView):
"""
API endpoint for list view of StockLocation objects:
@ -1213,6 +1227,7 @@ stock_api_urls = [
url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'),
url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'),
url(r'^assign/', StockAssign.as_view(), name='api-stock-assign'),
url(r'^merge/', StockMerge.as_view(), name='api-stock-merge'),
# StockItemAttachment API endpoints
url(r'^attachment/', include([

View File

@ -114,19 +114,6 @@
lft: 0
rght: 0
- model: stock.stockitem
pk: 501
fields:
part: 10001
location: 7
batch: "AAA"
quantity: 1
serial: 1
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 501
fields:

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.10 on 2021-12-20 21:49
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0072_remove_stockitem_scheduled_for_deletion'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='belongs_to',
field=models.ForeignKey(blank=True, help_text='Is this item installed in another item?', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='installed_parts', to='stock.stockitem', verbose_name='Installed In'),
),
]

View File

@ -455,6 +455,7 @@ class StockItem(MPTTModel):
uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field"))
# Note: When a StockItem is deleted, a pre_delete signal handles the parent/child relationship
parent = TreeForeignKey(
'self',
verbose_name=_('Parent Stock Item'),
@ -477,6 +478,7 @@ class StockItem(MPTTModel):
help_text=_('Select a matching supplier part for this stock item')
)
# Note: When a StockLocation is deleted, stock items are updated via a signal
location = TreeForeignKey(
StockLocation, on_delete=models.DO_NOTHING,
verbose_name=_('Stock Location'),
@ -492,10 +494,11 @@ class StockItem(MPTTModel):
help_text=_('Packaging this stock item is stored in')
)
# When deleting a stock item with installed items, those installed items are also installed
belongs_to = models.ForeignKey(
'self',
verbose_name=_('Installed In'),
on_delete=models.DO_NOTHING,
on_delete=models.CASCADE,
related_name='installed_parts', blank=True, null=True,
help_text=_('Is this item installed in another item?')
)
@ -800,14 +803,14 @@ class StockItem(MPTTModel):
def can_delete(self):
""" Can this stock item be deleted? It can NOT be deleted under the following circumstances:
- Has child StockItems
- Has installed stock items
- Has a serial number and is tracked
- Is installed inside another StockItem
- It has been assigned to a SalesOrder
- It has been assigned to a BuildOrder
"""
if self.child_count > 0:
if self.installed_item_count() > 0:
return False
if self.part.trackable and self.serial is not None:
@ -853,20 +856,13 @@ class StockItem(MPTTModel):
return installed
def installedItemCount(self):
def installed_item_count(self):
"""
Return the number of stock items installed inside this one.
"""
return self.installed_parts.count()
def hasInstalledItems(self):
"""
Returns true if this stock item has other stock items installed in it.
"""
return self.installedItemCount() > 0
@transaction.atomic
def installStockItem(self, other_item, quantity, user, notes):
"""
@ -1153,6 +1149,124 @@ class StockItem(MPTTModel):
result.stock_item = self
result.save()
def can_merge(self, other=None, raise_error=False, **kwargs):
"""
Check if this stock item can be merged into another stock item
"""
allow_mismatched_suppliers = kwargs.get('allow_mismatched_suppliers', False)
allow_mismatched_status = kwargs.get('allow_mismatched_status', False)
try:
# Generic checks (do not rely on the 'other' part)
if self.sales_order:
raise ValidationError(_('Stock item has been assigned to a sales order'))
if self.belongs_to:
raise ValidationError(_('Stock item is installed in another item'))
if self.installed_item_count() > 0:
raise ValidationError(_('Stock item contains other items'))
if self.customer:
raise ValidationError(_('Stock item has been assigned to a customer'))
if self.is_building:
raise ValidationError(_('Stock item is currently in production'))
if self.serialized:
raise ValidationError(_("Serialized stock cannot be merged"))
if other:
# Specific checks (rely on the 'other' part)
# Prevent stock item being merged with itself
if self == other:
raise ValidationError(_('Duplicate stock items'))
# Base part must match
if self.part != other.part:
raise ValidationError(_("Stock items must refer to the same part"))
# Check if supplier part references match
if self.supplier_part != other.supplier_part and not allow_mismatched_suppliers:
raise ValidationError(_("Stock items must refer to the same supplier part"))
# Check if stock status codes match
if self.status != other.status and not allow_mismatched_status:
raise ValidationError(_("Stock status codes must match"))
except ValidationError as e:
if raise_error:
raise e
else:
return False
return True
@transaction.atomic
def merge_stock_items(self, other_items, raise_error=False, **kwargs):
"""
Merge another stock item into this one; the two become one!
*This* stock item subsumes the other, which is essentially deleted:
- The quantity of this StockItem is increased
- Tracking history for the *other* item is deleted
- Any allocations (build order, sales order) are moved to this StockItem
"""
if len(other_items) == 0:
return
user = kwargs.get('user', None)
location = kwargs.get('location', None)
notes = kwargs.get('notes', None)
parent_id = self.parent.pk if self.parent else None
for other in other_items:
# If the stock item cannot be merged, return
if not self.can_merge(other, raise_error=raise_error, **kwargs):
return
for other in other_items:
self.quantity += other.quantity
# Any "build order allocations" for the other item must be assigned to this one
for allocation in other.allocations.all():
allocation.stock_item = self
allocation.save()
# Any "sales order allocations" for the other item must be assigned to this one
for allocation in other.sales_order_allocations.all():
allocation.stock_item = self()
allocation.save()
# Prevent atomicity issues when we are merging our own "parent" part in
if parent_id and parent_id == other.pk:
self.parent = None
self.save()
other.delete()
self.add_tracking_entry(
StockHistoryCode.MERGED_STOCK_ITEMS,
user,
quantity=self.quantity,
notes=notes,
deltas={
'location': location.pk,
}
)
self.location = location
self.save()
@transaction.atomic
def splitStock(self, quantity, location, user, **kwargs):
""" Split this stock item into two items, in the same location.
@ -1648,7 +1762,8 @@ class StockItem(MPTTModel):
@receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log')
def before_delete_stock_item(sender, instance, using, **kwargs):
""" Receives pre_delete signal from StockItem object.
"""
Receives pre_delete signal from StockItem object.
Before a StockItem is deleted, ensure that each child object is updated,
to point to the new parent item.

View File

@ -674,6 +674,149 @@ class StockAssignmentSerializer(serializers.Serializer):
)
class StockMergeItemSerializer(serializers.Serializer):
"""
Serializer for a single StockItem within the StockMergeSerializer class.
Here, the individual StockItem is being checked for merge compatibility.
"""
class Meta:
fields = [
'item',
]
item = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Stock Item'),
)
def validate_item(self, item):
# Check that the stock item is able to be merged
item.can_merge(raise_error=True)
return item
class StockMergeSerializer(serializers.Serializer):
"""
Serializer for merging two (or more) stock items together
"""
class Meta:
fields = [
'items',
'location',
'notes',
'allow_mismatched_suppliers',
'allow_mismatched_status',
]
items = StockMergeItemSerializer(
many=True,
required=True,
)
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Location'),
help_text=_('Destination stock location'),
)
notes = serializers.CharField(
required=False,
allow_blank=True,
label=_('Notes'),
help_text=_('Stock merging notes'),
)
allow_mismatched_suppliers = serializers.BooleanField(
required=False,
label=_('Allow mismatched suppliers'),
help_text=_('Allow stock items with different supplier parts to be merged'),
)
allow_mismatched_status = serializers.BooleanField(
required=False,
label=_('Allow mismatched status'),
help_text=_('Allow stock items with different status codes to be merged'),
)
def validate(self, data):
data = super().validate(data)
items = data['items']
if len(items) < 2:
raise ValidationError(_('At least two stock items must be provided'))
unique_items = set()
# The "base item" is the first item
base_item = items[0]['item']
data['base_item'] = base_item
# Ensure stock items are unique!
for element in items:
item = element['item']
if item.pk in unique_items:
raise ValidationError(_('Duplicate stock items'))
unique_items.add(item.pk)
# Checks from here refer to the "base_item"
if item == base_item:
continue
# Check that this item can be merged with the base_item
item.can_merge(
raise_error=True,
other=base_item,
allow_mismatched_suppliers=data.get('allow_mismatched_suppliers', False),
allow_mismatched_status=data.get('allow_mismatched_status', False),
)
return data
def save(self):
"""
Actually perform the stock merging action.
At this point we are confident that the merge can take place
"""
data = self.validated_data
base_item = data['base_item']
items = data['items'][1:]
request = self.context['request']
user = getattr(request, 'user', None)
items = []
for item in data['items'][1:]:
items.append(item['item'])
base_item.merge_stock_items(
items,
allow_mismatched_suppliers=data.get('allow_mismatched_suppliers', False),
allow_mismatched_status=data.get('allow_mismatched_status', False),
user=user,
location=data['location'],
notes=data.get('notes', None)
)
class StockAdjustmentItemSerializer(serializers.Serializer):
"""
Serializer for a single StockItem within a stock adjument request.
@ -837,7 +980,7 @@ class StockTransferSerializer(StockAdjustmentSerializer):
def validate(self, data):
super().validate(data)
data = super().validate(data)
# TODO: Any specific validation of location field?

View File

@ -274,14 +274,6 @@
<div class='alert alert-block alert-warning'>
{% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %}
</div>
{% elif item.child_count > 0 %}
<div class='alert alert-block alert-warning'>
{% trans "This stock item cannot be deleted as it has child items" %}
</div>
{% elif item.delete_on_deplete and item.can_delete %}
<div class='alert alert-block alert-warning'>
{% trans "This stock item will be automatically deleted when all stock is depleted." %}
</div>
{% endif %}
</div>

View File

@ -842,3 +842,189 @@ class StockAssignTest(StockAPITestCase):
# 5 stock items should now have been assigned to this customer
self.assertEqual(customer.assigned_stock.count(), 5)
class StockMergeTest(StockAPITestCase):
"""
Unit tests for merging stock items via the API
"""
URL = reverse('api-stock-merge')
def setUp(self):
super().setUp()
self.part = part.models.Part.objects.get(pk=25)
self.loc = StockLocation.objects.get(pk=1)
self.sp_1 = company.models.SupplierPart.objects.get(pk=100)
self.sp_2 = company.models.SupplierPart.objects.get(pk=101)
self.item_1 = StockItem.objects.create(
part=self.part,
supplier_part=self.sp_1,
quantity=100,
)
self.item_2 = StockItem.objects.create(
part=self.part,
supplier_part=self.sp_2,
quantity=100,
)
self.item_3 = StockItem.objects.create(
part=self.part,
supplier_part=self.sp_2,
quantity=50,
)
def test_missing_data(self):
"""
Test responses which are missing required data
"""
# Post completely empty
data = self.post(
self.URL,
{},
expected_code=400
).data
self.assertIn('This field is required', str(data['items']))
self.assertIn('This field is required', str(data['location']))
# Post with a location and empty items list
data = self.post(
self.URL,
{
'items': [],
'location': 1,
},
expected_code=400
).data
self.assertIn('At least two stock items', str(data))
def test_invalid_data(self):
"""
Test responses which have invalid data
"""
# Serialized stock items should be rejected
data = self.post(
self.URL,
{
'items': [
{
'item': 501,
},
{
'item': 502,
}
],
'location': 1,
},
expected_code=400,
).data
self.assertIn('Serialized stock cannot be merged', str(data))
# Prevent item duplication
data = self.post(
self.URL,
{
'items': [
{
'item': 11,
},
{
'item': 11,
}
],
'location': 1,
},
expected_code=400,
).data
self.assertIn('Duplicate stock items', str(data))
# Check for mismatching stock items
data = self.post(
self.URL,
{
'items': [
{
'item': 1234,
},
{
'item': 11,
}
],
'location': 1,
},
expected_code=400,
).data
self.assertIn('Stock items must refer to the same part', str(data))
# Check for mismatching supplier parts
payload = {
'items': [
{
'item': self.item_1.pk,
},
{
'item': self.item_2.pk,
},
],
'location': 1,
}
data = self.post(
self.URL,
payload,
expected_code=400,
).data
self.assertIn('Stock items must refer to the same supplier part', str(data))
def test_valid_merge(self):
"""
Test valid merging of stock items
"""
# Check initial conditions
n = StockItem.objects.filter(part=self.part).count()
self.assertEqual(self.item_1.quantity, 100)
payload = {
'items': [
{
'item': self.item_1.pk,
},
{
'item': self.item_2.pk,
},
{
'item': self.item_3.pk,
},
],
'location': 1,
'allow_mismatched_suppliers': True,
}
self.post(
self.URL,
payload,
expected_code=201,
)
self.item_1.refresh_from_db()
# Stock quantity should have been increased!
self.assertEqual(self.item_1.quantity, 250)
# Total number of stock items has been reduced!
self.assertEqual(StockItem.objects.filter(part=self.part).count(), n - 2)

View File

@ -16,12 +16,6 @@
<div class='panel panel-inventree'>
<div class='panel-content'>
{% include "search_form.html" with query_text=query %}
{% if query %}
{% else %}
<div id='empty-search-query'>
<h4><em>{% trans "Enter a search query" %}</em></h4>
</div>
{% endif %}
</div>
</div>

View File

@ -2,7 +2,7 @@
<div id='attachment-buttons'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="related" %}
{% include "filter_list.html" with id="attachments" %}
</div>
</div>

View File

@ -207,6 +207,11 @@ function showApiError(xhr, url) {
title = '{% trans "Error 404: Resource Not Found" %}';
message = '{% trans "The requested resource could not be located on the server" %}';
break;
// Method not allowed
case 405:
title = '{% trans "Error 405: Method Not Allowed" %}';
message = '{% trans "HTTP method not allowed at URL" %}';
break;
// Timeout
case 408:
title = '{% trans "Error 408: Timeout" %}';

View File

@ -67,6 +67,8 @@ function loadAttachmentTable(url, options) {
var table = options.table || '#attachment-table';
setupFilterList('attachments', $(table), '#filter-list-attachments');
addAttachmentButtonCallbacks(url, options.fields || {});
$(table).inventreeTable({

View File

@ -661,7 +661,7 @@ function loadBomTable(table, options={}) {
if (!row.inherited) {
return yesNoLabel(false);
} else if (row.part == options.parent_id) {
return '{% trans "Inherited" %}';
return yesNoLabel(true);
} else {
// If this BOM item is inherited from a parent part
return renderLink(

View File

@ -380,6 +380,7 @@ function loadCompanyTable(table, url, options={}) {
url: url,
method: 'get',
queryParams: filters,
original: params,
groupBy: false,
sidePagination: 'server',
formatNoMatches: function() {
@ -463,7 +464,9 @@ function loadManufacturerPartTable(table, url, options) {
filters[key] = params[key];
}
setupFilterList('manufacturer-part', $(table));
var filterTarget = options.filterTarget || '#filter-list-manufacturer-part';
setupFilterList('manufacturer-part', $(table), filterTarget);
$(table).inventreeTable({
url: url,

View File

@ -21,6 +21,7 @@
*/
/* exported
duplicateBom,
duplicatePart,
editCategory,
editPart,
@ -39,6 +40,7 @@
loadStockPricingChart,
partStockLabel,
toggleStar,
validateBom,
*/
/* Part API functions
@ -428,6 +430,59 @@ function toggleStar(options) {
}
/* Validate a BOM */
function validateBom(part_id, options={}) {
var html = `
<div class='alert alert-block alert-success'>
{% trans "Validating the BOM will mark each line item as valid" %}
</div>
`;
constructForm(`/api/part/${part_id}/bom-validate/`, {
method: 'PUT',
fields: {
valid: {},
},
preFormContent: html,
title: '{% trans "Validate Bill of Materials" %}',
reload: options.reload,
onSuccess: function(response) {
showMessage('{% trans "Validated Bill of Materials" %}');
}
});
}
/* Duplicate a BOM */
function duplicateBom(part_id, options={}) {
constructForm(`/api/part/${part_id}/bom-copy/`, {
method: 'POST',
fields: {
part: {
icon: 'fa-shapes',
filters: {
assembly: true,
exclude_tree: part_id,
}
},
include_inherited: {},
remove_existing: {},
skip_invalid: {},
},
confirm: true,
title: '{% trans "Copy Bill of Materials" %}',
onSuccess: function(response) {
if (options.success) {
options.success(response);
}
},
});
}
function partStockLabel(part, options={}) {
if (part.in_stock) {
@ -621,7 +676,9 @@ function loadPartParameterTable(table, url, options) {
filters[key] = params[key];
}
// setupFilterList("#part-parameters", $(table));
var filterTarget = options.filterTarget || '#filter-list-parameters';
setupFilterList('part-parameters', $(table), filterTarget);
$(table).inventreeTable({
url: url,
@ -727,7 +784,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
options.params.part_detail = true;
options.params.order_detail = true;
var filters = loadTableFilters('partpurchaseorders');
var filters = loadTableFilters('purchaseorderlineitem');
for (var key in options.params) {
filters[key] = options.params[key];
@ -871,7 +928,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
if (row.received >= row.quantity) {
// Already recevied
return `<span class='badge bg-success rounded-pill'>{% trans "Received" %}</span>`;
} else {
} else if (row.order_detail && row.order_detail.status == {{ PurchaseOrderStatus.PLACED }}) {
var html = `<div class='btn-group' role='group'>`;
var pk = row.pk;
@ -879,6 +936,8 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
html += `</div>`;
return html;
} else {
return '';
}
}
}

View File

@ -52,6 +52,7 @@
loadStockTestResultsTable,
loadStockTrackingTable,
loadTableFilters,
mergeStockItems,
removeStockRow,
serializeStockItem,
stockItemFields,
@ -595,17 +596,17 @@ function assignStockToCustomer(items, options={}) {
buttons += '</div>';
html += `
<tr id='stock_item_${pk}' class='stock-item'row'>
<td id='part_${pk}'>${thumbnail} ${part.full_name}</td>
<td id='stock_${pk}'>
<div id='div_id_items_item_${pk}'>
${quantity}
<div id='errors-items_item_${pk}'></div>
</div>
</td>
<td id='location_${pk}'>${location}</td>
<td id='buttons_${pk}'>${buttons}</td>
</tr>
<tr id='stock_item_${pk}' class='stock-item-row'>
<td id='part_${pk}'>${thumbnail} ${part.full_name}</td>
<td id='stock_${pk}'>
<div id='div_id_items_item_${pk}'>
${quantity}
<div id='errors-items_item_${pk}'></div>
</div>
</td>
<td id='location_${pk}'>${location}</td>
<td id='buttons_${pk}'>${buttons}</td>
</tr>
`;
}
@ -615,13 +616,13 @@ function assignStockToCustomer(items, options={}) {
method: 'POST',
preFormContent: html,
fields: {
'customer': {
customer: {
value: options.customer,
filters: {
is_customer: true,
},
},
'notes': {},
notes: {},
},
confirm: true,
confirmMessage: '{% trans "Confirm stock assignment" %}',
@ -694,6 +695,184 @@ function assignStockToCustomer(items, options={}) {
}
/**
* Merge multiple stock items together
*/
function mergeStockItems(items, options={}) {
// Generate HTML content for the form
var html = `
<div class='alert alert-block alert-danger'>
<h5>{% trans "Warning: Merge operation cannot be reversed" %}</h5>
<strong>{% trans "Some information will be lost when merging stock items" %}:</strong>
<ul>
<li>{% trans "Stock transaction history will be deleted for merged items" %}</li>
<li>{% trans "Supplier part information will be deleted for merged items" %}</li>
</ul>
</div>
`;
html += `
<table class='table table-striped table-condensed' id='stock-merge-table'>
<thead>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "Stock Item" %}</th>
<th>{% trans "Location" %}</th>
<th></th>
</tr>
</thead>
<tbody>
`;
// Keep track of how many "locations" there are
var locations = [];
for (var idx = 0; idx < items.length; idx++) {
var item = items[idx];
var pk = item.pk;
if (item.location && !locations.includes(item.location)) {
locations.push(item.location);
}
var part = item.part_detail;
var location = locationDetail(item, false);
var thumbnail = thumbnailImage(part.thumbnail || part.image);
var quantity = '';
if (item.serial && item.quantity == 1) {
quantity = `{% trans "Serial" %}: ${item.serial}`;
} else {
quantity = `{% trans "Quantity" %}: ${item.quantity}`;
}
quantity += stockStatusDisplay(item.status, {classes: 'float-right'});
var buttons = `<div class='btn-group' role='group'>`;
buttons += makeIconButton(
'fa-times icon-red',
'button-stock-item-remove',
pk,
'{% trans "Remove row" %}',
);
html += `
<tr id='stock_item_${pk}' class='stock-item-row'>
<td id='part_${pk}'>${thumbnail} ${part.full_name}</td>
<td id='stock_${pk}'>
<div id='div_id_items_item_${pk}'>
${quantity}
<div id='errors-items_item_${pk}'></div>
</div>
</td>
<td id='location_${pk}'>${location}</td>
<td id='buttons_${pk}'>${buttons}</td>
</tr>
`;
}
html += '</tbody></table>';
var location = locations.length == 1 ? locations[0] : null;
constructForm('{% url "api-stock-merge" %}', {
method: 'POST',
preFormContent: html,
fields: {
location: {
value: location,
icon: 'fa-sitemap',
},
notes: {},
allow_mismatched_suppliers: {},
allow_mismatched_status: {},
},
confirm: true,
confirmMessage: '{% trans "Confirm stock item merge" %}',
title: '{% trans "Merge Stock Items" %}',
afterRender: function(fields, opts) {
// Add button callbacks to remove rows
$(opts.modal).find('.button-stock-item-remove').click(function() {
var pk = $(this).attr('pk');
$(opts.modal).find(`#stock_item_${pk}`).remove();
});
},
onSubmit: function(fields, opts) {
// Extract data elements from the form
var data = {
items: [],
};
var item_pk_values = [];
items.forEach(function(item) {
var pk = item.pk;
// Does the row still exist in the form?
var row = $(opts.modal).find(`#stock_item_${pk}`);
if (row.exists()) {
item_pk_values.push(pk);
data.items.push({
item: pk,
});
}
});
var extra_fields = [
'location',
'notes',
'allow_mismatched_suppliers',
'allow_mismatched_status',
];
extra_fields.forEach(function(field) {
data[field] = getFormFieldValue(field, fields[field], opts);
});
opts.nested = {
'items': item_pk_values
};
// Submit the form data
inventreePut(
'{% url "api-stock-merge" %}',
data,
{
method: 'POST',
success: function(response) {
$(opts.modal).modal('hide');
if (options.success) {
options.success(response);
}
},
error: function(xhr) {
switch (xhr.status) {
case 400:
handleFormErrors(xhr.responseJSON, fields, opts);
break;
default:
$(opts.modal).modal('hide');
showApiError(xhr, opts.url);
break;
}
}
}
);
}
});
}
/**
* Perform stock adjustments
*/
@ -1289,7 +1468,7 @@ function loadStockTable(table, options) {
var params = options.params || {};
var filterListElement = options.filterList || '#filter-list-stock';
var filterTarget = options.filterTarget || '#filter-list-stock';
var filters = {};
@ -1305,7 +1484,7 @@ function loadStockTable(table, options) {
original[k] = params[k];
}
setupFilterList(filterKey, table, filterListElement);
setupFilterList(filterKey, table, filterTarget);
// Override the default values, or add new ones
for (var key in params) {
@ -1458,7 +1637,7 @@ function loadStockTable(table, options) {
}
if (row.quantity <= 0) {
html += `<span class='badge rounded-pill bg-danger'>{% trans "Depleted" %}</span>`;
html += `<span class='badge badge-right rounded-pill bg-danger'>{% trans "Depleted" %}</span>`;
}
return html;
@ -1875,6 +2054,20 @@ function loadStockTable(table, options) {
stockAdjustment('move');
});
$('#multi-item-merge').click(function() {
var items = $(table).bootstrapTable('getSelections');
mergeStockItems(items, {
success: function(response) {
$(table).bootstrapTable('refresh');
showMessage('{% trans "Merged stock items" %}', {
style: 'success',
});
}
});
});
$('#multi-item-assign').click(function() {
var items = $(table).bootstrapTable('getSelections');

View File

@ -381,6 +381,24 @@ function getAvailableTableFilters(tableKey) {
};
}
// Filters for "company" table
if (tableKey == 'company') {
return {
is_manufacturer: {
type: 'bool',
title: '{% trans "Manufacturer" %}',
},
is_supplier: {
type: 'bool',
title: '{% trans "Supplier" %}',
},
is_customer: {
type: 'bool',
title: '{% trans "Customer" %}',
},
};
}
// Filters for the "Parts" table
if (tableKey == 'parts') {
return {

View File

@ -49,6 +49,7 @@
<li><a class='dropdown-item' href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'><span class='fas fa-minus-circle'></span> {% trans "Remove stock" %}</a></li>
<li><a class='dropdown-item' href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-merge' title='{% trans "Merge selected stock items" %}'><span class='fas fa-object-group'></span> {% trans "Merge stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-assign' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li>