2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-30 04:26:44 +00:00

Merge branch 'master' into l10n_trans

This commit is contained in:
Matthias Mair 2021-04-21 14:17:19 +02:00 committed by GitHub
commit 37953f9926
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
166 changed files with 3974 additions and 859 deletions

5
.gitattributes vendored
View File

@ -4,3 +4,8 @@
*.md text *.md text
*.html text *.html text
*.txt text *.txt text
*.yml text
*.yaml text
*.conf text
*.sh text
*.js text

View File

@ -29,6 +29,7 @@ jobs:
- name: Install Dependencies - name: Install Dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install gettext
pip3 install invoke pip3 install invoke
invoke install invoke install
- name: Coverage Tests - name: Coverage Tests
@ -42,6 +43,8 @@ jobs:
rm test_db.sqlite rm test_db.sqlite
invoke migrate invoke migrate
invoke import-records -f data.json invoke import-records -f data.json
- name: Test Translations
run: invoke translate
- name: Check Migration Files - name: Check Migration Files
run: python3 ci/check_migration_files.py run: python3 ci/check_migration_files.py
- name: Upload Coverage Report - name: Upload Coverage Report

View File

@ -1,8 +1,11 @@
# Test that the docker file builds correctly # Build and push latest docker image on push to master branch
name: Docker name: Docker Build
on: ["push", "pull_request"] on:
push:
branches:
- 'master'
jobs: jobs:
@ -10,9 +13,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - name: Checkout Code
- name: Build Server Image uses: actions/checkout@v2
run: cd docker/inventree && docker build . --tag inventree:$(date +%s) - name: Login to Dockerhub
- name: Build nginx Image uses: docker/login-action@v1
run: cd docker/nginx && docker build . --tag nxinx:$(date +%s) with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and Push
uses: docker/build-push-action@v2
with:
context: ./docker
push: true
repository: inventree/inventree
tags: inventree/inventree:latest
- name: Image Digest
run: echo ${{ steps.docker_build.outputs.digest }}

View File

@ -7,12 +7,15 @@ on:
types: [published] types: [published]
jobs: jobs:
server_image: publish_image:
name: Push InvenTree web server image to dockerhub name: Push InvenTree web server image to dockerhub
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: cd
run: |
cd docker
- name: Push to Docker Hub - name: Push to Docker Hub
uses: docker/build-push-action@v1 uses: docker/build-push-action@v1
with: with:
@ -20,19 +23,4 @@ jobs:
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
repository: inventree/inventree repository: inventree/inventree
tag_with_ref: true tag_with_ref: true
dockerfile: docker/inventree/Dockerfile dockerfile: ./Dockerfile
nginx_image:
name: Push InvenTree nginx image to dockerhub
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@v2
- name: Push to Docker Hub
uses: docker/build-push-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: inventree/nginx
tag_with_ref: true
dockerfile: docker/nginx/Dockerfile

1
.gitignore vendored
View File

@ -40,6 +40,7 @@ docs/_build
# Local static and media file storage (only when running in development mode) # Local static and media file storage (only when running in development mode)
inventree_media inventree_media
inventree_static inventree_static
static_i18n
# Local config file # Local config file
config.yaml config.yaml

View File

@ -280,11 +280,25 @@ def MakeBarcode(object_name, object_pk, object_data={}, **kwargs):
json string of the supplied data plus some other data json string of the supplied data plus some other data
""" """
url = kwargs.get('url', False)
brief = kwargs.get('brief', True) brief = kwargs.get('brief', True)
data = {} data = {}
if brief: if url:
request = object_data.get('request', None)
item_url = object_data.get('item_url', None)
absolute_url = None
if request and item_url:
absolute_url = request.build_absolute_uri(item_url)
# Return URL (No JSON)
return absolute_url
if item_url:
# Return URL (No JSON)
return item_url
elif brief:
data[object_name] = object_pk data[object_name] = object_pk
else: else:
data['tool'] = 'InvenTree' data['tool'] = 'InvenTree'

View File

@ -0,0 +1,61 @@
"""
Custom management command to prerender files
"""
from django.core.management.base import BaseCommand
from django.conf import settings
from django.template.loader import render_to_string
from django.utils.module_loading import import_string
from django.http.request import HttpRequest
from django.utils.translation import override as lang_over
import os
def render_file(file_name, source, target, locales, ctx):
""" renders a file into all provided locales """
for locale in locales:
target_file = os.path.join(target, locale + '.' + file_name)
with open(target_file, 'w') as localised_file:
with lang_over(locale):
renderd = render_to_string(os.path.join(source, file_name), ctx)
localised_file.write(renderd)
class Command(BaseCommand):
"""
django command to prerender files
"""
def handle(self, *args, **kwargs):
# static directories
LC_DIR = settings.LOCALE_PATHS[0]
SOURCE_DIR = settings.STATICFILES_I18_SRC
TARGET_DIR = settings.STATICFILES_I18_TRG
# ensure static directory exists
if not os.path.exists(TARGET_DIR):
os.makedirs(TARGET_DIR, exist_ok=True)
# collect locales
locales = {}
for locale in os.listdir(LC_DIR):
path = os.path.join(LC_DIR, locale)
if os.path.exists(path) and os.path.isdir(path):
locales[locale] = locale
# render!
request = HttpRequest()
ctx = {}
processors = tuple(import_string(path) for path in settings.STATFILES_I18_PROCESSORS)
for processor in processors:
ctx.update(processor(request))
for file in os.listdir(SOURCE_DIR, ):
path = os.path.join(SOURCE_DIR, file)
if os.path.exists(path) and os.path.isfile(path):
print(f"render {file}")
render_file(file, SOURCE_DIR, TARGET_DIR, locales, ctx)
else:
raise NotImplementedError('Using multi-level directories is not implemented at this point') # TODO multilevel dir if needed
print(f"rendered all files in {SOURCE_DIR}")

View File

@ -52,6 +52,9 @@ def get_setting(environment_var, backup_val, default_value=None):
# Determine if we are running in "test" mode e.g. "manage.py test" # Determine if we are running in "test" mode e.g. "manage.py test"
TESTING = 'test' in sys.argv TESTING = 'test' in sys.argv
# New requirement for django 3.2+
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -190,6 +193,17 @@ STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'InvenTree', 'static'), os.path.join(BASE_DIR, 'InvenTree', 'static'),
] ]
# Translated Template settings
STATICFILES_I18_PREFIX = 'i18n'
STATICFILES_I18_SRC = os.path.join(BASE_DIR, 'templates', 'js')
STATICFILES_I18_TRG = STATICFILES_DIRS[0] + '_' + STATICFILES_I18_PREFIX
STATICFILES_DIRS.append(STATICFILES_I18_TRG)
STATICFILES_I18_TRG = os.path.join(STATICFILES_I18_TRG, STATICFILES_I18_PREFIX)
STATFILES_I18_PROCESSORS = [
'InvenTree.context.status_codes',
]
# Color Themes Directory # Color Themes Directory
STATIC_COLOR_THEMES_DIR = os.path.join(STATIC_ROOT, 'css', 'color-themes') STATIC_COLOR_THEMES_DIR = os.path.join(STATIC_ROOT, 'css', 'color-themes')
@ -396,7 +410,6 @@ for key in db_keys:
env_var = os.environ.get(env_key, None) env_var = os.environ.get(env_key, None)
if env_var: if env_var:
logger.info(f"{env_key}={env_var}")
# Override configuration value # Override configuration value
db_config[key] = env_var db_config[key] = env_var

View File

@ -185,6 +185,10 @@
color: #c55; color: #c55;
} }
.icon-orange {
color: #fcba03;
}
.icon-green { .icon-green {
color: #43bb43; color: #43bb43;
} }

View File

@ -56,17 +56,26 @@ def is_email_configured():
configured = True configured = True
if not settings.EMAIL_HOST: if not settings.EMAIL_HOST:
logger.warning("EMAIL_HOST is not configured")
configured = False configured = False
# Display warning unless in test mode
if not settings.TESTING:
logger.warning("EMAIL_HOST is not configured")
if not settings.EMAIL_HOST_USER: if not settings.EMAIL_HOST_USER:
logger.warning("EMAIL_HOST_USER is not configured")
configured = False configured = False
# Display warning unless in test mode
if not settings.TESTING:
logger.warning("EMAIL_HOST_USER is not configured")
if not settings.EMAIL_HOST_PASSWORD: if not settings.EMAIL_HOST_PASSWORD:
logger.warning("EMAIL_HOST_PASSWORD is not configured")
configured = False configured = False
# Display warning unless in test mode
if not settings.TESTING:
logger.warning("EMAIL_HOST_PASSWORD is not configured")
return configured return configured

View File

@ -62,6 +62,14 @@ class StatusCode:
def items(cls): def items(cls):
return cls.options.items() return cls.options.items()
@classmethod
def keys(cls):
return cls.options.keys()
@classmethod
def labels(cls):
return cls.options.values()
@classmethod @classmethod
def label(cls, value): def label(cls, value):
""" Return the status code label associated with the provided value """ """ Return the status code label associated with the provided value """

View File

@ -11,6 +11,7 @@ from django.contrib import admin
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from company.urls import company_urls from company.urls import company_urls
from company.urls import manufacturer_part_urls
from company.urls import supplier_part_urls from company.urls import supplier_part_urls
from company.urls import price_break_urls from company.urls import price_break_urls
@ -115,6 +116,7 @@ dynamic_javascript_urls = [
urlpatterns = [ urlpatterns = [
url(r'^part/', include(part_urls)), url(r'^part/', include(part_urls)),
url(r'^manufacturer-part/', include(manufacturer_part_urls)),
url(r'^supplier-part/', include(supplier_part_urls)), url(r'^supplier-part/', include(supplier_part_urls)),
url(r'^price-break/', include(price_break_urls)), url(r'^price-break/', include(price_break_urls)),

View File

@ -8,7 +8,7 @@ import re
import common.models import common.models
INVENTREE_SW_VERSION = "0.2.1 pre" INVENTREE_SW_VERSION = "0.2.2 pre"
# Increment this number whenever there is a significant change to the API that any clients need to know about # Increment this number whenever there is a significant change to the API that any clients need to know about
INVENTREE_API_VERSION = 2 INVENTREE_API_VERSION = 2
@ -19,6 +19,14 @@ def inventreeInstanceName():
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "") return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
def inventreeInstanceTitle():
""" Returns the InstanceTitle for the current database """
if common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE_TITLE", False):
return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "")
else:
return 'InvenTree'
def inventreeVersion(): def inventreeVersion():
""" Returns the InvenTree version string """ """ Returns the InvenTree version string """
return INVENTREE_SW_VERSION return INVENTREE_SW_VERSION

View File

@ -11,7 +11,7 @@ from rest_framework import generics
from django.conf.urls import url, include from django.conf.urls import url, include
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool, isNull
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus
from .models import Build, BuildItem from .models import Build, BuildItem
@ -194,7 +194,11 @@ class BuildItemList(generics.ListCreateAPIView):
output = params.get('output', None) output = params.get('output', None)
if output: if output:
queryset = queryset.filter(install_into=output)
if isNull(output):
queryset = queryset.filter(install_into=None)
else:
queryset = queryset.filter(install_into=output)
return queryset return queryset

View File

@ -3,6 +3,7 @@ Django Forms for interacting with Build objects
""" """
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -12,6 +13,8 @@ from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import RoundingDecimalFormField
from InvenTree.fields import DatePickerFormField from InvenTree.fields import DatePickerFormField
from InvenTree.status_codes import StockStatus
from .models import Build, BuildItem, BuildOrderAttachment from .models import Build, BuildItem, BuildOrderAttachment
from stock.models import StockLocation, StockItem from stock.models import StockLocation, StockItem
@ -165,16 +168,10 @@ class AutoAllocateForm(HelperForm):
confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm stock allocation')) confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm stock allocation'))
# Keep track of which build output we are interested in
output = forms.ModelChoiceField(
queryset=StockItem.objects.all(),
)
class Meta: class Meta:
model = Build model = Build
fields = [ fields = [
'confirm', 'confirm',
'output',
] ]
@ -214,6 +211,13 @@ class CompleteBuildOutputForm(HelperForm):
help_text=_('Location of completed parts'), help_text=_('Location of completed parts'),
) )
stock_status = forms.ChoiceField(
label=_('Status'),
help_text=_('Build output stock status'),
initial=StockStatus.OK,
choices=StockStatus.items(),
)
confirm_incomplete = forms.BooleanField( confirm_incomplete = forms.BooleanField(
required=False, required=False,
label=_('Confirm incomplete'), label=_('Confirm incomplete'),
@ -232,10 +236,15 @@ class CompleteBuildOutputForm(HelperForm):
fields = [ fields = [
'location', 'location',
'output', 'output',
'stock_status',
'confirm', 'confirm',
'confirm_incomplete', 'confirm_incomplete',
] ]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class CancelBuildForm(HelperForm): class CancelBuildForm(HelperForm):
""" Form for cancelling a build """ """ Form for cancelling a build """

View File

@ -22,7 +22,7 @@ from markdownx.models import MarkdownxField
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus, StockStatus
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
from InvenTree.validators import validate_build_order_reference from InvenTree.validators import validate_build_order_reference
from InvenTree.models import InvenTreeAttachment from InvenTree.models import InvenTreeAttachment
@ -314,6 +314,42 @@ class Build(MPTTModel):
'sub_part' 'sub_part'
) )
@property
def tracked_bom_items(self):
"""
Returns the "trackable" BOM items for this BuildOrder
"""
items = self.bom_items
items = items.filter(sub_part__trackable=True)
return items
def has_tracked_bom_items(self):
"""
Returns True if this BuildOrder has trackable BomItems
"""
return self.tracked_bom_items.count() > 0
@property
def untracked_bom_items(self):
"""
Returns the "non trackable" BOM items for this BuildOrder
"""
items = self.bom_items
items = items.filter(sub_part__trackable=False)
return items
def has_untracked_bom_items(self):
"""
Returns True if this BuildOrder has non trackable BomItems
"""
return self.untracked_bom_items.count() > 0
@property @property
def remaining(self): def remaining(self):
""" """
@ -449,6 +485,9 @@ class Build(MPTTModel):
if self.completed < self.quantity: if self.completed < self.quantity:
return False return False
if not self.areUntrackedPartsFullyAllocated():
return False
# No issues! # No issues!
return True return True
@ -458,7 +497,7 @@ class Build(MPTTModel):
Mark this build as complete Mark this build as complete
""" """
if not self.can_complete: if self.incomplete_count > 0:
return return
self.completion_date = datetime.now().date() self.completion_date = datetime.now().date()
@ -466,6 +505,9 @@ class Build(MPTTModel):
self.status = BuildStatus.COMPLETE self.status = BuildStatus.COMPLETE
self.save() self.save()
# Remove untracked allocated stock
self.subtractUntrackedStock(user)
# Ensure that there are no longer any BuildItem objects # Ensure that there are no longer any BuildItem objects
# which point to thie Build Order # which point to thie Build Order
self.allocated_stock.all().delete() self.allocated_stock.all().delete()
@ -489,7 +531,7 @@ class Build(MPTTModel):
self.status = BuildStatus.CANCELLED self.status = BuildStatus.CANCELLED
self.save() self.save()
def getAutoAllocations(self, output): def getAutoAllocations(self):
""" """
Return a list of StockItem objects which will be allocated Return a list of StockItem objects which will be allocated
using the 'AutoAllocate' function. using the 'AutoAllocate' function.
@ -521,15 +563,19 @@ class Build(MPTTModel):
part = bom_item.sub_part part = bom_item.sub_part
# If the part is "trackable" it cannot be auto-allocated
if part.trackable:
continue
# Skip any parts which are already fully allocated # Skip any parts which are already fully allocated
if self.isPartFullyAllocated(part, output): if self.isPartFullyAllocated(part, None):
continue continue
# How many parts are required to complete the output? # How many parts are required to complete the output?
required = self.unallocatedQuantity(part, output) required = self.unallocatedQuantity(part, None)
# Grab a list of stock items which are available # Grab a list of stock items which are available
stock_items = self.availableStockItems(part, output) stock_items = self.availableStockItems(part, None)
# Ensure that the available stock items are in the correct location # Ensure that the available stock items are in the correct location
if self.take_from is not None: if self.take_from is not None:
@ -544,7 +590,6 @@ class Build(MPTTModel):
build_items = BuildItem.objects.filter( build_items = BuildItem.objects.filter(
build=self, build=self,
stock_item=stock_item, stock_item=stock_item,
install_into=output
) )
if len(build_items) > 0: if len(build_items) > 0:
@ -567,24 +612,45 @@ class Build(MPTTModel):
return allocations return allocations
@transaction.atomic @transaction.atomic
def unallocateStock(self, output=None, part=None): def unallocateOutput(self, output, part=None):
""" """
Deletes all stock allocations for this build. Unallocate all stock which are allocated against the provided "output" (StockItem)
Args:
output: Specify which build output to delete allocations (optional)
""" """
allocations = BuildItem.objects.filter(build=self.pk) allocations = BuildItem.objects.filter(
build=self,
if output: install_into=output
allocations = allocations.filter(install_into=output.pk) )
if part: if part:
allocations = allocations.filter(stock_item__part=part) allocations = allocations.filter(stock_item__part=part)
# Remove all the allocations allocations.delete()
@transaction.atomic
def unallocateUntracked(self, part=None):
"""
Unallocate all "untracked" stock
"""
allocations = BuildItem.objects.filter(
build=self,
install_into=None
)
if part:
allocations = allocations.filter(stock_item__part=part)
allocations.delete()
@transaction.atomic
def unallocateAll(self):
"""
Deletes all stock allocations for this build.
"""
allocations = BuildItem.objects.filter(build=self)
allocations.delete() allocations.delete()
@transaction.atomic @transaction.atomic
@ -679,13 +745,13 @@ class Build(MPTTModel):
raise ValidationError(_("Build output does not match Build Order")) raise ValidationError(_("Build output does not match Build Order"))
# Unallocate all build items against the output # Unallocate all build items against the output
self.unallocateStock(output) self.unallocateOutput(output)
# Remove the build output from the database # Remove the build output from the database
output.delete() output.delete()
@transaction.atomic @transaction.atomic
def autoAllocate(self, output): def autoAllocate(self):
""" """
Run auto-allocation routine to allocate StockItems to this Build. Run auto-allocation routine to allocate StockItems to this Build.
@ -702,7 +768,7 @@ class Build(MPTTModel):
See: getAutoAllocations() See: getAutoAllocations()
""" """
allocations = self.getAutoAllocations(output) allocations = self.getAutoAllocations()
for item in allocations: for item in allocations:
# Create a new allocation # Create a new allocation
@ -710,11 +776,29 @@ class Build(MPTTModel):
build=self, build=self,
stock_item=item['stock_item'], stock_item=item['stock_item'],
quantity=item['quantity'], quantity=item['quantity'],
install_into=output, install_into=None
) )
build_item.save() build_item.save()
@transaction.atomic
def subtractUntrackedStock(self, user):
"""
Called when the Build is marked as "complete",
this function removes the allocated untracked items from stock.
"""
items = self.allocated_stock.filter(
stock_item__part__trackable=False
)
# Remove stock
for item in items:
item.complete_allocation(user)
# Delete allocation
items.all().delete()
@transaction.atomic @transaction.atomic
def completeBuildOutput(self, output, user, **kwargs): def completeBuildOutput(self, output, user, **kwargs):
""" """
@ -726,6 +810,7 @@ class Build(MPTTModel):
# Select the location for the build output # Select the location for the build output
location = kwargs.get('location', self.destination) location = kwargs.get('location', self.destination)
status = kwargs.get('status', StockStatus.OK)
# List the allocated BuildItem objects for the given output # List the allocated BuildItem objects for the given output
allocated_items = output.items_to_install.all() allocated_items = output.items_to_install.all()
@ -733,9 +818,7 @@ class Build(MPTTModel):
for build_item in allocated_items: for build_item in allocated_items:
# TODO: This is VERY SLOW as each deletion from the database takes ~1 second to complete # TODO: This is VERY SLOW as each deletion from the database takes ~1 second to complete
# TODO: Use celery / redis to offload the actual object deletion... # TODO: Use the background worker process to handle this task!
# REF: https://www.botreetechnologies.com/blog/implementing-celery-using-django-for-background-task-processing
# REF: https://code.tutsplus.com/tutorials/using-celery-with-django-for-background-task-processing--cms-28732
# Complete the allocation of stock for that item # Complete the allocation of stock for that item
build_item.complete_allocation(user) build_item.complete_allocation(user)
@ -747,6 +830,7 @@ class Build(MPTTModel):
output.build = self output.build = self
output.is_building = False output.is_building = False
output.location = location output.location = location
output.status = status
output.save() output.save()
@ -779,7 +863,7 @@ class Build(MPTTModel):
if output: if output:
quantity *= output.quantity quantity *= output.quantity
else: else:
quantity *= self.remaining quantity *= self.quantity
return quantity return quantity
@ -807,7 +891,13 @@ class Build(MPTTModel):
allocations = self.allocatedItems(part, output) allocations = self.allocatedItems(part, output)
allocated = allocations.aggregate(q=Coalesce(Sum('quantity'), 0)) allocated = allocations.aggregate(
q=Coalesce(
Sum('quantity'),
0,
output_field=models.DecimalField(),
)
)
return allocated['q'] return allocated['q']
@ -828,19 +918,39 @@ class Build(MPTTModel):
return self.unallocatedQuantity(part, output) == 0 return self.unallocatedQuantity(part, output) == 0
def isFullyAllocated(self, output): def isFullyAllocated(self, output, verbose=False):
""" """
Returns True if the particular build output is fully allocated. Returns True if the particular build output is fully allocated.
""" """
for bom_item in self.bom_items: # If output is not specified, we are talking about "untracked" items
if output is None:
bom_items = self.untracked_bom_items
else:
bom_items = self.tracked_bom_items
fully_allocated = True
for bom_item in bom_items:
part = bom_item.sub_part part = bom_item.sub_part
if not self.isPartFullyAllocated(part, output): if not self.isPartFullyAllocated(part, output):
return False fully_allocated = False
if verbose:
print(f"Part {part} is not fully allocated for output {output}")
else:
break
# All parts must be fully allocated! # All parts must be fully allocated!
return True return fully_allocated
def areUntrackedPartsFullyAllocated(self):
"""
Returns True if the un-tracked parts are fully allocated for this BuildOrder
"""
return self.isFullyAllocated(None)
def allocatedParts(self, output): def allocatedParts(self, output):
""" """
@ -849,7 +959,13 @@ class Build(MPTTModel):
allocated = [] allocated = []
for bom_item in self.bom_items: # If output is not specified, we are talking about "untracked" items
if output is None:
bom_items = self.untracked_bom_items
else:
bom_items = self.tracked_bom_items
for bom_item in bom_items:
part = bom_item.sub_part part = bom_item.sub_part
if self.isPartFullyAllocated(part, output): if self.isPartFullyAllocated(part, output):
@ -864,7 +980,13 @@ class Build(MPTTModel):
unallocated = [] unallocated = []
for bom_item in self.bom_items: # If output is not specified, we are talking about "untracked" items
if output is None:
bom_items = self.untracked_bom_items
else:
bom_items = self.tracked_bom_items
for bom_item in bom_items:
part = bom_item.sub_part part = bom_item.sub_part
if not self.isPartFullyAllocated(part, output): if not self.isPartFullyAllocated(part, output):
@ -1014,10 +1136,12 @@ class BuildItem(models.Model):
errors = {} errors = {}
if not self.install_into:
raise ValidationError(_('Build item must specify a build output'))
try: try:
# If the 'part' is trackable, then the 'install_into' field must be set!
if self.stock_item.part and self.stock_item.part.trackable and not self.install_into:
raise ValidationError(_('Build item must specify a build output, as master part is marked as trackable'))
# Allocated part must be in the BOM for the master part # Allocated part must be in the BOM for the master part
if self.stock_item.part not in self.build.part.getRequiredParts(recursive=False): if self.stock_item.part not in self.build.part.getRequiredParts(recursive=False):
errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name)] errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name)]

