From da9d2f7467fd81d9aa3c64e05e3316aa482b84c1 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Oct 2021 22:49:06 +0200 Subject: [PATCH 01/17] Added missing fields Fixes #2181 --- InvenTree/part/views.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 5a4167ea05..330e7ae49c 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -245,6 +245,7 @@ class PartImport(FileManagementFormView): 'Category', 'default_location', 'default_supplier', + 'variant_of', ] OPTIONAL_HEADERS = [ @@ -256,6 +257,16 @@ class PartImport(FileManagementFormView): 'minimum_stock', 'Units', 'Notes', + 'Active', + 'base_cost', + 'Multiple', + 'assembly', + 'component', + 'is_template', + 'purchaseable', + 'salable', + 'trackable', + 'virtual', ] name = 'part' @@ -284,6 +295,17 @@ 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', } file_manager_class = PartFileManager @@ -299,6 +321,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() From 71cc155dc99474f7e0c17066ee1eed46f5531436 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Oct 2021 22:50:01 +0200 Subject: [PATCH 02/17] Capitalize name --- InvenTree/part/views.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 330e7ae49c..66a101ba5a 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -260,13 +260,13 @@ class PartImport(FileManagementFormView): 'Active', 'base_cost', 'Multiple', - 'assembly', - 'component', + 'Assembly', + 'Component', 'is_template', - 'purchaseable', - 'salable', - 'trackable', - 'virtual', + 'Purchaseable', + 'Salable', + 'Trackable', + 'Virtual', ] name = 'part' From 15566632540cf0f1e521ddabd1f202af6aa30d07 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Oct 2021 23:40:29 +0200 Subject: [PATCH 03/17] added fields to save step --- InvenTree/part/views.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 66a101ba5a..55147c933b 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -388,6 +388,17 @@ 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=optional_matches['active'], + base_cost=optional_matches['base_cost'], + multiple=optional_matches['multiple'], + assembly=optional_matches['assembly'], + component=optional_matches['component'], + is_template=optional_matches['is_template'], + purchaseable=optional_matches['purchaseable'], + salable=optional_matches['salable'], + trackable=optional_matches['trackable'], + virtual=optional_matches['virtual'], ) try: new_part.save() From 8e6aaa89f91f94ada7bce7f5660dbe783d63cae0 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Oct 2021 23:40:57 +0200 Subject: [PATCH 04/17] calculate true / false for fields --- InvenTree/part/views.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 55147c933b..f9bd57e767 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -389,16 +389,16 @@ class PartImport(FileManagementFormView): default_location=optional_matches['default_location'], default_supplier=optional_matches['default_supplier'], variant_of=optional_matches['variant_of'], - active=optional_matches['active'], - base_cost=optional_matches['base_cost'], - multiple=optional_matches['multiple'], - assembly=optional_matches['assembly'], - component=optional_matches['component'], - is_template=optional_matches['is_template'], - purchaseable=optional_matches['purchaseable'], - salable=optional_matches['salable'], - trackable=optional_matches['trackable'], - virtual=optional_matches['virtual'], + active=str2bool(part_data.get('active', None)), + base_cost=part_data.get('base_cost', None), + multiple=part_data.get('multiple', None), + assembly=str2bool(part_data.get('assembly', None)), + component=str2bool(part_data.get('component', None)), + is_template=str2bool(part_data.get('is_template', None)), + purchaseable=str2bool(part_data.get('purchaseable', None)), + salable=str2bool(part_data.get('salable', None)), + trackable=str2bool(part_data.get('trackable', None)), + virtual=str2bool(part_data.get('virtual', None)), ) try: new_part.save() From 612832c3e771b3847abf948cd8cc1459962f10c2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Oct 2021 23:48:42 +0200 Subject: [PATCH 05/17] respect defaults --- InvenTree/part/views.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index f9bd57e767..8deec0750f 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -47,6 +47,7 @@ from stock.models import 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 @@ -389,16 +390,16 @@ class PartImport(FileManagementFormView): default_location=optional_matches['default_location'], default_supplier=optional_matches['default_supplier'], variant_of=optional_matches['variant_of'], - active=str2bool(part_data.get('active', None)), + active=str2bool(part_data.get('active', True)), base_cost=part_data.get('base_cost', None), multiple=part_data.get('multiple', None), - assembly=str2bool(part_data.get('assembly', None)), - component=str2bool(part_data.get('component', None)), - is_template=str2bool(part_data.get('is_template', None)), - purchaseable=str2bool(part_data.get('purchaseable', None)), - salable=str2bool(part_data.get('salable', None)), - trackable=str2bool(part_data.get('trackable', None)), - virtual=str2bool(part_data.get('virtual', None)), + 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() From bec845003d76bb93c50181c1f096cfc1fc939fac Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 23 Oct 2021 23:57:10 +0200 Subject: [PATCH 06/17] fix defaults --- InvenTree/part/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 8deec0750f..918c015f94 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -391,8 +391,8 @@ class PartImport(FileManagementFormView): 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', None), - multiple=part_data.get('multiple', None), + 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())), From d97e3cd4e5d85e0ebf71ad0f653e43358f872d21 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 24 Oct 2021 00:19:17 +0200 Subject: [PATCH 07/17] create stock on import --- InvenTree/part/views.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 918c015f94..5456f4e049 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -42,7 +42,7 @@ 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 @@ -268,6 +268,7 @@ class PartImport(FileManagementFormView): 'Salable', 'Trackable', 'Virtual', + 'Stock', ] name = 'part' @@ -307,6 +308,7 @@ class PartImport(FileManagementFormView): 'salable': 'salable', 'trackable': 'trackable', 'virtual': 'virtual', + 'stock': 'stock', } file_manager_class = PartFileManager @@ -403,6 +405,15 @@ class PartImport(FileManagementFormView): ) 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))) From 9dba3c3f106520399a2ebeeb93d96e51a6f2cb56 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 12:10:36 +1100 Subject: [PATCH 08/17] Refactored bom export --- InvenTree/part/bom.py | 215 +++++++++++++++--------------------------- 1 file changed, 76 insertions(+), 139 deletions(-) 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) From 6db6a70fc29840d688d1c8ed87866f613644df35 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 13:32:14 +1100 Subject: [PATCH 09/17] Add task to check required stock for build order --- InvenTree/build/tasks.py | 85 +++++++++++++++++++ InvenTree/part/models.py | 17 ++-- InvenTree/part/tasks.py | 2 +- .../email/build_order_required_stock.html | 37 ++++++++ 4 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 InvenTree/build/tasks.py create mode 100644 InvenTree/templates/email/build_order_required_stock.html diff --git a/InvenTree/build/tasks.py b/InvenTree/build/tasks.py new file mode 100644 index 0000000000..a087b66129 --- /dev/null +++ b/InvenTree/build/tasks.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import logging + +from django.utils.translation import ugettext_lazy as _ +from django.template.loader import render_to_string + +from allauth.account.models import EmailAddress + +from common.models import NotificationEntry + +import build.models +import InvenTree.helpers +import InvenTree.tasks + + +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 = [] + + for bom_item in build.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 = bom_item.quantity * 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/models.py b/InvenTree/part/models.py index 1c50bc321e..7a15657a90 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): """ 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/templates/email/build_order_required_stock.html b/InvenTree/templates/email/build_order_required_stock.html new file mode 100644 index 0000000000..6b28d39f8e --- /dev/null +++ b/InvenTree/templates/email/build_order_required_stock.html @@ -0,0 +1,37 @@ +{% 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 }} + + {% 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 %} From 99b324d1ef4ab063364576837e5deff4ee2ae0fc Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 14:30:23 +1100 Subject: [PATCH 10/17] Add a post-save hook the "Build" model to check stock --- InvenTree/build/models.py | 28 +++++++++++++++---- InvenTree/build/tasks.py | 3 +- .../email/build_order_required_stock.html | 4 ++- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 403b3a9430..e8263285b1 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -8,17 +8,19 @@ import decimal import os from datetime import datetime +from django import dispatch -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 +29,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 +1017,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 index a087b66129..8b5a0a1831 100644 --- a/InvenTree/build/tasks.py +++ b/InvenTree/build/tasks.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from decimal import Decimal import logging from django.utils.translation import ugettext_lazy as _ @@ -39,7 +40,7 @@ def check_build_stock(build: build.models.Build): available = max(0, in_stock - allocated) - required = bom_item.quantity * build.quantity + required = Decimal(bom_item.quantity) * Decimal(build.quantity) if available < required: # There is not sufficient stock for this part diff --git a/InvenTree/templates/email/build_order_required_stock.html b/InvenTree/templates/email/build_order_required_stock.html index 6b28d39f8e..5f4015da27 100644 --- a/InvenTree/templates/email/build_order_required_stock.html +++ b/InvenTree/templates/email/build_order_required_stock.html @@ -21,7 +21,9 @@ {% for line in lines %} - {{ line.part.full_name }} + + {{ line.part.full_name }}{% if part.description %} - {{ part.description }}{% endif %} + {% decimal line.required %} {% if line.part.units %}{{ line.part.units }}{% endif %} From 01191d84c56ba7094d7e26a18c79377c8db72f79 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 14:32:42 +1100 Subject: [PATCH 11/17] Only run check stock function when updating an existing part --- InvenTree/build/models.py | 2 -- InvenTree/build/tasks.py | 2 -- InvenTree/part/models.py | 9 ++++++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index e8263285b1..0dd6a404e5 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -8,8 +8,6 @@ import decimal import os from datetime import datetime -from django import dispatch - from django.contrib.auth.models import User from django.core.exceptions import ValidationError diff --git a/InvenTree/build/tasks.py b/InvenTree/build/tasks.py index 8b5a0a1831..7455d6eac2 100644 --- a/InvenTree/build/tasks.py +++ b/InvenTree/build/tasks.py @@ -9,8 +9,6 @@ from django.template.loader import render_to_string from allauth.account.models import EmailAddress -from common.models import NotificationEntry - import build.models import InvenTree.helpers import InvenTree.tasks diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 7a15657a90..f37b61864d 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2102,13 +2102,16 @@ class Part(MPTTModel): @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): From 42a794e8e47213294e2c3e190c56b93e94ee08f6 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 15:05:54 +1100 Subject: [PATCH 12/17] Fix CI errors --- InvenTree/build/tasks.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/InvenTree/build/tasks.py b/InvenTree/build/tasks.py index 7455d6eac2..6fe4be5119 100644 --- a/InvenTree/build/tasks.py +++ b/InvenTree/build/tasks.py @@ -12,6 +12,7 @@ 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') @@ -27,7 +28,18 @@ def check_build_stock(build: build.models.Build): lines = [] - for bom_item in build.part.get_bom_items(): + 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 From 47f6a8266dd0ead24e218df6517a25c1aafe964d Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 15:08:22 +1100 Subject: [PATCH 13/17] Fix for tree-view - Force "cascade" to be set --- InvenTree/templates/js/translated/part.js | 9 +++++++-- InvenTree/templates/js/translated/stock.js | 8 ++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) 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 07689b7638..a62e049651 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1426,6 +1426,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'; @@ -1446,8 +1452,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, From 5ae62410834228df8338bd150d5aeb8bf9cecd07 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 16:15:11 +1100 Subject: [PATCH 14/17] Fixes for low-stock emails - Include variant stock in test - Improve email template --- InvenTree/part/models.py | 2 +- InvenTree/templates/email/low_stock_notification.html | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index f37b61864d..9dd4031886 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2098,7 +2098,7 @@ 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') diff --git a/InvenTree/templates/email/low_stock_notification.html b/InvenTree/templates/email/low_stock_notification.html index 4db9c2ddaa..7b52ebc0cd 100644 --- a/InvenTree/templates/email/low_stock_notification.html +++ b/InvenTree/templates/email/low_stock_notification.html @@ -24,8 +24,8 @@ {{ part.full_name }} - {{ part.total_stock }} - {{ part.available_stock }} - {{ part.minimum_stock }} + {% decimal part.total_stock %} + {% part.available_stock %} + {% part.minimum_stock %} {% endblock %} From 75d7530e30dfc731771f739417db204f9d0bb5fa Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 16:17:05 +1100 Subject: [PATCH 15/17] Fix missing tag in template --- InvenTree/templates/email/low_stock_notification.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/templates/email/low_stock_notification.html b/InvenTree/templates/email/low_stock_notification.html index 7b52ebc0cd..bc0a82fd23 100644 --- a/InvenTree/templates/email/low_stock_notification.html +++ b/InvenTree/templates/email/low_stock_notification.html @@ -25,7 +25,7 @@ {{ part.full_name }} {% decimal part.total_stock %} - {% part.available_stock %} - {% part.minimum_stock %} + {% decimal part.available_stock %} + {% decimal part.minimum_stock %} {% endblock %} From 3a7f8c91966152378b984ff621360260eb110cf0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 16:18:49 +1100 Subject: [PATCH 16/17] Fix comparison operator --- InvenTree/part/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 9dd4031886..23aea29dbd 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2098,7 +2098,7 @@ class Part(MPTTModel): Returns True if the total stock for this part is less than the minimum stock level """ - return self.get_stock_count() <= self.minimum_stock + return self.get_stock_count() < self.minimum_stock @receiver(post_save, sender=Part, dispatch_uid='part_post_save_log') From 39d3a127e1abc1ff47bd6f7074383bd97bef469c Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Nov 2021 16:19:57 +1100 Subject: [PATCH 17/17] Template improvements --- .../templates/email/low_stock_notification.html | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/InvenTree/templates/email/low_stock_notification.html b/InvenTree/templates/email/low_stock_notification.html index bc0a82fd23..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" %} @@ -28,4 +25,8 @@ {% 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 %}