[FR] Add delivery cost (excluding unit cost that already exists) in PO

Fixes #2694
This commit is contained in:
Matthias 2022-03-04 01:06:39 +01:00
parent cb76b13eb2
commit 157f0e72a7
No known key found for this signature in database
GPG Key ID: F50EF5741D33E076
7 changed files with 404 additions and 1 deletions

View File

@ -9,7 +9,7 @@ from import_export.resources import ModelResource
from import_export.fields import Field from import_export.fields import Field
from .models import PurchaseOrder, PurchaseOrderLineItem from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import SalesOrder, SalesOrderLineItem from .models import SalesOrder, SalesOrderLineItem, SalesOrderAdditionalLineItem
from .models import SalesOrderShipment, SalesOrderAllocation from .models import SalesOrderShipment, SalesOrderAllocation
@ -117,6 +117,16 @@ class SOLineItemResource(ModelResource):
clean_model_instances = True 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): class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
resource_class = POLineItemResource resource_class = POLineItemResource
@ -154,6 +164,20 @@ class SalesOrderLineItemAdmin(ImportExportModelAdmin):
autocomplete_fields = ('order', 'part',) autocomplete_fields = ('order', 'part',)
class SalesOrderAdditionalLineItemAdmin(ImportExportModelAdmin):
resource_class = SOAdditionalLineItemResource
list_display = (
'order',
'title',
'quantity',
'reference'
)
autocomplete_fields = ('order', )
class SalesOrderShipmentAdmin(ImportExportModelAdmin): class SalesOrderShipmentAdmin(ImportExportModelAdmin):
list_display = [ list_display = [
@ -187,6 +211,7 @@ admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin)
admin.site.register(SalesOrder, SalesOrderAdmin) admin.site.register(SalesOrder, SalesOrderAdmin)
admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin) admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin)
admin.site.register(SalesOrderAdditionalLineItem, SalesOrderAdditionalLineItemAdmin)
admin.site.register(SalesOrderShipment, SalesOrderShipmentAdmin) admin.site.register(SalesOrderShipment, SalesOrderShipmentAdmin)
admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin) admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin)

View File

@ -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): class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of a SalesOrderLineItem object """ """ API endpoint for detail view of a SalesOrderLineItem object """

View File

@ -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')),
],
),
]

View File

@ -5,6 +5,7 @@ Order model definitions
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import os import os
import hashlib
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
@ -21,6 +22,10 @@ from django.utils.translation import ugettext_lazy as _
from markdownx.models import MarkdownxField from markdownx.models import MarkdownxField
from mptt.models import TreeForeignKey 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 users import models as UserModels
from part import models as PartModels from part import models as PartModels
from stock import models as stock_models from stock import models as stock_models
@ -609,6 +614,75 @@ class SalesOrder(Order):
return query.exists() 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 @property
def is_pending(self): def is_pending(self):
return self.status == SalesOrderStatus.PENDING return self.status == SalesOrderStatus.PENDING
@ -1163,6 +1237,38 @@ class SalesOrderShipment(models.Model):
trigger_event('salesordershipment.completed', id=self.pk) 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): class SalesOrderAllocation(models.Model):
""" """
This model is used to 'allocate' stock items to a SalesOrder. This model is used to 'allocate' stock items to a SalesOrder.

View File

@ -515,6 +515,21 @@ class SalesOrderSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSeria
reference = serializers.CharField(required=True) 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: class Meta:
model = order.models.SalesOrder model = order.models.SalesOrder
@ -535,6 +550,11 @@ class SalesOrderSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSeria
'status_text', 'status_text',
'shipment_date', 'shipment_date',
'target_date', 'target_date',
'sell_price',
'sell_price_string',
'sell_price_currency',
'total_price_string',
'is_valid',
] ]
read_only_fields = [ read_only_fields = [
@ -672,6 +692,16 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
help_text=_('Sale price currency'), 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: class Meta:
model = order.models.SalesOrderLineItem model = order.models.SalesOrderLineItem
@ -694,6 +724,67 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
'target_date', '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): 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): class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
""" """
Serializers for the SalesOrderAttachment model Serializers for the SalesOrderAttachment model

View File

@ -183,6 +183,16 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ order.responsible }}</td> <td>{{ order.responsible }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if order.sell_price %}
<tr>
<td><span class='fas fa-users'></span></td>
<td>{% trans "Sell Price" %}</td>
<td id='sales-order-sell-price'>
{% trans "Loading..." %}
</td>
</tr>
{% endif %}
</table> </table>
{% endblock %} {% endblock %}
@ -202,6 +212,8 @@ $("#edit-order").click(function() {
{% endif %} {% endif %}
customer_reference: {}, customer_reference: {},
description: {}, description: {},
sell_price: {},
sell_price_currency: {},
target_date: { target_date: {
icon: 'fa-calendar-alt', icon: 'fa-calendar-alt',
}, },

View File

@ -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:<br><i>{{ order }}</i> with a price of {{price}} (calculated price is {{price_calc}}, difference <span class="text-{{ diff_symbol }}">{{ price_diff }}</span>){% endblocktrans %}
{% endblock %}