View File

@ -4,7 +4,7 @@
{% load inventree_extras %} {% load inventree_extras %}
{% block page_title %} {% block page_title %}
InvenTree | Allocate Parts {% inventree_title %} | {% trans "Allocate Parts" %}
{% endblock %} {% endblock %}
{% block menubar %} {% block menubar %}
@ -12,48 +12,41 @@ InvenTree | Allocate Parts
{% endblock %} {% endblock %}
{% block heading %} {% block heading %}
{% trans "Incomplete Build Ouputs" %} {% trans "Allocate Stock to Build" %}
{% endblock %} {% endblock %}
{% block details %} {% block details %}
{% if build.is_complete %} {% if build.has_untracked_bom_items %}
<div class='alert alert-block alert-success'> {% if build.active %}
{% trans "Build order has been completed" %}
</div>
{% else %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if build.active %} <button class='btn btn-success' type='button' id='btn-auto-allocate' title='{% trans "Allocate stock to build" %}'>
<button class='btn btn-primary' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'> <span class='fas fa-magic'></span> {% trans "Auto Allocate" %}
<span class='fas fa-plus-circle'></span> {% trans "Create New Output" %}
</button> </button>
<!--
<button class='btn btn-primary' type='button' id='btn-order-parts' title='{% trans "Order required parts" %}'>
<span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}
</button>
-->
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'> <button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'>
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %} <span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %}
</button> </button>
{% endif %} <!--
<button class='btn btn-primary' type='button' id='btn-order-parts' title='{% trans "Order required parts" %}'>
<span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}
</button>
-->
</div> </div>
{% if build.areUntrackedPartsFullyAllocated %}
<hr> <div class='alert alert-block alert-success'>
{% if build.incomplete_outputs %} {% trans "Untracked stock has been fully allocated for this Build Order" %}
<div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true">
{% for item in build.incomplete_outputs %}
{% include "build/allocation_card.html" with item=item %}
{% endfor %}
</div> </div>
{% else %} {% else %}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-danger'>
<b>{% trans "Create a new build output" %}</b><br> {% trans "Untracked stock has not been fully allocated for this Build Order" %}
{% trans "No incomplete build outputs remain." %}<br> </div>
{% trans "Create a new build output using the button above" %} {% endif %}
{% endif %}
<table class='table table-striped table-condensed' id='allocation-table-untracked'></table>
{% else %}
<div class='alert alert-block alert-info'>
{% trans "This Build Order does not have any associated untracked BOM items" %}
</div> </div>
{% endif %} {% endif %}
{% endif %}
{% endblock %} {% endblock %}
{% block js_ready %} {% block js_ready %}
@ -66,19 +59,17 @@ InvenTree | Allocate Parts
part: {{ build.part.pk }}, part: {{ build.part.pk }},
}; };
{% for item in build.incomplete_outputs %} {% if build.has_untracked_bom_items %}
// Get the build output as a javascript object // Load allocation table for un-tracked parts
inventreeGet('{% url 'api-stock-detail' item.pk %}', {}, loadBuildOutputAllocationTable(buildInfo, null);
{ {% endif %}
success: function(response) {
loadBuildOutputAllocationTable(buildInfo, response); function reloadTable() {
} $('#allocation-table-untracked').bootstrapTable('refresh');
} }
);
{% endfor %}
{% if build.active %} {% if build.active %}
$("#btn-allocate").on('click', function() { $("#btn-auto-allocate").on('click', function() {
launchModalForm( launchModalForm(
"{% url 'build-auto-allocate' build.id %}", "{% url 'build-auto-allocate' build.id %}",
{ {
@ -86,20 +77,12 @@ InvenTree | Allocate Parts
} }
); );
}); });
$('#btn-unallocate').on('click', function() { $('#btn-unallocate').on('click', function() {
launchModalForm( launchModalForm(
"{% url 'build-unallocate' build.id %}", "{% url 'build-unallocate' build.id %}",
{ {
reload: true, success: reloadTable,
}
);
});
$('#btn-create-output').click(function() {
launchModalForm('{% url "build-output-create" build.id %}',
{
reload: true,
} }
); );
}); });

View File

