mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
1269 lines
37 KiB
Python
1269 lines
37 KiB
Python
"""
|
|
Build database model definitions
|
|
"""
|
|
|
|
# -*- coding: utf-8 -*-
|
|
from __future__ import unicode_literals
|
|
import decimal
|
|
|
|
import os
|
|
from datetime import datetime
|
|
|
|
from django.utils.translation import ugettext_lazy as _
|
|
|
|
from django.contrib.auth.models import User
|
|
from django.core.exceptions import ValidationError
|
|
|
|
from django.urls import reverse
|
|
from django.db import models, transaction
|
|
from django.db.models import Sum, Q
|
|
from django.db.models.functions import Coalesce
|
|
from django.core.validators import MinValueValidator
|
|
|
|
from markdownx.models import MarkdownxField
|
|
|
|
from mptt.models import MPTTModel, TreeForeignKey
|
|
from mptt.exceptions import InvalidMove
|
|
|
|
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
|
|
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
|
|
from InvenTree.validators import validate_build_order_reference
|
|
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
|
|
|
|
import common.models
|
|
|
|
import InvenTree.fields
|
|
import InvenTree.helpers
|
|
|
|
from stock import models as StockModels
|
|
from part import models as PartModels
|
|
from users import models as UserModels
|
|
|
|
|
|
def get_next_build_number():
|
|
"""
|
|
Returns the next available BuildOrder reference number
|
|
"""
|
|
|
|
if Build.objects.count() == 0:
|
|
return
|
|
|
|
build = Build.objects.exclude(reference=None).last()
|
|
|
|
attempts = set([build.reference])
|
|
|
|
reference = build.reference
|
|
|
|
while 1:
|
|
reference = increment(reference)
|
|
|
|
if reference in attempts:
|
|
# Escape infinite recursion
|
|
return reference
|
|
|
|
if Build.objects.filter(reference=reference).exists():
|
|
attempts.add(reference)
|
|
else:
|
|
break
|
|
|
|
return reference
|
|
|
|
|
|
class Build(MPTTModel, ReferenceIndexingMixin):
|
|
""" A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
|
|
|
Attributes:
|
|
part: The part to be built (from component BOM items)
|
|
reference: Build order reference (required, must be unique)
|
|
title: Brief title describing the build (required)
|
|
quantity: Number of units to be built
|
|
parent: Reference to a Build object for which this Build is required
|
|
sales_order: References to a SalesOrder object for which this Build is required (e.g. the output of this build will be used to fulfil a sales order)
|
|
take_from: Location to take stock from to make this build (if blank, can take from anywhere)
|
|
status: Build status code
|
|
batch: Batch code transferred to build parts (optional)
|
|
creation_date: Date the build was created (auto)
|
|
target_date: Date the build will be overdue
|
|
completion_date: Date the build was completed (or, if incomplete, the expected date of completion)
|
|
link: External URL for extra information
|
|
notes: Text notes
|
|
completed_by: User that completed the build
|
|
issued_by: User that issued the build
|
|
responsible: User (or group) responsible for completing the build
|
|
"""
|
|
|
|
OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
|
|
|
|
@staticmethod
|
|
def get_api_url():
|
|
return reverse('api-build-list')
|
|
|
|
def api_instance_filters(self):
|
|
|
|
return {
|
|
'parent': {
|
|
'exclude_tree': self.pk,
|
|
}
|
|
}
|
|
|
|
def save(self, *args, **kwargs):
|
|
|
|
self.rebuild_reference_field()
|
|
|
|
try:
|
|
super().save(*args, **kwargs)
|
|
except InvalidMove:
|
|
raise ValidationError({
|
|
'parent': _('Invalid choice for parent build'),
|
|
})
|
|
|
|
class Meta:
|
|
verbose_name = _("Build Order")
|
|
verbose_name_plural = _("Build Orders")
|
|
|
|
def format_barcode(self, **kwargs):
|
|
"""
|
|
Return a JSON string to represent this build as a barcode
|
|
"""
|
|
|
|
return MakeBarcode(
|
|
"buildorder",
|
|
self.pk,
|
|
{
|
|
"reference": self.title,
|
|
"url": self.get_absolute_url(),
|
|
}
|
|
)
|
|
|
|
@staticmethod
|
|
def filterByDate(queryset, min_date, max_date):
|
|
"""
|
|
Filter by 'minimum and maximum date range'
|
|
|
|
- Specified as min_date, max_date
|
|
- Both must be specified for filter to be applied
|
|
"""
|
|
|
|
date_fmt = '%Y-%m-%d' # ISO format date string
|
|
|
|
# Ensure that both dates are valid
|
|
try:
|
|
min_date = datetime.strptime(str(min_date), date_fmt).date()
|
|
max_date = datetime.strptime(str(max_date), date_fmt).date()
|
|
except (ValueError, TypeError):
|
|
# Date processing error, return queryset unchanged
|
|
return queryset
|
|
|
|
# Order was completed within the specified range
|
|
completed = Q(status=BuildStatus.COMPLETE) & Q(completion_date__gte=min_date) & Q(completion_date__lte=max_date)
|
|
|
|
# Order target date falls witin specified range
|
|
pending = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__gte=min_date) & Q(target_date__lte=max_date)
|
|
|
|
# TODO - Construct a queryset for "overdue" orders
|
|
|
|
queryset = queryset.filter(completed | pending)
|
|
|
|
return queryset
|
|
|
|
def __str__(self):
|
|
|
|
prefix = getSetting("BUILDORDER_REFERENCE_PREFIX")
|
|
|
|
return f"{prefix}{self.reference}"
|
|
|
|
def get_absolute_url(self):
|
|
return reverse('build-detail', kwargs={'pk': self.id})
|
|
|
|
reference = models.CharField(
|
|
unique=True,
|
|
max_length=64,
|
|
blank=False,
|
|
help_text=_('Build Order Reference'),
|
|
verbose_name=_('Reference'),
|
|
default=get_next_build_number,
|
|
validators=[
|
|
validate_build_order_reference
|
|
]
|
|
)
|
|
|
|
title = models.CharField(
|
|
verbose_name=_('Description'),
|
|
blank=False,
|
|
max_length=100,
|
|
help_text=_('Brief description of the build')
|
|
)
|
|
|
|
# TODO - Perhaps delete the build "tree"
|
|
parent = TreeForeignKey(
|
|
'self',
|
|
on_delete=models.SET_NULL,
|
|
blank=True, null=True,
|
|
related_name='children',
|
|
verbose_name=_('Parent Build'),
|
|
help_text=_('BuildOrder to which this build is allocated'),
|
|
)
|
|
|
|
part = models.ForeignKey(
|
|
'part.Part',
|
|
verbose_name=_('Part'),
|
|
on_delete=models.CASCADE,
|
|
related_name='builds',
|
|
limit_choices_to={
|
|
'assembly': True,
|
|
'active': True,
|
|
'virtual': False,
|
|
},
|
|
help_text=_('Select part to build'),
|
|
)
|
|
|
|
sales_order = models.ForeignKey(
|
|
'order.SalesOrder',
|
|
verbose_name=_('Sales Order Reference'),
|
|
on_delete=models.SET_NULL,
|
|
related_name='builds',
|
|
null=True, blank=True,
|
|
help_text=_('SalesOrder to which this build is allocated')
|
|
)
|
|
|
|
take_from = models.ForeignKey(
|
|
'stock.StockLocation',
|
|
verbose_name=_('Source Location'),
|
|
on_delete=models.SET_NULL,
|
|
related_name='sourcing_builds',
|
|
null=True, blank=True,
|
|
help_text=_('Select location to take stock from for this build (leave blank to take from any stock location)')
|
|
)
|
|
|
|
destination = models.ForeignKey(
|
|
'stock.StockLocation',
|
|
verbose_name=_('Destination Location'),
|
|
on_delete=models.SET_NULL,
|
|
related_name='incoming_builds',
|
|
null=True, blank=True,
|
|
help_text=_('Select location where the completed items will be stored'),
|
|
)
|
|
|
|
quantity = models.PositiveIntegerField(
|
|
verbose_name=_('Build Quantity'),
|
|
default=1,
|
|
validators=[MinValueValidator(1)],
|
|
help_text=_('Number of stock items to build')
|
|
)
|
|
|
|
completed = models.PositiveIntegerField(
|
|
verbose_name=_('Completed items'),
|
|
default=0,
|
|
help_text=_('Number of stock items which have been completed')
|
|
)
|
|
|
|
status = models.PositiveIntegerField(
|
|
verbose_name=_('Build Status'),
|
|
default=BuildStatus.PENDING,
|
|
choices=BuildStatus.items(),
|
|
validators=[MinValueValidator(0)],
|
|
help_text=_('Build status code')
|
|
)
|
|
|
|
batch = models.CharField(
|
|
verbose_name=_('Batch Code'),
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text=_('Batch code for this build output')
|
|
)
|
|
|
|
creation_date = models.DateField(auto_now_add=True, editable=False, verbose_name=_('Creation Date'))
|
|
|
|
target_date = models.DateField(
|
|
null=True, blank=True,
|
|
verbose_name=_('Target completion date'),
|
|
help_text=_('Target date for build completion. Build will be overdue after this date.')
|
|
)
|
|
|
|
completion_date = models.DateField(null=True, blank=True, verbose_name=_('Completion Date'))
|
|
|
|
completed_by = models.ForeignKey(
|
|
User,
|
|
on_delete=models.SET_NULL,
|
|
blank=True, null=True,
|
|
verbose_name=_('completed by'),
|
|
related_name='builds_completed'
|
|
)
|
|
|
|
issued_by = models.ForeignKey(
|
|
User,
|
|
on_delete=models.SET_NULL,
|
|
blank=True, null=True,
|
|
verbose_name=_('Issued by'),
|
|
help_text=_('User who issued this build order'),
|
|
related_name='builds_issued',
|
|
)
|
|
|
|
responsible = models.ForeignKey(
|
|
UserModels.Owner,
|
|
on_delete=models.SET_NULL,
|
|
blank=True, null=True,
|
|
verbose_name=_('Responsible'),
|
|
help_text=_('User responsible for this build order'),
|
|
related_name='builds_responsible',
|
|
)
|
|
|
|
link = InvenTree.fields.InvenTreeURLField(
|
|
verbose_name=_('External Link'),
|
|
blank=True, help_text=_('Link to external URL')
|
|
)
|
|
|
|
notes = MarkdownxField(
|
|
verbose_name=_('Notes'),
|
|
blank=True, help_text=_('Extra build notes')
|
|
)
|
|
|
|
def sub_builds(self, cascade=True):
|
|
"""
|
|
Return all Build Order objects under this one.
|
|
"""
|
|
|
|
if cascade:
|
|
return Build.objects.filter(parent=self.pk)
|
|
else:
|
|
descendants = self.get_descendants(include_self=True)
|
|
Build.objects.filter(parent__pk__in=[d.pk for d in descendants])
|
|
|
|
def sub_build_count(self, cascade=True):
|
|
"""
|
|
Return the number of sub builds under this one.
|
|
|
|
Args:
|
|
cascade: If True (defualt), include cascading builds under sub builds
|
|
"""
|
|
|
|
return self.sub_builds(cascade=cascade).count()
|
|
|
|
@property
|
|
def is_overdue(self):
|
|
"""
|
|
Returns true if this build is "overdue":
|
|
|
|
Makes use of the OVERDUE_FILTER to avoid code duplication
|
|
"""
|
|
|
|
query = Build.objects.filter(pk=self.pk)
|
|
query = query.filter(Build.OVERDUE_FILTER)
|
|
|
|
return query.exists()
|
|
|
|
@property
|
|
def active(self):
|
|
"""
|
|
Return True if this build is active
|
|
"""
|
|
|
|
return self.status in BuildStatus.ACTIVE_CODES
|
|
|
|
@property
|
|
def bom_items(self):
|
|
"""
|
|
Returns the BOM items for the part referenced by this BuildOrder
|
|
"""
|
|
|
|
return self.part.bom_items.all().prefetch_related(
|
|
'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
|
|
def remaining(self):
|
|
"""
|
|
Return the number of outputs remaining to be completed.
|
|
"""
|
|
|
|
return max(0, self.quantity - self.completed)
|
|
|
|
@property
|
|
def output_count(self):
|
|
return self.build_outputs.count()
|
|
|
|
def get_build_outputs(self, **kwargs):
|
|
"""
|
|
Return a list of build outputs.
|
|
|
|
kwargs:
|
|
complete = (True / False) - If supplied, filter by completed status
|
|
in_stock = (True / False) - If supplied, filter by 'in-stock' status
|
|
"""
|
|
|
|
outputs = self.build_outputs.all()
|
|
|
|
# Filter by 'in stock' status
|
|
in_stock = kwargs.get('in_stock', None)
|
|
|
|
if in_stock is not None:
|
|
if in_stock:
|
|
outputs = outputs.filter(StockModels.StockItem.IN_STOCK_FILTER)
|
|
else:
|
|
outputs = outputs.exclude(StockModels.StockItem.IN_STOCK_FILTER)
|
|
|
|
# Filter by 'complete' status
|
|
complete = kwargs.get('complete', None)
|
|
|
|
if complete is not None:
|
|
if complete:
|
|
outputs = outputs.filter(is_building=False)
|
|
else:
|
|
outputs = outputs.filter(is_building=True)
|
|
|
|
return outputs
|
|
|
|
@property
|
|
def complete_outputs(self):
|
|
"""
|
|
Return all the "completed" build outputs
|
|
"""
|
|
|
|
outputs = self.get_build_outputs(complete=True)
|
|
|
|
# TODO - Ordering?
|
|
|
|
return outputs
|
|
|
|
@property
|
|
def incomplete_outputs(self):
|
|
"""
|
|
Return all the "incomplete" build outputs
|
|
"""
|
|
|
|
outputs = self.get_build_outputs(complete=False)
|
|
|
|
# TODO - Order by how "complete" they are?
|
|
|
|
return outputs
|
|
|
|
@property
|
|
def incomplete_count(self):
|
|
"""
|
|
Return the total number of "incomplete" outputs
|
|
"""
|
|
|
|
quantity = 0
|
|
|
|
for output in self.incomplete_outputs:
|
|
quantity += output.quantity
|
|
|
|
return quantity
|
|
|
|
@classmethod
|
|
def getNextBuildNumber(cls):
|
|
"""
|
|
Try to predict the next Build Order reference:
|
|
"""
|
|
|
|
if cls.objects.count() == 0:
|
|
return None
|
|
|
|
# Extract the "most recent" build order reference
|
|
builds = cls.objects.exclude(reference=None)
|
|
|
|
if not builds.exists():
|
|
return None
|
|
|
|
build = builds.last()
|
|
ref = build.reference
|
|
|
|
if not ref:
|
|
return None
|
|
|
|
tries = set(ref)
|
|
|
|
new_ref = ref
|
|
|
|
while 1:
|
|
new_ref = increment(new_ref)
|
|
|
|
if new_ref in tries:
|
|
# We are potentially stuck in a loop - simply return the original reference
|
|
return ref
|
|
|
|
# Check if the existing build reference exists
|
|
if cls.objects.filter(reference=new_ref).exists():
|
|
tries.add(new_ref)
|
|
else:
|
|
break
|
|
|
|
return new_ref
|
|
|
|
@property
|
|
def can_complete(self):
|
|
"""
|
|
Returns True if this build can be "completed"
|
|
|
|
- Must not have any outstanding build outputs
|
|
- 'completed' value must meet (or exceed) the 'quantity' value
|
|
"""
|
|
|
|
if self.incomplete_count > 0:
|
|
return False
|
|
|
|
if self.completed < self.quantity:
|
|
return False
|
|
|
|
if not self.areUntrackedPartsFullyAllocated():
|
|
return False
|
|
|
|
# No issues!
|
|
return True
|
|
|
|
@transaction.atomic
|
|
def complete_build(self, user):
|
|
"""
|
|
Mark this build as complete
|
|
"""
|
|
|
|
if self.incomplete_count > 0:
|
|
return
|
|
|
|
self.completion_date = datetime.now().date()
|
|
self.completed_by = user
|
|
self.status = BuildStatus.COMPLETE
|
|
self.save()
|
|
|
|
# Remove untracked allocated stock
|
|
self.subtractUntrackedStock(user)
|
|
|
|
# Ensure that there are no longer any BuildItem objects
|
|
# which point to thie Build Order
|
|
self.allocated_stock.all().delete()
|
|
|
|
@transaction.atomic
|
|
def cancelBuild(self, user):
|
|
""" Mark the Build as CANCELLED
|
|
|
|
- Delete any pending BuildItem objects (but do not remove items from stock)
|
|
- Set build status to CANCELLED
|
|
- Save the Build object
|
|
"""
|
|
|
|
for item in self.allocated_stock.all():
|
|
item.delete()
|
|
|
|
# Date of 'completion' is the date the build was cancelled
|
|
self.completion_date = datetime.now().date()
|
|
self.completed_by = user
|
|
|
|
self.status = BuildStatus.CANCELLED
|
|
self.save()
|
|
|
|
@transaction.atomic
|
|
def unallocateStock(self, bom_item=None, output=None):
|
|
"""
|
|
Unallocate stock from this Build
|
|
|
|
arguments:
|
|
- bom_item: Specify a particular BomItem to unallocate stock against
|
|
- output: Specify a particular StockItem (output) to unallocate stock against
|
|
"""
|
|
|
|
allocations = BuildItem.objects.filter(
|
|
build=self,
|
|
install_into=output
|
|
)
|
|
|
|
if bom_item:
|
|
allocations = allocations.filter(bom_item=bom_item)
|
|
|
|
allocations.delete()
|
|
|
|
@transaction.atomic
|
|
def create_build_output(self, quantity, **kwargs):
|
|
"""
|
|
Create a new build output against this BuildOrder.
|
|
|
|
args:
|
|
quantity: The quantity of the item to produce
|
|
|
|
kwargs:
|
|
batch: Override batch code
|
|
serials: Serial numbers
|
|
location: Override location
|
|
"""
|
|
|
|
batch = kwargs.get('batch', self.batch)
|
|
location = kwargs.get('location', self.destination)
|
|
serials = kwargs.get('serials', None)
|
|
|
|
"""
|
|
Determine if we can create a single output (with quantity > 0),
|
|
or multiple outputs (with quantity = 1)
|
|
"""
|
|
|
|
multiple = False
|
|
|
|
# Serial numbers are provided? We need to split!
|
|
if serials:
|
|
multiple = True
|
|
|
|
# BOM has trackable parts, so we must split!
|
|
if self.part.has_trackable_parts:
|
|
multiple = True
|
|
|
|
if multiple:
|
|
"""
|
|
Create multiple build outputs with a single quantity of 1
|
|
"""
|
|
|
|
for ii in range(quantity):
|
|
|
|
if serials:
|
|
serial = serials[ii]
|
|
else:
|
|
serial = None
|
|
|
|
StockModels.StockItem.objects.create(
|
|
quantity=1,
|
|
location=location,
|
|
part=self.part,
|
|
build=self,
|
|
batch=batch,
|
|
serial=serial,
|
|
is_building=True,
|
|
)
|
|
|
|
else:
|
|
"""
|
|
Create a single build output of the given quantity
|
|
"""
|
|
|
|
StockModels.StockItem.objects.create(
|
|
quantity=quantity,
|
|
location=location,
|
|
part=self.part,
|
|
build=self,
|
|
batch=batch,
|
|
is_building=True
|
|
)
|
|
|
|
if self.status == BuildStatus.PENDING:
|
|
self.status = BuildStatus.PRODUCTION
|
|
self.save()
|
|
|
|
@transaction.atomic
|
|
def deleteBuildOutput(self, output):
|
|
"""
|
|
Remove a build output from the database:
|
|
|
|
- Unallocate any build items against the output
|
|
- Delete the output StockItem
|
|
"""
|
|
|
|
if not output:
|
|
raise ValidationError(_("No build output specified"))
|
|
|
|
if not output.is_building:
|
|
raise ValidationError(_("Build output is already completed"))
|
|
|
|
if not output.build == self:
|
|
raise ValidationError(_("Build output does not match Build Order"))
|
|
|
|
# Unallocate all build items against the output
|
|
self.unallocateStock(output=output)
|
|
|
|
# Remove the build output from the database
|
|
output.delete()
|
|
|
|
@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
|
|
def complete_build_output(self, output, user, **kwargs):
|
|
"""
|
|
Complete a particular build output
|
|
|
|
- Remove allocated StockItems
|
|
- Mark the output as complete
|
|
"""
|
|
|
|
# Select the location for the build output
|
|
location = kwargs.get('location', self.destination)
|
|
status = kwargs.get('status', StockStatus.OK)
|
|
notes = kwargs.get('notes', '')
|
|
|
|
# List the allocated BuildItem objects for the given output
|
|
allocated_items = output.items_to_install.all()
|
|
|
|
for build_item in allocated_items:
|
|
# Complete the allocation of stock for that item
|
|
build_item.complete_allocation(user)
|
|
|
|
# Delete the BuildItem objects from the database
|
|
allocated_items.all().delete()
|
|
|
|
# Ensure that the output is updated correctly
|
|
output.build = self
|
|
output.is_building = False
|
|
output.location = location
|
|
output.status = status
|
|
|
|
output.save()
|
|
|
|
output.add_tracking_entry(
|
|
StockHistoryCode.BUILD_OUTPUT_COMPLETED,
|
|
user,
|
|
notes=notes,
|
|
deltas={
|
|
'status': status,
|
|
}
|
|
)
|
|
|
|
# Increase the completed quantity for this build
|
|
self.completed += output.quantity
|
|
|
|
self.save()
|
|
|
|
def requiredQuantity(self, part, output):
|
|
"""
|
|
Get the quantity of a part required to complete the particular build output.
|
|
|
|
Args:
|
|
part: The Part object
|
|
output - The particular build output (StockItem)
|
|
"""
|
|
|
|
# Extract the BOM line item from the database
|
|
try:
|
|
bom_item = PartModels.BomItem.objects.get(part=self.part.pk, sub_part=part.pk)
|
|
quantity = bom_item.quantity
|
|
except (PartModels.BomItem.DoesNotExist):
|
|
quantity = 0
|
|
|
|
if output:
|
|
quantity *= output.quantity
|
|
else:
|
|
quantity *= self.quantity
|
|
|
|
return quantity
|
|
|
|
def allocatedItems(self, part, output):
|
|
"""
|
|
Return all BuildItem objects which allocate stock of <part> to <output>
|
|
|
|
Args:
|
|
part - The part object
|
|
output - Build output (StockItem).
|
|
"""
|
|
|
|
# Remember, if 'variant' stock is allowed to be allocated, it becomes more complicated!
|
|
variants = part.get_descendants(include_self=True)
|
|
|
|
allocations = BuildItem.objects.filter(
|
|
build=self,
|
|
stock_item__part__pk__in=[p.pk for p in variants],
|
|
install_into=output,
|
|
)
|
|
|
|
return allocations
|
|
|
|
def allocatedQuantity(self, part, output):
|
|
"""
|
|
Return the total quantity of given part allocated to a given build output.
|
|
"""
|
|
|
|
allocations = self.allocatedItems(part, output)
|
|
|
|
allocated = allocations.aggregate(
|
|
q=Coalesce(
|
|
Sum('quantity'),
|
|
0,
|
|
output_field=models.DecimalField(),
|
|
)
|
|
)
|
|
|
|
return allocated['q']
|
|
|
|
def unallocatedQuantity(self, part, output):
|
|
"""
|
|
Return the total unallocated (remaining) quantity of a part against a particular output.
|
|
"""
|
|
|
|
required = self.requiredQuantity(part, output)
|
|
allocated = self.allocatedQuantity(part, output)
|
|
|
|
return max(required - allocated, 0)
|
|
|
|
def isPartFullyAllocated(self, part, output):
|
|
"""
|
|
Returns True if the part has been fully allocated to the particular build output
|
|
"""
|
|
|
|
return self.unallocatedQuantity(part, output) == 0
|
|
|
|
def isFullyAllocated(self, output, verbose=False):
|
|
"""
|
|
Returns True if the particular build output is fully allocated.
|
|
"""
|
|
|
|
# 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
|
|
|
|
if not self.isPartFullyAllocated(part, output):
|
|
fully_allocated = False
|
|
|
|
if verbose:
|
|
print(f"Part {part} is not fully allocated for output {output}")
|
|
else:
|
|
break
|
|
|
|
# All parts must be fully allocated!
|
|
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):
|
|
"""
|
|
Return a list of parts which have been fully allocated against a particular output
|
|
"""
|
|
|
|
allocated = []
|
|
|
|
# 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
|
|
|
|
if self.isPartFullyAllocated(part, output):
|
|
allocated.append(part)
|
|
|
|
return allocated
|
|
|
|
def unallocatedParts(self, output):
|
|
"""
|
|
Return a list of parts which have *not* been fully allocated against a particular output
|
|
"""
|
|
|
|
unallocated = []
|
|
|
|
# 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
|
|
|
|
if not self.isPartFullyAllocated(part, output):
|
|
unallocated.append(part)
|
|
|
|
return unallocated
|
|
|
|
@property
|
|
def required_parts(self):
|
|
""" Returns a list of parts required to build this part (BOM) """
|
|
parts = []
|
|
|
|
for item in self.bom_items:
|
|
parts.append(item.sub_part)
|
|
|
|
return parts
|
|
|
|
@property
|
|
def required_parts_to_complete_build(self):
|
|
""" Returns a list of parts required to complete the full build """
|
|
parts = []
|
|
|
|
for bom_item in self.bom_items:
|
|
# Get remaining quantity needed
|
|
required_quantity_to_complete_build = self.remaining * bom_item.quantity
|
|
# Compare to net stock
|
|
if bom_item.sub_part.net_stock < required_quantity_to_complete_build:
|
|
parts.append(bom_item.sub_part)
|
|
|
|
return parts
|
|
|
|
def availableStockItems(self, part, output):
|
|
"""
|
|
Returns stock items which are available for allocation to this build.
|
|
|
|
Args:
|
|
part - Part object
|
|
output - The particular build output
|
|
"""
|
|
|
|
# Grab initial query for items which are "in stock" and match the part
|
|
items = StockModels.StockItem.objects.filter(
|
|
StockModels.StockItem.IN_STOCK_FILTER
|
|
)
|
|
|
|
# Check if variants are allowed for this part
|
|
try:
|
|
bom_item = PartModels.BomItem.objects.get(part=self.part, sub_part=part)
|
|
allow_part_variants = bom_item.allow_variants
|
|
except PartModels.BomItem.DoesNotExist:
|
|
allow_part_variants = False
|
|
|
|
if allow_part_variants:
|
|
parts = part.get_descendants(include_self=True)
|
|
items = items.filter(part__pk__in=[p.pk for p in parts])
|
|
|
|
else:
|
|
items = items.filter(part=part)
|
|
|
|
# Exclude any items which have already been allocated
|
|
allocated = BuildItem.objects.filter(
|
|
build=self,
|
|
stock_item__part=part,
|
|
install_into=output,
|
|
)
|
|
|
|
items = items.exclude(
|
|
id__in=[item.stock_item.id for item in allocated.all()]
|
|
)
|
|
|
|
# Limit query to stock items which are "downstream" of the source location
|
|
if self.take_from is not None:
|
|
items = items.filter(
|
|
location__in=[loc for loc in self.take_from.getUniqueChildren()]
|
|
)
|
|
|
|
# Exclude expired stock items
|
|
if not common.models.InvenTreeSetting.get_setting('STOCK_ALLOW_EXPIRED_BUILD'):
|
|
items = items.exclude(StockModels.StockItem.EXPIRED_FILTER)
|
|
|
|
return items
|
|
|
|
@property
|
|
def is_active(self):
|
|
""" Is this build active? An active build is either:
|
|
|
|
- PENDING
|
|
- HOLDING
|
|
"""
|
|
|
|
return self.status in BuildStatus.ACTIVE_CODES
|
|
|
|
@property
|
|
def is_complete(self):
|
|
""" Returns True if the build status is COMPLETE """
|
|
|
|
return self.status == BuildStatus.COMPLETE
|
|
|
|
|
|
class BuildOrderAttachment(InvenTreeAttachment):
|
|
"""
|
|
Model for storing file attachments against a BuildOrder object
|
|
"""
|
|
|
|
def getSubdir(self):
|
|
return os.path.join('bo_files', str(self.build.id))
|
|
|
|
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
|
|
|
|
|
|
class BuildItem(models.Model):
|
|
""" A BuildItem links multiple StockItem objects to a Build.
|
|
These are used to allocate part stock to a build.
|
|
Once the Build is completed, the parts are removed from stock and the
|
|
BuildItemAllocation objects are removed.
|
|
|
|
Attributes:
|
|
build: Link to a Build object
|
|
bom_item: Link to a BomItem object (may or may not point to the same part as the build)
|
|
stock_item: Link to a StockItem object
|
|
quantity: Number of units allocated
|
|
install_into: Destination stock item (or None)
|
|
"""
|
|
|
|
@staticmethod
|
|
def get_api_url():
|
|
return reverse('api-build-item-list')
|
|
|
|
def get_absolute_url(self):
|
|
# TODO - Fix!
|
|
return '/build/item/{pk}/'.format(pk=self.id)
|
|
# return reverse('build-detail', kwargs={'pk': self.id})
|
|
|
|
class Meta:
|
|
unique_together = [
|
|
('build', 'stock_item', 'install_into'),
|
|
]
|
|
|
|
def save(self, *args, **kwargs):
|
|
|
|
self.clean()
|
|
|
|
super().save()
|
|
|
|
def clean(self):
|
|
"""
|
|
Check validity of this BuildItem instance.
|
|
The following checks are performed:
|
|
|
|
- StockItem.part must be in the BOM of the Part object referenced by Build
|
|
- Allocation quantity cannot exceed available quantity
|
|
"""
|
|
|
|
self.validate_unique()
|
|
|
|
super().clean()
|
|
|
|
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 quantity cannot exceed available stock quantity
|
|
if self.quantity > self.stock_item.quantity:
|
|
|
|
q = normalize(self.quantity)
|
|
a = normalize(self.stock_item.quantity)
|
|
|
|
raise ValidationError({
|
|
'quantity': _(f'Allocated quantity ({q}) must not execed available stock quantity ({a})')
|
|
})
|
|
|
|
# Allocated quantity cannot cause the stock item to be over-allocated
|
|
available = decimal.Decimal(self.stock_item.quantity)
|
|
allocated = decimal.Decimal(self.stock_item.allocation_count())
|
|
quantity = decimal.Decimal(self.quantity)
|
|
|
|
if available - allocated + quantity < quantity:
|
|
raise ValidationError({
|
|
'quantity': _('Stock item is over-allocated')
|
|
})
|
|
|
|
# Allocated quantity must be positive
|
|
if self.quantity <= 0:
|
|
raise ValidationError({
|
|
'quantity': _('Allocation quantity must be greater than zero'),
|
|
})
|
|
|
|
# Quantity must be 1 for serialized stock
|
|
if self.stock_item.serialized and not self.quantity == 1:
|
|
raise ValidationError({
|
|
'quantity': _('Quantity must be 1 for serialized stock')
|
|
})
|
|
|
|
except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist):
|
|
pass
|
|
|
|
"""
|
|
Attempt to find the "BomItem" which links this BuildItem to the build.
|
|
|
|
- If a BomItem is already set, and it is valid, then we are ok!
|
|
"""
|
|
|
|
bom_item_valid = False
|
|
|
|
if self.bom_item:
|
|
"""
|
|
A BomItem object has already been assigned. This is valid if:
|
|
|
|
a) It points to the same "part" as the referenced build
|
|
b) Either:
|
|
i) The sub_part points to the same part as the referenced StockItem
|
|
ii) The BomItem allows variants and the part referenced by the StockItem
|
|
is a variant of the sub_part referenced by the BomItem
|
|
iii) The Part referenced by the StockItem is a valid substitute for the BomItem
|
|
"""
|
|
|
|
if self.build and self.build.part == self.bom_item.part:
|
|
|
|
bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item)
|
|
|
|
# If the existing BomItem is *not* valid, try to find a match
|
|
if not bom_item_valid:
|
|
|
|
if self.build and self.stock_item:
|
|
ancestors = self.stock_item.part.get_ancestors(include_self=True, ascending=True)
|
|
|
|
for idx, ancestor in enumerate(ancestors):
|
|
|
|
try:
|
|
bom_item = PartModels.BomItem.objects.get(part=self.build.part, sub_part=ancestor)
|
|
except PartModels.BomItem.DoesNotExist:
|
|
continue
|
|
|
|
# A matching BOM item has been found!
|
|
if idx == 0 or bom_item.allow_variants:
|
|
bom_item_valid = True
|
|
self.bom_item = bom_item
|
|
break
|
|
|
|
# BomItem did not exist or could not be validated.
|
|
# Search for a new one
|
|
if not bom_item_valid:
|
|
|
|
raise ValidationError({
|
|
'stock_item': _("Selected stock item not found in BOM")
|
|
})
|
|
|
|
@transaction.atomic
|
|
def complete_allocation(self, user):
|
|
"""
|
|
Complete the allocation of this BuildItem into the output stock item.
|
|
|
|
- If the referenced part is trackable, the stock item will be *installed* into the build output
|
|
- If the referenced part is *not* trackable, the stock item will be removed from stock
|
|
"""
|
|
|
|
item = self.stock_item
|
|
|
|
# For a trackable part, special consideration needed!
|
|
if item.part.trackable:
|
|
# Split the allocated stock if there are more available than allocated
|
|
if item.quantity > self.quantity:
|
|
item = item.splitStock(self.quantity, None, user)
|
|
|
|
# Make sure we are pointing to the new item
|
|
self.stock_item = item
|
|
self.save()
|
|
|
|
# Install the stock item into the output
|
|
item.belongs_to = self.install_into
|
|
item.save()
|
|
else:
|
|
# Simply remove the items from stock
|
|
item.take_stock(self.quantity, user)
|
|
|
|
def getStockItemThumbnail(self):
|
|
"""
|
|
Return qualified URL for part thumbnail image
|
|
"""
|
|
|
|
thumb_url = None
|
|
|
|
if self.stock_item and self.stock_item.part:
|
|
try:
|
|
# Try to extract the thumbnail
|
|
thumb_url = self.stock_item.part.image.thumbnail.url
|
|
except:
|
|
pass
|
|
|
|
if thumb_url is None and self.bom_item and self.bom_item.sub_part:
|
|
try:
|
|
thumb_url = self.bom_item.sub_part.image.thumbnail.url
|
|
except:
|
|
pass
|
|
|
|
if thumb_url is not None:
|
|
return InvenTree.helpers.getMediaUrl(thumb_url)
|
|
else:
|
|
return InvenTree.helpers.getBlankThumbnail()
|
|
|
|
build = models.ForeignKey(
|
|
Build,
|
|
on_delete=models.CASCADE,
|
|
related_name='allocated_stock',
|
|
verbose_name=_('Build'),
|
|
help_text=_('Build to allocate parts')
|
|
)
|
|
|
|
# Internal model which links part <-> sub_part
|
|
# We need to track this separately, to allow for "variant' stock
|
|
bom_item = models.ForeignKey(
|
|
PartModels.BomItem,
|
|
on_delete=models.CASCADE,
|
|
related_name='allocate_build_items',
|
|
blank=True, null=True,
|
|
)
|
|
|
|
stock_item = models.ForeignKey(
|
|
'stock.StockItem',
|
|
on_delete=models.CASCADE,
|
|
related_name='allocations',
|
|
verbose_name=_('Stock Item'),
|
|
help_text=_('Source stock item'),
|
|
limit_choices_to={
|
|
'sales_order': None,
|
|
'belongs_to': None,
|
|
}
|
|
)
|
|
|
|
quantity = models.DecimalField(
|
|
decimal_places=5,
|
|
max_digits=15,
|
|
default=1,
|
|
validators=[MinValueValidator(0)],
|
|
verbose_name=_('Quantity'),
|
|
help_text=_('Stock quantity to allocate to build')
|
|
)
|
|
|
|
install_into = models.ForeignKey(
|
|
'stock.StockItem',
|
|
on_delete=models.SET_NULL,
|
|
blank=True, null=True,
|
|
related_name='items_to_install',
|
|
verbose_name=_('Install into'),
|
|
help_text=_('Destination stock item'),
|
|
limit_choices_to={
|
|
'is_building': True,
|
|
}
|
|
)
|