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
4f3e955261
@ -2,11 +2,18 @@
|
||||
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 74
|
||||
INVENTREE_API_VERSION = 76
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v76 -> 2022-09-10 : https://github.com/inventree/InvenTree/pull/3640
|
||||
- Refactor of barcode data on the API
|
||||
- StockItem.uid renamed to StockItem.barcode_hash
|
||||
|
||||
v75 -> 2022-09-05 : https://github.com/inventree/InvenTree/pull/3644
|
||||
- Adds "pack_size" attribute to SupplierPart API serializer
|
||||
|
||||
v74 -> 2022-08-28 : https://github.com/inventree/InvenTree/pull/3615
|
||||
- Add confirmation field for completing PurchaseOrder if the order has incomplete lines
|
||||
- Add confirmation field for completing SalesOrder if the order has incomplete lines
|
||||
|
@ -165,11 +165,8 @@ class RoundingDecimalField(models.DecimalField):
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
"""Return a Field instance for this field."""
|
||||
defaults = {
|
||||
'form_class': RoundingDecimalFormField
|
||||
}
|
||||
|
||||
defaults.update(kwargs)
|
||||
kwargs['form_class'] = RoundingDecimalFormField
|
||||
|
||||
return super().formfield(**kwargs)
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""Provides helper functions used throughout the InvenTree project."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
@ -907,6 +908,23 @@ def remove_non_printable_characters(value: str, remove_ascii=True, remove_unicod
|
||||
return cleaned
|
||||
|
||||
|
||||
def hash_barcode(barcode_data):
|
||||
"""Calculate a 'unique' hash for a barcode string.
|
||||
|
||||
This hash is used for comparison / lookup.
|
||||
|
||||
We first remove any non-printable characters from the barcode data,
|
||||
as some browsers have issues scanning characters in.
|
||||
"""
|
||||
|
||||
barcode_data = str(barcode_data).strip()
|
||||
barcode_data = remove_non_printable_characters(barcode_data)
|
||||
|
||||
hash = hashlib.md5(str(barcode_data).encode())
|
||||
|
||||
return str(hash.hexdigest())
|
||||
|
||||
|
||||
def get_objectreference(obj, type_ref: str = 'content_type', object_ref: str = 'object_id'):
|
||||
"""Lookup method for the GenericForeignKey fields.
|
||||
|
||||
|
@ -636,6 +636,103 @@ class InvenTreeTree(MPTTModel):
|
||||
return "{path} - {desc}".format(path=self.pathstring, desc=self.description)
|
||||
|
||||
|
||||
class InvenTreeBarcodeMixin(models.Model):
|
||||
"""A mixin class for adding barcode functionality to a model class.
|
||||
|
||||
Two types of barcodes are supported:
|
||||
|
||||
- Internal barcodes (QR codes using a strictly defined format)
|
||||
- External barcodes (assign third party barcode data to a model instance)
|
||||
|
||||
The following fields are added to any model which implements this mixin:
|
||||
|
||||
- barcode_data : Raw data associated with an assigned barcode
|
||||
- barcode_hash : A 'hash' of the assigned barcode data used to improve matching
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options for this mixin.
|
||||
|
||||
Note: abstract must be true, as this is only a mixin, not a separate table
|
||||
"""
|
||||
abstract = True
|
||||
|
||||
barcode_data = models.CharField(
|
||||
blank=True, max_length=500,
|
||||
verbose_name=_('Barcode Data'),
|
||||
help_text=_('Third party barcode data'),
|
||||
)
|
||||
|
||||
barcode_hash = models.CharField(
|
||||
blank=True, max_length=128,
|
||||
verbose_name=_('Barcode Hash'),
|
||||
help_text=_('Unique hash of barcode data')
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def barcode_model_type(cls):
|
||||
"""Return the model 'type' for creating a custom QR code."""
|
||||
|
||||
# By default, use the name of the class
|
||||
return cls.__name__.lower()
|
||||
|
||||
def format_barcode(self, **kwargs):
|
||||
"""Return a JSON string for formatting a QR code for this model instance."""
|
||||
|
||||
return InvenTree.helpers.MakeBarcode(
|
||||
self.__class__.barcode_model_type(),
|
||||
self.pk,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@property
|
||||
def barcode(self):
|
||||
"""Format a minimal barcode string (e.g. for label printing)"""
|
||||
|
||||
return self.format_barcode(brief=True)
|
||||
|
||||
@classmethod
|
||||
def lookup_barcode(cls, barcode_hash):
|
||||
"""Check if a model instance exists with the specified third-party barcode hash."""
|
||||
|
||||
return cls.objects.filter(barcode_hash=barcode_hash).first()
|
||||
|
||||
def assign_barcode(self, barcode_hash=None, barcode_data=None, raise_error=True):
|
||||
"""Assign an external (third-party) barcode to this object."""
|
||||
|
||||
# Must provide either barcode_hash or barcode_data
|
||||
if barcode_hash is None and barcode_data is None:
|
||||
raise ValueError("Provide either 'barcode_hash' or 'barcode_data'")
|
||||
|
||||
# If barcode_hash is not provided, create from supplier barcode_data
|
||||
if barcode_hash is None:
|
||||
barcode_hash = InvenTree.helpers.hash_barcode(barcode_data)
|
||||
|
||||
# Check for existing item
|
||||
if self.__class__.lookup_barcode(barcode_hash) is not None:
|
||||
if raise_error:
|
||||
raise ValidationError(_("Existing barcode found"))
|
||||
else:
|
||||
return False
|
||||
|
||||
if barcode_data is not None:
|
||||
self.barcode_data = barcode_data
|
||||
|
||||
self.barcode_hash = barcode_hash
|
||||
|
||||
self.save()
|
||||
|
||||
return True
|
||||
|
||||
def unassign_barcode(self):
|
||||
"""Unassign custom barcode from this model"""
|
||||
|
||||
self.barcode_data = ''
|
||||
self.barcode_hash = ''
|
||||
|
||||
self.save()
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=InvenTreeTree, dispatch_uid='tree_pre_delete_log')
|
||||
def before_delete_tree_item(sender, instance, using, **kwargs):
|
||||
"""Receives pre_delete signal from InvenTreeTree object.
|
||||
|
@ -605,6 +605,7 @@ LANGUAGE_CODE = get_setting('INVENTREE_LANGUAGE', 'language', 'en-us')
|
||||
# If a new language translation is supported, it must be added here
|
||||
LANGUAGES = [
|
||||
('cs', _('Czech')),
|
||||
('da', _('Danish')),
|
||||
('de', _('German')),
|
||||
('el', _('Greek')),
|
||||
('en', _('English')),
|
||||
|
@ -140,6 +140,8 @@ function inventreeDocReady() {
|
||||
// start watcher
|
||||
startNotificationWatcher();
|
||||
|
||||
attachClipboard('.clip-btn');
|
||||
|
||||
// always refresh when the focus returns
|
||||
$(document).focus(function(){
|
||||
startNotificationWatcher();
|
||||
|
@ -19,6 +19,7 @@ from djmoney.contrib.exchange.models import Rate, convert_money
|
||||
from djmoney.money import Money
|
||||
|
||||
import InvenTree.format
|
||||
import InvenTree.helpers
|
||||
import InvenTree.tasks
|
||||
from common.models import InvenTreeSetting
|
||||
from common.settings import currency_codes
|
||||
@ -848,3 +849,32 @@ class TestOffloadTask(helpers.InvenTreeTestCase):
|
||||
1, 2, 3, 4, 5,
|
||||
force_async=True
|
||||
)
|
||||
|
||||
|
||||
class BarcodeMixinTest(helpers.InvenTreeTestCase):
|
||||
"""Tests for the InvenTreeBarcodeMixin mixin class"""
|
||||
|
||||
def test_barcode_model_type(self):
|
||||
"""Test that the barcode_model_type property works for each class"""
|
||||
|
||||
from part.models import Part
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
self.assertEqual(Part.barcode_model_type(), 'part')
|
||||
self.assertEqual(StockItem.barcode_model_type(), 'stockitem')
|
||||
self.assertEqual(StockLocation.barcode_model_type(), 'stocklocation')
|
||||
|
||||
def test_bacode_hash(self):
|
||||
"""Test that the barcode hashing function provides correct results"""
|
||||
|
||||
# Test multiple values for the hashing function
|
||||
# This is to ensure that the hash function is always "backwards compatible"
|
||||
hashing_tests = {
|
||||
'abcdefg': '7ac66c0f148de9519b8bd264312c4d64',
|
||||
'ABCDEFG': 'bb747b3df3130fe1ca4afa93fb7d97c9',
|
||||
'1234567': 'fcea920f7412b5da7be0cf42b8c93759',
|
||||
'{"part": 17, "stockitem": 12}': 'c88c11ed0628eb7fef0d59b098b96975',
|
||||
}
|
||||
|
||||
for barcode, hash in hashing_tests.items():
|
||||
self.assertEqual(InvenTree.helpers.hash_barcode(barcode), hash)
|
||||
|
@ -22,7 +22,7 @@ from mptt.exceptions import InvalidMove
|
||||
from rest_framework import serializers
|
||||
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
|
||||
from InvenTree.helpers import increment, normalize, MakeBarcode, notify_responsible
|
||||
from InvenTree.helpers import increment, normalize, notify_responsible
|
||||
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
||||
|
||||
from build.validators import generate_next_build_reference, validate_build_order_reference
|
||||
@ -110,17 +110,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
verbose_name = _("Build Order")
|
||||
verbose_name_plural = _("Build Orders")
|
||||
|
||||
def format_barcode(self, **kwargs):
|
||||
"""Return a JSON string to represent this build as a barcode."""
|
||||
return MakeBarcode(
|
||||
"buildorder",
|
||||
self.pk,
|
||||
{
|
||||
"reference": self.title,
|
||||
"url": self.get_absolute_url(),
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def filterByDate(queryset, min_date, max_date):
|
||||
"""Filter by 'minimum and maximum date range'.
|
||||
|
@ -8,6 +8,7 @@ from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import filters
|
||||
|
||||
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
|
||||
|
||||
@ -338,12 +339,30 @@ class SupplierPartList(ListCreateDestroyAPIView):
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
InvenTreeOrderingFilter,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
]
|
||||
|
||||
ordering_fields = [
|
||||
'SKU',
|
||||
'part',
|
||||
'supplier',
|
||||
'manufacturer',
|
||||
'MPN',
|
||||
'packaging',
|
||||
'pack_size',
|
||||
'in_stock',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
'part': 'part__name',
|
||||
'supplier': 'supplier__name',
|
||||
'manufacturer': 'manufacturer_part__manufacturer__name',
|
||||
'MPN': 'manufacturer_part__MPN',
|
||||
}
|
||||
|
||||
search_fields = [
|
||||
'SKU',
|
||||
'supplier__name',
|
||||
|
20
InvenTree/company/migrations/0047_supplierpart_pack_size.py
Normal file
20
InvenTree/company/migrations/0047_supplierpart_pack_size.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.2.15 on 2022-09-05 04:21
|
||||
|
||||
import InvenTree.fields
|
||||
import django.core.validators
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0046_alter_company_image'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='supplierpart',
|
||||
name='pack_size',
|
||||
field=InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Unit quantity supplied in a single pack', max_digits=15, validators=[django.core.validators.MinValueValidator(0.001)], verbose_name='Pack Quantity'),
|
||||
),
|
||||
]
|
23
InvenTree/company/migrations/0048_auto_20220913_0312.py
Normal file
23
InvenTree/company/migrations/0048_auto_20220913_0312.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.15 on 2022-09-13 03:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0047_supplierpart_pack_size'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='supplierpart',
|
||||
name='barcode_data',
|
||||
field=models.CharField(blank=True, help_text='Third party barcode data', max_length=500, verbose_name='Barcode Data'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='supplierpart',
|
||||
name='barcode_hash',
|
||||
field=models.CharField(blank=True, help_text='Unique hash of barcode data', max_length=128, verbose_name='Barcode Hash'),
|
||||
),
|
||||
]
|
@ -20,8 +20,8 @@ import InvenTree.fields
|
||||
import InvenTree.helpers
|
||||
import InvenTree.validators
|
||||
from common.settings import currency_code_default
|
||||
from InvenTree.fields import InvenTreeURLField
|
||||
from InvenTree.models import InvenTreeAttachment
|
||||
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
|
||||
from InvenTree.models import InvenTreeAttachment, InvenTreeBarcodeMixin
|
||||
from InvenTree.status_codes import PurchaseOrderStatus
|
||||
|
||||
|
||||
@ -391,7 +391,7 @@ class SupplierPartManager(models.Manager):
|
||||
)
|
||||
|
||||
|
||||
class SupplierPart(models.Model):
|
||||
class SupplierPart(InvenTreeBarcodeMixin, models.Model):
|
||||
"""Represents a unique part as provided by a Supplier Each SupplierPart is identified by a SKU (Supplier Part Number) Each SupplierPart is also linked to a Part or ManufacturerPart object. A Part may be available from multiple suppliers.
|
||||
|
||||
Attributes:
|
||||
@ -406,6 +406,7 @@ class SupplierPart(models.Model):
|
||||
multiple: Multiple that the part is provided in
|
||||
lead_time: Supplier lead time
|
||||
packaging: packaging that the part is supplied in, e.g. "Reel"
|
||||
pack_size: Quantity of item supplied in a single pack (e.g. 30ml in a single tube)
|
||||
"""
|
||||
|
||||
objects = SupplierPartManager()
|
||||
@ -527,6 +528,14 @@ class SupplierPart(models.Model):
|
||||
|
||||
packaging = models.CharField(max_length=50, blank=True, null=True, verbose_name=_('Packaging'), help_text=_('Part packaging'))
|
||||
|
||||
pack_size = RoundingDecimalField(
|
||||
verbose_name=_('Pack Quantity'),
|
||||
help_text=_('Unit quantity supplied in a single pack'),
|
||||
default=1,
|
||||
max_digits=15, decimal_places=5,
|
||||
validators=[MinValueValidator(0.001)],
|
||||
)
|
||||
|
||||
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Order multiple'))
|
||||
|
||||
# TODO - Reimplement lead-time as a charfield with special validation (pattern matching).
|
||||
|
@ -239,6 +239,8 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
|
||||
pretty_name = serializers.CharField(read_only=True)
|
||||
|
||||
pack_size = serializers.FloatField(label=_('Pack Quantity'))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize this serializer with extra detail fields as required"""
|
||||
|
||||
@ -273,6 +275,8 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
|
||||
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', read_only=True)
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
@ -291,12 +295,14 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
'note',
|
||||
'pk',
|
||||
'packaging',
|
||||
'pack_size',
|
||||
'part',
|
||||
'part_detail',
|
||||
'pretty_name',
|
||||
'SKU',
|
||||
'supplier',
|
||||
'supplier_detail',
|
||||
'url',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
|
@ -30,6 +30,22 @@
|
||||
{% url 'admin:company_supplierpart_change' part.pk as url %}
|
||||
{% include "admin_button.html" with url=url %}
|
||||
{% endif %}
|
||||
{% if barcodes %}
|
||||
<!-- Barcode actions menu -->
|
||||
<div class='btn-group' role='group'>
|
||||
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
|
||||
<span class='fas fa-qrcode'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||
{% if part.barcode_hash %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-unlink'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
|
||||
{% else %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-link'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if roles.purchase_order.change or roles.purchase_order.add or roles.purchase_order.delete %}
|
||||
<div class='btn-group'>
|
||||
<button id='supplier-part-actions' title='{% trans "Supplier part actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
|
||||
@ -49,6 +65,11 @@
|
||||
<span class='fas fa-edit icon-green'></span> {% trans "Edit Supplier Part" %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
{% if roles.purchase_order.add %}
|
||||
<li><a class='dropdown-item' href='#' id='duplicate-part' title='{% trans "Duplicate Supplier Part" %}'>
|
||||
<span class='fas fa-clone'></span> {% trans "Duplicate Supplier Part" %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
{% if roles.purchase_order.delete %}
|
||||
<li><a class='dropdown-item' href='#' id='delete-part' title='{% trans "Delete Supplier Part" %}'>
|
||||
<span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Supplier Part" %}
|
||||
@ -95,6 +116,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{% decimal part.available %}<span class='badge bg-dark rounded-pill float-right'>{% render_date part.availability_updated %}</span></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if part.barcode_hash %}
|
||||
<tr>
|
||||
<td><span class='fas fa-barcode'></span></td>
|
||||
<td>{% trans "Barcode Identifier" %}</td>
|
||||
<td {% if part.barcode_data %}title='{{ part.barcode_data }}'{% endif %}>{{ part.barcode_hash }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
{% endblock details %}
|
||||
@ -140,6 +168,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{{ part.packaging }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if part.pack_size != 1.0 %}
|
||||
<tr>
|
||||
<td><span class='fas fa-box'></span></td>
|
||||
<td>{% trans "Pack Quantity" %}</td>
|
||||
<td>{% decimal part.pack_size %} {% include "part/part_units.html" with part=part.part %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if part.note %}
|
||||
<tr>
|
||||
<td><span class='fas fa-sticky-note'></span></td>
|
||||
@ -229,6 +264,33 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
{% if barcodes %}
|
||||
|
||||
$("#show-qr-code").click(function() {
|
||||
launchModalForm("{% url 'supplier-part-qr' part.pk %}",
|
||||
{
|
||||
no_post: true,
|
||||
});
|
||||
});
|
||||
|
||||
$("#barcode-link").click(function() {
|
||||
linkBarcodeDialog(
|
||||
{
|
||||
supplierpart: {{ part.pk }},
|
||||
},
|
||||
{
|
||||
title: '{% trans "Link Barcode to Supplier Part" %}',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#barcode-unlink").click(function() {
|
||||
unlinkBarcode({
|
||||
supplierpart: {{ part.pk }},
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
function reloadPriceBreaks() {
|
||||
$("#price-break-table").bootstrapTable("refresh");
|
||||
}
|
||||
@ -386,6 +448,12 @@ $('#update-part-availability').click(function() {
|
||||
});
|
||||
});
|
||||
|
||||
$('#duplicate-part').click(function() {
|
||||
duplicateSupplierPart({{ part.pk }}, {
|
||||
follow: true
|
||||
});
|
||||
});
|
||||
|
||||
$('#edit-part').click(function () {
|
||||
|
||||
editSupplierPart({{ part.pk }}, {
|
||||
|
@ -25,5 +25,10 @@ manufacturer_part_urls = [
|
||||
]
|
||||
|
||||
supplier_part_urls = [
|
||||
re_path(r'^(?P<pk>\d+)/', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'),
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path('^qr_code/?', views.SupplierPartQRCode.as_view(), name='supplier-part-qr'),
|
||||
re_path('^.*$', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'),
|
||||
]))
|
||||
|
||||
|
||||
]
|
||||
|
@ -4,7 +4,7 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
from InvenTree.views import InvenTreeRoleMixin, QRCodeView
|
||||
from plugin.views import InvenTreePluginViewMixin
|
||||
|
||||
from .models import Company, ManufacturerPart, SupplierPart
|
||||
@ -112,3 +112,18 @@ class SupplierPartDetail(InvenTreePluginViewMixin, DetailView):
|
||||
context_object_name = 'part'
|
||||
queryset = SupplierPart.objects.all()
|
||||
permission_required = 'purchase_order.view'
|
||||
|
||||
|
||||
class SupplierPartQRCode(QRCodeView):
|
||||
"""View for displaying a QR code for a StockItem object."""
|
||||
|
||||
ajax_form_title = _("Stock Item QR Code")
|
||||
role_required = 'stock.view'
|
||||
|
||||
def get_qr_data(self):
|
||||
"""Generate QR code data for the StockItem."""
|
||||
try:
|
||||
part = SupplierPart.objects.get(id=self.pk)
|
||||
return part.format_barcode()
|
||||
except SupplierPart.DoesNotExist:
|
||||
return None
|
||||
|
@ -249,7 +249,8 @@ class StockItemLabel(LabelTemplate):
|
||||
'revision': stock_item.part.revision,
|
||||
'quantity': normalize(stock_item.quantity),
|
||||
'serial': stock_item.serial,
|
||||
'uid': stock_item.uid,
|
||||
'barcode_data': stock_item.barcode_data,
|
||||
'barcode_hash': stock_item.barcode_hash,
|
||||
'qr_data': stock_item.format_barcode(brief=True),
|
||||
'qr_url': stock_item.format_barcode(url=True, request=request),
|
||||
'tests': stock_item.testResultMap(),
|
||||
|
File diff suppressed because it is too large
Load Diff
10694
InvenTree/locale/da/LC_MESSAGES/django.po
Normal file
10694
InvenTree/locale/da/LC_MESSAGES/django.po
Normal file
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
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
@ -5,6 +5,7 @@
|
||||
pk: 1
|
||||
fields:
|
||||
reference: 'PO-0001'
|
||||
reference_int: 1
|
||||
description: "Ordering some screws"
|
||||
supplier: 1
|
||||
status: 10 # Pending
|
||||
@ -14,6 +15,7 @@
|
||||
pk: 2
|
||||
fields:
|
||||
reference: 'PO-0002'
|
||||
reference_int: 2
|
||||
description: "Ordering some more screws"
|
||||
supplier: 3
|
||||
status: 10 # Pending
|
||||
@ -22,6 +24,7 @@
|
||||
pk: 3
|
||||
fields:
|
||||
reference: 'PO-0003'
|
||||
reference_int: 3
|
||||
description: 'Another PO'
|
||||
supplier: 3
|
||||
status: 20 # Placed
|
||||
@ -30,6 +33,7 @@
|
||||
pk: 4
|
||||
fields:
|
||||
reference: 'PO-0004'
|
||||
reference_int: 4
|
||||
description: 'Another PO'
|
||||
supplier: 3
|
||||
status: 20 # Placed
|
||||
@ -38,6 +42,7 @@
|
||||
pk: 5
|
||||
fields:
|
||||
reference: 'PO-0005'
|
||||
reference_int: 5
|
||||
description: 'Another PO'
|
||||
supplier: 3
|
||||
status: 30 # Complete
|
||||
@ -46,6 +51,7 @@
|
||||
pk: 6
|
||||
fields:
|
||||
reference: 'PO-0006'
|
||||
reference_int: 6
|
||||
description: 'Another PO'
|
||||
supplier: 3
|
||||
status: 40 # Cancelled
|
||||
@ -55,6 +61,7 @@
|
||||
pk: 7
|
||||
fields:
|
||||
reference: 'PO-0007'
|
||||
reference_int: 7
|
||||
description: 'Another PO'
|
||||
supplier: 2
|
||||
status: 10 # Pending
|
||||
|
@ -450,11 +450,11 @@ class PurchaseOrder(Order):
|
||||
notes = kwargs.get('notes', '')
|
||||
|
||||
# Extract optional barcode field
|
||||
barcode = kwargs.get('barcode', None)
|
||||
barcode_hash = kwargs.get('barcode', None)
|
||||
|
||||
# Prevent null values for barcode
|
||||
if barcode is None:
|
||||
barcode = ''
|
||||
if barcode_hash is None:
|
||||
barcode_hash = ''
|
||||
|
||||
if self.status != PurchaseOrderStatus.PLACED:
|
||||
raise ValidationError(
|
||||
@ -475,6 +475,9 @@ class PurchaseOrder(Order):
|
||||
# Create a new stock item
|
||||
if line.part and quantity > 0:
|
||||
|
||||
# Take the 'pack_size' of the SupplierPart into account
|
||||
pack_quantity = Decimal(quantity) * Decimal(line.part.pack_size)
|
||||
|
||||
# Determine if we should individually serialize the items, or not
|
||||
if type(serials) is list and len(serials) > 0:
|
||||
serialize = True
|
||||
@ -488,13 +491,13 @@ class PurchaseOrder(Order):
|
||||
part=line.part.part,
|
||||
supplier_part=line.part,
|
||||
location=location,
|
||||
quantity=1 if serialize else quantity,
|
||||
quantity=1 if serialize else pack_quantity,
|
||||
purchase_order=self,
|
||||
status=status,
|
||||
batch=batch_code,
|
||||
serial=sn,
|
||||
purchase_price=line.purchase_price,
|
||||
uid=barcode
|
||||
barcode_hash=barcode_hash
|
||||
)
|
||||
|
||||
stock.save(add_note=False)
|
||||
@ -515,6 +518,7 @@ class PurchaseOrder(Order):
|
||||
)
|
||||
|
||||
# Update the number of parts received against the particular line item
|
||||
# Note that this quantity does *not* take the pack_size into account, it is "number of packs"
|
||||
line.received += quantity
|
||||
line.save()
|
||||
|
||||
|
@ -497,7 +497,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
||||
if not barcode or barcode.strip() == '':
|
||||
return None
|
||||
|
||||
if stock.models.StockItem.objects.filter(uid=barcode).exists():
|
||||
if stock.models.StockItem.objects.filter(barcode_hash=barcode).exists():
|
||||
raise ValidationError(_('Barcode is already in use'))
|
||||
|
||||
return barcode
|
||||
@ -515,11 +515,14 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
||||
serial_numbers = data.get('serial_numbers', '').strip()
|
||||
|
||||
base_part = line_item.part.part
|
||||
pack_size = line_item.part.pack_size
|
||||
|
||||
pack_quantity = pack_size * quantity
|
||||
|
||||
# Does the quantity need to be "integer" (for trackable parts?)
|
||||
if base_part.trackable:
|
||||
|
||||
if Decimal(quantity) != int(quantity):
|
||||
if Decimal(pack_quantity) != int(pack_quantity):
|
||||
raise ValidationError({
|
||||
'quantity': _('An integer quantity must be provided for trackable parts'),
|
||||
})
|
||||
@ -528,7 +531,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
||||
if serial_numbers:
|
||||
try:
|
||||
# Pass the serial numbers through to the parent serializer once validated
|
||||
data['serials'] = extract_serial_numbers(serial_numbers, quantity, base_part.getLatestSerialNumberInt())
|
||||
data['serials'] = extract_serial_numbers(serial_numbers, pack_quantity, base_part.getLatestSerialNumberInt())
|
||||
except DjangoValidationError as e:
|
||||
raise ValidationError({
|
||||
'serial_numbers': e.messages,
|
||||
|
@ -42,12 +42,18 @@
|
||||
<span class='fas fa-tools'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a class='dropdown-item' href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li>
|
||||
<li><a class='dropdown-item' href='#' id='edit-order'>
|
||||
<span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}
|
||||
</a></li>
|
||||
{% if order.can_cancel %}
|
||||
<li><a class='dropdown-item' href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li>
|
||||
<li><a class='dropdown-item' href='#' id='cancel-order'>
|
||||
<span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
{% if roles.purchase_order.add %}
|
||||
<li><a class='dropdown-item' href='#' id='duplicate-order'><span class='fas fa-clone'></span> {% trans "Duplicate order" %}</a></li>
|
||||
<li><a class='dropdown-item' href='#' id='duplicate-order'>
|
||||
<span class='fas fa-clone'></span> {% trans "Duplicate order" %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
@ -235,19 +241,11 @@ $("#edit-order").click(function() {
|
||||
$("#receive-order").click(function() {
|
||||
|
||||
// Auto select items which have not been fully allocated
|
||||
var items = $("#po-line-table").bootstrapTable('getData');
|
||||
|
||||
var items_to_receive = [];
|
||||
|
||||
items.forEach(function(item) {
|
||||
if (item.received < item.quantity) {
|
||||
items_to_receive.push(item);
|
||||
}
|
||||
});
|
||||
var items = getTableData('#po-line-table');
|
||||
|
||||
receivePurchaseOrderItems(
|
||||
{{ order.id }},
|
||||
items_to_receive,
|
||||
items,
|
||||
{
|
||||
success: function() {
|
||||
$("#po-line-table").bootstrapTable('refresh');
|
||||
|
@ -222,6 +222,10 @@ $("#new-po-extra-line").click(function() {
|
||||
order: {{ order.pk }},
|
||||
});
|
||||
|
||||
{% if order.supplier.currency %}
|
||||
fields.price_currency.value = '{{ order.supplier.currency }}';
|
||||
{% endif %}
|
||||
|
||||
constructForm('{% url "api-po-extra-line-list" %}', {
|
||||
fields: fields,
|
||||
method: 'POST',
|
||||
|
@ -582,11 +582,11 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
"""Tests for checking in items with invalid barcodes:
|
||||
|
||||
- Cannot check in "duplicate" barcodes
|
||||
- Barcodes cannot match UID field for existing StockItem
|
||||
- Barcodes cannot match 'barcode_hash' field for existing StockItem
|
||||
"""
|
||||
# Set stock item barcode
|
||||
item = StockItem.objects.get(pk=1)
|
||||
item.uid = 'MY-BARCODE-HASH'
|
||||
item.barcode_hash = 'MY-BARCODE-HASH'
|
||||
item.save()
|
||||
|
||||
response = self.post(
|
||||
@ -705,8 +705,8 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
self.assertEqual(stock_2.last().location.pk, 2)
|
||||
|
||||
# Barcodes should have been assigned to the stock items
|
||||
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-123').exists())
|
||||
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-456').exists())
|
||||
self.assertTrue(StockItem.objects.filter(barcode_hash='MY-UNIQUE-BARCODE-123').exists())
|
||||
self.assertTrue(StockItem.objects.filter(barcode_hash='MY-UNIQUE-BARCODE-456').exists())
|
||||
|
||||
def test_batch_code(self):
|
||||
"""Test that we can supply a 'batch code' when receiving items."""
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""Various unit tests for order models"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import django.core.exceptions as django_exceptions
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -194,11 +195,18 @@ class OrderTest(TestCase):
|
||||
# Receive the rest of the items
|
||||
order.receive_line_item(line, loc, 50, user=None)
|
||||
|
||||
self.assertEqual(part.on_order, 1300)
|
||||
|
||||
line = PurchaseOrderLineItem.objects.get(id=2)
|
||||
|
||||
in_stock = part.total_stock
|
||||
|
||||
order.receive_line_item(line, loc, 500, user=None)
|
||||
|
||||
self.assertEqual(part.on_order, 800)
|
||||
# Check that the part stock quantity has increased by the correct amount
|
||||
self.assertEqual(part.total_stock, in_stock + 500)
|
||||
|
||||
self.assertEqual(part.on_order, 1100)
|
||||
self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
|
||||
|
||||
for line in order.pending_line_items():
|
||||
@ -206,6 +214,91 @@ class OrderTest(TestCase):
|
||||
|
||||
self.assertEqual(order.status, PurchaseOrderStatus.COMPLETE)
|
||||
|
||||
def test_receive_pack_size(self):
|
||||
"""Test receiving orders from suppliers with different pack_size values"""
|
||||
|
||||
prt = Part.objects.get(pk=1)
|
||||
sup = Company.objects.get(pk=1)
|
||||
|
||||
# Create a new supplier part with larger pack size
|
||||
sp_1 = SupplierPart.objects.create(
|
||||
part=prt,
|
||||
supplier=sup,
|
||||
SKU='SKUx10',
|
||||
pack_size=10,
|
||||
)
|
||||
|
||||
# Create a new supplier part with smaller pack size
|
||||
sp_2 = SupplierPart.objects.create(
|
||||
part=prt,
|
||||
supplier=sup,
|
||||
SKU='SKUx0.1',
|
||||
pack_size=0.1,
|
||||
)
|
||||
|
||||
# Record values before we start
|
||||
on_order = prt.on_order
|
||||
in_stock = prt.total_stock
|
||||
|
||||
n = PurchaseOrder.objects.count()
|
||||
|
||||
# Create a new PurchaseOrder
|
||||
po = PurchaseOrder.objects.create(
|
||||
supplier=sup,
|
||||
reference=f"PO-{n + 1}",
|
||||
description='Some PO',
|
||||
)
|
||||
|
||||
# Add line items
|
||||
|
||||
# 3 x 10 = 30
|
||||
line_1 = PurchaseOrderLineItem.objects.create(
|
||||
order=po,
|
||||
part=sp_1,
|
||||
quantity=3
|
||||
)
|
||||
|
||||
# 13 x 0.1 = 1.3
|
||||
line_2 = PurchaseOrderLineItem.objects.create(
|
||||
order=po,
|
||||
part=sp_2,
|
||||
quantity=13,
|
||||
)
|
||||
|
||||
po.place_order()
|
||||
|
||||
# The 'on_order' quantity should have been increased by 31.3
|
||||
self.assertEqual(prt.on_order, round(on_order + Decimal(31.3), 1))
|
||||
|
||||
loc = StockLocation.objects.get(id=1)
|
||||
|
||||
# Receive 1x item against line_1
|
||||
po.receive_line_item(line_1, loc, 1, user=None)
|
||||
|
||||
# Receive 5x item against line_2
|
||||
po.receive_line_item(line_2, loc, 5, user=None)
|
||||
|
||||
# Check that the line items have been updated correctly
|
||||
self.assertEqual(line_1.quantity, 3)
|
||||
self.assertEqual(line_1.received, 1)
|
||||
self.assertEqual(line_1.remaining(), 2)
|
||||
|
||||
self.assertEqual(line_2.quantity, 13)
|
||||
self.assertEqual(line_2.received, 5)
|
||||
self.assertEqual(line_2.remaining(), 8)
|
||||
|
||||
# The 'on_order' quantity should have decreased by 10.5
|
||||
self.assertEqual(
|
||||
prt.on_order,
|
||||
round(on_order + Decimal(31.3) - Decimal(10.5), 1)
|
||||
)
|
||||
|
||||
# The 'in_stock' quantity should have increased by 10.5
|
||||
self.assertEqual(
|
||||
prt.total_stock,
|
||||
round(in_stock + Decimal(10.5), 1)
|
||||
)
|
||||
|
||||
def test_overdue_notification(self):
|
||||
"""Test overdue purchase order notification
|
||||
|
||||
|
@ -19,8 +19,8 @@ Relevant PRs:
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import (F, FloatField, Func, IntegerField, OuterRef, Q,
|
||||
Subquery)
|
||||
from django.db.models import (DecimalField, ExpressionWrapper, F, FloatField,
|
||||
Func, IntegerField, OuterRef, Q, Subquery)
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from sql_util.utils import SubquerySum
|
||||
@ -32,19 +32,43 @@ from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||
|
||||
|
||||
def annotate_on_order_quantity(reference: str = ''):
|
||||
"""Annotate the 'on order' quantity for each part in a queryset"""
|
||||
"""Annotate the 'on order' quantity for each part in a queryset.
|
||||
|
||||
Sum the 'remaining quantity' of each line item for any open purchase orders for each part:
|
||||
|
||||
- Purchase order must be 'active' or 'pending'
|
||||
- Received quantity must be less than line item quantity
|
||||
|
||||
Note that in addition to the 'quantity' on order, we must also take into account 'pack_size'.
|
||||
"""
|
||||
|
||||
# Filter only 'active' purhase orders
|
||||
order_filter = Q(order__status__in=PurchaseOrderStatus.OPEN)
|
||||
# Filter only line with outstanding quantity
|
||||
order_filter = Q(
|
||||
order__status__in=PurchaseOrderStatus.OPEN,
|
||||
quantity__gt=F('received'),
|
||||
)
|
||||
|
||||
return Coalesce(
|
||||
SubquerySum(f'{reference}supplier_parts__purchase_order_line_items__quantity', filter=order_filter),
|
||||
SubquerySum(
|
||||
ExpressionWrapper(
|
||||
F(f'{reference}supplier_parts__purchase_order_line_items__quantity') * F(f'{reference}supplier_parts__pack_size'),
|
||||
output_field=DecimalField(),
|
||||
),
|
||||
filter=order_filter
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField()
|
||||
output_field=DecimalField()
|
||||
) - Coalesce(
|
||||
SubquerySum(f'{reference}supplier_parts__purchase_order_line_items__received', filter=order_filter),
|
||||
SubquerySum(
|
||||
ExpressionWrapper(
|
||||
F(f'{reference}supplier_parts__purchase_order_line_items__received') * F(f'{reference}supplier_parts__pack_size'),
|
||||
output_field=DecimalField(),
|
||||
),
|
||||
filter=order_filter
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
output_field=DecimalField(),
|
||||
)
|
||||
|
||||
|
||||
|
23
InvenTree/part/migrations/0086_auto_20220912_0007.py
Normal file
23
InvenTree/part/migrations/0086_auto_20220912_0007.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.15 on 2022-09-12 00:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0085_partparametertemplate_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='part',
|
||||
name='barcode_data',
|
||||
field=models.CharField(blank=True, help_text='Third party barcode data', max_length=500, verbose_name='Barcode Data'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='part',
|
||||
name='barcode_hash',
|
||||
field=models.CharField(blank=True, help_text='Unique hash of barcode data', max_length=128, verbose_name='Barcode Hash'),
|
||||
),
|
||||
]
|
@ -43,7 +43,7 @@ from InvenTree import helpers, validators
|
||||
from InvenTree.fields import InvenTreeNotesField, InvenTreeURLField
|
||||
from InvenTree.helpers import decimal2money, decimal2string, normalize
|
||||
from InvenTree.models import (DataImportMixin, InvenTreeAttachment,
|
||||
InvenTreeTree)
|
||||
InvenTreeBarcodeMixin, InvenTreeTree)
|
||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||
SalesOrderStatus)
|
||||
from order import models as OrderModels
|
||||
@ -300,7 +300,7 @@ class PartManager(TreeManager):
|
||||
|
||||
|
||||
@cleanup.ignore
|
||||
class Part(MetadataMixin, MPTTModel):
|
||||
class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
"""The Part object represents an abstract part, the 'concept' of an actual entity.
|
||||
|
||||
An actual physical instance of a Part is a StockItem which is treated separately.
|
||||
@ -941,18 +941,6 @@ class Part(MetadataMixin, MPTTModel):
|
||||
|
||||
responsible = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Responsible'), related_name='parts_responible')
|
||||
|
||||
def format_barcode(self, **kwargs):
|
||||
"""Return a JSON string for formatting a barcode for this Part object."""
|
||||
return helpers.MakeBarcode(
|
||||
"part",
|
||||
self.id,
|
||||
{
|
||||
"name": self.full_name,
|
||||
"url": reverse('api-part-detail', kwargs={'pk': self.id}),
|
||||
},
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@property
|
||||
def category_path(self):
|
||||
"""Return the category path of this Part instance"""
|
||||
@ -2036,22 +2024,30 @@ class Part(MetadataMixin, MPTTModel):
|
||||
|
||||
@property
|
||||
def on_order(self):
|
||||
"""Return the total number of items on order for this part."""
|
||||
orders = self.supplier_parts.filter(purchase_order_line_items__order__status__in=PurchaseOrderStatus.OPEN).aggregate(
|
||||
quantity=Sum('purchase_order_line_items__quantity'),
|
||||
received=Sum('purchase_order_line_items__received')
|
||||
)
|
||||
"""Return the total number of items on order for this part.
|
||||
|
||||
quantity = orders['quantity']
|
||||
received = orders['received']
|
||||
Note that some supplier parts may have a different pack_size attribute,
|
||||
and this needs to be taken into account!
|
||||
"""
|
||||
|
||||
if quantity is None:
|
||||
quantity = 0
|
||||
quantity = 0
|
||||
|
||||
if received is None:
|
||||
received = 0
|
||||
# Iterate through all supplier parts
|
||||
for sp in self.supplier_parts.all():
|
||||
|
||||
return quantity - received
|
||||
# Look at any incomplete line item for open orders
|
||||
lines = sp.purchase_order_line_items.filter(
|
||||
order__status__in=PurchaseOrderStatus.OPEN,
|
||||
quantity__gt=F('received'),
|
||||
)
|
||||
|
||||
for line in lines:
|
||||
remaining = line.quantity - line.received
|
||||
|
||||
if remaining > 0:
|
||||
quantity += remaining * sp.pack_size
|
||||
|
||||
return quantity
|
||||
|
||||
def get_parameters(self):
|
||||
"""Return all parameters for this part, ordered by name."""
|
||||
|
@ -45,6 +45,11 @@
|
||||
{% if barcodes %}
|
||||
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||
{% endif %}
|
||||
{% if part.barcode_hash %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-unlink'><span class='fas fa-unlink'></span> {% trans "Unink Barcode" %}</a></li>
|
||||
{% else %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-link'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
|
||||
{% endif %}
|
||||
{% if labels_enabled %}
|
||||
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
||||
{% endif %}
|
||||
@ -167,6 +172,7 @@
|
||||
<td>{% trans "Description" %}</td>
|
||||
<td>{{ part.description }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
<!-- Part info messages -->
|
||||
@ -187,7 +193,7 @@
|
||||
<tr>
|
||||
<td><h5><span class='fas fa-boxes'></span></h5></td>
|
||||
<td><h5>{% trans "Available Stock" %}</h5></td>
|
||||
<td><h5>{% decimal available %}{% if part.units %} {{ part.units }}{% endif %}</h5></td>
|
||||
<td><h5>{% decimal available %} {% include "part/part_units.html" %}</h5></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-map-marker-alt'></span></td>
|
||||
@ -198,14 +204,14 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-flag'></span></td>
|
||||
<td>{% trans "Minimum Stock" %}</td>
|
||||
<td>{{ part.minimum_stock }}</td>
|
||||
<td>{{ part.minimum_stock }} {% include "part/part_units.html" %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if on_order > 0 %}
|
||||
<tr>
|
||||
<td><span class='fas fa-shopping-cart'></span></td>
|
||||
<td>{% trans "On Order" %}</td>
|
||||
<td>{% decimal on_order %}</td>
|
||||
<td>{% decimal on_order %} {% include "part/part_units.html" %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if part.component %}
|
||||
@ -295,6 +301,13 @@
|
||||
<td>{{ part.keywords }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if part.barcode_hash %}
|
||||
<tr>
|
||||
<td><span class='fas fa-barcode'></span></td>
|
||||
<td>{% trans "Barcode Identifier" %}</td>
|
||||
<td {% if part.barcode_data %}title='{{ part.barcode_data }}'{% endif %}>{{ part.barcode_hash }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
@ -391,6 +404,7 @@
|
||||
}
|
||||
);
|
||||
|
||||
{% if barcodes %}
|
||||
$("#show-qr-code").click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'part-qr' part.id %}",
|
||||
@ -400,6 +414,24 @@
|
||||
);
|
||||
});
|
||||
|
||||
$('#barcode-unlink').click(function() {
|
||||
unlinkBarcode({
|
||||
part: {{ part.pk }},
|
||||
});
|
||||
});
|
||||
|
||||
$('#barcode-link').click(function() {
|
||||
linkBarcodeDialog(
|
||||
{
|
||||
part: {{ part.pk }},
|
||||
},
|
||||
{
|
||||
title: '{% trans "Link Barcode to Part" %}',
|
||||
}
|
||||
);
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if labels_enabled %}
|
||||
$('#print-label').click(function() {
|
||||
printPartLabels([{{ part.pk }}]);
|
||||
|
1
InvenTree/part/templates/part/part_units.html
Normal file
1
InvenTree/part/templates/part/part_units.html
Normal file
@ -0,0 +1 @@
|
||||
{% if part.units %}{{ part.units }}{% endif %}
|
@ -1,7 +1,7 @@
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
|
||||
{% decimal total_stock %}
|
||||
{% decimal total_stock %} {% include "part/part_units.html" %}
|
||||
|
||||
{% if total_stock == 0 %}
|
||||
<span class='badge badge-right rounded-pill bg-danger'>{% trans "No Stock" %}</span>
|
||||
|
@ -1,5 +1,7 @@
|
||||
"""Unit tests for the various part API endpoints"""
|
||||
|
||||
from random import randint
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
import PIL
|
||||
@ -9,7 +11,7 @@ from rest_framework.test import APIClient
|
||||
import build.models
|
||||
import order.models
|
||||
from common.models import InvenTreeSetting
|
||||
from company.models import Company
|
||||
from company.models import Company, SupplierPart
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||
StockStatus)
|
||||
@ -1676,6 +1678,110 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(part.total_stock, 91)
|
||||
self.assertEqual(part.available_stock, 56)
|
||||
|
||||
def test_on_order(self):
|
||||
"""Test that the 'on_order' queryset annotation works as expected.
|
||||
|
||||
This queryset annotation takes into account any outstanding line items for active orders,
|
||||
and should also use the 'pack_size' of the supplier part objects.
|
||||
"""
|
||||
|
||||
supplier = Company.objects.create(
|
||||
name='Paint Supplies',
|
||||
description='A supplier of paints',
|
||||
is_supplier=True
|
||||
)
|
||||
|
||||
# First, create some parts
|
||||
paint = PartCategory.objects.create(
|
||||
parent=None,
|
||||
name="Paint",
|
||||
description="Paints and such",
|
||||
)
|
||||
|
||||
for color in ['Red', 'Green', 'Blue', 'Orange', 'Yellow']:
|
||||
p = Part.objects.create(
|
||||
category=paint,
|
||||
units='litres',
|
||||
name=f"{color} Paint",
|
||||
description=f"Paint which is {color} in color"
|
||||
)
|
||||
|
||||
# Create multiple supplier parts in different sizes
|
||||
for pk_sz in [1, 10, 25, 100]:
|
||||
sp = SupplierPart.objects.create(
|
||||
part=p,
|
||||
supplier=supplier,
|
||||
SKU=f"PNT-{color}-{pk_sz}L",
|
||||
pack_size=pk_sz,
|
||||
)
|
||||
|
||||
self.assertEqual(p.supplier_parts.count(), 4)
|
||||
|
||||
# Check that we have the right base data to start with
|
||||
self.assertEqual(paint.parts.count(), 5)
|
||||
self.assertEqual(supplier.supplied_parts.count(), 20)
|
||||
|
||||
supplier_parts = supplier.supplied_parts.all()
|
||||
|
||||
# Create multiple orders
|
||||
for _ii in range(5):
|
||||
|
||||
po = order.models.PurchaseOrder.objects.create(
|
||||
supplier=supplier,
|
||||
description='ordering some paint',
|
||||
)
|
||||
|
||||
# Order an assortment of items
|
||||
for sp in supplier_parts:
|
||||
|
||||
# Generate random quantity to order
|
||||
quantity = randint(10, 20)
|
||||
|
||||
# Mark up to half of the quantity as received
|
||||
received = randint(0, quantity // 2)
|
||||
|
||||
# Add a line item
|
||||
item = order.models.PurchaseOrderLineItem.objects.create(
|
||||
part=sp,
|
||||
order=po,
|
||||
quantity=quantity,
|
||||
received=received,
|
||||
)
|
||||
|
||||
# Now grab a list of parts from the API
|
||||
response = self.get(
|
||||
reverse('api-part-list'),
|
||||
{
|
||||
'category': paint.pk,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
# Check that the correct number of items have been returned
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
for item in response.data:
|
||||
# Calculate the 'ordering' quantity from first principles
|
||||
p = Part.objects.get(pk=item['pk'])
|
||||
|
||||
on_order = 0
|
||||
|
||||
for sp in p.supplier_parts.all():
|
||||
for line_item in sp.purchase_order_line_items.all():
|
||||
po = line_item.order
|
||||
|
||||
if po.status in PurchaseOrderStatus.OPEN:
|
||||
remaining = line_item.quantity - line_item.received
|
||||
|
||||
if remaining > 0:
|
||||
on_order += remaining * sp.pack_size
|
||||
|
||||
# The annotated quantity must be equal to the hand-calculated quantity
|
||||
self.assertEqual(on_order, item['ordering'])
|
||||
|
||||
# The annotated quantity must also match the part.on_order quantity
|
||||
self.assertEqual(on_order, p.on_order)
|
||||
|
||||
|
||||
class BomItemTest(InvenTreeAPITestCase):
|
||||
"""Unit tests for the BomItem API."""
|
||||
|
@ -144,6 +144,15 @@ class PartTest(TestCase):
|
||||
|
||||
Part.objects.rebuild()
|
||||
|
||||
def test_barcode_mixin(self):
|
||||
"""Test the barcode mixin functionality"""
|
||||
|
||||
self.assertEqual(Part.barcode_model_type(), 'part')
|
||||
|
||||
p = Part.objects.get(pk=1)
|
||||
barcode = p.format_barcode(brief=True)
|
||||
self.assertEqual(barcode, '{"part": 1}')
|
||||
|
||||
def test_tree(self):
|
||||
"""Test that the part variant tree is working properly"""
|
||||
chair = Part.objects.get(pk=10000)
|
||||
@ -243,7 +252,7 @@ class PartTest(TestCase):
|
||||
"""Test barcode format functionality"""
|
||||
barcode = self.r1.format_barcode(brief=False)
|
||||
self.assertIn('InvenTree', barcode)
|
||||
self.assertIn(self.r1.name, barcode)
|
||||
self.assertIn('"part": {"id": 3}', barcode)
|
||||
|
||||
def test_copy(self):
|
||||
"""Test that we can 'deep copy' a Part instance"""
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""API endpoints for barcode plugins."""
|
||||
|
||||
|
||||
from django.urls import path, re_path, reverse
|
||||
from django.urls import path, re_path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import permissions
|
||||
@ -9,11 +9,10 @@ from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from InvenTree.helpers import hash_barcode
|
||||
from plugin import registry
|
||||
from plugin.base.barcodes.mixins import hash_barcode
|
||||
from plugin.builtin.barcodes.inventree_barcode import InvenTreeBarcodePlugin
|
||||
from stock.models import StockItem
|
||||
from stock.serializers import StockItemSerializer
|
||||
from plugin.builtin.barcodes.inventree_barcode import (
|
||||
InvenTreeExternalBarcodePlugin, InvenTreeInternalBarcodePlugin)
|
||||
|
||||
|
||||
class BarcodeScan(APIView):
|
||||
@ -51,85 +50,40 @@ class BarcodeScan(APIView):
|
||||
if 'barcode' not in data:
|
||||
raise ValidationError({'barcode': _('Must provide barcode_data parameter')})
|
||||
|
||||
plugins = registry.with_mixin('barcode')
|
||||
# Ensure that the default barcode handlers are run first
|
||||
plugins = [
|
||||
InvenTreeInternalBarcodePlugin(),
|
||||
InvenTreeExternalBarcodePlugin(),
|
||||
] + registry.with_mixin('barcode')
|
||||
|
||||
barcode_data = data.get('barcode')
|
||||
|
||||
# Ensure that the default barcode handler is installed
|
||||
plugins.append(InvenTreeBarcodePlugin())
|
||||
barcode_hash = hash_barcode(barcode_data)
|
||||
|
||||
# Look for a barcode plugin which knows how to deal with this barcode
|
||||
plugin = None
|
||||
|
||||
for current_plugin in plugins:
|
||||
current_plugin.init(barcode_data)
|
||||
|
||||
if current_plugin.validate():
|
||||
plugin = current_plugin
|
||||
break
|
||||
|
||||
match_found = False
|
||||
response = {}
|
||||
|
||||
for current_plugin in plugins:
|
||||
|
||||
result = current_plugin.scan(barcode_data)
|
||||
|
||||
if result is not None:
|
||||
plugin = current_plugin
|
||||
response = result
|
||||
break
|
||||
|
||||
response['plugin'] = plugin.name if plugin else None
|
||||
response['barcode_data'] = barcode_data
|
||||
response['barcode_hash'] = barcode_hash
|
||||
|
||||
# A plugin has been found!
|
||||
if plugin is not None:
|
||||
|
||||
# Try to associate with a stock item
|
||||
item = plugin.getStockItem()
|
||||
|
||||
if item is None:
|
||||
item = plugin.getStockItemByHash()
|
||||
|
||||
if item is not None:
|
||||
response['stockitem'] = plugin.renderStockItem(item)
|
||||
response['url'] = reverse('stock-item-detail', kwargs={'pk': item.id})
|
||||
match_found = True
|
||||
|
||||
# Try to associate with a stock location
|
||||
loc = plugin.getStockLocation()
|
||||
|
||||
if loc is not None:
|
||||
response['stocklocation'] = plugin.renderStockLocation(loc)
|
||||
response['url'] = reverse('stock-location-detail', kwargs={'pk': loc.id})
|
||||
match_found = True
|
||||
|
||||
# Try to associate with a part
|
||||
part = plugin.getPart()
|
||||
|
||||
if part is not None:
|
||||
response['part'] = plugin.renderPart(part)
|
||||
response['url'] = reverse('part-detail', kwargs={'pk': part.id})
|
||||
match_found = True
|
||||
|
||||
response['hash'] = plugin.hash()
|
||||
response['plugin'] = plugin.name
|
||||
|
||||
# No plugin is found!
|
||||
# However, the hash of the barcode may still be associated with a StockItem!
|
||||
else:
|
||||
result_hash = hash_barcode(barcode_data)
|
||||
|
||||
response['hash'] = result_hash
|
||||
response['plugin'] = None
|
||||
|
||||
# Try to look for a matching StockItem
|
||||
try:
|
||||
item = StockItem.objects.get(uid=result_hash)
|
||||
serializer = StockItemSerializer(item, part_detail=True, location_detail=True, supplier_part_detail=True)
|
||||
response['stockitem'] = serializer.data
|
||||
response['url'] = reverse('stock-item-detail', kwargs={'pk': item.id})
|
||||
match_found = True
|
||||
except StockItem.DoesNotExist:
|
||||
pass
|
||||
|
||||
if not match_found:
|
||||
# A plugin has not been found!
|
||||
if plugin is None:
|
||||
response['error'] = _('No match found for barcode data')
|
||||
|
||||
raise ValidationError(response)
|
||||
else:
|
||||
response['success'] = _('Match found for barcode data')
|
||||
|
||||
return Response(response)
|
||||
return Response(response)
|
||||
|
||||
|
||||
class BarcodeAssign(APIView):
|
||||
@ -148,97 +102,134 @@ class BarcodeAssign(APIView):
|
||||
|
||||
Checks inputs and assign barcode (hash) to StockItem.
|
||||
"""
|
||||
|
||||
data = request.data
|
||||
|
||||
if 'barcode' not in data:
|
||||
raise ValidationError({'barcode': _('Must provide barcode_data parameter')})
|
||||
|
||||
if 'stockitem' not in data:
|
||||
raise ValidationError({'stockitem': _('Must provide stockitem parameter')})
|
||||
|
||||
barcode_data = data['barcode']
|
||||
|
||||
try:
|
||||
item = StockItem.objects.get(pk=data['stockitem'])
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
raise ValidationError({'stockitem': _('No matching stock item found')})
|
||||
# Here we only check against 'InvenTree' plugins
|
||||
plugins = [
|
||||
InvenTreeInternalBarcodePlugin(),
|
||||
InvenTreeExternalBarcodePlugin(),
|
||||
]
|
||||
|
||||
plugins = registry.with_mixin('barcode')
|
||||
# First check if the provided barcode matches an existing database entry
|
||||
for plugin in plugins:
|
||||
result = plugin.scan(barcode_data)
|
||||
|
||||
plugin = None
|
||||
if result is not None:
|
||||
result["error"] = _("Barcode matches existing item")
|
||||
result["plugin"] = plugin.name
|
||||
result["barcode_data"] = barcode_data
|
||||
|
||||
for current_plugin in plugins:
|
||||
current_plugin.init(barcode_data)
|
||||
raise ValidationError(result)
|
||||
|
||||
if current_plugin.validate():
|
||||
plugin = current_plugin
|
||||
break
|
||||
barcode_hash = hash_barcode(barcode_data)
|
||||
|
||||
match_found = False
|
||||
valid_labels = []
|
||||
|
||||
response = {}
|
||||
for model in InvenTreeExternalBarcodePlugin.get_supported_barcode_models():
|
||||
label = model.barcode_model_type()
|
||||
valid_labels.append(label)
|
||||
|
||||
response['barcode_data'] = barcode_data
|
||||
if label in data:
|
||||
try:
|
||||
instance = model.objects.get(pk=data[label])
|
||||
|
||||
# Matching plugin was found
|
||||
if plugin is not None:
|
||||
instance.assign_barcode(
|
||||
barcode_data=barcode_data,
|
||||
barcode_hash=barcode_hash,
|
||||
)
|
||||
|
||||
result_hash = plugin.hash()
|
||||
response['hash'] = result_hash
|
||||
response['plugin'] = plugin.name
|
||||
return Response({
|
||||
'success': f"Assigned barcode to {label} instance",
|
||||
label: {
|
||||
'pk': instance.pk,
|
||||
},
|
||||
"barcode_data": barcode_data,
|
||||
"barcode_hash": barcode_hash,
|
||||
})
|
||||
|
||||
# Ensure that the barcode does not already match a database entry
|
||||
except (ValueError, model.DoesNotExist):
|
||||
raise ValidationError({
|
||||
'error': f"No matching {label} instance found in database",
|
||||
})
|
||||
|
||||
if plugin.getStockItem() is not None:
|
||||
match_found = True
|
||||
response['error'] = _('Barcode already matches Stock Item')
|
||||
# If we got here, it means that no valid model types were provided
|
||||
raise ValidationError({
|
||||
'error': f"Missing data: provide one of '{valid_labels}'",
|
||||
})
|
||||
|
||||
if plugin.getStockLocation() is not None:
|
||||
match_found = True
|
||||
response['error'] = _('Barcode already matches Stock Location')
|
||||
|
||||
if plugin.getPart() is not None:
|
||||
match_found = True
|
||||
response['error'] = _('Barcode already matches Part')
|
||||
class BarcodeUnassign(APIView):
|
||||
"""Endpoint for unlinking / unassigning a custom barcode from a database object"""
|
||||
|
||||
if not match_found:
|
||||
item = plugin.getStockItemByHash()
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
|
||||
if item is not None:
|
||||
response['error'] = _('Barcode hash already matches Stock Item')
|
||||
match_found = True
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Respond to a barcode unassign POST request"""
|
||||
|
||||
else:
|
||||
result_hash = hash_barcode(barcode_data)
|
||||
# The following database models support assignment of third-party barcodes
|
||||
supported_models = InvenTreeExternalBarcodePlugin.get_supported_barcode_models()
|
||||
|
||||
response['hash'] = result_hash
|
||||
response['plugin'] = None
|
||||
supported_labels = [model.barcode_model_type() for model in supported_models]
|
||||
model_names = ', '.join(supported_labels)
|
||||
|
||||
# Lookup stock item by hash
|
||||
try:
|
||||
item = StockItem.objects.get(uid=result_hash)
|
||||
response['error'] = _('Barcode hash already matches Stock Item')
|
||||
match_found = True
|
||||
except StockItem.DoesNotExist:
|
||||
pass
|
||||
data = request.data
|
||||
|
||||
if not match_found:
|
||||
response['success'] = _('Barcode associated with Stock Item')
|
||||
matched_labels = []
|
||||
|
||||
# Save the barcode hash
|
||||
item.uid = response['hash']
|
||||
item.save()
|
||||
for label in supported_labels:
|
||||
if label in data:
|
||||
matched_labels.append(label)
|
||||
|
||||
serializer = StockItemSerializer(item, part_detail=True, location_detail=True, supplier_part_detail=True)
|
||||
response['stockitem'] = serializer.data
|
||||
if len(matched_labels) == 0:
|
||||
raise ValidationError({
|
||||
'error': f"Missing data: Provide one of '{model_names}'"
|
||||
})
|
||||
|
||||
return Response(response)
|
||||
if len(matched_labels) > 1:
|
||||
raise ValidationError({
|
||||
'error': f"Multiple conflicting fields: '{model_names}'",
|
||||
})
|
||||
|
||||
# At this stage, we know that we have received a single valid field
|
||||
for model in supported_models:
|
||||
label = model.barcode_model_type()
|
||||
|
||||
if label in data:
|
||||
try:
|
||||
instance = model.objects.get(pk=data[label])
|
||||
except (ValueError, model.DoesNotExist):
|
||||
raise ValidationError({
|
||||
label: _('No match found for provided value')
|
||||
})
|
||||
|
||||
# Unassign the barcode data from the model instance
|
||||
instance.unassign_barcode()
|
||||
|
||||
return Response({
|
||||
'success': 'Barcode unassigned from {label} instance',
|
||||
})
|
||||
|
||||
# If we get to this point, something has gone wrong!
|
||||
raise ValidationError({
|
||||
'error': 'Could not unassign barcode',
|
||||
})
|
||||
|
||||
|
||||
barcode_api_urls = [
|
||||
# Link a barcode to a part
|
||||
# Link a third-party barcode to an item (e.g. Part / StockItem / etc)
|
||||
path('link/', BarcodeAssign.as_view(), name='api-barcode-link'),
|
||||
|
||||
# Unlink a third-pary barcode from an item
|
||||
path('unlink/', BarcodeUnassign.as_view(), name='api-barcode-unlink'),
|
||||
|
||||
# Catch-all performs barcode 'scan'
|
||||
re_path(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'),
|
||||
]
|
||||
|
@ -1,33 +1,8 @@
|
||||
"""Plugin mixin classes for barcode plugin."""
|
||||
|
||||
import hashlib
|
||||
import string
|
||||
|
||||
from part.serializers import PartSerializer
|
||||
from stock.models import StockItem
|
||||
from stock.serializers import LocationSerializer, StockItemSerializer
|
||||
|
||||
|
||||
def hash_barcode(barcode_data):
|
||||
"""Calculate an MD5 hash of barcode data.
|
||||
|
||||
HACK: Remove any 'non printable' characters from the hash,
|
||||
as it seems browers will remove special control characters...
|
||||
|
||||
TODO: Work out a way around this!
|
||||
"""
|
||||
barcode_data = str(barcode_data).strip()
|
||||
|
||||
printable_chars = filter(lambda x: x in string.printable, barcode_data)
|
||||
|
||||
barcode_data = ''.join(list(printable_chars))
|
||||
|
||||
result_hash = hashlib.md5(str(barcode_data).encode())
|
||||
return str(result_hash.hexdigest())
|
||||
|
||||
|
||||
class BarcodeMixin:
|
||||
"""Mixin that enables barcode handeling.
|
||||
"""Mixin that enables barcode handling.
|
||||
|
||||
Custom barcode plugins should use and extend this mixin as necessary.
|
||||
"""
|
||||
@ -49,72 +24,16 @@ class BarcodeMixin:
|
||||
"""Does this plugin have everything needed to process a barcode."""
|
||||
return True
|
||||
|
||||
def init(self, barcode_data):
|
||||
"""Initialize the BarcodePlugin instance.
|
||||
def scan(self, barcode_data):
|
||||
"""Scan a barcode against this plugin.
|
||||
|
||||
Args:
|
||||
barcode_data: The raw barcode data
|
||||
This method is explicitly called from the /scan/ API endpoint,
|
||||
and thus it is expected that any barcode which matches this barcode will return a result.
|
||||
|
||||
If this plugin finds a match against the provided barcode, it should return a dict object
|
||||
with the intended result.
|
||||
|
||||
Default return value is None
|
||||
"""
|
||||
self.data = barcode_data
|
||||
|
||||
def getStockItem(self):
|
||||
"""Attempt to retrieve a StockItem associated with this barcode.
|
||||
|
||||
Default implementation returns None
|
||||
"""
|
||||
return None # pragma: no cover
|
||||
|
||||
def getStockItemByHash(self):
|
||||
"""Attempt to retrieve a StockItem associated with this barcode, based on the barcode hash."""
|
||||
try:
|
||||
item = StockItem.objects.get(uid=self.hash())
|
||||
return item
|
||||
except StockItem.DoesNotExist:
|
||||
return None
|
||||
|
||||
def renderStockItem(self, item):
|
||||
"""Render a stock item to JSON response."""
|
||||
serializer = StockItemSerializer(item, part_detail=True, location_detail=True, supplier_part_detail=True)
|
||||
return serializer.data
|
||||
|
||||
def getStockLocation(self):
|
||||
"""Attempt to retrieve a StockLocation associated with this barcode.
|
||||
|
||||
Default implementation returns None
|
||||
"""
|
||||
return None # pragma: no cover
|
||||
|
||||
def renderStockLocation(self, loc):
|
||||
"""Render a stock location to a JSON response."""
|
||||
serializer = LocationSerializer(loc)
|
||||
return serializer.data
|
||||
|
||||
def getPart(self):
|
||||
"""Attempt to retrieve a Part associated with this barcode.
|
||||
|
||||
Default implementation returns None
|
||||
"""
|
||||
return None # pragma: no cover
|
||||
|
||||
def renderPart(self, part):
|
||||
"""Render a part to JSON response."""
|
||||
serializer = PartSerializer(part)
|
||||
return serializer.data
|
||||
|
||||
def hash(self):
|
||||
"""Calculate a hash for the barcode data.
|
||||
|
||||
This is supposed to uniquely identify the barcode contents,
|
||||
at least within the bardcode sub-type.
|
||||
|
||||
The default implementation simply returns an MD5 hash of the barcode data,
|
||||
encoded to a string.
|
||||
|
||||
This may be sufficient for most applications, but can obviously be overridden
|
||||
by a subclass.
|
||||
"""
|
||||
return hash_barcode(self.data)
|
||||
|
||||
def validate(self):
|
||||
"""Default implementation returns False."""
|
||||
return False # pragma: no cover
|
||||
return None
|
||||
|
@ -52,16 +52,11 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
||||
"""
|
||||
response = self.postBarcode(self.scan_url, '')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
data = response.data
|
||||
self.assertIn('error', data)
|
||||
|
||||
self.assertIn('barcode_data', data)
|
||||
self.assertIn('hash', data)
|
||||
self.assertIn('plugin', data)
|
||||
self.assertIsNone(data['plugin'])
|
||||
|
||||
def test_find_part(self):
|
||||
"""Test that we can lookup a part based on ID."""
|
||||
response = self.client.post(
|
||||
@ -92,8 +87,7 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
self.assertEqual(response.data['part'], 'Part does not exist')
|
||||
self.assertIn('error', response.data)
|
||||
|
||||
def test_find_stock_item(self):
|
||||
"""Test that we can lookup a stock item based on ID."""
|
||||
@ -125,8 +119,7 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
self.assertEqual(response.data['stockitem'], 'Stock item does not exist')
|
||||
self.assertIn('error', response.data)
|
||||
|
||||
def test_find_location(self):
|
||||
"""Test that we can lookup a stock location based on ID."""
|
||||
@ -158,37 +151,26 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
self.assertEqual(response.data['stocklocation'], 'Stock location does not exist')
|
||||
self.assertIn('error', response.data)
|
||||
|
||||
def test_integer_barcode(self):
|
||||
"""Test scan of an integer barcode."""
|
||||
response = self.postBarcode(self.scan_url, '123456789')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
data = response.data
|
||||
self.assertIn('error', data)
|
||||
|
||||
self.assertIn('barcode_data', data)
|
||||
self.assertIn('hash', data)
|
||||
self.assertIn('plugin', data)
|
||||
self.assertIsNone(data['plugin'])
|
||||
|
||||
def test_array_barcode(self):
|
||||
"""Test scan of barcode with string encoded array."""
|
||||
response = self.postBarcode(self.scan_url, "['foo', 'bar']")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
data = response.data
|
||||
self.assertIn('error', data)
|
||||
|
||||
self.assertIn('barcode_data', data)
|
||||
self.assertIn('hash', data)
|
||||
self.assertIn('plugin', data)
|
||||
self.assertIsNone(data['plugin'])
|
||||
|
||||
def test_barcode_generation(self):
|
||||
"""Test that a barcode is generated with a scan."""
|
||||
item = StockItem.objects.get(pk=522)
|
||||
@ -208,7 +190,7 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
||||
"""Test that a barcode can be associated with a StockItem."""
|
||||
item = StockItem.objects.get(pk=522)
|
||||
|
||||
self.assertEqual(len(item.uid), 0)
|
||||
self.assertEqual(len(item.barcode_hash), 0)
|
||||
|
||||
barcode_data = 'A-TEST-BARCODE-STRING'
|
||||
|
||||
@ -226,14 +208,14 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
||||
|
||||
self.assertIn('success', data)
|
||||
|
||||
result_hash = data['hash']
|
||||
result_hash = data['barcode_hash']
|
||||
|
||||
# Read the item out from the database again
|
||||
item = StockItem.objects.get(pk=522)
|
||||
|
||||
self.assertEqual(result_hash, item.uid)
|
||||
self.assertEqual(result_hash, item.barcode_hash)
|
||||
|
||||
# Ensure that the same UID cannot be assigned to a different stock item!
|
||||
# Ensure that the same barcode hash cannot be assigned to a different stock item!
|
||||
response = self.client.post(
|
||||
self.assign_url, format='json',
|
||||
data={
|
||||
|
@ -9,8 +9,8 @@ references model objects actually exist in the database.
|
||||
|
||||
import json
|
||||
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from company.models import SupplierPart
|
||||
from InvenTree.helpers import hash_barcode
|
||||
from part.models import Part
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import BarcodeMixin
|
||||
@ -18,121 +18,89 @@ from stock.models import StockItem, StockLocation
|
||||
|
||||
|
||||
class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||
"""Generic base class for handling InvenTree barcodes"""
|
||||
|
||||
@staticmethod
|
||||
def get_supported_barcode_models():
|
||||
"""Returns a list of database models which support barcode functionality"""
|
||||
|
||||
return [
|
||||
Part,
|
||||
StockItem,
|
||||
StockLocation,
|
||||
SupplierPart,
|
||||
]
|
||||
|
||||
def format_matched_response(self, label, model, instance):
|
||||
"""Format a response for the scanned data"""
|
||||
|
||||
response = {
|
||||
'pk': instance.pk
|
||||
}
|
||||
|
||||
# Add in the API URL if available
|
||||
if hasattr(model, 'get_api_url'):
|
||||
response['api_url'] = f"{model.get_api_url()}{instance.pk}/"
|
||||
|
||||
# Add in the web URL if available
|
||||
if hasattr(instance, 'get_absolute_url'):
|
||||
response['web_url'] = instance.get_absolute_url()
|
||||
|
||||
return {label: response}
|
||||
|
||||
|
||||
class InvenTreeInternalBarcodePlugin(InvenTreeBarcodePlugin):
|
||||
"""Builtin BarcodePlugin for matching and generating internal barcodes."""
|
||||
|
||||
NAME = "InvenTreeBarcode"
|
||||
NAME = "InvenTreeInternalBarcode"
|
||||
|
||||
def validate(self):
|
||||
"""Validate a barcode.
|
||||
def scan(self, barcode_data):
|
||||
"""Scan a barcode against this plugin.
|
||||
|
||||
An "InvenTree" barcode must be a jsonnable-dict with the following tags:
|
||||
{
|
||||
'tool': 'InvenTree',
|
||||
'version': <anything>
|
||||
}
|
||||
Here we are looking for a dict object which contains a reference to a particular InvenTree database object
|
||||
"""
|
||||
# The data must either be dict or be able to dictified
|
||||
if type(self.data) is dict:
|
||||
|
||||
if type(barcode_data) is dict:
|
||||
pass
|
||||
elif type(self.data) is str:
|
||||
elif type(barcode_data) is str:
|
||||
try:
|
||||
self.data = json.loads(self.data)
|
||||
if type(self.data) is not dict:
|
||||
return False
|
||||
barcode_data = json.loads(barcode_data)
|
||||
except json.JSONDecodeError:
|
||||
return False
|
||||
return None
|
||||
else:
|
||||
return False # pragma: no cover
|
||||
return None
|
||||
|
||||
# If any of the following keys are in the JSON data,
|
||||
# let's go ahead and assume that the code is a valid InvenTree one...
|
||||
if type(barcode_data) is not dict:
|
||||
return None
|
||||
|
||||
for key in ['tool', 'version', 'InvenTree', 'stockitem', 'stocklocation', 'part']:
|
||||
if key in self.data.keys():
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
def getStockItem(self):
|
||||
"""Lookup StockItem by 'stockitem' key in barcode data."""
|
||||
for k in self.data.keys():
|
||||
if k.lower() == 'stockitem':
|
||||
|
||||
data = self.data[k]
|
||||
|
||||
pk = None
|
||||
|
||||
# Initially try casting to an integer
|
||||
# Look for various matches. First good match will be returned
|
||||
for model in self.get_supported_barcode_models():
|
||||
label = model.barcode_model_type()
|
||||
if label in barcode_data:
|
||||
try:
|
||||
pk = int(data)
|
||||
except (TypeError, ValueError): # pragma: no cover
|
||||
pk = None
|
||||
instance = model.objects.get(pk=barcode_data[label])
|
||||
return self.format_matched_response(label, model, instance)
|
||||
except (ValueError, model.DoesNotExist):
|
||||
pass
|
||||
|
||||
if pk is None: # pragma: no cover
|
||||
try:
|
||||
pk = self.data[k]['id']
|
||||
except (AttributeError, KeyError):
|
||||
raise ValidationError({k: "id parameter not supplied"})
|
||||
|
||||
try:
|
||||
item = StockItem.objects.get(pk=pk)
|
||||
return item
|
||||
except (ValueError, StockItem.DoesNotExist): # pragma: no cover
|
||||
raise ValidationError({k: "Stock item does not exist"})
|
||||
class InvenTreeExternalBarcodePlugin(InvenTreeBarcodePlugin):
|
||||
"""Builtin BarcodePlugin for matching arbitrary external barcodes."""
|
||||
|
||||
return None
|
||||
NAME = "InvenTreeExternalBarcode"
|
||||
|
||||
def getStockLocation(self):
|
||||
"""Lookup StockLocation by 'stocklocation' key in barcode data."""
|
||||
for k in self.data.keys():
|
||||
if k.lower() == 'stocklocation':
|
||||
def scan(self, barcode_data):
|
||||
"""Scan a barcode against this plugin.
|
||||
|
||||
pk = None
|
||||
Here we are looking for a dict object which contains a reference to a particular InvenTree databse object
|
||||
"""
|
||||
|
||||
# First try simple integer lookup
|
||||
try:
|
||||
pk = int(self.data[k])
|
||||
except (TypeError, ValueError): # pragma: no cover
|
||||
pk = None
|
||||
for model in self.get_supported_barcode_models():
|
||||
label = model.barcode_model_type()
|
||||
|
||||
if pk is None: # pragma: no cover
|
||||
# Lookup by 'id' field
|
||||
try:
|
||||
pk = self.data[k]['id']
|
||||
except (AttributeError, KeyError):
|
||||
raise ValidationError({k: "id parameter not supplied"})
|
||||
barcode_hash = hash_barcode(barcode_data)
|
||||
|
||||
try:
|
||||
loc = StockLocation.objects.get(pk=pk)
|
||||
return loc
|
||||
except (ValueError, StockLocation.DoesNotExist): # pragma: no cover
|
||||
raise ValidationError({k: "Stock location does not exist"})
|
||||
instance = model.lookup_barcode(barcode_hash)
|
||||
|
||||
return None
|
||||
|
||||
def getPart(self):
|
||||
"""Lookup Part by 'part' key in barcode data."""
|
||||
for k in self.data.keys():
|
||||
if k.lower() == 'part':
|
||||
|
||||
pk = None
|
||||
|
||||
# Try integer lookup first
|
||||
try:
|
||||
pk = int(self.data[k])
|
||||
except (TypeError, ValueError): # pragma: no cover
|
||||
pk = None
|
||||
|
||||
if pk is None: # pragma: no cover
|
||||
try:
|
||||
pk = self.data[k]['id']
|
||||
except (AttributeError, KeyError):
|
||||
raise ValidationError({k: 'id parameter not supplied'})
|
||||
|
||||
try:
|
||||
part = Part.objects.get(pk=pk)
|
||||
return part
|
||||
except (ValueError, Part.DoesNotExist): # pragma: no cover
|
||||
raise ValidationError({k: 'Part does not exist'})
|
||||
|
||||
return None
|
||||
if instance is not None:
|
||||
return self.format_matched_response(label, model, instance)
|
||||
|
@ -2,8 +2,8 @@
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from rest_framework import status
|
||||
|
||||
import part.models
|
||||
import stock.models
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
|
||||
|
||||
@ -14,21 +14,24 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'stock'
|
||||
'stock',
|
||||
'company',
|
||||
'supplier_part',
|
||||
]
|
||||
|
||||
def test_errors(self):
|
||||
"""Test all possible error cases for assigment action."""
|
||||
def test_assign_errors(self):
|
||||
"""Test error cases for assigment action."""
|
||||
|
||||
def test_assert_error(barcode_data):
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
reverse('api-barcode-link'), format='json',
|
||||
data={
|
||||
'barcode': barcode_data,
|
||||
'stockitem': 521
|
||||
}
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
self.assertIn('error', response.data)
|
||||
|
||||
# test with already existing stock
|
||||
@ -40,11 +43,358 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
|
||||
# test with already existing part location
|
||||
test_assert_error('{"part": 10004}')
|
||||
|
||||
# test with hash
|
||||
test_assert_error('{"blbla": 10004}')
|
||||
def assign(self, data, expected_code=None):
|
||||
"""Peform a 'barcode assign' request"""
|
||||
|
||||
return self.post(
|
||||
reverse('api-barcode-link'),
|
||||
data=data,
|
||||
expected_code=expected_code
|
||||
)
|
||||
|
||||
def unassign(self, data, expected_code=None):
|
||||
"""Perform a 'barcode unassign' request"""
|
||||
|
||||
return self.post(
|
||||
reverse('api-barcode-unlink'),
|
||||
data=data,
|
||||
expected_code=expected_code,
|
||||
)
|
||||
|
||||
def scan(self, data, expected_code=None):
|
||||
"""Perform a 'scan' operation"""
|
||||
|
||||
return self.post(
|
||||
reverse('api-barcode-scan'),
|
||||
data=data,
|
||||
expected_code=expected_code
|
||||
)
|
||||
|
||||
def test_unassign_errors(self):
|
||||
"""Test various error conditions for the barcode unassign endpoint"""
|
||||
|
||||
# Fail without any fields provided
|
||||
response = self.unassign(
|
||||
{},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('Missing data: Provide one of', str(response.data['error']))
|
||||
|
||||
# Fail with too many fields provided
|
||||
response = self.unassign(
|
||||
{
|
||||
'stockitem': 'abcde',
|
||||
'part': 'abcde',
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('Multiple conflicting fields:', str(response.data['error']))
|
||||
|
||||
# Fail with an invalid StockItem instance
|
||||
response = self.unassign(
|
||||
{
|
||||
'stockitem': 'invalid',
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('No match found', str(response.data['stockitem']))
|
||||
|
||||
# Fail with an invalid Part instance
|
||||
response = self.unassign(
|
||||
{
|
||||
'part': 'invalid',
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('No match found', str(response.data['part']))
|
||||
|
||||
def test_assign_to_stock_item(self):
|
||||
"""Test that we can assign a unique barcode to a StockItem object"""
|
||||
|
||||
# Test without providing any fields
|
||||
response = self.assign(
|
||||
{
|
||||
'barcode': 'abcde',
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('Missing data:', str(response.data))
|
||||
|
||||
# Provide too many fields
|
||||
response = self.assign(
|
||||
{
|
||||
'barcode': 'abcdefg',
|
||||
'part': 1,
|
||||
'stockitem': 1,
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
self.assertIn('Assigned barcode to part instance', str(response.data))
|
||||
self.assertEqual(response.data['part']['pk'], 1)
|
||||
|
||||
bc_data = '{"blbla": 10007}'
|
||||
|
||||
# Assign a barcode to a StockItem instance
|
||||
response = self.assign(
|
||||
data={
|
||||
'barcode': bc_data,
|
||||
'stockitem': 521,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
data = response.data
|
||||
self.assertEqual(data['barcode_data'], bc_data)
|
||||
self.assertEqual(data['stockitem']['pk'], 521)
|
||||
|
||||
# Check that the StockItem instance has actually been updated
|
||||
si = stock.models.StockItem.objects.get(pk=521)
|
||||
|
||||
self.assertEqual(si.barcode_data, bc_data)
|
||||
self.assertEqual(si.barcode_hash, "2f5dba5c83a360599ba7665b2a4131c6")
|
||||
|
||||
# Now test that we cannot assign this barcode to something else
|
||||
response = self.assign(
|
||||
data={
|
||||
'barcode': bc_data,
|
||||
'stockitem': 1,
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('Barcode matches existing item', str(response.data))
|
||||
|
||||
# Next, test that we can 'unassign' the barcode via the API
|
||||
response = self.unassign(
|
||||
{
|
||||
'stockitem': 521,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
si.refresh_from_db()
|
||||
|
||||
self.assertEqual(si.barcode_data, '')
|
||||
self.assertEqual(si.barcode_hash, '')
|
||||
|
||||
def test_assign_to_part(self):
|
||||
"""Test that we can assign a unique barcode to a Part instance"""
|
||||
|
||||
barcode = 'xyz-123'
|
||||
|
||||
# Test that an initial scan yields no results
|
||||
response = self.scan(
|
||||
{
|
||||
'barcode': barcode,
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
# Attempt to assign to an invalid part ID
|
||||
response = self.assign(
|
||||
{
|
||||
'barcode': barcode,
|
||||
'part': 99999999,
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('No matching part instance found in database', str(response.data))
|
||||
|
||||
# Test assigning to a valid part (should pass)
|
||||
response = self.assign(
|
||||
{
|
||||
'barcode': barcode,
|
||||
'part': 1,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(response.data['part']['pk'], 1)
|
||||
self.assertEqual(response.data['success'], 'Assigned barcode to part instance')
|
||||
|
||||
# Check that the Part instance has been updated
|
||||
p = part.models.Part.objects.get(pk=1)
|
||||
self.assertEqual(p.barcode_data, 'xyz-123')
|
||||
self.assertEqual(p.barcode_hash, 'bc39d07e9a395c7b5658c231bf910fae')
|
||||
|
||||
# Scanning the barcode should now reveal the 'Part' instance
|
||||
response = self.scan(
|
||||
{
|
||||
'barcode': barcode,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
def test_scan(self):
|
||||
"""Test that a barcode can be scanned."""
|
||||
response = self.client.post(reverse('api-barcode-scan'), format='json', data={'barcode': 'blbla=10004'})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn('success', response.data)
|
||||
self.assertEqual(response.data['plugin'], 'InvenTreeExternalBarcode')
|
||||
self.assertEqual(response.data['part']['pk'], 1)
|
||||
|
||||
# Attempting to assign the same barcode to a different part should result in an error
|
||||
response = self.assign(
|
||||
{
|
||||
'barcode': barcode,
|
||||
'part': 2,
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('Barcode matches existing item', str(response.data['error']))
|
||||
|
||||
# Now test that we can unassign the barcode data also
|
||||
response = self.unassign(
|
||||
{
|
||||
'part': 1,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
p.refresh_from_db()
|
||||
|
||||
self.assertEqual(p.barcode_data, '')
|
||||
self.assertEqual(p.barcode_hash, '')
|
||||
|
||||
def test_assign_to_location(self):
|
||||
"""Test that we can assign a unique barcode to a StockLocation instance"""
|
||||
|
||||
barcode = '555555555555555555555555'
|
||||
|
||||
# Assign random barcode data to a StockLocation instance
|
||||
response = self.assign(
|
||||
data={
|
||||
'barcode': barcode,
|
||||
'stocklocation': 1,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertIn('success', response.data)
|
||||
self.assertEqual(response.data['stocklocation']['pk'], 1)
|
||||
|
||||
# Check that the StockLocation instance has been updated
|
||||
loc = stock.models.StockLocation.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(loc.barcode_data, barcode)
|
||||
self.assertEqual(loc.barcode_hash, '4aa63f5e55e85c1f842796bf74896dbb')
|
||||
|
||||
# Check that an error is thrown if we try to assign the same value again
|
||||
response = self.assign(
|
||||
data={
|
||||
'barcode': barcode,
|
||||
'stocklocation': 2,
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('Barcode matches existing item', str(response.data['error']))
|
||||
|
||||
# Now, unassign the barcode
|
||||
response = self.unassign(
|
||||
{
|
||||
'stocklocation': 1,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
loc.refresh_from_db()
|
||||
self.assertEqual(loc.barcode_data, '')
|
||||
self.assertEqual(loc.barcode_hash, '')
|
||||
|
||||
def test_scan_third_party(self):
|
||||
"""Test scanning of third-party barcodes"""
|
||||
|
||||
# First scanned barcode is for a 'third-party' barcode (which does not exist)
|
||||
response = self.scan({'barcode': 'blbla=10008'}, expected_code=400)
|
||||
self.assertEqual(response.data['error'], 'No match found for barcode data')
|
||||
|
||||
# Next scanned barcode is for a 'third-party' barcode (which does exist)
|
||||
response = self.scan({'barcode': 'blbla=10004'}, expected_code=200)
|
||||
|
||||
self.assertEqual(response.data['barcode_data'], 'blbla=10004')
|
||||
self.assertEqual(response.data['plugin'], 'InvenTreeExternalBarcode')
|
||||
|
||||
# Scan for a StockItem instance
|
||||
si = stock.models.StockItem.objects.get(pk=1)
|
||||
|
||||
for barcode in ['abcde', 'ABCDE', '12345']:
|
||||
si.assign_barcode(barcode_data=barcode)
|
||||
|
||||
response = self.scan(
|
||||
{
|
||||
'barcode': barcode,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertIn('success', response.data)
|
||||
self.assertEqual(response.data['stockitem']['pk'], 1)
|
||||
|
||||
def test_scan_inventree(self):
|
||||
"""Test scanning of first-party barcodes"""
|
||||
|
||||
# Scan a StockItem object (which does not exist)
|
||||
response = self.scan(
|
||||
{
|
||||
'barcode': '{"stockitem": 5}',
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('No match found for barcode data', str(response.data))
|
||||
|
||||
# Scan a StockItem object (which does exist)
|
||||
response = self.scan(
|
||||
{
|
||||
'barcode': '{"stockitem": 1}',
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
self.assertIn('success', response.data)
|
||||
self.assertIn('stockitem', response.data)
|
||||
self.assertEqual(response.data['stockitem']['pk'], 1)
|
||||
|
||||
# Scan a StockLocation object
|
||||
response = self.scan(
|
||||
{
|
||||
'barcode': '{"stocklocation": 5}',
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertIn('success', response.data)
|
||||
self.assertEqual(response.data['stocklocation']['pk'], 5)
|
||||
self.assertEqual(response.data['stocklocation']['api_url'], '/api/stock/location/5/')
|
||||
self.assertEqual(response.data['stocklocation']['web_url'], '/stock/location/5/')
|
||||
self.assertEqual(response.data['plugin'], 'InvenTreeInternalBarcode')
|
||||
|
||||
# Scan a Part object
|
||||
response = self.scan(
|
||||
{
|
||||
'barcode': '{"part": 5}'
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(response.data['part']['pk'], 5)
|
||||
|
||||
# Scan a SupplierPart instance
|
||||
response = self.scan(
|
||||
{
|
||||
'barcode': '{"supplierpart": 1}',
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual(response.data['supplierpart']['pk'], 1)
|
||||
self.assertEqual(response.data['plugin'], 'InvenTreeInternalBarcode')
|
||||
|
||||
self.assertIn('success', response.data)
|
||||
self.assertIn('barcode_data', response.data)
|
||||
self.assertIn('barcode_hash', response.data)
|
||||
|
@ -74,7 +74,7 @@ table td.expand {
|
||||
|
||||
<div class='header-right'>
|
||||
<h3>{% trans "Purchase Order" %} {{ prefix }}{{ reference }}</h3>
|
||||
{% if supplier %}{{ supplier.name }}{% endif %}{% else %}{% trans "Supplier was deleted" %}{% endif %}
|
||||
{% if supplier %}{{ supplier.name }}{% else %}{% trans "Supplier was deleted" %}{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
@ -222,7 +222,7 @@
|
||||
lft: 0
|
||||
rght: 0
|
||||
expiry_date: "1990-10-10"
|
||||
uid: 9e5ae7fc20568ed4814c10967bba8b65
|
||||
barcode_hash: 9e5ae7fc20568ed4814c10967bba8b65
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 521
|
||||
@ -236,7 +236,7 @@
|
||||
lft: 0
|
||||
rght: 0
|
||||
status: 60
|
||||
uid: 1be0dfa925825c5c6c79301449e50c2d
|
||||
barcode_hash: 1be0dfa925825c5c6c79301449e50c2d
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 522
|
||||
|
23
InvenTree/stock/migrations/0084_auto_20220903_0154.py
Normal file
23
InvenTree/stock/migrations/0084_auto_20220903_0154.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.15 on 2022-09-03 01:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0083_stocklocation_icon'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stockitem',
|
||||
name='barcode_data',
|
||||
field=models.CharField(blank=True, help_text='Third party barcode data', max_length=500, verbose_name='Barcode Data'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='stockitem',
|
||||
name='barcode_hash',
|
||||
field=models.CharField(blank=True, help_text='Unique hash of barcode data', max_length=128, verbose_name='Barcode Hash'),
|
||||
),
|
||||
]
|
48
InvenTree/stock/migrations/0085_auto_20220903_0225.py
Normal file
48
InvenTree/stock/migrations/0085_auto_20220903_0225.py
Normal file
@ -0,0 +1,48 @@
|
||||
# Generated by Django 3.2.15 on 2022-09-03 02:25
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def uid_to_barcode(apps, schama_editor):
|
||||
"""Migrate old 'uid' field to new 'barcode_hash' field"""
|
||||
|
||||
StockItem = apps.get_model('stock', 'stockitem')
|
||||
|
||||
# Find all StockItem objects with non-empty UID field
|
||||
items = StockItem.objects.exclude(uid=None).exclude(uid='')
|
||||
|
||||
for item in items:
|
||||
item.barcode_hash = item.uid
|
||||
item.save()
|
||||
|
||||
if items.count() > 0:
|
||||
print(f"Updated barcode data for {items.count()} StockItem objects")
|
||||
|
||||
def barcode_to_uid(apps, schema_editor):
|
||||
"""Migrate new 'barcode_hash' field to old 'uid' field"""
|
||||
|
||||
StockItem = apps.get_model('stock', 'stockitem')
|
||||
|
||||
# Find all StockItem objects with non-empty UID field
|
||||
items = StockItem.objects.exclude(barcode_hash=None).exclude(barcode_hash='')
|
||||
|
||||
for item in items:
|
||||
item.uid = item.barcode_hash
|
||||
item.save()
|
||||
|
||||
if items.count() > 0:
|
||||
print(f"Updated barcode data for {items.count()} StockItem objects")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0084_auto_20220903_0154'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
uid_to_barcode,
|
||||
reverse_code=barcode_to_uid
|
||||
)
|
||||
]
|
17
InvenTree/stock/migrations/0086_remove_stockitem_uid.py
Normal file
17
InvenTree/stock/migrations/0086_remove_stockitem_uid.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.2.15 on 2022-09-03 02:54
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0085_auto_20220903_0225'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='stockitem',
|
||||
name='uid',
|
||||
),
|
||||
]
|
23
InvenTree/stock/migrations/0087_auto_20220912_2341.py
Normal file
23
InvenTree/stock/migrations/0087_auto_20220912_2341.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.15 on 2022-09-12 23:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0086_remove_stockitem_uid'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stocklocation',
|
||||
name='barcode_data',
|
||||
field=models.CharField(blank=True, help_text='Third party barcode data', max_length=500, verbose_name='Barcode Data'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='stocklocation',
|
||||
name='barcode_hash',
|
||||
field=models.CharField(blank=True, help_text='Unique hash of barcode data', max_length=128, verbose_name='Barcode Hash'),
|
||||
),
|
||||
]
|
@ -30,7 +30,8 @@ import report.models
|
||||
from company import models as CompanyModels
|
||||
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
|
||||
InvenTreeURLField)
|
||||
from InvenTree.models import InvenTreeAttachment, InvenTreeTree, extract_int
|
||||
from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin,
|
||||
InvenTreeTree, extract_int)
|
||||
from InvenTree.status_codes import StockHistoryCode, StockStatus
|
||||
from part import models as PartModels
|
||||
from plugin.events import trigger_event
|
||||
@ -38,7 +39,7 @@ from plugin.models import MetadataMixin
|
||||
from users.models import Owner
|
||||
|
||||
|
||||
class StockLocation(MetadataMixin, InvenTreeTree):
|
||||
class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
|
||||
"""Organization tree for StockItem objects.
|
||||
|
||||
A "StockLocation" can be considered a warehouse, or storage location
|
||||
@ -126,27 +127,6 @@ class StockLocation(MetadataMixin, InvenTreeTree):
|
||||
"""Return url for instance."""
|
||||
return reverse('stock-location-detail', kwargs={'pk': self.id})
|
||||
|
||||
def format_barcode(self, **kwargs):
|
||||
"""Return a JSON string for formatting a barcode for this StockLocation object."""
|
||||
return InvenTree.helpers.MakeBarcode(
|
||||
'stocklocation',
|
||||
self.pk,
|
||||
{
|
||||
"name": self.name,
|
||||
"url": reverse('api-location-detail', kwargs={'pk': self.id}),
|
||||
},
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@property
|
||||
def barcode(self) -> str:
|
||||
"""Get Brief payload data (e.g. for labels).
|
||||
|
||||
Returns:
|
||||
str: Brief pyload data
|
||||
"""
|
||||
return self.format_barcode(brief=True)
|
||||
|
||||
def get_stock_items(self, cascade=True):
|
||||
"""Return a queryset for all stock items under this category.
|
||||
|
||||
@ -221,12 +201,11 @@ def generate_batch_code():
|
||||
return Template(batch_template).render(context)
|
||||
|
||||
|
||||
class StockItem(MetadataMixin, MPTTModel):
|
||||
class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
"""A StockItem object represents a quantity of physical instances of a part.
|
||||
|
||||
Attributes:
|
||||
parent: Link to another StockItem from which this StockItem was created
|
||||
uid: Field containing a unique-id which is mapped to a third-party identifier (e.g. a barcode)
|
||||
part: Link to the master abstract part that this StockItem is an instance of
|
||||
supplier_part: Link to a specific SupplierPart (optional)
|
||||
location: Where this StockItem is located
|
||||
@ -552,38 +531,6 @@ class StockItem(MetadataMixin, MPTTModel):
|
||||
"""Returns part name."""
|
||||
return self.part.full_name
|
||||
|
||||
def format_barcode(self, **kwargs):
|
||||
"""Return a JSON string for formatting a barcode for this StockItem.
|
||||
|
||||
Can be used to perform lookup of a stockitem using barcode.
|
||||
|
||||
Contains the following data:
|
||||
`{ type: 'StockItem', stock_id: <pk>, part_id: <part_pk> }`
|
||||
|
||||
Voltagile data (e.g. stock quantity) should be looked up using the InvenTree API (as it may change)
|
||||
"""
|
||||
return InvenTree.helpers.MakeBarcode(
|
||||
"stockitem",
|
||||
self.id,
|
||||
{
|
||||
"request": kwargs.get('request', None),
|
||||
"item_url": reverse('stock-item-detail', kwargs={'pk': self.id}),
|
||||
"url": reverse('api-stock-detail', kwargs={'pk': self.id}),
|
||||
},
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@property
|
||||
def barcode(self):
|
||||
"""Get Brief payload data (e.g. for labels).
|
||||
|
||||
Returns:
|
||||
str: Brief pyload data
|
||||
"""
|
||||
return self.format_barcode(brief=True)
|
||||
|
||||
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',
|
||||
|
@ -62,7 +62,7 @@ class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
'quantity',
|
||||
'serial',
|
||||
'supplier_part',
|
||||
'uid',
|
||||
'barcode_hash',
|
||||
]
|
||||
|
||||
def validate_serial(self, value):
|
||||
@ -245,7 +245,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
'supplier_part',
|
||||
'supplier_part_detail',
|
||||
'tracking_items',
|
||||
'uid',
|
||||
'barcode_hash',
|
||||
'updated',
|
||||
'purchase_price',
|
||||
'purchase_price_currency',
|
||||
|
@ -44,7 +44,7 @@
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||
{% if roles.stock.change %}
|
||||
{% if item.uid %}
|
||||
{% if item.barcode_hash %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-unlink'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
|
||||
{% else %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-link'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
|
||||
@ -149,14 +149,17 @@
|
||||
{% if roles.part.view %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if item.part.description %}
|
||||
<span class='fas fa-info-circle icon-blue float-right' title='{{ item.part.description }}'></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% if item.uid %}
|
||||
{% if item.barcode_hash %}
|
||||
<tr>
|
||||
<td><span class='fas fa-barcode'></span></td>
|
||||
<td>{% trans "Barcode Identifier" %}</td>
|
||||
<td>{{ item.uid }}</td>
|
||||
<td {% if item.barcode_data %}title='{{ item.barcode_data }}'{% endif %}>{{ item.barcode_hash }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if item.batch %}
|
||||
@ -345,7 +348,7 @@
|
||||
<h5>{% trans "Available Quantity" %}</h5>
|
||||
</td>
|
||||
<td>
|
||||
<h5>{% if item.quantity != available %}{% decimal available %} / {% endif %}{% decimal item.quantity %} {% if item.part.units %}{{ item.part.units }}{% endif %}</h5>
|
||||
<h5>{% if item.quantity != available %}{% decimal available %} / {% endif %}{% decimal item.quantity %} {% include "part/part_units.html" with part=item.part %}</h5>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
@ -526,12 +529,22 @@ $("#show-qr-code").click(function() {
|
||||
});
|
||||
});
|
||||
|
||||
{% if barcodes %}
|
||||
$("#barcode-link").click(function() {
|
||||
linkBarcodeDialog({{ item.id }});
|
||||
linkBarcodeDialog(
|
||||
{
|
||||
stockitem: {{ item.pk }},
|
||||
},
|
||||
{
|
||||
title: '{% trans "Link Barcode to Stock Item" %}',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#barcode-unlink").click(function() {
|
||||
unlinkBarcode({{ item.id }});
|
||||
unlinkBarcode({
|
||||
stockitem: {{ item.pk }},
|
||||
});
|
||||
});
|
||||
|
||||
$("#barcode-scan-into-location").click(function() {
|
||||
@ -542,6 +555,7 @@ $("#barcode-scan-into-location").click(function() {
|
||||
}
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if plugins_enabled %}
|
||||
$('#locate-item-button').click(function() {
|
||||
|
@ -48,6 +48,11 @@
|
||||
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
|
||||
<ul class='dropdown-menu'>
|
||||
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||
{% if location.barcode_hash %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-unlink'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
|
||||
{% else %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-link'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
|
||||
{% endif %}
|
||||
{% if labels_enabled %}
|
||||
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
||||
{% endif %}
|
||||
@ -135,6 +140,13 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if location and location.barcode_hash %}
|
||||
<tr>
|
||||
<td><span class='fas fa-barcode'></span></td>
|
||||
<td>{% trans "Barcode Identifier" %}</td>
|
||||
<td {% if location.barcode_data %}title='{{ location.barcode_data }}'{% endif %}>{{ location.barcode_hash }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endblock details_left %}
|
||||
|
||||
@ -335,6 +347,7 @@
|
||||
adjustLocationStock('move');
|
||||
});
|
||||
|
||||
{% if barcodes %}
|
||||
$('#show-qr-code').click(function() {
|
||||
launchModalForm("{% url 'stock-location-qr' location.id %}",
|
||||
{
|
||||
@ -342,6 +355,26 @@
|
||||
});
|
||||
});
|
||||
|
||||
$("#barcode-link").click(function() {
|
||||
linkBarcodeDialog(
|
||||
{
|
||||
stocklocation: {{ location.pk }},
|
||||
},
|
||||
{
|
||||
title: '{% trans "Link Barcode to Stock Location" %}',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#barcode-unlink").click(function() {
|
||||
unlinkBarcode({
|
||||
stocklocation: {{ location.pk }},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
$('#item-create').click(function () {
|
||||
|
@ -49,7 +49,7 @@ class StockDetailTest(StockViewTestCase):
|
||||
|
||||
# Quantity
|
||||
self.assertIn('<h5>Available Quantity</h5>', html)
|
||||
self.assertIn('<h5>4000 </h5>', html)
|
||||
self.assertIn('<h5>4000', html)
|
||||
|
||||
# Batch code
|
||||
self.assertIn('Batch', html)
|
||||
|
@ -14,8 +14,8 @@ from .models import (StockItem, StockItemTestResult, StockItemTracking,
|
||||
StockLocation)
|
||||
|
||||
|
||||
class StockTest(InvenTreeTestCase):
|
||||
"""Tests to ensure that the stock location tree functions correcly."""
|
||||
class StockTestBase(InvenTreeTestCase):
|
||||
"""Base class for running Stock tests"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
@ -44,6 +44,10 @@ class StockTest(InvenTreeTestCase):
|
||||
Part.objects.rebuild()
|
||||
StockItem.objects.rebuild()
|
||||
|
||||
|
||||
class StockTest(StockTestBase):
|
||||
"""Tests to ensure that the stock location tree functions correcly."""
|
||||
|
||||
def test_link(self):
|
||||
"""Test the link URL field validation"""
|
||||
|
||||
@ -151,12 +155,6 @@ class StockTest(InvenTreeTestCase):
|
||||
|
||||
self.assertEqual(self.home.get_absolute_url(), '/stock/location/1/')
|
||||
|
||||
def test_barcode(self):
|
||||
"""Test format_barcode."""
|
||||
barcode = self.office.format_barcode(brief=False)
|
||||
|
||||
self.assertIn('"name": "Office"', barcode)
|
||||
|
||||
def test_strings(self):
|
||||
"""Test str function."""
|
||||
it = StockItem.objects.get(pk=1)
|
||||
@ -724,7 +722,38 @@ class StockTest(InvenTreeTestCase):
|
||||
self.assertEqual(C22.get_ancestors().count(), 1)
|
||||
|
||||
|
||||
class VariantTest(StockTest):
|
||||
class StockBarcodeTest(StockTestBase):
|
||||
"""Run barcode tests for the stock app"""
|
||||
|
||||
def test_stock_item_barcode_basics(self):
|
||||
"""Simple tests for the StockItem barcode integration"""
|
||||
|
||||
item = StockItem.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(StockItem.barcode_model_type(), 'stockitem')
|
||||
|
||||
# Call format_barcode method
|
||||
barcode = item.format_barcode(brief=False)
|
||||
|
||||
for key in ['tool', 'version', 'instance', 'stockitem']:
|
||||
self.assertIn(key, barcode)
|
||||
|
||||
# Render simple barcode data for the StockItem
|
||||
barcode = item.barcode
|
||||
self.assertEqual(barcode, '{"stockitem": 1}')
|
||||
|
||||
def test_location_barcode_basics(self):
|
||||
"""Simple tests for the StockLocation barcode integration"""
|
||||
|
||||
self.assertEqual(StockLocation.barcode_model_type(), 'stocklocation')
|
||||
|
||||
loc = StockLocation.objects.get(pk=1)
|
||||
|
||||
barcode = loc.format_barcode(brief=True)
|
||||
self.assertEqual('{"stocklocation": 1}', barcode)
|
||||
|
||||
|
||||
class VariantTest(StockTestBase):
|
||||
"""Tests for calculation stock counts against templates / variants."""
|
||||
|
||||
def test_variant_stock(self):
|
||||
@ -805,7 +834,7 @@ class VariantTest(StockTest):
|
||||
item.save()
|
||||
|
||||
|
||||
class TestResultTest(StockTest):
|
||||
class TestResultTest(StockTestBase):
|
||||
"""Tests for the StockItemTestResult model."""
|
||||
|
||||
def test_test_count(self):
|
||||
|
@ -11,12 +11,10 @@
|
||||
{% trans 'Site is in Maintenance' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}login-screen{% endblock %}
|
||||
{% block body_class %}login-screen' style='background: url({% inventree_splash %}); background-size: cover;{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<!--
|
||||
Background Image Attribution: https://unsplash.com/photos/Ixvv3YZkd7w
|
||||
-->
|
||||
|
||||
<div class='container-fluid'>
|
||||
<div class='notification-area' id='alerts'>
|
||||
<!-- Div for displayed alerts -->
|
||||
|
@ -25,9 +25,9 @@
|
||||
<a href='{{ line.link }}'>{{ line.part.full_name }}</a>{% if part.description %} - <em>{{ part.description }}</em>{% endif %}
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
{% decimal line.required %} {% if line.part.units %}{{ line.part.units }}{% endif %}
|
||||
{% decimal line.required %} {% include "part/part_units.html" with part=line.part %}
|
||||
</td>
|
||||
<td style="text-align: center;">{% decimal line.available %} {% if line.part.units %}{{ line.part.units }}{% endif %}</td>
|
||||
<td style="text-align: center;">{% decimal line.available %} {% include "part/part_units.html" with part=line.part %}</td>
|
||||
</tr>
|
||||
|
||||
{% endfor %}
|
||||
|
@ -352,19 +352,17 @@ function barcodeScanDialog() {
|
||||
|
||||
|
||||
/*
|
||||
* Dialog for linking a particular barcode to a stock item.
|
||||
* Dialog for linking a particular barcode to a database model instsance
|
||||
*/
|
||||
function linkBarcodeDialog(stockitem) {
|
||||
function linkBarcodeDialog(data, options={}) {
|
||||
|
||||
var modal = '#modal-form';
|
||||
|
||||
barcodeDialog(
|
||||
'{% trans "Link Barcode to Stock Item" %}',
|
||||
options.title,
|
||||
{
|
||||
url: '/api/barcode/link/',
|
||||
data: {
|
||||
stockitem: stockitem,
|
||||
},
|
||||
data: data,
|
||||
onScan: function() {
|
||||
|
||||
$(modal).modal('hide');
|
||||
@ -376,13 +374,13 @@ function linkBarcodeDialog(stockitem) {
|
||||
|
||||
|
||||
/*
|
||||
* Remove barcode association from a device.
|
||||
* Remove barcode association from a database model instance.
|
||||
*/
|
||||
function unlinkBarcode(stockitem) {
|
||||
function unlinkBarcode(data, options={}) {
|
||||
|
||||
var html = `<b>{% trans "Unlink Barcode" %}</b><br>`;
|
||||
|
||||
html += '{% trans "This will remove the association between this stock item and the barcode" %}';
|
||||
html += '{% trans "This will remove the link to the associated barcode" %}';
|
||||
|
||||
showQuestionDialog(
|
||||
'{% trans "Unlink Barcode" %}',
|
||||
@ -391,13 +389,10 @@ function unlinkBarcode(stockitem) {
|
||||
accept_text: '{% trans "Unlink" %}',
|
||||
accept: function() {
|
||||
inventreePut(
|
||||
`/api/stock/${stockitem}/`,
|
||||
'/api/barcode/unlink/',
|
||||
data,
|
||||
{
|
||||
// Clear the UID field
|
||||
uid: '',
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
method: 'POST',
|
||||
success: function() {
|
||||
location.reload();
|
||||
},
|
||||
|
@ -868,6 +868,10 @@ function loadBomTable(table, options={}) {
|
||||
// Let's make it a bit more pretty
|
||||
text = parseFloat(text);
|
||||
|
||||
if (row.sub_part_detail && row.sub_part_detail.units) {
|
||||
text += ` <small>${row.sub_part_detail.units}</small>`;
|
||||
}
|
||||
|
||||
if (row.optional) {
|
||||
text += ' ({% trans "Optional" %})';
|
||||
}
|
||||
@ -923,6 +927,10 @@ function loadBomTable(table, options={}) {
|
||||
|
||||
var text = `${available_stock}`;
|
||||
|
||||
if (row.sub_part_detail && row.sub_part_detail.units) {
|
||||
text += ` <small>${row.sub_part_detail.units}</small>`;
|
||||
}
|
||||
|
||||
if (available_stock <= 0) {
|
||||
text += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "No Stock Available" %}'></span>`;
|
||||
} else {
|
||||
@ -1482,6 +1490,10 @@ function loadUsedInTable(table, part_id, options={}) {
|
||||
formatter: function(value, row) {
|
||||
var html = value;
|
||||
|
||||
if (row.sub_part_detail && row.sub_part_detail.units) {
|
||||
html += ` <small>${row.sub_part_detail.units}</small>`;
|
||||
}
|
||||
|
||||
if (row.parent && row.parent != 'top-level-item') {
|
||||
html += ` <em>({% trans "Inherited from parent BOM" %})</em>`;
|
||||
}
|
||||
|
@ -16,6 +16,7 @@
|
||||
deleteManufacturerParts,
|
||||
deleteManufacturerPartParameters,
|
||||
deleteSupplierParts,
|
||||
duplicateSupplierPart,
|
||||
editCompany,
|
||||
loadCompanyTable,
|
||||
loadManufacturerPartTable,
|
||||
@ -130,7 +131,8 @@ function supplierPartFields(options={}) {
|
||||
},
|
||||
packaging: {
|
||||
icon: 'fa-box',
|
||||
}
|
||||
},
|
||||
pack_size: {},
|
||||
};
|
||||
|
||||
if (options.part) {
|
||||
@ -198,6 +200,39 @@ function createSupplierPart(options={}) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Launch a modal form to duplicate an existing SupplierPart instance
|
||||
*/
|
||||
function duplicateSupplierPart(part, options={}) {
|
||||
|
||||
var fields = options.fields || supplierPartFields();
|
||||
|
||||
// Retrieve information for the supplied part
|
||||
inventreeGet(`/api/company/part/${part}/`, {}, {
|
||||
success: function(data) {
|
||||
|
||||
// Remove fields which we do not want to duplicate
|
||||
delete data['pk'];
|
||||
delete data['available'];
|
||||
delete data['availability_updated'];
|
||||
|
||||
constructForm(`/api/company/part/`, {
|
||||
method: 'POST',
|
||||
fields: fields,
|
||||
title: '{% trans "Duplicate Supplier Part" %}',
|
||||
data: data,
|
||||
onSuccess: function(response) {
|
||||
handleFormSuccess(response, options);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Launch a modal form to edit an existing SupplierPart instance
|
||||
*/
|
||||
function editSupplierPart(part, options={}) {
|
||||
|
||||
var fields = options.fields || supplierPartFields();
|
||||
@ -865,6 +900,7 @@ function loadSupplierPartTable(table, url, options) {
|
||||
switchable: params['part_detail'],
|
||||
sortable: true,
|
||||
field: 'part_detail.full_name',
|
||||
sortName: 'part',
|
||||
title: '{% trans "Part" %}',
|
||||
formatter: function(value, row) {
|
||||
|
||||
@ -915,6 +951,7 @@ function loadSupplierPartTable(table, url, options) {
|
||||
visible: params['manufacturer_detail'],
|
||||
switchable: params['manufacturer_detail'],
|
||||
sortable: true,
|
||||
sortName: 'manufacturer',
|
||||
field: 'manufacturer_detail.name',
|
||||
title: '{% trans "Manufacturer" %}',
|
||||
formatter: function(value, row) {
|
||||
@ -933,6 +970,7 @@ function loadSupplierPartTable(table, url, options) {
|
||||
visible: params['manufacturer_detail'],
|
||||
switchable: params['manufacturer_detail'],
|
||||
sortable: true,
|
||||
sortName: 'MPN',
|
||||
field: 'manufacturer_part_detail.MPN',
|
||||
title: '{% trans "MPN" %}',
|
||||
formatter: function(value, row) {
|
||||
@ -943,8 +981,33 @@ function loadSupplierPartTable(table, url, options) {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '{% trans "Description" %}',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
field: 'packaging',
|
||||
title: '{% trans "Packaging" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'pack_size',
|
||||
title: '{% trans "Pack Quantity" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
var output = `${value}`;
|
||||
|
||||
if (row.part_detail && row.part_detail.units) {
|
||||
output += ` ${row.part_detail.units}`;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'link',
|
||||
sortable: false,
|
||||
title: '{% trans "Link" %}',
|
||||
formatter: function(value) {
|
||||
if (value) {
|
||||
@ -954,21 +1017,11 @@ function loadSupplierPartTable(table, url, options) {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '{% trans "Description" %}',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
field: 'note',
|
||||
title: '{% trans "Notes" %}',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
field: 'packaging',
|
||||
title: '{% trans "Packaging" %}',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
field: 'in_stock',
|
||||
title: '{% trans "In Stock" %}',
|
||||
@ -976,7 +1029,7 @@ function loadSupplierPartTable(table, url, options) {
|
||||
},
|
||||
{
|
||||
field: 'available',
|
||||
title: '{% trans "Available" %}',
|
||||
title: '{% trans "Availability" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
if (row.availability_updated) {
|
||||
|
@ -794,6 +794,35 @@ function poLineItemFields(options={}) {
|
||||
supplier_detail: true,
|
||||
supplier: options.supplier,
|
||||
},
|
||||
onEdit: function(value, name, field, opts) {
|
||||
// If the pack_size != 1, add a note to the field
|
||||
var pack_size = 1;
|
||||
var units = '';
|
||||
|
||||
// Remove any existing note fields
|
||||
$(opts.modal).find('#info-pack-size').remove();
|
||||
|
||||
if (value != null) {
|
||||
inventreeGet(`/api/company/part/${value}/`,
|
||||
{
|
||||
part_detail: true,
|
||||
},
|
||||
{
|
||||
success: function(response) {
|
||||
// Extract information from the returned query
|
||||
pack_size = response.pack_size || 1;
|
||||
units = response.part_detail.units || '';
|
||||
},
|
||||
}
|
||||
).then(function() {
|
||||
|
||||
if (pack_size != 1) {
|
||||
var txt = `<span class='fas fa-info-circle icon-blue'></span> {% trans "Pack Quantity" %}: ${pack_size} ${units}`;
|
||||
$(opts.modal).find('#hint_id_quantity').after(`<div class='form-info-message' id='info-pack-size'>${txt}</div>`);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
secondary: {
|
||||
method: 'POST',
|
||||
title: '{% trans "Add Supplier Part" %}',
|
||||
@ -1151,16 +1180,46 @@ function orderParts(parts_list, options={}) {
|
||||
afterRender: function(fields, opts) {
|
||||
parts.forEach(function(part) {
|
||||
|
||||
var pk = part.pk;
|
||||
|
||||
// Filter by base part
|
||||
supplier_part_filters.part = part.pk;
|
||||
supplier_part_filters.part = pk;
|
||||
|
||||
if (part.manufacturer_part) {
|
||||
// Filter by manufacturer part
|
||||
supplier_part_filters.manufacturer_part = part.manufacturer_part;
|
||||
}
|
||||
|
||||
// Configure the "supplier part" field
|
||||
initializeRelatedField({
|
||||
// Callback function when supplier part is changed
|
||||
// This is used to update the "pack size" attribute
|
||||
var onSupplierPartChanged = function(value, name, field, opts) {
|
||||
var pack_size = 1;
|
||||
var units = '';
|
||||
|
||||
$(opts.modal).find(`#info-pack-size-${pk}`).remove();
|
||||
|
||||
if (value != null) {
|
||||
inventreeGet(
|
||||
`/api/company/part/${value}/`,
|
||||
{
|
||||
part_detail: true,
|
||||
},
|
||||
{
|
||||
success: function(response) {
|
||||
pack_size = response.pack_size || 1;
|
||||
units = response.part_detail.units || '';
|
||||
}
|
||||
}
|
||||
).then(function() {
|
||||
if (pack_size != 1) {
|
||||
var txt = `<span class='fas fa-info-circle icon-blue'></span> {% trans "Pack Quantity" %}: ${pack_size} ${units}`;
|
||||
$(opts.modal).find(`#id_quantity_${pk}`).after(`<div class='form-info-message' id='info-pack-size-${pk}'>${txt}</div>`);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var supplier_part_field = {
|
||||
name: `part_${part.pk}`,
|
||||
model: 'supplierpart',
|
||||
api_url: '{% url "api-supplier-part-list" %}',
|
||||
@ -1169,10 +1228,15 @@ function orderParts(parts_list, options={}) {
|
||||
auto_fill: true,
|
||||
value: options.supplier_part,
|
||||
filters: supplier_part_filters,
|
||||
onEdit: onSupplierPartChanged,
|
||||
noResults: function(query) {
|
||||
return '{% trans "No matching supplier parts" %}';
|
||||
}
|
||||
}, null, opts);
|
||||
};
|
||||
|
||||
// Configure the "supplier part" field
|
||||
initializeRelatedField(supplier_part_field, null, opts);
|
||||
addFieldCallback(`part_${part.pk}`, supplier_part_field, opts);
|
||||
|
||||
// Configure the "purchase order" field
|
||||
initializeRelatedField({
|
||||
@ -1394,6 +1458,20 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
|
||||
</span>
|
||||
`;
|
||||
|
||||
var units = line_item.part_detail.units || '';
|
||||
var pack_size = line_item.supplier_part_detail.pack_size || 1;
|
||||
var pack_size_div = '';
|
||||
|
||||
var received = quantity * pack_size;
|
||||
|
||||
if (pack_size != 1) {
|
||||
pack_size_div = `
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% trans "Pack Quantity" %}: ${pack_size} ${units}<br>
|
||||
{% trans "Received Quantity" %}: <span class='pack_received_quantity' id='items_received_quantity_${pk}'>${received}</span> ${units}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Quantity to Receive
|
||||
var quantity_input = constructField(
|
||||
`items_quantity_${pk}`,
|
||||
@ -1433,7 +1511,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
|
||||
);
|
||||
|
||||
// Hidden inputs below the "quantity" field
|
||||
var quantity_input_group = `${quantity_input}<div class='collapse' id='div-batch-${pk}'>${batch_input}</div>`;
|
||||
var quantity_input_group = `${quantity_input}${pack_size_div}<div class='collapse' id='div-batch-${pk}'>${batch_input}</div>`;
|
||||
|
||||
if (line_item.part_detail.trackable) {
|
||||
quantity_input_group += `<div class='collapse' id='div-serials-${pk}'>${sn_input}</div>`;
|
||||
@ -1545,7 +1623,9 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
|
||||
var table_entries = '';
|
||||
|
||||
line_items.forEach(function(item) {
|
||||
table_entries += renderLineItem(item);
|
||||
if (item.received < item.quantity) {
|
||||
table_entries += renderLineItem(item);
|
||||
}
|
||||
});
|
||||
|
||||
var html = ``;
|
||||
@ -1581,7 +1661,8 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
|
||||
confirmMessage: '{% trans "Confirm receipt of items" %}',
|
||||
title: '{% trans "Receive Purchase Order Items" %}',
|
||||
afterRender: function(fields, opts) {
|
||||
// Initialize the "destination" field for each item
|
||||
|
||||
// Run initialization routines for each line in the form
|
||||
line_items.forEach(function(item) {
|
||||
|
||||
var pk = item.pk;
|
||||
@ -1602,18 +1683,21 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
|
||||
render_description: false,
|
||||
};
|
||||
|
||||
// Initialize the location field
|
||||
initializeRelatedField(
|
||||
field_details,
|
||||
null,
|
||||
opts,
|
||||
);
|
||||
|
||||
// Add 'clear' button callback for the location field
|
||||
addClearCallback(
|
||||
name,
|
||||
field_details,
|
||||
opts
|
||||
);
|
||||
|
||||
// Setup stock item status field
|
||||
initializeChoiceField(
|
||||
{
|
||||
name: `items_status_${pk}`,
|
||||
@ -1621,6 +1705,19 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
|
||||
null,
|
||||
opts
|
||||
);
|
||||
|
||||
// Add change callback for quantity field
|
||||
if (item.supplier_part_detail.pack_size != 1) {
|
||||
$(opts.modal).find(`#id_items_quantity_${pk}`).change(function() {
|
||||
var value = $(opts.modal).find(`#id_items_quantity_${pk}`).val();
|
||||
|
||||
var el = $(opts.modal).find(`#quantity_${pk}`).find('.pack_received_quantity');
|
||||
|
||||
var actual = value * item.supplier_part_detail.pack_size;
|
||||
actual = formatDecimal(actual);
|
||||
el.text(actual);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add callbacks to remove rows
|
||||
@ -2158,6 +2255,23 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
switchable: false,
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}',
|
||||
formatter: function(value, row) {
|
||||
var units = '';
|
||||
|
||||
if (row.part_detail.units) {
|
||||
units = ` ${row.part_detail.units}`;
|
||||
}
|
||||
|
||||
var data = value;
|
||||
|
||||
if (row.supplier_part_detail.pack_size != 1.0) {
|
||||
var pack_size = row.supplier_part_detail.pack_size;
|
||||
var total = value * pack_size;
|
||||
data += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Pack Quantity" %}: ${pack_size}${units} - {% trans "Total Quantity" %}: ${total}${units}'></span>`;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
return data.map(function(row) {
|
||||
return +row['quantity'];
|
||||
@ -2166,6 +2280,21 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: false,
|
||||
switchable: true,
|
||||
field: 'supplier_part_detail.pack_size',
|
||||
title: '{% trans "Pack Quantity" %}',
|
||||
formatter: function(value, row) {
|
||||
var units = row.part_detail.units;
|
||||
|
||||
if (units) {
|
||||
value += ` ${units}`;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'purchase_price',
|
||||
|
@ -574,43 +574,41 @@ function duplicateBom(part_id, options={}) {
|
||||
function partStockLabel(part, options={}) {
|
||||
|
||||
// Prevent literal string 'null' from being displayed
|
||||
if (part.units == null) {
|
||||
part.units = '';
|
||||
}
|
||||
var units = part.units || '';
|
||||
|
||||
if (part.in_stock) {
|
||||
// There IS stock available for this part
|
||||
|
||||
// Is stock "low" (below the 'minimum_stock' quantity)?
|
||||
if ((part.minimum_stock > 0) && (part.minimum_stock > part.in_stock)) {
|
||||
return `<span class='badge rounded-pill bg-warning ${options.classes}'>{% trans "Low stock" %}: ${part.in_stock}${part.units}</span>`;
|
||||
return `<span class='badge rounded-pill bg-warning ${options.classes}'>{% trans "Low stock" %}: ${part.in_stock} ${units}</span>`;
|
||||
} else if (part.unallocated_stock == 0) {
|
||||
if (part.ordering) {
|
||||
// There is no available stock, but stock is on order
|
||||
return `<span class='badge rounded-pill bg-info ${options.classes}'>{% trans "On Order" %}: ${part.ordering}${part.units}</span>`;
|
||||
return `<span class='badge rounded-pill bg-info ${options.classes}'>{% trans "On Order" %}: ${part.ordering} ${units}</span>`;
|
||||
} else if (part.building) {
|
||||
// There is no available stock, but stock is being built
|
||||
return `<span class='badge rounded-pill bg-info ${options.classes}'>{% trans "Building" %}: ${part.building}${part.units}</span>`;
|
||||
return `<span class='badge rounded-pill bg-info ${options.classes}'>{% trans "Building" %}: ${part.building} ${units}</span>`;
|
||||
} else {
|
||||
// There is no available stock at all
|
||||
return `<span class='badge rounded-pill bg-warning ${options.classes}'>{% trans "No stock available" %}</span>`;
|
||||
}
|
||||
} else if (part.unallocated_stock < part.in_stock) {
|
||||
// Unallocated quanttiy is less than total quantity
|
||||
return `<span class='badge rounded-pill bg-success ${options.classes}'>{% trans "Available" %}: ${part.unallocated_stock}/${part.in_stock}${part.units}</span>`;
|
||||
return `<span class='badge rounded-pill bg-success ${options.classes}'>{% trans "Available" %}: ${part.unallocated_stock}/${part.in_stock} ${units}</span>`;
|
||||
} else {
|
||||
// Stock is completely available
|
||||
return `<span class='badge rounded-pill bg-success ${options.classes}'>{% trans "Available" %}: ${part.unallocated_stock}${part.units}</span>`;
|
||||
return `<span class='badge rounded-pill bg-success ${options.classes}'>{% trans "Available" %}: ${part.unallocated_stock} ${units}</span>`;
|
||||
}
|
||||
} else {
|
||||
// There IS NO stock available for this part
|
||||
|
||||
if (part.ordering) {
|
||||
// There is no stock, but stock is on order
|
||||
return `<span class='badge rounded-pill bg-info ${options.classes}'>{% trans "On Order" %}: ${part.ordering}${part.units}</span>`;
|
||||
return `<span class='badge rounded-pill bg-info ${options.classes}'>{% trans "On Order" %}: ${part.ordering} ${units}</span>`;
|
||||
} else if (part.building) {
|
||||
// There is no stock, but stock is being built
|
||||
return `<span class='badge rounded-pill bg-info ${options.classes}'>{% trans "Building" %}: ${part.building}${part.units}</span>`;
|
||||
return `<span class='badge rounded-pill bg-info ${options.classes}'>{% trans "Building" %}: ${part.building} ${units}</span>`;
|
||||
} else {
|
||||
// There is no stock
|
||||
return `<span class='badge rounded-pill bg-danger ${options.classes}'>{% trans "No Stock" %}</span>`;
|
||||
@ -1036,6 +1034,17 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
|
||||
{
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}',
|
||||
formatter: function(value, row) {
|
||||
var data = value;
|
||||
|
||||
if (row.supplier_part_detail.pack_size != 1.0) {
|
||||
var pack_size = row.supplier_part_detail.pack_size;
|
||||
var total = value * pack_size;
|
||||
data += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Pack Quantity" %}: ${pack_size} - {% trans "Total Quantity" %}: ${total}'></span>`;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'target_date',
|
||||
@ -1077,6 +1086,17 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
|
||||
field: 'received',
|
||||
title: '{% trans "Received" %}',
|
||||
switchable: true,
|
||||
formatter: function(value, row) {
|
||||
var data = value;
|
||||
|
||||
if (value > 0 && row.supplier_part_detail.pack_size != 1.0) {
|
||||
var pack_size = row.supplier_part_detail.pack_size;
|
||||
var total = value * pack_size;
|
||||
data += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Pack Quantity" %}: ${pack_size} - {% trans "Total Quantity" %}: ${total}'></span>`;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'purchase_price',
|
||||
@ -1302,22 +1322,23 @@ function partGridTile(part) {
|
||||
// Rows for table view
|
||||
var rows = '';
|
||||
|
||||
var stock = `${part.in_stock}`;
|
||||
var units = part.units || '';
|
||||
var stock = `${part.in_stock} ${units}`;
|
||||
|
||||
if (!part.in_stock) {
|
||||
stock = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock" %}</span>`;
|
||||
} else if (!part.unallocated_stock) {
|
||||
stock = `<span class='badge rounded-pill bg-warning'>{% trans "Not available" %}</span>`;
|
||||
stock = `<span class='badge rounded-pill bg-warning'>{% trans "No Stock" %}</span>`;
|
||||
}
|
||||
|
||||
rows += `<tr><td><b>{% trans "Stock" %}</b></td><td>${stock}</td></tr>`;
|
||||
|
||||
if (part.ordering) {
|
||||
rows += `<tr><td><b>{% trans "On Order" %}</b></td><td>${part.ordering}</td></tr>`;
|
||||
rows += `<tr><td><b>{% trans "On Order" %}</b></td><td>${part.ordering} ${units}</td></tr>`;
|
||||
}
|
||||
|
||||
if (part.building) {
|
||||
rows += `<tr><td><b>{% trans "Building" %}</b></td><td>${part.building}</td></tr>`;
|
||||
rows += `<tr><td><b>{% trans "Building" %}</b></td><td>${part.building} ${units}</td></tr>`;
|
||||
}
|
||||
|
||||
var html = `
|
||||
@ -1334,10 +1355,10 @@ function partGridTile(part) {
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<img src='${part.thumbnail}' class='card-thumb' onclick='showModalImage("${part.image}")'>
|
||||
<div class='col-sm-4'>
|
||||
<img src='${part.thumbnail}' style='width: 100%;' class='card-thumb' onclick='showModalImage("${part.image}")'>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<div class='col-sm-8'>
|
||||
<table class='table table-striped table-condensed'>
|
||||
${rows}
|
||||
</table>
|
||||
@ -1483,11 +1504,7 @@ function loadPartTable(table, url, options={}) {
|
||||
total_stock += row.variant_stock;
|
||||
}
|
||||
|
||||
if (row.unallocated_stock != row.in_stock) {
|
||||
text = `${row.unallocated_stock} / ${total_stock}`;
|
||||
} else {
|
||||
text = `${total_stock}`;
|
||||
}
|
||||
var text = `${total_stock}`;
|
||||
|
||||
// Construct extra informational badges
|
||||
var badges = '';
|
||||
@ -1516,6 +1533,18 @@ function loadPartTable(table, url, options={}) {
|
||||
badges += `<span class='fas fa-info-circle float-right' title='{% trans "Includes variant stock" %}'></span>`;
|
||||
}
|
||||
|
||||
if (row.allocated_to_build_orders > 0) {
|
||||
badges += `<span class='fas fa-bookmark icon-yellow float-right' title='{% trans "Allocated to build orders" %}: ${row.allocated_to_build_orders}'></span>`;
|
||||
}
|
||||
|
||||
if (row.allocated_to_sales_orders > 0) {
|
||||
badges += `<span class='fas fa-bookmark icon-yellow float-right' title='{% trans "Allocated to sales orders" %}: ${row.allocated_to_sales_orders}'></span>`;
|
||||
}
|
||||
|
||||
if (row.units) {
|
||||
text += ` <small>${row.units}</small>`;
|
||||
}
|
||||
|
||||
text = renderLink(text, `/part/${row.pk}/?display=part-stock`);
|
||||
text += badges;
|
||||
|
||||
|
@ -1703,7 +1703,7 @@ function loadStockTable(table, options) {
|
||||
switchable: params['part_detail'],
|
||||
formatter: function(value, row) {
|
||||
|
||||
var url = `/stock/item/${row.pk}/`;
|
||||
var url = `/part/${row.part}/`;
|
||||
var thumb = row.part_detail.thumbnail;
|
||||
var name = row.part_detail.full_name;
|
||||
|
||||
@ -1757,20 +1757,16 @@ function loadStockTable(table, options) {
|
||||
|
||||
var val = '';
|
||||
|
||||
var available = Math.max(0, (row.quantity || 0) - (row.allocated || 0));
|
||||
|
||||
if (row.serial && row.quantity == 1) {
|
||||
// If there is a single unit with a serial number, use the serial number
|
||||
val = '# ' + row.serial;
|
||||
} else if (row.quantity != available) {
|
||||
// Some quantity is available, show available *and* quantity
|
||||
var ava = formatDecimal(available);
|
||||
var tot = formatDecimal(row.quantity);
|
||||
|
||||
val = `${ava} / ${tot}`;
|
||||
} else {
|
||||
// Format floating point numbers with this one weird trick
|
||||
val = formatDecimal(value);
|
||||
|
||||
if (row.part_detail && row.part_detail.units) {
|
||||
val += ` ${row.part_detail.units}`;
|
||||
}
|
||||
}
|
||||
|
||||
var html = renderLink(val, `/stock/item/${row.pk}/`);
|
||||
|
Loading…
Reference in New Issue
Block a user