@ -7,23 +7,31 @@
<div class="panel-heading" role="tab" id="heading-{{ pk }}"> <div class="panel-heading" role="tab" id="heading-{{ pk }}">
<div class="panel-title"> <div class="panel-title">
<div class='row'> <div class='row'>
{% if tracked_items %}
<a class='collapsed' aria-expanded='false' role="button" data-toggle="collapse" data-parent="#build-output-accordion" href="#collapse-{{ pk }}" aria-controls="collapse-{{ pk }}"> <a class='collapsed' aria-expanded='false' role="button" data-toggle="collapse" data-parent="#build-output-accordion" href="#collapse-{{ pk }}" aria-controls="collapse-{{ pk }}">
{% endif %}
<div class='col-sm-4'> <div class='col-sm-4'>
{% if tracked_items %}
<span class='fas fa-caret-right'></span> <span class='fas fa-caret-right'></span>
{% endif %}
{{ item.part.full_name }} {{ item.part.full_name }}
</div> </div>
<div class='col-sm-2'> <div class='col-sm-2'>
{% if item.serial %} {% if item.serial %}
# {{ item.serial }} {% trans "Serial Number" %}: {{ item.serial }}
{% else %} {% else %}
{% decimal item.quantity %} {% trans "Quantity" %}: {% decimal item.quantity %}
{% endif %} {% endif %}
</div> </div>
{% if tracked_items %}
</a> </a>
{% endif %}
<div class='col-sm-3'> <div class='col-sm-3'>
<div> <div>
<div id='output-progress-{{ pk }}'> <div id='output-progress-{{ pk }}'>
{% if tracked_items %}
<span class='fas fa-spin fa-spinner'></span> <span class='fas fa-spin fa-spinner'></span>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,24 +3,52 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load status_codes %} {% load status_codes %}
{% load inventree_extras %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Build Order" %} - {{ build }} {% inventree_title %} | {% trans "Build Order" %} - {{ build }}
{% endblock %} {% endblock %}
{% block pre_content %} {% block header_pre_content %}
{% if build.sales_order %} {% if build.sales_order %}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-info'>
{% trans "This Build Order is allocated to Sales Order" %} <b><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a></b> {% object_link 'so-detail' build.sales_order.id build.sales_order as link %}
{% blocktrans %}This Build Order is allocated to Sales Order {{link}}{% endblocktrans %}
</div> </div>
{% endif %} {% endif %}
{% if build.parent %} {% if build.parent %}
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-info'>
{% trans "This Build Order is a child of Build Order" %} <b><a href="{% url 'build-detail' build.parent.id %}">{{ build.parent }}</a></b> {% object_link 'build-detail' build.parent.id build.parent as link %}
{% blocktrans %}This Build Order is a child of Build Order {{link}}{% endblocktrans %}
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block header_post_content %}
{% if build.active %}
{% if build.can_complete %}
<div class='alert alert-block alert-success'>
{% trans "Build Order is ready to mark as completed" %}
</div>
{% endif %}
{% if build.incomplete_count > 0 %}
<div class='alert alert-block alert-danger'>
{% trans "Build Order cannot be completed as outstanding outputs remain" %}
</div>
{% endif %}
{% if build.completed < build.quantity %}
<div class='alert alert-block alert-warning'>
{% trans "Required build quantity has not yet been completed" %}
</div>
{% endif %}
{% if not build.areUntrackedPartsFullyAllocated %}
<div class='alert alert-block alert-warning'>
{% trans "Stock has not been fully allocated to this Build Order" %}
</div>
{% endif %}
{% endif %}
{% endblock %}
{% block thumbnail %} {% block thumbnail %}
<img class="part-thumb" <img class="part-thumb"
{% if build.part.image %} {% if build.part.image %}
@ -58,6 +86,11 @@ src="{% static 'img/blank_image.png' %}"
</div> </div>
<!-- Build actions --> <!-- Build actions -->
{% if roles.build.change %} {% if roles.build.change %}
{% if build.active %}
<button id='build-complete' title='{% trans "Complete Build" %}' class='btn btn-success'>
<span class='fas fa-paper-plane'></span>
</button>
{% endif %}
<div class='btn-group'> <div class='btn-group'>
<button id='build-options' title='{% trans "Build actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> <button id='build-options' title='{% trans "Build actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-tools'></span> <span class='caret'></span> <span class='fas fa-tools'></span> <span class='caret'></span>
@ -65,12 +98,11 @@ src="{% static 'img/blank_image.png' %}"
<ul class='dropdown-menu' role='menu'> <ul class='dropdown-menu' role='menu'>
<li><a href='#' id='build-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit Build" %}</a></li> <li><a href='#' id='build-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit Build" %}</a></li>
{% if build.is_active %} {% if build.is_active %}
<li><a href='#' id='build-complete'><span class='fas fa-tools'></span> {% trans "Complete Build" %}</a></li>
<li><a href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li> <li><a href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
{% endif %} {% endif %}
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %} {% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
<li><a href='#' id='build-delete'><span class='fas fa-trash-alt'></span> {% trans "Delete Build"% }</a> <li><a href='#' id='build-delete'><span class='fas fa-trash-alt'></span> {% trans "Delete Build"% }</a>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
@ -105,7 +137,7 @@ src="{% static 'img/blank_image.png' %}"
<td> <td>
{{ build.target_date }} {{ build.target_date }}
{% if build.is_overdue %} {% if build.is_overdue %}
<span title='{% trans "This build was due on" %} {{ build.target_date }}' class='label label-red'>{% trans "Overdue" %}</span> <span title='{% blocktrans with target=build.target_date %}This build was due on {{target}}{% endblocktrans %}' class='label label-red'>{% trans "Overdue" %}</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -169,6 +201,13 @@ src="{% static 'img/blank_image.png' %}"
}); });
$("#build-complete").on('click', function() { $("#build-complete").on('click', function() {
{% if build.incomplete_count > 0 %}
showAlertDialog(
'{% trans "Incomplete Outputs" %}',
'{% trans "Build Order cannot be completed as incomplete build outputs remain" %}'
);
{% else %}
launchModalForm( launchModalForm(
"{% url 'build-complete' build.id %}", "{% url 'build-complete' build.id %}",
{ {
@ -176,6 +215,7 @@ src="{% static 'img/blank_image.png' %}"
submit_text: '{% trans "Complete Build" %}', submit_text: '{% trans "Complete Build" %}',
} }
); );
{% endif %}
}); });
$('#print-build-report').click(function() { $('#print-build-report').click(function() {

View File

@ -6,19 +6,68 @@
{% include "build/navbar.html" with tab='output' %} {% include "build/navbar.html" with tab='output' %}
{% endblock %} {% endblock %}
{% block heading %} {% block content_panels %}
{% trans "Build Outputs" %}
{% endblock %}
{% block details %} {% if not build.is_complete %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
<h4>
{% trans "Incomplete Build Outputs" %}
</h4>
</div>
{% include "stock_table.html" with read_only=True %} <div class='panel-content'>
<div class='btn-group' role='group'>
{% if build.active %}
<button class='btn btn-primary' type='button' id='btn-create-output' title='{% trans "Create new build output" %}'>
<span class='fas fa-plus-circle'></span> {% trans "Create New Output" %}
</button>
{% endif %}
</div>
{% if build.incomplete_outputs %}
<div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true">
{% for item in build.incomplete_outputs %}
{% include "build/allocation_card.html" with item=item tracked_items=build.has_tracked_bom_items %}
{% endfor %}
</div>
{% else %}
<div class='alert alert-block alert-info'>
<b>{% trans "Create a new build output" %}</b><br>
{% trans "No incomplete build outputs remain." %}<br>
{% trans "Create a new build output using the button above" %}
</div>
{% endif %}
</div>
</div>
{% endif %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
<h4>
{% trans "Completed Build Outputs" %}
</h4>
</div>
<div class='panel-content'>
{% include "stock_table.html" with read_only=True %}
</div>
</div>
{% endblock %} {% endblock %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$('#btn-create-output').click(function() {
launchModalForm('{% url "build-output-create" build.id %}',
{
reload: true,
}
);
});
loadStockTable($("#stock-table"), { loadStockTable($("#stock-table"), {
params: { params: {
location_detail: true, location_detail: true,
@ -32,4 +81,23 @@ loadStockTable($("#stock-table"), {
url: "{% url 'api-stock-list' %}", url: "{% url 'api-stock-list' %}",
}); });
var buildInfo = {
pk: {{ build.pk }},
quantity: {{ build.quantity }},
completed: {{ build.completed }},
part: {{ build.part.pk }},
};
{% for item in build.incomplete_outputs %}
// Get the build output as a javascript object
inventreeGet('{% url 'api-stock-detail' item.pk %}', {},
{
success: function(response) {
loadBuildOutputAllocationTable(buildInfo, response);
}
}
);
{% endfor %}
{% endblock %} {% endblock %}

View File

@ -5,11 +5,11 @@
{% if build.can_complete %} {% if build.can_complete %}
<div class='alert alert-block alert-success'> <div class='alert alert-block alert-success'>
{% trans "Build can be completed" %} {% trans "Build Order is complete" %}
</div> </div>
{% else %} {% else %}
<div class='alert alert-block alert-danger'> <div class='alert alert-block alert-danger'>
<b>{% trans "Build cannot be completed" %}</b><br> <b>{% trans "Build Order is incomplete" %}</b><br>
<ul> <ul>
{% if build.incomplete_count > 0 %} {% if build.incomplete_count > 0 %}
<li>{% trans "Incompleted build outputs remain" %}</li> <li>{% trans "Incompleted build outputs remain" %}</li>
@ -17,6 +17,9 @@
{% if build.completed < build.quantity %} {% if build.completed < build.quantity %}
<li>{% trans "Required build quantity has not been completed" %}</li> <li>{% trans "Required build quantity has not been completed" %}</li>
{% endif %} {% endif %}
{% if not build.areUntrackedPartsFullyAllocated %}
<li>{% trans "Required stock has not been fully allocated" %}</li>
{% endif %}
</ul> </ul>
</div> </div>
{% endif %} {% endif %}

View File

@ -4,9 +4,10 @@
{% block pre_form_content %} {% block pre_form_content %}
{% if fully_allocated %} {% if not build.has_tracked_bom_items %}
<div class='alert alert-block alert-info'> {% elif fully_allocated %}
<h4>{% trans "Stock allocation is complete" %}</h4> <div class='alert alert-block alert-success'>
{% trans "Stock allocation is complete for this output" %}
</div> </div>
{% else %} {% else %}
<div class='alert alert-block alert-danger'> <div class='alert alert-block alert-danger'>
@ -16,7 +17,7 @@
<div class='panel panel-default'> <div class='panel panel-default'>
<div class='panel panel-heading'> <div class='panel panel-heading'>
<a data-toggle='collapse' href='#collapse-unallocated'> <a data-toggle='collapse' href='#collapse-unallocated'>
{{ unallocated_parts|length }} {% trans "parts have not been fully allocated" %} {{ unallocated_parts|length }} {% trans "tracked parts have not been fully allocated" %}
</a> </a>
</div> </div>
<div class='panel-collapse collapse' id='collapse-unallocated'> <div class='panel-collapse collapse' id='collapse-unallocated'>
@ -41,7 +42,11 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% include "hover_image.html" with image=build.part.image hover=True %} {% include "hover_image.html" with image=build.part.image hover=True %}
{% if output.serialized %}
{{ output.part.full_name }} - {% trans "Serial Number" %} {{ output.serial }}
{% else %}
{% decimal output.quantity %} x {{ output.part.full_name }} {% decimal output.quantity %} x {{ output.part.full_name }}
{% endif %}
</div> </div>
</div> </div>

View File

@ -8,15 +8,13 @@
</p> </p>
{% if output %} {% if output %}
<p> <p>
{% trans "The allocated stock will be installed into the following build output:" %} {% blocktrans %}The allocated stock will be installed into the following build output:<br><i>{{output}}</i>{% endblocktrans %}
<br>
<i>{{ output }}</i>
</p> </p>
{% endif %} {% endif %}
</div> </div>
{% if no_stock %} {% if no_stock %}
<div class='alert alert-danger alert-block' role='alert'> <div class='alert alert-danger alert-block' role='alert'>
{% trans "No stock available for" %} {{ part }} {% blocktrans %}No stock available for {{part}}{% endblocktrans %}
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -5,7 +5,7 @@
{% load i18n %} {% load i18n %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Build Orders" %} {% inventree_title %} | {% trans "Build Orders" %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -17,17 +17,11 @@
</li> </li>
{% if build.active %} {% if build.active %}
<li class='list-group-item {% if tab == "parts" %}active{% endif %}' title='{% trans "Required Parts" %}'>
<a href='{% url "build-parts" build.id %}'> <li class='list-group-item {% if tab == "allocate" %}active{% endif %}' title='{% trans "Allocate Stock" %}'>
<span class='fas fa-shapes'></span>
{% trans "Required Parts" %}
</a>
</li>
<li class='list-group-item {% if tab == "allocate" %}active{% endif %}' title='{% trans "In Progress" %}'>
<a href='{% url "build-allocate" build.id %}'> <a href='{% url "build-allocate" build.id %}'>
<span class='fas fa-tools'></span> <span class='fas fa-tools'></span>
{% trans "In Progress" %} {% trans "Allocate Stock" %}
</a> </a>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,30 +0,0 @@
{% extends "build/build_base.html" %}
{% load static %}
{% load i18n %}
{% load status_codes %}
{% block menubar %}
{% include "build/navbar.html" with tab='parts' %}
{% endblock %}
{% block heading %}
{% trans "Required Parts" %}
{% endblock %}
{% block details %}
<table class='table table-striped table-condensed' id='parts-table'></table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
loadBuildPartsTable($('#parts-table'), {
part: {{ build.part.pk }},
build: {{ build.pk }},
build_quantity: {{ build.quantity }},
build_remaining: {{ build.remaining }},
});
{% endblock %}

View File

@ -19,6 +19,18 @@ class BuildTest(TestCase):
def setUp(self): def setUp(self):
""" """
Initialize data to use for these tests. Initialize data to use for these tests.
The base Part 'assembly' has a BOM consisting of three parts:
- 5 x sub_part_1
- 3 x sub_part_2
- 2 x sub_part_3 (trackable)
We will build 10x 'assembly' parts, in two build outputs:
- 3 x output_1
- 7 x output_2
""" """
# Create a base "Part" # Create a base "Part"
@ -41,17 +53,31 @@ class BuildTest(TestCase):
component=True component=True
) )
self.sub_part_3 = Part.objects.create(
name="Widget C",
description="A widget",
component=True,
trackable=True
)
# Create BOM item links for the parts # Create BOM item links for the parts
BomItem.objects.create( BomItem.objects.create(
part=self.assembly, part=self.assembly,
sub_part=self.sub_part_1, sub_part=self.sub_part_1,
quantity=10 quantity=5
) )
BomItem.objects.create( BomItem.objects.create(
part=self.assembly, part=self.assembly,
sub_part=self.sub_part_2, sub_part=self.sub_part_2,
quantity=25 quantity=3
)
# sub_part_3 is trackable!
BomItem.objects.create(
part=self.assembly,
sub_part=self.sub_part_3,
quantity=2
) )
# Create a "Build" object to make 10x objects # Create a "Build" object to make 10x objects
@ -64,14 +90,14 @@ class BuildTest(TestCase):
# Create some build output (StockItem) objects # Create some build output (StockItem) objects
self.output_1 = StockItem.objects.create( self.output_1 = StockItem.objects.create(
part=self.assembly, part=self.assembly,
quantity=5, quantity=3,
is_building=True, is_building=True,
build=self.build build=self.build
) )
self.output_2 = StockItem.objects.create( self.output_2 = StockItem.objects.create(
part=self.assembly, part=self.assembly,
quantity=5, quantity=7,
is_building=True, is_building=True,
build=self.build, build=self.build,
) )
@ -82,10 +108,12 @@ class BuildTest(TestCase):
self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5000) self.stock_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=5000)
self.stock_3_1 = StockItem.objects.create(part=self.sub_part_3, quantity=1000)
def test_init(self): def test_init(self):
# Perform some basic tests before we start the ball rolling # Perform some basic tests before we start the ball rolling
self.assertEqual(StockItem.objects.count(), 5) self.assertEqual(StockItem.objects.count(), 6)
# Build is PENDING # Build is PENDING
self.assertEqual(self.build.status, status.BuildStatus.PENDING) self.assertEqual(self.build.status, status.BuildStatus.PENDING)
@ -100,10 +128,10 @@ class BuildTest(TestCase):
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1)) self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2)) self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_1), 50) self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_1), 15)
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_2), 50) self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_2), 35)
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_1), 125) self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_1), 9)
self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_2), 125) self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_2), 21)
self.assertFalse(self.build.is_complete) self.assertFalse(self.build.is_complete)
@ -144,84 +172,113 @@ class BuildTest(TestCase):
quantity=99 quantity=99
) )
def allocate_stock(self, q11, q12, q21, output): def allocate_stock(self, output, allocations):
# Assign stock to this build """
Allocate stock to this build, against a particular output
if q11 > 0: Args:
output - StockItem object (or None)
allocations - Map of {StockItem: quantity}
"""
for item, quantity in allocations.items():
BuildItem.objects.create( BuildItem.objects.create(
build=self.build, build=self.build,
stock_item=self.stock_1_1, stock_item=item,
quantity=q11, quantity=quantity,
install_into=output install_into=output
) )
if q12 > 0:
BuildItem.objects.create(
build=self.build,
stock_item=self.stock_1_2,
quantity=q12,
install_into=output
)
if q21 > 0:
BuildItem.objects.create(
build=self.build,
stock_item=self.stock_2_1,
quantity=q21,
install_into=output,
)
# Attempt to create another identical BuildItem
b = BuildItem(
build=self.build,
stock_item=self.stock_2_1,
quantity=q21
)
with self.assertRaises(ValidationError):
b.clean()
def test_partial_allocation(self): def test_partial_allocation(self):
""" """
Partially allocate against output 1 Test partial allocation of stock
""" """
self.allocate_stock(50, 50, 200, self.output_1) # Fully allocate tracked stock against build output 1
self.allocate_stock(
self.output_1,
{
self.stock_3_1: 6,
}
)
self.assertTrue(self.build.isFullyAllocated(self.output_1)) self.assertTrue(self.build.isFullyAllocated(self.output_1))
# Partially allocate tracked stock against build output 2
self.allocate_stock(
self.output_2,
{
self.stock_3_1: 1,
}
)
self.assertFalse(self.build.isFullyAllocated(self.output_2)) self.assertFalse(self.build.isFullyAllocated(self.output_2))
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1))
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, self.output_1))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_2))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
# Check that the part has been allocated # Partially allocate untracked stock against build
self.assertEqual(self.build.allocatedQuantity(self.sub_part_1, self.output_1), 100) self.allocate_stock(
None,
{
self.stock_1_1: 1,
self.stock_2_1: 1
}
)
self.build.unallocateStock(output=self.output_1) self.assertFalse(self.build.isFullyAllocated(None, verbose=True))
self.assertEqual(BuildItem.objects.count(), 0)
# Check that the part has been unallocated unallocated = self.build.unallocatedParts(None)
self.assertEqual(self.build.allocatedQuantity(self.sub_part_1, self.output_1), 0)
self.assertEqual(len(unallocated), 2)
self.allocate_stock(
None,
{
self.stock_1_2: 100,
}
)
self.assertFalse(self.build.isFullyAllocated(None, verbose=True))
unallocated = self.build.unallocatedParts(None)
self.assertEqual(len(unallocated), 1)
self.build.unallocateUntracked()
unallocated = self.build.unallocatedParts(None)
self.assertEqual(len(unallocated), 2)
self.assertFalse(self.build.areUntrackedPartsFullyAllocated())
# Now we "fully" allocate the untracked untracked items
self.allocate_stock(
None,
{
self.stock_1_1: 50,
self.stock_2_1: 50,
}
)
self.assertTrue(self.build.areUntrackedPartsFullyAllocated())
def test_auto_allocate(self): def test_auto_allocate(self):
""" """
Test auto-allocation functionality against the build outputs Test auto-allocation functionality against the build outputs.
Note: auto-allocations only work for un-tracked stock!
""" """
allocations = self.build.getAutoAllocations(self.output_1) allocations = self.build.getAutoAllocations()
self.assertEqual(len(allocations), 1) self.assertEqual(len(allocations), 1)
self.build.autoAllocate(self.output_1) self.build.autoAllocate()
self.assertEqual(BuildItem.objects.count(), 1) self.assertEqual(BuildItem.objects.count(), 1)
# Check that one part has been fully allocated to the build output # Check that one un-tracked part has been fully allocated to the build
self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, self.output_1)) self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, None))
# But, the *other* build output has not been allocated against self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, None))
self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2))
def test_cancel(self): def test_cancel(self):
""" """
@ -243,9 +300,33 @@ class BuildTest(TestCase):
Test completion of a build output Test completion of a build output
""" """
self.allocate_stock(50, 50, 250, self.output_1) # Allocate non-tracked parts
self.allocate_stock(50, 50, 250, self.output_2) self.allocate_stock(
None,
{
self.stock_1_1: self.stock_1_1.quantity, # Allocate *all* stock from this item
self.stock_1_2: 10,
self.stock_2_1: 30
}
)
# Allocate tracked parts to output_1
self.allocate_stock(
self.output_1,
{
self.stock_3_1: 6
}
)
# Allocate tracked parts to output_2
self.allocate_stock(
self.output_2,
{
self.stock_3_1: 14
}
)
self.assertTrue(self.build.isFullyAllocated(None, verbose=True))
self.assertTrue(self.build.isFullyAllocated(self.output_1)) self.assertTrue(self.build.isFullyAllocated(self.output_1))
self.assertTrue(self.build.isFullyAllocated(self.output_2)) self.assertTrue(self.build.isFullyAllocated(self.output_2))
@ -265,19 +346,16 @@ class BuildTest(TestCase):
self.assertEqual(BuildItem.objects.count(), 0) self.assertEqual(BuildItem.objects.count(), 0)
# New stock items should have been created! # New stock items should have been created!
self.assertEqual(StockItem.objects.count(), 4) self.assertEqual(StockItem.objects.count(), 7)
a = StockItem.objects.get(pk=self.stock_1_1.pk)
# This stock item has been depleted! # This stock item has been depleted!
with self.assertRaises(StockItem.DoesNotExist): with self.assertRaises(StockItem.DoesNotExist):
StockItem.objects.get(pk=self.stock_1_2.pk) StockItem.objects.get(pk=self.stock_1_1.pk)
c = StockItem.objects.get(pk=self.stock_2_1.pk) # This stock item has *not* been depleted
x = StockItem.objects.get(pk=self.stock_2_1.pk)
# Stock should have been subtracted from the original items self.assertEqual(x.quantity, 4970)
self.assertEqual(a.quantity, 900)
self.assertEqual(c.quantity, 4500)
# And 10 new stock items created for the build output # And 10 new stock items created for the build output
outputs = StockItem.objects.filter(build=self.build) outputs = StockItem.objects.filter(build=self.build)

View File

@ -15,7 +15,7 @@ from datetime import datetime, timedelta
from .models import Build from .models import Build
from stock.models import StockItem from stock.models import StockItem
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus, StockStatus
class BuildTestSimple(TestCase): class BuildTestSimple(TestCase):
@ -335,6 +335,7 @@ class TestBuildViews(TestCase):
'confirm_incomplete': 1, 'confirm_incomplete': 1,
'location': 1, 'location': 1,
'output': self.output.pk, 'output': self.output.pk,
'stock_status': StockStatus.DAMAGED
}, },
HTTP_X_REQUESTED_WITH='XMLHttpRequest' HTTP_X_REQUESTED_WITH='XMLHttpRequest'
) )
@ -342,6 +343,7 @@ class TestBuildViews(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = json.loads(response.content) data = json.loads(response.content)
self.assertTrue(data['form_valid']) self.assertTrue(data['form_valid'])
# Now the build should be able to be completed # Now the build should be able to be completed

View File

@ -21,7 +21,6 @@ build_detail_urls = [
url(r'^notes/', views.BuildNotes.as_view(), name='build-notes'), url(r'^notes/', views.BuildNotes.as_view(), name='build-notes'),
url(r'^children/', views.BuildDetail.as_view(template_name='build/build_children.html'), name='build-children'), url(r'^children/', views.BuildDetail.as_view(template_name='build/build_children.html'), name='build-children'),
url(r'^parts/', views.BuildDetail.as_view(template_name='build/parts.html'), name='build-parts'),
url(r'^attachments/', views.BuildDetail.as_view(template_name='build/attachments.html'), name='build-attachments'), url(r'^attachments/', views.BuildDetail.as_view(template_name='build/attachments.html'), name='build-attachments'),
url(r'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'), url(r'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'),

View File

@ -18,8 +18,8 @@ from stock.models import StockLocation, StockItem
from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
from InvenTree.views import InvenTreeRoleMixin from InvenTree.views import InvenTreeRoleMixin
from InvenTree.helpers import str2bool, extract_serial_numbers, normalize from InvenTree.helpers import str2bool, extract_serial_numbers, normalize, isNull
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus, StockStatus
class BuildIndex(InvenTreeRoleMixin, ListView): class BuildIndex(InvenTreeRoleMixin, ListView):
@ -98,16 +98,6 @@ class BuildAutoAllocate(AjaxUpdateView):
initials = super().get_initial() initials = super().get_initial()
# Pointing to a particular build output?
output = self.get_param('output')
if output:
try:
output = StockItem.objects.get(pk=output)
initials['output'] = output
except (ValueError, StockItem.DoesNotExist):
pass
return initials return initials
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
@ -119,18 +109,7 @@ class BuildAutoAllocate(AjaxUpdateView):
build = self.get_object() build = self.get_object()
form = self.get_form() context['allocations'] = build.getAutoAllocations()
output_id = form['output'].value()
try:
output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist):
output = None
if output:
context['output'] = output
context['allocations'] = build.getAutoAllocations(output)
context['build'] = build context['build'] = build
@ -140,18 +119,11 @@ class BuildAutoAllocate(AjaxUpdateView):
form = super().get_form() form = super().get_form()
if form['output'].value():
# Hide the 'output' field
form.fields['output'].widget = HiddenInput()
return form return form
def validate(self, build, form, **kwargs): def validate(self, build, form, **kwargs):
output = form.cleaned_data.get('output', None) pass
if not output:
form.add_error(None, _('Build output must be specified'))
def save(self, build, form, **kwargs): def save(self, build, form, **kwargs):
""" """
@ -159,9 +131,7 @@ class BuildAutoAllocate(AjaxUpdateView):
perform auto-allocations perform auto-allocations
""" """
output = form.cleaned_data.get('output', None) build.autoAllocate()
build.autoAllocate(output)
def get_data(self): def get_data(self):
return { return {
@ -242,7 +212,7 @@ class BuildOutputCreate(AjaxUpdateView):
# Calculate the required quantity # Calculate the required quantity
quantity = max(0, build.remaining - build.incomplete_count) quantity = max(0, build.remaining - build.incomplete_count)
initials['quantity'] = quantity initials['output_quantity'] = quantity
return initials return initials
@ -365,10 +335,16 @@ class BuildUnallocate(AjaxUpdateView):
output_id = request.POST.get('output_id', None) output_id = request.POST.get('output_id', None)
try: if output_id:
output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist): # If a "null" output is provided, we are trying to unallocate "untracked" stock
output = None if isNull(output_id):
output = None
else:
try:
output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist):
output = None
part_id = request.POST.get('part_id', None) part_id = request.POST.get('part_id', None)
@ -383,9 +359,19 @@ class BuildUnallocate(AjaxUpdateView):
form.add_error('confirm', _('Confirm unallocation of build stock')) form.add_error('confirm', _('Confirm unallocation of build stock'))
form.add_error(None, _('Check the confirmation box')) form.add_error(None, _('Check the confirmation box'))
else: else:
build.unallocateStock(output=output, part=part)
valid = True valid = True
# Unallocate the entire build
if not output_id:
build.unallocateAll()
# Unallocate a single output
elif output:
build.unallocateOutput(output, part=part)
# Unallocate "untracked" parts
else:
build.unallocateUntracked(part=part)
data = { data = {
'form_valid': valid, 'form_valid': valid,
} }
@ -410,8 +396,8 @@ class BuildComplete(AjaxUpdateView):
def validate(self, build, form, **kwargs): def validate(self, build, form, **kwargs):
if not build.can_complete: if build.incomplete_count > 0:
form.add_error(None, _('Build order cannot be completed')) form.add_error(None, _('Build order cannot be completed - incomplete outputs remain'))
def save(self, build, form, **kwargs): def save(self, build, form, **kwargs):
""" """
@ -431,7 +417,7 @@ class BuildOutputComplete(AjaxUpdateView):
View to mark a particular build output as Complete. View to mark a particular build output as Complete.
- Notifies the user of which parts will be removed from stock. - Notifies the user of which parts will be removed from stock.
- Removes allocated items from stock - Assignes (tracked) allocated items from stock to the build output
- Deletes pending BuildItem objects - Deletes pending BuildItem objects
""" """
@ -463,11 +449,25 @@ class BuildOutputComplete(AjaxUpdateView):
return form return form
def validate(self, build, form, **kwargs): def validate(self, build, form, **kwargs):
"""
Custom validation steps for the BuildOutputComplete" form
"""
data = form.cleaned_data data = form.cleaned_data
output = data.get('output', None) output = data.get('output', None)
stock_status = data.get('stock_status', StockStatus.OK)
# Any "invalid" stock status defaults to OK
try:
stock_status = int(stock_status)
except (ValueError):
stock_status = StockStatus.OK
if int(stock_status) not in StockStatus.keys():
form.add_error('stock_status', _('Invalid stock status value selected'))
if output: if output:
quantity = data.get('quantity', None) quantity = data.get('quantity', None)
@ -559,12 +559,20 @@ class BuildOutputComplete(AjaxUpdateView):
location = data.get('location', None) location = data.get('location', None)
output = data.get('output', None) output = data.get('output', None)
stock_status = data.get('stock_status', StockStatus.OK)
# Any "invalid" stock status defaults to OK
try:
stock_status = int(stock_status)
except (ValueError):
stock_status = StockStatus.OK
# Complete the build output # Complete the build output
build.completeBuildOutput( build.completeBuildOutput(
output, output,
self.request.user, self.request.user,
location=location, location=location,
status=stock_status,
) )
def get_data(self): def get_data(self):
@ -632,10 +640,12 @@ class BuildAllocate(InvenTreeRoleMixin, DetailView):
build = self.get_object() build = self.get_object()
part = build.part part = build.part
bom_items = part.bom_items bom_items = build.bom_items
context['part'] = part context['part'] = part
context['bom_items'] = bom_items context['bom_items'] = bom_items
context['has_tracked_bom_items'] = build.has_tracked_bom_items()
context['has_untracked_bom_items'] = build.has_untracked_bom_items()
context['BuildStatus'] = BuildStatus context['BuildStatus'] = BuildStatus
context['bom_price'] = build.part.get_price_info(build.quantity, buy=False) context['bom_price'] = build.part.get_price_info(build.quantity, buy=False)

View File

@ -58,6 +58,13 @@ class InvenTreeSetting(models.Model):
'description': _('String descriptor for the server instance'), 'description': _('String descriptor for the server instance'),
}, },
'INVENTREE_INSTANCE_TITLE': {
'name': _('Use instance name'),
'description': _('Use the instance name in the title-bar'),
'validator': bool,
'default': False,
},
'INVENTREE_COMPANY_NAME': { 'INVENTREE_COMPANY_NAME': {
'name': _('Company name'), 'name': _('Company name'),
'description': _('Internal company name'), 'description': _('Internal company name'),

View File

@ -15,9 +15,11 @@ from django.db.models import Q
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from .models import Company from .models import Company
from .models import ManufacturerPart
from .models import SupplierPart, SupplierPriceBreak from .models import SupplierPart, SupplierPriceBreak
from .serializers import CompanySerializer from .serializers import CompanySerializer
from .serializers import ManufacturerPartSerializer
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
@ -80,8 +82,105 @@ class CompanyDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = CompanySerializer.annotate_queryset(queryset) queryset = CompanySerializer.annotate_queryset(queryset)
return queryset return queryset
class ManufacturerPartList(generics.ListCreateAPIView):
""" API endpoint for list view of ManufacturerPart object
- GET: Return list of ManufacturerPart objects
- POST: Create a new ManufacturerPart object
"""
queryset = ManufacturerPart.objects.all().prefetch_related(
'part',
'manufacturer',
'supplier_parts',
)
serializer_class = ManufacturerPartSerializer
def get_serializer(self, *args, **kwargs):
# Do we wish to include extra detail?
try:
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None))
except AttributeError:
pass
try:
kwargs['manufacturer_detail'] = str2bool(self.request.query_params.get('manufacturer_detail', None))
except AttributeError:
pass
try:
kwargs['pretty'] = str2bool(self.request.query_params.get('pretty', None))
except AttributeError:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def filter_queryset(self, queryset):
"""
Custom filtering for the queryset.
"""
queryset = super().filter_queryset(queryset)
params = self.request.query_params
# Filter by manufacturer
manufacturer = params.get('company', None)
if manufacturer is not None:
queryset = queryset.filter(manufacturer=manufacturer)
# Filter by parent part?
part = params.get('part', None)
if part is not None:
queryset = queryset.filter(part=part)
# Filter by 'active' status of the part?
active = params.get('active', None)
if active is not None:
active = str2bool(active)
queryset = queryset.filter(part__active=active)
return queryset
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filter_fields = [
]
search_fields = [
'manufacturer__name',
'description',
'MPN',
'part__name',
'part__description',
]
class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of ManufacturerPart object
- GET: Retrieve detail view
- PATCH: Update object
- DELETE: Delete object
"""
queryset = ManufacturerPart.objects.all()
serializer_class = ManufacturerPartSerializer
class SupplierPartList(generics.ListCreateAPIView): class SupplierPartList(generics.ListCreateAPIView):
""" API endpoint for list view of SupplierPart object """ API endpoint for list view of SupplierPart object
@ -92,7 +191,7 @@ class SupplierPartList(generics.ListCreateAPIView):
queryset = SupplierPart.objects.all().prefetch_related( queryset = SupplierPart.objects.all().prefetch_related(
'part', 'part',
'supplier', 'supplier',
'manufacturer' 'manufacturer_part__manufacturer',
) )
def get_queryset(self): def get_queryset(self):
@ -114,7 +213,7 @@ class SupplierPartList(generics.ListCreateAPIView):
manufacturer = params.get('manufacturer', None) manufacturer = params.get('manufacturer', None)
if manufacturer is not None: if manufacturer is not None:
queryset = queryset.filter(manufacturer=manufacturer) queryset = queryset.filter(manufacturer_part__manufacturer=manufacturer)
# Filter by supplier # Filter by supplier
supplier = params.get('supplier', None) supplier = params.get('supplier', None)
@ -126,7 +225,7 @@ class SupplierPartList(generics.ListCreateAPIView):
company = params.get('company', None) company = params.get('company', None)
if company is not None: if company is not None:
queryset = queryset.filter(Q(manufacturer=company) | Q(supplier=company)) queryset = queryset.filter(Q(manufacturer_part__manufacturer=company) | Q(supplier=company))
# Filter by parent part? # Filter by parent part?
part = params.get('part', None) part = params.get('part', None)
@ -134,6 +233,12 @@ class SupplierPartList(generics.ListCreateAPIView):
if part is not None: if part is not None:
queryset = queryset.filter(part=part) queryset = queryset.filter(part=part)
# Filter by manufacturer part?
manufacturer_part = params.get('manufacturer_part', None)
if manufacturer_part is not None:
queryset = queryset.filter(manufacturer_part=manufacturer_part)
# Filter by 'active' status of the part? # Filter by 'active' status of the part?
active = params.get('active', None) active = params.get('active', None)
@ -184,9 +289,9 @@ class SupplierPartList(generics.ListCreateAPIView):
search_fields = [ search_fields = [
'SKU', 'SKU',
'supplier__name', 'supplier__name',
'manufacturer__name', 'manufacturer_part__manufacturer__name',
'description', 'description',
'MPN', 'manufacturer_part__MPN',
'part__name', 'part__name',
'part__description', 'part__description',
] ]
@ -197,7 +302,7 @@ class SupplierPartDetail(generics.RetrieveUpdateDestroyAPIView):
- GET: Retrieve detail view - GET: Retrieve detail view
- PATCH: Update object - PATCH: Update object
- DELETE: Delete objec - DELETE: Delete object
""" """
queryset = SupplierPart.objects.all() queryset = SupplierPart.objects.all()
@ -226,6 +331,15 @@ class SupplierPriceBreakList(generics.ListCreateAPIView):
] ]
manufacturer_part_api_urls = [
url(r'^(?P<pk>\d+)/?', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'),
# Catch anything else
url(r'^.*$', ManufacturerPartList.as_view(), name='api-manufacturer-part-list'),
]
supplier_part_api_urls = [ supplier_part_api_urls = [
url(r'^(?P<pk>\d+)/?', SupplierPartDetail.as_view(), name='api-supplier-part-detail'), url(r'^(?P<pk>\d+)/?', SupplierPartDetail.as_view(), name='api-supplier-part-detail'),
@ -236,7 +350,8 @@ supplier_part_api_urls = [
company_api_urls = [ company_api_urls = [
url(r'^part/manufacturer/', include(manufacturer_part_api_urls)),
url(r'^part/', include(supplier_part_api_urls)), url(r'^part/', include(supplier_part_api_urls)),
url(r'^price-break/', SupplierPriceBreakList.as_view(), name='api-part-supplier-price'), url(r'^price-break/', SupplierPriceBreakList.as_view(), name='api-part-supplier-price'),

View File

@ -31,3 +31,17 @@
name: Another customer! name: Another customer!
description: Yet another company description: Yet another company
is_customer: True is_customer: True
- model: company.company
pk: 6
fields:
name: A manufacturer
description: A company that makes parts!
is_manufacturer: True
- model: company.company
pk: 7
fields:
name: Another manufacturer
description: They build things and sell it to us
is_manufacturer: True

View File

@ -0,0 +1,39 @@
# Manufacturer Parts
- model: company.manufacturerpart
pk: 1
fields:
part: 5
manufacturer: 6
MPN: 'MPN123'
- model: company.manufacturerpart
pk: 2
fields:
part: 3
manufacturer: 7
MPN: 'MPN456'
- model: company.manufacturerpart
pk: 3
fields:
part: 5
manufacturer: 7
MPN: 'MPN789'
# Supplier parts linked to Manufacturer parts
- model: company.supplierpart
pk: 10
fields:
part: 3
manufacturer_part: 2
supplier: 2
SKU: 'MPN456-APPEL'
- model: company.supplierpart
pk: 11
fields:
part: 3
manufacturer_part: 2
supplier: 3
SKU: 'MPN456-ZERG'

View File

@ -17,6 +17,7 @@ from djmoney.forms.fields import MoneyField
import common.settings import common.settings
from .models import Company from .models import Company
from .models import ManufacturerPart
from .models import SupplierPart from .models import SupplierPart
from .models import SupplierPriceBreak from .models import SupplierPriceBreak
@ -85,12 +86,30 @@ class CompanyImageDownloadForm(HelperForm):
] ]
class EditManufacturerPartForm(HelperForm):
""" Form for editing a ManufacturerPart object """
field_prefix = {
'link': 'fa-link',
'MPN': 'fa-hashtag',
}
class Meta:
model = ManufacturerPart
fields = [
'part',
'manufacturer',
'MPN',
'description',
'link',
]
class EditSupplierPartForm(HelperForm): class EditSupplierPartForm(HelperForm):
""" Form for editing a SupplierPart object """ """ Form for editing a SupplierPart object """
field_prefix = { field_prefix = {
'link': 'fa-link', 'link': 'fa-link',
'MPN': 'fa-hashtag',
'SKU': 'fa-hashtag', 'SKU': 'fa-hashtag',
'note': 'fa-pencil-alt', 'note': 'fa-pencil-alt',
} }
@ -104,15 +123,28 @@ class EditSupplierPartForm(HelperForm):
required=False, required=False,
) )
manufacturer = django.forms.ChoiceField(
required=False,
help_text=_('Select manufacturer'),
choices=[],
)
MPN = django.forms.CharField(
required=False,
help_text=_('Manufacturer Part Number'),
max_length=100,
label=_('MPN'),
)
class Meta: class Meta:
model = SupplierPart model = SupplierPart
fields = [ fields = [
'part', 'part',
'supplier', 'supplier',
'SKU', 'SKU',
'description',
'manufacturer', 'manufacturer',
'MPN', 'MPN',
'description',
'link', 'link',
'note', 'note',
'single_pricing', 'single_pricing',
@ -121,6 +153,19 @@ class EditSupplierPartForm(HelperForm):
'packaging', 'packaging',
] ]
def get_manufacturer_choices(self):
""" Returns tuples for all manufacturers """
empty_choice = [('', '----------')]
manufacturers = [(manufacturer.id, manufacturer.name) for manufacturer in Company.objects.filter(is_manufacturer=True)]
return empty_choice + manufacturers
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['manufacturer'].choices = self.get_manufacturer_choices()
class EditPriceBreakForm(HelperForm): class EditPriceBreakForm(HelperForm):
""" Form for creating / editing a supplier price break """ """ Form for creating / editing a supplier price break """

View File

@ -0,0 +1,27 @@
import InvenTree.fields
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0033_auto_20210410_1528'),
]
operations = [
migrations.CreateModel(
name='ManufacturerPart',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('MPN', models.CharField(help_text='Manufacturer Part Number', max_length=100, null=True, verbose_name='MPN')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='URL for external manufacturer part link', null=True, verbose_name='Link')),
('description', models.CharField(blank=True, help_text='Manufacturer part description', max_length=250, null=True, verbose_name='Description')),
('manufacturer', models.ForeignKey(help_text='Select manufacturer', limit_choices_to={'is_manufacturer': True}, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='manufactured_parts', to='company.Company', verbose_name='Manufacturer')),
('part', models.ForeignKey(help_text='Select part', limit_choices_to={'purchaseable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='manufacturer_parts', to='part.Part', verbose_name='Base Part')),
],
options={
'unique_together': {('part', 'manufacturer', 'MPN')},
},
),
]

View File

@ -0,0 +1,18 @@
import InvenTree.fields
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0034_manufacturerpart'),
]
operations = [
migrations.AddField(
model_name='supplierpart',
name='manufacturer_part',
field=models.ForeignKey(blank=True, help_text='Select manufacturer part', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='supplier_parts', to='company.ManufacturerPart', verbose_name='Manufacturer Part'),
),
]

View File

@ -0,0 +1,110 @@
import InvenTree.fields
from django.db import migrations, models, transaction
import django.db.models.deletion
from django.db.utils import IntegrityError
def supplierpart_make_manufacturer_parts(apps, schema_editor):
Part = apps.get_model('part', 'Part')
ManufacturerPart = apps.get_model('company', 'ManufacturerPart')
SupplierPart = apps.get_model('company', 'SupplierPart')
supplier_parts = SupplierPart.objects.all()
if supplier_parts:
print(f'\nCreating ManufacturerPart Objects\n{"-"*10}')
for supplier_part in supplier_parts:
print(f'{supplier_part.supplier.name[:15].ljust(15)} | {supplier_part.SKU[:15].ljust(15)}\t', end='')
if supplier_part.manufacturer_part:
print(f'[ERROR: MANUFACTURER PART ALREADY EXISTS]')
continue
part = supplier_part.part
if not part:
print(f'[ERROR: SUPPLIER PART IS NOT CONNECTED TO PART]')
continue
manufacturer = supplier_part.manufacturer
MPN = supplier_part.MPN
link = supplier_part.link
description = supplier_part.description
if manufacturer or MPN:
print(f' | {part.name[:15].ljust(15)}', end='')
try:
print(f' | {manufacturer.name[:15].ljust(15)}', end='')
except AttributeError:
print(f' | {"EMPTY MANUF".ljust(15)}', end='')
try:
print(f' | {MPN[:15].ljust(15)}', end='')
except TypeError:
print(f' | {"EMPTY MPN".ljust(15)}', end='')
print('\t', end='')
# Create ManufacturerPart
manufacturer_part = ManufacturerPart(part=part, manufacturer=manufacturer, MPN=MPN, description=description, link=link)
created = False
try:
with transaction.atomic():
manufacturer_part.save()
created = True
except IntegrityError:
manufacturer_part = ManufacturerPart.objects.get(part=part, manufacturer=manufacturer, MPN=MPN)
# Link it to SupplierPart
supplier_part.manufacturer_part = manufacturer_part
supplier_part.save()
if created:
print(f'[SUCCESS: MANUFACTURER PART CREATED]')
else:
print(f'[IGNORED: MANUFACTURER PART ALREADY EXISTS]')
else:
print(f'[IGNORED: MISSING MANUFACTURER DATA]')
print(f'{"-"*10}\nDone\n')
def supplierpart_populate_manufacturer_info(apps, schema_editor):
Part = apps.get_model('part', 'Part')
ManufacturerPart = apps.get_model('company', 'ManufacturerPart')
SupplierPart = apps.get_model('company', 'SupplierPart')
supplier_parts = SupplierPart.objects.all()
if supplier_parts:
print(f'\nSupplierPart: Populating Manufacturer Information\n{"-"*10}')
for supplier_part in supplier_parts:
print(f'{supplier_part.supplier.name[:15].ljust(15)} | {supplier_part.SKU[:15].ljust(15)}\t', end='')
manufacturer_part = supplier_part.manufacturer_part
if manufacturer_part:
if manufacturer_part.manufacturer:
supplier_part.manufacturer = manufacturer_part.manufacturer
if manufacturer_part.MPN:
supplier_part.MPN = manufacturer_part.MPN
supplier_part.save()
print(f'[SUCCESS: UPDATED MANUFACTURER INFO]')
else:
print(f'[IGNORED: NO MANUFACTURER PART]')
print(f'{"-"*10}\nDone\n')
class Migration(migrations.Migration):
dependencies = [
('company', '0035_supplierpart_update_1'),
]
operations = [
# Make new ManufacturerPart with SupplierPart "manufacturer" and "MPN"
# fields, then link it to the new SupplierPart "manufacturer_part" field
migrations.RunPython(supplierpart_make_manufacturer_parts, reverse_code=supplierpart_populate_manufacturer_info),
]

View File

@ -0,0 +1,21 @@
import InvenTree.fields
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0036_supplierpart_update_2'),
]
operations = [
migrations.RemoveField(
model_name='supplierpart',
name='MPN',
),
migrations.RemoveField(
model_name='supplierpart',
name='manufacturer',
),
]

