diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index a96523fdc3..2677a3da5c 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -563,6 +563,12 @@ def send_email(subject, body, recipients, from_email=None, html_message=None): if type(recipients) == str: recipients = [recipients] + import InvenTree.ready + + if InvenTree.ready.isImportingData(): + # If we are importing data, don't send emails + return + offload_task( django_mail.send_mail, subject, diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 9942f455d7..5e32ab1f1b 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -1349,39 +1349,48 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model): """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 + - If the referenced part is *not* trackable, the stock item will be *consumed* by the build order """ item = self.stock_item + # Split the allocated stock if there are more available than allocated + if item.quantity > self.quantity: + item = item.splitStock( + self.quantity, + None, + user, + notes=notes, + ) + # 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, - code=StockHistoryCode.BUILD_CONSUMED, - ) - # Make sure we are pointing to the new item - self.stock_item = item - self.save() + # Make sure we are pointing to the new item + self.stock_item = item + self.save() # Install the stock item into the output self.install_into.installStockItem( item, self.quantity, user, - notes + notes, + build=self.build, ) else: - # Simply remove the items from stock - item.take_stock( - self.quantity, + # Mark the item as "consumed" by the build order + item.consumed_by = self.build + item.save(add_note=False) + + item.add_tracking_entry( + StockHistoryCode.BUILD_CONSUMED, user, - code=StockHistoryCode.BUILD_CONSUMED + notes=notes, + deltas={ + 'buildorder': self.build.pk, + 'quantity': float(item.quantity), + } ) def getStockItemThumbnail(self): diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 029464fd17..162263783a 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -282,6 +282,18 @@ +
+
+

+ {% trans "Consumed Stock" %} +

+
+ +
+ {% include "stock_table.html" with read_only=True prefix="consumed-" %} +
+
+

