""" Django views for interacting with Order app """ # -*- coding: utf-8 -*- from __future__ import unicode_literals from django.db.utils import IntegrityError from django.http.response import JsonResponse from django.shortcuts import get_object_or_404 from django.urls import reverse from django.http import HttpResponseRedirect from django.utils.translation import gettext_lazy as _ from django.views.generic import DetailView, ListView from django.forms import HiddenInput, IntegerField import logging from decimal import Decimal, InvalidOperation from .models import PurchaseOrder, PurchaseOrderLineItem from .models import SalesOrder, SalesOrderLineItem from .admin import PurchaseOrderLineItemResource, SalesOrderLineItemResource from company.models import SupplierPart # ManufacturerPart from part.models import Part from common.forms import UploadFileForm, MatchFieldForm from common.views import FileManagementFormView from common.files import FileManager from . import forms as order_forms from part.views import PartPricing from InvenTree.views import AjaxView, AjaxUpdateView from InvenTree.helpers import DownloadFile, str2bool from InvenTree.views import InvenTreeRoleMixin logger = logging.getLogger("inventree") class PurchaseOrderIndex(InvenTreeRoleMixin, ListView): """ List view for all purchase orders """ model = PurchaseOrder template_name = 'order/purchase_orders.html' context_object_name = 'orders' def get_queryset(self): """ Retrieve the list of purchase orders, ensure that the most recent ones are returned first. """ queryset = PurchaseOrder.objects.all().order_by('-creation_date') return queryset def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) return ctx class SalesOrderIndex(InvenTreeRoleMixin, ListView): model = SalesOrder template_name = 'order/sales_orders.html' context_object_name = 'orders' class PurchaseOrderDetail(InvenTreeRoleMixin, DetailView): """ Detail view for a PurchaseOrder object """ context_object_name = 'order' queryset = PurchaseOrder.objects.all().prefetch_related('lines') template_name = 'order/purchase_order_detail.html' def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) return ctx class SalesOrderDetail(InvenTreeRoleMixin, DetailView): """ Detail view for a SalesOrder object """ context_object_name = 'order' queryset = SalesOrder.objects.all().prefetch_related('lines__allocations__item__purchase_order') template_name = 'order/sales_order_detail.html' class PurchaseOrderCancel(AjaxUpdateView): """ View for cancelling a purchase order """ model = PurchaseOrder ajax_form_title = _('Cancel Order') ajax_template_name = 'order/order_cancel.html' form_class = order_forms.CancelPurchaseOrderForm def validate(self, order, form, **kwargs): confirm = str2bool(form.cleaned_data.get('confirm', False)) if not confirm: form.add_error('confirm', _('Confirm order cancellation')) if not order.can_cancel(): form.add_error(None, _('Order cannot be cancelled')) def save(self, order, form, **kwargs): """ Cancel the PurchaseOrder """ order.cancel_order() class SalesOrderCancel(AjaxUpdateView): """ View for cancelling a sales order """ model = SalesOrder ajax_form_title = _("Cancel sales order") ajax_template_name = "order/sales_order_cancel.html" form_class = order_forms.CancelSalesOrderForm def validate(self, order, form, **kwargs): confirm = str2bool(form.cleaned_data.get('confirm', False)) if not confirm: form.add_error('confirm', _('Confirm order cancellation')) if not order.can_cancel(): form.add_error(None, _('Order cannot be cancelled')) def save(self, order, form, **kwargs): """ Once the form has been validated, cancel the SalesOrder """ order.cancel_order() class PurchaseOrderIssue(AjaxUpdateView): """ View for changing a purchase order from 'PENDING' to 'ISSUED' """ model = PurchaseOrder ajax_form_title = _('Issue Order') ajax_template_name = "order/order_issue.html" form_class = order_forms.IssuePurchaseOrderForm def validate(self, order, form, **kwargs): confirm = str2bool(self.request.POST.get('confirm', False)) if not confirm: form.add_error('confirm', _('Confirm order placement')) def save(self, order, form, **kwargs): """ Once the form has been validated, place the order. """ order.place_order() def get_data(self): return { 'success': _('Purchase order issued') } class PurchaseOrderComplete(AjaxUpdateView): """ View for marking a PurchaseOrder as complete. """ form_class = order_forms.CompletePurchaseOrderForm model = PurchaseOrder ajax_template_name = "order/order_complete.html" ajax_form_title = _("Complete Order") context_object_name = 'order' def get_context_data(self): ctx = { 'order': self.get_object(), } return ctx def validate(self, order, form, **kwargs): confirm = str2bool(form.cleaned_data.get('confirm', False)) if not confirm: form.add_error('confirm', _('Confirm order completion')) def save(self, order, form, **kwargs): """ Complete the PurchaseOrder """ order.complete_order() def get_data(self): return { 'success': _('Purchase order completed') } class PurchaseOrderUpload(FileManagementFormView): ''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) ''' class OrderFileManager(FileManager): REQUIRED_HEADERS = [ 'Quantity', ] ITEM_MATCH_HEADERS = [ 'Manufacturer_MPN', 'Supplier_SKU', ] OPTIONAL_HEADERS = [ 'Purchase_Price', 'Reference', 'Notes', ] name = 'order' form_list = [ ('upload', UploadFileForm), ('fields', MatchFieldForm), ('items', order_forms.OrderMatchItemForm), ] form_steps_template = [ 'order/order_wizard/po_upload.html', 'order/order_wizard/match_fields.html', 'order/order_wizard/match_parts.html', ] form_steps_description = [ _("Upload File"), _("Match Fields"), _("Match Supplier Parts"), ] form_field_map = { 'item_select': 'part', 'quantity': 'quantity', 'purchase_price': 'purchase_price', 'reference': 'reference', 'notes': 'notes', } file_manager_class = OrderFileManager def get_order(self): """ Get order or return 404 """ return get_object_or_404(PurchaseOrder, pk=self.kwargs['pk']) def get_context_data(self, form, **kwargs): """ Handle context data for order """ context = super().get_context_data(form=form, **kwargs) order = self.get_order() context.update({'order': order}) return context 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 SupplierPart selection form. """ order = self.get_order() self.allowed_items = SupplierPart.objects.filter(supplier=order.supplier).prefetch_related('manufacturer_part') # Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database q_idx = self.get_column_index('Quantity') s_idx = self.get_column_index('Supplier_SKU') m_idx = self.get_column_index('Manufacturer_MPN') p_idx = self.get_column_index('Purchase_Price') r_idx = self.get_column_index('Reference') n_idx = self.get_column_index('Notes') for row in self.rows: # Initially use a quantity of zero quantity = Decimal(0) # Initially we do not have a part to reference exact_match_part = None # Check if there is a column corresponding to "quantity" if q_idx >= 0: q_val = row['data'][q_idx]['cell'] if q_val: # Delete commas q_val = q_val.replace(',', '') try: # Attempt to extract a valid quantity from the field quantity = Decimal(q_val) # Store the 'quantity' value row['quantity'] = quantity except (ValueError, InvalidOperation): pass # Check if there is a column corresponding to "Supplier SKU" if s_idx >= 0: sku = row['data'][s_idx]['cell'] try: # Attempt SupplierPart lookup based on SKU value exact_match_part = self.allowed_items.get(SKU__contains=sku) except (ValueError, SupplierPart.DoesNotExist, SupplierPart.MultipleObjectsReturned): exact_match_part = None # Check if there is a column corresponding to "Manufacturer MPN" and no exact match found yet if m_idx >= 0 and not exact_match_part: mpn = row['data'][m_idx]['cell'] try: # Attempt SupplierPart lookup based on MPN value exact_match_part = self.allowed_items.get(manufacturer_part__MPN__contains=mpn) except (ValueError, SupplierPart.DoesNotExist, SupplierPart.MultipleObjectsReturned): exact_match_part = None # Supply list of part options for each row, sorted by how closely they match the part name row['item_options'] = self.allowed_items # Unless found, the 'part_match' is blank row['item_match'] = None if exact_match_part: # If there is an exact match based on SKU or MPN, use that row['item_match'] = exact_match_part # Check if there is a column corresponding to "purchase_price" if p_idx >= 0: p_val = row['data'][p_idx]['cell'] if p_val: row['purchase_price'] = p_val # Check if there is a column corresponding to "reference" if r_idx >= 0: reference = row['data'][r_idx]['cell'] row['reference'] = reference # Check if there is a column corresponding to "notes" if n_idx >= 0: notes = row['data'][n_idx]['cell'] row['notes'] = notes def done(self, form_list, **kwargs): """ Once all the data is in, process it to add PurchaseOrderLineItem instances to the order """ order = self.get_order() items = self.get_clean_items() # Create PurchaseOrderLineItem instances for purchase_order_item in items.values(): try: supplier_part = SupplierPart.objects.get(pk=int(purchase_order_item['part'])) except (ValueError, SupplierPart.DoesNotExist): continue quantity = purchase_order_item.get('quantity', 0) if quantity: purchase_order_line_item = PurchaseOrderLineItem( order=order, part=supplier_part, quantity=quantity, purchase_price=purchase_order_item.get('purchase_price', None), reference=purchase_order_item.get('reference', ''), notes=purchase_order_item.get('notes', ''), ) try: purchase_order_line_item.save() except IntegrityError: # PurchaseOrderLineItem already exists pass return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']})) class SalesOrderExport(AjaxView): """ Export a sales order - File format can optionally be passed as a query parameter e.g. ?format=CSV - Default file format is CSV """ model = SalesOrder role_required = 'sales_order.view' def get(self, request, *args, **kwargs): order = get_object_or_404(SalesOrder, pk=self.kwargs.get('pk', None)) export_format = request.GET.get('format', 'csv') filename = f"{str(order)} - {order.customer.name}.{export_format}" dataset = SalesOrderLineItemResource().export(queryset=order.lines.all()) filedata = dataset.export(format=export_format) return DownloadFile(filedata, filename) class PurchaseOrderExport(AjaxView): """ File download for a purchase order - File format can be optionally passed as a query param e.g. ?format=CSV - Default file format is CSV """ model = PurchaseOrder # Specify role as we cannot introspect from "AjaxView" role_required = 'purchase_order.view' def get(self, request, *args, **kwargs): order = get_object_or_404(PurchaseOrder, pk=self.kwargs.get('pk', None)) export_format = request.GET.get('format', 'csv') filename = '{order} - {company}.{fmt}'.format( order=str(order), company=order.supplier.name, fmt=export_format ) dataset = PurchaseOrderLineItemResource().export(queryset=order.lines.all()) filedata = dataset.export(format=export_format) return DownloadFile(filedata, filename) class LineItemPricing(PartPricing): """ View for inspecting part pricing information """ class EnhancedForm(PartPricing.form_class): pk = IntegerField(widget=HiddenInput()) so_line = IntegerField(widget=HiddenInput()) form_class = EnhancedForm def get_part(self, id=False): if 'line_item' in self.request.GET: try: part_id = self.request.GET.get('line_item') part = SalesOrderLineItem.objects.get(id=part_id).part except Part.DoesNotExist: return None elif 'pk' in self.request.POST: try: part_id = self.request.POST.get('pk') part = Part.objects.get(id=part_id) except Part.DoesNotExist: return None else: return None if id: return part.id return part def get_so(self, pk=False): so_line = self.request.GET.get('line_item', None) if not so_line: so_line = self.request.POST.get('so_line', None) if so_line: try: sales_order = SalesOrderLineItem.objects.get(pk=so_line) if pk: return sales_order.pk return sales_order except Part.DoesNotExist: return None return None def get_quantity(self): """ Return set quantity in decimal format """ qty = Decimal(self.request.GET.get('quantity', 1)) if qty == 1: return Decimal(self.request.POST.get('quantity', 1)) return qty def get_initials(self): initials = super().get_initials() initials['pk'] = self.get_part(id=True) initials['so_line'] = self.get_so(pk=True) return initials def post(self, request, *args, **kwargs): # parse extra actions REF = 'act-btn_' act_btn = [a.replace(REF, '') for a in self.request.POST if REF in a] # check if extra action was passed if act_btn and act_btn[0] == 'update_price': # get sales order so_line = self.get_so() if not so_line: self.data = {'non_field_errors': [_('Sales order not found')]} else: quantity = self.get_quantity() price = self.get_pricing(quantity).get('unit_part_price', None) if not price: self.data = {'non_field_errors': [_('Price not found')]} else: # set normal update note note = _('Updated {part} unit-price to {price}') # check qunatity and update if different if so_line.quantity != quantity: so_line.quantity = quantity note = _('Updated {part} unit-price to {price} and quantity to {qty}') # update sale_price so_line.sale_price = price so_line.save() # parse response data = { 'form_valid': True, 'success': note.format(part=str(so_line.part), price=str(so_line.sale_price), qty=quantity) } return JsonResponse(data=data) # let the normal pricing view run return super().post(request, *args, **kwargs)