2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-30 04:26:44 +00:00

Merge pull request #2686 from SchrodingersGat/po-receive-serials

Po receive serials
This commit is contained in:
Oliver 2022-03-01 00:58:44 +11:00 committed by GitHub
commit f3e7af3cc3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 350 additions and 74 deletions

View File

@ -407,9 +407,11 @@ def DownloadFile(data, filename, content_type='application/text', inline=False):
def extract_serial_numbers(serials, expected_quantity, next_number: int): def extract_serial_numbers(serials, expected_quantity, next_number: int):
""" Attempt to extract serial numbers from an input string. """
- Serial numbers must be integer values Attempt to extract serial numbers from an input string:
- Serial numbers must be positive
Requirements:
- Serial numbers can be either strings, or integers
- Serial numbers can be split by whitespace / newline / commma chars - Serial numbers can be split by whitespace / newline / commma chars
- Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20 - Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20
- Serial numbers can be defined as ~ for getting the next available serial number - Serial numbers can be defined as ~ for getting the next available serial number
@ -428,17 +430,18 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
if '~' in serials: if '~' in serials:
serials = serials.replace('~', str(next_number)) serials = serials.replace('~', str(next_number))
# Split input string by whitespace or comma (,) characters
groups = re.split("[\s,]+", serials) groups = re.split("[\s,]+", serials)
numbers = [] numbers = []
errors = [] errors = []
# helpers # Helper function to check for duplicated numbers
def number_add(n): def add_sn(sn):
if n in numbers: if sn in numbers:
errors.append(_('Duplicate serial: {n}').format(n=n)) errors.append(_('Duplicate serial: {sn}').format(sn=sn))
else: else:
numbers.append(n) numbers.append(sn)
try: try:
expected_quantity = int(expected_quantity) expected_quantity = int(expected_quantity)
@ -466,7 +469,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
if a < b: if a < b:
for n in range(a, b + 1): for n in range(a, b + 1):
number_add(n) add_sn(n)
else: else:
errors.append(_("Invalid group: {g}").format(g=group)) errors.append(_("Invalid group: {g}").format(g=group))
@ -495,21 +498,20 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
end = start + expected_quantity end = start + expected_quantity
for n in range(start, end): for n in range(start, end):
number_add(n) add_sn(n)
# no case # no case
else: else:
errors.append(_("Invalid group: {g}").format(g=group)) errors.append(_("Invalid group: {g}").format(g=group))
# Group should be a number # At this point, we assume that the "group" is just a single serial value
elif group: elif group:
# try conversion
try:
number = int(group)
except:
# seem like it is not a number
raise ValidationError(_(f"Invalid group {group}"))
number_add(number) try:
# First attempt to add as an integer value
add_sn(int(group))
except (ValueError):
# As a backup, add as a string value
add_sn(group)
# No valid input group detected # No valid input group detected
else: else:

View File

@ -14,6 +14,8 @@ from django_q.monitor import Stat
from django.conf import settings from django.conf import settings
import InvenTree.ready
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
@ -56,6 +58,12 @@ def is_email_configured():
configured = True configured = True
if InvenTree.ready.isInTestMode():
return False
if InvenTree.ready.isImportingData():
return False
if not settings.EMAIL_HOST: if not settings.EMAIL_HOST:
configured = False configured = False
@ -89,6 +97,14 @@ def check_system_health(**kwargs):
result = True result = True
if InvenTree.ready.isInTestMode():
# Do not perform further checks if we are running unit tests
return False
if InvenTree.ready.isImportingData():
# Do not perform further checks if we are importing data
return False
if not is_worker_running(**kwargs): # pragma: no cover if not is_worker_running(**kwargs): # pragma: no cover
result = False result = False
logger.warning(_("Background worker check failed")) logger.warning(_("Background worker check failed"))

View File

