diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 403b3a9430..0dd6a404e5 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -9,16 +9,16 @@ import decimal import os from datetime import datetime -from django.utils.translation import ugettext_lazy as _ - from django.contrib.auth.models import User from django.core.exceptions import ValidationError - -from django.urls import reverse +from django.core.validators import MinValueValidator from django.db import models, transaction from django.db.models import Sum, Q from django.db.models.functions import Coalesce -from django.core.validators import MinValueValidator +from django.db.models.signals import post_save +from django.dispatch.dispatcher import receiver +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ from markdownx.models import MarkdownxField @@ -27,16 +27,17 @@ from mptt.exceptions import InvalidMove 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, ReferenceIndexingMixin +from InvenTree.validators import validate_build_order_reference import common.models import InvenTree.fields import InvenTree.helpers +import InvenTree.tasks -from stock import models as StockModels from part import models as PartModels +from stock import models as StockModels from users import models as UserModels @@ -1014,6 +1015,19 @@ class Build(MPTTModel, ReferenceIndexingMixin): return self.status == BuildStatus.COMPLETE +@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log') +def after_save_build(sender, instance: Build, created: bool, **kwargs): + """ + Callback function to be executed after a Build instance is saved + """ + + if created: + # A new Build has just been created + + # Run checks on required parts + InvenTree.tasks.offload_task('build.tasks.check_build_stock', instance) + + class BuildOrderAttachment(InvenTreeAttachment): """ Model for storing file attachments against a BuildOrder object diff --git a/InvenTree/build/tasks.py b/InvenTree/build/tasks.py new file mode 100644 index 0000000000..6fe4be5119 --- /dev/null +++ b/InvenTree/build/tasks.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from decimal import Decimal +import logging + +from django.utils.translation import ugettext_lazy as _ +from django.template.loader import render_to_string + +from allauth.account.models import EmailAddress + +import build.models +import InvenTree.helpers +import InvenTree.tasks +import part.models as part_models + + +logger = logging.getLogger('inventree') + + +def check_build_stock(build: build.models.Build): + """ + Check the required stock for a newly created build order, + and send an email out to any subscribed users if stock is low. + """ + + # Iterate through each of the parts required for this build + + lines = [] + + if not build: + logger.error("Invalid build passed to 'build.tasks.check_build_stock'") + return + + try: + part = build.part + except part_models.Part.DoesNotExist: + # Note: This error may be thrown during unit testing... + logger.error("Invalid build.part passed to 'build.tasks.check_build_stock'") + return + + for bom_item in part.get_bom_items(): + + sub_part = bom_item.sub_part + + # The 'in stock' quantity depends on whether the bom_item allows variants + in_stock = sub_part.get_stock_count(include_variants=bom_item.allow_variants) + + allocated = sub_part.allocation_count() + + available = max(0, in_stock - allocated) + + required = Decimal(bom_item.quantity) * Decimal(build.quantity) + + if available < required: + # There is not sufficient stock for this part + + lines.append({ + 'link': InvenTree.helpers.construct_absolute_url(sub_part.get_absolute_url()), + 'part': sub_part, + 'in_stock': in_stock, + 'allocated': allocated, + 'available': available, + 'required': required, + }) + + if len(lines) == 0: + # Nothing to do + return + + # Are there any users subscribed to these parts? + subscribers = build.part.get_subscribers() + + emails = EmailAddress.objects.filter( + user__in=subscribers, + ) + + if len(emails) > 0: + + logger.info(f"Notifying users of stock required for build {build.pk}") + + context = { + 'link': InvenTree.helpers.construct_absolute_url(build.get_absolute_url()), + 'build': build, + 'part': build.part, + 'lines': lines, + } + + # Render the HTML message + html_message = render_to_string('email/build_order_required_stock.html', context) + + subject = "[InvenTree] " + _("Stock required for build order") + + recipients = emails.values_list('email', flat=True) + + InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message) diff --git a/InvenTree/part/bom.py b/InvenTree/part/bom.py index f67e4ffe8f..d09cc5130a 100644 --- a/InvenTree/part/bom.py +++ b/InvenTree/part/bom.py @@ -160,171 +160,108 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa # Add stock columns to dataset add_columns_to_dataset(stock_cols, len(bom_items)) - if manufacturer_data and supplier_data: + if manufacturer_data or supplier_data: """ If requested, add extra columns for each SupplierPart and ManufacturerPart associated with each line item """ - # Expand dataset with manufacturer parts - manufacturer_headers = [ - _('Manufacturer'), - _('MPN'), - ] - - supplier_headers = [ - _('Supplier'), - _('SKU'), - ] + # Keep track of the supplier parts we have already exported + supplier_parts_used = set() manufacturer_cols = {} - for b_idx, bom_item in enumerate(bom_items): + for bom_idx, bom_item in enumerate(bom_items): # Get part instance b_part = bom_item.sub_part - # Filter manufacturer parts - manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk) - manufacturer_parts = manufacturer_parts.prefetch_related('supplier_parts') + # Include manufacturer data for each BOM item + if manufacturer_data: - # Process manufacturer part - for manufacturer_idx, manufacturer_part in enumerate(manufacturer_parts): + # Filter manufacturer parts + manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk).prefetch_related('supplier_parts') + + for mp_idx, mp_part in enumerate(manufacturer_parts): - if manufacturer_part and manufacturer_part.manufacturer: - manufacturer_name = manufacturer_part.manufacturer.name - else: - manufacturer_name = '' + # Extract the "name" field of the Manufacturer (Company) + if mp_part and mp_part.manufacturer: + manufacturer_name = mp_part.manufacturer.name + else: + manufacturer_name = '' - if manufacturer_part: - manufacturer_mpn = manufacturer_part.MPN - else: - manufacturer_mpn = '' + # Extract the "MPN" field from the Manufacturer Part + if mp_part: + manufacturer_mpn = mp_part.MPN + else: + manufacturer_mpn = '' - # Generate column names for this manufacturer - k_man = manufacturer_headers[0] + "_" + str(manufacturer_idx) - k_mpn = manufacturer_headers[1] + "_" + str(manufacturer_idx) + # Generate a column name for this manufacturer + k_man = f'{_("Manufacturer")}_{mp_idx}' + k_mpn = f'{_("MPN")}_{mp_idx}' + + try: + manufacturer_cols[k_man].update({bom_idx: manufacturer_name}) + manufacturer_cols[k_mpn].update({bom_idx: manufacturer_mpn}) + except KeyError: + manufacturer_cols[k_man] = {bom_idx: manufacturer_name} + manufacturer_cols[k_mpn] = {bom_idx: manufacturer_mpn} - try: - manufacturer_cols[k_man].update({b_idx: manufacturer_name}) - manufacturer_cols[k_mpn].update({b_idx: manufacturer_mpn}) - except KeyError: - manufacturer_cols[k_man] = {b_idx: manufacturer_name} - manufacturer_cols[k_mpn] = {b_idx: manufacturer_mpn} + # We wish to include supplier data for this manufacturer part + if supplier_data: + + for sp_idx, sp_part in enumerate(mp_part.supplier_parts.all()): - # Process supplier parts - for supplier_idx, supplier_part in enumerate(manufacturer_part.supplier_parts.all()): + supplier_parts_used.add(sp_part) - if supplier_part.supplier and supplier_part.supplier: - supplier_name = supplier_part.supplier.name + if sp_part.supplier and sp_part.supplier: + supplier_name = sp_part.supplier.name + else: + supplier_name = '' + + if sp_part: + supplier_sku = sp_part.SKU + else: + supplier_sku = '' + + # Generate column names for this supplier + k_sup = str(_("Supplier")) + "_" + str(mp_idx) + "_" + str(sp_idx) + k_sku = str(_("SKU")) + "_" + str(mp_idx) + "_" + str(sp_idx) + + try: + manufacturer_cols[k_sup].update({bom_idx: supplier_name}) + manufacturer_cols[k_sku].update({bom_idx: supplier_sku}) + except KeyError: + manufacturer_cols[k_sup] = {bom_idx: supplier_name} + manufacturer_cols[k_sku] = {bom_idx: supplier_sku} + + if supplier_data: + # Add in any extra supplier parts, which are not associated with a manufacturer part + + for sp_idx, sp_part in enumerate(SupplierPart.objects.filter(part__pk=b_part.pk)): + + if sp_part in supplier_parts_used: + continue + + supplier_parts_used.add(sp_part) + + if sp_part.supplier: + supplier_name = sp_part.supplier.name else: supplier_name = '' - if supplier_part: - supplier_sku = supplier_part.SKU - else: - supplier_sku = '' + supplier_sku = sp_part.SKU # Generate column names for this supplier - k_sup = str(supplier_headers[0]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx) - k_sku = str(supplier_headers[1]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx) + k_sup = str(_("Supplier")) + "_" + str(sp_idx) + k_sku = str(_("SKU")) + "_" + str(sp_idx) try: - manufacturer_cols[k_sup].update({b_idx: supplier_name}) - manufacturer_cols[k_sku].update({b_idx: supplier_sku}) + manufacturer_cols[k_sup].update({bom_idx: supplier_name}) + manufacturer_cols[k_sku].update({bom_idx: supplier_sku}) except KeyError: - manufacturer_cols[k_sup] = {b_idx: supplier_name} - manufacturer_cols[k_sku] = {b_idx: supplier_sku} + manufacturer_cols[k_sup] = {bom_idx: supplier_name} + manufacturer_cols[k_sku] = {bom_idx: supplier_sku} - # Add manufacturer columns to dataset - add_columns_to_dataset(manufacturer_cols, len(bom_items)) - - elif manufacturer_data: - """ - If requested, add extra columns for each ManufacturerPart associated with each line item - """ - - # Expand dataset with manufacturer parts - manufacturer_headers = [ - _('Manufacturer'), - _('MPN'), - ] - - manufacturer_cols = {} - - for b_idx, bom_item in enumerate(bom_items): - # Get part instance - b_part = bom_item.sub_part - - # Filter supplier parts - manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk) - - for idx, manufacturer_part in enumerate(manufacturer_parts): - - if manufacturer_part: - manufacturer_name = manufacturer_part.manufacturer.name - else: - manufacturer_name = '' - - manufacturer_mpn = manufacturer_part.MPN - - # Add manufacturer data to the manufacturer columns - - # Generate column names for this manufacturer - k_man = manufacturer_headers[0] + "_" + str(idx) - k_mpn = manufacturer_headers[1] + "_" + str(idx) - - try: - manufacturer_cols[k_man].update({b_idx: manufacturer_name}) - manufacturer_cols[k_mpn].update({b_idx: manufacturer_mpn}) - except KeyError: - manufacturer_cols[k_man] = {b_idx: manufacturer_name} - manufacturer_cols[k_mpn] = {b_idx: manufacturer_mpn} - - # Add manufacturer columns to dataset - add_columns_to_dataset(manufacturer_cols, len(bom_items)) - - elif supplier_data: - """ - If requested, add extra columns for each SupplierPart associated with each line item - """ - - # Expand dataset with manufacturer parts - manufacturer_headers = [ - _('Supplier'), - _('SKU'), - ] - - manufacturer_cols = {} - - for b_idx, bom_item in enumerate(bom_items): - # Get part instance - b_part = bom_item.sub_part - - # Filter supplier parts - supplier_parts = SupplierPart.objects.filter(part__pk=b_part.pk) - - for idx, supplier_part in enumerate(supplier_parts): - - if supplier_part.supplier: - supplier_name = supplier_part.supplier.name - else: - supplier_name = '' - - supplier_sku = supplier_part.SKU - - # Add manufacturer data to the manufacturer columns - - # Generate column names for this supplier - k_sup = manufacturer_headers[0] + "_" + str(idx) - k_sku = manufacturer_headers[1] + "_" + str(idx) - - try: - manufacturer_cols[k_sup].update({b_idx: supplier_name}) - manufacturer_cols[k_sku].update({b_idx: supplier_sku}) - except KeyError: - manufacturer_cols[k_sup] = {b_idx: supplier_name} - manufacturer_cols[k_sku] = {b_idx: supplier_sku} - - # Add manufacturer columns to dataset + # Add supplier columns to dataset add_columns_to_dataset(manufacturer_cols, len(bom_items)) data = dataset.export(fmt) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 1c50bc321e..23aea29dbd 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1324,6 +1324,17 @@ class Part(MPTTModel): return query + def get_stock_count(self, include_variants=True): + """ + Return the total "in stock" count for this part + """ + + entries = self.stock_entries(in_stock=True, include_variants=include_variants) + + query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0))) + + return query['t'] + @property def total_stock(self): """ Return the total stock quantity for this part. @@ -1332,11 +1343,7 @@ class Part(MPTTModel): - If this part is a "template" (variants exist) then these are counted too """ - entries = self.stock_entries(in_stock=True) - - query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0))) - - return query['t'] + return self.get_stock_count() def get_bom_item_filter(self, include_inherited=True): """ @@ -2091,17 +2098,20 @@ class Part(MPTTModel): Returns True if the total stock for this part is less than the minimum stock level """ - return self.total_stock <= self.minimum_stock + return self.get_stock_count() < self.minimum_stock @receiver(post_save, sender=Part, dispatch_uid='part_post_save_log') -def after_save_part(sender, instance: Part, **kwargs): +def after_save_part(sender, instance: Part, created, **kwargs): """ Function to be executed after a Part is saved """ - # Run this check in the background - InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance) + if not created: + # Check part stock only if we are *updating* the part (not creating it) + + # Run this check in the background + InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance) def attach_file(instance, filename): diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py index f4f1459214..0cd9cf09a7 100644 --- a/InvenTree/part/tasks.py +++ b/InvenTree/part/tasks.py @@ -50,7 +50,7 @@ def notify_low_stock(part: part.models.Part): 'link': InvenTree.helpers.construct_absolute_url(part.get_absolute_url()), } - subject = _(f'[InvenTree] {part.name} is low on stock') + subject = "[InvenTree] " + _("Low stock notification") html_message = render_to_string('email/low_stock_notification.html', context) recipients = emails.values_list('email', flat=True) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 56ab98004d..65c42f7e36 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -42,11 +42,12 @@ from common.files import FileManager from common.views import FileManagementFormView, FileManagementAjaxView from common.forms import UploadFileForm, MatchFieldForm -from stock.models import StockLocation +from stock.models import StockItem, StockLocation import common.settings as inventree_settings from . import forms as part_forms +from . import settings as part_settings from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat from order.models import PurchaseOrderLineItem @@ -245,6 +246,7 @@ class PartImport(FileManagementFormView): 'Category', 'default_location', 'default_supplier', + 'variant_of', ] OPTIONAL_HEADERS = [ @@ -256,6 +258,17 @@ class PartImport(FileManagementFormView): 'minimum_stock', 'Units', 'Notes', + 'Active', + 'base_cost', + 'Multiple', + 'Assembly', + 'Component', + 'is_template', + 'Purchaseable', + 'Salable', + 'Trackable', + 'Virtual', + 'Stock', ] name = 'part' @@ -284,6 +297,18 @@ class PartImport(FileManagementFormView): 'category': 'category', 'default_location': 'default_location', 'default_supplier': 'default_supplier', + 'variant_of': 'variant_of', + 'active': 'active', + 'base_cost': 'base_cost', + 'multiple': 'multiple', + 'assembly': 'assembly', + 'component': 'component', + 'is_template': 'is_template', + 'purchaseable': 'purchaseable', + 'salable': 'salable', + 'trackable': 'trackable', + 'virtual': 'virtual', + 'stock': 'stock', } file_manager_class = PartFileManager @@ -299,6 +324,8 @@ class PartImport(FileManagementFormView): self.matches['default_location'] = ['name__contains'] self.allowed_items['default_supplier'] = SupplierPart.objects.all() self.matches['default_supplier'] = ['SKU__contains'] + self.allowed_items['variant_of'] = Part.objects.all() + self.matches['variant_of'] = ['name__contains'] # setup self.file_manager.setup() @@ -364,9 +391,29 @@ class PartImport(FileManagementFormView): category=optional_matches['Category'], default_location=optional_matches['default_location'], default_supplier=optional_matches['default_supplier'], + variant_of=optional_matches['variant_of'], + active=str2bool(part_data.get('active', True)), + base_cost=part_data.get('base_cost', 0), + multiple=part_data.get('multiple', 1), + assembly=str2bool(part_data.get('assembly', part_settings.part_assembly_default())), + component=str2bool(part_data.get('component', part_settings.part_component_default())), + is_template=str2bool(part_data.get('is_template', part_settings.part_template_default())), + purchaseable=str2bool(part_data.get('purchaseable', part_settings.part_purchaseable_default())), + salable=str2bool(part_data.get('salable', part_settings.part_salable_default())), + trackable=str2bool(part_data.get('trackable', part_settings.part_trackable_default())), + virtual=str2bool(part_data.get('virtual', part_settings.part_virtual_default())), ) try: new_part.save() + + # add stock item if set + if part_data.get('stock', None): + stock = StockItem( + part=new_part, + location=new_part.default_location, + quantity=int(part_data.get('stock', 1)), + ) + stock.save() import_done += 1 except ValidationError as _e: import_error.append(', '.join(set(_e.messages))) diff --git a/InvenTree/templates/email/build_order_required_stock.html b/InvenTree/templates/email/build_order_required_stock.html new file mode 100644 index 0000000000..5f4015da27 --- /dev/null +++ b/InvenTree/templates/email/build_order_required_stock.html @@ -0,0 +1,39 @@ +{% extends "email/email.html" %} + +{% load i18n %} +{% load inventree_extras %} + +{% block title %} +{% trans "Stock is required for the following build order" %}
+{% blocktrans with build=build.reference part=part.full_name quantity=build.quantity %}Build order {{ build }} - building {{ quantity }} x {{ part }}{% endblocktrans %} +
+