View File

@ -11,7 +11,9 @@ import math
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.utils import IntegrityError
from django.db.models import Sum, Q, UniqueConstraint from django.db.models import Sum, Q, UniqueConstraint
from django.apps import apps from django.apps import apps
@ -208,7 +210,7 @@ class Company(models.Model):
@property @property
def parts(self): def parts(self):
""" Return SupplierPart objects which are supplied or manufactured by this company """ """ Return SupplierPart objects which are supplied or manufactured by this company """
return SupplierPart.objects.filter(Q(supplier=self.id) | Q(manufacturer=self.id)) return SupplierPart.objects.filter(Q(supplier=self.id) | Q(manufacturer_part__manufacturer=self.id))
@property @property
def part_count(self): def part_count(self):
@ -223,7 +225,7 @@ class Company(models.Model):
def stock_items(self): def stock_items(self):
""" Return a list of all stock items supplied or manufactured by this company """ """ Return a list of all stock items supplied or manufactured by this company """
stock = apps.get_model('stock', 'StockItem') stock = apps.get_model('stock', 'StockItem')
return stock.objects.filter(Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer=self.id)).all() return stock.objects.filter(Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer_part__manufacturer=self.id)).all()
@property @property
def stock_count(self): def stock_count(self):
@ -284,19 +286,106 @@ class Contact(models.Model):
on_delete=models.CASCADE) on_delete=models.CASCADE)
class SupplierPart(models.Model): class ManufacturerPart(models.Model):
""" Represents a unique part as provided by a Supplier """ Represents a unique part as provided by a Manufacturer
Each SupplierPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is identified by a MPN (Manufacturer Part Number)
Each SupplierPart is also linked to a Part object. Each ManufacturerPart is also linked to a Part object.
A Part may be available from multiple suppliers A Part may be available from multiple manufacturers
Attributes: Attributes:
part: Link to the master Part part: Link to the master Part
manufacturer: Company that manufactures the ManufacturerPart
MPN: Manufacture part number
link: Link to external website for this manufacturer part
description: Descriptive notes field
"""
class Meta:
unique_together = ('part', 'manufacturer', 'MPN')
part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
related_name='manufacturer_parts',
verbose_name=_('Base Part'),
limit_choices_to={
'purchaseable': True,
},
help_text=_('Select part'),
)
manufacturer = models.ForeignKey(
Company,
on_delete=models.CASCADE,
null=True,
related_name='manufactured_parts',
limit_choices_to={
'is_manufacturer': True
},
verbose_name=_('Manufacturer'),
help_text=_('Select manufacturer'),
)
MPN = models.CharField(
null=True,
max_length=100,
verbose_name=_('MPN'),
help_text=_('Manufacturer Part Number')
)
link = InvenTreeURLField(
blank=True, null=True,
verbose_name=_('Link'),
help_text=_('URL for external manufacturer part link')
)
description = models.CharField(
max_length=250, blank=True, null=True,
verbose_name=_('Description'),
help_text=_('Manufacturer part description')
)
@classmethod
def create(cls, part, manufacturer, mpn, description, link=None):
""" Check if ManufacturerPart instance does not already exist
then create it
"""
manufacturer_part = None
try:
manufacturer_part = ManufacturerPart.objects.get(part=part, manufacturer=manufacturer, MPN=mpn)
except ManufacturerPart.DoesNotExist:
pass
if not manufacturer_part:
manufacturer_part = ManufacturerPart(part=part, manufacturer=manufacturer, MPN=mpn, description=description, link=link)
manufacturer_part.save()
return manufacturer_part
def __str__(self):
s = ''
if self.manufacturer:
s += f'{self.manufacturer.name}'
s += ' | '
s += f'{self.MPN}'
return s
class SupplierPart(models.Model):
""" Represents a unique part as provided by a Supplier
Each SupplierPart is identified by a SKU (Supplier Part Number)
Each SupplierPart is also linked to a Part or ManufacturerPart object.
A Part may be available from multiple suppliers
Attributes:
part: Link to the master Part (Obsolete)
source_item: The sourcing item linked to this SupplierPart instance
supplier: Company that supplies this SupplierPart object supplier: Company that supplies this SupplierPart object
SKU: Stock keeping unit (supplier part number) SKU: Stock keeping unit (supplier part number)
manufacturer: Company that manufactures the SupplierPart (leave blank if it is the sample as the Supplier!) link: Link to external website for this supplier part
MPN: Manufacture part number
link: Link to external website for this part
description: Descriptive notes field description: Descriptive notes field
note: Longer form note field note: Longer form note field
base_cost: Base charge added to order independent of quantity e.g. "Reeling Fee" base_cost: Base charge added to order independent of quantity e.g. "Reeling Fee"
@ -308,6 +397,57 @@ class SupplierPart(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('supplier-part-detail', kwargs={'pk': self.id}) return reverse('supplier-part-detail', kwargs={'pk': self.id})
def save(self, *args, **kwargs):
""" Overriding save method to process the linked ManufacturerPart
"""
if 'manufacturer' in kwargs:
manufacturer_id = kwargs.pop('manufacturer')
try:
manufacturer = Company.objects.get(pk=int(manufacturer_id))
except (ValueError, Company.DoesNotExist):
manufacturer = None
else:
manufacturer = None
if 'MPN' in kwargs:
MPN = kwargs.pop('MPN')
else:
MPN = None
if manufacturer or MPN:
if not self.manufacturer_part:
# Create ManufacturerPart
manufacturer_part = ManufacturerPart.create(part=self.part,
manufacturer=manufacturer,
mpn=MPN,
description=self.description)
self.manufacturer_part = manufacturer_part
else:
# Update ManufacturerPart (if ID exists)
try:
manufacturer_part_id = self.manufacturer_part.id
except AttributeError:
manufacturer_part_id = None
if manufacturer_part_id:
try:
(manufacturer_part, created) = ManufacturerPart.objects.update_or_create(part=self.part,
manufacturer=manufacturer,
MPN=MPN)
except IntegrityError:
manufacturer_part = None
raise ValidationError(f'ManufacturerPart linked to {self.part} from manufacturer {manufacturer.name}'
f'with part number {MPN} already exists!')
if manufacturer_part:
self.manufacturer_part = manufacturer_part
self.clean()
self.validate_unique()
super().save(*args, **kwargs)
class Meta: class Meta:
unique_together = ('part', 'supplier', 'SKU') unique_together = ('part', 'supplier', 'SKU')
@ -336,23 +476,12 @@ class SupplierPart(models.Model):
help_text=_('Supplier stock keeping unit') help_text=_('Supplier stock keeping unit')
) )
manufacturer = models.ForeignKey( manufacturer_part = models.ForeignKey(ManufacturerPart, on_delete=models.CASCADE,
Company, blank=True, null=True,
on_delete=models.SET_NULL, related_name='supplier_parts',
related_name='manufactured_parts', verbose_name=_('Manufacturer Part'),
limit_choices_to={ help_text=_('Select manufacturer part'),
'is_manufacturer': True )
},
verbose_name=_('Manufacturer'),
help_text=_('Select manufacturer'),
null=True, blank=True
)
MPN = models.CharField(
max_length=100, blank=True, null=True,
verbose_name=_('MPN'),
help_text=_('Manufacturer part number')
)
link = InvenTreeURLField( link = InvenTreeURLField(
blank=True, null=True, blank=True, null=True,
@ -389,10 +518,11 @@ class SupplierPart(models.Model):
items = [] items = []
if self.manufacturer: if self.manufacturer_part:
items.append(self.manufacturer.name) if self.manufacturer_part.manufacturer:
if self.MPN: items.append(self.manufacturer_part.manufacturer.name)
items.append(self.MPN) if self.manufacturer_part.MPN:
items.append(self.manufacturer_part.MPN)
return ' | '.join(items) return ' | '.join(items)

View File

@ -7,6 +7,7 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount from sql_util.utils import SubqueryCount
from .models import Company from .models import Company
from .models import ManufacturerPart
from .models import SupplierPart, SupplierPriceBreak from .models import SupplierPart, SupplierPriceBreak
from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeModelSerializer
@ -80,6 +81,49 @@ class CompanySerializer(InvenTreeModelSerializer):
] ]
class ManufacturerPartSerializer(InvenTreeModelSerializer):
""" Serializer for ManufacturerPart object """
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
manufacturer_detail = CompanyBriefSerializer(source='manufacturer', many=False, read_only=True)
pretty_name = serializers.CharField(read_only=True)
def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False)
manufacturer_detail = kwargs.pop('manufacturer_detail', False)
prettify = kwargs.pop('pretty', False)
super(ManufacturerPartSerializer, self).__init__(*args, **kwargs)
if part_detail is not True:
self.fields.pop('part_detail')
if manufacturer_detail is not True:
self.fields.pop('manufacturer_detail')
if prettify is not True:
self.fields.pop('pretty_name')
manufacturer = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_manufacturer=True))
class Meta:
model = ManufacturerPart
fields = [
'pk',
'part',
'part_detail',
'pretty_name',
'manufacturer',
'manufacturer_detail',
'description',
'MPN',
'link',
]
class SupplierPartSerializer(InvenTreeModelSerializer): class SupplierPartSerializer(InvenTreeModelSerializer):
""" Serializer for SupplierPart object """ """ Serializer for SupplierPart object """
@ -87,7 +131,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True) supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
manufacturer_detail = CompanyBriefSerializer(source='manufacturer', many=False, read_only=True) manufacturer_detail = CompanyBriefSerializer(source='manufacturer_part.manufacturer', many=False, read_only=True)
pretty_name = serializers.CharField(read_only=True) pretty_name = serializers.CharField(read_only=True)
@ -113,8 +157,12 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
self.fields.pop('pretty_name') self.fields.pop('pretty_name')
supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True)) supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True))
manufacturer = serializers.PrimaryKeyRelatedField(source='manufacturer_part.manufacturer', read_only=True)
MPN = serializers.StringRelatedField(source='manufacturer_part.MPN')
manufacturer = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_manufacturer=True)) manufacturer_part = ManufacturerPartSerializer(read_only=True)
class Meta: class Meta:
model = SupplierPart model = SupplierPart
@ -127,12 +175,31 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
'supplier_detail', 'supplier_detail',
'SKU', 'SKU',
'manufacturer', 'manufacturer',
'manufacturer_detail',
'description',
'MPN', 'MPN',
'manufacturer_detail',
'manufacturer_part',
'description',
'link', 'link',
] ]
def create(self, validated_data):
""" Extract manufacturer data and process ManufacturerPart """
# Create SupplierPart
supplier_part = super().create(validated_data)
# Get ManufacturerPart raw data (unvalidated)
manufacturer_id = self.initial_data.get('manufacturer', None)
MPN = self.initial_data.get('MPN', None)
if manufacturer_id or MPN:
kwargs = {'manufacturer': manufacturer_id,
'MPN': MPN,
}
supplier_part.save(**kwargs)
return supplier_part
class SupplierPriceBreakSerializer(InvenTreeModelSerializer): class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
""" Serializer for SupplierPriceBreak object """ """ Serializer for SupplierPriceBreak object """

