-
New Purchase Order
-
+
{% trans "New Purchase Order" %}
+
diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html
new file mode 100644
index 0000000000..6028157a22
--- /dev/null
+++ b/InvenTree/order/templates/order/sales_order_base.html
@@ -0,0 +1,133 @@
+{% extends "two_column.html" %}
+
+{% load i18n %}
+{% load static %}
+{% load inventree_extras %}
+{% load status_codes %}
+
+{% block page_title %}
+InvenTree | {% trans "Sales Order" %}
+{% endblock %}
+
+{% block pre_content %}
+{% if order.status == SalesOrderStatus.PENDING and not order.is_fully_allocated %}
+
+ {% trans "This SalesOrder has not been fully allocated" %}
+
+{% endif %}
+{% endblock %}
+
+{% block thumbnail %}
+
+{% endblock %}
+
+
+{% block page_data %}
+
+
{% trans "Sales Order" %} {% sales_order_status_label order.status large=True %}
+
+
{{ order }}
+
{{ order.description }}
+
+
+
+
+
+
+
+
+ {% if order.status == SalesOrderStatus.PENDING %}
+
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
+
+{% block page_details %}
+
{% trans "Sales Order Details" %}
+
+
+
+
+ {% trans "Order Reference" %}
+ {{ order.reference }}
+
+
+
+ {% trans "Order Status" %}
+ {% sales_order_status_label order.status %}
+
+
+
+ {% trans "Customer" %}
+ {{ order.customer.name }}
+
+ {% if order.customer_reference %}
+
+
+ {% trans "Customer Reference" %}
+ {{ order.customer_reference }}
+
+ {% endif %}
+ {% if order.link %}
+
+
+ External Link
+ {{ order.link }}
+
+ {% endif %}
+
+
+ {% trans "Created" %}
+ {{ order.creation_date }}{{ order.created_by }}
+
+ {% if order.shipment_date %}
+
+
+ {% trans "Shipped" %}
+ {{ order.shipment_date }}{{ order.shipped_by }}
+
+ {% endif %}
+ {% if order.status == PurchaseOrderStatus.COMPLETE %}
+
+
+ {% trans "Received" %}
+ {{ order.complete_date }}{{ order.received_by }}
+
+ {% endif %}
+
+{% endblock %}
+
+{% block js_ready %}
+{{ block.super }}
+
+$("#edit-order").click(function() {
+ launchModalForm("{% url 'so-edit' order.id %}", {
+ reload: true,
+ });
+});
+
+$("#cancel-order").click(function() {
+ launchModalForm("{% url 'so-cancel' order.id %}", {
+ reload: true,
+ });
+});
+
+$("#ship-order").click(function() {
+ launchModalForm("{% url 'so-ship' order.id %}", {
+ reload: true,
+ });
+});
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/sales_order_cancel.html b/InvenTree/order/templates/order/sales_order_cancel.html
new file mode 100644
index 0000000000..2f0fe3beb1
--- /dev/null
+++ b/InvenTree/order/templates/order/sales_order_cancel.html
@@ -0,0 +1,12 @@
+{% extends "modal_form.html" %}
+
+{% load i18n %}
+
+{% block pre_form_content %}
+
+
+
{% trans "Warning" %}
+ {% trans "Cancelling this order means that the order will no longer be editable." %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html
new file mode 100644
index 0000000000..0ca63882b2
--- /dev/null
+++ b/InvenTree/order/templates/order/sales_order_detail.html
@@ -0,0 +1,361 @@
+{% extends "order/sales_order_base.html" %}
+
+{% load inventree_extras %}
+{% load status_codes %}
+{% load i18n %}
+{% load static %}
+
+{% block details %}
+
+{% include "order/so_tabs.html" with tab='details' %}
+
+
+
+
{% trans "Sales Order Items" %}
+
+
+ {% trans "Add Line Item" %}
+
+
+
+
+{% endblock %}
+
+{% block js_ready %}
+{{ block.super }}
+
+function reloadTable() {
+ $("#so-lines-table").bootstrapTable("refresh");
+}
+
+$("#new-so-line").click(function() {
+ launchModalForm("{% url 'so-line-item-create' %}", {
+ success: reloadTable,
+ data: {
+ order: {{ order.id }},
+ },
+ secondary: [
+ ]
+ });
+});
+
+{% 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 = `
`;
+
+ element.html(html);
+
+ var lineItem = row;
+
+ var table = $(`#allocation-table-${row.pk}`);
+
+ table.bootstrapTable({
+ data: row.allocations,
+ showHeader: false,
+ columns: [
+ {
+ width: '50%',
+ field: 'allocated',
+ title: 'Quantity',
+ formatter: function(value, row, index, field) {
+ var text = '';
+
+ if (row.serial != null && row.quantity == 1) {
+ text = `{% trans "Serial Number" %}: ${row.serial}`;
+ } else {
+ text = `{% trans "Quantity" %}: ${row.quantity}`;
+ }
+
+ return renderLink(text, `/stock/item/${row.item}/`);
+ },
+ },
+ {
+ field: 'location_id',
+ title: 'Location',
+ formatter: function(value, row, index, field) {
+ return renderLink(row.location_path, `/stock/location/${row.location_id}/`);
+ },
+ },
+ {
+ field: 'buttons',
+ title: 'Actions',
+ formatter: function(value, row, index, field) {
+
+ var html = "
";
+ var pk = row.pk;
+
+ {% if order.status == SalesOrderStatus.PENDING %}
+ html += makeIconButton('fa-edit', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
+ html += makeIconButton('fa-trash-alt', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
+ {% endif %}
+
+ html += "
";
+
+ return html;
+ },
+ },
+ ],
+ });
+
+ table.find(".button-allocation-edit").click(function() {
+
+ var pk = $(this).attr('pk');
+
+ launchModalForm(`/order/sales-order/allocation/${pk}/edit/`, {
+ success: reloadTable,
+ });
+ });
+
+ table.find(".button-allocation-delete").click(function() {
+ var pk = $(this).attr('pk');
+
+ launchModalForm(`/order/sales-order/allocation/${pk}/delete/`, {
+ success: reloadTable,
+ });
+ });
+}
+{% endif %}
+
+function showFulfilledSubTable(index, row, element) {
+ // Construct a table showing stock items which have been fulfilled against this line item
+
+ var id = `fulfilled-table-${row.pk}`;
+ var html = `
`;
+
+ element.html(html);
+
+ var lineItem = row;
+
+ $(`#${id}`).bootstrapTable({
+ url: "{% url 'api-stock-list' %}",
+ queryParams: {
+ part: row.part,
+ sales_order: {{ order.id }},
+ },
+ showHeader: false,
+ columns: [
+ {
+ field: 'pk',
+ visible: false,
+ },
+ {
+ field: 'stock',
+ formatter: function(value, row) {
+ var text = '';
+ if (row.serial && row.quantity == 1) {
+ text = `{% trans "Serial Number" %}: ${row.serial}`;
+ } else {
+ text = `{% trans "Quantity" %}: ${row.quantity}`;
+ }
+
+ return renderLink(text, `/stock/item/${row.pk}/`);
+ },
+ }
+ ],
+ });
+}
+
+$("#so-lines-table").inventreeTable({
+ formatNoMatches: function() { return "No matching line items"; },
+ queryParams: {
+ order: {{ order.id }},
+ part_detail: true,
+ allocations: true,
+ },
+ uniqueId: 'pk',
+ url: "{% url 'api-so-line-list' %}",
+ onPostBody: setupCallbacks,
+ {% if order.status == SalesOrderStatus.PENDING or order.status == SalesOrderStatus.SHIPPED %}
+ detailViewByClick: true,
+ detailView: true,
+ detailFilter: function(index, row) {
+ {% if order.status == SalesOrderStatus.PENDING %}
+ return row.allocated > 0;
+ {% else %}
+ return row.fulfilled > 0;
+ {% endif %}
+ },
+ {% if order.status == SalesOrderStatus.PENDING %}
+ detailFormatter: showAllocationSubTable,
+ {% else %}
+ detailFormatter: showFulfilledSubTable,
+ {% endif %}
+ {% endif %}
+ columns: [
+ {
+ field: 'pk',
+ title: 'ID',
+ visible: false,
+ },
+ {
+ sortable: true,
+ field: 'part',
+ title: 'Part',
+ formatter: function(value, row, index, field) {
+ if (row.part) {
+ return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${value}/`);
+ } else {
+ return '-';
+ }
+ }
+ },
+ {
+ sortable: true,
+ field: 'reference',
+ title: 'Reference'
+ },
+ {
+ sortable: true,
+ field: 'quantity',
+ title: 'Quantity',
+ },
+ {
+ sortable: true,
+ field: 'allocated',
+ {% if order.status == SalesOrderStatus.PENDING %}
+ title: '{% trans "Allocated" %}',
+ {% else %}
+ title: '{% trans "Fulfilled" %}',
+ {% endif %}
+ formatter: function(value, row, index, field) {
+ {% if order.status == SalesOrderStatus.PENDING %}
+ var quantity = row.allocated;
+ {% else %}
+ var quantity = row.fulfilled;
+ {% endif %}
+ return makeProgressBar(quantity, row.quantity, {
+ id: `order-line-progress-${row.pk}`,
+ });
+ },
+ sorter: function(valA, valB, rowA, rowB) {
+ {% if order.status == SalesOrderStatus.PENDING %}
+ var A = rowA.allocated;
+ var B = rowB.allocated;
+ {% else %}
+ 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;
+
+ return (progressA < progressB) ? 1 : -1;
+ }
+ },
+ {
+ field: 'notes',
+ title: 'Notes',
+ },
+ {% if order.status == SalesOrderStatus.PENDING %}
+ {
+ field: 'buttons',
+ formatter: function(value, row, index, field) {
+
+ var html = `
`;
+
+ var pk = row.pk;
+
+ if (row.part) {
+ var part = row.part_detail;
+
+ if (part.purchaseable) {
+ html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Buy parts" %}');
+ }
+
+ if (part.assembly) {
+ html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build parts" %}');
+ }
+
+ html += makeIconButton('fa-plus', 'button-add', pk, '{% trans "Allocate parts" %}');
+ }
+
+ html += makeIconButton('fa-edit', 'button-edit', pk, '{% trans "Edit line item" %}');
+ html += makeIconButton('fa-trash-alt', 'button-delete', pk, '{% trans "Delete line item " %}');
+
+ html += `
`;
+
+ return html;
+ }
+ },
+ {% endif %}
+ ],
+});
+
+function setupCallbacks() {
+
+ var table = $("#so-lines-table");
+
+ // Set up callbacks for the row buttons
+ table.find(".button-edit").click(function() {
+
+ var pk = $(this).attr('pk');
+
+ launchModalForm(`/order/sales-order/line/${pk}/edit/`, {
+ success: reloadTable,
+ });
+ });
+
+ table.find(".button-delete").click(function() {
+ var pk = $(this).attr('pk');
+
+ launchModalForm(`/order/sales-order/line/${pk}/delete/`, {
+ reload: true,
+ });
+ });
+
+ table.find(".button-add").click(function() {
+ var pk = $(this).attr('pk');
+
+ launchModalForm(`/order/sales-order/allocation/new/`, {
+ success: reloadTable,
+ data: {
+ line: pk,
+ },
+ });
+ });
+
+ table.find(".button-build").click(function() {
+
+ var pk = $(this).attr('pk');
+
+ // Extract the row data from the table!
+ var idx = $(this).closest('tr').attr('data-index');
+
+ var row = table.bootstrapTable('getData')[idx];
+
+ var quantity = 1;
+
+ if (row.allocated < row.quantity) {
+ quantity = row.quantity - row.allocated;
+ }
+
+ launchModalForm(`/build/new/`, {
+ follow: true,
+ data: {
+ part: pk,
+ sales_order: {{ order.id }},
+ quantity: quantity,
+ },
+ });
+ });
+
+ table.find(".button-buy").click(function() {
+ var pk = $(this).attr('pk');
+
+ launchModalForm("{% url 'order-parts' %}", {
+ data: {
+ parts: [pk],
+ },
+ });
+ });
+}
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/sales_order_notes.html b/InvenTree/order/templates/order/sales_order_notes.html
new file mode 100644
index 0000000000..671b592569
--- /dev/null
+++ b/InvenTree/order/templates/order/sales_order_notes.html
@@ -0,0 +1,62 @@
+{% extends "order/sales_order_base.html" %}
+
+{% load i18n %}
+{% load static %}
+{% load inventree_extras %}
+{% load status_codes %}
+{% load markdownify %}
+
+{% block page_title %}
+InvenTree | {% trans "Sales Order" %}
+{% endblock %}
+
+{% block details %}
+
+{% include "order/so_tabs.html" with tab='notes' %}
+
+{% if editing %}
+
{% trans "Order Notes" %}
+
+
+
+
+{{ form.media }}
+
+{% else %}
+
+
+
{% trans "Order Notes" %}
+
+
+
+
+
+
+
+
+ {{ order.notes | markdownify }}
+
+
+
+{% endif %}
+
+{% endblock %}
+
+{% block js_ready %}
+
+{{ block.super }}
+
+{% if editing %}
+{% else %}
+$("#edit-notes").click(function() {
+ location.href = "{% url 'so-notes' order.id %}?edit=1";
+});
+{% endif %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/sales_order_ship.html b/InvenTree/order/templates/order/sales_order_ship.html
new file mode 100644
index 0000000000..0060561e71
--- /dev/null
+++ b/InvenTree/order/templates/order/sales_order_ship.html
@@ -0,0 +1,30 @@
+{% extends "modal_form.html" %}
+
+{% load i18n %}
+
+{% block pre_form_content %}
+
+{% if not order.is_fully_allocated %}
+
+
{% trans "Warning" %}
+ {% trans "This order has not been fully allocated. If the order is marked as shipped, it can no longer be adjusted." %}
+
+ {% trans "Ensure that the order allocation is correct before shipping the order." %}
+
+{% endif %}
+
+{% if order.is_over_allocated %}
+
+ {% trans "Some line items in this order have been over-allocated" %}
+
+ {% trans "Ensure that this is correct before shipping the order." %}
+
+{% endif %}
+
+
+ {% trans "Sales Order" %} {{ order.reference }} - {{ order.customer.name }}
+
+ {% trans "Shipping this order means that the order will no longer be editable." %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/sales_orders.html b/InvenTree/order/templates/order/sales_orders.html
new file mode 100644
index 0000000000..4e29156773
--- /dev/null
+++ b/InvenTree/order/templates/order/sales_orders.html
@@ -0,0 +1,44 @@
+{% extends "base.html" %}
+
+{% load static %}
+{% load i18n %}
+
+{% block page_title %}
+InvenTree | {% trans "Sales Orders" %}
+{% endblock %}
+
+{% block content %}
+
+
{% trans "Sales Orders" %}
+
+
+
+
+
+
+{% endblock %}
+
+{% block js_ready %}
+{{ block.super }}
+
+loadSalesOrderTable("#sales-order-table", {
+ url: "{% url 'api-so-list' %}",
+});
+
+$("#so-create").click(function() {
+ launchModalForm("{% url 'so-create' %}",
+ {
+ follow: true,
+ }
+ );
+});
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/so_allocation_delete.html b/InvenTree/order/templates/order/so_allocation_delete.html
new file mode 100644
index 0000000000..e4cbe0b602
--- /dev/null
+++ b/InvenTree/order/templates/order/so_allocation_delete.html
@@ -0,0 +1,14 @@
+{% extends "modal_delete_form.html" %}
+{% load i18n %}
+{% load inventree_extras %}
+
+{% block pre_form_content %}
+
+ {% trans "This action will unallocate the following stock from the Sales Order" %}:
+
+
+ {% decimal allocation.get_allocated %} x {{ allocation.line.part.full_name }}
+ {% if allocation.item.location %} ({{ allocation.get_location }}){% endif %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/so_attachments.html b/InvenTree/order/templates/order/so_attachments.html
new file mode 100644
index 0000000000..82248fd5eb
--- /dev/null
+++ b/InvenTree/order/templates/order/so_attachments.html
@@ -0,0 +1,81 @@
+{% extends "order/sales_order_base.html" %}
+
+{% load inventree_extras %}
+{% load i18n %}
+{% load static %}
+
+{% block details %}
+
+{% include 'order/so_tabs.html' with tab='attachments' %}
+
+
{% trans "Sales Order Attachments" %}
+
+
+
+
+
+
+
+
+ {% trans "File" %}
+ {% trans "Comment" %}
+
+
+
+
+ {% for attachment in order.attachments.all %}
+
+ {{ attachment.basename }}
+ {{ attachment.comment }}
+
+
+
+
+
+
+
+
+
+
+
+ {% endfor %}
+
+
+
+{% endblock %}
+
+{% block js_ready %}
+{{ block.super }}
+
+$("#new-attachment").click(function() {
+ launchModalForm("{% url 'so-attachment-create' %}?order={{ order.id }}",
+ {
+ reload: true,
+ }
+ );
+});
+
+$("#attachment-table").on('click', '.attachment-edit-button', function() {
+ var button = $(this);
+
+ launchModalForm(button.attr('url'), {
+ reload: true,
+ });
+});
+
+$("#attachment-table").on('click', '.attachment-delete-button', function() {
+ var button = $(this);
+
+ launchModalForm(button.attr('url'), {
+ reload: true,
+ });
+});
+
+$("#attachment-table").inventreeTable({
+});
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/so_builds.html b/InvenTree/order/templates/order/so_builds.html
new file mode 100644
index 0000000000..f877a4977f
--- /dev/null
+++ b/InvenTree/order/templates/order/so_builds.html
@@ -0,0 +1,30 @@
+{% extends "order/sales_order_base.html" %}
+
+{% load inventree_extras %}
+{% load i18n %}
+{% load static %}
+
+{% block details %}
+
+{% include 'order/so_tabs.html' with tab='builds' %}
+
+
{% trans "Build Orders" %}
+
+
+
+
+{% endblock %}
+
+{% block js_ready %}
+
+{{ block.super }}
+
+loadBuildTable($("#builds-table"), {
+ url: "{% url 'api-build-list' %}",
+ params: {
+ sales_order: {{ order.id }},
+ part_detail: true,
+ },
+});
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/so_lineitem_delete.html b/InvenTree/order/templates/order/so_lineitem_delete.html
new file mode 100644
index 0000000000..1d9f80d137
--- /dev/null
+++ b/InvenTree/order/templates/order/so_lineitem_delete.html
@@ -0,0 +1,6 @@
+{% extends "modal_delete_form.html" %}
+{% load i18n %}
+
+{% block pre_form_content %}
+{% trans "Are you sure you wish to delete this line item?" %}
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/order/templates/order/so_tabs.html b/InvenTree/order/templates/order/so_tabs.html
new file mode 100644
index 0000000000..cb3740a073
--- /dev/null
+++ b/InvenTree/order/templates/order/so_tabs.html
@@ -0,0 +1,25 @@
+{% load i18n %}
+
+
\ No newline at end of file
diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py
new file mode 100644
index 0000000000..6cc48c3b6f
--- /dev/null
+++ b/InvenTree/order/test_sales_order.py
@@ -0,0 +1,140 @@
+# -*- coding: utf-8 -*-
+
+from django.test import TestCase
+
+from django.core.exceptions import ValidationError
+from django.db.utils import IntegrityError
+
+from company.models import Company
+from stock.models import StockItem
+from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
+from part.models import Part
+from InvenTree import status_codes as status
+
+
+class SalesOrderTest(TestCase):
+ """
+ Run tests to ensure that the SalesOrder model is working correctly.
+
+ """
+
+ def setUp(self):
+
+ # Create a Company to ship the goods to
+ self.customer = Company.objects.create(name="ABC Co", description="My customer", is_customer=True)
+
+ # Create a Part to ship
+ self.part = Part.objects.create(name='Spanner', salable=True, description='A spanner that I sell')
+
+ # Create some stock!
+ StockItem.objects.create(part=self.part, quantity=100)
+ StockItem.objects.create(part=self.part, quantity=200)
+
+ # Create a SalesOrder to ship against
+ self.order = SalesOrder.objects.create(
+ customer=self.customer,
+ reference='1234',
+ customer_reference='ABC 55555'
+ )
+
+ # Create a line item
+ self.line = SalesOrderLineItem.objects.create(quantity=50, order=self.order, part=self.part)
+
+ def test_empty_order(self):
+ self.assertEqual(self.line.quantity, 50)
+ self.assertEqual(self.line.allocated_quantity(), 0)
+ self.assertEqual(self.line.fulfilled_quantity(), 0)
+ self.assertFalse(self.line.is_fully_allocated())
+ self.assertFalse(self.line.is_over_allocated())
+
+ self.assertTrue(self.order.is_pending)
+ self.assertFalse(self.order.is_fully_allocated())
+
+ def test_add_duplicate_line_item(self):
+ # Adding a duplicate line item to a SalesOrder must throw an error
+
+ with self.assertRaises(IntegrityError):
+ SalesOrderLineItem.objects.create(order=self.order, part=self.part)
+
+ def allocate_stock(self, full=True):
+ # Allocate stock to the order
+ SalesOrderAllocation.objects.create(
+ line=self.line,
+ item=StockItem.objects.get(pk=1),
+ quantity=25)
+
+ SalesOrderAllocation.objects.create(
+ line=self.line,
+ item=StockItem.objects.get(pk=2),
+ quantity=25 if full else 20
+ )
+
+ def test_allocate_partial(self):
+ # Partially allocate stock
+ self.allocate_stock(False)
+
+ self.assertFalse(self.order.is_fully_allocated())
+ self.assertFalse(self.line.is_fully_allocated())
+ self.assertEqual(self.line.allocated_quantity(), 45)
+ self.assertEqual(self.line.fulfilled_quantity(), 0)
+
+ def test_allocate_full(self):
+ # Fully allocate stock
+ self.allocate_stock(True)
+
+ 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
+
+ self.allocate_stock(True)
+
+ self.assertEqual(SalesOrderAllocation.objects.count(), 2)
+ self.assertEqual(self.order.status, status.SalesOrderStatus.PENDING)
+
+ self.order.cancel_order()
+ self.assertEqual(SalesOrderAllocation.objects.count(), 0)
+ self.assertEqual(self.order.status, status.SalesOrderStatus.CANCELLED)
+
+ # Now try to ship it - should fail
+ with self.assertRaises(ValidationError):
+ self.order.ship_order(None)
+
+ def test_ship_order(self):
+ # Allocate line items, then ship the order
+
+ # Assert some stuff before we run the test
+ # Initially there are two stock items
+ self.assertEqual(StockItem.objects.count(), 2)
+
+ # Take 25 units from each StockItem
+ self.allocate_stock(True)
+
+ self.assertEqual(SalesOrderAllocation.objects.count(), 2)
+
+ self.order.ship_order(None)
+
+ # There should now be 4 stock items
+ self.assertEqual(StockItem.objects.count(), 4)
+
+ self.assertEqual(StockItem.objects.get(pk=1).quantity, 75)
+ self.assertEqual(StockItem.objects.get(pk=2).quantity, 175)
+ self.assertEqual(StockItem.objects.get(pk=3).quantity, 25)
+ self.assertEqual(StockItem.objects.get(pk=3).quantity, 25)
+
+ self.assertEqual(StockItem.objects.get(pk=1).sales_order, None)
+ self.assertEqual(StockItem.objects.get(pk=2).sales_order, None)
+ self.assertEqual(StockItem.objects.get(pk=3).sales_order, self.order)
+ self.assertEqual(StockItem.objects.get(pk=4).sales_order, self.order)
+
+ # And no allocations
+ 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)
+ self.assertEqual(self.line.allocated_quantity(), 0)
diff --git a/InvenTree/order/test_views.py b/InvenTree/order/test_views.py
index bf3608e2b0..932cac9060 100644
--- a/InvenTree/order/test_views.py
+++ b/InvenTree/order/test_views.py
@@ -7,7 +7,7 @@ from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
-from InvenTree.status_codes import OrderStatus
+from InvenTree.status_codes import PurchaseOrderStatus
from .models import PurchaseOrder, PurchaseOrderLineItem
@@ -53,7 +53,7 @@ class POTests(OrderViewTestCase):
response = self.client.get(reverse('po-detail', args=(1,)))
self.assertEqual(response.status_code, 200)
keys = response.context.keys()
- self.assertIn('OrderStatus', keys)
+ self.assertIn('PurchaseOrderStatus', keys)
def test_po_create(self):
""" Launch forms to create new PurchaseOrder"""
@@ -91,7 +91,7 @@ class POTests(OrderViewTestCase):
url = reverse('po-issue', args=(1,))
order = PurchaseOrder.objects.get(pk=1)
- self.assertEqual(order.status, OrderStatus.PENDING)
+ self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
# Test without confirmation
response = self.client.post(url, {'confirm': 0}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
@@ -109,7 +109,7 @@ class POTests(OrderViewTestCase):
# Test that the order was actually placed
order = PurchaseOrder.objects.get(pk=1)
- self.assertEqual(order.status, OrderStatus.PLACED)
+ self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
def test_line_item_create(self):
""" Test the form for adding a new LineItem to a PurchaseOrder """
@@ -117,7 +117,7 @@ class POTests(OrderViewTestCase):
# Record the number of line items in the PurchaseOrder
po = PurchaseOrder.objects.get(pk=1)
n = po.lines.count()
- self.assertEqual(po.status, OrderStatus.PENDING)
+ self.assertEqual(po.status, PurchaseOrderStatus.PENDING)
url = reverse('po-line-item-create')
@@ -181,7 +181,7 @@ class TestPOReceive(OrderViewTestCase):
super().setUp()
self.po = PurchaseOrder.objects.get(pk=1)
- self.po.status = OrderStatus.PLACED
+ self.po.status = PurchaseOrderStatus.PLACED
self.po.save()
self.url = reverse('po-receive', args=(1,))
diff --git a/InvenTree/order/tests.py b/InvenTree/order/tests.py
index 35cf8909be..ca24b9586d 100644
--- a/InvenTree/order/tests.py
+++ b/InvenTree/order/tests.py
@@ -6,7 +6,7 @@ from .models import PurchaseOrder, PurchaseOrderLineItem
from stock.models import StockLocation
from company.models import SupplierPart
-from InvenTree.status_codes import OrderStatus
+from InvenTree.status_codes import PurchaseOrderStatus
class OrderTest(TestCase):
@@ -31,11 +31,11 @@ class OrderTest(TestCase):
self.assertEqual(order.get_absolute_url(), '/order/purchase-order/1/')
- self.assertEqual(str(order), 'PO 1')
+ self.assertEqual(str(order), 'PO 1 - ACME')
line = PurchaseOrderLineItem.objects.get(pk=1)
- self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO 1)")
+ self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO 1 - ACME)")
def test_on_order(self):
""" There should be 3 separate items on order for the M2x4 LPHS part """
@@ -57,7 +57,7 @@ class OrderTest(TestCase):
order = PurchaseOrder.objects.get(pk=1)
- self.assertEqual(order.status, OrderStatus.PENDING)
+ self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
self.assertEqual(order.lines.count(), 3)
sku = SupplierPart.objects.get(SKU='ACME-WIDGET')
@@ -104,14 +104,14 @@ class OrderTest(TestCase):
self.assertEqual(len(order.pending_line_items()), 3)
# Should fail, as order is 'PENDING' not 'PLACED"
- self.assertEqual(order.status, OrderStatus.PENDING)
+ self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
with self.assertRaises(django_exceptions.ValidationError):
order.receive_line_item(line, loc, 50, user=None)
order.place_order()
- self.assertEqual(order.status, OrderStatus.PLACED)
+ self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
order.receive_line_item(line, loc, 50, user=None)
@@ -134,9 +134,9 @@ class OrderTest(TestCase):
order.receive_line_item(line, loc, 500, user=None)
self.assertEqual(part.on_order, 800)
- self.assertEqual(order.status, OrderStatus.PLACED)
+ self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
for line in order.pending_line_items():
order.receive_line_item(line, loc, line.quantity, user=None)
- self.assertEqual(order.status, OrderStatus.COMPLETE)
+ self.assertEqual(order.status, PurchaseOrderStatus.COMPLETE)
diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py
index 3346d7c44d..daa83eb5ab 100644
--- a/InvenTree/order/urls.py
+++ b/InvenTree/order/urls.py
@@ -9,21 +9,15 @@ from django.conf.urls import url, include
from . import views
-purchase_order_attachment_urls = [
- url(r'^new/', views.PurchaseOrderAttachmentCreate.as_view(), name='po-attachment-create'),
- url(r'^(?P
\d+)/edit/', views.PurchaseOrderAttachmentEdit.as_view(), name='po-attachment-edit'),
- url(r'^(?P\d+)/delete/', views.PurchaseOrderAttachmentDelete.as_view(), name='po-attachment-delete'),
-]
-
purchase_order_detail_urls = [
- url(r'^cancel/?', views.PurchaseOrderCancel.as_view(), name='po-cancel'),
- url(r'^edit/?', views.PurchaseOrderEdit.as_view(), name='po-edit'),
- url(r'^issue/?', views.PurchaseOrderIssue.as_view(), name='po-issue'),
- url(r'^receive/?', views.PurchaseOrderReceive.as_view(), name='po-receive'),
- url(r'^complete/?', views.PurchaseOrderComplete.as_view(), name='po-complete'),
+ url(r'^cancel/', views.PurchaseOrderCancel.as_view(), name='po-cancel'),
+ url(r'^edit/', views.PurchaseOrderEdit.as_view(), name='po-edit'),
+ url(r'^issue/', views.PurchaseOrderIssue.as_view(), name='po-issue'),
+ url(r'^receive/', views.PurchaseOrderReceive.as_view(), name='po-receive'),
+ url(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'),
- url(r'^export/?', views.PurchaseOrderExport.as_view(), name='po-export'),
+ url(r'^export/', views.PurchaseOrderExport.as_view(), name='po-export'),
url(r'^notes/', views.PurchaseOrderNotes.as_view(), name='po-notes'),
@@ -31,19 +25,6 @@ purchase_order_detail_urls = [
url(r'^.*$', views.PurchaseOrderDetail.as_view(), name='po-detail'),
]
-po_line_item_detail_urls = [
-
- url(r'^edit/', views.POLineItemEdit.as_view(), name='po-line-item-edit'),
- url(r'^delete/', views.POLineItemDelete.as_view(), name='po-line-item-delete'),
-]
-
-po_line_urls = [
-
- url(r'^new/', views.POLineItemCreate.as_view(), name='po-line-item-create'),
-
- url(r'^(?P\d+)/', include(po_line_item_detail_urls)),
-]
-
purchase_order_urls = [
url(r'^new/', views.PurchaseOrderCreate.as_view(), name='po-create'),
@@ -53,14 +34,72 @@ purchase_order_urls = [
# Display detail view for a single purchase order
url(r'^(?P\d+)/', include(purchase_order_detail_urls)),
- url(r'^line/', include(po_line_urls)),
+ url(r'^line/', include([
+ url(r'^new/', views.POLineItemCreate.as_view(), name='po-line-item-create'),
+ url(r'^(?P\d+)/', include([
+ url(r'^edit/', views.POLineItemEdit.as_view(), name='po-line-item-edit'),
+ url(r'^delete/', views.POLineItemDelete.as_view(), name='po-line-item-delete'),
+ ])),
+ ])),
- url(r'^attachments/', include(purchase_order_attachment_urls)),
+ url(r'^attachments/', include([
+ url(r'^new/', views.PurchaseOrderAttachmentCreate.as_view(), name='po-attachment-create'),
+ url(r'^(?P\d+)/edit/', views.PurchaseOrderAttachmentEdit.as_view(), name='po-attachment-edit'),
+ url(r'^(?P\d+)/delete/', views.PurchaseOrderAttachmentDelete.as_view(), name='po-attachment-delete'),
+ ])),
# Display complete list of purchase orders
url(r'^.*$', views.PurchaseOrderIndex.as_view(), name='po-index'),
]
+sales_order_detail_urls = [
+
+ url(r'^edit/', views.SalesOrderEdit.as_view(), name='so-edit'),
+ url(r'^cancel/', views.SalesOrderCancel.as_view(), name='so-cancel'),
+ url(r'^ship/', views.SalesOrderShip.as_view(), name='so-ship'),
+
+ url(r'^builds/', views.SalesOrderDetail.as_view(template_name='order/so_builds.html'), name='so-builds'),
+ url(r'^attachments/', views.SalesOrderDetail.as_view(template_name='order/so_attachments.html'), name='so-attachments'),
+ url(r'^notes/', views.SalesOrderNotes.as_view(), name='so-notes'),
+
+ url(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'),
+]
+
+sales_order_urls = [
+
+ url(r'^new/', views.SalesOrderCreate.as_view(), name='so-create'),
+
+ url(r'^line/', include([
+ url(r'^new/', views.SOLineItemCreate.as_view(), name='so-line-item-create'),
+ url(r'^(?P\d+)/', include([
+ url(r'^edit/', views.SOLineItemEdit.as_view(), name='so-line-item-edit'),
+ url(r'^delete/', views.SOLineItemDelete.as_view(), name='so-line-item-delete'),
+ ])),
+ ])),
+
+ # URLs for sales order allocations
+ url(r'^allocation/', include([
+ url(r'^new/', views.SalesOrderAllocationCreate.as_view(), name='so-allocation-create'),
+ url(r'(?P\d+)/', include([
+ url(r'^edit/', views.SalesOrderAllocationEdit.as_view(), name='so-allocation-edit'),
+ url(r'^delete/', views.SalesOrderAllocationDelete.as_view(), name='so-allocation-delete'),
+ ])),
+ ])),
+
+ url(r'^attachments/', include([
+ url(r'^new/', views.SalesOrderAttachmentCreate.as_view(), name='so-attachment-create'),
+ url(r'^(?P\d+)/edit/', views.SalesOrderAttachmentEdit.as_view(), name='so-attachment-edit'),
+ url(r'^(?P\d+)/delete/', views.SalesOrderAttachmentDelete.as_view(), name='so-attachment-delete'),
+ ])),
+
+ # Display detail view for a single SalesOrder
+ url(r'^(?P\d+)/', include(sales_order_detail_urls)),
+
+ # Display list of all sales orders
+ url(r'^.*$', views.SalesOrderIndex.as_view(), name='so-index'),
+]
+
order_urls = [
url(r'^purchase-order/', include(purchase_order_urls)),
+ url(r'^sales-order/', include(sales_order_urls)),
]
diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py
index 67d5b926d7..476a61b5e3 100644
--- a/InvenTree/order/views.py
+++ b/InvenTree/order/views.py
@@ -16,6 +16,8 @@ import logging
from decimal import Decimal, InvalidOperation
from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderAttachment
+from .models import SalesOrder, SalesOrderLineItem, SalesOrderAttachment
+from .models import SalesOrderAllocation
from .admin import POLineItemResource
from build.models import Build
from company.models import Company, SupplierPart
@@ -27,7 +29,7 @@ from . import forms as order_forms
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.helpers import DownloadFile, str2bool
-from InvenTree.status_codes import OrderStatus
+from InvenTree.status_codes import PurchaseOrderStatus
logger = logging.getLogger(__name__)
@@ -50,11 +52,16 @@ class PurchaseOrderIndex(ListView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
- ctx['OrderStatus'] = OrderStatus
-
return ctx
+class SalesOrderIndex(ListView):
+
+ model = SalesOrder
+ template_name = 'order/sales_orders.html'
+ context_object_name = 'orders'
+
+
class PurchaseOrderDetail(DetailView):
""" Detail view for a PurchaseOrder object """
@@ -65,11 +72,17 @@ class PurchaseOrderDetail(DetailView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
- ctx['OrderStatus'] = OrderStatus
-
return ctx
+class SalesOrderDetail(DetailView):
+ """ Detail view for a SalesOrder object """
+
+ context_object_name = 'order'
+ queryset = SalesOrder.objects.all().prefetch_related('lines')
+ template_name = 'order/sales_order_detail.html'
+
+
class PurchaseOrderAttachmentCreate(AjaxCreateView):
"""
View for creating a new PurchaseOrderAtt
@@ -113,6 +126,34 @@ class PurchaseOrderAttachmentCreate(AjaxCreateView):
return form
+class SalesOrderAttachmentCreate(AjaxCreateView):
+ """ View for creating a new SalesOrderAttachment """
+
+ model = SalesOrderAttachment
+ form_class = order_forms.EditSalesOrderAttachmentForm
+ ajax_form_title = _('Add Sales Order Attachment')
+
+ def get_data(self):
+ return {
+ 'success': _('Added attachment')
+ }
+
+ def get_initial(self):
+ initials = super().get_initial().copy()
+
+ initials['order'] = SalesOrder.objects.get(id=self.request.GET.get('order', None))
+
+ return initials
+
+ def get_form(self):
+ """ Hide the 'order' field """
+
+ form = super().get_form()
+ form.fields['order'].widget = HiddenInput()
+
+ return form
+
+
class PurchaseOrderAttachmentEdit(AjaxUpdateView):
""" View for editing a PurchaseOrderAttachment object """
@@ -134,12 +175,46 @@ class PurchaseOrderAttachmentEdit(AjaxUpdateView):
return form
+class SalesOrderAttachmentEdit(AjaxUpdateView):
+ """ View for editing a SalesOrderAttachment object """
+
+ model = SalesOrderAttachment
+ form_class = order_forms.EditSalesOrderAttachmentForm
+ ajax_form_title = _("Edit Attachment")
+
+ def get_data(self):
+ return {
+ 'success': _('Attachment updated')
+ }
+
+ def get_form(self):
+ form = super().get_form()
+
+ form.fields['order'].widget = HiddenInput()
+
+ return form
+
+
class PurchaseOrderAttachmentDelete(AjaxDeleteView):
""" View for deleting a PurchaseOrderAttachment """
model = PurchaseOrderAttachment
ajax_form_title = _("Delete Attachment")
- ajax_template_name = "order/po_delete.html"
+ ajax_template_name = "order/delete_attachment.html"
+ context_object_name = "attachment"
+
+ def get_data(self):
+ return {
+ "danger": _("Deleted attachment")
+ }
+
+
+class SalesOrderAttachmentDelete(AjaxDeleteView):
+ """ View for deleting a SalesOrderAttachment """
+
+ model = SalesOrderAttachment
+ ajax_form_title = _("Delete Attachment")
+ ajax_template_name = "order/delete_attachment.html"
context_object_name = "attachment"
def get_data(self):
@@ -165,7 +240,28 @@ class PurchaseOrderNotes(UpdateView):
ctx = super().get_context_data(**kwargs)
- ctx['editing'] = str2bool(self.request.GET.get('edit', ''))
+ ctx['editing'] = str2bool(self.request.GET.get('edit', False))
+
+ return ctx
+
+
+class SalesOrderNotes(UpdateView):
+ """ View for editing the 'notes' field of a SalesORder """
+
+ context_object_name = 'order'
+ template_name = 'order/sales_order_notes.html'
+ model = SalesOrder
+
+ fields = ['notes']
+
+ def get_success_url(self):
+ return reverse('so-notes', kwargs={'pk': self.get_object().pk})
+
+ def get_context_data(self, **kwargs):
+
+ ctx = super().get_context_data(**kwargs)
+
+ ctx['editing'] = str2bool(self.request.GET.get('edit', False))
return ctx
@@ -180,7 +276,7 @@ class PurchaseOrderCreate(AjaxCreateView):
def get_initial(self):
initials = super().get_initial().copy()
- initials['status'] = OrderStatus.PENDING
+ initials['status'] = PurchaseOrderStatus.PENDING
supplier_id = self.request.GET.get('supplier', None)
@@ -200,6 +296,35 @@ class PurchaseOrderCreate(AjaxCreateView):
self.object.save()
+class SalesOrderCreate(AjaxCreateView):
+ """ View for creating a new SalesOrder object """
+
+ model = SalesOrder
+ ajax_form_title = _("Create Sales Order")
+ form_class = order_forms.EditSalesOrderForm
+
+ def get_initial(self):
+ initials = super().get_initial().copy()
+
+ initials['status'] = PurchaseOrderStatus.PENDING
+
+ customer_id = self.request.GET.get('customer', None)
+
+ if customer_id is not None:
+ try:
+ customer = Company.objects.get(id=customer_id)
+ initials['customer'] = customer
+ except (Company.DoesNotExist, ValueError):
+ pass
+
+ return initials
+
+ def post_save(self, **kwargs):
+ # Record the user who created this sales order
+ self.object.created_by = self.request.user
+ self.object.save()
+
+
class PurchaseOrderEdit(AjaxUpdateView):
""" View for editing a PurchaseOrder using a modal form """
@@ -214,12 +339,28 @@ class PurchaseOrderEdit(AjaxUpdateView):
order = self.get_object()
# Prevent user from editing supplier if there are already lines in the order
- if order.lines.count() > 0 or not order.status == OrderStatus.PENDING:
+ if order.lines.count() > 0 or not order.status == PurchaseOrderStatus.PENDING:
form.fields['supplier'].widget = HiddenInput()
return form
+class SalesOrderEdit(AjaxUpdateView):
+ """ View for editing a SalesOrder """
+
+ model = SalesOrder
+ ajax_form_title = _('Edit Sales Order')
+ form_class = order_forms.EditSalesOrderForm
+
+ def get_form(self):
+ form = super().get_form()
+
+ # Prevent user from editing customer
+ form.fields['customer'].widget = HiddenInput()
+
+ return form
+
+
class PurchaseOrderCancel(AjaxUpdateView):
""" View for cancelling a purchase order """
@@ -253,6 +394,40 @@ class PurchaseOrderCancel(AjaxUpdateView):
return self.renderJsonResponse(request, form, data)
+class SalesOrderCancel(AjaxUpdateView):
+ """ View for cancelling a sales order """
+
+ model = SalesOrder
+ ajax_form_title = _("Cancel sales order")
+ ajax_template_name = "order/sales_order_cancel.html"
+ form_class = order_forms.CancelSalesOrderForm
+
+ def post(self, request, *args, **kwargs):
+
+ order = self.get_object()
+ form = self.get_form()
+
+ confirm = str2bool(request.POST.get('confirm', False))
+
+ valid = False
+
+ if not confirm:
+ form.errors['confirm'] = [_('Confirm order cancellation')]
+ else:
+ valid = True
+
+ if valid:
+ if not order.cancel_order():
+ form.non_field_errors = [_('Could not cancel order')]
+ valid = False
+
+ data = {
+ 'form_valid': valid,
+ }
+
+ return self.renderJsonResponse(request, form, data)
+
+
class PurchaseOrderIssue(AjaxUpdateView):
""" View for changing a purchase order from 'PENDING' to 'ISSUED' """
@@ -310,7 +485,7 @@ class PurchaseOrderComplete(AjaxUpdateView):
if confirm:
po = self.get_object()
- po.status = OrderStatus.COMPLETE
+ po.status = PurchaseOrderStatus.COMPLETE
po.save()
data = {
@@ -322,6 +497,48 @@ class PurchaseOrderComplete(AjaxUpdateView):
return self.renderJsonResponse(request, form, data)
+class SalesOrderShip(AjaxUpdateView):
+ """ View for 'shipping' a SalesOrder """
+ form_class = order_forms.ShipSalesOrderForm
+ model = SalesOrder
+ context_object_name = 'order'
+ ajax_template_name = 'order/sales_order_ship.html'
+ ajax_form_title = _('Ship Order')
+
+ def post(self, request, *args, **kwargs):
+
+ self.request = request
+
+ order = self.get_object()
+ self.object = order
+
+ form = self.get_form()
+
+ confirm = str2bool(request.POST.get('confirm', False))
+
+ valid = False
+
+ if not confirm:
+ form.errors['confirm'] = [_('Confirm order shipment')]
+ else:
+ valid = True
+
+ if valid:
+ if not order.ship_order(request.user):
+ form.non_field_errors = [_('Could not ship order')]
+ valid = False
+
+ data = {
+ 'form_valid': valid,
+ }
+
+ context = self.get_context_data()
+
+ context['order'] = order
+
+ return self.renderJsonResponse(request, form, data, context)
+
+
class PurchaseOrderExport(AjaxView):
""" File download for a purchase order
@@ -879,7 +1096,7 @@ class POLineItemCreate(AjaxCreateView):
# Limit the available to orders to ones that are PENDING
query = form.fields['order'].queryset
- query = query.filter(status=OrderStatus.PENDING)
+ query = query.filter(status=PurchaseOrderStatus.PENDING)
form.fields['order'].queryset = query
order_id = form['order'].value()
@@ -924,12 +1141,80 @@ class POLineItemCreate(AjaxCreateView):
order = PurchaseOrder.objects.get(id=order_id)
initials['order'] = order
- except PurchaseOrder.DoesNotExist:
+ except (PurchaseOrder.DoesNotExist, ValueError):
pass
return initials
+class SOLineItemCreate(AjaxCreateView):
+ """ Ajax view for creating a new SalesOrderLineItem object """
+
+ model = SalesOrderLineItem
+ context_order_name = 'line'
+ form_class = order_forms.EditSalesOrderLineItemForm
+ ajax_form_title = _('Add Line Item')
+
+ def get_form(self, *args, **kwargs):
+
+ form = super().get_form(*args, **kwargs)
+
+ # If the order is specified, hide the widget
+ order_id = form['order'].value()
+
+ if SalesOrder.objects.filter(id=order_id).exists():
+ form.fields['order'].widget = HiddenInput()
+
+ return form
+
+ def get_initial(self):
+ """
+ Extract initial data for this line item:
+
+ Options:
+ order: The SalesOrder object
+ part: The Part object
+ """
+
+ initials = super().get_initial().copy()
+
+ order_id = self.request.GET.get('order', None)
+ part_id = self.request.GET.get('part', None)
+
+ if order_id:
+ try:
+ order = SalesOrder.objects.get(id=order_id)
+ initials['order'] = order
+ except (SalesOrder.DoesNotExist, ValueError):
+ pass
+
+ if part_id:
+ try:
+ part = Part.objects.get(id=part_id)
+ if part.salable:
+ initials['part'] = part
+ except (Part.DoesNotExist, ValueError):
+ pass
+
+ return initials
+
+
+class SOLineItemEdit(AjaxUpdateView):
+ """ View for editing a SalesOrderLineItem """
+
+ model = SalesOrderLineItem
+ form_class = order_forms.EditSalesOrderLineItemForm
+ ajax_form_title = _('Edit Line Item')
+
+ def get_form(self):
+ form = super().get_form()
+
+ form.fields.pop('order')
+ form.fields.pop('part')
+
+ return form
+
+
class POLineItemEdit(AjaxUpdateView):
""" View for editing a PurchaseOrderLineItem object in a modal form.
"""
@@ -960,3 +1245,109 @@ class POLineItemDelete(AjaxDeleteView):
return {
'danger': _('Deleted line item'),
}
+
+
+class SOLineItemDelete(AjaxDeleteView):
+
+ model = SalesOrderLineItem
+ ajax_form_title = _("Delete Line Item")
+ ajax_template_name = "order/so_lineitem_delete.html"
+
+ def get_data(self):
+ return {
+ 'danger': _('Deleted line item'),
+ }
+
+
+class SalesOrderAllocationCreate(AjaxCreateView):
+ """ View for creating a new SalesOrderAllocation """
+
+ model = SalesOrderAllocation
+ form_class = order_forms.EditSalesOrderAllocationForm
+ ajax_form_title = _('Allocate Stock to Order')
+
+ def get_initial(self):
+ initials = super().get_initial().copy()
+
+ line_id = self.request.GET.get('line', None)
+
+ if line_id is not None:
+ line = SalesOrderLineItem.objects.get(pk=line_id)
+
+ initials['line'] = line
+
+ # Search for matching stock items, pre-fill if there is only one
+ 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
+
+ # Reduce the quantity IF there is not enough stock
+ qmax = item.quantity - item.allocation_count()
+
+ if qmax < quantity:
+ quantity = qmax
+
+ initials['quantity'] = quantity
+
+ return initials
+
+ def get_form(self):
+
+ form = super().get_form()
+
+ line_id = form['line'].value()
+
+ # If a line item has been specified, reduce the queryset for the stockitem accordingly
+ try:
+ line = SalesOrderLineItem.objects.get(pk=line_id)
+
+ queryset = form.fields['item'].queryset
+
+ # Ensure the part reference matches
+ queryset = queryset.filter(part=line.part)
+
+ # Exclude StockItem which are already allocated to this order
+ allocated = [allocation.item.pk for allocation in line.allocations.all()]
+
+ queryset = queryset.exclude(pk__in=allocated)
+
+ form.fields['item'].queryset = queryset
+
+ # Hide the 'line' field
+ form.fields['line'].widget = HiddenInput()
+
+ except (ValueError, SalesOrderLineItem.DoesNotExist):
+ pass
+
+ return form
+
+
+class SalesOrderAllocationEdit(AjaxUpdateView):
+
+ model = SalesOrderAllocation
+ form_class = order_forms.EditSalesOrderAllocationForm
+ ajax_form_title = _('Edit Allocation Quantity')
+
+ def get_form(self):
+ form = super().get_form()
+
+ # Prevent the user from editing particular fields
+ form.fields.pop('item')
+ form.fields.pop('line')
+
+ return form
+
+
+class SalesOrderAllocationDelete(AjaxDeleteView):
+
+ model = SalesOrderAllocation
+ ajax_form_title = _("Remove allocation")
+ context_object_name = 'allocation'
+ ajax_template_name = "order/so_allocation_delete.html"
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index ca5b8f11c2..a03f11cbfa 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -39,9 +39,10 @@ from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize
-from InvenTree.status_codes import BuildStatus, StockStatus, OrderStatus
+from InvenTree.status_codes import BuildStatus, StockStatus, PurchaseOrderStatus
from company.models import SupplierPart
+from stock import models as StockModels
class PartCategory(InvenTreeTree):
@@ -639,11 +640,12 @@ class Part(models.Model):
def stock_entries(self):
""" Return all 'in stock' items. To be in stock:
- - customer is None
+ - build_order is None
+ - sales_order is None
- belongs_to is None
"""
- return self.stock_items.filter(customer=None, belongs_to=None)
+ return self.stock_items.filter(StockModels.StockItem.IN_STOCK_FILTER).exclude(status__in=StockStatus.UNAVAILABLE_CODES)
@property
def total_stock(self):
@@ -824,6 +826,11 @@ class Part(models.Model):
max_price = None
for item in self.bom_items.all().select_related('sub_part'):
+
+ if item.sub_part.pk == self.pk:
+ print("Warning: Item contains itself in BOM")
+ continue
+
prices = item.sub_part.get_price_range(quantity * item.quantity)
if prices is None:
@@ -924,6 +931,17 @@ class Part(models.Model):
return n
+ def sales_orders(self):
+ """ Return a list of sales orders which reference this part """
+
+ orders = []
+
+ for line in self.sales_order_line_items.all().prefetch_related('order'):
+ if line.order not in orders:
+ orders.append(line.order)
+
+ return orders
+
def purchase_orders(self):
""" Return a list of purchase orders which reference this part """
@@ -939,18 +957,18 @@ class Part(models.Model):
def open_purchase_orders(self):
""" Return a list of open purchase orders against this part """
- return [order for order in self.purchase_orders() if order.status in OrderStatus.OPEN]
+ return [order for order in self.purchase_orders() if order.status in PurchaseOrderStatus.OPEN]
def closed_purchase_orders(self):
""" Return a list of closed purchase orders against this part """
- return [order for order in self.purchase_orders() if order.status not in OrderStatus.OPEN]
+ return [order for order in self.purchase_orders() if order.status not in PurchaseOrderStatus.OPEN]
@property
def on_order(self):
""" Return the total number of items on order for this part. """
- orders = self.supplier_parts.filter(purchase_order_line_items__order__status__in=OrderStatus.OPEN).aggregate(
+ orders = self.supplier_parts.filter(purchase_order_line_items__order__status__in=PurchaseOrderStatus.OPEN).aggregate(
quantity=Sum('purchase_order_line_items__quantity'),
received=Sum('purchase_order_line_items__received')
)
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index a7305dcb2e..8cb3584664 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -15,7 +15,7 @@ from decimal import Decimal
from django.db.models import Q, Sum
from django.db.models.functions import Coalesce
-from InvenTree.status_codes import StockStatus, OrderStatus, BuildStatus
+from InvenTree.status_codes import StockStatus, PurchaseOrderStatus, BuildStatus
from InvenTree.serializers import InvenTreeModelSerializer
@@ -52,19 +52,19 @@ class PartThumbSerializer(serializers.Serializer):
class PartBriefSerializer(InvenTreeModelSerializer):
""" Serializer for Part (brief detail) """
- url = serializers.CharField(source='get_absolute_url', read_only=True)
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
class Meta:
model = Part
fields = [
'pk',
- 'url',
'full_name',
'description',
'thumbnail',
'active',
'assembly',
+ 'purchaseable',
+ 'salable',
'virtual',
]
@@ -118,7 +118,7 @@ class PartSerializer(InvenTreeModelSerializer):
stock_filter = Q(stock_items__status__in=StockStatus.AVAILABLE_CODES)
# Filter to limit orders to "open"
- order_filter = Q(supplier_parts__purchase_order_line_items__order__status__in=OrderStatus.OPEN)
+ order_filter = Q(supplier_parts__purchase_order_line_items__order__status__in=PurchaseOrderStatus.OPEN)
# Filter to limit builds to "active"
build_filter = Q(builds__status__in=BuildStatus.ACTIVE_CODES)
@@ -233,9 +233,13 @@ class PartStarSerializer(InvenTreeModelSerializer):
class BomItemSerializer(InvenTreeModelSerializer):
""" Serializer for BomItem object """
+ price_range = serializers.CharField(read_only=True)
+
+ quantity = serializers.FloatField()
+
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True)
- price_range = serializers.CharField(read_only=True)
+
validated = serializers.BooleanField(read_only=True, source='is_line_valid')
def __init__(self, *args, **kwargs):
diff --git a/InvenTree/part/templates/part/allocation.html b/InvenTree/part/templates/part/allocation.html
index cab6c44022..77fa720403 100644
--- a/InvenTree/part/templates/part/allocation.html
+++ b/InvenTree/part/templates/part/allocation.html
@@ -18,7 +18,7 @@
{{ allocation.build.title }}
{{ allocation.build.quantity }} × {{ allocation.build.part.full_name }}
{{ allocation.quantity }}
- {% build_status allocation.build.status %}
+ {% build_status_label allocation.build.status %}
{% endfor %}