{% trans "Click on the following link to view this build order" %}: {{ link }}

+{% endblock title %} + +{% block body %} +{% trans "The following parts are low on required stock" %} + + + {% trans "Part" %} + {% trans "Required Quantity" %} + {% trans "Available" %} + + +{% for line in lines %} + + + {{ line.part.full_name }}{% if part.description %} - {{ part.description }}{% endif %} + + + {% decimal line.required %} {% if line.part.units %}{{ line.part.units }}{% endif %} + + {% decimal line.available %} {% if line.part.units %}{{ line.part.units }}{% endif %} + + +{% endfor %} + +{% endblock body %} + +{% block footer_prefix %} +

{% blocktrans with part=part.name %}You are receiving this email because you are subscribed to notifications for this part {% endblocktrans %}.

+{% endblock footer_prefix %} diff --git a/InvenTree/templates/email/low_stock_notification.html b/InvenTree/templates/email/low_stock_notification.html index 4db9c2ddaa..f922187f33 100644 --- a/InvenTree/templates/email/low_stock_notification.html +++ b/InvenTree/templates/email/low_stock_notification.html @@ -8,15 +8,12 @@ {% if link %}

{% trans "Click on the following link to view this part" %}: {{ link }}

{% endif %} -{% endblock %} +{% endblock title %} -{% block subtitle %} -