@@ -329,6 +341,17 @@ {% block js_ready %} {{ block.super }} +onPanelLoad('consumed', function() { + loadStockTable($('#consumed-stock-table'), { + params: { + location_detail: true, + part_detail: true, + consumed_by: {{ build.pk }}, + in_stock: false, + }, + }); +}); + onPanelLoad('completed', function() { loadStockTable($("#build-stock-table"), { params: { @@ -337,11 +360,9 @@ onPanelLoad('completed', function() { build: {{ build.id }}, is_building: false, }, - groupByField: 'location', buttons: [ '#stock-options', ], - url: "{% url 'api-stock-list' %}", }); }); diff --git a/InvenTree/build/templates/build/sidebar.html b/InvenTree/build/templates/build/sidebar.html index 4c22845980..a7b7b15df7 100644 --- a/InvenTree/build/templates/build/sidebar.html +++ b/InvenTree/build/templates/build/sidebar.html @@ -8,7 +8,9 @@ {% trans "Allocate Stock" as text %} {% include "sidebar_item.html" with label='allocate' text=text icon="fa-tasks" %} {% endif %} -{% if not build.is_complete %} +{% trans "Consumed Stock" as text %} +{% include "sidebar_item.html" with label='consumed' text=text icon="fa-list" %} +{% if build.is_active %} {% trans "Incomplete Outputs" as text %} {% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %} {% endif %} diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index 5279894cf1..be63936cb9 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -424,6 +424,7 @@ class BuildTest(BuildTestBase): extra_2_2: 4, # 35 } ) + self.assertTrue(self.build.has_overallocated_parts(None)) self.build.trim_allocated_stock() @@ -433,15 +434,30 @@ class BuildTest(BuildTestBase): self.build.complete_build_output(self.output_2, None) self.assertTrue(self.build.can_complete) + n = StockItem.objects.filter(consumed_by=self.build).count() + self.build.complete_build(None) self.assertEqual(self.build.status, status.BuildStatus.COMPLETE) # Check stock items are in expected state. self.assertEqual(StockItem.objects.get(pk=self.stock_1_2.pk).quantity, 53) - self.assertEqual(StockItem.objects.filter(part=self.sub_part_2).aggregate(Sum('quantity'))['quantity__sum'], 5) + + # Total stock quantity has not been decreased + items = StockItem.objects.filter(part=self.sub_part_2) + self.assertEqual(items.aggregate(Sum('quantity'))['quantity__sum'], 35) + + # However, the "available" stock quantity has been decreased + self.assertEqual(items.filter(consumed_by=None).aggregate(Sum('quantity'))['quantity__sum'], 5) + + # And the "consumed_by" quantity has been increased + self.assertEqual(items.filter(consumed_by=self.build).aggregate(Sum('quantity'))['quantity__sum'], 30) + self.assertEqual(StockItem.objects.get(pk=self.stock_3_1.pk).quantity, 980) + # Check that the "consumed_by" item count has increased + self.assertEqual(StockItem.objects.filter(consumed_by=self.build).count(), n + 8) + def test_cancel(self): """Test cancellation of the build""" @@ -510,15 +526,12 @@ class BuildTest(BuildTestBase): self.assertEqual(BuildItem.objects.count(), 0) # New stock items should have been created! - self.assertEqual(StockItem.objects.count(), 10) + self.assertEqual(StockItem.objects.count(), 13) - # This stock item has been depleted! - with self.assertRaises(StockItem.DoesNotExist): - StockItem.objects.get(pk=self.stock_1_1.pk) - - # This stock item has also been depleted - with self.assertRaises(StockItem.DoesNotExist): - StockItem.objects.get(pk=self.stock_2_1.pk) + # This stock item has been marked as "consumed" + item = StockItem.objects.get(pk=self.stock_1_1.pk) + self.assertIsNotNone(item.consumed_by) + self.assertFalse(item.in_stock) # And 10 new stock items created for the build output outputs = StockItem.objects.filter(build=self.build) diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index 87fc1f0f10..1e88babae3 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -313,9 +313,7 @@ loadStockTable($("#stock-table"), { location_detail: true, part_detail: false, }, - groupByField: 'location', buttons: ['#stock-options'], - url: "{% url 'api-stock-list' %}", }); $("#item-create").click(function() { diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index c4018e3055..562641d01a 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -822,11 +822,9 @@ part_detail: true, supplier_part_detail: true, }, - groupByField: 'location', buttons: [ '#stock-options', ], - url: "{% url 'api-stock-list' %}", }); $('#item-create').click(function () { diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 3fb3585fb7..803ccb979f 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -356,6 +356,7 @@ class StockFilter(rest_filters.FilterSet): 'belongs_to', 'build', 'customer', + 'consumed_by', 'sales_order', 'purchase_order', 'tags__name', diff --git a/InvenTree/stock/migrations/0100_stockitem_consumed_by.py b/InvenTree/stock/migrations/0100_stockitem_consumed_by.py new file mode 100644 index 0000000000..6f302c3a75 --- /dev/null +++ b/InvenTree/stock/migrations/0100_stockitem_consumed_by.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.19 on 2023-05-14 23:48 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0042_alter_build_notes'), + ('stock', '0100_auto_20230515_0004'), + ] + + operations = [ + migrations.AddField( + model_name='stockitem', + name='consumed_by', + field=models.ForeignKey(blank=True, help_text='Build order which consumed this stock item', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consumed_stock', to='build.build', verbose_name='Consumed By'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 498e59eee6..074265ed41 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -332,6 +332,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo sales_order=None, belongs_to=None, customer=None, + consumed_by=None, is_building=False, status__in=StockStatus.AVAILABLE_CODES ) @@ -755,6 +756,14 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo related_name='build_outputs', ) + consumed_by = models.ForeignKey( + 'build.Build', on_delete=models.CASCADE, + verbose_name=_('Consumed By'), + blank=True, null=True, + help_text=_('Build order which consumed this stock item'), + related_name='consumed_stock', + ) + is_building = models.BooleanField( default=False, ) @@ -1167,7 +1176,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo return self.installed_parts.count() @transaction.atomic - def installStockItem(self, other_item, quantity, user, notes): + def installStockItem(self, other_item, quantity, user, notes, build=None): """Install another stock item into this stock item. Args: @@ -1175,6 +1184,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo quantity: The quantity of stock to install user: The user performing the operation notes: Any notes associated with the operation + build: The BuildOrder to associate with the operation (optional) """ # If the quantity is less than the stock item, split the stock! stock_item = other_item.splitStock(quantity, None, user) @@ -1184,16 +1194,22 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo # Assign the other stock item into this one stock_item.belongs_to = self - stock_item.save() + stock_item.consumed_by = build + stock_item.save(add_note=False) + + deltas = { + 'stockitem': self.pk, + } + + if build is not None: + deltas['buildorder'] = build.pk # Add a transaction note to the other item stock_item.add_tracking_entry( StockHistoryCode.INSTALLED_INTO_ASSEMBLY, user, notes=notes, - deltas={ - 'stockitem': self.pk, - } + deltas=deltas, ) # Add a transaction note to this item (the assembly) @@ -1574,7 +1590,6 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo The new item will have a different StockItem ID, while this will remain the same. """ notes = kwargs.get('notes', '') - code = kwargs.get('code', StockHistoryCode.SPLIT_FROM_PARENT) # Do not split a serialized part if self.serialized: @@ -1606,30 +1621,31 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo else: new_stock.location = self.location - new_stock.save() + new_stock.save(add_note=False) - # Copy the transaction history of this part into the new one - new_stock.copyHistoryFrom(self) + # Add a stock tracking entry for the newly created item + new_stock.add_tracking_entry( + StockHistoryCode.SPLIT_FROM_PARENT, + user, + quantity=quantity, + notes=notes, + location=location, + deltas={ + 'stockitem': self.pk, + } + ) # Copy the test results of this part to the new one new_stock.copyTestResultsFrom(self) - # Add a new tracking item for the new stock item - new_stock.add_tracking_entry( - code, - user, - notes=notes, - deltas={ - 'stockitem': self.pk, - }, - location=location, - ) - # Remove the specified quantity from THIS stock item self.take_stock( quantity, user, - notes=notes + code=StockHistoryCode.SPLIT_CHILD_ITEM, + notes=notes, + location=location, + stockitem=new_stock, ) # Return a copy of the "new" stock item @@ -1798,7 +1814,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo return True @transaction.atomic - def take_stock(self, quantity, user, notes='', code=StockHistoryCode.STOCK_REMOVE): + def take_stock(self, quantity, user, notes='', code=StockHistoryCode.STOCK_REMOVE, **kwargs): """Remove items from stock.""" # Cannot remove items from a serialized part if self.serialized: @@ -1814,14 +1830,22 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo if self.updateQuantity(self.quantity - quantity): + deltas = { + 'removed': float(quantity), + 'quantity': float(self.quantity), + } + + if location := kwargs.get('location', None): + deltas['location'] = location.pk + + if stockitem := kwargs.get('stockitem', None): + deltas['stockitem'] = stockitem.pk + self.add_tracking_entry( code, user, notes=notes, - deltas={ - 'removed': float(quantity), - 'quantity': float(self.quantity), - } + deltas=deltas, ) return True diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 92f6a795c6..d048a82977 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -93,6 +93,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): 'batch', 'belongs_to', 'build', + 'consumed_by', 'customer', 'delete_on_deplete', 'expired', diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index cd08abbe0d..f922e158ec 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -315,11 +315,9 @@ ancestor: {{ item.id }}, }, name: 'item-childs', - groupByField: 'location', buttons: [ '#stock-options', ], - url: "{% url 'api-stock-list' %}", }); {% endif %} diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 45484d0701..da04f739da 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -358,6 +358,12 @@ {{ item.belongs_to }} + {% elif item.consumed_by %} + + + {% trans "Consumed By" %} + {{ item.consumed_by }} + {% elif item.sales_order %} diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 93e653053c..c65505a757 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -423,7 +423,6 @@ location_detail: true, supplier_part_detail: true, }, - url: "{% url 'api-stock-list' %}", }); }); diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html index 0ba67f5821..94a0713957 100644 --- a/InvenTree/templates/InvenTree/index.html +++ b/InvenTree/templates/InvenTree/index.html @@ -160,7 +160,6 @@ loadStockTable($('#table-recently-updated-stock'), { limit: {% settings_value "STOCK_RECENT_COUNT" user=request.user %}, }, name: 'recently-updated-stock', - grouping: false, }); {% endif %} diff --git a/InvenTree/templates/InvenTree/search.html b/InvenTree/templates/InvenTree/search.html index b25275b749..0b4c28c8d1 100644 --- a/InvenTree/templates/InvenTree/search.html +++ b/InvenTree/templates/InvenTree/search.html @@ -156,7 +156,6 @@ loadStockTable($('#table-stock'), { filterKey: 'stocksearch', - url: "{% url 'api-stock-list' %}", params: { original_search: search_text, part_detail: true, diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index c1b6b83cd3..0d675bb582 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -671,7 +671,11 @@ function scrapBuildOutputs(build_id, outputs, options={}) { method: 'POST', preFormContent: html, fields: { - location: {}, + location: { + filters: { + structural: false, + } + }, notes: {}, discard_allocations: {}, }, diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 0aebdfa0ae..39694458d0 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1617,26 +1617,29 @@ function loadStockTestResultsTable(table, options) { } +/* + * Function to display a "location" of a StockItem. + * + * Complicating factors: A StockItem may not actually *be* in a location! + * - Could be at a customer + * - Could be installed in another stock item + * - Could be assigned to a sales order + * - Could be currently in production! + * + * So, instead of being naive, we'll check! + */ function locationDetail(row, showLink=true) { - /* - * Function to display a "location" of a StockItem. - * - * Complicating factors: A StockItem may not actually *be* in a location! - * - Could be at a customer - * - Could be installed in another stock item - * - Could be assigned to a sales order - * - Could be currently in production! - * - * So, instead of being naive, we'll check! - */ // Display text - var text = ''; + let text = ''; // URL (optional) - var url = ''; + let url = ''; - if (row.is_building && row.build) { + if (row.consumed_by) { + text = '{% trans "Consumed by build order" %}'; + url = `/build/${row.consumed_by}/`; + } else if (row.is_building && row.build) { // StockItem is currently being built! text = '{% trans "In production" %}'; url = `/build/${row.build}/`; @@ -1827,6 +1830,8 @@ function loadStockTable(table, options) { } } else if (row.belongs_to) { html += makeIconBadge('fa-box', '{% trans "Stock item has been installed in another item" %}'); + } else if (row.consumed_by) { + html += makeIconBadge('fa-tools', '{% trans "Stock item has been consumed by a build order" %}'); } if (row.expired) { @@ -1836,13 +1841,11 @@ function loadStockTable(table, options) { } // Special stock status codes - - // REJECTED - if (row.status == {{ StockStatus.REJECTED }}) { + if (row.status == stockCodes.REJECTED.key) { html += makeIconBadge('fa-times-circle icon-red', '{% trans "Stock item has been rejected" %}'); - } else if (row.status == {{ StockStatus.LOST }}) { + } else if (row.status == stockCodes.LOST.key) { html += makeIconBadge('fa-question-circle', '{% trans "Stock item is lost" %}'); - } else if (row.status == {{ StockStatus.DESTROYED }}) { + } else if (row.status == stockCodes.DESTROYED.key) { html += makeIconBadge('fa-skull-crossbones', '{% trans "Stock item is destroyed" %}'); } diff --git a/docs/docs/build/allocate.md b/docs/docs/build/allocate.md index 9f7eb874a4..a054758553 100644 --- a/docs/docs/build/allocate.md +++ b/docs/docs/build/allocate.md @@ -17,17 +17,17 @@ Before continuing, it is important that the difference between *untracked* and * #### Untracked Stock -*Untracked* stock items disappear from the database once they are "used". Once stock items for these parts are removed from the InvenTree database (e.g. used to create an assembly), the tracking information for these stock items disappears. The stock items no longer persist in the database. +*Untracked* stock items are consumed against the build order, once the order is completed. When a build order is completed, any allocated stock items which are not [trackable](../part/trackable.md) are marked as *consumed*. These items remain in the InvenTree database, but are unavailable for use in any stock operations. !!! info "Example: Untracked Parts" - You require 15 x 47K resistors to make a batch of PCBs. You have a reel of 1,000 resistors which you allocate to the build. At completion of the build, the stock quantity is reduced to 985 + You require 15 x 47K resistors to make a batch of PCBs. You have a reel of 1,000 resistors which you allocate to the build. At completion of the build, the available stock quantity is reduced to 985. #### Tracked Stock -*Tracked* stock items, on the other hand, require special attention. These are parts which we wish to track indefinitely, even if they are "consumed" to create an assembly. *Tracked* stock items are not deleted as they are consumed. Instead, they are installed *within* the assembled unit +[Tracked](../part/trackable.md) stock items, on the other hand, require special attention. These are parts which we wish to track against specific [build outputs](./output.md). When the build order is completed, *tracked* stock items are installed *within* the assembled build output. !!! info "Example: Tracked Parts" - The assembled PCB (in the example above) is a *trackable* part, and is given a serial number #001. The PCB is then used to make a larger assembly in a subsequent build order. At the completion of that build order, the tracked PCB is *installed* in the assembly, rather than being deleted from stock. + The assembled PCB (in the example above) is a *trackable* part, and is given a serial number #001. The PCB is then used to make a larger assembly in a subsequent build order. At the completion of that build order, the tracked PCB is *installed* in the assembly. #### BOM Considerations @@ -151,4 +151,4 @@ Once all build outputs have been completed, the build order itself can be comple ### Allocated Stock -All *untracked* stock items which are allocated against this build will be removed from stock. +All *untracked* stock items which are allocated against this build will be removed from stock, and *consumed* by the build order. These consumed items can be later viewed in the [consumed stock tab](./build.md#consumed-stock). diff --git a/docs/docs/build/build.md b/docs/docs/build/build.md index 1736023e10..dd4f124c94 100644 --- a/docs/docs/build/build.md +++ b/docs/docs/build/build.md @@ -122,6 +122,13 @@ The allocation table (as shown above) shows the stock allocation progress for th !!! info "Completed Builds" The *Allocate Stock* tab is not available if the build has been completed! +### Consumed Stock + +The *Consumed Stock* tab displays all stock items which have been *consumed* by this build order. These stock items remain in the database after the build order has been completed, but are no longer available for use. + +- [Tracked stock items](./allocate.md#tracked-stock) are consumed by specific build outputs +- [Untracked stock items](./allocate.md#untracked-stock) are consumed by the build order + ### Build Outputs The *Build Outputs* tab shows the [build outputs](./output.md) (created stock items) associated with this build. diff --git a/docs/docs/terminology.md b/docs/docs/terminology.md index a9df1f8129..8e5c68d107 100644 --- a/docs/docs/terminology.md +++ b/docs/docs/terminology.md @@ -6,23 +6,27 @@ title: Terminology There are different systems in the industry for the management of getting, storing and making parts. An overview what they are for and what the acronyms mean. +**InvenTree** is mainly focused on [**IMS**](#inventory-management-system-ims) and [**PLM**](#part-library-management-plm) functionality. -### Inventory management *(IMS)* +### Inventory Management System *(IMS)* Evolves around manufacturing of parts out of other parts. It keeps track of stock, part origin, orders, shelf live and more. -### Part library management *(PLM)* +### Part Library Management *(PLM)* Keeps track of BOMs, part variants, possible substitutions, versions, IPNs and further part parameters. PLM can also mean product lifecycle management – those systems manage all stages from design through manufacturing up to customer support and recycling. - -**InvenTree** is mainly an **IMS**, it also has aspects of a **PLM** integrated. A similar system is [Partkeepr](https://partkeepr.org/) (seems mostly inactive - there is a 3rd party importer). -### Asset management *(AM)* +### Asset Management *(AM)* Manages many unique items, which need tracking per part and are assignable to users / groups / locations. These systems often include features like item states, refurbishing / maintenance / reservation, or request-flows. Often these systems are used for IT-Hardware (then they are called *ITAM*). A good open-source example would be [Snipe-IT](https://snipeitapp.com/). -### Enterprise resource planning *(ERP)* +### Enterprise Resource Planning *(ERP)* + Is the centre of your business. It manages timesheets, warehousing, finances (prices, taxes, …), customer relations and more. InvenTree covers parts of this but aims to keep an intuitive and simple user interface. Popular, fully fledged ERPs are [ERPNext](https://erpnext.com/) or [odoo](https://www.odoo.com). + +### Customer Relationship Manager *(CRM)* + +Customer relationship management (CRM) is a technology for managing all your company's relationships and interactions with customers and potential customers.