Merge remote-tracking branch 'inventree/master' into date-format

This commit is contained in:
Oliver 2022-03-01 08:18:24 +11:00
commit 5d37ce9175
17 changed files with 620 additions and 111 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): 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
- Serial numbers can be split by whitespace / newline / commma chars Requirements:
- Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20 - Serial numbers can be either strings, or integers
- Serial numbers can be defined as ~ for getting the next available serial number - Serial numbers can be split by whitespace / newline / commma chars
- Serial numbers can be supplied as <start>+ for getting all expecteded numbers starting from <start> - Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20
- Serial numbers can be supplied as <start>+<length> for getting <length> numbers starting from <start> - 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: Args:
serials: input string with patterns serials: input string with patterns
@ -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

@ -37,7 +37,6 @@ function showAlertOrCache(message, cache, options={}) {
if (cache) { if (cache) {
addCachedAlert(message, options); addCachedAlert(message, options);
} else { } else {
showMessage(message, options); showMessage(message, options);
} }
} }
@ -82,6 +81,8 @@ function showMessage(message, options={}) {
var timeout = options.timeout || 5000; var timeout = options.timeout || 5000;
var target = options.target || $('#alerts');
var details = ''; var details = '';
if (options.details) { if (options.details) {
@ -111,7 +112,7 @@ function showMessage(message, options={}) {
</div> </div>
`; `;
$('#alerts').append(html); target.append(html);
// Remove the alert automatically after a specified period of time // Remove the alert automatically after a specified period of time
$(`#alert-${id}`).delay(timeout).slideUp(200, function() { $(`#alert-${id}`).delay(timeout).slideUp(200, function() {

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

@ -12,11 +12,14 @@ import common.models
INVENTREE_SW_VERSION = "0.7.0 dev" INVENTREE_SW_VERSION = "0.7.0 dev"
# InvenTree API version # 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 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 v26 -> 2022-02-17
- Adds API endpoint for uploading a BOM file and extracting data - Adds API endpoint for uploading a BOM file and extracting data

View File

@ -274,7 +274,7 @@ class POLineItemFilter(rest_filters.FilterSet):
model = models.PurchaseOrderLineItem model = models.PurchaseOrderLineItem
fields = [ fields = [
'order', 'order',
'part' 'part',
] ]
pending = rest_filters.BooleanFilter(label='pending', method='filter_pending') pending = rest_filters.BooleanFilter(label='pending', method='filter_pending')
@ -391,6 +391,7 @@ class POLineItemList(generics.ListCreateAPIView):
'reference', 'reference',
'SKU', 'SKU',
'total_price', 'total_price',
'target_date',
] ]
search_fields = [ search_fields = [
@ -401,11 +402,6 @@ class POLineItemList(generics.ListCreateAPIView):
'reference', 'reference',
] ]
filter_fields = [
'order',
'part'
]
class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView): class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
""" """
@ -703,6 +699,7 @@ class SOLineItemList(generics.ListCreateAPIView):
'part__name', 'part__name',
'quantity', 'quantity',
'reference', 'reference',
'target_date',
] ]
search_fields = [ search_fields = [

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

View File

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

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,33 +437,45 @@ 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:
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 = { for sn in serials:
'status': status,
'purchaseorder': self.pk,
}
stock.add_tracking_entry( stock = stock_models.StockItem(
StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER, part=line.part.part,
user, supplier_part=line.part,
notes=notes, location=location,
deltas=tracking_info, quantity=1 if serialize else quantity,
location=location, purchase_order=self,
purchaseorder=self, status=status,
quantity=quantity 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 # Update the number of parts received against the particular line item
line.received += quantity line.received += quantity
@ -794,9 +816,18 @@ class OrderLineItem(models.Model):
Attributes: Attributes:
quantity: Number of items quantity: Number of items
reference: Reference text (e.g. customer reference) for this line item
note: Annotation for the 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: class Meta:
abstract = True 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')) 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): class PurchaseOrderLineItem(OrderLineItem):
""" Model for a purchase order line item. """ Model for a purchase order line item.
@ -824,7 +861,6 @@ class PurchaseOrderLineItem(OrderLineItem):
class Meta: class Meta:
unique_together = ( unique_together = (
('order', 'part', 'quantity', 'purchase_price')
) )
@staticmethod @staticmethod

View File

@ -5,12 +5,14 @@ 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
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Case, When, Value 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 import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
@ -26,7 +28,7 @@ from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeDecimalField from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.serializers import InvenTreeMoneySerializer from InvenTree.serializers import InvenTreeMoneySerializer
from InvenTree.serializers import ReferenceIndexingSerializerMixin from InvenTree.serializers import ReferenceIndexingSerializerMixin
from InvenTree.status_codes import StockStatus from InvenTree.status_codes import StockStatus, PurchaseOrderStatus, SalesOrderStatus
import order.models import order.models
@ -126,6 +128,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
Add some extra annotations to this queryset: Add some extra annotations to this queryset:
- Total price = purchase_price * quantity - Total price = purchase_price * quantity
- "Overdue" status (boolean field)
""" """
queryset = queryset.annotate( 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 return queryset
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -155,6 +167,8 @@ class POLineItemSerializer(InvenTreeModelSerializer):
quantity = serializers.FloatField(default=1) quantity = serializers.FloatField(default=1)
received = serializers.FloatField(default=0) received = serializers.FloatField(default=0)
overdue = serializers.BooleanField(required=False, read_only=True)
total_price = serializers.FloatField(read_only=True) total_price = serializers.FloatField(read_only=True)
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True) part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
@ -185,6 +199,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
'notes', 'notes',
'order', 'order',
'order_detail', 'order_detail',
'overdue',
'part', 'part',
'part_detail', 'part_detail',
'supplier_part_detail', 'supplier_part_detail',
@ -194,6 +209,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
'purchase_price_string', 'purchase_price_string',
'destination', 'destination',
'destination_detail', 'destination_detail',
'target_date',
'total_price', 'total_price',
] ]
@ -203,6 +219,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 +268,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 +313,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 +430,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
@ -549,6 +615,23 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
class SOLineItemSerializer(InvenTreeModelSerializer): class SOLineItemSerializer(InvenTreeModelSerializer):
""" Serializer for a SalesOrderLineItem object """ """ 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): def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False) part_detail = kwargs.pop('part_detail', False)
@ -570,6 +653,8 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True) part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True) allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
overdue = serializers.BooleanField(required=False, read_only=True)
quantity = InvenTreeDecimalField() quantity = InvenTreeDecimalField()
allocated = serializers.FloatField(source='allocated_quantity', read_only=True) allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
@ -599,12 +684,14 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
'notes', 'notes',
'order', 'order',
'order_detail', 'order_detail',
'overdue',
'part', 'part',
'part_detail', 'part_detail',
'sale_price', 'sale_price',
'sale_price_currency', 'sale_price_currency',
'sale_price_string', 'sale_price_string',
'shipped', 'shipped',
'target_date',
] ]

View File

@ -174,6 +174,7 @@ $('#new-po-line').click(function() {
value: '{{ order.supplier.currency }}', value: '{{ order.supplier.currency }}',
{% endif %} {% endif %}
}, },
target_date: {},
destination: {}, destination: {},
notes: {}, notes: {},
}, },
@ -210,7 +211,7 @@ $('#new-po-line').click(function() {
loadPurchaseOrderLineItemTable('#po-line-table', { loadPurchaseOrderLineItemTable('#po-line-table', {
order: {{ order.pk }}, order: {{ order.pk }},
supplier: {{ order.supplier.pk }}, supplier: {{ order.supplier.pk }},
{% if order.status == PurchaseOrderStatus.PENDING %} {% if roles.purchase_order.change %}
allow_edit: true, allow_edit: true,
{% else %} {% else %}
allow_edit: false, allow_edit: false,

View File

@ -238,6 +238,7 @@
reference: {}, reference: {},
sale_price: {}, sale_price: {},
sale_price_currency: {}, sale_price_currency: {},
target_date: {},
notes: {}, notes: {},
}, },
method: 'POST', method: 'POST',

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

@ -313,6 +313,10 @@
fields: fields, fields: fields,
groups: partGroups(), groups: partGroups(),
title: '{% trans "Create Part" %}', title: '{% trans "Create Part" %}',
reloadFormAfterSuccess: true,
persist: true,
persistMessage: '{% trans "Create another part after this one" %}',
successMessage: '{% trans "Part created successfully" %}',
onSuccess: function(data) { onSuccess: function(data) {
// Follow the new part // Follow the new part
location.href = `/part/${data.pk}/`; location.href = `/part/${data.pk}/`;

View File

@ -542,6 +542,11 @@ function constructFormBody(fields, options) {
insertConfirmButton(options); insertConfirmButton(options);
} }
// Insert "persist" button (if required)
if (options.persist) {
insertPersistButton(options);
}
// Display the modal // Display the modal
$(modal).modal('show'); $(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 * Extract all specified form values as a single object
*/ */
@ -934,19 +955,40 @@ function getFormFieldValue(name, field={}, options={}) {
*/ */
function handleFormSuccess(response, 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 // Display any required messages
// Should we show alerts immediately or cache them? // Should we show alerts immediately or cache them?
var cache = (options.follow && response.url) || options.redirect || options.reload; 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 // Display any messages
if (response && (response.success || options.successMessage)) { 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) { if (response && response.info) {
@ -961,20 +1003,41 @@ function handleFormSuccess(response, options) {
showAlertOrCache(response.danger, cache, {style: 'danger'}); showAlertOrCache(response.danger, cache, {style: 'danger'});
} }
if (options.onSuccess) { if (persist) {
// Callback function // Instead of closing the form and going somewhere else,
options.onSuccess(response, options); // 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) { // Remove any error flags from the form
// Follow the returned URL clearFormErrors(options);
window.location.href = response.url;
} else if (options.reload) { } else {
// Reload the current page
location.reload(); // Close the modal
} else if (options.redirect) { if (!options.preventClose) {
// Redirect to a specified URL // Note: The modal will be deleted automatically after closing
window.location.href = options.redirect; $(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) { if (options && options.modal) {
// Remove the individual error messages // Remove the individual error messages
$(options.modal).find('.form-error-message').remove(); $(options.modal).find('.form-error-message').remove();
$(options.modal).find('.modal-content').removeClass('modal-error');
// Remove the "has error" class // Remove the "has error" class
$(options.modal).find('.form-field-error').removeClass('form-field-error'); $(options.modal).find('.form-field-error').removeClass('form-field-error');
@ -1884,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 = '';
@ -1976,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) {
@ -1998,9 +2063,13 @@ function constructField(name, parameters, options) {
if (extra) { if (extra) {
html += `<div class='input-group'>`; html += `<div class='input-group'>`;
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>`;
} }
} }
@ -2147,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=''`);
@ -2192,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);
} }
@ -936,6 +1011,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
reference: {}, reference: {},
purchase_price: {}, purchase_price: {},
purchase_price_currency: {}, purchase_price_currency: {},
target_date: {},
destination: {}, destination: {},
notes: {}, notes: {},
}, },
@ -977,7 +1053,11 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
], ],
{ {
success: function() { success: function() {
// Reload the line item table
$(table).bootstrapTable('refresh'); $(table).bootstrapTable('refresh');
// Reload the "received stock" table
$('#stock-table').bootstrapTable('refresh');
} }
} }
); );
@ -1117,6 +1197,28 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
return formatter.format(total); 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, sortable: false,
field: 'received', field: 'received',
@ -1163,15 +1265,15 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
var pk = row.pk; 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) { if (options.allow_edit) {
html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}'); 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" %}'); 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>`; html += `</div>`;
return html; return html;
@ -2223,6 +2325,28 @@ function loadSalesOrderLineItemTable(table, options={}) {
return formatter.format(total); 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) { if (pending) {
@ -2366,6 +2490,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
reference: {}, reference: {},
sale_price: {}, sale_price: {},
sale_price_currency: {}, sale_price_currency: {},
target_date: {},
notes: {}, notes: {},
}, },
title: '{% trans "Edit Line Item" %}', title: '{% trans "Edit Line Item" %}',

View File

@ -905,6 +905,28 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
field: 'quantity', field: 'quantity',
title: '{% trans "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', field: 'received',
title: '{% trans "Received" %}', title: '{% trans "Received" %}',