Merge pull request #1232 from SchrodingersGat/purchase-order-target-date

Purchase order target date
This commit is contained in:
Oliver 2021-01-14 23:26:58 +11:00 committed by GitHub
commit 449b462bf2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1380 additions and 825 deletions

View File

@ -5,16 +5,19 @@
fields:
name: ACME
description: A Cool Military Enterprise
- model: company.company
pk: 2
fields:
name: Appel Computers
description: Think more differenter
- model: company.company
pk: 3
fields:
name: Zerg Corp
description: We eat the competition
- model: company.company
pk: 4
fields:
@ -22,3 +25,9 @@
description: A company that we sell things to!
is_customer: True
- model: company.company
pk: 5
fields:
name: Another customer!
description: Yet another company
is_customer: True

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -80,6 +80,17 @@ class POList(generics.ListCreateAPIView):
else:
queryset = queryset.exclude(status__in=PurchaseOrderStatus.OPEN)
# Filter by 'overdue' status
overdue = params.get('overdue', None)
if overdue is not None:
overdue = str2bool(overdue)
if overdue:
queryset = queryset.filter(PurchaseOrder.OVERDUE_FILTER)
else:
queryset = queryset.exclude(PurchaseOrder.OVERDUE_FILTER)
# Special filtering for 'status' field
status = params.get('status', None)

View File

@ -7,6 +7,7 @@
reference: '0001'
description: "Ordering some screws"
supplier: 1
status: 10 # Pending
# Ordering some screws from Zerg Corp
- model: order.purchaseorder
@ -15,6 +16,39 @@
reference: '0002'
description: "Ordering some more screws"
supplier: 3
status: 10 # Pending
- model: order.purchaseorder
pk: 3
fields:
reference: '0003'
description: 'Another PO'
supplier: 3
status: 20 # Placed
- model: order.purchaseorder
pk: 4
fields:
reference: '0004'
description: 'Another PO'
supplier: 3
status: 20 # Placed
- model: order.purchaseorder
pk: 5
fields:
reference: '0005'
description: 'Another PO'
supplier: 3
status: 30 # Complete
- model: order.purchaseorder
pk: 6
fields:
reference: '0006'
description: 'Another PO'
supplier: 3
status: 40 # Cancelled
# Add some line items against PO 0001

View File

@ -0,0 +1,39 @@
- model: order.salesorder
pk: 1
fields:
reference: 'ABC123'
description: "One sales order, please"
customer: 4
status: 10 # Pending
- model: order.salesorder
pk: 2
fields:
reference: 'ABC124'
description: "One sales order, please"
customer: 4
status: 10 # Pending
- model: order.salesorder
pk: 3
fields:
reference: 'ABC125'
description: "One sales order, please"
customer: 4
status: 10 # Pending
- model: order.salesorder
pk: 4
fields:
reference: 'ABC126'
description: "One sales order, please"
customer: 5
status: 20 # Shipped
- model: order.salesorder
pk: 5
fields:
reference: 'ABC127'
description: "One sales order, please"
customer: 5
status: 60 # Returned

View File

@ -94,6 +94,7 @@ class EditPurchaseOrderForm(HelperForm):
self.field_prefix = {
'reference': 'PO',
'link': 'fa-link',
'target_date': 'fa-calendar-alt',
}
self.field_placeholder = {
@ -102,6 +103,10 @@ class EditPurchaseOrderForm(HelperForm):
super().__init__(*args, **kwargs)
target_date = DatePickerFormField(
help_text=_('Target date for order delivery. Order will be overdue after this date.'),
)
class Meta:
model = PurchaseOrder
fields = [
@ -109,6 +114,7 @@ class EditPurchaseOrderForm(HelperForm):
'supplier',
'supplier_reference',
'description',
'target_date',
'link',
]

View File

@ -0,0 +1,28 @@
# Generated by Django 3.0.7 on 2021-01-14 06:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0040_salesorder_target_date'),
]
operations = [
migrations.AddField(
model_name='purchaseorder',
name='target_date',
field=models.DateField(blank=True, help_text='Expected date for order delivery. Order will be overdue after this date.', null=True, verbose_name='Target Delivery Date'),
),
migrations.AlterField(
model_name='purchaseorder',
name='complete_date',
field=models.DateField(blank=True, help_text='Date order was completed', null=True, verbose_name='Completion Date'),
),
migrations.AlterField(
model_name='purchaseorder',
name='issue_date',
field=models.DateField(blank=True, help_text='Date order was issued', null=True, verbose_name='Issue Date'),
),
]

