mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 12:35:46 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into part-import
This commit is contained in:
@ -22,9 +22,10 @@ from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from .models import PurchaseOrderAttachment
|
||||
from .serializers import POSerializer, POLineItemSerializer, POAttachmentSerializer
|
||||
|
||||
from .models import SalesOrder, SalesOrderLineItem
|
||||
from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
|
||||
from .models import SalesOrderAttachment
|
||||
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
|
||||
from .serializers import SalesOrderAllocationSerializer
|
||||
|
||||
|
||||
class POList(generics.ListCreateAPIView):
|
||||
@ -422,17 +423,11 @@ class SOLineItemList(generics.ListCreateAPIView):
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
try:
|
||||
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
params = self.request.query_params
|
||||
|
||||
try:
|
||||
kwargs['order_detail'] = str2bool(self.request.query_params.get('order_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
kwargs['allocations'] = str2bool(self.request.query_params.get('allocations', False))
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
||||
kwargs['order_detail'] = str2bool(params.get('order_detail', False))
|
||||
kwargs['allocations'] = str2bool(params.get('allocations', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@ -486,6 +481,70 @@ class SOLineItemDetail(generics.RetrieveUpdateAPIView):
|
||||
serializer_class = SOLineItemSerializer
|
||||
|
||||
|
||||
class SOAllocationList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for listing SalesOrderAllocation objects
|
||||
"""
|
||||
|
||||
queryset = SalesOrderAllocation.objects.all()
|
||||
serializer_class = SalesOrderAllocationSerializer
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
try:
|
||||
params = self.request.query_params
|
||||
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
||||
kwargs['item_detail'] = str2bool(params.get('item_detail', False))
|
||||
kwargs['order_detail'] = str2bool(params.get('order_detail', False))
|
||||
kwargs['location_detail'] = str2bool(params.get('location_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
# Filter by order
|
||||
params = self.request.query_params
|
||||
|
||||
# Filter by "part" reference
|
||||
part = params.get('part', None)
|
||||
|
||||
if part is not None:
|
||||
queryset = queryset.filter(item__part=part)
|
||||
|
||||
# Filter by "order" reference
|
||||
order = params.get('order', None)
|
||||
|
||||
if order is not None:
|
||||
queryset = queryset.filter(line__order=order)
|
||||
|
||||
# Filter by "outstanding" order status
|
||||
outstanding = params.get('outstanding', None)
|
||||
|
||||
if outstanding is not None:
|
||||
outstanding = str2bool(outstanding)
|
||||
|
||||
if outstanding:
|
||||
queryset = queryset.filter(line__order__status__in=SalesOrderStatus.OPEN)
|
||||
else:
|
||||
queryset = queryset.exclude(line__order__status__in=SalesOrderStatus.OPEN)
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
]
|
||||
|
||||
# Default filterable fields
|
||||
filter_fields = [
|
||||
'item',
|
||||
]
|
||||
|
||||
|
||||
class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||
"""
|
||||
API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)
|
||||
@ -494,10 +553,6 @@ class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||
queryset = PurchaseOrderAttachment.objects.all()
|
||||
serializer_class = POAttachmentSerializer
|
||||
|
||||
filter_fields = [
|
||||
'order',
|
||||
]
|
||||
|
||||
|
||||
order_api_urls = [
|
||||
# API endpoints for purchase orders
|
||||
@ -512,14 +567,26 @@ order_api_urls = [
|
||||
url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'),
|
||||
|
||||
# API endpoints for sales ordesr
|
||||
url(r'^so/(?P<pk>\d+)/$', SODetail.as_view(), name='api-so-detail'),
|
||||
url(r'so/attachment/', include([
|
||||
url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'),
|
||||
url(r'^so/', include([
|
||||
url(r'^(?P<pk>\d+)/$', SODetail.as_view(), name='api-so-detail'),
|
||||
url(r'attachment/', include([
|
||||
url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'),
|
||||
])),
|
||||
|
||||
# List all sales orders
|
||||
url(r'^.*$', SOList.as_view(), name='api-so-list'),
|
||||
])),
|
||||
|
||||
url(r'^so/.*$', SOList.as_view(), name='api-so-list'),
|
||||
|
||||
# API endpoints for sales order line items
|
||||
url(r'^so-line/(?P<pk>\d+)/$', SOLineItemDetail.as_view(), name='api-so-line-detail'),
|
||||
url(r'^so-line/$', SOLineItemList.as_view(), name='api-so-line-list'),
|
||||
url(r'^so-line/', include([
|
||||
url(r'^(?P<pk>\d+)/$', SOLineItemDetail.as_view(), name='api-so-line-detail'),
|
||||
url(r'^$', SOLineItemList.as_view(), name='api-so-line-list'),
|
||||
])),
|
||||
|
||||
# API endpoints for sales order allocations
|
||||
url(r'^so-allocation', include([
|
||||
|
||||
# List all sales order allocations
|
||||
url(r'^.*$', SOAllocationList.as_view(), name='api-so-allocation-list'),
|
||||
])),
|
||||
]
|
||||
|
@ -68,6 +68,7 @@
|
||||
order: 1
|
||||
part: 1
|
||||
quantity: 100
|
||||
destination: 5 # Desk/Drawer_1
|
||||
|
||||
# 250 x ACME0002 (M2x4 LPHS)
|
||||
# Partially received (50)
|
||||
@ -95,3 +96,10 @@
|
||||
part: 3
|
||||
quantity: 100
|
||||
|
||||
# 1 x R_4K7_0603
|
||||
- model: order.purchaseorderlineitem
|
||||
pk: 23
|
||||
fields:
|
||||
order: 1
|
||||
part: 5
|
||||
quantity: 1
|
||||
|
@ -86,12 +86,17 @@ class ShipSalesOrderForm(HelperForm):
|
||||
|
||||
class ReceivePurchaseOrderForm(HelperForm):
|
||||
|
||||
location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), required=True, label=_('Location'), help_text=_('Receive parts to this location'))
|
||||
location = TreeNodeChoiceField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
required=True,
|
||||
label=_("Destination"),
|
||||
help_text=_("Receive parts to this location"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
fields = [
|
||||
'location',
|
||||
"location",
|
||||
]
|
||||
|
||||
|
||||
@ -202,6 +207,7 @@ class EditPurchaseOrderLineItemForm(HelperForm):
|
||||
'quantity',
|
||||
'reference',
|
||||
'purchase_price',
|
||||
'destination',
|
||||
'notes',
|
||||
]
|
||||
|
||||
|
@ -0,0 +1,29 @@
|
||||
# Generated by Django 3.2 on 2021-05-13 22:38
|
||||
|
||||
from django.db import migrations
|
||||
import django.db.models.deletion
|
||||
import mptt.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("stock", "0063_auto_20210511_2343"),
|
||||
("order", "0045_auto_20210504_1946"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchaseorderlineitem",
|
||||
name="destination",
|
||||
field=mptt.fields.TreeForeignKey(
|
||||
blank=True,
|
||||
help_text="Where does the Purchaser want this item to be stored?",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="po_lines",
|
||||
to="stock.stocklocation",
|
||||
verbose_name="Destination",
|
||||
),
|
||||
),
|
||||
]
|
@ -20,6 +20,7 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from common.settings import currency_code_default
|
||||
|
||||
from markdownx.models import MarkdownxField
|
||||
from mptt.models import TreeForeignKey
|
||||
|
||||
from djmoney.models.fields import MoneyField
|
||||
|
||||
@ -672,6 +673,29 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
help_text=_('Unit purchase price'),
|
||||
)
|
||||
|
||||
destination = TreeForeignKey(
|
||||
'stock.StockLocation', on_delete=models.DO_NOTHING,
|
||||
verbose_name=_('Destination'),
|
||||
related_name='po_lines',
|
||||
blank=True, null=True,
|
||||
help_text=_('Where does the Purchaser want this item to be stored?')
|
||||
)
|
||||
|
||||
def get_destination(self):
|
||||
"""Show where the line item is or should be placed"""
|
||||
# NOTE: If a line item gets split when recieved, only an arbitrary
|
||||
# stock items location will be reported as the location for the
|
||||
# entire line.
|
||||
for stock in stock_models.StockItem.objects.filter(
|
||||
supplier_part=self.part, purchase_order=self.order
|
||||
):
|
||||
if stock.location:
|
||||
return stock.location
|
||||
if self.destination:
|
||||
return self.destination
|
||||
if self.part and self.part.part and self.part.part.default_location:
|
||||
return self.part.part.default_location
|
||||
|
||||
def remaining(self):
|
||||
""" Calculate the number of items remaining to be received """
|
||||
r = self.quantity - self.received
|
||||
|
@ -17,6 +17,8 @@ from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||
|
||||
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
||||
from part.serializers import PartBriefSerializer
|
||||
from stock.serializers import LocationBriefSerializer
|
||||
from stock.serializers import StockItemSerializer, LocationSerializer
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from .models import PurchaseOrderAttachment, SalesOrderAttachment
|
||||
@ -41,7 +43,7 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
"""
|
||||
Add extra information to the queryset
|
||||
|
||||
- Number of liens in the PurchaseOrder
|
||||
- Number of lines in the PurchaseOrder
|
||||
- Overdue status of the PurchaseOrder
|
||||
"""
|
||||
|
||||
@ -116,6 +118,8 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
|
||||
|
||||
destination = LocationBriefSerializer(source='get_destination', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderLineItem
|
||||
|
||||
@ -132,6 +136,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
'purchase_price',
|
||||
'purchase_price_currency',
|
||||
'purchase_price_string',
|
||||
'destination',
|
||||
]
|
||||
|
||||
|
||||
@ -232,11 +237,38 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
||||
This includes some fields from the related model objects.
|
||||
"""
|
||||
|
||||
location_path = serializers.CharField(source='get_location_path')
|
||||
location_id = serializers.IntegerField(source='get_location')
|
||||
serial = serializers.CharField(source='get_serial')
|
||||
po = serializers.CharField(source='get_po')
|
||||
quantity = serializers.FloatField()
|
||||
part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
|
||||
order = serializers.PrimaryKeyRelatedField(source='line.order', many=False, read_only=True)
|
||||
serial = serializers.CharField(source='get_serial', read_only=True)
|
||||
quantity = serializers.FloatField(read_only=True)
|
||||
location = serializers.PrimaryKeyRelatedField(source='item.location', many=False, read_only=True)
|
||||
|
||||
# Extra detail fields
|
||||
order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True)
|
||||
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
|
||||
item_detail = StockItemSerializer(source='item', many=False, read_only=True)
|
||||
location_detail = LocationSerializer(source='item.location', many=False, read_only=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
order_detail = kwargs.pop('order_detail', False)
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
item_detail = kwargs.pop('item_detail', False)
|
||||
location_detail = kwargs.pop('location_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not order_detail:
|
||||
self.fields.pop('order_detail')
|
||||
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail')
|
||||
|
||||
if not item_detail:
|
||||
self.fields.pop('item_detail')
|
||||
|
||||
if not location_detail:
|
||||
self.fields.pop('location_detail')
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderAllocation
|
||||
@ -246,10 +278,14 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
||||
'line',
|
||||
'serial',
|
||||
'quantity',
|
||||
'location_id',
|
||||
'location_path',
|
||||
'po',
|
||||
'location',
|
||||
'location_detail',
|
||||
'item',
|
||||
'item_detail',
|
||||
'order',
|
||||
'order_detail',
|
||||
'part',
|
||||
'part_detail',
|
||||
]
|
||||
|
||||
|
||||
|
@ -117,6 +117,7 @@ $("#po-table").inventreeTable({
|
||||
part_detail: true,
|
||||
},
|
||||
url: "{% url 'api-po-line-list' %}",
|
||||
showFooter: true,
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
@ -137,6 +138,9 @@ $("#po-table").inventreeTable({
|
||||
return '-';
|
||||
}
|
||||
},
|
||||
footerFormatter: function() {
|
||||
return '{% trans "Total" %}'
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'part_detail.description',
|
||||
@ -172,7 +176,14 @@ $("#po-table").inventreeTable({
|
||||
{
|
||||
sortable: true,
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}'
|
||||
title: '{% trans "Quantity" %}',
|
||||
footerFormatter: function(data) {
|
||||
return data.map(function (row) {
|
||||
return +row['quantity']
|
||||
}).reduce(function (sum, i) {
|
||||
return sum + i
|
||||
}, 0)
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
@ -182,6 +193,25 @@ $("#po-table").inventreeTable({
|
||||
return row.purchase_price_string || row.purchase_price;
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
title: '{% trans "Total price" %}',
|
||||
formatter: function(value, row) {
|
||||
var total = row.purchase_price * row.quantity;
|
||||
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.purchase_price_currency});
|
||||
return formatter.format(total)
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
var total = data.map(function (row) {
|
||||
return +row['purchase_price']*row['quantity']
|
||||
}).reduce(function (sum, i) {
|
||||
return sum + i
|
||||
}, 0)
|
||||
var currency = (data.slice(-1)[0] && data.slice(-1)[0].purchase_price_currency) || 'USD';
|
||||
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: currency});
|
||||
return formatter.format(total)
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'received',
|
||||
@ -204,6 +234,10 @@ $("#po-table").inventreeTable({
|
||||
return (progressA < progressB) ? 1 : -1;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'destination.pathstring',
|
||||
title: '{% trans "Destination" %}',
|
||||
},
|
||||
{
|
||||
field: 'notes',
|
||||
title: '{% trans "Notes" %}',
|
||||
|
@ -22,6 +22,7 @@
|
||||
<th>{% trans "Received" %}</th>
|
||||
<th>{% trans "Receive" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th>{% trans "Destination" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for line in lines %}
|
||||
@ -53,6 +54,9 @@
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ line.get_destination }}
|
||||
</td>
|
||||
<td>
|
||||
<button class='btn btn-default btn-remove' onClick="removeOrderRowFromOrderWizard()" id='del_item_{{ line.id }}' title='{% trans "Remove line" %}' type='button'>
|
||||
<span row='line_row_{{ line.id }}' class='fas fa-times-circle icon-red'></span>
|
||||
|
@ -81,10 +81,10 @@ function showAllocationSubTable(index, row, element) {
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'location_id',
|
||||
field: 'location',
|
||||
title: 'Location',
|
||||
formatter: function(value, row, index, field) {
|
||||
return renderLink(row.location_path, `/stock/location/${row.location_id}/`);
|
||||
return renderLink(row.location_path, `/stock/location/${row.location}/`);
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -199,6 +199,7 @@ $("#so-lines-table").inventreeTable({
|
||||
detailFormatter: showFulfilledSubTable,
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
showFooter: true,
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
@ -217,7 +218,10 @@ $("#so-lines-table").inventreeTable({
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
},
|
||||
footerFormatter: function() {
|
||||
return '{% trans "Total" %}'
|
||||
},
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
@ -228,6 +232,13 @@ $("#so-lines-table").inventreeTable({
|
||||
sortable: true,
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}',
|
||||
footerFormatter: function(data) {
|
||||
return data.map(function (row) {
|
||||
return +row['quantity']
|
||||
}).reduce(function (sum, i) {
|
||||
return sum + i
|
||||
}, 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
@ -237,6 +248,26 @@ $("#so-lines-table").inventreeTable({
|
||||
return row.sale_price_string || row.sale_price;
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
title: '{% trans "Total price" %}',
|
||||
formatter: function(value, row) {
|
||||
var total = row.sale_price * row.quantity;
|
||||
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.sale_price_currency});
|
||||
return formatter.format(total)
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
var total = data.map(function (row) {
|
||||
return +row['sale_price']*row['quantity']
|
||||
}).reduce(function (sum, i) {
|
||||
return sum + i
|
||||
}, 0)
|
||||
var currency = (data.slice(-1)[0] && data.slice(-1)[0].sale_price_currency) || 'USD';
|
||||
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: currency});
|
||||
return formatter.format(total)
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
field: 'allocated',
|
||||
{% if order.status == SalesOrderStatus.PENDING %}
|
||||
|
@ -87,7 +87,7 @@ class OrderTest(TestCase):
|
||||
order = PurchaseOrder.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
|
||||
self.assertEqual(order.lines.count(), 3)
|
||||
self.assertEqual(order.lines.count(), 4)
|
||||
|
||||
sku = SupplierPart.objects.get(SKU='ACME-WIDGET')
|
||||
part = sku.part
|
||||
@ -105,11 +105,11 @@ class OrderTest(TestCase):
|
||||
order.add_line_item(sku, 100)
|
||||
|
||||
self.assertEqual(part.on_order, 100)
|
||||
self.assertEqual(order.lines.count(), 4)
|
||||
self.assertEqual(order.lines.count(), 5)
|
||||
|
||||
# Order the same part again (it should be merged)
|
||||
order.add_line_item(sku, 50)
|
||||
self.assertEqual(order.lines.count(), 4)
|
||||
self.assertEqual(order.lines.count(), 5)
|
||||
self.assertEqual(part.on_order, 150)
|
||||
|
||||
# Try to order a supplier part from the wrong supplier
|
||||
@ -163,7 +163,7 @@ class OrderTest(TestCase):
|
||||
loc = StockLocation.objects.get(id=1)
|
||||
|
||||
# There should be two lines against this order
|
||||
self.assertEqual(len(order.pending_line_items()), 3)
|
||||
self.assertEqual(len(order.pending_line_items()), 4)
|
||||
|
||||
# Should fail, as order is 'PENDING' not 'PLACED"
|
||||
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
|
||||
|
Reference in New Issue
Block a user