{% blocktrans with part=part.name %}You are receiving this email because you are subscribed to notifications for this part {% endblocktrans %}.

-{% endblock %} {% block body %} - {% trans "Part Name" %} + {% trans "Part" %} {% trans "Total Stock" %} {% trans "Available" %} {% trans "Minimum Quantity" %} @@ -24,8 +21,12 @@ {{ part.full_name }} - {{ part.total_stock }} - {{ part.available_stock }} - {{ part.minimum_stock }} + {% decimal part.total_stock %} + {% decimal part.available_stock %} + {% decimal part.minimum_stock %} -{% endblock %} +{% endblock body %} + +{% block footer_prefix %} +

{% blocktrans with part=part.name %}You are receiving this email because you are subscribed to notifications for this part {% endblocktrans %}.

+{% endblock footer_prefix %} diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index e00f04aebd..742083bbe4 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -1159,6 +1159,13 @@ function loadPartCategoryTable(table, options) { filters = loadTableFilters(filterKey); } + + var tree_view = options.allowTreeView && inventreeLoad('category-tree-view') == 1; + + if (tree_view) { + params.cascade = true; + } + var original = {}; for (var key in params) { @@ -1168,8 +1175,6 @@ function loadPartCategoryTable(table, options) { setupFilterList(filterKey, table, filterListElement); - var tree_view = options.allowTreeView && inventreeLoad('category-tree-view') == 1; - table.inventreeTable({ treeEnable: tree_view, rootParentId: tree_view ? options.params.parent : null, diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 1b21275d41..7ba5a52b97 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1679,6 +1679,12 @@ function loadStockLocationTable(table, options) { var filterListElement = options.filterList || '#filter-list-location'; + var tree_view = options.allowTreeView && inventreeLoad('location-tree-view') == 1; + + if (tree_view) { + params.cascade = true; + } + var filters = {}; var filterKey = options.filterKey || options.name || 'location'; @@ -1699,8 +1705,6 @@ function loadStockLocationTable(table, options) { filters[key] = params[key]; } - var tree_view = options.allowTreeView && inventreeLoad('location-tree-view') == 1; - table.inventreeTable({ treeEnable: tree_view, rootParentId: tree_view ? options.params.parent : null,