View File

@ -119,8 +119,11 @@ class PurchaseOrder(Order):
supplier: Reference to the company supplying the goods in the order
supplier_reference: Optional field for supplier order reference code
received_by: User that received the goods
target_date: Expected delivery target date for PurchaseOrder completion (optional)
"""
OVERDUE_FILTER = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
@staticmethod
def filterByDate(queryset, min_date, max_date):
"""
@ -132,7 +135,7 @@ class PurchaseOrder(Order):
To be "interesting":
- A "received" order where the received date lies within the date range
- TODO: A "pending" order where the target date lies within the date range
- A "pending" order where the target date lies within the date range
- TODO: An "overdue" order where the target date is in the past
"""
@ -149,13 +152,12 @@ class PurchaseOrder(Order):
# Construct a queryset for "received" orders within the range
received = Q(status=PurchaseOrderStatus.COMPLETE) & Q(complete_date__gte=min_date) & Q(complete_date__lte=max_date)
# TODO - Construct a queryset for "pending" orders within the range
# Construct a queryset for "pending" orders within the range
pending = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__gte=min_date) & Q(target_date__lte=max_date)
# TODO - Construct a queryset for "overdue" orders within the range
flt = received
queryset = queryset.filter(flt)
queryset = queryset.filter(received | pending)
return queryset
@ -186,9 +188,23 @@ class PurchaseOrder(Order):
related_name='+'
)
issue_date = models.DateField(blank=True, null=True, help_text=_('Date order was issued'))
issue_date = models.DateField(
blank=True, null=True,
verbose_name=_('Issue Date'),
help_text=_('Date order was issued')
)
complete_date = models.DateField(blank=True, null=True, help_text=_('Date order was completed'))
target_date = models.DateField(
blank=True, null=True,
verbose_name=_('Target Delivery Date'),
help_text=_('Expected date for order delivery. Order will be overdue after this date.'),
)
complete_date = models.DateField(
blank=True, null=True,
verbose_name=_('Completion Date'),
help_text=_('Date order was completed')
)
def get_absolute_url(self):
return reverse('po-detail', kwargs={'pk': self.id})
@ -256,6 +272,18 @@ class PurchaseOrder(Order):
self.complete_date = datetime.now().date()
self.save()
def is_overdue(self):
"""
Returns True if this PurchaseOrder is "overdue"
Makes use of the OVERDUE_FILTER to avoid code duplication.
"""
query = PurchaseOrder.objects.filter(pk=self.pk)
query = query.filter(PurchaseOrder.OVERDUE_FILTER)
return query.exists()
def can_cancel(self):
"""
A PurchaseOrder can only be cancelled under the following circumstances:
@ -423,17 +451,13 @@ class SalesOrder(Order):
"""
Returns true if this SalesOrder is "overdue":
- Not completed
- Target date is "in the past"
Makes use of the OVERDUE_FILTER to avoid code duplication.
"""
# Order cannot be deemed overdue if target_date is not set
if self.target_date is None:
return False
query = SalesOrder.objects.filter(pk=self.pk)
query = query.filer(SalesOrder.OVERDUE_FILTER)
today = datetime.now().date()
return self.is_pending and self.target_date < today
return query.exists()
@property
def is_pending(self):

View File

@ -40,12 +40,24 @@ class POSerializer(InvenTreeModelSerializer):
def annotate_queryset(queryset):
"""
Add extra information to the queryset
- Number of liens in the PurchaseOrder
- Overdue status of the PurchaseOrder
"""
queryset = queryset.annotate(
line_items=SubqueryCount('lines')
)
queryset = queryset.annotate(
overdue=Case(
When(
PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField())
)
)
return queryset
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
@ -54,6 +66,8 @@ class POSerializer(InvenTreeModelSerializer):
status_text = serializers.CharField(source='get_status_display', read_only=True)
overdue = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = PurchaseOrder
@ -65,12 +79,14 @@ class POSerializer(InvenTreeModelSerializer):
'description',
'line_items',
'link',
'overdue',
'reference',
'supplier',
'supplier_detail',
'supplier_reference',
'status',
'status_text',
'target_date',
'notes',
]

View File

