From 5289609c115cbabda9178d605f9af2a8fb146e71 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 15 Feb 2026 11:19:05 +1100 Subject: [PATCH 1/3] Add code for supporting build outputs --- inventree/build.py | 92 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/inventree/build.py b/inventree/build.py index d20fb3d..a7c6158 100644 --- a/inventree/build.py +++ b/inventree/build.py @@ -21,7 +21,7 @@ class Build( def issue(self): """Mark this build as 'issued'.""" return self._statusupdate(status='issue') - + def hold(self): """Mark this build as 'on hold'.""" return self._statusupdate(status='hold') @@ -54,6 +54,94 @@ def getLines(self, **kwargs): """ Return the build line items associated with this build order """ return BuildLine.list(self._api, build=self.pk, **kwargs) + def getBuildOutputs(self, complete: bool = None, **kwargs): + """ Return the build output items associated with this build order + + Arguments: + - complete: If not None, filter the build outputs by their 'complete' status + """ + if complete is not None: + kwargs['complete'] = complete + + # Find stock items which are marked as 'outputs' of this build order + return inventree.stock.StockItem.list( + self._api, + build=self.pk, + **kwargs + ) + + def createBuildOutput(self, **kwargs): + """ Create a new build output (stock item) associated with this build order """ + return self._api.post( + f'{self.URL}create_output/', + data={ + **kwargs + } + ) + + def cancelBuildOutputs(self, outputs): + """ Cancel a build output item associated with this build order + + Arguments: + - outputs: The StockItem object (or list of StockItem objects, or PK(s)) to cancel + """ + + if not isinstance(outputs, list): + outputs = [outputs] + + for idx, output in outputs: + if isinstance(output, inventree.stock.StockItem): + outputs[idx] = output.pk + + return self._api.post( + f'{self.URL}delete-outputs/', + data={ + 'outputs': outputs, + } + ) + + def scrapBuildOutput(self, outputs, **kwargs): + """ Scrap a build output item associated with this build order + + Arguments: + - outputs: The StockItem object (or list of StockItem objects, or PK(s)) to scrap + """ + if not isinstance(outputs, list): + outputs = [outputs] + + for idx, output in outputs: + if isinstance(output, inventree.stock.StockItem): + outputs[idx] = output.pk + + return self._api.post( + f'{self.URL}scrap-outputs/', + data={ + 'build': self.pk, + 'outputs': { + # TODO + }, + **kwargs + } + ) + + def completeBuildOutput(self, stock_item, **kwargs): + """ Mark a build output item as complete + + Arguments: + - stock_item: The StockItem object (or PK) to mark as complete + """ + if isinstance(stock_item, inventree.stock.StockItem): + stock_item = stock_item.pk + + return self._api.post( + f'{self.URL}complete_output/', + data={ + 'build': self.pk, + 'stock_item': stock_item, + **kwargs + } + ) + class BuildLine( inventree.base.InventreeObject, @@ -83,7 +171,7 @@ def getBuild(self): def getBuildLine(self): """Return the BuildLine object associated with this build item""" return BuildLine(self._api, self.build_line) - + def getStockItem(self): """Return the StockItem object associated with this build item""" return inventree.stock.StockItem(self._api, self.stock_item) From 3d8c75163d1f946167fe1169a99428a669cdafc2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 15 Feb 2026 11:36:18 +1100 Subject: [PATCH 2/3] Add unit testing for creating build outputs --- inventree/build.py | 7 +++++-- test/test_build.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/inventree/build.py b/inventree/build.py index a7c6158..dd4ca67 100644 --- a/inventree/build.py +++ b/inventree/build.py @@ -72,13 +72,16 @@ def getBuildOutputs(self, complete: bool = None, **kwargs): def createBuildOutput(self, **kwargs): """ Create a new build output (stock item) associated with this build order """ - return self._api.post( - f'{self.URL}create_output/', + result = self._api.post( + f'{self.URL}{self.pk}/create-output/', data={ **kwargs } ) + # Note: The response is a list of created stock items + return [inventree.stock.StockItem(self._api, item['pk'], item) for item in result] + def cancelBuildOutputs(self, outputs): """ Cancel a build output item associated with this build order diff --git a/test/test_build.py b/test/test_build.py index f7db883..e215a74 100644 --- a/test/test_build.py +++ b/test/test_build.py @@ -144,3 +144,55 @@ def test_build_complete(self): # Check status self.assertEqual(build.status, 40) self.assertEqual(build.status_text, 'Complete') + + +class BuildOrderOutputTests(InvenTreeTestCase): + """ Unit tests for build output functionality """ + + def setUp(self): + """ Ensure we have a base build order to work with """ + + super().setUp() + + builds = Build.list(self.api) + + self.build = Build.create( + self.api, + { + "title": "A new build order", + "part": 25, + "quantity": 10, + "reference": f"BO-{len(builds) + 1:04d}" + } + ) + + def test_create_build_output(self): + """Test that we can create a build output item""" + + # Initially, there should be no build outputs + outputs = self.build.getBuildOutputs() + self.assertEqual(len(outputs), 0) + + # Let's create 3 new outputs (with serial numbers) + outputs = self.build.createBuildOutput( + quantity=3, + batch_code='TEST-BATCH-001', + serial_numbers='300+' + ) + + self.assertEqual(len(outputs), 3) + self.assertEqual(len(self.build.getBuildOutputs()), 3) + + for output in outputs: + self.assertIsNotNone(output) + self.assertEqual(output.quantity, 1) + self.assertEqual(output.batch, 'TEST-BATCH-001') + self.assertEqual(output.build, self.build.pk) + self.assertEqual(output.part, self.build.part) + self.assertTrue(output.is_building) + + # Directly delete the build output + output.delete() + + # There should now be no build outputs again + self.assertEqual(len(self.build.getBuildOutputs()), 0) From e32b12d49665d9b3e558e10ab63a269b43b8f32e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 15 Feb 2026 22:39:02 +1100 Subject: [PATCH 3/3] Implement remaining functionality --- inventree/build.py | 75 ++++++++++++++++++++++------------------- test/test_build.py | 84 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 123 insertions(+), 36 deletions(-) diff --git a/inventree/build.py b/inventree/build.py index dd4ca67..6c60b03 100644 --- a/inventree/build.py +++ b/inventree/build.py @@ -61,7 +61,7 @@ def getBuildOutputs(self, complete: bool = None, **kwargs): - complete: If not None, filter the build outputs by their 'complete' status """ if complete is not None: - kwargs['complete'] = complete + kwargs['is_building'] = not complete # Find stock items which are marked as 'outputs' of this build order return inventree.stock.StockItem.list( @@ -86,63 +86,68 @@ def cancelBuildOutputs(self, outputs): """ Cancel a build output item associated with this build order Arguments: - - outputs: The StockItem object (or list of StockItem objects, or PK(s)) to cancel + - outputs: The StockItem object (or list of StockItem objects) to cancel """ if not isinstance(outputs, list): outputs = [outputs] - for idx, output in outputs: - if isinstance(output, inventree.stock.StockItem): - outputs[idx] = output.pk - return self._api.post( - f'{self.URL}delete-outputs/', + f'{self.URL}{self.pk}/delete-outputs/', data={ - 'outputs': outputs, + 'outputs': [ + {'output': output.pk} for output in outputs + ] } ) - def scrapBuildOutput(self, outputs, **kwargs): - """ Scrap a build output item associated with this build order + def scrapBuildOutput(self, output, **kwargs): + """ Scrap a single build output item associated with this build order Arguments: - - outputs: The StockItem object (or list of StockItem objects, or PK(s)) to scrap + - output: The StockItem object to scrap """ - if not isinstance(outputs, list): - outputs = [outputs] - for idx, output in outputs: - if isinstance(output, inventree.stock.StockItem): - outputs[idx] = output.pk + data = { + **kwargs, + 'outputs': [ + { + 'output': output.pk, + 'quantity': kwargs.get('quantity', output.quantity), + } + ] + } + + data['location'] = kwargs.get('location', output.location) return self._api.post( - f'{self.URL}scrap-outputs/', - data={ - 'build': self.pk, - 'outputs': { - # TODO - }, - **kwargs - } + f'{self.URL}{self.pk}/scrap-outputs/', + data=data ) - def completeBuildOutput(self, stock_item, **kwargs): - """ Mark a build output item as complete + def completeBuildOutput(self, output, **kwargs): + """ Mark a single build output item as complete Arguments: - - stock_item: The StockItem object (or PK) to mark as complete + - output: The StockItem object to mark as complete """ - if isinstance(stock_item, inventree.stock.StockItem): - stock_item = stock_item.pk + + data = { + **kwargs, + 'outputs': [ + { + 'output': output.pk, + 'quantity': kwargs.get('quantity', output.quantity), + } + ] + } + + # If a location is not specified, use the current location of the stock item + data['location'] = kwargs.get('location', output.location) return self._api.post( - f'{self.URL}complete_output/', - data={ - 'build': self.pk, - 'stock_item': stock_item, - **kwargs - } + f'{self.URL}{self.pk}/complete/', + data=data ) diff --git a/test/test_build.py b/test/test_build.py index e215a74..914a34a 100644 --- a/test/test_build.py +++ b/test/test_build.py @@ -177,7 +177,7 @@ def test_create_build_output(self): outputs = self.build.createBuildOutput( quantity=3, batch_code='TEST-BATCH-001', - serial_numbers='300+' + serial_numbers='400+' ) self.assertEqual(len(outputs), 3) @@ -196,3 +196,85 @@ def test_create_build_output(self): # There should now be no build outputs again self.assertEqual(len(self.build.getBuildOutputs()), 0) + + def test_cancel_build_output(self): + """ Test that we can cancel a build output item """ + + self.assertEqual(len(self.build.getBuildOutputs()), 0) + + # Create a new build output + output = self.build.createBuildOutput( + quantity=1, + batch_code='TEST-BATCH-001', + serial_numbers='456' + )[0] + + self.assertEqual(len(self.build.getBuildOutputs()), 1) + + self.build.cancelBuildOutputs(output) + self.assertEqual(len(self.build.getBuildOutputs()), 0) + + def test_complete_build_output(self): + """ Test that we can complete a build output item """ + + self.assertEqual(len(self.build.getBuildOutputs()), 0) + + # Create a new build output + output = self.build.createBuildOutput( + quantity=1, + batch_code='TEST-BATCH-001', + serial_numbers='457' + )[0] + + q = self.build.completed + + self.assertTrue(output.is_building) + self.assertEqual(len(self.build.getBuildOutputs()), 1) + + # Complete the build output + self.build.completeBuildOutput(output, location=1) + + self.assertEqual(len(self.build.getBuildOutputs()), 1) + output.reload() + self.assertFalse(output.is_building) + + # Remove the output + output.delete() + self.assertEqual(len(self.build.getBuildOutputs()), 0) + + # The number of "completed" items should have increased by 1 + self.build.reload() + self.assertEqual(self.build.completed, q + 1) + + def test_scrap_build_output(self): + """Test that we can scrap a build output item""" + + self.assertEqual(len(self.build.getBuildOutputs()), 0) + + # Create a new build output + output = self.build.createBuildOutput( + quantity=1, + batch_code='TEST-BATCH-001', + serial_numbers='468' + )[0] + + q = self.build.completed + + self.assertTrue(output.is_building) + self.assertEqual(len(self.build.getBuildOutputs()), 1) + + # Scrap the build output + self.build.scrapBuildOutput(output, location=1, notes='Test scrap') + self.assertEqual(len(self.build.getBuildOutputs()), 1) + self.assertEqual(len(self.build.getBuildOutputs(complete=False)), 0) + self.assertEqual(len(self.build.getBuildOutputs(complete=True)), 1) + + output.reload() + self.assertFalse(output.is_building) + + # Remove the build output + output.delete() + + # The number of "completed" items should not have increased + self.build.reload() + self.assertEqual(self.build.completed, q)