mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master' into date-format
This commit is contained in:
commit
5d37ce9175
@ -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:
|
||||
|
@ -37,7 +37,6 @@ function showAlertOrCache(message, cache, options={}) {
|
||||
if (cache) {
|
||||
addCachedAlert(message, options);
|
||||
} else {
|
||||
|
||||
showMessage(message, options);
|
||||
}
|
||||
}
|
||||
@ -82,6 +81,8 @@ function showMessage(message, options={}) {
|
||||
|
||||
var timeout = options.timeout || 5000;
|
||||
|
||||
var target = options.target || $('#alerts');
|
||||
|
||||
var details = '';
|
||||
|
||||
if (options.details) {
|
||||
@ -111,7 +112,7 @@ function showMessage(message, options={}) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
$('#alerts').append(html);
|
||||
target.append(html);
|
||||
|
||||
// Remove the alert automatically after a specified period of time
|
||||
$(`#alert-${id}`).delay(timeout).slideUp(200, function() {
|
||||
|
@ -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"))
|
||||
|
@ -12,11 +12,14 @@ import common.models
|
||||
INVENTREE_SW_VERSION = "0.7.0 dev"
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 26
|
||||
INVENTREE_API_VERSION = 27
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v27 -> 2022-02-28
|
||||
- Adds target_date field to individual line items for purchase orders and sales orders
|
||||
|
||||
v26 -> 2022-02-17
|
||||
- Adds API endpoint for uploading a BOM file and extracting data
|
||||
|
||||
|
@ -274,7 +274,7 @@ class POLineItemFilter(rest_filters.FilterSet):
|
||||
model = models.PurchaseOrderLineItem
|
||||
fields = [
|
||||
'order',
|
||||
'part'
|
||||
'part',
|
||||
]
|
||||
|
||||
pending = rest_filters.BooleanFilter(label='pending', method='filter_pending')
|
||||
@ -391,6 +391,7 @@ class POLineItemList(generics.ListCreateAPIView):
|
||||
'reference',
|
||||
'SKU',
|
||||
'total_price',
|
||||
'target_date',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
@ -401,11 +402,6 @@ class POLineItemList(generics.ListCreateAPIView):
|
||||
'reference',
|
||||
]
|
||||
|
||||
filter_fields = [
|
||||
'order',
|
||||
'part'
|
||||
]
|
||||
|
||||
|
||||
class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
@ -703,6 +699,7 @@ class SOLineItemList(generics.ListCreateAPIView):
|
||||
'part__name',
|
||||
'quantity',
|
||||
'reference',
|
||||
'target_date',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
|
23
InvenTree/order/migrations/0062_auto_20220228_0321.py
Normal file
23
InvenTree/order/migrations/0062_auto_20220228_0321.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.10 on 2022-02-28 03:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchaseorderlineitem',
|
||||
name='target_date',
|
||||
field=models.DateField(blank=True, help_text='Target shipping date for this line item', null=True, verbose_name='Target Date'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesorderlineitem',
|
||||
name='target_date',
|
||||
field=models.DateField(blank=True, help_text='Target shipping date for this line item', null=True, verbose_name='Target Date'),
|
||||
),
|
||||
]
|
@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.2.10 on 2022-02-28 04:27
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0062_auto_20220228_0321'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='purchaseorderlineitem',
|
||||
unique_together=set(),
|
||||
),
|
||||
]
|
@ -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
|
||||
@ -794,9 +816,18 @@ class OrderLineItem(models.Model):
|
||||
|
||||
Attributes:
|
||||
quantity: Number of items
|
||||
reference: Reference text (e.g. customer reference) for this line item
|
||||
note: Annotation for the item
|
||||
target_date: An (optional) date for expected shipment of this line item.
|
||||
"""
|
||||
|
||||
"""
|
||||
Query filter for determining if an individual line item is "overdue":
|
||||
- Amount received is less than the required quantity
|
||||
- Target date is not None
|
||||
- Target date is in the past
|
||||
"""
|
||||
OVERDUE_FILTER = Q(received__lt=F('quantity')) & ~Q(target_date=None) & Q(target_date__lt=datetime.now().date())
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@ -813,6 +844,12 @@ class OrderLineItem(models.Model):
|
||||
|
||||
notes = models.CharField(max_length=500, blank=True, verbose_name=_('Notes'), help_text=_('Line item notes'))
|
||||
|
||||
target_date = models.DateField(
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Target Date'),
|
||||
help_text=_('Target shipping date for this line item'),
|
||||
)
|
||||
|
||||
|
||||
class PurchaseOrderLineItem(OrderLineItem):
|
||||
""" Model for a purchase order line item.
|
||||
@ -824,7 +861,6 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
|
||||
class Meta:
|
||||
unique_together = (
|
||||
('order', 'part', 'quantity', 'purchase_price')
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
@ -5,12 +5,14 @@ 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
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Case, When, Value
|
||||
from django.db.models import BooleanField, ExpressionWrapper, F
|
||||
from django.db.models import BooleanField, ExpressionWrapper, F, Q
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
@ -26,7 +28,7 @@ from InvenTree.serializers import InvenTreeModelSerializer
|
||||
from InvenTree.serializers import InvenTreeDecimalField
|
||||
from InvenTree.serializers import InvenTreeMoneySerializer
|
||||
from InvenTree.serializers import ReferenceIndexingSerializerMixin
|
||||
from InvenTree.status_codes import StockStatus
|
||||
from InvenTree.status_codes import StockStatus, PurchaseOrderStatus, SalesOrderStatus
|
||||
|
||||
import order.models
|
||||
|
||||
@ -126,6 +128,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
Add some extra annotations to this queryset:
|
||||
|
||||
- Total price = purchase_price * quantity
|
||||
- "Overdue" status (boolean field)
|
||||
"""
|
||||
|
||||
queryset = queryset.annotate(
|
||||
@ -135,6 +138,15 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
)
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
Q(order__status__in=PurchaseOrderStatus.OPEN) & order.models.OrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField())
|
||||
),
|
||||
default=Value(False, output_field=BooleanField()),
|
||||
)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -155,6 +167,8 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
quantity = serializers.FloatField(default=1)
|
||||
received = serializers.FloatField(default=0)
|
||||
|
||||
overdue = serializers.BooleanField(required=False, read_only=True)
|
||||
|
||||
total_price = serializers.FloatField(read_only=True)
|
||||
|
||||
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
|
||||
@ -185,6 +199,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
'notes',
|
||||
'order',
|
||||
'order_detail',
|
||||
'overdue',
|
||||
'part',
|
||||
'part_detail',
|
||||
'supplier_part_detail',
|
||||
@ -194,6 +209,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
'purchase_price_string',
|
||||
'destination',
|
||||
'destination_detail',
|
||||
'target_date',
|
||||
'total_price',
|
||||
]
|
||||
|
||||
@ -203,6 +219,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 +268,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 +313,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 +430,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
|
||||
@ -549,6 +615,23 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
||||
class SOLineItemSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for a SalesOrderLineItem object """
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""
|
||||
Add some extra annotations to this queryset:
|
||||
|
||||
- "Overdue" status (boolean field)
|
||||
"""
|
||||
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
When(
|
||||
Q(order__status__in=SalesOrderStatus.OPEN) & order.models.OrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
|
||||
),
|
||||
default=Value(False, output_field=BooleanField()),
|
||||
)
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
@ -570,6 +653,8 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
|
||||
|
||||
overdue = serializers.BooleanField(required=False, read_only=True)
|
||||
|
||||
quantity = InvenTreeDecimalField()
|
||||
|
||||
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
|
||||
@ -599,12 +684,14 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
||||
'notes',
|
||||
'order',
|
||||
'order_detail',
|
||||
'overdue',
|
||||
'part',
|
||||
'part_detail',
|
||||
'sale_price',
|
||||
'sale_price_currency',
|
||||
'sale_price_string',
|
||||
'shipped',
|
||||
'target_date',
|
||||
]
|
||||
|
||||
|
||||
|
@ -174,6 +174,7 @@ $('#new-po-line').click(function() {
|
||||
value: '{{ order.supplier.currency }}',
|
||||
{% endif %}
|
||||
},
|
||||
target_date: {},
|
||||
destination: {},
|
||||
notes: {},
|
||||
},
|
||||
@ -210,7 +211,7 @@ $('#new-po-line').click(function() {
|
||||
loadPurchaseOrderLineItemTable('#po-line-table', {
|
||||
order: {{ order.pk }},
|
||||
supplier: {{ order.supplier.pk }},
|
||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||
{% if roles.purchase_order.change %}
|
||||
allow_edit: true,
|
||||
{% else %}
|
||||
allow_edit: false,
|
||||
|
@ -238,6 +238,7 @@
|
||||
reference: {},
|
||||
sale_price: {},
|
||||
sale_price_currency: {},
|
||||
target_date: {},
|
||||
notes: {},
|
||||
},
|
||||
method: 'POST',
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -313,6 +313,10 @@
|
||||
fields: fields,
|
||||
groups: partGroups(),
|
||||
title: '{% trans "Create Part" %}',
|
||||
reloadFormAfterSuccess: true,
|
||||
persist: true,
|
||||
persistMessage: '{% trans "Create another part after this one" %}',
|
||||
successMessage: '{% trans "Part created successfully" %}',
|
||||
onSuccess: function(data) {
|
||||
// Follow the new part
|
||||
location.href = `/part/${data.pk}/`;
|
||||
|
@ -542,6 +542,11 @@ function constructFormBody(fields, options) {
|
||||
insertConfirmButton(options);
|
||||
}
|
||||
|
||||
// Insert "persist" button (if required)
|
||||
if (options.persist) {
|
||||
insertPersistButton(options);
|
||||
}
|
||||
|
||||
// Display the modal
|
||||
$(modal).modal('show');
|
||||
|
||||
@ -616,6 +621,22 @@ function insertConfirmButton(options) {
|
||||
}
|
||||
|
||||
|
||||
/* Add a checkbox to select if the modal will stay open after success */
|
||||
function insertPersistButton(options) {
|
||||
|
||||
var message = options.persistMessage || '{% trans "Keep this form open" %}';
|
||||
|
||||
var html = `
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="modal-persist">
|
||||
<label class="form-check-label" for="modal-persist">${message}</label>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$(options.modal).find('#modal-footer-buttons').append(html);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Extract all specified form values as a single object
|
||||
*/
|
||||
@ -934,19 +955,40 @@ function getFormFieldValue(name, field={}, options={}) {
|
||||
*/
|
||||
function handleFormSuccess(response, options) {
|
||||
|
||||
// Close the modal
|
||||
if (!options.preventClose) {
|
||||
// Note: The modal will be deleted automatically after closing
|
||||
$(options.modal).modal('hide');
|
||||
}
|
||||
|
||||
// Display any required messages
|
||||
// Should we show alerts immediately or cache them?
|
||||
var cache = (options.follow && response.url) || options.redirect || options.reload;
|
||||
|
||||
// Should the form "persist"?
|
||||
var persist = false;
|
||||
|
||||
if (options.persist && options.modal) {
|
||||
// Determine if this form should "persist", or be dismissed?
|
||||
var chk = $(options.modal).find('#modal-persist');
|
||||
|
||||
persist = chk.exists() && chk.prop('checked');
|
||||
}
|
||||
|
||||
if (persist) {
|
||||
cache = false;
|
||||
}
|
||||
|
||||
var msg_target = null;
|
||||
|
||||
if (persist) {
|
||||
// If the modal is persistant, the target for any messages should be the modal!
|
||||
msg_target = $(options.modal).find('#pre-form-content');
|
||||
}
|
||||
|
||||
// Display any messages
|
||||
if (response && (response.success || options.successMessage)) {
|
||||
showAlertOrCache(response.success || options.successMessage, cache, {style: 'success'});
|
||||
showAlertOrCache(
|
||||
response.success || options.successMessage,
|
||||
cache,
|
||||
{
|
||||
style: 'success',
|
||||
target: msg_target,
|
||||
});
|
||||
}
|
||||
|
||||
if (response && response.info) {
|
||||
@ -961,20 +1003,41 @@ function handleFormSuccess(response, options) {
|
||||
showAlertOrCache(response.danger, cache, {style: 'danger'});
|
||||
}
|
||||
|
||||
if (options.onSuccess) {
|
||||
// Callback function
|
||||
options.onSuccess(response, options);
|
||||
}
|
||||
if (persist) {
|
||||
// Instead of closing the form and going somewhere else,
|
||||
// reload (empty) the form so the user can input more data
|
||||
|
||||
// Reset the status of the "submit" button
|
||||
if (options.modal) {
|
||||
$(options.modal).find('#modal-form-submit').prop('disabled', false);
|
||||
}
|
||||
|
||||
if (options.follow && response.url) {
|
||||
// Follow the returned URL
|
||||
window.location.href = response.url;
|
||||
} else if (options.reload) {
|
||||
// Reload the current page
|
||||
location.reload();
|
||||
} else if (options.redirect) {
|
||||
// Redirect to a specified URL
|
||||
window.location.href = options.redirect;
|
||||
// Remove any error flags from the form
|
||||
clearFormErrors(options);
|
||||
|
||||
} else {
|
||||
|
||||
// Close the modal
|
||||
if (!options.preventClose) {
|
||||
// Note: The modal will be deleted automatically after closing
|
||||
$(options.modal).modal('hide');
|
||||
}
|
||||
|
||||
if (options.onSuccess) {
|
||||
// Callback function
|
||||
options.onSuccess(response, options);
|
||||
}
|
||||
|
||||
if (options.follow && response.url) {
|
||||
// Follow the returned URL
|
||||
window.location.href = response.url;
|
||||
} else if (options.reload) {
|
||||
// Reload the current page
|
||||
location.reload();
|
||||
} else if (options.redirect) {
|
||||
// Redirect to a specified URL
|
||||
window.location.href = options.redirect;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -988,6 +1051,8 @@ function clearFormErrors(options={}) {
|
||||
if (options && options.modal) {
|
||||
// Remove the individual error messages
|
||||
$(options.modal).find('.form-error-message').remove();
|
||||
|
||||
$(options.modal).find('.modal-content').removeClass('modal-error');
|
||||
|
||||
// Remove the "has error" class
|
||||
$(options.modal).find('.form-field-error').removeClass('form-field-error');
|
||||
@ -1884,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 = '';
|
||||
|
||||
@ -1976,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) {
|
||||
@ -1998,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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2147,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=''`);
|
||||
@ -2192,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}'`);
|
||||
|
@ -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>`;
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
@ -936,6 +1011,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
reference: {},
|
||||
purchase_price: {},
|
||||
purchase_price_currency: {},
|
||||
target_date: {},
|
||||
destination: {},
|
||||
notes: {},
|
||||
},
|
||||
@ -977,7 +1053,11 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
],
|
||||
{
|
||||
success: function() {
|
||||
// Reload the line item table
|
||||
$(table).bootstrapTable('refresh');
|
||||
|
||||
// Reload the "received stock" table
|
||||
$('#stock-table').bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -1117,6 +1197,28 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
return formatter.format(total);
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'target_date',
|
||||
switchable: true,
|
||||
title: '{% trans "Target Date" %}',
|
||||
formatter: function(value, row) {
|
||||
if (row.target_date) {
|
||||
var html = row.target_date;
|
||||
|
||||
if (row.overdue) {
|
||||
html += `<span class='fas fa-calendar-alt icon-red float-right' title='{% trans "This line item is overdue" %}'></span>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
|
||||
} else if (row.order_detail && row.order_detail.target_date) {
|
||||
return `<em>${row.order_detail.target_date}</em>`;
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: false,
|
||||
field: 'received',
|
||||
@ -1163,15 +1265,15 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
|
||||
var pk = row.pk;
|
||||
|
||||
if (options.allow_receive && row.received < row.quantity) {
|
||||
html += makeIconButton('fa-sign-in-alt icon-green', 'button-line-receive', pk, '{% trans "Receive line item" %}');
|
||||
}
|
||||
|
||||
if (options.allow_edit) {
|
||||
html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}');
|
||||
html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
|
||||
}
|
||||
|
||||
if (options.allow_receive && row.received < row.quantity) {
|
||||
html += makeIconButton('fa-sign-in-alt', 'button-line-receive', pk, '{% trans "Receive line item" %}');
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
return html;
|
||||
@ -2223,6 +2325,28 @@ function loadSalesOrderLineItemTable(table, options={}) {
|
||||
return formatter.format(total);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'target_date',
|
||||
title: '{% trans "Target Date" %}',
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
formatter: function(value, row) {
|
||||
if (row.target_date) {
|
||||
var html = row.target_date;
|
||||
|
||||
if (row.overdue) {
|
||||
html += `<span class='fas fa-calendar-alt icon-red float-right' title='{% trans "This line item is overdue" %}'></span>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
|
||||
} else if (row.order_detail && row.order_detail.target_date) {
|
||||
return `<em>${row.order_detail.target_date}</em>`;
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (pending) {
|
||||
@ -2366,6 +2490,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
|
||||
reference: {},
|
||||
sale_price: {},
|
||||
sale_price_currency: {},
|
||||
target_date: {},
|
||||
notes: {},
|
||||
},
|
||||
title: '{% trans "Edit Line Item" %}',
|
||||
|
@ -905,6 +905,28 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}',
|
||||
},
|
||||
{
|
||||
field: 'target_date',
|
||||
title: '{% trans "Target Date" %}',
|
||||
switchable: true,
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
if (row.target_date) {
|
||||
var html = row.target_date;
|
||||
|
||||
if (row.overdue) {
|
||||
html += `<span class='fas fa-calendar-alt icon-red float-right' title='{% trans "This line item is overdue" %}'></span>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
|
||||
} else if (row.order_detail && row.order_detail.target_date) {
|
||||
return `<em>${row.order_detail.target_date}</em>`;
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'received',
|
||||
title: '{% trans "Received" %}',
|
||||
|
Loading…
Reference in New Issue
Block a user