@ -26,7 +26,12 @@ src="{% static 'img/blank_image.png' %}"
<a href="{% url 'admin:order_purchaseorder_change' order.pk %}"><span title='{% trans "Admin view" %}' class='fas fa-user-shield'></span></a>
{% endif %}
</h3>
<h3>{% purchase_order_status_label order.status large=True %}</h3>
<h3>
{% purchase_order_status_label order.status large=True %}
{% if order.is_overdue %}
<span class='label label-large label-large-red'>{% trans "Overdue" %}</span>
{% endif %}
</h3>
<hr>
<p>{{ order.description }}</p>
<div class='btn-row'>
@ -72,7 +77,12 @@ src="{% static 'img/blank_image.png' %}"
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Order Status" %}</td>
<td>{% purchase_order_status_label order.status %}</td>
<td>
{% purchase_order_status_label order.status %}
{% if order.is_overdue %}
<span class='label label-red'>{% trans "Overdue" %}</span>
{% endif %}
</td>
</tr>
<tr>
<td><span class='fas fa-building'></span></td>
@ -105,6 +115,13 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ order.issue_date }}</td>
</tr>
{% endif %}
{% if order.target_date %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Target Date" %}</td>
<td>{{ order.target_date }}</td>
</tr>
{% endif %}
{% if order.status == PurchaseOrderStatus.COMPLETE %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>

View File

@ -70,6 +70,8 @@ InvenTree | {% trans "Purchase Orders" %}
if (order.complete_date) {
date = order.complete_date;
} else if (order.target_date) {
date = order.target_date;
}
var title = `${prefix}${order.reference} - ${order.supplier_detail.name}`;

View File

@ -2,12 +2,16 @@
Tests for the Order API
"""
from datetime import datetime, timedelta
from rest_framework.test import APITestCase
from rest_framework import status
from django.urls import reverse
from django.contrib.auth import get_user_model
from .models import PurchaseOrder, SalesOrder
class OrderTest(APITestCase):
@ -18,6 +22,8 @@ class OrderTest(APITestCase):
'location',
'supplier_part',
'stock',
'order',
'sales_order',
]
def setUp(self):
@ -26,21 +32,80 @@ class OrderTest(APITestCase):
get_user_model().objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password')
def doGet(self, url, options=''):
def doGet(self, url, data={}):
return self.client.get(url + "?" + options, format='json')
return self.client.get(url, data=data, format='json')
def doPost(self, url, data={}):
return self.client.post(url, data=data, format='json')
def filter(self, filters, count):
"""
Test API filters
"""
response = self.doGet(
self.LIST_URL,
filters
)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), count)
return response
class PurchaseOrderTest(OrderTest):
"""
Tests for the PurchaseOrder API
"""
LIST_URL = reverse('api-po-list')
def test_po_list(self):
url = reverse('api-po-list')
# List *ALL* PO items
self.filter({}, 6)
# Filter by supplier
self.filter({'supplier': 1}, 1)
self.filter({'supplier': 3}, 5)
# Filter by "outstanding"
self.filter({'outstanding': True}, 4)
self.filter({'outstanding': False}, 2)
# Filter by "status"
self.filter({'status': 10}, 2)
self.filter({'status': 40}, 1)
def test_overdue(self):
"""
Test "overdue" status
"""
self.filter({'overdue': True}, 0)
self.filter({'overdue': False}, 6)
order = PurchaseOrder.objects.get(pk=1)
order.target_date = datetime.now().date() - timedelta(days=10)
order.save()
self.filter({'overdue': True}, 1)
self.filter({'overdue': False}, 5)
def test_po_detail(self):
url = '/api/order/po/1/'
# List all order items
response = self.doGet(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Filter by stuff
response = self.doGet(url, 'status=10&part=1&supplier_part=1')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.status_code, 200)
data = response.data
self.assertEqual(data['pk'], 1)
self.assertEqual(data['description'], 'Ordering some screws')
def test_po_attachments(self):
@ -50,6 +115,60 @@ class OrderTest(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
class SalesOrderTest(OrderTest):
"""
Tests for the SalesOrder API
"""
LIST_URL = reverse('api-so-list')
def test_so_list(self):
# All orders
self.filter({}, 5)
# Filter by customer
self.filter({'customer': 4}, 3)
self.filter({'customer': 5}, 2)
# Filter by outstanding
self.filter({'outstanding': True}, 3)
self.filter({'outstanding': False}, 2)
# Filter by status
self.filter({'status': 10}, 3) # PENDING
self.filter({'status': 20}, 1) # SHIPPED
self.filter({'status': 99}, 0) # Invalid
def test_overdue(self):
"""
Test "overdue" status
"""
self.filter({'overdue': True}, 0)
self.filter({'overdue': False}, 5)
for pk in [1, 2]:
order = SalesOrder.objects.get(pk=pk)
order.target_date = datetime.now().date() - timedelta(days=10)
order.save()
self.filter({'overdue': True}, 2)
self.filter({'overdue': False}, 3)
def test_so_detail(self):
url = '/api/order/so/1/'
response = self.doGet(url)
self.assertEqual(response.status_code, 200)
data = response.data
self.assertEqual(data['pk'], 1)
def test_so_attachments(self):
url = reverse('api-so-attachment-list')

View File

@ -41,7 +41,7 @@ class OrderTest(TestCase):
next_ref = PurchaseOrder.getNextOrderNumber()
self.assertEqual(next_ref, '0003')
self.assertEqual(next_ref, '0007')
def test_on_order(self):
""" There should be 3 separate items on order for the M2x4 LPHS part """

View File

@ -35,6 +35,7 @@ InvenTree | {% trans "Index" %}
{% if roles.purchase_order.view %}
{% include "InvenTree/po_outstanding.html" with collapse_id="po_outstanding" %}
{% endif %}
{% include "InvenTree/po_overdue.html" with collapse_id="po_overdue" %}
{% if roles.sales_order.view %}
{% include "InvenTree/so_outstanding.html" with collapse_id="so_outstanding" %}
{% include "InvenTree/so_overdue.html" with collapse_id="so_overdue" %}
@ -130,6 +131,14 @@ loadPurchaseOrderTable("#po-outstanding-table", {
}
});
loadPurchaseOrderTable("#po-overdue-table", {
url: "{% url 'api-po-list' %}",
params: {
supplier_detail: true,
overdue: true,
}
});
loadSalesOrderTable("#so-outstanding-table", {
url: "{% url 'api-so-list' %}",
params: {
@ -158,6 +167,7 @@ loadSalesOrderTable("#so-overdue-table", {
{% include "InvenTree/index/on_load.html" with label="stock-to-build" %}
{% include "InvenTree/index/on_load.html" with label="po-outstanding" %}
{% include "InvenTree/index/on_load.html" with label="po-overdue" %}
{% include "InvenTree/index/on_load.html" with label="so-outstanding" %}
{% include "InvenTree/index/on_load.html" with label="so-overdue" %}

View File

@ -0,0 +1,15 @@
{% extends "collapse_index.html" %}
{% load i18n %}
{% block collapse_title %}
<span class='fas fa-calendar-times icon-header'></span>
{% trans "Overdue Purchase Orders" %}<span class='badge' id='po-overdue-count'><span class='fas fa-spin fa-spinner'></span></span>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' id='po-overdue-table'>
</table>
{% endblock %}

View File

@ -141,9 +141,9 @@ function loadPurchaseOrderTable(table, options) {
switchable: false,
},
{
sortable: true,
field: 'reference',
title: '{% trans "Purchase Order" %}',
sortable: true,
switchable: false,
formatter: function(value, row, index, field) {
@ -153,13 +153,19 @@ function loadPurchaseOrderTable(table, options) {
value = `${prefix}${value}`;
}
return renderLink(value, `/order/purchase-order/${row.pk}/`);
var html = renderLink(value, `/order/purchase-order/${row.pk}/`);
if (row.overdue) {
html += makeIconBadge('fa-calendar-times icon-red', '{% trans "Order is overdue" %}');
}
return html;
}
},
{
sortable: true,
field: 'supplier_detail',
title: '{% trans "Supplier" %}',
sortable: true,
formatter: function(value, row, index, field) {
return imageHoverIcon(row.supplier_detail.image) + renderLink(row.supplier_detail.name, `/company/${row.supplier}/purchase-orders/`);
}
@ -170,27 +176,32 @@ function loadPurchaseOrderTable(table, options) {
sortable: true,
},
{
sortable: true,
field: 'description',
title: '{% trans "Description" %}',
sortable: true,
},
{
sortable: true,
field: 'status',
title: '{% trans "Status" %}',
sortable: true,
formatter: function(value, row, index, field) {
return purchaseOrderStatusDisplay(row.status, row.status_text);
}
},
{
sortable: true,
field: 'creation_date',
title: '{% trans "Date" %}',
sortable: true,
},
{
field: 'target_date',
title: '{% trans "Target Date" %}',
sortable: true,
},
{
field: 'line_items',
title: '{% trans "Items" %}'
title: '{% trans "Items" %}',
sortable: true,
},
],
});

View File

@ -214,6 +214,10 @@ function getAvailableTableFilters(tableKey) {
type: 'bool',
title: '{% trans "Outstanding" %}',
},
overdue: {
type: 'bool',
title: '{% trans "Overdue" %}',
},
};
}