mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-20 22:00:27 -06:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cae0d5066 | ||
|
|
21d266ab95 | ||
|
|
1ae27a6b77 | ||
|
|
b18ac57fb8 | ||
|
|
053b37ec3a | ||
|
|
6fa6063639 | ||
|
|
9b68dea26d | ||
|
|
9d21776c86 | ||
|
|
4c9f042f8c | ||
|
|
3625b8f14c | ||
|
|
cd41ca2a87 | ||
|
|
8a2fce9c36 | ||
|
|
ee87cd7b23 | ||
|
|
940abaa179 | ||
|
|
7fefa5c213 | ||
|
|
28726db86f | ||
|
|
eef1aad464 | ||
|
|
3b6b41976f | ||
|
|
407ccb7bd2 | ||
|
|
d7ed114e2c | ||
|
|
c7a0265794 | ||
|
|
5bc56c826a | ||
|
|
1b42c00747 | ||
|
|
0f9bddbcd2 | ||
|
|
b0fc42d906 | ||
|
|
993849813f | ||
|
|
453c726d1e | ||
|
|
b6ca9ec6a4 |
@@ -70,7 +70,7 @@ RUN apk add --no-cache \
|
||||
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#alpine-3-12
|
||||
py3-pip py3-pillow py3-cffi py3-brotli pango poppler-utils openldap \
|
||||
# Postgres client
|
||||
postgresql13-client \
|
||||
postgresql15-client \
|
||||
# MySQL / MariaDB client
|
||||
mariadb-client mariadb-connector-c \
|
||||
&& \
|
||||
|
||||
@@ -8,5 +8,5 @@ apk add gcc g++ musl-dev openssl-dev libffi-dev cargo python3-dev openldap-dev \
|
||||
jpeg-dev openjpeg-dev libwebp-dev zlib-dev \
|
||||
sqlite sqlite-dev \
|
||||
mariadb-connector-c-dev mariadb-client mariadb-dev \
|
||||
postgresql13-dev postgresql-libs \
|
||||
postgresql15-dev postgresql-libs \
|
||||
$@
|
||||
|
||||
@@ -26,7 +26,12 @@ Refer to the [invoke guide](./start/invoke.md#cant-find-any-collection-named-tas
|
||||
|
||||
If the installed version of invoke is too old, users may see error messages during the installation procedure. Refer to the [invoke guide](./start/invoke.md#minimum-version) for more information.
|
||||
|
||||
### No module named 'django'
|
||||
### INVE-E1 - No frontend included
|
||||
|
||||
Make sure you are running a stable or production release of InvenTree. The frontend panel is not included in development releases.
|
||||
More Information: [Error Codes - INVE-E1](./settings/error_codes.md#inve-e1)
|
||||
|
||||
### No module named <xxx>
|
||||
|
||||
During the install or update process, you may be presented with an error like:
|
||||
|
||||
|
||||
26
docs/docs/settings/error_codes.md
Normal file
26
docs/docs/settings/error_codes.md
Normal file
@@ -0,0 +1,26 @@
|
||||
## Error Codes
|
||||
|
||||
InvenTree is starting to use error codes to help identify and diagnose issues. These are increasingly being added to the codebase. Error messages missing an error code should be reported on GitHub.
|
||||
Error codes are prefixed with `INVE-` and are followed by a letter to indicate the type of error and a number to indicate the specific error. Once a code is used it might not be reassigned to a different error, it can be marked as stricken from the list.
|
||||
|
||||
### INVE-E (InvenTree Error)
|
||||
Errors - These are critical errors which should be addressed as soon as possible.
|
||||
|
||||
#### INVE-E1
|
||||
**No frontend included - Backend/web**
|
||||
|
||||
Only stable / production releases of InvenTree include the frontend panel. This is both a measure of resource-saving and attack surface reduction. If you want to use the frontend panel, you can either:″
|
||||
- use a docker image that is version-tagged or the stable version
|
||||
- use a package version that is from the stable or version stream
|
||||
- install node and yarn on the server to build the frontend with the [invoke](../start/invoke.md) task `int.frontend-build`
|
||||
|
||||
Raise an issue if none of these options work.
|
||||
|
||||
### INVE-W (InvenTree Warning)
|
||||
Warnings - These are non-critical errors which should be addressed when possible.
|
||||
|
||||
### INVE-I (InvenTree Information)
|
||||
Information — These are not errors but information messages. They might point out potential issues or just provide information.
|
||||
|
||||
### INVE-M (InvenTree Miscellaneous)
|
||||
Miscellaneous — These are information messages that might be used to mark debug information or other messages helpful for the InvenTree team to understand behaviour.
|
||||
@@ -46,7 +46,7 @@ InvenTree run-time configuration options described in the [configuration documen
|
||||
|
||||
As docker containers are ephemeral, any *persistent* data must be stored in an external [volume](https://docs.docker.com/storage/volumes/). To simplify installation / implementation, all external data are stored in a single volume, arranged as follows:
|
||||
|
||||
#### Media FIles
|
||||
#### Media Files
|
||||
|
||||
Uploaded media files are stored in the `media/` subdirectory of the external data volume.
|
||||
|
||||
@@ -112,6 +112,13 @@ InvenTree stores any persistent data (e.g. uploaded media files, database data,
|
||||
!!! info "Data Directory"
|
||||
Make sure you change the path to the local directory where you want persistent data to be stored.
|
||||
|
||||
#### Database Connection
|
||||
|
||||
The `inventree-db` container is configured to use the `postgres:13` docker image. The `inventree-server` and `inventree-worker` containers support connection to a postgres database up to (and including) version 15.
|
||||
|
||||
!!! warning "Newer Postgres Versions"
|
||||
The InvenTree docker image supports connection to a postgres database up to version 15. Connecting to a database using a newer version of postgres is not possible.
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Volume Mapping
|
||||
|
||||
@@ -153,15 +153,18 @@ nav:
|
||||
- User Settings: settings/user.md
|
||||
- Reference Patterns: settings/reference.md
|
||||
- Admin Interface: settings/admin.md
|
||||
- User Permissions: settings/permissions.md
|
||||
- Single Sign on: settings/SSO.md
|
||||
- Multi Factor Authentication: settings/MFA.md
|
||||
- Setup:
|
||||
- User Permissions: settings/permissions.md
|
||||
- Single Sign on: settings/SSO.md
|
||||
- Multi Factor Authentication: settings/MFA.md
|
||||
- Email: settings/email.md
|
||||
- Currency Support: settings/currency.md
|
||||
- Export Data: settings/export.md
|
||||
- Import Data: settings/import.md
|
||||
- Error Logs: settings/logs.md
|
||||
- Email: settings/email.md
|
||||
- Background Tasks: settings/tasks.md
|
||||
- Currency Support: settings/currency.md
|
||||
- Operations:
|
||||
- Background Tasks: settings/tasks.md
|
||||
- Error Logs: settings/logs.md
|
||||
- Error Codes: settings/error_codes.md
|
||||
- App:
|
||||
- InvenTree App: app/app.md
|
||||
- Connect: app/connect.md
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import Http404
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
|
||||
import rest_framework.exceptions
|
||||
import sentry_sdk
|
||||
@@ -29,6 +30,7 @@ def sentry_ignore_errors():
|
||||
return [
|
||||
Http404,
|
||||
MissingRate,
|
||||
TemplateSyntaxError,
|
||||
ValidationError,
|
||||
rest_framework.exceptions.AuthenticationFailed,
|
||||
rest_framework.exceptions.NotAuthenticated,
|
||||
|
||||
@@ -46,6 +46,12 @@ class InvenTreeMoneySerializer(MoneyField):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_representation(self, obj):
|
||||
"""Convert the Money object to a decimal value for representation."""
|
||||
val = super().to_representation(obj)
|
||||
|
||||
return float(val)
|
||||
|
||||
def get_value(self, data):
|
||||
"""Test that the returned amount is a valid Decimal."""
|
||||
amount = super(DecimalField, self).get_value(data)
|
||||
@@ -74,7 +80,11 @@ class InvenTreeMoneySerializer(MoneyField):
|
||||
):
|
||||
return Money(amount, currency)
|
||||
|
||||
return amount
|
||||
try:
|
||||
fp_amount = float(amount)
|
||||
return fp_amount
|
||||
except Exception:
|
||||
return amount
|
||||
|
||||
|
||||
class InvenTreeCurrencySerializer(serializers.ChoiceField):
|
||||
|
||||
@@ -18,7 +18,7 @@ from django.conf import settings
|
||||
from .api_version import INVENTREE_API_TEXT, INVENTREE_API_VERSION
|
||||
|
||||
# InvenTree software version
|
||||
INVENTREE_SW_VERSION = '0.17.4'
|
||||
INVENTREE_SW_VERSION = '0.17.8'
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@@ -165,15 +165,6 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
If a particular setting is not present, create it with the default value
|
||||
"""
|
||||
cache_key = f'BUILD_DEFAULT_VALUES:{cls.__name__!s}'
|
||||
|
||||
try:
|
||||
if InvenTree.helpers.str2bool(cache.get(cache_key, False)):
|
||||
# Already built default values
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
existing_keys = cls.objects.filter(**kwargs).values_list('key', flat=True)
|
||||
settings_keys = cls.SETTINGS.keys()
|
||||
@@ -194,11 +185,6 @@ class BaseInvenTreeSetting(models.Model):
|
||||
'Failed to build default values for %s (%s)', str(cls), str(type(exc))
|
||||
)
|
||||
|
||||
try:
|
||||
cache.set(cache_key, True, timeout=3600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _call_settings_function(self, reference: str, args, kwargs):
|
||||
"""Call a function associated with a particular setting.
|
||||
|
||||
|
||||
@@ -111,17 +111,13 @@ class DataImportSession(models.Model):
|
||||
)
|
||||
|
||||
@property
|
||||
def field_mapping(self):
|
||||
def field_mapping(self) -> dict:
|
||||
"""Construct a dict of field mappings for this import session.
|
||||
|
||||
Returns: A dict of field: column mappings
|
||||
Returns:
|
||||
A dict of field -> column mappings
|
||||
"""
|
||||
mapping = {}
|
||||
|
||||
for i in self.column_mappings.all():
|
||||
mapping[i.field] = i.column
|
||||
|
||||
return mapping
|
||||
return {mapping.field: mapping.column for mapping in self.column_mappings.all()}
|
||||
|
||||
@property
|
||||
def model_class(self):
|
||||
@@ -138,7 +134,7 @@ class DataImportSession(models.Model):
|
||||
|
||||
return supported_models().get(self.model_type, None)
|
||||
|
||||
def extract_columns(self):
|
||||
def extract_columns(self) -> None:
|
||||
"""Run initial column extraction and mapping.
|
||||
|
||||
This method is called when the import session is first created.
|
||||
@@ -204,7 +200,7 @@ class DataImportSession(models.Model):
|
||||
self.status = DataImportStatusCode.MAPPING.value
|
||||
self.save()
|
||||
|
||||
def accept_mapping(self):
|
||||
def accept_mapping(self) -> None:
|
||||
"""Accept current mapping configuration.
|
||||
|
||||
- Validate that the current column mapping is correct
|
||||
@@ -243,7 +239,7 @@ class DataImportSession(models.Model):
|
||||
# No errors, so trigger the data import process
|
||||
self.trigger_data_import()
|
||||
|
||||
def trigger_data_import(self):
|
||||
def trigger_data_import(self) -> None:
|
||||
"""Trigger the data import process for this session.
|
||||
|
||||
Offloads the task to the background worker process.
|
||||
@@ -256,7 +252,7 @@ class DataImportSession(models.Model):
|
||||
|
||||
offload_task(importer.tasks.import_data, self.pk)
|
||||
|
||||
def import_data(self):
|
||||
def import_data(self) -> None:
|
||||
"""Perform the data import process for this session."""
|
||||
# Clear any existing data rows
|
||||
self.rows.all().delete()
|
||||
@@ -316,12 +312,12 @@ class DataImportSession(models.Model):
|
||||
return True
|
||||
|
||||
@property
|
||||
def row_count(self):
|
||||
def row_count(self) -> int:
|
||||
"""Return the number of rows in the import session."""
|
||||
return self.rows.count()
|
||||
|
||||
@property
|
||||
def completed_row_count(self):
|
||||
def completed_row_count(self) -> int:
|
||||
"""Return the number of completed rows for this session."""
|
||||
return self.rows.filter(complete=True).count()
|
||||
|
||||
@@ -349,7 +345,7 @@ class DataImportSession(models.Model):
|
||||
self._available_fields = fields
|
||||
return fields
|
||||
|
||||
def required_fields(self):
|
||||
def required_fields(self) -> dict:
|
||||
"""Returns information on which fields are *required* for import."""
|
||||
fields = self.available_fields()
|
||||
|
||||
@@ -591,7 +587,7 @@ class DataImportRow(models.Model):
|
||||
value = value or None
|
||||
|
||||
# Use the default value, if provided
|
||||
if value in [None, ''] and field in default_values:
|
||||
if value is None and field in default_values:
|
||||
value = default_values[field]
|
||||
|
||||
data[field] = value
|
||||
@@ -607,7 +603,9 @@ class DataImportRow(models.Model):
|
||||
- If available, we use the "default" values provided by the import session
|
||||
- If available, we use the "override" values provided by the import session
|
||||
"""
|
||||
data = self.default_values
|
||||
data = {}
|
||||
|
||||
data.update(self.default_values)
|
||||
|
||||
if self.data:
|
||||
data.update(self.data)
|
||||
|
||||
@@ -81,23 +81,6 @@ def extract_column_names(data_file) -> list:
|
||||
return headers
|
||||
|
||||
|
||||
def extract_rows(data_file) -> list:
|
||||
"""Extract rows from the data file.
|
||||
|
||||
Each returned row is a dictionary of column_name: value pairs.
|
||||
"""
|
||||
data = load_data_file(data_file)
|
||||
|
||||
headers = data.headers
|
||||
|
||||
rows = []
|
||||
|
||||
for row in data:
|
||||
rows.append(dict(zip(headers, row)))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def get_field_label(field) -> str:
|
||||
"""Return the label for a field in a serializer class.
|
||||
|
||||
|
||||
@@ -68,9 +68,9 @@ class GeneralExtraLineList(DataExportViewMixin):
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
ordering_fields = ['quantity', 'note', 'reference']
|
||||
ordering_fields = ['quantity', 'notes', 'reference']
|
||||
|
||||
search_fields = ['quantity', 'note', 'reference', 'description']
|
||||
search_fields = ['quantity', 'notes', 'reference', 'description']
|
||||
|
||||
filterset_fields = ['order']
|
||||
|
||||
|
||||
@@ -887,7 +887,10 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
)
|
||||
|
||||
trigger_event(
|
||||
PurchaseOrderEvents.ITEM_RECEIVED, order_id=self.pk, item_id=self.pk
|
||||
PurchaseOrderEvents.ITEM_RECEIVED,
|
||||
order_id=self.pk,
|
||||
item_id=item.pk,
|
||||
line_id=line.pk,
|
||||
)
|
||||
|
||||
# Update the number of parts received against the particular line item
|
||||
|
||||
@@ -62,7 +62,6 @@ from order import models as OrderModels
|
||||
from order.status_codes import (
|
||||
PurchaseOrderStatus,
|
||||
PurchaseOrderStatusGroups,
|
||||
SalesOrderStatus,
|
||||
SalesOrderStatusGroups,
|
||||
)
|
||||
from stock import models as StockModels
|
||||
@@ -2839,6 +2838,9 @@ class PartPricing(common.models.MetaMixin):
|
||||
for sub_part in bom_item.get_valid_parts_for_allocation():
|
||||
# Check each part which *could* be used
|
||||
|
||||
if sub_part != bom_item.sub_part and not sub_part.active:
|
||||
continue
|
||||
|
||||
sub_part_pricing = sub_part.pricing
|
||||
|
||||
sub_part_min = self.convert(sub_part_pricing.overall_min)
|
||||
@@ -3134,9 +3136,12 @@ class PartPricing(common.models.MetaMixin):
|
||||
min_sell_history = None
|
||||
max_sell_history = None
|
||||
|
||||
# Calculate sale price history too
|
||||
parts = self.part.get_descendants(include_self=True)
|
||||
|
||||
# Find all line items for shipped sales orders which reference this part
|
||||
line_items = OrderModels.SalesOrderLineItem.objects.filter(
|
||||
order__status=SalesOrderStatus.SHIPPED, part=self.part
|
||||
order__status__in=SalesOrderStatusGroups.COMPLETE, part__in=parts
|
||||
)
|
||||
|
||||
# Exclude line items which do not have associated pricing data
|
||||
|
||||
@@ -654,7 +654,7 @@ class BarcodeSOAllocate(BarcodeView):
|
||||
return shipment
|
||||
|
||||
shipments = order.models.SalesOrderShipment.objects.filter(
|
||||
order=sales_order, delivery_date=None
|
||||
order=sales_order, shipment_date=None
|
||||
)
|
||||
|
||||
if shipments.count() == 1:
|
||||
|
||||
@@ -11,7 +11,11 @@ import order.models
|
||||
import plugin.base.barcodes.helper
|
||||
import stock.models
|
||||
from InvenTree.serializers import UserSerializer
|
||||
from order.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
||||
from order.status_codes import (
|
||||
PurchaseOrderStatus,
|
||||
PurchaseOrderStatusGroups,
|
||||
SalesOrderStatusGroups,
|
||||
)
|
||||
|
||||
|
||||
class BarcodeScanResultSerializer(serializers.ModelSerializer):
|
||||
@@ -135,8 +139,8 @@ class BarcodePOAllocateSerializer(BarcodeSerializer):
|
||||
|
||||
def validate_purchase_order(self, order: order.models.PurchaseOrder):
|
||||
"""Validate the provided order."""
|
||||
if order.status != PurchaseOrderStatus.PENDING.value:
|
||||
raise ValidationError(_('Purchase order is not pending'))
|
||||
if order.status not in PurchaseOrderStatusGroups.OPEN:
|
||||
raise ValidationError(_('Purchase order is not open'))
|
||||
|
||||
return order
|
||||
|
||||
@@ -213,8 +217,8 @@ class BarcodeSOAllocateSerializer(BarcodeSerializer):
|
||||
|
||||
def validate_sales_order(self, order: order.models.SalesOrder):
|
||||
"""Validate the provided order."""
|
||||
if order and order.status != SalesOrderStatus.PENDING.value:
|
||||
raise ValidationError(_('Sales order is not pending'))
|
||||
if order and order.status not in SalesOrderStatusGroups.OPEN:
|
||||
raise ValidationError(_('Sales order is not open'))
|
||||
|
||||
return order
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
from django.apps import AppConfig
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db.utils import IntegrityError, OperationalError, ProgrammingError
|
||||
|
||||
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
|
||||
@@ -55,6 +56,18 @@ class ReportConfig(AppConfig):
|
||||
|
||||
set_maintenance_mode(False)
|
||||
|
||||
def file_from_template(self, dir_name: str, file_name: str) -> ContentFile:
|
||||
"""Construct a new ContentFile from a template file."""
|
||||
logger.info('Creating %s template file: %s', dir_name, file_name)
|
||||
|
||||
return ContentFile(
|
||||
Path(__file__)
|
||||
.parent.joinpath('templates', dir_name, file_name)
|
||||
.open('r')
|
||||
.read(),
|
||||
os.path.basename(file_name),
|
||||
)
|
||||
|
||||
def create_default_labels(self):
|
||||
"""Create default label templates."""
|
||||
# Test if models are ready
|
||||
@@ -106,29 +119,25 @@ class ReportConfig(AppConfig):
|
||||
]
|
||||
|
||||
for template in label_templates:
|
||||
# Ignore matching templates which are already in the database
|
||||
if report.models.LabelTemplate.objects.filter(
|
||||
name=template['name']
|
||||
).exists():
|
||||
continue
|
||||
|
||||
filename = template.pop('file')
|
||||
|
||||
template_file = Path(__file__).parent.joinpath(
|
||||
'templates', 'label', filename
|
||||
)
|
||||
|
||||
if not template_file.exists():
|
||||
logger.warning("Missing template file: '%s'", template['name'])
|
||||
# Template already exists in the database - check that the file exists too
|
||||
if existing_template := report.models.LabelTemplate.objects.filter(
|
||||
name=template['name'], model_type=template['model_type']
|
||||
).first():
|
||||
if not default_storage.exists(existing_template.template.name):
|
||||
# The file does not exist in the storage system - add it in
|
||||
existing_template.template = self.file_from_template(
|
||||
'label', filename
|
||||
)
|
||||
existing_template.save()
|
||||
continue
|
||||
|
||||
# Read the existing template file
|
||||
data = template_file.open('r').read()
|
||||
|
||||
# Otherwise, create a new entry
|
||||
try:
|
||||
# Create a new entry
|
||||
report.models.LabelTemplate.objects.create(
|
||||
**template, template=ContentFile(data, os.path.basename(filename))
|
||||
**template, template=self.file_from_template('label', filename)
|
||||
)
|
||||
logger.info("Creating new label template: '%s'", template['name'])
|
||||
except Exception:
|
||||
@@ -202,29 +211,24 @@ class ReportConfig(AppConfig):
|
||||
]
|
||||
|
||||
for template in report_templates:
|
||||
# Ignore matching templates which are already in the database
|
||||
if report.models.ReportTemplate.objects.filter(
|
||||
name=template['name']
|
||||
).exists():
|
||||
continue
|
||||
|
||||
filename = template.pop('file')
|
||||
|
||||
template_file = Path(__file__).parent.joinpath(
|
||||
'templates', 'report', filename
|
||||
)
|
||||
|
||||
if not template_file.exists():
|
||||
logger.warning("Missing template file: '%s'", template['name'])
|
||||
# Template already exists in the database - check that the file exists too
|
||||
if existing_template := report.models.ReportTemplate.objects.filter(
|
||||
name=template['name'], model_type=template['model_type']
|
||||
).first():
|
||||
if not default_storage.exists(existing_template.template.name):
|
||||
# The file does not exist in the storage system - add it in
|
||||
existing_template.template = self.file_from_template(
|
||||
'report', filename
|
||||
)
|
||||
existing_template.save()
|
||||
continue
|
||||
|
||||
# Read the existing template file
|
||||
data = template_file.open('r').read()
|
||||
|
||||
# Create a new entry
|
||||
# Otherwise, create a new entry
|
||||
try:
|
||||
report.models.ReportTemplate.objects.create(
|
||||
**template, template=ContentFile(data, os.path.basename(filename))
|
||||
**template, template=self.file_from_template('report', filename)
|
||||
)
|
||||
logger.info("Created new report template: '%s'", template['name'])
|
||||
except Exception:
|
||||
|
||||
@@ -1461,7 +1461,7 @@ class StockTrackingList(DataExportViewMixin, ListAPI):
|
||||
|
||||
ordering_fields = ['date']
|
||||
|
||||
search_fields = ['title', 'notes']
|
||||
search_fields = ['notes']
|
||||
|
||||
|
||||
class LocationDetail(CustomRetrieveUpdateDestroyAPI):
|
||||
|
||||
@@ -1487,13 +1487,13 @@ class StockItemTest(StockAPITestCase):
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
# Check fixture values
|
||||
self.assertEqual(data['purchase_price'], '123.000000')
|
||||
self.assertAlmostEqual(data['purchase_price'], 123, 3)
|
||||
self.assertEqual(data['purchase_price_currency'], 'AUD')
|
||||
|
||||
# Update just the amount
|
||||
data = self.patch(url, {'purchase_price': 456}, expected_code=200).data
|
||||
|
||||
self.assertEqual(data['purchase_price'], '456.000000')
|
||||
self.assertAlmostEqual(data['purchase_price'], 456, 3)
|
||||
self.assertEqual(data['purchase_price_currency'], 'AUD')
|
||||
|
||||
# Update the currency
|
||||
@@ -2150,6 +2150,11 @@ class StockTrackingTest(StockAPITestCase):
|
||||
response = self.get(url, {'limit': 1})
|
||||
self.assertEqual(response.data['count'], N)
|
||||
|
||||
# Test with search and pagination
|
||||
response = self.get(url, {'limit': 1, 'offset': 10, 'search': 'berries'})
|
||||
|
||||
self.assertEqual(response.data['count'], 0)
|
||||
|
||||
def test_list(self):
|
||||
"""Test list endpoint."""
|
||||
url = self.get_url()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% load spa_helper %}
|
||||
{% load inventree_extras %}
|
||||
{% spa_bundle as bundle %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -12,8 +13,17 @@
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
{% spa_settings %}
|
||||
{% spa_bundle %}
|
||||
<div id="spa_settings">{% spa_settings %}</div>
|
||||
{% if bundle == "NOT_FOUND" %}
|
||||
<div id="spa_bundle_error">
|
||||
<div>
|
||||
<h1>INVE-E1 - No frontend included</h1>
|
||||
<p>The frontend bundle could not be found. Please check that your deployment method includes the bundle or check the <a href="https://docs.inventree.org/en/stable/faq/">FAQ</a>.<br/>
|
||||
<span>Install method: <code>{% inventree_installer %}</code></span></p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="spa_bundle">{{ bundle }}</div>
|
||||
{% endif %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -37,13 +37,13 @@ def spa_bundle(manifest_path: Union[str, Path] = '', app: str = 'web'):
|
||||
# Final check - fail if manifest file not found
|
||||
if not manifest.exists():
|
||||
logger.error('Manifest file not found')
|
||||
return
|
||||
return 'NOT_FOUND'
|
||||
|
||||
try:
|
||||
manifest_data = json.load(manifest.open())
|
||||
except (TypeError, json.decoder.JSONDecodeError):
|
||||
logger.exception('Failed to parse manifest file')
|
||||
return
|
||||
return ''
|
||||
|
||||
return_string = ''
|
||||
# JS (based on index.html file as entrypoint)
|
||||
|
||||
@@ -26,9 +26,8 @@ class TemplateTagTest(InvenTreeTestCase):
|
||||
def test_spa_bundle(self):
|
||||
"""Test the 'spa_bundle' template tag."""
|
||||
resp = spa_helper.spa_bundle()
|
||||
if not resp:
|
||||
if resp == 'NOT_FOUND':
|
||||
# No Vite, no test
|
||||
# TODO: Add a test for the non-Vite case (docker)
|
||||
return # pragma: no cover
|
||||
|
||||
shipped_js = resp.split('<script type="module" src="')[1:]
|
||||
@@ -41,7 +40,7 @@ class TemplateTagTest(InvenTreeTestCase):
|
||||
manifest_file.with_suffix('.json.bak')
|
||||
) # Rename
|
||||
resp = spa_helper.spa_bundle()
|
||||
self.assertIsNone(resp)
|
||||
self.assertEqual(resp, 'NOT_FOUND')
|
||||
|
||||
# Try with differing name
|
||||
resp = spa_helper.spa_bundle(new_name)
|
||||
@@ -50,7 +49,7 @@ class TemplateTagTest(InvenTreeTestCase):
|
||||
# Broken manifest file
|
||||
manifest_file.write_text('broken')
|
||||
resp = spa_helper.spa_bundle(manifest_file)
|
||||
self.assertIsNone(resp)
|
||||
self.assertEqual(resp, '')
|
||||
|
||||
new_name.rename(manifest_file.with_suffix('.json')) # Name back
|
||||
|
||||
@@ -88,11 +87,6 @@ class TemplateTagTest(InvenTreeTestCase):
|
||||
self.assertNotIn('show_server_selector', rsp)
|
||||
self.assertEqual(rsp['server_list'], ['aa', 'bb'])
|
||||
|
||||
def test_redirects(self):
|
||||
"""Test the redirect helper."""
|
||||
response = self.client.get('/assets/testpath')
|
||||
self.assertEqual(response.url, '/static/web/assets/testpath')
|
||||
|
||||
|
||||
class TestWebHelpers(InvenTreeAPITestCase):
|
||||
"""Tests for the web helpers."""
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import include, path, re_path
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.generic import TemplateView
|
||||
@@ -12,16 +11,6 @@ from rest_framework import permissions, serializers
|
||||
from InvenTree.mixins import RetrieveUpdateAPI
|
||||
|
||||
|
||||
class RedirectAssetView(TemplateView):
|
||||
"""View to redirect to static asset."""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Redirect to static asset."""
|
||||
return redirect(
|
||||
f'{settings.STATIC_URL}web/assets/{kwargs["path"]}', permanent=True
|
||||
)
|
||||
|
||||
|
||||
class PreferredSerializer(serializers.Serializer):
|
||||
"""Serializer for the preferred serializer session setting."""
|
||||
|
||||
@@ -72,14 +61,12 @@ class PreferredUiView(RetrieveUpdateAPI):
|
||||
|
||||
|
||||
spa_view = ensure_csrf_cookie(TemplateView.as_view(template_name='web/index.html'))
|
||||
assets_path = path('assets/<path:path>', RedirectAssetView.as_view())
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
f'{settings.FRONTEND_URL_BASE}/',
|
||||
include([
|
||||
assets_path,
|
||||
path(
|
||||
'set-password?uid=<uid>&token=<token>',
|
||||
spa_view,
|
||||
@@ -88,7 +75,6 @@ urlpatterns = [
|
||||
re_path('.*', spa_view),
|
||||
]),
|
||||
),
|
||||
assets_path,
|
||||
path(settings.FRONTEND_URL_BASE, spa_view, name='platform'),
|
||||
]
|
||||
|
||||
|
||||
@@ -83,6 +83,26 @@ export function getStatusCodes(type: ModelType | string) {
|
||||
return statusCodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of status codes select options for a given model type
|
||||
* returns an array of objects with keys "value" and "display_name"
|
||||
*
|
||||
*/
|
||||
export function getStatusCodeOptions(type: ModelType | string): any[] {
|
||||
const statusCodes = getStatusCodes(type);
|
||||
|
||||
if (!statusCodes) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.values(statusCodes?.values ?? []).map((entry) => {
|
||||
return {
|
||||
value: entry.key,
|
||||
display_name: entry.label
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Return the name of a status code, based on the key
|
||||
*/
|
||||
|
||||
@@ -22,10 +22,7 @@ import {
|
||||
IconUser,
|
||||
IconUsers
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../App';
|
||||
import { ActionButton } from '../components/buttons/ActionButton';
|
||||
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||
import { StandaloneField } from '../components/forms/StandaloneField';
|
||||
@@ -40,6 +37,7 @@ import {
|
||||
import { Thumbnail } from '../components/images/Thumbnail';
|
||||
import { ProgressBar } from '../components/items/ProgressBar';
|
||||
import { StylishText } from '../components/items/StylishText';
|
||||
import { getStatusCodeOptions } from '../components/render/StatusRenderer';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { ModelType } from '../enums/ModelType';
|
||||
import { InvenTreeIcon } from '../functions/icons';
|
||||
@@ -291,10 +289,14 @@ function LineItemFormRow({
|
||||
order: record?.order
|
||||
});
|
||||
// Generate new serial numbers
|
||||
serialNumberGenerator.update({
|
||||
part: record?.supplier_part_detail?.part,
|
||||
quantity: props.item.quantity
|
||||
});
|
||||
if (trackable) {
|
||||
serialNumberGenerator.update({
|
||||
part: record?.supplier_part_detail?.part,
|
||||
quantity: props.item.quantity
|
||||
});
|
||||
} else {
|
||||
props.changeFn(props.idx, 'serial_numbers', undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -564,7 +566,10 @@ function LineItemFormRow({
|
||||
)}
|
||||
<TableFieldExtraRow
|
||||
visible={batchOpen}
|
||||
onValueChange={(value) => props.changeFn(props.idx, 'batch', value)}
|
||||
onValueChange={(value) => {
|
||||
props.changeFn(props.idx, 'batch_code', value);
|
||||
}}
|
||||
fieldName='batch_code'
|
||||
fieldDefinition={{
|
||||
field_type: 'string',
|
||||
label: t`Batch Code`,
|
||||
@@ -578,6 +583,7 @@ function LineItemFormRow({
|
||||
onValueChange={(value) =>
|
||||
props.changeFn(props.idx, 'serial_numbers', value)
|
||||
}
|
||||
fieldName='serial_numbers'
|
||||
fieldDefinition={{
|
||||
field_type: 'string',
|
||||
label: t`Serial Numbers`,
|
||||
@@ -589,6 +595,7 @@ function LineItemFormRow({
|
||||
<TableFieldExtraRow
|
||||
visible={packagingOpen}
|
||||
onValueChange={(value) => props.changeFn(props.idx, 'packaging', value)}
|
||||
fieldName='packaging'
|
||||
fieldDefinition={{
|
||||
field_type: 'string',
|
||||
label: t`Packaging`
|
||||
@@ -599,6 +606,7 @@ function LineItemFormRow({
|
||||
<TableFieldExtraRow
|
||||
visible={statusOpen}
|
||||
defaultValue={10}
|
||||
fieldName='status'
|
||||
onValueChange={(value) => props.changeFn(props.idx, 'status', value)}
|
||||
fieldDefinition={{
|
||||
field_type: 'choice',
|
||||
@@ -610,6 +618,7 @@ function LineItemFormRow({
|
||||
/>
|
||||
<TableFieldExtraRow
|
||||
visible={noteOpen}
|
||||
fieldName='note'
|
||||
onValueChange={(value) => props.changeFn(props.idx, 'note', value)}
|
||||
fieldDefinition={{
|
||||
field_type: 'string',
|
||||
@@ -634,23 +643,10 @@ type LineItemsForm = {
|
||||
};
|
||||
|
||||
export function useReceiveLineItems(props: LineItemsForm) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['stock', 'status'],
|
||||
queryFn: async () => {
|
||||
return api.get(apiUrl(ApiEndpoints.stock_status)).then((response) => {
|
||||
if (response.status === 200) {
|
||||
const entries = Object.values(response.data.values);
|
||||
const mapped = entries.map((item: any) => {
|
||||
return {
|
||||
value: item.key,
|
||||
display_name: item.label
|
||||
};
|
||||
});
|
||||
return mapped;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
const stockStatusCodes = useMemo(
|
||||
() => getStatusCodeOptions(ModelType.stockitem),
|
||||
[]
|
||||
);
|
||||
|
||||
const records = Object.fromEntries(
|
||||
props.items.map((item) => [item.pk, item])
|
||||
@@ -660,44 +656,46 @@ export function useReceiveLineItems(props: LineItemsForm) {
|
||||
(elem) => elem.quantity !== elem.received
|
||||
);
|
||||
|
||||
const fields: ApiFormFieldSet = {
|
||||
id: {
|
||||
value: props.orderPk,
|
||||
hidden: true
|
||||
},
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: filteredItems.map((elem, idx) => {
|
||||
return {
|
||||
line_item: elem.pk,
|
||||
location: elem.destination ?? elem.destination_detail?.pk ?? null,
|
||||
quantity: elem.quantity - elem.received,
|
||||
batch_code: '',
|
||||
serial_numbers: '',
|
||||
status: 10,
|
||||
barcode: null
|
||||
};
|
||||
}),
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record = records[row.item.line_item];
|
||||
|
||||
return (
|
||||
<LineItemFormRow
|
||||
props={row}
|
||||
record={record}
|
||||
statuses={data}
|
||||
key={record.pk}
|
||||
/>
|
||||
);
|
||||
const fields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
id: {
|
||||
value: props.orderPk,
|
||||
hidden: true
|
||||
},
|
||||
headers: [t`Part`, t`SKU`, t`Received`, t`Quantity`, t`Actions`]
|
||||
},
|
||||
location: {
|
||||
filters: {
|
||||
structural: false
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: filteredItems.map((elem, idx) => {
|
||||
return {
|
||||
line_item: elem.pk,
|
||||
location: elem.destination ?? elem.destination_detail?.pk ?? null,
|
||||
quantity: elem.quantity - elem.received,
|
||||
batch_code: '',
|
||||
serial_numbers: '',
|
||||
status: 10,
|
||||
barcode: null
|
||||
};
|
||||
}),
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record = records[row.item.line_item];
|
||||
|
||||
return (
|
||||
<LineItemFormRow
|
||||
props={row}
|
||||
record={record}
|
||||
statuses={stockStatusCodes}
|
||||
key={record.pk}
|
||||
/>
|
||||
);
|
||||
},
|
||||
headers: [t`Part`, t`SKU`, t`Received`, t`Quantity`, t`Actions`]
|
||||
},
|
||||
location: {
|
||||
filters: {
|
||||
structural: false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}, [filteredItems, props, stockStatusCodes]);
|
||||
|
||||
return useCreateApiFormModal({
|
||||
...props.formProps,
|
||||
@@ -707,6 +705,7 @@ export function useReceiveLineItems(props: LineItemsForm) {
|
||||
initialData: {
|
||||
location: props.destinationPk
|
||||
},
|
||||
size: '80%'
|
||||
size: '80%',
|
||||
successMessage: t`Items received`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@ function StockItemDefaultMove({
|
||||
/>
|
||||
</Flex>
|
||||
<Flex direction='column' gap='sm' align='center'>
|
||||
<Text>{stockItem.location_detail.pathstring}</Text>
|
||||
<Text>{stockItem.location_detail?.pathstring ?? '-'}</Text>
|
||||
<InvenTreeIcon icon='arrow_down' />
|
||||
<Suspense fallback={<Skeleton width='150px' />}>
|
||||
<Text>{data?.pathstring}</Text>
|
||||
|
||||
@@ -144,7 +144,10 @@ export function useTable(tableName: string): TableState {
|
||||
|
||||
// Pagination data
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [pageSize, setPageSize] = useState<number>(25);
|
||||
const [pageSize, setPageSize] = useLocalStorage<number>({
|
||||
key: 'inventree-table-page-size',
|
||||
defaultValue: 25
|
||||
});
|
||||
|
||||
// A list of hidden columns, saved to local storage
|
||||
const [hiddenColumns, setHiddenColumns] = useLocalStorage<string[]>({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Card, Container, Group, Loader, Stack, Text } from '@mantine/core';
|
||||
import { useDebouncedCallback } from '@mantine/hooks';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -9,8 +10,10 @@ export default function Logged_In() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const checkLoginStateDebounced = useDebouncedCallback(checkLoginState, 300);
|
||||
|
||||
useEffect(() => {
|
||||
checkLoginState(navigate, location?.state);
|
||||
checkLoginStateDebounced(navigate, location?.state);
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -362,14 +362,20 @@ export default function BuildDetail() {
|
||||
onFormSuccess: refreshInstance
|
||||
});
|
||||
|
||||
const duplicateBuildOrderInitialData = useMemo(() => {
|
||||
const data = { ...build };
|
||||
// if we set the reference to null/undefined, it will be left blank in the form
|
||||
// if we omit the reference altogether, it will be auto-generated via reference pattern
|
||||
// from the OPTIONS response
|
||||
delete data.reference;
|
||||
return data;
|
||||
}, [build]);
|
||||
|
||||
const duplicateBuild = useCreateApiFormModal({
|
||||
url: ApiEndpoints.build_order_list,
|
||||
title: t`Add Build Order`,
|
||||
fields: buildOrderFields,
|
||||
initialData: {
|
||||
...build,
|
||||
reference: undefined
|
||||
},
|
||||
initialData: duplicateBuildOrderInitialData,
|
||||
follow: true,
|
||||
modelType: ModelType.build
|
||||
});
|
||||
|
||||
@@ -277,7 +277,7 @@ export default function SupplierPartDetail() {
|
||||
label: t`Supplier Pricing`,
|
||||
icon: <IconCurrencyDollar />,
|
||||
content: supplierPart?.pk ? (
|
||||
<SupplierPriceBreakTable supplierPartId={supplierPart.pk} />
|
||||
<SupplierPriceBreakTable supplierPart={supplierPart} />
|
||||
) : (
|
||||
<Skeleton />
|
||||
)
|
||||
|
||||
@@ -107,18 +107,19 @@ export default function PartStocktakeDetail({
|
||||
return [
|
||||
{
|
||||
accessor: 'quantity',
|
||||
sortable: true,
|
||||
sortable: false,
|
||||
switchable: false
|
||||
},
|
||||
{
|
||||
accessor: 'item_count',
|
||||
title: t`Stock Items`,
|
||||
switchable: true,
|
||||
sortable: true
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
accessor: 'cost',
|
||||
title: t`Stock Value`,
|
||||
sortable: false,
|
||||
render: (record: any) => {
|
||||
return formatPriceRange(record.cost_min, record.cost_max, {
|
||||
currency: record.cost_min_currency
|
||||
@@ -127,10 +128,11 @@ export default function PartStocktakeDetail({
|
||||
},
|
||||
{
|
||||
accessor: 'date',
|
||||
sortable: true
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
accessor: 'note'
|
||||
accessor: 'note',
|
||||
sortable: false
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
@@ -174,17 +176,15 @@ export default function PartStocktakeDetail({
|
||||
return {
|
||||
date: new Date(record.date).valueOf(),
|
||||
quantity: record.quantity,
|
||||
value_min: record.cost_min,
|
||||
value_max: record.cost_max
|
||||
value_min: Number.parseFloat(record.cost_min),
|
||||
value_max: Number.parseFloat(record.cost_max)
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
// Sort records to ensure correct date order
|
||||
records.sort((a, b) => {
|
||||
return records.sort((a, b) => {
|
||||
return a < b ? -1 : 1;
|
||||
});
|
||||
|
||||
return records;
|
||||
}, [table.records]);
|
||||
|
||||
// Calculate the date limits of the chart
|
||||
@@ -216,7 +216,8 @@ export default function PartStocktakeDetail({
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
params: {
|
||||
part: partId
|
||||
part: partId,
|
||||
ordering: 'date'
|
||||
},
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions
|
||||
@@ -241,6 +242,12 @@ export default function PartStocktakeDetail({
|
||||
<ChartTooltip label={label} payload={payload} />
|
||||
)
|
||||
}}
|
||||
yAxisProps={{
|
||||
allowDataOverflow: false
|
||||
}}
|
||||
rightYAxisProps={{
|
||||
allowDataOverflow: false
|
||||
}}
|
||||
xAxisProps={{
|
||||
scale: 'time',
|
||||
type: 'number',
|
||||
|
||||
@@ -68,7 +68,7 @@ function BomPieChart({
|
||||
return {
|
||||
// Note: Replace '.' in name to avoid issues with tooltip
|
||||
name: entry?.name?.replace('.', '') ?? '',
|
||||
value: entry?.total_price_max,
|
||||
value: Number.parseFloat(entry?.total_price_max),
|
||||
color: `${CHART_COLORS[index % CHART_COLORS.length]}.5`
|
||||
};
|
||||
}) ?? []
|
||||
|
||||
@@ -26,6 +26,7 @@ import { type ReactNode, useCallback, useMemo } from 'react';
|
||||
|
||||
import { api } from '../../../App';
|
||||
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
|
||||
import type { ApiFormFieldSet } from '../../../components/forms/fields/ApiFormField';
|
||||
import {
|
||||
EditItemAction,
|
||||
OptionsActionDropdown
|
||||
@@ -35,12 +36,14 @@ import { ApiEndpoints } from '../../../enums/ApiEndpoints';
|
||||
import { InvenTreeIcon } from '../../../functions/icons';
|
||||
import { useEditApiFormModal } from '../../../hooks/UseForm';
|
||||
import { apiUrl } from '../../../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../../../states/SettingsState';
|
||||
import { panelOptions } from '../PartPricingPanel';
|
||||
|
||||
interface PricingOverviewEntry {
|
||||
icon: ReactNode;
|
||||
name: panelOptions;
|
||||
title: string;
|
||||
valid: boolean;
|
||||
min_value: number | null | undefined;
|
||||
max_value: number | null | undefined;
|
||||
visible?: boolean;
|
||||
@@ -58,6 +61,8 @@ export default function PricingOverviewPanel({
|
||||
pricingQuery: UseQueryResult;
|
||||
doNavigation: (panel: panelOptions) => void;
|
||||
}>): ReactNode {
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const refreshPricing = useCallback(() => {
|
||||
const url = apiUrl(ApiEndpoints.part_pricing, part.pk);
|
||||
|
||||
@@ -99,19 +104,29 @@ export default function PricingOverviewPanel({
|
||||
});
|
||||
}, [part]);
|
||||
|
||||
const editPricing = useEditApiFormModal({
|
||||
title: t`Edit Pricing`,
|
||||
url: apiUrl(ApiEndpoints.part_pricing, part.pk),
|
||||
fields: {
|
||||
const pricingFields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
override_min: {},
|
||||
override_min_currency: {},
|
||||
override_min_currency: {
|
||||
default:
|
||||
globalSettings.getSetting('INVENTREE_DEFAULT_CURRENCY') ?? 'USD'
|
||||
},
|
||||
override_max: {},
|
||||
override_max_currency: {},
|
||||
override_max_currency: {
|
||||
default:
|
||||
globalSettings.getSetting('INVENTREE_DEFAULT_CURRENCY') ?? 'USD'
|
||||
},
|
||||
update: {
|
||||
hidden: true,
|
||||
value: true
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [globalSettings]);
|
||||
|
||||
const editPricing = useEditApiFormModal({
|
||||
title: t`Edit Pricing`,
|
||||
url: apiUrl(ApiEndpoints.part_pricing, part.pk),
|
||||
fields: pricingFields,
|
||||
onFormSuccess: () => {
|
||||
pricingQuery.refetch();
|
||||
}
|
||||
@@ -168,71 +183,89 @@ export default function PricingOverviewPanel({
|
||||
|
||||
const overviewData: PricingOverviewEntry[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: panelOptions.internal,
|
||||
title: t`Internal Pricing`,
|
||||
icon: <IconList />,
|
||||
min_value: pricing?.internal_cost_min,
|
||||
max_value: pricing?.internal_cost_max
|
||||
},
|
||||
{
|
||||
name: panelOptions.bom,
|
||||
title: t`BOM Pricing`,
|
||||
icon: <IconChartDonut />,
|
||||
min_value: pricing?.bom_cost_min,
|
||||
max_value: pricing?.bom_cost_max
|
||||
},
|
||||
{
|
||||
name: panelOptions.purchase,
|
||||
title: t`Purchase Pricing`,
|
||||
icon: <IconShoppingCart />,
|
||||
min_value: pricing?.purchase_cost_min,
|
||||
max_value: pricing?.purchase_cost_max
|
||||
},
|
||||
{
|
||||
name: panelOptions.supplier,
|
||||
title: t`Supplier Pricing`,
|
||||
icon: <IconBuildingWarehouse />,
|
||||
min_value: pricing?.supplier_price_min,
|
||||
max_value: pricing?.supplier_price_max
|
||||
},
|
||||
{
|
||||
name: panelOptions.variant,
|
||||
title: t`Variant Pricing`,
|
||||
icon: <IconTriangleSquareCircle />,
|
||||
min_value: pricing?.variant_cost_min,
|
||||
max_value: pricing?.variant_cost_max
|
||||
},
|
||||
{
|
||||
name: panelOptions.sale_pricing,
|
||||
title: t`Sale Pricing`,
|
||||
icon: <IconTriangleSquareCircle />,
|
||||
min_value: pricing?.sale_price_min,
|
||||
max_value: pricing?.sale_price_max
|
||||
},
|
||||
{
|
||||
name: panelOptions.sale_history,
|
||||
title: t`Sale History`,
|
||||
icon: <IconTriangleSquareCircle />,
|
||||
min_value: pricing?.sale_history_min,
|
||||
max_value: pricing?.sale_history_max
|
||||
},
|
||||
{
|
||||
name: panelOptions.override,
|
||||
title: t`Override Pricing`,
|
||||
icon: <IconExclamationCircle />,
|
||||
min_value: pricing?.override_min,
|
||||
max_value: pricing?.override_max
|
||||
min_value: Number.parseFloat(pricing?.override_min),
|
||||
max_value: Number.parseFloat(pricing?.override_max),
|
||||
valid: pricing?.override_min != null && pricing?.override_max != null
|
||||
},
|
||||
{
|
||||
name: panelOptions.overall,
|
||||
title: t`Overall Pricing`,
|
||||
icon: <IconReportAnalytics />,
|
||||
min_value: pricing?.overall_min,
|
||||
max_value: pricing?.overall_max
|
||||
min_value: Number.parseFloat(pricing?.overall_min),
|
||||
max_value: Number.parseFloat(pricing?.overall_max),
|
||||
valid: pricing?.overall_min != null && pricing?.overall_max != null
|
||||
},
|
||||
{
|
||||
name: panelOptions.internal,
|
||||
title: t`Internal Pricing`,
|
||||
icon: <IconList />,
|
||||
min_value: Number.parseFloat(pricing?.internal_cost_min),
|
||||
max_value: Number.parseFloat(pricing?.internal_cost_max),
|
||||
valid:
|
||||
pricing?.internal_cost_min != null &&
|
||||
pricing?.internal_cost_max != null
|
||||
},
|
||||
{
|
||||
name: panelOptions.bom,
|
||||
title: t`BOM Pricing`,
|
||||
icon: <IconChartDonut />,
|
||||
min_value: Number.parseFloat(pricing?.bom_cost_min),
|
||||
max_value: Number.parseFloat(pricing?.bom_cost_max),
|
||||
valid: pricing?.bom_cost_min != null && pricing?.bom_cost_max != null
|
||||
},
|
||||
{
|
||||
name: panelOptions.purchase,
|
||||
title: t`Purchase Pricing`,
|
||||
icon: <IconShoppingCart />,
|
||||
min_value: Number.parseFloat(pricing?.purchase_cost_min),
|
||||
max_value: Number.parseFloat(pricing?.purchase_cost_max),
|
||||
valid:
|
||||
pricing?.purchase_cost_min != null &&
|
||||
pricing?.purchase_cost_max != null
|
||||
},
|
||||
{
|
||||
name: panelOptions.supplier,
|
||||
title: t`Supplier Pricing`,
|
||||
icon: <IconBuildingWarehouse />,
|
||||
min_value: Number.parseFloat(pricing?.supplier_price_min),
|
||||
max_value: Number.parseFloat(pricing?.supplier_price_max),
|
||||
valid:
|
||||
pricing?.supplier_price_min != null &&
|
||||
pricing?.supplier_price_max != null
|
||||
},
|
||||
{
|
||||
name: panelOptions.variant,
|
||||
title: t`Variant Pricing`,
|
||||
icon: <IconTriangleSquareCircle />,
|
||||
min_value: Number.parseFloat(pricing?.variant_cost_min),
|
||||
max_value: Number.parseFloat(pricing?.variant_cost_max),
|
||||
valid:
|
||||
pricing?.variant_cost_min != null && pricing?.variant_cost_max != null
|
||||
},
|
||||
{
|
||||
name: panelOptions.sale_pricing,
|
||||
title: t`Sale Pricing`,
|
||||
icon: <IconTriangleSquareCircle />,
|
||||
min_value: Number.parseFloat(pricing?.sale_price_min),
|
||||
max_value: Number.parseFloat(pricing?.sale_price_max),
|
||||
valid:
|
||||
pricing?.sale_price_min != null && pricing?.sale_price_max != null
|
||||
},
|
||||
{
|
||||
name: panelOptions.sale_history,
|
||||
title: t`Sale History`,
|
||||
icon: <IconTriangleSquareCircle />,
|
||||
min_value: Number.parseFloat(pricing?.sale_history_min),
|
||||
max_value: Number.parseFloat(pricing?.sale_history_max),
|
||||
valid:
|
||||
pricing?.sale_history_min != null && pricing?.sale_history_max != null
|
||||
}
|
||||
].filter((entry) => {
|
||||
return !(entry.min_value == null || entry.max_value == null);
|
||||
return entry.valid;
|
||||
});
|
||||
}, [part, pricing]);
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function PurchaseHistoryPanel({
|
||||
const calculateUnitPrice = useCallback((record: any) => {
|
||||
const pack_quantity =
|
||||
record?.supplier_part_detail?.pack_quantity_native ?? 1;
|
||||
const unit_price = record.purchase_price / pack_quantity;
|
||||
const unit_price = Number.parseFloat(record.purchase_price) / pack_quantity;
|
||||
|
||||
return unit_price;
|
||||
}, []);
|
||||
@@ -95,7 +95,7 @@ export default function PurchaseHistoryPanel({
|
||||
return table.records.map((record: any) => {
|
||||
return {
|
||||
quantity: record.quantity,
|
||||
purchase_price: record.purchase_price,
|
||||
purchase_price: Number.parseFloat(record.purchase_price),
|
||||
unit_price: calculateUnitPrice(record),
|
||||
name: record.order_detail.reference
|
||||
};
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function SaleHistoryPanel({
|
||||
return table.records.map((record: any) => {
|
||||
return {
|
||||
name: record.order_detail.reference,
|
||||
sale_price: record.sale_price
|
||||
sale_price: Number.parseFloat(record.sale_price)
|
||||
};
|
||||
});
|
||||
}, [table.records]);
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function SupplierPricingPanel({
|
||||
table.records?.map((record: any) => {
|
||||
return {
|
||||
quantity: record.quantity,
|
||||
supplier_price: record.price,
|
||||
supplier_price: Number.parseFloat(record.price),
|
||||
unit_price: calculateSupplierPartUnitPrice(record),
|
||||
name: record.part_detail?.SKU
|
||||
};
|
||||
|
||||
@@ -63,8 +63,10 @@ export default function VariantPricingPanel({
|
||||
return {
|
||||
part: variant,
|
||||
name: variant.full_name,
|
||||
pmin: variant.pricing_min ?? variant.pricing_max ?? 0,
|
||||
pmax: variant.pricing_max ?? variant.pricing_min ?? 0
|
||||
pmin: Number.parseFloat(
|
||||
variant.pricing_min ?? variant.pricing_max ?? 0
|
||||
),
|
||||
pmax: Number.parseFloat(variant.pricing_max ?? variant.pricing_min ?? 0)
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -94,14 +94,20 @@ export default function PurchaseOrderDetail() {
|
||||
}
|
||||
});
|
||||
|
||||
const duplicatePurchaseOrderInitialData = useMemo(() => {
|
||||
const data = { ...order };
|
||||
// if we set the reference to null/undefined, it will be left blank in the form
|
||||
// if we omit the reference altogether, it will be auto-generated via reference pattern
|
||||
// from the OPTIONS response
|
||||
delete data.reference;
|
||||
return data;
|
||||
}, [order]);
|
||||
|
||||
const duplicatePurchaseOrder = useCreateApiFormModal({
|
||||
url: ApiEndpoints.purchase_order_list,
|
||||
title: t`Add Purchase Order`,
|
||||
fields: duplicatePurchaseOrderFields,
|
||||
initialData: {
|
||||
...order,
|
||||
reference: undefined
|
||||
},
|
||||
initialData: duplicatePurchaseOrderInitialData,
|
||||
follow: true,
|
||||
modelType: ModelType.purchaseorder
|
||||
});
|
||||
|
||||
@@ -329,14 +329,20 @@ export default function ReturnOrderDetail() {
|
||||
}
|
||||
});
|
||||
|
||||
const duplicateReturnOrderInitialData = useMemo(() => {
|
||||
const data = { ...order };
|
||||
// if we set the reference to null/undefined, it will be left blank in the form
|
||||
// if we omit the reference altogether, it will be auto-generated via reference pattern
|
||||
// from the OPTIONS response
|
||||
delete data.reference;
|
||||
return data;
|
||||
}, [order]);
|
||||
|
||||
const duplicateReturnOrder = useCreateApiFormModal({
|
||||
url: ApiEndpoints.return_order_list,
|
||||
title: t`Add Return Order`,
|
||||
fields: duplicateReturnOrderFields,
|
||||
initialData: {
|
||||
...order,
|
||||
reference: undefined
|
||||
},
|
||||
initialData: duplicateReturnOrderInitialData,
|
||||
modelType: ModelType.returnorder,
|
||||
follow: true
|
||||
});
|
||||
|
||||
@@ -272,14 +272,20 @@ export default function SalesOrderDetail() {
|
||||
duplicateOrderId: order.pk
|
||||
});
|
||||
|
||||
const duplicateSalesOrderInitialData = useMemo(() => {
|
||||
const data = { ...order };
|
||||
// if we set the reference to null/undefined, it will be left blank in the form
|
||||
// if we omit the reference altogether, it will be auto-generated via reference pattern
|
||||
// from the OPTIONS response
|
||||
delete data.reference;
|
||||
return data;
|
||||
}, [order]);
|
||||
|
||||
const duplicateSalesOrder = useCreateApiFormModal({
|
||||
url: ApiEndpoints.sales_order_list,
|
||||
title: t`Add Sales Order`,
|
||||
fields: duplicateOrderFields,
|
||||
initialData: {
|
||||
...order,
|
||||
reference: undefined
|
||||
},
|
||||
initialData: duplicateSalesOrderInitialData,
|
||||
follow: true,
|
||||
modelType: ModelType.salesorder
|
||||
});
|
||||
|
||||
@@ -65,11 +65,14 @@ export function LocationColumn(props: TableColumnProps): TableColumn {
|
||||
|
||||
if (!location) {
|
||||
return (
|
||||
<Text style={{ fontStyle: 'italic' }}>{t`No location set`}</Text>
|
||||
<Text
|
||||
size='sm'
|
||||
style={{ fontStyle: 'italic' }}
|
||||
>{t`No location set`}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return <Text>{location.name}</Text>;
|
||||
return <Text size='sm'>{location.name}</Text>;
|
||||
},
|
||||
...props
|
||||
};
|
||||
|
||||
@@ -593,7 +593,6 @@ export default function BuildLineTable({
|
||||
icon: <IconShoppingCart />,
|
||||
title: t`Order Stock`,
|
||||
hidden: !canOrder,
|
||||
disabled: !table.hasSelectedRecords,
|
||||
color: 'blue',
|
||||
onClick: () => {
|
||||
setPartsToOrder([record.part_detail]);
|
||||
|
||||
@@ -90,7 +90,8 @@ export function RelatedPartTable({
|
||||
part_1: {
|
||||
hidden: true
|
||||
},
|
||||
part_2: {}
|
||||
part_2: {},
|
||||
note: {}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { type RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
|
||||
|
||||
export function calculateSupplierPartUnitPrice(record: any) {
|
||||
const pack_quantity = record?.part_detail?.pack_quantity_native ?? 1;
|
||||
const unit_price = record.price / pack_quantity;
|
||||
const unit_price = Number.parseFloat(record.price) / pack_quantity;
|
||||
|
||||
return unit_price;
|
||||
}
|
||||
@@ -111,9 +111,9 @@ export function SupplierPriceBreakColumns(): TableColumn[] {
|
||||
}
|
||||
|
||||
export default function SupplierPriceBreakTable({
|
||||
supplierPartId
|
||||
supplierPart
|
||||
}: Readonly<{
|
||||
supplierPartId: number;
|
||||
supplierPart: any;
|
||||
}>) {
|
||||
const table = useTable('supplierpricebreaks');
|
||||
|
||||
@@ -142,7 +142,8 @@ export default function SupplierPriceBreakTable({
|
||||
title: t`Add Price Break`,
|
||||
fields: supplierPriceBreakFields,
|
||||
initialData: {
|
||||
part: supplierPartId
|
||||
part: supplierPart.pk,
|
||||
price_currency: supplierPart.supplier_detail.currency
|
||||
},
|
||||
table: table
|
||||
});
|
||||
@@ -208,7 +209,7 @@ export default function SupplierPriceBreakTable({
|
||||
tableState={table}
|
||||
props={{
|
||||
params: {
|
||||
part: supplierPartId,
|
||||
part: supplierPart.pk,
|
||||
part_detail: true,
|
||||
supplier_detail: true
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../baseFixtures.ts';
|
||||
import { baseUrl } from '../defaults.ts';
|
||||
import {
|
||||
@@ -271,3 +272,22 @@ test('Build Order - Filters', async ({ page }) => {
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
});
|
||||
|
||||
test('Build Order - Duplicate', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await navigate(page, 'manufacturing/build-order/24/details');
|
||||
await page.getByLabel('action-menu-build-order-').click();
|
||||
await page.getByLabel('action-menu-build-order-actions-duplicate').click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await expect(page.getByLabel('text-field-reference')).not.toBeEmpty();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('tab', { name: 'Build Details' }).waitFor();
|
||||
await page.getByRole('tab', { name: 'Build Details' }).click();
|
||||
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../baseFixtures.ts';
|
||||
import { baseUrl } from '../defaults.ts';
|
||||
import { clickButtonIfVisible, openFilterDrawer } from '../helpers.ts';
|
||||
@@ -186,3 +187,22 @@ test('Purchase Orders - Receive Items', async ({ page }) => {
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('Purchase Orders - Duplicate', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await navigate(page, 'purchasing/purchase-order/13/detail');
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await expect(page.getByLabel('text-field-reference')).not.toBeEmpty();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).waitFor();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).click();
|
||||
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../baseFixtures.ts';
|
||||
import { baseUrl } from '../defaults.ts';
|
||||
import { clearTableFilters, setTableChoiceFilter } from '../helpers.ts';
|
||||
@@ -150,34 +151,21 @@ test('Purchase Orders', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
});
|
||||
|
||||
test('Purchase Orders - Barcodes', async ({ page }) => {
|
||||
test('Sales Orders - Duplicate', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/purchasing/purchase-order/13/detail`);
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
await navigate(page, 'sales/sales-order/11/detail');
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Display QR code
|
||||
await page.getByLabel('action-menu-barcode-actions').click();
|
||||
await page.getByLabel('action-menu-barcode-actions-view').click();
|
||||
await page.getByRole('img', { name: 'QR Code' }).waitFor();
|
||||
await page.getByRole('banner').getByRole('button').click();
|
||||
// Ensure a new reference is suggested
|
||||
await expect(page.getByLabel('text-field-reference')).not.toBeEmpty();
|
||||
|
||||
// Link to barcode
|
||||
await page.getByLabel('action-menu-barcode-actions').click();
|
||||
await page.getByLabel('action-menu-barcode-actions-link-barcode').click();
|
||||
await page.getByRole('heading', { name: 'Link Barcode' }).waitFor();
|
||||
await page
|
||||
.getByPlaceholder('Scan barcode data here using')
|
||||
.fill('1234567890');
|
||||
await page.getByRole('button', { name: 'Link' }).click();
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).waitFor();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).click();
|
||||
|
||||
// Unlink barcode
|
||||
await page.getByLabel('action-menu-barcode-actions').click();
|
||||
await page.getByLabel('action-menu-barcode-actions-unlink-barcode').click();
|
||||
await page.getByRole('heading', { name: 'Unlink Barcode' }).waitFor();
|
||||
await page.getByText('This will remove the link to').waitFor();
|
||||
await page.getByRole('button', { name: 'Unlink Barcode' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ export default defineConfig({
|
||||
uploadToken: process.env.CODECOV_TOKEN
|
||||
})
|
||||
],
|
||||
base: '',
|
||||
build: {
|
||||
manifest: true,
|
||||
outDir: '../../src/backend/InvenTree/web/static/web',
|
||||
|
||||
Reference in New Issue
Block a user