mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-16 20:15:44 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into price-history
This commit is contained in:
@ -64,7 +64,7 @@ class CancelSalesOrderForm(HelperForm):
|
||||
fields = [
|
||||
'confirm',
|
||||
]
|
||||
|
||||
|
||||
|
||||
class ShipSalesOrderForm(HelperForm):
|
||||
|
||||
@ -211,6 +211,7 @@ class EditSalesOrderLineItemForm(HelperForm):
|
||||
'part',
|
||||
'quantity',
|
||||
'reference',
|
||||
'sale_price',
|
||||
'notes'
|
||||
]
|
||||
|
||||
|
24
InvenTree/order/migrations/0045_auto_20210504_1946.py
Normal file
24
InvenTree/order/migrations/0045_auto_20210504_1946.py
Normal file
@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.2 on 2021-05-04 19:46
|
||||
|
||||
from django.db import migrations
|
||||
import djmoney.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0044_auto_20210404_2016'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='salesorderlineitem',
|
||||
name='sale_price',
|
||||
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency='USD', help_text='Unit sale price', max_digits=19, null=True, verbose_name='Sale Price'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesorderlineitem',
|
||||
name='sale_price_currency',
|
||||
field=djmoney.models.fields.CurrencyField(choices=[('AUD', 'Australian Dollar'), ('GBP', 'British Pound'), ('CAD', 'Canadian Dollar'), ('EUR', 'Euro'), ('JPY', 'Japanese Yen'), ('NZD', 'New Zealand Dollar'), ('USD', 'US Dollar')], default='USD', editable=False, max_length=3),
|
||||
),
|
||||
]
|
@ -309,7 +309,7 @@ class PurchaseOrder(Order):
|
||||
"""
|
||||
A PurchaseOrder can only be cancelled under the following circumstances:
|
||||
"""
|
||||
|
||||
|
||||
return self.status in [
|
||||
PurchaseOrderStatus.PLACED,
|
||||
PurchaseOrderStatus.PENDING
|
||||
@ -367,7 +367,7 @@ class PurchaseOrder(Order):
|
||||
stock.save()
|
||||
|
||||
text = _("Received items")
|
||||
note = f"{_('Received')} {quantity} {_('items against order')} {str(self)}"
|
||||
note = _('Received {n} items against order {name}').format(n=quantity, name=str(self))
|
||||
|
||||
# Add a new transaction note to the newly created stock item
|
||||
stock.addTransactionNote(text, user, note)
|
||||
@ -378,7 +378,7 @@ class PurchaseOrder(Order):
|
||||
|
||||
# Has this order been completed?
|
||||
if len(self.pending_line_items()) == 0:
|
||||
|
||||
|
||||
self.received_by = user
|
||||
self.complete_order() # This will save the model
|
||||
|
||||
@ -419,7 +419,7 @@ class SalesOrder(Order):
|
||||
except (ValueError, TypeError):
|
||||
# Date processing error, return queryset unchanged
|
||||
return queryset
|
||||
|
||||
|
||||
# Construct a queryset for "completed" orders within the range
|
||||
completed = Q(status__in=SalesOrderStatus.COMPLETE) & Q(shipment_date__gte=min_date) & Q(shipment_date__lte=max_date)
|
||||
|
||||
@ -495,7 +495,7 @@ class SalesOrder(Order):
|
||||
for line in self.lines.all():
|
||||
if not line.is_fully_allocated():
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
|
||||
def is_over_allocated(self):
|
||||
@ -590,11 +590,11 @@ class SalesOrderAttachment(InvenTreeAttachment):
|
||||
|
||||
class OrderLineItem(models.Model):
|
||||
""" Abstract model for an order line item
|
||||
|
||||
|
||||
Attributes:
|
||||
quantity: Number of items
|
||||
note: Annotation for the item
|
||||
|
||||
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
@ -603,13 +603,13 @@ class OrderLineItem(models.Model):
|
||||
quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, verbose_name=_('Quantity'), help_text=_('Item quantity'))
|
||||
|
||||
reference = models.CharField(max_length=100, blank=True, verbose_name=_('Reference'), help_text=_('Line item reference'))
|
||||
|
||||
|
||||
notes = models.CharField(max_length=500, blank=True, verbose_name=_('Notes'), help_text=_('Line item notes'))
|
||||
|
||||
|
||||
class PurchaseOrderLineItem(OrderLineItem):
|
||||
""" Model for a purchase order line item.
|
||||
|
||||
|
||||
Attributes:
|
||||
order: Reference to a PurchaseOrder object
|
||||
|
||||
@ -637,7 +637,7 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
def get_base_part(self):
|
||||
""" Return the base-part for the line item """
|
||||
return self.part.part
|
||||
|
||||
|
||||
# TODO - Function callback for when the SupplierPart is deleted?
|
||||
|
||||
part = models.ForeignKey(
|
||||
@ -672,12 +672,22 @@ class SalesOrderLineItem(OrderLineItem):
|
||||
Attributes:
|
||||
order: Link to the SalesOrder that this line item belongs to
|
||||
part: Link to a Part object (may be null)
|
||||
sale_price: The unit sale price for this OrderLineItem
|
||||
"""
|
||||
|
||||
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='lines', verbose_name=_('Order'), help_text=_('Sales Order'))
|
||||
|
||||
part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, verbose_name=_('Part'), help_text=_('Part'), limit_choices_to={'salable': True})
|
||||
|
||||
sale_price = MoneyField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
default_currency='USD',
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Sale Price'),
|
||||
help_text=_('Unit sale price'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = [
|
||||
]
|
||||
|
@ -61,7 +61,7 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
return queryset
|
||||
|
||||
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
|
||||
|
||||
|
||||
line_items = serializers.IntegerField(read_only=True)
|
||||
|
||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
||||
@ -70,7 +70,7 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'issue_date',
|
||||
@ -89,7 +89,7 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
'target_date',
|
||||
'notes',
|
||||
]
|
||||
|
||||
|
||||
read_only_fields = [
|
||||
'reference',
|
||||
'status'
|
||||
@ -110,10 +110,10 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
quantity = serializers.FloatField()
|
||||
received = serializers.FloatField()
|
||||
|
||||
|
||||
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
|
||||
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
|
||||
|
||||
|
||||
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
|
||||
|
||||
class Meta:
|
||||
@ -144,7 +144,7 @@ class POAttachmentSerializer(InvenTreeModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderAttachment
|
||||
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'order',
|
||||
@ -270,7 +270,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
if allocations is not True:
|
||||
self.fields.pop('allocations')
|
||||
|
||||
|
||||
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
allocations = SalesOrderAllocationSerializer(many=True, read_only=True)
|
||||
@ -278,6 +278,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
||||
quantity = serializers.FloatField()
|
||||
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
|
||||
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
|
||||
sale_price_string = serializers.CharField(source='sale_price', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderLineItem
|
||||
@ -294,6 +295,9 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
||||
'order_detail',
|
||||
'part',
|
||||
'part_detail',
|
||||
'sale_price',
|
||||
'sale_price_currency',
|
||||
'sale_price_string',
|
||||
]
|
||||
|
||||
|
||||
@ -306,7 +310,7 @@ class SOAttachmentSerializer(InvenTreeModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderAttachment
|
||||
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'order',
|
||||
|
@ -44,7 +44,7 @@ $("#new-attachment").click(function() {
|
||||
|
||||
$("#attachment-table").on('click', '.attachment-edit-button', function() {
|
||||
var button = $(this);
|
||||
|
||||
|
||||
var url = `/order/purchase-order/attachment/${button.attr('pk')}/edit/`;
|
||||
|
||||
launchModalForm(url, {
|
||||
|
@ -1,5 +1,6 @@
|
||||
{% extends "modal_delete_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
Are you sure you wish to delete this line item?
|
||||
{% trans "Are you sure you wish to delete this line item?" %}
|
||||
{% endblock %}
|
@ -193,11 +193,11 @@ $("#po-table").inventreeTable({
|
||||
});
|
||||
},
|
||||
sorter: function(valA, valB, rowA, rowB) {
|
||||
|
||||
|
||||
if (rowA.received == 0 && rowB.received == 0) {
|
||||
return (rowA.quantity > rowB.quantity) ? 1 : -1;
|
||||
}
|
||||
|
||||
|
||||
var progressA = parseFloat(rowA.received) / rowA.quantity;
|
||||
var progressB = parseFloat(rowB.received) / rowB.quantity;
|
||||
|
||||
|
@ -83,7 +83,7 @@
|
||||
var title = `${prefix}${order.reference} - ${order.supplier_detail.name}`;
|
||||
|
||||
var color = '#4c68f5';
|
||||
|
||||
|
||||
if (order.complete_date) {
|
||||
color = '#25c235';
|
||||
} else if (order.overdue) {
|
||||
@ -143,16 +143,18 @@ $('#view-calendar').click(function() {
|
||||
$(".columns-right").hide();
|
||||
$(".search").hide();
|
||||
$('#filter-list-salesorder').hide();
|
||||
|
||||
|
||||
$("#purchase-order-calendar").show();
|
||||
$("#view-list").show();
|
||||
|
||||
calendar.render();
|
||||
});
|
||||
|
||||
$("#view-list").click(function() {
|
||||
// Hide the calendar view, show the list view
|
||||
$("#purchase-order-calendar").hide();
|
||||
$("#view-list").hide();
|
||||
|
||||
|
||||
$(".fixed-table-pagination").show();
|
||||
$(".columns-right").show();
|
||||
$(".search").show();
|
||||
|
@ -51,13 +51,13 @@ $("#new-so-line").click(function() {
|
||||
{% if order.status == SalesOrderStatus.PENDING %}
|
||||
function showAllocationSubTable(index, row, element) {
|
||||
// Construct a table showing stock items which have been allocated against this line item
|
||||
|
||||
|
||||
var html = `<div class='sub-table'><table class='table table-striped table-condensed' id='allocation-table-${row.pk}'></table></div>`;
|
||||
|
||||
element.html(html);
|
||||
|
||||
var lineItem = row;
|
||||
|
||||
|
||||
var table = $(`#allocation-table-${row.pk}`);
|
||||
|
||||
table.bootstrapTable({
|
||||
@ -70,7 +70,7 @@ function showAllocationSubTable(index, row, element) {
|
||||
title: '{% trans "Quantity" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
var text = '';
|
||||
|
||||
|
||||
if (row.serial != null && row.quantity == 1) {
|
||||
text = `{% trans "Serial Number" %}: ${row.serial}`;
|
||||
} else {
|
||||
@ -91,10 +91,10 @@ function showAllocationSubTable(index, row, element) {
|
||||
field: 'buttons',
|
||||
title: '{% trans "Actions" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
|
||||
|
||||
var html = "<div class='btn-group float-right' role='group'>";
|
||||
var pk = row.pk;
|
||||
|
||||
|
||||
{% if order.status == SalesOrderStatus.PENDING %}
|
||||
html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
|
||||
html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
|
||||
@ -223,6 +223,14 @@ $("#so-lines-table").inventreeTable({
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}',
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'sale_price',
|
||||
title: '{% trans "Unit Price" %}',
|
||||
formatter: function(value, row) {
|
||||
return row.sale_price_string || row.sale_price;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'allocated',
|
||||
{% if order.status == SalesOrderStatus.PENDING %}
|
||||
@ -248,11 +256,11 @@ $("#so-lines-table").inventreeTable({
|
||||
var A = rowA.fulfilled;
|
||||
var B = rowB.fulfilled;
|
||||
{% endif %}
|
||||
|
||||
|
||||
if (A == 0 && B == 0) {
|
||||
return (rowA.quantity > rowB.quantity) ? 1 : -1;
|
||||
}
|
||||
|
||||
|
||||
var progressA = parseFloat(A) / rowA.quantity;
|
||||
var progressB = parseFloat(B) / rowB.quantity;
|
||||
|
||||
@ -271,7 +279,7 @@ $("#so-lines-table").inventreeTable({
|
||||
var html = `<div class='btn-group float-right' role='group'>`;
|
||||
|
||||
var pk = row.pk;
|
||||
|
||||
|
||||
if (row.part) {
|
||||
var part = row.part_detail;
|
||||
|
||||
@ -279,18 +287,19 @@ $("#so-lines-table").inventreeTable({
|
||||
html += makeIconButton('fa-hashtag icon-green', 'button-add-by-sn', pk, '{% trans "Allocate serial numbers" %}');
|
||||
}
|
||||
|
||||
html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', pk, '{% trans "Allocate stock" %}');
|
||||
html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', pk, '{% trans "Allocate stock" %}');
|
||||
|
||||
if (part.purchaseable) {
|
||||
html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}');
|
||||
}
|
||||
|
||||
|
||||
if (part.assembly) {
|
||||
html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}');
|
||||
}
|
||||
|
||||
|
||||
html += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}');
|
||||
}
|
||||
|
||||
|
||||
html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}');
|
||||
html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line item " %}');
|
||||
|
||||
@ -388,6 +397,26 @@ function setupCallbacks() {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$(".button-price").click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
var idx = $(this).closest('tr').attr('data-index');
|
||||
var row = table.bootstrapTable('getData')[idx];
|
||||
|
||||
launchModalForm(
|
||||
"{% url 'line-pricing' %}",
|
||||
{
|
||||
submit_text: '{% trans "Calculate price" %}',
|
||||
data: {
|
||||
line_item: pk,
|
||||
quantity: row.quantity,
|
||||
},
|
||||
buttons: [{name: 'update_price',
|
||||
title: '{% trans "Update Unit Price" %}'},],
|
||||
success: reloadTable,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
{% endblock %}
|
@ -141,16 +141,18 @@ $('#view-calendar').click(function() {
|
||||
$(".columns-right").hide();
|
||||
$(".search").hide();
|
||||
$('#filter-list-salesorder').hide();
|
||||
|
||||
|
||||
$("#sales-order-calendar").show();
|
||||
$("#view-list").show();
|
||||
|
||||
calendar.render();
|
||||
});
|
||||
|
||||
$("#view-list").click(function() {
|
||||
// Hide the calendar view, show the list view
|
||||
$("#sales-order-calendar").hide();
|
||||
$("#view-list").hide();
|
||||
|
||||
|
||||
$(".fixed-table-pagination").show();
|
||||
$(".columns-right").show();
|
||||
$(".search").show();
|
||||
|
@ -94,7 +94,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
url = '/api/order/po/1/'
|
||||
|
||||
response = self.get(url)
|
||||
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = response.data
|
||||
@ -109,7 +109,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
response = self.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
|
||||
class SalesOrderTest(OrderTest):
|
||||
"""
|
||||
|
@ -73,7 +73,7 @@ class SalesOrderTest(TestCase):
|
||||
|
||||
def test_add_duplicate_line_item(self):
|
||||
# Adding a duplicate line item to a SalesOrder is accepted
|
||||
|
||||
|
||||
for ii in range(1, 5):
|
||||
SalesOrderLineItem.objects.create(order=self.order, part=self.part, quantity=ii)
|
||||
|
||||
@ -107,7 +107,7 @@ class SalesOrderTest(TestCase):
|
||||
self.assertTrue(self.order.is_fully_allocated())
|
||||
self.assertTrue(self.line.is_fully_allocated())
|
||||
self.assertEqual(self.line.allocated_quantity(), 50)
|
||||
|
||||
|
||||
def test_order_cancel(self):
|
||||
# Allocate line items then cancel the order
|
||||
|
||||
@ -154,7 +154,7 @@ class SalesOrderTest(TestCase):
|
||||
|
||||
for item in outputs.all():
|
||||
self.assertEqual(item.quantity, 25)
|
||||
|
||||
|
||||
self.assertEqual(sa.sales_order, None)
|
||||
self.assertEqual(sb.sales_order, None)
|
||||
|
||||
@ -162,7 +162,7 @@ class SalesOrderTest(TestCase):
|
||||
self.assertEqual(SalesOrderAllocation.objects.count(), 0)
|
||||
|
||||
self.assertEqual(self.order.status, status.SalesOrderStatus.SHIPPED)
|
||||
|
||||
|
||||
self.assertTrue(self.order.is_fully_allocated())
|
||||
self.assertTrue(self.line.is_fully_allocated())
|
||||
self.assertEqual(self.line.fulfilled_quantity(), 50)
|
||||
|
@ -17,7 +17,7 @@ import json
|
||||
|
||||
|
||||
class OrderViewTestCase(TestCase):
|
||||
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
@ -193,7 +193,7 @@ class POTests(OrderViewTestCase):
|
||||
# Test without confirmation
|
||||
response = self.client.post(url, {'confirm': 0}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
data = json.loads(response.content)
|
||||
|
||||
self.assertFalse(data['form_valid'])
|
||||
@ -221,7 +221,7 @@ class POTests(OrderViewTestCase):
|
||||
|
||||
# GET the form (pass the correct info)
|
||||
response = self.client.get(url, {'order': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
|
||||
post_data = {
|
||||
'part': 100,
|
||||
'quantity': 45,
|
||||
@ -303,7 +303,7 @@ class TestPOReceive(OrderViewTestCase):
|
||||
self.client.get(self.url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
def test_receive_lines(self):
|
||||
|
||||
|
||||
post_data = {
|
||||
}
|
||||
|
||||
@ -330,7 +330,7 @@ class TestPOReceive(OrderViewTestCase):
|
||||
|
||||
# Receive negative number
|
||||
post_data['line-1'] = -100
|
||||
|
||||
|
||||
self.post(post_data, validate=False)
|
||||
|
||||
# Receive 75 items
|
||||
|
@ -37,7 +37,7 @@ class OrderTest(TestCase):
|
||||
self.assertEqual(order.get_absolute_url(), '/order/purchase-order/1/')
|
||||
|
||||
self.assertEqual(str(order), 'PO0001 - ACME')
|
||||
|
||||
|
||||
line = PurchaseOrderLineItem.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO0001 - ACME)")
|
||||
@ -114,7 +114,7 @@ class OrderTest(TestCase):
|
||||
|
||||
# Try to order a supplier part from the wrong supplier
|
||||
sku = SupplierPart.objects.get(SKU='ZERG-WIDGET')
|
||||
|
||||
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
order.add_line_item(sku, 99)
|
||||
|
||||
@ -187,7 +187,7 @@ class OrderTest(TestCase):
|
||||
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
order.receive_line_item(line, loc, 'not a number', user=None)
|
||||
|
||||
|
||||
# Receive the rest of the items
|
||||
order.receive_line_item(line, loc, 50, user=None)
|
||||
|
||||
|
@ -31,6 +31,7 @@ purchase_order_urls = [
|
||||
url(r'^new/', views.PurchaseOrderCreate.as_view(), name='po-create'),
|
||||
|
||||
url(r'^order-parts/', views.OrderParts.as_view(), name='order-parts'),
|
||||
url(r'^pricing/', views.LineItemPricing.as_view(), name='line-pricing'),
|
||||
|
||||
# Display detail view for a single purchase order
|
||||
url(r'^(?P<pk>\d+)/', include(purchase_order_detail_urls)),
|
||||
|
@ -6,13 +6,14 @@ Django views for interacting with Order app
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import transaction
|
||||
from django.http.response import JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView, UpdateView
|
||||
from django.views.generic.edit import FormMixin
|
||||
from django.forms import HiddenInput
|
||||
from django.forms import HiddenInput, IntegerField
|
||||
|
||||
import logging
|
||||
from decimal import Decimal, InvalidOperation
|
||||
@ -29,6 +30,7 @@ from part.models import Part
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
from . import forms as order_forms
|
||||
from part.views import PartPricing
|
||||
|
||||
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
||||
from InvenTree.helpers import DownloadFile, str2bool
|
||||
@ -152,7 +154,7 @@ class SalesOrderAttachmentCreate(AjaxCreateView):
|
||||
"""
|
||||
Save the user that uploaded the attachment
|
||||
"""
|
||||
|
||||
|
||||
attachment = form.save(commit=False)
|
||||
attachment.user = self.request.user
|
||||
attachment.save()
|
||||
@ -330,7 +332,7 @@ class PurchaseOrderCreate(AjaxCreateView):
|
||||
|
||||
order = form.save(commit=False)
|
||||
order.created_by = self.request.user
|
||||
|
||||
|
||||
return super().save(form)
|
||||
|
||||
|
||||
@ -365,7 +367,7 @@ class SalesOrderCreate(AjaxCreateView):
|
||||
|
||||
order = form.save(commit=False)
|
||||
order.created_by = self.request.user
|
||||
|
||||
|
||||
return super().save(form)
|
||||
|
||||
|
||||
@ -414,7 +416,7 @@ class PurchaseOrderCancel(AjaxUpdateView):
|
||||
form_class = order_forms.CancelPurchaseOrderForm
|
||||
|
||||
def validate(self, order, form, **kwargs):
|
||||
|
||||
|
||||
confirm = str2bool(form.cleaned_data.get('confirm', False))
|
||||
|
||||
if not confirm:
|
||||
@ -536,11 +538,11 @@ class SalesOrderShip(AjaxUpdateView):
|
||||
|
||||
order = self.get_object()
|
||||
self.object = order
|
||||
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
confirm = str2bool(request.POST.get('confirm', False))
|
||||
|
||||
|
||||
valid = False
|
||||
|
||||
if not confirm:
|
||||
@ -823,7 +825,7 @@ class OrderParts(AjaxView):
|
||||
|
||||
for supplier in self.suppliers:
|
||||
supplier.order_items = []
|
||||
|
||||
|
||||
suppliers[supplier.name] = supplier
|
||||
|
||||
for part in self.parts:
|
||||
@ -844,9 +846,9 @@ class OrderParts(AjaxView):
|
||||
supplier.selected_purchase_order = orders.first().id
|
||||
else:
|
||||
supplier.selected_purchase_order = None
|
||||
|
||||
|
||||
suppliers[supplier.name] = supplier
|
||||
|
||||
|
||||
suppliers[supplier.name].order_items.append(part)
|
||||
|
||||
self.suppliers = [suppliers[key] for key in suppliers.keys()]
|
||||
@ -864,7 +866,7 @@ class OrderParts(AjaxView):
|
||||
if 'stock[]' in self.request.GET:
|
||||
|
||||
stock_id_list = self.request.GET.getlist('stock[]')
|
||||
|
||||
|
||||
""" Get a list of all the parts associated with the stock items.
|
||||
- Base part must be purchaseable.
|
||||
- Return a set of corresponding Part IDs
|
||||
@ -907,7 +909,7 @@ class OrderParts(AjaxView):
|
||||
parts = build.required_parts
|
||||
|
||||
for part in parts:
|
||||
|
||||
|
||||
# If ordering from a Build page, ignore parts that we have enough of
|
||||
if part.quantity_to_order <= 0:
|
||||
continue
|
||||
@ -963,19 +965,19 @@ class OrderParts(AjaxView):
|
||||
|
||||
# Extract part information from the form
|
||||
for item in self.request.POST:
|
||||
|
||||
|
||||
if item.startswith('part-supplier-'):
|
||||
|
||||
|
||||
pk = item.replace('part-supplier-', '')
|
||||
|
||||
|
||||
# Check that the part actually exists
|
||||
try:
|
||||
part = Part.objects.get(id=pk)
|
||||
except (Part.DoesNotExist, ValueError):
|
||||
continue
|
||||
|
||||
|
||||
supplier_part_id = self.request.POST[item]
|
||||
|
||||
|
||||
quantity = self.request.POST.get('part-quantity-' + str(pk), 0)
|
||||
|
||||
# Ensure a valid supplier has been passed
|
||||
@ -1245,6 +1247,18 @@ class SOLineItemCreate(AjaxCreateView):
|
||||
|
||||
return initials
|
||||
|
||||
def save(self, form):
|
||||
ret = form.save()
|
||||
# check if price s set in form - else autoset
|
||||
if not ret.sale_price:
|
||||
price = ret.part.get_price(ret.quantity)
|
||||
# only if price is avail
|
||||
if price:
|
||||
ret.sale_price = price / ret.quantity
|
||||
ret.save()
|
||||
self.object = ret
|
||||
return ret
|
||||
|
||||
|
||||
class SOLineItemEdit(AjaxUpdateView):
|
||||
""" View for editing a SalesOrderLineItem """
|
||||
@ -1377,7 +1391,7 @@ class SalesOrderAssignSerials(AjaxView, FormMixin):
|
||||
self.form.fields['line'].widget = HiddenInput()
|
||||
else:
|
||||
self.form.add_error('line', _('Select line item'))
|
||||
|
||||
|
||||
if self.part:
|
||||
self.form.fields['part'].widget = HiddenInput()
|
||||
else:
|
||||
@ -1407,17 +1421,17 @@ class SalesOrderAssignSerials(AjaxView, FormMixin):
|
||||
except StockItem.DoesNotExist:
|
||||
self.form.add_error(
|
||||
'serials',
|
||||
_('No matching item for serial') + f" '{serial}'"
|
||||
_('No matching item for serial {serial}').format(serial=serial)
|
||||
)
|
||||
continue
|
||||
|
||||
# Now we have a valid stock item - but can it be added to the sales order?
|
||||
|
||||
|
||||
# If not in stock, cannot be added to the order
|
||||
if not stock_item.in_stock:
|
||||
self.form.add_error(
|
||||
'serials',
|
||||
f"'{serial}' " + _("is not in stock")
|
||||
_('{serial} is not in stock').format(serial=serial)
|
||||
)
|
||||
continue
|
||||
|
||||
@ -1425,7 +1439,7 @@ class SalesOrderAssignSerials(AjaxView, FormMixin):
|
||||
if stock_item.is_allocated():
|
||||
self.form.add_error(
|
||||
'serials',
|
||||
f"'{serial}' " + _("already allocated to an order")
|
||||
_('{serial} already allocated to an order').format(serial=serial)
|
||||
)
|
||||
continue
|
||||
|
||||
@ -1480,7 +1494,7 @@ class SalesOrderAllocationCreate(AjaxCreateView):
|
||||
model = SalesOrderAllocation
|
||||
form_class = order_forms.CreateSalesOrderAllocationForm
|
||||
ajax_form_title = _('Allocate Stock to Order')
|
||||
|
||||
|
||||
def get_initial(self):
|
||||
initials = super().get_initial().copy()
|
||||
|
||||
@ -1495,10 +1509,10 @@ class SalesOrderAllocationCreate(AjaxCreateView):
|
||||
items = StockItem.objects.filter(part=line.part)
|
||||
|
||||
quantity = line.quantity - line.allocated_quantity()
|
||||
|
||||
|
||||
if quantity < 0:
|
||||
quantity = 0
|
||||
|
||||
|
||||
if items.count() == 1:
|
||||
item = items.first()
|
||||
initials['item'] = item
|
||||
@ -1514,7 +1528,7 @@ class SalesOrderAllocationCreate(AjaxCreateView):
|
||||
return initials
|
||||
|
||||
def get_form(self):
|
||||
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
line_id = form['line'].value()
|
||||
@ -1542,10 +1556,10 @@ class SalesOrderAllocationCreate(AjaxCreateView):
|
||||
|
||||
# Hide the 'line' field
|
||||
form.fields['line'].widget = HiddenInput()
|
||||
|
||||
|
||||
except (ValueError, SalesOrderLineItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
|
||||
return form
|
||||
|
||||
|
||||
@ -1554,7 +1568,7 @@ class SalesOrderAllocationEdit(AjaxUpdateView):
|
||||
model = SalesOrderAllocation
|
||||
form_class = order_forms.EditSalesOrderAllocationForm
|
||||
ajax_form_title = _('Edit Allocation Quantity')
|
||||
|
||||
|
||||
def get_form(self):
|
||||
form = super().get_form()
|
||||
|
||||
@ -1571,3 +1585,101 @@ class SalesOrderAllocationDelete(AjaxDeleteView):
|
||||
ajax_form_title = _("Remove allocation")
|
||||
context_object_name = 'allocation'
|
||||
ajax_template_name = "order/so_allocation_delete.html"
|
||||
|
||||
|
||||
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)
|
||||
|
Reference in New Issue
Block a user