[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 .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)

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):
""" 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 -*-
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.

View File

@ -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

View File

@ -183,6 +183,16 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ order.responsible }}</td>
</tr>
{% 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>
{% endblock %}
@ -202,6 +212,8 @@ $("#edit-order").click(function() {
{% endif %}
customer_reference: {},
description: {},
sell_price: {},
sell_price_currency: {},
target_date: {
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 %}