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 response.status_code != expected_code:
|
||||
print(f"Unexpected response at '{url}':")
|
||||
print(response.data)
|
||||
print(f"Unexpected response at '{url}': status code = {response.status_code}")
|
||||
|
||||
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)
|
||||
|
||||
|
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 os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
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.models import MPTTModel, TreeForeignKey
|
||||
|
||||
import InvenTree.format
|
||||
import InvenTree.helpers
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.fields import InvenTreeURLField
|
||||
from InvenTree.validators import validate_tree_name
|
||||
|
||||
@ -96,9 +99,6 @@ class DataImportMixin(object):
|
||||
class ReferenceIndexingMixin(models.Model):
|
||||
"""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,
|
||||
for performing fast natural sorting.
|
||||
|
||||
@ -112,24 +112,216 @@ class ReferenceIndexingMixin(models.Model):
|
||||
- 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:
|
||||
"""Metaclass options. Abstract ensures no database table is created."""
|
||||
|
||||
abstract = True
|
||||
|
||||
def rebuild_reference_field(self):
|
||||
"""Extract integer out of reference for sorting."""
|
||||
reference = getattr(self, 'reference', '')
|
||||
self.reference_int = extract_int(reference)
|
||||
@classmethod
|
||||
def rebuild_reference_field(cls, reference, validate=False):
|
||||
"""Extract integer out of reference for sorting.
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def extract_int(reference, clip=0x7fffffff):
|
||||
"""Extract integer out of reference."""
|
||||
def extract_int(reference, clip=0x7fffffff, allow_negative=False):
|
||||
"""Extract an integer out of reference."""
|
||||
|
||||
# Default value if we cannot convert to an integer
|
||||
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"?
|
||||
result = re.match(r"^(\d+)", reference)
|
||||
|
||||
@ -139,6 +331,16 @@ def extract_int(reference, clip=0x7fffffff):
|
||||
ref_int = int(ref)
|
||||
except Exception:
|
||||
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
|
||||
# Note: This will result in large values being "clipped"
|
||||
@ -148,6 +350,9 @@ def extract_int(reference, clip=0x7fffffff):
|
||||
elif ref_int < -clip:
|
||||
ref_int = -clip
|
||||
|
||||
if not allow_negative and ref_int < 0:
|
||||
ref_int = abs(ref_int)
|
||||
|
||||
return ref_int
|
||||
|
||||
|
||||
|
@ -7,7 +7,6 @@ from decimal import Decimal
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import tablib
|
||||
@ -20,8 +19,6 @@ from rest_framework.fields import empty
|
||||
from rest_framework.serializers import DecimalField
|
||||
from rest_framework.utils import model_meta
|
||||
|
||||
from .models import extract_int
|
||||
|
||||
|
||||
class InvenTreeMoneySerializer(MoneyField):
|
||||
"""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):
|
||||
"""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.money import Money
|
||||
|
||||
import InvenTree.format
|
||||
import InvenTree.tasks
|
||||
from common.models import InvenTreeSetting
|
||||
from common.settings import currency_codes
|
||||
@ -60,6 +61,137 @@ class ValidatorTest(TestCase):
|
||||
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):
|
||||
"""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))
|
||||
|
||||
|
||||
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):
|
||||
"""Validate the 'reference' field of a PurchaseOrder."""
|
||||
pattern = common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_REGEX')
|
||||
|
@ -5,7 +5,7 @@
|
||||
fields:
|
||||
part: 100 # Build against part 100 "Bob"
|
||||
batch: 'B1'
|
||||
reference: "0001"
|
||||
reference: "BO-0001"
|
||||
title: 'Building 7 parts'
|
||||
quantity: 7
|
||||
notes: 'Some simple notes'
|
||||
@ -21,7 +21,7 @@
|
||||
pk: 2
|
||||
fields:
|
||||
part: 50
|
||||
reference: "0002"
|
||||
reference: "BO-0002"
|
||||
title: 'Making things'
|
||||
batch: 'B2'
|
||||
status: 40 # COMPLETE
|
||||
@ -37,7 +37,7 @@
|
||||
pk: 3
|
||||
fields:
|
||||
part: 50
|
||||
reference: "0003"
|
||||
reference: "BO-003"
|
||||
title: 'Making things'
|
||||
batch: 'B2'
|
||||
status: 40 # COMPLETE
|
||||
@ -53,7 +53,7 @@
|
||||
pk: 4
|
||||
fields:
|
||||
part: 50
|
||||
reference: "0004"
|
||||
reference: "BO-4"
|
||||
title: 'Making things'
|
||||
batch: 'B4'
|
||||
status: 40 # COMPLETE
|
||||
@ -69,7 +69,7 @@
|
||||
pk: 5
|
||||
fields:
|
||||
part: 25
|
||||
reference: "0005"
|
||||
reference: "BO-0005"
|
||||
title: "Building some Widgets"
|
||||
batch: "B10"
|
||||
status: 40 # Complete
|
||||
|
@ -1,6 +1,6 @@
|
||||
# Generated by Django 3.0.7 on 2020-10-19 13:02
|
||||
|
||||
import InvenTree.validators
|
||||
import build.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@ -18,6 +18,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='build',
|
||||
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
|
||||
|
||||
import InvenTree.validators
|
||||
import build.validators
|
||||
import build.models
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -15,6 +15,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='build',
|
||||
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 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.validators import validate_build_order_reference
|
||||
|
||||
from build.validators import generate_next_build_reference, validate_build_order_reference
|
||||
|
||||
import InvenTree.fields
|
||||
import InvenTree.helpers
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
|
||||
from plugin.events import trigger_event
|
||||
@ -38,32 +40,6 @@ from stock import models as StockModels
|
||||
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):
|
||||
"""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())
|
||||
|
||||
# Global setting for specifying reference pattern
|
||||
REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN'
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the BuildOrder model"""
|
||||
@ -106,7 +85,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
def api_defaults(cls, request):
|
||||
"""Return default values for this model when issuing an API OPTIONS request."""
|
||||
defaults = {
|
||||
'reference': get_next_build_number(),
|
||||
'reference': generate_next_build_reference(),
|
||||
}
|
||||
|
||||
if request and request.user:
|
||||
@ -116,7 +95,8 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""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:
|
||||
super().save(*args, **kwargs)
|
||||
@ -172,9 +152,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
|
||||
def __str__(self):
|
||||
"""String representation of a BuildOrder"""
|
||||
prefix = getSetting("BUILDORDER_REFERENCE_PREFIX")
|
||||
|
||||
return f"{prefix}{self.reference}"
|
||||
return self.reference
|
||||
|
||||
def get_absolute_url(self):
|
||||
"""Return the web URL associated with this BuildOrder"""
|
||||
@ -186,9 +164,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
blank=False,
|
||||
help_text=_('Build Order Reference'),
|
||||
verbose_name=_('Reference'),
|
||||
default=get_next_build_number,
|
||||
default=generate_next_build_reference,
|
||||
validators=[
|
||||
validate_build_order_reference
|
||||
validate_build_order_reference,
|
||||
]
|
||||
)
|
||||
|
||||
@ -199,7 +177,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
help_text=_('Brief description of the build')
|
||||
)
|
||||
|
||||
# TODO - Perhaps delete the build "tree"
|
||||
parent = TreeForeignKey(
|
||||
'self',
|
||||
on_delete=models.SET_NULL,
|
||||
@ -1092,6 +1069,10 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
||||
@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log')
|
||||
def after_save_build(sender, instance: Build, created: bool, **kwargs):
|
||||
"""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
|
||||
|
||||
if created:
|
||||
|
@ -11,7 +11,7 @@ from rest_framework import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
|
||||
from InvenTree.serializers import ReferenceIndexingSerializerMixin, UserSerializer
|
||||
from InvenTree.serializers import UserSerializer
|
||||
|
||||
import InvenTree.helpers
|
||||
from InvenTree.helpers import extract_serial_numbers
|
||||
@ -28,7 +28,7 @@ from users.serializers import OwnerSerializer
|
||||
from .models import Build, BuildItem, BuildOrderAttachment
|
||||
|
||||
|
||||
class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
|
||||
class BuildSerializer(InvenTreeModelSerializer):
|
||||
"""Serializes a Build object."""
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
@ -74,6 +74,16 @@ class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer
|
||||
if part_detail is not True:
|
||||
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:
|
||||
"""Serializer metaclass"""
|
||||
model = Build
|
||||
|
@ -748,6 +748,7 @@ class BuildListTest(BuildAPITest):
|
||||
|
||||
Build.objects.create(
|
||||
part=part,
|
||||
reference="BO-0006",
|
||||
quantity=10,
|
||||
title='Just some thing',
|
||||
status=BuildStatus.PRODUCTION,
|
||||
@ -773,20 +774,23 @@ class BuildListTest(BuildAPITest):
|
||||
Build.objects.create(
|
||||
part=part,
|
||||
quantity=10,
|
||||
reference=f"build-000{i}",
|
||||
reference=f"BO-{i + 10}",
|
||||
title=f"Sub build {i}",
|
||||
parent=parent
|
||||
)
|
||||
|
||||
# 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):
|
||||
|
||||
x = ii * 10 + i + 50
|
||||
|
||||
Build.objects.create(
|
||||
part=part,
|
||||
reference=f"{sub_build.reference}-00{i}-sub",
|
||||
reference=f"BO-{x}",
|
||||
title=f"{sub_build.reference}-00{i}-sub",
|
||||
quantity=40,
|
||||
title=f"sub sub build {i}",
|
||||
parent=sub_build
|
||||
)
|
||||
|
||||
|
@ -12,7 +12,7 @@ from InvenTree import status_codes as status
|
||||
|
||||
import common.models
|
||||
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 stock.models import StockItem
|
||||
from users.models import Owner
|
||||
@ -88,7 +88,7 @@ class BuildTestBase(TestCase):
|
||||
quantity=2
|
||||
)
|
||||
|
||||
ref = get_next_build_number()
|
||||
ref = generate_next_build_reference()
|
||||
|
||||
# Create a "Build" object to make 10x objects
|
||||
self.build = Build.objects.create(
|
||||
@ -133,20 +133,97 @@ class BuildTest(BuildTestBase):
|
||||
def test_ref_int(self):
|
||||
"""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(
|
||||
reference=f"{ii}_abcde",
|
||||
reference=ref,
|
||||
quantity=1,
|
||||
part=self.assembly,
|
||||
title="Making some parts"
|
||||
title='Making some parts',
|
||||
)
|
||||
|
||||
self.assertEqual(build.reference_int, 0)
|
||||
|
||||
build.save()
|
||||
self.assertEqual(build.reference_int, ref_int)
|
||||
|
||||
# After saving, the integer reference should have been updated
|
||||
self.assertEqual(build.reference_int, ii)
|
||||
def test_ref_validation(self):
|
||||
"""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):
|
||||
"""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"""
|
||||
|
||||
Build.objects.create(
|
||||
reference='IIIII',
|
||||
reference='BO-9999',
|
||||
title='Some new build',
|
||||
part=self.assembly,
|
||||
quantity=5,
|
||||
|
@ -104,3 +104,57 @@ class TestReferenceMigration(MigratorTestCase):
|
||||
# Check that the build reference is properly assigned
|
||||
for build in Build.objects.all():
|
||||
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.quantity, 21)
|
||||
|
||||
self.assertEqual(str(b), 'BO0002')
|
||||
self.assertEqual(str(b), 'BO-0002')
|
||||
|
||||
def test_url(self):
|
||||
"""Test URL lookup"""
|
||||
@ -75,11 +75,6 @@ class BuildTestSimple(InvenTreeTestCase):
|
||||
self.assertEqual(b1.is_active, True)
|
||||
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):
|
||||
"""Test build cancellation function."""
|
||||
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 rest_framework.exceptions import PermissionDenied
|
||||
|
||||
import build.validators
|
||||
import InvenTree.fields
|
||||
import InvenTree.helpers
|
||||
import InvenTree.ready
|
||||
import InvenTree.validators
|
||||
import order.validators
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@ -1139,21 +1141,18 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'BUILDORDER_REFERENCE_PREFIX': {
|
||||
'name': _('Build Order Reference Prefix'),
|
||||
'description': _('Prefix value for build order reference'),
|
||||
'default': 'BO',
|
||||
'BUILDORDER_REFERENCE_PATTERN': {
|
||||
'name': _('Build Order Reference Pattern'),
|
||||
'description': _('Required pattern for generating Build Order reference field'),
|
||||
'default': 'BO-{ref:04d}',
|
||||
'validator': build.validators.validate_build_order_reference_pattern,
|
||||
},
|
||||
|
||||
'BUILDORDER_REFERENCE_REGEX': {
|
||||
'name': _('Build Order Reference Regex'),
|
||||
'description': _('Regular expression pattern for matching build order reference')
|
||||
},
|
||||
|
||||
'SALESORDER_REFERENCE_PREFIX': {
|
||||
'name': _('Sales Order Reference Prefix'),
|
||||
'description': _('Prefix value for sales order reference'),
|
||||
'default': 'SO',
|
||||
'SALESORDER_REFERENCE_PATTERN': {
|
||||
'name': _('Sales Order Reference Pattern'),
|
||||
'description': _('Required pattern for generating Sales Order reference field'),
|
||||
'default': 'SO-{ref:04d}',
|
||||
'validator': order.validators.validate_sales_order_reference_pattern,
|
||||
},
|
||||
|
||||
'SALESORDER_DEFAULT_SHIPMENT': {
|
||||
@ -1163,10 +1162,11 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'PURCHASEORDER_REFERENCE_PREFIX': {
|
||||
'name': _('Purchase Order Reference Prefix'),
|
||||
'description': _('Prefix value for purchase order reference'),
|
||||
'default': 'PO',
|
||||
'PURCHASEORDER_REFERENCE_PATTERN': {
|
||||
'name': _('Purchase Order Reference Pattern'),
|
||||
'description': _('Required pattern for generating Purchase Order reference field'),
|
||||
'default': 'PO-{ref:04d}',
|
||||
'validator': order.validators.validate_purchase_order_reference_pattern,
|
||||
},
|
||||
|
||||
# login / SSO
|
||||
|
@ -4,7 +4,7 @@
|
||||
- model: order.purchaseorder
|
||||
pk: 1
|
||||
fields:
|
||||
reference: '0001'
|
||||
reference: 'PO-0001'
|
||||
description: "Ordering some screws"
|
||||
supplier: 1
|
||||
status: 10 # Pending
|
||||
@ -13,7 +13,7 @@
|
||||
- model: order.purchaseorder
|
||||
pk: 2
|
||||
fields:
|
||||
reference: '0002'
|
||||
reference: 'PO-0002'
|
||||
description: "Ordering some more screws"
|
||||
supplier: 3
|
||||
status: 10 # Pending
|
||||
@ -21,7 +21,7 @@
|
||||
- model: order.purchaseorder
|
||||
pk: 3
|
||||
fields:
|
||||
reference: '0003'
|
||||
reference: 'PO-0003'
|
||||
description: 'Another PO'
|
||||
supplier: 3
|
||||
status: 20 # Placed
|
||||
@ -29,7 +29,7 @@
|
||||
- model: order.purchaseorder
|
||||
pk: 4
|
||||
fields:
|
||||
reference: '0004'
|
||||
reference: 'PO-0004'
|
||||
description: 'Another PO'
|
||||
supplier: 3
|
||||
status: 20 # Placed
|
||||
@ -37,7 +37,7 @@
|
||||
- model: order.purchaseorder
|
||||
pk: 5
|
||||
fields:
|
||||
reference: '0005'
|
||||
reference: 'PO-0005'
|
||||
description: 'Another PO'
|
||||
supplier: 3
|
||||
status: 30 # Complete
|
||||
@ -45,7 +45,7 @@
|
||||
- model: order.purchaseorder
|
||||
pk: 6
|
||||
fields:
|
||||
reference: '0006'
|
||||
reference: 'PO-0006'
|
||||
description: 'Another PO'
|
||||
supplier: 3
|
||||
status: 40 # Cancelled
|
||||
@ -54,7 +54,7 @@
|
||||
- model: order.purchaseorder
|
||||
pk: 7
|
||||
fields:
|
||||
reference: '0007'
|
||||
reference: 'PO-0007'
|
||||
description: 'Another PO'
|
||||
supplier: 2
|
||||
status: 10 # Pending
|
||||
|
@ -1,7 +1,6 @@
|
||||
# Generated by Django 3.2.4 on 2021-07-02 13:21
|
||||
|
||||
from django.db import migrations, models
|
||||
import order.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@ -14,11 +13,11 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorder',
|
||||
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(
|
||||
model_name='salesorder',
|
||||
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.ready
|
||||
import order.validators
|
||||
from common.notifications import InvenTreeNotificationBodies
|
||||
from common.settings import currency_code_default
|
||||
from company.models import Company, SupplierPart
|
||||
from InvenTree.exceptions import log_error
|
||||
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
|
||||
RoundingDecimalField)
|
||||
from InvenTree.helpers import (decimal2string, getSetting, increment,
|
||||
notify_responsible)
|
||||
from InvenTree.helpers import decimal2string, getSetting, notify_responsible
|
||||
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
||||
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
|
||||
StockHistoryCode, StockStatus)
|
||||
@ -44,58 +44,6 @@ from users import models as UserModels
|
||||
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):
|
||||
"""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.
|
||||
"""
|
||||
self.rebuild_reference_field()
|
||||
self.reference_int = self.rebuild_reference_field(self.reference)
|
||||
|
||||
if not self.creation_date:
|
||||
self.creation_date = datetime.now().date()
|
||||
@ -230,8 +178,21 @@ class PurchaseOrder(Order):
|
||||
"""Return the API URL associated with the PurchaseOrder model"""
|
||||
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())
|
||||
|
||||
# Global setting for specifying reference pattern
|
||||
REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN'
|
||||
|
||||
@staticmethod
|
||||
def filterByDate(queryset, min_date, max_date):
|
||||
"""Filter by 'minimum and maximum date range'.
|
||||
@ -269,9 +230,8 @@ class PurchaseOrder(Order):
|
||||
|
||||
def __str__(self):
|
||||
"""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(
|
||||
unique=True,
|
||||
@ -279,7 +239,10 @@ class PurchaseOrder(Order):
|
||||
blank=False,
|
||||
verbose_name=_('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(),
|
||||
@ -595,8 +558,20 @@ class SalesOrder(Order):
|
||||
"""Return the API URL associated with the SalesOrder model"""
|
||||
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())
|
||||
|
||||
# Global setting for specifying reference pattern
|
||||
REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN'
|
||||
|
||||
@staticmethod
|
||||
def filterByDate(queryset, min_date, max_date):
|
||||
"""Filter by "minimum and maximum date range".
|
||||
@ -634,9 +609,8 @@ class SalesOrder(Order):
|
||||
|
||||
def __str__(self):
|
||||
"""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):
|
||||
"""Return the web URL for the detail view of this order"""
|
||||
@ -648,7 +622,10 @@ class SalesOrder(Order):
|
||||
blank=False,
|
||||
verbose_name=_('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(
|
||||
|
@ -23,8 +23,7 @@ from InvenTree.helpers import extract_serial_numbers, normalize
|
||||
from InvenTree.serializers import (InvenTreeAttachmentSerializer,
|
||||
InvenTreeDecimalField,
|
||||
InvenTreeModelSerializer,
|
||||
InvenTreeMoneySerializer,
|
||||
ReferenceIndexingSerializerMixin)
|
||||
InvenTreeMoneySerializer)
|
||||
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
|
||||
StockStatus)
|
||||
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."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -130,6 +129,14 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializ
|
||||
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
@ -639,7 +646,7 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
]
|
||||
|
||||
|
||||
class SalesOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
|
||||
class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
|
||||
"""Serializers for the SalesOrder object."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -683,6 +690,14 @@ class SalesOrderSerializer(AbstractOrderSerializer, ReferenceIndexingSerializerM
|
||||
|
||||
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:
|
||||
"""Metaclass options."""
|
||||
|
||||
|
@ -82,7 +82,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></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>
|
||||
<td><span class='fas fa-info-circle'></span></td>
|
||||
@ -222,7 +222,7 @@ $("#edit-order").click(function() {
|
||||
constructForm('{% url "api-po-detail" order.pk %}', {
|
||||
fields: {
|
||||
reference: {
|
||||
prefix: global_settings.PURCHASEORDER_REFERENCE_PREFIX,
|
||||
icon: 'fa-hashtag',
|
||||
},
|
||||
{% if order.lines.count == 0 and order.status == PurchaseOrderStatus.PENDING %}
|
||||
supplier: {
|
||||
|
@ -78,7 +78,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></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>
|
||||
<td><span class='fas fa-info-circle'></span></td>
|
||||
@ -209,7 +209,7 @@ $("#edit-order").click(function() {
|
||||
constructForm('{% url "api-so-detail" order.pk %}', {
|
||||
fields: {
|
||||
reference: {
|
||||
prefix: global_settings.SALESORDER_REFERENCE_PREFIX,
|
||||
icon: 'fa-hashtag',
|
||||
},
|
||||
{% if order.lines.count == 0 and order.status == SalesOrderStatus.PENDING %}
|
||||
customer: {
|
||||
|
@ -94,23 +94,28 @@ class PurchaseOrderTest(OrderTest):
|
||||
self.assertEqual(data['description'], 'Ordering some screws')
|
||||
|
||||
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
|
||||
self.assignRole('purchase_order.add')
|
||||
|
||||
url = reverse('api-po-list')
|
||||
huge_number = 9223372036854775808
|
||||
huge_number = "PO-92233720368547758089999999999999999"
|
||||
|
||||
self.post(
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'supplier': 1,
|
||||
'reference': huge_number,
|
||||
'description': 'PO not created via the API',
|
||||
'description': 'PO created via the API',
|
||||
},
|
||||
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):
|
||||
"""Test the list endpoint for the PurchaseOrderAttachment model"""
|
||||
url = reverse('api-po-attachment-list')
|
||||
@ -149,7 +154,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
url,
|
||||
{
|
||||
'supplier': 1,
|
||||
'reference': '123456789-xyz',
|
||||
'reference': 'PO-123456789',
|
||||
'description': 'PO created via the API',
|
||||
},
|
||||
expected_code=201
|
||||
@ -177,19 +182,19 @@ class PurchaseOrderTest(OrderTest):
|
||||
# Get detail info!
|
||||
response = self.get(url)
|
||||
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
|
||||
response = self.patch(
|
||||
url,
|
||||
{
|
||||
'reference': '12345-abc',
|
||||
'reference': 'PO-12345',
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
# 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!
|
||||
# Initially, we do *not* have the required permission!
|
||||
@ -213,7 +218,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
self.post(
|
||||
reverse('api-po-list'),
|
||||
{
|
||||
'reference': '12345678',
|
||||
'reference': 'PO-12345678',
|
||||
'supplier': 1,
|
||||
'description': 'A test purchase order',
|
||||
},
|
||||
@ -807,7 +812,7 @@ class SalesOrderTest(OrderTest):
|
||||
url,
|
||||
{
|
||||
'customer': 4,
|
||||
'reference': '12345',
|
||||
'reference': 'SO-12345',
|
||||
'description': 'Sales order',
|
||||
},
|
||||
expected_code=201
|
||||
@ -824,7 +829,7 @@ class SalesOrderTest(OrderTest):
|
||||
url,
|
||||
{
|
||||
'customer': 4,
|
||||
'reference': '12345',
|
||||
'reference': 'SO-12345',
|
||||
'description': 'Another sales order',
|
||||
},
|
||||
expected_code=400
|
||||
@ -834,19 +839,28 @@ class SalesOrderTest(OrderTest):
|
||||
|
||||
# Extract detail info for the SalesOrder
|
||||
response = self.get(url)
|
||||
self.assertEqual(response.data['reference'], '12345')
|
||||
self.assertEqual(response.data['reference'], 'SO-12345')
|
||||
|
||||
# Try to alter (edit) the SalesOrder
|
||||
# Initially try with an invalid reference field value
|
||||
response = self.patch(
|
||||
url,
|
||||
{
|
||||
'reference': '12345-a',
|
||||
'reference': 'SO-12345-a',
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
response = self.patch(
|
||||
url,
|
||||
{
|
||||
'reference': 'SO-12346',
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
# 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
|
||||
# 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."""
|
||||
self.assignRole('sales_order.add')
|
||||
|
||||
self.post(
|
||||
reverse('api-so-list'),
|
||||
url = reverse('api-so-list')
|
||||
|
||||
# Will fail due to invalid reference field
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'reference': '1234566778',
|
||||
'customer': 4,
|
||||
'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):
|
||||
|
@ -40,19 +40,27 @@ class SalesOrderTest(TestCase):
|
||||
# Create a SalesOrder to ship against
|
||||
self.order = SalesOrder.objects.create(
|
||||
customer=self.customer,
|
||||
reference='1234',
|
||||
reference='SO-1234',
|
||||
customer_reference='ABC 55555'
|
||||
)
|
||||
|
||||
# Create a Shipment against this SalesOrder
|
||||
self.shipment = SalesOrderShipment.objects.create(
|
||||
order=self.order,
|
||||
reference='001',
|
||||
reference='SO-001',
|
||||
)
|
||||
|
||||
# Create a line item
|
||||
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):
|
||||
"""Test that the 'reference_int' field gets rebuilt when the model is saved"""
|
||||
|
||||
|
@ -35,15 +35,17 @@ class OrderTest(TestCase):
|
||||
|
||||
def test_basics(self):
|
||||
"""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)
|
||||
|
||||
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO0001 - ACME)")
|
||||
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO-0001 - ACME)")
|
||||
|
||||
def test_rebuild_reference(self):
|
||||
"""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),
|
||||
quantity=10,
|
||||
title='Making some assemblies',
|
||||
reference='BO-9999',
|
||||
status=BuildStatus.PRODUCTION,
|
||||
)
|
||||
|
||||
|
@ -425,7 +425,6 @@ class PurchaseOrderReport(ReportTemplateBase):
|
||||
'order': order,
|
||||
'reference': order.reference,
|
||||
'supplier': order.supplier,
|
||||
'prefix': common.models.InvenTreeSetting.get_setting('PURCHASEORDER_REFERENCE_PREFIX'),
|
||||
'title': str(order),
|
||||
}
|
||||
|
||||
@ -463,7 +462,6 @@ class SalesOrderReport(ReportTemplateBase):
|
||||
'lines': order.lines,
|
||||
'extra_lines': order.extra_lines,
|
||||
'order': order,
|
||||
'prefix': common.models.InvenTreeSetting.get_setting('SALESORDER_REFERENCE_PREFIX'),
|
||||
'reference': order.reference,
|
||||
'title': str(order),
|
||||
}
|
||||
|
@ -30,8 +30,7 @@ import report.models
|
||||
from company import models as CompanyModels
|
||||
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
|
||||
InvenTreeURLField)
|
||||
from InvenTree.models import InvenTreeAttachment, InvenTreeTree
|
||||
from InvenTree.serializers import extract_int
|
||||
from InvenTree.models import InvenTreeAttachment, InvenTreeTree, extract_int
|
||||
from InvenTree.status_codes import StockHistoryCode, StockStatus
|
||||
from part import models as PartModels
|
||||
from plugin.events import trigger_event
|
||||
@ -1708,8 +1707,7 @@ class StockItem(MetadataMixin, MPTTModel):
|
||||
s += ' @ {loc}'.format(loc=self.location.name)
|
||||
|
||||
if self.purchase_order:
|
||||
s += " ({pre}{po})".format(
|
||||
pre=InvenTree.helpers.getSetting("PURCHASEORDER_REFERENCE_PREFIX"),
|
||||
s += " ({po})".format(
|
||||
po=self.purchase_order,
|
||||
)
|
||||
|
||||
|
@ -20,7 +20,8 @@ import InvenTree.serializers
|
||||
import part.models as part_models
|
||||
from common.settings import currency_code_default, currency_code_mappings
|
||||
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 .models import (StockItem, StockItemAttachment, StockItemTestResult,
|
||||
@ -67,8 +68,8 @@ class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
|
||||
def validate_serial(self, value):
|
||||
"""Make sure serial is not to big."""
|
||||
if extract_int(value) > 2147483647:
|
||||
raise serializers.ValidationError('serial is to to big')
|
||||
if abs(extract_int(value)) > 0x7fffffff:
|
||||
raise serializers.ValidationError(_("Serial number is too large"))
|
||||
return value
|
||||
|
||||
|
||||
|
@ -87,7 +87,7 @@ class StockTest(InvenTreeTestCase):
|
||||
# And there should be *no* items being build
|
||||
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"
|
||||
for _ in range(10):
|
||||
@ -395,13 +395,14 @@ class StockTest(InvenTreeTestCase):
|
||||
item.serial = "-123"
|
||||
item.save()
|
||||
|
||||
# Negative number should map to zero
|
||||
self.assertEqual(item.serial_int, 0)
|
||||
# Negative number should map to positive value
|
||||
self.assertEqual(item.serial_int, 123)
|
||||
|
||||
# Test a very very large value
|
||||
item.serial = '99999999999999999999999999999999999999999999999999999'
|
||||
item.save()
|
||||
|
||||
# The 'integer' portion has been clipped to a maximum value
|
||||
self.assertEqual(item.serial_int, 0x7fffffff)
|
||||
|
||||
# Non-numeric values should encode to zero
|
||||
|
@ -12,8 +12,7 @@
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_PREFIX" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_REGEX" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_PATTERN" %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
@ -10,7 +10,7 @@
|
||||
{% block content %}
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PREFIX" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PATTERN" %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
@ -11,7 +11,7 @@
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
<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" %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -292,8 +292,8 @@ function exportBom(part_id, options={}) {
|
||||
choices: exportFormatOptions(),
|
||||
},
|
||||
cascade: {
|
||||
label: '{% trans "Cascading" %}',
|
||||
help_text: '{% trans "Download cascading / multi-level BOM" %}',
|
||||
label: '{% trans "Multi Level BOM" %}',
|
||||
help_text: '{% trans "Include BOM data for subassemblies" %}',
|
||||
type: 'boolean',
|
||||
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)" %}',
|
||||
type: 'integer',
|
||||
value: 0,
|
||||
required: true,
|
||||
min_value: 0,
|
||||
},
|
||||
parameter_data: {
|
||||
|
@ -4,7 +4,6 @@
|
||||
/* globals
|
||||
buildStatusDisplay,
|
||||
constructForm,
|
||||
global_settings,
|
||||
imageHoverIcon,
|
||||
inventreeGet,
|
||||
launchModalForm,
|
||||
@ -36,7 +35,7 @@
|
||||
function buildFormFields() {
|
||||
return {
|
||||
reference: {
|
||||
prefix: global_settings.BUILDORDER_REFERENCE_PREFIX,
|
||||
icon: 'fa-hashtag',
|
||||
},
|
||||
part: {
|
||||
filters: {
|
||||
@ -731,9 +730,8 @@ function loadBuildOrderAllocationTable(table, options={}) {
|
||||
switchable: false,
|
||||
title: '{% trans "Build Order" %}',
|
||||
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}/`);
|
||||
}
|
||||
@ -2372,7 +2370,6 @@ function loadBuildTable(table, options) {
|
||||
filters,
|
||||
{
|
||||
success: function(response) {
|
||||
var prefix = global_settings.BUILDORDER_REFERENCE_PREFIX;
|
||||
|
||||
for (var idx = 0; idx < response.length; idx++) {
|
||||
|
||||
@ -2386,7 +2383,7 @@ function loadBuildTable(table, options) {
|
||||
date = order.target_date;
|
||||
}
|
||||
|
||||
var title = `${prefix}${order.reference}`;
|
||||
var title = `${order.reference}`;
|
||||
|
||||
var color = '#4c68f5';
|
||||
|
||||
@ -2460,12 +2457,6 @@ function loadBuildTable(table, options) {
|
||||
switchable: true,
|
||||
formatter: function(value, row) {
|
||||
|
||||
var prefix = global_settings.BUILDORDER_REFERENCE_PREFIX;
|
||||
|
||||
if (prefix) {
|
||||
value = `${prefix}${value}`;
|
||||
}
|
||||
|
||||
var html = renderLink(value, '/build/' + row.pk + '/');
|
||||
|
||||
if (row.overdue) {
|
||||
|
@ -255,8 +255,7 @@ function renderOwner(name, data, parameters={}, options={}) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderPurchaseOrder(name, data, parameters={}, options={}) {
|
||||
|
||||
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
|
||||
var html = `<span>${prefix}${data.reference}</span>`;
|
||||
var html = `<span>${data.reference}</span>`;
|
||||
|
||||
var thumbnail = null;
|
||||
|
||||
@ -281,8 +280,7 @@ function renderPurchaseOrder(name, data, parameters={}, options={}) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderSalesOrder(name, data, parameters={}, options={}) {
|
||||
|
||||
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
|
||||
var html = `<span>${prefix}${data.reference}</span>`;
|
||||
var html = `<span>${data.reference}</span>`;
|
||||
|
||||
var thumbnail = null;
|
||||
|
||||
@ -307,10 +305,8 @@ function renderSalesOrder(name, data, parameters={}, options={}) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function renderSalesOrderShipment(name, data, parameters={}, options={}) {
|
||||
|
||||
var so_prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
|
||||
|
||||
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'>
|
||||
<small>{% trans "Shipment ID" %}: ${data.pk}</small>
|
||||
</span>
|
||||
|
@ -431,7 +431,7 @@ function createSalesOrderShipment(options={}) {
|
||||
var fields = salesOrderShipmentFields(options);
|
||||
|
||||
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" %}', {
|
||||
method: 'POST',
|
||||
@ -456,7 +456,7 @@ function createSalesOrder(options={}) {
|
||||
method: 'POST',
|
||||
fields: {
|
||||
reference: {
|
||||
prefix: global_settings.SALESORDER_REFERENCE_PREFIX,
|
||||
icon: 'fa-hashtag',
|
||||
},
|
||||
customer: {
|
||||
value: options.customer,
|
||||
@ -497,7 +497,7 @@ function createPurchaseOrder(options={}) {
|
||||
method: 'POST',
|
||||
fields: {
|
||||
reference: {
|
||||
prefix: global_settings.PURCHASEORDER_REFERENCE_PREFIX,
|
||||
icon: 'fa-hashtag',
|
||||
},
|
||||
supplier: {
|
||||
icon: 'fa-building',
|
||||
@ -1081,9 +1081,7 @@ function newPurchaseOrderFromOrderWizard(e) {
|
||||
},
|
||||
{
|
||||
success: function(response) {
|
||||
var text = global_settings.PURCHASEORDER_REFERENCE_PREFIX || '';
|
||||
|
||||
text += response.reference;
|
||||
var text = response.reference;
|
||||
|
||||
if (response.supplier_detail) {
|
||||
text += ` ${response.supplier_detail.name}`;
|
||||
@ -1545,8 +1543,6 @@ function loadPurchaseOrderTable(table, options) {
|
||||
filters,
|
||||
{
|
||||
success: function(response) {
|
||||
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
|
||||
|
||||
for (var idx = 0; idx < response.length; idx++) {
|
||||
|
||||
var order = response[idx];
|
||||
@ -1559,7 +1555,7 @@ function loadPurchaseOrderTable(table, options) {
|
||||
date = order.target_date;
|
||||
}
|
||||
|
||||
var title = `${prefix}${order.reference} - ${order.supplier_detail.name}`;
|
||||
var title = `${order.reference} - ${order.supplier_detail.name}`;
|
||||
|
||||
var color = '#4c68f5';
|
||||
|
||||
@ -1623,12 +1619,6 @@ function loadPurchaseOrderTable(table, options) {
|
||||
switchable: false,
|
||||
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}/`);
|
||||
|
||||
if (row.overdue) {
|
||||
@ -2336,8 +2326,6 @@ function loadSalesOrderTable(table, options) {
|
||||
{
|
||||
success: function(response) {
|
||||
|
||||
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
|
||||
|
||||
for (var idx = 0; idx < response.length; idx++) {
|
||||
var order = response[idx];
|
||||
|
||||
@ -2349,7 +2337,7 @@ function loadSalesOrderTable(table, options) {
|
||||
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
|
||||
var color = '#4c68f5';
|
||||
@ -2435,13 +2423,6 @@ function loadSalesOrderTable(table, options) {
|
||||
field: 'reference',
|
||||
title: '{% trans "Sales Order" %}',
|
||||
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}/`);
|
||||
|
||||
if (row.overdue) {
|
||||
@ -2891,7 +2872,7 @@ function allocateStockToSalesOrder(order_id, line_items, options={}) {
|
||||
var fields = salesOrderShipmentFields(options);
|
||||
|
||||
fields.reference.value = ref;
|
||||
fields.reference.prefix = global_settings.SALESORDER_REFERENCE_PREFIX + options.reference;
|
||||
fields.reference.prefix = options.reference;
|
||||
|
||||
return fields;
|
||||
}
|
||||
@ -3123,9 +3104,7 @@ function loadSalesOrderAllocationTable(table, options={}) {
|
||||
title: '{% trans "Order" %}',
|
||||
formatter: function(value, row) {
|
||||
|
||||
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
|
||||
|
||||
var ref = `${prefix}${row.order_detail.reference}`;
|
||||
var ref = `${row.order_detail.reference}`;
|
||||
|
||||
return renderLink(ref, `/order/sales-order/${row.order}/`);
|
||||
}
|
||||
|
@ -974,9 +974,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
var ref = global_settings.PURCHASEORDER_REFERENCE_PREFIX + order.reference;
|
||||
|
||||
var html = renderLink(ref, `/order/purchase-order/${order.pk}/`);
|
||||
var html = renderLink(order.reference, `/order/purchase-order/${order.pk}/`);
|
||||
|
||||
html += purchaseOrderStatusDisplay(
|
||||
order.status,
|
||||
|
@ -1916,10 +1916,7 @@ function loadStockTable(table, options) {
|
||||
var text = `${row.purchase_order}`;
|
||||
|
||||
if (row.purchase_order_reference) {
|
||||
|
||||
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
|
||||
|
||||
text = prefix + row.purchase_order_reference;
|
||||
text = row.purchase_order_reference;
|
||||
}
|
||||
|
||||
return renderLink(text, link);
|
||||
|
Loading…
Reference in New Issue
Block a user