[Feature] Add RMA support (#4488)

* Adds ReturnOrder and ReturnOrderAttachment models

* Adds new 'role' specific for return orders

* Refactor total_price into a mixin

- Required for PurchaseOrder and SalesOrder
- May not be required for ReturnOrder (remains to be seen)

* Adds API endpoints for ReturnOrder

- Add list endpoint
- Add detail endpoint
- Adds required serializer models

* Adds basic "index" page for Return Order model

* Update API version

* Update navbar text

* Add db migration for new "role"

* Add ContactList and ContactDetail API endpoints

* Adds template and JS code for manipulation of contacts

- Display a table
- Create / edit / delete

* Splits order.js into multiple files

- Javascript files was becoming extremely large
- Hard to debug and find code
- Split into purchase_order / return_order / sales_order

* Fix role name (change 'returns' to 'return_order')

- Similar to existing roles for purchase_order and sales_order

* Adds detail page for ReturnOrder

* URL cleanup

- Use <int:pk> instead of complex regex

* More URL cleanup

* Add "return orders" list to company detail page

* Break JS status codes into new javascript file

- Always difficult to track down where these are rendered
- Enough to warrant their own file now

* Add ability to edit return order from detail page

* Database migrations

- Add new ReturnOrder modeles
- Add new 'contact' field to external orders

* Adds "contact" to ReturnOrder

- Implement check to ensure that the selected "contact" matches the selected "company"

* Adjust filters to limit contact options

* Fix typo

* Expose 'contact' field for PurchaseOrder model

* Render contact information

* Add "contact" for SalesOrder

* Adds setting to enable / disable return order functionality

- Simply hides the navigation elements
- API is not disabled

* Support filtering ReturnOrder by 'status'

- Refactors existing filter into the OrderFilter class

* js linting

* More JS linting

* Adds ReturnOrderReport model

* Add serializer for the ReturnOrderReport model

- A little bit of refactoring along the way

* Admin integration for new report model

* Refactoring for report.api

- Adds generic mixins for filtering queryset (based on updates to label.api)
- Reduces repeated code a *lot*

* Exposes API endpoints for ReturnOrderReport

* Adds default example report file for ReturnOrder

- Requires some more work :)

* Refactor report printing javascript code

- Replace all existing functions with 'printReports'

* Improvements for default StockItem test report template

- Fix bug in template
- Handle potential errors in template tags
- Add more helpers to report tags
- Improve test result rendering

* Reduce logging verbosity from weasyprint

* Refactor javascript for label printing

- Consolidate into a single function
- Similar to refactor of report functions

* Add report print button to return order page

* Record user reference when creating via API

* Refactor order serializers

- Move common code into AbstractOrderSerializer class

* Adds extra line item model for the return order

- Adds serializer and API endpoints as appropriate

* Render extra line table for return order

- Refactor existing functions into a single generic function
- Reduces repeated JS code a lot

* Add ability to create a new extra line item

* Adds button for creating a new lien item

* JS linting

* Update test

* Typo fix

(cherry picked from commit 28ac2be35b)

* Enable search for return order

* Don't do pricing (yet) for returnorder extra line table

- Fixes an uncaught error

* Error catching for api.js

* Updates for order models:

- Add 'target_date' field to abstract Order model
- Add IN_PROGRESS status code for return order
- Refactor 'overdue' and 'outstanding' API queries
- Refactor OVERDUE_FILTER on order models
- Refactor is_overdue on order models
- More table filters for return order model

* JS cleanup

* Create ReturnOrderLineItem model

- New type of status label
- Add TotalPriceMixin to ReturnOrder model

* Adds an API serializer for the ReturnOrderLineItem model

* Add API endpoints for ReturnOrderLineItem model

- Including some refactoring along the way

* javascript: refactor loadTableFilters function

- Pass enforced query through to the filters
- Call Object.assign() to construct a superset query
- Removes a lot of code duplication

* Refactor hard-coded URLS to use {% url %} lookup

- Forces error if the URL is wrong
- If we ever change the URL, will still work

* Implement creation of new return order line items

* Adds 'part_detail' annotation to ReturnOrderLineItem serializer

- Required for rendering part information

* javascript: refactor method for creating a group of buttons in a table

* javascript: refactor common buttons with helper functions

* Allow edit and delete of return order line items

* Add form option to automatically reload a table on success

- Pass table name to options.refreshTable

* JS linting

* Add common function for createExtraLineItem

* Refactor loading of attachment tables

- Setup drag-and-drop as part of core function

* CI fixes

* Refactoring out some more common API endpoint code

* Update migrations

* Fix permission typo

* Refactor for unit testing code

* Add unit tests for Contact model

* Tests for returnorder list API

* Annotate 'line_items' to ReturnOrder serializer

* Driving the refactor tractor

* More unit tests for the ReturnOrder API endpoints

* Refactor "print orders" button for various order tables

- Move into "setupFilterList" code (generic)

* add generic 'label printing' button to table actions buttons

* Refactor build output table

* Refactoring icon generation for js

* Refactoring for Part API

* Fix database model type for 'received_date'

* Add API endpoint to "issue" a ReturnOrder

* Improvements for stock tracking table

- Add new status codes
- Add rendering for SalesOrder
- Add rendering for ReturnOrder
- Fix status badges

* Adds functionality to receive line items against a return order

* Add endpoints for completing and cancelling orders

* Add option to allow / prevent editing of ReturnOrder after completed

* js linting

* Wrap "add extra line" button in setting check

* Updates to order/admin.py

* Remove inline admin for returnorderline model

* Updates to pass CI

* Serializer fix

* order template fixes

* Unit test fix

* Fixes for ReturnOrder.receive_line_item

* Unit testing for receiving line items against an RMA

* Improve example report for return order

* Extend unit tests for reporting

* Cleanup here and there

* Unit testing for order views

* Clear "sales_order" field when returning against ReturnOrder

* Add 'location' to deltas when returning from customer

* Bug fix for unit test
This commit is contained in:
Oliver 2023-03-29 10:35:43 +11:00 committed by GitHub
parent d4a64b4f7d
commit 27aa16d55d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
122 changed files with 10391 additions and 7053 deletions

View File

@ -242,6 +242,7 @@ class APISearchView(APIView):
'part': part.api.PartList, 'part': part.api.PartList,
'partcategory': part.api.CategoryList, 'partcategory': part.api.CategoryList,
'purchaseorder': order.api.PurchaseOrderList, 'purchaseorder': order.api.PurchaseOrderList,
'returnorder': order.api.ReturnOrderList,
'salesorder': order.api.SalesOrderList, 'salesorder': order.api.SalesOrderList,
'stockitem': stock.api.StockList, 'stockitem': stock.api.StockList,
'stocklocation': stock.api.StockLocationList, 'stocklocation': stock.api.StockLocationList,

View File

@ -165,6 +165,26 @@ class ExchangeRateMixin:
class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
"""Base class for running InvenTree API tests.""" """Base class for running InvenTree API tests."""
def checkResponse(self, url, method, expected_code, response):
"""Debug output for an unexpected response"""
# No expected code, return
if expected_code is None:
return
if expected_code != response.status_code:
print(f"Unexpected {method} response at '{url}': status_code = {response.status_code}")
if hasattr(response, 'data'):
print('data:', response.data)
if hasattr(response, 'body'):
print('body:', response.body)
if hasattr(response, 'content'):
print('content:', response.content)
self.assertEqual(expected_code, response.status_code)
def getActions(self, url): def getActions(self, url):
"""Return a dict of the 'actions' available at a given endpoint. """Return a dict of the 'actions' available at a given endpoint.
@ -188,19 +208,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
response = self.client.get(url, data, format=format) response = self.client.get(url, data, format=format)
if expected_code is not None: self.checkResponse(url, 'GET', expected_code, response)
if response.status_code != expected_code:
print(f"Unexpected response at '{url}': status_code = {response.status_code}")
if hasattr(response, 'data'):
print('data:', response.data)
if hasattr(response, 'body'):
print('body:', response.body)
if hasattr(response, 'content'):
print('content:', response.content)
self.assertEqual(response.status_code, expected_code)
return response return response
@ -213,17 +221,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
response = self.client.post(url, data=data, format=format) response = self.client.post(url, data=data, format=format)
if expected_code is not None: self.checkResponse(url, 'POST', expected_code, response)
if response.status_code != expected_code:
print(f"Unexpected response at '{url}': status code = {response.status_code}")
if hasattr(response, 'data'):
print(response.data)
else:
print(f"(response object {type(response)} has no 'data' attribute")
self.assertEqual(response.status_code, expected_code)
return response return response
@ -235,8 +233,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
response = self.client.delete(url, data=data, format=format) response = self.client.delete(url, data=data, format=format)
if expected_code is not None: self.checkResponse(url, 'DELETE', expected_code, response)
self.assertEqual(response.status_code, expected_code)
return response return response
@ -244,8 +241,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
"""Issue a PATCH request.""" """Issue a PATCH request."""
response = self.client.patch(url, data=data, format=format) response = self.client.patch(url, data=data, format=format)
if expected_code is not None: self.checkResponse(url, 'PATCH', expected_code, response)
self.assertEqual(response.status_code, expected_code)
return response return response
@ -253,13 +249,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
"""Issue a PUT request.""" """Issue a PUT request."""
response = self.client.put(url, data=data, format=format) response = self.client.put(url, data=data, format=format)
if expected_code is not None: self.checkResponse(url, 'PUT', expected_code, response)
if response.status_code != expected_code:
print(f"Unexpected response at '{url}':")
print(response.data)
self.assertEqual(response.status_code, expected_code)
return response return response
@ -267,8 +257,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
"""Issue an OPTIONS request.""" """Issue an OPTIONS request."""
response = self.client.options(url, format='json') response = self.client.options(url, format='json')
if expected_code is not None: self.checkResponse(url, 'OPTIONS', expected_code, response)
self.assertEqual(response.status_code, expected_code)
return response return response

View File

@ -2,16 +2,21 @@
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 103 INVENTREE_API_VERSION = 104
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v104 -> 2023-03-23 : https://github.com/inventree/InvenTree/pull/4488
- Adds various endpoints for new "ReturnOrder" models
- Adds various endpoints for new "ReturnOrderReport" templates
- Exposes API endpoints for "Contact" model
v103 -> 2023-03-17 : https://github.com/inventree/InvenTree/pull/4410 v103 -> 2023-03-17 : https://github.com/inventree/InvenTree/pull/4410
- Add metadata to several more models - Add metadata to several more models
v102 -> 2023-03-18 : https://github.com/inventree/InvenTree/pull/4505 v102 -> 2023-03-18 : https://github.com/inventree/InvenTree/pull/4505
- Adds global search API endpoint for consolidated search results - Adds global search API endpoint for consolidated search results
v101 -> 2023-03-07 : https://github.com/inventree/InvenTree/pull/4462 v101 -> 2023-03-07 : https://github.com/inventree/InvenTree/pull/4462
- Adds 'total_in_stock' to Part serializer, and supports API ordering - Adds 'total_in_stock' to Part serializer, and supports API ordering

View File