View File

@ -6,7 +6,7 @@
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Company" %} - {{ company.name }} {% inventree_title %} | {% trans "Company" %} - {{ company.name }}
{% endblock %} {% endblock %}

View File

@ -21,11 +21,13 @@
<td>{% trans "Company Name" %}</td> <td>{% trans "Company Name" %}</td>
<td>{{ company.name }}</td> <td>{{ company.name }}</td>
</tr> </tr>
{% if company.description %}
<tr> <tr>
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>
<td>{% trans "Description" %}</td> <td>{% trans "Description" %}</td>
<td>{{ company.description }}</td> <td>{{ company.description }}</td>
</tr> </tr>
{% endif %}
<tr> <tr>
<td><span class='fas fa-globe'></span></td> <td><span class='fas fa-globe'></span></td>
<td>{% trans "Website" %}</td> <td>{% trans "Website" %}</td>

View File

@ -0,0 +1,127 @@
{% extends "company/company_base.html" %}
{% load static %}
{% load i18n %}
{% load inventree_extras %}
{% block menubar %}
{% include 'company/navbar.html' with tab='manufacturer_parts' %}
{% endblock %}
{% block heading %}
{% trans "Manufacturer Parts" %}
{% endblock %}
{% block details %}
{% if roles.purchase_order.change %}
<div id='button-toolbar'>
<div class='button-toolbar container-fluid'>
<div class='btn-group role='group'>
{% if roles.purchase_order.add %}
<button class="btn btn-success" id='manufacturer-part-create' title='{% trans "Create new manufacturer part" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
</button>
{% endif %}
<div class='btn-group'>
<div class="dropdown" style="float: right;">
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if roles.purchase_order.add %}
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
{% endif %}
{% if roles.purchase_order.delete %}
<li><a href='#' id='multi-part-delete' title='{% trans "Delete parts" %}'>{% trans "Delete Parts" %}</a></li>
{% endif %}
</ul>
</div>
</div>
</div>
<div class='filter-list' id='filter-list-supplier-part'>
<!-- Empty div (will be filled out with available BOM filters) -->
</div>
</div>
</div>
{% endif %}
<table class='table table-striped table-condensed' id='part-table' data-toolbar='#button-toolbar'>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$("#manufacturer-part-create").click(function () {
launchModalForm(
"{% url 'manufacturer-part-create' %}",
{
data: {
manufacturer: {{ company.id }},
},
reload: true,
secondary: [
{
field: 'part',
label: '{% trans "New Part" %}',
title: '{% trans "Create new Part" %}',
url: "{% url 'part-create' %}"
},
{
field: 'manufacturer',
label: '{% trans "New Manufacturer" %}',
title: '{% trans "Create new Manufacturer" %}',
url: "{% url 'manufacturer-create' %}",
},
]
});
});
loadManufacturerPartTable(
"#part-table",
"{% url 'api-manufacturer-part-list' %}",
{
params: {
part_detail: true,
manufacturer_detail: true,
company: {{ company.id }},
},
}
);
$("#multi-part-delete").click(function() {
var selections = $("#part-table").bootstrapTable("getSelections");
var parts = [];
selections.forEach(function(item) {
parts.push(item.pk);
});
var url = "{% url 'manufacturer-part-delete' %}"
launchModalForm(url, {
data: {
parts: parts,
},
reload: true,
});
});
$("#multi-part-order").click(function() {
var selections = $("#part-table").bootstrapTable("getSelections");
var parts = [];
selections.forEach(function(item) {
parts.push(item.part);
});
launchModalForm("/order/purchase-order/order-parts/", {
data: {
parts: parts,
},
});
});
{% endblock %}

View File

@ -1,9 +1,10 @@
{% extends "company/company_base.html" %} {% extends "company/company_base.html" %}
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load inventree_extras %}
{% block menubar %} {% block menubar %}
{% include 'company/navbar.html' with tab='parts' %} {% include 'company/navbar.html' with tab='supplier_parts' %}
{% endblock %} {% endblock %}
{% block heading %} {% block heading %}
@ -17,9 +18,9 @@
<div class='button-toolbar container-fluid'> <div class='button-toolbar container-fluid'>
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
<button class="btn btn-success" id='part-create' title='{% trans "Create new supplier part" %}'> <button class="btn btn-success" id='supplier-part-create' title='{% trans "Create new supplier part" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %} <span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
</button> </button>
{% endif %} {% endif %}
<div class='btn-group'> <div class='btn-group'>
<div class="dropdown" style="float: right;"> <div class="dropdown" style="float: right;">
@ -51,13 +52,12 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$("#part-create").click(function () { $("#supplier-part-create").click(function () {
launchModalForm( launchModalForm(
"{% url 'supplier-part-create' %}", "{% url 'supplier-part-create' %}",
{ {
data: { data: {
{% if company.is_supplier %}supplier: {{ company.id }},{% endif %} supplier: {{ company.id }},
{% if company.is_manufacturer %}manufacturer: {{ company.id }},{% endif %}
}, },
reload: true, reload: true,
secondary: [ secondary: [
@ -73,12 +73,6 @@
title: "{% trans 'Create new Supplier' %}", title: "{% trans 'Create new Supplier' %}",
url: "{% url 'supplier-create' %}", url: "{% url 'supplier-create' %}",
}, },
{
field: 'manufacturer',
label: '{% trans "New Manufacturer" %}',
title: '{% trans "Create new Manufacturer" %}',
url: "{% url 'manufacturer-create' %}",
},
] ]
}); });
}); });
@ -105,7 +99,9 @@
parts.push(item.pk); parts.push(item.pk);
}); });
launchModalForm("{% url 'supplier-part-delete' %}", { var url = "{% url 'supplier-part-delete' %}"
launchModalForm(url, {
data: { data: {
parts: parts, parts: parts,
}, },

View File

@ -2,9 +2,10 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load inventree_extras %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Supplier List" %} {% inventree_title %} | {% trans "Supplier List" %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -0,0 +1,133 @@
{% extends "two_column.html" %}
{% load static %}
{% load i18n %}
{% block page_title %}
InvenTree | {% trans "Manufacturer Part" %}
{% endblock %}
{% block thumbnail %}
<img class='part-thumb'
{% if part.part.image %}
src='{{ part.part.image.url }}'
{% else %}
src="{% static 'img/blank_image.png' %}"
{% endif %}/>
{% endblock %}
{% block page_data %}
<h3>{% trans "Manufacturer Part" %}</h3>
<hr>
<h4>
{{ part.part.full_name }}
{% if user.is_staff and perms.company.change_company %}
<a href="{% url 'admin:company_supplierpart_change' part.pk %}">
<span title='{% trans "Admin view" %}' class='fas fa-user-shield'></span>
</a>
{% endif %}
</h4>
<p>{{ part.manufacturer.name }} - {{ part.MPN }}</p>
{% if roles.purchase_order.change %}
<div class='btn-row'>
<div class='btn-group action-buttons' role='group'>
{% comment "for later" %}
{% if roles.purchase_order.add %}
<button type='button' class='btn btn-default btn-glyph' id='order-part' title='{% trans "Order part" %}'>
<span class='fas fa-shopping-cart'></span>
</button>
{% endif %}
{% endcomment %}
<button type='button' class='btn btn-default btn-glyph' id='edit-part' title='{% trans "Edit manufacturer part" %}'>
<span class='fas fa-edit icon-green'/>
</button>
{% if roles.purchase_order.delete %}
<button type='button' class='btn btn-default btn-glyph' id='delete-part' title='{% trans "Delete manufacturer part" %}'>
<span class='fas fa-trash-alt icon-red'/>
</button>
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}
{% block page_details %}
<h4>{% trans "Manufacturer Part Details" %}</h4>
<table class="table table-striped table-condensed">
<col width='25'>
<tr>
<td><span class='fas fa-shapes'></span></td>
<td>{% trans "Internal Part" %}</td>
<td>
{% if part.part %}
<a href="{% url 'part-manufacturers' part.part.id %}">{{ part.part.full_name }}</a>
{% endif %}
</td>
</tr>
{% if part.description %}
<tr>
<td></td>
<td>{% trans "Description" %}</td>
<td>{{ part.description }}</td>
</tr>
{% endif %}
{% if part.link %}
<tr>
<td><span class='fas fa-link'></span></td>
<td>{% trans "External Link" %}</td>
<td><a href="{{ part.link }}">{{ part.link }}</a></td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-industry'></span></td>
<td>{% trans "Manufacturer" %}</td>
<td><a href="{% url 'company-detail-manufacturer-parts' part.manufacturer.id %}">{{ part.manufacturer.name }}</a></td></tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "MPN" %}</td>
<td>{{ part.MPN }}</td>
</tr>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
enableNavbar({
label: 'manufacturer-part',
toggleId: '#manufacturer-part-menu-toggle'
})
$('#order-part, #order-part2').click(function() {
launchModalForm(
"{% url 'order-parts' %}",
{
data: {
part: {{ part.part.id }},
},
reload: true,
},
);
});
$('#edit-part').click(function () {
launchModalForm(
"{% url 'manufacturer-part-edit' part.id %}",
{
reload: true
}
);
});
$('#delete-part').click(function() {
launchModalForm(
"{% url 'manufacturer-part-delete' %}?part={{ part.id }}",
{
redirect: "{% url 'company-detail-manufacturer-parts' part.manufacturer.id %}"
}
);
});
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
{{ block.super }}
{% if part %}
<div class='alert alert-block alert-info'>
{% include "hover_image.html" with image=part.image %}
{{ part.full_name}}
<br>
<i>{{ part.description }}</i>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,46 @@
{% extends "modal_delete_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<div class='alert alert-block alert-warning'>
{% trans "Are you sure you want to delete the following Manufacturer Parts?" %}
</div>
{% for part in parts %}
{% endfor %}
{% endblock %}
{% block form_data %}
{% for part in parts %}
<table class='table table-striped table-condensed'>
<tr>
<input type='hidden' name='manufacturer-part-{{ part.id}}' value='manufacturer-part-{{ part.id }}'/>
<td>
{% include "hover_image.html" with image=part.part.image %}
{{ part.part.full_name }}
</td>
<td>
{% include "hover_image.html" with image=part.manufacturer.image %}
{{ part.manufacturer.name }}
</td>
<td>
{{ part.MPN }}
</td>
</tr>
</table>
{% if part.supplier_parts.all|length > 0 %}
<div class='alert alert-block alert-danger'>
<p>{% blocktrans with count=part.supplier_parts.all|length %}There are {{count}} suppliers defined for this manufacturer part. If you delete it, the following supplier parts will also be deleted:{% endblocktrans %}</p>
<ul class='list-group' style='margin-top:10px'>
{% for spart in part.supplier_parts.all %}
<li class='list-group-item'>{{ spart.supplier.name }} - {{ spart.SKU }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,38 @@
{% extends "company/manufacturer_part_base.html" %}
{% load static %}
{% load i18n %}
{% block menubar %}
{% include "company/manufacturer_part_navbar.html" with tab='details' %}
{% endblock %}
{% block heading %}
{% trans "Manufacturer Part Details" %}
{% endblock %}
{% block details %}
<table class="table table-striped table-condensed">
<tr>
<td>{% trans "Internal Part" %}</td>
<td>
{% if part.part %}
<a href="{% url 'part-manufacturers' part.part.id %}">{{ part.part.full_name }}</a>
{% endif %}
</td>
</tr>
<tr><td>{% trans "Manufacturer" %}</td><td><a href="{% url 'company-detail-manufacturer-parts' part.manufacturer.id %}">{{ part.manufacturer.name }}</a></td></tr>
<tr><td>{% trans "MPN" %}</td><td>{{ part.MPN }}</tr></tr>
{% if part.link %}
<tr><td>{% trans "External Link" %}</td><td><a href="{{ part.link }}">{{ part.link }}</a></td></tr>
{% endif %}
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
{% endblock %}

View File

@ -0,0 +1,34 @@
{% load i18n %}
<ul class='list-group'>
<li class='list-group-item'>
<a href='#' id='manufacturer-part-menu-toggle'>
<span class='menu-tab-icon fas fa-expand-arrows-alt'></span>
</a>
</li>
<li class='list-group-item {% if tab == "suppliers" %}active{% endif %}' title='{% trans "Supplier Parts" %}'>
<a href='{% url "manufacturer-part-suppliers" part.id %}'>
<span class='fas fa-building'></span>
{% trans "Suppliers" %}
</a>
</li>
{% comment "for later" %}
<li class='list-group-item {% if tab == "stock" %}active{% endif %}' title='{% trans "Manufacturer Part Stock" %}'>
<a href='{% url "manufacturer-part-stock" part.id %}'>
<span class='fas fa-boxes'></span>
{% trans "Stock" %}
</a>
</li>
<li class='list-group-item {% if tab == "orders" %}active{% endif %}' title='{% trans "Manufacturer Part Orders" %}'>
<a href='{% url "manufacturer-part-orders" part.id %}'>
<span class='fas fa-shopping-cart'></span>
{% trans "Orders" %}
</a>
</li>
{% endcomment %}
</ul>

View File

@ -0,0 +1,89 @@
{% extends "company/manufacturer_part_base.html" %}
{% load static %}
{% load i18n %}
{% block menubar %}
{% include "company/manufacturer_part_navbar.html" with tab='suppliers' %}
{% endblock %}
{% block heading %}
{% trans "Supplier Parts" %}
{% endblock %}
{% block details %}
<div id='button-toolbar'>
<div class='btn-group'>
<button class="btn btn-success" id='supplier-create'>
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
</button>
<div id='opt-dropdown' class="btn-group">
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
</ul>
</div>
</div>
</div>
<table class="table table-striped table-condensed" id='supplier-table' data-toolbar='#button-toolbar'>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$('#supplier-create').click(function () {
launchModalForm(
"{% url 'supplier-part-create' %}",
{
reload: true,
data: {
manufacturer_part: {{ part.id }}
},
secondary: [
{
field: 'supplier',
label: '{% trans "New Supplier" %}',
title: '{% trans "Create new supplier" %}',
url: "{% url 'supplier-create' %}"
},
]
});
});
$("#supplier-part-delete").click(function() {
var selections = $("#supplier-table").bootstrapTable("getSelections");
var parts = [];
selections.forEach(function(item) {
parts.push(item.pk);
});
launchModalForm("{% url 'supplier-part-delete' %}", {
data: {
parts: parts,
},
reload: true,
});
});
loadSupplierPartTable(
"#supplier-table",
"{% url 'api-supplier-part-list' %}",
{
params: {
part: {{ part.part.id }},
manufacturer_part: {{ part.id }},
part_detail: false,
supplier_detail: true,
manufacturer_detail: false,
},
}
);
linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options'])
{% endblock %}

View File

@ -16,14 +16,25 @@
</a> </a>
</li> </li>
{% if company.is_supplier or company.is_manufacturer %} {% if company.is_manufacturer %}
<li class='list-group-item {% if tab == "parts" %}active{% endif %}' title='{% trans "Supplied Parts" %}'> <li class='list-group-item {% if tab == "manufacturer_parts" %}active{% endif %}' title='{% trans "Manufactured Parts" %}'>
<a href='{% url "company-detail-parts" company.id %}'> <a href='{% url "company-detail-manufacturer-parts" company.id %}'>
<span class='fas fa-shapes'></span> <span class='fas fa-industry'></span>
{% trans "Parts" %} {% trans "Manufactured Parts" %}
</a> </a>
</li> </li>
{% endif %}
{% if company.is_supplier or company.is_manufacturer %}
<li class='list-group-item {% if tab == "supplier_parts" %}active{% endif %}' title='{% trans "Supplied Parts" %}'>
<a href='{% url "company-detail-supplier-parts" company.id %}'>
<span class='fas fa-building'></span>
{% trans "Supplied Parts" %}
</a>
</li>
{% endif %}
{% if company.is_manufacturer or company.is_supplier %}
<li class='list-group-item {% if tab == "stock" %}active{% endif %}' title='{% trans "Stock Items" %}'> <li class='list-group-item {% if tab == "stock" %}active{% endif %}' title='{% trans "Stock Items" %}'>
<a href='{% url "company-detail-stock" company.id %}'> <a href='{% url "company-detail-stock" company.id %}'>
<span class='fas fa-boxes'></span> <span class='fas fa-boxes'></span>

View File

@ -1,9 +1,10 @@
{% extends "two_column.html" %} {% extends "two_column.html" %}
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load inventree_extras %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Supplier Part" %} {% inventree_title %} | {% trans "Supplier Part" %}
{% endblock %} {% endblock %}
{% block thumbnail %} {% block thumbnail %}
@ -81,23 +82,24 @@ src="{% static 'img/blank_image.png' %}"
<tr> <tr>
<td><span class='fas fa-building'></span></td> <td><span class='fas fa-building'></span></td>
<td>{% trans "Supplier" %}</td> <td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr> <td><a href="{% url 'company-detail-supplier-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr>
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "SKU" %}</td> <td>{% trans "SKU" %}</td>
<td>{{ part.SKU }}</tr> <td>{{ part.SKU }}</tr>
</tr> </tr>
{% if part.manufacturer %} {% if part.manufacturer_part.manufacturer %}
<tr> <tr>
<td><span class='fas fa-industry'></span></td> <td><span class='fas fa-industry'></span></td>
<td>{% trans "Manufacturer" %}</td> <td>{% trans "Manufacturer" %}</td>
<td><a href="{% url 'company-detail-parts' part.manufacturer.id %}">{{ part.manufacturer.name }}</a></td></tr> <td><a href="{% url 'company-detail-manufacturer-parts' part.manufacturer_part.manufacturer.id %}">{{ part.manufacturer_part.manufacturer.name }}</a></td>
</tr>
{% endif %} {% endif %}
{% if part.MPN %} {% if part.manufacturer_part.MPN %}
<tr> <tr>
<td><span class='fas fa-hashtag'></span></td> <td><span class='fas fa-hashtag'></span></td>
<td>{% trans "MPN" %}</td> <td>{% trans "MPN" %}</td>
<td>{{ part.MPN }}</td> <td><a href="{% url 'manufacturer-part-detail' part.manufacturer_part.id %}">{{ part.manufacturer_part.MPN }}</a></td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.packaging %} {% if part.packaging %}
@ -150,7 +152,7 @@ $('#delete-part').click(function() {
launchModalForm( launchModalForm(
"{% url 'supplier-part-delete' %}?part={{ part.id }}", "{% url 'supplier-part-delete' %}?part={{ part.id }}",
{ {
redirect: "{% url 'company-detail-parts' part.supplier.id %}" redirect: "{% url 'company-detail-supplier-parts' part.supplier.id %}"
} }
); );
}); });

