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,
|
||||
'partcategory': part.api.CategoryList,
|
||||
'purchaseorder': order.api.PurchaseOrderList,
|
||||
'returnorder': order.api.ReturnOrderList,
|
||||
'salesorder': order.api.SalesOrderList,
|
||||
'stockitem': stock.api.StockList,
|
||||
'stocklocation': stock.api.StockLocationList,
|
||||
|
@ -165,6 +165,26 @@ class ExchangeRateMixin:
|
||||
class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
"""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):
|
||||
"""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)
|
||||
|
||||
if expected_code is not None:
|
||||
|
||||
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)
|
||||
self.checkResponse(url, 'GET', expected_code, response)
|
||||
|
||||
return response
|
||||
|
||||
@ -213,17 +221,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
|
||||
response = self.client.post(url, data=data, format=format)
|
||||
|
||||
if expected_code is not None:
|
||||
|
||||
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)
|
||||
self.checkResponse(url, 'POST', expected_code, response)
|
||||
|
||||
return response
|
||||
|
||||
@ -235,8 +233,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
|
||||
response = self.client.delete(url, data=data, format=format)
|
||||
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
self.checkResponse(url, 'DELETE', expected_code, response)
|
||||
|
||||
return response
|
||||
|
||||
@ -244,8 +241,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
"""Issue a PATCH request."""
|
||||
response = self.client.patch(url, data=data, format=format)
|
||||
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
self.checkResponse(url, 'PATCH', expected_code, response)
|
||||
|
||||
return response
|
||||
|
||||
@ -253,13 +249,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
"""Issue a PUT request."""
|
||||
response = self.client.put(url, data=data, format=format)
|
||||
|
||||
if expected_code is not None:
|
||||
|
||||
if response.status_code != expected_code:
|
||||
print(f"Unexpected response at '{url}':")
|
||||
print(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
self.checkResponse(url, 'PUT', expected_code, response)
|
||||
|
||||
return response
|
||||
|
||||
@ -267,8 +257,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
"""Issue an OPTIONS request."""
|
||||
response = self.client.options(url, format='json')
|
||||
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
self.checkResponse(url, 'OPTIONS', expected_code, response)
|
||||
|
||||
return response
|
||||
|
||||
|
@ -2,16 +2,21 @@
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
- Add metadata to several more models
|
||||
|
||||
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
|
||||
- Adds 'total_in_stock' to Part serializer, and supports API ordering
|
||||
|
@ -4,6 +4,7 @@
|
||||
|
||||
import InvenTree.status
|
||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||
ReturnOrderLineStatus, ReturnOrderStatus,
|
||||
SalesOrderStatus, StockHistoryCode,
|
||||
StockStatus)
|
||||
from users.models import RuleSet, check_user_role
|
||||
@ -58,6 +59,8 @@ def status_codes(request):
|
||||
|
||||
return {
|
||||
# Expose the StatusCode classes to the templates
|
||||
'ReturnOrderStatus': ReturnOrderStatus,
|
||||
'ReturnOrderLineStatus': ReturnOrderLineStatus,
|
||||
'SalesOrderStatus': SalesOrderStatus,
|
||||
'PurchaseOrderStatus': PurchaseOrderStatus,
|
||||
'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.
|
||||
"""
|
||||
|
||||
if money is None or money.amount is None:
|
||||
if money in [None, '']:
|
||||
return '-'
|
||||
|
||||
if type(money) is not Money:
|
||||
return '-'
|
||||
|
||||
if currency is not None:
|
||||
|
@ -315,9 +315,7 @@ main {
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
padding: 2px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
|
@ -155,30 +155,37 @@ function inventreeDocReady() {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Determine if a transfer (e.g. drag-and-drop) is a file transfer
|
||||
*/
|
||||
function isFileTransfer(transfer) {
|
||||
/* Determine if a transfer (e.g. drag-and-drop) is a file transfer
|
||||
*/
|
||||
|
||||
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:
|
||||
element - HTML element lookup string e.g. "#drop-div"
|
||||
url - URL to POST the file to
|
||||
options - object with following possible values:
|
||||
label - Label of the file to upload (default='file')
|
||||
data - Other form data to upload
|
||||
success - Callback function in case of success
|
||||
error - Callback function in case of error
|
||||
method - HTTP method
|
||||
*/
|
||||
Params:
|
||||
element - HTML element lookup string e.g. "#drop-div"
|
||||
url - URL to POST the file to
|
||||
options - object with following possible values:
|
||||
label - Label of the file to upload (default='file')
|
||||
data - Other form data to upload
|
||||
success - Callback function in case of success
|
||||
error - Callback function in case of error
|
||||
method - HTTP method
|
||||
*/
|
||||
function enableDragAndDrop(elementId, url, options={}) {
|
||||
|
||||
var data = options.data || {};
|
||||
|
||||
let element = $(elementId);
|
||||
|
||||
if (!element.exists()) {
|
||||
console.error(`enableDragAndDrop called with invalid target: '${elementId}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
$(element).on('drop', function(event) {
|
||||
|
||||
var transfer = event.originalEvent.dataTransfer;
|
||||
@ -200,6 +207,11 @@ function enableDragAndDrop(element, url, options) {
|
||||
formData,
|
||||
{
|
||||
success: function(data, status, xhr) {
|
||||
// Reload a table
|
||||
if (options.refreshTable) {
|
||||
reloadBootstrapTable(options.refreshTable);
|
||||
}
|
||||
|
||||
if (options.success) {
|
||||
options.success(data, status, xhr);
|
||||
}
|
||||
|
@ -247,10 +247,14 @@ class StockHistoryCode(StatusCode):
|
||||
BUILD_CONSUMED = 57
|
||||
|
||||
# Sales order codes
|
||||
SHIPPED_AGAINST_SALES_ORDER = 60
|
||||
|
||||
# Purchase order codes
|
||||
RECEIVED_AGAINST_PURCHASE_ORDER = 70
|
||||
|
||||
# Return order codes
|
||||
RETURNED_AGAINST_RETURN_ORDER = 80
|
||||
|
||||
# Customer actions
|
||||
SENT_TO_CUSTOMER = 100
|
||||
RETURNED_FROM_CUSTOMER = 105
|
||||
@ -289,8 +293,11 @@ class StockHistoryCode(StatusCode):
|
||||
BUILD_OUTPUT_COMPLETED: _('Build order output completed'),
|
||||
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,
|
||||
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'^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'^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'^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'^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'^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'),
|
||||
|
@ -1,6 +1,6 @@
|
||||
"""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.contrib.auth.models import User
|
||||
|
||||
@ -509,13 +509,13 @@ build_api_urls = [
|
||||
|
||||
# Attachments
|
||||
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'),
|
||||
])),
|
||||
|
||||
# Build Items
|
||||
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'^.*$', BuildItemDetail.as_view(), name='api-build-item-detail'),
|
||||
])),
|
||||
@ -523,7 +523,7 @@ build_api_urls = [
|
||||
])),
|
||||
|
||||
# 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'^auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-allocate'),
|
||||
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
|
||||
|
||||
import common.notifications
|
||||
from part import models as PartModels
|
||||
from stock import models as StockModels
|
||||
from users import models as UserModels
|
||||
|
||||
import part.models
|
||||
import stock.models
|
||||
import users.models
|
||||
|
||||
|
||||
class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
|
||||
@ -279,7 +280,7 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
|
||||
)
|
||||
|
||||
responsible = models.ForeignKey(
|
||||
UserModels.Owner,
|
||||
users.models.Owner,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Responsible'),
|
||||
@ -395,9 +396,9 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
|
||||
|
||||
if in_stock is not None:
|
||||
if in_stock:
|
||||
outputs = outputs.filter(StockModels.StockItem.IN_STOCK_FILTER)
|
||||
outputs = outputs.filter(stock.models.StockItem.IN_STOCK_FILTER)
|
||||
else:
|
||||
outputs = outputs.exclude(StockModels.StockItem.IN_STOCK_FILTER)
|
||||
outputs = outputs.exclude(stock.models.StockItem.IN_STOCK_FILTER)
|
||||
|
||||
# Filter by 'complete' status
|
||||
complete = kwargs.get('complete', None)
|
||||
@ -659,7 +660,7 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
|
||||
else:
|
||||
serial = None
|
||||
|
||||
output = StockModels.StockItem.objects.create(
|
||||
output = stock.models.StockItem.objects.create(
|
||||
quantity=1,
|
||||
location=location,
|
||||
part=self.part,
|
||||
@ -677,11 +678,11 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
|
||||
|
||||
parts = bom_item.get_valid_parts_for_allocation()
|
||||
|
||||
items = StockModels.StockItem.objects.filter(
|
||||
items = stock.models.StockItem.objects.filter(
|
||||
part__in=parts,
|
||||
serial=str(serial),
|
||||
quantity=1,
|
||||
).filter(StockModels.StockItem.IN_STOCK_FILTER)
|
||||
).filter(stock.models.StockItem.IN_STOCK_FILTER)
|
||||
|
||||
"""
|
||||
Test if there is a matching serial number!
|
||||
@ -701,7 +702,7 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
|
||||
else:
|
||||
"""Create a single build output of the given quantity."""
|
||||
|
||||
StockModels.StockItem.objects.create(
|
||||
stock.models.StockItem.objects.create(
|
||||
quantity=quantity,
|
||||
location=location,
|
||||
part=self.part,
|
||||
@ -877,7 +878,7 @@ class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
|
||||
)
|
||||
|
||||
# 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
|
||||
available_stock = available_stock.filter(
|
||||
@ -1220,7 +1221,7 @@ class BuildItem(MetadataMixin, models.Model):
|
||||
'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
|
||||
|
||||
"""
|
||||
@ -1259,8 +1260,8 @@ class BuildItem(MetadataMixin, models.Model):
|
||||
for idx, ancestor in enumerate(ancestors):
|
||||
|
||||
try:
|
||||
bom_item = PartModels.BomItem.objects.get(part=self.build.part, sub_part=ancestor)
|
||||
except PartModels.BomItem.DoesNotExist:
|
||||
bom_item = part.models.BomItem.objects.get(part=self.build.part, sub_part=ancestor)
|
||||
except part.models.BomItem.DoesNotExist:
|
||||
continue
|
||||
|
||||
# A matching BOM item has been found!
|
||||
@ -1350,7 +1351,7 @@ class BuildItem(MetadataMixin, models.Model):
|
||||
# Internal model which links part <-> sub_part
|
||||
# We need to track this separately, to allow for "variant' stock
|
||||
bom_item = models.ForeignKey(
|
||||
PartModels.BomItem,
|
||||
part.models.BomItem,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='allocate_build_items',
|
||||
blank=True, null=True,
|
||||
|
@ -247,7 +247,11 @@ src="{% static 'img/blank_image.png' %}"
|
||||
|
||||
{% if report_enabled %}
|
||||
$('#print-build-report').click(function() {
|
||||
printBuildReports([{{ build.pk }}]);
|
||||
printReports({
|
||||
items: [{{ build.pk }}],
|
||||
key: 'build',
|
||||
url: '{% url "api-build-report-list" %}',
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
|
@ -268,19 +268,6 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
</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' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -367,20 +354,6 @@ onPanelLoad('children', 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" %}', {
|
||||
filters: {
|
||||
build: {{ build.pk }},
|
||||
@ -409,10 +382,6 @@ onPanelLoad('notes', function() {
|
||||
);
|
||||
});
|
||||
|
||||
function reloadTable() {
|
||||
$('#allocation-table-untracked').bootstrapTable('refresh');
|
||||
}
|
||||
|
||||
onPanelLoad('outputs', function() {
|
||||
{% if build.active %}
|
||||
|
||||
|
@ -26,20 +26,6 @@
|
||||
<div id='button-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<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" %}
|
||||
</div>
|
||||
</div>
|
||||
@ -62,17 +48,4 @@ loadBuildTable($("#build-table"), {
|
||||
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 %}
|
||||
|
@ -1,13 +1,13 @@
|
||||
"""URL lookup for Build app."""
|
||||
|
||||
from django.urls import include, re_path
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
build_urls = [
|
||||
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
path(r'<int:pk>/', include([
|
||||
re_path(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
|
||||
])),
|
||||
|
||||
|
@ -457,7 +457,7 @@ settings_api_urls = [
|
||||
# Notification settings
|
||||
re_path(r'^notification/', include([
|
||||
# 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
|
||||
re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notifcation-setting-list'),
|
||||
@ -486,7 +486,7 @@ common_api_urls = [
|
||||
# Notifications
|
||||
re_path(r'^notifications/', include([
|
||||
# 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'),
|
||||
])),
|
||||
# Read all
|
||||
@ -498,7 +498,7 @@ common_api_urls = [
|
||||
|
||||
# News
|
||||
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'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'),
|
||||
|
@ -1441,6 +1441,27 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'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': {
|
||||
'name': _('Sales Order Reference Pattern'),
|
||||
'description': _('Required pattern for generating Sales Order reference field'),
|
||||
@ -1937,6 +1958,20 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'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': {
|
||||
'name': _('Search Preview Results'),
|
||||
'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',
|
||||
)
|
||||
|
||||
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):
|
||||
"""Send out a notification."""
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""Provides a JSON API for the Company app."""
|
||||
|
||||
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.rest_framework import DjangoFilterBackend
|
||||
@ -15,10 +15,11 @@ from InvenTree.mixins import (ListCreateAPI, RetrieveUpdateAPI,
|
||||
RetrieveUpdateDestroyAPI)
|
||||
from plugin.serializers import MetadataSerializer
|
||||
|
||||
from .models import (Company, CompanyAttachment, ManufacturerPart,
|
||||
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
|
||||
ManufacturerPartAttachment, ManufacturerPartParameter,
|
||||
SupplierPart, SupplierPriceBreak)
|
||||
from .serializers import (CompanyAttachmentSerializer, CompanySerializer,
|
||||
ContactSerializer,
|
||||
ManufacturerPartAttachmentSerializer,
|
||||
ManufacturerPartParameterSerializer,
|
||||
ManufacturerPartSerializer, SupplierPartSerializer,
|
||||
@ -118,6 +119,41 @@ class CompanyAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
||||
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):
|
||||
"""Custom API filters for the ManufacturerPart list endpoint."""
|
||||
|
||||
@ -519,12 +555,12 @@ manufacturer_part_api_urls = [
|
||||
|
||||
# Base URL for ManufacturerPartAttachment API endpoints
|
||||
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'^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
|
||||
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'^(?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'^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'),
|
||||
|
||||
]
|
||||
|
@ -235,6 +235,11 @@ class Contact(models.Model):
|
||||
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',
|
||||
on_delete=models.CASCADE)
|
||||
|
||||
|
@ -17,7 +17,7 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializer,
|
||||
InvenTreeMoneySerializer, RemoteImageMixin)
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
from .models import (Company, CompanyAttachment, ManufacturerPart,
|
||||
from .models import (Company, CompanyAttachment, Contact, ManufacturerPart,
|
||||
ManufacturerPartAttachment, ManufacturerPartParameter,
|
||||
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):
|
||||
"""Serializer for ManufacturerPart object."""
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
{% extends "company/company_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'company/sidebar.html' %}
|
||||
@ -137,6 +138,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if company.is_customer %}
|
||||
{% if roles.sales_order.view %}
|
||||
<div class='panel panel-hidden' id='panel-sales-orders'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
@ -162,7 +165,9 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if roles.stock.view %}
|
||||
<div class='panel panel-hidden' id='panel-assigned-stock'>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Assigned Stock" %}</h4>
|
||||
@ -175,9 +180,40 @@
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table>
|
||||
|
||||
</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-heading'>
|
||||
@ -194,6 +230,31 @@
|
||||
</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-heading'>
|
||||
<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() {
|
||||
|
||||
setupNotesField(
|
||||
@ -250,18 +318,7 @@
|
||||
{
|
||||
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() {
|
||||
@ -282,20 +339,65 @@
|
||||
});
|
||||
|
||||
{% 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() {
|
||||
{% if roles.sales_order.view %}
|
||||
loadSalesOrderTable("#sales-order-table", {
|
||||
url: "{% url 'api-so-list' %}",
|
||||
params: {
|
||||
customer: {{ company.id }},
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if roles.salse_order.add %}
|
||||
$("#new-sales-order").click(function() {
|
||||
|
||||
createSalesOrder({
|
||||
customer: {{ company.pk }},
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
@ -334,7 +436,7 @@
|
||||
createManufacturerPart({
|
||||
manufacturer: {{ company.pk }},
|
||||
onSuccess: function() {
|
||||
$("#part-table").bootstrapTable("refresh");
|
||||
$("#part-table").bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -356,7 +458,7 @@
|
||||
|
||||
deleteManufacturerParts(selections, {
|
||||
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() {
|
||||
|
||||
constructForm('{% url "api-manufacturer-part-parameter-list" %}', {
|
||||
@ -243,7 +225,7 @@ $('#parameter-create').click(function() {
|
||||
}
|
||||
},
|
||||
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" %}
|
||||
{% endif %}
|
||||
{% if company.is_customer %}
|
||||
{% if roles.sales_order.view %}
|
||||
{% trans "Sales Orders" as text %}
|
||||
{% include "sidebar_item.html" with label='sales-orders' text=text icon="fa-truck" %}
|
||||
{% endif %}
|
||||
{% if roles.stock.view %}
|
||||
{% trans "Assigned Stock Items" as text %}
|
||||
{% include "sidebar_item.html" with label='assigned-stock' text=text icon="fa-sign-out-alt" %}
|
||||
{% 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 %}
|
||||
{% include "sidebar_item.html" with label='company-notes' text=text icon="fa-clipboard" %}
|
||||
{% trans "Attachments" as text %}
|
||||
|
@ -301,7 +301,7 @@ loadSupplierPriceBreakTable({
|
||||
$('#new-price-break').click(function() {
|
||||
createSupplierPartPriceBreak({{ part.pk }}, {
|
||||
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 .models import Company, SupplierPart
|
||||
from .models import Company, Contact, SupplierPart
|
||||
|
||||
|
||||
class CompanyTest(InvenTreeAPITestCase):
|
||||
@ -140,6 +140,144 @@ class CompanyTest(InvenTreeAPITestCase):
|
||||
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):
|
||||
"""Series of tests for the Manufacturer DRF API."""
|
||||
|
||||
|
@ -1,13 +1,13 @@
|
||||
"""URL lookup for Company app."""
|
||||
|
||||
from django.urls import include, re_path
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from . import views
|
||||
|
||||
company_urls = [
|
||||
|
||||
# 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'),
|
||||
])),
|
||||
|
||||
@ -21,11 +21,11 @@ company_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 = [
|
||||
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'),
|
||||
]))
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import FieldError, ValidationError
|
||||
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.views.decorators.cache import cache_page, never_cache
|
||||
|
||||
@ -403,7 +403,7 @@ label_api_urls = [
|
||||
# Stock item labels
|
||||
re_path(r'stock/', include([
|
||||
# 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'metadata/', StockItemLabelMetadata.as_view(), name='api-stockitem-label-metadata'),
|
||||
re_path(r'^.*$', StockItemLabelDetail.as_view(), name='api-stockitem-label-detail'),
|
||||
@ -416,7 +416,7 @@ label_api_urls = [
|
||||
# Stock location labels
|
||||
re_path(r'location/', include([
|
||||
# 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'metadata/', StockLocationLabelMetadata.as_view(), name='api-stocklocation-label-metadata'),
|
||||
re_path(r'^.*$', StockLocationLabelDetail.as_view(), name='api-stocklocation-label-detail'),
|
||||
@ -429,7 +429,7 @@ label_api_urls = [
|
||||
# Part labels
|
||||
re_path(r'^part/', include([
|
||||
# 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'^metadata/', PartLabelMetadata.as_view(), name='api-part-label-metadata'),
|
||||
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.fields import Field
|
||||
|
||||
import order.models as models
|
||||
from InvenTree.admin import InvenTreeResource
|
||||
|
||||
from .models import (PurchaseOrder, PurchaseOrderExtraLine,
|
||||
PurchaseOrderLineItem, SalesOrder, SalesOrderAllocation,
|
||||
SalesOrderExtraLine, SalesOrderLineItem,
|
||||
SalesOrderShipment)
|
||||
|
||||
|
||||
# region general classes
|
||||
class GeneralExtraLineAdmin:
|
||||
@ -42,7 +38,7 @@ class GeneralExtraLineMeta:
|
||||
|
||||
class PurchaseOrderLineItemInlineAdmin(admin.StackedInline):
|
||||
"""Inline admin class for the PurchaseOrderLineItem model"""
|
||||
model = PurchaseOrderLineItem
|
||||
model = models.PurchaseOrderLineItem
|
||||
extra = 0
|
||||
|
||||
|
||||
@ -103,7 +99,7 @@ class PurchaseOrderResource(InvenTreeResource):
|
||||
|
||||
class Meta:
|
||||
"""Metaclass"""
|
||||
model = PurchaseOrder
|
||||
model = models.PurchaseOrder
|
||||
skip_unchanged = True
|
||||
clean_model_instances = True
|
||||
exclude = [
|
||||
@ -122,7 +118,7 @@ class PurchaseOrderLineItemResource(InvenTreeResource):
|
||||
|
||||
class Meta:
|
||||
"""Metaclass"""
|
||||
model = PurchaseOrderLineItem
|
||||
model = models.PurchaseOrderLineItem
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
clean_model_instances = True
|
||||
@ -142,7 +138,7 @@ class PurchaseOrderExtraLineResource(InvenTreeResource):
|
||||
class Meta(GeneralExtraLineMeta):
|
||||
"""Metaclass options."""
|
||||
|
||||
model = PurchaseOrderExtraLine
|
||||
model = models.PurchaseOrderExtraLine
|
||||
|
||||
|
||||
class SalesOrderResource(InvenTreeResource):
|
||||
@ -150,7 +146,7 @@ class SalesOrderResource(InvenTreeResource):
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
model = SalesOrder
|
||||
model = models.SalesOrder
|
||||
skip_unchanged = True
|
||||
clean_model_instances = True
|
||||
exclude = [
|
||||
@ -169,7 +165,7 @@ class SalesOrderLineItemResource(InvenTreeResource):
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
model = SalesOrderLineItem
|
||||
model = models.SalesOrderLineItem
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
clean_model_instances = True
|
||||
@ -198,8 +194,7 @@ class SalesOrderExtraLineResource(InvenTreeResource):
|
||||
|
||||
class Meta(GeneralExtraLineMeta):
|
||||
"""Metaclass options."""
|
||||
|
||||
model = SalesOrderExtraLine
|
||||
model = models.SalesOrderExtraLine
|
||||
|
||||
|
||||
class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
|
||||
@ -281,13 +276,92 @@ class SalesOrderAllocationAdmin(ImportExportModelAdmin):
|
||||
autocomplete_fields = ('line', 'shipment', 'item',)
|
||||
|
||||
|
||||
admin.site.register(PurchaseOrder, PurchaseOrderAdmin)
|
||||
admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin)
|
||||
admin.site.register(PurchaseOrderExtraLine, PurchaseOrderExtraLineAdmin)
|
||||
class ReturnOrderResource(InvenTreeResource):
|
||||
"""Class for managing import / export of ReturnOrder data"""
|
||||
|
||||
admin.site.register(SalesOrder, SalesOrderAdmin)
|
||||
admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin)
|
||||
admin.site.register(SalesOrderExtraLine, SalesOrderExtraLineAdmin)
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
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.tasks
|
||||
import order.validators
|
||||
import stock.models
|
||||
import users.models as UserModels
|
||||
from common.notifications import InvenTreeNotificationBodies
|
||||
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.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
|
||||
InvenTreeURLField, RoundingDecimalField)
|
||||
from InvenTree.helpers import decimal2string, getSetting, notify_responsible
|
||||
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
||||
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
|
||||
from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderLineStatus,
|
||||
ReturnOrderStatus, SalesOrderStatus,
|
||||
StockHistoryCode, StockStatus)
|
||||
from part import models as PartModels
|
||||
from plugin.events import trigger_event
|
||||
from plugin.models import MetadataMixin
|
||||
from stock import models as stock_models
|
||||
from users import models as UserModels
|
||||
|
||||
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):
|
||||
"""Abstract model for an order.
|
||||
|
||||
@ -78,15 +163,49 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
|
||||
if not self.creation_date:
|
||||
self.creation_date = datetime.now().date()
|
||||
|
||||
# Recalculate total_price for this order
|
||||
self.update_total_price(commit=False)
|
||||
|
||||
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'))
|
||||
|
||||
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'))
|
||||
|
||||
created_by = models.ForeignKey(User,
|
||||
@ -105,84 +224,25 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
|
||||
related_name='+',
|
||||
)
|
||||
|
||||
notes = InvenTreeNotesField(help_text=_('Order notes'))
|
||||
|
||||
total_price = InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
allow_negative=False,
|
||||
verbose_name=_('Total Price'),
|
||||
help_text=_('Total price for this order')
|
||||
contact = models.ForeignKey(
|
||||
Contact,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Contact'),
|
||||
help_text=_('Point of contact for this order'),
|
||||
related_name='+',
|
||||
)
|
||||
|
||||
def update_total_price(self, commit=True):
|
||||
"""Recalculate and save the total_price for this order"""
|
||||
notes = InvenTreeNotesField(help_text=_('Order notes'))
|
||||
|
||||
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:
|
||||
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
|
||||
raise NotImplementedError(f"get_status_class() not implemented for {__class__}")
|
||||
|
||||
|
||||
class PurchaseOrder(Order):
|
||||
class PurchaseOrder(TotalPriceMixin, Order):
|
||||
"""A PurchaseOrder represents goods shipped inwards from an external supplier.
|
||||
|
||||
Attributes:
|
||||
@ -192,14 +252,23 @@ class PurchaseOrder(Order):
|
||||
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
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the PurchaseOrder model"""
|
||||
return reverse('api-po-list')
|
||||
|
||||
@classmethod
|
||||
def get_status_class(cls):
|
||||
"""Return the PurchasOrderStatus class"""
|
||||
return PurchaseOrderStatus
|
||||
|
||||
@classmethod
|
||||
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 = {
|
||||
'reference': order.validators.generate_next_purchase_order_reference(),
|
||||
@ -207,8 +276,6 @@ class PurchaseOrder(Order):
|
||||
|
||||
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
|
||||
REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN'
|
||||
|
||||
@ -283,6 +350,11 @@ class PurchaseOrder(Order):
|
||||
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"))
|
||||
|
||||
received_by = models.ForeignKey(
|
||||
@ -299,22 +371,12 @@ class PurchaseOrder(Order):
|
||||
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(
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Completion Date'),
|
||||
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
|
||||
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.
|
||||
@ -417,17 +479,6 @@ class PurchaseOrder(Order):
|
||||
"""Return True if the PurchaseOrder is '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):
|
||||
"""A PurchaseOrder can only be cancelled under the following circumstances.
|
||||
|
||||
@ -534,7 +585,7 @@ class PurchaseOrder(Order):
|
||||
|
||||
for sn in serials:
|
||||
|
||||
stock = stock_models.StockItem(
|
||||
item = stock.models.StockItem(
|
||||
part=line.part.part,
|
||||
supplier_part=line.part,
|
||||
location=location,
|
||||
@ -547,14 +598,14 @@ class PurchaseOrder(Order):
|
||||
barcode_hash=barcode_hash
|
||||
)
|
||||
|
||||
stock.save(add_note=False)
|
||||
item.save(add_note=False)
|
||||
|
||||
tracking_info = {
|
||||
'status': status,
|
||||
'purchaseorder': self.pk,
|
||||
}
|
||||
|
||||
stock.add_tracking_entry(
|
||||
item.add_tracking_entry(
|
||||
StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
|
||||
user,
|
||||
notes=notes,
|
||||
@ -595,20 +646,23 @@ def after_save_purchase_order(sender, instance: PurchaseOrder, created: bool, **
|
||||
notify_responsible(instance, sender, exclude=instance.created_by)
|
||||
|
||||
|
||||
class SalesOrder(Order):
|
||||
"""A SalesOrder represents a list of goods shipped outwards to a customer.
|
||||
class SalesOrder(TotalPriceMixin, Order):
|
||||
"""A SalesOrder represents a list of goods shipped outwards to a customer."""
|
||||
|
||||
Attributes:
|
||||
customer: Reference to the company receiving the goods in the order
|
||||
customer_reference: Optional field for customer order reference code
|
||||
target_date: Target date for SalesOrder completion (optional)
|
||||
"""
|
||||
def get_absolute_url(self):
|
||||
"""Get the 'web' URL for this order"""
|
||||
return reverse('so-detail', kwargs={'pk': self.pk})
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the API URL associated with the SalesOrder model"""
|
||||
return reverse('api-so-list')
|
||||
|
||||
@classmethod
|
||||
def get_status_class(cls):
|
||||
"""Return the SalesOrderStatus class"""
|
||||
return SalesOrderStatus
|
||||
|
||||
@classmethod
|
||||
def api_defaults(cls, request):
|
||||
"""Return default values for this model when issuing an API OPTIONS request"""
|
||||
@ -618,8 +672,6 @@ class SalesOrder(Order):
|
||||
|
||||
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
|
||||
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')}"
|
||||
|
||||
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(
|
||||
unique=True,
|
||||
max_length=64,
|
||||
@ -684,13 +732,21 @@ class SalesOrder(Order):
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
limit_choices_to={'is_customer': True},
|
||||
related_name='sales_orders',
|
||||
related_name='return_orders',
|
||||
verbose_name=_('Customer'),
|
||||
help_text=_("Company to which the items are being sold"),
|
||||
)
|
||||
|
||||
status = models.PositiveIntegerField(default=SalesOrderStatus.PENDING, choices=SalesOrderStatus.items(),
|
||||
verbose_name=_('Status'), help_text=_('Purchase order status'))
|
||||
@property
|
||||
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
|
||||
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"))
|
||||
|
||||
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'))
|
||||
|
||||
shipped_by = models.ForeignKey(
|
||||
@ -715,17 +765,6 @@ class SalesOrder(Order):
|
||||
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
|
||||
def is_pending(self):
|
||||
"""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
|
||||
entire line.
|
||||
"""
|
||||
for stock in stock_models.StockItem.objects.filter(supplier_part=self.part, purchase_order=self.order):
|
||||
if stock.location:
|
||||
return stock.location
|
||||
for item in stock.models.StockItem.objects.filter(supplier_part=self.part, purchase_order=self.order):
|
||||
if item.location:
|
||||
return item.location
|
||||
if self.destination:
|
||||
return self.destination
|
||||
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 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):
|
||||
@ -1455,7 +1498,7 @@ class SalesOrderAllocation(models.Model):
|
||||
try:
|
||||
if not self.item:
|
||||
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')})
|
||||
|
||||
try:
|
||||
@ -1547,3 +1590,299 @@ class SalesOrderAllocation(models.Model):
|
||||
# (It may have changed if the stock was split)
|
||||
self.item = item
|
||||
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 stock.models
|
||||
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.serializers import (InvenTreeAttachmentSerializer,
|
||||
InvenTreeCurrencySerializer,
|
||||
InvenTreeDecimalField,
|
||||
InvenTreeModelSerializer,
|
||||
InvenTreeMoneySerializer)
|
||||
from InvenTree.status_codes import (PurchaseOrderStatus, SalesOrderStatus,
|
||||
StockStatus)
|
||||
from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderStatus,
|
||||
SalesOrderStatus, StockStatus)
|
||||
from part.serializers import PartBriefSerializer
|
||||
from users.serializers import OwnerSerializer
|
||||
|
||||
|
||||
class AbstractOrderSerializer(serializers.Serializer):
|
||||
"""Abstract field definitions for OrderSerializers."""
|
||||
class TotalPriceMixin(serializers.Serializer):
|
||||
"""Serializer mixin which provides total price fields"""
|
||||
|
||||
total_price = InvenTreeMoneySerializer(
|
||||
allow_null=True,
|
||||
@ -41,6 +42,69 @@ class AbstractOrderSerializer(serializers.Serializer):
|
||||
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):
|
||||
"""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."""
|
||||
|
||||
class Meta:
|
||||
@ -86,31 +150,17 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer)
|
||||
|
||||
model = order.models.PurchaseOrder
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
fields = AbstractOrderSerializer.order_fields([
|
||||
'issue_date',
|
||||
'complete_date',
|
||||
'creation_date',
|
||||
'description',
|
||||
'line_items',
|
||||
'link',
|
||||
'overdue',
|
||||
'reference',
|
||||
'responsible',
|
||||
'responsible_detail',
|
||||
'supplier',
|
||||
'supplier_detail',
|
||||
'supplier_reference',
|
||||
'status',
|
||||
'status_text',
|
||||
'target_date',
|
||||
'notes',
|
||||
'total_price',
|
||||
'total_price_currency',
|
||||
]
|
||||
])
|
||||
|
||||
read_only_fields = [
|
||||
'status'
|
||||
'issue_date',
|
||||
'complete_date',
|
||||
'creation_date',
|
||||
@ -132,14 +182,13 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer)
|
||||
- Number of lines in the PurchaseOrder
|
||||
- Overdue status of the PurchaseOrder
|
||||
"""
|
||||
queryset = queryset.annotate(
|
||||
line_items=SubqueryCount('lines')
|
||||
)
|
||||
queryset = AbstractOrderSerializer.annotate_queryset(queryset)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
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())
|
||||
)
|
||||
@ -149,24 +198,6 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer)
|
||||
|
||||
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):
|
||||
"""Serializer for cancelling a PurchaseOrder."""
|
||||
@ -307,7 +338,7 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
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()),
|
||||
)
|
||||
@ -531,7 +562,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
||||
"""Serializer for receiving items against a purchase order."""
|
||||
"""Serializer for receiving items against a PurchaseOrder."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
@ -644,34 +675,22 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
])
|
||||
|
||||
|
||||
class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
|
||||
"""Serializers for the SalesOrder object."""
|
||||
class SalesOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer):
|
||||
"""Serializer for the SalesOrder model class"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.SalesOrder
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'creation_date',
|
||||
fields = AbstractOrderSerializer.order_fields([
|
||||
'customer',
|
||||
'customer_detail',
|
||||
'customer_reference',
|
||||
'description',
|
||||
'line_items',
|
||||
'link',
|
||||
'notes',
|
||||
'overdue',
|
||||
'reference',
|
||||
'responsible',
|
||||
'status',
|
||||
'status_text',
|
||||
'shipment_date',
|
||||
'target_date',
|
||||
'total_price',
|
||||
'total_price_currency',
|
||||
]
|
||||
])
|
||||
|
||||
read_only_fields = [
|
||||
'status',
|
||||
@ -695,14 +714,13 @@ class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
|
||||
- Number of line items in the SalesOrder
|
||||
- Overdue status of the SalesOrder
|
||||
"""
|
||||
queryset = queryset.annotate(
|
||||
line_items=SubqueryCount('lines')
|
||||
)
|
||||
queryset = AbstractOrderSerializer.annotate_queryset(queryset)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
overdue=Case(
|
||||
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())
|
||||
)
|
||||
@ -712,22 +730,6 @@ class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
|
||||
|
||||
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):
|
||||
"""Serializer for the SalesOrderAllocation model.
|
||||
@ -1379,13 +1381,13 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
|
||||
class SalesOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer):
|
||||
"""Serializer for a SalesOrderExtraLine object."""
|
||||
|
||||
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
|
||||
|
||||
class Meta(AbstractExtraLineMeta):
|
||||
"""Metaclass options."""
|
||||
|
||||
model = order.models.SalesOrderExtraLine
|
||||
|
||||
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
|
||||
|
||||
|
||||
class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
"""Serializers for the SalesOrderAttachment model."""
|
||||
@ -1398,3 +1400,253 @@ class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
fields = InvenTreeAttachmentSerializer.attachment_fields([
|
||||
'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 %}
|
||||
{% inventree_title %} | {% trans "Purchase Order" %}
|
||||
{% endblock %}
|
||||
{% endblock page_title %}
|
||||
|
||||
{% 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>
|
||||
{% endblock %}
|
||||
{% endblock breadcrumbs %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Purchase Order" %}: {{ order.reference }}
|
||||
{% endblock %}
|
||||
{% endblock heading %}
|
||||
|
||||
{% block actions %}
|
||||
{% if user.is_staff and roles.purchase_order.change %}
|
||||
@ -67,8 +67,7 @@
|
||||
{% trans "Receive Items" %}
|
||||
</button>
|
||||
<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" %}
|
||||
<span class='fas fa-check-circle'></span> {% trans "Complete Order" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@ -82,7 +81,7 @@ src="{{ order.supplier.image.url }}"
|
||||
src="{% static 'img/blank_image.png' %}"
|
||||
{% endif %}
|
||||
/>
|
||||
{% endblock %}
|
||||
{% endblock thumbnail %}
|
||||
|
||||
{% block details %}
|
||||
|
||||
@ -111,7 +110,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock details %}
|
||||
|
||||
{% block details_right %}
|
||||
<table class='table table-condensed table-striped'>
|
||||
@ -169,7 +168,10 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></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>
|
||||
{% endif %}
|
||||
{% 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>
|
||||
</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>
|
||||
@ -201,12 +210,11 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock %}
|
||||
{% endblock details_right %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
|
||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||
$("#place-order").click(function() {
|
||||
|
||||
@ -222,7 +230,11 @@ $("#place-order").click(function() {
|
||||
|
||||
{% if report_enabled %}
|
||||
$('#print-order-report').click(function() {
|
||||
printPurchaseOrderReports([{{ order.pk }}]);
|
||||
printReports({
|
||||
items: [{{ order.pk }}],
|
||||
key: 'order',
|
||||
url: '{% url "api-po-report-list" %}',
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
@ -293,4 +305,4 @@ $("#export-order").click(function() {
|
||||
});
|
||||
|
||||
|
||||
{% endblock %}
|
||||
{% endblock js_ready %}
|
||||
|
@ -8,7 +8,7 @@
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'order/po_sidebar.html' %}
|
||||
{% endblock %}
|
||||
{% endblock sidebar %}
|
||||
|
||||
{% block page_content %}
|
||||
{% 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'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
|
||||
</button>
|
||||
{% elif order.status == PurchaseOrderStatus.PLACED %}
|
||||
<button type='button' class='btn btn-primary' id='receive-selected-items' title='{% trans "Receive selected items" %}'>
|
||||
<span class='fas fa-sign-in-alt'></span> {% trans "Receive Items" %}
|
||||
{% endif %}
|
||||
{% if order.status == PurchaseOrderStatus.PLACED %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@ -126,7 +127,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock page_content %}
|
||||
|
||||
{% block js_ready %}
|
||||
|
||||
@ -146,30 +147,18 @@
|
||||
);
|
||||
});
|
||||
|
||||
enableDragAndDrop(
|
||||
'#attachment-dropzone',
|
||||
'{% url "api-po-attachment-list" %}',
|
||||
{
|
||||
data: {
|
||||
order: {{ order.id }},
|
||||
onPanelLoad('order-attachments', function() {
|
||||
loadAttachmentTable('{% url "api-po-attachment-list" %}', {
|
||||
filters: {
|
||||
order: {{ order.pk }},
|
||||
},
|
||||
label: 'attachment',
|
||||
success: function(data, status, xhr) {
|
||||
$('#attachment-table').bootstrapTable('refresh');
|
||||
fields: {
|
||||
order: {
|
||||
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"), {
|
||||
@ -204,7 +193,7 @@ $('#new-po-line').click(function() {
|
||||
{% elif order.status == PurchaseOrderStatus.PLACED %}
|
||||
|
||||
$('#receive-selected-items').click(function() {
|
||||
var items = getTableData('#po-line-table');
|
||||
let items = getTableData('#po-line-table');
|
||||
|
||||
receivePurchaseOrderItems(
|
||||
{{ order.id }},
|
||||
@ -219,59 +208,56 @@ $('#new-po-line').click(function() {
|
||||
|
||||
{% endif %}
|
||||
|
||||
loadPurchaseOrderLineItemTable('#po-line-table', {
|
||||
order: {{ order.pk }},
|
||||
{% 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({
|
||||
onPanelLoad('order-items', function() {
|
||||
loadPurchaseOrderLineItemTable('#po-line-table', {
|
||||
order: {{ order.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,
|
||||
{% 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() {
|
||||
|
||||
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(
|
||||
'#poTotalPrice',
|
||||
|
@ -26,11 +26,6 @@
|
||||
<div id='table-buttons'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<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" %}
|
||||
</div>
|
||||
</div>
|
||||
@ -53,20 +48,6 @@
|
||||
{% block js_ready %}
|
||||
{{ 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() {
|
||||
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 %}
|
||||
|
||||
{% 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>
|
||||
{% endblock %}
|
||||
|
||||
@ -162,7 +162,10 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></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>
|
||||
{% endif %}
|
||||
{% if order.shipment_date %}
|
||||
@ -177,6 +180,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</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>
|
||||
@ -187,7 +197,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
|
||||
<tr>
|
||||
<td><span class='fas fa-dollar-sign'></span></td>
|
||||
<td>{% trans "Total cost" %}</td>
|
||||
<td>{% trans "Total Cost" %}</td>
|
||||
<td id="soTotalPrice">
|
||||
{% with order.total_price as tp %}
|
||||
{% if tp == None %}
|
||||
@ -204,12 +214,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
{% if roles.sales_order.change %}
|
||||
$("#edit-order").click(function() {
|
||||
|
||||
editSalesOrder({{ order.pk }}, {
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
$("#complete-order-shipments").click(function() {
|
||||
|
||||
@ -242,7 +253,11 @@ $("#complete-order").click(function() {
|
||||
|
||||
{% if report_enabled %}
|
||||
$('#print-order-report').click(function() {
|
||||
printSalesOrderReports([{{ order.pk }}]);
|
||||
printReports({
|
||||
items: [{{ order.pk }}],
|
||||
key: 'order',
|
||||
url: '{% url "api-so-report-list" %}',
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
|
@ -18,7 +18,7 @@
|
||||
<h4>{% trans "Sales Order Items" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% if roles.sales_order.change %}
|
||||
{% if roles.sales_order.add %}
|
||||
{% if order.is_pending or allow_extra_editing %}
|
||||
<button type='button' class='btn btn-success' id='new-so-line'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
|
||||
@ -209,30 +209,19 @@
|
||||
);
|
||||
});
|
||||
|
||||
enableDragAndDrop(
|
||||
'#attachment-dropzone',
|
||||
'{% url "api-so-attachment-list" %}',
|
||||
{
|
||||
data: {
|
||||
order: {{ order.id }},
|
||||
},
|
||||
label: 'attachment',
|
||||
success: function(data, status, xhr) {
|
||||
reloadAttachmentTable();
|
||||
}
|
||||
}
|
||||
);
|
||||
onPanelLoad('order-attachments', function() {
|
||||
|
||||
loadAttachmentTable('{% url "api-so-attachment-list" %}', {
|
||||
filters: {
|
||||
order: {{ order.pk }},
|
||||
},
|
||||
fields: {
|
||||
order: {
|
||||
value: {{ order.pk }},
|
||||
hidden: true,
|
||||
loadAttachmentTable('{% url "api-so-attachment-list" %}', {
|
||||
filters: {
|
||||
order: {{ order.pk }},
|
||||
},
|
||||
}
|
||||
fields: {
|
||||
order: {
|
||||
value: {{ order.pk }},
|
||||
hidden: true,
|
||||
},
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
loadBuildTable($("#builds-table"), {
|
||||
@ -242,60 +231,67 @@
|
||||
},
|
||||
});
|
||||
|
||||
$("#new-so-line").click(function() {
|
||||
createSalesOrderLineItem({
|
||||
order: {{ order.pk }},
|
||||
onSuccess: function() {
|
||||
$("#so-lines-table").bootstrapTable("refresh");
|
||||
onPanelLoad('order-items', function() {
|
||||
|
||||
$("#new-so-line").click(function() {
|
||||
createSalesOrderLineItem({
|
||||
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
|
||||
var fields = soLineItemFields({
|
||||
loadExtraLineTable({
|
||||
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(
|
||||
'#soTotalPrice',
|
||||
|
@ -29,11 +29,6 @@
|
||||
<div id='table-buttons'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<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" %}
|
||||
</div>
|
||||
</div>
|
||||
@ -54,20 +49,6 @@ loadSalesOrderTable("#sales-order-table", {
|
||||
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() {
|
||||
createSalesOrder();
|
||||
});
|
||||
|
@ -17,7 +17,9 @@ import order.models as models
|
||||
from common.settings import currency_codes
|
||||
from company.models import Company
|
||||
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 stock.models import StockItem
|
||||
|
||||
@ -1802,3 +1804,286 @@ class SalesOrderAllocateTest(OrderTest):
|
||||
response = self.get(url, expected_code=200)
|
||||
|
||||
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',
|
||||
'stock',
|
||||
'order',
|
||||
'sales_order',
|
||||
'return_order',
|
||||
]
|
||||
|
||||
roles = [
|
||||
@ -25,14 +27,17 @@ class OrderViewTestCase(InvenTreeTestCase):
|
||||
'sales_order.change',
|
||||
'sales_order.add',
|
||||
'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"""
|
||||
def test_order_list(self):
|
||||
"""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)
|
||||
|
||||
@ -53,3 +58,31 @@ class PurchaseOrderTests(OrderViewTestCase):
|
||||
|
||||
# Response should be streaming-content (file download)
|
||||
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
|
||||
"""
|
||||
|
||||
from django.urls import include, re_path
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from . import views
|
||||
|
||||
@ -21,10 +21,10 @@ purchase_order_urls = [
|
||||
re_path(r'^pricing/', views.LineItemPricing.as_view(), name='line-pricing'),
|
||||
|
||||
# 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
|
||||
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 = [
|
||||
@ -35,13 +35,23 @@ sales_order_detail_urls = [
|
||||
|
||||
sales_order_urls = [
|
||||
# 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
|
||||
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 = [
|
||||
re_path(r'^purchase-order/', include(purchase_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()
|
||||
|
||||
|
||||
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):
|
||||
"""Validate the SalesOrder reference 'pattern' setting"""
|
||||
|
||||
@ -33,6 +41,14 @@ def validate_purchase_order_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):
|
||||
"""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
|
||||
|
||||
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 .admin import PurchaseOrderLineItemResource, SalesOrderLineItemResource
|
||||
from .models import (PurchaseOrder, PurchaseOrderLineItem, SalesOrder,
|
||||
SalesOrderLineItem)
|
||||
from .models import (PurchaseOrder, PurchaseOrderLineItem, ReturnOrder,
|
||||
SalesOrder, SalesOrderLineItem)
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
@ -51,6 +51,14 @@ class SalesOrderIndex(InvenTreeRoleMixin, ListView):
|
||||
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):
|
||||
"""Detail view for a PurchaseOrder object."""
|
||||
|
||||
@ -67,6 +75,14 @@ class SalesOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView)
|
||||
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):
|
||||
"""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)
|
||||
|
||||
|
||||
class CategoryList(APIDownloadMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of PartCategory objects.
|
||||
|
||||
- GET: Return a list of PartCategory objects
|
||||
- POST: Create a new PartCategory object
|
||||
"""
|
||||
|
||||
queryset = PartCategory.objects.all()
|
||||
class CategoryMixin:
|
||||
"""Mixin class for PartCategory endpoints"""
|
||||
serializer_class = part_serializers.CategorySerializer
|
||||
|
||||
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)
|
||||
queryset = PartCategory.objects.all()
|
||||
|
||||
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 = part_serializers.CategorySerializer.annotate_queryset(queryset)
|
||||
return queryset
|
||||
|
||||
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()
|
||||
|
||||
try:
|
||||
@ -77,6 +63,23 @@ class CategoryList(APIDownloadMixin, ListCreateAPI):
|
||||
|
||||
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):
|
||||
"""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."""
|
||||
|
||||
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):
|
||||
"""Perform 'update' function and mark this part as 'starred' (or not)"""
|
||||
# Clean up input data
|
||||
@ -234,6 +215,21 @@ class CategoryDetail(CustomRetrieveUpdateDestroyAPI):
|
||||
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):
|
||||
"""API endpoint for viewing / updating PartCategory metadata."""
|
||||
|
||||
@ -292,21 +288,6 @@ class CategoryParameterDetail(RetrieveUpdateDestroyAPI):
|
||||
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):
|
||||
"""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):
|
||||
"""Custom filters for the PartList endpoint.
|
||||
|
||||
@ -1090,22 +1001,30 @@ class PartFilter(rest_filters.FilterSet):
|
||||
virtual = rest_filters.BooleanFilter()
|
||||
|
||||
|
||||
class PartList(APIDownloadMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of Part objects, or creating a new Part instance"""
|
||||
|
||||
class PartMixin:
|
||||
"""Mixin class for Part API endpoints"""
|
||||
serializer_class = part_serializers.PartSerializer
|
||||
queryset = Part.objects.all()
|
||||
filterset_class = PartFilter
|
||||
|
||||
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):
|
||||
"""Return a serializer instance for this endpoint"""
|
||||
# Ensure the request context is passed through
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
# 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
|
||||
# We do this to reduce the number of database queries required!
|
||||
@ -1132,6 +1051,13 @@ class PartList(APIDownloadMixin, ListCreateAPI):
|
||||
|
||||
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):
|
||||
"""Download the filtered queryset as a data file"""
|
||||
dataset = PartResource().export(queryset=queryset)
|
||||
@ -1169,13 +1095,6 @@ class PartList(APIDownloadMixin, ListCreateAPI):
|
||||
else:
|
||||
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):
|
||||
"""Perform custom filtering of the queryset"""
|
||||
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):
|
||||
"""API endpoint for accessing a list of PartRelated objects."""
|
||||
|
||||
@ -1674,42 +1630,11 @@ class BomFilter(rest_filters.FilterSet):
|
||||
return queryset
|
||||
|
||||
|
||||
class BomList(ListCreateDestroyAPIView):
|
||||
"""API endpoint for accessing a list of BomItem objects.
|
||||
|
||||
- GET: Return list of BomItem objects
|
||||
- POST: Create a new BomItem object
|
||||
"""
|
||||
class BomMixin:
|
||||
"""Mixin class for BomItem API endpoints"""
|
||||
|
||||
serializer_class = part_serializers.BomItemSerializer
|
||||
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):
|
||||
"""Return the serializer instance for this API endpoint
|
||||
@ -1744,6 +1669,42 @@ class BomList(ListCreateDestroyAPIView):
|
||||
|
||||
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):
|
||||
"""Custom query filtering for the BomItem list API"""
|
||||
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):
|
||||
"""API endpoint for uploading a complete Bill of Materials.
|
||||
|
||||
@ -1866,22 +1832,6 @@ class BomImportSubmit(CreateAPI):
|
||||
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):
|
||||
"""API endpoint for validating a BomItem."""
|
||||
|
||||
@ -1958,7 +1908,7 @@ part_api_urls = [
|
||||
])),
|
||||
|
||||
# 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'),
|
||||
|
||||
@ -1971,31 +1921,31 @@ part_api_urls = [
|
||||
|
||||
# Base URL for PartTestTemplate API endpoints
|
||||
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'),
|
||||
])),
|
||||
|
||||
# Base URL for PartAttachment API endpoints
|
||||
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'),
|
||||
])),
|
||||
|
||||
# Base URL for part sale pricing
|
||||
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'),
|
||||
])),
|
||||
|
||||
# Base URL for part internal pricing
|
||||
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'),
|
||||
])),
|
||||
|
||||
# Base URL for PartRelated API endpoints
|
||||
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'),
|
||||
])),
|
||||
|
||||
@ -2009,7 +1959,7 @@ part_api_urls = [
|
||||
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'),
|
||||
])),
|
||||
|
||||
@ -2021,7 +1971,7 @@ part_api_urls = [
|
||||
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'),
|
||||
])),
|
||||
|
||||
@ -2033,7 +1983,7 @@ part_api_urls = [
|
||||
# BOM 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
|
||||
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([
|
||||
|
||||
# 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
|
||||
re_path(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'),
|
||||
])),
|
||||
|
||||
# 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'^metadata/?', BomItemMetadata.as_view(), name='api-bom-item-metadata'),
|
||||
re_path(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),
|
||||
|
@ -37,7 +37,6 @@ import common.settings
|
||||
import InvenTree.fields
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
import part.filters as part_filters
|
||||
import part.settings as part_settings
|
||||
from build import models as BuildModels
|
||||
from common.models import InvenTreeSetting
|
||||
@ -1223,6 +1222,9 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
@property
|
||||
def can_build(self):
|
||||
"""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 not self.has_bom:
|
||||
return 0
|
||||
@ -1246,9 +1248,9 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
# Annotate the 'available stock' for each part in the BOM
|
||||
ref = 'sub_part__'
|
||||
queryset = queryset.alias(
|
||||
total_stock=part_filters.annotate_total_stock(reference=ref),
|
||||
so_allocations=part_filters.annotate_sales_order_allocations(reference=ref),
|
||||
bo_allocations=part_filters.annotate_build_order_allocations(reference=ref),
|
||||
total_stock=part.filters.annotate_total_stock(reference=ref),
|
||||
so_allocations=part.filters.annotate_sales_order_allocations(reference=ref),
|
||||
bo_allocations=part.filters.annotate_build_order_allocations(reference=ref),
|
||||
)
|
||||
|
||||
# Calculate the 'available stock' based on previous annotations
|
||||
@ -1262,9 +1264,9 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
# Extract similar information for any 'substitute' parts
|
||||
ref = 'substitutes__part__'
|
||||
queryset = queryset.alias(
|
||||
sub_total_stock=part_filters.annotate_total_stock(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_total_stock=part.filters.annotate_total_stock(reference=ref),
|
||||
sub_so_allocations=part.filters.annotate_sales_order_allocations(reference=ref),
|
||||
sub_bo_allocations=part.filters.annotate_build_order_allocations(reference=ref),
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
@ -1275,12 +1277,12 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
)
|
||||
|
||||
# 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(
|
||||
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_so_allocations=part_filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__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_so_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'),
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
@ -2083,6 +2085,16 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
|
||||
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):
|
||||
"""Return the tests which are required by this part"""
|
||||
return self.getTestTemplates(required=True)
|
||||
|
@ -183,11 +183,6 @@
|
||||
<li><a class='dropdown-item' href='#' id='multi-part-order' title='{% trans "Order parts" %}'>
|
||||
<span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}
|
||||
</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>
|
||||
</div>
|
||||
{% include "filter_list.html" with id="parts" %}
|
||||
|
@ -548,7 +548,7 @@
|
||||
|
||||
deleteManufacturerParts(selections, {
|
||||
success: function() {
|
||||
$("#manufacturer-part-table").bootstrapTable("refresh");
|
||||
$("#manufacturer-part-table").bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -558,7 +558,7 @@
|
||||
createManufacturerPart({
|
||||
part: {{ part.pk }},
|
||||
onSuccess: function() {
|
||||
$("#manufacturer-part-table").bootstrapTable("refresh");
|
||||
$("#manufacturer-part-table").bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -677,7 +677,11 @@
|
||||
|
||||
{% if report_enabled %}
|
||||
$("#print-bom-report").click(function() {
|
||||
printBomReports([{{ part.pk }}]);
|
||||
printReports({
|
||||
items: [{{ part.pk }}],
|
||||
key: 'part',
|
||||
url: '{% url "api-bom-report-list" %}'
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
@ -709,9 +713,7 @@
|
||||
},
|
||||
focus: 'part_2',
|
||||
title: '{% trans "Add Related Part" %}',
|
||||
onSuccess: function() {
|
||||
$('#related-parts-table').bootstrapTable('refresh');
|
||||
}
|
||||
refreshTable: '#related-parts-table',
|
||||
});
|
||||
});
|
||||
|
||||
@ -797,9 +799,7 @@
|
||||
part: {{ part.pk }}
|
||||
}),
|
||||
title: '{% trans "Add Test Result Template" %}',
|
||||
onSuccess: function() {
|
||||
$("#test-template-table").bootstrapTable("refresh");
|
||||
}
|
||||
refreshTable: '#test-template-table',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -870,9 +870,7 @@
|
||||
data: {},
|
||||
},
|
||||
title: '{% trans "Add Parameter" %}',
|
||||
onSuccess: function() {
|
||||
$('#parameter-table').bootstrapTable('refresh');
|
||||
}
|
||||
refreshTable: '#parameter-table',
|
||||
});
|
||||
});
|
||||
{% 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() {
|
||||
|
@ -475,7 +475,11 @@
|
||||
|
||||
{% if labels_enabled %}
|
||||
$('#print-label').click(function() {
|
||||
printPartLabels([{{ part.pk }}]);
|
||||
printLabels({
|
||||
items: [{{ part.pk }}],
|
||||
key: 'part',
|
||||
url: '{% url "api-part-label-list" %}',
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
|
@ -4,7 +4,8 @@ from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||
SalesOrderStatus, StockStatus)
|
||||
ReturnOrderStatus, SalesOrderStatus,
|
||||
StockStatus)
|
||||
|
||||
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)))
|
||||
|
||||
|
||||
@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
|
||||
def stock_status_label(key, *args, **kwargs):
|
||||
"""Render a StockItem status label."""
|
||||
|
@ -6,7 +6,7 @@
|
||||
- Display / Create / Edit / Delete SupplierPart
|
||||
"""
|
||||
|
||||
from django.urls import include, re_path
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from . import views
|
||||
|
||||
@ -35,7 +35,7 @@ part_urls = [
|
||||
re_path(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'),
|
||||
|
||||
# Individual part using pk
|
||||
re_path(r'^(?P<pk>\d+)/', include(part_detail_urls)),
|
||||
path(r'<int:pk>/', include(part_detail_urls)),
|
||||
|
||||
# Part category
|
||||
re_path(r'^category/', include(category_urls)),
|
||||
|
@ -1,6 +1,6 @@
|
||||
"""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 rest_framework import filters, permissions, status
|
||||
@ -255,7 +255,7 @@ plugin_api_urls = [
|
||||
])),
|
||||
|
||||
# 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'^activate/', PluginActivate.as_view(), name='api-plugin-detail-activate'),
|
||||
re_path(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'),
|
||||
|
@ -3,7 +3,8 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
|
||||
ReportAsset, ReportSnippet, SalesOrderReport, TestReport)
|
||||
ReportAsset, ReportSnippet, ReturnOrderReport,
|
||||
SalesOrderReport, TestReport)
|
||||
|
||||
|
||||
class ReportTemplateAdmin(admin.ModelAdmin):
|
||||
@ -28,4 +29,5 @@ admin.site.register(TestReport, ReportTemplateAdmin)
|
||||
admin.site.register(BuildReport, ReportTemplateAdmin)
|
||||
admin.site.register(BillOfMaterialsReport, ReportTemplateAdmin)
|
||||
admin.site.register(PurchaseOrderReport, ReportTemplateAdmin)
|
||||
admin.site.register(ReturnOrderReport, ReportTemplateAdmin)
|
||||
admin.site.register(SalesOrderReport, ReportTemplateAdmin)
|
||||
|
@ -24,9 +24,10 @@ from plugin.serializers import MetadataSerializer
|
||||
from stock.models import StockItem, StockItemAttachment
|
||||
|
||||
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
|
||||
SalesOrderReport, TestReport)
|
||||
ReturnOrderReport, SalesOrderReport, TestReport)
|
||||
from .serializers import (BOMReportSerializer, BuildReportSerializer,
|
||||
PurchaseOrderReportSerializer,
|
||||
ReturnOrderReportSerializer,
|
||||
SalesOrderReportSerializer, TestReportSerializer)
|
||||
|
||||
|
||||
@ -423,6 +424,31 @@ class SalesOrderReportPrint(SalesOrderReportMixin, ReportPrintMixin, RetrieveAPI
|
||||
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):
|
||||
"""API endpoint for viewing / updating Report metadata."""
|
||||
MODEL_REF = 'reportmodel'
|
||||
@ -453,7 +479,7 @@ report_api_urls = [
|
||||
# Purchase order reports
|
||||
re_path(r'po/', include([
|
||||
# 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'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: PurchaseOrderReport}, name='api-po-report-metadata'),
|
||||
path('', PurchaseOrderReportDetail.as_view(), name='api-po-report-detail'),
|
||||
@ -466,7 +492,7 @@ report_api_urls = [
|
||||
# Sales order reports
|
||||
re_path(r'so/', include([
|
||||
# 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'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: SalesOrderReport}, name='api-so-report-metadata'),
|
||||
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'),
|
||||
])),
|
||||
|
||||
# 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
|
||||
re_path(r'build/', include([
|
||||
# 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'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: BuildReport}, name='api-build-report-metadata'),
|
||||
re_path(r'^.$', BuildReportDetail.as_view(), name='api-build-report-detail'),
|
||||
@ -492,7 +527,7 @@ report_api_urls = [
|
||||
re_path(r'bom/', include([
|
||||
|
||||
# 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'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: BillOfMaterialsReport}, name='api-bom-report-metadata'),
|
||||
re_path(r'^.*$', BOMReportDetail.as_view(), name='api-bom-report-detail'),
|
||||
@ -505,7 +540,7 @@ report_api_urls = [
|
||||
# Stock item test reports
|
||||
re_path(r'test/', include([
|
||||
# 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'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: TestReport}, name='api-stockitem-testreport-metadata'),
|
||||
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.conf import settings
|
||||
|
||||
from InvenTree.ready import canAppAccessDatabase
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
@ -19,12 +17,21 @@ class ReportConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
"""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):
|
||||
self.create_default_test_reports()
|
||||
self.create_default_build_reports()
|
||||
self.create_default_bill_of_materials_reports()
|
||||
self.create_default_purchase_order_reports()
|
||||
self.create_default_sales_order_reports()
|
||||
self.create_default_return_order_reports()
|
||||
|
||||
def create_default_reports(self, model, reports):
|
||||
"""Copy defualt report files across to the media directory."""
|
||||
@ -174,3 +181,23 @@ class ReportConfig(AppConfig):
|
||||
]
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def validate_return_order_filters(filters):
|
||||
"""Validate filter string against ReturnOrder model"""
|
||||
return validateFilterString(filters, model=order.models.ReturnOrder)
|
||||
|
||||
|
||||
class WeasyprintReportMixin(WeasyTemplateResponseMixin):
|
||||
"""Class for rendering a HTML template to a PDF."""
|
||||
|
||||
@ -303,6 +308,30 @@ class TestReport(ReportTemplateBase):
|
||||
|
||||
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):
|
||||
"""Return custom context data for the TestReport template"""
|
||||
stock_item = self.object_to_print
|
||||
@ -312,6 +341,9 @@ class TestReport(ReportTemplateBase):
|
||||
'serial': stock_item.serial,
|
||||
'part': stock_item.part,
|
||||
'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),
|
||||
'result_list': stock_item.testResultList(include_installed=self.include_installed),
|
||||
'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):
|
||||
"""Function to rename a report snippet once uploaded"""
|
||||
|
||||
|
@ -4,99 +4,83 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
||||
InvenTreeModelSerializer)
|
||||
|
||||
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"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = TestReport
|
||||
fields = [
|
||||
'pk',
|
||||
'name',
|
||||
'description',
|
||||
'template',
|
||||
'filters',
|
||||
'enabled',
|
||||
]
|
||||
|
||||
template = InvenTreeAttachmentSerializerField(required=True)
|
||||
fields = ReportSerializerBase.report_fields()
|
||||
|
||||
|
||||
class BuildReportSerializer(InvenTreeModelSerializer):
|
||||
class BuildReportSerializer(ReportSerializerBase):
|
||||
"""Serializer class for the BuildReport model"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = BuildReport
|
||||
fields = [
|
||||
'pk',
|
||||
'name',
|
||||
'description',
|
||||
'template',
|
||||
'filters',
|
||||
'enabled',
|
||||
]
|
||||
|
||||
template = InvenTreeAttachmentSerializerField(required=True)
|
||||
fields = ReportSerializerBase.report_fields()
|
||||
|
||||
|
||||
class BOMReportSerializer(InvenTreeModelSerializer):
|
||||
class BOMReportSerializer(ReportSerializerBase):
|
||||
"""Serializer class for the BillOfMaterialsReport model"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = BillOfMaterialsReport
|
||||
fields = [
|
||||
'pk',
|
||||
'name',
|
||||
'description',
|
||||
'template',
|
||||
'filters',
|
||||
'enabled',
|
||||
]
|
||||
|
||||
template = InvenTreeAttachmentSerializerField(required=True)
|
||||
fields = ReportSerializerBase.report_fields()
|
||||
|
||||
|
||||
class PurchaseOrderReportSerializer(InvenTreeModelSerializer):
|
||||
class PurchaseOrderReportSerializer(ReportSerializerBase):
|
||||
"""Serializer class for the PurchaseOrdeReport model"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = PurchaseOrderReport
|
||||
fields = [
|
||||
'pk',
|
||||
'name',
|
||||
'description',
|
||||
'template',
|
||||
'filters',
|
||||
'enabled',
|
||||
]
|
||||
|
||||
template = InvenTreeAttachmentSerializerField(required=True)
|
||||
fields = ReportSerializerBase.report_fields()
|
||||
|
||||
|
||||
class SalesOrderReportSerializer(InvenTreeModelSerializer):
|
||||
class SalesOrderReportSerializer(ReportSerializerBase):
|
||||
"""Serializer class for the SalesOrderReport model"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = SalesOrderReport
|
||||
fields = [
|
||||
'pk',
|
||||
'name',
|
||||
'description',
|
||||
'template',
|
||||
'filters',
|
||||
'enabled',
|
||||
]
|
||||
fields = ReportSerializerBase.report_fields()
|
||||
|
||||
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 report %}
|
||||
{% load barcode %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% 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 %}
|
||||
{% load markdownify %}
|
||||
|
||||
{% 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 report %}
|
||||
@ -6,69 +6,6 @@
|
||||
{% 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 %}
|
||||
|
||||
<img class='logo' src='{% company_image customer %}' alt="{{ customer }}" width='150'>
|
||||
|
@ -33,6 +33,15 @@ content: "{% trans 'Stock Item Test Report' %}";
|
||||
color: #F55;
|
||||
}
|
||||
|
||||
.test-not-found {
|
||||
color: #33A;
|
||||
}
|
||||
|
||||
.required-test-not-found {
|
||||
color: #EEE;
|
||||
background-color: #F55;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 5px;
|
||||
border: 1px solid;
|
||||
@ -84,7 +93,7 @@ content: "{% trans 'Stock Item Test Report' %}";
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if resul_list|length > 0 %}
|
||||
{% if test_keys|length > 0 %}
|
||||
<h3>{% trans "Test Results" %}</h3>
|
||||
|
||||
<table class='table test-table'>
|
||||
@ -101,22 +110,44 @@ content: "{% trans 'Stock Item Test Report' %}";
|
||||
<tr>
|
||||
<td colspan='5'><hr></td>
|
||||
</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'>
|
||||
<td>{{ test.test }}</td>
|
||||
{% if test.result %}
|
||||
<td>
|
||||
{% 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>
|
||||
{% else %}
|
||||
<td class='test-fail'>{% trans "Fail" %}</td>
|
||||
{% endif %}
|
||||
<td>{{ test.value }}</td>
|
||||
<td>{{ test.user.username }}</td>
|
||||
<td>{{ test.date.date.isoformat }}</td>
|
||||
<td>{{ test_result.value }}</td>
|
||||
<td>{{ test_result.user.username }}</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>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
{% else %}
|
||||
<em>No tests defined for this stock item</em>
|
||||
{% endif %}
|
||||
|
||||
{% if installed_items|length > 0 %}
|
||||
|
@ -19,17 +19,52 @@ logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
This function is provided to get around template rendering limitations.
|
||||
Ref: https://stackoverflow.com/questions/1906129/dict-keys-with-spaces-in-django-templates
|
||||
|
||||
Arguments:
|
||||
value: A python dict object
|
||||
arg: The 'key' to be found within the dict
|
||||
container: A python dict object
|
||||
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()
|
||||
@ -215,3 +250,31 @@ def render_currency(money, **kwargs):
|
||||
"""Render a currency / Money object"""
|
||||
|
||||
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"""
|
||||
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):
|
||||
"""Tests for the 'getkey' template tag"""
|
||||
|
||||
@ -419,7 +433,7 @@ class BOMReportTest(ReportTest):
|
||||
|
||||
|
||||
class PurchaseOrderReportTest(ReportTest):
|
||||
"""Unit test class fort he PurchaseOrderReport model"""
|
||||
"""Unit test class for the PurchaseOrderReport model"""
|
||||
model = report_models.PurchaseOrderReport
|
||||
|
||||
list_url = 'api-po-report-list'
|
||||
@ -446,3 +460,18 @@ class SalesOrderReportTest(ReportTest):
|
||||
self.copyReportTemplate('inventree_so_report.html', 'sales order report')
|
||||
|
||||
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,
|
||||
ListAPI, ListCreateAPI, RetrieveAPI,
|
||||
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI)
|
||||
from order.models import PurchaseOrder, SalesOrder, SalesOrderAllocation
|
||||
from order.serializers import PurchaseOrderSerializer
|
||||
from order.models import (PurchaseOrder, ReturnOrder, SalesOrder,
|
||||
SalesOrderAllocation)
|
||||
from order.serializers import (PurchaseOrderSerializer, ReturnOrderSerializer,
|
||||
SalesOrderSerializer)
|
||||
from part.models import BomItem, Part, PartCategory
|
||||
from part.serializers import PartBriefSerializer
|
||||
from plugin.serializers import MetadataSerializer
|
||||
@ -1262,7 +1264,7 @@ class StockTrackingList(ListAPI):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add purchaseorder detail
|
||||
# Add PurchaseOrder detail
|
||||
if 'purchaseorder' in deltas:
|
||||
try:
|
||||
order = PurchaseOrder.objects.get(pk=deltas['purchaseorder'])
|
||||
@ -1271,6 +1273,24 @@ class StockTrackingList(ListAPI):
|
||||
except Exception:
|
||||
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():
|
||||
return JsonResponse(data, safe=False)
|
||||
else:
|
||||
@ -1368,7 +1388,7 @@ stock_api_urls = [
|
||||
re_path(r'^tree/', StockLocationTree.as_view(), name='api-location-tree'),
|
||||
|
||||
# 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'),
|
||||
|
||||
@ -1388,24 +1408,24 @@ stock_api_urls = [
|
||||
|
||||
# StockItemAttachment API endpoints
|
||||
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'),
|
||||
])),
|
||||
|
||||
# StockItemTestResult API endpoints
|
||||
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'),
|
||||
])),
|
||||
|
||||
# StockItemTracking API endpoints
|
||||
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'),
|
||||
])),
|
||||
|
||||
# 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'^install/', StockItemInstall.as_view(), name='api-stock-item-install'),
|
||||
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:
|
||||
deltas['status'] = self.status
|
||||
|
||||
# TODO - Other interesting changes we are interested in...
|
||||
|
||||
if add_note and len(deltas) > 0:
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.EDITED,
|
||||
@ -960,17 +958,22 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M
|
||||
item.customer = customer
|
||||
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(
|
||||
StockHistoryCode.SENT_TO_CUSTOMER,
|
||||
code,
|
||||
user,
|
||||
{
|
||||
'customer': customer.id,
|
||||
'customer_name': customer.name,
|
||||
},
|
||||
deltas,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
@ -992,7 +995,9 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, common.models.MetaMixin, M
|
||||
"""
|
||||
notes = kwargs.get('notes', '')
|
||||
|
||||
tracking_info = {}
|
||||
tracking_info = {
|
||||
'location': location.pk,
|
||||
}
|
||||
|
||||
if self.customer:
|
||||
tracking_info['customer'] = self.customer.id
|
||||
|
@ -222,30 +222,18 @@
|
||||
);
|
||||
});
|
||||
|
||||
enableDragAndDrop(
|
||||
'#attachment-dropzone',
|
||||
"{% url 'api-stock-attachment-list' %}",
|
||||
{
|
||||
data: {
|
||||
stock_item: {{ item.id }},
|
||||
},
|
||||
label: 'attachment',
|
||||
success: function(data, status, xhr) {
|
||||
reloadAttachmentTable();
|
||||
onPanelLoad('attachments', function() {
|
||||
loadAttachmentTable('{% url "api-stock-attachment-list" %}', {
|
||||
filters: {
|
||||
stock_item: {{ item.pk }},
|
||||
},
|
||||
fields: {
|
||||
stock_item: {
|
||||
value: {{ item.pk }},
|
||||
hidden: true,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
loadAttachmentTable('{% url "api-stock-attachment-list" %}', {
|
||||
filters: {
|
||||
stock_item: {{ item.pk }},
|
||||
},
|
||||
fields: {
|
||||
stock_item: {
|
||||
value: {{ item.pk }},
|
||||
hidden: true,
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
loadStockTestResultsTable(
|
||||
@ -255,12 +243,12 @@
|
||||
}
|
||||
);
|
||||
|
||||
function reloadTable() {
|
||||
$("#test-result-table").bootstrapTable("refresh");
|
||||
}
|
||||
|
||||
$("#test-report").click(function() {
|
||||
printTestReports([{{ item.pk }}]);
|
||||
printReports({
|
||||
items: [{{ item.pk }}],
|
||||
key: 'item',
|
||||
url: '{% url "api-stockitem-testreport-list" %}',
|
||||
});
|
||||
});
|
||||
|
||||
{% if user.is_staff %}
|
||||
@ -299,7 +287,7 @@
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete Test Data" %}',
|
||||
preFormContent: html,
|
||||
onSuccess: reloadTable,
|
||||
refreshTable: '#test-result-table',
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -315,7 +303,7 @@
|
||||
stock_item: {{ item.pk }},
|
||||
}),
|
||||
title: '{% trans "Add Test Result" %}',
|
||||
onSuccess: reloadTable,
|
||||
refreshTable: '#test-result-table',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -493,11 +493,19 @@ $('#stock-uninstall').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() {
|
||||
printStockItemLabels([{{ item.pk }}]);
|
||||
printLabels({
|
||||
items: [{{ item.pk }}],
|
||||
url: '{% url "api-stockitem-label-list" %}',
|
||||
key: 'item',
|
||||
});
|
||||
});
|
||||
|
||||
{% if roles.stock.change %}
|
||||
|
@ -228,17 +228,6 @@
|
||||
<div class='panel-content'>
|
||||
<div id='sublocation-button-toolbar'>
|
||||
<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" %}
|
||||
</div>
|
||||
</div>
|
||||
@ -299,21 +288,11 @@
|
||||
|
||||
var locs = [{{ location.pk }}];
|
||||
|
||||
printStockLocationLabels(locs);
|
||||
|
||||
});
|
||||
|
||||
$('#multi-location-print-label').click(function() {
|
||||
|
||||
var selections = getTableData('#sublocation-table');
|
||||
|
||||
var locations = [];
|
||||
|
||||
selections.forEach(function(loc) {
|
||||
locations.push(loc.pk);
|
||||
printLabels({
|
||||
items: locs,
|
||||
key: 'location',
|
||||
url: '{% url "api-stocklocation-label-list" %}',
|
||||
});
|
||||
|
||||
printStockLocationLabels(locations);
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
|
@ -491,7 +491,7 @@ class StockTest(StockTestBase):
|
||||
# Check that a tracking item was added
|
||||
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)
|
||||
|
||||
def test_return_from_customer(self):
|
||||
|
@ -1,12 +1,12 @@
|
||||
"""URL lookup for Stock app."""
|
||||
|
||||
from django.urls import include, re_path
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from stock import views
|
||||
|
||||
location_urls = [
|
||||
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
path(r'<int:pk>/', include([
|
||||
# Anything else - direct to the location detail view
|
||||
re_path('^.*$', views.StockLocationDetail.as_view(), name='stock-location-detail'),
|
||||
])),
|
||||
|
@ -75,9 +75,7 @@ $('#history-delete').click(function() {
|
||||
multi_delete: true,
|
||||
preFormContent: html,
|
||||
title: '{% trans "Delete Notifications" %}',
|
||||
onSuccess: function() {
|
||||
$('#history-table').bootstrapTable('refresh');
|
||||
},
|
||||
refreshTable: '#history-table',
|
||||
form_data: {
|
||||
filters: {
|
||||
read: true,
|
||||
@ -88,7 +86,7 @@ $('#history-delete').click(function() {
|
||||
});
|
||||
|
||||
$("#history-table").on('click', '.notification-delete', function() {
|
||||
constructForm(`/api/notifications/${$(this).attr('pk')}/`, {
|
||||
constructForm(`{% url "api-notifications-list" %}${$(this).attr('pk')}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete Notification" %}',
|
||||
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/po.html" %}
|
||||
{% include "InvenTree/settings/so.html" %}
|
||||
{% include "InvenTree/settings/returns.html" %}
|
||||
|
||||
{% include "InvenTree/settings/plugin.html" %}
|
||||
{% plugin_list as pl_list %}
|
||||
|
@ -284,9 +284,7 @@ onPanelLoad('parts', function() {
|
||||
},
|
||||
method: 'POST',
|
||||
title: '{% trans "Create Part Parameter Template" %}',
|
||||
onSuccess: function() {
|
||||
$("#param-table").bootstrapTable('refresh');
|
||||
},
|
||||
refreshTable: '#param-table',
|
||||
});
|
||||
});
|
||||
|
||||
@ -303,9 +301,7 @@ onPanelLoad('parts', function() {
|
||||
description: {},
|
||||
},
|
||||
title: '{% trans "Edit Part Parameter Template" %}',
|
||||
onSuccess: function() {
|
||||
$("#param-table").bootstrapTable('refresh');
|
||||
},
|
||||
refreshTable: '#param-table',
|
||||
}
|
||||
);
|
||||
});
|
||||
@ -325,9 +321,7 @@ onPanelLoad('parts', function() {
|
||||
method: 'DELETE',
|
||||
preFormContent: html,
|
||||
title: '{% trans "Delete Part Parameter Template" %}',
|
||||
onSuccess: function() {
|
||||
$("#param-table").bootstrapTable('refresh');
|
||||
},
|
||||
refreshTable: '#param-table',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -52,6 +52,8 @@
|
||||
{% include "sidebar_item.html" with label='purchase-order' text=text icon="fa-shopping-cart" %}
|
||||
{% trans "Sales Orders" as text %}
|
||||
{% 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 %}
|
||||
{% 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_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_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' %}
|
||||
|
||||
|
@ -5,6 +5,7 @@
|
||||
{% plugins_enabled as plugins_enabled %}
|
||||
{% settings_value 'BARCODE_ENABLE' as barcodes %}
|
||||
{% 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 "SERVER_RESTART_REQUIRED" as server_restart_required %}
|
||||
{% 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 'order.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 'sales_order.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 'plugin.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
|
||||
*/
|
||||
|
||||
/*
|
||||
* Extract the first displayed date on the calendar
|
||||
*/
|
||||
function startDate(calendar) {
|
||||
// Extract the first displayed date on the calendar
|
||||
return calendar.currentData.dateProfile.activeRange.start.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/*
|
||||
* Extract the last display date on the calendar
|
||||
*/
|
||||
function endDate(calendar) {
|
||||
// Extract the last display date on the calendar
|
||||
return calendar.currentData.dateProfile.activeRange.end.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove all events from the calendar
|
||||
*/
|
||||
function clearEvents(calendar) {
|
||||
// Remove all events from the calendar
|
||||
|
||||
var events = calendar.getEvents();
|
||||
|
||||
|
@ -40,8 +40,17 @@ function getCookie(name) {
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Perform a GET request to the InvenTree server
|
||||
*/
|
||||
function inventreeGet(url, filters={}, options={}) {
|
||||
|
||||
if (!url) {
|
||||
console.error('inventreeGet called without url');
|
||||
return;
|
||||
}
|
||||
|
||||
// Middleware token required for data update
|
||||
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={}) {
|
||||
/* Upload via AJAX using the FormData approach.
|
||||
*
|
||||
* Note that the following AJAX parameters are required for FormData upload
|
||||
*
|
||||
* processData: false
|
||||
* contentType: false
|
||||
*/
|
||||
|
||||
if (!url) {
|
||||
console.error('inventreeFormDataUpload called without url');
|
||||
return;
|
||||
}
|
||||
|
||||
// CSRF cookie token
|
||||
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={}) {
|
||||
|
||||
if (!url) {
|
||||
console.error('inventreePut called without url');
|
||||
return;
|
||||
}
|
||||
|
||||
var method = options.method || 'PUT';
|
||||
|
||||
// Middleware token required for data update
|
||||
@ -164,6 +188,11 @@ function inventreePut(url, data={}, options={}) {
|
||||
*/
|
||||
function inventreeDelete(url, options={}) {
|
||||
|
||||
if (!url) {
|
||||
console.error('inventreeDelete called without url');
|
||||
return;
|
||||
}
|
||||
|
||||
options = options || {};
|
||||
|
||||
options.method = 'DELETE';
|
||||
|
@ -1,15 +1,14 @@
|
||||
{% load i18n %}
|
||||
|
||||
/* globals
|
||||
makeIconButton,
|
||||
renderLink,
|
||||
wrapButtons,
|
||||
*/
|
||||
|
||||
/* exported
|
||||
attachmentLink,
|
||||
addAttachmentButtonCallbacks,
|
||||
loadAttachmentTable,
|
||||
reloadAttachmentTable,
|
||||
loadAttachmentTable
|
||||
*/
|
||||
|
||||
|
||||
@ -35,7 +34,7 @@ function addAttachmentButtonCallbacks(url, fields={}) {
|
||||
constructForm(url, {
|
||||
fields: file_fields,
|
||||
method: 'POST',
|
||||
onSuccess: reloadAttachmentTable,
|
||||
refreshTable: '#attachment-table',
|
||||
title: '{% trans "Add Attachment" %}',
|
||||
});
|
||||
});
|
||||
@ -57,7 +56,7 @@ function addAttachmentButtonCallbacks(url, fields={}) {
|
||||
constructForm(url, {
|
||||
fields: link_fields,
|
||||
method: 'POST',
|
||||
onSuccess: reloadAttachmentTable,
|
||||
refreshTable: '#attachment-table',
|
||||
title: '{% trans "Add Link" %}',
|
||||
});
|
||||
});
|
||||
@ -79,9 +78,9 @@ function deleteAttachments(attachments, url, options={}) {
|
||||
var icon = '';
|
||||
|
||||
if (attachment.filename) {
|
||||
icon = `<span class='fas fa-file-alt'></span>`;
|
||||
icon = makeIcon(attachmentIcon(attachment.filename), '');
|
||||
} else if (attachment.link) {
|
||||
icon = `<span class='fas fa-link'></span>`;
|
||||
icon = makeIcon('fa-link', '');
|
||||
}
|
||||
|
||||
return `
|
||||
@ -123,29 +122,15 @@ function deleteAttachments(attachments, url, options={}) {
|
||||
items: ids,
|
||||
filters: options.filters,
|
||||
},
|
||||
onSuccess: function() {
|
||||
// Refresh the table once all attachments are deleted
|
||||
$('#attachment-table').bootstrapTable('refresh');
|
||||
}
|
||||
refreshTable: '#attachment-table',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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) {
|
||||
|
||||
if (!filename) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function attachmentIcon(filename) {
|
||||
// Default file icon (if no better choice is found)
|
||||
let icon = 'fa-file-alt';
|
||||
let fn = filename.toLowerCase();
|
||||
@ -171,10 +156,25 @@ function attachmentLink(filename) {
|
||||
});
|
||||
}
|
||||
|
||||
let split = filename.split('/');
|
||||
fn = split[split.length - 1];
|
||||
return icon;
|
||||
}
|
||||
|
||||
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});
|
||||
|
||||
@ -271,7 +271,7 @@ function loadAttachmentTable(url, options) {
|
||||
delete opts.fields.link;
|
||||
}
|
||||
},
|
||||
onSuccess: reloadAttachmentTable,
|
||||
refreshTable: '#attachment-table',
|
||||
title: '{% trans "Edit Attachment" %}',
|
||||
});
|
||||
});
|
||||
@ -299,7 +299,7 @@ function loadAttachmentTable(url, options) {
|
||||
if (row.attachment) {
|
||||
return attachmentLink(row.attachment);
|
||||
} 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);
|
||||
} else {
|
||||
return '-';
|
||||
@ -327,13 +327,10 @@ function loadAttachmentTable(url, options) {
|
||||
{
|
||||
field: 'actions',
|
||||
formatter: function(value, row) {
|
||||
var html = '';
|
||||
|
||||
html = `<div class='btn-group float-right' role='group'>`;
|
||||
let buttons = '';
|
||||
|
||||
if (permissions.change) {
|
||||
html += makeIconButton(
|
||||
'fa-edit icon-blue',
|
||||
buttons += makeEditButton(
|
||||
'button-attachment-edit',
|
||||
row.pk,
|
||||
'{% trans "Edit attachment" %}',
|
||||
@ -341,19 +338,30 @@ function loadAttachmentTable(url, options) {
|
||||
}
|
||||
|
||||
if (permissions.delete) {
|
||||
html += makeIconButton(
|
||||
'fa-trash-alt icon-red',
|
||||
buttons += makeDeleteButton(
|
||||
'button-attachment-delete',
|
||||
row.pk,
|
||||
'{% trans "Delete attachment" %}',
|
||||
);
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
return html;
|
||||
return wrapButtons(buttons);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// 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
|
||||
imageHoverIcon,
|
||||
inventreePut,
|
||||
makeIconButton,
|
||||
modalEnable,
|
||||
modalSetContent,
|
||||
modalSetTitle,
|
||||
@ -43,11 +42,11 @@ function makeBarcodeInput(placeholderText='', hintText='') {
|
||||
<div class='controls'>
|
||||
<div class='input-group'>
|
||||
<span class='input-group-text'>
|
||||
<span class='fas fa-qrcode'></span>
|
||||
${makeIcon('fa-qrcode')}
|
||||
</span>
|
||||
<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;'>
|
||||
<span class='fas fa-camera'></span>
|
||||
${makeIcon('fa-camera')}
|
||||
</button>
|
||||
</div>
|
||||
<div id='hint_barcode_data' class='help-block'>${hintText}</div>
|
||||
@ -132,7 +131,7 @@ function makeNotesField(options={}) {
|
||||
<div class='controls'>
|
||||
<div class='input-group'>
|
||||
<span class='input-group-text'>
|
||||
<span class='fas fa-sticky-note'></span>
|
||||
${makeIcon('fa-sticky-note')}
|
||||
</span>
|
||||
<input id='notes' class='textinput textInput form-control' type='text' name='notes' placeholder='${placeholder}'>
|
||||
</div>
|
||||
@ -149,7 +148,7 @@ function postBarcodeData(barcode_data, options={}) {
|
||||
|
||||
var modal = options.modal || '#modal-form';
|
||||
|
||||
var url = options.url || '/api/barcode/';
|
||||
var url = options.url || '{% url "api-barcode-scan" %}';
|
||||
|
||||
var data = options.data || {};
|
||||
|
||||
@ -462,7 +461,7 @@ function unlinkBarcode(data, options={}) {
|
||||
accept_text: '{% trans "Unlink" %}',
|
||||
accept: function() {
|
||||
inventreePut(
|
||||
'/api/barcode/unlink/',
|
||||
'{% url "api-barcode-unlink" %}',
|
||||
data,
|
||||
{
|
||||
method: 'POST',
|
||||
@ -521,7 +520,7 @@ function barcodeCheckInStockItems(location_id, options={}) {
|
||||
<td>${imageHoverIcon(item.part_detail.thumbnail)} ${item.part_detail.name}</td>
|
||||
<td>${location_info}</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>`;
|
||||
});
|
||||
|
||||
@ -691,7 +690,7 @@ function barcodeCheckInStockLocations(location_id, options={}) {
|
||||
if ('stocklocation' in response) {
|
||||
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
|
||||
inventreePut(
|
||||
@ -812,7 +811,7 @@ function scanItemsIntoLocation(item_list, options={}) {
|
||||
|
||||
var pk = response.stocklocation.pk;
|
||||
|
||||
inventreeGet(`/api/stock/location/${pk}/`, {}, {
|
||||
inventreeGet(`{% url "api-location-list" %}${pk}/`, {}, {
|
||||
success: function(response) {
|
||||
|
||||
stock_location = response;
|
||||
|
@ -96,12 +96,12 @@ function constructBomUploadTable(data, options={}) {
|
||||
var optional = constructRowField('optional');
|
||||
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 += makeIconButton('fa-times icon-red', 'button-row-remove', idx, '{% trans "Remove row" %}');
|
||||
buttons += makeInfoButton('button-row-data', idx, '{% trans "Display row data" %}');
|
||||
buttons += makeRemoveButton('button-row-remove', idx, '{% trans "Remove row" %}');
|
||||
|
||||
buttons += `</div>`;
|
||||
buttons = wrapButtons(buttons);
|
||||
|
||||
var html = `
|
||||
<tr id='items_${idx}' class='bom-import-row' idx='${idx}'>
|
||||
@ -557,7 +557,7 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
|
||||
|
||||
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
|
||||
var html = `
|
||||
@ -626,7 +626,7 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
constructForm(`/api/bom/substitute/${pk}/`, {
|
||||
constructForm(`{% url "api-bom-substitute-list" %}${pk}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Remove Substitute Part" %}',
|
||||
preFormContent: pre,
|
||||
@ -785,9 +785,7 @@ function loadBomTable(table, options={}) {
|
||||
filters = loadTableFilters('bom');
|
||||
}
|
||||
|
||||
for (var key in params) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
Object.assign(filters, params);
|
||||
|
||||
setupFilterList('bom', $(table));
|
||||
|
||||
@ -1142,7 +1140,7 @@ function loadBomTable(table, options={}) {
|
||||
}
|
||||
|
||||
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 {
|
||||
var extra = '';
|
||||
|
||||
@ -1160,7 +1158,10 @@ function loadBomTable(table, options={}) {
|
||||
}
|
||||
|
||||
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);
|
||||
@ -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 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) {
|
||||
html += bValidate;
|
||||
@ -1254,13 +1254,13 @@ function loadBomTable(table, options={}) {
|
||||
html += bValid;
|
||||
}
|
||||
|
||||
html += bEdit;
|
||||
html += bSubs;
|
||||
html += bDelt;
|
||||
var buttons = '';
|
||||
buttons += bEdit;
|
||||
buttons += bSubs;
|
||||
buttons += bDelt;
|
||||
|
||||
html += `</div>`;
|
||||
return wrapButtons(buttons);
|
||||
|
||||
return html;
|
||||
} else {
|
||||
// Return a link to the external BOM
|
||||
|
||||
@ -1273,7 +1273,7 @@ function loadBomTable(table, options={}) {
|
||||
footerFormatter: function(data) {
|
||||
return `
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
@ -1436,7 +1436,7 @@ function loadBomTable(table, options={}) {
|
||||
|
||||
var fields = bomItemFields();
|
||||
|
||||
constructForm(`/api/bom/${pk}/`, {
|
||||
constructForm(`{% url "api-bom-list" %}${pk}/`, {
|
||||
fields: fields,
|
||||
title: '{% trans "Edit BOM Item" %}',
|
||||
focus: 'sub_part',
|
||||
@ -1508,15 +1508,7 @@ function loadUsedInTable(table, part_id, options={}) {
|
||||
params.part_detail = true;
|
||||
params.sub_part_detail = true;
|
||||
|
||||
var filters = {};
|
||||
|
||||
if (!options.disableFilters) {
|
||||
filters = loadTableFilters('usedin');
|
||||
}
|
||||
|
||||
for (var key in params) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
var filters = loadTableFilters('usedin', params);
|
||||
|
||||
setupFilterList('usedin', $(table), options.filterTarget || '#filter-list-usedin');
|
||||
|
||||
|
@ -90,7 +90,7 @@ function editBuildOrder(pk) {
|
||||
|
||||
var fields = buildFormFields();
|
||||
|
||||
constructForm(`/api/build/${pk}/`, {
|
||||
constructForm(`{% url "api-build-list" %}${pk}/`, {
|
||||
fields: fields,
|
||||
reload: true,
|
||||
title: '{% trans "Edit Build Order" %}',
|
||||
@ -147,7 +147,7 @@ function newBuildOrder(options={}) {
|
||||
*/
|
||||
function duplicateBuildOrder(build_id, options={}) {
|
||||
|
||||
inventreeGet(`/api/build/${build_id}/`, {}, {
|
||||
inventreeGet(`{% url "api-build-list" %}${build_id}/`, {}, {
|
||||
success: function(data) {
|
||||
// Clear out data we do not want to be duplicated
|
||||
delete data['pk'];
|
||||
@ -166,7 +166,7 @@ function duplicateBuildOrder(build_id, options={}) {
|
||||
function cancelBuildOrder(build_id, options={}) {
|
||||
|
||||
constructForm(
|
||||
`/api/build/${build_id}/cancel/`,
|
||||
`{% url "api-build-list" %}${build_id}/cancel/`,
|
||||
{
|
||||
method: 'POST',
|
||||
title: '{% trans "Cancel Build Order" %}',
|
||||
@ -208,7 +208,7 @@ function cancelBuildOrder(build_id, options={}) {
|
||||
/* Construct a form to "complete" (finish) a build order */
|
||||
function completeBuildOrder(build_id, options={}) {
|
||||
|
||||
constructForm(`/api/build/${build_id}/finish/`, {
|
||||
constructForm(`{% url "api-build-list" %}${build_id}/finish/`, {
|
||||
fieldsFunction: function(opts) {
|
||||
var ctx = opts.context || {};
|
||||
|
||||
@ -287,7 +287,7 @@ function createBuildOutput(build_id, options) {
|
||||
|
||||
// Request build order information from the server
|
||||
inventreeGet(
|
||||
`/api/build/${build_id}/`,
|
||||
`{% url "api-build-list" %}${build_id}/`,
|
||||
{},
|
||||
{
|
||||
success: function(build) {
|
||||
@ -312,7 +312,7 @@ function createBuildOutput(build_id, options) {
|
||||
};
|
||||
|
||||
// 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) {
|
||||
if (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',
|
||||
title: '{% trans "Create Build Output" %}',
|
||||
confirm: true,
|
||||
@ -364,7 +364,7 @@ function createBuildOutput(build_id, 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
|
||||
if (options.has_bom_items) {
|
||||
@ -398,17 +398,13 @@ function makeBuildOutputButtons(output_id, build_info, options={}) {
|
||||
);
|
||||
|
||||
// Add a button to "delete" this build output
|
||||
html += makeIconButton(
|
||||
'fa-trash-alt icon-red',
|
||||
html += makeDeleteButton(
|
||||
'button-output-delete',
|
||||
output_id,
|
||||
'{% trans "Delete build output" %}',
|
||||
);
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
return html;
|
||||
|
||||
return wrapButtons(html);
|
||||
}
|
||||
|
||||
|
||||
@ -421,7 +417,7 @@ function makeBuildOutputButtons(output_id, build_info, options={}) {
|
||||
*/
|
||||
function unallocateStock(build_id, options={}) {
|
||||
|
||||
var url = `/api/build/${build_id}/unallocate/`;
|
||||
var url = `{% url "api-build-list" %}${build_id}/unallocate/`;
|
||||
|
||||
var html = `
|
||||
<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'>`;
|
||||
|
||||
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove row" %}');
|
||||
buttons += makeRemoveButton('button-row-remove', pk, '{% trans "Remove row" %}');
|
||||
|
||||
buttons += '</div>';
|
||||
|
||||
@ -529,7 +525,7 @@ function completeBuildOutputs(build_id, outputs, options={}) {
|
||||
</tbody>
|
||||
</table>`;
|
||||
|
||||
constructForm(`/api/build/${build_id}/complete/`, {
|
||||
constructForm(`{% url "api-build-list" %}${build_id}/complete/`, {
|
||||
method: 'POST',
|
||||
preFormContent: html,
|
||||
fields: {
|
||||
@ -647,7 +643,7 @@ function deleteBuildOutputs(build_id, outputs, options={}) {
|
||||
|
||||
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>';
|
||||
|
||||
@ -690,7 +686,7 @@ function deleteBuildOutputs(build_id, outputs, options={}) {
|
||||
</tbody>
|
||||
</table>`;
|
||||
|
||||
constructForm(`/api/build/${build_id}/delete-outputs/`, {
|
||||
constructForm(`{% url "api-build-list" %}${build_id}/delete-outputs/`, {
|
||||
method: 'POST',
|
||||
preFormContent: html,
|
||||
fields: {},
|
||||
@ -768,11 +764,7 @@ function loadBuildOrderAllocationTable(table, options={}) {
|
||||
options.params['location_detail'] = true;
|
||||
options.params['stock_detail'] = true;
|
||||
|
||||
var filters = loadTableFilters('buildorderallocation');
|
||||
|
||||
for (var key in options.params) {
|
||||
filters[key] = options.params[key];
|
||||
}
|
||||
var filters = loadTableFilters('buildorderallocation', options.params);
|
||||
|
||||
setupFilterList('buildorderallocation', $(table));
|
||||
|
||||
@ -893,7 +885,12 @@ function loadBuildOutputTable(build_info, options={}) {
|
||||
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() {
|
||||
|
||||
@ -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() {
|
||||
$(table).bootstrapTable('expandAllRows');
|
||||
});
|
||||
@ -1482,13 +1466,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
}
|
||||
|
||||
// Filters
|
||||
var filters = loadTableFilters('builditems');
|
||||
|
||||
var params = options.params || {};
|
||||
|
||||
for (var key in params) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
let filters = loadTableFilters('builditems', options.params);
|
||||
|
||||
setupFilterList('builditems', $(table), options.filterTarget);
|
||||
|
||||
@ -1703,6 +1681,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
name: 'build-allocation',
|
||||
uniqueId: 'sub_part',
|
||||
search: options.search || false,
|
||||
queryParams: filters,
|
||||
original: options.params,
|
||||
onPostBody: function(data) {
|
||||
// Setup button callbacks
|
||||
setupCallbacks();
|
||||
@ -1796,15 +1776,13 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
|
||||
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 html;
|
||||
return wrapButtons(html);
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -1814,7 +1792,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
subTable.find('.button-allocation-edit').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/build/item/${pk}/`, {
|
||||
constructForm(`{% url "api-build-item-list" %}${pk}/`, {
|
||||
fields: {
|
||||
quantity: {},
|
||||
},
|
||||
@ -1826,7 +1804,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
subTable.find('.button-allocation-delete').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/build/item/${pk}/`, {
|
||||
constructForm(`{% url "api-build-item-list" %}${pk}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Remove Allocation" %}',
|
||||
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>`;
|
||||
} else {
|
||||
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 {
|
||||
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) {
|
||||
@ -1953,13 +1931,15 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
}
|
||||
|
||||
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) {
|
||||
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;
|
||||
@ -2027,7 +2007,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
}
|
||||
|
||||
// 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 (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-minus-circle icon-red', 'button-unallocate', row.sub_part,
|
||||
html += makeRemoveButton(
|
||||
'button-unallocate',
|
||||
row.sub_part,
|
||||
'{% trans "Unallocate stock" %}',
|
||||
{
|
||||
disabled: allocatedQuantity(row) == 0,
|
||||
}
|
||||
);
|
||||
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
return wrapButtons(html);
|
||||
}
|
||||
},
|
||||
]
|
||||
@ -2093,7 +2072,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
|
||||
if (output_id) {
|
||||
// Request information on the particular build output (stock item)
|
||||
inventreeGet(`/api/stock/${output_id}/`, {}, {
|
||||
inventreeGet(`{% url "api-stock-list" %}${output_id}/`, {}, {
|
||||
success: function(output) {
|
||||
if (output.quantity == 1 && output.serial != null) {
|
||||
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'>`;
|
||||
|
||||
delete_button += makeIconButton(
|
||||
'fa-times icon-red',
|
||||
delete_button += makeRemoveButton(
|
||||
'button-row-remove',
|
||||
pk,
|
||||
'{% trans "Remove row" %}',
|
||||
@ -2245,7 +2223,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
||||
</table>
|
||||
`;
|
||||
|
||||
constructForm(`/api/build/${build_id}/allocate/`, {
|
||||
constructForm(`{% url "api-build-list" %}${build_id}/allocate/`, {
|
||||
method: 'POST',
|
||||
fields: {},
|
||||
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',
|
||||
fields: fields,
|
||||
title: '{% trans "Allocate Stock Items" %}',
|
||||
@ -2484,21 +2462,19 @@ function loadBuildTable(table, options) {
|
||||
|
||||
var params = options.params || {};
|
||||
|
||||
var filters = {};
|
||||
|
||||
params['part_detail'] = true;
|
||||
|
||||
if (!options.disableFilters) {
|
||||
filters = loadTableFilters('build');
|
||||
}
|
||||
|
||||
for (var key in params) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
var filters = loadTableFilters('build', params);
|
||||
|
||||
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?
|
||||
var display_mode = inventreeLoad('build-table-display-mode', 'list');
|
||||
|
@ -4,23 +4,26 @@
|
||||
constructForm,
|
||||
imageHoverIcon,
|
||||
loadTableFilters,
|
||||
makeIconButton,
|
||||
renderLink,
|
||||
setupFilterList,
|
||||
*/
|
||||
|
||||
/* exported
|
||||
createCompany,
|
||||
createContact,
|
||||
createManufacturerPart,
|
||||
createSupplierPart,
|
||||
createSupplierPartPriceBreak,
|
||||
deleteContacts,
|
||||
deleteManufacturerParts,
|
||||
deleteManufacturerPartParameters,
|
||||
deleteSupplierParts,
|
||||
duplicateSupplierPart,
|
||||
editCompany,
|
||||
editContact,
|
||||
editSupplierPartPriceBreak,
|
||||
loadCompanyTable,
|
||||
loadContactTable,
|
||||
loadManufacturerPartTable,
|
||||
loadManufacturerPartParameterTable,
|
||||
loadSupplierPartTable,
|
||||
@ -197,7 +200,7 @@ function createSupplierPart(options={}) {
|
||||
var header = '';
|
||||
if (options.part) {
|
||||
var part_model = {};
|
||||
inventreeGet(`/api/part/${options.part}/.*`, {}, {
|
||||
inventreeGet(`{% url "api-part-list" %}${options.part}/.*`, {}, {
|
||||
async: false,
|
||||
success: function(response) {
|
||||
part_model = response;
|
||||
@ -226,7 +229,7 @@ function duplicateSupplierPart(part, options={}) {
|
||||
var fields = options.fields || supplierPartFields();
|
||||
|
||||
// Retrieve information for the supplied part
|
||||
inventreeGet(`/api/company/part/${part}/`, {}, {
|
||||
inventreeGet(`{% url "api-supplier-part-list" %}${part}/`, {}, {
|
||||
success: function(data) {
|
||||
|
||||
// Remove fields which we do not want to duplicate
|
||||
@ -234,7 +237,7 @@ function duplicateSupplierPart(part, options={}) {
|
||||
delete data['available'];
|
||||
delete data['availability_updated'];
|
||||
|
||||
constructForm(`/api/company/part/`, {
|
||||
constructForm('{% url "api-supplier-part-list" %}', {
|
||||
method: 'POST',
|
||||
fields: fields,
|
||||
title: '{% trans "Duplicate Supplier Part" %}',
|
||||
@ -260,7 +263,7 @@ function editSupplierPart(part, options={}) {
|
||||
fields.part.hidden = true;
|
||||
}
|
||||
|
||||
constructForm(`/api/company/part/${part}/`, {
|
||||
constructForm(`{% url "api-supplier-part-list" %}${part}/`, {
|
||||
fields: fields,
|
||||
title: options.title || '{% trans "Edit Supplier Part" %}',
|
||||
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={}) {
|
||||
/*
|
||||
* 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
|
||||
var params = options.params || {};
|
||||
|
||||
var filters = loadTableFilters('company');
|
||||
|
||||
for (var key in params) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
let params = options.params || {};
|
||||
let filters = loadTableFilters('company', params);
|
||||
|
||||
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.
|
||||
* - User will be provided with a modal form, showing all the parts to be deleted.
|
||||
* - Delete operations are performed sequentialy, not simultaneously
|
||||
@ -653,21 +874,16 @@ function deleteManufacturerPartParameters(selections, options={}) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Load manufacturer part table
|
||||
*/
|
||||
function loadManufacturerPartTable(table, url, options) {
|
||||
/*
|
||||
* Load manufacturer part table
|
||||
*
|
||||
*/
|
||||
|
||||
// Query parameters
|
||||
var params = options.params || {};
|
||||
|
||||
// Load filters
|
||||
var filters = loadTableFilters('manufacturer-part');
|
||||
|
||||
for (var key in params) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
var filters = loadTableFilters('manufacturer-part', params);
|
||||
|
||||
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);
|
||||
|
||||
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) {
|
||||
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) {
|
||||
@ -764,16 +980,13 @@ function loadManufacturerPartTable(table, url, options) {
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
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" %}');
|
||||
html += makeIconButton('fa-trash-alt icon-red', 'button-manufacturer-part-delete', pk, '{% trans "Delete manufacturer part" %}');
|
||||
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
return wrapButtons(html);
|
||||
}
|
||||
}
|
||||
],
|
||||
@ -810,20 +1023,15 @@ function loadManufacturerPartTable(table, url, options) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Load table of ManufacturerPartParameter objects
|
||||
*/
|
||||
function loadManufacturerPartParameterTable(table, url, options) {
|
||||
/*
|
||||
* Load table of ManufacturerPartParameter objects
|
||||
*/
|
||||
|
||||
var params = options.params || {};
|
||||
|
||||
// Load filters
|
||||
var filters = loadTableFilters('manufacturer-part-parameters');
|
||||
|
||||
// Overwrite explicit parameters
|
||||
for (var key in params) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
var filters = loadTableFilters('manufacturer-part-parameters', params);
|
||||
|
||||
setupFilterList('manufacturer-part-parameters', $(table));
|
||||
|
||||
@ -867,17 +1075,13 @@ function loadManufacturerPartParameterTable(table, url, options) {
|
||||
switchable: false,
|
||||
sortable: false,
|
||||
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'>`;
|
||||
|
||||
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;
|
||||
return wrapButtons(html);
|
||||
}
|
||||
}
|
||||
],
|
||||
@ -886,27 +1090,23 @@ function loadManufacturerPartParameterTable(table, url, options) {
|
||||
$(table).find('.button-parameter-edit').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/company/part/manufacturer/parameter/${pk}/`, {
|
||||
constructForm(`{% url "api-manufacturer-part-parameter-list" %}${pk}/`, {
|
||||
fields: {
|
||||
name: {},
|
||||
value: {},
|
||||
units: {},
|
||||
},
|
||||
title: '{% trans "Edit Parameter" %}',
|
||||
onSuccess: function() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
}
|
||||
refreshTable: table,
|
||||
});
|
||||
});
|
||||
$(table).find('.button-parameter-delete').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/company/part/manufacturer/parameter/${pk}/`, {
|
||||
constructForm(`{% url "api-manufacturer-part-parameter-list" %}${pk}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete Parameter" %}',
|
||||
onSuccess: function() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
}
|
||||
refreshTable: table,
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -914,21 +1114,16 @@ function loadManufacturerPartParameterTable(table, url, options) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Load supplier part table
|
||||
*/
|
||||
function loadSupplierPartTable(table, url, options) {
|
||||
/*
|
||||
* Load supplier part table
|
||||
*
|
||||
*/
|
||||
|
||||
// Query parameters
|
||||
var params = options.params || {};
|
||||
|
||||
// Load filters
|
||||
var filters = loadTableFilters('supplier-part');
|
||||
|
||||
for (var key in params) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
var filters = loadTableFilters('supplier-part', params);
|
||||
|
||||
setupFilterList('supplier-part', $(table));
|
||||
|
||||
@ -964,11 +1159,11 @@ function loadSupplierPartTable(table, url, options) {
|
||||
var html = imageHoverIcon(row.part_detail.thumbnail) + renderLink(value, url);
|
||||
|
||||
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) {
|
||||
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) {
|
||||
@ -1088,9 +1283,13 @@ function loadSupplierPartTable(table, url, options) {
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
if (row.availability_updated) {
|
||||
var html = formatDecimal(value);
|
||||
var date = renderDate(row.availability_updated, {showTime: true});
|
||||
html += `<span class='fas fa-info-circle float-right' title='{% trans "Last Updated" %}: ${date}'></span>`;
|
||||
let html = formatDecimal(value);
|
||||
let date = renderDate(row.availability_updated, {showTime: true});
|
||||
|
||||
html += makeIconBadge(
|
||||
'fa-info-circle',
|
||||
`{% trans "Last Updated" %}: ${date}`
|
||||
);
|
||||
return html;
|
||||
} else {
|
||||
return '-';
|
||||
@ -1108,16 +1307,13 @@ function loadSupplierPartTable(table, url, options) {
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
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" %}');
|
||||
html += makeIconButton('fa-trash-alt icon-red', 'button-supplier-part-delete', pk, '{% trans "Delete supplier part" %}');
|
||||
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
return wrapButtons(html);
|
||||
}
|
||||
}
|
||||
],
|
||||
@ -1166,24 +1362,20 @@ function loadSupplierPriceBreakTable(options={}) {
|
||||
table.find('.button-price-break-delete').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/company/price-break/${pk}/`, {
|
||||
constructForm(`{% url "api-part-supplier-price-list" %}${pk}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete Price Break" %}',
|
||||
onSuccess: function() {
|
||||
table.bootstrapTable('refresh');
|
||||
},
|
||||
refreshTable: table,
|
||||
});
|
||||
});
|
||||
|
||||
table.find('.button-price-break-edit').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/company/price-break/${pk}/`, {
|
||||
constructForm(`{% url "api-part-supplier-price-list" %}${pk}/`, {
|
||||
fields: supplierPartPriceBreakFields(),
|
||||
title: '{% trans "Edit Price Break" %}',
|
||||
onSuccess: function() {
|
||||
table.bootstrapTable('refresh');
|
||||
}
|
||||
refreshTable: table,
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -1231,10 +1423,12 @@ function loadSupplierPriceBreakTable(options={}) {
|
||||
formatter: function(value, row) {
|
||||
var html = renderDate(value);
|
||||
|
||||
html += `<div class='btn-group float-right' role='group'>`;
|
||||
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" %}');
|
||||
html += `</div>`;
|
||||
let buttons = '';
|
||||
|
||||
buttons += makeEditButton('button-price-break-edit', row.pk, '{% trans "Edit price break" %}');
|
||||
buttons += makeDeleteButton('button-price-break-delete', row.pk, '{% trans "Delete price break" %}');
|
||||
|
||||
html += wrapButtons(buttons);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ function defaultFilters() {
|
||||
* @param tableKey - String key for the particular table
|
||||
* @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();
|
||||
|
||||
@ -67,6 +67,9 @@ function loadTableFilters(tableKey) {
|
||||
}
|
||||
});
|
||||
|
||||
// Override configurable filters with hard-coded query
|
||||
Object.assign(filters, query);
|
||||
|
||||
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
|
||||
*
|
||||
@ -290,21 +305,58 @@ function setupFilterList(tableKey, table, target, options={}) {
|
||||
// One blank slate, please
|
||||
element.empty();
|
||||
|
||||
// Construct a set of 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
|
||||
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 (!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) {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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
|
||||
element.find(`#reload-${tableKey}`).click(function() {
|
||||
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