mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
[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:
parent
d4a64b4f7d
commit
27aa16d55d
@ -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,
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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:
|
||||||
|
@ -315,9 +315,7 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-button {
|
.filter-button {
|
||||||
padding: 2px;
|
padding: 6px;
|
||||||
padding-left: 4px;
|
|
||||||
padding-right: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-input {
|
.filter-input {
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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',
|
||||||
|
}
|
||||||
|
@ -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'),
|
||||||
|
@ -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'),
|
||||||
|
@ -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,
|
||||||
|
@ -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 %}
|
||||||
|
|
||||||
|
@ -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 %}
|
||||||
|
|
||||||
|
@ -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 %}
|
||||||
|
@ -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'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
@ -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'),
|
||||||
|
@ -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'),
|
||||||
|
@ -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."""
|
||||||
|
@ -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'),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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."""
|
||||||
|
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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 %}
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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."""
|
||||||
|
|
||||||
|
@ -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'),
|
||||||
]))
|
]))
|
||||||
|
|
||||||
|
@ -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'),
|
||||||
|
@ -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
53
InvenTree/order/fixtures/return_order.yaml
Normal file
53
InvenTree/order/fixtures/return_order.yaml
Normal 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
|
64
InvenTree/order/migrations/0081_auto_20230314_0725.py
Normal file
64
InvenTree/order/migrations/0081_auto_20230314_0725.py
Normal 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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
30
InvenTree/order/migrations/0082_auto_20230314_1259.py
Normal file
30
InvenTree/order/migrations/0082_auto_20230314_1259.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
35
InvenTree/order/migrations/0083_returnorderextraline.py
Normal file
35
InvenTree/order/migrations/0083_returnorderextraline.py
Normal 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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
33
InvenTree/order/migrations/0084_auto_20230321_1111.py
Normal file
33
InvenTree/order/migrations/0084_auto_20230321_1111.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
49
InvenTree/order/migrations/0085_auto_20230322_1056.py
Normal file
49
InvenTree/order/migrations/0085_auto_20230322_1056.py
Normal 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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
23
InvenTree/order/migrations/0086_auto_20230323_1108.py
Normal file
23
InvenTree/order/migrations/0086_auto_20230323_1108.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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',
|
||||||
|
)
|
||||||
|
@ -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',
|
||||||
|
])
|
||||||
|
@ -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 %}
|
||||||
|
@ -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',
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
|
235
InvenTree/order/templates/order/return_order_base.html
Normal file
235
InvenTree/order/templates/order/return_order_base.html
Normal 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 %}
|
209
InvenTree/order/templates/order/return_order_detail.html
Normal file
209
InvenTree/order/templates/order/return_order_detail.html
Normal 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 %}
|
10
InvenTree/order/templates/order/return_order_sidebar.html
Normal file
10
InvenTree/order/templates/order/return_order_sidebar.html
Normal 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" %}
|
55
InvenTree/order/templates/order/return_orders.html
Normal file
55
InvenTree/order/templates/order/return_orders.html
Normal 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 %}
|
@ -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 %}
|
||||||
|
|
||||||
|
@ -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',
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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)),
|
||||||
]
|
]
|
||||||
|
@ -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)
|
||||||
|
@ -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)"""
|
||||||
|
|
||||||
|
@ -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'),
|
||||||
|
@ -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)
|
||||||
|
@ -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" %}
|
||||||
|
@ -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() {
|
||||||
|
@ -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 %}
|
||||||
|
|
||||||
|
@ -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."""
|
||||||
|
@ -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)),
|
||||||
|
@ -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'),
|
||||||
|
@ -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)
|
||||||
|
@ -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'),
|
||||||
|
@ -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)
|
||||||
|
31
InvenTree/report/migrations/0018_returnorderreport.py
Normal file
31
InvenTree/report/migrations/0018_returnorderreport.py
Normal 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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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"""
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -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 %}
|
@ -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 %}
|
||||||
|
|
||||||
|
@ -0,0 +1 @@
|
|||||||
|
{% extends "report/inventree_return_order_report_base.html" %}
|
@ -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 %}
|
@ -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'>
|
||||||
|
@ -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 %}
|
||||||
|
@ -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)
|
||||||
|
@ -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()
|
||||||
|
@ -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'),
|
||||||
|
@ -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
|
||||||
|
@ -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',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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 %}
|
||||||
|
@ -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 %}
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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'),
|
||||||
])),
|
])),
|
||||||
|
@ -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) {
|
||||||
|
20
InvenTree/templates/InvenTree/settings/returns.html
Normal file
20
InvenTree/templates/InvenTree/settings/returns.html
Normal 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 %}
|
@ -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 %}
|
||||||
|
@ -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');
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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 %}
|
||||||
|
@ -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' %}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
11
InvenTree/templates/email/return_order_received.html
Normal file
11
InvenTree/templates/email/return_order_received.html
Normal 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 %}
|
@ -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();
|
||||||
|
|
||||||
|
@ -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';
|
||||||
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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');
|
||||||
|
|
||||||
|
@ -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');
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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
Loading…
Reference in New Issue
Block a user