View File

@ -13,13 +13,16 @@
<tr> <tr>
<input type='hidden' name='supplier-part-{{ part.id}}' value='supplier-part-{{ part.id }}'/> <input type='hidden' name='supplier-part-{{ part.id}}' value='supplier-part-{{ part.id }}'/>
<td>
{% include "hover_image.html" with image=part.part.image %}
{{ part.part.full_name }}
</td>
<td> <td>
{% include "hover_image.html" with image=part.supplier.image %} {% include "hover_image.html" with image=part.supplier.image %}
{{ part.supplier.name }} {{ part.supplier.name }}
</td> </td>
<td> <td>
{% include "hover_image.html" with image=part.part.image %} {{ part.SKU }}
{{ part.part.full_name }}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -3,7 +3,7 @@
{% load i18n %} {% load i18n %}
{% block menubar %} {% block menubar %}
{% include "company/part_navbar.html" with tab='details' %} {% include "company/supplier_part_navbar.html" with tab='details' %}
{% endblock %} {% endblock %}
{% block heading %} {% block heading %}
@ -22,7 +22,7 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr><td>{% trans "Supplier" %}</td><td><a href="{% url 'company-detail-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr> <tr><td>{% trans "Supplier" %}</td><td><a href="{% url 'company-detail-supplier-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr>
<tr><td>{% trans "SKU" %}</td><td>{{ part.SKU }}</tr></tr> <tr><td>{% trans "SKU" %}</td><td>{{ part.SKU }}</tr></tr>
{% if part.link %} {% if part.link %}
<tr><td>{% trans "External Link" %}</td><td><a href="{{ part.link }}">{{ part.link }}</a></td></tr> <tr><td>{% trans "External Link" %}</td><td><a href="{{ part.link }}">{{ part.link }}</a></td></tr>

View File

@ -1,4 +1,5 @@
{% load i18n %} {% load i18n %}
{% load inventree_extras %}
<ul class='list-group'> <ul class='list-group'>

View File

@ -3,7 +3,7 @@
{% load i18n %} {% load i18n %}
{% block menubar %} {% block menubar %}
{% include "company/part_navbar.html" with tab='orders' %} {% include "company/supplier_part_navbar.html" with tab='orders' %}
{% endblock %} {% endblock %}
{% block heading %} {% block heading %}

View File

@ -4,7 +4,7 @@
{% load inventree_extras %} {% load inventree_extras %}
{% block menubar %} {% block menubar %}
{% include "company/part_navbar.html" with tab='pricing' %} {% include "company/supplier_part_navbar.html" with tab='pricing' %}
{% endblock %} {% endblock %}
{% block heading %} {% block heading %}

View File

@ -3,7 +3,7 @@
{% load i18n %} {% load i18n %}
{% block menubar %} {% block menubar %}
{% include "company/part_navbar.html" with tab='stock' %} {% include "company/supplier_part_navbar.html" with tab='stock' %}
{% endblock %} {% endblock %}
{% block heading %} {% block heading %}

View File

@ -27,7 +27,7 @@ class CompanyTest(InvenTreeAPITestCase):
def test_company_list(self): def test_company_list(self):
url = reverse('api-company-list') url = reverse('api-company-list')
# There should be two companies # There should be three companies
response = self.get(url) response = self.get(url)
self.assertEqual(len(response.data), 3) self.assertEqual(len(response.data), 3)
@ -62,3 +62,90 @@ class CompanyTest(InvenTreeAPITestCase):
data = {'search': 'cup'} data = {'search': 'cup'}
response = self.get(url, data) response = self.get(url, data)
self.assertEqual(len(response.data), 2) self.assertEqual(len(response.data), 2)
class ManufacturerTest(InvenTreeAPITestCase):
"""
Series of tests for the Manufacturer DRF API
"""
fixtures = [
'category',
'part',
'location',
'company',
'manufacturer_part',
]
roles = [
'part.add',
'part.change',
]
def test_manufacturer_part_list(self):
url = reverse('api-manufacturer-part-list')
# There should be three manufacturer parts
response = self.get(url)
self.assertEqual(len(response.data), 3)
# Create manufacturer part
data = {
'part': 1,
'manufacturer': 7,
'MPN': 'MPN_TEST',
}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['MPN'], 'MPN_TEST')
# Filter by manufacturer
data = {'company': 7}
response = self.get(url, data)
self.assertEqual(len(response.data), 3)
# Filter by part
data = {'part': 5}
response = self.get(url, data)
self.assertEqual(len(response.data), 2)
def test_manufacturer_part_detail(self):
url = reverse('api-manufacturer-part-detail', kwargs={'pk': 1})
response = self.get(url)
self.assertEqual(response.data['MPN'], 'MPN123')
# Change the MPN
data = {
'MPN': 'MPN-TEST-123',
}
response = self.client.patch(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['MPN'], 'MPN-TEST-123')
def test_manufacturer_part_search(self):
# Test search functionality in manufacturer list
url = reverse('api-manufacturer-part-list')
data = {'search': 'MPN'}
response = self.get(url, data)
self.assertEqual(len(response.data), 3)
def test_supplier_part_create(self):
url = reverse('api-supplier-part-list')
# Create supplier part
data = {
'part': 1,
'supplier': 1,
'SKU': 'SKU_TEST',
'manufacturer': 7,
'MPN': 'PART_NUMBER',
}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# Check manufacturer part
manufacturer_part_id = int(response.data['manufacturer_part']['pk'])
url = reverse('api-manufacturer-part-detail', kwargs={'pk': manufacturer_part_id})
response = self.get(url)
self.assertEqual(response.data['MPN'], 'PART_NUMBER')

View File

@ -79,7 +79,7 @@ class TestManufacturerField(MigratorTestCase):
part=part, part=part,
supplier=supplier, supplier=supplier,
SKU='SCREW.002', SKU='SCREW.002',
manufacturer_name='Zero Corp' manufacturer_name='Zero Corp',
) )
self.assertEqual(Company.objects.count(), 1) self.assertEqual(Company.objects.count(), 1)
@ -107,6 +107,136 @@ class TestManufacturerField(MigratorTestCase):
self.assertEqual(part.manufacturer.name, 'ACME') self.assertEqual(part.manufacturer.name, 'ACME')
class TestManufacturerPart(MigratorTestCase):
"""
Tests for migration 0034-0037 which added and transitioned to the ManufacturerPart model
"""
migrate_from = ('company', '0033_auto_20210410_1528')
migrate_to = ('company', '0037_supplierpart_update_3')
def prepare(self):
"""
Prepare the database by adding some test data 'before' the change:
- Part object
- Company object (supplier)
- SupplierPart object
"""
Part = self.old_state.apps.get_model('part', 'part')
Company = self.old_state.apps.get_model('company', 'company')
SupplierPart = self.old_state.apps.get_model('company', 'supplierpart')
# Create an initial part
part = Part.objects.create(
name='CAP CER 0.1UF 10V X5R 0402',
description='CAP CER 0.1UF 10V X5R 0402',
purchaseable=True,
level=0,
tree_id=0,
lft=0,
rght=0,
)
# Create a manufacturer
manufacturer = Company.objects.create(
name='Murata',
description='Makes capacitors',
is_manufacturer=True,
is_supplier=False,
is_customer=False,
)
# Create suppliers
supplier_1 = Company.objects.create(
name='Digi-Key',
description='A supplier of components',
is_manufacturer=False,
is_supplier=True,
is_customer=False,
)
supplier_2 = Company.objects.create(
name='Mouser',
description='We sell components',
is_manufacturer=False,
is_supplier=True,
is_customer=False,
)
# Add some SupplierPart objects
SupplierPart.objects.create(
part=part,
supplier=supplier_1,
SKU='DK-MUR-CAP-123456-ND',
manufacturer=manufacturer,
MPN='MUR-CAP-123456',
)
SupplierPart.objects.create(
part=part,
supplier=supplier_1,
SKU='DK-MUR-CAP-987654-ND',
manufacturer=manufacturer,
MPN='MUR-CAP-987654',
)
SupplierPart.objects.create(
part=part,
supplier=supplier_2,
SKU='CAP-CER-01UF',
manufacturer=manufacturer,
MPN='MUR-CAP-123456',
)
# No MPN
SupplierPart.objects.create(
part=part,
supplier=supplier_2,
SKU='CAP-CER-01UF-1',
manufacturer=manufacturer,
)
# No Manufacturer
SupplierPart.objects.create(
part=part,
supplier=supplier_2,
SKU='CAP-CER-01UF-2',
MPN='MUR-CAP-123456',
)
# No Manufacturer data
SupplierPart.objects.create(
part=part,
supplier=supplier_2,
SKU='CAP-CER-01UF-3',
)
def test_manufacturer_part_objects(self):
"""
Test that the new companies have been created successfully
"""
# Check on the SupplierPart objects
SupplierPart = self.new_state.apps.get_model('company', 'supplierpart')
supplier_parts = SupplierPart.objects.all()
self.assertEqual(supplier_parts.count(), 6)
supplier_parts = SupplierPart.objects.filter(supplier__name='Mouser')
self.assertEqual(supplier_parts.count(), 4)
# Check on the ManufacturerPart objects
ManufacturerPart = self.new_state.apps.get_model('company', 'manufacturerpart')
manufacturer_parts = ManufacturerPart.objects.all()
self.assertEqual(manufacturer_parts.count(), 4)
manufacturer_part = manufacturer_parts.first()
self.assertEqual(manufacturer_part.MPN, 'MUR-CAP-123456')
class TestCurrencyMigration(MigratorTestCase): class TestCurrencyMigration(MigratorTestCase):
""" """
Tests for upgrade from basic currency support to django-money Tests for upgrade from basic currency support to django-money

View File

