mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into matmair/issue2201
This commit is contained in:
commit
2a0e07abe0
60
.github/workflows/coverage.yaml
vendored
60
.github/workflows/coverage.yaml
vendored
@ -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
|
54
.github/workflows/html.yaml
vendored
54
.github/workflows/html.yaml
vendored
@ -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
|
||||
|
51
.github/workflows/javascript.yaml
vendored
51
.github/workflows/javascript.yaml
vendored
@ -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
|
67
.github/workflows/mysql.yaml
vendored
67
.github/workflows/mysql.yaml
vendored
@ -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
|
70
.github/workflows/postgresql.yaml
vendored
70
.github/workflows/postgresql.yaml
vendored
@ -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
|
49
.github/workflows/python.yaml
vendored
49
.github/workflows/python.yaml
vendored
@ -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
318
.github/workflows/qc_checks.yaml
vendored
Normal 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
|
34
.github/workflows/style.yaml
vendored
34
.github/workflows/style.yaml
vendored
@ -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
|
20
.github/workflows/version.yaml
vendored
20
.github/workflows/version.yaml
vendored
@ -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
1
.gitignore
vendored
@ -77,5 +77,4 @@ dev/
|
||||
locale_stats.json
|
||||
|
||||
# node.js
|
||||
package-lock.json
|
||||
node_modules/
|
@ -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',
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
|
@ -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.
|
||||
|
@ -423,7 +423,7 @@ class BuildAllocationSerializer(serializers.Serializer):
|
||||
Validation
|
||||
"""
|
||||
|
||||
super().validate(data)
|
||||
data = super().validate(data)
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
@ -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)
|
||||
|
@ -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'),
|
||||
])),
|
||||
|
||||
|
@ -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 """
|
||||
|
||||
|
31
InvenTree/order/migrations/0053_salesordershipment.py
Normal file
31
InvenTree/order/migrations/0053_salesordershipment.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
92
InvenTree/order/migrations/0055_auto_20211025_0645.py
Normal file
92
InvenTree/order/migrations/0055_auto_20211025_0645.py
Normal 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,
|
||||
)
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
62
InvenTree/order/migrations/0058_auto_20211126_1210.py
Normal file
62
InvenTree/order/migrations/0058_auto_20211126_1210.py
Normal 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
|
||||
)
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
22
InvenTree/order/migrations/0060_auto_20211129_1339.py
Normal file
22
InvenTree/order/migrations/0060_auto_20211129_1339.py
Normal 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')},
|
||||
),
|
||||
]
|
@ -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 = [
|
||||
]
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
@ -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: {
|
||||
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)),
|
||||
|
||||
|
@ -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 """
|
||||
|
||||
|
@ -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)
|
||||
|
@ -583,6 +583,8 @@ class PartFilter(rest_filters.FilterSet):
|
||||
|
||||
active = rest_filters.BooleanFilter()
|
||||
|
||||
virtual = rest_filters.BooleanFilter()
|
||||
|
||||
|
||||
class PartList(generics.ListCreateAPIView):
|
||||
"""
|
||||
|
@ -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
|
||||
|
@ -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() {
|
||||
|
@ -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)
|
||||
|
@ -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')
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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: {
|
||||
|
@ -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')
|
||||
|
@ -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
3788
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user