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,14 +407,16 @@ def DownloadFile(data, filename, content_type='application/text', inline=False):
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
- Serial numbers must be positive
- 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 defined as ~ for getting the next available serial number
- Serial numbers can be supplied as <start>+ for getting all expecteded numbers starting from <start>
- Serial numbers can be supplied as <start>+<length> for getting <length> numbers starting from <start>
"""
Attempt to extract serial numbers from an input string:
Requirements:
- Serial numbers can be either strings, or integers
- 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 defined as ~ for getting the next available serial number
- Serial numbers can be supplied as <start>+ for getting all expecteded numbers starting from <start>
- Serial numbers can be supplied as <start>+<length> for getting <length> numbers starting from <start>
Args:
serials: input string with patterns
@ -428,17 +430,18 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
if '~' in serials:
serials = serials.replace('~', str(next_number))
# Split input string by whitespace or comma (,) characters
groups = re.split("[\s,]+", serials)
numbers = []
errors = []
# helpers
def number_add(n):
if n in numbers:
errors.append(_('Duplicate serial: {n}').format(n=n))
# Helper function to check for duplicated numbers
def add_sn(sn):
if sn in numbers:
errors.append(_('Duplicate serial: {sn}').format(sn=sn))
else:
numbers.append(n)
numbers.append(sn)
try:
expected_quantity = int(expected_quantity)
@ -466,7 +469,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
if a < b:
for n in range(a, b + 1):
number_add(n)
add_sn(n)
else:
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
for n in range(start, end):
number_add(n)
add_sn(n)
# no case
else:
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:
# 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
else:

View File

@ -14,6 +14,8 @@ from django_q.monitor import Stat
from django.conf import settings
import InvenTree.ready
logger = logging.getLogger("inventree")
@ -56,6 +58,12 @@ def is_email_configured():
configured = True
if InvenTree.ready.isInTestMode():
return False
if InvenTree.ready.isImportingData():
return False
if not settings.EMAIL_HOST:
configured = False
@ -89,6 +97,14 @@ def check_system_health(**kwargs):
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
result = False
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
@transaction.atomic
def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None, **kwargs):
""" Receive a line item (or partial line item) against this PO
def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, **kwargs):
"""
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', '')
barcode = kwargs.get('barcode', '')
# Extract optional barcode field
barcode = kwargs.get('barcode', None)
# Prevent null values for barcode
if barcode is None:
@ -427,33 +437,45 @@ class PurchaseOrder(Order):
# Create a new stock item
if line.part and quantity > 0:
stock = stock_models.StockItem(
part=line.part.part,
supplier_part=line.part,
location=location,
quantity=quantity,
purchase_order=self,
status=status,
purchase_price=line.purchase_price,
uid=barcode
)
stock.save(add_note=False)
# 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]
tracking_info = {
'status': status,
'purchaseorder': self.pk,
}
for sn in serials:
stock.add_tracking_entry(
StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
user,
notes=notes,
deltas=tracking_info,
location=location,
purchaseorder=self,
quantity=quantity
)
stock = stock_models.StockItem(
part=line.part.part,
supplier_part=line.part,
location=location,
quantity=1 if serialize else quantity,
purchase_order=self,
status=status,
batch=batch_code,
serial=sn,
purchase_price=line.purchase_price,
uid=barcode
)
stock.save(add_note=False)
tracking_info = {
'status': status,
'purchaseorder': self.pk,
}
stock.add_tracking_entry(
StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
user,
notes=notes,
deltas=tracking_info,
location=location,
purchaseorder=self,
quantity=quantity
)
# Update the number of parts received against the particular line item
line.received += quantity

View File

@ -5,6 +5,8 @@ JSON serializers for the Order API
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from decimal import Decimal
from django.utils.translation import ugettext_lazy as _
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
"""
class Meta:
fields = [
'barcode',
'line_item',
'location',
'quantity',
'status',
'batch_code'
'serial_numbers',
]
line_item = serializers.PrimaryKeyRelatedField(
queryset=order.models.PurchaseOrderLineItem.objects.all(),
many=False,
@ -241,6 +254,22 @@ class POLineItemReceiveSerializer(serializers.Serializer):
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(
choices=list(StockStatus.items()),
default=StockStatus.OK,
@ -270,14 +299,35 @@ class POLineItemReceiveSerializer(serializers.Serializer):
return barcode
class Meta:
fields = [
'barcode',
'line_item',
'location',
'quantity',
'status',
]
def validate(self, data):
data = super().validate(data)
line_item = data['line_item']
quantity = data['quantity']
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):
@ -366,6 +416,8 @@ class POReceiveSerializer(serializers.Serializer):
request.user,
status=item['status'],
barcode=item.get('barcode', ''),
batch_code=item.get('batch_code', ''),
serials=item.get('serials', None),
)
except (ValidationError, DjangoValidationError) as exc:
# 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-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):
"""

View File

@ -1949,7 +1949,7 @@ function getFieldName(name, options={}) {
* - Field description (help text)
* - Field errors
*/
function constructField(name, parameters, options) {
function constructField(name, parameters, options={}) {
var html = '';
@ -2041,7 +2041,7 @@ function constructField(name, parameters, options) {
html += `<div class='controls'>`;
// 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
if (!parameters.required && !parameters.read_only) {
@ -2063,9 +2063,13 @@ function constructField(name, parameters, options) {
if (extra) {
html += `<div class='input-group'>`;
if (parameters.prefix) {
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}'`);
if (parameters.title || parameters.help_text) {
opts.push(`title='${parameters.title || parameters.help_text}'`);
}
// Read only?
if (parameters.read_only) {
opts.push(`readonly=''`);
@ -2257,11 +2265,6 @@ function constructInputOptions(name, classes, type, parameters, options={}) {
opts.push(`required=''`);
}
// Custom mouseover title?
if (parameters.title != null) {
opts.push(`title='${parameters.title}'`);
}
// Placeholder?
if (parameters.placeholder != null) {
opts.push(`placeholder='${parameters.placeholder}'`);

View File

@ -116,6 +116,10 @@ function makeIconButton(icon, cls, pk, title, options={}) {
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 += `<span class='fas ${icon}'></span>`;
html += `</button>`;

View File

@ -476,6 +476,19 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
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
var quantity_input = constructField(
`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
var choices = [];
@ -528,16 +571,38 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
);
// 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',
'button-row-remove',
pk,
'{% trans "Remove row" %}',
);
delete_button += '</div>';
buttons += '</div>';
var html = `
<tr id='receive_row_${pk}' class='stock-receive-row'>
@ -554,7 +619,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
${line_item.received}
</td>
<td id='quantity_${pk}'>
${quantity_input}
${quantity_input_group}
</td>
<td id='status_${pk}'>
${status_input}
@ -563,7 +628,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
${destination_input}
</td>
<td id='actions_${pk}'>
${delete_button}
${buttons}
</td>
</tr>`;
@ -587,7 +652,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
<th>{% trans "Order Code" %}</th>
<th>{% trans "Ordered" %}</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: 300px;'>{% trans "Destination" %}</th>
<th></th>
@ -678,13 +743,23 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
var location = getFormFieldValue(`items_location_${pk}`, {}, opts);
if (quantity != null) {
data.items.push({
var line = {
line_item: pk,
quantity: quantity,
status: status,
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);
}