@ -10,6 +10,7 @@ from django.urls import reverse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from .models import ManufacturerPart
from .models import SupplierPart from .models import SupplierPart
@ -20,6 +21,7 @@ class CompanyViewTestBase(TestCase):
'part', 'part',
'location', 'location',
'company', 'company',
'manufacturer_part',
'supplier_part', 'supplier_part',
] ]
@ -200,3 +202,105 @@ class CompanyViewTest(CompanyViewTestBase):
response = self.client.get(reverse('customer-create'), HTTP_X_REQUESTED_WITH='XMLHttpRequest') response = self.client.get(reverse('customer-create'), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertContains(response, 'Create new Customer') self.assertContains(response, 'Create new Customer')
class ManufacturerPartViewTests(CompanyViewTestBase):
"""
Tests for the ManufacturerPart views.
"""
def test_manufacturer_part_create(self):
"""
Test the ManufacturerPartCreate view.
"""
url = reverse('manufacturer-part-create')
# First check that we can GET the form
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# How many manufaturer parts are already in the database?
n = ManufacturerPart.objects.all().count()
data = {
'part': 1,
'manufacturer': 6,
}
# MPN is required! (form should fail)
(response, errors) = self.post(url, data, valid=False)
self.assertIsNotNone(errors.get('MPN', None))
data['MPN'] = 'TEST-ME-123'
(response, errors) = self.post(url, data, valid=True)
# Check that the ManufacturerPart was created!
self.assertEqual(n + 1, ManufacturerPart.objects.all().count())
# Try to create duplicate ManufacturerPart
(response, errors) = self.post(url, data, valid=False)
self.assertIsNotNone(errors.get('__all__', None))
# Check that the ManufacturerPart count stayed the same
self.assertEqual(n + 1, ManufacturerPart.objects.all().count())
def test_supplier_part_create(self):
"""
Test that the SupplierPartCreate view creates Manufacturer Part.
"""
url = reverse('supplier-part-create')
# First check that we can GET the form
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# How many manufacturer parts are already in the database?
n = ManufacturerPart.objects.all().count()
data = {
'part': 1,
'supplier': 1,
'SKU': 'SKU_TEST',
'manufacturer': 6,
'MPN': 'MPN_TEST',
}
(response, errors) = self.post(url, data, valid=True)
# Check that the ManufacturerPart was created!
self.assertEqual(n + 1, ManufacturerPart.objects.all().count())
def test_manufacturer_part_delete(self):
"""
Test the ManufacturerPartDelete view
"""
url = reverse('manufacturer-part-delete')
# Get form using 'part' argument
response = self.client.get(url, {'part': '2'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# POST to delete manufacturer part
n = ManufacturerPart.objects.count()
m = SupplierPart.objects.count()
response = self.client.post(
url,
{
'manufacturer-part-2': 'manufacturer-part-2',
'confirm_delete': True
},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Check that the ManufacturerPart was deleted
self.assertEqual(n - 1, ManufacturerPart.objects.count())
# Check that the SupplierParts were deleted
self.assertEqual(m - 2, SupplierPart.objects.count())

View File

@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError
import os import os
from .models import Company, Contact, SupplierPart from .models import Company, Contact, ManufacturerPart, SupplierPart
from .models import rename_company_image from .models import rename_company_image
from part.models import Part from part.models import Part
@ -22,6 +22,7 @@ class CompanySimpleTest(TestCase):
'part', 'part',
'location', 'location',
'bom', 'bom',
'manufacturer_part',
'supplier_part', 'supplier_part',
'price_breaks', 'price_breaks',
] ]
@ -74,10 +75,10 @@ class CompanySimpleTest(TestCase):
self.assertEqual(acme.supplied_part_count, 4) self.assertEqual(acme.supplied_part_count, 4)
self.assertTrue(appel.has_parts) self.assertTrue(appel.has_parts)
self.assertEqual(appel.supplied_part_count, 2) self.assertEqual(appel.supplied_part_count, 3)
self.assertTrue(zerg.has_parts) self.assertTrue(zerg.has_parts)
self.assertEqual(zerg.supplied_part_count, 1) self.assertEqual(zerg.supplied_part_count, 2)
def test_price_breaks(self): def test_price_breaks(self):
@ -166,3 +167,53 @@ class ContactSimpleTest(TestCase):
# Remove the parent company # Remove the parent company
Company.objects.get(pk=self.c.pk).delete() Company.objects.get(pk=self.c.pk).delete()
self.assertEqual(Contact.objects.count(), 0) self.assertEqual(Contact.objects.count(), 0)
class ManufacturerPartSimpleTest(TestCase):
fixtures = [
'category',
'company',
'location',
'part',
'manufacturer_part',
]
def setUp(self):
# Create a manufacturer part
self.part = Part.objects.get(pk=1)
manufacturer = Company.objects.get(pk=1)
self.mp = ManufacturerPart.create(
part=self.part,
manufacturer=manufacturer,
mpn='PART_NUMBER',
description='THIS IS A MANUFACTURER PART',
)
# Create a supplier part
supplier = Company.objects.get(pk=5)
supplier_part = SupplierPart.objects.create(
part=self.part,
supplier=supplier,
SKU='SKU_TEST',
)
kwargs = {
'manufacturer': manufacturer.id,
'MPN': 'MPN_TEST',
}
supplier_part.save(**kwargs)
def test_exists(self):
self.assertEqual(ManufacturerPart.objects.count(), 5)
# Check that manufacturer part was created from supplier part creation
manufacturer_parts = ManufacturerPart.objects.filter(manufacturer=1)
self.assertEqual(manufacturer_parts.count(), 2)
def test_delete(self):
# Remove a part
Part.objects.get(pk=self.part.id).delete()
# Check that ManufacturerPart was deleted
self.assertEqual(ManufacturerPart.objects.count(), 3)

View File

@ -13,7 +13,8 @@ company_detail_urls = [
# url(r'orders/?', views.CompanyDetail.as_view(template_name='company/orders.html'), name='company-detail-orders'), # url(r'orders/?', views.CompanyDetail.as_view(template_name='company/orders.html'), name='company-detail-orders'),
url(r'^parts/', views.CompanyDetail.as_view(template_name='company/detail_part.html'), name='company-detail-parts'), url(r'^supplier-parts/', views.CompanyDetail.as_view(template_name='company/detail_supplier_part.html'), name='company-detail-supplier-parts'),
url(r'^manufacturer-parts/', views.CompanyDetail.as_view(template_name='company/detail_manufacturer_part.html'), name='company-detail-manufacturer-parts'),
url(r'^stock/', views.CompanyDetail.as_view(template_name='company/detail_stock.html'), name='company-detail-stock'), url(r'^stock/', views.CompanyDetail.as_view(template_name='company/detail_stock.html'), name='company-detail-stock'),
url(r'^purchase-orders/', views.CompanyDetail.as_view(template_name='company/purchase_orders.html'), name='company-detail-purchase-orders'), url(r'^purchase-orders/', views.CompanyDetail.as_view(template_name='company/purchase_orders.html'), name='company-detail-purchase-orders'),
url(r'^assigned-stock/', views.CompanyDetail.as_view(template_name='company/assigned_stock.html'), name='company-detail-assigned-stock'), url(r'^assigned-stock/', views.CompanyDetail.as_view(template_name='company/assigned_stock.html'), name='company-detail-assigned-stock'),
@ -52,9 +53,26 @@ price_break_urls = [
url(r'^(?P<pk>\d+)/delete/', views.PriceBreakDelete.as_view(), name='price-break-delete'), url(r'^(?P<pk>\d+)/delete/', views.PriceBreakDelete.as_view(), name='price-break-delete'),
] ]
manufacturer_part_detail_urls = [
url(r'^edit/?', views.ManufacturerPartEdit.as_view(), name='manufacturer-part-edit'),
url(r'^suppliers/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-suppliers'),
url('^.*$', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-detail'),
]
manufacturer_part_urls = [
url(r'^new/?', views.ManufacturerPartCreate.as_view(), name='manufacturer-part-create'),
url(r'delete/', views.ManufacturerPartDelete.as_view(), name='manufacturer-part-delete'),
url(r'^(?P<pk>\d+)/', include(manufacturer_part_detail_urls)),
]
supplier_part_detail_urls = [ supplier_part_detail_urls = [
url(r'^edit/?', views.SupplierPartEdit.as_view(), name='supplier-part-edit'), url(r'^edit/?', views.SupplierPartEdit.as_view(), name='supplier-part-edit'),
url(r'^manufacturers/', views.SupplierPartDetail.as_view(template_name='company/supplier_part_manufacturers.html'), name='supplier-part-manufacturers'),
url(r'^pricing/', views.SupplierPartDetail.as_view(template_name='company/supplier_part_pricing.html'), name='supplier-part-pricing'), url(r'^pricing/', views.SupplierPartDetail.as_view(template_name='company/supplier_part_pricing.html'), name='supplier-part-pricing'),
url(r'^orders/', views.SupplierPartDetail.as_view(template_name='company/supplier_part_orders.html'), name='supplier-part-orders'), url(r'^orders/', views.SupplierPartDetail.as_view(template_name='company/supplier_part_orders.html'), name='supplier-part-orders'),
url(r'^stock/', views.SupplierPartDetail.as_view(template_name='company/supplier_part_stock.html'), name='supplier-part-stock'), url(r'^stock/', views.SupplierPartDetail.as_view(template_name='company/supplier_part_stock.html'), name='supplier-part-stock'),

View File

@ -24,6 +24,7 @@ from InvenTree.helpers import str2bool
from InvenTree.views import InvenTreeRoleMixin from InvenTree.views import InvenTreeRoleMixin
from .models import Company from .models import Company
from .models import ManufacturerPart
from .models import SupplierPart from .models import SupplierPart
from .models import SupplierPriceBreak from .models import SupplierPriceBreak
@ -31,6 +32,7 @@ from part.models import Part
from .forms import EditCompanyForm from .forms import EditCompanyForm
from .forms import CompanyImageForm from .forms import CompanyImageForm
from .forms import EditManufacturerPartForm
from .forms import EditSupplierPartForm from .forms import EditSupplierPartForm
from .forms import EditPriceBreakForm from .forms import EditPriceBreakForm
from .forms import CompanyImageDownloadForm from .forms import CompanyImageDownloadForm
@ -331,6 +333,177 @@ class CompanyDelete(AjaxDeleteView):
} }
class ManufacturerPartDetail(DetailView):
""" Detail view for ManufacturerPart """
model = ManufacturerPart
template_name = 'company/manufacturer_part_detail.html'
context_object_name = 'part'
queryset = ManufacturerPart.objects.all()
permission_required = 'purchase_order.view'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
return ctx
class ManufacturerPartEdit(AjaxUpdateView):
""" Update view for editing ManufacturerPart """
model = ManufacturerPart
context_object_name = 'part'
form_class = EditManufacturerPartForm
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Edit Manufacturer Part')
class ManufacturerPartCreate(AjaxCreateView):
""" Create view for making new ManufacturerPart """
model = ManufacturerPart
form_class = EditManufacturerPartForm
ajax_template_name = 'company/manufacturer_part_create.html'
ajax_form_title = _('Create New Manufacturer Part')
context_object_name = 'part'
def get_context_data(self):
"""
Supply context data to the form
"""
ctx = super().get_context_data()
# Add 'part' object
form = self.get_form()
part = form['part'].value()
try:
part = Part.objects.get(pk=part)
except (ValueError, Part.DoesNotExist):
part = None
ctx['part'] = part
return ctx
def get_form(self):
""" Create Form instance to create a new ManufacturerPart object.
Hide some fields if they are not appropriate in context
"""
form = super(AjaxCreateView, self).get_form()
if form.initial.get('part', None):
# Hide the part field
form.fields['part'].widget = HiddenInput()
return form
def get_initial(self):
""" Provide initial data for new ManufacturerPart:
- If 'manufacturer_id' provided, pre-fill manufacturer field
- If 'part_id' provided, pre-fill part field
"""
initials = super(ManufacturerPartCreate, self).get_initial().copy()
manufacturer_id = self.get_param('manufacturer')
part_id = self.get_param('part')
if manufacturer_id:
try:
initials['manufacturer'] = Company.objects.get(pk=manufacturer_id)
except (ValueError, Company.DoesNotExist):
pass
if part_id:
try:
initials['part'] = Part.objects.get(pk=part_id)
except (ValueError, Part.DoesNotExist):
pass
return initials
class ManufacturerPartDelete(AjaxDeleteView):
""" Delete view for removing a ManufacturerPart.
ManufacturerParts can be deleted using a variety of 'selectors'.
- ?part=<pk> -> Delete a single ManufacturerPart object
- ?parts=[] -> Delete a list of ManufacturerPart objects
"""
success_url = '/manufacturer/'
ajax_template_name = 'company/manufacturer_part_delete.html'
ajax_form_title = _('Delete Manufacturer Part')
role_required = 'purchase_order.delete'
parts = []
def get_context_data(self):
ctx = {}
ctx['parts'] = self.parts
return ctx
def get_parts(self):
""" Determine which ManufacturerPart object(s) the user wishes to delete.
"""
self.parts = []
# User passes a single ManufacturerPart ID
if 'part' in self.request.GET:
try:
self.parts.append(ManufacturerPart.objects.get(pk=self.request.GET.get('part')))
except (ValueError, ManufacturerPart.DoesNotExist):
pass
elif 'parts[]' in self.request.GET:
part_id_list = self.request.GET.getlist('parts[]')
self.parts = ManufacturerPart.objects.filter(id__in=part_id_list)
def get(self, request, *args, **kwargs):
self.request = request
self.get_parts()
return self.renderJsonResponse(request, form=self.get_form())
def post(self, request, *args, **kwargs):
""" Handle the POST action for deleting ManufacturerPart object.
"""
self.request = request
self.parts = []
for item in self.request.POST:
if item.startswith('manufacturer-part-'):
pk = item.replace('manufacturer-part-', '')
try:
self.parts.append(ManufacturerPart.objects.get(pk=pk))
except (ValueError, ManufacturerPart.DoesNotExist):
pass
confirm = str2bool(self.request.POST.get('confirm_delete', False))
data = {
'form_valid': confirm,
}
if confirm:
for part in self.parts:
part.delete()
return self.renderJsonResponse(self.request, data=data, form=self.get_form())
class SupplierPartDetail(DetailView): class SupplierPartDetail(DetailView):
""" Detail view for SupplierPart """ """ Detail view for SupplierPart """
model = SupplierPart model = SupplierPart
@ -354,11 +527,25 @@ class SupplierPartEdit(AjaxUpdateView):
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
ajax_form_title = _('Edit Supplier Part') ajax_form_title = _('Edit Supplier Part')
def save(self, supplier_part, form, **kwargs):
""" Process ManufacturerPart data """
manufacturer = form.cleaned_data.get('manufacturer', None)
MPN = form.cleaned_data.get('MPN', None)
kwargs = {'manufacturer': manufacturer,
'MPN': MPN,
}
supplier_part.save(**kwargs)
def get_form(self): def get_form(self):
form = super().get_form() form = super().get_form()
supplier_part = self.get_object() supplier_part = self.get_object()
# Hide Manufacturer fields
form.fields['manufacturer'].widget = HiddenInput()
form.fields['MPN'].widget = HiddenInput()
# It appears that hiding a MoneyField fails validation # It appears that hiding a MoneyField fails validation
# Therefore the idea to set the value before hiding # Therefore the idea to set the value before hiding
if form.is_valid(): if form.is_valid():
@ -368,6 +555,19 @@ class SupplierPartEdit(AjaxUpdateView):
return form return form
def get_initial(self):
""" Fetch data from ManufacturerPart """
initials = super(SupplierPartEdit, self).get_initial().copy()
supplier_part = self.get_object()
if supplier_part.manufacturer_part:
initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id
initials['MPN'] = supplier_part.manufacturer_part.MPN
return initials
class SupplierPartCreate(AjaxCreateView): class SupplierPartCreate(AjaxCreateView):
""" Create view for making new SupplierPart """ """ Create view for making new SupplierPart """
@ -415,6 +615,14 @@ class SupplierPartCreate(AjaxCreateView):
# Save the supplier part object # Save the supplier part object
supplier_part = super().save(form) supplier_part = super().save(form)
# Process manufacturer data
manufacturer = form.cleaned_data.get('manufacturer', None)
MPN = form.cleaned_data.get('MPN', None)
kwargs = {'manufacturer': manufacturer,
'MPN': MPN,
}
supplier_part.save(**kwargs)
single_pricing = form.cleaned_data.get('single_pricing', None) single_pricing = form.cleaned_data.get('single_pricing', None)
if single_pricing: if single_pricing:
@ -433,6 +641,12 @@ class SupplierPartCreate(AjaxCreateView):
# Hide the part field # Hide the part field
form.fields['part'].widget = HiddenInput() form.fields['part'].widget = HiddenInput()
if form.initial.get('manufacturer', None):
# Hide the manufacturer field
form.fields['manufacturer'].widget = HiddenInput()
# Hide the MPN field
form.fields['MPN'].widget = HiddenInput()
return form return form
def get_initial(self): def get_initial(self):
@ -446,6 +660,7 @@ class SupplierPartCreate(AjaxCreateView):
manufacturer_id = self.get_param('manufacturer') manufacturer_id = self.get_param('manufacturer')
supplier_id = self.get_param('supplier') supplier_id = self.get_param('supplier')
part_id = self.get_param('part') part_id = self.get_param('part')
manufacturer_part_id = self.get_param('manufacturer_part')
supplier = None supplier = None
@ -461,6 +676,16 @@ class SupplierPartCreate(AjaxCreateView):
initials['manufacturer'] = Company.objects.get(pk=manufacturer_id) initials['manufacturer'] = Company.objects.get(pk=manufacturer_id)
except (ValueError, Company.DoesNotExist): except (ValueError, Company.DoesNotExist):
pass pass
if manufacturer_part_id:
try:
# Get ManufacturerPart instance information
manufacturer_part_obj = ManufacturerPart.objects.get(pk=manufacturer_part_id)
initials['part'] = Part.objects.get(pk=manufacturer_part_obj.part.id)
initials['manufacturer'] = manufacturer_part_obj.manufacturer.id
initials['MPN'] = manufacturer_part_obj.MPN
except (ValueError, ManufacturerPart.DoesNotExist, Part.DoesNotExist, Company.DoesNotExist):
pass
if part_id: if part_id:
try: try:
@ -493,7 +718,7 @@ class SupplierPartDelete(AjaxDeleteView):
""" """
success_url = '/supplier/' success_url = '/supplier/'
ajax_template_name = 'company/partdelete.html' ajax_template_name = 'company/supplier_part_delete.html'
ajax_form_title = _('Delete Supplier Part') ajax_form_title = _('Delete Supplier Part')
role_required = 'purchase_order.delete' role_required = 'purchase_order.delete'

View File

@ -253,10 +253,12 @@ class StockItemLabel(LabelTemplate):
'part': stock_item.part, 'part': stock_item.part,
'name': stock_item.part.full_name, 'name': stock_item.part.full_name,
'ipn': stock_item.part.IPN, 'ipn': stock_item.part.IPN,
'revision': stock_item.part.revision,
'quantity': normalize(stock_item.quantity), 'quantity': normalize(stock_item.quantity),
'serial': stock_item.serial, 'serial': stock_item.serial,
'uid': stock_item.uid, 'uid': stock_item.uid,
'qr_data': stock_item.format_barcode(brief=True), 'qr_data': stock_item.format_barcode(brief=True),
'qr_url': stock_item.format_barcode(url=True, request=request),
'tests': stock_item.testResultMap() 'tests': stock_item.testResultMap()
} }

View File

@ -1,6 +1,6 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: inventree1\n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-04-21 09:17+0000\n" "POT-Creation-Date: 2021-04-21 09:17+0000\n"
"PO-Revision-Date: 2021-04-21 10:02\n" "PO-Revision-Date: 2021-04-21 10:02\n"
@ -11,11 +11,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: inventree1\n"
"X-Crowdin-Project-ID: 450990\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: /l10_base/InvenTree/locale/en/LC_MESSAGES/django.po\n"
"X-Crowdin-File-ID: 98\n"
#: InvenTree/api.py:64 #: InvenTree/api.py:64
msgid "API endpoint not found" msgid "API endpoint not found"
@ -7230,4 +7225,3 @@ msgstr "Berechtigungen Einträge zu ändern"
#: users/models.py:184 #: users/models.py:184
msgid "Permission to delete items" msgid "Permission to delete items"
msgstr "Berechtigung Einträge zu löschen" msgstr "Berechtigung Einträge zu löschen"

View File

@ -1,6 +1,6 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: inventree1\n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-04-21 09:17+0000\n" "POT-Creation-Date: 2021-04-21 09:17+0000\n"
"PO-Revision-Date: 2021-04-21 09:33\n" "PO-Revision-Date: 2021-04-21 09:33\n"
@ -11,11 +11,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: inventree1\n"
"X-Crowdin-Project-ID: 450990\n"
"X-Crowdin-Language: es-ES\n"
"X-Crowdin-File: /l10_base/InvenTree/locale/en/LC_MESSAGES/django.po\n"
"X-Crowdin-File-ID: 98\n"
#: InvenTree/api.py:64 #: InvenTree/api.py:64
msgid "API endpoint not found" msgid "API endpoint not found"
@ -7226,4 +7221,3 @@ msgstr ""
#: users/models.py:184 #: users/models.py:184
msgid "Permission to delete items" msgid "Permission to delete items"
msgstr "" msgstr ""

Binary file not shown.

View File

@ -1,6 +1,6 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: inventree1\n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-04-21 09:17+0000\n" "POT-Creation-Date: 2021-04-21 09:17+0000\n"
"PO-Revision-Date: 2021-04-21 09:33\n" "PO-Revision-Date: 2021-04-21 09:33\n"
@ -11,11 +11,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: inventree1\n"
"X-Crowdin-Project-ID: 450990\n"
"X-Crowdin-Language: fr\n"
"X-Crowdin-File: /l10_base/InvenTree/locale/en/LC_MESSAGES/django.po\n"
"X-Crowdin-File-ID: 98\n"
#: InvenTree/api.py:64 #: InvenTree/api.py:64
msgid "API endpoint not found" msgid "API endpoint not found"

Binary file not shown.

View File

@ -1,6 +1,6 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: inventree1\n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-04-21 09:17+0000\n" "POT-Creation-Date: 2021-04-21 09:17+0000\n"
"PO-Revision-Date: 2021-04-21 09:33\n" "PO-Revision-Date: 2021-04-21 09:33\n"
@ -11,11 +11,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: inventree1\n"
"X-Crowdin-Project-ID: 450990\n"
"X-Crowdin-Language: it\n"
"X-Crowdin-File: /l10_base/InvenTree/locale/en/LC_MESSAGES/django.po\n"
"X-Crowdin-File-ID: 98\n"
#: InvenTree/api.py:64 #: InvenTree/api.py:64
msgid "API endpoint not found" msgid "API endpoint not found"

Binary file not shown.

View File

@ -1,6 +1,6 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: inventree1\n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-04-21 09:17+0000\n" "POT-Creation-Date: 2021-04-21 09:17+0000\n"
"PO-Revision-Date: 2021-04-21 09:33\n" "PO-Revision-Date: 2021-04-21 09:33\n"
@ -11,11 +11,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
"X-Crowdin-Project: inventree1\n"
"X-Crowdin-Project-ID: 450990\n"
"X-Crowdin-Language: ja\n"
"X-Crowdin-File: /l10_base/InvenTree/locale/en/LC_MESSAGES/django.po\n"
"X-Crowdin-File-ID: 98\n"
#: InvenTree/api.py:64 #: InvenTree/api.py:64
msgid "API endpoint not found" msgid "API endpoint not found"
@ -7226,4 +7221,3 @@ msgstr ""
#: users/models.py:184 #: users/models.py:184
msgid "Permission to delete items" msgid "Permission to delete items"
msgstr "" msgstr ""

Binary file not shown.

View File

@ -1,6 +1,6 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: inventree1\n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-04-21 09:17+0000\n" "POT-Creation-Date: 2021-04-21 09:17+0000\n"
"PO-Revision-Date: 2021-04-21 09:33\n" "PO-Revision-Date: 2021-04-21 09:33\n"
@ -11,11 +11,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
"X-Crowdin-Project: inventree1\n"
"X-Crowdin-Project-ID: 450990\n"
"X-Crowdin-Language: pl\n"
"X-Crowdin-File: /l10_base/InvenTree/locale/en/LC_MESSAGES/django.po\n"
"X-Crowdin-File-ID: 98\n"
#: InvenTree/api.py:64 #: InvenTree/api.py:64
msgid "API endpoint not found" msgid "API endpoint not found"
@ -7226,4 +7221,3 @@ msgstr ""
#: users/models.py:184 #: users/models.py:184
msgid "Permission to delete items" msgid "Permission to delete items"
msgstr "" msgstr ""

Binary file not shown.

View File

@ -1,6 +1,6 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: inventree1\n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-04-21 09:17+0000\n" "POT-Creation-Date: 2021-04-21 09:17+0000\n"
"PO-Revision-Date: 2021-04-21 09:33\n" "PO-Revision-Date: 2021-04-21 09:33\n"
@ -11,11 +11,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" "Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
"X-Crowdin-Project: inventree1\n"
"X-Crowdin-Project-ID: 450990\n"
"X-Crowdin-Language: ru\n"
"X-Crowdin-File: /l10_base/InvenTree/locale/en/LC_MESSAGES/django.po\n"
"X-Crowdin-File-ID: 98\n"
#: InvenTree/api.py:64 #: InvenTree/api.py:64
msgid "API endpoint not found" msgid "API endpoint not found"
@ -7226,4 +7221,3 @@ msgstr ""
#: users/models.py:184 #: users/models.py:184
msgid "Permission to delete items" msgid "Permission to delete items"
msgstr "" msgstr ""

Binary file not shown.

View File

@ -1,6 +1,6 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: inventree1\n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-04-21 09:17+0000\n" "POT-Creation-Date: 2021-04-21 09:17+0000\n"
"PO-Revision-Date: 2021-04-21 09:33\n" "PO-Revision-Date: 2021-04-21 09:33\n"
@ -11,11 +11,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
"X-Crowdin-Project: inventree1\n"
"X-Crowdin-Project-ID: 450990\n"
"X-Crowdin-Language: zh-CN\n"
"X-Crowdin-File: /l10_base/InvenTree/locale/en/LC_MESSAGES/django.po\n"
"X-Crowdin-File-ID: 98\n"
#: InvenTree/api.py:64 #: InvenTree/api.py:64
msgid "API endpoint not found" msgid "API endpoint not found"

View File

@ -6,7 +6,7 @@
{% load status_codes %} {% load status_codes %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Purchase Order" %} {% inventree_title %} | {% trans "Purchase Order" %}
{% endblock %} {% endblock %}
{% block thumbnail %} {% block thumbnail %}

View File

@ -7,8 +7,8 @@
{% trans 'Mark this order as complete?' %} {% trans 'Mark this order as complete?' %}
{% if not order.is_complete %} {% if not order.is_complete %}
<div class='alert alert-warning alert-block'> <div class='alert alert-warning alert-block'>
{%trans 'This order has line items which have not been marked as received. {% trans 'This order has line items which have not been marked as received.' %}
Marking this order as complete will remove these line items.' %} {% trans 'Marking this order as complete will remove these line items.' %}
</div> </div>
{% endif %} {% endif %}

View File

@ -54,7 +54,7 @@
</select> </select>
</div> </div>
{% if not part.order_supplier %} {% if not part.order_supplier %}
<span class='help-inline'>{% trans "Select a supplier for" %} <i>{{ part.name }}</i></span> <span class='help-inline'>{% blocktrans with name=part.name %}Select a supplier for <i>{{name}}</i>{% endblocktrans %}</span>
{% endif %} {% endif %}
</div> </div>
</td> </td>

View File

@ -42,7 +42,7 @@
<button <button
class='btn btn-default btn-create' class='btn btn-default btn-create'
id='new_po_{{ supplier.id }}' id='new_po_{{ supplier.id }}'
title='{% trans "Create new purchase order for {{ supplier.name }}" %}' title='{% blocktrans with name=supplier.name %}Create new purchase order for {{name}}{% endblocktrans %}'
type='button' type='button'
supplierid='{{ supplier.id }}' supplierid='{{ supplier.id }}'
onclick='newPurchaseOrderFromOrderWizard()'> onclick='newPurchaseOrderFromOrderWizard()'>
@ -65,7 +65,7 @@
</select> </select>
</div> </div>
{% if not supplier.selected_purchase_order %} {% if not supplier.selected_purchase_order %}
<span class='help-inline'>{% trans "Select a purchase order for" %} {{ supplier.name }}</span> <span class='help-inline'>{% blocktrans with name=supplier.name %}Select a purchase order for {{name}}{% endblocktrans %}</span>
{% endif %} {% endif %}
</div> </div>
</td> </td>

View File

@ -181,6 +181,13 @@ $("#po-table").inventreeTable({
sortName: 'part__MPN', sortName: 'part__MPN',
field: 'supplier_part_detail.MPN', field: 'supplier_part_detail.MPN',
title: '{% trans "MPN" %}', title: '{% trans "MPN" %}',
formatter: function(value, row, index, field) {
if (row.supplier_part_detail.manufacturer_part) {
return renderLink(value, `/manufacturer-part/${row.supplier_part_detail.manufacturer_part.pk}/`);
} else {
return "";
}
},
}, },
{ {
sortable: true, sortable: true,

View File

@ -5,7 +5,7 @@
{% load i18n %} {% load i18n %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Purchase Orders" %} {% inventree_title %} | {% trans "Purchase Orders" %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -5,7 +5,7 @@
{% block form %} {% block form %}
{% trans "Receive outstanding parts for" %} <b>{{ order }}</b> - <i>{{ order.description }}</i> {% blocktrans with desc=order.description %}Receive outstanding parts for <b>{{order}}</b> - <i>{{desc}}</i>{% endblocktrans %}
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'> <form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
{% csrf_token %} {% csrf_token %}

View File

@ -6,7 +6,7 @@
{% load status_codes %} {% load status_codes %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Sales Order" %} {% inventree_title %} | {% trans "Sales Order" %}
{% endblock %} {% endblock %}
{% block pre_content %} {% block pre_content %}

View File

@ -5,7 +5,7 @@
{% load i18n %} {% load i18n %}
{% block page_title %} {% block page_title %}
InvenTree | {% trans "Sales Orders" %} {% inventree_title %} | {% trans "Sales Orders" %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -8,6 +8,7 @@ from __future__ import unicode_literals
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django.http import JsonResponse from django.http import JsonResponse
from django.db.models import Q, F, Count, Prefetch, Sum from django.db.models import Q, F, Count, Prefetch, Sum
from django.utils.translation import ugettext_lazy as _
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
@ -36,7 +37,7 @@ from InvenTree.status_codes import BuildStatus
class PartCategoryTree(TreeSerializer): class PartCategoryTree(TreeSerializer):
title = "Parts" title = _("Parts")
model = PartCategory model = PartCategory
queryset = PartCategory.objects.all() queryset = PartCategory.objects.all()
@ -59,28 +60,43 @@ class CategoryList(generics.ListCreateAPIView):
queryset = PartCategory.objects.all() queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategorySerializer serializer_class = part_serializers.CategorySerializer
def get_queryset(self): def filter_queryset(self, queryset):
""" """
Custom filtering: Custom filtering:
- Allow filtering by "null" parent to retrieve top-level part categories - Allow filtering by "null" parent to retrieve top-level part categories
""" """
cat_id = self.request.query_params.get('parent', None) queryset = super().filter_queryset(queryset)
queryset = super().get_queryset() params = self.request.query_params
if cat_id is not None: cat_id = params.get('parent', None)
cascade = str2bool(params.get('cascade', False))
# Do not filter by category
if cat_id is None:
pass
# Look for top-level categories
elif isNull(cat_id):
# Look for top-level categories if not cascade:
if isNull(cat_id):
queryset = queryset.filter(parent=None) queryset = queryset.filter(parent=None)
else: else:
try: try:
cat_id = int(cat_id) category = PartCategory.objects.get(pk=cat_id)
queryset = queryset.filter(parent=cat_id)
except ValueError: if cascade:
pass parents = category.get_descendants(include_self=True)
parent_ids = [p.id for p in parents]
queryset = queryset.filter(parent__in=parent_ids)
else:
queryset = queryset.filter(parent=category)
except (ValueError, PartCategory.DoesNotExist):
pass
return queryset return queryset

View File

@ -16,7 +16,7 @@ from InvenTree.helpers import DownloadFile, GetExportFormats
from .admin import BomItemResource from .admin import BomItemResource
from .models import BomItem from .models import BomItem
from company.models import SupplierPart from company.models import ManufacturerPart, SupplierPart
def IsValidBOMFormat(fmt): def IsValidBOMFormat(fmt):
@ -49,7 +49,7 @@ def MakeBomTemplate(fmt):
return DownloadFile(data, filename) return DownloadFile(data, filename)
def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=False, stock_data=False, supplier_data=False): def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=False, stock_data=False, supplier_data=False, manufacturer_data=False):
""" Export a BOM (Bill of Materials) for a given part. """ Export a BOM (Bill of Materials) for a given part.
Args: Args:
@ -160,7 +160,123 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
# Add stock columns to dataset # Add stock columns to dataset
add_columns_to_dataset(stock_cols, len(bom_items)) add_columns_to_dataset(stock_cols, len(bom_items))
if supplier_data: if manufacturer_data and supplier_data:
"""
If requested, add extra columns for each SupplierPart and ManufacturerPart associated with each line item
"""
# Expand dataset with manufacturer parts
manufacturer_headers = [
_('Manufacturer'),
_('MPN'),
]
supplier_headers = [
_('Supplier'),
_('SKU'),
]
manufacturer_cols = {}
for b_idx, bom_item in enumerate(bom_items):
# Get part instance
b_part = bom_item.sub_part
# Filter manufacturer parts
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk)
manufacturer_parts = manufacturer_parts.prefetch_related('supplier_parts')
# Process manufacturer part
for manufacturer_idx, manufacturer_part in enumerate(manufacturer_parts):
if manufacturer_part:
manufacturer_name = manufacturer_part.manufacturer.name
else:
manufacturer_name = ''
manufacturer_mpn = manufacturer_part.MPN
# Generate column names for this manufacturer
k_man = manufacturer_headers[0] + "_" + str(manufacturer_idx)
k_mpn = manufacturer_headers[1] + "_" + str(manufacturer_idx)
try:
manufacturer_cols[k_man].update({b_idx: manufacturer_name})
manufacturer_cols[k_mpn].update({b_idx: manufacturer_mpn})
except KeyError:
manufacturer_cols[k_man] = {b_idx: manufacturer_name}
manufacturer_cols[k_mpn] = {b_idx: manufacturer_mpn}
# Process supplier parts
for supplier_idx, supplier_part in enumerate(manufacturer_part.supplier_parts.all()):
if supplier_part.supplier:
supplier_name = supplier_part.supplier.name
else:
supplier_name = ''
supplier_sku = supplier_part.SKU
# Generate column names for this supplier
k_sup = str(supplier_headers[0]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx)
k_sku = str(supplier_headers[1]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx)
try:
manufacturer_cols[k_sup].update({b_idx: supplier_name})
manufacturer_cols[k_sku].update({b_idx: supplier_sku})
except KeyError:
manufacturer_cols[k_sup] = {b_idx: supplier_name}
manufacturer_cols[k_sku] = {b_idx: supplier_sku}
# Add manufacturer columns to dataset
add_columns_to_dataset(manufacturer_cols, len(bom_items))
elif manufacturer_data:
"""
If requested, add extra columns for each ManufacturerPart associated with each line item
"""
# Expand dataset with manufacturer parts
manufacturer_headers = [
_('Manufacturer'),
_('MPN'),
]
manufacturer_cols = {}
for b_idx, bom_item in enumerate(bom_items):
# Get part instance
b_part = bom_item.sub_part
# Filter supplier parts
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk)
for idx, manufacturer_part in enumerate(manufacturer_parts):
if manufacturer_part:
manufacturer_name = manufacturer_part.manufacturer.name
else:
manufacturer_name = ''
manufacturer_mpn = manufacturer_part.MPN
# Add manufacturer data to the manufacturer columns
# Generate column names for this manufacturer
k_man = manufacturer_headers[0] + "_" + str(idx)
k_mpn = manufacturer_headers[1] + "_" + str(idx)
try:
manufacturer_cols[k_man].update({b_idx: manufacturer_name})
manufacturer_cols[k_mpn].update({b_idx: manufacturer_mpn})
except KeyError:
manufacturer_cols[k_man] = {b_idx: manufacturer_name}
manufacturer_cols[k_mpn] = {b_idx: manufacturer_mpn}
# Add manufacturer columns to dataset
add_columns_to_dataset(manufacturer_cols, len(bom_items))
elif supplier_data:
""" """
If requested, add extra columns for each SupplierPart associated with each line item If requested, add extra columns for each SupplierPart associated with each line item
""" """
@ -169,8 +285,6 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
manufacturer_headers = [ manufacturer_headers = [
_('Supplier'), _('Supplier'),
_('SKU'), _('SKU'),
_('Manufacturer'),
_('MPN'),
] ]
manufacturer_cols = {} manufacturer_cols = {}
@ -191,31 +305,18 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
supplier_sku = supplier_part.SKU supplier_sku = supplier_part.SKU
if supplier_part.manufacturer:
manufacturer_name = supplier_part.manufacturer.name
else:
manufacturer_name = ''
manufacturer_mpn = supplier_part.MPN
# Add manufacturer data to the manufacturer columns # Add manufacturer data to the manufacturer columns
# Generate column names for this supplier # Generate column names for this supplier
k_sup = manufacturer_headers[0] + "_" + str(idx) k_sup = manufacturer_headers[0] + "_" + str(idx)
k_sku = manufacturer_headers[1] + "_" + str(idx) k_sku = manufacturer_headers[1] + "_" + str(idx)
k_man = manufacturer_headers[2] + "_" + str(idx)
k_mpn = manufacturer_headers[3] + "_" + str(idx)
try: try:
manufacturer_cols[k_sup].update({b_idx: supplier_name}) manufacturer_cols[k_sup].update({b_idx: supplier_name})
manufacturer_cols[k_sku].update({b_idx: supplier_sku}) manufacturer_cols[k_sku].update({b_idx: supplier_sku})
manufacturer_cols[k_man].update({b_idx: manufacturer_name})
manufacturer_cols[k_mpn].update({b_idx: manufacturer_mpn})
except KeyError: except KeyError:
manufacturer_cols[k_sup] = {b_idx: supplier_name} manufacturer_cols[k_sup] = {b_idx: supplier_name}
manufacturer_cols[k_sku] = {b_idx: supplier_sku} manufacturer_cols[k_sku] = {b_idx: supplier_sku}
manufacturer_cols[k_man] = {b_idx: manufacturer_name}
manufacturer_cols[k_mpn] = {b_idx: manufacturer_mpn}
# Add manufacturer columns to dataset # Add manufacturer columns to dataset
add_columns_to_dataset(manufacturer_cols, len(bom_items)) add_columns_to_dataset(manufacturer_cols, len(bom_items))

View File

@ -95,9 +95,11 @@ class BomExportForm(forms.Form):
parameter_data = forms.BooleanField(label=_("Include Parameter Data"), required=False, initial=False, help_text=_("Include part parameters data in exported BOM")) parameter_data = forms.BooleanField(label=_("Include Parameter Data"), required=False, initial=False, help_text=_("Include part parameters data in exported BOM"))
stock_data = forms.BooleanField(label=_("Include Stock Data"), required=False, initial=False, help_text=_("Include part stock data in exported BOM")) stock_data = forms.BooleanField(label=_("Include Stock Data"), required=False, initial=False, help_text=_("Include part stock data in exported BOM"))
manufacturer_data = forms.BooleanField(label=_("Include Manufacturer Data"), required=False, initial=True, help_text=_("Include part manufacturer data in exported BOM"))
supplier_data = forms.BooleanField(label=_("Include Supplier Data"), required=False, initial=True, help_text=_("Include part supplier data in exported BOM")) supplier_data = forms.BooleanField(label=_("Include Supplier Data"), required=False, initial=True, help_text=_("Include part supplier data in exported BOM"))
def get_choices(self): def get_choices(self):
""" BOM export format choices """ """ BOM export format choices """

View File

@ -1163,7 +1163,16 @@ class Part(MPTTModel):
Return the total amount of this part allocated to build orders Return the total amount of this part allocated to build orders
""" """
query = self.build_order_allocations().aggregate(total=Coalesce(Sum('quantity'), 0)) query = self.build_order_allocations().aggregate(
total=Coalesce(
Sum(
'quantity',
output_field=models.DecimalField()
),
0,
output_field=models.DecimalField(),
)
)
return query['total'] return query['total']
@ -1179,7 +1188,16 @@ class Part(MPTTModel):
Return the tutal quantity of this part allocated to sales orders Return the tutal quantity of this part allocated to sales orders
""" """
query = self.sales_order_allocations().aggregate(total=Coalesce(Sum('quantity'), 0)) query = self.sales_order_allocations().aggregate(
total=Coalesce(
Sum(
'quantity',
output_field=models.DecimalField(),
),
0,
output_field=models.DecimalField(),
)
)
return query['total'] return query['total']
@ -1189,10 +1207,12 @@ class Part(MPTTModel):
against both build orders and sales orders. against both build orders and sales orders.
""" """
return sum([ return sum(
self.build_order_allocation_count(), [
self.sales_order_allocation_count(), self.build_order_allocation_count(),
]) self.sales_order_allocation_count(),
],
)
def stock_entries(self, include_variants=True, in_stock=None): def stock_entries(self, include_variants=True, in_stock=None):
""" Return all stock entries for this Part. """ Return all stock entries for this Part.

View File

@ -4,6 +4,7 @@ JSON serializers for Part app
import imghdr import imghdr
from decimal import Decimal from decimal import Decimal
from django.db import models
from django.db.models import Q from django.db.models import Q
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from InvenTree.serializers import (InvenTreeAttachmentSerializerField, from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
@ -208,7 +209,8 @@ class PartSerializer(InvenTreeModelSerializer):
queryset = queryset.annotate( queryset = queryset.annotate(
in_stock=Coalesce( in_stock=Coalesce(
SubquerySum('stock_items__quantity', filter=StockItem.IN_STOCK_FILTER), SubquerySum('stock_items__quantity', filter=StockItem.IN_STOCK_FILTER),
Decimal(0) Decimal(0),
output_field=models.DecimalField(),
), ),
) )
@ -227,6 +229,7 @@ class PartSerializer(InvenTreeModelSerializer):
building=Coalesce( building=Coalesce(
SubquerySum('builds__quantity', filter=build_filter), SubquerySum('builds__quantity', filter=build_filter),
Decimal(0), Decimal(0),
output_field=models.DecimalField(),
) )
) )
@ -240,9 +243,11 @@ class PartSerializer(InvenTreeModelSerializer):
ordering=Coalesce( ordering=Coalesce(
SubquerySum('supplier_parts__purchase_order_line_items__quantity', filter=order_filter), SubquerySum('supplier_parts__purchase_order_line_items__quantity', filter=order_filter),
Decimal(0), Decimal(0),
output_field=models.DecimalField(),
) - Coalesce( ) - Coalesce(
SubquerySum('supplier_parts__purchase_order_line_items__received', filter=order_filter), SubquerySum('supplier_parts__purchase_order_line_items__received', filter=order_filter),
Decimal(0), Decimal(0),
output_field=models.DecimalField(),
) )
) )
@ -251,6 +256,7 @@ class PartSerializer(InvenTreeModelSerializer):
suppliers=Coalesce( suppliers=Coalesce(
SubqueryCount('supplier_parts'), SubqueryCount('supplier_parts'),
Decimal(0), Decimal(0),
output_field=models.DecimalField(),
), ),
) )

