Merge branch 'inventree:master' into matmair/issue2201

This commit is contained in:
Matthias Mair 2021-12-04 17:53:13 +01:00 committed by GitHub
commit 2a0e07abe0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
82 changed files with 23406 additions and 16264 deletions

View File

@ -1,60 +0,0 @@
# Perform CI checks, and calculate code coverage
name: SQLite
on:
push:
branches-ignore:
- l10*
pull_request:
branches-ignore:
- l10*
jobs:
# Run tests on SQLite database
# These tests are used for code coverage analysis
coverage:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_NAME: './test_db.sqlite'
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install gettext
pip3 install invoke
invoke install
invoke static
- name: Coverage Tests
run: |
invoke coverage
- name: Data Import Export
run: |
invoke migrate
invoke import-fixtures
invoke export-records -f data.json
rm test_db.sqlite
invoke migrate
invoke import-records -f data.json
invoke import-records -f data.json
- name: Test Translations
run: invoke translate
- name: Check Migration Files
run: python3 ci/check_migration_files.py
- name: Upload Coverage Report
run: coveralls

View File

@ -1,54 +0,0 @@
# Check javascript template files
name: HTML Templates
on:
push:
branches:
- master
pull_request:
branches-ignore:
- l10*
jobs:
html:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: inventree
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Install node.js
uses: actions/setup-node@v2
with:
node-version: '16'
- run: npm install
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install gettext
pip3 install invoke
invoke install
invoke static
- name: Check HTML Files
run: |
npx markuplint InvenTree/build/templates/build/*.html
npx markuplint InvenTree/company/templates/company/*.html
npx markuplint InvenTree/order/templates/order/*.html
npx markuplint InvenTree/part/templates/part/*.html
npx markuplint InvenTree/stock/templates/stock/*.html
npx markuplint InvenTree/templates/*.html
npx markuplint InvenTree/templates/InvenTree/*.html
npx markuplint InvenTree/templates/InvenTree/settings/*.html

View File

@ -1,51 +0,0 @@
# Check javascript template files
name: Javascript Templates
on:
push:
branches:
- master
pull_request:
branches-ignore:
- l10*
jobs:
javascript:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: inventree
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Install node.js
uses: actions/setup-node@v2
with:
node-version: '16'
- run: npm install
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install gettext
pip3 install invoke
invoke install
invoke static
- name: Check Templated Files
run: |
cd ci
python check_js_templates.py
- name: Lint Javascript Files
run: |
invoke render-js-files
npx eslint js_tmp/*.js

View File

@ -1,67 +0,0 @@
# MySQL Unit Testing
name: MySQL
on:
push:
branches-ignore:
- l10*
pull_request:
branches-ignore:
- l10*
jobs:
test:
runs-on: ubuntu-latest
env:
# Database backend configuration
INVENTREE_DB_ENGINE: django.db.backends.mysql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: root
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: '127.0.0.1'
INVENTREE_DB_PORT: 3306
INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
services:
mysql:
image: mysql:latest
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_DATABASE: inventree
MYSQL_USER: inventree
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
ports:
- 3306:3306
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Install Dependencies
run: |
sudo apt-get install mysql-server libmysqlclient-dev
pip3 install invoke
pip3 install mysqlclient
invoke install
- name: Run Tests
run: invoke test
- name: Data Import Export
run: |
invoke migrate
python3 ./InvenTree/manage.py flush --noinput
invoke import-fixtures
invoke export-records -f data.json
python3 ./InvenTree/manage.py flush --noinput
invoke import-records -f data.json
invoke import-records -f data.json

View File

@ -1,70 +0,0 @@
# PostgreSQL Unit Testing
name: PostgreSQL
on:
push:
branches-ignore:
- l10*
pull_request:
branches-ignore:
- l10*
jobs:
test:
runs-on: ubuntu-latest
env:
# Database backend configuration
INVENTREE_DB_ENGINE: django.db.backends.postgresql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: inventree
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: '127.0.0.1'
INVENTREE_DB_PORT: 5432
INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
INVENTREE_CACHE_HOST: localhost
services:
postgres:
image: postgres
env:
POSTGRES_USER: inventree
POSTGRES_PASSWORD: password
ports:
- 5432:5432
redis:
image: redis
ports:
- 6379:6379
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Install Dependencies
run: |
sudo apt-get install libpq-dev
pip3 install invoke
pip3 install psycopg2
pip3 install django-redis>=5.0.0
invoke install
- name: Run Tests
run: invoke test
- name: Data Import Export
run: |
invoke migrate
python3 ./InvenTree/manage.py flush --noinput
invoke import-fixtures
invoke export-records -f data.json
python3 ./InvenTree/manage.py flush --noinput
invoke import-records -f data.json
invoke import-records -f data.json

View File

@ -1,49 +0,0 @@
# Run python library tests whenever code is pushed to master
name: Python Bindings
on:
push:
branches:
- master
pull_request:
branches-ignore:
- l10*
jobs:
python:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_NAME: './test_db.sqlite'
INVENTREE_DB_ENGINE: 'sqlite3'
INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Install InvenTree
run: |
sudo apt-get update
sudo apt-get install python3-dev python3-pip python3-venv
pip3 install invoke
invoke install
invoke migrate
- name: Download Python Code
run: |
git clone --depth 1 https://github.com/inventree/inventree-python ./inventree-python
- name: Start Server
run: |
invoke import-records -f ./inventree-python/test/test_data.json
invoke server -a 127.0.0.1:8000 &
sleep 60
- name: Run Tests
run: |
cd inventree-python
invoke test

318
.github/workflows/qc_checks.yaml vendored Normal file
View File

@ -0,0 +1,318 @@
# Checks for each PR / push
name: QC checks
on:
push:
branches-ignore:
- l10*
pull_request:
branches-ignore:
- l10*
env:
python_version: 3.7
node_version: 16
server_start_sleep: 60
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: inventree
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
jobs:
check_version:
name: version number
runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request' }}
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Check version number
if: ${{ github.event_name == 'pull_request' }}
run: |
python3 ci/check_version_number.py --branch ${{ github.base_ref }}
- name: Finish
if: always()
run: echo 'done'
pep_style:
name: PEP style (python)
needs: check_version
runs-on: ubuntu-latest
if: always()
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@v2
with:
python-version: ${{ env.python_version }}
cache: 'pip'
- name: Install deps
run: |
pip install flake8==3.8.3
pip install pep8-naming==0.11.1
- name: flake8
run: |
flake8 InvenTree
javascript:
name: javascript template files
needs: pep_style
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Install node.js ${{ env.node_version }}
uses: actions/setup-node@v2
with:
node-version: ${{ env.node_version }}
cache: 'npm'
- run: npm install
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: ${{ env.python_version }}
cache: 'pip'
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install gettext
pip3 install invoke
invoke install
invoke static
- name: Check Templated Files
run: |
cd ci
python check_js_templates.py
- name: Lint Javascript Files
run: |
invoke render-js-files
npx eslint js_tmp/*.js
html:
name: html template files
needs: pep_style
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Install node.js ${{ env.node_version }}
uses: actions/setup-node@v2
with:
node-version: ${{ env.node_version }}
cache: 'npm'
- run: npm install
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: ${{ env.python_version }}
cache: 'pip'
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install gettext
pip3 install invoke
invoke install
invoke static
- name: Check HTML Files
run: |
npx markuplint InvenTree/build/templates/build/*.html
npx markuplint InvenTree/company/templates/company/*.html
npx markuplint InvenTree/order/templates/order/*.html
npx markuplint InvenTree/part/templates/part/*.html
npx markuplint InvenTree/stock/templates/stock/*.html
npx markuplint InvenTree/templates/*.html
npx markuplint InvenTree/templates/InvenTree/*.html
npx markuplint InvenTree/templates/InvenTree/settings/*.html
python:
name: python bindings
needs: pep_style
runs-on: ubuntu-latest
env:
wrapper_name: inventree-python
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Install InvenTree
run: |
sudo apt-get update
sudo apt-get install python3-dev python3-pip python3-venv
pip3 install invoke
invoke install
invoke migrate
- name: Download Python Code
run: |
git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} ./${{ env.wrapper_name }}
- name: Start Server
run: |
invoke import-records -f ./${{ env.wrapper_name }}/test/test_data.json
invoke server -a 127.0.0.1:8000 &
sleep ${{ env.server_start_sleep }}
- name: Run Tests
run: |
cd ${{ env.wrapper_name }}
invoke test
coverage:
name: Sqlite / coverage
needs: ['javascript', 'html']
runs-on: ubuntu-latest
env:
INVENTREE_DB_NAME: ./inventree.sqlite
INVENTREE_DB_ENGINE: sqlite3
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Setup Python ${{ env.python_version }}
uses: actions/setup-python@v2
with:
python-version: ${{ env.python_version }}
cache: 'pip'
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install gettext
pip3 install invoke
invoke install
invoke static
- name: Coverage Tests
run: |
invoke coverage
- name: Data Import Export
run: |
invoke migrate
invoke import-fixtures
invoke export-records -f data.json
rm inventree.sqlite
invoke migrate
invoke import-records -f data.json
invoke import-records -f data.json
- name: Test Translations
run: invoke translate
- name: Check Migration Files
run: python3 ci/check_migration_files.py
- name: Upload Coverage Report
run: coveralls
postgres:
name: Postgres
needs: ['javascript', 'html']
runs-on: ubuntu-latest
env:
INVENTREE_DB_ENGINE: django.db.backends.postgresql
INVENTREE_DB_USER: inventree
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: '127.0.0.1'
INVENTREE_DB_PORT: 5432
INVENTREE_DEBUG: info
INVENTREE_CACHE_HOST: localhost
services:
postgres:
image: postgres
env:
POSTGRES_USER: inventree
POSTGRES_PASSWORD: password
ports:
- 5432:5432
redis:
image: redis
ports:
- 6379:6379
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Setup Python ${{ env.python_version }}
uses: actions/setup-python@v2
with:
python-version: ${{ env.python_version }}
cache: 'pip'
- name: Install Dependencies
run: |
sudo apt-get install libpq-dev
pip3 install invoke
pip3 install psycopg2
pip3 install django-redis>=5.0.0
invoke install
- name: Run Tests
run: invoke test
- name: Data Import Export
run: |
invoke migrate
python3 ./InvenTree/manage.py flush --noinput
invoke import-fixtures
invoke export-records -f data.json
python3 ./InvenTree/manage.py flush --noinput
invoke import-records -f data.json
invoke import-records -f data.json
mysql:
name: MySql
needs: ['javascript', 'html']
runs-on: ubuntu-latest
env:
# Database backend configuration
INVENTREE_DB_ENGINE: django.db.backends.mysql
INVENTREE_DB_USER: root
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: '127.0.0.1'
INVENTREE_DB_PORT: 3306
INVENTREE_DEBUG: info
services:
mysql:
image: mysql:latest
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_DATABASE: ${{ env.INVENTREE_DB_NAME }}
MYSQL_USER: inventree
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
ports:
- 3306:3306
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Setup Python ${{ env.python_version }}
uses: actions/setup-python@v2
with:
python-version: ${{ env.python_version }}
cache: 'pip'
- name: Install Dependencies
run: |
sudo apt-get install mysql-server libmysqlclient-dev
pip3 install invoke
pip3 install mysqlclient
invoke install
- name: Run Tests
run: invoke test
- name: Data Import Export
run: |
invoke migrate
python3 ./InvenTree/manage.py flush --noinput
invoke import-fixtures
invoke export-records -f data.json
python3 ./InvenTree/manage.py flush --noinput
invoke import-records -f data.json
invoke import-records -f data.json

View File

@ -1,34 +0,0 @@
name: Style Checks
on:
push:
branches-ignore:
- l10*
pull_request:
branches-ignore:
- l10*
jobs:
style:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: [3.7]
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install deps
run: |
pip install flake8==3.8.3
pip install pep8-naming==0.11.1
- name: flake8
run: |
flake8 InvenTree

View File

@ -1,20 +0,0 @@
# Check that the version number format matches the current branch
name: Version Numbering
on:
pull_request:
branches-ignore:
- l10*
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Check version number
run: |
python3 ci/check_version_number.py --branch ${{ github.base_ref }}

1
.gitignore vendored
View File

@ -77,5 +77,4 @@ dev/
locale_stats.json
# node.js
package-lock.json
node_modules/

View File

@ -107,7 +107,7 @@ class PurchaseOrderStatus(StatusCode):
}
colors = {
PENDING: 'primary',
PENDING: 'secondary',
PLACED: 'primary',
COMPLETE: 'success',
CANCELLED: 'danger',
@ -147,7 +147,7 @@ class SalesOrderStatus(StatusCode):
}
colors = {
PENDING: 'primary',
PENDING: 'secondary',
SHIPPED: 'success',
CANCELLED: 'danger',
LOST: 'warning',

View File

@ -12,10 +12,17 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev"
# InvenTree API version
INVENTREE_API_VERSION = 19
INVENTREE_API_VERSION = 21
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v21 -> 2021-12-04
- Adds support for multiple "Shipments" against a SalesOrder
- Refactors process for stock allocation against a SalesOrder
v20 -> 2021-12-03
- Adds ability to filter POLineItem endpoint by "base_part"
- Adds optional "order_detail" to POLineItem list endpoint
v19 -> 2021-12-02
- Adds the ability to filter the StockItem API by "part_tree"

View File

@ -29,6 +29,14 @@ class BuildAdmin(ImportExportModelAdmin):
'part__description',
]
autocomplete_fields = [
'parent',
'part',
'sales_order',
'take_from',
'destination',
]
class BuildItemAdmin(admin.ModelAdmin):
@ -38,6 +46,13 @@ class BuildItemAdmin(admin.ModelAdmin):
'quantity'
)
autocomplete_fields = [
'build',
'bom_item',
'stock_item',
'install_into',
]
admin.site.register(Build, BuildAdmin)
admin.site.register(BuildItem, BuildItemAdmin)

View File

@ -20,6 +20,7 @@ from InvenTree.status_codes import BuildStatus
from .models import Build, BuildItem, BuildOrderAttachment
from .serializers import BuildAttachmentSerializer, BuildCompleteSerializer, BuildSerializer, BuildItemSerializer
from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer
from users.models import Owner
class BuildFilter(rest_filters.FilterSet):
@ -51,6 +52,25 @@ class BuildFilter(rest_filters.FilterSet):
return queryset
assigned_to_me = rest_filters.BooleanFilter(label='assigned_to_me', method='filter_assigned_to_me')
def filter_assigned_to_me(self, queryset, name, value):
"""
Filter by orders which are assigned to the current user
"""
value = str2bool(value)
# Work out who "me" is!
owners = Owner.get_owners_matching_user(self.request.user)
if value:
queryset = queryset.filter(responsible__in=owners)
else:
queryset = queryset.exclude(responsible__in=owners)
return queryset
class BuildList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of Build objects.

View File

@ -423,7 +423,7 @@ class BuildAllocationSerializer(serializers.Serializer):
Validation
"""
super().validate(data)
data = super().validate(data)
items = data.get('items', [])

View File

@ -71,6 +71,8 @@ class SupplierPartAdmin(ImportExportModelAdmin):
'SKU',
]
autocomplete_fields = ('part', 'supplier', 'manufacturer_part',)
class ManufacturerPartResource(ModelResource):
"""
@ -92,23 +94,6 @@ class ManufacturerPartResource(ModelResource):
clean_model_instances = True
class ManufacturerPartParameterInline(admin.TabularInline):
"""
Inline for editing ManufacturerPartParameter objects,
directly from the ManufacturerPart admin view.
"""
model = ManufacturerPartParameter
class SupplierPartInline(admin.TabularInline):
"""
Inline for the SupplierPart model
"""
model = SupplierPart
class ManufacturerPartAdmin(ImportExportModelAdmin):
"""
Admin class for ManufacturerPart model
@ -124,10 +109,7 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
'MPN',
]
inlines = [
SupplierPartInline,
ManufacturerPartParameterInline,
]
autocomplete_fields = ('part', 'manufacturer',)
class ManufacturerPartParameterResource(ModelResource):
@ -157,6 +139,8 @@ class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
'value'
]
autocomplete_fields = ('manufacturer_part',)
class SupplierPriceBreakResource(ModelResource):
""" Class for managing SupplierPriceBreak data import/export """
@ -186,6 +170,8 @@ class SupplierPriceBreakAdmin(ImportExportModelAdmin):
list_display = ('part', 'quantity', 'price')
autocomplete_fields = ('part',)
admin.site.register(Company, CompanyAdmin)
admin.site.register(SupplierPart, SupplierPartAdmin)

View File

@ -222,4 +222,4 @@
});
}
{% endblock %}
{% endblock %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ from import_export.fields import Field
from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import SalesOrder, SalesOrderLineItem
from .models import SalesOrderAllocation
from .models import SalesOrderShipment, SalesOrderAllocation
class PurchaseOrderLineItemInlineAdmin(admin.StackedInline):
@ -42,6 +42,8 @@ class PurchaseOrderAdmin(ImportExportModelAdmin):
PurchaseOrderLineItemInlineAdmin
]
autocomplete_fields = ('supplier',)
class SalesOrderAdmin(ImportExportModelAdmin):
@ -63,6 +65,8 @@ class SalesOrderAdmin(ImportExportModelAdmin):
'description',
]
autocomplete_fields = ('customer',)
class POLineItemResource(ModelResource):
""" Class for managing import / export of POLineItem data """
@ -124,6 +128,10 @@ class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
'reference'
)
search_fields = ('reference',)
autocomplete_fields = ('order', 'part', 'destination',)
class SalesOrderLineItemAdmin(ImportExportModelAdmin):
@ -136,6 +144,32 @@ class SalesOrderLineItemAdmin(ImportExportModelAdmin):
'reference'
)
search_fields = [
'part__name',
'order__reference',
'order__customer__name',
'reference',
]
autocomplete_fields = ('order', 'part',)
class SalesOrderShipmentAdmin(ImportExportModelAdmin):
list_display = [
'order',
'shipment_date',
'reference',
]
search_fields = [
'reference',
'order__reference',
'order__customer__name',
]
autocomplete_fields = ('order',)
class SalesOrderAllocationAdmin(ImportExportModelAdmin):
@ -145,6 +179,8 @@ class SalesOrderAllocationAdmin(ImportExportModelAdmin):
'quantity'
)
autocomplete_fields = ('line', 'shipment', 'item',)
admin.site.register(PurchaseOrder, PurchaseOrderAdmin)
admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin)
@ -152,4 +188,5 @@ admin.site.register(PurchaseOrderLineItem, PurchaseOrderLineItemAdmin)
admin.site.register(SalesOrder, SalesOrderAdmin)
admin.site.register(SalesOrderLineItem, SalesOrderLineItemAdmin)
admin.site.register(SalesOrderShipment, SalesOrderShipmentAdmin)
admin.site.register(SalesOrderAllocation, SalesOrderAllocationAdmin)

View File

@ -13,24 +13,48 @@ from rest_framework import generics
from rest_framework import filters, status
from rest_framework.response import Response
from company.models import SupplierPart
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import str2bool
from InvenTree.api import AttachmentMixin
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
import order.models as models
import order.serializers as serializers
from part.models import Part
from company.models import SupplierPart
from users.models import Owner
from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import PurchaseOrderAttachment
from .serializers import POSerializer, POLineItemSerializer, POAttachmentSerializer
from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
from .models import SalesOrderAttachment
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
from .serializers import SalesOrderAllocationSerializer
from .serializers import POReceiveSerializer
class POFilter(rest_filters.FilterSet):
"""
Custom API filters for the POList endpoint
"""
assigned_to_me = rest_filters.BooleanFilter(label='assigned_to_me', method='filter_assigned_to_me')
def filter_assigned_to_me(self, queryset, name, value):
"""
Filter by orders which are assigned to the current user
"""
value = str2bool(value)
# Work out who "me" is!
owners = Owner.get_owners_matching_user(self.request.user)
if value:
queryset = queryset.filter(responsible__in=owners)
else:
queryset = queryset.exclude(responsible__in=owners)
return queryset
class Meta:
model = models.PurchaseOrder
fields = [
'supplier',
]
class POList(generics.ListCreateAPIView):
@ -40,8 +64,9 @@ class POList(generics.ListCreateAPIView):
- POST: Create a new PurchaseOrder object
"""
queryset = PurchaseOrder.objects.all()
serializer_class = POSerializer
queryset = models.PurchaseOrder.objects.all()
serializer_class = serializers.POSerializer
filterset_class = POFilter
def create(self, request, *args, **kwargs):
"""
@ -78,7 +103,7 @@ class POList(generics.ListCreateAPIView):
'lines',
)
queryset = POSerializer.annotate_queryset(queryset)
queryset = serializers.POSerializer.annotate_queryset(queryset)
return queryset
@ -107,9 +132,9 @@ class POList(generics.ListCreateAPIView):
overdue = str2bool(overdue)
if overdue:
queryset = queryset.filter(PurchaseOrder.OVERDUE_FILTER)
queryset = queryset.filter(models.PurchaseOrder.OVERDUE_FILTER)
else:
queryset = queryset.exclude(PurchaseOrder.OVERDUE_FILTER)
queryset = queryset.exclude(models.PurchaseOrder.OVERDUE_FILTER)
# Special filtering for 'status' field
status = params.get('status', None)
@ -143,7 +168,7 @@ class POList(generics.ListCreateAPIView):
max_date = params.get('max_date', None)
if min_date is not None and max_date is not None:
queryset = PurchaseOrder.filterByDate(queryset, min_date, max_date)
queryset = models.PurchaseOrder.filterByDate(queryset, min_date, max_date)
return queryset
@ -157,10 +182,6 @@ class POList(generics.ListCreateAPIView):
'reference': ['reference_int', 'reference'],
}
filter_fields = [
'supplier',
]
search_fields = [
'reference',
'supplier__name',
@ -183,8 +204,8 @@ class POList(generics.ListCreateAPIView):
class PODetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of a PurchaseOrder object """
queryset = PurchaseOrder.objects.all()
serializer_class = POSerializer
queryset = models.PurchaseOrder.objects.all()
serializer_class = serializers.POSerializer
def get_serializer(self, *args, **kwargs):
@ -207,7 +228,7 @@ class PODetail(generics.RetrieveUpdateDestroyAPIView):
'lines',
)
queryset = POSerializer.annotate_queryset(queryset)
queryset = serializers.POSerializer.annotate_queryset(queryset)
return queryset
@ -225,9 +246,9 @@ class POReceive(generics.CreateAPIView):
- A global location can also be specified
"""
queryset = PurchaseOrderLineItem.objects.none()
queryset = models.PurchaseOrderLineItem.objects.none()
serializer_class = POReceiveSerializer
serializer_class = serializers.POReceiveSerializer
def get_serializer_context(self):
@ -235,7 +256,7 @@ class POReceive(generics.CreateAPIView):
# Pass the purchase order through to the serializer for validation
try:
context['order'] = PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
context['order'] = models.PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
@ -250,7 +271,7 @@ class POLineItemFilter(rest_filters.FilterSet):
"""
class Meta:
model = PurchaseOrderLineItem
model = models.PurchaseOrderLineItem
fields = [
'order',
'part'
@ -284,15 +305,15 @@ class POLineItemList(generics.ListCreateAPIView):
- POST: Create a new PurchaseOrderLineItem object
"""
queryset = PurchaseOrderLineItem.objects.all()
serializer_class = POLineItemSerializer
queryset = models.PurchaseOrderLineItem.objects.all()
serializer_class = serializers.POLineItemSerializer
filterset_class = POLineItemFilter
def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs)
queryset = POLineItemSerializer.annotate_queryset(queryset)
queryset = serializers.POLineItemSerializer.annotate_queryset(queryset)
return queryset
@ -300,6 +321,7 @@ class POLineItemList(generics.ListCreateAPIView):
try:
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False))
kwargs['order_detail'] = str2bool(self.request.query_params.get('order_detail', False))
except AttributeError:
pass
@ -307,6 +329,28 @@ class POLineItemList(generics.ListCreateAPIView):
return self.serializer_class(*args, **kwargs)
def filter_queryset(self, queryset):
"""
Additional filtering options
"""
params = self.request.query_params
queryset = super().filter_queryset(queryset)
base_part = params.get('base_part', None)
if base_part:
try:
base_part = Part.objects.get(pk=base_part)
queryset = queryset.filter(part__part=base_part)
except (ValueError, Part.DoesNotExist):
pass
return queryset
filter_backends = [
rest_filters.DjangoFilterBackend,
filters.SearchFilter,
@ -349,14 +393,14 @@ class POLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
Detail API endpoint for PurchaseOrderLineItem object
"""
queryset = PurchaseOrderLineItem.objects.all()
serializer_class = POLineItemSerializer
queryset = models.PurchaseOrderLineItem.objects.all()
serializer_class = serializers.POLineItemSerializer
def get_queryset(self):
queryset = super().get_queryset()
queryset = POLineItemSerializer.annotate_queryset(queryset)
queryset = serializers.POLineItemSerializer.annotate_queryset(queryset)
return queryset
@ -366,8 +410,8 @@ class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
API endpoint for listing (and creating) a SalesOrderAttachment (file upload)
"""
queryset = SalesOrderAttachment.objects.all()
serializer_class = SOAttachmentSerializer
queryset = models.SalesOrderAttachment.objects.all()
serializer_class = serializers.SOAttachmentSerializer
filter_backends = [
rest_filters.DjangoFilterBackend,
@ -383,8 +427,8 @@ class SOAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin)
Detail endpoint for SalesOrderAttachment
"""
queryset = SalesOrderAttachment.objects.all()
serializer_class = SOAttachmentSerializer
queryset = models.SalesOrderAttachment.objects.all()
serializer_class = serializers.SOAttachmentSerializer
class SOList(generics.ListCreateAPIView):
@ -395,8 +439,8 @@ class SOList(generics.ListCreateAPIView):
- POST: Create a new SalesOrder
"""
queryset = SalesOrder.objects.all()
serializer_class = SalesOrderSerializer
queryset = models.SalesOrder.objects.all()
serializer_class = serializers.SalesOrderSerializer
def create(self, request, *args, **kwargs):
"""
@ -433,7 +477,7 @@ class SOList(generics.ListCreateAPIView):
'lines'
)
queryset = SalesOrderSerializer.annotate_queryset(queryset)
queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset)
return queryset
@ -453,9 +497,9 @@ class SOList(generics.ListCreateAPIView):
outstanding = str2bool(outstanding)
if outstanding:
queryset = queryset.filter(status__in=SalesOrderStatus.OPEN)
queryset = queryset.filter(status__in=models.SalesOrderStatus.OPEN)
else:
queryset = queryset.exclude(status__in=SalesOrderStatus.OPEN)
queryset = queryset.exclude(status__in=models.SalesOrderStatus.OPEN)
# Filter by 'overdue' status
overdue = params.get('overdue', None)
@ -464,9 +508,9 @@ class SOList(generics.ListCreateAPIView):
overdue = str2bool(overdue)
if overdue:
queryset = queryset.filter(SalesOrder.OVERDUE_FILTER)
queryset = queryset.filter(models.SalesOrder.OVERDUE_FILTER)
else:
queryset = queryset.exclude(SalesOrder.OVERDUE_FILTER)
queryset = queryset.exclude(models.SalesOrder.OVERDUE_FILTER)
status = params.get('status', None)
@ -489,7 +533,7 @@ class SOList(generics.ListCreateAPIView):
max_date = params.get('max_date', None)
if min_date is not None and max_date is not None:
queryset = SalesOrder.filterByDate(queryset, min_date, max_date)
queryset = models.SalesOrder.filterByDate(queryset, min_date, max_date)
return queryset
@ -533,8 +577,8 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView):
API endpoint for detail view of a SalesOrder object.
"""
queryset = SalesOrder.objects.all()
serializer_class = SalesOrderSerializer
queryset = models.SalesOrder.objects.all()
serializer_class = serializers.SalesOrderSerializer
def get_serializer(self, *args, **kwargs):
@ -553,7 +597,40 @@ class SODetail(generics.RetrieveUpdateDestroyAPIView):
queryset = queryset.prefetch_related('customer', 'lines')
queryset = SalesOrderSerializer.annotate_queryset(queryset)
queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset)
return queryset
class SOLineItemFilter(rest_filters.FilterSet):
"""
Custom filters for SOLineItemList endpoint
"""
class Meta:
model = models.SalesOrderLineItem
fields = [
'order',
'part',
]
completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
def filter_completed(self, queryset, name, value):
"""
Filter by lines which are "completed"
A line is completed when shipped >= quantity
"""
value = str2bool(value)
q = Q(shipped__gte=F('quantity'))
if value:
queryset = queryset.filter(q)
else:
queryset = queryset.exclude(q)
return queryset
@ -563,8 +640,9 @@ class SOLineItemList(generics.ListCreateAPIView):
API endpoint for accessing a list of SalesOrderLineItem objects.
"""
queryset = SalesOrderLineItem.objects.all()
serializer_class = SOLineItemSerializer
queryset = models.SalesOrderLineItem.objects.all()
serializer_class = serializers.SOLineItemSerializer
filterset_class = SOLineItemFilter
def get_serializer(self, *args, **kwargs):
@ -623,8 +701,80 @@ class SOLineItemList(generics.ListCreateAPIView):
class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of a SalesOrderLineItem object """
queryset = SalesOrderLineItem.objects.all()
serializer_class = SOLineItemSerializer
queryset = models.SalesOrderLineItem.objects.all()
serializer_class = serializers.SOLineItemSerializer
class SalesOrderComplete(generics.CreateAPIView):
"""
API endpoint for manually marking a SalesOrder as "complete".
"""
queryset = models.SalesOrder.objects.all()
serializer_class = serializers.SalesOrderCompleteSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['request'] = self.request
try:
ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
return ctx
class SalesOrderAllocateSerials(generics.CreateAPIView):
"""
API endpoint to allocation stock items against a SalesOrder,
by specifying serial numbers.
"""
queryset = models.SalesOrder.objects.none()
serializer_class = serializers.SOSerialAllocationSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
# Pass through the SalesOrder object to the serializer
try:
ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
ctx['request'] = self.request
return ctx
class SalesOrderAllocate(generics.CreateAPIView):
"""
API endpoint to allocate stock items against a SalesOrder
- The SalesOrder is specified in the URL
- See the SOShipmentAllocationSerializer class
"""
queryset = models.SalesOrder.objects.none()
serializer_class = serializers.SOShipmentAllocationSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
# Pass through the SalesOrder object to the serializer
try:
ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
ctx['request'] = self.request
return ctx
class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView):
@ -632,17 +782,17 @@ class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView):
API endpoint for detali view of a SalesOrderAllocation object
"""
queryset = SalesOrderAllocation.objects.all()
serializer_class = SalesOrderAllocationSerializer
queryset = models.SalesOrderAllocation.objects.all()
serializer_class = serializers.SalesOrderAllocationSerializer
class SOAllocationList(generics.ListCreateAPIView):
class SOAllocationList(generics.ListAPIView):
"""
API endpoint for listing SalesOrderAllocation objects
"""
queryset = SalesOrderAllocation.objects.all()
serializer_class = SalesOrderAllocationSerializer
queryset = models.SalesOrderAllocation.objects.all()
serializer_class = serializers.SalesOrderAllocationSerializer
def get_serializer(self, *args, **kwargs):
@ -700,13 +850,87 @@ class SOAllocationList(generics.ListCreateAPIView):
]
class SOShipmentFilter(rest_filters.FilterSet):
"""
Custom filterset for the SOShipmentList endpoint
"""
shipped = rest_filters.BooleanFilter(label='shipped', method='filter_shipped')
def filter_shipped(self, queryset, name, value):
value = str2bool(value)
if value:
queryset = queryset.exclude(shipment_date=None)
else:
queryset = queryset.filter(shipment_date=None)
return queryset
class Meta:
model = models.SalesOrderShipment
fields = [
'order',
]
class SOShipmentList(generics.ListCreateAPIView):
"""
API list endpoint for SalesOrderShipment model
"""
queryset = models.SalesOrderShipment.objects.all()
serializer_class = serializers.SalesOrderShipmentSerializer
filterset_class = SOShipmentFilter
filter_backends = [
rest_filters.DjangoFilterBackend,
]
class SOShipmentDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API detail endpooint for SalesOrderShipment model
"""
queryset = models.SalesOrderShipment.objects.all()
serializer_class = serializers.SalesOrderShipmentSerializer
class SOShipmentComplete(generics.CreateAPIView):
"""
API endpoint for completing (shipping) a SalesOrderShipment
"""
queryset = models.SalesOrderShipment.objects.all()
serializer_class = serializers.SalesOrderShipmentCompleteSerializer
def get_serializer_context(self):
"""
Pass the request object to the serializer
"""
ctx = super().get_serializer_context()
ctx['request'] = self.request
try:
ctx['shipment'] = models.SalesOrderShipment.objects.get(
pk=self.kwargs.get('pk', None)
)
except:
pass
return ctx
class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
"""
API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)
"""
queryset = PurchaseOrderAttachment.objects.all()
serializer_class = POAttachmentSerializer
queryset = models.PurchaseOrderAttachment.objects.all()
serializer_class = serializers.POAttachmentSerializer
filter_backends = [
rest_filters.DjangoFilterBackend,
@ -722,8 +946,8 @@ class POAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin)
Detail endpoint for a PurchaseOrderAttachment
"""
queryset = PurchaseOrderAttachment.objects.all()
serializer_class = POAttachmentSerializer
queryset = models.PurchaseOrderAttachment.objects.all()
serializer_class = serializers.POAttachmentSerializer
order_api_urls = [
@ -760,7 +984,23 @@ order_api_urls = [
url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'),
])),
url(r'^(?P<pk>\d+)/$', SODetail.as_view(), name='api-so-detail'),
url(r'^shipment/', include([
url(r'^(?P<pk>\d+)/', include([
url(r'^ship/$', SOShipmentComplete.as_view(), name='api-so-shipment-ship'),
url(r'^.*$', SOShipmentDetail.as_view(), name='api-so-shipment-detail'),
])),
url(r'^.*$', SOShipmentList.as_view(), name='api-so-shipment-list'),
])),
# Sales order detail view
url(r'^(?P<pk>\d+)/', include([
url(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
url(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
url(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'),
url(r'^.*$', SODetail.as_view(), name='api-so-detail'),
])),
# Sales order list view
url(r'^.*$', SOList.as_view(), name='api-so-list'),
])),

View File

@ -15,10 +15,8 @@ from InvenTree.helpers import clean_decimal
from common.forms import MatchItemForm
import part.models
from .models import PurchaseOrder
from .models import SalesOrder, SalesOrderLineItem
from .models import SalesOrder
class IssuePurchaseOrderForm(HelperForm):
@ -65,57 +63,6 @@ class CancelSalesOrderForm(HelperForm):
]
class ShipSalesOrderForm(HelperForm):
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Ship order'))
class Meta:
model = SalesOrder
fields = [
'confirm',
]
class AllocateSerialsToSalesOrderForm(forms.Form):
"""
Form for assigning stock to a sales order,
by serial number lookup
TODO: Refactor this form / view to use the new API forms interface
"""
line = forms.ModelChoiceField(
queryset=SalesOrderLineItem.objects.all(),
)
part = forms.ModelChoiceField(
queryset=part.models.Part.objects.all(),
)
serials = forms.CharField(
label=_("Serial Numbers"),
required=True,
help_text=_('Enter stock item serial numbers'),
)
quantity = forms.IntegerField(
label=_('Quantity'),
required=True,
help_text=_('Enter quantity of stock items'),
initial=1,
min_value=1
)
class Meta:
fields = [
'line',
'part',
'serials',
'quantity',
]
class OrderMatchItemForm(MatchItemForm):
""" Override MatchItemForm fields """

View File

@ -0,0 +1,31 @@
# Generated by Django 3.2.5 on 2021-10-25 02:08
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import order.models
import markdownx.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('order', '0053_auto_20211128_0151'),
]
operations = [
migrations.CreateModel(
name='SalesOrderShipment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('shipment_date', models.DateField(blank=True, help_text='Date of shipment', null=True, verbose_name='Shipment Date')),
('reference', models.CharField(default='1', help_text='Shipment reference', max_length=100, verbose_name='Reference')),
('notes', markdownx.models.MarkdownxField(blank=True, help_text='Shipment notes', verbose_name='Notes')),
('checked_by', models.ForeignKey(blank=True, help_text='User who checked this shipment', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Checked By')),
('order', models.ForeignKey(help_text='Sales Order', on_delete=django.db.models.deletion.CASCADE, related_name='shipments', to='order.salesorder', verbose_name='Order')),
],
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.5 on 2021-10-25 06:42
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('order', '0053_salesordershipment'),
]
operations = [
migrations.AddField(
model_name='salesorderallocation',
name='shipment',
field=models.ForeignKey(blank=True, help_text='Sales order shipment reference', null=True, on_delete=django.db.models.deletion.CASCADE, to='order.salesordershipment', verbose_name='Shipment'),
),
]

View File

@ -0,0 +1,92 @@
# Generated by Django 3.2.5 on 2021-10-25 06:45
from django.db import migrations
from InvenTree.status_codes import SalesOrderStatus
def add_shipment(apps, schema_editor):
"""
Create a SalesOrderShipment for each existing SalesOrder instance.
Any "allocations" are marked against that shipment.
For each existing SalesOrder instance, we create a default SalesOrderShipment,
and associate each SalesOrderAllocation with this shipment
"""
Allocation = apps.get_model('order', 'salesorderallocation')
SalesOrder = apps.get_model('order', 'salesorder')
Shipment = apps.get_model('order', 'salesordershipment')
n = 0
for order in SalesOrder.objects.all():
"""
We only create an automatic shipment for "PENDING" orders,
as SalesOrderAllocations were historically deleted for "SHIPPED" or "CANCELLED" orders
"""
allocations = Allocation.objects.filter(
line__order=order
)
if allocations.count() == 0 and order.status != SalesOrderStatus.PENDING:
continue
# Create a new Shipment instance against this order
shipment = Shipment.objects.create(
order=order,
)
if order.status == SalesOrderStatus.SHIPPED:
shipment.shipment_date = order.shipment_date
shipment.save()
# Iterate through each allocation associated with this order
for allocation in allocations:
allocation.shipment = shipment
allocation.save()
n += 1
if n > 0:
print(f"\nCreated SalesOrderShipment for {n} SalesOrder instances")
def reverse_add_shipment(apps, schema_editor):
"""
Reverse the migration, delete and SalesOrderShipment instances
"""
Allocation = apps.get_model('order', 'salesorderallocation')
# First, ensure that all SalesOrderAllocation objects point to a null shipment
for allocation in Allocation.objects.exclude(shipment=None):
allocation.shipment = None
allocation.save()
SOS = apps.get_model('order', 'salesordershipment')
n = SOS.objects.count()
print(f"Deleting {n} SalesOrderShipment instances")
SOS.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('order', '0054_salesorderallocation_shipment'),
]
operations = [
migrations.RunPython(
add_shipment,
reverse_code=reverse_add_shipment,
)
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.5 on 2021-10-25 11:40
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('order', '0055_auto_20211025_0645'),
]
operations = [
migrations.AlterField(
model_name='salesorderallocation',
name='shipment',
field=models.ForeignKey(help_text='Sales order shipment reference', on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='order.salesordershipment', verbose_name='Shipment'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.2.5 on 2021-11-26 12:06
import InvenTree.fields
import django.core.validators
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('order', '0056_alter_salesorderallocation_shipment'),
]
operations = [
migrations.AddField(
model_name='salesorderlineitem',
name='shipped',
field=InvenTree.fields.RoundingDecimalField(decimal_places=5, default=0, help_text='Shipped quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Shipped'),
),
]

View File

@ -0,0 +1,62 @@
# Generated by Django 3.2.5 on 2021-11-26 12:10
from django.db import migrations
from InvenTree.status_codes import SalesOrderStatus
def calculate_shipped_quantity(apps, schema_editor):
"""
In migration 0057 we added a new field 'shipped' to the SalesOrderLineItem model.
This field is used to record the number of items shipped,
even if the actual stock items get deleted from the database.
For existing orders in the database, we calculate this as follows:
- If the order is "shipped" then we use the total quantity
- Otherwise, we use the "fulfilled" calculated quantity
"""
StockItem = apps.get_model('stock', 'stockitem')
SalesOrderLineItem = apps.get_model('order', 'salesorderlineitem')
for item in SalesOrderLineItem.objects.all():
if item.order.status == SalesOrderStatus.SHIPPED:
item.shipped = item.quantity
else:
# Calculate total stock quantity of items allocated to this order?
items = StockItem.objects.filter(
sales_order=item.order,
part=item.part
)
q = sum([item.quantity for item in items])
item.shipped = q
item.save()
def reverse_calculate_shipped_quantity(apps, schema_editor):
"""
Provided only for reverse migration compatibility.
This function does nothing.
"""
pass
class Migration(migrations.Migration):
dependencies = [
('order', '0057_salesorderlineitem_shipped'),
]
operations = [
migrations.RunPython(
calculate_shipped_quantity,
reverse_code=reverse_calculate_shipped_quantity
)
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.5 on 2021-11-29 11:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0058_auto_20211126_1210'),
]
operations = [
migrations.AddField(
model_name='salesordershipment',
name='tracking_number',
field=models.CharField(blank=True, help_text='Shipment tracking information', max_length=100, verbose_name='Tracking Number'),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 3.2.5 on 2021-11-29 13:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0059_salesordershipment_tracking_number'),
]
operations = [
migrations.AlterField(
model_name='salesordershipment',
name='reference',
field=models.CharField(default='1', help_text='Shipment number', max_length=100, verbose_name='Shipment'),
),
migrations.AlterUniqueTogether(
name='salesordershipment',
unique_together={('order', 'reference')},
),
]

View File

@ -0,0 +1,14 @@
# Generated by Django 3.2.5 on 2021-12-02 13:31
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('order', '0054_auto_20211201_2139'),
('order', '0060_auto_20211129_1339'),
]
operations = [
]

View File

@ -107,45 +107,6 @@ class Order(ReferenceIndexingMixin):
responsible: User (or group) responsible for managing the order
"""
@classmethod
def getNextOrderNumber(cls):
"""
Try to predict the next order-number
"""
if cls.objects.count() == 0:
return None
# We will assume that the latest pk has the highest PO number
order = cls.objects.last()
ref = order.reference
if not ref:
return None
tries = set()
tries.add(ref)
while 1:
new_ref = increment(ref)
print("Reference:", new_ref)
if new_ref in tries:
# We are in a looping situation - simply return the original one
return ref
# Check that the new ref does not exist in the database
if cls.objects.filter(reference=new_ref).exists():
tries.add(new_ref)
new_ref = increment(new_ref)
else:
break
return new_ref
def save(self, *args, **kwargs):
self.rebuild_reference_field()
@ -402,11 +363,30 @@ class PurchaseOrder(Order):
return self.lines.filter(quantity__gt=F('received'))
def completed_line_items(self):
"""
Return a list of completed line items against this order
"""
return self.lines.filter(quantity__lte=F('received'))
@property
def line_count(self):
return self.lines.count()
@property
def completed_line_count(self):
return self.completed_line_items().count()
@property
def pending_line_count(self):
return self.pending_line_items().count()
@property
def is_complete(self):
""" Return True if all line items have been received """
return self.pending_line_items().count() == 0
return self.lines.count() > 0 and self.pending_line_items().count() == 0
@transaction.atomic
def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None, **kwargs):
@ -606,6 +586,16 @@ class SalesOrder(Order):
def is_pending(self):
return self.status == SalesOrderStatus.PENDING
@property
def stock_allocations(self):
"""
Return a queryset containing all allocations for this order
"""
return SalesOrderAllocation.objects.filter(
line__in=[line.pk for line in self.lines.all()]
)
def is_fully_allocated(self):
""" Return True if all line items are fully allocated """
@ -624,29 +614,55 @@ class SalesOrder(Order):
return False
@transaction.atomic
def ship_order(self, user):
""" Mark this order as 'shipped' """
def is_completed(self):
"""
Check if this order is "shipped" (all line items delivered),
"""
# The order can only be 'shipped' if the current status is PENDING
if not self.status == SalesOrderStatus.PENDING:
raise ValidationError({'status': _("SalesOrder cannot be shipped as it is not currently pending")})
return self.lines.count() > 0 and all([line.is_completed() for line in self.lines.all()])
# Complete the allocation for each allocated StockItem
for line in self.lines.all():
for allocation in line.allocations.all():
allocation.complete_allocation(user)
def can_complete(self, raise_error=False):
"""
Test if this SalesOrder can be completed.
# Remove the allocation from the database once it has been 'fulfilled'
if allocation.item.sales_order == self:
allocation.delete()
else:
raise ValidationError("Could not complete order - allocation item not fulfilled")
Throws a ValidationError if cannot be completed.
"""
# Order without line items cannot be completed
if self.lines.count() == 0:
if raise_error:
raise ValidationError(_('Order cannot be completed as no parts have been assigned'))
# Only a PENDING order can be marked as SHIPPED
elif self.status != SalesOrderStatus.PENDING:
if raise_error:
raise ValidationError(_('Only a pending order can be marked as complete'))
elif self.pending_shipment_count > 0:
if raise_error:
raise ValidationError(_("Order cannot be completed as there are incomplete shipments"))
elif self.pending_line_count > 0:
if raise_error:
raise ValidationError(_("Order cannot be completed as there are incomplete line items"))
else:
return True
return False
def complete_order(self, user):
"""
Mark this order as "complete"
"""
if not self.can_complete():
return False
# Ensure the order status is marked as "Shipped"
self.status = SalesOrderStatus.SHIPPED
self.shipment_date = datetime.now().date()
self.shipped_by = user
self.shipment_date = datetime.now()
self.save()
return True
@ -682,6 +698,55 @@ class SalesOrder(Order):
return True
@property
def line_count(self):
return self.lines.count()
def completed_line_items(self):
"""
Return a queryset of the completed line items for this order
"""
return self.lines.filter(shipped__gte=F('quantity'))
def pending_line_items(self):
"""
Return a queryset of the pending line items for this order
"""
return self.lines.filter(shipped__lt=F('quantity'))
@property
def completed_line_count(self):
return self.completed_line_items().count()
@property
def pending_line_count(self):
return self.pending_line_items().count()
def completed_shipments(self):
"""
Return a queryset of the completed shipments for this order
"""
return self.shipments.exclude(shipment_date=None)
def pending_shipments(self):
"""
Return a queryset of the pending shipments for this order
"""
return self.shipments.filter(shipment_date=None)
@property
def shipment_count(self):
return self.shipments.count()
@property
def completed_shipment_count(self):
return self.completed_shipments().count()
@property
def pending_shipment_count(self):
return self.pending_shipments().count()
class PurchaseOrderAttachment(InvenTreeAttachment):
"""
@ -815,13 +880,15 @@ class PurchaseOrderLineItem(OrderLineItem):
)
def get_destination(self):
"""Show where the line item is or should be placed"""
# NOTE: If a line item gets split when recieved, only an arbitrary
# 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
):
"""
Show where the line item is or should be placed
NOTE: If a line item gets split when recieved, only an arbitrary
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
if self.destination:
@ -843,6 +910,7 @@ class SalesOrderLineItem(OrderLineItem):
order: Link to the SalesOrder that this line item belongs to
part: Link to a Part object (may be null)
sale_price: The unit sale price for this OrderLineItem
shipped: The number of items which have actually shipped against this line item
"""
@staticmethod
@ -867,6 +935,14 @@ class SalesOrderLineItem(OrderLineItem):
help_text=_('Unit sale price'),
)
shipped = RoundingDecimalField(
verbose_name=_('Shipped'),
help_text=_('Shipped quantity'),
default=0,
max_digits=15, decimal_places=5,
validators=[MinValueValidator(0)]
)
class Meta:
unique_together = [
]
@ -902,6 +978,130 @@ class SalesOrderLineItem(OrderLineItem):
""" Return True if this line item is over allocated """
return self.allocated_quantity() > self.quantity
def is_completed(self):
"""
Return True if this line item is completed (has been fully shipped)
"""
return self.shipped >= self.quantity
class SalesOrderShipment(models.Model):
"""
The SalesOrderShipment model represents a physical shipment made against a SalesOrder.
- Points to a single SalesOrder object
- Multiple SalesOrderAllocation objects point to a particular SalesOrderShipment
- When a given SalesOrderShipment is "shipped", stock items are removed from stock
Attributes:
order: SalesOrder reference
shipment_date: Date this shipment was "shipped" (or null)
checked_by: User reference field indicating who checked this order
reference: Custom reference text for this shipment (e.g. consignment number?)
notes: Custom notes field for this shipment
"""
class Meta:
# Shipment reference must be unique for a given sales order
unique_together = [
'order', 'reference',
]
@staticmethod
def get_api_url():
return reverse('api-so-shipment-list')
order = models.ForeignKey(
SalesOrder,
on_delete=models.CASCADE,
blank=False, null=False,
related_name='shipments',
verbose_name=_('Order'),
help_text=_('Sales Order'),
)
shipment_date = models.DateField(
null=True, blank=True,
verbose_name=_('Shipment Date'),
help_text=_('Date of shipment'),
)
checked_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('Checked By'),
help_text=_('User who checked this shipment'),
related_name='+',
)
reference = models.CharField(
max_length=100,
blank=False,
verbose_name=('Shipment'),
help_text=_('Shipment number'),
default='1',
)
notes = MarkdownxField(
blank=True,
verbose_name=_('Notes'),
help_text=_('Shipment notes'),
)
tracking_number = models.CharField(
max_length=100,
blank=True,
unique=False,
verbose_name=_('Tracking Number'),
help_text=_('Shipment tracking information'),
)
def is_complete(self):
return self.shipment_date is not None
def check_can_complete(self):
if self.shipment_date:
# Shipment has already been sent!
raise ValidationError(_("Shipment has already been sent"))
if self.allocations.count() == 0:
raise ValidationError(_("Shipment has no allocated stock items"))
@transaction.atomic
def complete_shipment(self, user, **kwargs):
"""
Complete this particular shipment:
1. Update any stock items associated with this shipment
2. Update the "shipped" quantity of all associated line items
3. Set the "shipment_date" to now
"""
# Check if the shipment can be completed (throw error if not)
self.check_can_complete()
allocations = self.allocations.all()
# Iterate through each stock item assigned to this shipment
for allocation in allocations:
# Mark the allocation as "complete"
allocation.complete_allocation(user)
# Update the "shipment" date
self.shipment_date = datetime.now()
self.shipped_by = user
# Was a tracking number provided?
tracking_number = kwargs.get('tracking_number', None)
if tracking_number is not None:
self.tracking_number = tracking_number
self.save()
class SalesOrderAllocation(models.Model):
"""
@ -911,6 +1111,7 @@ class SalesOrderAllocation(models.Model):
Attributes:
line: SalesOrderLineItem reference
shipment: SalesOrderShipment reference
item: StockItem reference
quantity: Quantity to take from the StockItem
@ -966,6 +1167,10 @@ class SalesOrderAllocation(models.Model):
if self.item.serial and not self.quantity == 1:
errors['quantity'] = _('Quantity must be 1 for serialized stock item')
if self.line.order != self.shipment.order:
errors['line'] = _('Sales order does not match shipment')
errors['shipment'] = _('Shipment does not match sales order')
if len(errors) > 0:
raise ValidationError(errors)
@ -973,7 +1178,16 @@ class SalesOrderAllocation(models.Model):
SalesOrderLineItem,
on_delete=models.CASCADE,
verbose_name=_('Line'),
related_name='allocations')
related_name='allocations'
)
shipment = models.ForeignKey(
SalesOrderShipment,
on_delete=models.CASCADE,
related_name='allocations',
verbose_name=_('Shipment'),
help_text=_('Sales order shipment reference'),
)
item = models.ForeignKey(
'stock.StockItem',
@ -1022,6 +1236,10 @@ class SalesOrderAllocation(models.Model):
user=user
)
# Update the 'shipped' quantity
self.line.shipped += self.quantity
self.line.save()
# Update our own reference to the StockItem
# (It may have changed if the stock was split)
self.item = item

View File

@ -21,21 +21,19 @@ from common.settings import currency_code_mappings
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
from InvenTree.serializers import InvenTreeAttachmentSerializer
from InvenTree.helpers import normalize, extract_serial_numbers
from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.serializers import InvenTreeMoneySerializer
from InvenTree.serializers import ReferenceIndexingSerializerMixin
from InvenTree.status_codes import StockStatus
import order.models
from part.serializers import PartBriefSerializer
import stock.models
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import PurchaseOrderAttachment, SalesOrderAttachment
from .models import SalesOrder, SalesOrderLineItem
from .models import SalesOrderAllocation
import stock.serializers
from users.serializers import OwnerSerializer
@ -68,7 +66,7 @@ class POSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
queryset = queryset.annotate(
overdue=Case(
When(
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())
)
@ -89,7 +87,7 @@ class POSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer):
responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False)
class Meta:
model = PurchaseOrder
model = order.models.PurchaseOrder
fields = [
'pk',
@ -143,12 +141,17 @@ class POLineItemSerializer(InvenTreeModelSerializer):
part_detail = kwargs.pop('part_detail', False)
order_detail = kwargs.pop('order_detail', False)
super().__init__(*args, **kwargs)
if part_detail is not True:
self.fields.pop('part_detail')
self.fields.pop('supplier_part_detail')
if order_detail is not True:
self.fields.pop('order_detail')
quantity = serializers.FloatField(default=1)
received = serializers.FloatField(default=0)
@ -163,15 +166,17 @@ class POLineItemSerializer(InvenTreeModelSerializer):
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
destination_detail = LocationBriefSerializer(source='get_destination', read_only=True)
destination_detail = stock.serializers.LocationBriefSerializer(source='get_destination', read_only=True)
purchase_price_currency = serializers.ChoiceField(
choices=currency_code_mappings(),
help_text=_('Purchase price currency'),
)
order_detail = POSerializer(source='order', read_only=True, many=False)
class Meta:
model = PurchaseOrderLineItem
model = order.models.PurchaseOrderLineItem
fields = [
'pk',
@ -179,6 +184,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
'reference',
'notes',
'order',
'order_detail',
'part',
'part_detail',
'supplier_part_detail',
@ -198,7 +204,7 @@ class POLineItemReceiveSerializer(serializers.Serializer):
"""
line_item = serializers.PrimaryKeyRelatedField(
queryset=PurchaseOrderLineItem.objects.all(),
queryset=order.models.PurchaseOrderLineItem.objects.all(),
many=False,
allow_null=False,
required=True,
@ -378,7 +384,7 @@ class POAttachmentSerializer(InvenTreeAttachmentSerializer):
"""
class Meta:
model = PurchaseOrderAttachment
model = order.models.PurchaseOrderAttachment
fields = [
'pk',
@ -425,7 +431,7 @@ class SalesOrderSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSeria
queryset = queryset.annotate(
overdue=Case(
When(
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())
)
@ -444,7 +450,7 @@ class SalesOrderSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSeria
reference = serializers.CharField(required=True)
class Meta:
model = SalesOrder
model = order.models.SalesOrder
fields = [
'pk',
@ -487,13 +493,15 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
# Extra detail fields
order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True)
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
item_detail = StockItemSerializer(source='item', many=False, read_only=True)
location_detail = LocationSerializer(source='item.location', many=False, read_only=True)
item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True)
location_detail = stock.serializers.LocationSerializer(source='item.location', many=False, read_only=True)
shipment_date = serializers.DateField(source='shipment.shipment_date', read_only=True)
def __init__(self, *args, **kwargs):
order_detail = kwargs.pop('order_detail', False)
part_detail = kwargs.pop('part_detail', False)
part_detail = kwargs.pop('part_detail', True)
item_detail = kwargs.pop('item_detail', False)
location_detail = kwargs.pop('location_detail', False)
@ -512,7 +520,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
self.fields.pop('location_detail')
class Meta:
model = SalesOrderAllocation
model = order.models.SalesOrderAllocation
fields = [
'pk',
@ -527,6 +535,8 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
'order_detail',
'part',
'part_detail',
'shipment',
'shipment_date',
]
@ -557,7 +567,8 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
quantity = InvenTreeDecimalField()
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
shipped = InvenTreeDecimalField(read_only=True)
sale_price = InvenTreeMoneySerializer(
allow_null=True
@ -571,14 +582,13 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
)
class Meta:
model = SalesOrderLineItem
model = order.models.SalesOrderLineItem
fields = [
'pk',
'allocated',
'allocations',
'quantity',
'fulfilled',
'reference',
'notes',
'order',
@ -588,16 +598,421 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
'sale_price',
'sale_price_currency',
'sale_price_string',
'shipped',
]
class SalesOrderShipmentSerializer(InvenTreeModelSerializer):
"""
Serializer for the SalesOrderShipment class
"""
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
order_detail = SalesOrderSerializer(source='order', read_only=True, many=False)
class Meta:
model = order.models.SalesOrderShipment
fields = [
'pk',
'order',
'order_detail',
'allocations',
'shipment_date',
'checked_by',
'reference',
'tracking_number',
'notes',
]
class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
"""
Serializer for completing (shipping) a SalesOrderShipment
"""
class Meta:
model = order.models.SalesOrderShipment
fields = [
'tracking_number',
]
def validate(self, data):
data = super().validate(data)
shipment = self.context.get('shipment', None)
if not shipment:
raise ValidationError(_("No shipment details provided"))
shipment.check_can_complete()
return data
def save(self):
shipment = self.context.get('shipment', None)
if not shipment:
return
data = self.validated_data
request = self.context['request']
user = request.user
# Extract provided tracking number (optional)
tracking_number = data.get('tracking_number', None)
shipment.complete_shipment(user, tracking_number=tracking_number)
class SOShipmentAllocationItemSerializer(serializers.Serializer):
"""
A serializer for allocating a single stock-item against a SalesOrder shipment
"""
class Meta:
fields = [
'line_item',
'stock_item',
'quantity',
]
line_item = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderLineItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Stock Item'),
)
def validate_line_item(self, line_item):
order = self.context['order']
# Ensure that the line item points to the correct order
if line_item.order != order:
raise ValidationError(_("Line item is not associated with this order"))
return line_item
stock_item = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Stock Item'),
)
quantity = serializers.DecimalField(
max_digits=15,
decimal_places=5,
min_value=0,
required=True
)
def validate_quantity(self, quantity):
if quantity <= 0:
raise ValidationError(_("Quantity must be positive"))
return quantity
def validate(self, data):
data = super().validate(data)
stock_item = data['stock_item']
quantity = data['quantity']
if stock_item.serialized and quantity != 1:
raise ValidationError({
'quantity': _("Quantity must be 1 for serialized stock item"),
})
q = normalize(stock_item.unallocated_quantity())
if quantity > q:
raise ValidationError({
'quantity': _(f"Available quantity ({q}) exceeded")
})
return data
class SalesOrderCompleteSerializer(serializers.Serializer):
"""
DRF serializer for manually marking a sales order as complete
"""
def validate(self, data):
data = super().validate(data)
order = self.context['order']
order.can_complete(raise_error=True)
return data
def save(self):
request = self.context['request']
order = self.context['order']
user = getattr(request, 'user', None)
order.complete_order(user)
class SOSerialAllocationSerializer(serializers.Serializer):
"""
DRF serializer for allocation of serial numbers against a sales order / shipment
"""
class Meta:
fields = [
'line_item',
'quantity',
'serial_numbers',
'shipment',
]
line_item = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderLineItem.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Line Item'),
)
def validate_line_item(self, line_item):
"""
Ensure that the line_item is valid
"""
order = self.context['order']
# Ensure that the line item points to the correct order
if line_item.order != order:
raise ValidationError(_("Line item is not associated with this order"))
return line_item
quantity = serializers.IntegerField(
min_value=1,
required=True,
allow_null=False,
label=_('Quantity'),
)
serial_numbers = serializers.CharField(
label=_("Serial Numbers"),
help_text=_("Enter serial numbers to allocate"),
required=True,
allow_blank=False,
)
shipment = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderShipment.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Shipment'),
)
def validate_shipment(self, shipment):
"""
Validate the shipment:
- Must point to the same order
- Must not be shipped
"""
order = self.context['order']
if shipment.shipment_date is not None:
raise ValidationError(_("Shipment has already been shipped"))
if shipment.order != order:
raise ValidationError(_("Shipment is not associated with this order"))
return shipment
def validate(self, data):
"""
Validation for the serializer:
- Ensure the serial_numbers and quantity fields match
- Check that all serial numbers exist
- Check that the serial numbers are not yet allocated
"""
data = super().validate(data)
line_item = data['line_item']
quantity = data['quantity']
serial_numbers = data['serial_numbers']
part = line_item.part
try:
data['serials'] = extract_serial_numbers(serial_numbers, quantity)
except DjangoValidationError as e:
raise ValidationError({
'serial_numbers': e.messages,
})
serials_not_exist = []
serials_allocated = []
stock_items_to_allocate = []
for serial in data['serials']:
items = stock.models.StockItem.objects.filter(
part=part,
serial=serial,
quantity=1,
)
if not items.exists():
serials_not_exist.append(str(serial))
continue
stock_item = items[0]
if stock_item.unallocated_quantity() == 1:
stock_items_to_allocate.append(stock_item)
else:
serials_allocated.append(str(serial))
if len(serials_not_exist) > 0:
error_msg = _("No match found for the following serial numbers")
error_msg += ": "
error_msg += ",".join(serials_not_exist)
raise ValidationError({
'serial_numbers': error_msg
})
if len(serials_allocated) > 0:
error_msg = _("The following serial numbers are already allocated")
error_msg += ": "
error_msg += ",".join(serials_allocated)
raise ValidationError({
'serial_numbers': error_msg,
})
data['stock_items'] = stock_items_to_allocate
return data
def save(self):
data = self.validated_data
line_item = data['line_item']
stock_items = data['stock_items']
shipment = data['shipment']
with transaction.atomic():
for stock_item in stock_items:
# Create a new SalesOrderAllocation
order.models.SalesOrderAllocation.objects.create(
line=line_item,
item=stock_item,
quantity=1,
shipment=shipment
)
class SOShipmentAllocationSerializer(serializers.Serializer):
"""
DRF serializer for allocation of stock items against a sales order / shipment
"""
class Meta:
fields = [
'items',
'shipment',
]
items = SOShipmentAllocationItemSerializer(many=True)
shipment = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderShipment.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Shipment'),
)
def validate_shipment(self, shipment):
"""
Run validation against the provided shipment instance
"""
order = self.context['order']
if shipment.shipment_date is not None:
raise ValidationError(_("Shipment has already been shipped"))
if shipment.order != order:
raise ValidationError(_("Shipment is not associated with this order"))
return shipment
def validate(self, data):
"""
Serializer validation
"""
data = super().validate(data)
# Extract SalesOrder from serializer context
# order = self.context['order']
items = data.get('items', [])
if len(items) == 0:
raise ValidationError(_('Allocation items must be provided'))
return data
def save(self):
"""
Perform the allocation of items against this order
"""
data = self.validated_data
items = data['items']
shipment = data['shipment']
with transaction.atomic():
for entry in items:
# Create a new SalesOrderAllocation
order.models.SalesOrderAllocation.objects.create(
line=entry.get('line_item'),
item=entry.get('stock_item'),
quantity=entry.get('quantity'),
shipment=shipment,
)
class SOAttachmentSerializer(InvenTreeAttachmentSerializer):
"""
Serializers for the SalesOrderAttachment model
"""
class Meta:
model = SalesOrderAttachment
model = order.models.SalesOrderAttachment
fields = [
'pk',

View File

@ -119,6 +119,18 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ order.supplier_reference }}{% include "clip.html"%}</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-tasks'></span></td>
<td>{% trans "Completed Line Items" %}</td>
<td>
{{ order.completed_line_count }} / {{ order.line_count }}
{% if order.is_complete %}
<span class='badge bg-success badge-right rounded-pill'>{% trans "Complete" %}</span>
{% else %}
<span class='badge bg-danger badge-right rounded-pill'>{% trans "Incomplete" %}</span>
{% endif %}
</td>
</tr>
{% if order.link %}
<tr>
<td><span class='fas fa-link'></span></td>

View File

@ -37,7 +37,7 @@
</div>
<div class='panel-content'>
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
{% include "filter_list.html" with id="order-lines" %}
{% include "filter_list.html" with id="purchase-order-lines" %}
</div>
<table class='table table-striped table-condensed' id='po-line-table' data-toolbar='#order-toolbar-buttons'>
@ -190,6 +190,10 @@ $('#new-po-line').click(function() {
$('#receive-selected-items').click(function() {
var items = $("#po-line-table").bootstrapTable('getSelections');
if (items.length == 0) {
items = $("#po-line-table").bootstrapTable('getData');
}
receivePurchaseOrderItems(
{{ order.id }},
items,

View File

@ -63,8 +63,8 @@ src="{% static 'img/blank_image.png' %}"
</div>
{% if order.status == SalesOrderStatus.PENDING %}
<button type='button' class='btn btn-success' id='ship-order' title='{% trans "Ship Order" %}'>
<span class='fas fa-truck'></span> {% trans "Ship Order" %}
<button type='button' class='btn btn-success' id='complete-order' title='{% trans "Complete Sales Order" %}'{% if not order.is_completed %} disabled{% endif %}>
<span class='fas fa-check-circle'></span> {% trans "Complete Order" %}
</button>
{% endif %}
{% endif %}
@ -123,6 +123,28 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ order.customer_reference }}{% include "clip.html"%}</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-tasks'></span></td>
<td>{% trans "Completed Line Items" %}</td>
<td>
{{ order.completed_line_count }} / {{ order.line_count }}
{% if order.is_completed %}
<span class='badge bg-success badge-right rounded-pill'>{% trans "Complete" %}</span>
{% else %}
<span class='badge bg-danger badge-right rounded-pill'>{% trans "Incomplete" %}</span>
{% endif %}
</td>
</tr>
<tr>
<td><span class='fas fa-truck'></span></td>
<td>{% trans "Completed Shipments" %}</td>
<td>
{{ order.completed_shipment_count }} / {{ order.shipment_count }}
{% if order.pending_shipment_count > 0 %}
<span class='badge bg-danger badge-right rounded-pill'>{% trans "Incomplete" %}</span>
{% endif %}
</td>
</tr>
{% if order.link %}
<tr>
<td><span class='fas fa-link'></span></td>
@ -145,15 +167,13 @@ src="{% static 'img/blank_image.png' %}"
{% if order.shipment_date %}
<tr>
<td><span class='fas fa-truck'></span></td>
<td>{% trans "Shipped" %}</td>
<td>{{ order.shipment_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.shipped_by }}</span></td>
</tr>
{% endif %}
{% if order.status == PurchaseOrderStatus.COMPLETE %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Received" %}</td>
<td>{{ order.complete_date }}<span class='badge badge-right rounded-pill bg-dark'>{{ order.received_by }}</span></td>
<td>{% trans "Completed" %}</td>
<td>
{{ order.shipment_date }}
{% if order.shipped_by %}
<span class='badge badge-right rounded-pill bg-dark'>{{ order.shipped_by }}</span>
{% endif %}
</td>
</tr>
{% endif %}
{% if order.responsible %}
@ -203,8 +223,11 @@ $("#cancel-order").click(function() {
});
});
$("#ship-order").click(function() {
launchModalForm("{% url 'so-ship' order.id %}", {
$("#complete-order").click(function() {
constructForm('{% url "api-so-complete" order.id %}', {
method: 'POST',
title: '{% trans "Complete Sales Order" %}',
confirm: true,
reload: true,
});
});

View File

@ -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.change and order.is_pending %}
<button type='button' class='btn btn-success' id='new-so-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
</button>
@ -37,12 +37,67 @@
</div>
</div>
{% if order.is_pending %}
<div class='panel panel-hidden' id='panel-order-shipments'>
<div class='panel-heading'>
<div class='d-flex flex-row'>
<h4>{% trans "Pending Shipments" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if 0 %}
<button id='pending-shipment-options' title='{% trans "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'>
</ul>
{% endif %}
{% if roles.sales_order.change %}
<button type='button' class='btn btn-success' id='new-shipment'>
<span class='fas fa-plus-circle'></span> {% trans "New Shipment" %}
</button>
{% endif %}
</div>
</div>
</div>
<div class='panel-content'>
{% if roles.sales_order.change %}
<div id='pending-shipment-toolbar' class='btn-group' style='float: right;'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="pending-shipments" %}
</div>
</div>
{% endif %}
<table class='table table-striped table-condensed' id='pending-shipments-table' data-toolbar='#pending-shipment-toolbar'></table>
</div>
</div>
{% endif %}
<div class='panel panel-hidden' id='panel-order-shipments-complete'>
<div class='panel-heading'>
<h4>{% trans "Completed Shipments" %}</h4>
</div>
<div class='panel-content'>
<div id='completed-shipment-toolbar' class='btn-group' style='float: right;'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="completed-shipments" %}
</div>
</div>
<table class='table table-striped table-condensed' id='completed-shipments-table' data-toolbar='#completed-shipment-toolbar'></table>
</div>
</div>
<div class='panel panel-hidden' id='panel-order-builds'>
<div class='panel-heading'>
<h4>{% trans "Build Orders" %}</h4>
</div>
<div class='panel-content'>
<table class='table table-striped table-condensed' id='builds-table'></table>
<div id='builds-toolbar' class='btn-group' style='float: right;'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id='build' %}
</div>
</div>
<table class='table table-striped table-condensed' id='builds-table' data-toolbar='#builds-toolbar'></table>
</div>
</div>
@ -89,6 +144,38 @@
{% block js_ready %}
{{ block.super }}
// Callback when the "shipments" panel is first loaded
onPanelLoad('order-shipments', function() {
{% if order.is_pending %}
loadSalesOrderShipmentTable('#pending-shipments-table', {
order: {{ order.pk }},
shipped: false,
filter_target: '#filter-list-pending-shipments',
});
$('#new-shipment').click(function() {
createSalesOrderShipment({
order: {{ order.pk }},
reference: '{{ order.reference }}',
onSuccess: function(data) {
$('#pending-shipments-table').bootstrapTable('refresh');
}
});
});
{% endif %}
});
onPanelLoad('order-shipments-complete', function() {
loadSalesOrderShipmentTable('#completed-shipments-table', {
order: {{ order.pk }},
shipped: true,
filter_target: '#filter-list-completed-shipments',
});
});
$('#edit-notes').click(function() {
constructForm('{% url "api-so-detail" order.pk %}', {
fields: {

View File

@ -1,30 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
{% if not order.is_fully_allocated %}
<div class='alert alert-block alert-danger'>
<h4>{% trans "Warning" %}</h4>
{% trans "This order has not been fully allocated. If the order is marked as shipped, it can no longer be adjusted." %}
<br>
{% trans "Ensure that the order allocation is correct before shipping the order." %}
</div>
{% endif %}
{% if order.is_over_allocated %}
<div class='alert alert-block alert-warning'>
{% trans "Some line items in this order have been over-allocated" %}
<br>
{% trans "Ensure that this is correct before shipping the order." %}
</div>
{% endif %}
<div class='alert alert-block alert-info'>
<strong>{% trans "Sales Order" %} {{ order.reference }} - {{ order.customer.name }}</strong>
<br>
{% trans "Shipping this order means that the order will no longer be editable." %}
</div>
{% endblock %}

View File

@ -1,12 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<div class='alert alert-block alert-info'>
{% include "hover_image.html" with image=part.image hover=true %}{{ part }}
<hr>
{% trans "Allocate stock items by serial number" %}
</div>
{% endblock %}

View File

@ -4,6 +4,12 @@
{% trans "Line Items" as text %}
{% include "sidebar_item.html" with label='order-items' text=text icon="fa-list-ol" %}
{% if order.is_pending %}
{% trans "Pending Shipments" as text %}
{% include "sidebar_item.html" with label='order-shipments' text=text icon="fa-truck-loading" %}
{% endif %}
{% trans "Completed Shipments" as text %}
{% include "sidebar_item.html" with label='order-shipments-complete' text=text icon="fa-truck" %}
{% trans "Build Orders" as text %}
{% include "sidebar_item.html" with label='order-builds' text=text icon="fa-tools" %}
{% trans "Attachments" as text %}

View File

@ -11,9 +11,10 @@ from django.urls import reverse
from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import PurchaseOrderStatus
from part.models import Part
from stock.models import StockItem
from .models import PurchaseOrder, PurchaseOrderLineItem, SalesOrder
import order.models as models
class OrderTest(InvenTreeAPITestCase):
@ -85,7 +86,7 @@ class PurchaseOrderTest(OrderTest):
self.filter({'overdue': True}, 0)
self.filter({'overdue': False}, 7)
order = PurchaseOrder.objects.get(pk=1)
order = models.PurchaseOrder.objects.get(pk=1)
order.target_date = datetime.now().date() - timedelta(days=10)
order.save()
@ -137,7 +138,7 @@ class PurchaseOrderTest(OrderTest):
Test that we can create / edit and delete a PurchaseOrder via the API
"""
n = PurchaseOrder.objects.count()
n = models.PurchaseOrder.objects.count()
url = reverse('api-po-list')
@ -154,7 +155,7 @@ class PurchaseOrderTest(OrderTest):
)
# And no new PurchaseOrder objects should have been created
self.assertEqual(PurchaseOrder.objects.count(), n)
self.assertEqual(models.PurchaseOrder.objects.count(), n)
# Ok, now let's give this user the correct permission
self.assignRole('purchase_order.add')
@ -171,7 +172,7 @@ class PurchaseOrderTest(OrderTest):
expected_code=201
)
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
self.assertEqual(models.PurchaseOrder.objects.count(), n + 1)
pk = response.data['pk']
@ -186,7 +187,7 @@ class PurchaseOrderTest(OrderTest):
expected_code=400
)
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
self.assertEqual(models.PurchaseOrder.objects.count(), n + 1)
url = reverse('api-po-detail', kwargs={'pk': pk})
@ -217,7 +218,7 @@ class PurchaseOrderTest(OrderTest):
response = self.delete(url, expected_code=204)
# Number of PurchaseOrder objects should have decreased
self.assertEqual(PurchaseOrder.objects.count(), n)
self.assertEqual(models.PurchaseOrder.objects.count(), n)
# And if we try to access the detail view again, it has gone
response = self.get(url, expected_code=404)
@ -256,7 +257,7 @@ class PurchaseOrderReceiveTest(OrderTest):
self.n = StockItem.objects.count()
# Mark the order as "placed" so we can receive line items
order = PurchaseOrder.objects.get(pk=1)
order = models.PurchaseOrder.objects.get(pk=1)
order.status = PurchaseOrderStatus.PLACED
order.save()
@ -453,8 +454,8 @@ class PurchaseOrderReceiveTest(OrderTest):
Test receipt of valid data
"""
line_1 = PurchaseOrderLineItem.objects.get(pk=1)
line_2 = PurchaseOrderLineItem.objects.get(pk=2)
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 0)
self.assertEqual(StockItem.objects.filter(supplier_part=line_2.part).count(), 0)
@ -481,7 +482,7 @@ class PurchaseOrderReceiveTest(OrderTest):
# Before posting "valid" data, we will mark the purchase order as "pending"
# In this case we do expect an error!
order = PurchaseOrder.objects.get(pk=1)
order = models.PurchaseOrder.objects.get(pk=1)
order.status = PurchaseOrderStatus.PENDING
order.save()
@ -507,8 +508,8 @@ class PurchaseOrderReceiveTest(OrderTest):
# There should be two newly created stock items
self.assertEqual(self.n + 2, StockItem.objects.count())
line_1 = PurchaseOrderLineItem.objects.get(pk=1)
line_2 = PurchaseOrderLineItem.objects.get(pk=2)
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
line_2 = models.PurchaseOrderLineItem.objects.get(pk=2)
self.assertEqual(line_1.received, 50)
self.assertEqual(line_2.received, 250)
@ -563,7 +564,7 @@ class SalesOrderTest(OrderTest):
self.filter({'overdue': False}, 5)
for pk in [1, 2]:
order = SalesOrder.objects.get(pk=pk)
order = models.SalesOrder.objects.get(pk=pk)
order.target_date = datetime.now().date() - timedelta(days=10)
order.save()
@ -591,7 +592,7 @@ class SalesOrderTest(OrderTest):
Test that we can create / edit and delete a SalesOrder via the API
"""
n = SalesOrder.objects.count()
n = models.SalesOrder.objects.count()
url = reverse('api-so-list')
@ -621,7 +622,7 @@ class SalesOrderTest(OrderTest):
)
# Check that the new order has been created
self.assertEqual(SalesOrder.objects.count(), n + 1)
self.assertEqual(models.SalesOrder.objects.count(), n + 1)
# Grab the PK for the newly created SalesOrder
pk = response.data['pk']
@ -664,7 +665,7 @@ class SalesOrderTest(OrderTest):
response = self.delete(url, expected_code=204)
# Check that the number of sales orders has decreased
self.assertEqual(SalesOrder.objects.count(), n)
self.assertEqual(models.SalesOrder.objects.count(), n)
# And the resource should no longer be available
response = self.get(url, expected_code=404)
@ -685,3 +686,131 @@ class SalesOrderTest(OrderTest):
},
expected_code=201
)
class SalesOrderAllocateTest(OrderTest):
"""
Unit tests for allocating stock items against a SalesOrder
"""
def setUp(self):
super().setUp()
self.assignRole('sales_order.add')
self.url = reverse('api-so-allocate', kwargs={'pk': 1})
self.order = models.SalesOrder.objects.get(pk=1)
# Create some line items for this purchase order
parts = Part.objects.filter(salable=True)
for part in parts:
# Create a new line item
models.SalesOrderLineItem.objects.create(
order=self.order,
part=part,
quantity=5,
)
# Ensure we have stock!
StockItem.objects.create(
part=part,
quantity=100,
)
# Create a new shipment against this SalesOrder
self.shipment = models.SalesOrderShipment.objects.create(
order=self.order,
)
def test_invalid(self):
"""
Test POST with invalid data
"""
# No data
response = self.post(self.url, {}, expected_code=400)
self.assertIn('This field is required', str(response.data['items']))
self.assertIn('This field is required', str(response.data['shipment']))
# Test with a single line items
line = self.order.lines.first()
part = line.part
# Valid stock_item, but quantity is invalid
data = {
'items': [{
"line_item": line.pk,
"stock_item": part.stock_items.last().pk,
"quantity": 0,
}],
}
response = self.post(self.url, data, expected_code=400)
self.assertIn('Quantity must be positive', str(response.data['items']))
# Valid stock item, too much quantity
data['items'][0]['quantity'] = 250
response = self.post(self.url, data, expected_code=400)
self.assertIn('Available quantity (100) exceeded', str(response.data['items']))
# Valid stock item, valid quantity
data['items'][0]['quantity'] = 50
# Invalid shipment!
data['shipment'] = 9999
response = self.post(self.url, data, expected_code=400)
self.assertIn('does not exist', str(response.data['shipment']))
# Valid shipment, but points to the wrong order
shipment = models.SalesOrderShipment.objects.create(
order=models.SalesOrder.objects.get(pk=2),
)
data['shipment'] = shipment.pk
response = self.post(self.url, data, expected_code=400)
self.assertIn('Shipment is not associated with this order', str(response.data['shipment']))
def test_allocate(self):
"""
Test the the allocation endpoint acts as expected,
when provided with valid data!
"""
# First, check that there are no line items allocated against this SalesOrder
self.assertEqual(self.order.stock_allocations.count(), 0)
data = {
"items": [],
"shipment": self.shipment.pk,
}
for line in self.order.lines.all():
stock_item = line.part.stock_items.last()
# Fully-allocate each line
data['items'].append({
"line_item": line.pk,
"stock_item": stock_item.pk,
"quantity": 5
})
self.post(self.url, data, expected_code=201)
# There should have been 1 stock item allocated against each line item
n_lines = self.order.lines.count()
self.assertEqual(self.order.stock_allocations.count(), n_lines)
for line in self.order.lines.all():
self.assertEqual(line.allocations.count(), 1)

View File

@ -4,16 +4,16 @@ Unit tests for the 'order' model data migrations
from django_test_migrations.contrib.unittest_case import MigratorTestCase
from InvenTree import helpers
from InvenTree.status_codes import SalesOrderStatus
class TestForwardMigrations(MigratorTestCase):
class TestRefIntMigrations(MigratorTestCase):
"""
Test entire schema migration
"""
migrate_from = ('order', helpers.getOldestMigrationFile('order'))
migrate_to = ('order', helpers.getNewestMigrationFile('order'))
migrate_from = ('order', '0040_salesorder_target_date')
migrate_to = ('order', '0061_merge_0054_auto_20211201_2139_0060_auto_20211129_1339')
def prepare(self):
"""
@ -26,10 +26,12 @@ class TestForwardMigrations(MigratorTestCase):
supplier = Company.objects.create(
name='Supplier A',
description='A great supplier!',
is_supplier=True
is_supplier=True,
is_customer=True,
)
PurchaseOrder = self.old_state.apps.get_model('order', 'purchaseorder')
SalesOrder = self.old_state.apps.get_model('order', 'salesorder')
# Create some orders
for ii in range(10):
@ -44,16 +46,79 @@ class TestForwardMigrations(MigratorTestCase):
with self.assertRaises(AttributeError):
print(order.reference_int)
sales_order = SalesOrder.objects.create(
customer=supplier,
reference=f"{ii}-xyz",
description="A test sales order",
)
# Initially, the 'reference_int' field is unavailable
with self.assertRaises(AttributeError):
print(sales_order.reference_int)
def test_ref_field(self):
"""
Test that the 'reference_int' field has been created and is filled out correctly
"""
PurchaseOrder = self.new_state.apps.get_model('order', 'purchaseorder')
SalesOrder = self.new_state.apps.get_model('order', 'salesorder')
for ii in range(10):
order = PurchaseOrder.objects.get(reference=f"{ii}-abcde")
po = PurchaseOrder.objects.get(reference=f"{ii}-abcde")
so = SalesOrder.objects.get(reference=f"{ii}-xyz")
# The integer reference field must have been correctly updated
self.assertEqual(order.reference_int, ii)
self.assertEqual(po.reference_int, ii)
self.assertEqual(so.reference_int, ii)
class TestShipmentMigration(MigratorTestCase):
"""
Test data migration for the "SalesOrderShipment" model
"""
migrate_from = ('order', '0051_auto_20211014_0623')
migrate_to = ('order', '0055_auto_20211025_0645')
def prepare(self):
"""
Create an initial SalesOrder
"""
Company = self.old_state.apps.get_model('company', 'company')
customer = Company.objects.create(
name='My customer',
description='A customer we sell stuff too',
is_customer=True
)
SalesOrder = self.old_state.apps.get_model('order', 'salesorder')
for ii in range(5):
order = SalesOrder.objects.create(
reference=f'SO{ii}',
customer=customer,
description='A sales order for stuffs',
status=SalesOrderStatus.PENDING,
)
order.save()
# The "shipment" model does not exist yet
with self.assertRaises(LookupError):
self.old_state.apps.get_model('order', 'salesordershipment')
def test_shipment_creation(self):
"""
Check that a SalesOrderShipment has been created
"""
SalesOrder = self.new_state.apps.get_model('order', 'salesorder')
Shipment = self.new_state.apps.get_model('order', 'salesordershipment')
# Check that the correct number of Shipments have been created
self.assertEqual(SalesOrder.objects.count(), 5)
self.assertEqual(Shipment.objects.count(), 5)

View File

@ -7,11 +7,15 @@ from django.core.exceptions import ValidationError
from datetime import datetime, timedelta
from company.models import Company
from stock.models import StockItem
from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
from part.models import Part
from InvenTree import status_codes as status
from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation, SalesOrderShipment
from part.models import Part
from stock.models import StockItem
class SalesOrderTest(TestCase):
"""
@ -38,6 +42,12 @@ class SalesOrderTest(TestCase):
customer_reference='ABC 55555'
)
# Create a Shipment against this SalesOrder
self.shipment = SalesOrderShipment.objects.create(
order=self.order,
reference='001',
)
# Create a line item
self.line = SalesOrderLineItem.objects.create(quantity=50, order=self.order, part=self.part)
@ -82,11 +92,13 @@ class SalesOrderTest(TestCase):
# Allocate stock to the order
SalesOrderAllocation.objects.create(
line=self.line,
shipment=self.shipment,
item=StockItem.objects.get(pk=self.Sa.pk),
quantity=25)
SalesOrderAllocation.objects.create(
line=self.line,
shipment=self.shipment,
item=StockItem.objects.get(pk=self.Sb.pk),
quantity=25 if full else 20
)
@ -120,11 +132,14 @@ class SalesOrderTest(TestCase):
self.assertEqual(SalesOrderAllocation.objects.count(), 0)
self.assertEqual(self.order.status, status.SalesOrderStatus.CANCELLED)
# Now try to ship it - should fail
with self.assertRaises(ValidationError):
self.order.ship_order(None)
self.order.can_complete(raise_error=True)
def test_ship_order(self):
# Now try to ship it - should fail
result = self.order.complete_order(None)
self.assertFalse(result)
def test_complete_order(self):
# Allocate line items, then ship the order
# Assert some stuff before we run the test
@ -136,7 +151,25 @@ class SalesOrderTest(TestCase):
self.assertEqual(SalesOrderAllocation.objects.count(), 2)
self.order.ship_order(None)
# Attempt to complete the order (but shipments are not completed!)
result = self.order.complete_order(None)
self.assertFalse(result)
self.assertIsNone(self.shipment.shipment_date)
self.assertFalse(self.shipment.is_complete())
# Mark the shipments as complete
self.shipment.complete_shipment(None)
self.assertTrue(self.shipment.is_complete())
# Now, should be OK to ship
result = self.order.complete_order(None)
self.assertTrue(result)
self.assertEqual(self.order.status, status.SalesOrderStatus.SHIPPED)
self.assertIsNotNone(self.order.shipment_date)
# There should now be 4 stock items
self.assertEqual(StockItem.objects.count(), 4)
@ -158,12 +191,12 @@ class SalesOrderTest(TestCase):
self.assertEqual(sa.sales_order, None)
self.assertEqual(sb.sales_order, None)
# And no allocations
self.assertEqual(SalesOrderAllocation.objects.count(), 0)
# And the allocations still exist
self.assertEqual(SalesOrderAllocation.objects.count(), 2)
self.assertEqual(self.order.status, status.SalesOrderStatus.SHIPPED)
self.assertTrue(self.order.is_fully_allocated())
self.assertTrue(self.line.is_fully_allocated())
self.assertEqual(self.line.fulfilled_quantity(), 50)
self.assertEqual(self.line.allocated_quantity(), 0)
self.assertEqual(self.line.allocated_quantity(), 50)

View File

@ -35,18 +35,12 @@ purchase_order_urls = [
sales_order_detail_urls = [
url(r'^cancel/', views.SalesOrderCancel.as_view(), name='so-cancel'),
url(r'^ship/', views.SalesOrderShip.as_view(), name='so-ship'),
url(r'^export/', views.SalesOrderExport.as_view(), name='so-export'),
url(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'),
]
sales_order_urls = [
# URLs for sales order allocations
url(r'^allocation/', include([
url(r'^assign-serials/', views.SalesOrderAssignSerials.as_view(), name='so-assign-serials'),
])),
# Display detail view for a single SalesOrder
url(r'^(?P<pk>\d+)/', include(sales_order_detail_urls)),

View File

@ -9,12 +9,10 @@ from django.db import transaction
from django.db.utils import IntegrityError
from django.http.response import JsonResponse
from django.shortcuts import get_object_or_404
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.http import HttpResponseRedirect
from django.utils.translation import ugettext_lazy as _
from django.views.generic import DetailView, ListView
from django.views.generic.edit import FormMixin
from django.forms import HiddenInput, IntegerField
import logging
@ -22,7 +20,6 @@ from decimal import Decimal, InvalidOperation
from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import SalesOrder, SalesOrderLineItem
from .models import SalesOrderAllocation
from .admin import POLineItemResource, SOLineItemResource
from build.models import Build
from company.models import Company, SupplierPart # ManufacturerPart
@ -38,7 +35,6 @@ from part.views import PartPricing
from InvenTree.views import AjaxView, AjaxUpdateView
from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.helpers import extract_serial_numbers
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.status_codes import PurchaseOrderStatus
@ -213,48 +209,6 @@ class PurchaseOrderComplete(AjaxUpdateView):
}
class SalesOrderShip(AjaxUpdateView):
""" View for 'shipping' a SalesOrder """
form_class = order_forms.ShipSalesOrderForm
model = SalesOrder
context_object_name = 'order'
ajax_template_name = 'order/sales_order_ship.html'
ajax_form_title = _('Ship Order')
def post(self, request, *args, **kwargs):
self.request = request
order = self.get_object()
self.object = order
form = self.get_form()
confirm = str2bool(request.POST.get('confirm', False))
valid = False
if not confirm:
form.add_error('confirm', _('Confirm order shipment'))
else:
valid = True
if valid:
if not order.ship_order(request.user):
form.add_error(None, _('Could not ship order'))
valid = False
data = {
'form_valid': valid,
}
context = self.get_context_data()
context['order'] = order
return self.renderJsonResponse(request, form, data, context)
class PurchaseOrderUpload(FileManagementFormView):
''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) '''
@ -834,174 +788,6 @@ class OrderParts(AjaxView):
order.add_line_item(supplier_part, quantity, purchase_price=purchase_price)
class SalesOrderAssignSerials(AjaxView, FormMixin):
"""
View for assigning stock items to a sales order,
by serial number lookup.
"""
model = SalesOrderAllocation
role_required = 'sales_order.change'
ajax_template_name = 'order/so_allocate_by_serial.html'
ajax_form_title = _('Allocate Serial Numbers')
form_class = order_forms.AllocateSerialsToSalesOrderForm
# Keep track of SalesOrderLineItem and Part references
line = None
part = None
def get_initial(self):
"""
Initial values are passed as query params
"""
initials = super().get_initial()
try:
self.line = SalesOrderLineItem.objects.get(pk=self.request.GET.get('line', None))
initials['line'] = self.line
except (ValueError, SalesOrderLineItem.DoesNotExist):
pass
try:
self.part = Part.objects.get(pk=self.request.GET.get('part', None))
initials['part'] = self.part
except (ValueError, Part.DoesNotExist):
pass
return initials
def post(self, request, *args, **kwargs):
self.form = self.get_form()
# Validate the form
self.form.is_valid()
self.validate()
valid = self.form.is_valid()
if valid:
self.allocate_items()
data = {
'form_valid': valid,
'form_errors': self.form.errors.as_json(),
'non_field_errors': self.form.non_field_errors().as_json(),
'success': _("Allocated {n} items").format(n=len(self.stock_items))
}
return self.renderJsonResponse(request, self.form, data)
def validate(self):
data = self.form.cleaned_data
# Extract hidden fields from posted data
self.line = data.get('line', None)
self.part = data.get('part', None)
if self.line:
self.form.fields['line'].widget = HiddenInput()
else:
self.form.add_error('line', _('Select line item'))
if self.part:
self.form.fields['part'].widget = HiddenInput()
else:
self.form.add_error('part', _('Select part'))
if not self.form.is_valid():
return
# Form is otherwise valid - check serial numbers
serials = data.get('serials', '')
quantity = data.get('quantity', 1)
# Save a list of serial_numbers
self.serial_numbers = None
self.stock_items = []
try:
self.serial_numbers = extract_serial_numbers(serials, quantity)
for serial in self.serial_numbers:
try:
# Find matching stock item
stock_item = StockItem.objects.get(
part=self.part,
serial=serial
)
except StockItem.DoesNotExist:
self.form.add_error(
'serials',
_('No matching item for serial {serial}').format(serial=serial)
)
continue
# Now we have a valid stock item - but can it be added to the sales order?
# If not in stock, cannot be added to the order
if not stock_item.in_stock:
self.form.add_error(
'serials',
_('{serial} is not in stock').format(serial=serial)
)
continue
# Already allocated to an order
if stock_item.is_allocated():
self.form.add_error(
'serials',
_('{serial} already allocated to an order').format(serial=serial)
)
continue
# Add it to the list!
self.stock_items.append(stock_item)
except ValidationError as e:
self.form.add_error('serials', e.messages)
def allocate_items(self):
"""
Create stock item allocations for each selected serial number
"""
for stock_item in self.stock_items:
SalesOrderAllocation.objects.create(
item=stock_item,
line=self.line,
quantity=1,
)
def get_form(self):
form = super().get_form()
if self.line:
form.fields['line'].widget = HiddenInput()
if self.part:
form.fields['part'].widget = HiddenInput()
return form
def get_context_data(self):
return {
'line': self.line,
'part': self.part,
}
def get(self, request, *args, **kwargs):
return self.renderJsonResponse(
request,
self.get_form(),
context=self.get_context_data(),
)
class LineItemPricing(PartPricing):
""" View for inspecting part pricing information """

View File

@ -8,10 +8,9 @@ from import_export.resources import ModelResource
from import_export.fields import Field
import import_export.widgets as widgets
import part.models as models
from stock.models import StockLocation
from company.models import SupplierPart
import part.models as models
from stock.models import StockLocation
class PartResource(ModelResource):
@ -76,6 +75,13 @@ class PartAdmin(ImportExportModelAdmin):
search_fields = ('name', 'description', 'category__name', 'category__description', 'IPN')
autocomplete_fields = [
'variant_of',
'category',
'default_location',
'default_supplier',
]
class PartCategoryResource(ModelResource):
""" Class for managing PartCategory data import/export """
@ -105,13 +111,6 @@ class PartCategoryResource(ModelResource):
models.PartCategory.objects.rebuild()
class PartCategoryInline(admin.TabularInline):
"""
Inline for PartCategory model
"""
model = models.PartCategory
class PartCategoryAdmin(ImportExportModelAdmin):
resource_class = PartCategoryResource
@ -120,35 +119,44 @@ class PartCategoryAdmin(ImportExportModelAdmin):
search_fields = ('name', 'description')
inlines = [
PartCategoryInline,
]
autocomplete_fields = ('parent', 'default_location',)
class PartRelatedAdmin(admin.ModelAdmin):
''' Class to manage PartRelated objects '''
pass
"""
Class to manage PartRelated objects
"""
autocomplete_fields = ('part_1', 'part_2')
class PartAttachmentAdmin(admin.ModelAdmin):
list_display = ('part', 'attachment', 'comment')
autocomplete_fields = ('part',)
class PartStarAdmin(admin.ModelAdmin):
list_display = ('part', 'user')
autocomplete_fields = ('part',)
class PartCategoryStarAdmin(admin.ModelAdmin):
list_display = ('category', 'user')
autocomplete_fields = ('category',)
class PartTestTemplateAdmin(admin.ModelAdmin):
list_display = ('part', 'test_name', 'required')
autocomplete_fields = ('part',)
class BomItemResource(ModelResource):
""" Class for managing BomItem data import/export """
@ -253,10 +261,14 @@ class BomItemAdmin(ImportExportModelAdmin):
search_fields = ('part__name', 'part__description', 'sub_part__name', 'sub_part__description')
autocomplete_fields = ('part', 'sub_part',)
class ParameterTemplateAdmin(ImportExportModelAdmin):
list_display = ('name', 'units')
search_fields = ('name', 'units')
class ParameterResource(ModelResource):
""" Class for managing PartParameter data import/export """
@ -282,10 +294,12 @@ class ParameterAdmin(ImportExportModelAdmin):
list_display = ('part', 'template', 'data')
autocomplete_fields = ('part', 'template')
class PartCategoryParameterAdmin(admin.ModelAdmin):
pass
autocomplete_fields = ('category', 'parameter_template',)
class PartSellPriceBreakAdmin(admin.ModelAdmin):
@ -303,6 +317,8 @@ class PartInternalPriceBreakAdmin(admin.ModelAdmin):
list_display = ('part', 'quantity', 'price',)
autocomplete_fields = ('part',)
admin.site.register(models.Part, PartAdmin)
admin.site.register(models.PartCategory, PartCategoryAdmin)

View File

@ -583,6 +583,8 @@ class PartFilter(rest_filters.FilterSet):
active = rest_filters.BooleanFilter()
virtual = rest_filters.BooleanFilter()
class PartList(generics.ListCreateAPIView):
"""

View File

@ -69,6 +69,7 @@
name: 'Widget'
description: 'A watchamacallit'
category: 7
salable: true
assembly: true
trackable: true
tree_id: 0
@ -83,6 +84,7 @@
name: 'Orphan'
description: 'A part without a category'
category: null
salable: true
tree_id: 0
level: 0
lft: 0
@ -95,6 +97,7 @@
name: 'Bob'
description: 'Can we build it?'
assembly: true
salable: true
purchaseable: false
category: 7
active: False
@ -113,6 +116,7 @@
description: 'A chair'
is_template: True
trackable: true
salable: true
category: 7
tree_id: 1
level: 0

View File

@ -73,7 +73,7 @@
<div class='panel-content'>
<div id='po-button-bar'>
<div class='button-toolbar container-fluid' style='float: right;'>
{% include "filter_list.html" with id="purchaseorder" %}
{% include "filter_list.html" with id="partpurchaseorders" %}
</div>
</div>
@ -703,12 +703,10 @@
});
onPanelLoad("purchase-orders", function() {
loadPurchaseOrderTable($("#purchase-order-table"), {
url: "{% url 'api-po-list' %}",
params: {
part: {{ part.id }},
},
});
loadPartPurchaseOrderTable(
"#purchase-order-table",
{{ part.pk }},
);
});
onPanelLoad("sales-orders", function() {

View File

@ -63,6 +63,10 @@ class LocationAdmin(ImportExportModelAdmin):
LocationInline,
]
autocomplete_fields = [
'parent',
]
class StockItemResource(ModelResource):
""" Class for managing StockItem data import/export """
@ -136,20 +140,45 @@ class StockItemAdmin(ImportExportModelAdmin):
'batch',
]
autocomplete_fields = [
'belongs_to',
'build',
'customer',
'location',
'parent',
'part',
'purchase_order',
'sales_order',
'stocktake_user',
'supplier_part',
]
class StockAttachmentAdmin(admin.ModelAdmin):
list_display = ('stock_item', 'attachment', 'comment')
autocomplete_fields = [
'stock_item',
]
class StockTrackingAdmin(ImportExportModelAdmin):
list_display = ('item', 'date', 'label')
autocomplete_fields = [
'item',
]
class StockItemTestResultAdmin(admin.ModelAdmin):
list_display = ('stock_item', 'test', 'result', 'value')
autocomplete_fields = [
'stock_item',
]
admin.site.register(StockLocation, LocationAdmin)
admin.site.register(StockItem, StockItemAdmin)

View File

@ -10,7 +10,7 @@ from datetime import datetime, timedelta
from django.core.exceptions import ValidationError as DjangoValidationError
from django.conf.urls import url, include
from django.http import JsonResponse
from django.db.models import Q
from django.db.models import Q, F
from django.db import transaction
from django.utils.translation import ugettext_lazy as _
@ -304,6 +304,24 @@ class StockFilter(rest_filters.FilterSet):
return queryset
available = rest_filters.BooleanFilter(label='Available', method='filter_available')
def filter_available(self, queryset, name, value):
"""
Filter by whether the StockItem is "available" or not.
Here, "available" means that the allocated quantity is less than the total quantity
"""
if str2bool(value):
# The 'quantity' field is greater than the calculated 'allocated' field
queryset = queryset.filter(Q(quantity__gt=F('allocated')))
else:
# The 'quantity' field is less than (or equal to) the calculated 'allocated' field
queryset = queryset.filter(Q(quantity__lte=F('allocated')))
return queryset
batch = rest_filters.CharFilter(label="Batch code filter (case insensitive)", lookup_expr='iexact')
batch_regex = rest_filters.CharFilter(label="Batch code filter (regex)", field_name='batch', lookup_expr='iregex')

View File

@ -252,7 +252,7 @@
<div class='alert alert-block alert-info'>
{% object_link 'so-detail' allocation.line.order.id allocation.line.order as link %}
{% decimal allocation.quantity as qty %}
{% blocktrans %}This stock item is allocated to Sales Order {{ link }} (Quantity: {{ qty }}){% endblocktrans %}
{% trans "This stock item is allocated to Sales Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
</div>
{% endfor %}
@ -260,7 +260,7 @@
<div class='alert alert-block alert-info'>
{% object_link 'build-detail' allocation.build.id allocation.build %}
{% decimal allocation.quantity as qty %}
{% blocktrans %}This stock item is allocated to Build {{ link }} (Quantity: {{ qty }}){% endblocktrans %}
{% trans "This stock item is allocated to Build Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
</div>
{% endfor %}

View File

@ -34,7 +34,12 @@ function buildFormFields() {
reference: {
prefix: global_settings.BUILDORDER_REFERENCE_PREFIX,
},
part: {},
part: {
filters: {
assembly: true,
virtual: false,
}
},
title: {},
quantity: {},
parent: {
@ -1226,7 +1231,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
*
* options:
* - output: ID / PK of the associated build output (or null for untracked items)
* - source_location: ID / PK of the top-level StockLocation to take parts from (or null)
* - source_location: ID / PK of the top-level StockLocation to source stock from (or null)
*/
function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
@ -1339,7 +1344,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
var html = ``;
// Render a "take from" input
// Render a "source location" input
html += constructField(
'take_from',
{
@ -1397,6 +1402,13 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
options,
);
// Add callback to "clear" button for take_from field
addClearCallback(
'take_from',
take_from_field,
options,
);
// Initialize stock item fields
bom_items.forEach(function(bom_item) {
initializeRelatedField(
@ -1406,6 +1418,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
filters: {
bom_item: bom_item.pk,
in_stock: true,
available: true,
part_detail: true,
location_detail: true,
},
@ -1456,14 +1469,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
);
});
// Add callback to "clear" button for take_from field
addClearCallback(
'take_from',
take_from_field,
options,
);
// Add button callbacks
// Add remove-row button callbacks
$(options.modal).find('.button-row-remove').click(function() {
var pk = $(this).attr('pk');

View File

@ -730,6 +730,9 @@ function submitFormData(fields, options) {
data = options.processBeforeUpload(data);
}
// Show the progress spinner
$(options.modal).find('#modal-progress-spinner').show();
// Submit data
upload_func(
options.url,
@ -737,10 +740,13 @@ function submitFormData(fields, options) {
{
method: options.method,
success: function(response) {
$(options.modal).find('#modal-progress-spinner').hide();
handleFormSuccess(response, options);
},
error: function(xhr) {
$(options.modal).find('#modal-progress-spinner').hide();
switch (xhr.status) {
case 400:
handleFormErrors(xhr.responseJSON, fields, options);
@ -1713,6 +1719,9 @@ function renderModelData(name, model, data, parameters, options) {
case 'salesorder':
renderer = renderSalesOrder;
break;
case 'salesordershipment':
renderer = renderSalesOrderShipment;
break;
case 'manufacturerpart':
renderer = renderManufacturerPart;
break;

View File

@ -72,6 +72,7 @@ function createNewModal(options={}) {
<!-- Extra buttons can be inserted here -->
</div>
<span class='flex-item' style='flex-grow: 1;'></span>
<h4><span id='modal-progress-spinner' class='fas fa-circle-notch fa-spin' style='display: none;'></span></h4>
<button type='button' class='btn btn-secondary' id='modal-form-close' data-bs-dismiss='modal'>{% trans "Cancel" %}</button>
<button type='button' class='btn btn-primary' id='modal-form-submit'>{% trans "Submit" %}</button>
</div>

View File

@ -241,6 +241,23 @@ function renderSalesOrder(name, data, parameters, options) {
}
// Renderer for "SalesOrderShipment" model
// eslint-disable-next-line no-unused-vars
function renderSalesOrderShipment(name, data, parameters, options) {
var so_prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
var html = `
<span>${so_prefix}${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference}</span>
<span class='float-right'>
<small>{% trans "Shipment ID" %}: ${data.pk}</small>
</span>
`;
return html;
}
// Renderer for "PartCategory" model
// eslint-disable-next-line no-unused-vars
function renderPartCategory(name, data, parameters, options) {

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,7 @@
loadParametricPartTable,
loadPartCategoryTable,
loadPartParameterTable,
loadPartPurchaseOrderTable,
loadPartTable,
loadPartTestTemplateTable,
loadPartVariantTable,
@ -712,6 +713,180 @@ function loadPartParameterTable(table, url, options) {
}
/*
* Construct a table showing a list of purchase orders for a given part.
*
* This requests API data from the PurchaseOrderLineItem endpoint
*/
function loadPartPurchaseOrderTable(table, part_id, options={}) {
options.params = options.params || {};
// Construct API filterset
options.params.base_part = part_id;
options.params.part_detail = true;
options.params.order_detail = true;
var filters = loadTableFilters('partpurchaseorders');
for (var key in options.params) {
filters[key] = options.params[key];
}
setupFilterList('purchaseorderlineitem', $(table), '#filter-list-partpurchaseorders');
$(table).inventreeTable({
url: '{% url "api-po-line-list" %}',
queryParams: filters,
name: 'partpurchaseorders',
original: options.params,
showColumns: true,
uniqueId: 'pk',
formatNoMatches: function() {
return '{% trans "No purchase orders found" %}';
},
onPostBody: function() {
$(table).find('.button-line-receive').click(function() {
var pk = $(this).attr('pk');
var line_item = $(table).bootstrapTable('getRowByUniqueId', pk);
if (!line_item) {
console.log('WARNING: getRowByUniqueId returned null');
return;
}
receivePurchaseOrderItems(
line_item.order,
[
line_item,
],
{
success: function() {
$(table).bootstrapTable('refresh');
}
}
);
});
},
columns: [
{
field: 'order',
title: '{% trans "Purchase Order" %}',
switchable: false,
formatter: function(value, row) {
var order = row.order_detail;
if (!order) {
return '-';
}
var ref = global_settings.PURCHASEORDER_REFERENCE_PREFIX + order.reference;
var html = renderLink(ref, `/order/purchase-order/${order.pk}/`);
html += purchaseOrderStatusDisplay(
order.status,
{
classes: 'float-right',
}
);
return html;
},
},
{
field: 'supplier',
title: '{% trans "Supplier" %}',
switchable: true,
formatter: function(value, row) {
if (row.supplier_part_detail && row.supplier_part_detail.supplier_detail) {
var supp = row.supplier_part_detail.supplier_detail;
var html = imageHoverIcon(supp.thumbnail || supp.image);
html += ' ' + renderLink(supp.name, `/company/${supp.pk}/`);
return html;
} else {
return '-';
}
}
},
{
field: 'sku',
title: '{% trans "SKU" %}',
switchable: true,
formatter: function(value, row) {
if (row.supplier_part_detail) {
var supp = row.supplier_part_detail;
return renderLink(supp.SKU, `/supplier-part/${supp.pk}/`);
} else {
return '-';
}
},
},
{
field: 'mpn',
title: '{% trans "MPN" %}',
switchable: true,
formatter: function(value, row) {
if (row.supplier_part_detail && row.supplier_part_detail.manufacturer_part_detail) {
var manu = row.supplier_part_detail.manufacturer_part_detail;
return renderLink(manu.MPN, `/manufacturer-part/${manu.pk}/`);
}
}
},
{
field: 'quantity',
title: '{% trans "Quantity" %}',
},
{
field: 'received',
title: '{% trans "Received" %}',
switchable: true,
},
{
field: 'purchase_price',
title: '{% trans "Price" %}',
switchable: true,
formatter: function(value, row) {
var formatter = new Intl.NumberFormat(
'en-US',
{
style: 'currency',
currency: row.purchase_price_currency,
}
);
return formatter.format(row.purchase_price);
}
},
{
field: 'actions',
title: '',
formatter: function(value, row) {
if (row.received >= row.quantity) {
// Already recevied
return `<span class='badge bg-success rounded-pill'>{% trans "Received" %}</span>`;
} else {
var html = `<div class='btn-group' role='group'>`;
var pk = row.pk;
html += makeIconButton('fa-sign-in-alt', 'button-line-receive', pk, '{% trans "Receive line item" %}');
html += `</div>`;
return html;
}
}
}
],
});
}
function loadRelatedPartsTable(table, part_id, options={}) {
/*
* Load table of "related" parts

View File

@ -95,6 +95,9 @@ function serializeStockItem(pk, options={}) {
});
}
options.confirm = true;
options.confirmMessage = '{% trans "Confirm Stock Serialization" %}';
constructForm(url, options);
}
@ -1275,7 +1278,14 @@ function loadStockTable(table, options) {
}
if (row.allocated) {
html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been allocated" %}');
if (row.serial != null && row.quantity == 1) {
html += makeIconBadge('fa-bookmark icon-yellow', '{% trans "Serialized stock item has been allocated" %}');
} else if (row.allocated >= row.quantity) {
html += makeIconBadge('fa-bookmark icon-yellow', '{% trans "Stock item has been fully allocated" %}');
} else {
html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been partially allocated" %}');
}
}
if (row.belongs_to) {

View File

@ -173,6 +173,11 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "Is allocated" %}',
description: '{% trans "Item has been allocated" %}',
},
available: {
type: 'bool',
title: '{% trans "Available" %}',
description: '{% trans "Stock is available for use" %}',
},
cascade: {
type: 'bool',
title: '{% trans "Include sublocations" %}',
@ -293,6 +298,10 @@ function getAvailableTableFilters(tableKey) {
type: 'bool',
title: '{% trans "Overdue" %}',
},
assigned_to_me: {
type: 'bool',
title: '{% trans "Assigned to me" %}',
},
};
}
@ -305,6 +314,7 @@ function getAvailableTableFilters(tableKey) {
},
};
}
// Filters for the PurchaseOrder table
if (tableKey == 'purchaseorder') {
@ -321,6 +331,10 @@ function getAvailableTableFilters(tableKey) {
type: 'bool',
title: '{% trans "Overdue" %}',
},
assigned_to_me: {
type: 'bool',
title: '{% trans "Assigned to me" %}',
},
};
}
@ -341,6 +355,15 @@ function getAvailableTableFilters(tableKey) {
};
}
if (tableKey == 'salesorderlineitem') {
return {
completed: {
type: 'bool',
title: '{% trans "Completed" %}',
},
};
}
if (tableKey == 'supplier-part') {
return {
active: {

View File

@ -137,9 +137,10 @@ class RuleSet(models.Model):
'sales_order': [
'company_company',
'order_salesorder',
'order_salesorderallocation',
'order_salesorderattachment',
'order_salesorderlineitem',
'order_salesorderallocation',
'order_salesordershipment',
]
}
@ -503,6 +504,34 @@ class Owner(models.Model):
owner: Returns the Group or User instance combining the owner_type and owner_id fields
"""
@classmethod
def get_owners_matching_user(cls, user):
"""
Return all "owner" objects matching the provided user:
A) An exact match for the user
B) Any groups that the user is a part of
"""
user_type = ContentType.objects.get(app_label='auth', model='user')
group_type = ContentType.objects.get(app_label='auth', model='group')
owners = []
try:
owners.append(cls.objects.get(owner_id=user.pk, owner_type=user_type))
except:
pass
for group in user.groups.all():
try:
owner = cls.objects.get(owner_id=group.pk, owner_type=group_type)
owners.append(owner)
except:
pass
return owners
@staticmethod
def get_api_url():
return reverse('api-owner-list')

View File

@ -6,10 +6,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Coverage Status](https://coveralls.io/repos/github/inventree/InvenTree/badge.svg)](https://coveralls.io/github/inventree/InvenTree)
[![Crowdin](https://badges.crowdin.net/inventree/localized.svg)](https://crowdin.com/project/inventree)
![PEP](https://github.com/inventree/inventree/actions/workflows/style.yaml/badge.svg)
![SQLite](https://github.com/inventree/inventree/actions/workflows/coverage.yaml/badge.svg)
![MySQL](https://github.com/inventree/inventree/actions/workflows/mysql.yaml/badge.svg)
![PostgreSQL](https://github.com/inventree/inventree/actions/workflows/postgresql.yaml/badge.svg)
![CI](https://github.com/inventree/inventree/actions/workflows/qc_checks.yaml/badge.svg)
InvenTree is an open-source Inventory Management System which provides powerful low-level stock control and part tracking. The core of the InvenTree system is a Python/Django database backend which provides an admin interface (web-based) and a JSON API for interaction with external interfaces and applications.

3788
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff