From 7578cab9a8572bcff29b604cbeaa35f9d8e2ac05 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 1 Jun 2021 15:25:39 +1000
Subject: [PATCH] Add 'bom_item' field to BuildItem model

- Required to link the build to the output in case of variant stock
---
 .../migrations/0028_builditem_bom_item.py     | 20 +++++
 InvenTree/build/models.py                     | 82 +++++++++++++++++--
 2 files changed, 97 insertions(+), 5 deletions(-)
 create mode 100644 InvenTree/build/migrations/0028_builditem_bom_item.py

diff --git a/InvenTree/build/migrations/0028_builditem_bom_item.py b/InvenTree/build/migrations/0028_builditem_bom_item.py
new file mode 100644
index 0000000000..f93c63dc4c
--- /dev/null
+++ b/InvenTree/build/migrations/0028_builditem_bom_item.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2 on 2021-06-01 05:23
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('part', '0066_bomitem_allow_variants'),
+        ('build', '0027_auto_20210404_2016'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='builditem',
+            name='bom_item',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='allocate_build_items', to='part.bomitem'),
+        ),
+    ]
diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py
index c80c0e8523..afe7f335e4 100644
--- a/InvenTree/build/models.py
+++ b/InvenTree/build/models.py
@@ -1036,7 +1036,19 @@ class Build(MPTTModel):
             StockModels.StockItem.IN_STOCK_FILTER
         )
 
-        items = items.filter(part=part)
+        # 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(
@@ -1160,10 +1172,6 @@ class BuildItem(models.Model):
             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
-            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)]
-
             # Allocated quantity cannot exceed available stock quantity
             if self.quantity > self.stock_item.quantity:
                 errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})").format(
@@ -1189,6 +1197,61 @@ class BuildItem(models.Model):
         if len(errors) > 0:
             raise ValidationError(errors)
 
+        """
+        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 referened 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
+            """
+
+            if self.build and self.build.part == self.bom_item.part:
+
+                # Check that the sub_part points to the stock_item (either directly or via a variant)
+                if self.bom_item.sub_part == self.stock_item.part:
+                    bom_item_valid = True
+
+                elif self.bom_item.allow_variants and self.stock_item.part in self.bom_item.sub_part.get_descendants(include_self=False):
+                    bom_item_valid = True
+
+        # 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 for part '{p}'").format(p=self.build.part.full_name)
+            })
+
     @transaction.atomic
     def complete_allocation(self, user):
         """
@@ -1225,6 +1288,15 @@ class BuildItem(models.Model):
         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,