View File

@ -2,6 +2,10 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% block menubar %}
{% include 'part/category_navbar.html' with tab='parts' %}
{% endblock %}
{% block content %} {% block content %}
<div class='panel panel-default panel-inventree'> <div class='panel panel-default panel-inventree'>
@ -100,14 +104,10 @@
</div> </div>
</div> </div>
{% if category and category.children.all|length > 0 %}
{% include "part/subcategories.html" with children=category.children.all collapse_id="categories" %}
{% elif children|length > 0 %}
{% include "part/subcategories.html" with children=children collapse_id="categories" %}
{% endif %}
</div> </div>
{% block category_content %}
<div id='button-toolbar'> <div id='button-toolbar'>
<div class='btn-group'> <div class='btn-group'>
<button class='btn btn-default' id='part-export' title='{% trans "Export Part Data" %}'> <button class='btn btn-default' id='part-export' title='{% trans "Export Part Data" %}'>
@ -150,6 +150,8 @@
</div> </div>
</div> </div>
{% endblock %}
{% block category_tables %} {% block category_tables %}
{% endblock category_tables %} {% endblock category_tables %}
@ -162,24 +164,10 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
{% if category %}
enableNavbar({ enableNavbar({
label: 'category', label: 'category',
toggleId: '#category-menu-toggle', toggleId: '#category-menu-toggle',
}); });
{% endif %}
if (inventreeLoadInt("show-part-cats") == 1) {
$("#collapse-item-categories").collapse('show');
}
$("#collapse-item-categories").on('shown.bs.collapse', function() {
inventreeSave('show-part-cats', 1);
});
$("#collapse-item-categories").on('hidden.bs.collapse', function() {
inventreeDel('show-part-cats');
});
$("#cat-create").click(function() { $("#cat-create").click(function() {
launchModalForm( launchModalForm(

View File

@ -5,7 +5,7 @@
{% trans 'Are you sure you want to delete category' %} <strong>{{ category.name }}</strong>? {% trans 'Are you sure you want to delete category' %} <strong>{{ category.name }}</strong>?
{% if category.children.all|length > 0 %} {% if category.children.all|length > 0 %}
<p>{% trans 'This category contains' %} {{ category.children.all|length }} {% trans 'child categories' %}.<br> <p>{% blocktrans with count=category.children.all|length%}This category contains {{count}} child categories{% endblocktrans %}.<br>
{% trans 'If this category is deleted, these child categories will be moved to the' %} {% trans 'If this category is deleted, these child categories will be moved to the' %}
{% if category.parent %} {% if category.parent %}
<strong>{{ category.parent.name }}</strong> {% trans 'category' %}. <strong>{{ category.parent.name }}</strong> {% trans 'category' %}.
@ -22,9 +22,9 @@
{% endif %} {% endif %}
{% if category.parts.all|length > 0 %} {% if category.parts.all|length > 0 %}
<p>{% trans 'This category contains' %} {{ category.parts.all|length }} {% trans 'parts' %}.<br> <p>{% blocktrans with count=category.parts.all|length %}This category contains {{count}} parts{% endblocktrans %}.<br>
{% if category.parent %} {% if category.parent %}
{% trans 'If this category is deleted, these parts will be moved to the parent category' %} {{ category.parent.pathstring }} {% blocktrans with path=category.parent.pathstring %}If this category is deleted, these parts will be moved to the parent category {{path}}{% endblocktrans %}
{% else %} {% else %}
{% trans 'If this category is deleted, these parts will be moved to the top-level category Teile' %} {% trans 'If this category is deleted, these parts will be moved to the top-level category Teile' %}
{% endif %} {% endif %}

View File

@ -8,17 +8,34 @@
</a> </a>
</li> </li>
<li class='list-group-item {% if tab == "subcategories" %}active{% endif %}' title='{% trans "Subcategories" %}'>
{% if category %}
<a href='{% url "category-subcategory" category.id %}'>
{% else %}
<a href='{% url "category-index-subcategory" %}'>
{% endif %}
<span class='fas fa-sitemap'></span>
{% trans "Subcategories" %}
</a>
</li>
<li class='list-group-item {% if tab == "parts" %}active{% endif %}' title='{% trans "Parts" %}'> <li class='list-group-item {% if tab == "parts" %}active{% endif %}' title='{% trans "Parts" %}'>
{% if category %}
<a href='{% url "category-detail" category.id %}'> <a href='{% url "category-detail" category.id %}'>
{% else %}
<a href='{% url "part-index" %}'>
{% endif %}
<span class='fas fa-shapes'></span> <span class='fas fa-shapes'></span>
{% trans "Parts" %} {% trans "Parts" %}
</a> </a>
</li> </li>
{% if category %}
<li class='list-group-item {% if tab == "parameters" %}active{% endif %}' title='{% trans "Parameters" %}'> <li class='list-group-item {% if tab == "parameters" %}active{% endif %}' title='{% trans "Parameters" %}'>
<a href='{% url "category-parametric" category.id %}'> <a href='{% url "category-parametric" category.id %}'>
<span class='fas fa-tasks'></span> <span class='fas fa-tasks'></span>
{% trans "Parameters" %} {% trans "Parameters" %}
</a> </a>
</li> </li>
{% endif %}
</ul> </ul>

View File

@ -7,7 +7,7 @@
<div class='alert alert-info alert-block'> <div class='alert alert-info alert-block'>
<strong>{% trans 'Duplicate Part' %}</strong><br> <strong>{% trans 'Duplicate Part' %}</strong><br>
{% trans 'Make a copy of part' %} '{{ part.full_name }}'. {% blocktrans with full_name=part.full_name %}Make a copy of part '{{full_name}}'.{% endblocktrans %}
</div> </div>
{% if matches %} {% if matches %}

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