@ -398,12 +398,22 @@ class PurchaseOrder(Order):
return self.lines.count() > 0 and self.pending_line_items().count() == 0 return self.lines.count() > 0 and self.pending_line_items().count() == 0
@transaction.atomic @transaction.atomic
def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None, **kwargs): def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, **kwargs):
""" Receive a line item (or partial line item) against this PO """
Receive a line item (or partial line item) against this PO
""" """
# Extract optional batch code for the new stock item
batch_code = kwargs.get('batch_code', '')
# Extract optional list of serial numbers
serials = kwargs.get('serials', None)
# Extract optional notes field
notes = kwargs.get('notes', '') notes = kwargs.get('notes', '')
barcode = kwargs.get('barcode', '')
# Extract optional barcode field
barcode = kwargs.get('barcode', None)
# Prevent null values for barcode # Prevent null values for barcode
if barcode is None: if barcode is None:
@ -427,13 +437,25 @@ class PurchaseOrder(Order):
# Create a new stock item # Create a new stock item
if line.part and quantity > 0: if line.part and quantity > 0:
# Determine if we should individually serialize the items, or not
if type(serials) is list and len(serials) > 0:
serialize = True
else:
serialize = False
serials = [None]
for sn in serials:
stock = stock_models.StockItem( stock = stock_models.StockItem(
part=line.part.part, part=line.part.part,
supplier_part=line.part, supplier_part=line.part,
location=location, location=location,
quantity=quantity, quantity=1 if serialize else quantity,
purchase_order=self, purchase_order=self,
status=status, status=status,
batch=batch_code,
serial=sn,
purchase_price=line.purchase_price, purchase_price=line.purchase_price,
uid=barcode uid=barcode
) )

View File

@ -5,6 +5,8 @@ JSON serializers for the Order API
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from decimal import Decimal
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
@ -203,6 +205,17 @@ class POLineItemReceiveSerializer(serializers.Serializer):
A serializer for receiving a single purchase order line item against a purchase order A serializer for receiving a single purchase order line item against a purchase order
""" """
class Meta:
fields = [
'barcode',
'line_item',
'location',
'quantity',
'status',
'batch_code'
'serial_numbers',
]
line_item = serializers.PrimaryKeyRelatedField( line_item = serializers.PrimaryKeyRelatedField(
queryset=order.models.PurchaseOrderLineItem.objects.all(), queryset=order.models.PurchaseOrderLineItem.objects.all(),
many=False, many=False,
@ -241,6 +254,22 @@ class POLineItemReceiveSerializer(serializers.Serializer):
return quantity return quantity
batch_code = serializers.CharField(
label=_('Batch Code'),
help_text=_('Enter batch code for incoming stock items'),
required=False,
default='',
allow_blank=True,
)
serial_numbers = serializers.CharField(
label=_('Serial Numbers'),
help_text=_('Enter serial numbers for incoming stock items'),
required=False,
default='',
allow_blank=True,
)
status = serializers.ChoiceField( status = serializers.ChoiceField(
choices=list(StockStatus.items()), choices=list(StockStatus.items()),
default=StockStatus.OK, default=StockStatus.OK,
@ -270,14 +299,35 @@ class POLineItemReceiveSerializer(serializers.Serializer):
return barcode return barcode
class Meta: def validate(self, data):
fields = [
'barcode', data = super().validate(data)
'line_item',
'location', line_item = data['line_item']
'quantity', quantity = data['quantity']
'status', serial_numbers = data.get('serial_numbers', '').strip()
]
base_part = line_item.part.part
# Does the quantity need to be "integer" (for trackable parts?)
if base_part.trackable:
if Decimal(quantity) != int(quantity):
raise ValidationError({
'quantity': _('An integer quantity must be provided for trackable parts'),
})
# If serial numbers are provided
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())
except DjangoValidationError as e:
raise ValidationError({
'serial_numbers': e.messages,
})
return data
class POReceiveSerializer(serializers.Serializer): class POReceiveSerializer(serializers.Serializer):
@ -366,6 +416,8 @@ class POReceiveSerializer(serializers.Serializer):
request.user, request.user,
status=item['status'], status=item['status'],
barcode=item.get('barcode', ''), barcode=item.get('barcode', ''),
batch_code=item.get('batch_code', ''),
serials=item.get('serials', None),
) )
except (ValidationError, DjangoValidationError) as exc: except (ValidationError, DjangoValidationError) as exc:
# Catch model errors and re-throw as DRF errors # Catch model errors and re-throw as DRF errors

View File

@ -529,6 +529,108 @@ class PurchaseOrderReceiveTest(OrderTest):
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-123').exists()) 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(uid='MY-UNIQUE-BARCODE-456').exists())
def test_batch_code(self):
"""
Test that we can supply a 'batch code' when receiving items
"""
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0)
self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0)
data = {
'items': [
{
'line_item': 1,
'quantity': 10,
'batch_code': 'abc-123',
},
{
'line_item': 2,
'quantity': 10,
'batch_code': 'xyz-789',
}
],
'location': 1,
}
n = StockItem.objects.count()
self.post(
self.url,
data,
expected_code=201,
)
# Check that two new stock items have been created!
self.assertEqual(n + 2, StockItem.objects.count())
item_1 = StockItem.objects.filter(supplier_part=line_1.part).first()
item_2 = StockItem.objects.filter(supplier_part=line_2.part).first()
self.assertEqual(item_1.batch, 'abc-123')
self.assertEqual(item_2.batch, 'xyz-789')
def test_serial_numbers(self):
"""
Test that we can supply a 'serial number' when receiving items
"""
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0)
self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0)
data = {
'items': [
{
'line_item': 1,
'quantity': 10,
'batch_code': 'abc-123',
'serial_numbers': '100+',
},
{
'line_item': 2,
'quantity': 10,
'batch_code': 'xyz-789',
}
],
'location': 1,
}
n = StockItem.objects.count()
self.post(
self.url,
data,
expected_code=201,
)
# Check that the expected number of stock items has been created
self.assertEqual(n + 11, StockItem.objects.count())
# 10 serialized stock items created for the first line item
self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 10)
# Check that the correct serial numbers have been allocated
for i in range(100, 110):
item = StockItem.objects.get(serial_int=i)
self.assertEqual(item.serial, str(i))
self.assertEqual(item.quantity, 1)
self.assertEqual(item.batch, 'abc-123')
# A single stock item (quantity 10) created for the second line item
items = StockItem.objects.filter(supplier_part=line_2.part)
self.assertEqual(items.count(), 1)
item = items.first()
self.assertEqual(item.quantity, 10)
self.assertEqual(item.batch, 'xyz-789')
class SalesOrderTest(OrderTest): class SalesOrderTest(OrderTest):
""" """

