Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2022-09-16 09:56:46 +10:00
commit 4f3e955261
84 changed files with 34683 additions and 21689 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -140,6 +140,8 @@ function inventreeDocReady() {
// start watcher
startNotificationWatcher();
attachClipboard('.clip-btn');
// always refresh when the focus returns
$(document).focus(function(){
startNotificationWatcher();

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -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 }}, {

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -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');

View File

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

View File

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

View File

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

View File

@ -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(),
)

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

View File

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

View File

@ -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 }}]);

View File

@ -0,0 +1 @@
{% if part.units %}{{ part.units }}{% endif %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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
)
]

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

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

View File

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

View File

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

View File

@ -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() {

View File

@ -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 () {

View File

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

View File

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

View File

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

View File

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

View File

@ -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();
},

View File

@ -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>`;
}

View File

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

View File

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

View File

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

View File

@ -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}/`);

View File

@ -43,6 +43,7 @@ def content_excludes():
"exchange.rate",
"exchange.exchangebackend",
"common.notificationentry",
"common.notificationmessage",
"user_sessions.session",
]