diff --git a/.github/workflows/docker_build.yaml b/.github/workflows/docker_build.yaml index 26fc69a0f5..ec8bdf7306 100644 --- a/.github/workflows/docker_build.yaml +++ b/.github/workflows/docker_build.yaml @@ -30,6 +30,7 @@ jobs: context: ./docker platforms: linux/amd64,linux/arm64,linux/arm/v7 push: true + target: production repository: inventree/inventree tags: inventree/inventree:latest - name: Image Digest diff --git a/.github/workflows/docker_publish.yaml b/.github/workflows/docker_publish.yaml index c25696d6dd..4a8cef0952 100644 --- a/.github/workflows/docker_publish.yaml +++ b/.github/workflows/docker_publish.yaml @@ -28,4 +28,5 @@ jobs: repository: inventree/inventree tag_with_ref: true dockerfile: ./Dockerfile + target: production platforms: linux/amd64,linux/arm64,linux/arm/v7 diff --git a/.gitignore b/.gitignore index 54ad8f07b6..7c360a8231 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ var/ *.log local_settings.py *.sqlite3 +*.sqlite3-journal *.backup *.old diff --git a/InvenTree/InvenTree/context.py b/InvenTree/InvenTree/context.py index 669b55b0c0..3e1f98ffc2 100644 --- a/InvenTree/InvenTree/context.py +++ b/InvenTree/InvenTree/context.py @@ -6,6 +6,7 @@ Provides extra global data to all templates. from InvenTree.status_codes import SalesOrderStatus, PurchaseOrderStatus from InvenTree.status_codes import BuildStatus, StockStatus +from InvenTree.status_codes import StockHistoryCode import InvenTree.status @@ -65,6 +66,7 @@ def status_codes(request): 'PurchaseOrderStatus': PurchaseOrderStatus, 'BuildStatus': BuildStatus, 'StockStatus': StockStatus, + 'StockHistoryCode': StockHistoryCode, } diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index cc61748372..7ff90fc7c3 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -263,6 +263,7 @@ INSTALLED_APPS = [ 'djmoney.contrib.exchange', # django-money exchange rates 'error_report', # Error reporting in the admin interface 'django_q', + 'formtools', # Form wizard tools ] MIDDLEWARE = CONFIG.get('middleware', [ @@ -430,11 +431,15 @@ It can be specified in config.yaml (or envvar) as either (for example): - django.db.backends.postgresql """ -db_engine = db_config['ENGINE'] +db_engine = db_config['ENGINE'].lower() -if db_engine.lower() in ['sqlite3', 'postgresql', 'mysql']: +# Correct common misspelling +if db_engine == 'sqlite': + db_engine = 'sqlite3' + +if db_engine in ['sqlite3', 'postgresql', 'mysql']: # Prepend the required python module string - db_engine = f'django.db.backends.{db_engine.lower()}' + db_engine = f'django.db.backends.{db_engine}' db_config['ENGINE'] = db_engine db_name = db_config['NAME'] diff --git a/InvenTree/InvenTree/status_codes.py b/InvenTree/InvenTree/status_codes.py index c73ef10018..63fc8a491c 100644 --- a/InvenTree/InvenTree/status_codes.py +++ b/InvenTree/InvenTree/status_codes.py @@ -7,6 +7,8 @@ class StatusCode: This is used to map a set of integer values to text. """ + colors = {} + @classmethod def render(cls, key, large=False): """ @@ -224,6 +226,82 @@ class StockStatus(StatusCode): ] +class StockHistoryCode(StatusCode): + + LEGACY = 0 + + CREATED = 1 + + # Manual editing operations + EDITED = 5 + ASSIGNED_SERIAL = 6 + + # Manual stock operations + STOCK_COUNT = 10 + STOCK_ADD = 11 + STOCK_REMOVE = 12 + + # Location operations + STOCK_MOVE = 20 + + # Installation operations + INSTALLED_INTO_ASSEMBLY = 30 + REMOVED_FROM_ASSEMBLY = 31 + + INSTALLED_CHILD_ITEM = 35 + REMOVED_CHILD_ITEM = 36 + + # Stock splitting operations + SPLIT_FROM_PARENT = 40 + SPLIT_CHILD_ITEM = 42 + + # Build order codes + BUILD_OUTPUT_CREATED = 50 + BUILD_OUTPUT_COMPLETED = 55 + + # Sales order codes + + # Purchase order codes + RECEIVED_AGAINST_PURCHASE_ORDER = 70 + + # Customer actions + SENT_TO_CUSTOMER = 100 + RETURNED_FROM_CUSTOMER = 105 + + options = { + LEGACY: _('Legacy stock tracking entry'), + + CREATED: _('Stock item created'), + + EDITED: _('Edited stock item'), + ASSIGNED_SERIAL: _('Assigned serial number'), + + STOCK_COUNT: _('Stock counted'), + STOCK_ADD: _('Stock manually added'), + STOCK_REMOVE: _('Stock manually removed'), + + STOCK_MOVE: _('Location changed'), + + INSTALLED_INTO_ASSEMBLY: _('Installed into assembly'), + REMOVED_FROM_ASSEMBLY: _('Removed from assembly'), + + INSTALLED_CHILD_ITEM: _('Installed component item'), + REMOVED_CHILD_ITEM: _('Removed component item'), + + SPLIT_FROM_PARENT: _('Split from parent item'), + SPLIT_CHILD_ITEM: _('Split child item'), + + SENT_TO_CUSTOMER: _('Sent to customer'), + RETURNED_FROM_CUSTOMER: _('Returned from customer'), + + BUILD_OUTPUT_CREATED: _('Build order output created'), + BUILD_OUTPUT_COMPLETED: _('Build order output completed'), + + RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against purchase order') + + } + + class BuildStatus(StatusCode): # Build status codes diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index a278b4e17c..c80c0e8523 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -22,7 +22,7 @@ from markdownx.models import MarkdownxField from mptt.models import MPTTModel, TreeForeignKey -from InvenTree.status_codes import BuildStatus, StockStatus +from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode from InvenTree.validators import validate_build_order_reference from InvenTree.models import InvenTreeAttachment @@ -811,6 +811,7 @@ class Build(MPTTModel): # Select the location for the build output location = kwargs.get('location', self.destination) status = kwargs.get('status', StockStatus.OK) + notes = kwargs.get('notes', '') # List the allocated BuildItem objects for the given output allocated_items = output.items_to_install.all() @@ -834,10 +835,13 @@ class Build(MPTTModel): output.save() - output.addTransactionNote( - _('Completed build output'), + output.add_tracking_entry( + StockHistoryCode.BUILD_OUTPUT_COMPLETED, user, - system=True + notes=notes, + deltas={ + 'status': status, + } ) # Increase the completed quantity for this build diff --git a/InvenTree/common/files.py b/InvenTree/common/files.py new file mode 100644 index 0000000000..377120f44d --- /dev/null +++ b/InvenTree/common/files.py @@ -0,0 +1,240 @@ +""" +Files management tools. +""" + +from rapidfuzz import fuzz +import tablib +import os + +from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ValidationError + +# from company.models import ManufacturerPart, SupplierPart + + +class FileManager: + """ Class for managing an uploaded file """ + + name = '' + + # Fields which are absolutely necessary for valid upload + REQUIRED_HEADERS = [] + + # Fields which are used for item matching (only one of them is needed) + ITEM_MATCH_HEADERS = [] + + # Fields which would be helpful but are not required + OPTIONAL_HEADERS = [] + + EDITABLE_HEADERS = [] + + HEADERS = [] + + def __init__(self, file, name=None): + """ Initialize the FileManager class with a user-uploaded file object """ + + # Set name + if name: + self.name = name + + # Process initial file + self.process(file) + + # Update headers + self.update_headers() + + @classmethod + def validate(cls, file): + """ Validate file extension and data """ + + cleaned_data = None + + ext = os.path.splitext(file.name)[-1].lower().replace('.', '') + + if ext in ['csv', 'tsv', ]: + # These file formats need string decoding + raw_data = file.read().decode('utf-8') + # Reset stream position to beginning of file + file.seek(0) + elif ext in ['xls', 'xlsx', 'json', 'yaml', ]: + raw_data = file.read() + # Reset stream position to beginning of file + file.seek(0) + else: + raise ValidationError(_(f'Unsupported file format: {ext.upper()}')) + + try: + cleaned_data = tablib.Dataset().load(raw_data, format=ext) + except tablib.UnsupportedFormat: + raise ValidationError(_('Error reading file (invalid format)')) + except tablib.core.InvalidDimensions: + raise ValidationError(_('Error reading file (incorrect dimension)')) + except KeyError: + raise ValidationError(_('Error reading file (data could be corrupted)')) + + return cleaned_data + + def process(self, file): + """ Process file """ + + self.data = self.__class__.validate(file) + + def update_headers(self): + """ Update headers """ + + self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_HEADERS + + def setup(self): + """ Setup headers depending on the file name """ + + if not self.name: + return + + if self.name == 'order': + self.REQUIRED_HEADERS = [ + 'Quantity', + ] + + self.ITEM_MATCH_HEADERS = [ + 'Manufacturer_MPN', + 'Supplier_SKU', + ] + + self.OPTIONAL_HEADERS = [ + 'Purchase_Price', + 'Reference', + 'Notes', + ] + + # Update headers + self.update_headers() + + def guess_header(self, header, threshold=80): + """ Try to match a header (from the file) to a list of known headers + + Args: + header - Header name to look for + threshold - Match threshold for fuzzy search + """ + + # Try for an exact match + for h in self.HEADERS: + if h == header: + return h + + # Try for a case-insensitive match + for h in self.HEADERS: + if h.lower() == header.lower(): + return h + + # Try for a case-insensitive match with space replacement + for h in self.HEADERS: + if h.lower() == header.lower().replace(' ', '_'): + return h + + # Finally, look for a close match using fuzzy matching + matches = [] + + for h in self.HEADERS: + ratio = fuzz.partial_ratio(header, h) + if ratio > threshold: + matches.append({'header': h, 'match': ratio}) + + if len(matches) > 0: + matches = sorted(matches, key=lambda item: item['match'], reverse=True) + return matches[0]['header'] + + return None + + def columns(self): + """ Return a list of headers for the thingy """ + headers = [] + + for header in self.data.headers: + # Guess header + guess = self.guess_header(header, threshold=95) + # Check if already present + guess_exists = False + for idx, data in enumerate(headers): + if guess == data['guess']: + guess_exists = True + break + + if not guess_exists: + headers.append({ + 'name': header, + 'guess': guess + }) + else: + headers.append({ + 'name': header, + 'guess': None + }) + + return headers + + def col_count(self): + if self.data is None: + return 0 + + return len(self.data.headers) + + def row_count(self): + """ Return the number of rows in the file. """ + + if self.data is None: + return 0 + + return len(self.data) + + def rows(self): + """ Return a list of all rows """ + rows = [] + + for i in range(self.row_count()): + + data = [item for item in self.get_row_data(i)] + + # Is the row completely empty? Skip! + empty = True + + for idx, item in enumerate(data): + if len(str(item).strip()) > 0: + empty = False + + try: + # Excel import casts number-looking-items into floats, which is annoying + if item == int(item) and not str(item) == str(int(item)): + data[idx] = int(item) + except ValueError: + pass + except TypeError: + data[idx] = '' + + # Skip empty rows + if empty: + continue + + row = { + 'data': data, + 'index': i + } + + rows.append(row) + + return rows + + def get_row_data(self, index): + """ Retrieve row data at a particular index """ + if self.data is None or index >= len(self.data): + return None + + return self.data[index] + + def get_row_dict(self, index): + """ Retrieve a dict object representing the data row at a particular offset """ + + if self.data is None or index >= len(self.data): + return None + + return self.data.dict[index] diff --git a/InvenTree/common/forms.py b/InvenTree/common/forms.py index 84e44f3a31..8a0017e38b 100644 --- a/InvenTree/common/forms.py +++ b/InvenTree/common/forms.py @@ -5,8 +5,16 @@ Django forms for interacting with common objects # -*- coding: utf-8 -*- from __future__ import unicode_literals +from decimal import Decimal, InvalidOperation + +from django import forms +from django.utils.translation import gettext as _ + +from djmoney.forms.fields import MoneyField + from InvenTree.forms import HelperForm +from .files import FileManager from .models import InvenTreeSetting @@ -21,3 +29,183 @@ class SettingEditForm(HelperForm): fields = [ 'value' ] + + +class UploadFile(forms.Form): + """ Step 1 of FileManagementFormView """ + + file = forms.FileField( + label=_('File'), + help_text=_('Select file to upload'), + ) + + def __init__(self, *args, **kwargs): + """ Update label and help_text """ + + # Get file name + name = None + if 'name' in kwargs: + name = kwargs.pop('name') + + super().__init__(*args, **kwargs) + + if name: + # Update label and help_text with file name + self.fields['file'].label = _(f'{name.title()} File') + self.fields['file'].help_text = _(f'Select {name} file to upload') + + def clean_file(self): + """ + Run tabular file validation. + If anything is wrong with the file, it will raise ValidationError + """ + + file = self.cleaned_data['file'] + + # Validate file using FileManager class - will perform initial data validation + # (and raise a ValidationError if there is something wrong with the file) + FileManager.validate(file) + + return file + + +class MatchField(forms.Form): + """ Step 2 of FileManagementFormView """ + + def __init__(self, *args, **kwargs): + + # Get FileManager + file_manager = None + if 'file_manager' in kwargs: + file_manager = kwargs.pop('file_manager') + + super().__init__(*args, **kwargs) + + # Setup FileManager + file_manager.setup() + # Get columns + columns = file_manager.columns() + # Get headers choices + headers_choices = [(header, header) for header in file_manager.HEADERS] + + # Create column fields + for col in columns: + field_name = col['name'] + self.fields[field_name] = forms.ChoiceField( + choices=[('', '-' * 10)] + headers_choices, + required=False, + widget=forms.Select(attrs={ + 'class': 'select fieldselect', + }) + ) + if col['guess']: + self.fields[field_name].initial = col['guess'] + + +class MatchItem(forms.Form): + """ Step 3 of FileManagementFormView """ + + def __init__(self, *args, **kwargs): + + # Get FileManager + file_manager = None + if 'file_manager' in kwargs: + file_manager = kwargs.pop('file_manager') + + if 'row_data' in kwargs: + row_data = kwargs.pop('row_data') + else: + row_data = None + + super().__init__(*args, **kwargs) + + def clean(number): + """ Clean-up decimal value """ + + # Check if empty + if not number: + return number + + # Check if decimal type + try: + clean_number = Decimal(number) + except InvalidOperation: + clean_number = number + + return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize() + + # Setup FileManager + file_manager.setup() + + # Create fields + if row_data: + # Navigate row data + for row in row_data: + # Navigate column data + for col in row['data']: + # Get column matching + col_guess = col['column'].get('guess', None) + + # Create input for required headers + if col_guess in file_manager.REQUIRED_HEADERS: + # Set field name + field_name = col_guess.lower() + '-' + str(row['index']) + # Set field input box + if 'quantity' in col_guess.lower(): + self.fields[field_name] = forms.CharField( + required=False, + widget=forms.NumberInput(attrs={ + 'name': 'quantity' + str(row['index']), + 'class': 'numberinput', # form-control', + 'type': 'number', + 'min': '0', + 'step': 'any', + 'value': clean(row.get('quantity', '')), + }) + ) + + # Create item selection box + elif col_guess in file_manager.ITEM_MATCH_HEADERS: + # Get item options + item_options = [(option.id, option) for option in row['item_options']] + # Get item match + item_match = row['item_match'] + # Set field name + field_name = 'item_select-' + str(row['index']) + # Set field select box + self.fields[field_name] = forms.ChoiceField( + choices=[('', '-' * 10)] + item_options, + required=False, + widget=forms.Select(attrs={ + 'class': 'select bomselect', + }) + ) + # Update select box when match was found + if item_match: + # Make it a required field: does not validate if + # removed using JS function + # self.fields[field_name].required = True + # Update initial value + self.fields[field_name].initial = item_match.id + + # Optional entries + elif col_guess in file_manager.OPTIONAL_HEADERS: + # Set field name + field_name = col_guess.lower() + '-' + str(row['index']) + # Get value + value = row.get(col_guess.lower(), '') + # Set field input box + if 'price' in col_guess.lower(): + self.fields[field_name] = MoneyField( + label=_(col_guess), + default_currency=InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY'), + decimal_places=5, + max_digits=19, + required=False, + default_amount=clean(value), + ) + else: + self.fields[field_name] = forms.CharField( + required=False, + initial=value, + ) diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index 8cc344c9ab..fa605c2b80 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -5,14 +5,21 @@ Django views for interacting with common models # -*- coding: utf-8 -*- from __future__ import unicode_literals +import os + from django.utils.translation import ugettext_lazy as _ from django.forms import CheckboxInput, Select +from django.conf import settings +from django.core.files.storage import FileSystemStorage + +from formtools.wizard.views import SessionWizardView from InvenTree.views import AjaxUpdateView from InvenTree.helpers import str2bool from . import models from . import forms +from .files import FileManager class SettingEdit(AjaxUpdateView): @@ -101,3 +108,392 @@ class SettingEdit(AjaxUpdateView): if not str2bool(value, test=True) and not str2bool(value, test=False): form.add_error('value', _('Supplied value must be a boolean')) + + +class MultiStepFormView(SessionWizardView): + """ Setup basic methods of multi-step form + + form_list: list of forms + form_steps_description: description for each form + """ + + form_list = [] + form_steps_template = [] + form_steps_description = [] + file_manager = None + media_folder = '' + file_storage = FileSystemStorage(settings.MEDIA_ROOT) + + def __init__(self, *args, **kwargs): + """ Override init method to set media folder """ + super().__init__(*args, **kwargs) + + self.process_media_folder() + + def process_media_folder(self): + """ Process media folder """ + + if self.media_folder: + media_folder_abs = os.path.join(settings.MEDIA_ROOT, self.media_folder) + if not os.path.exists(media_folder_abs): + os.mkdir(media_folder_abs) + self.file_storage = FileSystemStorage(location=media_folder_abs) + + def get_template_names(self): + """ Select template """ + + try: + # Get template + template = self.form_steps_template[self.steps.index] + except IndexError: + return self.template_name + + return template + + def get_context_data(self, **kwargs): + """ Update context data """ + + # Retrieve current context + context = super().get_context_data(**kwargs) + + # Get form description + try: + description = self.form_steps_description[self.steps.index] + except IndexError: + description = '' + # Add description to form steps + context.update({'description': description}) + + return context + + +class FileManagementFormView(MultiStepFormView): + """ Setup form wizard to perform the following steps: + 1. Upload tabular data file + 2. Match headers to InvenTree fields + 3. Edit row data and match InvenTree items + """ + + name = None + form_list = [ + ('upload', forms.UploadFile), + ('fields', forms.MatchField), + ('items', forms.MatchItem), + ] + form_steps_description = [ + _("Upload File"), + _("Match Fields"), + _("Match Items"), + ] + media_folder = 'file_upload/' + extra_context_data = {} + + def get_context_data(self, form, **kwargs): + context = super().get_context_data(form=form, **kwargs) + + if self.steps.current in ('fields', 'items'): + + # Get columns and row data + self.columns = self.file_manager.columns() + self.rows = self.file_manager.rows() + # Check for stored data + stored_data = self.storage.get_step_data(self.steps.current) + if stored_data: + self.get_form_table_data(stored_data) + elif self.steps.current == 'items': + # Set form table data + self.set_form_table_data(form=form) + + # Update context + context.update({'rows': self.rows}) + context.update({'columns': self.columns}) + + # Load extra context data + for key, items in self.extra_context_data.items(): + context.update({key: items}) + + return context + + def get_file_manager(self, step=None, form=None): + """ Get FileManager instance from uploaded file """ + + if self.file_manager: + return + + if step is not None: + # Retrieve stored files from upload step + upload_files = self.storage.get_step_files('upload') + if upload_files: + # Get file + file = upload_files.get('upload-file', None) + if file: + self.file_manager = FileManager(file=file, name=self.name) + + def get_form_kwargs(self, step=None): + """ Update kwargs to dynamically build forms """ + + # Always retrieve FileManager instance from uploaded file + self.get_file_manager(step) + + if step == 'upload': + # Dynamically build upload form + if self.name: + kwargs = { + 'name': self.name + } + return kwargs + elif step == 'fields': + # Dynamically build match field form + kwargs = { + 'file_manager': self.file_manager + } + return kwargs + elif step == 'items': + # Dynamically build match item form + kwargs = {} + kwargs['file_manager'] = self.file_manager + + # Get data from fields step + data = self.storage.get_step_data('fields') + + # Process to update columns and rows + self.rows = self.file_manager.rows() + self.columns = self.file_manager.columns() + self.get_form_table_data(data) + self.set_form_table_data() + self.get_field_selection() + + kwargs['row_data'] = self.rows + + return kwargs + + return super().get_form_kwargs() + + def get_form_table_data(self, form_data): + """ Extract table cell data from form data and fields. + These data are used to maintain state between sessions. + + Table data keys are as follows: + + col_name_ - Column name at idx as provided in the uploaded file + col_guess_ - Column guess at idx as selected + row__col - Cell data as provided in the uploaded file + + """ + + # Map the columns + self.column_names = {} + self.column_selections = {} + + self.row_data = {} + + for item, value in form_data.items(): + + # Column names as passed as col_name_ where idx is an integer + + # Extract the column names + if item.startswith('col_name_'): + try: + col_id = int(item.replace('col_name_', '')) + except ValueError: + continue + + self.column_names[col_id] = value + + # Extract the column selections (in the 'select fields' view) + if item.startswith('fields-'): + + try: + col_name = item.replace('fields-', '') + except ValueError: + continue + + for idx, name in self.column_names.items(): + if name == col_name: + self.column_selections[idx] = value + break + + # Extract the row data + if item.startswith('row_'): + # Item should be of the format row__col_ + s = item.split('_') + + if len(s) < 4: + continue + + # Ignore row/col IDs which are not correct numeric values + try: + row_id = int(s[1]) + col_id = int(s[3]) + except ValueError: + continue + + if row_id not in self.row_data: + self.row_data[row_id] = {} + + self.row_data[row_id][col_id] = value + + def set_form_table_data(self, form=None): + """ Set the form table data """ + + if self.column_names: + # Re-construct the column data + self.columns = [] + + for idx, value in self.column_names.items(): + header = ({ + 'name': value, + 'guess': self.column_selections.get(idx, ''), + }) + self.columns.append(header) + + if self.row_data: + # Re-construct the row data + self.rows = [] + + # Update the row data + for row_idx, row_key in enumerate(sorted(self.row_data.keys())): + row_data = self.row_data[row_key] + + data = [] + + for idx, item in row_data.items(): + column_data = { + 'name': self.column_names[idx], + 'guess': self.column_selections[idx], + } + + cell_data = { + 'cell': item, + 'idx': idx, + 'column': column_data, + } + data.append(cell_data) + + row = { + 'index': row_idx, + 'data': data, + 'errors': {}, + } + self.rows.append(row) + + # In the item selection step: update row data with mapping to form fields + if form and self.steps.current == 'items': + # Find field keys + field_keys = [] + for field in form.fields: + field_key = field.split('-')[0] + if field_key not in field_keys: + field_keys.append(field_key) + + # Populate rows + for row in self.rows: + for field_key in field_keys: + # Map row data to field + row[field_key] = field_key + '-' + str(row['index']) + + def get_column_index(self, name): + """ Return the index of the column with the given name. + It named column is not found, return -1 + """ + + try: + idx = list(self.column_selections.values()).index(name) + except ValueError: + idx = -1 + + return idx + + def get_field_selection(self): + """ Once data columns have been selected, attempt to pre-select the proper data from the database. + This function is called once the field selection has been validated. + The pre-fill data are then passed through to the part selection form. + + This method is very specific to the type of data found in the file, + therefore overwrite it in the subclass. + """ + pass + + def check_field_selection(self, form): + """ Check field matching """ + + # Are there any missing columns? + missing_columns = [] + + # Check that all required fields are present + for col in self.file_manager.REQUIRED_HEADERS: + if col not in self.column_selections.values(): + missing_columns.append(col) + + # Check that at least one of the part match field is present + part_match_found = False + for col in self.file_manager.ITEM_MATCH_HEADERS: + if col in self.column_selections.values(): + part_match_found = True + break + + # If not, notify user + if not part_match_found: + for col in self.file_manager.ITEM_MATCH_HEADERS: + missing_columns.append(col) + + # Track any duplicate column selections + duplicates = [] + + for col in self.column_names: + + if col in self.column_selections: + guess = self.column_selections[col] + else: + guess = None + + if guess: + n = list(self.column_selections.values()).count(self.column_selections[col]) + if n > 1: + duplicates.append(col) + + # Store extra context data + self.extra_context_data = { + 'missing_columns': missing_columns, + 'duplicates': duplicates, + } + + # Data validation + valid = not missing_columns and not duplicates + + return valid + + def validate(self, step, form): + """ Validate forms """ + + valid = True + + # Get form table data + self.get_form_table_data(form.data) + + if step == 'fields': + # Validate user form data + valid = self.check_field_selection(form) + + if not valid: + form.add_error(None, _('Fields matching failed')) + + elif step == 'items': + pass + + return valid + + def post(self, request, *args, **kwargs): + """ Perform validations before posting data """ + + wizard_goto_step = self.request.POST.get('wizard_goto_step', None) + + form = self.get_form(data=self.request.POST, files=self.request.FILES) + + form_valid = self.validate(self.steps.current, form) + + if not form_valid and not wizard_goto_step: + # Re-render same step + return self.render(form) + + return super().post(*args, **kwargs) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 534775ebaf..5305038b4f 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -28,7 +28,7 @@ from company.models import Company, SupplierPart from InvenTree.fields import RoundingDecimalField from InvenTree.helpers import decimal2string, increment, getSetting -from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus +from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus, StockHistoryCode from InvenTree.models import InvenTreeAttachment @@ -336,10 +336,12 @@ class PurchaseOrder(Order): return self.pending_line_items().count() == 0 @transaction.atomic - def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None): + def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None, **kwargs): """ Receive a line item (or partial line item) against this PO """ + notes = kwargs.get('notes', '') + if not self.status == PurchaseOrderStatus.PLACED: raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")}) @@ -364,13 +366,22 @@ class PurchaseOrder(Order): purchase_price=purchase_price, ) - stock.save() + stock.save(add_note=False) - text = _("Received items") - note = _('Received {n} items against order {name}').format(n=quantity, name=str(self)) + tracking_info = { + 'status': status, + 'purchaseorder': self.pk, + } - # Add a new transaction note to the newly created stock item - stock.addTransactionNote(text, user, note) + stock.add_tracking_entry( + StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER, + user, + notes=notes, + deltas=tracking_info, + location=location, + purchaseorder=self, + quantity=quantity + ) # Update the number of parts received against the particular line item line.received += quantity diff --git a/InvenTree/order/templates/order/order_wizard/match_fields.html b/InvenTree/order/templates/order/order_wizard/match_fields.html new file mode 100644 index 0000000000..4ff7b6a963 --- /dev/null +++ b/InvenTree/order/templates/order/order_wizard/match_fields.html @@ -0,0 +1,99 @@ +{% extends "order/order_wizard/po_upload.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block form_alert %} +{% if missing_columns and missing_columns|length > 0 %} + +{% endif %} +{% if duplicates and duplicates|length > 0 %} + +{% endif %} +{% endblock form_alert %} + +{% block form_buttons_top %} + {% if wizard.steps.prev %} + + {% endif %} + +{% endblock form_buttons_top %} + +{% block form_content %} + + + {% trans "File Fields" %} + + {% for col in form %} + +
+ + {{ col.name }} + +
+ + {% endfor %} + + + + + {% trans "Match Fields" %} + + {% for col in form %} + + {{ col }} + {% for duplicate in duplicates %} + {% if duplicate == col.name %} + + {% endif %} + {% endfor %} + + {% endfor %} + + {% for row in rows %} + {% with forloop.counter as row_index %} + + + + + {{ row_index }} + {% for item in row.data %} + + + {{ item }} + + {% endfor %} + + {% endwith %} + {% endfor %} + +{% endblock form_content %} + +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} + +{% block js_ready %} +{{ block.super }} + +$('.fieldselect').select2({ + width: '100%', + matcher: partialMatcher, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/order_wizard/match_parts.html b/InvenTree/order/templates/order/order_wizard/match_parts.html new file mode 100644 index 0000000000..f97edff913 --- /dev/null +++ b/InvenTree/order/templates/order/order_wizard/match_parts.html @@ -0,0 +1,125 @@ +{% extends "order/order_wizard/po_upload.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block form_alert %} +{% if form.errors %} +{% endif %} +{% if form_errors %} + +{% endif %} +{% endblock form_alert %} + +{% block form_buttons_top %} + {% if wizard.steps.prev %} + + {% endif %} + +{% endblock form_buttons_top %} + +{% block form_content %} + + + + {% trans "Row" %} + {% trans "Select Supplier Part" %} + {% trans "Quantity" %} + {% for col in columns %} + {% if col.guess != 'Quantity' %} + + + + {% if col.guess %} + {{ col.guess }} + {% else %} + {{ col.name }} + {% endif %} + + {% endif %} + {% endfor %} + + + + {% comment %} Dummy row for javascript del_row method {% endcomment %} + {% for row in rows %} + + + + + + {% add row.index 1 %} + + + {% for field in form.visible_fields %} + {% if field.name == row.item_select %} + {{ field }} + {% endif %} + {% endfor %} + {% if row.errors.part %} +

{{ row.errors.part }}

+ {% endif %} + + + {% for field in form.visible_fields %} + {% if field.name == row.quantity %} + {{ field }} + {% endif %} + {% endfor %} + {% if row.errors.quantity %} +

{{ row.errors.quantity }}

+ {% endif %} + + {% for item in row.data %} + {% if item.column.guess != 'Quantity' %} + + {% if item.column.guess == 'Purchase_Price' %} + {% for field in form.visible_fields %} + {% if field.name == row.purchase_price %} + {{ field }} + {% endif %} + {% endfor %} + {% elif item.column.guess == 'Reference' %} + {% for field in form.visible_fields %} + {% if field.name == row.reference %} + {{ field }} + {% endif %} + {% endfor %} + {% elif item.column.guess == 'Notes' %} + {% for field in form.visible_fields %} + {% if field.name == row.notes %} + {{ field }} + {% endif %} + {% endfor %} + {% else %} + {{ item.cell }} + {% endif %} + + + {% endif %} + {% endfor %} + + {% endfor %} + +{% endblock form_content %} + +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} + +{% block js_ready %} +{{ block.super }} + +$('.bomselect').select2({ + dropdownAutoWidth: true, + matcher: partialMatcher, +}); + +$('.currencyselect').select2({ + dropdownAutoWidth: true, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/order_wizard/po_upload.html b/InvenTree/order/templates/order/order_wizard/po_upload.html new file mode 100644 index 0000000000..a281725173 --- /dev/null +++ b/InvenTree/order/templates/order/order_wizard/po_upload.html @@ -0,0 +1,56 @@ +{% extends "order/order_base.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block menubar %} +{% include 'order/po_navbar.html' with tab='upload' %} +{% endblock %} + +{% block heading %} +{% trans "Upload File for Purchase Order" %} +{{ wizard.form.media }} +{% endblock %} + +{% block details %} +{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %} + +

{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} +{% if description %}- {{ description }}{% endif %}

+ +{% block form_alert %} +{% endblock form_alert %} + +
+{% csrf_token %} +{% load crispy_forms_tags %} + +{% block form_buttons_top %} +{% endblock form_buttons_top %} + + +{{ wizard.management_form }} +{% block form_content %} +{% crispy wizard.form %} +{% endblock form_content %} +
+ +{% block form_buttons_bottom %} +{% if wizard.steps.prev %} + +{% endif %} + +
+{% endblock form_buttons_bottom %} + +{% else %} + +{% endif %} +{% endblock details %} + +{% block js_ready %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/po_navbar.html b/InvenTree/order/templates/order/po_navbar.html index eac24d47b3..f8e818c2e3 100644 --- a/InvenTree/order/templates/order/po_navbar.html +++ b/InvenTree/order/templates/order/po_navbar.html @@ -1,6 +1,7 @@ {% load i18n %} {% load static %} {% load inventree_extras %} +{% load status_codes %}