View File

@ -1949,7 +1949,7 @@ function getFieldName(name, options={}) {
* - Field description (help text) * - Field description (help text)
* - Field errors * - Field errors
*/ */
function constructField(name, parameters, options) { function constructField(name, parameters, options={}) {
var html = ''; var html = '';
@ -2041,7 +2041,7 @@ function constructField(name, parameters, options) {
html += `<div class='controls'>`; html += `<div class='controls'>`;
// Does this input deserve "extra" decorators? // Does this input deserve "extra" decorators?
var extra = parameters.prefix != null; var extra = (parameters.icon != null) || (parameters.prefix != null) || (parameters.prefixRaw != null);
// Some fields can have 'clear' inputs associated with them // Some fields can have 'clear' inputs associated with them
if (!parameters.required && !parameters.read_only) { if (!parameters.required && !parameters.read_only) {
@ -2066,6 +2066,10 @@ function constructField(name, parameters, options) {
if (parameters.prefix) { if (parameters.prefix) {
html += `<span class='input-group-text'>${parameters.prefix}</span>`; html += `<span class='input-group-text'>${parameters.prefix}</span>`;
} else if (parameters.prefixRaw) {
html += parameters.prefixRaw;
} else if (parameters.icon) {
html += `<span class='input-group-text'><span class='fas ${parameters.icon}'></span></span>`;
} }
} }
@ -2212,6 +2216,10 @@ function constructInputOptions(name, classes, type, parameters, options={}) {
opts.push(`type='${type}'`); opts.push(`type='${type}'`);
if (parameters.title || parameters.help_text) {
opts.push(`title='${parameters.title || parameters.help_text}'`);
}
// Read only? // Read only?
if (parameters.read_only) { if (parameters.read_only) {
opts.push(`readonly=''`); opts.push(`readonly=''`);
@ -2257,11 +2265,6 @@ function constructInputOptions(name, classes, type, parameters, options={}) {
opts.push(`required=''`); opts.push(`required=''`);
} }
// Custom mouseover title?
if (parameters.title != null) {
opts.push(`title='${parameters.title}'`);
}
// Placeholder? // Placeholder?
if (parameters.placeholder != null) { if (parameters.placeholder != null) {
opts.push(`placeholder='${parameters.placeholder}'`); opts.push(`placeholder='${parameters.placeholder}'`);

View File

@ -116,6 +116,10 @@ function makeIconButton(icon, cls, pk, title, options={}) {
extraProps += `disabled='true' `; extraProps += `disabled='true' `;
} }
if (options.collapseTarget) {
extraProps += `data-bs-toggle='collapse' href='#${options.collapseTarget}'`;
}
html += `<button pk='${pk}' id='${id}' class='${classes}' title='${title}' ${extraProps}>`; html += `<button pk='${pk}' id='${id}' class='${classes}' title='${title}' ${extraProps}>`;
html += `<span class='fas ${icon}'></span>`; html += `<span class='fas ${icon}'></span>`;
html += `</button>`; html += `</button>`;

View File

@ -476,6 +476,19 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
quantity = 0; quantity = 0;
} }
// Prepend toggles to the quantity input
var toggle_batch = `
<span class='input-group-text' title='{% trans "Add batch code" %}' data-bs-toggle='collapse' href='#div-batch-${pk}'>
<span class='fas fa-layer-group'></span>
</span>
`;
var toggle_serials = `
<span class='input-group-text' title='{% trans "Add serial numbers" %}' data-bs-toggle='collapse' href='#div-serials-${pk}'>
<span class='fas fa-hashtag'></span>
</span>
`;
// Quantity to Receive // Quantity to Receive
var quantity_input = constructField( var quantity_input = constructField(
`items_quantity_${pk}`, `items_quantity_${pk}`,
@ -491,6 +504,36 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
} }
); );
// Add in options for "batch code" and "serial numbers"
var batch_input = constructField(
`items_batch_code_${pk}`,
{
type: 'string',
required: false,
label: '{% trans "Batch Code" %}',
help_text: '{% trans "Enter batch code for incoming stock items" %}',
prefixRaw: toggle_batch,
}
);
var sn_input = constructField(
`items_serial_numbers_${pk}`,
{
type: 'string',
required: false,
label: '{% trans "Serial Numbers" %}',
help_text: '{% trans "Enter serial numbers for incoming stock items" %}',
prefixRaw: toggle_serials,
}
);
// Hidden inputs below the "quantity" field
var quantity_input_group = `${quantity_input}<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>`;
}
// Construct list of StockItem status codes // Construct list of StockItem status codes
var choices = []; var choices = [];
@ -528,16 +571,38 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
); );
// Button to remove the row // Button to remove the row
var delete_button = `<div class='btn-group float-right' role='group'>`; var buttons = `<div class='btn-group float-right' role='group'>`;
delete_button += makeIconButton( buttons += makeIconButton(
'fa-layer-group',
'button-row-add-batch',
pk,
'{% trans "Add batch code" %}',
{
collapseTarget: `div-batch-${pk}`
}
);
if (line_item.part_detail.trackable) {
buttons += makeIconButton(
'fa-hashtag',
'button-row-add-serials',
pk,
'{% trans "Add serial numbers" %}',
{
collapseTarget: `div-serials-${pk}`,
}
);
}
buttons += makeIconButton(
'fa-times icon-red', 'fa-times icon-red',
'button-row-remove', 'button-row-remove',
pk, pk,
'{% trans "Remove row" %}', '{% trans "Remove row" %}',
); );
delete_button += '</div>'; buttons += '</div>';
var html = ` var html = `
<tr id='receive_row_${pk}' class='stock-receive-row'> <tr id='receive_row_${pk}' class='stock-receive-row'>
@ -554,7 +619,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
${line_item.received} ${line_item.received}
</td> </td>
<td id='quantity_${pk}'> <td id='quantity_${pk}'>
${quantity_input} ${quantity_input_group}
</td> </td>
<td id='status_${pk}'> <td id='status_${pk}'>
${status_input} ${status_input}
@ -563,7 +628,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
${destination_input} ${destination_input}
</td> </td>
<td id='actions_${pk}'> <td id='actions_${pk}'>
${delete_button} ${buttons}
</td> </td>
</tr>`; </tr>`;
@ -587,7 +652,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
<th>{% trans "Order Code" %}</th> <th>{% trans "Order Code" %}</th>
<th>{% trans "Ordered" %}</th> <th>{% trans "Ordered" %}</th>
<th>{% trans "Received" %}</th> <th>{% trans "Received" %}</th>
<th style='min-width: 50px;'>{% trans "Receive" %}</th> <th style='min-width: 50px;'>{% trans "Quantity to Receive" %}</th>
<th style='min-width: 150px;'>{% trans "Status" %}</th> <th style='min-width: 150px;'>{% trans "Status" %}</th>
<th style='min-width: 300px;'>{% trans "Destination" %}</th> <th style='min-width: 300px;'>{% trans "Destination" %}</th>
<th></th> <th></th>
@ -678,13 +743,23 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
var location = getFormFieldValue(`items_location_${pk}`, {}, opts); var location = getFormFieldValue(`items_location_${pk}`, {}, opts);
if (quantity != null) { if (quantity != null) {
data.items.push({
var line = {
line_item: pk, line_item: pk,
quantity: quantity, quantity: quantity,
status: status, status: status,
location: location, location: location,
}); };
if (getFormFieldElement(`items_batch_code_${pk}`).exists()) {
line.batch_code = getFormFieldValue(`items_batch_code_${pk}`);
}
if (getFormFieldElement(`items_serial_numbers_${pk}`).exists()) {
line.serial_numbers = getFormFieldValue(`items_serial_numbers_${pk}`);
}
data.items.push(line);
item_pk_values.push(pk); item_pk_values.push(pk);
} }