Compare commits

..

28 Commits

Author SHA1 Message Date
Oliver
3cae0d5066 Fix asset file serving (#9295)
- Backport of https://github.com/inventree/InvenTree/pull/9292
2025-03-14 09:04:42 +11:00
github-actions[bot]
21d266ab95 Auto-fill currency for new supplier part (#9286) (#9287)
- Closes https://github.com/inventree/InvenTree/issues/9284

(cherry picked from commit 7a43c3a83e)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-03-13 02:02:49 +11:00
github-actions[bot]
1ae27a6b77 Ignore sentry for TemplateSyntaxError (#9239) (#9241)
- Getting flodded with reports of users misapplied template filters

(cherry picked from commit 017d96f64e)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-03-05 22:01:12 +11:00
github-actions[bot]
b18ac57fb8 Tracking api fix (#9238) (#9240)
* [Bug] Fix search for StockTrackingList

- Removed invalid field

* Add unit test coverage for failing condition

* Fix 'notes' field for extra line item API

(cherry picked from commit 21ae1138ce)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-03-05 22:00:47 +11:00
github-actions[bot]
053b37ec3a Fix font size in location column (#9230) (#9231)
(cherry picked from commit d5a176c121)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-03-04 23:30:28 +11:00
github-actions[bot]
6fa6063639 [UI] Table Update (#9220) (#9221)
- Retain user selection for pageSize

(cherry picked from commit 8cee2e36ca)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-03-03 19:16:53 +11:00
github-actions[bot]
9b68dea26d Remove restriction on row action (#9201) (#9202)
(cherry picked from commit 92a9423c21)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-02-28 16:49:41 +11:00
github-actions[bot]
9d21776c86 Add 'note' field to form (#9186) (#9188)
(cherry picked from commit 92edbf41ab)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-02-26 14:14:30 +11:00
github-actions[bot]
4c9f042f8c Handle case of null stock location (#9183) (#9187)
(cherry picked from commit 94c2157d3c)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-02-26 09:31:58 +11:00
github-actions[bot]
3625b8f14c Use ref pattern on PO duplicate (#9100) (#9147)
* use ref pattern on PO duplicate

* use ref patterns on duplicate for other types of orders

* revert unintentional change to pre-commit

* add playwright tests

---------

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
(cherry picked from commit 2cabd02c6b)

Co-authored-by: Jacob Felknor <jacobfelknor073@gmail.com>
2025-02-22 20:46:00 +11:00
Oliver
cd41ca2a87 Batch code backport (#9138)
* Batch code fix (#9123)

* Fix batch code assignment when receiving items

* Add playwright tests

* Harden playwright tests

* Refactoring

* Handle undefined values

* Fix conflicts
2025-02-22 11:52:16 +11:00
github-actions[bot]
8a2fce9c36 Barcode validation fix (#9127) (#9130)
* Fix logic for adding items to SalesOrder

* Same thing for purchase orders

* Update serializers.py

Revert typo fix

- Otherwise, we need to do an API bump and the PR can't be back-ported!

(cherry picked from commit bc9dbf7df4)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-02-21 22:22:45 +11:00
github-actions[bot]
ee87cd7b23 Ignore inactive parts (#9125) (#9128)
(cherry picked from commit 6930ae7122)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-02-21 21:06:28 +11:00
Oliver
940abaa179 [UI] Pricing chart fixes (#9119) (#9124)
* Fix default values for pricing override

* Fix broken calculation for sale pricing

- Was previously excluding COMPLETED orders

* Fix for PricingOverviewPanel

* Fix for InvenTreeMoneySerializer

- Numbers should be represented as numbers!

* Front-end wrangling too

* Fix unit test
2025-02-21 20:39:20 +11:00
Oliver
7fefa5c213 Update version.py (#9112)
Bump version number to 0.17.8
2025-02-20 11:57:56 +11:00
github-actions[bot]
28726db86f Bug fix for receiving line item event (#9071) (#9072)
- Use StockItem ID correctly
- Provide line ID

(cherry picked from commit f27a84a7e5)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-02-13 14:46:55 +11:00
Oliver
eef1aad464 Update version.py (#9067)
Bump version number to 0.17.7
2025-02-12 17:14:45 +11:00
github-actions[bot]
3b6b41976f Fix for data import (#9060) (#9065)
- Prevent shadow overwrite of default_values dict
- Remove dead code

(cherry picked from commit 7049e84ac3)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-02-12 07:31:16 +11:00
Oliver
407ccb7bd2 [Backport] Update supported postgres-cli version to 15 (#9042)
* Update supported postgres-cli version to 15

* Update docs
2025-02-07 13:58:40 +11:00
Oliver
d7ed114e2c Adjust playwright testing (#9043)
* Adjust playwright testing

- Backporting critical playwright changes

* Fix for test

* Remove problematic test
2025-02-07 12:49:00 +11:00
Matthias Mair
c7a0265794 feat(backend): Improve error with missing manifest (#8957) (#9036)
* package tag results better

* Add docs for missing frontend

* better error indication
Fixes #8875

* fix test assertations

* fix test exception

* group setup admin stuff

* add operations supgourp

* add basic structure

* move error code

* fix link

* fix grammar issues

(cherry picked from commit bbeaf0e791)

# Conflicts:
#	docs/docs/faq.md

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-02-05 13:55:04 +11:00
Oliver
5bc56c826a Update version.py (#9019)
Bump version number

Co-authored-by: Matthias Mair <code@mjmair.com>
2025-02-05 12:52:56 +11:00
github-actions[bot]
1b42c00747 Enhance creation of default settings (#9028) (#9029)
- Remove cache requirement
- Replaces https://github.com/inventree/InvenTree/pull/9021

(cherry picked from commit 445fa45394)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-02-04 23:04:01 +11:00
github-actions[bot]
0f9bddbcd2 Report bugfix (#9013) (#9014)
- Ensure default label templates exist
- Ensure default report templates exist

(cherry picked from commit 2a6434ead8)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-02-03 08:03:59 +11:00
github-actions[bot]
b0fc42d906 Debounce checkLoginState to prevent unexpected redirection during testing (#9012) (#9016)
(cherry picked from commit e93b9692a1)

Co-authored-by: Dean <me@dgardiner.net>
2025-02-03 08:03:40 +11:00
github-actions[bot]
993849813f Select first and only pending shipment for sales order barcode allocation (#8984) (#8986)
(cherry picked from commit ede30cec7a507aab9c063fc44ab5d9e70ec902d6)

Co-authored-by: Dean <me@dgardiner.net>
2025-01-29 22:35:51 +11:00
github-actions[bot]
453c726d1e Fix for chart rendering (#8981) (#8982)
- Graphs like numbers, not strings, I guess...

(cherry picked from commit 0c56a3132b)

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
2025-01-29 17:43:33 +11:00
Oliver
b6ca9ec6a4 Update version.py
Bump version number to 0.17.5
2025-01-28 20:04:50 +11:00
49 changed files with 484 additions and 329 deletions

View File

@@ -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 \
&& \

View File

@@ -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 \
$@

View File

@@ -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:

View 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.

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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):

View File

@@ -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')

View File

@@ -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.

View File

@@ -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)

View File

@@ -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.

View File

@@ -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']

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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:

View File

@@ -1461,7 +1461,7 @@ class StockTrackingList(DataExportViewMixin, ListAPI):
ordering_fields = ['date']
search_fields = ['title', 'notes']
search_fields = ['notes']
class LocationDetail(CustomRetrieveUpdateDestroyAPI):

View File

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

View File

@@ -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>

View File

@@ -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)

View File

@@ -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."""

View File

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

View File

@@ -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
*/

View File

@@ -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`
});
}

View File

@@ -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>

View File

@@ -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[]>({

View File

@@ -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 (

View File

@@ -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
});

View File

@@ -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 />
)

View File

@@ -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',

View File

@@ -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`
};
}) ?? []

View File

@@ -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]);

View File

@@ -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
};

View File

@@ -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]);

View File

@@ -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
};

View File

@@ -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)
};
});

View File

@@ -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
});

View File

@@ -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
});

View File

@@ -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
});

View File

@@ -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
};

View File

@@ -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]);

View File

@@ -90,7 +90,8 @@ export function RelatedPartTable({
part_1: {
hidden: true
},
part_2: {}
part_2: {},
note: {}
};
}, []);

View File

@@ -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
},

View File

@@ -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();
});

View File

@@ -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();
});

View File

@@ -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();
});

View File

@@ -48,6 +48,7 @@ export default defineConfig({
uploadToken: process.env.CODECOV_TOKEN
})
],
base: '',
build: {
manifest: true,
outDir: '../../src/backend/InvenTree/web/static/web',