From 157f0e72a72c41cbf7847831e2add04a3a294293 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 4 Mar 2022 01:06:39 +0100 Subject: [PATCH] [FR] Add delivery cost (excluding unit cost that already exists) in PO Fixes #2694 --- InvenTree/order/admin.py | 27 +++- InvenTree/order/api.py | 55 +++++++ .../migrations/0064_auto_20220304_0004.py | 47 ++++++ InvenTree/order/models.py | 106 +++++++++++++ InvenTree/order/serializers.py | 149 ++++++++++++++++++ .../templates/order/sales_order_base.html | 12 ++ InvenTree/order/templates/order/validate.html | 9 ++ 7 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 InvenTree/order/migrations/0064_auto_20220304_0004.py create mode 100644 InvenTree/order/templates/order/validate.html diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index e98b31939a..7dff5d84bb 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -9,7 +9,7 @@ from import_export.resources import ModelResource from import_export.fields import Field from .models import PurchaseOrder, PurchaseOrderLineItem -from .models import SalesOrder, SalesOrderLineItem +from .models import SalesOrder, SalesOrderLineItem, SalesOrderAdditionalLineItem from .models import SalesOrderShipment, SalesOrderAllocation @@ -117,6 +117,16 @@ class SOLineItemResource(ModelResource): clean_model_instances = True +class SOAdditionalLineItemResource(ModelResource): + """ Class for managing import / export of SOAdditionalLineItem data """ + + class Meta: + model = SalesOrderAdditionalLineItem + skip_unchanged = True + report_skipped = False + clean_model_instances = True + + class PurchaseOrderLineItemAdmin(ImportExportModelAdmin): resource_class = POLineItemResource @@ -154,6 +164,20 @@ class SalesOrderLineItemAdmin(ImportExportModelAdmin): autocomplete_fields = ('order', 'part',) +class SalesOrderAdditionalLineItemAdmin(ImportExportModelAdmin): + + resource_class = SOAdditionalLineItemResource + + list_display = ( + 'order', + 'title', + 'quantity', + 'reference' + ) + + autocomplete_fields = ('order', ) + + class SalesOrderShipmentAdmin(ImportExportModelAdmin): list_display = [ @@ -187,6 +211,7 @@ admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin) admin.site.register(SalesOrder, SalesOrderAdmin) admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin) +admin.site.register(SalesOrderAdditionalLineItem, SalesOrderAdditionalLineItemAdmin) admin.site.register(SalesOrderShipment, SalesOrderShipmentAdmin) admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 2d079f8d45..247e391767 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -743,6 +743,61 @@ class SOLineItemList(generics.ListCreateAPIView): ] +class SOAdditionalLineItemList(generics.ListCreateAPIView): + """ + API endpoint for accessing a list of SalesOrderAdditionalLineItem objects. + """ + + queryset = models.SalesOrderAdditionalLineItem.objects.all() + serializer_class = serializers.SOAdditionalLineItemSerializer + + def get_serializer(self, *args, **kwargs): + try: + params = self.request.query_params + + kwargs['order_detail'] = str2bool(params.get('order_detail', False)) + except AttributeError: + pass + + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) + + def get_queryset(self, *args, **kwargs): + + queryset = super().get_queryset(*args, **kwargs) + + queryset = queryset.prefetch_related( + 'order', + ) + + return queryset + + filter_backends = [ + rest_filters.DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter + ] + + ordering_fields = [ + 'title', + 'quantity', + 'note', + 'reference', + ] + + search_fields = [ + 'title', + 'quantity', + 'note', + 'reference' + ] + + filter_fields = [ + 'order', + ] + + class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a SalesOrderLineItem object """ diff --git a/InvenTree/order/migrations/0064_auto_20220304_0004.py b/InvenTree/order/migrations/0064_auto_20220304_0004.py new file mode 100644 index 0000000000..01ef4bb58e --- /dev/null +++ b/InvenTree/order/migrations/0064_auto_20220304_0004.py @@ -0,0 +1,47 @@ +# Generated by Django 3.2.12 on 2022-03-04 00:04 + +import InvenTree.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import djmoney.models.fields +import djmoney.models.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0063_alter_purchaseorderlineitem_unique_together'), + ] + + operations = [ + migrations.AddField( + model_name='salesorder', + name='checksum', + field=models.CharField(blank=True, help_text='Stored order checksum', max_length=128, verbose_name='order checksum'), + ), + migrations.AddField( + model_name='salesorder', + name='sell_price', + field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=4, default_currency='', help_text='Price for this sale order', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Sell Price'), + ), + migrations.AddField( + model_name='salesorder', + name='sell_price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3), + ), + migrations.CreateModel( + name='SalesOrderAdditionalLineItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Item quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')), + ('reference', models.CharField(blank=True, help_text='Line item reference', max_length=100, verbose_name='Reference')), + ('notes', models.CharField(blank=True, help_text='Line item notes', max_length=500, verbose_name='Notes')), + ('target_date', models.DateField(blank=True, help_text='Target shipping date for this line item', null=True, verbose_name='Target Date')), + ('title', models.CharField(help_text='titel of the additional line', max_length=250, verbose_name='title')), + ('sale_price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)), + ('sale_price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=4, default_currency='', help_text='Unit sale price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Sale Price')), + ('order', models.ForeignKey(help_text='Sales Order', on_delete=django.db.models.deletion.CASCADE, related_name='additional_lines', to='order.salesorder', verbose_name='Order')), + ], + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index f08880a882..8ca3992494 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -5,6 +5,7 @@ Order model definitions # -*- coding: utf-8 -*- import os +import hashlib from datetime import datetime from decimal import Decimal @@ -21,6 +22,10 @@ from django.utils.translation import ugettext_lazy as _ from markdownx.models import MarkdownxField from mptt.models import TreeForeignKey +from djmoney.contrib.exchange.models import convert_money +from djmoney.money import Money +from common.settings import currency_code_default + from users import models as UserModels from part import models as PartModels from stock import models as stock_models @@ -609,6 +614,75 @@ class SalesOrder(Order): return query.exists() + checksum = models.CharField(max_length=128, blank=True, verbose_name=_('order checksum'), help_text=_('Stored order checksum')) + + sell_price = InvenTreeModelMoneyField( + max_digits=19, + decimal_places=4, + blank=True, null=True, + verbose_name=_('Sell Price'), + help_text=_('Price for this sale order'), + ) + + def get_hash(self): + """ Return a checksum hash for this sale order. """ + + hash = hashlib.md5(str(self.id).encode()) + + # hash own values + hash.update(str(self.customer.id).encode()) + hash.update(str(self.customer_reference).encode()) + hash.update(str(self.target_date).encode()) + hash.update(str(self.reference).encode()) + hash.update(str(self.link).encode()) + hash.update(str(self.notes).encode()) + hash.update(str(self.sell_price).encode()) + hash.update(str(self.sell_price_currency).encode()) + + # List *all* items + items = self.lines.all() + for item in items: + hash.update(str(item.get_item_hash()).encode()) + + return str(hash.digest()) + + def is_valid(self): + """ Check if the sale order is 'valid' - if the calculated checksum matches the stored value + """ + return self.get_hash() == self.checksum or not self.sell_price + + @transaction.atomic + def validate(self, user): + """ Validate the sale order + - Calculates and stores the hash for the sale order + """ + self.checksum = self.get_hash() + self.save() + + def get_total_price(self): + """ + Calculates the total price of all order lines + """ + target_currency = self.sell_price_currency if self.sell_price else currency_code_default() + total = Money(0, target_currency) + + # order items + total += sum([a.quantity * convert_money(a.sale_price, target_currency) for a in self.lines.all() if a.sale_price]) + + # additional lines + total += sum([a.quantity * convert_money(a.sale_price, target_currency) for a in self.additional_lines.all() if a.sale_price]) + + # set decimal-places + total.decimal_places = 4 + return total + + @property + def is_price_total(self): + """ + Returns true if the set sale price and the calculated total price are equal + """ + return self.get_total_price() == self.sell_price + @property def is_pending(self): return self.status == SalesOrderStatus.PENDING @@ -1163,6 +1237,38 @@ class SalesOrderShipment(models.Model): trigger_event('salesordershipment.completed', id=self.pk) +class SalesOrderAdditionalLineItem(OrderLineItem): + """ + Model for a single AdditionalLineItem in a SalesOrder + Attributes: + order: Link to the SalesOrder that this line item belongs to + title: titile of line item + sale_price: The unit sale price for this OrderLineItem + """ + + order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='additional_lines', verbose_name=_('Order'), help_text=_('Sales Order')) + + title = models.CharField(verbose_name=_('title'), help_text=_('titel of the additional line'), max_length=250) + + sale_price = InvenTreeModelMoneyField( + max_digits=19, + decimal_places=4, + null=True, blank=True, + verbose_name=_('Sale Price'), + help_text=_('Unit sale price'), + ) + + def sale_price_converted(self): + return convert_money(self.sale_price, currency_code_default()) + + def sale_price_converted_currency(self): + return currency_code_default() + + class Meta: + unique_together = [ + ] + + class SalesOrderAllocation(models.Model): """ This model is used to 'allocate' stock items to a SalesOrder. diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 2f4c1ea5df..4fb346c7a5 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -515,6 +515,21 @@ class SalesOrderSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSeria reference = serializers.CharField(required=True) + sell_price = InvenTreeMoneySerializer( + max_digits=19, + decimal_places=4, + allow_null=True + ) + + sell_price_string = serializers.CharField(source='sell_price', read_only=True) + + sell_price_currency = serializers.ChoiceField( + choices=currency_code_mappings(), + help_text=_('Sell price currency'), + ) + + total_price_string = serializers.CharField(source='get_total_price', read_only=True) + class Meta: model = order.models.SalesOrder @@ -535,6 +550,11 @@ class SalesOrderSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSeria 'status_text', 'shipment_date', 'target_date', + 'sell_price', + 'sell_price_string', + 'sell_price_currency', + 'total_price_string', + 'is_valid', ] read_only_fields = [ @@ -672,6 +692,16 @@ class SOLineItemSerializer(InvenTreeModelSerializer): help_text=_('Sale price currency'), ) + sale_price_converted = InvenTreeMoneySerializer( + max_digits=19, + decimal_places=4, + allow_null=True + ) + + sale_price_converted_string = serializers.CharField(source='sale_price_converted', read_only=True) + + sale_price_converted_currency = serializers.CharField(read_only=True) + class Meta: model = order.models.SalesOrderLineItem @@ -694,6 +724,67 @@ class SOLineItemSerializer(InvenTreeModelSerializer): 'target_date', ] + line_item = serializers.PrimaryKeyRelatedField( + queryset=order.models.SalesOrderLineItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Stock Item'), + ) + + def validate_line_item(self, line_item): + + order = self.context['order'] + + # Ensure that the line item points to the correct order + if line_item.order != order: + raise ValidationError(_("Line item is not associated with this order")) + + return line_item + + stock_item = serializers.PrimaryKeyRelatedField( + queryset=stock.models.StockItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Stock Item'), + ) + + quantity = serializers.DecimalField( + max_digits=15, + decimal_places=5, + min_value=0, + required=True + ) + + def validate_quantity(self, quantity): + + if quantity <= 0: + raise ValidationError(_("Quantity must be positive")) + + return quantity + + def validate(self, data): + + data = super().validate(data) + + stock_item = data['stock_item'] + quantity = data['quantity'] + + if stock_item.serialized and quantity != 1: + raise ValidationError({ + 'quantity': _("Quantity must be 1 for serialized stock item"), + }) + + q = normalize(stock_item.unallocated_quantity()) + + if quantity > q: + raise ValidationError({ + 'quantity': _(f"Available quantity ({q}) exceeded") + }) + + return data + class SalesOrderShipmentSerializer(InvenTreeModelSerializer): """ @@ -1099,6 +1190,64 @@ class SOShipmentAllocationSerializer(serializers.Serializer): ) +class SOAdditionalLineItemSerializer(InvenTreeModelSerializer): + """ Serializer for a SalesOrderAdditionalLineItem object """ + def __init__(self, *args, **kwargs): + + order_detail = kwargs.pop('order_detail', False) + + super().__init__(*args, **kwargs) + + if order_detail is not True: + self.fields.pop('order_detail') + + order_detail = SalesOrderSerializer(source='order', many=False, read_only=True) + + quantity = serializers.FloatField() + + sale_price = InvenTreeMoneySerializer( + max_digits=19, + decimal_places=4, + allow_null=True + ) + + sale_price_string = serializers.CharField(source='sale_price', read_only=True) + + sale_price_currency = serializers.ChoiceField( + choices=currency_code_mappings(), + help_text=_('Sale price currency'), + ) + + sale_price_converted = InvenTreeMoneySerializer( + max_digits=19, + decimal_places=4, + allow_null=True + ) + + sale_price_converted_string = serializers.CharField(source='sale_price_converted', read_only=True) + + sale_price_converted_currency = serializers.CharField(read_only=True) + + class Meta: + model = order.models.SalesOrderAdditionalLineItem + + fields = [ + 'pk', + 'quantity', + 'reference', + 'notes', + 'order', + 'order_detail', + 'title', + 'sale_price', + 'sale_price_currency', + 'sale_price_string', + 'sale_price_converted', + 'sale_price_converted_currency', + 'sale_price_converted_string', + ] + + class SOAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializers for the SalesOrderAttachment model diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 423090f917..37f8fe9fde 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -183,6 +183,16 @@ src="{% static 'img/blank_image.png' %}" {{ order.responsible }} {% endif %} + + {% if order.sell_price %} + + + {% trans "Sell Price" %} + + {% trans "Loading..." %} + + + {% endif %} {% endblock %} @@ -202,6 +212,8 @@ $("#edit-order").click(function() { {% endif %} customer_reference: {}, description: {}, + sell_price: {}, + sell_price_currency: {}, target_date: { icon: 'fa-calendar-alt', }, diff --git a/InvenTree/order/templates/order/validate.html b/InvenTree/order/templates/order/validate.html new file mode 100644 index 0000000000..9f809163f9 --- /dev/null +++ b/InvenTree/order/templates/order/validate.html @@ -0,0 +1,9 @@ +{% extends "modal_form.html" %} + +{% load i18n %} +{% load inventree_extras %} + +{% block pre_form_content %} +{% blocktrans %}Confirm that the order is valid for:
{{ order }} with a price of {{price}} (calculated price is {{price_calc}}, difference {{ price_diff }}){% endblocktrans %} + +{% endblock %} \ No newline at end of file