mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Reference fields (#3267)
* Adds a configurable 'reference pattern' to the IndexingReferenceMixin class * Expand tests for reference_pattern validator: - Prevent inclusion of illegal characters - Prevent multiple groups of hash (#) characters - Add unit tests * Validator now checks for valid strftime formatter * Adds build order reference pattern * Adds function for creating a valid regex from the supplied pattern - More unit tests - Use it to validate BuildOrder reference field * Refactoring the whole thing again - try using python string.format * remove datetime-matcher from requirements.txt * Add some more formatting helper functions - Construct a regular expression from a format string - Extract named values from a string, based on a format string * Fix validator for build order reference field * Adding unit tests for the new format string functionality * Adds validation for reference fields * Require the 'ref' format key as part of a valid reference pattern * Extend format extraction to allow specification of integer groups * Remove unused import * Fix requirements * Add method for generating the 'next' reference field for a model * Fix function for generating next BuildOrder reference value - A function is required as class methods cannot be used - Simply wraps the existing class method * Remove BUILDORDER_REFERENCE_REGEX setting * Add unit test for build order reference field validation * Adds unit testing for extracting integer values from a reference field * Fix bugs from previous commit * Add unit test for generation of default build order reference * Add data migration for BuildOrder model - Update reference field with old prefix - Construct new pattern based on old prefix * Adds unit test for data migration - Check that the BuildOrder reference field is updated as expected * Remove 'BUILDORDER_REFERENCE_PREFIX' setting * Adds new setting for SalesOrder reference pattern * Update method by which next reference value is generated * Improved error handling in api_tester code * Improve automated generation of order reference fields - Handle potential errors - Return previous reference if something goes wrong * SalesOrder reference has now been updated also - New reference pattern setting - Updated default and validator for reference field - Updated serializer and API - Added unit tests * Migrate the "PurchaseOrder" reference field to the new system * Data migration for SalesOrder and PurchaseOrder reference fields * Remove PURCHASEORDER_REFERENCE_PREFIX * Remove references to SALESORDER_REFERENCE_PREFIX * Re-add maximum value validation * Bug fixes * Improve algorithm for generating new reference - Handle case where most recent reference does not conform to the reference pattern * Fixes for 'order' unit tests * Unit test fixes for order app * More unit test fixes * More unit test fixing * Revert behaviour for "extract_int" clipping function * Unit test value fix * Prevent build order notification if we are importing records
This commit is contained in:
parent
6133c745d7
commit
648faf4ed2
@ -133,8 +133,12 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
|||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
|
|
||||||
if response.status_code != expected_code:
|
if response.status_code != expected_code:
|
||||||
print(f"Unexpected response at '{url}':")
|
print(f"Unexpected response at '{url}': status code = {response.status_code}")
|
||||||
print(response.data)
|
|
||||||
|
if hasattr(response, 'data'):
|
||||||
|
print(response.data)
|
||||||
|
else:
|
||||||
|
print(f"(response object {type(response)} has no 'data' attribute")
|
||||||
|
|
||||||
self.assertEqual(response.status_code, expected_code)
|
self.assertEqual(response.status_code, expected_code)
|
||||||
|
|
||||||
|
156
InvenTree/InvenTree/format.py
Normal file
156
InvenTree/InvenTree/format.py
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
"""Custom string formatting functions and helpers"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import string
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
def parse_format_string(fmt_string: str) -> dict:
|
||||||
|
"""Extract formatting information from the provided format string.
|
||||||
|
|
||||||
|
Returns a dict object which contains structured information about the format groups
|
||||||
|
"""
|
||||||
|
|
||||||
|
groups = string.Formatter().parse(fmt_string)
|
||||||
|
|
||||||
|
info = {}
|
||||||
|
|
||||||
|
for group in groups:
|
||||||
|
# Skip any group which does not have a named value
|
||||||
|
if not group[1]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
info[group[1]] = {
|
||||||
|
'format': group[1],
|
||||||
|
'prefix': group[0],
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def construct_format_regex(fmt_string: str) -> str:
|
||||||
|
r"""Construct a regular expression based on a provided format string
|
||||||
|
|
||||||
|
This function turns a python format string into a regular expression,
|
||||||
|
which can be used for two purposes:
|
||||||
|
|
||||||
|
- Ensure that a particular string matches the specified format
|
||||||
|
- Extract named variables from a matching string
|
||||||
|
|
||||||
|
This function also provides support for wildcard characters:
|
||||||
|
|
||||||
|
- '?' provides single character matching; is converted to a '.' (period) for regex
|
||||||
|
- '#' provides single digit matching; is converted to '\d'
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fmt_string: A typical format string e.g. "PO-???-{ref:04d}"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: A regular expression pattern e.g. ^PO\-...\-(?P<ref>.*)$
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Format string is invalid
|
||||||
|
"""
|
||||||
|
|
||||||
|
pattern = "^"
|
||||||
|
|
||||||
|
for group in string.Formatter().parse(fmt_string):
|
||||||
|
prefix = group[0] # Prefix (literal text appearing before this group)
|
||||||
|
name = group[1] # Name of this format variable
|
||||||
|
format = group[2] # Format specifier e.g :04d
|
||||||
|
|
||||||
|
rep = [
|
||||||
|
'+', '-', '.',
|
||||||
|
'{', '}', '(', ')',
|
||||||
|
'^', '$', '~', '!', '@', ':', ';', '|', '\'', '"',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Escape any special regex characters
|
||||||
|
for ch in rep:
|
||||||
|
prefix = prefix.replace(ch, '\\' + ch)
|
||||||
|
|
||||||
|
# Replace ? with single-character match
|
||||||
|
prefix = prefix.replace('?', '.')
|
||||||
|
|
||||||
|
# Replace # with single-digit match
|
||||||
|
prefix = prefix.replace('#', r'\d')
|
||||||
|
|
||||||
|
pattern += prefix
|
||||||
|
|
||||||
|
# Add a named capture group for the format entry
|
||||||
|
if name:
|
||||||
|
|
||||||
|
# Check if integer values are requried
|
||||||
|
if format.endswith('d'):
|
||||||
|
chr = '\d'
|
||||||
|
else:
|
||||||
|
chr = '.'
|
||||||
|
|
||||||
|
# Specify width
|
||||||
|
# TODO: Introspect required width
|
||||||
|
w = '+'
|
||||||
|
|
||||||
|
pattern += f"(?P<{name}>{chr}{w})"
|
||||||
|
|
||||||
|
pattern += "$"
|
||||||
|
|
||||||
|
return pattern
|
||||||
|
|
||||||
|
|
||||||
|
def validate_string(value: str, fmt_string: str) -> str:
|
||||||
|
"""Validate that the provided string matches the specified format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: The string to be tested e.g. 'SO-1234-ABC',
|
||||||
|
fmt_string: The required format e.g. 'SO-{ref}-???',
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the value matches the required format, else False
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: The provided format string is invalid
|
||||||
|
"""
|
||||||
|
|
||||||
|
pattern = construct_format_regex(fmt_string)
|
||||||
|
|
||||||
|
result = re.match(pattern, value)
|
||||||
|
|
||||||
|
return result is not None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_named_group(name: str, value: str, fmt_string: str) -> str:
|
||||||
|
"""Extract a named value from the provided string, given the provided format string
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Name of group to extract e.g. 'ref'
|
||||||
|
value: Raw string e.g. 'PO-ABC-1234'
|
||||||
|
fmt_string: Format pattern e.g. 'PO-???-{ref}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: String value of the named group
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: format string is incorrectly specified, or provided value does not match format string
|
||||||
|
NameError: named value does not exist in the format string
|
||||||
|
IndexError: named value could not be found in the provided entry
|
||||||
|
"""
|
||||||
|
|
||||||
|
info = parse_format_string(fmt_string)
|
||||||
|
|
||||||
|
if name not in info.keys():
|
||||||
|
raise NameError(_(f"Value '{name}' does not appear in pattern format"))
|
||||||
|
|
||||||
|
# Construct a regular expression for matching against the provided format string
|
||||||
|
# Note: This will raise a ValueError if 'fmt_string' is incorrectly specified
|
||||||
|
pattern = construct_format_regex(fmt_string)
|
||||||
|
|
||||||
|
# Run the regex matcher against the raw string
|
||||||
|
result = re.match(pattern, value)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise ValueError(_("Provided value does not match required pattern: ") + fmt_string)
|
||||||
|
|
||||||
|
# And return the value we are interested in
|
||||||
|
# Note: This will raise an IndexError if the named group was not matched
|
||||||
|
return result.group(name)
|
@ -3,6 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
@ -19,7 +20,9 @@ from error_report.models import Error
|
|||||||
from mptt.exceptions import InvalidMove
|
from mptt.exceptions import InvalidMove
|
||||||
from mptt.models import MPTTModel, TreeForeignKey
|
from mptt.models import MPTTModel, TreeForeignKey
|
||||||
|
|
||||||
|
import InvenTree.format
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
from InvenTree.fields import InvenTreeURLField
|
from InvenTree.fields import InvenTreeURLField
|
||||||
from InvenTree.validators import validate_tree_name
|
from InvenTree.validators import validate_tree_name
|
||||||
|
|
||||||
@ -96,9 +99,6 @@ class DataImportMixin(object):
|
|||||||
class ReferenceIndexingMixin(models.Model):
|
class ReferenceIndexingMixin(models.Model):
|
||||||
"""A mixin for keeping track of numerical copies of the "reference" field.
|
"""A mixin for keeping track of numerical copies of the "reference" field.
|
||||||
|
|
||||||
!!DANGER!! always add `ReferenceIndexingSerializerMixin`to all your models serializers to
|
|
||||||
ensure the reference field is not too big
|
|
||||||
|
|
||||||
Here, we attempt to convert a "reference" field value (char) to an integer,
|
Here, we attempt to convert a "reference" field value (char) to an integer,
|
||||||
for performing fast natural sorting.
|
for performing fast natural sorting.
|
||||||
|
|
||||||
@ -112,24 +112,216 @@ class ReferenceIndexingMixin(models.Model):
|
|||||||
- Otherwise, we store zero
|
- Otherwise, we store zero
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Name of the global setting which defines the required reference pattern for this model
|
||||||
|
REFERENCE_PATTERN_SETTING = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_reference_pattern(cls):
|
||||||
|
"""Returns the reference pattern associated with this model.
|
||||||
|
|
||||||
|
This is defined by a global setting object, specified by the REFERENCE_PATTERN_SETTING attribute
|
||||||
|
"""
|
||||||
|
|
||||||
|
# By default, we return an empty string
|
||||||
|
if cls.REFERENCE_PATTERN_SETTING is None:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
return InvenTreeSetting.get_setting(cls.REFERENCE_PATTERN_SETTING, create=False).strip()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_reference_context(cls):
|
||||||
|
"""Generate context data for generating the 'reference' field for this class.
|
||||||
|
|
||||||
|
- Returns a python dict object which contains the context data for formatting the reference string.
|
||||||
|
- The default implementation provides some default context information
|
||||||
|
"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
'ref': cls.get_next_reference(),
|
||||||
|
'date': datetime.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_most_recent_item(cls):
|
||||||
|
"""Return the item which is 'most recent'
|
||||||
|
|
||||||
|
In practice, this means the item with the highest reference value
|
||||||
|
"""
|
||||||
|
|
||||||
|
query = cls.objects.all().order_by('-reference_int', '-pk')
|
||||||
|
|
||||||
|
if query.exists():
|
||||||
|
return query.first()
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_next_reference(cls):
|
||||||
|
"""Return the next available reference value for this particular class."""
|
||||||
|
|
||||||
|
# Find the "most recent" item
|
||||||
|
latest = cls.get_most_recent_item()
|
||||||
|
|
||||||
|
if not latest:
|
||||||
|
# No existing items
|
||||||
|
return 1
|
||||||
|
|
||||||
|
reference = latest.reference.strip
|
||||||
|
|
||||||
|
try:
|
||||||
|
reference = InvenTree.format.extract_named_group('ref', reference, cls.get_reference_pattern())
|
||||||
|
except Exception:
|
||||||
|
# If reference cannot be extracted using the pattern, try just the integer value
|
||||||
|
reference = str(latest.reference_int)
|
||||||
|
|
||||||
|
# Attempt to perform 'intelligent' incrementing of the reference field
|
||||||
|
incremented = InvenTree.helpers.increment(reference)
|
||||||
|
|
||||||
|
try:
|
||||||
|
incremented = int(incremented)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return incremented
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_reference(cls):
|
||||||
|
"""Generate the next 'reference' field based on specified pattern"""
|
||||||
|
|
||||||
|
fmt = cls.get_reference_pattern()
|
||||||
|
ctx = cls.get_reference_context()
|
||||||
|
|
||||||
|
reference = None
|
||||||
|
|
||||||
|
attempts = set()
|
||||||
|
|
||||||
|
while reference is None:
|
||||||
|
try:
|
||||||
|
ref = fmt.format(**ctx)
|
||||||
|
|
||||||
|
if ref in attempts:
|
||||||
|
# We are stuck in a loop!
|
||||||
|
reference = ref
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
attempts.add(ref)
|
||||||
|
|
||||||
|
if cls.objects.filter(reference=ref).exists():
|
||||||
|
# Handle case where we have duplicated an existing reference
|
||||||
|
ctx['ref'] = InvenTree.helpers.increment(ctx['ref'])
|
||||||
|
else:
|
||||||
|
# We have found an 'unused' reference
|
||||||
|
reference = ref
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# If anything goes wrong, return the most recent reference
|
||||||
|
recent = cls.get_most_recent_item()
|
||||||
|
if recent:
|
||||||
|
reference = recent.reference
|
||||||
|
else:
|
||||||
|
reference = ""
|
||||||
|
|
||||||
|
return reference
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate_reference_pattern(cls, pattern):
|
||||||
|
"""Ensure that the provided pattern is valid"""
|
||||||
|
|
||||||
|
ctx = cls.get_reference_context()
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = InvenTree.format.parse_format_string(pattern)
|
||||||
|
except Exception:
|
||||||
|
raise ValidationError({
|
||||||
|
"value": _("Improperly formatted pattern"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check that only 'allowed' keys are provided
|
||||||
|
for key in info.keys():
|
||||||
|
if key not in ctx.keys():
|
||||||
|
raise ValidationError({
|
||||||
|
"value": _("Unknown format key specified") + f": '{key}'"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check that the 'ref' variable is specified
|
||||||
|
if 'ref' not in info.keys():
|
||||||
|
raise ValidationError({
|
||||||
|
'value': _("Missing required format key") + ": 'ref'"
|
||||||
|
})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate_reference_field(cls, value):
|
||||||
|
"""Check that the provided 'reference' value matches the requisite pattern"""
|
||||||
|
|
||||||
|
pattern = cls.get_reference_pattern()
|
||||||
|
|
||||||
|
value = str(value).strip()
|
||||||
|
|
||||||
|
if len(value) == 0:
|
||||||
|
raise ValidationError(_("Reference field cannot be empty"))
|
||||||
|
|
||||||
|
# An 'empty' pattern means no further validation is required
|
||||||
|
if not pattern:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not InvenTree.format.validate_string(value, pattern):
|
||||||
|
raise ValidationError(_("Reference must match required pattern") + ": " + pattern)
|
||||||
|
|
||||||
|
# Check that the reference field can be rebuild
|
||||||
|
cls.rebuild_reference_field(value, validate=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Metaclass options. Abstract ensures no database table is created."""
|
"""Metaclass options. Abstract ensures no database table is created."""
|
||||||
|
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
def rebuild_reference_field(self):
|
@classmethod
|
||||||
"""Extract integer out of reference for sorting."""
|
def rebuild_reference_field(cls, reference, validate=False):
|
||||||
reference = getattr(self, 'reference', '')
|
"""Extract integer out of reference for sorting.
|
||||||
self.reference_int = extract_int(reference)
|
|
||||||
|
If the 'integer' portion is buried somewhere 'within' the reference,
|
||||||
|
we can first try to extract it using the pattern.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
reference - BO-123-ABC
|
||||||
|
pattern - BO-{ref}-???
|
||||||
|
extracted - 123
|
||||||
|
|
||||||
|
If we cannot extract using the pattern for some reason, fallback to the entire reference
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Extract named group based on provided pattern
|
||||||
|
reference = InvenTree.format.extract_named_group('ref', reference, cls.get_reference_pattern())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
reference_int = extract_int(reference)
|
||||||
|
|
||||||
|
if validate:
|
||||||
|
if reference_int > models.BigIntegerField.MAX_BIGINT:
|
||||||
|
raise ValidationError({
|
||||||
|
"reference": _("Reference number is too large")
|
||||||
|
})
|
||||||
|
|
||||||
|
return reference_int
|
||||||
|
|
||||||
reference_int = models.BigIntegerField(default=0)
|
reference_int = models.BigIntegerField(default=0)
|
||||||
|
|
||||||
|
|
||||||
def extract_int(reference, clip=0x7fffffff):
|
def extract_int(reference, clip=0x7fffffff, allow_negative=False):
|
||||||
"""Extract integer out of reference."""
|
"""Extract an integer out of reference."""
|
||||||
|
|
||||||
# Default value if we cannot convert to an integer
|
# Default value if we cannot convert to an integer
|
||||||
ref_int = 0
|
ref_int = 0
|
||||||
|
|
||||||
|
reference = str(reference).strip()
|
||||||
|
|
||||||
|
# Ignore empty string
|
||||||
|
if len(reference) == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
# Look at the start of the string - can it be "integerized"?
|
# Look at the start of the string - can it be "integerized"?
|
||||||
result = re.match(r"^(\d+)", reference)
|
result = re.match(r"^(\d+)", reference)
|
||||||
|
|
||||||
@ -139,6 +331,16 @@ def extract_int(reference, clip=0x7fffffff):
|
|||||||
ref_int = int(ref)
|
ref_int = int(ref)
|
||||||
except Exception:
|
except Exception:
|
||||||
ref_int = 0
|
ref_int = 0
|
||||||
|
else:
|
||||||
|
# Look at the "end" of the string
|
||||||
|
result = re.search(r'(\d+)$', reference)
|
||||||
|
|
||||||
|
if result and len(result.groups()) == 1:
|
||||||
|
ref = result.groups()[0]
|
||||||
|
try:
|
||||||
|
ref_int = int(ref)
|
||||||
|
except Exception:
|
||||||
|
ref_int = 0
|
||||||
|
|
||||||
# Ensure that the returned values are within the range that can be stored in an IntegerField
|
# Ensure that the returned values are within the range that can be stored in an IntegerField
|
||||||
# Note: This will result in large values being "clipped"
|
# Note: This will result in large values being "clipped"
|
||||||
@ -148,6 +350,9 @@ def extract_int(reference, clip=0x7fffffff):
|
|||||||
elif ref_int < -clip:
|
elif ref_int < -clip:
|
||||||
ref_int = -clip
|
ref_int = -clip
|
||||||
|
|
||||||
|
if not allow_negative and ref_int < 0:
|
||||||
|
ref_int = abs(ref_int)
|
||||||
|
|
||||||
return ref_int
|
return ref_int
|
||||||
|
|
||||||
|
|
||||||
|
@ -7,7 +7,6 @@ from decimal import Decimal
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.db import models
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import tablib
|
import tablib
|
||||||
@ -20,8 +19,6 @@ from rest_framework.fields import empty
|
|||||||
from rest_framework.serializers import DecimalField
|
from rest_framework.serializers import DecimalField
|
||||||
from rest_framework.utils import model_meta
|
from rest_framework.utils import model_meta
|
||||||
|
|
||||||
from .models import extract_int
|
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeMoneySerializer(MoneyField):
|
class InvenTreeMoneySerializer(MoneyField):
|
||||||
"""Custom serializer for 'MoneyField', which ensures that passed values are numerically valid.
|
"""Custom serializer for 'MoneyField', which ensures that passed values are numerically valid.
|
||||||
@ -211,16 +208,6 @@ class UserSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class ReferenceIndexingSerializerMixin():
|
|
||||||
"""This serializer mixin ensures the the reference is not to big / small for the BigIntegerField."""
|
|
||||||
|
|
||||||
def validate_reference(self, value):
|
|
||||||
"""Ensures the reference is not to big / small for the BigIntegerField."""
|
|
||||||
if extract_int(value) > models.BigIntegerField.MAX_BIGINT:
|
|
||||||
raise serializers.ValidationError('reference is to to big')
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeAttachmentSerializerField(serializers.FileField):
|
class InvenTreeAttachmentSerializerField(serializers.FileField):
|
||||||
"""Override the DRF native FileField serializer, to remove the leading server path.
|
"""Override the DRF native FileField serializer, to remove the leading server path.
|
||||||
|
|
||||||
|
@ -17,6 +17,7 @@ from djmoney.contrib.exchange.exceptions import MissingRate
|
|||||||
from djmoney.contrib.exchange.models import Rate, convert_money
|
from djmoney.contrib.exchange.models import Rate, convert_money
|
||||||
from djmoney.money import Money
|
from djmoney.money import Money
|
||||||
|
|
||||||
|
import InvenTree.format
|
||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from common.settings import currency_codes
|
from common.settings import currency_codes
|
||||||
@ -60,6 +61,137 @@ class ValidatorTest(TestCase):
|
|||||||
validate_overage("aaaa")
|
validate_overage("aaaa")
|
||||||
|
|
||||||
|
|
||||||
|
class FormatTest(TestCase):
|
||||||
|
"""Unit tests for custom string formatting functionality"""
|
||||||
|
|
||||||
|
def test_parse(self):
|
||||||
|
"""Tests for the 'parse_format_string' function"""
|
||||||
|
|
||||||
|
# Extract data from a valid format string
|
||||||
|
fmt = "PO-{abc:02f}-{ref:04d}-{date}-???"
|
||||||
|
|
||||||
|
info = InvenTree.format.parse_format_string(fmt)
|
||||||
|
|
||||||
|
self.assertIn('abc', info)
|
||||||
|
self.assertIn('ref', info)
|
||||||
|
self.assertIn('date', info)
|
||||||
|
|
||||||
|
# Try with invalid strings
|
||||||
|
for fmt in [
|
||||||
|
'PO-{{xyz}',
|
||||||
|
'PO-{xyz}}',
|
||||||
|
'PO-{xyz}-{',
|
||||||
|
]:
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
InvenTree.format.parse_format_string(fmt)
|
||||||
|
|
||||||
|
def test_create_regex(self):
|
||||||
|
"""Test function for creating a regex from a format string"""
|
||||||
|
|
||||||
|
tests = {
|
||||||
|
"PO-123-{ref:04f}": r"^PO\-123\-(?P<ref>.+)$",
|
||||||
|
"{PO}-???-{ref}-{date}-22": r"^(?P<PO>.+)\-...\-(?P<ref>.+)\-(?P<date>.+)\-22$",
|
||||||
|
"ABC-123-###-{ref}": r"^ABC\-123\-\d\d\d\-(?P<ref>.+)$",
|
||||||
|
"ABC-123": r"^ABC\-123$",
|
||||||
|
}
|
||||||
|
|
||||||
|
for fmt, reg in tests.items():
|
||||||
|
self.assertEqual(InvenTree.format.construct_format_regex(fmt), reg)
|
||||||
|
|
||||||
|
def test_validate_format(self):
|
||||||
|
"""Test that string validation works as expected"""
|
||||||
|
|
||||||
|
# These tests should pass
|
||||||
|
for value, pattern in {
|
||||||
|
"ABC-hello-123": "???-{q}-###",
|
||||||
|
"BO-1234": "BO-{ref}",
|
||||||
|
"111.222.fred.china": "???.###.{name}.{place}",
|
||||||
|
"PO-1234": "PO-{ref:04d}"
|
||||||
|
}.items():
|
||||||
|
self.assertTrue(InvenTree.format.validate_string(value, pattern))
|
||||||
|
|
||||||
|
# These tests should fail
|
||||||
|
for value, pattern in {
|
||||||
|
"ABC-hello-123": "###-{q}-???",
|
||||||
|
"BO-1234": "BO.{ref}",
|
||||||
|
"BO-####": "BO-{pattern}-{next}",
|
||||||
|
"BO-123d": "BO-{ref:04d}"
|
||||||
|
}.items():
|
||||||
|
self.assertFalse(InvenTree.format.validate_string(value, pattern))
|
||||||
|
|
||||||
|
def test_extract_value(self):
|
||||||
|
"""Test that we can extract named values based on a format string"""
|
||||||
|
|
||||||
|
# Simple tests based on a straight-forward format string
|
||||||
|
fmt = "PO-###-{ref:04d}"
|
||||||
|
|
||||||
|
tests = {
|
||||||
|
"123": "PO-123-123",
|
||||||
|
"456": "PO-123-456",
|
||||||
|
"789": "PO-123-789",
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v in tests.items():
|
||||||
|
self.assertEqual(InvenTree.format.extract_named_group('ref', v, fmt), k)
|
||||||
|
|
||||||
|
# However these ones should fail
|
||||||
|
tests = {
|
||||||
|
'abc': 'PO-123-abc',
|
||||||
|
'xyz': 'PO-123-xyz',
|
||||||
|
}
|
||||||
|
|
||||||
|
for v in tests.values():
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
InvenTree.format.extract_named_group('ref', v, fmt)
|
||||||
|
|
||||||
|
# More complex tests
|
||||||
|
fmt = "PO-{date}-{test}-???-{ref}-###"
|
||||||
|
val = "PO-2022-02-01-hello-ABC-12345-222"
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'date': '2022-02-01',
|
||||||
|
'test': 'hello',
|
||||||
|
'ref': '12345',
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v in data.items():
|
||||||
|
self.assertEqual(InvenTree.format.extract_named_group(k, val, fmt), v)
|
||||||
|
|
||||||
|
# Test for error conditions
|
||||||
|
|
||||||
|
# Raises a ValueError as the format string is bad
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
InvenTree.format.extract_named_group(
|
||||||
|
"test",
|
||||||
|
"PO-1234-5",
|
||||||
|
"PO-{test}-{"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Raises a NameError as the named group does not exist in the format string
|
||||||
|
with self.assertRaises(NameError):
|
||||||
|
InvenTree.format.extract_named_group(
|
||||||
|
"missing",
|
||||||
|
"PO-12345",
|
||||||
|
"PO-{test}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Raises a ValueError as the value does not match the format string
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
InvenTree.format.extract_named_group(
|
||||||
|
"test",
|
||||||
|
"PO-1234",
|
||||||
|
"PO-{test}-1234",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
InvenTree.format.extract_named_group(
|
||||||
|
"test",
|
||||||
|
"PO-ABC-xyz",
|
||||||
|
"PO-###-{test}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestHelpers(TestCase):
|
class TestHelpers(TestCase):
|
||||||
"""Tests for InvenTree helper functions."""
|
"""Tests for InvenTree helper functions."""
|
||||||
|
|
||||||
|
@ -57,17 +57,6 @@ def validate_part_ipn(value):
|
|||||||
raise ValidationError(_('IPN must match regex pattern {pat}').format(pat=pattern))
|
raise ValidationError(_('IPN must match regex pattern {pat}').format(pat=pattern))
|
||||||
|
|
||||||
|
|
||||||
def validate_build_order_reference(value):
|
|
||||||
"""Validate the 'reference' field of a BuildOrder."""
|
|
||||||
pattern = common.models.InvenTreeSetting.get_setting('BUILDORDER_REFERENCE_REGEX')
|
|
||||||
|
|
||||||
if pattern:
|
|
||||||
match = re.search(pattern, value)
|
|
||||||
|
|
||||||
if match is None:
|
|
||||||
raise ValidationError(_('Reference must match pattern {pattern}').format(pattern=pattern))
|
|
||||||
|
|
||||||
|
|
||||||
def validate_purchase_order_reference(value):
|
def validate_purchase_order_reference(value):
|
||||||
"""Validate the 'reference' field of a PurchaseOrder."""
|
"""Validate the 'reference' field of a PurchaseOrder."""
|
||||||
pattern = common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_REGEX')
|
pattern = common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_REGEX')
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
fields:
|
fields:
|
||||||
part: 100 # Build against part 100 "Bob"
|
part: 100 # Build against part 100 "Bob"
|
||||||
batch: 'B1'
|
batch: 'B1'
|
||||||
reference: "0001"
|
reference: "BO-0001"
|
||||||
title: 'Building 7 parts'
|
title: 'Building 7 parts'
|
||||||
quantity: 7
|
quantity: 7
|
||||||
notes: 'Some simple notes'
|
notes: 'Some simple notes'
|
||||||
@ -21,7 +21,7 @@
|
|||||||
pk: 2
|
pk: 2
|
||||||
fields:
|
fields:
|
||||||
part: 50
|
part: 50
|
||||||
reference: "0002"
|
reference: "BO-0002"
|
||||||
title: 'Making things'
|
title: 'Making things'
|
||||||
batch: 'B2'
|
batch: 'B2'
|
||||||
status: 40 # COMPLETE
|
status: 40 # COMPLETE
|
||||||
@ -37,7 +37,7 @@
|
|||||||
pk: 3
|
pk: 3
|
||||||
fields:
|
fields:
|
||||||
part: 50
|
part: 50
|
||||||
reference: "0003"
|
reference: "BO-003"
|
||||||
title: 'Making things'
|
title: 'Making things'
|
||||||
batch: 'B2'
|
batch: 'B2'
|
||||||
status: 40 # COMPLETE
|
status: 40 # COMPLETE
|
||||||
@ -53,7 +53,7 @@
|
|||||||
pk: 4
|
pk: 4
|
||||||
fields:
|
fields:
|
||||||
part: 50
|
part: 50
|
||||||
reference: "0004"
|
reference: "BO-4"
|
||||||
title: 'Making things'
|
title: 'Making things'
|
||||||
batch: 'B4'
|
batch: 'B4'
|
||||||
status: 40 # COMPLETE
|
status: 40 # COMPLETE
|
||||||
@ -69,7 +69,7 @@
|
|||||||
pk: 5
|
pk: 5
|
||||||
fields:
|
fields:
|
||||||
part: 25
|
part: 25
|
||||||
reference: "0005"
|
reference: "BO-0005"
|
||||||
title: "Building some Widgets"
|
title: "Building some Widgets"
|
||||||
batch: "B10"
|
batch: "B10"
|
||||||
status: 40 # Complete
|
status: 40 # Complete
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# Generated by Django 3.0.7 on 2020-10-19 13:02
|
# Generated by Django 3.0.7 on 2020-10-19 13:02
|
||||||
|
|
||||||
import InvenTree.validators
|
import build.validators
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@ -18,6 +18,6 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='build',
|
model_name='build',
|
||||||
name='reference',
|
name='reference',
|
||||||
field=models.CharField(help_text='Build Order Reference', max_length=64, unique=True, validators=[InvenTree.validators.validate_build_order_reference], verbose_name='Reference'),
|
field=models.CharField(help_text='Build Order Reference', max_length=64, unique=True, validators=[build.validators.validate_build_order_reference], verbose_name='Reference'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# Generated by Django 3.2.4 on 2021-07-08 14:14
|
# Generated by Django 3.2.4 on 2021-07-08 14:14
|
||||||
|
|
||||||
import InvenTree.validators
|
import build.validators
|
||||||
import build.models
|
import build.models
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
@ -15,6 +15,6 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='build',
|
model_name='build',
|
||||||
name='reference',
|
name='reference',
|
||||||
field=models.CharField(default=build.models.get_next_build_number, help_text='Build Order Reference', max_length=64, unique=True, validators=[InvenTree.validators.validate_build_order_reference], verbose_name='Reference'),
|
field=models.CharField(default=build.validators.generate_next_build_reference, help_text='Build Order Reference', max_length=64, unique=True, validators=[build.validators.validate_build_order_reference], verbose_name='Reference'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
69
InvenTree/build/migrations/0036_auto_20220707_1101.py
Normal file
69
InvenTree/build/migrations/0036_auto_20220707_1101.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# Generated by Django 3.2.14 on 2022-07-07 11:01
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def update_build_reference(apps, schema_editor):
|
||||||
|
"""Update the build order reference.
|
||||||
|
|
||||||
|
Ref: https://github.com/inventree/InvenTree/pull/3267
|
||||||
|
|
||||||
|
Performs the following steps:
|
||||||
|
- Extract existing 'prefix' value
|
||||||
|
- Generate a build order pattern based on the prefix value
|
||||||
|
- Update any existing build order references with the specified prefix
|
||||||
|
"""
|
||||||
|
|
||||||
|
InvenTreeSetting = apps.get_model('common', 'inventreesetting')
|
||||||
|
|
||||||
|
try:
|
||||||
|
prefix = InvenTreeSetting.objects.get(key='BUILDORDER_REFERENCE_PREFIX').value
|
||||||
|
except Exception:
|
||||||
|
prefix = 'BO-'
|
||||||
|
|
||||||
|
# Construct a reference pattern
|
||||||
|
pattern = prefix + '{ref:04d}'
|
||||||
|
|
||||||
|
# Create or update the BuildOrder.reference pattern
|
||||||
|
try:
|
||||||
|
setting = InvenTreeSetting.objects.get(key='BUILDORDER_REFERENCE_PATTERN')
|
||||||
|
setting.value = pattern
|
||||||
|
setting.save()
|
||||||
|
except InvenTreeSetting.DoesNotExist:
|
||||||
|
setting = InvenTreeSetting.objects.create(
|
||||||
|
key='BUILDORDER_REFERENCE_PATTERN',
|
||||||
|
value=pattern,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update any existing build order references with the prefix
|
||||||
|
Build = apps.get_model('build', 'build')
|
||||||
|
|
||||||
|
n = 0
|
||||||
|
|
||||||
|
for build in Build.objects.all():
|
||||||
|
if not build.reference.startswith(prefix):
|
||||||
|
build.reference = prefix + build.reference
|
||||||
|
build.save()
|
||||||
|
n += 1
|
||||||
|
|
||||||
|
if n > 0:
|
||||||
|
print(f"Updated reference field for {n} BuildOrder objects")
|
||||||
|
|
||||||
|
|
||||||
|
def nupdate_build_reference(apps, schema_editor):
|
||||||
|
"""Reverse migration code. Does nothing."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('build', '0035_alter_build_notes'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
update_build_reference,
|
||||||
|
reverse_code=nupdate_build_reference,
|
||||||
|
)
|
||||||
|
]
|
@ -22,12 +22,14 @@ from mptt.exceptions import InvalidMove
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
|
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
|
||||||
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode, notify_responsible
|
from InvenTree.helpers import increment, normalize, MakeBarcode, notify_responsible
|
||||||
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
||||||
from InvenTree.validators import validate_build_order_reference
|
|
||||||
|
from build.validators import generate_next_build_reference, validate_build_order_reference
|
||||||
|
|
||||||
import InvenTree.fields
|
import InvenTree.fields
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
|
import InvenTree.ready
|
||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
|
|
||||||
from plugin.events import trigger_event
|
from plugin.events import trigger_event
|
||||||
@ -38,32 +40,6 @@ from stock import models as StockModels
|
|||||||
from users import models as UserModels
|
from users import models as UserModels
|
||||||
|
|
||||||
|
|
||||||
def get_next_build_number():
|
|
||||||
"""Returns the next available BuildOrder reference number."""
|
|
||||||
if Build.objects.count() == 0:
|
|
||||||
return '0001'
|
|
||||||
|
|
||||||
build = Build.objects.exclude(reference=None).last()
|
|
||||||
|
|
||||||
attempts = {build.reference}
|
|
||||||
|
|
||||||
reference = build.reference
|
|
||||||
|
|
||||||
while 1:
|
|
||||||
reference = increment(reference)
|
|
||||||
|
|
||||||
if reference in attempts:
|
|
||||||
# Escape infinite recursion
|
|
||||||
return reference
|
|
||||||
|
|
||||||
if Build.objects.filter(reference=reference).exists():
|
|
||||||
attempts.add(reference)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
return reference
|
|
||||||
|
|
||||||
|
|
||||||
class Build(MPTTModel, ReferenceIndexingMixin):
|
class Build(MPTTModel, ReferenceIndexingMixin):
|
||||||
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
||||||
|
|
||||||
@ -89,6 +65,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
|
OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
|
||||||
|
|
||||||
|
# Global setting for specifying reference pattern
|
||||||
|
REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN'
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
"""Return the API URL associated with the BuildOrder model"""
|
"""Return the API URL associated with the BuildOrder model"""
|
||||||
@ -106,7 +85,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
def api_defaults(cls, request):
|
def api_defaults(cls, request):
|
||||||
"""Return default values for this model when issuing an API OPTIONS request."""
|
"""Return default values for this model when issuing an API OPTIONS request."""
|
||||||
defaults = {
|
defaults = {
|
||||||
'reference': get_next_build_number(),
|
'reference': generate_next_build_reference(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if request and request.user:
|
if request and request.user:
|
||||||
@ -116,7 +95,8 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Custom save method for the BuildOrder model"""
|
"""Custom save method for the BuildOrder model"""
|
||||||
self.rebuild_reference_field()
|
self.validate_reference_field(self.reference)
|
||||||
|
self.reference_int = self.rebuild_reference_field(self.reference)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
@ -172,9 +152,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""String representation of a BuildOrder"""
|
"""String representation of a BuildOrder"""
|
||||||
prefix = getSetting("BUILDORDER_REFERENCE_PREFIX")
|
return self.reference
|
||||||
|
|
||||||
return f"{prefix}{self.reference}"
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
"""Return the web URL associated with this BuildOrder"""
|
"""Return the web URL associated with this BuildOrder"""
|
||||||
@ -186,9 +164,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
blank=False,
|
blank=False,
|
||||||
help_text=_('Build Order Reference'),
|
help_text=_('Build Order Reference'),
|
||||||
verbose_name=_('Reference'),
|
verbose_name=_('Reference'),
|
||||||
default=get_next_build_number,
|
default=generate_next_build_reference,
|
||||||
validators=[
|
validators=[
|
||||||
validate_build_order_reference
|
validate_build_order_reference,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -199,7 +177,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
help_text=_('Brief description of the build')
|
help_text=_('Brief description of the build')
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO - Perhaps delete the build "tree"
|
|
||||||
parent = TreeForeignKey(
|
parent = TreeForeignKey(
|
||||||
'self',
|
'self',
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@ -1092,6 +1069,10 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log')
|
@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log')
|
||||||
def after_save_build(sender, instance: Build, created: bool, **kwargs):
|
def after_save_build(sender, instance: Build, created: bool, **kwargs):
|
||||||
"""Callback function to be executed after a Build instance is saved."""
|
"""Callback function to be executed after a Build instance is saved."""
|
||||||
|
# Escape if we are importing data
|
||||||
|
if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase(allow_test=True):
|
||||||
|
return
|
||||||
|
|
||||||
from . import tasks as build_tasks
|
from . import tasks as build_tasks
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
|
@ -11,7 +11,7 @@ from rest_framework import serializers
|
|||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
|
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
|
||||||
from InvenTree.serializers import ReferenceIndexingSerializerMixin, UserSerializer
|
from InvenTree.serializers import UserSerializer
|
||||||
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
from InvenTree.helpers import extract_serial_numbers
|
from InvenTree.helpers import extract_serial_numbers
|
||||||
@ -28,7 +28,7 @@ from users.serializers import OwnerSerializer
|
|||||||
from .models import Build, BuildItem, BuildOrderAttachment
|
from .models import Build, BuildItem, BuildOrderAttachment
|
||||||
|
|
||||||
|
|
||||||
class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
|
class BuildSerializer(InvenTreeModelSerializer):
|
||||||
"""Serializes a Build object."""
|
"""Serializes a Build object."""
|
||||||
|
|
||||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||||
@ -74,6 +74,16 @@ class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer
|
|||||||
if part_detail is not True:
|
if part_detail is not True:
|
||||||
self.fields.pop('part_detail')
|
self.fields.pop('part_detail')
|
||||||
|
|
||||||
|
reference = serializers.CharField(required=True)
|
||||||
|
|
||||||
|
def validate_reference(self, reference):
|
||||||
|
"""Custom validation for the Build reference field"""
|
||||||
|
|
||||||
|
# Ensure the reference matches the required pattern
|
||||||
|
Build.validate_reference_field(reference)
|
||||||
|
|
||||||
|
return reference
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Serializer metaclass"""
|
"""Serializer metaclass"""
|
||||||
model = Build
|
model = Build
|
||||||
|
@ -748,6 +748,7 @@ class BuildListTest(BuildAPITest):
|
|||||||
|
|
||||||
Build.objects.create(
|
Build.objects.create(
|
||||||
part=part,
|
part=part,
|
||||||
|
reference="BO-0006",
|
||||||
quantity=10,
|
quantity=10,
|
||||||
title='Just some thing',
|
title='Just some thing',
|
||||||
status=BuildStatus.PRODUCTION,
|
status=BuildStatus.PRODUCTION,
|
||||||
@ -773,20 +774,23 @@ class BuildListTest(BuildAPITest):
|
|||||||
Build.objects.create(
|
Build.objects.create(
|
||||||
part=part,
|
part=part,
|
||||||
quantity=10,
|
quantity=10,
|
||||||
reference=f"build-000{i}",
|
reference=f"BO-{i + 10}",
|
||||||
title=f"Sub build {i}",
|
title=f"Sub build {i}",
|
||||||
parent=parent
|
parent=parent
|
||||||
)
|
)
|
||||||
|
|
||||||
# And some sub-sub builds
|
# And some sub-sub builds
|
||||||
for sub_build in Build.objects.filter(parent=parent):
|
for ii, sub_build in enumerate(Build.objects.filter(parent=parent)):
|
||||||
|
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
|
|
||||||
|
x = ii * 10 + i + 50
|
||||||
|
|
||||||
Build.objects.create(
|
Build.objects.create(
|
||||||
part=part,
|
part=part,
|
||||||
reference=f"{sub_build.reference}-00{i}-sub",
|
reference=f"BO-{x}",
|
||||||
|
title=f"{sub_build.reference}-00{i}-sub",
|
||||||
quantity=40,
|
quantity=40,
|
||||||
title=f"sub sub build {i}",
|
|
||||||
parent=sub_build
|
parent=sub_build
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ from InvenTree import status_codes as status
|
|||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
import build.tasks
|
import build.tasks
|
||||||
from build.models import Build, BuildItem, get_next_build_number
|
from build.models import Build, BuildItem, generate_next_build_reference
|
||||||
from part.models import Part, BomItem, BomItemSubstitute
|
from part.models import Part, BomItem, BomItemSubstitute
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
from users.models import Owner
|
from users.models import Owner
|
||||||
@ -88,7 +88,7 @@ class BuildTestBase(TestCase):
|
|||||||
quantity=2
|
quantity=2
|
||||||
)
|
)
|
||||||
|
|
||||||
ref = get_next_build_number()
|
ref = generate_next_build_reference()
|
||||||
|
|
||||||
# Create a "Build" object to make 10x objects
|
# Create a "Build" object to make 10x objects
|
||||||
self.build = Build.objects.create(
|
self.build = Build.objects.create(
|
||||||
@ -133,20 +133,97 @@ class BuildTest(BuildTestBase):
|
|||||||
def test_ref_int(self):
|
def test_ref_int(self):
|
||||||
"""Test the "integer reference" field used for natural sorting"""
|
"""Test the "integer reference" field used for natural sorting"""
|
||||||
|
|
||||||
for ii in range(10):
|
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None)
|
||||||
|
|
||||||
|
refs = {
|
||||||
|
'BO-123-456': 123,
|
||||||
|
'BO-456-123': 456,
|
||||||
|
'BO-999-ABC': 999,
|
||||||
|
'BO-123ABC-ABC': 123,
|
||||||
|
'BO-ABC123-ABC': 123,
|
||||||
|
}
|
||||||
|
|
||||||
|
for ref, ref_int in refs.items():
|
||||||
build = Build(
|
build = Build(
|
||||||
reference=f"{ii}_abcde",
|
reference=ref,
|
||||||
quantity=1,
|
quantity=1,
|
||||||
part=self.assembly,
|
part=self.assembly,
|
||||||
title="Making some parts"
|
title='Making some parts',
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(build.reference_int, 0)
|
self.assertEqual(build.reference_int, 0)
|
||||||
|
|
||||||
build.save()
|
build.save()
|
||||||
|
self.assertEqual(build.reference_int, ref_int)
|
||||||
|
|
||||||
# After saving, the integer reference should have been updated
|
def test_ref_validation(self):
|
||||||
self.assertEqual(build.reference_int, ii)
|
"""Test that the reference field validation works as expected"""
|
||||||
|
|
||||||
|
# Default reference pattern = 'BO-{ref:04d}
|
||||||
|
|
||||||
|
# These patterns should fail
|
||||||
|
for ref in [
|
||||||
|
'BO-1234x',
|
||||||
|
'BO1234',
|
||||||
|
'OB-1234',
|
||||||
|
'BO--1234'
|
||||||
|
]:
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
Build.objects.create(
|
||||||
|
part=self.assembly,
|
||||||
|
quantity=10,
|
||||||
|
reference=ref,
|
||||||
|
title='Invalid reference',
|
||||||
|
)
|
||||||
|
|
||||||
|
for ref in [
|
||||||
|
'BO-1234',
|
||||||
|
'BO-9999',
|
||||||
|
'BO-123'
|
||||||
|
]:
|
||||||
|
Build.objects.create(
|
||||||
|
part=self.assembly,
|
||||||
|
quantity=10,
|
||||||
|
reference=ref,
|
||||||
|
title='Valid reference',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try a new validator pattern
|
||||||
|
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', '{ref}-BO', change_user=None)
|
||||||
|
|
||||||
|
for ref in [
|
||||||
|
'1234-BO',
|
||||||
|
'9999-BO'
|
||||||
|
]:
|
||||||
|
Build.objects.create(
|
||||||
|
part=self.assembly,
|
||||||
|
quantity=10,
|
||||||
|
reference=ref,
|
||||||
|
title='Valid reference',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_next_ref(self):
|
||||||
|
"""Test that the next reference is automatically generated"""
|
||||||
|
|
||||||
|
common.models.InvenTreeSetting.set_setting('BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None)
|
||||||
|
|
||||||
|
build = Build.objects.create(
|
||||||
|
part=self.assembly,
|
||||||
|
quantity=5,
|
||||||
|
reference='XYZ-987',
|
||||||
|
title='Some thing',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(build.reference_int, 987)
|
||||||
|
|
||||||
|
# Now create one *without* specifying the reference
|
||||||
|
build = Build.objects.create(
|
||||||
|
part=self.assembly,
|
||||||
|
quantity=1,
|
||||||
|
title='Some new title',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(build.reference, 'XYZ-000988')
|
||||||
|
self.assertEqual(build.reference_int, 988)
|
||||||
|
|
||||||
def test_init(self):
|
def test_init(self):
|
||||||
"""Perform some basic tests before we start the ball rolling"""
|
"""Perform some basic tests before we start the ball rolling"""
|
||||||
@ -404,7 +481,7 @@ class BuildTest(BuildTestBase):
|
|||||||
"""Test that a notification is sent when a new build is created"""
|
"""Test that a notification is sent when a new build is created"""
|
||||||
|
|
||||||
Build.objects.create(
|
Build.objects.create(
|
||||||
reference='IIIII',
|
reference='BO-9999',
|
||||||
title='Some new build',
|
title='Some new build',
|
||||||
part=self.assembly,
|
part=self.assembly,
|
||||||
quantity=5,
|
quantity=5,
|
||||||
|
@ -104,3 +104,57 @@ class TestReferenceMigration(MigratorTestCase):
|
|||||||
# Check that the build reference is properly assigned
|
# Check that the build reference is properly assigned
|
||||||
for build in Build.objects.all():
|
for build in Build.objects.all():
|
||||||
self.assertEqual(str(build.reference), str(build.pk))
|
self.assertEqual(str(build.reference), str(build.pk))
|
||||||
|
|
||||||
|
|
||||||
|
class TestReferencePatternMigration(MigratorTestCase):
|
||||||
|
"""Unit test for data migration which converts reference to new format.
|
||||||
|
|
||||||
|
Ref: https://github.com/inventree/InvenTree/pull/3267
|
||||||
|
"""
|
||||||
|
|
||||||
|
migrate_from = ('build', '0019_auto_20201019_1302')
|
||||||
|
migrate_to = ('build', helpers.getNewestMigrationFile('build'))
|
||||||
|
|
||||||
|
def prepare(self):
|
||||||
|
"""Create some initial data prior to migration"""
|
||||||
|
|
||||||
|
Setting = self.old_state.apps.get_model('common', 'inventreesetting')
|
||||||
|
|
||||||
|
# Create a custom existing prefix so we can confirm the operation is working
|
||||||
|
Setting.objects.create(
|
||||||
|
key='BUILDORDER_REFERENCE_PREFIX',
|
||||||
|
value='BuildOrder-',
|
||||||
|
)
|
||||||
|
|
||||||
|
Part = self.old_state.apps.get_model('part', 'part')
|
||||||
|
|
||||||
|
assembly = Part.objects.create(
|
||||||
|
name='Assy 1',
|
||||||
|
description='An assembly',
|
||||||
|
level=0, lft=0, rght=0, tree_id=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
Build = self.old_state.apps.get_model('build', 'build')
|
||||||
|
|
||||||
|
for idx in range(1, 11):
|
||||||
|
Build.objects.create(
|
||||||
|
part=assembly,
|
||||||
|
title=f"Build {idx}",
|
||||||
|
quantity=idx,
|
||||||
|
reference=f"{idx + 100}",
|
||||||
|
level=0, lft=0, rght=0, tree_id=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reference_migration(self):
|
||||||
|
"""Test that the reference fields have been correctly updated"""
|
||||||
|
|
||||||
|
Build = self.new_state.apps.get_model('build', 'build')
|
||||||
|
|
||||||
|
for build in Build.objects.all():
|
||||||
|
self.assertTrue(build.reference.startswith('BuildOrder-'))
|
||||||
|
|
||||||
|
Setting = self.new_state.apps.get_model('common', 'inventreesetting')
|
||||||
|
|
||||||
|
pattern = Setting.objects.get(key='BUILDORDER_REFERENCE_PATTERN')
|
||||||
|
|
||||||
|
self.assertEqual(pattern.value, 'BuildOrder-{ref:04d}')
|
||||||
|
@ -35,7 +35,7 @@ class BuildTestSimple(InvenTreeTestCase):
|
|||||||
self.assertEqual(b.batch, 'B2')
|
self.assertEqual(b.batch, 'B2')
|
||||||
self.assertEqual(b.quantity, 21)
|
self.assertEqual(b.quantity, 21)
|
||||||
|
|
||||||
self.assertEqual(str(b), 'BO0002')
|
self.assertEqual(str(b), 'BO-0002')
|
||||||
|
|
||||||
def test_url(self):
|
def test_url(self):
|
||||||
"""Test URL lookup"""
|
"""Test URL lookup"""
|
||||||
@ -75,11 +75,6 @@ class BuildTestSimple(InvenTreeTestCase):
|
|||||||
self.assertEqual(b1.is_active, True)
|
self.assertEqual(b1.is_active, True)
|
||||||
self.assertEqual(b2.is_active, False)
|
self.assertEqual(b2.is_active, False)
|
||||||
|
|
||||||
def test_required_parts(self):
|
|
||||||
"""Test set of required BOM items for the build"""
|
|
||||||
# TODO: Generate BOM for test part
|
|
||||||
...
|
|
||||||
|
|
||||||
def test_cancel_build(self):
|
def test_cancel_build(self):
|
||||||
"""Test build cancellation function."""
|
"""Test build cancellation function."""
|
||||||
build = Build.objects.get(id=1)
|
build = Build.objects.get(id=1)
|
||||||
|
25
InvenTree/build/validators.py
Normal file
25
InvenTree/build/validators.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
"""Validation methods for the build app"""
|
||||||
|
|
||||||
|
|
||||||
|
def generate_next_build_reference():
|
||||||
|
"""Generate the next available BuildOrder reference"""
|
||||||
|
|
||||||
|
from build.models import Build
|
||||||
|
|
||||||
|
return Build.generate_reference()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_build_order_reference_pattern(pattern):
|
||||||
|
"""Validate the BuildOrder reference 'pattern' setting"""
|
||||||
|
|
||||||
|
from build.models import Build
|
||||||
|
|
||||||
|
Build.validate_reference_pattern(pattern)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_build_order_reference(value):
|
||||||
|
"""Validate that the BuildOrder reference field matches the required pattern"""
|
||||||
|
|
||||||
|
from build.models import Build
|
||||||
|
|
||||||
|
Build.validate_reference_field(value)
|
@ -36,10 +36,12 @@ from djmoney.contrib.exchange.models import convert_money
|
|||||||
from djmoney.settings import CURRENCY_CHOICES
|
from djmoney.settings import CURRENCY_CHOICES
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
import build.validators
|
||||||
import InvenTree.fields
|
import InvenTree.fields
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import InvenTree.ready
|
import InvenTree.ready
|
||||||
import InvenTree.validators
|
import InvenTree.validators
|
||||||
|
import order.validators
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
@ -1139,21 +1141,18 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
'BUILDORDER_REFERENCE_PREFIX': {
|
'BUILDORDER_REFERENCE_PATTERN': {
|
||||||
'name': _('Build Order Reference Prefix'),
|
'name': _('Build Order Reference Pattern'),
|
||||||
'description': _('Prefix value for build order reference'),
|
'description': _('Required pattern for generating Build Order reference field'),
|
||||||
'default': 'BO',
|
'default': 'BO-{ref:04d}',
|
||||||
|
'validator': build.validators.validate_build_order_reference_pattern,
|
||||||
},
|
},
|
||||||
|
|
||||||
'BUILDORDER_REFERENCE_REGEX': {
|
'SALESORDER_REFERENCE_PATTERN': {
|
||||||
'name': _('Build Order Reference Regex'),
|
'name': _('Sales Order Reference Pattern'),
|
||||||
'description': _('Regular expression pattern for matching build order reference')
|
'description': _('Required pattern for generating Sales Order reference field'),
|
||||||
},
|
'default': 'SO-{ref:04d}',
|
||||||
|
'validator': order.validators.validate_sales_order_reference_pattern,
|
||||||
'SALESORDER_REFERENCE_PREFIX': {
|
|
||||||
'name': _('Sales Order Reference Prefix'),
|
|
||||||
'description': _('Prefix value for sales order reference'),
|
|
||||||
'default': 'SO',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
'SALESORDER_DEFAULT_SHIPMENT': {
|
'SALESORDER_DEFAULT_SHIPMENT': {
|
||||||
@ -1163,10 +1162,11 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
'PURCHASEORDER_REFERENCE_PREFIX': {
|
'PURCHASEORDER_REFERENCE_PATTERN': {
|
||||||
'name': _('Purchase Order Reference Prefix'),
|
'name': _('Purchase Order Reference Pattern'),
|
||||||
'description': _('Prefix value for purchase order reference'),
|
'description': _('Required pattern for generating Purchase Order reference field'),
|
||||||
'default': 'PO',
|
'default': 'PO-{ref:04d}',
|
||||||
|
'validator': order.validators.validate_purchase_order_reference_pattern,
|
||||||
},
|
},
|
||||||
|
|
||||||
# login / SSO
|
# login / SSO
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
- model: order.purchaseorder
|
- model: order.purchaseorder
|
||||||
pk: 1
|
pk: 1
|
||||||
fields:
|
fields:
|
||||||
reference: '0001'
|
reference: 'PO-0001'
|
||||||
description: "Ordering some screws"
|
description: "Ordering some screws"
|
||||||
supplier: 1
|
supplier: 1
|
||||||
status: 10 # Pending
|
status: 10 # Pending
|
||||||
@ -13,7 +13,7 @@
|
|||||||
- model: order.purchaseorder
|
- model: order.purchaseorder
|
||||||
pk: 2
|
pk: 2
|
||||||
fields:
|
fields:
|
||||||
reference: '0002'
|
reference: 'PO-0002'
|
||||||
description: "Ordering some more screws"
|
description: "Ordering some more screws"
|
||||||
supplier: 3
|
supplier: 3
|
||||||
status: 10 # Pending
|
status: 10 # Pending
|
||||||
@ -21,7 +21,7 @@
|
|||||||
- model: order.purchaseorder
|
- model: order.purchaseorder
|
||||||
pk: 3
|
pk: 3
|
||||||
fields:
|
fields:
|
||||||
reference: '0003'
|
reference: 'PO-0003'
|
||||||
description: 'Another PO'
|
description: 'Another PO'
|
||||||
supplier: 3
|
supplier: 3
|
||||||
status: 20 # Placed
|
status: 20 # Placed
|
||||||
@ -29,7 +29,7 @@
|
|||||||
- model: order.purchaseorder
|
- model: order.purchaseorder
|
||||||
pk: 4
|
pk: 4
|
||||||
fields:
|
fields:
|
||||||
reference: '0004'
|
reference: 'PO-0004'
|
||||||
description: 'Another PO'
|
description: 'Another PO'
|
||||||
supplier: 3
|
supplier: 3
|
||||||
status: 20 # Placed
|
status: 20 # Placed
|
||||||
@ -37,7 +37,7 @@
|
|||||||
- model: order.purchaseorder
|
- model: order.purchaseorder
|
||||||
pk: 5
|
pk: 5
|
||||||
fields:
|
fields:
|
||||||
reference: '0005'
|
reference: 'PO-0005'
|
||||||
description: 'Another PO'
|
description: 'Another PO'
|
||||||
supplier: 3
|
supplier: 3
|
||||||
status: 30 # Complete
|
status: 30 # Complete
|
||||||
@ -45,7 +45,7 @@
|
|||||||
- model: order.purchaseorder
|
- model: order.purchaseorder
|
||||||
pk: 6
|
pk: 6
|
||||||
fields:
|
fields:
|
||||||
reference: '0006'
|
reference: 'PO-0006'
|
||||||
description: 'Another PO'
|
description: 'Another PO'
|
||||||
supplier: 3
|
supplier: 3
|
||||||
status: 40 # Cancelled
|
status: 40 # Cancelled
|
||||||
@ -54,7 +54,7 @@
|
|||||||
- model: order.purchaseorder
|
- model: order.purchaseorder
|
||||||
pk: 7
|
pk: 7
|
||||||
fields:
|
fields:
|
||||||
reference: '0007'
|
reference: 'PO-0007'
|
||||||
description: 'Another PO'
|
description: 'Another PO'
|
||||||
supplier: 2
|
supplier: 2
|
||||||
status: 10 # Pending
|
status: 10 # Pending
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
# Generated by Django 3.2.4 on 2021-07-02 13:21
|
# Generated by Django 3.2.4 on 2021-07-02 13:21
|
||||||
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import order.models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@ -14,11 +13,11 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='purchaseorder',
|
model_name='purchaseorder',
|
||||||
name='reference',
|
name='reference',
|
||||||
field=models.CharField(default=order.models.get_next_po_number, help_text='Order reference', max_length=64, unique=True, verbose_name='Reference'),
|
field=models.CharField(default="PO", help_text='Order reference', max_length=64, unique=True, verbose_name='Reference'),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='salesorder',
|
model_name='salesorder',
|
||||||
name='reference',
|
name='reference',
|
||||||
field=models.CharField(default=order.models.get_next_so_number, help_text='Order reference', max_length=64, unique=True, verbose_name='Reference'),
|
field=models.CharField(default="SO", help_text='Order reference', max_length=64, unique=True, verbose_name='Reference'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 3.2.14 on 2022-07-07 11:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import order.validators
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('order', '0071_auto_20220628_0133'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='salesorder',
|
||||||
|
name='reference',
|
||||||
|
field=models.CharField(default=order.validators.generate_next_sales_order_reference, help_text='Order reference', max_length=64, unique=True, validators=[order.validators.validate_sales_order_reference], verbose_name='Reference'),
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 3.2.14 on 2022-07-09 01:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import order.validators
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('order', '0072_alter_salesorder_reference'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='purchaseorder',
|
||||||
|
name='reference',
|
||||||
|
field=models.CharField(default=order.validators.generate_next_purchase_order_reference, help_text='Order reference', max_length=64, unique=True, validators=[order.validators.validate_purchase_order_reference], verbose_name='Reference'),
|
||||||
|
),
|
||||||
|
]
|
107
InvenTree/order/migrations/0074_auto_20220709_0108.py
Normal file
107
InvenTree/order/migrations/0074_auto_20220709_0108.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
# Generated by Django 3.2.14 on 2022-07-09 01:08
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def update_order_references(order_model, prefix):
|
||||||
|
"""Update all references of the given model, with the specified prefix"""
|
||||||
|
|
||||||
|
n = 0
|
||||||
|
|
||||||
|
for order in order_model.objects.all():
|
||||||
|
if not order.reference.startswith(prefix):
|
||||||
|
order.reference = prefix + order.reference
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
n += 1
|
||||||
|
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def update_salesorder_reference(apps, schema_editor):
|
||||||
|
"""Migrate the reference pattern for the SalesOrder model"""
|
||||||
|
|
||||||
|
# Extract the existing "prefix" value
|
||||||
|
InvenTreeSetting = apps.get_model('common', 'inventreesetting')
|
||||||
|
|
||||||
|
try:
|
||||||
|
prefix = InvenTreeSetting.objects.get(key='SALESORDER_REFERENCE_PREFIX').value
|
||||||
|
except Exception:
|
||||||
|
prefix = 'SO-'
|
||||||
|
|
||||||
|
# Construct a reference pattern
|
||||||
|
pattern = prefix + '{ref:04d}'
|
||||||
|
|
||||||
|
# Create or update the BuildOrder.reference pattern
|
||||||
|
try:
|
||||||
|
setting = InvenTreeSetting.objects.get(key='SALESORDER_REFERENCE_PATTERN')
|
||||||
|
setting.value = pattern
|
||||||
|
setting.save()
|
||||||
|
except InvenTreeSetting.DoesNotExist:
|
||||||
|
setting = InvenTreeSetting.objects.create(
|
||||||
|
key='SALESORDER_REFERENCE_PATTERN',
|
||||||
|
value=pattern,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update any existing sales order references
|
||||||
|
SalesOrder = apps.get_model('order', 'salesorder')
|
||||||
|
n = update_order_references(SalesOrder, prefix)
|
||||||
|
|
||||||
|
if n > 0:
|
||||||
|
print(f"Updated reference field for {n} SalesOrder objects")
|
||||||
|
|
||||||
|
|
||||||
|
def update_purchaseorder_reference(apps, schema_editor):
|
||||||
|
"""Migrate the reference pattern for the PurchaseOrder model"""
|
||||||
|
|
||||||
|
# Extract the existing "prefix" value
|
||||||
|
InvenTreeSetting = apps.get_model('common', 'inventreesetting')
|
||||||
|
|
||||||
|
try:
|
||||||
|
prefix = InvenTreeSetting.objects.get(key='PURCHASEORDER_REFERENCE_PREFIX').value
|
||||||
|
except Exception:
|
||||||
|
prefix = 'PO-'
|
||||||
|
|
||||||
|
# Construct a reference pattern
|
||||||
|
pattern = prefix + '{ref:04d}'
|
||||||
|
|
||||||
|
# Create or update the BuildOrder.reference pattern
|
||||||
|
try:
|
||||||
|
setting = InvenTreeSetting.objects.get(key='PURCHASEORDER_REFERENCE_PATTERN')
|
||||||
|
setting.value = pattern
|
||||||
|
setting.save()
|
||||||
|
except InvenTreeSetting.DoesNotExist:
|
||||||
|
setting = InvenTreeSetting.objects.create(
|
||||||
|
key='PURCHASEORDER_REFERENCE_PATTERN',
|
||||||
|
value=pattern,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update any existing sales order references
|
||||||
|
PurchaseOrder = apps.get_model('order', 'purchaseorder')
|
||||||
|
n = update_order_references(PurchaseOrder, prefix)
|
||||||
|
|
||||||
|
if n > 0:
|
||||||
|
print(f"Updated reference field for {n} PurchaseOrder objects")
|
||||||
|
|
||||||
|
|
||||||
|
def nop(apps, schema_editor):
|
||||||
|
"""Empty function for reverse migration"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('order', '0073_alter_purchaseorder_reference'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
update_salesorder_reference,
|
||||||
|
reverse_code=nop,
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
update_purchaseorder_reference,
|
||||||
|
reverse_code=nop,
|
||||||
|
)
|
||||||
|
]
|
@ -24,14 +24,14 @@ from mptt.models import TreeForeignKey
|
|||||||
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import InvenTree.ready
|
import InvenTree.ready
|
||||||
|
import order.validators
|
||||||
from common.notifications import InvenTreeNotificationBodies
|
from common.notifications import InvenTreeNotificationBodies
|
||||||
from common.settings import currency_code_default
|
from common.settings import currency_code_default
|
||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
from InvenTree.exceptions import log_error
|
from InvenTree.exceptions import log_error
|
||||||
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
|
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
|
||||||
RoundingDecimalField)
|
RoundingDecimalField)
|
||||||
from InvenTree.helpers import (decimal2string, getSetting, increment,
|
from InvenTree.helpers import decimal2string, getSetting, notify_responsible
|
||||||
notify_responsible)
|
|
||||||
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
||||||
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
|
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
|
||||||
StockHistoryCode, StockStatus)
|
StockHistoryCode, StockStatus)
|
||||||
@ -44,58 +44,6 @@ from users import models as UserModels
|
|||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
def get_next_po_number():
|
|
||||||
"""Returns the next available PurchaseOrder reference number."""
|
|
||||||
if PurchaseOrder.objects.count() == 0:
|
|
||||||
return '0001'
|
|
||||||
|
|
||||||
order = PurchaseOrder.objects.exclude(reference=None).last()
|
|
||||||
|
|
||||||
attempts = {order.reference}
|
|
||||||
|
|
||||||
reference = order.reference
|
|
||||||
|
|
||||||
while 1:
|
|
||||||
reference = increment(reference)
|
|
||||||
|
|
||||||
if reference in attempts:
|
|
||||||
# Escape infinite recursion
|
|
||||||
return reference
|
|
||||||
|
|
||||||
if PurchaseOrder.objects.filter(reference=reference).exists():
|
|
||||||
attempts.add(reference)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
return reference
|
|
||||||
|
|
||||||
|
|
||||||
def get_next_so_number():
|
|
||||||
"""Returns the next available SalesOrder reference number."""
|
|
||||||
if SalesOrder.objects.count() == 0:
|
|
||||||
return '0001'
|
|
||||||
|
|
||||||
order = SalesOrder.objects.exclude(reference=None).last()
|
|
||||||
|
|
||||||
attempts = {order.reference}
|
|
||||||
|
|
||||||
reference = order.reference
|
|
||||||
|
|
||||||
while 1:
|
|
||||||
reference = increment(reference)
|
|
||||||
|
|
||||||
if reference in attempts:
|
|
||||||
# Escape infinite recursion
|
|
||||||
return reference
|
|
||||||
|
|
||||||
if SalesOrder.objects.filter(reference=reference).exists():
|
|
||||||
attempts.add(reference)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
return reference
|
|
||||||
|
|
||||||
|
|
||||||
class Order(MetadataMixin, ReferenceIndexingMixin):
|
class Order(MetadataMixin, ReferenceIndexingMixin):
|
||||||
"""Abstract model for an order.
|
"""Abstract model for an order.
|
||||||
|
|
||||||
@ -119,7 +67,7 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
Ensures that the reference field is rebuilt whenever the instance is saved.
|
Ensures that the reference field is rebuilt whenever the instance is saved.
|
||||||
"""
|
"""
|
||||||
self.rebuild_reference_field()
|
self.reference_int = self.rebuild_reference_field(self.reference)
|
||||||
|
|
||||||
if not self.creation_date:
|
if not self.creation_date:
|
||||||
self.creation_date = datetime.now().date()
|
self.creation_date = datetime.now().date()
|
||||||
@ -230,8 +178,21 @@ class PurchaseOrder(Order):
|
|||||||
"""Return the API URL associated with the PurchaseOrder model"""
|
"""Return the API URL associated with the PurchaseOrder model"""
|
||||||
return reverse('api-po-list')
|
return reverse('api-po-list')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def api_defaults(cls, request):
|
||||||
|
"""Return default values for thsi model when issuing an API OPTIONS request"""
|
||||||
|
|
||||||
|
defaults = {
|
||||||
|
'reference': order.validators.generate_next_purchase_order_reference(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaults
|
||||||
|
|
||||||
OVERDUE_FILTER = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
|
OVERDUE_FILTER = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
|
||||||
|
|
||||||
|
# Global setting for specifying reference pattern
|
||||||
|
REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN'
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def filterByDate(queryset, min_date, max_date):
|
def filterByDate(queryset, min_date, max_date):
|
||||||
"""Filter by 'minimum and maximum date range'.
|
"""Filter by 'minimum and maximum date range'.
|
||||||
@ -269,9 +230,8 @@ class PurchaseOrder(Order):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Render a string representation of this PurchaseOrder"""
|
"""Render a string representation of this PurchaseOrder"""
|
||||||
prefix = getSetting('PURCHASEORDER_REFERENCE_PREFIX')
|
|
||||||
|
|
||||||
return f"{prefix}{self.reference} - {self.supplier.name if self.supplier else _('deleted')}"
|
return f"{self.reference} - {self.supplier.name if self.supplier else _('deleted')}"
|
||||||
|
|
||||||
reference = models.CharField(
|
reference = models.CharField(
|
||||||
unique=True,
|
unique=True,
|
||||||
@ -279,7 +239,10 @@ class PurchaseOrder(Order):
|
|||||||
blank=False,
|
blank=False,
|
||||||
verbose_name=_('Reference'),
|
verbose_name=_('Reference'),
|
||||||
help_text=_('Order reference'),
|
help_text=_('Order reference'),
|
||||||
default=get_next_po_number,
|
default=order.validators.generate_next_purchase_order_reference,
|
||||||
|
validators=[
|
||||||
|
order.validators.validate_purchase_order_reference,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
status = models.PositiveIntegerField(default=PurchaseOrderStatus.PENDING, choices=PurchaseOrderStatus.items(),
|
status = models.PositiveIntegerField(default=PurchaseOrderStatus.PENDING, choices=PurchaseOrderStatus.items(),
|
||||||
@ -595,8 +558,20 @@ class SalesOrder(Order):
|
|||||||
"""Return the API URL associated with the SalesOrder model"""
|
"""Return the API URL associated with the SalesOrder model"""
|
||||||
return reverse('api-so-list')
|
return reverse('api-so-list')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def api_defaults(cls, request):
|
||||||
|
"""Return default values for this model when issuing an API OPTIONS request"""
|
||||||
|
defaults = {
|
||||||
|
'reference': order.validators.generate_next_sales_order_reference(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaults
|
||||||
|
|
||||||
OVERDUE_FILTER = Q(status__in=SalesOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
|
OVERDUE_FILTER = Q(status__in=SalesOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
|
||||||
|
|
||||||
|
# Global setting for specifying reference pattern
|
||||||
|
REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN'
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def filterByDate(queryset, min_date, max_date):
|
def filterByDate(queryset, min_date, max_date):
|
||||||
"""Filter by "minimum and maximum date range".
|
"""Filter by "minimum and maximum date range".
|
||||||
@ -634,9 +609,8 @@ class SalesOrder(Order):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Render a string representation of this SalesOrder"""
|
"""Render a string representation of this SalesOrder"""
|
||||||
prefix = getSetting('SALESORDER_REFERENCE_PREFIX')
|
|
||||||
|
|
||||||
return f"{prefix}{self.reference} - {self.customer.name if self.customer else _('deleted')}"
|
return f"{self.reference} - {self.customer.name if self.customer else _('deleted')}"
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
"""Return the web URL for the detail view of this order"""
|
"""Return the web URL for the detail view of this order"""
|
||||||
@ -648,7 +622,10 @@ class SalesOrder(Order):
|
|||||||
blank=False,
|
blank=False,
|
||||||
verbose_name=_('Reference'),
|
verbose_name=_('Reference'),
|
||||||
help_text=_('Order reference'),
|
help_text=_('Order reference'),
|
||||||
default=get_next_so_number,
|
default=order.validators.generate_next_sales_order_reference,
|
||||||
|
validators=[
|
||||||
|
order.validators.validate_sales_order_reference,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
customer = models.ForeignKey(
|
customer = models.ForeignKey(
|
||||||
|
@ -23,8 +23,7 @@ from InvenTree.helpers import extract_serial_numbers, normalize
|
|||||||
from InvenTree.serializers import (InvenTreeAttachmentSerializer,
|
from InvenTree.serializers import (InvenTreeAttachmentSerializer,
|
||||||
InvenTreeDecimalField,
|
InvenTreeDecimalField,
|
||||||
InvenTreeModelSerializer,
|
InvenTreeModelSerializer,
|
||||||
InvenTreeMoneySerializer,
|
InvenTreeMoneySerializer)
|
||||||
ReferenceIndexingSerializerMixin)
|
|
||||||
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
|
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
|
||||||
StockStatus)
|
StockStatus)
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
@ -86,7 +85,7 @@ class AbstractExtraLineMeta:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
|
class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
|
||||||
"""Serializer for a PurchaseOrder object."""
|
"""Serializer for a PurchaseOrder object."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@ -130,6 +129,14 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializ
|
|||||||
|
|
||||||
reference = serializers.CharField(required=True)
|
reference = serializers.CharField(required=True)
|
||||||
|
|
||||||
|
def validate_reference(self, reference):
|
||||||
|
"""Custom validation for the reference field"""
|
||||||
|
|
||||||
|
# Ensure that the reference matches the required pattern
|
||||||
|
order.models.PurchaseOrder.validate_reference_field(reference)
|
||||||
|
|
||||||
|
return reference
|
||||||
|
|
||||||
responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False)
|
responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -639,7 +646,7 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
|
class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
|
||||||
"""Serializers for the SalesOrder object."""
|
"""Serializers for the SalesOrder object."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@ -683,6 +690,14 @@ class SalesOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerM
|
|||||||
|
|
||||||
reference = serializers.CharField(required=True)
|
reference = serializers.CharField(required=True)
|
||||||
|
|
||||||
|
def validate_reference(self, reference):
|
||||||
|
"""Custom validation for the reference field"""
|
||||||
|
|
||||||
|
# Ensure that the reference matches the required pattern
|
||||||
|
order.models.SalesOrder.validate_reference_field(reference)
|
||||||
|
|
||||||
|
return reference
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Metaclass options."""
|
"""Metaclass options."""
|
||||||
|
|
||||||
|
@ -82,7 +82,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-hashtag'></span></td>
|
<td><span class='fas fa-hashtag'></span></td>
|
||||||
<td>{% trans "Order Reference" %}</td>
|
<td>{% trans "Order Reference" %}</td>
|
||||||
<td>{% settings_value 'PURCHASEORDER_REFERENCE_PREFIX' %}{{ order.reference }}{% include "clip.html"%}</td>
|
<td>{{ order.reference }}{% include "clip.html"%}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-info-circle'></span></td>
|
<td><span class='fas fa-info-circle'></span></td>
|
||||||
@ -222,7 +222,7 @@ $("#edit-order").click(function() {
|
|||||||
constructForm('{% url "api-po-detail" order.pk %}', {
|
constructForm('{% url "api-po-detail" order.pk %}', {
|
||||||
fields: {
|
fields: {
|
||||||
reference: {
|
reference: {
|
||||||
prefix: global_settings.PURCHASEORDER_REFERENCE_PREFIX,
|
icon: 'fa-hashtag',
|
||||||
},
|
},
|
||||||
{% if order.lines.count == 0 and order.status == PurchaseOrderStatus.PENDING %}
|
{% if order.lines.count == 0 and order.status == PurchaseOrderStatus.PENDING %}
|
||||||
supplier: {
|
supplier: {
|
||||||
|
@ -78,7 +78,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-hashtag'></span></td>
|
<td><span class='fas fa-hashtag'></span></td>
|
||||||
<td>{% trans "Order Reference" %}</td>
|
<td>{% trans "Order Reference" %}</td>
|
||||||
<td>{% settings_value 'SALESORDER_REFERENCE_PREFIX' %}{{ order.reference }}{% include "clip.html"%}</td>
|
<td>{{ order.reference }}{% include "clip.html"%}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-info-circle'></span></td>
|
<td><span class='fas fa-info-circle'></span></td>
|
||||||
@ -209,7 +209,7 @@ $("#edit-order").click(function() {
|
|||||||
constructForm('{% url "api-so-detail" order.pk %}', {
|
constructForm('{% url "api-so-detail" order.pk %}', {
|
||||||
fields: {
|
fields: {
|
||||||
reference: {
|
reference: {
|
||||||
prefix: global_settings.SALESORDER_REFERENCE_PREFIX,
|
icon: 'fa-hashtag',
|
||||||
},
|
},
|
||||||
{% if order.lines.count == 0 and order.status == SalesOrderStatus.PENDING %}
|
{% if order.lines.count == 0 and order.status == SalesOrderStatus.PENDING %}
|
||||||
customer: {
|
customer: {
|
||||||
|
@ -94,23 +94,28 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
self.assertEqual(data['description'], 'Ordering some screws')
|
self.assertEqual(data['description'], 'Ordering some screws')
|
||||||
|
|
||||||
def test_po_reference(self):
|
def test_po_reference(self):
|
||||||
"""Test that a reference with a too big / small reference is not possible."""
|
"""Test that a reference with a too big / small reference is handled correctly."""
|
||||||
# get permissions
|
# get permissions
|
||||||
self.assignRole('purchase_order.add')
|
self.assignRole('purchase_order.add')
|
||||||
|
|
||||||
url = reverse('api-po-list')
|
url = reverse('api-po-list')
|
||||||
huge_number = 9223372036854775808
|
huge_number = "PO-92233720368547758089999999999999999"
|
||||||
|
|
||||||
self.post(
|
response = self.post(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'supplier': 1,
|
'supplier': 1,
|
||||||
'reference': huge_number,
|
'reference': huge_number,
|
||||||
'description': 'PO not created via the API',
|
'description': 'PO created via the API',
|
||||||
},
|
},
|
||||||
expected_code=201,
|
expected_code=201,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
order = models.PurchaseOrder.objects.get(pk=response.data['pk'])
|
||||||
|
|
||||||
|
self.assertEqual(order.reference, 'PO-92233720368547758089999999999999999')
|
||||||
|
self.assertEqual(order.reference_int, 0x7fffffff)
|
||||||
|
|
||||||
def test_po_attachments(self):
|
def test_po_attachments(self):
|
||||||
"""Test the list endpoint for the PurchaseOrderAttachment model"""
|
"""Test the list endpoint for the PurchaseOrderAttachment model"""
|
||||||
url = reverse('api-po-attachment-list')
|
url = reverse('api-po-attachment-list')
|
||||||
@ -149,7 +154,7 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'supplier': 1,
|
'supplier': 1,
|
||||||
'reference': '123456789-xyz',
|
'reference': 'PO-123456789',
|
||||||
'description': 'PO created via the API',
|
'description': 'PO created via the API',
|
||||||
},
|
},
|
||||||
expected_code=201
|
expected_code=201
|
||||||
@ -177,19 +182,19 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
# Get detail info!
|
# Get detail info!
|
||||||
response = self.get(url)
|
response = self.get(url)
|
||||||
self.assertEqual(response.data['pk'], pk)
|
self.assertEqual(response.data['pk'], pk)
|
||||||
self.assertEqual(response.data['reference'], '123456789-xyz')
|
self.assertEqual(response.data['reference'], 'PO-123456789')
|
||||||
|
|
||||||
# Try to alter (edit) the PurchaseOrder
|
# Try to alter (edit) the PurchaseOrder
|
||||||
response = self.patch(
|
response = self.patch(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'reference': '12345-abc',
|
'reference': 'PO-12345',
|
||||||
},
|
},
|
||||||
expected_code=200
|
expected_code=200
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reference should have changed
|
# Reference should have changed
|
||||||
self.assertEqual(response.data['reference'], '12345-abc')
|
self.assertEqual(response.data['reference'], 'PO-12345')
|
||||||
|
|
||||||
# Now, let's try to delete it!
|
# Now, let's try to delete it!
|
||||||
# Initially, we do *not* have the required permission!
|
# Initially, we do *not* have the required permission!
|
||||||
@ -213,7 +218,7 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
self.post(
|
self.post(
|
||||||
reverse('api-po-list'),
|
reverse('api-po-list'),
|
||||||
{
|
{
|
||||||
'reference': '12345678',
|
'reference': 'PO-12345678',
|
||||||
'supplier': 1,
|
'supplier': 1,
|
||||||
'description': 'A test purchase order',
|
'description': 'A test purchase order',
|
||||||
},
|
},
|
||||||
@ -807,7 +812,7 @@ class SalesOrderTest(OrderTest):
|
|||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'customer': 4,
|
'customer': 4,
|
||||||
'reference': '12345',
|
'reference': 'SO-12345',
|
||||||
'description': 'Sales order',
|
'description': 'Sales order',
|
||||||
},
|
},
|
||||||
expected_code=201
|
expected_code=201
|
||||||
@ -824,7 +829,7 @@ class SalesOrderTest(OrderTest):
|
|||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'customer': 4,
|
'customer': 4,
|
||||||
'reference': '12345',
|
'reference': 'SO-12345',
|
||||||
'description': 'Another sales order',
|
'description': 'Another sales order',
|
||||||
},
|
},
|
||||||
expected_code=400
|
expected_code=400
|
||||||
@ -834,19 +839,28 @@ class SalesOrderTest(OrderTest):
|
|||||||
|
|
||||||
# Extract detail info for the SalesOrder
|
# Extract detail info for the SalesOrder
|
||||||
response = self.get(url)
|
response = self.get(url)
|
||||||
self.assertEqual(response.data['reference'], '12345')
|
self.assertEqual(response.data['reference'], 'SO-12345')
|
||||||
|
|
||||||
# Try to alter (edit) the SalesOrder
|
# Try to alter (edit) the SalesOrder
|
||||||
|
# Initially try with an invalid reference field value
|
||||||
response = self.patch(
|
response = self.patch(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'reference': '12345-a',
|
'reference': 'SO-12345-a',
|
||||||
|
},
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.patch(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'reference': 'SO-12346',
|
||||||
},
|
},
|
||||||
expected_code=200
|
expected_code=200
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reference should have changed
|
# Reference should have changed
|
||||||
self.assertEqual(response.data['reference'], '12345-a')
|
self.assertEqual(response.data['reference'], 'SO-12346')
|
||||||
|
|
||||||
# Now, let's try to delete this SalesOrder
|
# Now, let's try to delete this SalesOrder
|
||||||
# Initially, we do not have the required permission
|
# Initially, we do not have the required permission
|
||||||
@ -866,14 +880,29 @@ class SalesOrderTest(OrderTest):
|
|||||||
"""Test that we can create a new SalesOrder via the API."""
|
"""Test that we can create a new SalesOrder via the API."""
|
||||||
self.assignRole('sales_order.add')
|
self.assignRole('sales_order.add')
|
||||||
|
|
||||||
self.post(
|
url = reverse('api-so-list')
|
||||||
reverse('api-so-list'),
|
|
||||||
|
# Will fail due to invalid reference field
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
{
|
{
|
||||||
'reference': '1234566778',
|
'reference': '1234566778',
|
||||||
'customer': 4,
|
'customer': 4,
|
||||||
'description': 'A test sales order',
|
'description': 'A test sales order',
|
||||||
},
|
},
|
||||||
expected_code=201
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('Reference must match required pattern', str(response.data['reference']))
|
||||||
|
|
||||||
|
self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'reference': 'SO-12345',
|
||||||
|
'customer': 4,
|
||||||
|
'description': 'A better test sales order',
|
||||||
|
},
|
||||||
|
expected_code=201,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_so_cancel(self):
|
def test_so_cancel(self):
|
||||||
|
@ -40,19 +40,27 @@ class SalesOrderTest(TestCase):
|
|||||||
# Create a SalesOrder to ship against
|
# Create a SalesOrder to ship against
|
||||||
self.order = SalesOrder.objects.create(
|
self.order = SalesOrder.objects.create(
|
||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
reference='1234',
|
reference='SO-1234',
|
||||||
customer_reference='ABC 55555'
|
customer_reference='ABC 55555'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a Shipment against this SalesOrder
|
# Create a Shipment against this SalesOrder
|
||||||
self.shipment = SalesOrderShipment.objects.create(
|
self.shipment = SalesOrderShipment.objects.create(
|
||||||
order=self.order,
|
order=self.order,
|
||||||
reference='001',
|
reference='SO-001',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a line item
|
# Create a line item
|
||||||
self.line = SalesOrderLineItem.objects.create(quantity=50, order=self.order, part=self.part)
|
self.line = SalesOrderLineItem.objects.create(quantity=50, order=self.order, part=self.part)
|
||||||
|
|
||||||
|
def test_so_reference(self):
|
||||||
|
"""Unit tests for sales order generation"""
|
||||||
|
|
||||||
|
# Test that a good reference is created when we have no existing orders
|
||||||
|
SalesOrder.objects.all().delete()
|
||||||
|
|
||||||
|
self.assertEqual(SalesOrder.generate_reference(), 'SO-0001')
|
||||||
|
|
||||||
def test_rebuild_reference(self):
|
def test_rebuild_reference(self):
|
||||||
"""Test that the 'reference_int' field gets rebuilt when the model is saved"""
|
"""Test that the 'reference_int' field gets rebuilt when the model is saved"""
|
||||||
|
|
||||||
|
@ -35,15 +35,17 @@ class OrderTest(TestCase):
|
|||||||
|
|
||||||
def test_basics(self):
|
def test_basics(self):
|
||||||
"""Basic tests e.g. repr functions etc."""
|
"""Basic tests e.g. repr functions etc."""
|
||||||
order = PurchaseOrder.objects.get(pk=1)
|
|
||||||
|
|
||||||
self.assertEqual(order.get_absolute_url(), '/order/purchase-order/1/')
|
for pk in range(1, 8):
|
||||||
|
|
||||||
self.assertEqual(str(order), 'PO0001 - ACME')
|
order = PurchaseOrder.objects.get(pk=pk)
|
||||||
|
|
||||||
|
self.assertEqual(order.get_absolute_url(), f'/order/purchase-order/{pk}/')
|
||||||
|
|
||||||
|
self.assertEqual(order.reference, f'PO-{pk:04d}')
|
||||||
|
|
||||||
line = PurchaseOrderLineItem.objects.get(pk=1)
|
line = PurchaseOrderLineItem.objects.get(pk=1)
|
||||||
|
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO-0001 - ACME)")
|
||||||
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO0001 - ACME)")
|
|
||||||
|
|
||||||
def test_rebuild_reference(self):
|
def test_rebuild_reference(self):
|
||||||
"""Test that the reference_int field is correctly updated when the model is saved"""
|
"""Test that the reference_int field is correctly updated when the model is saved"""
|
||||||
|
49
InvenTree/order/validators.py
Normal file
49
InvenTree/order/validators.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"""Validation methods for the order app"""
|
||||||
|
|
||||||
|
|
||||||
|
def generate_next_sales_order_reference():
|
||||||
|
"""Generate the next available SalesOrder reference"""
|
||||||
|
|
||||||
|
from order.models import SalesOrder
|
||||||
|
|
||||||
|
return SalesOrder.generate_reference()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_next_purchase_order_reference():
|
||||||
|
"""Generate the next available PurchasesOrder reference"""
|
||||||
|
|
||||||
|
from order.models import PurchaseOrder
|
||||||
|
|
||||||
|
return PurchaseOrder.generate_reference()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_sales_order_reference_pattern(pattern):
|
||||||
|
"""Validate the SalesOrder reference 'pattern' setting"""
|
||||||
|
|
||||||
|
from order.models import SalesOrder
|
||||||
|
|
||||||
|
SalesOrder.validate_reference_pattern(pattern)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_purchase_order_reference_pattern(pattern):
|
||||||
|
"""Validate the PurchaseOrder reference 'pattern' setting"""
|
||||||
|
|
||||||
|
from order.models import PurchaseOrder
|
||||||
|
|
||||||
|
PurchaseOrder.validate_reference_pattern(pattern)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_sales_order_reference(value):
|
||||||
|
"""Validate that the SalesOrder reference field matches the required pattern"""
|
||||||
|
|
||||||
|
from order.models import SalesOrder
|
||||||
|
|
||||||
|
SalesOrder.validate_reference_field(value)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_purchase_order_reference(value):
|
||||||
|
"""Validate that the PurchaseOrder reference field matches the required pattern"""
|
||||||
|
|
||||||
|
from order.models import PurchaseOrder
|
||||||
|
|
||||||
|
PurchaseOrder.validate_reference_field(value)
|
@ -1514,6 +1514,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
|||||||
part=Part.objects.get(pk=101),
|
part=Part.objects.get(pk=101),
|
||||||
quantity=10,
|
quantity=10,
|
||||||
title='Making some assemblies',
|
title='Making some assemblies',
|
||||||
|
reference='BO-9999',
|
||||||
status=BuildStatus.PRODUCTION,
|
status=BuildStatus.PRODUCTION,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -425,7 +425,6 @@ class PurchaseOrderReport(ReportTemplateBase):
|
|||||||
'order': order,
|
'order': order,
|
||||||
'reference': order.reference,
|
'reference': order.reference,
|
||||||
'supplier': order.supplier,
|
'supplier': order.supplier,
|
||||||
'prefix': common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_PREFIX'),
|
|
||||||
'title': str(order),
|
'title': str(order),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -463,7 +462,6 @@ class SalesOrderReport(ReportTemplateBase):
|
|||||||
'lines': order.lines,
|
'lines': order.lines,
|
||||||
'extra_lines': order.extra_lines,
|
'extra_lines': order.extra_lines,
|
||||||
'order': order,
|
'order': order,
|
||||||
'prefix': common.models.InvenTreeSetting.get_setting('SALESORDER_REFERENCE_PREFIX'),
|
|
||||||
'reference': order.reference,
|
'reference': order.reference,
|
||||||
'title': str(order),
|
'title': str(order),
|
||||||
}
|
}
|
||||||
|
@ -30,8 +30,7 @@ import report.models
|
|||||||
from company import models as CompanyModels
|
from company import models as CompanyModels
|
||||||
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
|
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
|
||||||
InvenTreeURLField)
|
InvenTreeURLField)
|
||||||
from InvenTree.models import InvenTreeAttachment, InvenTreeTree
|
from InvenTree.models import InvenTreeAttachment, InvenTreeTree, extract_int
|
||||||
from InvenTree.serializers import extract_int
|
|
||||||
from InvenTree.status_codes import StockHistoryCode, StockStatus
|
from InvenTree.status_codes import StockHistoryCode, StockStatus
|
||||||
from part import models as PartModels
|
from part import models as PartModels
|
||||||
from plugin.events import trigger_event
|
from plugin.events import trigger_event
|
||||||
@ -1708,8 +1707,7 @@ class StockItem(MetadataMixin, MPTTModel):
|
|||||||
s += ' @ {loc}'.format(loc=self.location.name)
|
s += ' @ {loc}'.format(loc=self.location.name)
|
||||||
|
|
||||||
if self.purchase_order:
|
if self.purchase_order:
|
||||||
s += " ({pre}{po})".format(
|
s += " ({po})".format(
|
||||||
pre=InvenTree.helpers.getSetting("PURCHASEORDER_REFERENCE_PREFIX"),
|
|
||||||
po=self.purchase_order,
|
po=self.purchase_order,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -20,7 +20,8 @@ import InvenTree.serializers
|
|||||||
import part.models as part_models
|
import part.models as part_models
|
||||||
from common.settings import currency_code_default, currency_code_mappings
|
from common.settings import currency_code_default, currency_code_mappings
|
||||||
from company.serializers import SupplierPartSerializer
|
from company.serializers import SupplierPartSerializer
|
||||||
from InvenTree.serializers import InvenTreeDecimalField, extract_int
|
from InvenTree.models import extract_int
|
||||||
|
from InvenTree.serializers import InvenTreeDecimalField
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
|
|
||||||
from .models import (StockItem, StockItemAttachment, StockItemTestResult,
|
from .models import (StockItem, StockItemAttachment, StockItemTestResult,
|
||||||
@ -67,8 +68,8 @@ class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
|
|||||||
|
|
||||||
def validate_serial(self, value):
|
def validate_serial(self, value):
|
||||||
"""Make sure serial is not to big."""
|
"""Make sure serial is not to big."""
|
||||||
if extract_int(value) > 2147483647:
|
if abs(extract_int(value)) > 0x7fffffff:
|
||||||
raise serializers.ValidationError('serial is to to big')
|
raise serializers.ValidationError(_("Serial number is too large"))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@ -87,7 +87,7 @@ class StockTest(InvenTreeTestCase):
|
|||||||
# And there should be *no* items being build
|
# And there should be *no* items being build
|
||||||
self.assertEqual(part.quantity_being_built, 0)
|
self.assertEqual(part.quantity_being_built, 0)
|
||||||
|
|
||||||
build = Build.objects.create(reference='12345', part=part, title='A test build', quantity=1)
|
build = Build.objects.create(reference='BO-4444', part=part, title='A test build', quantity=1)
|
||||||
|
|
||||||
# Add some stock items which are "building"
|
# Add some stock items which are "building"
|
||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
@ -395,13 +395,14 @@ class StockTest(InvenTreeTestCase):
|
|||||||
item.serial = "-123"
|
item.serial = "-123"
|
||||||
item.save()
|
item.save()
|
||||||
|
|
||||||
# Negative number should map to zero
|
# Negative number should map to positive value
|
||||||
self.assertEqual(item.serial_int, 0)
|
self.assertEqual(item.serial_int, 123)
|
||||||
|
|
||||||
# Test a very very large value
|
# Test a very very large value
|
||||||
item.serial = '99999999999999999999999999999999999999999999999999999'
|
item.serial = '99999999999999999999999999999999999999999999999999999'
|
||||||
item.save()
|
item.save()
|
||||||
|
|
||||||
|
# The 'integer' portion has been clipped to a maximum value
|
||||||
self.assertEqual(item.serial_int, 0x7fffffff)
|
self.assertEqual(item.serial_int, 0x7fffffff)
|
||||||
|
|
||||||
# Non-numeric values should encode to zero
|
# Non-numeric values should encode to zero
|
||||||
|
@ -12,8 +12,7 @@
|
|||||||
|
|
||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_PREFIX" %}
|
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_PATTERN" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_REGEX" %}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PREFIX" %}
|
{% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PATTERN" %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
<table class='table table-striped table-condensed'>
|
<table class='table table-striped table-condensed'>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="SALESORDER_REFERENCE_PREFIX" %}
|
{% include "InvenTree/settings/setting.html" with key="SALESORDER_REFERENCE_PATTERN" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="SALESORDER_DEFAULT_SHIPMENT" icon="fa-truck-loading" %}
|
{% include "InvenTree/settings/setting.html" with key="SALESORDER_DEFAULT_SHIPMENT" icon="fa-truck-loading" %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@ -292,8 +292,8 @@ function exportBom(part_id, options={}) {
|
|||||||
choices: exportFormatOptions(),
|
choices: exportFormatOptions(),
|
||||||
},
|
},
|
||||||
cascade: {
|
cascade: {
|
||||||
label: '{% trans "Cascading" %}',
|
label: '{% trans "Multi Level BOM" %}',
|
||||||
help_text: '{% trans "Download cascading / multi-level BOM" %}',
|
help_text: '{% trans "Include BOM data for subassemblies" %}',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
value: inventreeLoad('bom-export-cascading', true),
|
value: inventreeLoad('bom-export-cascading', true),
|
||||||
},
|
},
|
||||||
@ -302,6 +302,7 @@ function exportBom(part_id, options={}) {
|
|||||||
help_text: '{% trans "Select maximum number of BOM levels to export (0 = all levels)" %}',
|
help_text: '{% trans "Select maximum number of BOM levels to export (0 = all levels)" %}',
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
value: 0,
|
value: 0,
|
||||||
|
required: true,
|
||||||
min_value: 0,
|
min_value: 0,
|
||||||
},
|
},
|
||||||
parameter_data: {
|
parameter_data: {
|
||||||
|
@ -4,7 +4,6 @@
|
|||||||
/* globals
|
/* globals
|
||||||
buildStatusDisplay,
|
buildStatusDisplay,
|
||||||
constructForm,
|
constructForm,
|
||||||
global_settings,
|
|
||||||
imageHoverIcon,
|
imageHoverIcon,
|
||||||
inventreeGet,
|
inventreeGet,
|
||||||
launchModalForm,
|
launchModalForm,
|
||||||
@ -36,7 +35,7 @@
|
|||||||
function buildFormFields() {
|
function buildFormFields() {
|
||||||
return {
|
return {
|
||||||
reference: {
|
reference: {
|
||||||
prefix: global_settings.BUILDORDER_REFERENCE_PREFIX,
|
icon: 'fa-hashtag',
|
||||||
},
|
},
|
||||||
part: {
|
part: {
|
||||||
filters: {
|
filters: {
|
||||||
@ -731,9 +730,8 @@ function loadBuildOrderAllocationTable(table, options={}) {
|
|||||||
switchable: false,
|
switchable: false,
|
||||||
title: '{% trans "Build Order" %}',
|
title: '{% trans "Build Order" %}',
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
var prefix = global_settings.BUILDORDER_REFERENCE_PREFIX;
|
|
||||||
|
|
||||||
var ref = `${prefix}${row.build_detail.reference}`;
|
var ref = `${row.build_detail.reference}`;
|
||||||
|
|
||||||
return renderLink(ref, `/build/${row.build}/`);
|
return renderLink(ref, `/build/${row.build}/`);
|
||||||
}
|
}
|
||||||
@ -2372,7 +2370,6 @@ function loadBuildTable(table, options) {
|
|||||||
filters,
|
filters,
|
||||||
{
|
{
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
var prefix = global_settings.BUILDORDER_REFERENCE_PREFIX;
|
|
||||||
|
|
||||||
for (var idx = 0; idx < response.length; idx++) {
|
for (var idx = 0; idx < response.length; idx++) {
|
||||||
|
|
||||||
@ -2386,7 +2383,7 @@ function loadBuildTable(table, options) {
|
|||||||
date = order.target_date;
|
date = order.target_date;
|
||||||
}
|
}
|
||||||
|
|
||||||
var title = `${prefix}${order.reference}`;
|
var title = `${order.reference}`;
|
||||||
|
|
||||||
var color = '#4c68f5';
|
var color = '#4c68f5';
|
||||||
|
|
||||||
@ -2460,12 +2457,6 @@ function loadBuildTable(table, options) {
|
|||||||
switchable: true,
|
switchable: true,
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
|
|
||||||
var prefix = global_settings.BUILDORDER_REFERENCE_PREFIX;
|
|
||||||
|
|
||||||
if (prefix) {
|
|
||||||
value = `${prefix}${value}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
var html = renderLink(value, '/build/' + row.pk + '/');
|
var html = renderLink(value, '/build/' + row.pk + '/');
|
||||||
|
|
||||||
if (row.overdue) {
|
if (row.overdue) {
|
||||||
|
@ -255,8 +255,7 @@ function renderOwner(name, data, parameters={}, options={}) {
|
|||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
function renderPurchaseOrder(name, data, parameters={}, options={}) {
|
function renderPurchaseOrder(name, data, parameters={}, options={}) {
|
||||||
|
|
||||||
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
|
var html = `<span>${data.reference}</span>`;
|
||||||
var html = `<span>${prefix}${data.reference}</span>`;
|
|
||||||
|
|
||||||
var thumbnail = null;
|
var thumbnail = null;
|
||||||
|
|
||||||
@ -281,8 +280,7 @@ function renderPurchaseOrder(name, data, parameters={}, options={}) {
|
|||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
function renderSalesOrder(name, data, parameters={}, options={}) {
|
function renderSalesOrder(name, data, parameters={}, options={}) {
|
||||||
|
|
||||||
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
|
var html = `<span>${data.reference}</span>`;
|
||||||
var html = `<span>${prefix}${data.reference}</span>`;
|
|
||||||
|
|
||||||
var thumbnail = null;
|
var thumbnail = null;
|
||||||
|
|
||||||
@ -307,10 +305,8 @@ function renderSalesOrder(name, data, parameters={}, options={}) {
|
|||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
function renderSalesOrderShipment(name, data, parameters={}, options={}) {
|
function renderSalesOrderShipment(name, data, parameters={}, options={}) {
|
||||||
|
|
||||||
var so_prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
|
|
||||||
|
|
||||||
var html = `
|
var html = `
|
||||||
<span>${so_prefix}${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference}</span>
|
<span>${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference}</span>
|
||||||
<span class='float-right'>
|
<span class='float-right'>
|
||||||
<small>{% trans "Shipment ID" %}: ${data.pk}</small>
|
<small>{% trans "Shipment ID" %}: ${data.pk}</small>
|
||||||
</span>
|
</span>
|
||||||
|
@ -431,7 +431,7 @@ function createSalesOrderShipment(options={}) {
|
|||||||
var fields = salesOrderShipmentFields(options);
|
var fields = salesOrderShipmentFields(options);
|
||||||
|
|
||||||
fields.reference.value = ref;
|
fields.reference.value = ref;
|
||||||
fields.reference.prefix = global_settings.SALESORDER_REFERENCE_PREFIX + options.reference;
|
fields.reference.prefix = options.reference;
|
||||||
|
|
||||||
constructForm('{% url "api-so-shipment-list" %}', {
|
constructForm('{% url "api-so-shipment-list" %}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -456,7 +456,7 @@ function createSalesOrder(options={}) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
fields: {
|
fields: {
|
||||||
reference: {
|
reference: {
|
||||||
prefix: global_settings.SALESORDER_REFERENCE_PREFIX,
|
icon: 'fa-hashtag',
|
||||||
},
|
},
|
||||||
customer: {
|
customer: {
|
||||||
value: options.customer,
|
value: options.customer,
|
||||||
@ -497,7 +497,7 @@ function createPurchaseOrder(options={}) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
fields: {
|
fields: {
|
||||||
reference: {
|
reference: {
|
||||||
prefix: global_settings.PURCHASEORDER_REFERENCE_PREFIX,
|
icon: 'fa-hashtag',
|
||||||
},
|
},
|
||||||
supplier: {
|
supplier: {
|
||||||
icon: 'fa-building',
|
icon: 'fa-building',
|
||||||
@ -1081,9 +1081,7 @@ function newPurchaseOrderFromOrderWizard(e) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
var text = global_settings.PURCHASEORDER_REFERENCE_PREFIX || '';
|
var text = response.reference;
|
||||||
|
|
||||||
text += response.reference;
|
|
||||||
|
|
||||||
if (response.supplier_detail) {
|
if (response.supplier_detail) {
|
||||||
text += ` ${response.supplier_detail.name}`;
|
text += ` ${response.supplier_detail.name}`;
|
||||||
@ -1545,8 +1543,6 @@ function loadPurchaseOrderTable(table, options) {
|
|||||||
filters,
|
filters,
|
||||||
{
|
{
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
|
|
||||||
|
|
||||||
for (var idx = 0; idx < response.length; idx++) {
|
for (var idx = 0; idx < response.length; idx++) {
|
||||||
|
|
||||||
var order = response[idx];
|
var order = response[idx];
|
||||||
@ -1559,7 +1555,7 @@ function loadPurchaseOrderTable(table, options) {
|
|||||||
date = order.target_date;
|
date = order.target_date;
|
||||||
}
|
}
|
||||||
|
|
||||||
var title = `${prefix}${order.reference} - ${order.supplier_detail.name}`;
|
var title = `${order.reference} - ${order.supplier_detail.name}`;
|
||||||
|
|
||||||
var color = '#4c68f5';
|
var color = '#4c68f5';
|
||||||
|
|
||||||
@ -1623,12 +1619,6 @@ function loadPurchaseOrderTable(table, options) {
|
|||||||
switchable: false,
|
switchable: false,
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
|
|
||||||
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
|
|
||||||
|
|
||||||
if (prefix) {
|
|
||||||
value = `${prefix}${value}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
var html = renderLink(value, `/order/purchase-order/${row.pk}/`);
|
var html = renderLink(value, `/order/purchase-order/${row.pk}/`);
|
||||||
|
|
||||||
if (row.overdue) {
|
if (row.overdue) {
|
||||||
@ -2336,8 +2326,6 @@ function loadSalesOrderTable(table, options) {
|
|||||||
{
|
{
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
|
|
||||||
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
|
|
||||||
|
|
||||||
for (var idx = 0; idx < response.length; idx++) {
|
for (var idx = 0; idx < response.length; idx++) {
|
||||||
var order = response[idx];
|
var order = response[idx];
|
||||||
|
|
||||||
@ -2349,7 +2337,7 @@ function loadSalesOrderTable(table, options) {
|
|||||||
date = order.target_date;
|
date = order.target_date;
|
||||||
}
|
}
|
||||||
|
|
||||||
var title = `${prefix}${order.reference} - ${order.customer_detail.name}`;
|
var title = `${order.reference} - ${order.customer_detail.name}`;
|
||||||
|
|
||||||
// Default color is blue
|
// Default color is blue
|
||||||
var color = '#4c68f5';
|
var color = '#4c68f5';
|
||||||
@ -2435,13 +2423,6 @@ function loadSalesOrderTable(table, options) {
|
|||||||
field: 'reference',
|
field: 'reference',
|
||||||
title: '{% trans "Sales Order" %}',
|
title: '{% trans "Sales Order" %}',
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
|
|
||||||
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
|
|
||||||
|
|
||||||
if (prefix) {
|
|
||||||
value = `${prefix}${value}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
var html = renderLink(value, `/order/sales-order/${row.pk}/`);
|
var html = renderLink(value, `/order/sales-order/${row.pk}/`);
|
||||||
|
|
||||||
if (row.overdue) {
|
if (row.overdue) {
|
||||||
@ -2891,7 +2872,7 @@ function allocateStockToSalesOrder(order_id, line_items, options={}) {
|
|||||||
var fields = salesOrderShipmentFields(options);
|
var fields = salesOrderShipmentFields(options);
|
||||||
|
|
||||||
fields.reference.value = ref;
|
fields.reference.value = ref;
|
||||||
fields.reference.prefix = global_settings.SALESORDER_REFERENCE_PREFIX + options.reference;
|
fields.reference.prefix = options.reference;
|
||||||
|
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
@ -3123,9 +3104,7 @@ function loadSalesOrderAllocationTable(table, options={}) {
|
|||||||
title: '{% trans "Order" %}',
|
title: '{% trans "Order" %}',
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
|
|
||||||
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
|
var ref = `${row.order_detail.reference}`;
|
||||||
|
|
||||||
var ref = `${prefix}${row.order_detail.reference}`;
|
|
||||||
|
|
||||||
return renderLink(ref, `/order/sales-order/${row.order}/`);
|
return renderLink(ref, `/order/sales-order/${row.order}/`);
|
||||||
}
|
}
|
||||||
|
@ -974,9 +974,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
|
|||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
var ref = global_settings.PURCHASEORDER_REFERENCE_PREFIX + order.reference;
|
var html = renderLink(order.reference, `/order/purchase-order/${order.pk}/`);
|
||||||
|
|
||||||
var html = renderLink(ref, `/order/purchase-order/${order.pk}/`);
|
|
||||||
|
|
||||||
html += purchaseOrderStatusDisplay(
|
html += purchaseOrderStatusDisplay(
|
||||||
order.status,
|
order.status,
|
||||||
|
@ -1916,10 +1916,7 @@ function loadStockTable(table, options) {
|
|||||||
var text = `${row.purchase_order}`;
|
var text = `${row.purchase_order}`;
|
||||||
|
|
||||||
if (row.purchase_order_reference) {
|
if (row.purchase_order_reference) {
|
||||||
|
text = row.purchase_order_reference;
|
||||||
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
|
|
||||||
|
|
||||||
text = prefix + row.purchase_order_reference;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderLink(text, link);
|
return renderLink(text, link);
|
||||||
|
Loading…
Reference in New Issue
Block a user