@ -4,6 +4,7 @@
import InvenTree.status import InvenTree.status
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
ReturnOrderLineStatus, ReturnOrderStatus,
SalesOrderStatus, StockHistoryCode, SalesOrderStatus, StockHistoryCode,
StockStatus) StockStatus)
from users.models import RuleSet, check_user_role from users.models import RuleSet, check_user_role
@ -58,6 +59,8 @@ def status_codes(request):
return { return {
# Expose the StatusCode classes to the templates # Expose the StatusCode classes to the templates
'ReturnOrderStatus': ReturnOrderStatus,
'ReturnOrderLineStatus': ReturnOrderLineStatus,
'SalesOrderStatus': SalesOrderStatus, 'SalesOrderStatus': SalesOrderStatus,
'PurchaseOrderStatus': PurchaseOrderStatus, 'PurchaseOrderStatus': PurchaseOrderStatus,
'BuildStatus': BuildStatus, 'BuildStatus': BuildStatus,

View File

@ -1123,7 +1123,10 @@ def render_currency(money, decimal_places=None, currency=None, include_symbol=Tr
min_decimal_places: The minimum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES_MIN setting. min_decimal_places: The minimum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES_MIN setting.
""" """
if money is None or money.amount is None: if money in [None, '']:
return '-'
if type(money) is not Money:
return '-' return '-'
if currency is not None: if currency is not None:

View File

@ -315,9 +315,7 @@ main {
} }
.filter-button { .filter-button {
padding: 2px; padding: 6px;
padding-left: 4px;
padding-right: 4px;
} }
.filter-input { .filter-input {

View File

@ -155,30 +155,37 @@ function inventreeDocReady() {
} }
/*
* Determine if a transfer (e.g. drag-and-drop) is a file transfer
*/
function isFileTransfer(transfer) { function isFileTransfer(transfer) {
/* Determine if a transfer (e.g. drag-and-drop) is a file transfer
*/
return transfer.files.length > 0; return transfer.files.length > 0;
} }
function enableDragAndDrop(element, url, options) { /* Enable drag-and-drop file uploading for a given element.
/* Enable drag-and-drop file uploading for a given element.
Params: Params:
element - HTML element lookup string e.g. "#drop-div" element - HTML element lookup string e.g. "#drop-div"
url - URL to POST the file to url - URL to POST the file to
options - object with following possible values: options - object with following possible values:
label - Label of the file to upload (default='file') label - Label of the file to upload (default='file')
data - Other form data to upload data - Other form data to upload
success - Callback function in case of success success - Callback function in case of success
error - Callback function in case of error error - Callback function in case of error
method - HTTP method method - HTTP method
*/ */
function enableDragAndDrop(elementId, url, options={}) {
var data = options.data || {}; var data = options.data || {};
let element = $(elementId);
if (!element.exists()) {
console.error(`enableDragAndDrop called with invalid target: '${elementId}'`);
return;
}
$(element).on('drop', function(event) { $(element).on('drop', function(event) {
var transfer = event.originalEvent.dataTransfer; var transfer = event.originalEvent.dataTransfer;
@ -200,6 +207,11 @@ function enableDragAndDrop(element, url, options) {
formData, formData,
{ {
success: function(data, status, xhr) { success: function(data, status, xhr) {
// Reload a table
if (options.refreshTable) {
reloadBootstrapTable(options.refreshTable);
}
if (options.success) { if (options.success) {
options.success(data, status, xhr); options.success(data, status, xhr);
} }

View File

@ -247,10 +247,14 @@ class StockHistoryCode(StatusCode):
BUILD_CONSUMED = 57 BUILD_CONSUMED = 57
# Sales order codes # Sales order codes
SHIPPED_AGAINST_SALES_ORDER = 60
# Purchase order codes # Purchase order codes
RECEIVED_AGAINST_PURCHASE_ORDER = 70 RECEIVED_AGAINST_PURCHASE_ORDER = 70
# Return order codes
RETURNED_AGAINST_RETURN_ORDER = 80
# Customer actions # Customer actions
SENT_TO_CUSTOMER = 100 SENT_TO_CUSTOMER = 100
RETURNED_FROM_CUSTOMER = 105 RETURNED_FROM_CUSTOMER = 105
@ -289,8 +293,11 @@ class StockHistoryCode(StatusCode):
BUILD_OUTPUT_COMPLETED: _('Build order output completed'), BUILD_OUTPUT_COMPLETED: _('Build order output completed'),
BUILD_CONSUMED: _('Consumed by build order'), BUILD_CONSUMED: _('Consumed by build order'),
RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against purchase order') SHIPPED_AGAINST_SALES_ORDER: _("Shipped against Sales Order"),
RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against Purchase Order'),
RETURNED_AGAINST_RETURN_ORDER: _('Returned against Return Order'),
} }
@ -320,3 +327,74 @@ class BuildStatus(StatusCode):
PENDING, PENDING,
PRODUCTION, PRODUCTION,
] ]
class ReturnOrderStatus(StatusCode):
"""Defines a set of status codes for a ReturnOrder"""
# Order is pending, waiting for receipt of items
PENDING = 10
# Items have been received, and are being inspected
IN_PROGRESS = 20
COMPLETE = 30
CANCELLED = 40
OPEN = [
PENDING,
IN_PROGRESS,
]
options = {
PENDING: _("Pending"),
IN_PROGRESS: _("In Progress"),
COMPLETE: _("Complete"),
CANCELLED: _("Cancelled"),
}
colors = {
PENDING: 'secondary',
IN_PROGRESS: 'primary',
COMPLETE: 'success',
CANCELLED: 'danger',
}
class ReturnOrderLineStatus(StatusCode):
"""Defines a set of status codes for a ReturnOrderLineItem"""
PENDING = 10
# Item is to be returned to customer, no other action
RETURN = 20
# Item is to be repaired, and returned to customer
REPAIR = 30
# Item is to be replaced (new item shipped)
REPLACE = 40
# Item is to be refunded (cannot be repaired)
REFUND = 50
# Item is rejected
REJECT = 60
options = {
PENDING: _('Pending'),
RETURN: _('Return'),
REPAIR: _('Repair'),
REFUND: _('Refund'),
REPLACE: _('Replace'),
REJECT: _('Reject')
}
colors = {
PENDING: 'secondary',
RETURN: 'success',
REPAIR: 'primary',
REFUND: 'info',
REPLACE: 'warning',
REJECT: 'danger',
}

View File

@ -112,9 +112,13 @@ translated_javascript_urls = [
re_path(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'), re_path(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'),
re_path(r'^order.js', DynamicJsView.as_view(template_name='js/translated/order.js'), name='order.js'), re_path(r'^order.js', DynamicJsView.as_view(template_name='js/translated/order.js'), name='order.js'),
re_path(r'^part.js', DynamicJsView.as_view(template_name='js/translated/part.js'), name='part.js'), re_path(r'^part.js', DynamicJsView.as_view(template_name='js/translated/part.js'), name='part.js'),
re_path(r'^purchase_order.js', DynamicJsView.as_view(template_name='js/translated/purchase_order.js'), name='purchase_order.js'),
re_path(r'^return_order.js', DynamicJsView.as_view(template_name='js/translated/return_order.js'), name='return_order.js'),
re_path(r'^report.js', DynamicJsView.as_view(template_name='js/translated/report.js'), name='report.js'), re_path(r'^report.js', DynamicJsView.as_view(template_name='js/translated/report.js'), name='report.js'),
re_path(r'^sales_order.js', DynamicJsView.as_view(template_name='js/translated/sales_order.js'), name='sales_order.js'),
re_path(r'^search.js', DynamicJsView.as_view(template_name='js/translated/search.js'), name='search.js'), re_path(r'^search.js', DynamicJsView.as_view(template_name='js/translated/search.js'), name='search.js'),
re_path(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'), re_path(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'),
re_path(r'^status_codes.js', DynamicJsView.as_view(template_name='js/translated/status_codes.js'), name='status_codes.js'),
re_path(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'), re_path(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'),
re_path(r'^pricing.js', DynamicJsView.as_view(template_name='js/translated/pricing.js'), name='pricing.js'), re_path(r'^pricing.js', DynamicJsView.as_view(template_name='js/translated/pricing.js'), name='pricing.js'),
re_path(r'^news.js', DynamicJsView.as_view(template_name='js/translated/news.js'), name='news.js'), re_path(r'^news.js', DynamicJsView.as_view(template_name='js/translated/news.js'), name='news.js'),

View File

@ -1,6 +1,6 @@
"""JSON API for the Build app.""" """JSON API for the Build app."""
from django.urls import include, re_path from django.urls import include, path, re_path
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -509,13 +509,13 @@ build_api_urls = [
# Attachments # Attachments
re_path(r'^attachment/', include([ re_path(r'^attachment/', include([
re_path(r'^(?P<pk>\d+)/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'), path(r'<int:pk>/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'),
re_path(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'), re_path(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
])), ])),
# Build Items # Build Items
re_path(r'^item/', include([ re_path(r'^item/', include([
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'^metadata/', BuildItemMetadata.as_view(), name='api-build-item-metadata'), re_path(r'^metadata/', BuildItemMetadata.as_view(), name='api-build-item-metadata'),
re_path(r'^.*$', BuildItemDetail.as_view(), name='api-build-item-detail'), re_path(r'^.*$', BuildItemDetail.as_view(), name='api-build-item-detail'),
])), ])),
@ -523,7 +523,7 @@ build_api_urls = [
])), ])),
# Build Detail # Build Detail
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), re_path(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
re_path(r'^auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-allocate'), re_path(r'^auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-allocate'),
re_path(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'), re_path(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),

View File

@ -36,9 +36,10 @@ from plugin.events import trigger_event
from plugin.models import MetadataMixin from plugin.models import MetadataMixin
import common.notifications import common.notifications
from part import models as PartModels
from stock import models as StockModels import part.models
from users import models as UserModels import stock.models
import users.models
class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin): class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
@ -279,7 +280,7 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
) )
responsible = models.ForeignKey( responsible = models.ForeignKey(
UserModels.Owner, users.models.Owner,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, null=True, blank=True, null=True,
verbose_name=_('Responsible'), verbose_name=_('Responsible'),
@ -395,9 +396,9 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
if in_stock is not None: if in_stock is not None:
if in_stock: if in_stock:
outputs = outputs.filter(StockModels.StockItem.IN_STOCK_FILTER) outputs = outputs.filter(stock.models.StockItem.IN_STOCK_FILTER)
else: else:
outputs = outputs.exclude(StockModels.StockItem.IN_STOCK_FILTER) outputs = outputs.exclude(stock.models.StockItem.IN_STOCK_FILTER)
# Filter by 'complete' status # Filter by 'complete' status
complete = kwargs.get('complete', None) complete = kwargs.get('complete', None)
@ -659,7 +660,7 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
else: else:
serial = None serial = None
output = StockModels.StockItem.objects.create( output = stock.models.StockItem.objects.create(
quantity=1, quantity=1,
location=location, location=location,
part=self.part, part=self.part,
@ -677,11 +678,11 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
parts = bom_item.get_valid_parts_for_allocation() parts = bom_item.get_valid_parts_for_allocation()
items = StockModels.StockItem.objects.filter( items = stock.models.StockItem.objects.filter(
part__in=parts, part__in=parts,
serial=str(serial), serial=str(serial),
quantity=1, quantity=1,
).filter(StockModels.StockItem.IN_STOCK_FILTER) ).filter(stock.models.StockItem.IN_STOCK_FILTER)
""" """
Test if there is a matching serial number! Test if there is a matching serial number!
@ -701,7 +702,7 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
else: else:
"""Create a single build output of the given quantity.""" """Create a single build output of the given quantity."""
StockModels.StockItem.objects.create( stock.models.StockItem.objects.create(
quantity=quantity, quantity=quantity,
location=location, location=location,
part=self.part, part=self.part,
@ -877,7 +878,7 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
) )
# Look for available stock items # Look for available stock items
available_stock = StockModels.StockItem.objects.filter(StockModels.StockItem.IN_STOCK_FILTER) available_stock = stock.models.StockItem.objects.filter(stock.models.StockItem.IN_STOCK_FILTER)
# Filter by list of available parts # Filter by list of available parts
available_stock = available_stock.filter( available_stock = available_stock.filter(
@ -1220,7 +1221,7 @@ class BuildItem(MetadataMixin, models.Model):
'quantity': _('Quantity must be 1 for serialized stock') 'quantity': _('Quantity must be 1 for serialized stock')
}) })
except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist): except (stock.models.StockItem.DoesNotExist, part.models.Part.DoesNotExist):
pass pass
""" """
@ -1259,8 +1260,8 @@ class BuildItem(MetadataMixin, models.Model):
for idx, ancestor in enumerate(ancestors): for idx, ancestor in enumerate(ancestors):
try: try:
bom_item = PartModels.BomItem.objects.get(part=self.build.part, sub_part=ancestor) bom_item = part.models.BomItem.objects.get(part=self.build.part, sub_part=ancestor)
except PartModels.BomItem.DoesNotExist: except part.models.BomItem.DoesNotExist:
continue continue
# A matching BOM item has been found! # A matching BOM item has been found!
@ -1350,7 +1351,7 @@ class BuildItem(MetadataMixin, models.Model):
# Internal model which links part <-> sub_part # Internal model which links part <-> sub_part
# We need to track this separately, to allow for "variant' stock # We need to track this separately, to allow for "variant' stock
bom_item = models.ForeignKey( bom_item = models.ForeignKey(
PartModels.BomItem, part.models.BomItem,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='allocate_build_items', related_name='allocate_build_items',
blank=True, null=True, blank=True, null=True,

View File

@ -247,7 +247,11 @@ src="{% static 'img/blank_image.png' %}"
{% if report_enabled %} {% if report_enabled %}
$('#print-build-report').click(function() { $('#print-build-report').click(function() {
printBuildReports([{{ build.pk }}]); printReports({
items: [{{ build.pk }}],
key: 'build',
url: '{% url "api-build-report-list" %}',
});
}); });
{% endif %} {% endif %}

View File

@ -268,19 +268,6 @@
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
<!-- Label Printing Actions -->
<div class='btn-group'>
<button id='output-print-options' class='btn btn-primary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Printing Actions" %}'>
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
<li><a class='dropdown-item' href='#' id='incomplete-output-print-label' title='{% trans "Print labels" %}'>
<span class='fas fa-tags'></span> {% trans "Print labels" %}
</a></li>
</ul>
</div>
{% include "filter_list.html" with id='incompletebuilditems' %} {% include "filter_list.html" with id='incompletebuilditems' %}
</div> </div>
{% endif %} {% endif %}
@ -367,20 +354,6 @@ onPanelLoad('children', function() {
onPanelLoad('attachments', function() { onPanelLoad('attachments', function() {
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-build-attachment-list" %}',
{
data: {
build: {{ build.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
$('#attachment-table').bootstrapTable('refresh');
}
}
);
loadAttachmentTable('{% url "api-build-attachment-list" %}', { loadAttachmentTable('{% url "api-build-attachment-list" %}', {
filters: { filters: {
build: {{ build.pk }}, build: {{ build.pk }},
@ -409,10 +382,6 @@ onPanelLoad('notes', function() {
); );
}); });
function reloadTable() {
$('#allocation-table-untracked').bootstrapTable('refresh');
}
onPanelLoad('outputs', function() { onPanelLoad('outputs', function() {
{% if build.active %} {% if build.active %}

View File

@ -26,20 +26,6 @@
<div id='button-toolbar'> <div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if report_enabled %}
<div class='btn-group' role='group'>
<!-- Print actions -->
<button id='build-print-options' class='btn btn-primary dropdown-toggle' data-bs-toggle='dropdown'>
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
<li><a class='dropdown-item' href='#' id='multi-build-print' title='{% trans "Print Build Orders" %}'>
<span class='fas fa-file-pdf'></span> {% trans "Print Build Orders" %}
</a></li>
</ul>
</div>
{% endif %}
{% include "filter_list.html" with id="build" %} {% include "filter_list.html" with id="build" %}
</div> </div>
</div> </div>
@ -62,17 +48,4 @@ loadBuildTable($("#build-table"), {
locale: '{{ request.LANGUAGE_CODE }}', locale: '{{ request.LANGUAGE_CODE }}',
}); });
{% if report_enabled %}
$('#multi-build-print').click(function() {
var rows = getTableData("#build-table");
var build_ids = [];
rows.forEach(function(row) {
build_ids.push(row.pk);
});
printBuildReports(build_ids);
});
{% endif %}
{% endblock %} {% endblock %}

View File

@ -1,13 +1,13 @@
"""URL lookup for Build app.""" """URL lookup for Build app."""
from django.urls import include, re_path from django.urls import include, path, re_path
from . import views from . import views
build_urls = [ build_urls = [
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), re_path(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
])), ])),

View File

@ -457,7 +457,7 @@ settings_api_urls = [
# Notification settings # Notification settings
re_path(r'^notification/', include([ re_path(r'^notification/', include([
# Notification Settings Detail # Notification Settings Detail
re_path(r'^(?P<pk>\d+)/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'), path(r'<int:pk>/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'),
# Notification Settings List # Notification Settings List
re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notifcation-setting-list'), re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notifcation-setting-list'),
@ -486,7 +486,7 @@ common_api_urls = [
# Notifications # Notifications
re_path(r'^notifications/', include([ re_path(r'^notifications/', include([
# Individual purchase order detail URLs # Individual purchase order detail URLs
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'.*$', NotificationDetail.as_view(), name='api-notifications-detail'), re_path(r'.*$', NotificationDetail.as_view(), name='api-notifications-detail'),
])), ])),
# Read all # Read all
@ -498,7 +498,7 @@ common_api_urls = [
# News # News
re_path(r'^news/', include([ re_path(r'^news/', include([
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'.*$', NewsFeedEntryDetail.as_view(), name='api-news-detail'), re_path(r'.*$', NewsFeedEntryDetail.as_view(), name='api-news-detail'),
])), ])),
re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'), re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'),

View File

@ -1441,6 +1441,27 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': build.validators.validate_build_order_reference_pattern, 'validator': build.validators.validate_build_order_reference_pattern,
}, },
'RETURNORDER_ENABLED': {
'name': _('Enable Return Orders'),
'description': _('Enable return order functionality in the user interface'),
'validator': bool,
'default': False,
},
'RETURNORDER_REFERENCE_PATTERN': {
'name': _('Return Order Reference Pattern'),
'description': _('Required pattern for generating Return Order reference field'),
'default': 'RMA-{ref:04d}',
'validator': order.validators.validate_return_order_reference_pattern,
},
'RETURNORDER_EDIT_COMPLETED_ORDERS': {
'name': _('Edit Completed Return Orders'),
'description': _('Allow editing of return orders after they have been completed'),
'default': False,
'validator': bool,
},
'SALESORDER_REFERENCE_PATTERN': { 'SALESORDER_REFERENCE_PATTERN': {
'name': _('Sales Order Reference Pattern'), 'name': _('Sales Order Reference Pattern'),
'description': _('Required pattern for generating Sales Order reference field'), 'description': _('Required pattern for generating Sales Order reference field'),
@ -1937,6 +1958,20 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'default': True, 'default': True,
}, },
'SEARCH_PREVIEW_SHOW_RETURN_ORDERS': {
'name': _('Search Return Orders'),
'description': _('Display return orders in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_EXCLUDE_INACTIVE_RETURN_ORDERS': {
'name': _('Exclude Inactive Return Orders'),
'description': _('Exclude inactive return orders from search preview window'),
'validator': bool,
'default': True,
},
'SEARCH_PREVIEW_RESULTS': { 'SEARCH_PREVIEW_RESULTS': {
'name': _('Search Preview Results'), 'name': _('Search Preview Results'),
'description': _('Number of results to show in each section of the search preview window'), 'description': _('Number of results to show in each section of the search preview window'),

View File

@ -305,6 +305,13 @@ class InvenTreeNotificationBodies:
template='email/purchase_order_received.html', template='email/purchase_order_received.html',
) )
ReturnOrderItemsReceived = NotificationBody(
name=_('Items Received'),
slug='return_order.items_received',
message=_('Items have been received against a return order'),
template='email/return_order_received.html',
)
def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
"""Send out a notification.""" """Send out a notification."""

View File

@ -1,7 +1,7 @@
"""Provides a JSON API for the Company app.""" """Provides a JSON API for the Company app."""
from django.db.models import Q from django.db.models import Q
from django.urls import include, re_path from django.urls import include, path, re_path
from django_filters import rest_framework as rest_filters from django_filters import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
@ -15,10 +15,11 @@ from InvenTree.mixins import (ListCreateAPI, RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI) RetrieveUpdateDestroyAPI)
from plugin.serializers import MetadataSerializer from plugin.serializers import MetadataSerializer
from .models import (Company, CompanyAttachment, ManufacturerPart, from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
ManufacturerPartAttachment, ManufacturerPartParameter, ManufacturerPartAttachment, ManufacturerPartParameter,
SupplierPart, SupplierPriceBreak) SupplierPart, SupplierPriceBreak)
from .serializers import (CompanyAttachmentSerializer, CompanySerializer, from .serializers import (CompanyAttachmentSerializer, CompanySerializer,
ContactSerializer,
ManufacturerPartAttachmentSerializer, ManufacturerPartAttachmentSerializer,
ManufacturerPartParameterSerializer, ManufacturerPartParameterSerializer,
ManufacturerPartSerializer, SupplierPartSerializer, ManufacturerPartSerializer, SupplierPartSerializer,
@ -118,6 +119,41 @@ class CompanyAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
serializer_class = CompanyAttachmentSerializer serializer_class = CompanyAttachmentSerializer
class ContactList(ListCreateDestroyAPIView):
"""API endpoint for list view of Company model"""
queryset = Contact.objects.all()
serializer_class = ContactSerializer
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filterset_fields = [
'company',
]
search_fields = [
'company__name',
'name',
]
ordering_fields = [
'name',
]
ordering = 'name'
class ContactDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for Company model"""
queryset = Contact.objects.all()
serializer_class = ContactSerializer
class ManufacturerPartFilter(rest_filters.FilterSet): class ManufacturerPartFilter(rest_filters.FilterSet):
"""Custom API filters for the ManufacturerPart list endpoint.""" """Custom API filters for the ManufacturerPart list endpoint."""
@ -519,12 +555,12 @@ manufacturer_part_api_urls = [
# Base URL for ManufacturerPartAttachment API endpoints # Base URL for ManufacturerPartAttachment API endpoints
re_path(r'^attachment/', include([ re_path(r'^attachment/', include([
re_path(r'^(?P<pk>\d+)/', ManufacturerPartAttachmentDetail.as_view(), name='api-manufacturer-part-attachment-detail'), path(r'<int:pk>/', ManufacturerPartAttachmentDetail.as_view(), name='api-manufacturer-part-attachment-detail'),
re_path(r'^$', ManufacturerPartAttachmentList.as_view(), name='api-manufacturer-part-attachment-list'), re_path(r'^$', ManufacturerPartAttachmentList.as_view(), name='api-manufacturer-part-attachment-list'),
])), ])),
re_path(r'^parameter/', include([ re_path(r'^parameter/', include([
re_path(r'^(?P<pk>\d+)/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'), path(r'<int:pk>/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'),
# Catch anything else # Catch anything else
re_path(r'^.*$', ManufacturerPartParameterList.as_view(), name='api-manufacturer-part-parameter-list'), re_path(r'^.*$', ManufacturerPartParameterList.as_view(), name='api-manufacturer-part-parameter-list'),
@ -570,10 +606,15 @@ company_api_urls = [
])), ])),
re_path(r'^attachment/', include([ re_path(r'^attachment/', include([
re_path(r'^(?P<pk>\d+)/', CompanyAttachmentDetail.as_view(), name='api-company-attachment-detail'), path(r'<int:pk>/', CompanyAttachmentDetail.as_view(), name='api-company-attachment-detail'),
re_path(r'^$', CompanyAttachmentList.as_view(), name='api-company-attachment-list'), re_path(r'^$', CompanyAttachmentList.as_view(), name='api-company-attachment-list'),
])), ])),
re_path(r'^contact/', include([
path('<int:pk>/', ContactDetail.as_view(), name='api-contact-detail'),
re_path(r'^.*$', ContactList.as_view(), name='api-contact-list'),
])),
re_path(r'^.*$', CompanyList.as_view(), name='api-company-list'), re_path(r'^.*$', CompanyList.as_view(), name='api-company-list'),
] ]

View File

@ -235,6 +235,11 @@ class Contact(models.Model):
role: position in company role: position in company
""" """
@staticmethod
def get_api_url():
"""Return the API URL associated with the Contcat model"""
return reverse('api-contact-list')
company = models.ForeignKey(Company, related_name='contacts', company = models.ForeignKey(Company, related_name='contacts',
on_delete=models.CASCADE) on_delete=models.CASCADE)

View File

@ -17,7 +17,7 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializer,
InvenTreeMoneySerializer, RemoteImageMixin) InvenTreeMoneySerializer, RemoteImageMixin)
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from .models import (Company, CompanyAttachment, ManufacturerPart, from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
ManufacturerPartAttachment, ManufacturerPartParameter, ManufacturerPartAttachment, ManufacturerPartParameter,
SupplierPart, SupplierPriceBreak) SupplierPart, SupplierPriceBreak)
@ -132,6 +132,23 @@ class CompanyAttachmentSerializer(InvenTreeAttachmentSerializer):
]) ])
class ContactSerializer(InvenTreeModelSerializer):
"""Serializer class for the Contact model"""
class Meta:
"""Metaclass options"""
model = Contact
fields = [
'pk',
'company',
'name',
'phone',
'email',
'role',
]
class ManufacturerPartSerializer(InvenTreeModelSerializer): class ManufacturerPartSerializer(InvenTreeModelSerializer):
"""Serializer for ManufacturerPart object.""" """Serializer for ManufacturerPart object."""

View File

@ -1,6 +1,7 @@
{% extends "company/company_base.html" %} {% extends "company/company_base.html" %}
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load inventree_extras %}
{% block sidebar %} {% block sidebar %}
{% include 'company/sidebar.html' %} {% include 'company/sidebar.html' %}
@ -137,6 +138,8 @@
</div> </div>
</div> </div>
{% if company.is_customer %}
{% if roles.sales_order.view %}
<div class='panel panel-hidden' id='panel-sales-orders'> <div class='panel panel-hidden' id='panel-sales-orders'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-wrap'> <div class='d-flex flex-wrap'>
@ -162,7 +165,9 @@
</table> </table>
</div> </div>
</div> </div>
{% endif %}
{% if roles.stock.view %}
<div class='panel panel-hidden' id='panel-assigned-stock'> <div class='panel panel-hidden' id='panel-assigned-stock'>
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Assigned Stock" %}</h4> <h4>{% trans "Assigned Stock" %}</h4>
@ -175,9 +180,40 @@
</div> </div>
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table> <table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table>
</div> </div>
</div> </div>
{% endif %}
{% if roles.return_order.view and return_order_enabled %}
<div class='panel panel-hidden' id='panel-return-orders'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Return Orders" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.return_order.add %}
<button class='btn btn-success' type='button' id='new-return-order' title='{% trans "Create new return order" %}'>
<div class='fas fa-plus-circle'></div> {% trans "New Return Order" %}
</button>
{% endif %}
</div>
</div>
</div>
<div class='panel-content'>
<div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'>
{% include "filter_list.html" with id="returnorder" %}
</div>
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar='#table-buttons' id='return-order-table'>
</table>
</div>
</div>
{% endif %}
{% endif %}
<div class='panel panel-hidden' id='panel-company-notes'> <div class='panel panel-hidden' id='panel-company-notes'>
<div class='panel-heading'> <div class='panel-heading'>
@ -194,6 +230,31 @@
</div> </div>
</div> </div>
<div class='panel panel-hidden' id='panel-company-contacts'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Company Contacts" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.purchase_order.add or roles.sales_order.add %}
<button class='btn btn-success' type='button' id='new-contact' title='{% trans "Add Contact" %}'>
<div class='fas fa-plus-circle'></div> {% trans "Add Contact" %}
</button>
{% endif %}
</div>
</div>
</div>
<div class='panel-content'>
<div id='contacts-button-toolbar'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="contacts" %}
</div>
</div>
<table class='table table-striped table-condensed' id='contacts-table' data-toolbar='#contacts-button-toolbar'></table>
</div>
</div>
<div class='panel panel-hidden' id='panel-attachments'> <div class='panel panel-hidden' id='panel-attachments'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-wrap'> <div class='d-flex flex-wrap'>
@ -226,22 +287,29 @@
} }
} }
}); });
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-company-attachment-list" %}',
{
data: {
company: {{ company.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
reloadAttachmentTable();
}
}
);
}); });
// Callback function when the 'contacts' panel is loaded
onPanelLoad('company-contacts', function() {
loadContactTable('#contacts-table', {
params: {
company: {{ company.pk }},
},
allow_edit: {% js_bool roles.purchase_order.change %} || {% js_bool roles.sales_order.change %},
allow_delete: {% js_bool roles.purchase_order.delete %} || {% js_bool roles.sales_order.delete %},
});
$('#new-contact').click(function() {
createContact({
company: {{ company.pk }},
onSuccess: function() {
$('#contacts-table').bootstrapTable('refresh');
}
});
});
});
// Callback function when the 'notes' panel is loaded
onPanelLoad('company-notes', function() { onPanelLoad('company-notes', function() {
setupNotesField( setupNotesField(
@ -250,18 +318,7 @@
{ {
editable: true, editable: true,
} }
) );
});
loadStockTable($("#assigned-stock-table"), {
params: {
customer: {{ company.id }},
part_detail: true,
location_detail: true,
},
url: "{% url 'api-stock-list' %}",
filterKey: "customerstock",
filterTarget: '#filter-list-customerstock',
}); });
onPanelLoad('company-stock', function() { onPanelLoad('company-stock', function() {
@ -282,20 +339,65 @@
}); });
{% if company.is_customer %} {% if company.is_customer %}
{% if return_order_enabled %}
// Callback function when the 'return orders' panel is loaded
onPanelLoad('return-orders', function() {
{% if roles.return_order.view %}
loadReturnOrderTable('#return-order-table', {
params: {
customer: {{ company.pk }},
}
});
{% endif %}
{% if roles.return_order.add %}
$('#new-return-order').click(function() {
createReturnOrder({
customer: {{ company.pk }},
});
});
{% endif %}
});
{% endif %}
// Callback function when the 'assigned stock' panel is loaded
onPanelLoad('assigned-stock', function() {
{% if roles.stock.view %}
loadStockTable($("#assigned-stock-table"), {
params: {
customer: {{ company.id }},
part_detail: true,
location_detail: true,
},
url: "{% url 'api-stock-list' %}",
filterKey: "customerstock",
filterTarget: '#filter-list-customerstock',
});
{% endif %}
});
// Callback function when the 'sales orders' panel is loaded
onPanelLoad('sales-orders', function() { onPanelLoad('sales-orders', function() {
{% if roles.sales_order.view %}
loadSalesOrderTable("#sales-order-table", { loadSalesOrderTable("#sales-order-table", {
url: "{% url 'api-so-list' %}", url: "{% url 'api-so-list' %}",
params: { params: {
customer: {{ company.id }}, customer: {{ company.id }},
} }
}); });
{% endif %}
{% if roles.salse_order.add %}
$("#new-sales-order").click(function() { $("#new-sales-order").click(function() {
createSalesOrder({ createSalesOrder({
customer: {{ company.pk }}, customer: {{ company.pk }},
}); });
}); });
{% endif %}
}); });
{% endif %} {% endif %}
@ -334,7 +436,7 @@
createManufacturerPart({ createManufacturerPart({
manufacturer: {{ company.pk }}, manufacturer: {{ company.pk }},
onSuccess: function() { onSuccess: function() {
$("#part-table").bootstrapTable("refresh"); $("#part-table").bootstrapTable('refresh');
} }
}); });
}); });
@ -356,7 +458,7 @@
deleteManufacturerParts(selections, { deleteManufacturerParts(selections, {
success: function() { success: function() {
$("#manufacturer-part-table").bootstrapTable("refresh"); $("#manufacturer-part-table").bootstrapTable('refresh');
} }
}); });
}); });

View File

@ -209,26 +209,8 @@ onPanelLoad("attachments", function() {
} }
} }
}); });
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-manufacturer-part-attachment-list" %}',
{
data: {
manufacturer_part: {{ part.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
reloadAttachmentTable();
}
}
);
}); });
function reloadParameters() {
$("#parameter-table").bootstrapTable("refresh");
}
$('#parameter-create').click(function() { $('#parameter-create').click(function() {
constructForm('{% url "api-manufacturer-part-parameter-list" %}', { constructForm('{% url "api-manufacturer-part-parameter-list" %}', {
@ -243,7 +225,7 @@ $('#parameter-create').click(function() {
} }
}, },
title: '{% trans "Add Parameter" %}', title: '{% trans "Add Parameter" %}',
onSuccess: reloadParameters refreshTable: '#parameter-table',
}); });
}); });

View File

@ -17,11 +17,21 @@
{% include "sidebar_item.html" with label='company-stock' text=text icon="fa-boxes" %} {% include "sidebar_item.html" with label='company-stock' text=text icon="fa-boxes" %}
{% endif %} {% endif %}
{% if company.is_customer %} {% if company.is_customer %}
{% if roles.sales_order.view %}
{% trans "Sales Orders" as text %} {% trans "Sales Orders" as text %}
{% include "sidebar_item.html" with label='sales-orders' text=text icon="fa-truck" %} {% include "sidebar_item.html" with label='sales-orders' text=text icon="fa-truck" %}
{% endif %}
{% if roles.stock.view %}
{% trans "Assigned Stock Items" as text %} {% trans "Assigned Stock Items" as text %}
{% include "sidebar_item.html" with label='assigned-stock' text=text icon="fa-sign-out-alt" %} {% include "sidebar_item.html" with label='assigned-stock' text=text icon="fa-sign-out-alt" %}
{% endif %} {% endif %}
{% if roles.return_order.view and return_order_enabled %}
{% trans "Return Orders" as text %}
{% include "sidebar_item.html" with label='return-orders' text=text icon="fa-undo" %}
{% endif %}
{% endif %}
{% trans "Contacts" as text %}
{% include "sidebar_item.html" with label='company-contacts' text=text icon="fa-users" %}
{% trans "Notes" as text %} {% trans "Notes" as text %}
{% include "sidebar_item.html" with label='company-notes' text=text icon="fa-clipboard" %} {% include "sidebar_item.html" with label='company-notes' text=text icon="fa-clipboard" %}
{% trans "Attachments" as text %} {% trans "Attachments" as text %}

View File

@ -301,7 +301,7 @@ loadSupplierPriceBreakTable({
$('#new-price-break').click(function() { $('#new-price-break').click(function() {
createSupplierPartPriceBreak({{ part.pk }}, { createSupplierPartPriceBreak({{ part.pk }}, {
onSuccess: function() { onSuccess: function() {
$("#price-break-table").bootstrapTable("refresh"); $("#price-break-table").bootstrapTable('refresh');
} }
}); });

View File

@ -6,7 +6,7 @@ from rest_framework import status
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
from .models import Company, SupplierPart from .models import Company, Contact, SupplierPart
class CompanyTest(InvenTreeAPITestCase): class CompanyTest(InvenTreeAPITestCase):
@ -140,6 +140,144 @@ class CompanyTest(InvenTreeAPITestCase):
self.assertTrue('currency' in response.data) self.assertTrue('currency' in response.data)
class ContactTest(InvenTreeAPITestCase):
"""Tests for the Contact models"""
roles = []
@classmethod
def setUpTestData(cls):
"""Perform init for this test class"""
super().setUpTestData()
# Create some companies
companies = [
Company(
name=f"Company {idx}",
description="Some company"
) for idx in range(3)
]
Company.objects.bulk_create(companies)
contacts = []
# Create some contacts
for cmp in Company.objects.all():
contacts += [
Contact(
company=cmp,
name=f"My name {idx}",
) for idx in range(3)
]
Contact.objects.bulk_create(contacts)
cls.url = reverse('api-contact-list')
def test_list(self):
"""Test company list API endpoint"""
# List all results
response = self.get(self.url, {}, expected_code=200)
self.assertEqual(len(response.data), 9)
for result in response.data:
for key in ['name', 'email', 'pk', 'company']:
self.assertIn(key, result)
# Filter by particular company
for cmp in Company.objects.all():
response = self.get(
self.url,
{
'company': cmp.pk,
},
expected_code=200
)
self.assertEqual(len(response.data), 3)
def test_create(self):
"""Test that we can create a new Contact object via the API"""
n = Contact.objects.count()
company = Company.objects.first()
# Without required permissions, creation should fail
self.post(
self.url,
{
'company': company.pk,
'name': 'Joe Bloggs',
},
expected_code=403
)
self.assignRole('return_order.add')
self.post(
self.url,
{
'company': company.pk,
'name': 'Joe Bloggs',
},
expected_code=201
)
self.assertEqual(Contact.objects.count(), n + 1)
def test_edit(self):
"""Test that we can edit a Contact via the API"""
url = reverse('api-contact-detail', kwargs={'pk': 1})
# Retrieve detail view
data = self.get(url, expected_code=200).data
for key in ['pk', 'name', 'role']:
self.assertIn(key, data)
self.patch(
url,
{
'role': 'model',
},
expected_code=403
)
self.assignRole('purchase_order.change')
self.patch(
url,
{
'role': 'x',
},
expected_code=200
)
contact = Contact.objects.get(pk=1)
self.assertEqual(contact.role, 'x')
def test_delete(self):
"""Tests that we can delete a Contact via the API"""
url = reverse('api-contact-detail', kwargs={'pk': 6})
# Delete (without required permissions)
self.delete(url, expected_code=403)
self.assignRole('sales_order.delete')
self.delete(url, expected_code=204)
# Try to access again (gone!)
self.get(url, expected_code=404)
class ManufacturerTest(InvenTreeAPITestCase): class ManufacturerTest(InvenTreeAPITestCase):
"""Series of tests for the Manufacturer DRF API.""" """Series of tests for the Manufacturer DRF API."""

View File

@ -1,13 +1,13 @@
"""URL lookup for Company app.""" """URL lookup for Company app."""
from django.urls import include, re_path from django.urls import include, path, re_path
from . import views from . import views
company_urls = [ company_urls = [
# Detail URLs for a specific Company instance # Detail URLs for a specific Company instance
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'^.*$', views.CompanyDetail.as_view(), name='company-detail'), re_path(r'^.*$', views.CompanyDetail.as_view(), name='company-detail'),
])), ])),
@ -21,11 +21,11 @@ company_urls = [
manufacturer_part_urls = [ manufacturer_part_urls = [
re_path(r'^(?P<pk>\d+)/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part.html'), name='manufacturer-part-detail'), path(r'<int:pk>/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part.html'), name='manufacturer-part-detail'),
] ]
supplier_part_urls = [ supplier_part_urls = [
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path('^.*$', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'), re_path('^.*$', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'),
])) ]))

View File

@ -3,7 +3,7 @@
from django.conf import settings from django.conf import settings
from django.core.exceptions import FieldError, ValidationError from django.core.exceptions import FieldError, ValidationError
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.urls import include, re_path from django.urls import include, path, re_path
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page, never_cache from django.views.decorators.cache import cache_page, never_cache
@ -403,7 +403,7 @@ label_api_urls = [
# Stock item labels # Stock item labels
re_path(r'stock/', include([ re_path(r'stock/', include([
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'print/?', StockItemLabelPrint.as_view(), name='api-stockitem-label-print'), re_path(r'print/?', StockItemLabelPrint.as_view(), name='api-stockitem-label-print'),
re_path(r'metadata/', StockItemLabelMetadata.as_view(), name='api-stockitem-label-metadata'), re_path(r'metadata/', StockItemLabelMetadata.as_view(), name='api-stockitem-label-metadata'),
re_path(r'^.*$', StockItemLabelDetail.as_view(), name='api-stockitem-label-detail'), re_path(r'^.*$', StockItemLabelDetail.as_view(), name='api-stockitem-label-detail'),
@ -416,7 +416,7 @@ label_api_urls = [
# Stock location labels # Stock location labels
re_path(r'location/', include([ re_path(r'location/', include([
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'print/?', StockLocationLabelPrint.as_view(), name='api-stocklocation-label-print'), re_path(r'print/?', StockLocationLabelPrint.as_view(), name='api-stocklocation-label-print'),
re_path(r'metadata/', StockLocationLabelMetadata.as_view(), name='api-stocklocation-label-metadata'), re_path(r'metadata/', StockLocationLabelMetadata.as_view(), name='api-stocklocation-label-metadata'),
re_path(r'^.*$', StockLocationLabelDetail.as_view(), name='api-stocklocation-label-detail'), re_path(r'^.*$', StockLocationLabelDetail.as_view(), name='api-stocklocation-label-detail'),
@ -429,7 +429,7 @@ label_api_urls = [
# Part labels # Part labels
re_path(r'^part/', include([ re_path(r'^part/', include([
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'^print/', PartLabelPrint.as_view(), name='api-part-label-print'), re_path(r'^print/', PartLabelPrint.as_view(), name='api-part-label-print'),
re_path(r'^metadata/', PartLabelMetadata.as_view(), name='api-part-label-metadata'), re_path(r'^metadata/', PartLabelMetadata.as_view(), name='api-part-label-metadata'),
re_path(r'^.*$', PartLabelDetail.as_view(), name='api-part-label-detail'), re_path(r'^.*$', PartLabelDetail.as_view(), name='api-part-label-detail'),

View File

@ -6,13 +6,9 @@ import import_export.widgets as widgets
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field from import_export.fields import Field
import order.models as models
from InvenTree.admin import InvenTreeResource from InvenTree.admin import InvenTreeResource
from .models import (PurchaseOrder, PurchaseOrderExtraLine,
PurchaseOrderLineItem, SalesOrder, SalesOrderAllocation,
SalesOrderExtraLine, SalesOrderLineItem,
SalesOrderShipment)
# region general classes # region general classes
class GeneralExtraLineAdmin: class GeneralExtraLineAdmin:
@ -42,7 +38,7 @@ class GeneralExtraLineMeta:
class PurchaseOrderLineItemInlineAdmin(admin.StackedInline): class PurchaseOrderLineItemInlineAdmin(admin.StackedInline):
"""Inline admin class for the PurchaseOrderLineItem model""" """Inline admin class for the PurchaseOrderLineItem model"""
model = PurchaseOrderLineItem model = models.PurchaseOrderLineItem
extra = 0 extra = 0
@ -103,7 +99,7 @@ class PurchaseOrderResource(InvenTreeResource):
class Meta: class Meta:
"""Metaclass""" """Metaclass"""
model = PurchaseOrder model = models.PurchaseOrder
skip_unchanged = True skip_unchanged = True
clean_model_instances = True clean_model_instances = True
exclude = [ exclude = [
@ -122,7 +118,7 @@ class PurchaseOrderLineItemResource(InvenTreeResource):
class Meta: class Meta:
"""Metaclass""" """Metaclass"""
model = PurchaseOrderLineItem model = models.PurchaseOrderLineItem
skip_unchanged = True skip_unchanged = True
report_skipped = False report_skipped = False
clean_model_instances = True clean_model_instances = True
@ -142,7 +138,7 @@ class PurchaseOrderExtraLineResource(InvenTreeResource):
class Meta(GeneralExtraLineMeta): class Meta(GeneralExtraLineMeta):
"""Metaclass options.""" """Metaclass options."""
model = PurchaseOrderExtraLine model = models.PurchaseOrderExtraLine
class SalesOrderResource(InvenTreeResource): class SalesOrderResource(InvenTreeResource):
@ -150,7 +146,7 @@ class SalesOrderResource(InvenTreeResource):
class Meta: class Meta:
"""Metaclass options""" """Metaclass options"""
model = SalesOrder model = models.SalesOrder
skip_unchanged = True skip_unchanged = True
clean_model_instances = True clean_model_instances = True
exclude = [ exclude = [
@ -169,7 +165,7 @@ class SalesOrderLineItemResource(InvenTreeResource):
class Meta: class Meta:
"""Metaclass options""" """Metaclass options"""
model = SalesOrderLineItem model = models.SalesOrderLineItem
skip_unchanged = True skip_unchanged = True
report_skipped = False report_skipped = False
clean_model_instances = True clean_model_instances = True
@ -198,8 +194,7 @@ class SalesOrderExtraLineResource(InvenTreeResource):
class Meta(GeneralExtraLineMeta): class Meta(GeneralExtraLineMeta):
"""Metaclass options.""" """Metaclass options."""
model = models.SalesOrderExtraLine
model = SalesOrderExtraLine
class PurchaseOrderLineItemAdmin(ImportExportModelAdmin): class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
@ -281,13 +276,92 @@ class SalesOrderAllocationAdmin(ImportExportModelAdmin):
autocomplete_fields = ('line', 'shipment', 'item',) autocomplete_fields = ('line', 'shipment', 'item',)
admin.site.register(PurchaseOrder, PurchaseOrderAdmin) class ReturnOrderResource(InvenTreeResource):
admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin) """Class for managing import / export of ReturnOrder data"""
admin.site.register(PurchaseOrderExtraLine, PurchaseOrderExtraLineAdmin)
admin.site.register(SalesOrder, SalesOrderAdmin) class Meta:
admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin) """Metaclass options"""
admin.site.register(SalesOrderExtraLine, SalesOrderExtraLineAdmin) model = models.ReturnOrder
skip_unchanged = True
clean_model_instances = True
exclude = [
'metadata',
]
admin.site.register(SalesOrderShipment, SalesOrderShipmentAdmin)
admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin) class ReturnOrderAdmin(ImportExportModelAdmin):
"""Admin class for the ReturnOrder model"""
exclude = [
'reference_int',
]
list_display = [
'reference',
'customer',
'status',
]
search_fields = [
'reference',
'customer__name',
'description',
]
autocomplete_fields = [
'customer',
]
class ReturnOrderLineItemResource(InvenTreeResource):
"""Class for managing import / export of ReturnOrderLineItem data"""
class Meta:
"""Metaclass options"""
model = models.ReturnOrderLineItem
skip_unchanged = True
report_skipped = False
clean_model_instances = True
class ReturnOrderLineItemAdmin(ImportExportModelAdmin):
"""Admin class for ReturnOrderLine model"""
resource_class = ReturnOrderLineItemResource
list_display = [
'order',
'item',
'reference',
]
class ReturnOrderExtraLineClass(InvenTreeResource):
"""Class for managing import/export of ReturnOrderExtraLine data"""
class Meta(GeneralExtraLineMeta):
"""Metaclass options"""
model = models.ReturnOrderExtraLine
class ReturnOrdeerExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin):
"""Admin class for the ReturnOrderExtraLine model"""
resource_class = ReturnOrderExtraLineClass
# Purchase Order models
admin.site.register(models.PurchaseOrder, PurchaseOrderAdmin)
admin.site.register(models.PurchaseOrderLineItem, PurchaseOrderLineItemAdmin)
admin.site.register(models.PurchaseOrderExtraLine, PurchaseOrderExtraLineAdmin)
# Sales Order models
admin.site.register(models.SalesOrder, SalesOrderAdmin)
admin.site.register(models.SalesOrderLineItem, SalesOrderLineItemAdmin)
admin.site.register(models.SalesOrderExtraLine, SalesOrderExtraLineAdmin)
admin.site.register(models.SalesOrderShipment, SalesOrderShipmentAdmin)
admin.site.register(models.SalesOrderAllocation, SalesOrderAllocationAdmin)
# Return Order models
admin.site.register(models.ReturnOrder, ReturnOrderAdmin)
admin.site.register(models.ReturnOrderLineItem, ReturnOrderLineItemAdmin)
admin.site.register(models.ReturnOrderExtraLine, ReturnOrdeerExtraLineAdmin)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,53 @@
- model: order.returnorder
pk: 1
fields:
reference: 'RMA-001'
reference_int: 1
description: 'RMA from a customer'
customer: 4
status: 10 # Pending
- model: order.returnorder
pk: 2
fields:
reference: 'RMA-002'
reference_int: 2
description: 'RMA from a customer'
customer: 4
status: 20 # In Progress
- model: order.returnorder
pk: 3
fields:
reference: 'RMA-003'
reference_int: 3
description: 'RMA from a customer'
customer: 4
status: 30 # Complete
- model: order.returnorder
pk: 4
fields:
reference: 'RMA-004'
reference_int: 4
description: 'RMA from a customer'
customer: 5
status: 40 # Cancelled
- model: order.returnorder
pk: 5
fields:
reference: 'RMA-005'
reference_int: 5
description: 'RMA from a customer'
customer: 5
status: 20 # In progress
- model: order.returnorder
pk: 6
fields:
reference: 'RMA-006'
reference_int: 6
description: 'RMA from a customer'
customer: 5
status: 10 # Pending

View File

@ -0,0 +1,64 @@
# Generated by Django 3.2.18 on 2023-03-14 07:25
import InvenTree.fields
import InvenTree.models
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import order.validators
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('company', '0054_companyattachment'),
('users', '0006_alter_ruleset_name'),
('order', '0080_auto_20230317_0816'),
]
operations = [
migrations.CreateModel(
name='ReturnOrder',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('metadata', models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata')),
('reference_int', models.BigIntegerField(default=0)),
('description', models.CharField(help_text='Order description', max_length=250, verbose_name='Description')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external page', verbose_name='Link')),
('creation_date', models.DateField(blank=True, null=True, verbose_name='Creation Date')),
('notes', InvenTree.fields.InvenTreeNotesField(blank=True, help_text='Order notes', max_length=50000, null=True, verbose_name='Notes')),
('reference', models.CharField(default=order.validators.generate_next_return_order_reference, help_text='Return Order reference', max_length=64, unique=True, validators=[order.validators.validate_return_order_reference], verbose_name='Reference')),
('status', models.PositiveIntegerField(choices=[(10, 'Pending'), (30, 'Complete'), (40, 'Cancelled')], default=10, help_text='Return order status', verbose_name='Status')),
('customer_reference', models.CharField(blank=True, help_text='Customer order reference code', max_length=64, verbose_name='Customer Reference ')),
('issue_date', models.DateField(blank=True, help_text='Date order was issued', null=True, verbose_name='Issue Date')),
('complete_date', models.DateField(blank=True, help_text='Date order was completed', null=True, verbose_name='Completion Date')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('customer', models.ForeignKey(help_text='Company from which items are being returned', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_orders', to='company.company', verbose_name='Customer')),
('responsible', models.ForeignKey(blank=True, help_text='User or group responsible for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='users.owner', verbose_name='Responsible')),
],
options={
'abstract': False,
},
),
migrations.AlterField(
model_name='salesorder',
name='customer',
field=models.ForeignKey(help_text='Company to which the items are being sold', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='return_orders', to='company.company', verbose_name='Customer'),
),
migrations.CreateModel(
name='ReturnOrderAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='order.returnorder')),
('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 3.2.18 on 2023-03-14 12:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0054_companyattachment'),
('order', '0081_auto_20230314_0725'),
]
operations = [
migrations.AddField(
model_name='purchaseorder',
name='contact',
field=models.ForeignKey(blank=True, help_text='Point of contact for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='company.contact', verbose_name='Contact'),
),
migrations.AddField(
model_name='returnorder',
name='contact',
field=models.ForeignKey(blank=True, help_text='Point of contact for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='company.contact', verbose_name='Contact'),
),
migrations.AddField(
model_name='salesorder',
name='contact',
field=models.ForeignKey(blank=True, help_text='Point of contact for this order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='company.contact', verbose_name='Contact'),
),
]

View File

@ -0,0 +1,35 @@
# Generated by Django 3.2.18 on 2023-03-16 02:52
import InvenTree.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import djmoney.models.fields
import djmoney.models.validators
class Migration(migrations.Migration):
dependencies = [
('order', '0082_auto_20230314_1259'),
]
operations = [
migrations.CreateModel(
name='ReturnOrderExtraLine',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Item quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')),
('reference', models.CharField(blank=True, help_text='Line item reference', max_length=100, verbose_name='Reference')),
('notes', models.CharField(blank=True, help_text='Line item notes', max_length=500, verbose_name='Notes')),
('target_date', models.DateField(blank=True, help_text='Target date for this line item (leave blank to use the target date from the order)', null=True, verbose_name='Target Date')),
('context', models.JSONField(blank=True, help_text='Additional context for this line', null=True, verbose_name='Context')),
('price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Unit price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price')),
('order', models.ForeignKey(help_text='Return Order', on_delete=django.db.models.deletion.CASCADE, related_name='extra_lines', to='order.returnorder', verbose_name='Order')),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 3.2.18 on 2023-03-21 11:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0083_returnorderextraline'),
]
operations = [
migrations.AddField(
model_name='returnorder',
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 Date'),
),
migrations.AlterField(
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 Date'),
),
migrations.AlterField(
model_name='returnorder',
name='status',
field=models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'In Progress'), (30, 'Complete'), (40, 'Cancelled')], default=10, help_text='Return order status', verbose_name='Status'),
),
migrations.AlterField(
model_name='salesorder',
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 Date'),
),
]

View File

@ -0,0 +1,49 @@
# Generated by Django 3.2.18 on 2023-03-22 10:56
import InvenTree.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import djmoney.models.fields
import djmoney.models.validators
class Migration(migrations.Migration):
dependencies = [
('stock', '0095_stocklocation_external'),
('order', '0084_auto_20230321_1111'),
]
operations = [
migrations.AddField(
model_name='returnorder',
name='total_price',
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Total price for this order', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Total Price'),
),
migrations.AddField(
model_name='returnorder',
name='total_price_currency',
field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3),
),
migrations.CreateModel(
name='ReturnOrderLineItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Item quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')),
('reference', models.CharField(blank=True, help_text='Line item reference', max_length=100, verbose_name='Reference')),
('notes', models.CharField(blank=True, help_text='Line item notes', max_length=500, verbose_name='Notes')),
('target_date', models.DateField(blank=True, help_text='Target date for this line item (leave blank to use the target date from the order)', null=True, verbose_name='Target Date')),
('received_date', models.DateField(blank=True, help_text='The date this this return item was received', null=True, verbose_name='Received Date')),
('outcome', models.PositiveIntegerField(choices=[(10, 'Pending'), (20, 'Return'), (30, 'Repair'), (50, 'Refund'), (40, 'Replace'), (60, 'Reject')], default=10, help_text='Outcome for this line item', verbose_name='Outcome')),
('price_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('price', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Cost associated with return or repair for this line item', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external page', verbose_name='Link')),
('item', models.ForeignKey(help_text='Select item to return from customer', on_delete=django.db.models.deletion.CASCADE, related_name='return_order_lines', to='stock.stockitem', verbose_name='Item')),
('order', models.ForeignKey(help_text='Return Order', on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='order.returnorder', verbose_name='Order')),
],
options={
'unique_together': {('order', 'item')},
},
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.18 on 2023-03-23 11:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0085_auto_20230322_1056'),
]
operations = [
migrations.AddField(
model_name='returnorderextraline',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
migrations.AddField(
model_name='returnorderlineitem',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
]

View File

@ -26,25 +26,110 @@ import InvenTree.helpers
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
import order.validators import order.validators
import stock.models
import users.models as UserModels
from common.notifications import InvenTreeNotificationBodies from common.notifications import InvenTreeNotificationBodies
from common.settings import currency_code_default from common.settings import currency_code_default
from company.models import Company, SupplierPart from company.models import Company, Contact, SupplierPart
from InvenTree.exceptions import log_error from InvenTree.exceptions import log_error
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField, from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
InvenTreeURLField, RoundingDecimalField) InvenTreeURLField, RoundingDecimalField)
from InvenTree.helpers import decimal2string, getSetting, notify_responsible from InvenTree.helpers import decimal2string, getSetting, notify_responsible
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus, from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderLineStatus,
ReturnOrderStatus, SalesOrderStatus,
StockHistoryCode, StockStatus) StockHistoryCode, StockStatus)
from part import models as PartModels from part import models as PartModels
from plugin.events import trigger_event from plugin.events import trigger_event
from plugin.models import MetadataMixin from plugin.models import MetadataMixin
from stock import models as stock_models
from users import models as UserModels
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
class TotalPriceMixin(models.Model):
"""Mixin which provides 'total_price' field for an order"""
class Meta:
"""Meta for MetadataMixin."""
abstract = True
def save(self, *args, **kwargs):
"""Update the total_price field when saved"""
# Recalculate total_price for this order
self.update_total_price(commit=False)
super().save(*args, **kwargs)
total_price = InvenTreeModelMoneyField(
null=True, blank=True,
allow_negative=False,
verbose_name=_('Total Price'),
help_text=_('Total price for this order')
)
def update_total_price(self, commit=True):
"""Recalculate and save the total_price for this order"""
self.total_price = self.calculate_total_price()
if commit:
self.save()
def calculate_total_price(self, target_currency=None):
"""Calculates the total price of all order lines, and converts to the specified target currency.
If not specified, the default system currency is used.
If currency conversion fails (e.g. there are no valid conversion rates),
then we simply return zero, rather than attempting some other calculation.
"""
# Set default - see B008
if target_currency is None:
target_currency = currency_code_default()
total = Money(0, target_currency)
# order items
for line in self.lines.all():
if not line.price:
continue
try:
total += line.quantity * convert_money(line.price, target_currency)
except MissingRate:
# Record the error, try to press on
kind, info, data = sys.exc_info()
log_error('order.calculate_total_price')
logger.error(f"Missing exchange rate for '{target_currency}'")
# Return None to indicate the calculated price is invalid
return None
# extra items
for line in self.extra_lines.all():
if not line.price:
continue
try:
total += line.quantity * convert_money(line.price, target_currency)
except MissingRate:
# Record the error, try to press on
log_error('order.calculate_total_price')
logger.error(f"Missing exchange rate for '{target_currency}'")
# Return None to indicate the calculated price is invalid
return None
# set decimal-places
total.decimal_places = 4
return total
class Order(MetadataMixin, ReferenceIndexingMixin): class Order(MetadataMixin, ReferenceIndexingMixin):
"""Abstract model for an order. """Abstract model for an order.
@ -78,15 +163,49 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
if not self.creation_date: if not self.creation_date:
self.creation_date = datetime.now().date() self.creation_date = datetime.now().date()
# Recalculate total_price for this order
self.update_total_price(commit=False)
super().save(*args, **kwargs) super().save(*args, **kwargs)
def clean(self):
"""Custom clean method for the generic order class"""
super().clean()
# Check that the referenced 'contact' matches the correct 'company'
if self.company and self.contact:
if self.contact.company != self.company:
raise ValidationError({
"contact": _("Contact does not match selected company")
})
@classmethod
def overdue_filter(cls):
"""A generic implementation of an 'overdue' filter for the Model class
It requires any subclasses to implement the get_status_class() class method
"""
today = datetime.now().date()
return Q(status__in=cls.get_status_class().OPEN) & ~Q(target_date=None) & Q(target_date__lt=today)
@property
def is_overdue(self):
"""Method to determine if this order is overdue.
Makes use of the overdue_filter() method to avoid code duplication
"""
return self.__class__.objects.filter(pk=self.pk).filter(self.__class__.overdue_filter()).exists()
description = models.CharField(max_length=250, verbose_name=_('Description'), help_text=_('Order description')) description = models.CharField(max_length=250, verbose_name=_('Description'), help_text=_('Order description'))
link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page')) link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page'))
target_date = models.DateField(
blank=True, null=True,
verbose_name=_('Target Date'),
help_text=_('Expected date for order delivery. Order will be overdue after this date.'),
)
creation_date = models.DateField(blank=True, null=True, verbose_name=_('Creation Date')) creation_date = models.DateField(blank=True, null=True, verbose_name=_('Creation Date'))
created_by = models.ForeignKey(User, created_by = models.ForeignKey(User,
@ -105,84 +224,25 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
related_name='+', related_name='+',
) )
notes = InvenTreeNotesField(help_text=_('Order notes')) contact = models.ForeignKey(
Contact,
total_price = InvenTreeModelMoneyField( on_delete=models.SET_NULL,
null=True, blank=True, blank=True, null=True,
allow_negative=False, verbose_name=_('Contact'),
verbose_name=_('Total Price'), help_text=_('Point of contact for this order'),
help_text=_('Total price for this order') related_name='+',
) )
def update_total_price(self, commit=True): notes = InvenTreeNotesField(help_text=_('Order notes'))
"""Recalculate and save the total_price for this order"""
self.total_price = self.calculate_total_price() @classmethod
def get_status_class(cls):
"""Return the enumeration class which represents the 'status' field for this model"""
if commit: raise NotImplementedError(f"get_status_class() not implemented for {__class__}")
self.save()
def calculate_total_price(self, target_currency=None):
"""Calculates the total price of all order lines, and converts to the specified target currency.
If not specified, the default system currency is used.
If currency conversion fails (e.g. there are no valid conversion rates),
then we simply return zero, rather than attempting some other calculation.
"""
# Set default - see B008
if target_currency is None:
target_currency = currency_code_default()
total = Money(0, target_currency)
# gather name reference
price_ref_tag = 'sale_price' if isinstance(self, SalesOrder) else 'purchase_price'
# order items
for line in self.lines.all():
price_ref = getattr(line, price_ref_tag)
if not price_ref:
continue
try:
total += line.quantity * convert_money(price_ref, target_currency)
except MissingRate:
# Record the error, try to press on
kind, info, data = sys.exc_info()
log_error('order.calculate_total_price')
logger.error(f"Missing exchange rate for '{target_currency}'")
# Return None to indicate the calculated price is invalid
return None
# extra items
for line in self.extra_lines.all():
if not line.price:
continue
try:
total += line.quantity * convert_money(line.price, target_currency)
except MissingRate:
# Record the error, try to press on
log_error('order.calculate_total_price')
logger.error(f"Missing exchange rate for '{target_currency}'")
# Return None to indicate the calculated price is invalid
return None
# set decimal-places
total.decimal_places = 4
return total
class PurchaseOrder(Order): class PurchaseOrder(TotalPriceMixin, Order):
"""A PurchaseOrder represents goods shipped inwards from an external supplier. """A PurchaseOrder represents goods shipped inwards from an external supplier.
Attributes: Attributes:
@ -192,14 +252,23 @@ class PurchaseOrder(Order):
target_date: Expected delivery target date for PurchaseOrder completion (optional) target_date: Expected delivery target date for PurchaseOrder completion (optional)
""" """
def get_absolute_url(self):
"""Get the 'web' URL for this order"""
return reverse('po-detail', kwargs={'pk': self.pk})
@staticmethod @staticmethod
def get_api_url(): def get_api_url():
"""Return the API URL associated with the PurchaseOrder model""" """Return the API URL associated with the PurchaseOrder model"""
return reverse('api-po-list') return reverse('api-po-list')
@classmethod
def get_status_class(cls):
"""Return the PurchasOrderStatus class"""
return PurchaseOrderStatus
@classmethod @classmethod
def api_defaults(cls, request): def api_defaults(cls, request):
"""Return default values for thsi model when issuing an API OPTIONS request""" """Return default values for this model when issuing an API OPTIONS request"""
defaults = { defaults = {
'reference': order.validators.generate_next_purchase_order_reference(), 'reference': order.validators.generate_next_purchase_order_reference(),
@ -207,8 +276,6 @@ class PurchaseOrder(Order):
return defaults return defaults
OVERDUE_FILTER = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
# Global setting for specifying reference pattern # Global setting for specifying reference pattern
REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN' REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN'
@ -283,6 +350,11 @@ class PurchaseOrder(Order):
help_text=_('Company from which the items are being ordered') help_text=_('Company from which the items are being ordered')
) )
@property
def company(self):
"""Accessor helper for Order base class"""
return self.supplier
supplier_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Supplier Reference'), help_text=_("Supplier order reference code")) supplier_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Supplier Reference'), help_text=_("Supplier order reference code"))
received_by = models.ForeignKey( received_by = models.ForeignKey(
@ -299,22 +371,12 @@ class PurchaseOrder(Order):
help_text=_('Date order was issued') help_text=_('Date order was issued')
) )
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( complete_date = models.DateField(
blank=True, null=True, blank=True, null=True,
verbose_name=_('Completion Date'), verbose_name=_('Completion Date'),
help_text=_('Date order was completed') help_text=_('Date order was completed')
) )
def get_absolute_url(self):
"""Return the web URL of the detail view for this order"""
return reverse('po-detail', kwargs={'pk': self.id})
@transaction.atomic @transaction.atomic
def add_line_item(self, supplier_part, quantity, group: bool = True, reference: str = '', purchase_price=None): def add_line_item(self, supplier_part, quantity, group: bool = True, reference: str = '', purchase_price=None):
"""Add a new line item to this purchase order. """Add a new line item to this purchase order.
@ -417,17 +479,6 @@ class PurchaseOrder(Order):
"""Return True if the PurchaseOrder is 'pending'""" """Return True if the PurchaseOrder is 'pending'"""
return self.status == PurchaseOrderStatus.PENDING return self.status == PurchaseOrderStatus.PENDING
@property
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): def can_cancel(self):
"""A PurchaseOrder can only be cancelled under the following circumstances. """A PurchaseOrder can only be cancelled under the following circumstances.
@ -534,7 +585,7 @@ class PurchaseOrder(Order):
for sn in serials: for sn in serials:
stock = stock_models.StockItem( item = stock.models.StockItem(
part=line.part.part, part=line.part.part,
supplier_part=line.part, supplier_part=line.part,
location=location, location=location,
@ -547,14 +598,14 @@ class PurchaseOrder(Order):
barcode_hash=barcode_hash barcode_hash=barcode_hash
) )
stock.save(add_note=False) item.save(add_note=False)
tracking_info = { tracking_info = {
'status': status, 'status': status,
'purchaseorder': self.pk, 'purchaseorder': self.pk,
} }
stock.add_tracking_entry( item.add_tracking_entry(
StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER, StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
user, user,
notes=notes, notes=notes,
@ -595,20 +646,23 @@ def after_save_purchase_order(sender, instance: PurchaseOrder, created: bool, **
notify_responsible(instance, sender, exclude=instance.created_by) notify_responsible(instance, sender, exclude=instance.created_by)
class SalesOrder(Order): class SalesOrder(TotalPriceMixin, Order):
"""A SalesOrder represents a list of goods shipped outwards to a customer. """A SalesOrder represents a list of goods shipped outwards to a customer."""
Attributes: def get_absolute_url(self):
customer: Reference to the company receiving the goods in the order """Get the 'web' URL for this order"""
customer_reference: Optional field for customer order reference code return reverse('so-detail', kwargs={'pk': self.pk})
target_date: Target date for SalesOrder completion (optional)
"""
@staticmethod @staticmethod
def get_api_url(): def get_api_url():
"""Return the API URL associated with the SalesOrder model""" """Return the API URL associated with the SalesOrder model"""
return reverse('api-so-list') return reverse('api-so-list')
@classmethod
def get_status_class(cls):
"""Return the SalesOrderStatus class"""
return SalesOrderStatus
@classmethod @classmethod
def api_defaults(cls, request): def api_defaults(cls, request):
"""Return default values for this model when issuing an API OPTIONS request""" """Return default values for this model when issuing an API OPTIONS request"""
@ -618,8 +672,6 @@ class SalesOrder(Order):
return defaults return defaults
OVERDUE_FILTER = Q(status__in=SalesOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
# Global setting for specifying reference pattern # Global setting for specifying reference pattern
REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN' REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN'
@ -663,10 +715,6 @@ class SalesOrder(Order):
return f"{self.reference} - {self.customer.name if self.customer else _('deleted')}" return f"{self.reference} - {self.customer.name if self.customer else _('deleted')}"
def get_absolute_url(self):
"""Return the web URL for the detail view of this order"""
return reverse('so-detail', kwargs={'pk': self.id})
reference = models.CharField( reference = models.CharField(
unique=True, unique=True,
max_length=64, max_length=64,
@ -684,13 +732,21 @@ class SalesOrder(Order):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True, null=True,
limit_choices_to={'is_customer': True}, limit_choices_to={'is_customer': True},
related_name='sales_orders', related_name='return_orders',
verbose_name=_('Customer'), verbose_name=_('Customer'),
help_text=_("Company to which the items are being sold"), help_text=_("Company to which the items are being sold"),
) )
status = models.PositiveIntegerField(default=SalesOrderStatus.PENDING, choices=SalesOrderStatus.items(), @property
verbose_name=_('Status'), help_text=_('Purchase order status')) def company(self):
"""Accessor helper for Order base"""
return self.customer
status = models.PositiveIntegerField(
default=SalesOrderStatus.PENDING,
choices=SalesOrderStatus.items(),
verbose_name=_('Status'), help_text=_('Purchase order status')
)
@property @property
def status_text(self): def status_text(self):
@ -699,12 +755,6 @@ class SalesOrder(Order):
customer_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Customer Reference '), help_text=_("Customer order reference code")) customer_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Customer Reference '), help_text=_("Customer order reference code"))
target_date = models.DateField(
null=True, blank=True,
verbose_name=_('Target completion date'),
help_text=_('Target date for order completion. Order will be overdue after this date.')
)
shipment_date = models.DateField(blank=True, null=True, verbose_name=_('Shipment Date')) shipment_date = models.DateField(blank=True, null=True, verbose_name=_('Shipment Date'))
shipped_by = models.ForeignKey( shipped_by = models.ForeignKey(
@ -715,17 +765,6 @@ class SalesOrder(Order):
verbose_name=_('shipped by') verbose_name=_('shipped by')
) )
@property
def is_overdue(self):
"""Returns true if this SalesOrder is "overdue".
Makes use of the OVERDUE_FILTER to avoid code duplication.
"""
query = SalesOrder.objects.filter(pk=self.pk)
query = query.filter(SalesOrder.OVERDUE_FILTER)
return query.exists()
@property @property
def is_pending(self): def is_pending(self):
"""Return True if this order is 'pending'""" """Return True if this order is 'pending'"""
@ -1121,9 +1160,9 @@ class PurchaseOrderLineItem(OrderLineItem):
stock items location will be reported as the location for the stock items location will be reported as the location for the
entire line. entire line.
""" """
for stock in stock_models.StockItem.objects.filter(supplier_part=self.part, purchase_order=self.order): for item in stock.models.StockItem.objects.filter(supplier_part=self.part, purchase_order=self.order):
if stock.location: if item.location:
return stock.location return item.location
if self.destination: if self.destination:
return self.destination return self.destination
if self.part and self.part.part and self.part.part.default_location: if self.part and self.part.part and self.part.part.default_location:
@ -1420,7 +1459,11 @@ class SalesOrderExtraLine(OrderExtraLine):
"""Return the API URL associated with the SalesOrderExtraLine model""" """Return the API URL associated with the SalesOrderExtraLine model"""
return reverse('api-so-extra-line-list') return reverse('api-so-extra-line-list')
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='extra_lines', verbose_name=_('Order'), help_text=_('Sales Order')) order = models.ForeignKey(
SalesOrder, on_delete=models.CASCADE,
related_name='extra_lines',
verbose_name=_('Order'), help_text=_('Sales Order')
)
class SalesOrderAllocation(models.Model): class SalesOrderAllocation(models.Model):
@ -1455,7 +1498,7 @@ class SalesOrderAllocation(models.Model):
try: try:
if not self.item: if not self.item:
raise ValidationError({'item': _('Stock item has not been assigned')}) raise ValidationError({'item': _('Stock item has not been assigned')})
except stock_models.StockItem.DoesNotExist: except stock.models.StockItem.DoesNotExist:
raise ValidationError({'item': _('Stock item has not been assigned')}) raise ValidationError({'item': _('Stock item has not been assigned')})
try: try:
@ -1547,3 +1590,299 @@ class SalesOrderAllocation(models.Model):
# (It may have changed if the stock was split) # (It may have changed if the stock was split)
self.item = item self.item = item
self.save() self.save()
class ReturnOrder(TotalPriceMixin, Order):
"""A ReturnOrder represents goods returned from a customer, e.g. an RMA or warranty
Attributes:
customer: Reference to the customer
sales_order: Reference to an existing SalesOrder (optional)
status: The status of the order (refer to status_codes.ReturnOrderStatus)
"""
def get_absolute_url(self):
"""Get the 'web' URL for this order"""
return reverse('return-order-detail', kwargs={'pk': self.pk})
@staticmethod
def get_api_url():
"""Return the API URL associated with the ReturnOrder model"""
return reverse('api-return-order-list')
@classmethod
def get_status_class(cls):
"""Return the ReturnOrderStatus class"""
return ReturnOrderStatus
@classmethod
def api_defaults(cls, request):
"""Return default values for this model when issuing an API OPTIONS request"""
defaults = {
'reference': order.validators.generate_next_return_order_reference(),
}
return defaults
REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN'
def __str__(self):
"""Render a string representation of this ReturnOrder"""
return f"{self.reference} - {self.customer.name if self.customer else _('no customer')}"
reference = models.CharField(
unique=True,
max_length=64,
blank=False,
verbose_name=_('Reference'),
help_text=_('Return Order reference'),
default=order.validators.generate_next_return_order_reference,
validators=[
order.validators.validate_return_order_reference,
]
)
customer = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
null=True,
limit_choices_to={'is_customer': True},
related_name='sales_orders',
verbose_name=_('Customer'),
help_text=_("Company from which items are being returned"),
)
@property
def company(self):
"""Accessor helper for Order base class"""
return self.customer
status = models.PositiveIntegerField(
default=ReturnOrderStatus.PENDING,
choices=ReturnOrderStatus.items(),
verbose_name=_('Status'), help_text=_('Return order status')
)
customer_reference = models.CharField(
max_length=64, blank=True,
verbose_name=_('Customer Reference '),
help_text=_("Customer order reference code")
)
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,
verbose_name=_('Completion Date'),
help_text=_('Date order was completed')
)
@property
def is_pending(self):
"""Return True if this order is pending"""
return self.status == ReturnOrderStatus.PENDING
@property
def is_open(self):
"""Return True if this order is outstanding"""
return self.status in ReturnOrderStatus.OPEN
@property
def is_received(self):
"""Return True if this order is fully received"""
return not self.lines.filter(received_date=None).exists()
@transaction.atomic
def cancel_order(self):
"""Cancel this ReturnOrder (if not already cancelled)"""
if self.status != ReturnOrderStatus.CANCELLED:
self.status = ReturnOrderStatus.CANCELLED
self.save()
trigger_event('returnorder.cancelled', id=self.pk)
@transaction.atomic
def complete_order(self):
"""Complete this ReturnOrder (if not already completed)"""
if self.status == ReturnOrderStatus.IN_PROGRESS:
self.status = ReturnOrderStatus.COMPLETE
self.complete_date = datetime.now().date()
self.save()
trigger_event('returnorder.completed', id=self.pk)
@transaction.atomic
def place_order(self):
"""Issue this ReturnOrder (if currently pending)"""
if self.status == ReturnOrderStatus.PENDING:
self.status = ReturnOrderStatus.IN_PROGRESS
self.issue_date = datetime.now().date()
self.save()
trigger_event('returnorder.placed', id=self.pk)
@transaction.atomic
def receive_line_item(self, line, location, user, note=''):
"""Receive a line item against this ReturnOrder:
- Transfers the StockItem to the specified location
- Marks the StockItem as "quarantined"
- Adds a tracking entry to the StockItem
- Removes the 'customer' reference from the StockItem
"""
# Prevent an item from being "received" multiple times
if line.received_date is not None:
logger.warning("receive_line_item called with item already returned")
return
stock_item = line.item
deltas = {
'status': StockStatus.QUARANTINED,
'returnorder': self.pk,
'location': location.pk,
}
if stock_item.customer:
deltas['customer'] = stock_item.customer.pk
# Update the StockItem
stock_item.status = StockStatus.QUARANTINED
stock_item.location = location
stock_item.customer = None
stock_item.sales_order = None
stock_item.save(add_note=False)
# Add a tracking entry to the StockItem
stock_item.add_tracking_entry(
StockHistoryCode.RETURNED_AGAINST_RETURN_ORDER,
user,
notes=note,
deltas=deltas,
location=location,
returnorder=self,
)
# Update the LineItem
line.received_date = datetime.now().date()
line.save()
trigger_event('returnorder.received', id=self.pk)
# Notify responsible users
notify_responsible(
self,
ReturnOrder,
exclude=user,
content=InvenTreeNotificationBodies.ReturnOrderItemsReceived,
)
class ReturnOrderLineItem(OrderLineItem):
"""Model for a single LineItem in a ReturnOrder"""
class Meta:
"""Metaclass options for this model"""
unique_together = [
('order', 'item'),
]
@staticmethod
def get_api_url():
"""Return the API URL associated with this model"""
return reverse('api-return-order-line-list')
def clean(self):
"""Perform extra validation steps for the ReturnOrderLineItem model"""
super().clean()
if self.item and not self.item.serialized:
raise ValidationError({
'item': _("Only serialized items can be assigned to a Return Order"),
})
order = models.ForeignKey(
ReturnOrder,
on_delete=models.CASCADE,
related_name='lines',
verbose_name=_('Order'),
help_text=_('Return Order'),
)
item = models.ForeignKey(
stock.models.StockItem,
on_delete=models.CASCADE,
related_name='return_order_lines',
verbose_name=_('Item'),
help_text=_('Select item to return from customer')
)
received_date = models.DateField(
null=True, blank=True,
verbose_name=_('Received Date'),
help_text=_('The date this this return item was received'),
)
@property
def received(self):
"""Return True if this item has been received"""
return self.received_date is not None
outcome = models.PositiveIntegerField(
default=ReturnOrderLineStatus.PENDING,
choices=ReturnOrderLineStatus.items(),
verbose_name=_('Outcome'), help_text=_('Outcome for this line item')
)
price = InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Price'),
help_text=_('Cost associated with return or repair for this line item'),
)
link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page'))
class ReturnOrderExtraLine(OrderExtraLine):
"""Model for a single ExtraLine in a ReturnOrder"""
@staticmethod
def get_api_url():
"""Return the API URL associated with the ReturnOrderExtraLine model"""
return reverse('api-return-order-extra-line-list')
order = models.ForeignKey(
ReturnOrder, on_delete=models.CASCADE,
related_name='extra_lines',
verbose_name=_('Order'), help_text=_('Return Order')
)
class ReturnOrderAttachment(InvenTreeAttachment):
"""Model for storing file attachments against a ReturnOrder object"""
@staticmethod
def get_api_url():
"""Return the API URL associated with the ReturnOrderAttachment class"""
return reverse('api-return-order-attachment-list')
def getSubdir(self):
"""Return the directory path where ReturnOrderAttachment files are located"""
return os.path.join('return_files', str(self.order.id))
order = models.ForeignKey(
ReturnOrder,
on_delete=models.CASCADE,
related_name='attachments',
)

View File

@ -17,21 +17,22 @@ import order.models
import part.filters import part.filters
import stock.models import stock.models
import stock.serializers import stock.serializers
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer from company.serializers import (CompanyBriefSerializer, ContactSerializer,
SupplierPartSerializer)
from InvenTree.helpers import extract_serial_numbers, normalize, str2bool from InvenTree.helpers import extract_serial_numbers, normalize, str2bool
from InvenTree.serializers import (InvenTreeAttachmentSerializer, from InvenTree.serializers import (InvenTreeAttachmentSerializer,
InvenTreeCurrencySerializer, InvenTreeCurrencySerializer,
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
InvenTreeMoneySerializer) InvenTreeMoneySerializer)
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus, from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderStatus,
StockStatus) SalesOrderStatus, StockStatus)
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from users.serializers import OwnerSerializer from users.serializers import OwnerSerializer
class AbstractOrderSerializer(serializers.Serializer): class TotalPriceMixin(serializers.Serializer):
"""Abstract field definitions for OrderSerializers.""" """Serializer mixin which provides total price fields"""
total_price = InvenTreeMoneySerializer( total_price = InvenTreeMoneySerializer(
allow_null=True, allow_null=True,
@ -41,6 +42,69 @@ class AbstractOrderSerializer(serializers.Serializer):
total_price_currency = InvenTreeCurrencySerializer(read_only=True) total_price_currency = InvenTreeCurrencySerializer(read_only=True)
class AbstractOrderSerializer(serializers.Serializer):
"""Abstract serializer class which provides fields common to all order types"""
# Number of line items in this order
line_items = serializers.IntegerField(read_only=True)
# Human-readable status text (read-only)
status_text = serializers.CharField(source='get_status_display', read_only=True)
# status field cannot be set directly
status = serializers.IntegerField(read_only=True)
# Reference string is *required*
reference = serializers.CharField(required=True)
# Detail for point-of-contact field
contact_detail = ContactSerializer(source='contact', many=False, read_only=True)
# Detail for responsible field
responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False)
# Boolean field indicating if this order is overdue (Note: must be annotated)
overdue = serializers.BooleanField(required=False, read_only=True)
def validate_reference(self, reference):
"""Custom validation for the reference field"""
self.Meta.model.validate_reference_field(reference)
return reference
@staticmethod
def annotate_queryset(queryset):
"""Add extra information to the queryset"""
queryset = queryset.annotate(
line_items=SubqueryCount('lines')
)
return queryset
@staticmethod
def order_fields(extra_fields):
"""Construct a set of fields for this serializer"""
return [
'pk',
'creation_date',
'target_date',
'description',
'line_items',
'link',
'reference',
'responsible',
'responsible_detail',
'contact',
'contact_detail',
'status',
'status_text',
'notes',
'overdue',
] + extra_fields
class AbstractExtraLineSerializer(serializers.Serializer): class AbstractExtraLineSerializer(serializers.Serializer):
"""Abstract Serializer for a ExtraLine object.""" """Abstract Serializer for a ExtraLine object."""
@ -78,7 +142,7 @@ class AbstractExtraLineMeta:
] ]
class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer): class PurchaseOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer):
"""Serializer for a PurchaseOrder object.""" """Serializer for a PurchaseOrder object."""
class Meta: class Meta:
@ -86,31 +150,17 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer)
model = order.models.PurchaseOrder model = order.models.PurchaseOrder
fields = [ fields = AbstractOrderSerializer.order_fields([
'pk',
'issue_date', 'issue_date',
'complete_date', 'complete_date',
'creation_date',
'description',
'line_items',
'link',
'overdue',
'reference',
'responsible',
'responsible_detail',
'supplier', 'supplier',
'supplier_detail', 'supplier_detail',
'supplier_reference', 'supplier_reference',
'status',
'status_text',
'target_date',
'notes',
'total_price', 'total_price',
'total_price_currency', 'total_price_currency',
] ])
read_only_fields = [ read_only_fields = [
'status'
'issue_date', 'issue_date',
'complete_date', 'complete_date',
'creation_date', 'creation_date',
@ -132,14 +182,13 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer)
- Number of lines in the PurchaseOrder - Number of lines in the PurchaseOrder
- Overdue status of the PurchaseOrder - Overdue status of the PurchaseOrder
""" """
queryset = queryset.annotate( queryset = AbstractOrderSerializer.annotate_queryset(queryset)
line_items=SubqueryCount('lines')
)
queryset = queryset.annotate( queryset = queryset.annotate(
overdue=Case( overdue=Case(
When( When(
order.models.PurchaseOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), order.models.PurchaseOrder.overdue_filter(),
then=Value(True, output_field=BooleanField()),
), ),
default=Value(False, output_field=BooleanField()) default=Value(False, output_field=BooleanField())
) )
@ -149,24 +198,6 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer)
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True) 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)
overdue = serializers.BooleanField(required=False, read_only=True)
reference = serializers.CharField(required=True)
def validate_reference(self, reference):
"""Custom validation for the reference field"""
# Ensure that the reference matches the required pattern
order.models.PurchaseOrder.validate_reference_field(reference)
return reference
responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False)
class PurchaseOrderCancelSerializer(serializers.Serializer): class PurchaseOrderCancelSerializer(serializers.Serializer):
"""Serializer for cancelling a PurchaseOrder.""" """Serializer for cancelling a PurchaseOrder."""
@ -307,7 +338,7 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
queryset = queryset.annotate( queryset = queryset.annotate(
overdue=Case( overdue=Case(
When( When(
Q(order__status__in=PurchaseOrderStatus.OPEN) & order.models.PurchaseOrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()) order.models.PurchaseOrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField())
), ),
default=Value(False, output_field=BooleanField()), default=Value(False, output_field=BooleanField()),
) )
@ -531,7 +562,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
class PurchaseOrderReceiveSerializer(serializers.Serializer): class PurchaseOrderReceiveSerializer(serializers.Serializer):
"""Serializer for receiving items against a purchase order.""" """Serializer for receiving items against a PurchaseOrder."""
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@ -644,34 +675,22 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
]) ])
class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer): class SalesOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer):
"""Serializers for the SalesOrder object.""" """Serializer for the SalesOrder model class"""
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
model = order.models.SalesOrder model = order.models.SalesOrder
fields = [ fields = AbstractOrderSerializer.order_fields([
'pk',
'creation_date',
'customer', 'customer',
'customer_detail', 'customer_detail',
'customer_reference', 'customer_reference',
'description',
'line_items',
'link',
'notes',
'overdue',
'reference',
'responsible',
'status',
'status_text',
'shipment_date', 'shipment_date',
'target_date',
'total_price', 'total_price',
'total_price_currency', 'total_price_currency',
] ])
read_only_fields = [ read_only_fields = [
'status', 'status',
@ -695,14 +714,13 @@ class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
- Number of line items in the SalesOrder - Number of line items in the SalesOrder
- Overdue status of the SalesOrder - Overdue status of the SalesOrder
""" """
queryset = queryset.annotate( queryset = AbstractOrderSerializer.annotate_queryset(queryset)
line_items=SubqueryCount('lines')
)
queryset = queryset.annotate( queryset = queryset.annotate(
overdue=Case( overdue=Case(
When( When(
order.models.SalesOrder.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), order.models.SalesOrder.overdue_filter(),
then=Value(True, output_field=BooleanField()),
), ),
default=Value(False, output_field=BooleanField()) default=Value(False, output_field=BooleanField())
) )
@ -712,22 +730,6 @@ class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
customer_detail = CompanyBriefSerializer(source='customer', many=False, read_only=True) customer_detail = CompanyBriefSerializer(source='customer', many=False, read_only=True)
line_items = serializers.IntegerField(read_only=True)
status_text = serializers.CharField(source='get_status_display', read_only=True)
overdue = serializers.BooleanField(required=False, read_only=True)
reference = serializers.CharField(required=True)
def validate_reference(self, reference):
"""Custom validation for the reference field"""
# Ensure that the reference matches the required pattern
order.models.SalesOrder.validate_reference_field(reference)
return reference
class SalesOrderAllocationSerializer(InvenTreeModelSerializer): class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
"""Serializer for the SalesOrderAllocation model. """Serializer for the SalesOrderAllocation model.
@ -1379,13 +1381,13 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
class SalesOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer): class SalesOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
"""Serializer for a SalesOrderExtraLine object.""" """Serializer for a SalesOrderExtraLine object."""
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
class Meta(AbstractExtraLineMeta): class Meta(AbstractExtraLineMeta):
"""Metaclass options.""" """Metaclass options."""
model = order.models.SalesOrderExtraLine model = order.models.SalesOrderExtraLine
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer): class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializers for the SalesOrderAttachment model.""" """Serializers for the SalesOrderAttachment model."""
@ -1398,3 +1400,253 @@ class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
fields = InvenTreeAttachmentSerializer.attachment_fields([ fields = InvenTreeAttachmentSerializer.attachment_fields([
'order', 'order',
]) ])
class ReturnOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
"""Serializer for the ReturnOrder model class"""
class Meta:
"""Metaclass options"""
model = order.models.ReturnOrder
fields = AbstractOrderSerializer.order_fields([
'customer',
'customer_detail',
'customer_reference',
])
read_only_fields = [
'creation_date',
]
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer"""
customer_detail = kwargs.pop('customer_detail', False)
super().__init__(*args, **kwargs)
if customer_detail is not True:
self.fields.pop('customer_detail')
@staticmethod
def annotate_queryset(queryset):
"""Custom annotation for the serializer queryset"""
queryset = AbstractOrderSerializer.annotate_queryset(queryset)
queryset = queryset.annotate(
overdue=Case(
When(
order.models.ReturnOrder.overdue_filter(),
then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField())
)
)
return queryset
customer_detail = CompanyBriefSerializer(source='customer', many=False, read_only=True)
class ReturnOrderIssueSerializer(serializers.Serializer):
"""Serializer for issuing a ReturnOrder"""
class Meta:
"""Metaclass options"""
fields = []
def save(self):
"""Save the serializer to 'issue' the order"""
order = self.context['order']
order.place_order()
class ReturnOrderCancelSerializer(serializers.Serializer):
"""Serializer for cancelling a ReturnOrder"""
class Meta:
"""Metaclass options"""
fields = []
def save(self):
"""Save the serializer to 'cancel' the order"""
order = self.context['order']
order.cancel_order()
class ReturnOrderCompleteSerializer(serializers.Serializer):
"""Serializer for completing a ReturnOrder"""
class Meta:
"""Metaclass options"""
fields = []
def save(self):
"""Save the serializer to 'complete' the order"""
order = self.context['order']
order.complete_order()
class ReturnOrderLineItemReceiveSerializer(serializers.Serializer):
"""Serializer for receiving a single line item against a ReturnOrder"""
class Meta:
"""Metaclass options"""
fields = [
'item',
]
item = serializers.PrimaryKeyRelatedField(
queryset=order.models.ReturnOrderLineItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Return order line item'),
)
def validate_line_item(self, item):
"""Validation for a single line item"""
if item.order != self.context['order']:
raise ValidationError(_("Line item does not match return order"))
if item.received:
raise ValidationError(_("Line item has already been received"))
return item
class ReturnOrderReceiveSerializer(serializers.Serializer):
"""Serializer for receiving items against a ReturnOrder"""
class Meta:
"""Metaclass options"""
fields = [
'items',
'location',
]
items = ReturnOrderLineItemReceiveSerializer(many=True)
location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Location'),
help_text=_('Select destination location for received items'),
)
def validate(self, data):
"""Perform data validation for this serializer"""
order = self.context['order']
if order.status != ReturnOrderStatus.IN_PROGRESS:
raise ValidationError(_("Items can only be received against orders which are in progress"))
data = super().validate(data)
items = data.get('items', [])
if len(items) == 0:
raise ValidationError(_("Line items must be provided"))
return data
@transaction.atomic
def save(self):
"""Saving this serializer marks the returned items as received"""
order = self.context['order']
request = self.context['request']
data = self.validated_data
items = data['items']
location = data['location']
with transaction.atomic():
for item in items:
line_item = item['item']
order.receive_line_item(
line_item,
location,
request.user
)
class ReturnOrderLineItemSerializer(InvenTreeModelSerializer):
"""Serializer for a ReturnOrderLineItem object"""
class Meta:
"""Metaclass options"""
model = order.models.ReturnOrderLineItem
fields = [
'pk',
'order',
'order_detail',
'item',
'item_detail',
'received_date',
'outcome',
'part_detail',
'price',
'price_currency',
'link',
'reference',
'notes',
'target_date',
]
def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer"""
order_detail = kwargs.pop('order_detail', False)
item_detail = kwargs.pop('item_detail', False)
part_detail = kwargs.pop('part_detail', False)
super().__init__(*args, **kwargs)
if not order_detail:
self.fields.pop('order_detail')
if not item_detail:
self.fields.pop('item_detail')
if not part_detail:
self.fields.pop('part_detail')
order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True)
item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True)
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
price = InvenTreeMoneySerializer(allow_null=True)
price_currency = InvenTreeCurrencySerializer(help_text=_('Line price currency'))
class ReturnOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
"""Serializer for a ReturnOrderExtraLine object"""
class Meta(AbstractExtraLineMeta):
"""Metaclass options"""
model = order.models.ReturnOrderExtraLine
order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True)
class ReturnOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for the ReturnOrderAttachment model"""
class Meta:
"""Metaclass options"""
model = order.models.ReturnOrderAttachment
fields = InvenTreeAttachmentSerializer.attachment_fields([
'order',
])

View File

@ -7,16 +7,16 @@
{% block page_title %} {% block page_title %}
{% inventree_title %} | {% trans "Purchase Order" %} {% inventree_title %} | {% trans "Purchase Order" %}
{% endblock %} {% endblock page_title %}
{% block breadcrumbs %} {% block breadcrumbs %}
<li class='breadcrumb-item'><a href='{% url "po-index" %}'>{% trans "Purchase Orders" %}</a></li> <li class='breadcrumb-item'><a href='{% url "purchase-order-index" %}'>{% trans "Purchase Orders" %}</a></li>
<li class="breadcrumb-item active" aria-current="page"><a href='{% url "po-detail" order.id %}'>{{ order }}</a></li> <li class="breadcrumb-item active" aria-current="page"><a href='{% url "po-detail" order.id %}'>{{ order }}</a></li>
{% endblock %} {% endblock breadcrumbs %}
{% block heading %} {% block heading %}
{% trans "Purchase Order" %}: {{ order.reference }} {% trans "Purchase Order" %}: {{ order.reference }}
{% endblock %} {% endblock heading %}
{% block actions %} {% block actions %}
{% if user.is_staff and roles.purchase_order.change %} {% if user.is_staff and roles.purchase_order.change %}
@ -67,8 +67,7 @@
{% trans "Receive Items" %} {% trans "Receive Items" %}
</button> </button>
<button type='button' class='btn btn-success' id='complete-order' title='{% trans "Mark order as complete" %}'> <button type='button' class='btn btn-success' id='complete-order' title='{% trans "Mark order as complete" %}'>
<span class='fas fa-check-circle'></span> <span class='fas fa-check-circle'></span> {% trans "Complete Order" %}
{% trans "Complete Order" %}
</button> </button>
{% endif %} {% endif %}
{% endif %} {% endif %}
@ -82,7 +81,7 @@ src="{{ order.supplier.image.url }}"
src="{% static 'img/blank_image.png' %}" src="{% static 'img/blank_image.png' %}"
{% endif %} {% endif %}
/> />
{% endblock %} {% endblock thumbnail %}
{% block details %} {% block details %}
@ -111,7 +110,7 @@ src="{% static 'img/blank_image.png' %}"
</table> </table>
{% endblock %} {% endblock details %}
{% block details_right %} {% block details_right %}
<table class='table table-condensed table-striped'> <table class='table table-condensed table-striped'>
@ -169,7 +168,10 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Target Date" %}</td> <td>{% trans "Target Date" %}</td>
<td>{% render_date order.target_date %}</td> <td>
{% render_date order.target_date %}
{% if order.is_overdue %}<span class='fas fa-calendar-times icon-red float-right'></span>{% endif %}
</td>
</tr> </tr>
{% endif %} {% endif %}
{% if order.status == PurchaseOrderStatus.COMPLETE %} {% if order.status == PurchaseOrderStatus.COMPLETE %}
@ -179,6 +181,13 @@ src="{% static 'img/blank_image.png' %}"
<td>{% render_date order.complete_date %}<span class='badge badge-right rounded-pill bg-dark'>{{ order.received_by }}</span></td> <td>{% render_date order.complete_date %}<span class='badge badge-right rounded-pill bg-dark'>{{ order.received_by }}</span></td>
</tr> </tr>
{% endif %} {% endif %}
{% if order.contact %}
<tr>
<td><span class='fas fa-user-tie'></span></td>
<td>{% trans "Contact" %}</td>
<td>{{ order.contact.name }}</td>
</tr>
{% endif %}
{% if order.responsible %} {% if order.responsible %}
<tr> <tr>
<td><span class='fas fa-users'></span></td> <td><span class='fas fa-users'></span></td>
@ -201,12 +210,11 @@ src="{% static 'img/blank_image.png' %}"
</td> </td>
</tr> </tr>
</table> </table>
{% endblock %} {% endblock details_right %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
{% if order.status == PurchaseOrderStatus.PENDING %} {% if order.status == PurchaseOrderStatus.PENDING %}
$("#place-order").click(function() { $("#place-order").click(function() {
@ -222,7 +230,11 @@ $("#place-order").click(function() {
{% if report_enabled %} {% if report_enabled %}
$('#print-order-report').click(function() { $('#print-order-report').click(function() {
printPurchaseOrderReports([{{ order.pk }}]); printReports({
items: [{{ order.pk }}],
key: 'order',
url: '{% url "api-po-report-list" %}',
});
}); });
{% endif %} {% endif %}
@ -293,4 +305,4 @@ $("#export-order").click(function() {
}); });
{% endblock %} {% endblock js_ready %}

View File

@ -8,7 +8,7 @@
{% block sidebar %} {% block sidebar %}
{% include 'order/po_sidebar.html' %} {% include 'order/po_sidebar.html' %}
{% endblock %} {% endblock sidebar %}
{% block page_content %} {% block page_content %}
{% settings_value "PURCHASEORDER_EDIT_COMPLETED_ORDERS" as allow_extra_editing %} {% settings_value "PURCHASEORDER_EDIT_COMPLETED_ORDERS" as allow_extra_editing %}
@ -27,9 +27,10 @@
<button type='button' class='btn btn-success' id='new-po-line'> <button type='button' class='btn btn-success' id='new-po-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %} <span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
</button> </button>
{% elif order.status == PurchaseOrderStatus.PLACED %} {% endif %}
<button type='button' class='btn btn-primary' id='receive-selected-items' title='{% trans "Receive selected items" %}'> {% if order.status == PurchaseOrderStatus.PLACED %}
<span class='fas fa-sign-in-alt'></span> {% trans "Receive Items" %} <button type='button' class='btn btn-primary' id='receive-selected-items' title='{% trans "Receive Line Items" %}'>
<span class='fas fa-sign-in-alt'></span> {% trans "Receive Line Items" %}
</button> </button>
{% endif %} {% endif %}
{% endif %} {% endif %}
@ -126,7 +127,7 @@
</div> </div>
</div> </div>
{% endblock %} {% endblock page_content %}
{% block js_ready %} {% block js_ready %}
@ -146,30 +147,18 @@
); );
}); });
enableDragAndDrop( onPanelLoad('order-attachments', function() {
'#attachment-dropzone', loadAttachmentTable('{% url "api-po-attachment-list" %}', {
'{% url "api-po-attachment-list" %}', filters: {
{ order: {{ order.pk }},
data: {
order: {{ order.id }},
}, },
label: 'attachment', fields: {
success: function(data, status, xhr) { order: {
$('#attachment-table').bootstrapTable('refresh'); value: {{ order.pk }},
hidden: true,
}
} }
} });
);
loadAttachmentTable('{% url "api-po-attachment-list" %}', {
filters: {
order: {{ order.pk }},
},
fields: {
order: {
value: {{ order.pk }},
hidden: true,
}
}
}); });
loadStockTable($("#stock-table"), { loadStockTable($("#stock-table"), {
@ -204,7 +193,7 @@ $('#new-po-line').click(function() {
{% elif order.status == PurchaseOrderStatus.PLACED %} {% elif order.status == PurchaseOrderStatus.PLACED %}
$('#receive-selected-items').click(function() { $('#receive-selected-items').click(function() {
var items = getTableData('#po-line-table'); let items = getTableData('#po-line-table');
receivePurchaseOrderItems( receivePurchaseOrderItems(
{{ order.id }}, {{ order.id }},
@ -219,59 +208,56 @@ $('#new-po-line').click(function() {
{% endif %} {% endif %}
loadPurchaseOrderLineItemTable('#po-line-table', { onPanelLoad('order-items', function() {
order: {{ order.pk }}, loadPurchaseOrderLineItemTable('#po-line-table', {
{% if order.supplier %}
supplier: {{ order.supplier.pk }},
{% endif %}
{% if roles.purchase_order.change %}
allow_edit: true,
{% else %}
allow_edit: false,
{% endif %}
{% if order.status == PurchaseOrderStatus.PENDING %}
pending: true,
{% endif %}
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
allow_receive: true,
{% else %}
allow_receive: false,
{% endif %}
});
$("#new-po-extra-line").click(function() {
var fields = extraLineFields({
order: {{ order.pk }}, order: {{ order.pk }},
}); {% if order.supplier %}
supplier: {{ order.supplier.pk }},
{% if order.supplier.currency %}
fields.price_currency.value = '{{ order.supplier.currency }}';
{% endif %}
constructForm('{% url "api-po-extra-line-list" %}', {
fields: fields,
method: 'POST',
title: '{% trans "Add Order Line" %}',
onSuccess: function() {
$("#po-extra-lines-table").bootstrapTable("refresh");
},
});
});
loadPurchaseOrderExtraLineTable(
'#po-extra-lines-table',
{
order: {{ order.pk }},
status: {{ order.status }},
{% if order.is_pending %}
pending: true,
{% endif %} {% endif %}
{% if roles.purchase_order.change %} {% if roles.purchase_order.change %}
allow_edit: true, allow_edit: true,
{% else %}
allow_edit: false,
{% endif %} {% endif %}
} {% if order.status == PurchaseOrderStatus.PENDING %}
); pending: true,
{% endif %}
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
allow_receive: true,
{% else %}
allow_receive: false,
{% endif %}
});
$("#new-po-extra-line").click(function() {
createExtraLineItem({
order: {{ order.pk }},
table: '#po-extra-lines-table',
url: '{% url "api-po-extra-line-list" %}',
{% if order.supplier.currency %}
currency: '{{ order.supplier.currency }}',
{% endif %}
});
});
loadExtraLineTable({
table: '#po-extra-lines-table',
order: {{ order.pk }},
url: '{% url "api-po-extra-line-list" %}',
name: 'purchaseorderextraline',
filtertarget: '#filter-list-purchase-order-extra-lines',
{% settings_value "PURCHASEORDER_EDIT_COMPLETED_ORDERS" as allow_edit %}
{% if order.is_pending or allow_edit %}
allow_edit: {% js_bool roles.purchase_order.change %},
allow_delete: {% js_bool roles.purchase_order.delete %},
{% else %}
allow_edit: false,
allow_delete: false,
{% endif %}
pricing: true,
});
});
loadOrderTotal( loadOrderTotal(
'#poTotalPrice', '#poTotalPrice',

View File

@ -26,11 +26,6 @@
<div id='table-buttons'> <div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if report_enabled %}
<button id='order-print' class='btn btn-outline-secondary' title='{% trans "Print Order Reports" %}'>
<span class='fas fa-print'></span>
</button>
{% endif %}
{% include "filter_list.html" with id="purchaseorder" %} {% include "filter_list.html" with id="purchaseorder" %}
</div> </div>
</div> </div>
@ -53,20 +48,6 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
{% if report_enabled %}
$("#order-print").click(function() {
var rows = getTableData('#purchase-order-table');
var orders = [];
rows.forEach(function(row) {
orders.push(row.pk);
});
printPurchaseOrderReports(orders);
})
{% endif %}
$("#po-create").click(function() { $("#po-create").click(function() {
createPurchaseOrder(); createPurchaseOrder();
}); });

View File

@ -0,0 +1,235 @@
{% extends "page_base.html" %}
{% load i18n %}
{% load static %}
{% load inventree_extras %}
{% load status_codes %}
{% block page_title %}
{% inventree_title %} | {% trans "Return Order" %}
{% endblock page_title %}
{% block breadcrumbs %}
<li class='breadcrumb-item'><a href='{% url "return-order-index" %}'>{% trans "Return Orders" %}</a></li>
<li class="breadcrumb-item active" aria-current="page"><a href='{% url "return-order-detail" order.id %}'>{{ order }}</a></li>
{% endblock breadcrumbs %}
{% block thumbnail %}
<img class='part-thumb'
{% if order.customer and order.customer.image %}
src="{{ order.customer.image.url }}"
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}
/>
{% endblock thumbnail%}
{% block heading %}
{% trans "Return Order" %} {{ order.reference }}
{% endblock heading %}
{% block actions %}
{% if user.is_staff and roles.return_order.change %}
{% url 'admin:order_returnorder_change' order.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
<!-- Printing actions -->
<div class='btn-group' role='group'>
<button id='print-options' title='{% trans "Print actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
{% if report_enabled %}
<li><a class='dropdown-item' href='#' id='print-order-report'><span class='fas fa-file-pdf'></span> {% trans "Print return order report" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href='#' id='export-order'><span class='fas fa-file-download'></span> {% trans "Export order to file" %}</a></li>
<!--
<li><a class='dropdown-item' href='#' id='print-packing-list'><span class='fas fa-clipboard-list'></span>{% trans "Print packing list" %}</a></li>
-->
</ul>
</div>
{% if roles.return_order.change %}
<!-- Order actions -->
<div class='btn-group' role='group'>
<button id='order-options' title='{% trans "Order actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
<span class='fas fa-tools'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
<li><a class='dropdown-item' href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li>
{% if order.is_open %}
<li><a class='dropdown-item' href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li>
{% endif %}
</ul>
{% if order.status == ReturnOrderStatus.PENDING %}
<button type='button' class='btn btn-primary' id='submit-order' title='{% trans "Submit Order" %}'>
<span class='fas fa-paper-plane'></span> {% trans "Submit Order" %}
</button>
{% elif order.status == ReturnOrderStatus.IN_PROGRESS %}
<button type='button' class='btn btn-success' id='complete-order' title='{% trans "Mark order as complete" %}'>
<span class='fas fa-check-circle'></span> {% trans "Complete Order" %}
</button>
{% endif %}
</div>
{% endif %}
{% endblock actions %}
{% block details %}
<table class='table table-striped table-condensed'>
<col width='25'>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td>
<td>{{ order.reference }}{% include "clip.html"%}</td>
</tr>
<tr>
<td><span class='fas fa-info-circle'></span></td>
<td>{% trans "Order Description" %}</td>
<td>{{ order.description }}{% include "clip.html" %}</td>
</tr>
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Order Status" %}</td>
<td>
{% return_order_status_label order.status %}
{% if order.is_overdue %}
<span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span>
{% endif %}
</td>
</tr>
</table>
{% endblock details %}
{% block details_right %}
<table class='table table-striped table-condensed'>
<col width='25'>
{% if order.customer %}
<tr>
<td><span class='fas fa-building'></span></td>
<td>{% trans "Customer" %}</td>
<td><a href="{% url 'company-detail' order.customer.id %}">{{ order.customer.name }}</a>{% include "clip.html"%}</td>
</tr>
{% endif %}
{% if order.customer_reference %}
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Customer Reference" %}</td>
<td>{{ order.customer_reference }}{% include "clip.html"%}</td>
</tr>
{% endif %}
{% if order.link %}
<tr>
<td><span class='fas fa-link'></span></td>
<td>External Link</td>
<td><a href="{{ order.link }}">{{ order.link }}</a>{% include "clip.html"%}</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Created" %}</td>
<td>{% render_date order.creation_date %}<span class='badge badge-right rounded-pill bg-dark'>{{ order.created_by }}</span></td>
</tr>
{% if order.issue_date %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Issued" %}</td>
<td>{% render_date 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>
{% render_date order.target_date %}
{% if order.is_overdue %}<span class='fas fa-calendar-times icon-red float-right'></span>{% endif %}
</td>
</tr>
{% endif %}
{% if order.contact %}
<tr>
<td><span class='fas fa-user-tie'></span></td>
<td>{% trans "Contact" %}</td>
<td>{{ order.contact.name }}</td>
</tr>
{% endif %}
{% if order.responsible %}
<tr>
<td><span class='fas fa-users'></span></td>
<td>{% trans "Responsible" %}</td>
<td>{{ order.responsible }}</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Total Cost" %}</td>
<td id='roTotalPrice'>
{% with order.total_price as tp %}
{% if tp == None %}
<span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span>
{% else %}
{% render_currency tp currency=order.customer.currency %}
{% endif %}
{% endwith %}
</td>
</tr>
</table>
{% endblock details_right %}
{% block js_ready %}
{{ block.super }}
{% if roles.return_order.change %}
{% if order.status == ReturnOrderStatus.PENDING %}
$('#submit-order').click(function() {
issueReturnOrder({{ order.pk }}, {
reload: true,
});
});
{% elif order.status == ReturnOrderStatus.IN_PROGRESS %}
$('#complete-order').click(function() {
completeReturnOrder(
{{ order.pk }},
{
reload: true,
}
);
})
{% endif %}
$('#edit-order').click(function() {
editReturnOrder({{ order.pk }}, {
reload: true,
});
});
{% if order.is_open %}
$('#cancel-order').click(function() {
cancelReturnOrder(
{{ order.pk }},
{
reload: true
}
);
});
{% endif %}
{% endif %}
{% if report_enabled %}
$('#print-order-report').click(function() {
printReports({
items: [{{ order.pk }}],
key: 'order',
url: '{% url "api-return-order-report-list" %}',
});
});
{% endif %}
<!-- TODO: Export order callback -->
{% endblock js_ready %}

View File

@ -0,0 +1,209 @@
{% extends "order/return_order_base.html" %}
{% load inventree_extras %}
{% load status_codes %}
{% load i18n %}
{% load static %}
{% block sidebar %}
{% include "order/return_order_sidebar.html" %}
{% endblock sidebar %}
{% block page_content %}
{% settings_value "RETURNORDER_EDIT_COMPLETED_ORDERS" as allow_extra_editing %}
<div class='panel panel-hidden' id='panel-order-details'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Line Items" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.return_order.add %}
{% if order.is_open or allow_extra_editing %}
<button type='button' class='btn btn-success' id='new-return-order-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
</button>
{% endif %}
{% if order.status == ReturnOrderStatus.IN_PROGRESS %}
<button type='button' class='btn btn-primary' id='receive-line-items' title='{% trans "Receive Line Items" %}'>
<span class='fas fa-sign-in-alt'></span> {% trans "Receive Line Items" %}
</button>
{% endif %}
{% endif %}
</div>
</div>
</div>
<div class='panel-content'>
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
<div class='btn-group'>
{% include "filter_list.html" with id="returnorderlines" %}
</div>
</div>
<table class='table table-striped table-condensed' id='return-order-lines-table' data-toolbar='#order-toolbar-buttons'>
</table>
</div>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Extra Lines" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.return_order.add %}
{% if order.is_open or allow_extra_editing %}
<button type='button' class='btn btn-success' id='new-return-order-extra-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Extra Line" %}
</button>
{% endif %}
{% endif %}
</div>
</div>
</div>
<div class='panel-content'>
<div id='order-extra-toolbar-buttons' class='btn-group' style='float: right;'>
<div class='btn-group'>
{% include "filter_list.html" with id="return-order-extra-lines" %}
</div>
</div>
<table class='table table-striped table-condensed' id='return-order-extra-lines-table' data-toolbar='#order-extra-toolbar-buttons'>
</table>
</div>
</div>
<div class='panel panel-hidden' id='panel-order-attachments'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Attachments" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "attachment_button.html" %}
</div>
</div>
</div>
<div class='panel-content'>
{% include "attachment_table.html" %}
</div>
</div>
<div class='panel panel-hidden' id='panel-order-notes'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Order Notes" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "notes_buttons.html" %}
</div>
</div>
</div>
<div class='panel-content'>
<textarea id='order-notes'></textarea>
</div>
</div>
{% endblock page_content %}
{% block js_ready %}
{{ block.super }}
// Callback function when the 'details' panel is loaded
onPanelLoad('order-details', function() {
{% if roles.return_order.add %}
$('#receive-line-items').click(function() {
let items = getTableData('#return-order-lines-table');
receiveReturnOrderItems(
{{ order.pk }},
items,
{
onSuccess: function() {
reloadBootstrapTable('#return-order-lines-table');
}
}
);
});
$('#new-return-order-line').click(function() {
createReturnOrderLineItem({
order: {{ order.pk }},
customer: {{ order.customer.pk }},
onSuccess: function() {
reloadBootstrapTable('#return-order-lines-table');
}
});
});
$('#new-return-order-extra-line').click(function() {
createExtraLineItem({
order: {{ order.pk }},
table: '#return-order-extra-lines-table',
url: '{% url "api-return-order-extra-line-list" %}',
{% if order.customer.currency %}
currency: '{{ order.customer.currency }}',
{% endif %}
});
});
{% endif %}
{% settings_value "RETURNORDER_EDIT_COMPLETED_ORDERS" as allow_extra_editing %}
loadReturnOrderLineItemTable({
table: '#return-order-lines-table',
order: {{ order.pk }},
{% if order.status == ReturnOrderStatus.IN_PROGRESS %}
allow_receive: true,
{% endif %}
{% if order.is_open or allow_extra_editing %}
allow_edit: {% js_bool roles.return_order.change %},
allow_delete: {% js_bool roles.return_order.delete %},
{% endif %}
});
loadExtraLineTable({
order: {{ order.pk }},
url: '{% url "api-return-order-extra-line-list" %}',
table: "#return-order-extra-lines-table",
name: 'returnorderextralines',
filtertarget: '#filter-list-return-order-extra-lines',
{% if order.is_open or allow_extra_editing %}
allow_edit: {% js_bool roles.return_order.change %},
allow_delete: {% js_bool roles.return_order.delete %},
{% endif %}
});
});
// Callback function when the 'notes' panel is loaded
onPanelLoad('order-notes', function() {
setupNotesField(
'order-notes',
'{% url "api-return-order-detail" order.pk %}',
{
{% if roles.purchase_order.change %}
editable: true,
{% else %}
editable: false,
{% endif %}
}
);
});
// Callback function when the 'attachments' panel is loaded
onPanelLoad('order-attachments', function() {
loadAttachmentTable('{% url "api-return-order-attachment-list" %}', {
filters: {
order: {{ order.pk }},
},
fields: {
order: {
value: {{ order.pk }},
hidden: true,
},
}
});
});
enableSidebar('returnorder');
{% endblock js_ready %}

View File

@ -0,0 +1,10 @@
{% load i18n %}
{% load static %}
{% load inventree_extras %}
{% trans "Order Details" as text %}
{% include "sidebar_item.html" with label='order-details' text=text icon="fa-info-circle" %}
{% trans "Attachments" as text %}
{% include "sidebar_item.html" with label='order-attachments' text=text icon="fa-paperclip" %}
{% trans "Notes" as text %}
{% include "sidebar_item.html" with label='order-notes' text=text icon="fa-clipboard" %}

View File

@ -0,0 +1,55 @@
{% extends "page_base.html" %}
{% load inventree_extras %}
{% load static %}
{% load i18n %}
{% block page_title %}
{% inventree_title %} | {% trans "Return Orders" %}
{% endblock %}
{% block breadcrumb_list %}
{% endblock %}
{% block heading %}
{% trans "Return Orders" %}
{% endblock %}
{% block actions %}
{% if roles.return_order.add %}
<button class='btn btn-success' type='button' id='return-order-create' title='{% trans "Create new return order" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Return Order" %}
</button>
{% endif %}
{% endblock actions %}
{% block page_info %}
<div class='panel-content'>
<div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'>
{% include "filter_list.html" with id="returnorder" %}
</div>
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar='#table-buttons' id='return-order-table'>
</table>
<div id='return-order-calendar'></div>
</div>
{% endblock page_info %}
{% block js_ready %}
{{ block.super }}
loadReturnOrderTable('#return-order-table', {});
$('#return-order-create').click(function() {
createReturnOrder();
});
{% endblock js_ready %}

View File

@ -10,7 +10,7 @@
{% endblock %} {% endblock %}
{% block breadcrumbs %} {% block breadcrumbs %}
<li class='breadcrumb-item'><a href='{% url "so-index" %}'>{% trans "Sales Orders" %}</a></li> <li class='breadcrumb-item'><a href='{% url "sales-order-index" %}'>{% trans "Sales Orders" %}</a></li>
<li class="breadcrumb-item active" aria-current="page"><a href='{% url "so-detail" order.id %}'>{{ order }}</a></li> <li class="breadcrumb-item active" aria-current="page"><a href='{% url "so-detail" order.id %}'>{{ order }}</a></li>
{% endblock %} {% endblock %}
@ -162,7 +162,10 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Target Date" %}</td> <td>{% trans "Target Date" %}</td>
<td>{% render_date order.target_date %}</td> <td>
{% render_date order.target_date %}
{% if order.is_overdue %}<span class='fas fa-calendar-times icon-red float-right'></span>{% endif %}
</td>
</tr> </tr>
{% endif %} {% endif %}
{% if order.shipment_date %} {% if order.shipment_date %}
@ -177,6 +180,13 @@ src="{% static 'img/blank_image.png' %}"
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
{% if order.contact %}
<tr>
<td><span class='fas fa-user-tie'></span></td>
<td>{% trans "Contact" %}</td>
<td>{{ order.contact.name }}</td>
</tr>
{% endif %}
{% if order.responsible %} {% if order.responsible %}
<tr> <tr>
<td><span class='fas fa-users'></span></td> <td><span class='fas fa-users'></span></td>
@ -187,7 +197,7 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-dollar-sign'></span></td> <td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Total cost" %}</td> <td>{% trans "Total Cost" %}</td>
<td id="soTotalPrice"> <td id="soTotalPrice">
{% with order.total_price as tp %} {% with order.total_price as tp %}
{% if tp == None %} {% if tp == None %}
@ -204,12 +214,13 @@ src="{% static 'img/blank_image.png' %}"
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
{% if roles.sales_order.change %}
$("#edit-order").click(function() { $("#edit-order").click(function() {
editSalesOrder({{ order.pk }}, { editSalesOrder({{ order.pk }}, {
reload: true, reload: true,
}); });
}); });
{% endif %}
$("#complete-order-shipments").click(function() { $("#complete-order-shipments").click(function() {
@ -242,7 +253,11 @@ $("#complete-order").click(function() {
{% if report_enabled %} {% if report_enabled %}
$('#print-order-report').click(function() { $('#print-order-report').click(function() {
printSalesOrderReports([{{ order.pk }}]); printReports({
items: [{{ order.pk }}],
key: 'order',
url: '{% url "api-so-report-list" %}',
});
}); });
{% endif %} {% endif %}

View File

@ -18,7 +18,7 @@
<h4>{% trans "Sales Order Items" %}</h4> <h4>{% trans "Sales Order Items" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if roles.sales_order.change %} {% if roles.sales_order.add %}
{% if order.is_pending or allow_extra_editing %} {% if order.is_pending or allow_extra_editing %}
<button type='button' class='btn btn-success' id='new-so-line'> <button type='button' class='btn btn-success' id='new-so-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %} <span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
@ -209,30 +209,19 @@
); );
}); });
enableDragAndDrop( onPanelLoad('order-attachments', function() {
'#attachment-dropzone',
'{% url "api-so-attachment-list" %}',
{
data: {
order: {{ order.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
reloadAttachmentTable();
}
}
);
loadAttachmentTable('{% url "api-so-attachment-list" %}', { loadAttachmentTable('{% url "api-so-attachment-list" %}', {
filters: { filters: {
order: {{ order.pk }}, order: {{ order.pk }},
},
fields: {
order: {
value: {{ order.pk }},
hidden: true,
}, },
} fields: {
order: {
value: {{ order.pk }},
hidden: true,
},
}
});
}); });
loadBuildTable($("#builds-table"), { loadBuildTable($("#builds-table"), {
@ -242,60 +231,67 @@
}, },
}); });
$("#new-so-line").click(function() { onPanelLoad('order-items', function() {
createSalesOrderLineItem({
order: {{ order.pk }}, $("#new-so-line").click(function() {
onSuccess: function() { createSalesOrderLineItem({
$("#so-lines-table").bootstrapTable("refresh"); order: {{ order.pk }},
onSuccess: function() {
$("#so-lines-table").bootstrapTable('refresh');
}
});
// Create a new SalesOrderLine item
var fields = soLineItemFields({
order: {{ order.pk }},
});
});
loadSalesOrderLineItemTable(
'#so-lines-table',
{
order: {{ order.pk }},
reference: '{{ order.reference }}',
status: {{ order.status }},
{% if roles.sales_order.change %}
allow_edit: true,
{% endif %}
{% if order.is_pending %}
pending: true,
{% endif %}
} }
);
$("#new-so-extra-line").click(function() {
createExtraLineItem({
order: {{ order.pk }},
table: '#so-extra-lines-table',
url: '{% url "api-so-extra-line-list" %}',
{% if order.customer.currency %}
currency: '{{ order.customer.currency }}',
{% endif %}
});
}); });
// Create a new SalesOrderLine item loadExtraLineTable({
var fields = soLineItemFields({
order: {{ order.pk }}, order: {{ order.pk }},
table: '#so-extra-lines-table',
url: '{% url "api-so-extra-line-list" %}',
name: 'salesorderextraline',
filtertarget: '#filter-list-sales-order-extra-lines',
{% settings_value "SALESORDER_EDIT_COMPLETED_ORDERS" as allow_edit %}
{% if order.is_pending or allow_edit %}
allow_edit: {% js_bool roles.sales_order.change %},
allow_delete: {% js_bool roles.sales_order.delete %},
{% else %}
allow_edit: false,
allow_delete: false,
{% endif %}
pricing: true,
}); });
}); });
loadSalesOrderLineItemTable(
'#so-lines-table',
{
order: {{ order.pk }},
reference: '{{ order.reference }}',
status: {{ order.status }},
{% if roles.sales_order.change %}
allow_edit: true,
{% endif %}
{% if order.is_pending %}
pending: true,
{% endif %}
}
);
$("#new-so-extra-line").click(function() {
var fields = extraLineFields({
order: {{ order.pk }},
});
constructForm('{% url "api-so-extra-line-list" %}', {
fields: fields,
method: 'POST',
title: '{% trans "Add Extra Line" %}',
onSuccess: function() {
$("#so-extra-lines-table").bootstrapTable("refresh");
},
});
});
loadSalesOrderExtraLineTable(
'#so-extra-lines-table',
{
order: {{ order.pk }},
status: {{ order.status }},
{% if roles.sales_order.change %}allow_edit: true,{% endif %}
{% if order.is_pending %}pending: true,{% endif %}
}
);
loadOrderTotal( loadOrderTotal(
'#soTotalPrice', '#soTotalPrice',

View File

@ -29,11 +29,6 @@
<div id='table-buttons'> <div id='table-buttons'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'> <div class='btn-group'>
{% if report_enabled %}
<button id='order-print' class='btn btn-outline-secondary' title='{% trans "Print Order Reports" %}'>
<span class='fas fa-print'></span>
</button>
{% endif %}
{% include "filter_list.html" with id="salesorder" %} {% include "filter_list.html" with id="salesorder" %}
</div> </div>
</div> </div>
@ -54,20 +49,6 @@ loadSalesOrderTable("#sales-order-table", {
url: "{% url 'api-so-list' %}", url: "{% url 'api-so-list' %}",
}); });
{% if report_enabled %}
$("#order-print").click(function() {
var rows = getTableData('#sales-order-table');
var orders = [];
rows.forEach(function(row) {
orders.push(row.pk);
});
printSalesOrderReports(orders);
})
{% endif %}
$("#so-create").click(function() { $("#so-create").click(function() {
createSalesOrder(); createSalesOrder();
}); });

View File

@ -17,7 +17,9 @@ import order.models as models
from common.settings import currency_codes from common.settings import currency_codes
from company.models import Company from company.models import Company
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderLineStatus,
ReturnOrderStatus, SalesOrderStatus,
StockStatus)
from part.models import Part from part.models import Part
from stock.models import StockItem from stock.models import StockItem
@ -1802,3 +1804,286 @@ class SalesOrderAllocateTest(OrderTest):
response = self.get(url, expected_code=200) response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 1 + 3 * models.SalesOrder.objects.count()) self.assertEqual(len(response.data), 1 + 3 * models.SalesOrder.objects.count())
class ReturnOrderTests(InvenTreeAPITestCase):
"""Unit tests for ReturnOrder API endpoints"""
fixtures = [
'category',
'company',
'return_order',
'part',
'location',
'supplier_part',
'stock',
]
def test_options(self):
"""Test the OPTIONS endpoint"""
self.assignRole('return_order.add')
data = self.options(reverse('api-return-order-list'), expected_code=200).data
self.assertEqual(data['name'], 'Return Order List')
# Some checks on the 'reference' field
post = data['actions']['POST']
reference = post['reference']
self.assertEqual(reference['default'], 'RMA-0007')
self.assertEqual(reference['label'], 'Reference')
self.assertEqual(reference['help_text'], 'Return Order reference')
self.assertEqual(reference['required'], True)
self.assertEqual(reference['type'], 'string')
def test_list(self):
"""Tests for the list endpoint"""
url = reverse('api-return-order-list')
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 6)
# Paginated query
data = self.get(
url,
{
'limit': 1,
'ordering': 'reference',
'customer_detail': True,
},
expected_code=200
).data
self.assertEqual(data['count'], 6)
self.assertEqual(len(data['results']), 1)
result = data['results'][0]
self.assertEqual(result['reference'], 'RMA-001')
self.assertEqual(result['customer_detail']['name'], 'A customer')
# Reverse ordering
data = self.get(
url,
{
'ordering': '-reference',
},
expected_code=200
).data
self.assertEqual(data[0]['reference'], 'RMA-006')
# Filter by customer
for cmp_id in [4, 5]:
data = self.get(
url,
{
'customer': cmp_id,
},
expected_code=200
).data
self.assertEqual(len(data), 3)
for result in data:
self.assertEqual(result['customer'], cmp_id)
# Filter by status
data = self.get(
url,
{
'status': 20,
},
expected_code=200
).data
self.assertEqual(len(data), 2)
for result in data:
self.assertEqual(result['status'], 20)
def test_create(self):
"""Test creation of ReturnOrder via the API"""
url = reverse('api-return-order-list')
# Do not have required permissions yet
self.post(
url,
{
'customer': 1,
'description': 'a return order',
},
expected_code=403
)
self.assignRole('return_order.add')
data = self.post(
url,
{
'customer': 4,
'customer_reference': 'cr',
'description': 'a return order',
},
expected_code=201
).data
# Reference automatically generated
self.assertEqual(data['reference'], 'RMA-0007')
self.assertEqual(data['customer_reference'], 'cr')
def test_update(self):
"""Test that we can update a ReturnOrder via the API"""
url = reverse('api-return-order-detail', kwargs={'pk': 1})
# Test detail endpoint
data = self.get(url, expected_code=200).data
self.assertEqual(data['reference'], 'RMA-001')
# Attempt to update, incorrect permissions
self.patch(
url,
{
'customer_reference': 'My customer reference',
},
expected_code=403
)
self.assignRole('return_order.change')
self.patch(
url,
{
'customer_reference': 'customer ref',
},
expected_code=200
)
rma = models.ReturnOrder.objects.get(pk=1)
self.assertEqual(rma.customer_reference, 'customer ref')
def test_ro_issue(self):
"""Test the 'issue' order for a ReturnOrder"""
order = models.ReturnOrder.objects.get(pk=1)
self.assertEqual(order.status, ReturnOrderStatus.PENDING)
self.assertIsNone(order.issue_date)
url = reverse('api-return-order-issue', kwargs={'pk': 1})
# POST without required permissions
self.post(url, expected_code=403)
self.assignRole('return_order.add')
self.post(url, expected_code=201)
order.refresh_from_db()
self.assertEqual(order.status, ReturnOrderStatus.IN_PROGRESS)
self.assertIsNotNone(order.issue_date)
def test_receive(self):
"""Test that we can receive items against a ReturnOrder"""
customer = Company.objects.get(pk=4)
# Create an order
rma = models.ReturnOrder.objects.create(
customer=customer,
description='A return order',
)
self.assertEqual(rma.reference, 'RMA-0007')
# Create some line items
part = Part.objects.get(pk=25)
for idx in range(3):
stock_item = StockItem.objects.create(
part=part, customer=customer,
quantity=1, serial=idx
)
line_item = models.ReturnOrderLineItem.objects.create(
order=rma,
item=stock_item,
)
self.assertEqual(line_item.outcome, ReturnOrderLineStatus.PENDING)
self.assertIsNone(line_item.received_date)
self.assertFalse(line_item.received)
self.assertEqual(rma.lines.count(), 3)
def receive(items, location=None, expected_code=400):
"""Helper function to receive items against this ReturnOrder"""
url = reverse('api-return-order-receive', kwargs={'pk': rma.pk})
response = self.post(
url,
{
'items': items,
'location': location,
},
expected_code=expected_code
)
return response.data
# Receive without required permissions
receive([], expected_code=403)
self.assignRole('return_order.add')
# Receive, without any location
data = receive([], expected_code=400)
self.assertIn('This field may not be null', str(data['location']))
# Receive, with incorrect order code
data = receive([], 1, expected_code=400)
self.assertIn('Items can only be received against orders which are in progress', str(data))
# Issue the order (via the API)
self.assertIsNone(rma.issue_date)
self.post(
reverse("api-return-order-issue", kwargs={"pk": rma.pk}),
expected_code=201,
)
rma.refresh_from_db()
self.assertIsNotNone(rma.issue_date)
self.assertEqual(rma.status, ReturnOrderStatus.IN_PROGRESS)
# Receive, without any items
data = receive([], 1, expected_code=400)
self.assertIn('Line items must be provided', str(data))
# Get a reference to one of the stock items
stock_item = rma.lines.first().item
n_tracking = stock_item.tracking_info.count()
# Receive items successfully
data = receive(
[{'item': line.pk} for line in rma.lines.all()],
1,
expected_code=201
)
# Check that all line items have been received
for line in rma.lines.all():
self.assertTrue(line.received)
self.assertIsNotNone(line.received_date)
# A single tracking entry should have been added to the item
self.assertEqual(stock_item.tracking_info.count(), n_tracking + 1)
tracking_entry = stock_item.tracking_info.last()
deltas = tracking_entry.deltas
self.assertEqual(deltas['status'], StockStatus.QUARANTINED)
self.assertEqual(deltas['customer'], customer.pk)
self.assertEqual(deltas['location'], 1)
self.assertEqual(deltas['returnorder'], rma.pk)

View File

@ -16,6 +16,8 @@ class OrderViewTestCase(InvenTreeTestCase):
'supplier_part', 'supplier_part',
'stock', 'stock',
'order', 'order',
'sales_order',
'return_order',
] ]
roles = [ roles = [
@ -25,14 +27,17 @@ class OrderViewTestCase(InvenTreeTestCase):
'sales_order.change', 'sales_order.change',
'sales_order.add', 'sales_order.add',
'sales_order.delete', 'sales_order.delete',
'return_order.change',
'return_order.add',
'return_order.delete',
] ]
class OrderListTest(OrderViewTestCase): class PurchaseOrderListTest(OrderViewTestCase):
"""Unit tests for the PurchaseOrder index page""" """Unit tests for the PurchaseOrder index page"""
def test_order_list(self): def test_order_list(self):
"""Tests for the PurchaseOrder index page""" """Tests for the PurchaseOrder index page"""
response = self.client.get(reverse('po-index')) response = self.client.get(reverse('purchase-order-index'))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -53,3 +58,31 @@ class PurchaseOrderTests(OrderViewTestCase):
# Response should be streaming-content (file download) # Response should be streaming-content (file download)
self.assertIn('streaming_content', dir(response)) self.assertIn('streaming_content', dir(response))
class SalesOrderViews(OrderViewTestCase):
"""Unit tests for the SalesOrder pages"""
def test_index(self):
"""Test the SalesOrder index page"""
response = self.client.get(reverse('sales-order-index'))
self.assertEqual(response.status_code, 200)
def test_detail(self):
"""Test SalesOrder detail view"""
response = self.client.get(reverse('so-detail', args=(1,)))
self.assertEqual(response.status_code, 200)
class ReturnOrderVIews(OrderViewTestCase):
"""Unit tests for the ReturnOrder pages"""
def test_index(self):
"""Test the ReturnOrder index page"""
response = self.client.get(reverse('return-order-index'))
self.assertEqual(response.status_code, 200)
def test_detail(self):
"""Test ReturnOrder detail view"""
response = self.client.get(reverse('return-order-detail', args=(1,)))
self.assertEqual(response.status_code, 200)

View File

@ -4,7 +4,7 @@
- Detail view of Purchase Orders - Detail view of Purchase Orders
""" """
from django.urls import include, re_path from django.urls import include, path, re_path
from . import views from . import views
@ -21,10 +21,10 @@ purchase_order_urls = [
re_path(r'^pricing/', views.LineItemPricing.as_view(), name='line-pricing'), re_path(r'^pricing/', views.LineItemPricing.as_view(), name='line-pricing'),
# Display detail view for a single purchase order # Display detail view for a single purchase order
re_path(r'^(?P<pk>\d+)/', include(purchase_order_detail_urls)), path(r'<int:pk>/', include(purchase_order_detail_urls)),
# Display complete list of purchase orders # Display complete list of purchase orders
re_path(r'^.*$', views.PurchaseOrderIndex.as_view(), name='po-index'), re_path(r'^.*$', views.PurchaseOrderIndex.as_view(), name='purchase-order-index'),
] ]
sales_order_detail_urls = [ sales_order_detail_urls = [
@ -35,13 +35,23 @@ sales_order_detail_urls = [
sales_order_urls = [ sales_order_urls = [
# Display detail view for a single SalesOrder # Display detail view for a single SalesOrder
re_path(r'^(?P<pk>\d+)/', include(sales_order_detail_urls)), path(r'<int:pk>/', include(sales_order_detail_urls)),
# Display list of all sales orders # Display list of all sales orders
re_path(r'^.*$', views.SalesOrderIndex.as_view(), name='so-index'), re_path(r'^.*$', views.SalesOrderIndex.as_view(), name='sales-order-index'),
] ]
return_order_urls = [
path(r'<int:pk>/', views.ReturnOrderDetail.as_view(), name='return-order-detail'),
# Display list of all return orders
re_path(r'^.*$', views.ReturnOrderIndex.as_view(), name='return-order-index'),
]
order_urls = [ order_urls = [
re_path(r'^purchase-order/', include(purchase_order_urls)), re_path(r'^purchase-order/', include(purchase_order_urls)),
re_path(r'^sales-order/', include(sales_order_urls)), re_path(r'^sales-order/', include(sales_order_urls)),
re_path(r'^return-order/', include(return_order_urls)),
] ]

View File

@ -17,6 +17,14 @@ def generate_next_purchase_order_reference():
return PurchaseOrder.generate_reference() return PurchaseOrder.generate_reference()
def generate_next_return_order_reference():
"""Generate the next available ReturnOrder reference"""
from order.models import ReturnOrder
return ReturnOrder.generate_reference()
def validate_sales_order_reference_pattern(pattern): def validate_sales_order_reference_pattern(pattern):
"""Validate the SalesOrder reference 'pattern' setting""" """Validate the SalesOrder reference 'pattern' setting"""
@ -33,6 +41,14 @@ def validate_purchase_order_reference_pattern(pattern):
PurchaseOrder.validate_reference_pattern(pattern) PurchaseOrder.validate_reference_pattern(pattern)
def validate_return_order_reference_pattern(pattern):
"""Validate the ReturnOrder reference 'pattern' setting"""
from order.models import ReturnOrder
ReturnOrder.validate_reference_pattern(pattern)
def validate_sales_order_reference(value): def validate_sales_order_reference(value):
"""Validate that the SalesOrder reference field matches the required pattern""" """Validate that the SalesOrder reference field matches the required pattern"""
@ -47,3 +63,11 @@ def validate_purchase_order_reference(value):
from order.models import PurchaseOrder from order.models import PurchaseOrder
PurchaseOrder.validate_reference_field(value) PurchaseOrder.validate_reference_field(value)
def validate_return_order_reference(value):
"""Validate that the ReturnOrder reference field matches the required pattern"""
from order.models import ReturnOrder
ReturnOrder.validate_reference_field(value)

View File

@ -24,8 +24,8 @@ from plugin.views import InvenTreePluginViewMixin
from . import forms as order_forms from . import forms as order_forms
from .admin import PurchaseOrderLineItemResource, SalesOrderLineItemResource from .admin import PurchaseOrderLineItemResource, SalesOrderLineItemResource
from .models import (PurchaseOrder, PurchaseOrderLineItem, SalesOrder, from .models import (PurchaseOrder, PurchaseOrderLineItem, ReturnOrder,
SalesOrderLineItem) SalesOrder, SalesOrderLineItem)
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
@ -51,6 +51,14 @@ class SalesOrderIndex(InvenTreeRoleMixin, ListView):
context_object_name = 'orders' context_object_name = 'orders'
class ReturnOrderIndex(InvenTreeRoleMixin, ListView):
"""ReturnOrder index (list) view"""
model = ReturnOrder
template_name = 'order/return_orders.html'
context_object_name = 'orders'
class PurchaseOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView): class PurchaseOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
"""Detail view for a PurchaseOrder object.""" """Detail view for a PurchaseOrder object."""
@ -67,6 +75,14 @@ class SalesOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView)
template_name = 'order/sales_order_detail.html' template_name = 'order/sales_order_detail.html'
class ReturnOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
"""Detail view for a ReturnOrder object"""
context_object_name = 'order'
queryset = ReturnOrder.objects.all()
template_name = 'order/return_order_detail.html'
class PurchaseOrderUpload(FileManagementFormView): class PurchaseOrderUpload(FileManagementFormView):
"""PurchaseOrder: Upload file, match to fields and parts (using multi-Step form)""" """PurchaseOrder: Upload file, match to fields and parts (using multi-Step form)"""

View File

@ -39,34 +39,20 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
PartStocktake, PartStocktakeReport, PartTestTemplate) PartStocktake, PartStocktakeReport, PartTestTemplate)
class CategoryList(APIDownloadMixin, ListCreateAPI): class CategoryMixin:
"""API endpoint for accessing a list of PartCategory objects. """Mixin class for PartCategory endpoints"""
- GET: Return a list of PartCategory objects
- POST: Create a new PartCategory object
"""
queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategorySerializer serializer_class = part_serializers.CategorySerializer
queryset = PartCategory.objects.all()
def download_queryset(self, queryset, export_format):
"""Download the filtered queryset as a data file"""
dataset = PartCategoryResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_Categories.{export_format}"
return DownloadFile(filedata, filename)
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset for the CategoryList endpoint""" """Return an annotated queryset for the CategoryDetail endpoint"""
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.CategorySerializer.annotate_queryset(queryset) queryset = part_serializers.CategorySerializer.annotate_queryset(queryset)
return queryset return queryset
def get_serializer_context(self): def get_serializer_context(self):
"""Add extra context data to the serializer for the PartCategoryList endpoint""" """Add extra context to the serializer for the CategoryDetail endpoint"""
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
try: try:
@ -77,6 +63,23 @@ class CategoryList(APIDownloadMixin, ListCreateAPI):
return ctx return ctx
class CategoryList(CategoryMixin, APIDownloadMixin, ListCreateAPI):
"""API endpoint for accessing a list of PartCategory objects.
- GET: Return a list of PartCategory objects
- POST: Create a new PartCategory object
"""
def download_queryset(self, queryset, export_format):
"""Download the filtered queryset as a data file"""
dataset = PartCategoryResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_Categories.{export_format}"
return DownloadFile(filedata, filename)
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
"""Custom filtering: """Custom filtering:
@ -184,31 +187,9 @@ class CategoryList(APIDownloadMixin, ListCreateAPI):
] ]
class CategoryDetail(CustomRetrieveUpdateDestroyAPI): class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single PartCategory object.""" """API endpoint for detail view of a single PartCategory object."""
serializer_class = part_serializers.CategorySerializer
queryset = PartCategory.objects.all()
def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset for the CategoryDetail endpoint"""
queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.CategorySerializer.annotate_queryset(queryset)
return queryset
def get_serializer_context(self):
"""Add extra context to the serializer for the CategoryDetail endpoint"""
ctx = super().get_serializer_context()
try:
ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()]
except AttributeError:
# Error is thrown if the view does not have an associated request
ctx['starred_categories'] = []
return ctx
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
"""Perform 'update' function and mark this part as 'starred' (or not)""" """Perform 'update' function and mark this part as 'starred' (or not)"""
# Clean up input data # Clean up input data
@ -234,6 +215,21 @@ class CategoryDetail(CustomRetrieveUpdateDestroyAPI):
delete_child_categories=delete_child_categories)) delete_child_categories=delete_child_categories))
class CategoryTree(ListAPI):
"""API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategoryTree
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
]
# Order by tree level (top levels first) and then name
ordering = ['level', 'name']
class CategoryMetadata(RetrieveUpdateAPI): class CategoryMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating PartCategory metadata.""" """API endpoint for viewing / updating PartCategory metadata."""
@ -292,21 +288,6 @@ class CategoryParameterDetail(RetrieveUpdateDestroyAPI):
serializer_class = part_serializers.CategoryParameterTemplateSerializer serializer_class = part_serializers.CategoryParameterTemplateSerializer
class CategoryTree(ListAPI):
"""API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategoryTree
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
]
# Order by tree level (top levels first) and then name
ordering = ['level', 'name']
class PartSalePriceDetail(RetrieveUpdateDestroyAPI): class PartSalePriceDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for PartSellPriceBreak model.""" """Detail endpoint for PartSellPriceBreak model."""
@ -845,76 +826,6 @@ class PartValidateBOM(RetrieveUpdateAPI):
}) })
class PartDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single Part object."""
queryset = Part.objects.all()
serializer_class = part_serializers.PartSerializer
starred_parts = None
def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset object for the PartDetail endpoint"""
queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
return queryset
def get_serializer(self, *args, **kwargs):
"""Return a serializer instance for the PartDetail endpoint"""
# By default, include 'category_detail' information in the detail view
try:
kwargs['category_detail'] = str2bool(self.request.query_params.get('category_detail', True))
except AttributeError:
pass
# Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context()
# Pass a list of "starred" parts of the current user to the serializer
# We do this to reduce the number of database queries required!
if self.starred_parts is None and self.request is not None:
self.starred_parts = [star.part for star in self.request.user.starred_parts.all()]
kwargs['starred_parts'] = self.starred_parts
return self.serializer_class(*args, **kwargs)
def destroy(self, request, *args, **kwargs):
"""Delete a Part instance via the API
- If the part is 'active' it cannot be deleted
- It must first be marked as 'inactive'
"""
part = Part.objects.get(pk=int(kwargs['pk']))
# Check if inactive
if not part.active:
# Delete
return super(PartDetail, self).destroy(request, *args, **kwargs)
else:
# Return 405 error
message = 'Part is active: cannot delete'
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED, data=message)
def update(self, request, *args, **kwargs):
"""Custom update functionality for Part instance.
- If the 'starred' field is provided, update the 'starred' status against current user
"""
# Clean input data
data = self.clean_data(request.data)
if 'starred' in data:
starred = str2bool(data.get('starred', False))
self.get_object().set_starred(request.user, starred)
response = super().update(request, *args, **kwargs)
return response
class PartFilter(rest_filters.FilterSet): class PartFilter(rest_filters.FilterSet):
"""Custom filters for the PartList endpoint. """Custom filters for the PartList endpoint.
@ -1090,22 +1001,30 @@ class PartFilter(rest_filters.FilterSet):
virtual = rest_filters.BooleanFilter() virtual = rest_filters.BooleanFilter()
class PartList(APIDownloadMixin, ListCreateAPI): class PartMixin:
"""API endpoint for accessing a list of Part objects, or creating a new Part instance""" """Mixin class for Part API endpoints"""
serializer_class = part_serializers.PartSerializer serializer_class = part_serializers.PartSerializer
queryset = Part.objects.all() queryset = Part.objects.all()
filterset_class = PartFilter
starred_parts = None starred_parts = None
is_create = False
def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset object for the PartDetail endpoint"""
queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
return queryset
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
"""Return a serializer instance for this endpoint""" """Return a serializer instance for this endpoint"""
# Ensure the request context is passed through # Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context() kwargs['context'] = self.get_serializer_context()
# Indicate that we can create a new Part via this endpoint # Indicate that we can create a new Part via this endpoint
kwargs['create'] = True kwargs['create'] = self.is_create
# Pass a list of "starred" parts to the current user to the serializer # Pass a list of "starred" parts to the current user to the serializer
# We do this to reduce the number of database queries required! # We do this to reduce the number of database queries required!
@ -1132,6 +1051,13 @@ class PartList(APIDownloadMixin, ListCreateAPI):
return context return context
class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
"""API endpoint for accessing a list of Part objects, or creating a new Part instance"""
filterset_class = PartFilter
is_create = True
def download_queryset(self, queryset, export_format): def download_queryset(self, queryset, export_format):
"""Download the filtered queryset as a data file""" """Download the filtered queryset as a data file"""
dataset = PartResource().export(queryset=queryset) dataset = PartResource().export(queryset=queryset)
@ -1169,13 +1095,6 @@ class PartList(APIDownloadMixin, ListCreateAPI):
else: else:
return Response(data) return Response(data)
def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset object"""
queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
return queryset
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
"""Perform custom filtering of the queryset""" """Perform custom filtering of the queryset"""
params = self.request.query_params params = self.request.query_params
@ -1358,6 +1277,43 @@ class PartList(APIDownloadMixin, ListCreateAPI):
] ]
class PartDetail(PartMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single Part object."""
def destroy(self, request, *args, **kwargs):
"""Delete a Part instance via the API
- If the part is 'active' it cannot be deleted
- It must first be marked as 'inactive'
"""
part = Part.objects.get(pk=int(kwargs['pk']))
# Check if inactive
if not part.active:
# Delete
return super(PartDetail, self).destroy(request, *args, **kwargs)
else:
# Return 405 error
message = 'Part is active: cannot delete'
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED, data=message)
def update(self, request, *args, **kwargs):
"""Custom update functionality for Part instance.
- If the 'starred' field is provided, update the 'starred' status against current user
"""
# Clean input data
data = self.clean_data(request.data)
if 'starred' in data:
starred = str2bool(data.get('starred', False))
self.get_object().set_starred(request.user, starred)
response = super().update(request, *args, **kwargs)
return response
class PartRelatedList(ListCreateAPI): class PartRelatedList(ListCreateAPI):
"""API endpoint for accessing a list of PartRelated objects.""" """API endpoint for accessing a list of PartRelated objects."""
@ -1674,42 +1630,11 @@ class BomFilter(rest_filters.FilterSet):
return queryset return queryset
class BomList(ListCreateDestroyAPIView): class BomMixin:
"""API endpoint for accessing a list of BomItem objects. """Mixin class for BomItem API endpoints"""
- GET: Return list of BomItem objects
- POST: Create a new BomItem object
"""
serializer_class = part_serializers.BomItemSerializer serializer_class = part_serializers.BomItemSerializer
queryset = BomItem.objects.all() queryset = BomItem.objects.all()
filterset_class = BomFilter
def list(self, request, *args, **kwargs):
"""Return serialized list response for this endpoint"""
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
else:
serializer = self.get_serializer(queryset, many=True)
data = serializer.data
"""
Determine the response type based on the request.
a) For HTTP requests (e.g. via the browseable API) return a DRF response
b) For AJAX requests, simply return a JSON rendered response.
"""
if page is not None:
return self.get_paginated_response(data)
elif request.is_ajax():
return JsonResponse(data, safe=False)
else:
return Response(data)
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
"""Return the serializer instance for this API endpoint """Return the serializer instance for this API endpoint
@ -1744,6 +1669,42 @@ class BomList(ListCreateDestroyAPIView):
return queryset return queryset
class BomList(BomMixin, ListCreateDestroyAPIView):
"""API endpoint for accessing a list of BomItem objects.
- GET: Return list of BomItem objects
- POST: Create a new BomItem object
"""
filterset_class = BomFilter
def list(self, request, *args, **kwargs):
"""Return serialized list response for this endpoint"""
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
else:
serializer = self.get_serializer(queryset, many=True)
data = serializer.data
"""
Determine the response type based on the request.
a) For HTTP requests (e.g. via the browseable API) return a DRF response
b) For AJAX requests, simply return a JSON rendered response.
"""
if page is not None:
return self.get_paginated_response(data)
elif request.is_ajax():
return JsonResponse(data, safe=False)
else:
return Response(data)
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
"""Custom query filtering for the BomItem list API""" """Custom query filtering for the BomItem list API"""
queryset = super().filter_queryset(queryset) queryset = super().filter_queryset(queryset)
@ -1828,6 +1789,11 @@ class BomList(ListCreateDestroyAPIView):
} }
class BomDetail(BomMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single BomItem object."""
pass
class BomImportUpload(CreateAPI): class BomImportUpload(CreateAPI):
"""API endpoint for uploading a complete Bill of Materials. """API endpoint for uploading a complete Bill of Materials.
@ -1866,22 +1832,6 @@ class BomImportSubmit(CreateAPI):
serializer_class = part_serializers.BomImportSubmitSerializer serializer_class = part_serializers.BomImportSubmitSerializer
class BomDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single BomItem object."""
queryset = BomItem.objects.all()
serializer_class = part_serializers.BomItemSerializer
def get_queryset(self, *args, **kwargs):
"""Prefetch related fields for this queryset"""
queryset = super().get_queryset(*args, **kwargs)
queryset = self.get_serializer_class().setup_eager_loading(queryset)
queryset = self.get_serializer_class().annotate_queryset(queryset)
return queryset
class BomItemValidate(UpdateAPI): class BomItemValidate(UpdateAPI):
"""API endpoint for validating a BomItem.""" """API endpoint for validating a BomItem."""
@ -1958,7 +1908,7 @@ part_api_urls = [
])), ])),
# Category detail endpoints # Category detail endpoints
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'^metadata/', CategoryMetadata.as_view(), name='api-part-category-metadata'), re_path(r'^metadata/', CategoryMetadata.as_view(), name='api-part-category-metadata'),
@ -1971,31 +1921,31 @@ part_api_urls = [
# Base URL for PartTestTemplate API endpoints # Base URL for PartTestTemplate API endpoints
re_path(r'^test-template/', include([ re_path(r'^test-template/', include([
re_path(r'^(?P<pk>\d+)/', PartTestTemplateDetail.as_view(), name='api-part-test-template-detail'), path(r'<int:pk>/', PartTestTemplateDetail.as_view(), name='api-part-test-template-detail'),
path('', PartTestTemplateList.as_view(), name='api-part-test-template-list'), path('', PartTestTemplateList.as_view(), name='api-part-test-template-list'),
])), ])),
# Base URL for PartAttachment API endpoints # Base URL for PartAttachment API endpoints
re_path(r'^attachment/', include([ re_path(r'^attachment/', include([
re_path(r'^(?P<pk>\d+)/', PartAttachmentDetail.as_view(), name='api-part-attachment-detail'), path(r'<int:pk>/', PartAttachmentDetail.as_view(), name='api-part-attachment-detail'),
path('', PartAttachmentList.as_view(), name='api-part-attachment-list'), path('', PartAttachmentList.as_view(), name='api-part-attachment-list'),
])), ])),
# Base URL for part sale pricing # Base URL for part sale pricing
re_path(r'^sale-price/', include([ re_path(r'^sale-price/', include([
re_path(r'^(?P<pk>\d+)/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'), path(r'<int:pk>/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'),
re_path(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'), re_path(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
])), ])),
# Base URL for part internal pricing # Base URL for part internal pricing
re_path(r'^internal-price/', include([ re_path(r'^internal-price/', include([
re_path(r'^(?P<pk>\d+)/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'), path(r'<int:pk>/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'),
re_path(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'), re_path(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
])), ])),
# Base URL for PartRelated API endpoints # Base URL for PartRelated API endpoints
re_path(r'^related/', include([ re_path(r'^related/', include([
re_path(r'^(?P<pk>\d+)/', PartRelatedDetail.as_view(), name='api-part-related-detail'), path(r'<int:pk>/', PartRelatedDetail.as_view(), name='api-part-related-detail'),
re_path(r'^.*$', PartRelatedList.as_view(), name='api-part-related-list'), re_path(r'^.*$', PartRelatedList.as_view(), name='api-part-related-list'),
])), ])),
@ -2009,7 +1959,7 @@ part_api_urls = [
re_path(r'^.*$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'), re_path(r'^.*$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'),
])), ])),
re_path(r'^(?P<pk>\d+)/', PartParameterDetail.as_view(), name='api-part-parameter-detail'), path(r'<int:pk>/', PartParameterDetail.as_view(), name='api-part-parameter-detail'),
re_path(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'), re_path(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'),
])), ])),
@ -2021,7 +1971,7 @@ part_api_urls = [
re_path(r'^.*$', PartStocktakeReportList.as_view(), name='api-part-stocktake-report-list'), re_path(r'^.*$', PartStocktakeReportList.as_view(), name='api-part-stocktake-report-list'),
])), ])),
re_path(r'^(?P<pk>\d+)/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'), path(r'<int:pk>/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'),
re_path(r'^.*$', PartStocktakeList.as_view(), name='api-part-stocktake-list'), re_path(r'^.*$', PartStocktakeList.as_view(), name='api-part-stocktake-list'),
])), ])),
@ -2033,7 +1983,7 @@ part_api_urls = [
# BOM template # BOM template
re_path(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='api-bom-upload-template'), re_path(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='api-bom-upload-template'),
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
# Endpoint for extra serial number information # Endpoint for extra serial number information
re_path(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'), re_path(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'),
@ -2073,14 +2023,14 @@ bom_api_urls = [
re_path(r'^substitute/', include([ re_path(r'^substitute/', include([
# Detail view # Detail view
re_path(r'^(?P<pk>\d+)/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'), path(r'<int:pk>/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'),
# Catch all # Catch all
re_path(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'), re_path(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'),
])), ])),
# BOM Item Detail # BOM Item Detail
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'), re_path(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'),
re_path(r'^metadata/?', BomItemMetadata.as_view(), name='api-bom-item-metadata'), re_path(r'^metadata/?', BomItemMetadata.as_view(), name='api-bom-item-metadata'),
re_path(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'), re_path(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),

View File

@ -37,7 +37,6 @@ import common.settings
import InvenTree.fields import InvenTree.fields
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
import part.filters as part_filters
import part.settings as part_settings import part.settings as part_settings
from build import models as BuildModels from build import models as BuildModels
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
@ -1223,6 +1222,9 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
@property @property
def can_build(self): def can_build(self):
"""Return the number of units that can be build with available stock.""" """Return the number of units that can be build with available stock."""
import part.filters
# If this part does NOT have a BOM, result is simply the currently available stock # If this part does NOT have a BOM, result is simply the currently available stock
if not self.has_bom: if not self.has_bom:
return 0 return 0
@ -1246,9 +1248,9 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
# Annotate the 'available stock' for each part in the BOM # Annotate the 'available stock' for each part in the BOM
ref = 'sub_part__' ref = 'sub_part__'
queryset = queryset.alias( queryset = queryset.alias(
total_stock=part_filters.annotate_total_stock(reference=ref), total_stock=part.filters.annotate_total_stock(reference=ref),
so_allocations=part_filters.annotate_sales_order_allocations(reference=ref), so_allocations=part.filters.annotate_sales_order_allocations(reference=ref),
bo_allocations=part_filters.annotate_build_order_allocations(reference=ref), bo_allocations=part.filters.annotate_build_order_allocations(reference=ref),
) )
# Calculate the 'available stock' based on previous annotations # Calculate the 'available stock' based on previous annotations
@ -1262,9 +1264,9 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
# Extract similar information for any 'substitute' parts # Extract similar information for any 'substitute' parts
ref = 'substitutes__part__' ref = 'substitutes__part__'
queryset = queryset.alias( queryset = queryset.alias(
sub_total_stock=part_filters.annotate_total_stock(reference=ref), sub_total_stock=part.filters.annotate_total_stock(reference=ref),
sub_so_allocations=part_filters.annotate_sales_order_allocations(reference=ref), sub_so_allocations=part.filters.annotate_sales_order_allocations(reference=ref),
sub_bo_allocations=part_filters.annotate_build_order_allocations(reference=ref), sub_bo_allocations=part.filters.annotate_build_order_allocations(reference=ref),
) )
queryset = queryset.annotate( queryset = queryset.annotate(
@ -1275,12 +1277,12 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
) )
# Extract similar information for any 'variant' parts # Extract similar information for any 'variant' parts
variant_stock_query = part_filters.variant_stock_query(reference='sub_part__') variant_stock_query = part.filters.variant_stock_query(reference='sub_part__')
queryset = queryset.alias( queryset = queryset.alias(
var_total_stock=part_filters.annotate_variant_quantity(variant_stock_query, reference='quantity'), var_total_stock=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'),
var_bo_allocations=part_filters.annotate_variant_quantity(variant_stock_query, reference='allocations__quantity'), var_bo_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='allocations__quantity'),
var_so_allocations=part_filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'), var_so_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'),
) )
queryset = queryset.annotate( queryset = queryset.annotate(
@ -2083,6 +2085,16 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
return tests return tests
def getTestTemplateMap(self, **kwargs):
"""Return a map of all test templates associated with this Part"""
templates = {}
for template in self.getTestTemplates(**kwargs):
templates[template.key] = template
return templates
def getRequiredTests(self): def getRequiredTests(self):
"""Return the tests which are required by this part""" """Return the tests which are required by this part"""
return self.getTestTemplates(required=True) return self.getTestTemplates(required=True)

View File

@ -183,11 +183,6 @@
<li><a class='dropdown-item' href='#' id='multi-part-order' title='{% trans "Order parts" %}'> <li><a class='dropdown-item' href='#' id='multi-part-order' title='{% trans "Order parts" %}'>
<span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %} <span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}
</a></li> </a></li>
{% if report_enabled %}
<li><a class='dropdown-item' href='#' id='multi-part-print-label' title='{% trans "Print Labels" %}'>
<span class='fas fa-tag'></span> {% trans "Print Labels" %}
</a></li>
{% endif %}
</ul> </ul>
</div> </div>
{% include "filter_list.html" with id="parts" %} {% include "filter_list.html" with id="parts" %}

View File

@ -548,7 +548,7 @@
deleteManufacturerParts(selections, { deleteManufacturerParts(selections, {
success: function() { success: function() {
$("#manufacturer-part-table").bootstrapTable("refresh"); $("#manufacturer-part-table").bootstrapTable('refresh');
} }
}); });
}); });
@ -558,7 +558,7 @@
createManufacturerPart({ createManufacturerPart({
part: {{ part.pk }}, part: {{ part.pk }},
onSuccess: function() { onSuccess: function() {
$("#manufacturer-part-table").bootstrapTable("refresh"); $("#manufacturer-part-table").bootstrapTable('refresh');
} }
}); });
}); });
@ -677,7 +677,11 @@
{% if report_enabled %} {% if report_enabled %}
$("#print-bom-report").click(function() { $("#print-bom-report").click(function() {
printBomReports([{{ part.pk }}]); printReports({
items: [{{ part.pk }}],
key: 'part',
url: '{% url "api-bom-report-list" %}'
});
}); });
{% endif %} {% endif %}
}); });
@ -709,9 +713,7 @@
}, },
focus: 'part_2', focus: 'part_2',
title: '{% trans "Add Related Part" %}', title: '{% trans "Add Related Part" %}',
onSuccess: function() { refreshTable: '#related-parts-table',
$('#related-parts-table').bootstrapTable('refresh');
}
}); });
}); });
@ -797,9 +799,7 @@
part: {{ part.pk }} part: {{ part.pk }}
}), }),
title: '{% trans "Add Test Result Template" %}', title: '{% trans "Add Test Result Template" %}',
onSuccess: function() { refreshTable: '#test-template-table',
$("#test-template-table").bootstrapTable("refresh");
}
}); });
}); });
}); });
@ -870,9 +870,7 @@
data: {}, data: {},
}, },
title: '{% trans "Add Parameter" %}', title: '{% trans "Add Parameter" %}',
onSuccess: function() { refreshTable: '#parameter-table',
$('#parameter-table').bootstrapTable('refresh');
}
}); });
}); });
{% endif %} {% endif %}
@ -906,20 +904,6 @@
} }
} }
}); });
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-part-attachment-list" %}',
{
data: {
part: {{ part.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
reloadAttachmentTable();
}
}
);
}); });
onPanelLoad('pricing', function() { onPanelLoad('pricing', function() {

View File

@ -475,7 +475,11 @@
{% if labels_enabled %} {% if labels_enabled %}
$('#print-label').click(function() { $('#print-label').click(function() {
printPartLabels([{{ part.pk }}]); printLabels({
items: [{{ part.pk }}],
key: 'part',
url: '{% url "api-part-label-list" %}',
});
}); });
{% endif %} {% endif %}

View File

@ -4,7 +4,8 @@ from django import template
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
SalesOrderStatus, StockStatus) ReturnOrderStatus, SalesOrderStatus,
StockStatus)
register = template.Library() register = template.Library()
@ -21,6 +22,12 @@ def sales_order_status_label(key, *args, **kwargs):
return mark_safe(SalesOrderStatus.render(key, large=kwargs.get('large', False))) return mark_safe(SalesOrderStatus.render(key, large=kwargs.get('large', False)))
@register.simple_tag
def return_order_status_label(key, *args, **kwargs):
"""Render a ReturnOrder status label"""
return mark_safe(ReturnOrderStatus.render(key, large=kwargs.get('large', False)))
@register.simple_tag @register.simple_tag
def stock_status_label(key, *args, **kwargs): def stock_status_label(key, *args, **kwargs):
"""Render a StockItem status label.""" """Render a StockItem status label."""

View File

@ -6,7 +6,7 @@
- Display / Create / Edit / Delete SupplierPart - Display / Create / Edit / Delete SupplierPart
""" """
from django.urls import include, re_path from django.urls import include, path, re_path
from . import views from . import views
@ -35,7 +35,7 @@ part_urls = [
re_path(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'), re_path(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'),
# Individual part using pk # Individual part using pk
re_path(r'^(?P<pk>\d+)/', include(part_detail_urls)), path(r'<int:pk>/', include(part_detail_urls)),
# Part category # Part category
re_path(r'^category/', include(category_urls)), re_path(r'^category/', include(category_urls)),

View File

@ -1,6 +1,6 @@
"""API for the plugin app.""" """API for the plugin app."""
from django.urls import include, re_path from django.urls import include, path, re_path
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, permissions, status from rest_framework import filters, permissions, status
@ -255,7 +255,7 @@ plugin_api_urls = [
])), ])),
# Detail views for a single PluginConfig item # Detail views for a single PluginConfig item
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'^settings/(?P<key>\w+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail-pk'), re_path(r'^settings/(?P<key>\w+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail-pk'),
re_path(r'^activate/', PluginActivate.as_view(), name='api-plugin-detail-activate'), re_path(r'^activate/', PluginActivate.as_view(), name='api-plugin-detail-activate'),
re_path(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'), re_path(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'),

View File

@ -3,7 +3,8 @@
from django.contrib import admin from django.contrib import admin
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport, from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
ReportAsset, ReportSnippet, SalesOrderReport, TestReport) ReportAsset, ReportSnippet, ReturnOrderReport,
SalesOrderReport, TestReport)
class ReportTemplateAdmin(admin.ModelAdmin): class ReportTemplateAdmin(admin.ModelAdmin):
@ -28,4 +29,5 @@ admin.site.register(TestReport, ReportTemplateAdmin)
admin.site.register(BuildReport, ReportTemplateAdmin) admin.site.register(BuildReport, ReportTemplateAdmin)
admin.site.register(BillOfMaterialsReport, ReportTemplateAdmin) admin.site.register(BillOfMaterialsReport, ReportTemplateAdmin)
admin.site.register(PurchaseOrderReport, ReportTemplateAdmin) admin.site.register(PurchaseOrderReport, ReportTemplateAdmin)
admin.site.register(ReturnOrderReport, ReportTemplateAdmin)
admin.site.register(SalesOrderReport, ReportTemplateAdmin) admin.site.register(SalesOrderReport, ReportTemplateAdmin)

View File

@ -24,9 +24,10 @@ from plugin.serializers import MetadataSerializer
from stock.models import StockItem, StockItemAttachment from stock.models import StockItem, StockItemAttachment
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport, from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
SalesOrderReport, TestReport) ReturnOrderReport, SalesOrderReport, TestReport)
from .serializers import (BOMReportSerializer, BuildReportSerializer, from .serializers import (BOMReportSerializer, BuildReportSerializer,
PurchaseOrderReportSerializer, PurchaseOrderReportSerializer,
ReturnOrderReportSerializer,
SalesOrderReportSerializer, TestReportSerializer) SalesOrderReportSerializer, TestReportSerializer)
@ -423,6 +424,31 @@ class SalesOrderReportPrint(SalesOrderReportMixin, ReportPrintMixin, RetrieveAPI
pass pass
class ReturnOrderReportMixin(ReportFilterMixin):
"""Mixin for the ReturnOrderReport report template"""
ITEM_MODEL = order.models.ReturnOrder
ITEM_KEY = 'order'
queryset = ReturnOrderReport.objects.all()
serializer_class = ReturnOrderReportSerializer
class ReturnOrderReportList(ReturnOrderReportMixin, ReportListView):
"""API list endpoint for the ReturnOrderReport model"""
pass
class ReturnOrderReportDetail(ReturnOrderReportMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single ReturnOrderReport object"""
pass
class ReturnOrderReportPrint(ReturnOrderReportMixin, ReportPrintMixin, RetrieveAPI):
"""API endpoint for printing a ReturnOrderReport object"""
pass
class ReportMetadata(RetrieveUpdateAPI): class ReportMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating Report metadata.""" """API endpoint for viewing / updating Report metadata."""
MODEL_REF = 'reportmodel' MODEL_REF = 'reportmodel'
@ -453,7 +479,7 @@ report_api_urls = [
# Purchase order reports # Purchase order reports
re_path(r'po/', include([ re_path(r'po/', include([
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'print/', PurchaseOrderReportPrint.as_view(), name='api-po-report-print'), re_path(r'print/', PurchaseOrderReportPrint.as_view(), name='api-po-report-print'),
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: PurchaseOrderReport}, name='api-po-report-metadata'), re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: PurchaseOrderReport}, name='api-po-report-metadata'),
path('', PurchaseOrderReportDetail.as_view(), name='api-po-report-detail'), path('', PurchaseOrderReportDetail.as_view(), name='api-po-report-detail'),
@ -466,7 +492,7 @@ report_api_urls = [
# Sales order reports # Sales order reports
re_path(r'so/', include([ re_path(r'so/', include([
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'print/', SalesOrderReportPrint.as_view(), name='api-so-report-print'), re_path(r'print/', SalesOrderReportPrint.as_view(), name='api-so-report-print'),
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: SalesOrderReport}, name='api-so-report-metadata'), re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: SalesOrderReport}, name='api-so-report-metadata'),
path('', SalesOrderReportDetail.as_view(), name='api-so-report-detail'), path('', SalesOrderReportDetail.as_view(), name='api-so-report-detail'),
@ -475,10 +501,19 @@ report_api_urls = [
path('', SalesOrderReportList.as_view(), name='api-so-report-list'), path('', SalesOrderReportList.as_view(), name='api-so-report-list'),
])), ])),
# Return order reports
re_path(r'return-order/', include([
path(r'<int:pk>/', include([
path(r'print/', ReturnOrderReportPrint.as_view(), name='api-return-order-report-print'),
path('', ReturnOrderReportDetail.as_view(), name='api-return-order-report-detail'),
])),
path('', ReturnOrderReportList.as_view(), name='api-return-order-report-list'),
])),
# Build reports # Build reports
re_path(r'build/', include([ re_path(r'build/', include([
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'print/?', BuildReportPrint.as_view(), name='api-build-report-print'), re_path(r'print/?', BuildReportPrint.as_view(), name='api-build-report-print'),
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: BuildReport}, name='api-build-report-metadata'), re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: BuildReport}, name='api-build-report-metadata'),
re_path(r'^.$', BuildReportDetail.as_view(), name='api-build-report-detail'), re_path(r'^.$', BuildReportDetail.as_view(), name='api-build-report-detail'),
@ -492,7 +527,7 @@ report_api_urls = [
re_path(r'bom/', include([ re_path(r'bom/', include([
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'print/?', BOMReportPrint.as_view(), name='api-bom-report-print'), re_path(r'print/?', BOMReportPrint.as_view(), name='api-bom-report-print'),
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: BillOfMaterialsReport}, name='api-bom-report-metadata'), re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: BillOfMaterialsReport}, name='api-bom-report-metadata'),
re_path(r'^.*$', BOMReportDetail.as_view(), name='api-bom-report-detail'), re_path(r'^.*$', BOMReportDetail.as_view(), name='api-bom-report-detail'),
@ -505,7 +540,7 @@ report_api_urls = [
# Stock item test reports # Stock item test reports
re_path(r'test/', include([ re_path(r'test/', include([
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'print/?', StockItemTestReportPrint.as_view(), name='api-stockitem-testreport-print'), re_path(r'print/?', StockItemTestReportPrint.as_view(), name='api-stockitem-testreport-print'),
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: TestReport}, name='api-stockitem-testreport-metadata'), re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: TestReport}, name='api-stockitem-testreport-metadata'),
re_path(r'^.*$', StockItemTestReportDetail.as_view(), name='api-stockitem-testreport-detail'), re_path(r'^.*$', StockItemTestReportDetail.as_view(), name='api-stockitem-testreport-detail'),

View File

@ -8,8 +8,6 @@ from pathlib import Path
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings from django.conf import settings
from InvenTree.ready import canAppAccessDatabase
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
@ -19,12 +17,21 @@ class ReportConfig(AppConfig):
def ready(self): def ready(self):
"""This function is called whenever the report app is loaded.""" """This function is called whenever the report app is loaded."""
from InvenTree.ready import canAppAccessDatabase
# Configure logging for PDF generation (disable "info" messages)
logging.getLogger('fontTools').setLevel(logging.WARNING)
logging.getLogger('weasyprint').setLevel(logging.WARNING)
# Create entries for default report templates
if canAppAccessDatabase(allow_test=True): if canAppAccessDatabase(allow_test=True):
self.create_default_test_reports() self.create_default_test_reports()
self.create_default_build_reports() self.create_default_build_reports()
self.create_default_bill_of_materials_reports() self.create_default_bill_of_materials_reports()
self.create_default_purchase_order_reports() self.create_default_purchase_order_reports()
self.create_default_sales_order_reports() self.create_default_sales_order_reports()
self.create_default_return_order_reports()
def create_default_reports(self, model, reports): def create_default_reports(self, model, reports):
"""Copy defualt report files across to the media directory.""" """Copy defualt report files across to the media directory."""
@ -174,3 +181,23 @@ class ReportConfig(AppConfig):
] ]
self.create_default_reports(SalesOrderReport, reports) self.create_default_reports(SalesOrderReport, reports)
def create_default_return_order_reports(self):
"""Create database entries for the default ReturnOrderReport templates"""
try:
from report.models import ReturnOrderReport
except Exception: # pragma: no cover
# Database not yet ready
return
# List of templates to copy across
reports = [
{
'file': 'inventree_return_order_report.html',
'name': 'InvenTree Return Order',
'description': 'Return Order example report',
}
]
self.create_default_reports(ReturnOrderReport, reports)

View File

@ -0,0 +1,31 @@
# Generated by Django 3.2.18 on 2023-03-15 11:17
import django.core.validators
from django.db import migrations, models
import report.models
class Migration(migrations.Migration):
dependencies = [
('report', '0017_auto_20230317_0816'),
]
operations = [
migrations.CreateModel(
name='ReturnOrderReport',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')),
('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')),
('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')),
('filename_pattern', models.CharField(default='report.pdf', help_text='Pattern for generating report filenames', max_length=100, verbose_name='Filename Pattern')),
('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')),
('filters', models.CharField(blank=True, help_text='Return order query filters', max_length=250, validators=[report.models.validate_return_order_filters], verbose_name='Filters')),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.18 on 2023-03-23 11:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('report', '0018_returnorderreport'),
]
operations = [
migrations.AddField(
model_name='returnorderreport',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
]

View File

@ -68,6 +68,11 @@ def validate_sales_order_filters(filters):
return validateFilterString(filters, model=order.models.SalesOrder) return validateFilterString(filters, model=order.models.SalesOrder)
def validate_return_order_filters(filters):
"""Validate filter string against ReturnOrder model"""
return validateFilterString(filters, model=order.models.ReturnOrder)
class WeasyprintReportMixin(WeasyTemplateResponseMixin): class WeasyprintReportMixin(WeasyTemplateResponseMixin):
"""Class for rendering a HTML template to a PDF.""" """Class for rendering a HTML template to a PDF."""
@ -303,6 +308,30 @@ class TestReport(ReportTemplateBase):
return items.exists() return items.exists()
def get_test_keys(self, stock_item):
"""Construct a flattened list of test 'keys' for this StockItem:
- First, any 'required' tests
- Second, any 'non required' tests
- Finally, any test results which do not match a test
"""
keys = []
for test in stock_item.part.getTestTemplates(required=True):
if test.key not in keys:
keys.append(test.key)
for test in stock_item.part.getTestTemplates(required=False):
if test.key not in keys:
keys.append(test.key)
for result in stock_item.testResultList(include_installed=self.include_installed):
if result.key not in keys:
keys.append(result.key)
return list(keys)
def get_context_data(self, request): def get_context_data(self, request):
"""Return custom context data for the TestReport template""" """Return custom context data for the TestReport template"""
stock_item = self.object_to_print stock_item = self.object_to_print
@ -312,6 +341,9 @@ class TestReport(ReportTemplateBase):
'serial': stock_item.serial, 'serial': stock_item.serial,
'part': stock_item.part, 'part': stock_item.part,
'parameters': stock_item.part.parameters_map(), 'parameters': stock_item.part.parameters_map(),
'test_keys': self.get_test_keys(stock_item),
'test_template_list': stock_item.part.getTestTemplates(),
'test_template_map': stock_item.part.getTestTemplateMap(),
'results': stock_item.testResultMap(include_installed=self.include_installed), 'results': stock_item.testResultMap(include_installed=self.include_installed),
'result_list': stock_item.testResultList(include_installed=self.include_installed), 'result_list': stock_item.testResultList(include_installed=self.include_installed),
'installed_items': stock_item.get_installed_items(cascade=True), 'installed_items': stock_item.get_installed_items(cascade=True),
@ -468,6 +500,45 @@ class SalesOrderReport(ReportTemplateBase):
} }
class ReturnOrderReport(ReportTemplateBase):
"""Render a custom report against a ReturnOrder object"""
@staticmethod
def get_api_url():
"""Return the API URL associated with the ReturnOrderReport model"""
return reverse('api-return-order-report-list')
@classmethod
def getSubdir(cls):
"""Return the directory where the ReturnOrderReport templates are stored"""
return 'returnorder'
filters = models.CharField(
blank=True,
max_length=250,
verbose_name=_('Filters'),
help_text=_('Return order query filters'),
validators=[
validate_return_order_filters,
]
)
def get_context_data(self, request):
"""Return custom context data for the ReturnOrderReport template"""
order = self.object_to_print
return {
'order': order,
'description': order.description,
'reference': order.reference,
'customer': order.customer,
'lines': order.lines,
'extra_lines': order.extra_lines,
'title': str(order),
}
def rename_snippet(instance, filename): def rename_snippet(instance, filename):
"""Function to rename a report snippet once uploaded""" """Function to rename a report snippet once uploaded"""

View File

@ -4,99 +4,83 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
InvenTreeModelSerializer) InvenTreeModelSerializer)
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport, from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
SalesOrderReport, TestReport) ReturnOrderReport, SalesOrderReport, TestReport)
class TestReportSerializer(InvenTreeModelSerializer): class ReportSerializerBase(InvenTreeModelSerializer):
"""Base class for report serializer"""
template = InvenTreeAttachmentSerializerField(required=True)
@staticmethod
def report_fields():
"""Generic serializer fields for a report template"""
return [
'pk',
'name',
'description',
'template',
'filters',
'enabled',
]
class TestReportSerializer(ReportSerializerBase):
"""Serializer class for the TestReport model""" """Serializer class for the TestReport model"""
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
model = TestReport model = TestReport
fields = [ fields = ReportSerializerBase.report_fields()
'pk',
'name',
'description',
'template',
'filters',
'enabled',
]
template = InvenTreeAttachmentSerializerField(required=True)
class BuildReportSerializer(InvenTreeModelSerializer): class BuildReportSerializer(ReportSerializerBase):
"""Serializer class for the BuildReport model""" """Serializer class for the BuildReport model"""
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
model = BuildReport model = BuildReport
fields = [ fields = ReportSerializerBase.report_fields()
'pk',
'name',
'description',
'template',
'filters',
'enabled',
]
template = InvenTreeAttachmentSerializerField(required=True)
class BOMReportSerializer(InvenTreeModelSerializer): class BOMReportSerializer(ReportSerializerBase):
"""Serializer class for the BillOfMaterialsReport model""" """Serializer class for the BillOfMaterialsReport model"""
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
model = BillOfMaterialsReport model = BillOfMaterialsReport
fields = [ fields = ReportSerializerBase.report_fields()
'pk',
'name',
'description',
'template',
'filters',
'enabled',
]
template = InvenTreeAttachmentSerializerField(required=True)
class PurchaseOrderReportSerializer(InvenTreeModelSerializer): class PurchaseOrderReportSerializer(ReportSerializerBase):
"""Serializer class for the PurchaseOrdeReport model""" """Serializer class for the PurchaseOrdeReport model"""
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
model = PurchaseOrderReport model = PurchaseOrderReport
fields = [ fields = ReportSerializerBase.report_fields()
'pk',
'name',
'description',
'template',
'filters',
'enabled',
]
template = InvenTreeAttachmentSerializerField(required=True)
class SalesOrderReportSerializer(InvenTreeModelSerializer): class SalesOrderReportSerializer(ReportSerializerBase):
"""Serializer class for the SalesOrderReport model""" """Serializer class for the SalesOrderReport model"""
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
model = SalesOrderReport model = SalesOrderReport
fields = [ fields = ReportSerializerBase.report_fields()
'pk',
'name',
'description',
'template',
'filters',
'enabled',
]
template = InvenTreeAttachmentSerializerField(required=True)
class ReturnOrderReportSerializer(ReportSerializerBase):
"""Serializer class for the ReturnOrderReport model"""
class Meta:
"""Metaclass options"""
model = ReturnOrderReport
fields = ReportSerializerBase.report_fields()

View File

@ -0,0 +1,70 @@
{% extends "report/inventree_report_base.html" %}
{% load i18n %}
{% load report %}
{% load barcode %}
{% load inventree_extras %}
{% load markdownify %}
{% block page_margin %}
margin: 2cm;
margin-top: 4cm;
{% endblock %}
{% block bottom_left %}
content: "v{{report_revision}} - {{ date.isoformat }}";
{% endblock %}
{% block bottom_center %}
content: "{% inventree_version shortstring=True %}";
{% endblock %}
{% block style %}
.header-right {
text-align: right;
float: right;
}
.logo {
height: 20mm;
vertical-align: middle;
}
.thumb-container {
width: 32px;
display: inline;
}
.part-thumb {
max-width: 32px;
max-height: 32px;
display: inline;
}
.part-text {
display: inline;
}
table {
border: 1px solid #eee;
border-radius: 3px;
border-collapse: collapse;
width: 100%;
font-size: 80%;
}
table td {
border: 1px solid #eee;
}
table td.shrink {
white-space: nowrap
}
table td.expand {
width: 99%
}
{% endblock %}

View File

@ -1,72 +1,10 @@
{% extends "report/inventree_report_base.html" %} {% extends "report/inventree_order_report_base.html" %}
{% load i18n %} {% load i18n %}
{% load report %} {% load report %}
{% load barcode %} {% load barcode %}
{% load inventree_extras %} {% load inventree_extras %}
{% load markdownify %}
{% block page_margin %}
margin: 2cm;
margin-top: 4cm;
{% endblock %}
{% block bottom_left %}
content: "v{{report_revision}} - {{ date.isoformat }}";
{% endblock %}
{% block bottom_center %}
content: "{% inventree_version shortstring=True %}";
{% endblock %}
{% block style %}
.header-right {
text-align: right;
float: right;
}
.logo {
height: 20mm;
vertical-align: middle;
}
.thumb-container {
width: 32px;
display: inline;
}
.part-thumb {
max-width: 32px;
max-height: 32px;
display: inline;
}
.part-text {
display: inline;
}
table {
border: 1px solid #eee;
border-radius: 3px;
border-collapse: collapse;
width: 100%;
font-size: 80%;
}
table td {
border: 1px solid #eee;
}
table td.shrink {
white-space: nowrap
}
table td.expand {
width: 99%
}
{% endblock %}
{% block header_content %} {% block header_content %}

View File

@ -0,0 +1 @@
{% extends "report/inventree_return_order_report_base.html" %}

View File

@ -0,0 +1,62 @@
{% extends "report/inventree_order_report_base.html" %}
{% load i18n %}
{% load report %}
{% load barcode %}
{% load inventree_extras %}
{% load markdownify %}
{% block header_content %}
<img class='logo' src='{% company_image customer %}' alt="{{ customer }}" width='150'>
<div class='header-right'>
<h3>{% trans "Return Order" %} {{ prefix }}{{ reference }}</h3>
{% if customer %}{{ customer.name }}{% endif %}
</div>
{% endblock header_content %}
{% block page_content %}
<h3>{% trans "Line Items" %}</h3>
<table class='table table-striped table-condensed'>
<thead>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "Serial Number" %}</th>
<th>{% trans "Reference" %}</th>
<th>{% trans "Note" %}</th>
</tr>
</thead>
<tbody>
{% for line in lines.all %}
<tr>
<td>
<div class='thumb-container'>
<img src='{% part_image line.item.part %}' class='part-thumb'>
</div>
<div class='part-text'>
{{ line.item.part.full_name }}
</div>
</td>
<td>{{ line.item.serial }}</td>
<td>{{ line.reference }}</td>
<td>{{ line.notes }}</td>
</tr>
{% endfor %}
{% if extra_lines %}
<tr><th colspan='4'>{% trans "Extra Line Items" %}</th></tr>
{% for line in extra_lines.all %}
<tr>
<td><!-- No part --></td>
<td><!-- No serial --></td>
<td>{{ line.reference }}</td>
<td>{{ line.notes }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
{% endblock page_content %}

View File

@ -1,4 +1,4 @@
{% extends "report/inventree_report_base.html" %} {% extends "report/inventree_order_report_base.html" %}
{% load i18n %} {% load i18n %}
{% load report %} {% load report %}
@ -6,69 +6,6 @@
{% load inventree_extras %} {% load inventree_extras %}
{% load markdownify %} {% load markdownify %}
{% block page_margin %}
margin: 2cm;
margin-top: 4cm;
{% endblock %}
{% block bottom_left %}
content: "v{{report_revision}} - {{ date.isoformat }}";
{% endblock %}
{% block bottom_center %}
content: "{% inventree_version shortstring=True %}";
{% endblock %}
{% block style %}
.header-right {
text-align: right;
float: right;
}
.logo {
height: 20mm;
vertical-align: middle;
}
.thumb-container {
width: 32px;
display: inline;
}
.part-thumb {
max-width: 32px;
max-height: 32px;
display: inline;
}
.part-text {
display: inline;
}
table {
border: 1px solid #eee;
border-radius: 3px;
border-collapse: collapse;
width: 100%;
font-size: 80%;
}
table td {
border: 1px solid #eee;
}
table td.shrink {
white-space: nowrap
}
table td.expand {
width: 99%
}
{% endblock %}
{% block header_content %} {% block header_content %}
<img class='logo' src='{% company_image customer %}' alt="{{ customer }}" width='150'> <img class='logo' src='{% company_image customer %}' alt="{{ customer }}" width='150'>

View File

@ -33,6 +33,15 @@ content: "{% trans 'Stock Item Test Report' %}";
color: #F55; color: #F55;
} }
.test-not-found {
color: #33A;
}
.required-test-not-found {
color: #EEE;
background-color: #F55;
}
.container { .container {
padding: 5px; padding: 5px;
border: 1px solid; border: 1px solid;
@ -84,7 +93,7 @@ content: "{% trans 'Stock Item Test Report' %}";
</div> </div>
</div> </div>
{% if resul_list|length > 0 %} {% if test_keys|length > 0 %}
<h3>{% trans "Test Results" %}</h3> <h3>{% trans "Test Results" %}</h3>
<table class='table test-table'> <table class='table test-table'>
@ -101,22 +110,44 @@ content: "{% trans 'Stock Item Test Report' %}";
<tr> <tr>
<td colspan='5'><hr></td> <td colspan='5'><hr></td>
</tr> </tr>
{% for test in result_list %} {% for key in test_keys %}
<!-- test key = {{ key }} -->
{% getkey test_template_map key as test_template %}
{% getkey results key as test_result %}
<tr class='test-row'> <tr class='test-row'>
<td>{{ test.test }}</td> <td>
{% if test.result %} {% if test_template %}
{% render_html_text test_template.test_name bold=test_template.required %}
{% elif test_result %}
{% render_html_text test_result.test italic=True %}
{% else %}
<!-- No matching test template or result for {{ key }} -->
<span style='color: red;'>{{ key }}</span>
{% endif %}
</td>
{% if test_result %}
{% if test_result.result %}
<td class='test-pass'>{% trans "Pass" %}</td> <td class='test-pass'>{% trans "Pass" %}</td>
{% else %} {% else %}
<td class='test-fail'>{% trans "Fail" %}</td> <td class='test-fail'>{% trans "Fail" %}</td>
{% endif %} {% endif %}
<td>{{ test.value }}</td> <td>{{ test_result.value }}</td>
<td>{{ test.user.username }}</td> <td>{{ test_result.user.username }}</td>
<td>{{ test.date.date.isoformat }}</td> <td>{{ test_result.date.date.isoformat }}</td>
{% else %}
{% if test_template.required %}
<td colspan='4' class='required-test-not-found'>{% trans "No result (required)" %}</td>
{% else %}
<td colspan='4' class='test-not-found'>{% trans "No result" %}</td>
{% endif %}
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %}
<em>No tests defined for this stock item</em>
{% endif %} {% endif %}
{% if installed_items|length > 0 %} {% if installed_items|length > 0 %}

View File

@ -19,17 +19,52 @@ logger = logging.getLogger('inventree')
@register.simple_tag() @register.simple_tag()
def getkey(value: dict, arg): def getindex(container: list, index: int):
"""Return the value contained at the specified index of the list.
This function is provideed to get around template rendering limitations.
Arguments:
container: A python list object
index: The index to retrieve from the list
"""
# Index *must* be an integer
try:
index = int(index)
except ValueError:
return None
if index < 0 or index >= len(container):
return None
try:
value = container[index]
except IndexError:
value = None
return value
@register.simple_tag()
def getkey(container: dict, key):
"""Perform key lookup in the provided dict object. """Perform key lookup in the provided dict object.
This function is provided to get around template rendering limitations. This function is provided to get around template rendering limitations.
Ref: https://stackoverflow.com/questions/1906129/dict-keys-with-spaces-in-django-templates Ref: https://stackoverflow.com/questions/1906129/dict-keys-with-spaces-in-django-templates
Arguments: Arguments:
value: A python dict object container: A python dict object
arg: The 'key' to be found within the dict key: The 'key' to be found within the dict
""" """
return value[arg] if type(container) is not dict:
logger.warning("getkey() called with non-dict object")
return None
if key in container:
return container[key]
else:
return None
@register.simple_tag() @register.simple_tag()
@ -215,3 +250,31 @@ def render_currency(money, **kwargs):
"""Render a currency / Money object""" """Render a currency / Money object"""
return InvenTree.helpers.render_currency(money, **kwargs) return InvenTree.helpers.render_currency(money, **kwargs)
@register.simple_tag
def render_html_text(text: str, **kwargs):
"""Render a text item with some simple html tags.
kwargs:
bold: Boolean, whether bold (or not)
italic: Boolean, whether italic (or not)
heading: str, heading level e.g. 'h3'
"""
tags = []
if kwargs.get('bold', False):
tags.append('strong')
if kwargs.get('italic', False):
tags.append('em')
if heading := kwargs.get('heading', ''):
tags.append(heading)
output = ''.join([f'<{tag}>' for tag in tags])
output += text
output += ''.join([f'</{tag}>' for tag in tags])
return mark_safe(output)

View File

@ -29,6 +29,20 @@ class ReportTagTest(TestCase):
"""Enable or disable debug mode for reports""" """Enable or disable debug mode for reports"""
InvenTreeSetting.set_setting('REPORT_DEBUG_MODE', value, change_user=None) InvenTreeSetting.set_setting('REPORT_DEBUG_MODE', value, change_user=None)
def test_getindex(self):
"""Tests for the 'getindex' template tag"""
fn = report_tags.getindex
data = [1, 2, 3, 4, 5, 6]
# Out of bounds or invalid
self.assertEqual(fn(data, -1), None)
self.assertEqual(fn(data, 99), None)
self.assertEqual(fn(data, 'xx'), None)
for idx in range(len(data)):
self.assertEqual(fn(data, idx), data[idx])
def test_getkey(self): def test_getkey(self):
"""Tests for the 'getkey' template tag""" """Tests for the 'getkey' template tag"""
@ -419,7 +433,7 @@ class BOMReportTest(ReportTest):
class PurchaseOrderReportTest(ReportTest): class PurchaseOrderReportTest(ReportTest):
"""Unit test class fort he PurchaseOrderReport model""" """Unit test class for the PurchaseOrderReport model"""
model = report_models.PurchaseOrderReport model = report_models.PurchaseOrderReport
list_url = 'api-po-report-list' list_url = 'api-po-report-list'
@ -446,3 +460,18 @@ class SalesOrderReportTest(ReportTest):
self.copyReportTemplate('inventree_so_report.html', 'sales order report') self.copyReportTemplate('inventree_so_report.html', 'sales order report')
return super().setUp() return super().setUp()
class ReturnOrderReportTest(ReportTest):
"""Unit tests for the ReturnOrderReport model"""
model = report_models.ReturnOrderReport
list_url = 'api-return-order-report-list'
detail_url = 'api-return-order-report-detail'
print_url = 'api-return-order-report-print'
def setUp(self):
"""Setup function for the ReturnOrderReport tests"""
self.copyReportTemplate('inventree_return_order_report.html', 'return order report')
return super().setUp()

View File

@ -30,8 +30,10 @@ from InvenTree.helpers import (DownloadFile, extract_serial_numbers, isNull,
from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI, from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI,
ListAPI, ListCreateAPI, RetrieveAPI, ListAPI, ListCreateAPI, RetrieveAPI,
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI) RetrieveUpdateAPI, RetrieveUpdateDestroyAPI)
from order.models import PurchaseOrder, SalesOrder, SalesOrderAllocation from order.models import (PurchaseOrder, ReturnOrder, SalesOrder,
from order.serializers import PurchaseOrderSerializer SalesOrderAllocation)
from order.serializers import (PurchaseOrderSerializer, ReturnOrderSerializer,
SalesOrderSerializer)
from part.models import BomItem, Part, PartCategory from part.models import BomItem, Part, PartCategory
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from plugin.serializers import MetadataSerializer from plugin.serializers import MetadataSerializer
@ -1262,7 +1264,7 @@ class StockTrackingList(ListAPI):
except Exception: except Exception:
pass pass
# Add purchaseorder detail # Add PurchaseOrder detail
if 'purchaseorder' in deltas: if 'purchaseorder' in deltas:
try: try:
order = PurchaseOrder.objects.get(pk=deltas['purchaseorder']) order = PurchaseOrder.objects.get(pk=deltas['purchaseorder'])
@ -1271,6 +1273,24 @@ class StockTrackingList(ListAPI):
except Exception: except Exception:
pass pass
# Add SalesOrder detail
if 'salesorder' in deltas:
try:
order = SalesOrder.objects.get(pk=deltas['salesorder'])
serializer = SalesOrderSerializer(order)
deltas['salesorder_detail'] = serializer.data
except Exception:
pass
# Add ReturnOrder detail
if 'returnorder' in deltas:
try:
order = ReturnOrder.objects.get(pk=deltas['returnorder'])
serializer = ReturnOrderSerializer(order)
deltas['returnorder_detail'] = serializer.data
except Exception:
pass
if request.is_ajax(): if request.is_ajax():
return JsonResponse(data, safe=False) return JsonResponse(data, safe=False)
else: else:
@ -1368,7 +1388,7 @@ stock_api_urls = [
re_path(r'^tree/', StockLocationTree.as_view(), name='api-location-tree'), re_path(r'^tree/', StockLocationTree.as_view(), name='api-location-tree'),
# Stock location detail endpoints # Stock location detail endpoints
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'^metadata/', LocationMetadata.as_view(), name='api-location-metadata'), re_path(r'^metadata/', LocationMetadata.as_view(), name='api-location-metadata'),
@ -1388,24 +1408,24 @@ stock_api_urls = [
# StockItemAttachment API endpoints # StockItemAttachment API endpoints
re_path(r'^attachment/', include([ re_path(r'^attachment/', include([
re_path(r'^(?P<pk>\d+)/', StockAttachmentDetail.as_view(), name='api-stock-attachment-detail'), path(r'<int:pk>/', StockAttachmentDetail.as_view(), name='api-stock-attachment-detail'),
path('', StockAttachmentList.as_view(), name='api-stock-attachment-list'), path('', StockAttachmentList.as_view(), name='api-stock-attachment-list'),
])), ])),
# StockItemTestResult API endpoints # StockItemTestResult API endpoints
re_path(r'^test/', include([ re_path(r'^test/', include([
re_path(r'^(?P<pk>\d+)/', StockItemTestResultDetail.as_view(), name='api-stock-test-result-detail'), path(r'<int:pk>/', StockItemTestResultDetail.as_view(), name='api-stock-test-result-detail'),
re_path(r'^.*$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'), re_path(r'^.*$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'),
])), ])),
# StockItemTracking API endpoints # StockItemTracking API endpoints
re_path(r'^track/', include([ re_path(r'^track/', include([
re_path(r'^(?P<pk>\d+)/', StockTrackingDetail.as_view(), name='api-stock-tracking-detail'), path(r'<int:pk>/', StockTrackingDetail.as_view(), name='api-stock-tracking-detail'),
re_path(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'), re_path(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'),
])), ])),
# Detail views for a single stock item # Detail views for a single stock item
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
re_path(r'^convert/', StockItemConvert.as_view(), name='api-stock-item-convert'), re_path(r'^convert/', StockItemConvert.as_view(), name='api-stock-item-convert'),
re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'), re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'),
re_path(r'^metadata/', StockMetadata.as_view(), name='api-stock-item-metadata'), re_path(r'^metadata/', StockMetadata.as_view(), name='api-stock-item-metadata'),

View File

@ -457,8 +457,6 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M
if old.status != self.status: if old.status != self.status:
deltas['status'] = self.status deltas['status'] = self.status
# TODO - Other interesting changes we are interested in...
if add_note and len(deltas) > 0: if add_note and len(deltas) > 0:
self.add_tracking_entry( self.add_tracking_entry(
StockHistoryCode.EDITED, StockHistoryCode.EDITED,
@ -960,17 +958,22 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M
item.customer = customer item.customer = customer
item.location = None item.location = None
item.save() item.save(add_note=False)
# TODO - Remove any stock item allocations from this stock item code = StockHistoryCode.SENT_TO_CUSTOMER
deltas = {
'customer': customer.pk,
'customer_name': customer.pk,
}
if order:
code = StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER
deltas['salesorder'] = order.pk
item.add_tracking_entry( item.add_tracking_entry(
StockHistoryCode.SENT_TO_CUSTOMER, code,
user, user,
{ deltas,
'customer': customer.id,
'customer_name': customer.name,
},
notes=notes, notes=notes,
) )
@ -992,7 +995,9 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M
""" """
notes = kwargs.get('notes', '') notes = kwargs.get('notes', '')
tracking_info = {} tracking_info = {
'location': location.pk,
}
if self.customer: if self.customer:
tracking_info['customer'] = self.customer.id tracking_info['customer'] = self.customer.id

View File

@ -222,30 +222,18 @@
); );
}); });
enableDragAndDrop( onPanelLoad('attachments', function() {
'#attachment-dropzone', loadAttachmentTable('{% url "api-stock-attachment-list" %}', {
"{% url 'api-stock-attachment-list' %}", filters: {
{ stock_item: {{ item.pk }},
data: { },
stock_item: {{ item.id }}, fields: {
}, stock_item: {
label: 'attachment', value: {{ item.pk }},
success: function(data, status, xhr) { hidden: true,
reloadAttachmentTable();
} }
} }
); });
loadAttachmentTable('{% url "api-stock-attachment-list" %}', {
filters: {
stock_item: {{ item.pk }},
},
fields: {
stock_item: {
value: {{ item.pk }},
hidden: true,
}
}
}); });
loadStockTestResultsTable( loadStockTestResultsTable(
@ -255,12 +243,12 @@
} }
); );
function reloadTable() {
$("#test-result-table").bootstrapTable("refresh");
}
$("#test-report").click(function() { $("#test-report").click(function() {
printTestReports([{{ item.pk }}]); printReports({
items: [{{ item.pk }}],
key: 'item',
url: '{% url "api-stockitem-testreport-list" %}',
});
}); });
{% if user.is_staff %} {% if user.is_staff %}
@ -299,7 +287,7 @@
method: 'DELETE', method: 'DELETE',
title: '{% trans "Delete Test Data" %}', title: '{% trans "Delete Test Data" %}',
preFormContent: html, preFormContent: html,
onSuccess: reloadTable, refreshTable: '#test-result-table',
}); });
} }
} }
@ -315,7 +303,7 @@
stock_item: {{ item.pk }}, stock_item: {{ item.pk }},
}), }),
title: '{% trans "Add Test Result" %}', title: '{% trans "Add Test Result" %}',
onSuccess: reloadTable, refreshTable: '#test-result-table',
}); });
}); });

View File

@ -493,11 +493,19 @@ $('#stock-uninstall').click(function() {
}); });
$("#stock-test-report").click(function() { $("#stock-test-report").click(function() {
printTestReports([{{ item.pk }}]); printReports({
items: [{{ item.pk }}],
key: 'item',
url: '{% url "api-stockitem-testreport-list" %}',
});
}); });
$("#print-label").click(function() { $("#print-label").click(function() {
printStockItemLabels([{{ item.pk }}]); printLabels({
items: [{{ item.pk }}],
url: '{% url "api-stockitem-label-list" %}',
key: 'item',
});
}); });
{% if roles.stock.change %} {% if roles.stock.change %}

View File

@ -228,17 +228,6 @@
<div class='panel-content'> <div class='panel-content'>
<div id='sublocation-button-toolbar'> <div id='sublocation-button-toolbar'>
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
<!-- Printing actions menu -->
{% if labels_enabled %}
<div class='btn-group' role='group'>
<button id='location-print-options' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle="dropdown" title='{% trans "Printing Actions" %}'>
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
<li><a class='dropdown-item' href='#' id='multi-location-print-label' title='{% trans "Print labels" %}'><span class='fas fa-tags'></span> {% trans "Print labels" %}</a></li>
</ul>
</div>
{% endif %}
{% include "filter_list.html" with id="location" %} {% include "filter_list.html" with id="location" %}
</div> </div>
</div> </div>
@ -299,21 +288,11 @@
var locs = [{{ location.pk }}]; var locs = [{{ location.pk }}];
printStockLocationLabels(locs); printLabels({
items: locs,
}); key: 'location',
url: '{% url "api-stocklocation-label-list" %}',
$('#multi-location-print-label').click(function() {
var selections = getTableData('#sublocation-table');
var locations = [];
selections.forEach(function(loc) {
locations.push(loc.pk);
}); });
printStockLocationLabels(locations);
}); });
{% endif %} {% endif %}

View File

@ -491,7 +491,7 @@ class StockTest(StockTestBase):
# Check that a tracking item was added # Check that a tracking item was added
track = StockItemTracking.objects.filter(item=ait).latest('id') track = StockItemTracking.objects.filter(item=ait).latest('id')
self.assertEqual(track.tracking_type, StockHistoryCode.SENT_TO_CUSTOMER) self.assertEqual(track.tracking_type, StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER)
self.assertIn('Allocated some stock', track.notes) self.assertIn('Allocated some stock', track.notes)
def test_return_from_customer(self): def test_return_from_customer(self):

View File

@ -1,12 +1,12 @@
"""URL lookup for Stock app.""" """URL lookup for Stock app."""
from django.urls import include, re_path from django.urls import include, path, re_path
from stock import views from stock import views
location_urls = [ location_urls = [
re_path(r'^(?P<pk>\d+)/', include([ path(r'<int:pk>/', include([
# Anything else - direct to the location detail view # Anything else - direct to the location detail view
re_path('^.*$', views.StockLocationDetail.as_view(), name='stock-location-detail'), re_path('^.*$', views.StockLocationDetail.as_view(), name='stock-location-detail'),
])), ])),

View File

@ -75,9 +75,7 @@ $('#history-delete').click(function() {
multi_delete: true, multi_delete: true,
preFormContent: html, preFormContent: html,
title: '{% trans "Delete Notifications" %}', title: '{% trans "Delete Notifications" %}',
onSuccess: function() { refreshTable: '#history-table',
$('#history-table').bootstrapTable('refresh');
},
form_data: { form_data: {
filters: { filters: {
read: true, read: true,
@ -88,7 +86,7 @@ $('#history-delete').click(function() {
}); });
$("#history-table").on('click', '.notification-delete', function() { $("#history-table").on('click', '.notification-delete', function() {
constructForm(`/api/notifications/${$(this).attr('pk')}/`, { constructForm(`{% url "api-notifications-list" %}${$(this).attr('pk')}/`, {
method: 'DELETE', method: 'DELETE',
title: '{% trans "Delete Notification" %}', title: '{% trans "Delete Notification" %}',
onSuccess: function(data) { onSuccess: function(data) {

View File

@ -0,0 +1,20 @@
{% extends "panel.html" %}
{% load i18n %}
{% block label %}return-order{% endblock %}
{% block heading %}
{% trans "Return Order Settings" %}
{% endblock %}
{% block content %}
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="RETURNORDER_ENABLED" icon="fa-check-circle" %}
{% include "InvenTree/settings/setting.html" with key="RETURNORDER_REFERENCE_PATTERN" %}
{% include "InvenTree/settings/setting.html" with key="RETURNORDER_EDIT_COMPLETED_ORDERS" icon="fa-edit" %}
</tbody>
</table>
{% endblock %}

View File

@ -42,6 +42,7 @@
{% include "InvenTree/settings/build.html" %} {% include "InvenTree/settings/build.html" %}
{% include "InvenTree/settings/po.html" %} {% include "InvenTree/settings/po.html" %}
{% include "InvenTree/settings/so.html" %} {% include "InvenTree/settings/so.html" %}
{% include "InvenTree/settings/returns.html" %}
{% include "InvenTree/settings/plugin.html" %} {% include "InvenTree/settings/plugin.html" %}
{% plugin_list as pl_list %} {% plugin_list as pl_list %}

View File

@ -284,9 +284,7 @@ onPanelLoad('parts', function() {
}, },
method: 'POST', method: 'POST',
title: '{% trans "Create Part Parameter Template" %}', title: '{% trans "Create Part Parameter Template" %}',
onSuccess: function() { refreshTable: '#param-table',
$("#param-table").bootstrapTable('refresh');
},
}); });
}); });
@ -303,9 +301,7 @@ onPanelLoad('parts', function() {
description: {}, description: {},
}, },
title: '{% trans "Edit Part Parameter Template" %}', title: '{% trans "Edit Part Parameter Template" %}',
onSuccess: function() { refreshTable: '#param-table',
$("#param-table").bootstrapTable('refresh');
},
} }
); );
}); });
@ -325,9 +321,7 @@ onPanelLoad('parts', function() {
method: 'DELETE', method: 'DELETE',
preFormContent: html, preFormContent: html,
title: '{% trans "Delete Part Parameter Template" %}', title: '{% trans "Delete Part Parameter Template" %}',
onSuccess: function() { refreshTable: '#param-table',
$("#param-table").bootstrapTable('refresh');
},
} }
); );
}); });

View File

@ -52,6 +52,8 @@
{% include "sidebar_item.html" with label='purchase-order' text=text icon="fa-shopping-cart" %} {% include "sidebar_item.html" with label='purchase-order' text=text icon="fa-shopping-cart" %}
{% trans "Sales Orders" as text %} {% trans "Sales Orders" as text %}
{% include "sidebar_item.html" with label='sales-order' text=text icon="fa-truck" %} {% include "sidebar_item.html" with label='sales-order' text=text icon="fa-truck" %}
{% trans "Return Orders" as text %}
{% include "sidebar_item.html" with label='return-order' text=text icon="fa-undo" %}
{% trans "Plugin Settings" as text %} {% trans "Plugin Settings" as text %}
{% include "sidebar_header.html" with text=text %} {% include "sidebar_header.html" with text=text %}

View File

@ -28,6 +28,8 @@
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_EXCLUDE_INACTIVE_PURCHASE_ORDERS" user_setting=True icon='fa-eye-slash' %} {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_EXCLUDE_INACTIVE_PURCHASE_ORDERS" user_setting=True icon='fa-eye-slash' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_SALES_ORDERS" user_setting=True icon='fa-truck' %} {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_SALES_ORDERS" user_setting=True icon='fa-truck' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_EXCLUDE_INACTIVE_SALES_ORDERS" user_setting=True icon='fa-eye-slash' %} {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_EXCLUDE_INACTIVE_SALES_ORDERS" user_setting=True icon='fa-eye-slash' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_RETURN_ORDERS" user_setting=True icon='fa-truck' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_EXCLUDE_INACTIVE_RETURN_ORDERS" user_setting=True icon='fa-eye-slash' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" user_setting=True icon='fa-search' %} {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" user_setting=True icon='fa-search' %}

View File

@ -5,6 +5,7 @@
{% plugins_enabled as plugins_enabled %} {% plugins_enabled as plugins_enabled %}
{% settings_value 'BARCODE_ENABLE' as barcodes %} {% settings_value 'BARCODE_ENABLE' as barcodes %}
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %} {% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
{% settings_value 'RETURNORDER_ENABLED' as return_order_enabled %}
{% settings_value "REPORT_ENABLE" as report_enabled %} {% settings_value "REPORT_ENABLE" as report_enabled %}
{% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %} {% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %}
{% settings_value "LABEL_ENABLE" as labels_enabled %} {% settings_value "LABEL_ENABLE" as labels_enabled %}
@ -164,8 +165,12 @@
<script defer type='text/javascript' src="{% i18n_static 'model_renderers.js' %}"></script> <script defer type='text/javascript' src="{% i18n_static 'model_renderers.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'order.js' %}"></script> <script defer type='text/javascript' src="{% i18n_static 'order.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'part.js' %}"></script> <script defer type='text/javascript' src="{% i18n_static 'part.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'purchase_order.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'return_order.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'report.js' %}"></script> <script defer type='text/javascript' src="{% i18n_static 'report.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'sales_order.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'search.js' %}"></script> <script defer type='text/javascript' src="{% i18n_static 'search.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'status_codes.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'stock.js' %}"></script> <script defer type='text/javascript' src="{% i18n_static 'stock.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'plugin.js' %}"></script> <script defer type='text/javascript' src="{% i18n_static 'plugin.js' %}"></script>
<script defer type='text/javascript' src="{% i18n_static 'pricing.js' %}"></script> <script defer type='text/javascript' src="{% i18n_static 'pricing.js' %}"></script>

View File

@ -0,0 +1,11 @@
{% extends "email/email.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block title %}
{{ message }}
{% if link %}
<p>{% trans "Click on the following link to view this order" %}: <a href='{{ link }}'>{{ link }}</a></p>
{% endif %}
{% endblock title %}

View File

@ -14,18 +14,24 @@
* Helper functions for calendar display * Helper functions for calendar display
*/ */
/*
* Extract the first displayed date on the calendar
*/
function startDate(calendar) { function startDate(calendar) {
// Extract the first displayed date on the calendar
return calendar.currentData.dateProfile.activeRange.start.toISOString().split('T')[0]; return calendar.currentData.dateProfile.activeRange.start.toISOString().split('T')[0];
} }
/*
* Extract the last display date on the calendar
*/
function endDate(calendar) { function endDate(calendar) {
// Extract the last display date on the calendar
return calendar.currentData.dateProfile.activeRange.end.toISOString().split('T')[0]; return calendar.currentData.dateProfile.activeRange.end.toISOString().split('T')[0];
} }
/*
* Remove all events from the calendar
*/
function clearEvents(calendar) { function clearEvents(calendar) {
// Remove all events from the calendar
var events = calendar.getEvents(); var events = calendar.getEvents();

View File

@ -40,8 +40,17 @@ function getCookie(name) {
return cookieValue; return cookieValue;
} }
/*
* Perform a GET request to the InvenTree server
*/
function inventreeGet(url, filters={}, options={}) { function inventreeGet(url, filters={}, options={}) {
if (!url) {
console.error('inventreeGet called without url');
return;
}
// Middleware token required for data update // Middleware token required for data update
var csrftoken = getCookie('csrftoken'); var csrftoken = getCookie('csrftoken');
@ -78,14 +87,20 @@ function inventreeGet(url, filters={}, options={}) {
}); });
} }
/* Upload via AJAX using the FormData approach.
*
* Note that the following AJAX parameters are required for FormData upload
*
* processData: false
* contentType: false
*/
function inventreeFormDataUpload(url, data, options={}) { function inventreeFormDataUpload(url, data, options={}) {
/* Upload via AJAX using the FormData approach.
* if (!url) {
* Note that the following AJAX parameters are required for FormData upload console.error('inventreeFormDataUpload called without url');
* return;
* processData: false }
* contentType: false
*/
// CSRF cookie token // CSRF cookie token
var csrftoken = getCookie('csrftoken'); var csrftoken = getCookie('csrftoken');
@ -116,8 +131,17 @@ function inventreeFormDataUpload(url, data, options={}) {
}); });
} }
/*
* Perform a PUT or PATCH request to the InvenTree server
*/
function inventreePut(url, data={}, options={}) { function inventreePut(url, data={}, options={}) {
if (!url) {
console.error('inventreePut called without url');
return;
}
var method = options.method || 'PUT'; var method = options.method || 'PUT';
// Middleware token required for data update // Middleware token required for data update
@ -164,6 +188,11 @@ function inventreePut(url, data={}, options={}) {
*/ */
function inventreeDelete(url, options={}) { function inventreeDelete(url, options={}) {
if (!url) {
console.error('inventreeDelete called without url');
return;
}
options = options || {}; options = options || {};
options.method = 'DELETE'; options.method = 'DELETE';

View File

@ -1,15 +1,14 @@
{% load i18n %} {% load i18n %}
/* globals /* globals
makeIconButton,
renderLink, renderLink,
wrapButtons,
*/ */
/* exported /* exported
attachmentLink, attachmentLink,
addAttachmentButtonCallbacks, addAttachmentButtonCallbacks,
loadAttachmentTable, loadAttachmentTable
reloadAttachmentTable,
*/ */
@ -35,7 +34,7 @@ function addAttachmentButtonCallbacks(url, fields={}) {
constructForm(url, { constructForm(url, {
fields: file_fields, fields: file_fields,
method: 'POST', method: 'POST',
onSuccess: reloadAttachmentTable, refreshTable: '#attachment-table',
title: '{% trans "Add Attachment" %}', title: '{% trans "Add Attachment" %}',
}); });
}); });
@ -57,7 +56,7 @@ function addAttachmentButtonCallbacks(url, fields={}) {
constructForm(url, { constructForm(url, {
fields: link_fields, fields: link_fields,
method: 'POST', method: 'POST',
onSuccess: reloadAttachmentTable, refreshTable: '#attachment-table',
title: '{% trans "Add Link" %}', title: '{% trans "Add Link" %}',
}); });
}); });
@ -79,9 +78,9 @@ function deleteAttachments(attachments, url, options={}) {
var icon = ''; var icon = '';
if (attachment.filename) { if (attachment.filename) {
icon = `<span class='fas fa-file-alt'></span>`; icon = makeIcon(attachmentIcon(attachment.filename), '');
} else if (attachment.link) { } else if (attachment.link) {
icon = `<span class='fas fa-link'></span>`; icon = makeIcon('fa-link', '');
} }
return ` return `
@ -123,29 +122,15 @@ function deleteAttachments(attachments, url, options={}) {
items: ids, items: ids,
filters: options.filters, filters: options.filters,
}, },
onSuccess: function() { refreshTable: '#attachment-table',
// Refresh the table once all attachments are deleted
$('#attachment-table').bootstrapTable('refresh');
}
}); });
} }
function reloadAttachmentTable() {
$('#attachment-table').bootstrapTable('refresh');
}
/* /*
* Render a link (with icon) to an internal attachment (file) * Return a particular icon based on filename extension
*/ */
function attachmentLink(filename) { function attachmentIcon(filename) {
if (!filename) {
return null;
}
// Default file icon (if no better choice is found) // Default file icon (if no better choice is found)
let icon = 'fa-file-alt'; let icon = 'fa-file-alt';
let fn = filename.toLowerCase(); let fn = filename.toLowerCase();
@ -171,10 +156,25 @@ function attachmentLink(filename) {
}); });
} }
let split = filename.split('/'); return icon;
fn = split[split.length - 1]; }
let html = `<span class='fas ${icon}'></span> ${fn}`;
/*
* Render a link (with icon) to an internal attachment (file)
*/
function attachmentLink(filename) {
if (!filename) {
return null;
}
let split = filename.split('/');
let fn = split[split.length - 1];
let icon = attachmentIcon(filename);
let html = makeIcon(icon) + ` ${fn}`;
return renderLink(html, filename, {download: true}); return renderLink(html, filename, {download: true});
@ -271,7 +271,7 @@ function loadAttachmentTable(url, options) {
delete opts.fields.link; delete opts.fields.link;
} }
}, },
onSuccess: reloadAttachmentTable, refreshTable: '#attachment-table',
title: '{% trans "Edit Attachment" %}', title: '{% trans "Edit Attachment" %}',
}); });
}); });
@ -299,7 +299,7 @@ function loadAttachmentTable(url, options) {
if (row.attachment) { if (row.attachment) {
return attachmentLink(row.attachment); return attachmentLink(row.attachment);
} else if (row.link) { } else if (row.link) {
var html = `<span class='fas fa-link'></span> ${row.link}`; let html = makeIcon('fa-link') + ` ${row.link}`;
return renderLink(html, row.link); return renderLink(html, row.link);
} else { } else {
return '-'; return '-';
@ -327,13 +327,10 @@ function loadAttachmentTable(url, options) {
{ {
field: 'actions', field: 'actions',
formatter: function(value, row) { formatter: function(value, row) {
var html = ''; let buttons = '';
html = `<div class='btn-group float-right' role='group'>`;
if (permissions.change) { if (permissions.change) {
html += makeIconButton( buttons += makeEditButton(
'fa-edit icon-blue',
'button-attachment-edit', 'button-attachment-edit',
row.pk, row.pk,
'{% trans "Edit attachment" %}', '{% trans "Edit attachment" %}',
@ -341,19 +338,30 @@ function loadAttachmentTable(url, options) {
} }
if (permissions.delete) { if (permissions.delete) {
html += makeIconButton( buttons += makeDeleteButton(
'fa-trash-alt icon-red',
'button-attachment-delete', 'button-attachment-delete',
row.pk, row.pk,
'{% trans "Delete attachment" %}', '{% trans "Delete attachment" %}',
); );
} }
html += `</div>`; return wrapButtons(buttons);
return html;
} }
} }
] ]
}); });
// Enable drag-and-drop functionality
enableDragAndDrop(
'#attachment-dropzone',
url,
{
data: options.filters,
label: 'attachment',
method: 'POST',
success: function() {
reloadBootstrapTable('#attachment-table');
}
}
);
} }

View File

@ -3,7 +3,6 @@
/* globals /* globals
imageHoverIcon, imageHoverIcon,
inventreePut, inventreePut,
makeIconButton,
modalEnable, modalEnable,
modalSetContent, modalSetContent,
modalSetTitle, modalSetTitle,
@ -43,11 +42,11 @@ function makeBarcodeInput(placeholderText='', hintText='') {
<div class='controls'> <div class='controls'>
<div class='input-group'> <div class='input-group'>
<span class='input-group-text'> <span class='input-group-text'>
<span class='fas fa-qrcode'></span> ${makeIcon('fa-qrcode')}
</span> </span>
<input id='barcode' class='textinput textInput form-control' type='text' name='barcode' placeholder='${placeholderText}'> <input id='barcode' class='textinput textInput form-control' type='text' name='barcode' placeholder='${placeholderText}'>
<button title='{% trans "Scan barcode using connected webcam" %}' id='barcode_scan_btn' type='button' class='btn btn-secondary' onclick='onBarcodeScanClicked()' style='display: none;'> <button title='{% trans "Scan barcode using connected webcam" %}' id='barcode_scan_btn' type='button' class='btn btn-secondary' onclick='onBarcodeScanClicked()' style='display: none;'>
<span class='fas fa-camera'></span> ${makeIcon('fa-camera')}
</button> </button>
</div> </div>
<div id='hint_barcode_data' class='help-block'>${hintText}</div> <div id='hint_barcode_data' class='help-block'>${hintText}</div>
@ -132,7 +131,7 @@ function makeNotesField(options={}) {
<div class='controls'> <div class='controls'>
<div class='input-group'> <div class='input-group'>
<span class='input-group-text'> <span class='input-group-text'>
<span class='fas fa-sticky-note'></span> ${makeIcon('fa-sticky-note')}
</span> </span>
<input id='notes' class='textinput textInput form-control' type='text' name='notes' placeholder='${placeholder}'> <input id='notes' class='textinput textInput form-control' type='text' name='notes' placeholder='${placeholder}'>
</div> </div>
@ -149,7 +148,7 @@ function postBarcodeData(barcode_data, options={}) {
var modal = options.modal || '#modal-form'; var modal = options.modal || '#modal-form';
var url = options.url || '/api/barcode/'; var url = options.url || '{% url "api-barcode-scan" %}';
var data = options.data || {}; var data = options.data || {};
@ -462,7 +461,7 @@ function unlinkBarcode(data, options={}) {
accept_text: '{% trans "Unlink" %}', accept_text: '{% trans "Unlink" %}',
accept: function() { accept: function() {
inventreePut( inventreePut(
'/api/barcode/unlink/', '{% url "api-barcode-unlink" %}',
data, data,
{ {
method: 'POST', method: 'POST',
@ -521,7 +520,7 @@ function barcodeCheckInStockItems(location_id, options={}) {
<td>${imageHoverIcon(item.part_detail.thumbnail)} ${item.part_detail.name}</td> <td>${imageHoverIcon(item.part_detail.thumbnail)} ${item.part_detail.name}</td>
<td>${location_info}</td> <td>${location_info}</td>
<td>${item.quantity}</td> <td>${item.quantity}</td>
<td>${makeIconButton('fa-times-circle icon-red', 'button-item-remove', item.pk, '{% trans "Remove stock item" %}')}</td> <td>${makeRemoveButton('button-item-remove', item.pk, '{% trans "Remove stock item" %}')}</td>
</tr>`; </tr>`;
}); });
@ -691,7 +690,7 @@ function barcodeCheckInStockLocations(location_id, options={}) {
if ('stocklocation' in response) { if ('stocklocation' in response) {
var pk = response.stocklocation.pk; var pk = response.stocklocation.pk;
var url = `/api/stock/location/${pk}/`; var url = `{% url "api-location-list" %}${pk}/`;
// Move the scanned location into *this* location // Move the scanned location into *this* location
inventreePut( inventreePut(
@ -812,7 +811,7 @@ function scanItemsIntoLocation(item_list, options={}) {
var pk = response.stocklocation.pk; var pk = response.stocklocation.pk;
inventreeGet(`/api/stock/location/${pk}/`, {}, { inventreeGet(`{% url "api-location-list" %}${pk}/`, {}, {
success: function(response) { success: function(response) {
stock_location = response; stock_location = response;

View File

@ -96,12 +96,12 @@ function constructBomUploadTable(data, options={}) {
var optional = constructRowField('optional'); var optional = constructRowField('optional');
var note = constructRowField('note'); var note = constructRowField('note');
var buttons = `<div class='btn-group float-right' role='group'>`; let buttons = '';
buttons += makeIconButton('fa-info-circle', 'button-row-data', idx, '{% trans "Display row data" %}'); buttons += makeInfoButton('button-row-data', idx, '{% trans "Display row data" %}');
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', idx, '{% trans "Remove row" %}'); buttons += makeRemoveButton('button-row-remove', idx, '{% trans "Remove row" %}');
buttons += `</div>`; buttons = wrapButtons(buttons);
var html = ` var html = `
<tr id='items_${idx}' class='bom-import-row' idx='${idx}'> <tr id='items_${idx}' class='bom-import-row' idx='${idx}'>
@ -557,7 +557,7 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
var buttons = ''; var buttons = '';
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove substitute part" %}'); buttons += makeRemoveButton('button-row-remove', pk, '{% trans "Remove substitute part" %}');
// Render a single row // Render a single row
var html = ` var html = `
@ -626,7 +626,7 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
</div> </div>
`; `;
constructForm(`/api/bom/substitute/${pk}/`, { constructForm(`{% url "api-bom-substitute-list" %}${pk}/`, {
method: 'DELETE', method: 'DELETE',
title: '{% trans "Remove Substitute Part" %}', title: '{% trans "Remove Substitute Part" %}',
preFormContent: pre, preFormContent: pre,
@ -785,9 +785,7 @@ function loadBomTable(table, options={}) {
filters = loadTableFilters('bom'); filters = loadTableFilters('bom');
} }
for (var key in params) { Object.assign(filters, params);
filters[key] = params[key];
}
setupFilterList('bom', $(table)); setupFilterList('bom', $(table));
@ -1142,7 +1140,7 @@ function loadBomTable(table, options={}) {
} }
if (available_stock <= 0) { if (available_stock <= 0) {
text += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "No Stock Available" %}'></span>`; text += makeIconBadge('fa-times-circle icon-red', '{% trans "No Stock Available" %}');
} else { } else {
var extra = ''; var extra = '';
@ -1160,7 +1158,10 @@ function loadBomTable(table, options={}) {
} }
if (row.on_order && row.on_order > 0) { if (row.on_order && row.on_order > 0) {
text += `<span class='fas fa-shopping-cart float-right' title='{% trans "On Order" %}: ${row.on_order}'></span>`; text += makeIconBadge(
'fa-shopping-cart',
`{% trans "On Order" %}: ${row.on_order}`,
);
} }
return renderLink(text, url); return renderLink(text, url);
@ -1242,11 +1243,10 @@ function loadBomTable(table, options={}) {
var bSubs = makeIconButton('fa-exchange-alt icon-blue', 'bom-substitutes-button', row.pk, '{% trans "Edit substitute parts" %}'); var bSubs = makeIconButton('fa-exchange-alt icon-blue', 'bom-substitutes-button', row.pk, '{% trans "Edit substitute parts" %}');
var bEdit = makeIconButton('fa-edit icon-blue', 'bom-edit-button', row.pk, '{% trans "Edit BOM Item" %}'); var bEdit = makeEditButton('bom-edit-button', row.pk, '{% trans "Edit BOM Item" %}');
var bDelt = makeIconButton('fa-trash-alt icon-red', 'bom-delete-button', row.pk, '{% trans "Delete BOM Item" %}'); var bDelt = makeDeleteButton('bom-delete-button', row.pk, '{% trans "Delete BOM Item" %}');
var html = `<div class='btn-group float-right' role='group' style='min-width: 100px;'>`;
if (!row.validated) { if (!row.validated) {
html += bValidate; html += bValidate;
@ -1254,13 +1254,13 @@ function loadBomTable(table, options={}) {
html += bValid; html += bValid;
} }
html += bEdit; var buttons = '';
html += bSubs; buttons += bEdit;
html += bDelt; buttons += bSubs;
buttons += bDelt;
html += `</div>`; return wrapButtons(buttons);
return html;
} else { } else {
// Return a link to the external BOM // Return a link to the external BOM
@ -1273,7 +1273,7 @@ function loadBomTable(table, options={}) {
footerFormatter: function(data) { footerFormatter: function(data) {
return ` return `
<button class='btn btn-success float-right' type='button' title='{% trans "Add BOM Item" %}' id='bom-item-new-footer'> <button class='btn btn-success float-right' type='button' title='{% trans "Add BOM Item" %}' id='bom-item-new-footer'>
<span class='fas fa-plus-circle'></span> {% trans "Add BOM Item" %} ${makeIcon('fa-plus-circle')} {% trans "Add BOM Item" %}
</button> </button>
`; `;
} }
@ -1436,7 +1436,7 @@ function loadBomTable(table, options={}) {
var fields = bomItemFields(); var fields = bomItemFields();
constructForm(`/api/bom/${pk}/`, { constructForm(`{% url "api-bom-list" %}${pk}/`, {
fields: fields, fields: fields,
title: '{% trans "Edit BOM Item" %}', title: '{% trans "Edit BOM Item" %}',
focus: 'sub_part', focus: 'sub_part',
@ -1508,15 +1508,7 @@ function loadUsedInTable(table, part_id, options={}) {
params.part_detail = true; params.part_detail = true;
params.sub_part_detail = true; params.sub_part_detail = true;
var filters = {}; var filters = loadTableFilters('usedin', params);
if (!options.disableFilters) {
filters = loadTableFilters('usedin');
}
for (var key in params) {
filters[key] = params[key];
}
setupFilterList('usedin', $(table), options.filterTarget || '#filter-list-usedin'); setupFilterList('usedin', $(table), options.filterTarget || '#filter-list-usedin');

View File

@ -90,7 +90,7 @@ function editBuildOrder(pk) {
var fields = buildFormFields(); var fields = buildFormFields();
constructForm(`/api/build/${pk}/`, { constructForm(`{% url "api-build-list" %}${pk}/`, {
fields: fields, fields: fields,
reload: true, reload: true,
title: '{% trans "Edit Build Order" %}', title: '{% trans "Edit Build Order" %}',
@ -147,7 +147,7 @@ function newBuildOrder(options={}) {
*/ */
function duplicateBuildOrder(build_id, options={}) { function duplicateBuildOrder(build_id, options={}) {
inventreeGet(`/api/build/${build_id}/`, {}, { inventreeGet(`{% url "api-build-list" %}${build_id}/`, {}, {
success: function(data) { success: function(data) {
// Clear out data we do not want to be duplicated // Clear out data we do not want to be duplicated
delete data['pk']; delete data['pk'];
@ -166,7 +166,7 @@ function duplicateBuildOrder(build_id, options={}) {
function cancelBuildOrder(build_id, options={}) { function cancelBuildOrder(build_id, options={}) {
constructForm( constructForm(
`/api/build/${build_id}/cancel/`, `{% url "api-build-list" %}${build_id}/cancel/`,
{ {
method: 'POST', method: 'POST',
title: '{% trans "Cancel Build Order" %}', title: '{% trans "Cancel Build Order" %}',
@ -208,7 +208,7 @@ function cancelBuildOrder(build_id, options={}) {
/* Construct a form to "complete" (finish) a build order */ /* Construct a form to "complete" (finish) a build order */
function completeBuildOrder(build_id, options={}) { function completeBuildOrder(build_id, options={}) {
constructForm(`/api/build/${build_id}/finish/`, { constructForm(`{% url "api-build-list" %}${build_id}/finish/`, {
fieldsFunction: function(opts) { fieldsFunction: function(opts) {
var ctx = opts.context || {}; var ctx = opts.context || {};
@ -287,7 +287,7 @@ function createBuildOutput(build_id, options) {
// Request build order information from the server // Request build order information from the server
inventreeGet( inventreeGet(
`/api/build/${build_id}/`, `{% url "api-build-list" %}${build_id}/`,
{}, {},
{ {
success: function(build) { success: function(build) {
@ -312,7 +312,7 @@ function createBuildOutput(build_id, options) {
}; };
// Work out the next available serial numbers // Work out the next available serial numbers
inventreeGet(`/api/part/${build.part}/serial-numbers/`, {}, { inventreeGet(`{% url "api-part-list" %}${build.part}/serial-numbers/`, {}, {
success: function(data) { success: function(data) {
if (data.next) { if (data.next) {
fields.serial_numbers.placeholder = `{% trans "Next available serial number" %}: ${data.next}`; fields.serial_numbers.placeholder = `{% trans "Next available serial number" %}: ${data.next}`;
@ -341,7 +341,7 @@ function createBuildOutput(build_id, options) {
`; `;
} }
constructForm(`/api/build/${build_id}/create-output/`, { constructForm(`{% url "api-build-list" %}${build_id}/create-output/`, {
method: 'POST', method: 'POST',
title: '{% trans "Create Build Output" %}', title: '{% trans "Create Build Output" %}',
confirm: true, confirm: true,
@ -364,7 +364,7 @@ function createBuildOutput(build_id, options) {
*/ */
function makeBuildOutputButtons(output_id, build_info, options={}) { function makeBuildOutputButtons(output_id, build_info, options={}) {
var html = `<div class='btn-group float-right' role='group'>`; var html = '';
// Tracked parts? Must be individually allocated // Tracked parts? Must be individually allocated
if (options.has_bom_items) { if (options.has_bom_items) {
@ -398,17 +398,13 @@ function makeBuildOutputButtons(output_id, build_info, options={}) {
); );
// Add a button to "delete" this build output // Add a button to "delete" this build output
html += makeIconButton( html += makeDeleteButton(
'fa-trash-alt icon-red',
'button-output-delete', 'button-output-delete',
output_id, output_id,
'{% trans "Delete build output" %}', '{% trans "Delete build output" %}',
); );
html += `</div>`; return wrapButtons(html);
return html;
} }
@ -421,7 +417,7 @@ function makeBuildOutputButtons(output_id, build_info, options={}) {
*/ */
function unallocateStock(build_id, options={}) { function unallocateStock(build_id, options={}) {
var url = `/api/build/${build_id}/unallocate/`; var url = `{% url "api-build-list" %}${build_id}/unallocate/`;
var html = ` var html = `
<div class='alert alert-block alert-warning'> <div class='alert alert-block alert-warning'>
@ -486,7 +482,7 @@ function completeBuildOutputs(build_id, outputs, options={}) {
var buttons = `<div class='btn-group float-right' role='group'>`; var buttons = `<div class='btn-group float-right' role='group'>`;
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove row" %}'); buttons += makeRemoveButton('button-row-remove', pk, '{% trans "Remove row" %}');
buttons += '</div>'; buttons += '</div>';
@ -529,7 +525,7 @@ function completeBuildOutputs(build_id, outputs, options={}) {
</tbody> </tbody>
</table>`; </table>`;
constructForm(`/api/build/${build_id}/complete/`, { constructForm(`{% url "api-build-list" %}${build_id}/complete/`, {
method: 'POST', method: 'POST',
preFormContent: html, preFormContent: html,
fields: { fields: {
@ -647,7 +643,7 @@ function deleteBuildOutputs(build_id, outputs, options={}) {
var buttons = `<div class='btn-group float-right' role='group'>`; var buttons = `<div class='btn-group float-right' role='group'>`;
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove row" %}'); buttons += makeRemoveButton('button-row-remove', pk, '{% trans "Remove row" %}');
buttons += '</div>'; buttons += '</div>';
@ -690,7 +686,7 @@ function deleteBuildOutputs(build_id, outputs, options={}) {
</tbody> </tbody>
</table>`; </table>`;
constructForm(`/api/build/${build_id}/delete-outputs/`, { constructForm(`{% url "api-build-list" %}${build_id}/delete-outputs/`, {
method: 'POST', method: 'POST',
preFormContent: html, preFormContent: html,
fields: {}, fields: {},
@ -768,11 +764,7 @@ function loadBuildOrderAllocationTable(table, options={}) {
options.params['location_detail'] = true; options.params['location_detail'] = true;
options.params['stock_detail'] = true; options.params['stock_detail'] = true;
var filters = loadTableFilters('buildorderallocation'); var filters = loadTableFilters('buildorderallocation', options.params);
for (var key in options.params) {
filters[key] = options.params[key];
}
setupFilterList('buildorderallocation', $(table)); setupFilterList('buildorderallocation', $(table));
@ -893,7 +885,12 @@ function loadBuildOutputTable(build_info, options={}) {
filters[key] = params[key]; filters[key] = params[key];
} }
setupFilterList('builditems', $(table), options.filterTarget || '#filter-list-incompletebuilditems'); setupFilterList('builditems', $(table), options.filterTarget || '#filter-list-incompletebuilditems', {
labels: {
url: '{% url "api-stockitem-label-list" %}',
key: 'item',
}
});
function setupBuildOutputButtonCallbacks() { function setupBuildOutputButtonCallbacks() {
@ -1407,19 +1404,6 @@ function loadBuildOutputTable(build_info, options={}) {
); );
}); });
// Print stock item labels
$('#incomplete-output-print-label').click(function() {
var outputs = getTableData(table);
var stock_id_values = [];
outputs.forEach(function(output) {
stock_id_values.push(output.pk);
});
printStockItemLabels(stock_id_values);
});
$('#outputs-expand').click(function() { $('#outputs-expand').click(function() {
$(table).bootstrapTable('expandAllRows'); $(table).bootstrapTable('expandAllRows');
}); });
@ -1482,13 +1466,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
} }
// Filters // Filters
var filters = loadTableFilters('builditems'); let filters = loadTableFilters('builditems', options.params);
var params = options.params || {};
for (var key in params) {
filters[key] = params[key];
}
setupFilterList('builditems', $(table), options.filterTarget); setupFilterList('builditems', $(table), options.filterTarget);
@ -1703,6 +1681,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
name: 'build-allocation', name: 'build-allocation',
uniqueId: 'sub_part', uniqueId: 'sub_part',
search: options.search || false, search: options.search || false,
queryParams: filters,
original: options.params,
onPostBody: function(data) { onPostBody: function(data) {
// Setup button callbacks // Setup button callbacks
setupCallbacks(); setupCallbacks();
@ -1796,15 +1776,13 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
var pk = row.pk; var pk = row.pk;
var html = `<div class='btn-group float-right' role='group'>`; var html = '';
html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); html += makeEditButton('button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); html += makeDeleteButton('button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
html += `</div>`; return wrapButtons(html);
return html;
} }
} }
] ]
@ -1814,7 +1792,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
subTable.find('.button-allocation-edit').click(function() { subTable.find('.button-allocation-edit').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
constructForm(`/api/build/item/${pk}/`, { constructForm(`{% url "api-build-item-list" %}${pk}/`, {
fields: { fields: {
quantity: {}, quantity: {},
}, },
@ -1826,7 +1804,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
subTable.find('.button-allocation-delete').click(function() { subTable.find('.button-allocation-delete').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
constructForm(`/api/build/item/${pk}/`, { constructForm(`{% url "api-build-item-list" %}${pk}/`, {
method: 'DELETE', method: 'DELETE',
title: '{% trans "Remove Allocation" %}', title: '{% trans "Remove Allocation" %}',
onSuccess: reloadAllocationData, onSuccess: reloadAllocationData,
@ -1935,9 +1913,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
icons += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Consumable item" %}'></span>`; icons += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Consumable item" %}'></span>`;
} else { } else {
if (available_stock < (required - allocated)) { if (available_stock < (required - allocated)) {
icons += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "Insufficient stock available" %}'></span>`; icons += makeIconBadge('fa-times-circle icon-red', '{% trans "Insufficient stock available" %}');
} else { } else {
icons += `<span class='fas fa-check-circle icon-green float-right' title='{% trans "Sufficient stock available" %}'></span>`; icons += makeIconBadge('fa-check-circle icon-green', '{% trans "Sufficient stock available" %}');
} }
if (available_stock <= 0) { if (available_stock <= 0) {
@ -1953,13 +1931,15 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
} }
if (extra) { if (extra) {
icons += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`; icons += makeInfoButton('fa-info-circle icon-blue', extra);
} }
} }
} }
if (row.on_order && row.on_order > 0) { if (row.on_order && row.on_order > 0) {
icons += `<span class='fas fa-shopping-cart float-right' title='{% trans "On Order" %}: ${row.on_order}'></span>`; makeIconBadge('fa-shopping-cart', '{% trans "On Order" %}', {
content: row.on_order,
});
} }
return renderLink(text, url) + icons; return renderLink(text, url) + icons;
@ -2027,7 +2007,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
} }
// Generate action buttons for this build output // Generate action buttons for this build output
var html = `<div class='btn-group float-right' role='group'>`; let html = '';
if (allocatedQuantity(row) < requiredQuantity(row)) { if (allocatedQuantity(row) < requiredQuantity(row)) {
if (row.sub_part_detail.assembly) { if (row.sub_part_detail.assembly) {
@ -2041,17 +2021,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', row.sub_part, '{% trans "Allocate stock" %}'); html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', row.sub_part, '{% trans "Allocate stock" %}');
} }
html += makeIconButton( html += makeRemoveButton(
'fa-minus-circle icon-red', 'button-unallocate', row.sub_part, 'button-unallocate',
row.sub_part,
'{% trans "Unallocate stock" %}', '{% trans "Unallocate stock" %}',
{ {
disabled: allocatedQuantity(row) == 0, disabled: allocatedQuantity(row) == 0,
} }
); );
html += '</div>'; return wrapButtons(html);
return html;
} }
}, },
] ]
@ -2093,7 +2072,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
if (output_id) { if (output_id) {
// Request information on the particular build output (stock item) // Request information on the particular build output (stock item)
inventreeGet(`/api/stock/${output_id}/`, {}, { inventreeGet(`{% url "api-stock-list" %}${output_id}/`, {}, {
success: function(output) { success: function(output) {
if (output.quantity == 1 && output.serial != null) { if (output.quantity == 1 && output.serial != null) {
auto_fill_filters.serial = output.serial; auto_fill_filters.serial = output.serial;
@ -2112,8 +2091,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
var delete_button = `<div class='btn-group float-right' role='group'>`; var delete_button = `<div class='btn-group float-right' role='group'>`;
delete_button += makeIconButton( delete_button += makeRemoveButton(
'fa-times icon-red',
'button-row-remove', 'button-row-remove',
pk, pk,
'{% trans "Remove row" %}', '{% trans "Remove row" %}',
@ -2245,7 +2223,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
</table> </table>
`; `;
constructForm(`/api/build/${build_id}/allocate/`, { constructForm(`{% url "api-build-list" %}${build_id}/allocate/`, {
method: 'POST', method: 'POST',
fields: {}, fields: {},
preFormContent: html, preFormContent: html,
@ -2459,7 +2437,7 @@ function autoAllocateStockToBuild(build_id, bom_items=[], options={}) {
}, },
}; };
constructForm(`/api/build/${build_id}/auto-allocate/`, { constructForm(`{% url "api-build-list" %}${build_id}/auto-allocate/`, {
method: 'POST', method: 'POST',
fields: fields, fields: fields,
title: '{% trans "Allocate Stock Items" %}', title: '{% trans "Allocate Stock Items" %}',
@ -2484,21 +2462,19 @@ function loadBuildTable(table, options) {
var params = options.params || {}; var params = options.params || {};
var filters = {};
params['part_detail'] = true; params['part_detail'] = true;
if (!options.disableFilters) { var filters = loadTableFilters('build', params);
filters = loadTableFilters('build');
}
for (var key in params) {
filters[key] = params[key];
}
var filterTarget = options.filterTarget || null; var filterTarget = options.filterTarget || null;
setupFilterList('build', table, filterTarget, {download: true}); setupFilterList('build', table, filterTarget, {
download: true,
report: {
url: '{% url "api-build-report-list" %}',
key: 'build',
}
});
// Which display mode to use for the build table? // Which display mode to use for the build table?
var display_mode = inventreeLoad('build-table-display-mode', 'list'); var display_mode = inventreeLoad('build-table-display-mode', 'list');

View File

@ -4,23 +4,26 @@
constructForm, constructForm,
imageHoverIcon, imageHoverIcon,
loadTableFilters, loadTableFilters,
makeIconButton,
renderLink, renderLink,
setupFilterList, setupFilterList,
*/ */
/* exported /* exported
createCompany, createCompany,
createContact,
createManufacturerPart, createManufacturerPart,
createSupplierPart, createSupplierPart,
createSupplierPartPriceBreak, createSupplierPartPriceBreak,
deleteContacts,
deleteManufacturerParts, deleteManufacturerParts,
deleteManufacturerPartParameters, deleteManufacturerPartParameters,
deleteSupplierParts, deleteSupplierParts,
duplicateSupplierPart, duplicateSupplierPart,
editCompany, editCompany,
editContact,
editSupplierPartPriceBreak, editSupplierPartPriceBreak,
loadCompanyTable, loadCompanyTable,
loadContactTable,
loadManufacturerPartTable, loadManufacturerPartTable,
loadManufacturerPartParameterTable, loadManufacturerPartParameterTable,
loadSupplierPartTable, loadSupplierPartTable,
@ -197,7 +200,7 @@ function createSupplierPart(options={}) {
var header = ''; var header = '';
if (options.part) { if (options.part) {
var part_model = {}; var part_model = {};
inventreeGet(`/api/part/${options.part}/.*`, {}, { inventreeGet(`{% url "api-part-list" %}${options.part}/.*`, {}, {
async: false, async: false,
success: function(response) { success: function(response) {
part_model = response; part_model = response;
@ -226,7 +229,7 @@ function duplicateSupplierPart(part, options={}) {
var fields = options.fields || supplierPartFields(); var fields = options.fields || supplierPartFields();
// Retrieve information for the supplied part // Retrieve information for the supplied part
inventreeGet(`/api/company/part/${part}/`, {}, { inventreeGet(`{% url "api-supplier-part-list" %}${part}/`, {}, {
success: function(data) { success: function(data) {
// Remove fields which we do not want to duplicate // Remove fields which we do not want to duplicate
@ -234,7 +237,7 @@ function duplicateSupplierPart(part, options={}) {
delete data['available']; delete data['available'];
delete data['availability_updated']; delete data['availability_updated'];
constructForm(`/api/company/part/`, { constructForm('{% url "api-supplier-part-list" %}', {
method: 'POST', method: 'POST',
fields: fields, fields: fields,
title: '{% trans "Duplicate Supplier Part" %}', title: '{% trans "Duplicate Supplier Part" %}',
@ -260,7 +263,7 @@ function editSupplierPart(part, options={}) {
fields.part.hidden = true; fields.part.hidden = true;
} }
constructForm(`/api/company/part/${part}/`, { constructForm(`{% url "api-supplier-part-list" %}${part}/`, {
fields: fields, fields: fields,
title: options.title || '{% trans "Edit Supplier Part" %}', title: options.title || '{% trans "Edit Supplier Part" %}',
onSuccess: options.onSuccess onSuccess: options.onSuccess
@ -443,24 +446,18 @@ function createCompany(options={}) {
} }
/*
* Load company listing data into specified table.
*
* Args:
* - table: Table element on the page
* - url: Base URL for the API query
* - options: table options.
*/
function loadCompanyTable(table, url, options={}) { function loadCompanyTable(table, url, options={}) {
/*
* Load company listing data into specified table.
*
* Args:
* - table: Table element on the page
* - url: Base URL for the API query
* - options: table options.
*/
// Query parameters let params = options.params || {};
var params = options.params || {}; let filters = loadTableFilters('company', params);
var filters = loadTableFilters('company');
for (var key in params) {
filters[key] = params[key];
}
setupFilterList('company', $(table)); setupFilterList('company', $(table));
@ -547,6 +544,230 @@ function loadCompanyTable(table, url, options={}) {
} }
/*
* Construct a set of form fields for the Contact model
*/
function contactFields(options={}) {
let fields = {
company: {
icon: 'fa-building',
},
name: {
icon: 'fa-user',
},
phone: {
icon: 'fa-phone'
},
email: {
icon: 'fa-at',
},
role: {
icon: 'fa-user-tag',
},
};
if (options.company) {
fields.company.value = options.company;
}
return fields;
}
/*
* Launches a form to create a new Contact
*/
function createContact(options={}) {
let fields = options.fields || contactFields(options);
constructForm('{% url "api-contact-list" %}', {
method: 'POST',
fields: fields,
title: '{% trans "Create New Contact" %}',
onSuccess: function(response) {
handleFormSuccess(response, options);
}
});
}
/*
* Launches a form to edit an existing Contact
*/
function editContact(pk, options={}) {
let fields = options.fields || contactFields(options);
constructForm(`{% url "api-contact-list" %}${pk}/`, {
fields: fields,
title: '{% trans "Edit Contact" %}',
onSuccess: function(response) {
handleFormSuccess(response, options);
}
});
}
/*
* Launches a form to delete one (or more) contacts
*/
function deleteContacts(contacts, options={}) {
if (contacts.length == 0) {
return;
}
function renderContact(contact) {
return `
<tr>
<td>${contact.name}</td>
<td>${contact.email}</td>
<td>${contact.role}</td>
</tr>`;
}
let rows = '';
let ids = [];
contacts.forEach(function(contact) {
rows += renderContact(contact);
ids.push(contact.pk);
});
let html = `
<div class='alert alert-block alert-danger'>
{% trans "All selected contacts will be deleted" %}
</div>
<table class='table table-striped table-condensed'>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Role" %}</th>
</tr>
${rows}
</table>`;
constructForm('{% url "api-contact-list" %}', {
method: 'DELETE',
multi_delete: true,
title: '{% trans "Delete Contacts" %}',
preFormContent: html,
form_data: {
items: ids,
},
onSuccess: function(response) {
handleFormSuccess(response, options);
}
});
}
/*
* Load table listing company contacts
*/
function loadContactTable(table, options={}) {
var params = options.params || {};
var filters = loadTableFilters('contact', params);
setupFilterList('contact', $(table), '#filter-list-contacts');
$(table).inventreeTable({
url: '{% url "api-contact-list" %}',
queryParams: filters,
original: params,
idField: 'pk',
uniqueId: 'pk',
sidePagination: 'server',
formatNoMatches: function() {
return '{% trans "No contacts found" %}';
},
showColumns: true,
name: 'contacts',
columns: [
{
field: 'name',
title: '{% trans "Name" %}',
sortable: true,
switchable: false,
},
{
field: 'phone',
title: '{% trans "Phone Number" %}',
sortable: false,
switchable: true,
},
{
field: 'email',
title: '{% trans "Email Address" %}',
sortable: false,
switchable: true,
},
{
field: 'role',
title: '{% trans "Role" %}',
sortable: false,
switchable: false,
},
{
field: 'actions',
title: '',
sortable: false,
switchable: false,
visible: options.allow_edit || options.allow_delete,
formatter: function(value, row) {
var pk = row.pk;
let html = '';
if (options.allow_edit) {
html += makeEditButton('btn-contact-edit', pk, '{% trans "Edit Contact" %}');
}
if (options.allow_delete) {
html += makeDeleteButton('btn-contact-delete', pk, '{% trans "Delete Contact" %}');
}
return wrapButtons(html);
}
}
],
onPostBody: function() {
// Edit button callback
if (options.allow_edit) {
$(table).find('.btn-contact-edit').click(function() {
var pk = $(this).attr('pk');
editContact(pk, {
onSuccess: function() {
$(table).bootstrapTable('refresh');
}
});
});
}
// Delete button callback
if (options.allow_delete) {
$(table).find('.btn-contact-delete').click(function() {
var pk = $(this).attr('pk');
var row = $(table).bootstrapTable('getRowByUniqueId', pk);
if (row && row.pk) {
deleteContacts([row], {
onSuccess: function() {
$(table).bootstrapTable('refresh');
}
});
}
});
}
}
});
}
/* Delete one or more ManufacturerPart objects from the database. /* Delete one or more ManufacturerPart objects from the database.
* - User will be provided with a modal form, showing all the parts to be deleted. * - User will be provided with a modal form, showing all the parts to be deleted.
* - Delete operations are performed sequentialy, not simultaneously * - Delete operations are performed sequentialy, not simultaneously
@ -653,21 +874,16 @@ function deleteManufacturerPartParameters(selections, options={}) {
} }
/*
* Load manufacturer part table
*/
function loadManufacturerPartTable(table, url, options) { function loadManufacturerPartTable(table, url, options) {
/*
* Load manufacturer part table
*
*/
// Query parameters // Query parameters
var params = options.params || {}; var params = options.params || {};
// Load filters // Load filters
var filters = loadTableFilters('manufacturer-part'); var filters = loadTableFilters('manufacturer-part', params);
for (var key in params) {
filters[key] = params[key];
}
var filterTarget = options.filterTarget || '#filter-list-manufacturer-part'; var filterTarget = options.filterTarget || '#filter-list-manufacturer-part';
@ -703,11 +919,11 @@ function loadManufacturerPartTable(table, url, options) {
var html = imageHoverIcon(row.part_detail.thumbnail) + renderLink(value, url); var html = imageHoverIcon(row.part_detail.thumbnail) + renderLink(value, url);
if (row.part_detail.is_template) { if (row.part_detail.is_template) {
html += `<span class='fas fa-clone float-right' title='{% trans "Template part" %}'></span>`; html += makeIconBadge('fa-clone', '{% trans "Template part" %}');
} }
if (row.part_detail.assembly) { if (row.part_detail.assembly) {
html += `<span class='fas fa-tools float-right' title='{% trans "Assembled part" %}'></span>`; html += makeIconBadge('fa-tools', '{% trans "Assembled part" %}');
} }
if (!row.part_detail.active) { if (!row.part_detail.active) {
@ -764,16 +980,13 @@ function loadManufacturerPartTable(table, url, options) {
sortable: false, sortable: false,
switchable: false, switchable: false,
formatter: function(value, row) { formatter: function(value, row) {
var pk = row.pk; let pk = row.pk;
let html = '';
var html = `<div class='btn-group float-right' role='group'>`; html += makeEditButton('button-manufacturer-part-edit', pk, '{% trans "Edit manufacturer part" %}');
html += makeDeleteButton('button-manufacturer-part-delete', pk, '{% trans "Delete manufacturer part" %}');
html += makeIconButton('fa-edit icon-blue', 'button-manufacturer-part-edit', pk, '{% trans "Edit manufacturer part" %}'); return wrapButtons(html);
html += makeIconButton('fa-trash-alt icon-red', 'button-manufacturer-part-delete', pk, '{% trans "Delete manufacturer part" %}');
html += '</div>';
return html;
} }
} }
], ],
@ -810,20 +1023,15 @@ function loadManufacturerPartTable(table, url, options) {
} }
/*
* Load table of ManufacturerPartParameter objects
*/
function loadManufacturerPartParameterTable(table, url, options) { function loadManufacturerPartParameterTable(table, url, options) {
/*
* Load table of ManufacturerPartParameter objects
*/
var params = options.params || {}; var params = options.params || {};
// Load filters // Load filters
var filters = loadTableFilters('manufacturer-part-parameters'); var filters = loadTableFilters('manufacturer-part-parameters', params);
// Overwrite explicit parameters
for (var key in params) {
filters[key] = params[key];
}
setupFilterList('manufacturer-part-parameters', $(table)); setupFilterList('manufacturer-part-parameters', $(table));
@ -867,17 +1075,13 @@ function loadManufacturerPartParameterTable(table, url, options) {
switchable: false, switchable: false,
sortable: false, sortable: false,
formatter: function(value, row) { formatter: function(value, row) {
let pk = row.pk;
let html = '';
var pk = row.pk; html += makeEditButton('button-parameter-edit', pk, '{% trans "Edit parameter" %}');
html += makeDeleteButton('button-parameter-delete', pk, '{% trans "Delete parameter" %}');
var html = `<div class='btn-group float-right' role='group'>`; return wrapButtons(html);
html += makeIconButton('fa-edit icon-blue', 'button-parameter-edit', pk, '{% trans "Edit parameter" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-parameter-delete', pk, '{% trans "Delete parameter" %}');
html += `</div>`;
return html;
} }
} }
], ],
@ -886,27 +1090,23 @@ function loadManufacturerPartParameterTable(table, url, options) {
$(table).find('.button-parameter-edit').click(function() { $(table).find('.button-parameter-edit').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
constructForm(`/api/company/part/manufacturer/parameter/${pk}/`, { constructForm(`{% url "api-manufacturer-part-parameter-list" %}${pk}/`, {
fields: { fields: {
name: {}, name: {},
value: {}, value: {},
units: {}, units: {},
}, },
title: '{% trans "Edit Parameter" %}', title: '{% trans "Edit Parameter" %}',
onSuccess: function() { refreshTable: table,
$(table).bootstrapTable('refresh');
}
}); });
}); });
$(table).find('.button-parameter-delete').click(function() { $(table).find('.button-parameter-delete').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
constructForm(`/api/company/part/manufacturer/parameter/${pk}/`, { constructForm(`{% url "api-manufacturer-part-parameter-list" %}${pk}/`, {
method: 'DELETE', method: 'DELETE',
title: '{% trans "Delete Parameter" %}', title: '{% trans "Delete Parameter" %}',
onSuccess: function() { refreshTable: table,
$(table).bootstrapTable('refresh');
}
}); });
}); });
} }
@ -914,21 +1114,16 @@ function loadManufacturerPartParameterTable(table, url, options) {
} }
/*
* Load supplier part table
*/
function loadSupplierPartTable(table, url, options) { function loadSupplierPartTable(table, url, options) {
/*
* Load supplier part table
*
*/
// Query parameters // Query parameters
var params = options.params || {}; var params = options.params || {};
// Load filters // Load filters
var filters = loadTableFilters('supplier-part'); var filters = loadTableFilters('supplier-part', params);
for (var key in params) {
filters[key] = params[key];
}
setupFilterList('supplier-part', $(table)); setupFilterList('supplier-part', $(table));
@ -964,11 +1159,11 @@ function loadSupplierPartTable(table, url, options) {
var html = imageHoverIcon(row.part_detail.thumbnail) + renderLink(value, url); var html = imageHoverIcon(row.part_detail.thumbnail) + renderLink(value, url);
if (row.part_detail.is_template) { if (row.part_detail.is_template) {
html += `<span class='fas fa-clone float-right' title='{% trans "Template part" %}'></span>`; html += makeIconBadge('fa-clone', '{% trans "Template part" %}');
} }
if (row.part_detail.assembly) { if (row.part_detail.assembly) {
html += `<span class='fas fa-tools float-right' title='{% trans "Assembled part" %}'></span>`; html += makeIconBadge('fa-tools', '{% trans "Assembled part" %}');
} }
if (!row.part_detail.active) { if (!row.part_detail.active) {
@ -1088,9 +1283,13 @@ function loadSupplierPartTable(table, url, options) {
sortable: true, sortable: true,
formatter: function(value, row) { formatter: function(value, row) {
if (row.availability_updated) { if (row.availability_updated) {
var html = formatDecimal(value); let html = formatDecimal(value);
var date = renderDate(row.availability_updated, {showTime: true}); let date = renderDate(row.availability_updated, {showTime: true});
html += `<span class='fas fa-info-circle float-right' title='{% trans "Last Updated" %}: ${date}'></span>`;
html += makeIconBadge(
'fa-info-circle',
`{% trans "Last Updated" %}: ${date}`
);
return html; return html;
} else { } else {
return '-'; return '-';
@ -1108,16 +1307,13 @@ function loadSupplierPartTable(table, url, options) {
sortable: false, sortable: false,
switchable: false, switchable: false,
formatter: function(value, row) { formatter: function(value, row) {
var pk = row.pk; let pk = row.pk;
let html = '';
var html = `<div class='btn-group float-right' role='group'>`; html += makeEditButton('button-supplier-part-edit', pk, '{% trans "Edit supplier part" %}');
html += makeDeleteButton('button-supplier-part-delete', pk, '{% trans "Delete supplier part" %}');
html += makeIconButton('fa-edit icon-blue', 'button-supplier-part-edit', pk, '{% trans "Edit supplier part" %}'); return wrapButtons(html);
html += makeIconButton('fa-trash-alt icon-red', 'button-supplier-part-delete', pk, '{% trans "Delete supplier part" %}');
html += '</div>';
return html;
} }
} }
], ],
@ -1166,24 +1362,20 @@ function loadSupplierPriceBreakTable(options={}) {
table.find('.button-price-break-delete').click(function() { table.find('.button-price-break-delete').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
constructForm(`/api/company/price-break/${pk}/`, { constructForm(`{% url "api-part-supplier-price-list" %}${pk}/`, {
method: 'DELETE', method: 'DELETE',
title: '{% trans "Delete Price Break" %}', title: '{% trans "Delete Price Break" %}',
onSuccess: function() { refreshTable: table,
table.bootstrapTable('refresh');
},
}); });
}); });
table.find('.button-price-break-edit').click(function() { table.find('.button-price-break-edit').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
constructForm(`/api/company/price-break/${pk}/`, { constructForm(`{% url "api-part-supplier-price-list" %}${pk}/`, {
fields: supplierPartPriceBreakFields(), fields: supplierPartPriceBreakFields(),
title: '{% trans "Edit Price Break" %}', title: '{% trans "Edit Price Break" %}',
onSuccess: function() { refreshTable: table,
table.bootstrapTable('refresh');
}
}); });
}); });
} }
@ -1231,10 +1423,12 @@ function loadSupplierPriceBreakTable(options={}) {
formatter: function(value, row) { formatter: function(value, row) {
var html = renderDate(value); var html = renderDate(value);
html += `<div class='btn-group float-right' role='group'>`; let buttons = '';
html += makeIconButton('fa-edit icon-blue', 'button-price-break-edit', row.pk, '{% trans "Edit price break" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-price-break-delete', row.pk, '{% trans "Delete price break" %}'); buttons += makeEditButton('button-price-break-edit', row.pk, '{% trans "Edit price break" %}');
html += `</div>`; buttons += makeDeleteButton('button-price-break-delete', row.pk, '{% trans "Delete price break" %}');
html += wrapButtons(buttons);
return html; return html;
} }

View File

@ -43,7 +43,7 @@ function defaultFilters() {
* @param tableKey - String key for the particular table * @param tableKey - String key for the particular table
* @param defaults - Default filters for this table e.g. 'cascade=1&location=5' * @param defaults - Default filters for this table e.g. 'cascade=1&location=5'
*/ */
function loadTableFilters(tableKey) { function loadTableFilters(tableKey, query={}) {
var lookup = 'table-filters-' + tableKey.toLowerCase(); var lookup = 'table-filters-' + tableKey.toLowerCase();
@ -67,6 +67,9 @@ function loadTableFilters(tableKey) {
} }
}); });
// Override configurable filters with hard-coded query
Object.assign(filters, query);
return filters; return filters;
} }
@ -258,6 +261,18 @@ function generateFilterInput(tableKey, filterKey) {
} }
/*
* Helper function to make a 'filter' style button
*/
function makeFilterButton(options={}) {
return `
<button id='${options.id}' title='${options.title}' class='btn btn-outline-secondary filter-button'>
<span class='fas ${options.icon}'></span>
</button>`;
}
/** /**
* Configure a filter list for a given table * Configure a filter list for a given table
* *
@ -290,21 +305,58 @@ function setupFilterList(tableKey, table, target, options={}) {
// One blank slate, please // One blank slate, please
element.empty(); element.empty();
// Construct a set of buttons
var buttons = ''; var buttons = '';
// Add 'print reports' button
if (options.report && global_settings.REPORT_ENABLE) {
buttons += makeFilterButton({
id: `print-report-${tableKey}`,
title: options.report.title || '{% trans "Print reports for selected items" %}',
icon: 'fa-print',
});
}
// Add 'print labels' button
if (options.labels && global_settings.LABEL_ENABLE) {
buttons += makeFilterButton({
id: `print-labels-${tableKey}`,
title: options.labels.title || '{% trans "Print labels for selected items" %}',
icon: 'fa-tag',
});
}
// Add download button // Add download button
if (options.download) { if (options.download) {
buttons += `<button id='download-${tableKey}' title='{% trans "Download data" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-download'></span></button>`; buttons += makeFilterButton({
id: `download-${tableKey}`,
title: '{% trans "Download table data" %}',
icon: 'fa-download',
});
} }
buttons += `<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-redo-alt'></span></button>`; buttons += makeFilterButton({
id: `reload-${tableKey}`,
title: '{% trans "Reload table data" %}',
icon: 'fa-redo-alt',
});
// If there are filters defined for this table, add more buttons // If there are filters defined for this table, add more buttons
if (!jQuery.isEmptyObject(getAvailableTableFilters(tableKey))) { if (!jQuery.isEmptyObject(getAvailableTableFilters(tableKey))) {
buttons += `<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-filter'></span></button>`;
buttons += makeFilterButton({
id: add,
title: '{% trans "Add new filter" %}',
icon: 'fa-filter',
});
if (Object.keys(filters).length > 0) { if (Object.keys(filters).length > 0) {
buttons += `<button id='${clear}' title='{% trans "Clear all filters" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-backspace icon-red'></span></button>`; buttons += makeFilterButton({
id: clear,
title: '{% trans "Clear all filters" %}',
icon: 'fa-backspace icon-red',
});
} }
} }
@ -331,6 +383,42 @@ function setupFilterList(tableKey, table, target, options={}) {
element.append(filter_tag); element.append(filter_tag);
} }
// Callback for printing reports
if (options.report && global_settings.REPORT_ENABLE) {
element.find(`#print-report-${tableKey}`).click(function() {
let data = getTableData(table);
let items = [];
data.forEach(function(row) {
items.push(row.pk);
});
printReports({
items: items,
url: options.report.url,
key: options.report.key
});
});
}
// Callback for printing labels
if (options.labels && global_settings.LABEL_ENABLE) {
element.find(`#print-labels-${tableKey}`).click(function() {
let data = getTableData(table);
let items = [];
data.forEach(function(row) {
items.push(row.pk);
});
printLabels({
items: items,
url: options.labels.url,
key: options.labels.key,
});
});
}
// Callback for reloading the table // Callback for reloading the table
element.find(`#reload-${tableKey}`).click(function() { element.find(`#reload-${tableKey}`).click(function() {
reloadTableFilters(table); reloadTableFilters(table);

Some files were not shown because too many files have changed in this diff Show More